diff --git a/.circleci/config.yml b/.circleci/config.yml index 36ff52284..4c452be5e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -28,84 +28,85 @@ jobs: # To see the list of pre-built images that CircleCI provides for most common languages see # https://circleci.com/docs/2.0/circleci-images/ docker: - - image: circleci/build-image:ubuntu-14.04-XXL-upstart-1189-5614f37 - command: /sbin/init - - image: circleci/postgres:11 - environment: - POSTGRES_USER: postgres + - image: circleci/build-image:ubuntu-14.04-XXL-upstart-1189-5614f37 + command: /sbin/init + - image: circleci/postgres:11 + environment: + POSTGRES_USER: postgres steps: - # Machine Setup - # If you break your build into multiple jobs with workflows, you will probably want to do the parts of this that are relevant in each - # The following `checkout` command checks out your code to your working directory. In 1.0 we did this implicitly. In 2.0 you can choose where in the course of a job your code should be checked out. - - checkout - # Prepare for artifact and test results collection equivalent to how it was done on 1.0. - # In many cases you can simplify this from what is generated here. - # 'See docs on artifact collection here https://circleci.com/docs/2.0/artifacts/' - - run: mkdir -p $CIRCLE_ARTIFACTS $CIRCLE_TEST_REPORTS - # This is based on your 1.0 configuration file or project settings - - run: - working_directory: ~/Hylozoic/hylo-node - command: nvm install 8.6.0 && nvm alias default 8.6.0 - - run: - working_directory: ~/Hylozoic/hylo-node - command: 'sudo redis-cli ping >/dev/null 2>&1 || sudo service redis-server - start; ' - # Dependencies - # This would typically go in either a build or a build-and-test job when using workflows - # Restore the dependency cache - - restore_cache: - keys: - # This branch if available - - v1-dep-{{ .Branch }}- - # Default branch if not - - v1-dep-master- - # Any branch if there are none on the default branch - this should be unnecessary if you have your default branch configured correctly - - v1-dep- - # This is based on your 1.0 configuration file or project settings - - run: |- - createdb -h localhost hylo_test -U postgres - npm install -g codecov - curl -o- -L https://yarnpkg.com/install.sh | bash - # This is based on your 1.0 configuration file or project settings - - run: yarn install - # Save dependency cache - - save_cache: - key: v1-dep-{{ .Branch }}-{{ epoch }} - paths: - # This is a broad list of cache paths to include many possible development environments - # You can probably delete some of these entries - - vendor/bundle - - ~/virtualenvs - - ~/.m2 - - ~/.ivy2 - - ~/.bundle - - ~/.go_workspace - - ~/.gradle - - ~/.cache/bower - - ./node_modules - # Test - # This would typically be a build job when using workflows, possibly combined with build - # This is based on your 1.0 configuration file or project settings - - run: npm run cover --forbid-only - # This is based on your 1.0 configuration file or project settings - - run: codecov - # Deployment - # Your existing circle.yml file contains deployment steps. - # The config translation tool does not support translating deployment steps - # since deployment in CircleCI 2.0 are better handled through workflows. - # See the documentation for more information https://circleci.com/docs/2.0/workflows/ - # Teardown - # If you break your build into multiple jobs with workflows, you will probably want to do the parts of this that are relevant in each - # Save test results - - store_test_results: - path: /tmp/circleci-test-results - # Save artifacts - - store_artifacts: - path: /tmp/circleci-artifacts - - store_artifacts: - path: coverage - - store_artifacts: - path: /tmp/circleci-test-results + # Machine Setup + # If you break your build into multiple jobs with workflows, you will probably want to do the parts of this that are relevant in each + # The following `checkout` command checks out your code to your working directory. In 1.0 we did this implicitly. In 2.0 you can choose where in the course of a job your code should be checked out. + - checkout + # Prepare for artifact and test results collection equivalent to how it was done on 1.0. + # In many cases you can simplify this from what is generated here. + # 'See docs on artifact collection here https://circleci.com/docs/2.0/artifacts/' + - run: mkdir -p $CIRCLE_ARTIFACTS $CIRCLE_TEST_REPORTS + # This is based on your 1.0 configuration file or project settings + - run: + working_directory: ~/Hylozoic/hylo-node + command: nvm install 8.6.0 && nvm alias default 8.6.0 + - run: + working_directory: ~/Hylozoic/hylo-node + command: + "sudo redis-cli ping >/dev/null 2>&1 || sudo service redis-server + start; " + # Dependencies + # This would typically go in either a build or a build-and-test job when using workflows + # Restore the dependency cache + - restore_cache: + keys: + # This branch if available + - v1-dep-{{ .Branch }}- + # Default branch if not + - v1-dep-master- + # Any branch if there are none on the default branch - this should be unnecessary if you have your default branch configured correctly + - v1-dep- + # This is based on your 1.0 configuration file or project settings + - run: |- + createdb -h localhost hylo_test -U postgres + npm install -g codecov + curl -o- -L https://yarnpkg.com/install.sh | bash + # This is based on your 1.0 configuration file or project settings + - run: yarn install + # Save dependency cache + - save_cache: + key: v1-dep-{{ .Branch }}-{{ epoch }} + paths: + # This is a broad list of cache paths to include many possible development environments + # You can probably delete some of these entries + - vendor/bundle + - ~/virtualenvs + - ~/.m2 + - ~/.ivy2 + - ~/.bundle + - ~/.go_workspace + - ~/.gradle + - ~/.cache/bower + - ./node_modules + # Test + # This would typically be a build job when using workflows, possibly combined with build + # This is based on your 1.0 configuration file or project settings + - run: npm run cover --forbid-only + # This is based on your 1.0 configuration file or project settings + - run: codecov + # Deployment + # Your existing circle.yml file contains deployment steps. + # The config translation tool does not support translating deployment steps + # since deployment in CircleCI 2.0 are better handled through workflows. + # See the documentation for more information https://circleci.com/docs/2.0/workflows/ + # Teardown + # If you break your build into multiple jobs with workflows, you will probably want to do the parts of this that are relevant in each + # Save test results + - store_test_results: + path: /tmp/circleci-test-results + # Save artifacts + - store_artifacts: + path: /tmp/circleci-artifacts + - store_artifacts: + path: coverage + - store_artifacts: + path: /tmp/circleci-test-results deploy: docker: - image: buildpack-deps:trusty @@ -126,4 +127,4 @@ workflows: - build filters: branches: - only: master + only: master diff --git a/.codeclimate.yml b/.codeclimate.yml index 689555bae..edec3ac21 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -5,11 +5,11 @@ engines: enabled: true config: languages: - - javascript + - javascript ratings: paths: - - api/** - - lib/** + - api/** + - lib/** exclude_paths: -- "**/*.test.js" -- migrations + - "**/*.test.js" + - migrations diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 000000000..10605f290 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,12 @@ +module.exports = { + env: { + browser: true, + es2021: true, + }, + extends: ["standard"], + parserOptions: { + ecmaVersion: 12, + sourceType: "module", + }, + rules: {}, +}; diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 91f4a4d95..4cca55074 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,3 +1,3 @@ # These are supported funding model platforms -custom: ['https://www.flipcause.com/secure/cause_pdetails/Nzc4ODU='] +custom: ["https://www.flipcause.com/secure/cause_pdetails/Nzc4ODU="] diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..47469d37b --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +/node_modules +/assets \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 000000000..e69de29bb diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d6e6f776..3bf629d92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ # Changelog + All notable changes to Hylo Node (the Hylo server) will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), @@ -7,53 +8,75 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ## [1.3.8] + ### Added + - Ability to sort posts by created_at date ### Fixed + - Correct URL for access to user avatar images from Facebook - Try increasing knex connection pool size to fix server timeouts ## [1.3.7] + ### Added + - Add migrations, models, resolvers, and GraphQL schema changes for creating, deleting, and viewing saved searches - Add digest for saved searches ## Fixed + - Member Profile > Recent Activity feed loading fixed ## [1.3.6] + ### Added + - Adds contactEmail and contactPhone to User and related graphql ### Fixed + - Updates Passport Google Auth scheme to latest ## [1.3.5] - 2020-09-12 + ### Changed + - Do less database queries when loading posts to speed things up ## [1.3.4] - 2020-08-27 + ### Fixed + - Anyone can see comments on public posts ## [1.3.3] - 2020-08-25 + ### Added + - Session endpoint for support of "Sign in with Apple" ## [1.3.2] - 2020-08-19 + ### Added + - Beta version of importing posts by CSV ## [1.3.1] - 2020-08-17 + ### Added + - Allow for querying for public posts and communities without being an authenticated user ## [1.3.0] - 2020-08-14 + ### Added + - Join Requests: Ability to create a join request, and for admins to accept or reject join requests. Notify admins about incoming join requests and users when their request was accepted. - Topics: Support for default, pinned and hiding CommunityTopics. Can show topics for networks and all communities. - Comment Attachments: images and files can now be attached to comments. ### Changed -- Remove location requirement for resource posts. \ No newline at end of file + +- Remove location requirement for resource posts. diff --git a/Gruntfile.js b/Gruntfile.js index b84b6f353..7bac8b761 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -12,71 +12,69 @@ * Check out the `tasks` directory instead. */ -module.exports = function(grunt) { +module.exports = function (grunt) { + // Load the include-all library in order to require all of our grunt + // configurations and task registrations dynamically. + let includeAll; + try { + includeAll = require("include-all"); + } catch (e0) { + try { + includeAll = require("sails/node_modules/include-all"); + } catch (e1) { + console.error("Could not find `include-all` module."); + console.error("Skipping grunt tasks..."); + console.error("To fix this, please run:"); + console.error("npm install include-all --save`"); + console.error(); + grunt.registerTask("default", []); + return; + } + } - // Load the include-all library in order to require all of our grunt - // configurations and task registrations dynamically. - var includeAll; - try { - includeAll = require('include-all'); - } catch (e0) { - try { - includeAll = require('sails/node_modules/include-all'); - } - catch(e1) { - console.error('Could not find `include-all` module.'); - console.error('Skipping grunt tasks...'); - console.error('To fix this, please run:'); - console.error('npm install include-all --save`'); - console.error(); + /** + * Loads Grunt configuration modules from the specified + * relative path. These modules should export a function + * that, when run, should either load/configure or register + * a Grunt task. + */ + function loadTasks(relPath) { + return ( + includeAll({ + dirname: require("path").resolve(__dirname, relPath), + filter: /(.+)\.js$/, + }) || {} + ); + } - grunt.registerTask('default', []); - return; - } - } + /** + * Invokes the function from a Grunt configuration module with + * a single argument - the `grunt` object. + */ + function invokeConfigFn(tasks) { + for (const taskName in tasks) { + if (tasks.hasOwnProperty(taskName)) { + tasks[taskName](grunt); + } + } + } + // Load task functions + const taskConfigurations = loadTasks("./tasks/config"); + const registerDefinitions = {}; - /** - * Loads Grunt configuration modules from the specified - * relative path. These modules should export a function - * that, when run, should either load/configure or register - * a Grunt task. - */ - function loadTasks(relPath) { - return includeAll({ - dirname: require('path').resolve(__dirname, relPath), - filter: /(.+)\.js$/ - }) || {}; - } - - /** - * Invokes the function from a Grunt configuration module with - * a single argument - the `grunt` object. - */ - function invokeConfigFn(tasks) { - for (var taskName in tasks) { - if (tasks.hasOwnProperty(taskName)) { - tasks[taskName](grunt); - } - } - } - - - - - // Load task functions - var taskConfigurations = loadTasks('./tasks/config'), - registerDefinitions = {}; - - // (ensure that a default task exists) - if (!registerDefinitions.default) { - registerDefinitions.default = function (grunt) { grunt.registerTask('default', []); }; - registerDefinitions.prod = function (grunt) { grunt.registerTask('prod', []); }; - } - - // Run task functions to configure Grunt. - invokeConfigFn(taskConfigurations); - invokeConfigFn(registerDefinitions); + // (ensure that a default task exists) + if (!registerDefinitions.default) { + registerDefinitions.default = function (grunt) { + grunt.registerTask("default", []); + }; + registerDefinitions.prod = function (grunt) { + grunt.registerTask("prod", []); + }; + } + // Run task functions to configure Grunt. + invokeConfigFn(taskConfigurations); + invokeConfigFn(registerDefinitions); }; diff --git a/README.md b/README.md index ddafb8342..b8ceaabec 100644 --- a/README.md +++ b/README.md @@ -75,15 +75,16 @@ SLACK_APP_CLIENT_SECRET=[ client secret ] UPLOADER_HOST=[ hostname ] UPLOADER_PATH_PREFIX=[ path ] ``` -* `ADMIN_GOOGLE_CLIENT_*`: To access the admin console. Get these values from the [hylo-admin Google project](https://console.developers.google.com/project/hylo-admin). -* `ASSET_HOST_URL`: The host for static assets. In development, this is the [hylo-frontend](https://github.com/Hylozoic/hylo-frontend) server, which listens at `localhost:1337` by default. -* `DEBUG_SQL`: set to `true` if you want to output the SQL used within knex/bookshelf -* `DATABASE_URL`: set to your local DB instance -* `PLAY_APP_SECRET`: set to a string over length 16 to avoid the code erroring. real value only needed for running in production environment -* `ROLLBAR_SERVER_TOKEN`: use the `post_server_item` token in [Rollbar](https://rollbar.com/hylo_dev/Hylo/settings/access_tokens/) -* `SENDWITHUS_KEY`: set up a test key in SendWithUs to send all email only to you (ask someone with admin rights to set this up) -* `SLACK_APP_CLIENT_ID`: set up an app on Slack and reference its' client id, optional for dev installation -* `SLACK_APP_CLIENT_SECRET`: reference the client secret from that same app on Slack, optional for dev installation + +- `ADMIN_GOOGLE_CLIENT_*`: To access the admin console. Get these values from the [hylo-admin Google project](https://console.developers.google.com/project/hylo-admin). +- `ASSET_HOST_URL`: The host for static assets. In development, this is the [hylo-frontend](https://github.com/Hylozoic/hylo-frontend) server, which listens at `localhost:1337` by default. +- `DEBUG_SQL`: set to `true` if you want to output the SQL used within knex/bookshelf +- `DATABASE_URL`: set to your local DB instance +- `PLAY_APP_SECRET`: set to a string over length 16 to avoid the code erroring. real value only needed for running in production environment +- `ROLLBAR_SERVER_TOKEN`: use the `post_server_item` token in [Rollbar](https://rollbar.com/hylo_dev/Hylo/settings/access_tokens/) +- `SENDWITHUS_KEY`: set up a test key in SendWithUs to send all email only to you (ask someone with admin rights to set this up) +- `SLACK_APP_CLIENT_ID`: set up an app on Slack and reference its' client id, optional for dev installation +- `SLACK_APP_CLIENT_SECRET`: reference the client secret from that same app on Slack, optional for dev installation ### populating the database @@ -104,8 +105,7 @@ You will also need to login to run `psql hylo -c "CREATE EXTENSION postgis;"` NODE_ENV=dummy npm run knex seed:run ``` -*This will trash everything in your current `hylo` database, so make sure you really want to do that!* The script will ask for confirmation. By default the test user will be `test@hylo.com` with password `hylo`, configurable at the top of `seeds/dummy/dummy.js`. - +_This will trash everything in your current `hylo` database, so make sure you really want to do that!_ The script will ask for confirmation. By default the test user will be `test@hylo.com` with password `hylo`, configurable at the top of `seeds/dummy/dummy.js`. ### running the dev server @@ -143,7 +143,6 @@ AWS_ACCESS_KEY_ID=foo UPLOADER_PATH_PREFIX=foo ``` - (Without the above Mailgun values, you'll see a failing test in the suite.) Since the test database was created above, `npm test` should work at this point. ### creating and running database migrations @@ -172,11 +171,9 @@ createdb $LOCAL_DB_NAME -h localhost cat $DUMP_FILENAME | psql -h localhost $LOCAL_DB_NAME ``` - - ### design guidelines -* GET methods on `FooController` should return instances of `Foo`. (See policies.js for some related FIXME's) +- GET methods on `FooController` should return instances of `Foo`. (See policies.js for some related FIXME's) ### style guidelines @@ -187,23 +184,34 @@ The [standard-formatter Atom package](https://atom.io/packages/standard-formatte ```javascript // yes return Do(() => { - amaze() - very() + amaze(); + very(); }) -.then(such) -.tap(wow) + .then(such) + .tap(wow); // no return Do(() => { - amaze() - very() + amaze(); + very(); }) - .then(such) - .tap(wow) + .then(such) + .tap(wow); ``` The [linter-js-standard](https://atom.io/packages/linter-js-standard) package is also very helpful. +## Linter and Prettier + +To run ESLint and/or Prettier for the project, please do as the below command says - + +```javascript +"eslint-fix": "eslint --fix --ignore-path .gitignore ." // This will run the linet + fix errors that it finds +"eslint": "eslint --ignore-path .gitignore ." // This will run the linter and just check +"prettier": "prettier --write ." // This will run Prettier, and will overwrite the existing code for better format +"prettier-check": "prettier --check ." // THis will run Prettier, and just check if formatting is needed or not +``` + ## GraphQL API Many queries can also be issued using the newer GraphQL API. Types available: diff --git a/api/controllers/AdminController.js b/api/controllers/AdminController.js index fff09b628..55e2f63d3 100644 --- a/api/controllers/AdminController.js +++ b/api/controllers/AdminController.js @@ -1,68 +1,89 @@ -import moment from 'moment' -import { merge, transform, sortBy } from 'lodash' +import moment from "moment"; +import { merge, transform, sortBy } from "lodash"; -var rawMetricsQuery = startTime => Promise.props({ - community: Community.query(q => { - q.select(['id', 'name', 'created_at', 'avatar_url']) - }).query(), +const rawMetricsQuery = (startTime) => + Promise.props({ + community: Community.query((q) => { + q.select(["id", "name", "created_at", "avatar_url"]); + }).query(), - user: User.query(q => { - q.where('users.created_at', '>', startTime) - q.leftJoin('communities_users', 'users.id', 'communities_users.user_id') - q.select(['users.id', 'users.created_at', 'communities_users.community_id']) - }).query(), + user: User.query((q) => { + q.where("users.created_at", ">", startTime); + q.leftJoin("communities_users", "users.id", "communities_users.user_id"); + q.select([ + "users.id", + "users.created_at", + "communities_users.community_id", + ]); + }).query(), - post: Post.query(q => { - q.where('posts.created_at', '>', startTime) - q.where('posts.type', '!=', 'welcome') - q.where('posts.user_id', '!=', User.AXOLOTL_ID) - q.join('communities_posts', 'posts.id', 'communities_posts.post_id') - q.select(['posts.id', 'posts.created_at', 'communities_posts.community_id', 'posts.user_id']) - }).query(), + post: Post.query((q) => { + q.where("posts.created_at", ">", startTime); + q.where("posts.type", "!=", "welcome"); + q.where("posts.user_id", "!=", User.AXOLOTL_ID); + q.join("communities_posts", "posts.id", "communities_posts.post_id"); + q.select([ + "posts.id", + "posts.created_at", + "communities_posts.community_id", + "posts.user_id", + ]); + }).query(), - comment: Comment.query(q => { - q.where('comments.created_at', '>', startTime) - q.join('communities_posts', 'comments.post_id', 'communities_posts.post_id') - q.select(['comments.id', 'comments.created_at', 'communities_posts.community_id', 'comments.user_id']) - }).query() -}) + comment: Comment.query((q) => { + q.where("comments.created_at", ">", startTime); + q.join( + "communities_posts", + "comments.post_id", + "communities_posts.post_id" + ); + q.select([ + "comments.id", + "comments.created_at", + "communities_posts.community_id", + "comments.user_id", + ]); + }).query(), + }); module.exports = { loginAsUser: function (req, res) { - return User.find(req.param('userId')) - .then(user => UserSession.login(req, user, 'admin')) - .then(() => res.redirect('/app')) + return User.find(req.param("userId")) + .then((user) => UserSession.login(req, user, "admin")) + .then(() => res.redirect("/app")); }, rawMetrics: function (req, res) { - const startTime = moment().subtract(3, 'months').toDate() - return rawMetricsQuery(startTime) - .then(props => { + const startTime = moment().subtract(3, "months").toDate(); + return rawMetricsQuery(startTime).then((props) => { let result = props.community.reduce((acc, c) => { - acc[c.id] = merge(c, {events: []}) - return acc - }, {}) + acc[c.id] = merge(c, { events: [] }); + return acc; + }, {}); - result.none = {id: 'none', name: 'No community', events: []} - - ;['user', 'post', 'comment'].forEach(name => { - props[name].forEach(item => { - const key = item.community_id || 'none' + result.none = { id: "none", name: "No community", events: [] }; + ["user", "post", "comment"].forEach((name) => { + props[name].forEach((item) => { + const key = item.community_id || "none"; result[key].events.push({ time: Date.parse(item.created_at), user_id: item.user_id || item.id, - name - }) - }) - }) + name, + }); + }); + }); - result = transform(result, (acc, c, k) => { - if (c.events.length === 0) return - c.events = sortBy(c.events, 'time') - acc[k] = c - }, {}) + result = transform( + result, + (acc, c, k) => { + if (c.events.length === 0) return; + c.events = sortBy(c.events, "time"); + acc[k] = c; + }, + {} + ); - res.ok(result) - }) - } -} + res.ok(result); + }); + }, +}; diff --git a/api/controllers/AdminSessionController.js b/api/controllers/AdminSessionController.js index 759f423d5..c762f2be8 100644 --- a/api/controllers/AdminSessionController.js +++ b/api/controllers/AdminSessionController.js @@ -1,25 +1,29 @@ -var passport = require('passport'); +const passport = require("passport"); module.exports = { - - create: function(req, res) { - passport.authenticate('admin', {scope: 'email'})(req, res); + create: function (req, res) { + passport.authenticate("admin", { scope: "email" })(req, res); }, - oauth: function(req, res, next) { - passport.authenticate('admin', function(err, user, info) { - if (err) { return next(err); } - if (!user) { return res.redirect('/noo/admin/login'); } - req.login(user, function(err) { - if (err) { return next(err); } - return res.redirect('/admin'); + oauth: function (req, res, next) { + passport.authenticate("admin", function (err, user, info) { + if (err) { + return next(err); + } + if (!user) { + return res.redirect("/noo/admin/login"); + } + req.login(user, function (err) { + if (err) { + return next(err); + } + return res.redirect("/admin"); }); })(req, res, next); }, - destroy: function(req, res) { + destroy: function (req, res) { req.logout(); - res.redirect('/'); - } - -} \ No newline at end of file + res.redirect("/"); + }, +}; diff --git a/api/controllers/CommentController.js b/api/controllers/CommentController.js index 2fbd7506e..e343b4217 100644 --- a/api/controllers/CommentController.js +++ b/api/controllers/CommentController.js @@ -1,101 +1,113 @@ /* eslint-disable camelcase */ -import { isEmpty } from 'lodash' -import { flow, filter, map, includes } from 'lodash/fp' -import { markdown } from 'hylo-utils/text' -import createComment from '../models/comment/createComment' +import { isEmpty } from "lodash"; +import { flow, filter, map, includes } from "lodash/fp"; +import { markdown } from "hylo-utils/text"; +import createComment from "../models/comment/createComment"; module.exports = { createFromEmail: function (req, res) { try { - var replyData = Email.decodePostReplyAddress(req.param('To')) + var replyData = Email.decodePostReplyAddress(req.param("To")); } catch (e) { - return res.status(422).send('Invalid reply address: ' + req.param('To')) + return res.status(422).send("Invalid reply address: " + req.param("To")); } return Promise.join( - Post.find(replyData.postId, {withRelated: 'communities'}), + Post.find(replyData.postId, { withRelated: "communities" }), User.find(replyData.userId), (post, user) => { - if (!post) return res.status(422).send('valid token, but post not found') - if (!user) return res.status(422).send('valid token, but user not found') + if (!post) + return res.status(422).send("valid token, but post not found"); + if (!user) + return res.status(422).send("valid token, but user not found"); - const community = post.relations.communities.first() + const community = post.relations.communities.first(); Analytics.track({ userId: replyData.userId, - event: 'Post: Comment: Add by Email', + event: "Post: Comment: Add by Email", properties: { post_id: post.id, - community: community && community.get('name') - } - }) + community: community && community.get("name"), + }, + }); - const text = Comment.cleanEmailText(user, req.param('stripped-text'), { - useMarkdown: !post.isThread() - }) - return createComment(replyData.userId, {text, post, created_from: 'email'}) - .then(() => res.ok({}), res.serverError) + const text = Comment.cleanEmailText(user, req.param("stripped-text"), { + useMarkdown: !post.isThread(), + }); + return createComment(replyData.userId, { + text, + post, + created_from: "email", + }).then(() => res.ok({}), res.serverError); } - ) + ); }, createBatchFromEmailForm: function (req, res) { - const { communityId, userId } = res.locals.tokenData + const { communityId, userId } = res.locals.tokenData; - const replyText = postId => markdown(req.param(`post-${postId}`)) + const replyText = (postId) => markdown(req.param(`post-${postId}`)); const postIds = flow( Object.keys, - filter(k => k.match(/^post-(\d)+$/)), - map(k => k.replace(/^post-/, '')) - )(req.allParams()) - - var failures = false + filter((k) => k.match(/^post-(\d)+$/)), + map((k) => k.replace(/^post-/, "")) + )(req.allParams()); - return Community.find(communityId) - .then(community => Promise.map(postIds, id => { - if (isEmpty(replyText(id))) return - return Post.find(id, {withRelated: ['communities']}) - .then(post => { - if (!post || !includes(communityId, post.relations.communities.pluck('id'))) { - failures = true - return Promise.resolve() - } - return Comment.where({ - user_id: userId, - post_id: post.id, - text: replyText(post.id) - }).fetch() - .then(comment => { - if (post && (new Date() - post.get('created_at') < 5 * 60000)) return + let failures = false; - Analytics.track({ - userId, - event: 'Post: Comment: Add by Email Form', - properties: { - post_id: post.id, - community: community && community.get('name') - } - }) - return createComment(userId, { + return Community.find(communityId).then((community) => + Promise.map(postIds, (id) => { + if (isEmpty(replyText(id))) return; + return Post.find(id, { withRelated: ["communities"] }).then((post) => { + if ( + !post || + !includes(communityId, post.relations.communities.pluck("id")) + ) { + failures = true; + return Promise.resolve(); + } + return Comment.where({ + user_id: userId, + post_id: post.id, text: replyText(post.id), - post, - created_from: 'email batch form' }) - .then(() => Post.updateFromNewComment({ - postId: post.id, - commentId: comment.id - })) - }) - }) - }) - .then(() => { - var notification - if (failures) { - notification = 'Some of your comments could not be added.' - } else { - notification = 'Your comments have been added.' - } - return res.redirect(Frontend.Route.community(community) + - `?notification=${notification}${failures ? '&error=true' : ''}`) - }, res.serverError)) - } -} + .fetch() + .then((comment) => { + if (post && new Date() - post.get("created_at") < 5 * 60000) + return; + + Analytics.track({ + userId, + event: "Post: Comment: Add by Email Form", + properties: { + post_id: post.id, + community: community && community.get("name"), + }, + }); + return createComment(userId, { + text: replyText(post.id), + post, + created_from: "email batch form", + }).then(() => + Post.updateFromNewComment({ + postId: post.id, + commentId: comment.id, + }) + ); + }); + }); + }).then(() => { + let notification; + if (failures) { + notification = "Some of your comments could not be added."; + } else { + notification = "Your comments have been added."; + } + return res.redirect( + Frontend.Route.community(community) + + `?notification=${notification}${failures ? "&error=true" : ""}` + ); + }, res.serverError) + ); + }, +}; diff --git a/api/controllers/CommunityController.js b/api/controllers/CommunityController.js index 1a365c0c5..535440f60 100644 --- a/api/controllers/CommunityController.js +++ b/api/controllers/CommunityController.js @@ -1,11 +1,11 @@ -import { joinRoom, leaveRoom } from '../services/Websockets' +import { joinRoom, leaveRoom } from "../services/Websockets"; module.exports = { subscribe: function (req, res) { - joinRoom(req, res, 'community', res.locals.community.id) + joinRoom(req, res, "community", res.locals.community.id); }, unsubscribe: function (req, res) { - leaveRoom(req, res, 'community', res.locals.community.id) - } -} + leaveRoom(req, res, "community", res.locals.community.id); + }, +}; diff --git a/api/controllers/MobileAppController.js b/api/controllers/MobileAppController.js index c6f7185ec..a7b1610f4 100644 --- a/api/controllers/MobileAppController.js +++ b/api/controllers/MobileAppController.js @@ -1,90 +1,102 @@ -import semver from 'semver' -import rollbar from '../../lib/rollbar' +import semver from "semver"; +import rollbar from "../../lib/rollbar"; module.exports = { updateInfo: function (req, res) { - var result = { - type: 'force', - title: 'A new version of the app is available', - message: 'The version you are using is no longer compatible with the site. Please go to the App Store now to update', - iTunesItemIdentifier: '1002185140' - } - res.ok(result) + const result = { + type: "force", + title: "A new version of the app is available", + message: + "The version you are using is no longer compatible with the site. Please go to the App Store now to update", + iTunesItemIdentifier: "1002185140", + }; + res.ok(result); }, checkShouldUpdate: function (req, res) { - const iosVersion = req.param('ios-version') - const androidVersion = req.param('android-version') - const version = iosVersion || androidVersion - const platform = iosVersion ? IOS : ANDROID - return res.ok(shouldUpdate(version, platform)) + const iosVersion = req.param("ios-version"); + const androidVersion = req.param("android-version"); + const version = iosVersion || androidVersion; + const platform = iosVersion ? IOS : ANDROID; + return res.ok(shouldUpdate(version, platform)); }, logError: function (req, res) { - const error = req.param('error') - const extra = req.param('extra') + const error = req.param("error"); + const extra = req.param("extra"); - let errorJSON = error - let extraJSON = extra + let errorJSON = error; + let extraJSON = extra; try { - errorJSON = JSON.parse(error) - extraJSON = JSON.parse(extra) + errorJSON = JSON.parse(error); + extraJSON = JSON.parse(extra); } catch (e) { - ; // TODO should we do something here? + // TODO should we do something here? } - rollbar.error(new Error('ReactNativeError'), null, {custom: {errorJSON, extraJSON}}) + rollbar.error(new Error("ReactNativeError"), null, { + custom: { errorJSON, extraJSON }, + }); - return res.ok({success: true}) - } -} + return res.ok({ success: true }); + }, +}; -const SUGGEST = 'suggest' -const FORCE = 'force' -const IOS = 'ios' -const ANDROID = 'android' +const SUGGEST = "suggest"; +const FORCE = "force"; +const IOS = "ios"; +const ANDROID = "android"; -function shouldUpdate (version, platform) { +function shouldUpdate(version, platform) { // fix incomplete values like 2 or 2.0 if (!isNaN(Number(version))) { - if (semver.valid(version + '.0')) { - version = version + '.0' - } else if (semver.valid(version + '.0.0')) { - version = version + '.0.0' + if (semver.valid(version + ".0")) { + version = version + ".0"; + } else if (semver.valid(version + ".0.0")) { + version = version + ".0.0"; } } if (semver.valid(version)) { - if (semver.lt(version, process.env.MINIMUM_SUPPORTED_MOBILE_VERSION || '0.0.0')) { - return resultBuilder(FORCE, platform) + if ( + semver.lt( + version, + process.env.MINIMUM_SUPPORTED_MOBILE_VERSION || "0.0.0" + ) + ) { + return resultBuilder(FORCE, platform); } else { - return { success: true } + return { success: true }; } } switch (version) { - case 'test-suggest': - return resultBuilder(SUGGEST, platform) + case "test-suggest": + return resultBuilder(SUGGEST, platform); default: - return resultBuilder(FORCE, platform) + return resultBuilder(FORCE, platform); } } -function resultBuilder (type, platform) { - var appStoreLink = process.env.IOS_APP_STORE_URL - var playStoreLink = process.env.ANDROID_APP_STORE_URL - var title = type === 'suggest' ? 'An update is available' : 'A new version of the app is available' - var store = platform === 'ios' ? 'App Store' : 'Play Store' - var suggestUpdateMessage = `The version you are using is no longer up to date. Please go to the ${store} to update.` - var forceUpdateMessage = `The version you are using is no longer supported. Please go to the ${store} now to update.` - var message = type === 'suggest' ? suggestUpdateMessage : forceUpdateMessage - var link = platform === 'ios' ? appStoreLink : playStoreLink +function resultBuilder(type, platform) { + const appStoreLink = process.env.IOS_APP_STORE_URL; + const playStoreLink = process.env.ANDROID_APP_STORE_URL; + const title = + type === "suggest" + ? "An update is available" + : "A new version of the app is available"; + const store = platform === "ios" ? "App Store" : "Play Store"; + const suggestUpdateMessage = `The version you are using is no longer up to date. Please go to the ${store} to update.`; + const forceUpdateMessage = `The version you are using is no longer supported. Please go to the ${store} now to update.`; + const message = + type === "suggest" ? suggestUpdateMessage : forceUpdateMessage; + const link = platform === "ios" ? appStoreLink : playStoreLink; return { type, title, message, - link - } + link, + }; } diff --git a/api/controllers/NexudusController.js b/api/controllers/NexudusController.js index 1b28628b4..40277ab1b 100644 --- a/api/controllers/NexudusController.js +++ b/api/controllers/NexudusController.js @@ -1,33 +1,49 @@ -var md5 = require('md5') -var Nexudus = require('../services/Nexudus') +const md5 = require("md5"); +const Nexudus = require("../services/Nexudus"); -var generateToken = function (token, key, date, hash) { - var secret = process.env.NEXUDUS_SECRET_KEY - var checkString = [token, key, date].sort().join('|') + secret - var checkHash = md5(checkString) +const generateToken = function (token, key, date, hash) { + const secret = process.env.NEXUDUS_SECRET_KEY; + const checkString = [token, key, date].sort().join("|") + secret; + const checkHash = md5(checkString); if (hash !== checkHash) { - throw new Error(format('bad hash: expected %s, got %s', hash, checkHash)) - } - return md5(token + secret) -} + throw new Error(format("bad hash: expected %s, got %s", hash, checkHash)); + } + return md5(token + secret); +}; module.exports = { generateToken: generateToken, create: function (req, res) { - var params = req.allParams() - var email = params.e - var token = generateToken(params.t, params.a, params.d, params.h) - + const params = req.allParams(); + const email = params.e; + const token = generateToken(params.t, params.a, params.d, params.h); + Nexudus.fetchUsers(params.a, token) - .tap(results => Email.sendRawEmail('robbie@hylo.com', { - subject: format('Nexudus user records (%s) for %s', results.length, email) - }, { - files: [{ - id: 'users.json', - data: new Buffer(JSON.stringify(results, null, ' ')).toString('base64') - }] - })) - .tap(results => res.ok(format('Sent %s records to Hylo.', results.length))) - .catch(res.serverError) - } -} + .tap((results) => + Email.sendRawEmail( + "robbie@hylo.com", + { + subject: format( + "Nexudus user records (%s) for %s", + results.length, + email + ), + }, + { + files: [ + { + id: "users.json", + data: new Buffer(JSON.stringify(results, null, " ")).toString( + "base64" + ), + }, + ], + } + ) + ) + .tap((results) => + res.ok(format("Sent %s records to Hylo.", results.length)) + ) + .catch(res.serverError); + }, +}; diff --git a/api/controllers/PaymentController.js b/api/controllers/PaymentController.js index 6f083b064..a38cbb23f 100644 --- a/api/controllers/PaymentController.js +++ b/api/controllers/PaymentController.js @@ -1,13 +1,21 @@ -import { registerStripeAccount } from '../graphql/mutations/user' +import { registerStripeAccount } from "../graphql/mutations/user"; module.exports = { registerStripe: function (req, res) { - const code = req.param('code') + const code = req.param("code"); if (!code) { - throw new Error('registerStripe requires a code param') + throw new Error("registerStripe requires a code param"); } return registerStripeAccount(req.session.userId, code) - .then(() => res.redirect(Frontend.Route.evo.paymentSettings({registered: 'success'}))) - .catch(() => res.redirect(Frontend.Route.evo.paymentSettings({registered: 'error'}))) - } -} + .then(() => + res.redirect( + Frontend.Route.evo.paymentSettings({ registered: "success" }) + ) + ) + .catch(() => + res.redirect( + Frontend.Route.evo.paymentSettings({ registered: "error" }) + ) + ); + }, +}; diff --git a/api/controllers/PostController.js b/api/controllers/PostController.js index 5632684b1..585522fee 100644 --- a/api/controllers/PostController.js +++ b/api/controllers/PostController.js @@ -1,90 +1,111 @@ -import { includes } from 'lodash' -import createPost from '../models/post/createPost' -import { joinRoom, leaveRoom } from '../services/Websockets' +import { includes } from "lodash"; +import createPost from "../models/post/createPost"; +import { joinRoom, leaveRoom } from "../services/Websockets"; const PostController = { createFromEmailForm: function (req, res) { - const { tokenData: { userId, communityId } } = res.locals + const { + tokenData: { userId, communityId }, + } = res.locals; const namePrefixes = { offer: "I'd like to share", request: "I'm looking for", resource: "I'd like to share", - intention: "I'd like to create" - } + intention: "I'd like to create", + }; - const type = req.param('type') + const type = req.param("type"); if (!includes(Object.keys(namePrefixes), type)) { - return res.serverError(new Error(`invalid type: ${type}`)) + return res.serverError(new Error(`invalid type: ${type}`)); } const attributes = { - created_from: 'email_form', - name: `${namePrefixes[type]} ${req.param('name')}`, + created_from: "email_form", + name: `${namePrefixes[type]} ${req.param("name")}`, community_ids: [communityId], topicNames: [type], - description: req.param('description') - } + description: req.param("description"), + }; - let community - return Post.where({name: attributes.name, user_id: userId}).fetch() - .then(post => { - if (post && (new Date() - post.get('created_at') < 5 * 60000)) { - res.redirect(Frontend.Route.post(post)) - return true - } - }) - .then(stop => stop || Community.find(communityId) - .then(c => { - community = c - if (!c.get('active')) { - const message = 'Your post was not created. That community no longer exists.' - res.redirect(Frontend.Route.root() + `?notification=${encodeURIComponent(message)}&error=1`) - return true + let community; + return Post.where({ name: attributes.name, user_id: userId }) + .fetch() + .then((post) => { + if (post && new Date() - post.get("created_at") < 5 * 60000) { + res.redirect(Frontend.Route.post(post)); + return true; } - })) - .then(stop => stop || createPost(userId, attributes) - .tap(() => Analytics.track({ - userId, - event: 'Add Post by Email Form', - properties: {community: community.get('name')} - })) - .then(post => res.redirect(Frontend.Route.post(post, community)))) - .catch(res.serverError) + }) + .then( + (stop) => + stop || + Community.find(communityId).then((c) => { + community = c; + if (!c.get("active")) { + const message = + "Your post was not created. That community no longer exists."; + res.redirect( + Frontend.Route.root() + + `?notification=${encodeURIComponent(message)}&error=1` + ); + return true; + } + }) + ) + .then( + (stop) => + stop || + createPost(userId, attributes) + .tap(() => + Analytics.track({ + userId, + event: "Add Post by Email Form", + properties: { community: community.get("name") }, + }) + ) + .then((post) => res.redirect(Frontend.Route.post(post, community))) + ) + .catch(res.serverError); }, updateLastRead: async function (req, res) { try { - await res.locals.post.markAsRead(req.session.userId) - res.ok({}) + await res.locals.post.markAsRead(req.session.userId); + res.ok({}); } catch (err) { - res.serverError(err) + res.serverError(err); } }, subscribe: function (req, res) { - joinRoom(req, res, 'post', res.locals.post.id) + joinRoom(req, res, "post", res.locals.post.id); }, unsubscribe: function (req, res) { - leaveRoom(req, res, 'post', res.locals.post.id) + leaveRoom(req, res, "post", res.locals.post.id); }, typing: function (req, res) { - const { post } = res.locals - const { body: { isTyping }, socket } = req + const { post } = res.locals; + const { + body: { isTyping }, + socket, + } = req; return User.find(req.session.userId) - .then(user => post.pushTypingToSockets(user.id, user.get('name'), isTyping, socket)) - .then(() => res.ok({})) + .then((user) => + post.pushTypingToSockets(user.id, user.get("name"), isTyping, socket) + ) + .then(() => res.ok({})); }, subscribeToUpdates: function (req, res) { - joinRoom(req, res, 'user', req.session.userId) + joinRoom(req, res, "user", req.session.userId); }, unsubscribeFromUpdates: function (req, res) { - leaveRoom(req, res, 'user', req.session.userId) - } -} + leaveRoom(req, res, "user", req.session.userId); + }, +}; -module.exports = PostController +module.exports = PostController; diff --git a/api/controllers/SessionController.js b/api/controllers/SessionController.js index 738f91687..b05e73dba 100644 --- a/api/controllers/SessionController.js +++ b/api/controllers/SessionController.js @@ -1,249 +1,286 @@ -import passport from 'passport' -import appleSigninAuth from 'apple-signin-auth' -import crypto from 'crypto' +import passport from "passport"; +import appleSigninAuth from "apple-signin-auth"; +import crypto from "crypto"; -const rollbar = require('../../lib/rollbar') +const rollbar = require("../../lib/rollbar"); const findUser = function (service, email, id) { return User.query(function (qb) { - qb.where('users.active', true) + qb.where("users.active", true); - qb.leftJoin('linked_account', function () { - this.on('linked_account.user_id', '=', 'users.id') - }) + qb.leftJoin("linked_account", function () { + this.on("linked_account.user_id", "=", "users.id"); + }); qb.where(function () { - this.where({provider_user_id: id, 'linked_account.provider_key': service}) - .orWhereRaw('lower(email) = ?', email ? email.toLowerCase() : null) - }) - }).fetchAll({withRelated: ['linkedAccounts']}) - .then(users => { - // if we find both a user matching the email address and one with a matching - // linked account, prioritize the latter - if (users.length >= 2) { - return users.find(u => _.some(u.relations.linkedAccounts.models, a => - a.get('provider_user_id') === id && a.get('provider_key') === service)) - } - return users.first() + this.where({ + provider_user_id: id, + "linked_account.provider_key": service, + }).orWhereRaw("lower(email) = ?", email ? email.toLowerCase() : null); + }); }) -} + .fetchAll({ withRelated: ["linkedAccounts"] }) + .then((users) => { + // if we find both a user matching the email address and one with a matching + // linked account, prioritize the latter + if (users.length >= 2) { + return users.find((u) => + _.some( + u.relations.linkedAccounts.models, + (a) => + a.get("provider_user_id") === id && + a.get("provider_key") === service + ) + ); + } + return users.first(); + }); +}; // FIXME: this doesn't check that the profile_user_id we just got matches the // stored one. we should update any existing row to match the new // profile_user_id as necessary. const hasLinkedAccount = function (user, service) { - return !!user.relations.linkedAccounts.where({provider_key: service})[0] -} + return !!user.relations.linkedAccounts.where({ provider_key: service })[0]; +}; const upsertUser = (req, service, profile) => { - return findUser(service, profile.email, profile.id) - .then(user => { + return findUser(service, profile.email, profile.id).then((user) => { if (user) { - return UserSession.login(req, user, service) - // if this is a new account, link it to the user - .tap(() => hasLinkedAccount(user, service) || - LinkedAccount.create(user.id, {type: service, profile}, {updateUser: true})) + return ( + UserSession.login(req, user, service) + // if this is a new account, link it to the user + .tap( + () => + hasLinkedAccount(user, service) || + LinkedAccount.create( + user.id, + { type: service, profile }, + { updateUser: true } + ) + ) + ); } - const attrs = _.merge(_.pick(profile, 'email', 'name'), { - account: {type: service, profile} - }) + const attrs = _.merge(_.pick(profile, "email", "name"), { + account: { type: service, profile }, + }); return User.create(attrs) - .tap(user => Analytics.trackSignup(user.id, req)) - .tap(user => UserSession.login(req, user, service)) - }) -} + .tap((user) => Analytics.trackSignup(user.id, req)) + .tap((user) => UserSession.login(req, user, service)); + }); +}; const upsertLinkedAccount = (req, service, profile) => { - var userId = req.session.userId - return LinkedAccount.where({provider_key: service, provider_user_id: profile.id}).fetch() - .then(account => { - if (account) { - // user has this linked account already - if (account.get('user_id') === userId) { - return LinkedAccount.updateUser(userId, {type: service, profile}) + const userId = req.session.userId; + return LinkedAccount.where({ + provider_key: service, + provider_user_id: profile.id, + }) + .fetch() + .then((account) => { + if (account) { + // user has this linked account already + if (account.get("user_id") === userId) { + return LinkedAccount.updateUser(userId, { type: service, profile }); + } + // linked account belongs to someone else -- change its ownership + return account + .save({ user_id: userId }, { patch: true }) + .then(() => + LinkedAccount.updateUser(userId, { type: service, profile }) + ); } - // linked account belongs to someone else -- change its ownership - return account.save({user_id: userId}, {patch: true}) - .then(() => LinkedAccount.updateUser(userId, {type: service, profile})) - } - // we create a new account regardless of whether one exists for the service; - // this allows the user to continue to log in with the old one - return LinkedAccount.create(userId, {type: service, profile}, {updateUser: true}) - }) -} + // we create a new account regardless of whether one exists for the service; + // this allows the user to continue to log in with the old one + return LinkedAccount.create( + userId, + { type: service, profile }, + { updateUser: true } + ); + }); +}; const finishOAuth = function (strategy, req, res, next) { - var provider = strategy - if (strategy === 'facebook-token') { - provider = 'facebook' - } else if (strategy === 'google-token') { - provider = 'google' - } else if (strategy === 'linkedin-token') { - provider = 'linkedin' + let provider = strategy; + if (strategy === "facebook-token") { + provider = "facebook"; + } else if (strategy === "google-token") { + provider = "google"; + } else if (strategy === "linkedin-token") { + provider = "linkedin"; } return new Promise((resolve, reject) => { - var respond = error => { - if (error && error.stack) rollbar.error(error, req) - if (req.headers.accept === 'application/json') { - error ? res.serverError(error) : res.ok({}) - return resolve() + const respond = (error) => { + if (error && error.stack) rollbar.error(error, req); + if (req.headers.accept === "application/json") { + error ? res.serverError(error) : res.ok({}); + return resolve(); } - return resolve(res.view('popupDone', { - error, + return resolve( + res.view("popupDone", { + error, + provider, + context: req.session.authContext || "oauth", + layout: null, + returnDomain: req.session.returnDomain, + }) + ); + }; + + const authCallback = function (err, profile, info) { + if (err || !profile) return respond(err || "no user"); + if (!profile.email) return respond("no email"); + + return (UserSession.isLoggedIn(req) ? upsertLinkedAccount : upsertUser)( + req, provider, - context: req.session.authContext || 'oauth', - layout: null, - returnDomain: req.session.returnDomain - })) - } - - var authCallback = function (err, profile, info) { - if (err || !profile) return respond(err || 'no user') - if (!profile.email) return respond('no email') - - return (UserSession.isLoggedIn(req) - ? upsertLinkedAccount - : upsertUser)(req, provider, profile) - .then(() => UserExternalData.store(req.session.userId, provider, profile._json)) - .then(() => respond()) - .catch(respond) - } - - passport.authenticate(strategy, authCallback)(req, res, next) - }) -} + profile + ) + .then(() => + UserExternalData.store(req.session.userId, provider, profile._json) + ) + .then(() => respond()) + .catch(respond); + }; + + passport.authenticate(strategy, authCallback)(req, res, next); + }); +}; // save params into session variables so that they can be used to return to the // right control flow -const setSessionFromParams = fn => (req, res) => { - req.session.returnDomain = req.param('returnDomain') - req.session.authContext = req.param('authContext') - return fn(req, res) -} +const setSessionFromParams = (fn) => (req, res) => { + req.session.returnDomain = req.param("returnDomain"); + req.session.authContext = req.param("authContext"); + return fn(req, res); +}; module.exports = { create: function (req, res) { - var email = req.param('email') ? req.param('email').toLowerCase() : null - var password = req.param('password') + const email = req.param("email") ? req.param("email").toLowerCase() : null; + const password = req.param("password"); return User.authenticate(email, password) - .tap(user => UserSession.login(req, user, 'password')) - .tap(user => user.save({last_login_at: new Date()}, {patch: true})) - .tap(user => res.ok({})) - .catch(function (err) { - // 422 means 'well-formed but semantically invalid' - res.status(422).send(err.message) - }) + .tap((user) => UserSession.login(req, user, "password")) + .tap((user) => user.save({ last_login_at: new Date() }, { patch: true })) + .tap((user) => res.ok({})) + .catch(function (err) { + // 422 means 'well-formed but semantically invalid' + res.status(422).send(err.message); + }); }, finishAppleOAuth: async function (req, res, next) { - const { nonce, user, identityToken, email, fullName } = req.body + const { nonce, user, identityToken, email, fullName } = req.body; // Check nonce or identityToken with nonce or audience (clientId) or both? See: // https://medium.com/@rossbulat/react-native-sign-in-with-apple-75733d3fbc3 (search "As a side note...") - const appleIdTokenClaims = await appleSigninAuth.verifyIdToken(identityToken, { - /** sha256 hex hash of raw nonce */ - nonce: nonce - ? crypto.createHash('sha256').update(nonce).digest('hex') - : undefined - }) + const appleIdTokenClaims = await appleSigninAuth.verifyIdToken( + identityToken, + { + /** sha256 hex hash of raw nonce */ + nonce: nonce + ? crypto.createHash("sha256").update(nonce).digest("hex") + : undefined, + } + ); // Confirm that identityToken was verified: if (appleIdTokenClaims.sub === user) { - upsertUser(req, 'apple', { + upsertUser(req, "apple", { id: user, email, - name: fullName.givenName + ' ' + fullName.familyName + name: fullName.givenName + " " + fullName.familyName, }) - .then(user => res.ok(user)) + .then((user) => res.ok(user)) .catch(function (err) { // 422 means 'well-formed but semantically invalid' - res.status(422).send(err.message) - }) + res.status(422).send(err.message); + }); } }, startGoogleOAuth: setSessionFromParams(function (req, res) { - passport.authenticate('google', {scope: 'email'})(req, res) + passport.authenticate("google", { scope: "email" })(req, res); }), finishGoogleOAuth: function (req, res, next) { - return finishOAuth('google', req, res, next) + return finishOAuth("google", req, res, next); }, startFacebookOAuth: setSessionFromParams(function (req, res) { - passport.authenticate('facebook', { - display: 'popup', - scope: ['email', 'public_profile'] - })(req, res) + passport.authenticate("facebook", { + display: "popup", + scope: ["email", "public_profile"], + })(req, res); }), finishFacebookOAuth: function (req, res, next) { - return finishOAuth('facebook', req, res, next) + return finishOAuth("facebook", req, res, next); }, finishFacebookTokenOAuth: function (req, res, next) { - return finishOAuth('facebook-token', req, res, next) + return finishOAuth("facebook-token", req, res, next); }, finishGoogleTokenOAuth: function (req, res, next) { - return finishOAuth('google-token', req, res, next) + return finishOAuth("google-token", req, res, next); }, startLinkedinOAuth: setSessionFromParams(function (req, res) { - passport.authenticate('linkedin')(req, res) + passport.authenticate("linkedin")(req, res); }), finishLinkedinOauth: function (req, res, next) { - return finishOAuth('linkedin', req, res, next) + return finishOAuth("linkedin", req, res, next); }, finishLinkedinTokenOauth: function (req, res, next) { - return finishOAuth('linkedin-token', req, res, next) + return finishOAuth("linkedin-token", req, res, next); }, destroy: function (req, res) { - req.session.destroy() - res.redirect('/') + req.session.destroy(); + res.redirect("/"); }, // a 'pure' version of the above for API-only use destroySession: function (req, res) { - req.session.destroy() - res.ok({}) + req.session.destroy(); + res.ok({}); }, createWithToken: async function (req, res) { // Web links will go directly to the server and redirects from here, // Native does a POST as an API call and this should not redirect - const shouldRedirect = req.method === 'GET' - const nextUrl = req.param('n') || Frontend.Route.evo.passwordSetting() + const shouldRedirect = req.method === "GET"; + const nextUrl = req.param("n") || Frontend.Route.evo.passwordSetting(); try { - const user = await User.find(req.param('u')) - if (!user) return res.status(422).send('Link expired') - const match = await user.checkToken(req.param('t')) + const user = await User.find(req.param("u")); + if (!user) return res.status(422).send("Link expired"); + const match = await user.checkToken(req.param("t")); if (match) { - UserSession.login(req, user, 'password') + UserSession.login(req, user, "password"); return shouldRedirect ? res.redirect(nextUrl) - : res.ok({success: true}) + : res.ok({ success: true }); } else { // still redirect, to give the user a chance to log in manually // if a specific URL other than the default was the entry point - return shouldRedirect && req.param('n') + return shouldRedirect && req.param("n") ? res.redirect(nextUrl) - : res.status(422).send('Link expired') + : res.status(422).send("Link expired"); } } catch (e) { - return res.serverError + return res.serverError; } }, // these are here for testing findUser, - upsertLinkedAccount -} + upsertLinkedAccount, +}; diff --git a/api/controllers/SubscriptionController.js b/api/controllers/SubscriptionController.js index 2b0e023fe..0ce804312 100644 --- a/api/controllers/SubscriptionController.js +++ b/api/controllers/SubscriptionController.js @@ -1,15 +1,16 @@ -var stripe = require('stripe')(process.env.STRIPE_SECRET_KEY) +const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY); module.exports = { create: function (req, res) { - var params = req.allParams() + const params = req.allParams(); - return stripe.customers.create({ - email: params.token.email, - source: params.token.id, - plan: params.planId - }) - .then(() => res.ok({})) - .catch(res.serverError) - } -} + return stripe.customers + .create({ + email: params.token.email, + source: params.token.id, + plan: params.planId, + }) + .then(() => res.ok({})) + .catch(res.serverError); + }, +}; diff --git a/api/controllers/UploadController.js b/api/controllers/UploadController.js index b81585dd2..6fe21e89e 100644 --- a/api/controllers/UploadController.js +++ b/api/controllers/UploadController.js @@ -1,6 +1,6 @@ -import Busboy from 'busboy' -import { upload } from '../../lib/uploader' -import { getMediaTypeFromMimetype } from '../models/media/util' +import Busboy from "busboy"; +import { upload } from "../../lib/uploader"; +import { getMediaTypeFromMimetype } from "../models/media/util"; module.exports = { create: function (req, res) { @@ -12,52 +12,54 @@ module.exports = { // parser (see config/customMiddleware). but if the request is // multipart/form-data, then they will be blank, and populated later by // busboy's 'field' event handler. - type: req.param('type'), - id: req.param('id'), - url: req.param('url'), - filename: req.param('filename') - } + type: req.param("type"), + id: req.param("id"), + url: req.param("url"), + filename: req.param("filename"), + }; // this promise is used for tests - return new Promise(resolve => { - if (req.headers['content-type'] === 'application/json') { - doUpload(res, args, resolve) + return new Promise((resolve) => { + if (req.headers["content-type"] === "application/json") { + doUpload(res, args, resolve); } else { - setupBusboy(req, res, args, resolve) + setupBusboy(req, res, args, resolve); } - }) - } -} + }); + }, +}; const doUpload = (res, args, resolve) => upload(args) - .then(({ type, id, url, mimetype }) => { - const uploadResponse = { - type, - id, - url, - // Roughly, the frontend and graphql implemenations use - // 'attachment' and 'attachmentType' for what we call - // Media and Media.type here on in the backend. - attachmentType: getMediaTypeFromMimetype(mimetype) - } + .then(({ type, id, url, mimetype }) => { + const uploadResponse = { + type, + id, + url, + // Roughly, the frontend and graphql implemenations use + // 'attachment' and 'attachmentType' for what we call + // Media and Media.type here on in the backend. + attachmentType: getMediaTypeFromMimetype(mimetype), + }; - return resolve(res.ok(uploadResponse)) - }) - .catch(err => { - if (err.message.startsWith('Validation error')) { - return resolve(res.status(422).send({error: err.message})) - } + return resolve(res.ok(uploadResponse)); + }) + .catch((err) => { + if (err.message.startsWith("Validation error")) { + return resolve(res.status(422).send({ error: err.message })); + } - if (err.message.includes('unsupported image format')) { - return resolve(res.status(422).send({error: 'Unsupported image format'})) - } + if (err.message.includes("unsupported image format")) { + return resolve( + res.status(422).send({ error: "Unsupported image format" }) + ); + } - resolve(res.serverError(err)) - }) + resolve(res.serverError(err)); + }); -function setupBusboy (req, res, args, resolve) { - let busboy, gotFile +function setupBusboy(req, res, args, resolve) { + let busboy, gotFile; try { // this can throw errors due to invalid Content-Type @@ -65,28 +67,28 @@ function setupBusboy (req, res, args, resolve) { headers: req.headers, limits: { files: 1, - fileSize: 10 * 1048576 - } - }) + fileSize: 10 * 1048576, + }, + }); } catch (err) { - return resolve(res.status(422).send({error: err.message})) + return resolve(res.status(422).send({ error: err.message })); } - busboy.on('field', (name, value) => { - if (['id', 'type', 'url'].includes(name)) { - args[name] = value + busboy.on("field", (name, value) => { + if (["id", "type", "url"].includes(name)) { + args[name] = value; } - }) + }); - busboy.on('file', (name, stream, filename) => { + busboy.on("file", (name, stream, filename) => { // we assume that all 'field' events have already been handled by now - Object.assign(args, {stream, filename}) - gotFile = true - doUpload(res, args, resolve) - }) + Object.assign(args, { stream, filename }); + gotFile = true; + doUpload(res, args, resolve); + }); - busboy.on('error', err => resolve(res.serverError(err))) - busboy.on('finish', () => gotFile || doUpload(res, args, resolve)) + busboy.on("error", (err) => resolve(res.serverError(err))); + busboy.on("finish", () => gotFile || doUpload(res, args, resolve)); - req.pipe(busboy) + req.pipe(busboy); } diff --git a/api/controllers/UserController.js b/api/controllers/UserController.js index c9b9bfade..5af6867fd 100644 --- a/api/controllers/UserController.js +++ b/api/controllers/UserController.js @@ -1,49 +1,59 @@ module.exports = { create: function (req, res) { - const { name, email, password } = req.allParams() + const { name, email, password } = req.allParams(); - return User.create({name, email: email ? email.toLowerCase() : null, account: {type: 'password', password}}) - .tap(user => Analytics.trackSignup(user.id, req)) - .tap(user => req.param('login') && UserSession.login(req, user, 'password')) - .then(user => { - if (req.param('resp') === 'user') { - return res.ok({ - name: user.get('name'), - email: user.get('email') - }) - } else { - return res.ok({}) - } - }) - .catch(function (err) { - res.status(422).send(req.__(err.message ? err.message : err)) + return User.create({ + name, + email: email ? email.toLowerCase() : null, + account: { type: "password", password }, }) + .tap((user) => Analytics.trackSignup(user.id, req)) + .tap( + (user) => req.param("login") && UserSession.login(req, user, "password") + ) + .then((user) => { + if (req.param("resp") === "user") { + return res.ok({ + name: user.get("name"), + email: user.get("email"), + }); + } else { + return res.ok({}); + } + }) + .catch(function (err) { + res.status(422).send(req.__(err.message ? err.message : err)); + }); }, status: function (req, res) { - res.ok({signedIn: UserSession.isLoggedIn(req)}) + res.ok({ signedIn: UserSession.isLoggedIn(req) }); }, sendPasswordReset: function (req, res) { - var email = req.param('email') - return User.query(q => q.whereRaw('lower(email) = ?', email.toLowerCase())).fetch().then(function (user) { - if (!user) { - return res.ok({}) - } else { - const nextUrl = req.param('evo') - ? Frontend.Route.evo.passwordSetting() - : null - user.generateToken().then(function (token) { - Queue.classMethod('Email', 'sendPasswordReset', { - email: user.get('email'), - templateData: { - login_url: Frontend.Route.tokenLogin(user, token, nextUrl) - } - }) - return res.ok({}) - }) - } - }) - .catch(res.serverError.bind(res)) - } -} + const email = req.param("email"); + return User.query((q) => + q.whereRaw("lower(email) = ?", email.toLowerCase()) + ) + .fetch() + .then(function (user) { + if (!user) { + return res.ok({}); + } else { + const nextUrl = req.param("evo") + ? Frontend.Route.evo.passwordSetting() + : null; + user.generateToken().then(function (token) { + Queue.classMethod("Email", "sendPasswordReset", { + email: user.get("email"), + templateData: { + login_url: Frontend.Route.tokenLogin(user, token, nextUrl), + }, + }); + return res.ok({}); + }); + } + }) + .catch(res.serverError.bind(res)); + }, +}; diff --git a/api/graphql/filters.js b/api/graphql/filters.js index c750c38c9..60f4faf4a 100644 --- a/api/graphql/filters.js +++ b/api/graphql/filters.js @@ -1,156 +1,182 @@ -import { curry } from 'lodash' -import { myCommunityIds, myNetworkCommunityIds } from '../models/util/queryFilters' -import { isFollowing } from '../models/group/queryUtils' -import GroupDataType from '../models/group/DataType' - -export function makeFilterToggle (enabled) { - return filterFn => relation => - enabled ? filterFn(relation) : relation +import { curry } from "lodash"; +import { + myCommunityIds, + myNetworkCommunityIds, +} from "../models/util/queryFilters"; +import { isFollowing } from "../models/group/queryUtils"; +import GroupDataType from "../models/group/DataType"; + +export function makeFilterToggle(enabled) { + return (filterFn) => (relation) => (enabled ? filterFn(relation) : relation); } // This does not include users connected by a network -function sharesMembership (userId, q) { +function sharesMembership(userId, q) { const subq = GroupMembership.forMember([userId, User.AXOLOTL_ID], Community) - .query().pluck('group_id') + .query() + .pluck("group_id"); - q.where('group_memberships.active', true) - q.where('group_memberships.group_id', 'in', subq) + q.where("group_memberships.active", true); + q.where("group_memberships.group_id", "in", subq); } -export const membershipFilter = userId => relation => - relation.query(q => sharesMembership(userId, q)) +export const membershipFilter = (userId) => (relation) => + relation.query((q) => sharesMembership(userId, q)); -export const personFilter = userId => relation => relation.query(q => { - if (userId) { - // find all other memberships for users that share a network - const sharedMemberships = GroupMembership.query(q3 => { - filterCommunities(q3, 'groups.group_data_id', userId) - q3.join('groups', 'groups.id', 'group_memberships.group_id') - q3.where('group_memberships.group_data_type', GroupDataType.COMMUNITY) - }) - - q.where('users.id', 'NOT IN', BlockedUser.blockedFor(userId)) - - // limit to users that are in those other memberships - - const sharedConnections = UserConnection.query(ucq =>{ - ucq.where('user_id', userId) - }) - - q.where(inner => - inner.where('users.id', User.AXOLOTL_ID) - .orWhere('users.id', 'in', sharedMemberships.query().pluck('user_id')) - .orWhere('users.id', 'in', sharedConnections.query().pluck('other_user_id'))) - } -}) +export const personFilter = (userId) => (relation) => + relation.query((q) => { + if (userId) { + // find all other memberships for users that share a network + const sharedMemberships = GroupMembership.query((q3) => { + filterCommunities(q3, "groups.group_data_id", userId); + q3.join("groups", "groups.id", "group_memberships.group_id"); + q3.where("group_memberships.group_data_type", GroupDataType.COMMUNITY); + }); + + q.where("users.id", "NOT IN", BlockedUser.blockedFor(userId)); + + // limit to users that are in those other memberships + + const sharedConnections = UserConnection.query((ucq) => { + ucq.where("user_id", userId); + }); + + q.where((inner) => + inner + .where("users.id", User.AXOLOTL_ID) + .orWhere("users.id", "in", sharedMemberships.query().pluck("user_id")) + .orWhere( + "users.id", + "in", + sharedConnections.query().pluck("other_user_id") + ) + ); + } + }); -export const messageFilter = userId => relation => relation.query(q => { - q.where('user_id', 'NOT IN', BlockedUser.blockedFor(userId)) -}) +export const messageFilter = (userId) => (relation) => + relation.query((q) => { + q.where("user_id", "NOT IN", BlockedUser.blockedFor(userId)); + }); -function filterCommunities (q, idColumn, userId) { +function filterCommunities(q, idColumn, userId) { // the effect of using `where` like this is to wrap everything within its // callback in parentheses -- this is necessary to keep `or` from "leaking" // out to the rest of the query - q.where(inner => { - inner.where(idColumn, 'in', myCommunityIds(userId)).orWhere(idColumn, 'in', myNetworkCommunityIds(userId)) - if (idColumn === 'communities.id') { + q.where((inner) => { + inner + .where(idColumn, "in", myCommunityIds(userId)) + .orWhere(idColumn, "in", myNetworkCommunityIds(userId)); + if (idColumn === "communities.id") { // XXX: hack to make sure to show public communities on the map when logged in - inner.orWhere('communities.is_public', true) + inner.orWhere("communities.is_public", true); } - }) + }); // non authenticated queries can only see public communities - if (!userId && idColumn === 'communities.id') { - q.where('communities.is_public', true) + if (!userId && idColumn === "communities.id") { + q.where("communities.is_public", true); } } export const sharedNetworkMembership = curry((tableName, userId, relation) => - relation.query(q => { + relation.query((q) => { switch (tableName) { - case 'communities': - return filterCommunities(q, 'communities.id', userId) - case 'posts': - const subq = PostMembership.query(q2 => { - filterCommunities(q2, 'community_id', userId) - }).query().select('post_id') - - return q.where(q2 => { - q2.where('posts.id', 'in', subq).orWhere('posts.is_public', true) + case "communities": + return filterCommunities(q, "communities.id", userId); + case "posts": + const subq = PostMembership.query((q2) => { + filterCommunities(q2, "community_id", userId); }) - case 'votes': - q.join('communities_posts', 'votes.post_id', 'communities_posts.post_id') - return filterCommunities(q, 'communities_posts.community_id', userId) + .query() + .select("post_id"); + + return q.where((q2) => { + q2.where("posts.id", "in", subq).orWhere("posts.is_public", true); + }); + case "votes": + q.join( + "communities_posts", + "votes.post_id", + "communities_posts.post_id" + ); + return filterCommunities(q, "communities_posts.community_id", userId); default: - throw new Error(`sharedNetworkMembership filter does not support ${tableName}`) + throw new Error( + `sharedNetworkMembership filter does not support ${tableName}` + ); } - })) - -export const commentFilter = userId => relation => relation.query(q => { - q.distinct() - q.where({'comments.active': true}) - - if (userId) { - q.leftJoin('communities_posts', 'comments.post_id', 'communities_posts.post_id') - q.leftJoin('posts', 'communities_posts.post_id', 'posts.id') - q.where('comments.user_id', 'NOT IN', BlockedUser.blockedFor(userId)) - q.where(q2 => { - const groupIds = Group.pluckIdsForMember(userId, Post, isFollowing) - q2.where('comments.post_id', 'in', groupIds) - .orWhere(q3 => filterCommunities(q3, 'communities_posts.community_id', userId)) - .orWhere('posts.is_public', true) - }) - } -}) + }) +); + +export const commentFilter = (userId) => (relation) => + relation.query((q) => { + q.distinct(); + q.where({ "comments.active": true }); -export const activePost = userId => relation => { - return relation.query(q => { if (userId) { - q.where('posts.user_id', 'NOT IN', BlockedUser.blockedFor(userId)) + q.leftJoin( + "communities_posts", + "comments.post_id", + "communities_posts.post_id" + ); + q.leftJoin("posts", "communities_posts.post_id", "posts.id"); + q.where("comments.user_id", "NOT IN", BlockedUser.blockedFor(userId)); + q.where((q2) => { + const groupIds = Group.pluckIdsForMember(userId, Post, isFollowing); + q2.where("comments.post_id", "in", groupIds) + .orWhere((q3) => + filterCommunities(q3, "communities_posts.community_id", userId) + ) + .orWhere("posts.is_public", true); + }); } - q.where('posts.active', true) - }) -} + }); + +export const activePost = (userId) => (relation) => { + return relation.query((q) => { + if (userId) { + q.where("posts.user_id", "NOT IN", BlockedUser.blockedFor(userId)); + } + q.where("posts.active", true); + }); +}; -export const authFilter = (userId, tableName) => relation => { - return relation.query(q => { +export const authFilter = (userId, tableName) => (relation) => { + return relation.query((q) => { // non authenticated queries can only see public things if (!userId) { - q.where(tableName + '.is_public', true) + q.where(tableName + ".is_public", true); } - }) -} - -export function communityTopicFilter (userId, { - autocomplete, - communityId, - isDefault, - subscribed, - visibility -}) { - return q => { + }); +}; + +export function communityTopicFilter( + userId, + { autocomplete, communityId, isDefault, subscribed, visibility } +) { + return (q) => { if (communityId) { - q.where('communities_tags.community_id', communityId) + q.where("communities_tags.community_id", communityId); } if (autocomplete) { - q.join('tags', 'tags.id', 'communities_tags.tag_id') - q.whereRaw('tags.name ilike ?', autocomplete + '%') + q.join("tags", "tags.id", "communities_tags.tag_id"); + q.whereRaw("tags.name ilike ?", autocomplete + "%"); } if (isDefault) { - q.where('communities_tags.is_default', true) + q.where("communities_tags.is_default", true); } if (subscribed) { - q.join('tag_follows', 'tag_follows.tag_id', 'communities_tags.tag_id') - q.where('tag_follows.user_id', userId) - q.whereRaw('tag_follows.community_id = communities_tags.community_id') + q.join("tag_follows", "tag_follows.tag_id", "communities_tags.tag_id"); + q.where("tag_follows.user_id", userId); + q.whereRaw("tag_follows.community_id = communities_tags.community_id"); } if (visibility) { - q.where('communities_tags.visibility', 'in', visibility) + q.where("communities_tags.visibility", "in", visibility); } - } + }; } diff --git a/api/graphql/filters.test.js b/api/graphql/filters.test.js index 5531e4a09..16791c3ec 100644 --- a/api/graphql/filters.test.js +++ b/api/graphql/filters.test.js @@ -1,49 +1,51 @@ -import { makeFilterToggle, sharedNetworkMembership } from './filters' -import makeModels from './makeModels' -import { expectEqualQuery } from '../../test/setup/helpers' +import { makeFilterToggle, sharedNetworkMembership } from "./filters"; +import makeModels from "./makeModels"; +import { expectEqualQuery } from "../../test/setup/helpers"; import { - myCommunityIdsSqlFragment, myNetworkCommunityIdsSqlFragment, blockedUserSqlFragment -} from '../models/util/queryFilters.test.helpers' -import factories from '../../test/setup/factories' + myCommunityIdsSqlFragment, + myNetworkCommunityIdsSqlFragment, + blockedUserSqlFragment, +} from "../models/util/queryFilters.test.helpers"; +import factories from "../../test/setup/factories"; -const myId = '42' +const myId = "42"; -var models, sharedMemberships +let models, sharedMemberships; const setupBlockedUserData = async () => { - const u1 = factories.user() - const u2 = factories.user() - const u3 = factories.user() - const u4 = factories.user() - const community = factories.community() - await u1.save() - await u2.save() - await u3.save() - await u4.save() - await community.save() - await u1.joinCommunity(community) - await u2.joinCommunity(community) - await u3.joinCommunity(community) - await u4.joinCommunity(community) - await BlockedUser.create(u1.id, u2.id) - await BlockedUser.create(u3.id, u1.id) - return {u1, u2, u3, u4, community} -} - -describe('makeFilterToggle', () => { - var filterFn = relation => relation.query(q => 'filtered') - var relation = {query: fn => fn()} - - it('adds a filter when enabled', () => { - expect(makeFilterToggle(true)(filterFn)(relation)).to.equal('filtered') - }) - - it('adds no filter when disabled', () => { - expect(makeFilterToggle(false)(filterFn)(relation)).to.equal(relation) - }) -}) - -describe('model filters', () => { + const u1 = factories.user(); + const u2 = factories.user(); + const u3 = factories.user(); + const u4 = factories.user(); + const community = factories.community(); + await u1.save(); + await u2.save(); + await u3.save(); + await u4.save(); + await community.save(); + await u1.joinCommunity(community); + await u2.joinCommunity(community); + await u3.joinCommunity(community); + await u4.joinCommunity(community); + await BlockedUser.create(u1.id, u2.id); + await BlockedUser.create(u3.id, u1.id); + return { u1, u2, u3, u4, community }; +}; + +describe("makeFilterToggle", () => { + const filterFn = (relation) => relation.query((q) => "filtered"); + const relation = { query: (fn) => fn() }; + + it("adds a filter when enabled", () => { + expect(makeFilterToggle(true)(filterFn)(relation)).to.equal("filtered"); + }); + + it("adds no filter when disabled", () => { + expect(makeFilterToggle(false)(filterFn)(relation)).to.equal(relation); + }); +}); + +describe("model filters", () => { before(async () => { sharedMemberships = `"group_memberships" where "group_memberships"."active" = true @@ -52,48 +54,54 @@ describe('model filters', () => { where "group_memberships"."group_data_type" = ${Group.DataType.COMMUNITY} and "group_memberships"."user_id" in ('${myId}', '${User.AXOLOTL_ID}') and "group_memberships"."active" = true - )` + )`; - models = await makeModels(myId, false) - }) + models = await makeModels(myId, false); + }); - describe('Membership', () => { - it('filters down to memberships for communities the user is in', () => { - const collection = models.Membership.filter(GroupMembership.collection()) - expectEqualQuery(collection, `select * from ${sharedMemberships}`) - }) - }) + describe("Membership", () => { + it("filters down to memberships for communities the user is in", () => { + const collection = models.Membership.filter(GroupMembership.collection()); + expectEqualQuery(collection, `select * from ${sharedMemberships}`); + }); + }); - describe('Person', () => { - var u1, u4; + describe("Person", () => { + let u1, u4; before(async () => { - const blockedUserData = await setupBlockedUserData() - u1 = blockedUserData.u1 - u4 = blockedUserData.u4 - }) - - it('filters out blocked and blocking users', async () => { - const models = await makeModels(u1.id, false) - const users = await models.Person.filter(User.collection()).fetch() - expect(users.map('id')).to.deep.equal([u1.id, u4.id]) - }) - - it('includes people you share a connection with', async () => { - const currentUser = await factories.user().save() - const connectedUser = await factories.user().save() + const blockedUserData = await setupBlockedUserData(); + u1 = blockedUserData.u1; + u4 = blockedUserData.u4; + }); + + it("filters out blocked and blocking users", async () => { + const models = await makeModels(u1.id, false); + const users = await models.Person.filter(User.collection()).fetch(); + expect(users.map("id")).to.deep.equal([u1.id, u4.id]); + }); + + it("includes people you share a connection with", async () => { + const currentUser = await factories.user().save(); + const connectedUser = await factories.user().save(); // another user - await factories.user().save() - await UserConnection.create(currentUser.id, connectedUser.id, UserConnection.Type.MESSAGE) - - const models = await makeModels(currentUser.id, false) - const users = await models.Person.filter(User.collection()).fetch() - expect(users.map('id')).to.deep.equal([connectedUser.id]) - }) - - it.skip('filters down to people that share a community with the user', () => { - const collection = models.Person.filter(User.collection()) - expectEqualQuery(collection, `select * from "users" + await factories.user().save(); + await UserConnection.create( + currentUser.id, + connectedUser.id, + UserConnection.Type.MESSAGE + ); + + const models = await makeModels(currentUser.id, false); + const users = await models.Person.filter(User.collection()).fetch(); + expect(users.map("id")).to.deep.equal([connectedUser.id]); + }); + + it.skip("filters down to people that share a community with the user", () => { + const collection = models.Person.filter(User.collection()); + expectEqualQuery( + collection, + `select * from "users" where ${blockedUserSqlFragment(42)} and @@ -107,40 +115,43 @@ describe('model filters', () => { ${myCommunityIdsSqlFragment(42)} or "groups"."group_data_id" in ${myNetworkCommunityIdsSqlFragment(42)}) - and "group_memberships"."group_data_type" = 1))`) - }) - }) + and "group_memberships"."group_data_type" = 1))` + ); + }); + }); - describe('Post', () => { - var u1, u2, u3, u4, community; + describe("Post", () => { + let u1, u2, u3, u4, community; before(async () => { - const blockedUserData = await setupBlockedUserData() - u1 = blockedUserData.u1 - u2 = blockedUserData.u2 - u3 = blockedUserData.u3 - u4 = blockedUserData.u4 - community = blockedUserData.community - const p1 = factories.post({user_id: u2.id}) - const p2 = factories.post({user_id: u3.id}) - const p3 = factories.post({user_id: u4.id}) - await p1.save({active: true}) - await p1.communities().attach(community) - await p2.save({active: true}) - await p2.communities().attach(community) - await p3.save({active: true}) - await p3.communities().attach(community) - }) - - it('filters posts by blocked and blocking users', async () => { - const models = await makeModels(u1.id, false) - const posts = await models.Post.filter(Post.collection()).fetch() - expect(posts.models.map(p => p.get('user_id'))).to.deep.equal([u4.id]) - }) - - it.skip('filters down to active in-network posts', () => { - const collection = models.Post.filter(Post.collection()) - expectEqualQuery(collection, `select * from "posts" + const blockedUserData = await setupBlockedUserData(); + u1 = blockedUserData.u1; + u2 = blockedUserData.u2; + u3 = blockedUserData.u3; + u4 = blockedUserData.u4; + community = blockedUserData.community; + const p1 = factories.post({ user_id: u2.id }); + const p2 = factories.post({ user_id: u3.id }); + const p3 = factories.post({ user_id: u4.id }); + await p1.save({ active: true }); + await p1.communities().attach(community); + await p2.save({ active: true }); + await p2.communities().attach(community); + await p3.save({ active: true }); + await p3.communities().attach(community); + }); + + it("filters posts by blocked and blocking users", async () => { + const models = await makeModels(u1.id, false); + const posts = await models.Post.filter(Post.collection()).fetch(); + expect(posts.models.map((p) => p.get("user_id"))).to.deep.equal([u4.id]); + }); + + it.skip("filters down to active in-network posts", () => { + const collection = models.Post.filter(Post.collection()); + expectEqualQuery( + collection, + `select * from "posts" where "posts"."active" = true and "posts"."id" in ( select "post_id" from "communities_posts" @@ -148,14 +159,17 @@ describe('model filters', () => { "community_id" in ${myCommunityIdsSqlFragment(myId)} or "community_id" in ${myNetworkCommunityIdsSqlFragment(myId)} ) - )`) - }) - }) - - describe('Comment', () => { - it.skip('filters down to active comments on in-network posts or followed posts', () => { - const collection = models.Comment.filter(Comment.collection()) - expectEqualQuery(collection, `select distinct * from "comments" + )` + ); + }); + }); + + describe("Comment", () => { + it.skip("filters down to active comments on in-network posts or followed posts", () => { + const collection = models.Comment.filter(Comment.collection()); + expectEqualQuery( + collection, + `select distinct * from "comments" left join "communities_posts" on "comments"."post_id" = "communities_posts"."post_id" where "comments"."active" = true @@ -171,18 +185,23 @@ describe('model filters', () => { and "groups"."active" = true ) or (( - "communities_posts"."community_id" in ${myCommunityIdsSqlFragment(myId)} - or "communities_posts"."community_id" in ${myNetworkCommunityIdsSqlFragment(myId)} + "communities_posts"."community_id" in ${myCommunityIdsSqlFragment( + myId + )} + or "communities_posts"."community_id" in ${myNetworkCommunityIdsSqlFragment( + myId + )} )) - )`) - }) - }) -}) - -describe('sharedNetworkMembership', () => { - it('supports a limited set of tables', () => { + )` + ); + }); + }); +}); + +describe("sharedNetworkMembership", () => { + it("supports a limited set of tables", () => { expect(() => { - sharedNetworkMembership('foo', 42, Post.collection()) - }).to.throw(/does not support foo/) - }) -}) \ No newline at end of file + sharedNetworkMembership("foo", 42, Post.collection()); + }).to.throw(/does not support foo/); + }); +}); diff --git a/api/graphql/index.js b/api/graphql/index.js index 2b2fedfdf..289aa1db3 100644 --- a/api/graphql/index.js +++ b/api/graphql/index.js @@ -1,8 +1,8 @@ -import { readFileSync } from 'fs' -import graphqlHTTP from 'express-graphql' -import { join } from 'path' -import setupBridge from '../../lib/graphql-bookshelf-bridge' -import { presentQuerySet } from '../../lib/graphql-bookshelf-bridge/util' +import { readFileSync } from "fs"; +import graphqlHTTP from "express-graphql"; +import { join } from "path"; +import setupBridge from "../../lib/graphql-bookshelf-bridge"; +import { presentQuerySet } from "../../lib/graphql-bookshelf-bridge/util"; import { acceptJoinRequest, addCommunityToNetwork, @@ -71,145 +71,170 @@ import { updatePost, updateStripeAccount, useInvitation, - vote -} from './mutations' -import InvitationService from '../services/InvitationService' -import makeModels from './makeModels' -import { makeExecutableSchema } from 'graphql-tools' -import { inspect } from 'util' -import { red } from 'chalk' -import { mapValues, merge, reduce } from 'lodash' - -const schemaText = readFileSync(join(__dirname, 'schema.graphql')).toString() - -async function createSchema (userId, isAdmin) { - const models = await makeModels(userId, isAdmin) - const { resolvers, fetchOne, fetchMany } = setupBridge(models) - - let allResolvers = Object.assign({ - Query: userId ? makeAuthenticatedQueries(userId, fetchOne, fetchMany) : makePublicQueries(userId, fetchOne, fetchMany), - Mutation: userId ? makeMutations(userId, isAdmin) : {}, - - FeedItemContent: { - __resolveType (data, context, info) { - if (data instanceof bookshelf.Model) { - return info.schema.getType('Post') - } - throw new Error('Post is the only implemented FeedItemContent type') - } + vote, +} from "./mutations"; +import InvitationService from "../services/InvitationService"; +import makeModels from "./makeModels"; +import { makeExecutableSchema } from "graphql-tools"; +import { inspect } from "util"; +import { red } from "chalk"; +import { mapValues, merge, reduce } from "lodash"; + +const schemaText = readFileSync(join(__dirname, "schema.graphql")).toString(); + +async function createSchema(userId, isAdmin) { + const models = await makeModels(userId, isAdmin); + const { resolvers, fetchOne, fetchMany } = setupBridge(models); + + const allResolvers = Object.assign( + { + Query: userId + ? makeAuthenticatedQueries(userId, fetchOne, fetchMany) + : makePublicQueries(userId, fetchOne, fetchMany), + Mutation: userId ? makeMutations(userId, isAdmin) : {}, + + FeedItemContent: { + __resolveType(data, context, info) { + if (data instanceof bookshelf.Model) { + return info.schema.getType("Post"); + } + throw new Error("Post is the only implemented FeedItemContent type"); + }, + }, + + SearchResultContent: { + __resolveType(data, context, info) { + return getTypeForInstance(data, models); + }, + }, }, - - SearchResultContent: { - __resolveType (data, context, info) { - return getTypeForInstance(data, models) - } - } - }, resolvers) + resolvers + ); return makeExecutableSchema({ typeDefs: [schemaText], - resolvers: allResolvers - }) + resolvers: allResolvers, + }); } // Queries that non-logged in users can make -export function makePublicQueries (userId, fetchOne, fetchMany) { +export function makePublicQueries(userId, fetchOne, fetchMany) { return { // Can only access public communities and posts - community: async (root, { id, slug }) => fetchOne('Community', slug || id, slug ? 'slug' : 'id', { isPublic: true }), - communities: (root, args) => fetchMany('Community', Object.assign(args, { isPublic: true })), - posts: (root, args) => fetchMany('Post', Object.assign(args, { isPublic: true })), + community: async (root, { id, slug }) => + fetchOne("Community", slug || id, slug ? "slug" : "id", { + isPublic: true, + }), + communities: (root, args) => + fetchMany("Community", Object.assign(args, { isPublic: true })), + posts: (root, args) => + fetchMany("Post", Object.assign(args, { isPublic: true })), checkInvitation: (root, { invitationToken, accessCode }) => - InvitationService.check(userId, invitationToken, accessCode) - } + InvitationService.check(userId, invitationToken, accessCode), + }; } // Queries that logged in users can make -export function makeAuthenticatedQueries (userId, fetchOne, fetchMany) { +export function makeAuthenticatedQueries(userId, fetchOne, fetchMany) { return { - activity: (root, { id }) => fetchOne('Activity', id), - me: () => fetchOne('Me', userId), + activity: (root, { id }) => fetchOne("Activity", id), + me: () => fetchOne("Me", userId), community: async (root, { id, slug, updateLastViewed }) => { // you can specify id or slug, but not both - const response = await fetchOne('Community', slug || id, slug ? 'slug' : 'id') + const response = await fetchOne( + "Community", + slug || id, + slug ? "slug" : "id" + ); if (updateLastViewed) { - const community = await Community.find(id || slug) + const community = await Community.find(id || slug); if (community) { - const membership = await GroupMembership.forPair(userId, community).fetch() + const membership = await GroupMembership.forPair( + userId, + community + ).fetch(); if (membership) { - await membership.addSetting({lastReadAt: new Date()}, true) + await membership.addSetting({ lastReadAt: new Date() }, true); } } } - return response + return response; }, communityExists: (root, { slug }) => { if (Community.isSlugValid(slug)) { - return Community.where(bookshelf.knex.raw('slug = ?', slug)) - .count() - .then(count => { - if (count > 0) return {exists: true} - return {exists: false} - }) + return Community.where(bookshelf.knex.raw("slug = ?", slug)) + .count() + .then((count) => { + if (count > 0) return { exists: true }; + return { exists: false }; + }); } - throw new Error('Slug is invalid') + throw new Error("Slug is invalid"); }, - joinRequests: (root, args) => fetchMany('JoinRequest', args), - communities: (root, args) => fetchMany('Community', args), - notifications: (root, { first, offset, resetCount, order = 'desc' }) => { - return fetchMany('Notification', { first, offset, order }) - .tap(() => resetCount && User.resetNewNotificationCount(userId)) + joinRequests: (root, args) => fetchMany("JoinRequest", args), + communities: (root, args) => fetchMany("Community", args), + notifications: (root, { first, offset, resetCount, order = "desc" }) => { + return fetchMany("Notification", { first, offset, order }).tap( + () => resetCount && User.resetNewNotificationCount(userId) + ); }, - person: (root, { id }) => fetchOne('Person', id), - messageThread: (root, { id }) => fetchOne('MessageThread', id), - post: (root, { id }) => fetchOne('Post', id), - posts: (root, args) => fetchMany('Post', args), - people: (root, args) => fetchMany('Person', args), - connections: (root, args) => fetchMany('PersonConnection', args), - communityTopics: (root, args) => fetchMany('CommunityTopic', args), - topics: (root, args) => fetchMany('Topic', args), - topic: (root, { id, name }) => // you can specify id or name, but not both - fetchOne('Topic', name || id, name ? 'name' : 'id'), + person: (root, { id }) => fetchOne("Person", id), + messageThread: (root, { id }) => fetchOne("MessageThread", id), + post: (root, { id }) => fetchOne("Post", id), + posts: (root, args) => fetchMany("Post", args), + people: (root, args) => fetchMany("Person", args), + connections: (root, args) => fetchMany("PersonConnection", args), + communityTopics: (root, args) => fetchMany("CommunityTopic", args), + topics: (root, args) => fetchMany("Topic", args), + topic: ( + root, + { id, name } // you can specify id or name, but not both + ) => fetchOne("Topic", name || id, name ? "name" : "id"), communityTopic: (root, { topicName, communitySlug }) => CommunityTag.findByTagAndCommunity(topicName, communitySlug), search: (root, args) => { - if (!args.first) args.first = 20 - return Search.fullTextSearch(userId, args) - .then(({ models, total }) => { + if (!args.first) args.first = 20; + return Search.fullTextSearch(userId, args).then(({ models, total }) => { // FIXME this shouldn't be used directly here -- there should be some // way of integrating this into makeModels and using the presentation // logic that's already in the fetcher - return presentQuerySet(models, merge(args, {total})) - }) + return presentQuerySet(models, merge(args, { total })); + }); }, - network: (root, { id, slug }) => // you can specify id or slug, but not both - fetchOne('Network', slug || id, slug ? 'slug' : 'id'), - skills: (root, args) => fetchMany('Skill', args), + network: ( + root, + { id, slug } // you can specify id or slug, but not both + ) => fetchOne("Network", slug || id, slug ? "slug" : "id"), + skills: (root, args) => fetchMany("Skill", args), checkInvitation: (root, { invitationToken, accessCode }) => InvitationService.check(userId, invitationToken, accessCode), - savedSearches: (root, args) => fetchMany('SavedSearch', args), - } + savedSearches: (root, args) => fetchMany("SavedSearch", args), + }; } -export function makeMutations (userId, isAdmin) { +export function makeMutations(userId, isAdmin) { return { - acceptJoinRequest: (root, { joinRequestId, communityId, userId, moderatorId }) => acceptJoinRequest(joinRequestId, communityId, userId, moderatorId), + acceptJoinRequest: ( + root, + { joinRequestId, communityId, userId, moderatorId } + ) => acceptJoinRequest(joinRequestId, communityId, userId, moderatorId), addCommunityToNetwork: (root, { communityId, networkId }) => - addCommunityToNetwork({ userId, isAdmin }, { communityId, networkId }), + addCommunityToNetwork({ userId, isAdmin }, { communityId, networkId }), addModerator: (root, { personId, communityId }) => - addModerator(userId, personId, communityId), + addModerator(userId, personId, communityId), addNetworkModeratorRole: (root, { personId, networkId }) => - addNetworkModeratorRole({ userId, isAdmin }, { personId, networkId }), + addNetworkModeratorRole({ userId, isAdmin }, { personId, networkId }), addPeopleToProjectRole: (root, { peopleIds, projectRoleId }) => - addPeopleToProjectRole(userId, peopleIds, projectRoleId), + addPeopleToProjectRole(userId, peopleIds, projectRoleId), addSkill: (root, { name }) => addSkill(userId, name), - allowCommunityInvites: (root, { communityId, data }) => allowCommunityInvites(communityId, data), + allowCommunityInvites: (root, { communityId, data }) => + allowCommunityInvites(communityId, data), blockUser: (root, { blockedUserId }) => blockUser(userId, blockedUserId), @@ -217,10 +242,11 @@ export function makeMutations (userId, isAdmin) { createCommunity: (root, { data }) => createCommunity(userId, data), - createInvitation: (root, {communityId, data}) => - createInvitation(userId, communityId, data), + createInvitation: (root, { communityId, data }) => + createInvitation(userId, communityId, data), - createJoinRequest: (root, {communityId, userId}) => createJoinRequest(communityId, userId), + createJoinRequest: (root, { communityId, userId }) => + createJoinRequest(communityId, userId), createMessage: (root, { data }) => createMessage(userId, data), @@ -228,17 +254,21 @@ export function makeMutations (userId, isAdmin) { createProject: (root, { data }) => createProject(userId, data), - createProjectRole: (root, { projectId, roleName }) => createProjectRole(userId, projectId, roleName), + createProjectRole: (root, { projectId, roleName }) => + createProjectRole(userId, projectId, roleName), createSavedSearch: (root, { data }) => createSavedSearch(data), - - joinCommunity: (root, {communityId, userId}) => joinCommunity(communityId, userId), + + joinCommunity: (root, { communityId, userId }) => + joinCommunity(communityId, userId), joinProject: (root, { id }) => joinProject(id, userId), - createTopic: (root, { topicName, communityId, isDefault, isSubscribing }) => createTopic(userId, topicName, communityId, isDefault, isSubscribing), + createTopic: (root, { topicName, communityId, isDefault, isSubscribing }) => + createTopic(userId, topicName, communityId, isDefault, isSubscribing), - declineJoinRequest: (root, { joinRequestId }) => declineJoinRequest(joinRequestId), + declineJoinRequest: (root, { joinRequestId }) => + declineJoinRequest(joinRequestId), deleteComment: (root, { id }) => deleteComment(userId, id), @@ -252,7 +282,7 @@ export function makeMutations (userId, isAdmin) { deleteSavedSearch: (root, { id }) => deleteSavedSearch(id), - expireInvitation: (root, {invitationId}) => + expireInvitation: (root, { invitationId }) => expireInvitation(userId, invitationId), findOrCreateThread: (root, { data }) => findOrCreateThread(userId, data), @@ -267,7 +297,7 @@ export function makeMutations (userId, isAdmin) { fulfillPost: (root, { postId }) => fulfillPost(userId, postId), - invitePeopleToEvent: (root, {eventId, inviteeIds}) => + invitePeopleToEvent: (root, { eventId, inviteeIds }) => invitePeopleToEvent(userId, eventId, inviteeIds), leaveCommunity: (root, { id }) => leaveCommunity(userId, id), @@ -293,10 +323,13 @@ export function makeMutations (userId, isAdmin) { registerStripeAccount: (root, { authorizationCode }) => registerStripeAccount(userId, authorizationCode), - reinviteAll: (root, {communityId}) => reinviteAll(userId, communityId), + reinviteAll: (root, { communityId }) => reinviteAll(userId, communityId), removeCommunityFromNetwork: (root, { communityId, networkId }) => - removeCommunityFromNetwork({ userId, isAdmin }, { communityId, networkId }), + removeCommunityFromNetwork( + { userId, isAdmin }, + { communityId, networkId } + ), removeMember: (root, { personId, communityId }) => removeMember(userId, personId, communityId), @@ -312,21 +345,21 @@ export function makeMutations (userId, isAdmin) { removeSkill: (root, { id, name }) => removeSkill(userId, id || name), - resendInvitation: (root, {invitationId}) => + resendInvitation: (root, { invitationId }) => resendInvitation(userId, invitationId), - respondToEvent: (root, {id, response}) => + respondToEvent: (root, { id, response }) => respondToEvent(userId, id, response), subscribe: (root, { communityId, topicId, isSubscribing }) => subscribe(userId, topicId, communityId, isSubscribing), - unblockUser: (root, { blockedUserId }) => unblockUser(userId, blockedUserId), + unblockUser: (root, { blockedUserId }) => + unblockUser(userId, blockedUserId), unfulfillPost: (root, { postId }) => unfulfillPost(userId, postId), - unlinkAccount: (root, { provider }) => - unlinkAccount(userId, provider), + unlinkAccount: (root, { provider }) => unlinkAccount(userId, provider), updateCommunitySettings: (root, { id, changes }) => updateCommunity(userId, id, changes), @@ -334,9 +367,11 @@ export function makeMutations (userId, isAdmin) { updateCommunityHiddenSetting: (root, { id, hidden }) => updateCommunityHiddenSetting({ userId, isAdmin }, id, hidden), - updateCommunityTopic: (root, { id, data }) => updateCommunityTopic(id, data), + updateCommunityTopic: (root, { id, data }) => + updateCommunityTopic(id, data), - updateCommunityTopicFollow: (root, args) => updateCommunityTopic(userId, args), + updateCommunityTopicFollow: (root, args) => + updateCommunityTopic(userId, args), updateMe: (root, { changes }) => updateMe(userId, changes), @@ -347,24 +382,28 @@ export function makeMutations (userId, isAdmin) { updatePost: (root, args) => updatePost(userId, args), updateComment: (root, args) => updateComment(userId, args), - updateStripeAccount: (root, { accountId }) => updateStripeAccount(userId, accountId), + updateStripeAccount: (root, { accountId }) => + updateStripeAccount(userId, accountId), useInvitation: (root, { invitationToken, accessCode }) => useInvitation(userId, invitationToken, accessCode), - vote: (root, { postId, isUpvote }) => vote(userId, postId, isUpvote) - } + vote: (root, { postId, isUpvote }) => vote(userId, postId, isUpvote), + }; } export const createRequestHandler = () => graphqlHTTP(async (req, res) => { if (process.env.DEBUG_GRAPHQL) { - sails.log.info('\n' + - red('graphql query start') + '\n' + - req.body.query + '\n' + - red('graphql query end') - ) - sails.log.info(inspect(req.body.variables)) + sails.log.info( + "\n" + + red("graphql query start") + + "\n" + + req.body.query + + "\n" + + red("graphql query end") + ); + sails.log.info(inspect(req.body.variables)); } // TODO: since this function can return a promise, we could run through some @@ -375,37 +414,44 @@ export const createRequestHandler = () => // query to find the policies which should be tested, and run them to allow // or deny access to those paths - const schema = await createSchema(req.session.userId, Admin.isSignedIn(req)) + const schema = await createSchema( + req.session.userId, + Admin.isSignedIn(req) + ); return { schema, graphiql: true, - formatError: process.env.NODE_ENV === 'development' ? logError : null - } - }) + formatError: process.env.NODE_ENV === "development" ? logError : null, + }; + }); -var modelToTypeMap +let modelToTypeMap; -function getTypeForInstance (instance, models) { +function getTypeForInstance(instance, models) { if (!modelToTypeMap) { - modelToTypeMap = reduce(models, (m, v, k) => { - const tableName = v.model.forge().tableName - if (!m[tableName] || v.isDefaultTypeForTable) { - m[tableName] = k - } - return m - }, {}) + modelToTypeMap = reduce( + models, + (m, v, k) => { + const tableName = v.model.forge().tableName; + if (!m[tableName] || v.isDefaultTypeForTable) { + m[tableName] = k; + } + return m; + }, + {} + ); } - return modelToTypeMap[instance.tableName] + return modelToTypeMap[instance.tableName]; } -function logError (error) { - console.error(error.stack) +function logError(error) { + console.error(error.stack); return { message: error.message, locations: error.locations, stack: error.stack, - path: error.path - } + path: error.path, + }; } diff --git a/api/graphql/index.test.js b/api/graphql/index.test.js index 0f2b0e672..21ac64b94 100644 --- a/api/graphql/index.test.js +++ b/api/graphql/index.test.js @@ -1,59 +1,66 @@ /* eslint-disable no-unused-expressions */ -import { createRequestHandler, makeMutations, makeQueries } from './index' -import '../../test/setup' -import factories from '../../test/setup/factories' -import { spyify, unspyify } from '../../test/setup/helpers' -import { some, sortBy } from 'lodash/fp' -import { updateNetworkMemberships } from '../models/post/util' - -describe('graphql request handler', () => { - var handler, - req, res, - user, user2, - community, network, - post, post2, comment, media +import { createRequestHandler, makeMutations, makeQueries } from "./index"; +import "../../test/setup"; +import factories from "../../test/setup/factories"; +import { spyify, unspyify } from "../../test/setup/helpers"; +import { some, sortBy } from "lodash/fp"; +import { updateNetworkMemberships } from "../models/post/util"; + +describe("graphql request handler", () => { + let handler, + req, + res, + user, + user2, + community, + network, + post, + post2, + comment, + media; before(async () => { - handler = createRequestHandler() - - user = factories.user() - user2 = factories.user() - community = factories.community() - network = factories.network() - post = factories.post({type: Post.Type.DISCUSSION}) - post2 = factories.post({type: Post.Type.REQUEST}) - comment = factories.comment() - media = factories.media() - await network.save() - await community.save({network_id: network.id}) - await user.save() - await user2.save() - await post.save({user_id: user.id}) - await post2.save() - await comment.save({post_id: post.id}) - await media.save({comment_id: comment.id}) + handler = createRequestHandler(); + + user = factories.user(); + user2 = factories.user(); + community = factories.community(); + network = factories.network(); + post = factories.post({ type: Post.Type.DISCUSSION }); + post2 = factories.post({ type: Post.Type.REQUEST }); + comment = factories.comment(); + media = factories.media(); + await network.save(); + await community.save({ network_id: network.id }); + await user.save(); + await user2.save(); + await post.save({ user_id: user.id }); + await post2.save(); + await comment.save({ post_id: post.id }); + await media.save({ comment_id: comment.id }); return Promise.all([ community.posts().attach(post), community.posts().attach(post2), community.addMembers([user.id, user2.id]).then((memberships) => { - const earlier = new Date(new Date().getTime() - 86400000) - return memberships[0].save({created_at: earlier}, {patch: true}) - }) - ]) - .then(() => Promise.all([ - updateNetworkMemberships(post), - updateNetworkMemberships(post2) - ])) - }) + const earlier = new Date(new Date().getTime() - 86400000); + return memberships[0].save({ created_at: earlier }, { patch: true }); + }), + ]).then(() => + Promise.all([ + updateNetworkMemberships(post), + updateNetworkMemberships(post2), + ]) + ); + }); beforeEach(() => { - req = factories.mock.request() - req.method = 'POST' - req.session = {userId: user.id} - res = factories.mock.response() - }) + req = factories.mock.request(); + req.method = "POST"; + req.session = { userId: user.id }; + res = factories.mock.response(); + }); - describe('with a simple query', () => { + describe("with a simple query", () => { beforeEach(() => { req.body = { query: `{ @@ -71,56 +78,58 @@ describe('graphql request handler', () => { } } } - }` - } - }) + }`, + }; + }); - it('responds as expected', () => { + it("responds as expected", () => { return handler(req, res).then(() => { expectJSON(res, { data: { me: { - name: user.get('name'), + name: user.get("name"), memberships: [ { community: { - name: community.get('name') - } - } + name: community.get("name"), + }, + }, ], posts: [ { - title: post.get('name'), + title: post.get("name"), communities: [ { - name: community.get('name') - } - ] - } - ] - } - } - }) - }) - }) - }) + name: community.get("name"), + }, + ], + }, + ], + }, + }, + }); + }); + }); + }); - describe('with a complex query', () => { - var thread, message + describe("with a complex query", () => { + let thread, message; before(async () => { - thread = factories.post({type: Post.Type.THREAD}) - await thread.save() - await comment.save({user_id: user2.id}) - - message = await factories.comment({ - post_id: thread.id, - user_id: user2.id - }).save() + thread = factories.post({ type: Post.Type.THREAD }); + await thread.save(); + await comment.save({ user_id: user2.id }); + + message = await factories + .comment({ + post_id: thread.id, + user_id: user2.id, + }) + .save(); - await post.addFollowers([user2.id]) - await thread.addFollowers([user.id, user2.id]) - }) + await post.addFollowers([user2.id]); + await thread.addFollowers([user.id, user2.id]); + }); beforeEach(() => { req.body = { @@ -170,48 +179,48 @@ describe('graphql request handler', () => { } } } - }` - } - }) + }`, + }; + }); - it('responds as expected', () => { + it("responds as expected", () => { return handler(req, res).then(() => { expectJSON(res, { data: { me: { - name: user.get('name'), + name: user.get("name"), memberships: [ { community: { - name: community.get('name') - } - } + name: community.get("name"), + }, + }, ], posts: [ { - title: post.get('name'), + title: post.get("name"), communities: [ { - name: community.get('name') - } + name: community.get("name"), + }, ], comments: { items: [ { - text: comment.get('text'), + text: comment.get("text"), creator: { - name: user2.get('name') - } - } - ] + name: user2.get("name"), + }, + }, + ], }, followers: [ { - name: user2.get('name') - } + name: user2.get("name"), + }, ], - followersTotal: 1 - } + followersTotal: 1, + }, ], messageThreads: { hasMore: false, @@ -222,33 +231,33 @@ describe('graphql request handler', () => { messages: { items: [ { - text: message.get('text'), + text: message.get("text"), creator: { - name: user2.get('name') - } - } - ] + name: user2.get("name"), + }, + }, + ], }, participants: [ { - name: user.get('name') + name: user.get("name"), }, { - name: user2.get('name') - } + name: user2.get("name"), + }, ], - participantsTotal: 2 - } - ] - } - } - } - }) - }) - }) - }) + participantsTotal: 2, + }, + ], + }, + }, + }, + }); + }); + }); + }); - describe('querying Comment attachments', () => { + describe("querying Comment attachments", () => { beforeEach(() => { req.body = { query: `{ @@ -265,11 +274,11 @@ describe('graphql request handler', () => { } } } - }` - } - }) + }`, + }; + }); - it('responds as expected', () => { + it("responds as expected", () => { return handler(req, res).then(() => { expectJSON(res, { data: { @@ -277,29 +286,29 @@ describe('graphql request handler', () => { comments: { items: [ { - text: comment.get('text'), + text: comment.get("text"), attachments: [ { id: media.id, - type: media.get('type'), - position: media.get('position'), - url: media.get('url') - } - ] - } - ] - } - } - } - }) - }) - }) - }) + type: media.get("type"), + position: media.get("position"), + url: media.get("url"), + }, + ], + }, + ], + }, + }, + }, + }); + }); + }); + }); - describe('without a logged-in user', () => { + describe("without a logged-in user", () => { beforeEach(() => { - req.session = {} - }) + req.session = {}; + }); it('shows "not logged in" errors for most queries', () => { req.body = { @@ -310,57 +319,53 @@ describe('graphql request handler', () => { community(id: 9) { name } - }` - } + }`, + }; return handler(req, res).then(() => { expectJSON(res, { data: { me: null, - community: null + community: null, }, errors: [ { - locations: [ - {column: 11, line: 2} - ], - message: 'not logged in', - path: ['me'] + locations: [{ column: 11, line: 2 }], + message: "not logged in", + path: ["me"], }, { - locations: [ - {column: 11, line: 5} - ], - message: 'not logged in', - path: ['community'] - } - ] - }) - }) - }) + locations: [{ column: 11, line: 5 }], + message: "not logged in", + path: ["community"], + }, + ], + }); + }); + }); - it('allows checkInvitation', () => { + it("allows checkInvitation", () => { req.body = { query: `{ checkInvitation(invitationToken: "foo") { valid } - }` - } + }`, + }; return handler(req, res).then(() => { expectJSON(res, { data: { checkInvitation: { - valid: false - } - } - }) - }) - }) - }) + valid: false, + }, + }, + }); + }); + }); + }); - describe('querying community data', () => { - it('works as expected', () => { + describe("querying community data", () => { + it("works as expected", () => { req.body = { query: `{ community(id: "${community.id}") { @@ -376,33 +381,31 @@ describe('graphql request handler', () => { } } } - }` - } + }`, + }; return handler(req, res).then(() => { expectJSON(res, { data: { community: { - slug: community.get('slug'), + slug: community.get("slug"), members: { items: [ - {name: user2.get('name')}, - {name: user.get('name')} - ] + { name: user2.get("name") }, + { name: user.get("name") }, + ], }, posts: { - items: [ - {title: post2.get('name')} - ] - } - } - } - }) - }) - }) + items: [{ title: post2.get("name") }], + }, + }, + }, + }); + }); + }); - describe('with an invalid sort option', () => { - it('shows an error', () => { + describe("with an invalid sort option", () => { + it("shows an error", () => { req.body = { query: `{ community(id: "${community.id}") { @@ -412,33 +415,31 @@ describe('graphql request handler', () => { } } } - }` - } + }`, + }; return handler(req, res).then(() => { expectJSON(res, { data: { community: { - members: null - } + members: null, + }, }, errors: [ { - locations: [ - {column: 15, line: 3} - ], + locations: [{ column: 15, line: 3 }], message: 'Cannot sort by "height"', - path: ['community', 'members'] - } - ] - }) - }) - }) - }) - }) - - describe('querying network data', () => { - it('works as expected', () => { + path: ["community", "members"], + }, + ], + }); + }); + }); + }); + }); + + describe("querying network data", () => { + it("works as expected", () => { req.body = { query: `{ network(id: "${network.id}") { @@ -456,44 +457,43 @@ describe('graphql request handler', () => { } } } - }` - } + }`, + }; return handler(req, res).then(() => { expectJSON(res, { data: { network: { - slug: network.get('slug'), + slug: network.get("slug"), isAdmin: false, isModerator: false, members: { - items: sortBy('name', [ - {name: user2.get('name')}, - {name: user.get('name')} - ]) + items: sortBy("name", [ + { name: user2.get("name") }, + { name: user.get("name") }, + ]), }, posts: { - items: [ - {title: post2.get('name')} - ] - } - } - } - }) - }) - }) - }) + items: [{ title: post2.get("name") }], + }, + }, + }, + }); + }); + }); + }); - describe('search', () => { + describe("search", () => { beforeEach(() => { - return FullTextSearch.dropView().catch(() => {}) - .then(() => FullTextSearch.createView()) - }) + return FullTextSearch.dropView() + .catch(() => {}) + .then(() => FullTextSearch.createView()); + }); - it('works', () => { + it("works", () => { req.body = { query: `{ - search(term: "${post.get('name').substring(0, 4)}") { + search(term: "${post.get("name").substring(0, 4)}") { items { content { __typename @@ -503,8 +503,8 @@ describe('graphql request handler', () => { } } } - }` - } + }`, + }; return handler(req, res).then(() => { expectJSON(res, { @@ -513,186 +513,196 @@ describe('graphql request handler', () => { items: [ { content: { - __typename: 'Post', - title: post.get('name') - } - } - ] - } - } - }) - }) - }) - }) + __typename: "Post", + title: post.get("name"), + }, + }, + ], + }, + }, + }); + }); + }); + }); - describe('removeSkill', () => { - var skill1, skill2 + describe("removeSkill", () => { + let skill1, skill2; before(() => { - skill1 = factories.skill() - skill2 = factories.skill() - return Promise.join(skill1.save(), skill2.save()) - }) + skill1 = factories.skill(); + skill2 = factories.skill(); + return Promise.join(skill1.save(), skill2.save()); + }); beforeEach(() => { return Promise.join( user.skills().detach(skill1), user.skills().detach(skill2) - ).then(() => Promise.join( - user.skills().attach(skill1), - user.skills().attach(skill2) - )) - }) + ).then(() => + Promise.join(user.skills().attach(skill1), user.skills().attach(skill2)) + ); + }); - it('removes a skill with an id', () => { + it("removes a skill with an id", () => { req.body = { query: `mutation { removeSkill(id: ${skill1.id}) { success } - }` - } + }`, + }; return handler(req, res) - .then(() => user.load('skills')) - .then(() => { - expectJSON(res, { - data: { - removeSkill: { - success: true - } - } - }) - expect(user.relations.skills.length).to.equal(1) - expect(user.relations.skills.first().id).to.equal(skill2.id) - }) - }) + .then(() => user.load("skills")) + .then(() => { + expectJSON(res, { + data: { + removeSkill: { + success: true, + }, + }, + }); + expect(user.relations.skills.length).to.equal(1); + expect(user.relations.skills.first().id).to.equal(skill2.id); + }); + }); - it('removes a skill with a name', () => { + it("removes a skill with a name", () => { req.body = { query: `mutation { - removeSkill(name: "${skill2.get('name')}") { + removeSkill(name: "${skill2.get("name")}") { success } - }` - } + }`, + }; return handler(req, res) - .then(() => user.load('skills')) - .then(() => { - expectJSON(res, { - data: { - removeSkill: { - success: true - } - } - }) - expect(user.relations.skills.length).to.equal(1) - expect(user.relations.skills.first().id).to.equal(skill1.id) - }) - }) - }) -}) - -describe('makeMutations', () => { - it('imports mutation functions correctly', () => { + .then(() => user.load("skills")) + .then(() => { + expectJSON(res, { + data: { + removeSkill: { + success: true, + }, + }, + }); + expect(user.relations.skills.length).to.equal(1); + expect(user.relations.skills.first().id).to.equal(skill1.id); + }); + }); + }); +}); + +describe("makeMutations", () => { + it("imports mutation functions correctly", () => { // this test does not check the correctness of the functions used in // mutations; it only checks that they are actually functions (i.e. it fails // if there are any broken imports) - const mutations = makeMutations(11) - const root = {} - const args = {} + const mutations = makeMutations(11); + const root = {}; + const args = {}; - return Promise.each(Object.keys(mutations), key => { - const fn = mutations[key] + return Promise.each(Object.keys(mutations), (key) => { + const fn = mutations[key]; return Promise.resolve() - .then(() => fn(root, args)) - .catch(err => { - if (some(pattern => err.message.match(pattern), [ - /is not a function/, - /is not defined/ - ])) { - expect.fail(null, null, `Mutation "${key}" is not imported correctly: ${err.message}`) - } - - // FIXME: the console.log below shows a number of places where we need - // more validation and/or are exposing SQL errors to the end-user - // console.log(`${key}: ${err.message}`) - }) - }) - }) -}) - -describe('makeQueries', () => { - let queries, user + .then(() => fn(root, args)) + .catch((err) => { + if ( + some((pattern) => err.message.match(pattern), [ + /is not a function/, + /is not defined/, + ]) + ) { + expect.fail( + null, + null, + `Mutation "${key}" is not imported correctly: ${err.message}` + ); + } + + // FIXME: the console.log below shows a number of places where we need + // more validation and/or are exposing SQL errors to the end-user + // console.log(`${key}: ${err.message}`) + }); + }); + }); +}); + +describe("makeQueries", () => { + let queries, user; before(async () => { - user = await factories.user().save() - const fetchOne = spy(() => Promise.resolve({})) - const fetchMany = spy(() => Promise.resolve([])) - queries = makeQueries(user.id, fetchOne, fetchMany) - }) - - describe('communityExists', () => { - it('throws an error if slug is invalid', () => { + user = await factories.user().save(); + const fetchOne = spy(() => Promise.resolve({})); + const fetchMany = spy(() => Promise.resolve([])); + queries = makeQueries(user.id, fetchOne, fetchMany); + }); + + describe("communityExists", () => { + it("throws an error if slug is invalid", () => { expect(() => { - queries.communityExists(null, {slug: 'a b'}) - }).to.throw() - }) - - it('returns true if the slug is in use', () => { - const community = factories.community() - return community.save() - .then(() => queries.communityExists(null, {slug: community.get('slug')})) - .then(result => expect(result.exists).to.be.true) - }) - - it('returns false if the slug is not in use', () => { - return queries.communityExists(null, {slug: 'sofadogtotherescue'}) - .then(result => expect(result.exists).to.be.false) - }) - }) - - describe('notifications', () => { - beforeEach(() => spyify(User, 'resetNewNotificationCount')) - afterEach(() => unspyify(User, 'query')) - - it('resets new notification count if requested', () => { - return queries.notifications(null, {resetCount: true}) - .then(() => { - expect(User.resetNewNotificationCount).to.have.been.called.with(user.id) - }) - }) - - it('does not reset new notification count if not requested', () => { - return queries.notifications(null, {}) - .then(() => { - expect(User.resetNewNotificationCount).not.to.have.been.called() - }) - }) - }) - - describe('community', () => { - let community + queries.communityExists(null, { slug: "a b" }); + }).to.throw(); + }); + + it("returns true if the slug is in use", () => { + const community = factories.community(); + return community + .save() + .then(() => + queries.communityExists(null, { slug: community.get("slug") }) + ) + .then((result) => expect(result.exists).to.be.true); + }); + + it("returns false if the slug is not in use", () => { + return queries + .communityExists(null, { slug: "sofadogtotherescue" }) + .then((result) => expect(result.exists).to.be.false); + }); + }); + + describe("notifications", () => { + beforeEach(() => spyify(User, "resetNewNotificationCount")); + afterEach(() => unspyify(User, "query")); + + it("resets new notification count if requested", () => { + return queries.notifications(null, { resetCount: true }).then(() => { + expect(User.resetNewNotificationCount).to.have.been.called.with( + user.id + ); + }); + }); + + it("does not reset new notification count if not requested", () => { + return queries.notifications(null, {}).then(() => { + expect(User.resetNewNotificationCount).not.to.have.been.called(); + }); + }); + }); + + describe("community", () => { + let community; beforeEach(async () => { - community = await factories.community().save() - await community.addGroupMembers([user]) - }) + community = await factories.community().save(); + await community.addGroupMembers([user]); + }); - it('updates last viewed time', async () => { + it("updates last viewed time", async () => { await queries.community(null, { id: community.id, - updateLastViewed: true - }) - - const membership = await GroupMembership.forPair(user, community).fetch() - expect(new Date(membership.getSetting('lastReadAt')).getTime()) - .to.be.closeTo(new Date().getTime(), 2000) - }) - }) -}) - -function expectJSON (res, expected) { - expect(res.body).to.exist - return expect(JSON.parse(res.body)).to.deep.equal(expected) + updateLastViewed: true, + }); + + const membership = await GroupMembership.forPair(user, community).fetch(); + expect( + new Date(membership.getSetting("lastReadAt")).getTime() + ).to.be.closeTo(new Date().getTime(), 2000); + }); + }); +}); + +function expectJSON(res, expected) { + expect(res.body).to.exist; + return expect(JSON.parse(res.body)).to.deep.equal(expected); } diff --git a/api/graphql/makeModels.js b/api/graphql/makeModels.js index 12f563616..bb713b058 100644 --- a/api/graphql/makeModels.js +++ b/api/graphql/makeModels.js @@ -1,4 +1,4 @@ -import searchQuerySet from './searchQuerySet' +import searchQuerySet from "./searchQuerySet"; import { commentFilter, communityTopicFilter, @@ -8,18 +8,18 @@ import { sharedNetworkMembership, activePost, authFilter, - messageFilter -} from './filters' -import { myCommunityIds } from '../models/util/queryFilters' -import { flow, mapKeys, camelCase } from 'lodash/fp' -import InvitationService from '../services/InvitationService' + messageFilter, +} from "./filters"; +import { myCommunityIds } from "../models/util/queryFilters"; +import { flow, mapKeys, camelCase } from "lodash/fp"; +import InvitationService from "../services/InvitationService"; import { filterAndSortCommunities, filterAndSortPosts, - filterAndSortUsers -} from '../services/Search/util' -import { isFollowing } from '../models/group/queryUtils' -import he from 'he'; + filterAndSortUsers, +} from "../services/Search/util"; +import { isFollowing } from "../models/group/queryUtils"; +import he from "he"; // this defines what subset of attributes and relations in each Bookshelf model // should be exposed through GraphQL, and what query filters should be applied @@ -27,99 +27,110 @@ import he from 'he'; // // keys in the returned object are GraphQL schema type names // -export default async function makeModels (userId, isAdmin) { - const nonAdminFilter = makeFilterToggle(!isAdmin) +export default async function makeModels(userId, isAdmin) { + const nonAdminFilter = makeFilterToggle(!isAdmin); return { Me: { model: User, attributes: [ - 'id', - 'name', - 'email', - 'avatar_url', - 'banner_url', - 'contact_email', - 'contact_phone', - 'twitter_name', - 'linkedin_url', - 'facebook_url', - 'url', - 'location', - 'bio', - 'updated_at', - 'tagline', - 'new_notification_count', - 'intercomHash' + "id", + "name", + "email", + "avatar_url", + "banner_url", + "contact_email", + "contact_phone", + "twitter_name", + "linkedin_url", + "facebook_url", + "url", + "location", + "bio", + "updated_at", + "tagline", + "new_notification_count", + "intercomHash", ], relations: [ - 'communities', - 'memberships', - 'posts', - 'locationObject', - {skills: {querySet: true}}, - {messageThreads: {typename: 'MessageThread', querySet: true}} + "communities", + "memberships", + "posts", + "locationObject", + { skills: { querySet: true } }, + { messageThreads: { typename: "MessageThread", querySet: true } }, ], getters: { - blockedUsers: u => u.blockedUsers().fetch(), + blockedUsers: (u) => u.blockedUsers().fetch(), isAdmin: () => isAdmin || false, - settings: u => mapKeys(camelCase, u.get('settings')), - hasStripeAccount: u => u.hasStripeAccount() - } + settings: (u) => mapKeys(camelCase, u.get("settings")), + hasStripeAccount: (u) => u.hasStripeAccount(), + }, }, Membership: { model: GroupMembership, - attributes: [ - 'created_at' - ], + attributes: ["created_at"], getters: { - settings: m => mapKeys(camelCase, m.get('settings')), - lastViewedAt: m => - m.get('user_id') === userId ? m.getSetting('lastReadAt') : null, - newPostCount: m => - m.get('user_id') === userId ? m.get('new_post_count') : null, - community: m => m.groupData().fetch(), - hasModeratorRole: async m => { - const community = await m.groupData().fetch() - return GroupMembership.hasModeratorRole(userId, community) - } + settings: (m) => mapKeys(camelCase, m.get("settings")), + lastViewedAt: (m) => + m.get("user_id") === userId ? m.getSetting("lastReadAt") : null, + newPostCount: (m) => + m.get("user_id") === userId ? m.get("new_post_count") : null, + community: (m) => m.groupData().fetch(), + hasModeratorRole: async (m) => { + const community = await m.groupData().fetch(); + return GroupMembership.hasModeratorRole(userId, community); + }, }, - filter: nonAdminFilter(membershipFilter(userId)) + filter: nonAdminFilter(membershipFilter(userId)), }, Person: { model: User, attributes: [ - 'name', - 'avatar_url', - 'banner_url', - 'bio', - 'contact_email', - 'contact_phone', - 'twitter_name', - 'linkedin_url', - 'facebook_url', - 'url', - 'location', - 'tagline' + "name", + "avatar_url", + "banner_url", + "bio", + "contact_email", + "contact_phone", + "twitter_name", + "linkedin_url", + "facebook_url", + "url", + "location", + "tagline", ], getters: { - messageThreadId: p => p.getMessageThreadWith(userId).then(post => post ? post.id : null) + messageThreadId: (p) => + p + .getMessageThreadWith(userId) + .then((post) => (post ? post.id : null)), }, relations: [ - 'memberships', - 'moderatedCommunityMemberships', - 'locationObject', - {posts: {querySet: true}}, - {comments: {querySet: true}}, - {skills: {querySet: true}}, - {votes: {querySet: true}} + "memberships", + "moderatedCommunityMemberships", + "locationObject", + { posts: { querySet: true } }, + { comments: { querySet: true } }, + { skills: { querySet: true } }, + { votes: { querySet: true } }, ], filter: nonAdminFilter(personFilter(userId)), isDefaultTypeForTable: true, - fetchMany: ({ boundingBox, first, order, sortBy, offset, search, autocomplete, communityIds, filter }) => - searchQuerySet('users', { + fetchMany: ({ + boundingBox, + first, + order, + sortBy, + offset, + search, + autocomplete, + communityIds, + filter, + }) => + searchQuerySet("users", { boundingBox, term: search, limit: first, @@ -127,62 +138,80 @@ export default async function makeModels (userId, isAdmin) { type: filter, autocomplete, communities: communityIds, - sort: sortBy - }) + sort: sortBy, + }), }, Post: { model: Post, attributes: [ - 'created_at', - 'updated_at', - 'fulfilled_at', - 'end_time', - 'start_time', - 'location', - 'announcement', - 'accept_contributions', - 'is_public', - 'type' + "created_at", + "updated_at", + "fulfilled_at", + "end_time", + "start_time", + "location", + "announcement", + "accept_contributions", + "is_public", + "type", ], getters: { - title: p => he.decode(p.get('name')), - details: p => p.get('description'), - detailsText: p => p.getDetailsText(), - isPublic: p => p.get('is_public'), + title: (p) => he.decode(p.get("name")), + details: (p) => p.get("description"), + detailsText: (p) => p.getDetailsText(), + isPublic: (p) => p.get("is_public"), commenters: (p, { first }) => p.getCommenters(first, userId), - commentersTotal: p => p.getCommentersTotal(userId), - commentsTotal: p => p.get('num_comments'), - votesTotal: p => p.get('num_votes'), - myVote: p => userId ? p.userVote(userId).then(v => !!v) : false, - myEventResponse: p => - userId ? p.userEventInvitation(userId) - .then(eventInvitation => eventInvitation ? eventInvitation.get('response') : '') - : '' + commentersTotal: (p) => p.getCommentersTotal(userId), + commentsTotal: (p) => p.get("num_comments"), + votesTotal: (p) => p.get("num_votes"), + myVote: (p) => (userId ? p.userVote(userId).then((v) => !!v) : false), + myEventResponse: (p) => + userId + ? p + .userEventInvitation(userId) + .then((eventInvitation) => + eventInvitation ? eventInvitation.get("response") : "" + ) + : "", }, relations: [ - {comments: {querySet: true}}, - 'communities', - {user: {alias: 'creator'}}, - 'followers', - 'locationObject', - {members: {querySet: true}}, - {eventInvitations: {querySet: true}}, - 'linkPreview', - 'postMemberships', - {media: { - alias: 'attachments', - arguments: ({ type }) => [type] - }}, - {tags: {alias: 'topics'}} + { comments: { querySet: true } }, + "communities", + { user: { alias: "creator" } }, + "followers", + "locationObject", + { members: { querySet: true } }, + { eventInvitations: { querySet: true } }, + "linkPreview", + "postMemberships", + { + media: { + alias: "attachments", + arguments: ({ type }) => [type], + }, + }, + { tags: { alias: "topics" } }, ], filter: flow( - authFilter(userId, 'posts'), + authFilter(userId, "posts"), activePost(userId), - nonAdminFilter(sharedNetworkMembership('posts', userId))), + nonAdminFilter(sharedNetworkMembership("posts", userId)) + ), isDefaultTypeForTable: true, - fetchMany: ({ first, order, sortBy, offset, search, filter, topic, boundingBox, networkSlugs, isPublic }) => - searchQuerySet('posts', { + fetchMany: ({ + first, + order, + sortBy, + offset, + search, + filter, + topic, + boundingBox, + networkSlugs, + isPublic, + }) => + searchQuerySet("posts", { boundingBox, term: search, limit: first, @@ -191,81 +220,117 @@ export default async function makeModels (userId, isAdmin) { sort: sortBy, topic, networkSlugs, - is_public: isPublic - }) + is_public: isPublic, + }), }, Community: { model: Community, attributes: [ - 'name', - 'slug', - 'description', - 'created_at', - 'avatar_url', - 'banner_url', - 'memberCount', - 'postCount', - 'location', - 'hidden', - 'allow_community_invites', - 'is_public', - 'is_auto_joinable', - 'public_member_directory' + "name", + "slug", + "description", + "created_at", + "avatar_url", + "banner_url", + "memberCount", + "postCount", + "location", + "hidden", + "allow_community_invites", + "is_public", + "is_auto_joinable", + "public_member_directory", ], relations: [ - 'locationObject', - 'network', - {moderators: {querySet: true}}, - {communityTags: { - querySet: true, - alias: 'communityTopics', - filter: (relation, { autocomplete, subscribed }) => - relation.query(communityTopicFilter(userId, { - autocomplete, - subscribed, - communityId: relation.relatedData.parentId - })) - }}, - {skills: { - querySet: true, - filter: (relation, { autocomplete }) => - relation.query(q => { - if (autocomplete) { - q.whereRaw('skills.name ilike ?', autocomplete + '%') - } - }) - }}, - {users: { - alias: 'members', - querySet: true, - filter: (relation, { autocomplete, boundingBox, search, sortBy }) => - relation.query(filterAndSortUsers({ autocomplete, boundingBox, search, sortBy })) - }}, - {posts: { - querySet: true, - filter: (relation, { search, sortBy, topic, filter, boundingBox }) => - relation.query(filterAndSortPosts({ - boundingBox, - search, - sortBy, - topic, - type: filter, - showPinnedFirst: true - })) - }} + "locationObject", + "network", + { moderators: { querySet: true } }, + { + communityTags: { + querySet: true, + alias: "communityTopics", + filter: (relation, { autocomplete, subscribed }) => + relation.query( + communityTopicFilter(userId, { + autocomplete, + subscribed, + communityId: relation.relatedData.parentId, + }) + ), + }, + }, + { + skills: { + querySet: true, + filter: (relation, { autocomplete }) => + relation.query((q) => { + if (autocomplete) { + q.whereRaw("skills.name ilike ?", autocomplete + "%"); + } + }), + }, + }, + { + users: { + alias: "members", + querySet: true, + filter: (relation, { autocomplete, boundingBox, search, sortBy }) => + relation.query( + filterAndSortUsers({ + autocomplete, + boundingBox, + search, + sortBy, + }) + ), + }, + }, + { + posts: { + querySet: true, + filter: ( + relation, + { search, sortBy, topic, filter, boundingBox } + ) => + relation.query( + filterAndSortPosts({ + boundingBox, + search, + sortBy, + topic, + type: filter, + showPinnedFirst: true, + }) + ), + }, + }, ], getters: { feedItems: (c, args) => c.feedItems(args), - isPublic: c => c.get('is_public'), - pendingInvitations: (c, { first }) => InvitationService.find({communityId: c.id, pendingOnly: true}), - invitePath: c => - GroupMembership.hasModeratorRole(userId, c) - .then(isModerator => isModerator ? Frontend.Route.invitePath(c) : null) + isPublic: (c) => c.get("is_public"), + pendingInvitations: (c, { first }) => + InvitationService.find({ communityId: c.id, pendingOnly: true }), + invitePath: (c) => + GroupMembership.hasModeratorRole(userId, c).then((isModerator) => + isModerator ? Frontend.Route.invitePath(c) : null + ), }, - filter: nonAdminFilter(sharedNetworkMembership('communities', userId)), - fetchMany: ({ first, order, sortBy, communityIds, offset, search, autocomplete, filter, isPublic, boundingBox, networkSlugs }) => - searchQuerySet('communities', { + filter: nonAdminFilter(sharedNetworkMembership("communities", userId)), + fetchMany: ({ + first, + order, + sortBy, + communityIds, + offset, + search, + autocomplete, + filter, + isPublic, + boundingBox, + networkSlugs, + }) => + searchQuerySet("communities", { boundingBox, communities: communityIds, networkSlugs, @@ -275,208 +340,235 @@ export default async function makeModels (userId, isAdmin) { type: filter, autocomplete, sort: sortBy, - is_public: isPublic - }) + is_public: isPublic, + }), }, Invitation: { model: Invitation, - attributes: [ - 'email', - 'created_at', - 'last_sent_at' - ] + attributes: ["email", "created_at", "last_sent_at"], }, JoinRequest: { model: JoinRequest, - attributes: [ - 'created_at', - 'updated_at', - 'status' - ], - relations: ['user' ], - fetchMany: ({ communityId }) => JoinRequest.where({ 'community_id': communityId }) + attributes: ["created_at", "updated_at", "status"], + relations: ["user"], + fetchMany: ({ communityId }) => + JoinRequest.where({ community_id: communityId }), }, EventInvitation: { model: EventInvitation, - attributes: [ - 'response' - ], - relations: [ - {user: {alias: 'person'}} - ] + attributes: ["response"], + relations: [{ user: { alias: "person" } }], }, Comment: { model: Comment, - attributes: [ - 'created_at' - ], + attributes: ["created_at"], relations: [ - 'post', - {user: {alias: 'creator'}}, - {media: { - alias: 'attachments', - arguments: ({ type }) => [type] - }} + "post", + { user: { alias: "creator" } }, + { + media: { + alias: "attachments", + arguments: ({ type }) => [type], + }, + }, ], filter: nonAdminFilter(commentFilter(userId)), - isDefaultTypeForTable: true + isDefaultTypeForTable: true, }, LinkPreview: { model: LinkPreview, attributes: [ - 'title', - 'url', - 'image_url', - 'image_width', - 'image_height', - 'status' - ] + "title", + "url", + "image_url", + "image_width", + "image_height", + "status", + ], }, Location: { model: Location, attributes: [ - 'accuracy', - 'address_number', - 'address_street', - 'bbox', - 'center', - 'city', - 'country', - 'full_text', - 'locality', - 'neighborhood', - 'region', - 'postcode' - ] + "accuracy", + "address_number", + "address_street", + "bbox", + "center", + "city", + "country", + "full_text", + "locality", + "neighborhood", + "region", + "postcode", + ], }, MessageThread: { model: Post, - attributes: ['created_at', 'updated_at'], + attributes: ["created_at", "updated_at"], getters: { - unreadCount: t => t.unreadCountForUser(userId), - lastReadAt: t => t.lastReadAtForUser(userId) + unreadCount: (t) => t.unreadCountForUser(userId), + lastReadAt: (t) => t.lastReadAtForUser(userId), }, relations: [ - {followers: {alias: 'participants'}}, - {comments: {alias: 'messages', typename: 'Message', querySet: true}} + { followers: { alias: "participants" } }, + { + comments: { alias: "messages", typename: "Message", querySet: true }, + }, ], - filter: relation => relation.query(q => - q.where('posts.id', 'in', - Group.pluckIdsForMember(userId, Post, isFollowing))) + filter: (relation) => + relation.query((q) => + q.where( + "posts.id", + "in", + Group.pluckIdsForMember(userId, Post, isFollowing) + ) + ), }, Message: { model: Comment, - attributes: ['created_at'], + attributes: ["created_at"], relations: [ - {post: {alias: 'messageThread', typename: 'MessageThread'}}, - {user: {alias: 'creator'}} + { post: { alias: "messageThread", typename: "MessageThread" } }, + { user: { alias: "creator" } }, ], - filter: messageFilter(userId) + filter: messageFilter(userId), }, Vote: { model: Vote, getters: { - createdAt: v => v.get('date_voted') + createdAt: (v) => v.get("date_voted"), }, - relations: [ - 'post', - {user: {alias: 'voter'}} - ], - filter: nonAdminFilter(sharedNetworkMembership('votes', userId)) + relations: ["post", { user: { alias: "voter" } }], + filter: nonAdminFilter(sharedNetworkMembership("votes", userId)), }, CommunityTopic: { model: CommunityTag, - attributes: ['is_default', 'visibility', 'updated_at', 'created_at'], + attributes: ["is_default", "visibility", "updated_at", "created_at"], getters: { - postsTotal: ct => ct.postCount(), - followersTotal: ct => ct.followerCount(), - isSubscribed: ct => ct.isFollowed(userId), - newPostCount: ct => ct.newPostCount(userId) + postsTotal: (ct) => ct.postCount(), + followersTotal: (ct) => ct.followerCount(), + isSubscribed: (ct) => ct.isFollowed(userId), + newPostCount: (ct) => ct.newPostCount(userId), }, - relations: [ - 'community', - {tag: {alias: 'topic'}} - ], - filter: nonAdminFilter(relation => relation.query(q => { - q.where('communities_tags.community_id', 'in', myCommunityIds(userId)) - })), - fetchMany: args => CommunityTag.query(communityTopicFilter(userId, args)) + relations: ["community", { tag: { alias: "topic" } }], + filter: nonAdminFilter((relation) => + relation.query((q) => { + q.where( + "communities_tags.community_id", + "in", + myCommunityIds(userId) + ); + }) + ), + fetchMany: (args) => + CommunityTag.query(communityTopicFilter(userId, args)), }, SavedSearch: { model: SavedSearch, attributes: [ - 'boundingBox', - 'community', - 'context', - 'created_at', - 'name', - 'network', - 'is_active', - 'search_text', - 'post_types' + "boundingBox", + "community", + "context", + "created_at", + "name", + "network", + "is_active", + "search_text", + "post_types", ], - fetchMany: ({ userId }) => SavedSearch.where({ 'user_id': userId, 'is_active': true }) + fetchMany: ({ userId }) => + SavedSearch.where({ user_id: userId, is_active: true }), }, Skill: { model: Skill, - attributes: ['name'], + attributes: ["name"], fetchMany: ({ autocomplete, first = 1000, offset = 0 }) => - searchQuerySet('skills', { - autocomplete, first, offset, currentUserId: userId - }) + searchQuerySet("skills", { + autocomplete, + first, + offset, + currentUserId: userId, + }), }, Topic: { model: Tag, - attributes: ['name'], + attributes: ["name"], getters: { postsTotal: (t, opts = {}) => Tag.taggedPostCount(t.id, Object.assign({}, opts, { userId })), followersTotal: (t, opts = {}) => - Tag.followersCount(t.id, Object.assign({}, opts, { userId })) + Tag.followersCount(t.id, Object.assign({}, opts, { userId })), }, - relations: [{ - communityTags: { - alias: 'communityTopics', - querySet: true, - filter: (relation, { autocomplete, subscribed, isDefault, visibility }) => - relation.query(communityTopicFilter(userId, { - autocomplete, - isDefault, - subscribed, - visibility - }) - ) - } - }], - fetchMany: ({ communitySlug, networkSlug, name, isDefault, visibility, autocomplete, first, offset = 0, sortBy }) => - searchQuerySet('tags', { userId, communitySlug, networkSlug, name, autocomplete, isDefault, visibility, limit: first, offset, sort: sortBy }) + relations: [ + { + communityTags: { + alias: "communityTopics", + querySet: true, + filter: ( + relation, + { autocomplete, subscribed, isDefault, visibility } + ) => + relation.query( + communityTopicFilter(userId, { + autocomplete, + isDefault, + subscribed, + visibility, + }) + ), + }, + }, + ], + fetchMany: ({ + communitySlug, + networkSlug, + name, + isDefault, + visibility, + autocomplete, + first, + offset = 0, + sortBy, + }) => + searchQuerySet("tags", { + userId, + communitySlug, + networkSlug, + name, + autocomplete, + isDefault, + visibility, + limit: first, + offset, + sort: sortBy, + }), }, Notification: { model: Notification, - relations: ['activity'], + relations: ["activity"], getters: { - createdAt: n => n.get('created_at') + createdAt: (n) => n.get("created_at"), }, fetchMany: ({ first, order, offset = 0 }) => Notification.where({ - 'medium': Notification.MEDIUM.InApp, - 'notifications.user_id': userId - }) - .orderBy('id', order), + medium: Notification.MEDIUM.InApp, + "notifications.user_id": userId, + }).orderBy("id", order), // TODO: fix this filter. Currently it filters out any notification without a comment // filter: (relation) => relation.query(q => { // q.join('activities', 'activities.id', 'notifications.activity_id') @@ -490,89 +582,96 @@ export default async function makeModels (userId, isAdmin) { Activity: { model: Activity, - attributes: ['meta', 'unread'], - relations: [ - 'actor', - 'post', - 'comment', - 'community' - ], + attributes: ["meta", "unread"], + relations: ["actor", "post", "comment", "community"], getters: { - action: a => Notification.priorityReason(a.get('meta').reasons) - } + action: (a) => Notification.priorityReason(a.get("meta").reasons), + }, }, PersonConnection: { model: UserConnection, - attributes: [ - 'type', - 'created_at', - 'updated_at' - ], - relations: [ {otherUser: {alias: 'person'}} ], + attributes: ["type", "created_at", "updated_at"], + relations: [{ otherUser: { alias: "person" } }], fetchMany: () => UserConnection, - filter: relation => { - return relation.query(q => { + filter: (relation) => { + return relation.query((q) => { if (userId) { - q.where('other_user_id', 'NOT IN', BlockedUser.blockedFor(userId)) - q.where('user_id', userId) + q.where("other_user_id", "NOT IN", BlockedUser.blockedFor(userId)); + q.where("user_id", userId); } - q.orderBy('created_at', 'desc') - }) - } + q.orderBy("created_at", "desc"); + }); + }, }, Network: { model: Network, attributes: [ - 'name', - 'slug', - 'description', - 'created_at', - 'avatar_url', - 'banner_url', - 'memberCount' + "name", + "slug", + "description", + "created_at", + "avatar_url", + "banner_url", + "memberCount", ], getters: { - isModerator: n => NetworkMembership.hasModeratorRole(userId, n.id), - isAdmin: n => NetworkMembership.hasAdminRole(userId, n.id) + isModerator: (n) => NetworkMembership.hasModeratorRole(userId, n.id), + isAdmin: (n) => NetworkMembership.hasAdminRole(userId, n.id), }, relations: [ - {moderators: {querySet: true}}, - {members: { - querySet: true, - filter: (relation, { autocomplete, boundingBox, search, sortBy }) => - relation.query(filterAndSortUsers({ autocomplete, boundingBox, search, sortBy })) - }}, - {posts: { - querySet: true, - filter: (relation, { search, sortBy, topic, filter, boundingBox }) => - relation.query(filterAndSortPosts({ search, sortBy, topic, type: filter, boundingBox })) - }}, - {communities: { - querySet: true, - filter: (relation, { search, sortBy }) => - relation.query(filterAndSortCommunities({ search, sortBy })) - }} - ] + { moderators: { querySet: true } }, + { + members: { + querySet: true, + filter: (relation, { autocomplete, boundingBox, search, sortBy }) => + relation.query( + filterAndSortUsers({ + autocomplete, + boundingBox, + search, + sortBy, + }) + ), + }, + }, + { + posts: { + querySet: true, + filter: ( + relation, + { search, sortBy, topic, filter, boundingBox } + ) => + relation.query( + filterAndSortPosts({ + search, + sortBy, + topic, + type: filter, + boundingBox, + }) + ), + }, + }, + { + communities: { + querySet: true, + filter: (relation, { search, sortBy }) => + relation.query(filterAndSortCommunities({ search, sortBy })), + }, + }, + ], }, Attachment: { model: Media, - attributes: [ - 'type', - 'url', - 'thumbnail_url', - 'position', - 'created_at' - ] + attributes: ["type", "url", "thumbnail_url", "position", "created_at"], }, PostMembership: { model: PostMembership, - relations: [ - 'community' - ] - } - } + relations: ["community"], + }, + }; } diff --git a/api/graphql/mutations/comment.js b/api/graphql/mutations/comment.js index f71af2f98..e518795d6 100644 --- a/api/graphql/mutations/comment.js +++ b/api/graphql/mutations/comment.js @@ -1,79 +1,97 @@ -import underlyingDeleteComment from '../../models/comment/deleteComment' -import underlyingCreateComment from '../../models/comment/createComment' -import underlyingUpdateComment from '../../models/comment/updateComment' -import { merge, trim } from 'lodash' -import { includes } from 'lodash/fp' +import underlyingDeleteComment from "../../models/comment/deleteComment"; +import underlyingCreateComment from "../../models/comment/createComment"; +import underlyingUpdateComment from "../../models/comment/updateComment"; +import { merge, trim } from "lodash"; +import { includes } from "lodash/fp"; -export function canDeleteComment (userId, comment) { - if (comment.get('user_id') === userId) return Promise.resolve(true) - return comment.load('post.communities') - .then(comment => Promise.any( - comment.relations.post.relations.communities.map(c => - GroupMembership.hasModeratorRole(userId, c)) - )) +export function canDeleteComment(userId, comment) { + if (comment.get("user_id") === userId) return Promise.resolve(true); + return comment + .load("post.communities") + .then((comment) => + Promise.any( + comment.relations.post.relations.communities.map((c) => + GroupMembership.hasModeratorRole(userId, c) + ) + ) + ); } -export function canUpdateComment (userId, comment) { - if (comment.get('user_id') === userId) return Promise.resolve(true) - return Promise.resolve(false) +export function canUpdateComment(userId, comment) { + if (comment.get("user_id") === userId) return Promise.resolve(true); + return Promise.resolve(false); } -export function deleteComment (userId, commentId) { - return Comment.find(commentId) - .then(comment => canDeleteComment(userId, comment) - .then(canDelete => { - if (!canDelete) throw new Error("You don't have permission to delete this comment") - return underlyingDeleteComment(comment, userId) - })) - .then(() => ({success: true})) +export function deleteComment(userId, commentId) { + return Comment.find(commentId) + .then((comment) => + canDeleteComment(userId, comment).then((canDelete) => { + if (!canDelete) + throw new Error("You don't have permission to delete this comment"); + return underlyingDeleteComment(comment, userId); + }) + ) + .then(() => ({ success: true })); } -export function createComment (userId, data) { - return validateCommentCreateData(userId, data) - .then(() => Promise.props({ - post: Post.find(data.postId), - parentComment: data.parentCommentId ? Comment.find(data.parentCommentId) : null - })) - .then(extraData => underlyingCreateComment(userId, merge(data, extraData))) +export function createComment(userId, data) { + return validateCommentCreateData(userId, data) + .then(() => + Promise.props({ + post: Post.find(data.postId), + parentComment: data.parentCommentId + ? Comment.find(data.parentCommentId) + : null, + }) + ) + .then((extraData) => + underlyingCreateComment(userId, merge(data, extraData)) + ); } -export async function createMessage (userId, data) { - const post = await Post.find(data.messageThreadId) - const followers = await post.followers().fetch() - const blockedUserIds = (await BlockedUser.blockedFor(userId)).rows.map(r => r.user_id) - const otherParticipants = followers.filter(f => f.id !== userId && !includes(f.id, blockedUserIds)) - if (otherParticipants.length < 1) throw new Error ('cannot send a message to this thread') - data.postId = data.messageThreadId - return createComment(userId, data) +export async function createMessage(userId, data) { + const post = await Post.find(data.messageThreadId); + const followers = await post.followers().fetch(); + const blockedUserIds = (await BlockedUser.blockedFor(userId)).rows.map( + (r) => r.user_id + ); + const otherParticipants = followers.filter( + (f) => f.id !== userId && !includes(f.id, blockedUserIds) + ); + if (otherParticipants.length < 1) + throw new Error("cannot send a message to this thread"); + data.postId = data.messageThreadId; + return createComment(userId, data); } -export function updateComment (userId, { id, data }) { - return Comment.find(id) - .then(comment => canUpdateComment(userId, comment)) - .then(canUpdate => { - if (!canUpdate) throw new Error("You don't have permission to edit this comment") - return validateCommentUpdateData(userId, data) - .then(validatedData => underlyingUpdateComment(userId, id, validatedData)) - }) +export function updateComment(userId, { id, data }) { + return Comment.find(id) + .then((comment) => canUpdateComment(userId, comment)) + .then((canUpdate) => { + if (!canUpdate) + throw new Error("You don't have permission to edit this comment"); + return validateCommentUpdateData(userId, data).then((validatedData) => + underlyingUpdateComment(userId, id, validatedData) + ); + }); } -export function validateCommentCreateData (userId, data) { - return Post.isVisibleToUser(data.postId, userId) - .then(isVisible => { - if (isVisible) { - if (!data.imageUrl && !trim(data.text)) { - throw new Error("Can't create a blank comment") - } - return Promise.resolve() - } else { - throw new Error('post not found') - } - }) +export function validateCommentCreateData(userId, data) { + return Post.isVisibleToUser(data.postId, userId).then((isVisible) => { + if (isVisible) { + if (!data.imageUrl && !trim(data.text)) { + throw new Error("Can't create a blank comment"); + } + return Promise.resolve(); + } else { + throw new Error("post not found"); + } + }); } -export function validateCommentUpdateData (userId, data) { - if (!data.imageUrl && !trim(data.text)) { - throw new Error("Can't create a blank comment") - } - return Promise.resolve(data) +export function validateCommentUpdateData(userId, data) { + if (!data.imageUrl && !trim(data.text)) { + throw new Error("Can't create a blank comment"); + } + return Promise.resolve(data); } diff --git a/api/graphql/mutations/comment.test.js b/api/graphql/mutations/comment.test.js index e5ab5af0d..9a33d33a5 100644 --- a/api/graphql/mutations/comment.test.js +++ b/api/graphql/mutations/comment.test.js @@ -1,97 +1,97 @@ -import factories from '../../../test/setup/factories' -import { canDeleteComment, validateCommentCreateData, createMessage } from './comment' +import factories from "../../../test/setup/factories"; +import { + canDeleteComment, + validateCommentCreateData, + createMessage, +} from "./comment"; -describe('validateCommentCreateData', () => { - var user, post, post2 +describe("validateCommentCreateData", () => { + let user, post, post2; - before(function () { - user = new User({name: 'King Kong', email: 'a@b.c'}) - post = factories.post() - post2 = factories.post() - return Promise.join( - post.save(), - post2.save(), - user.save() - ).then(function () { - return post.addFollowers([user.id], user.id) - }) - }) + before(function () { + user = new User({ name: "King Kong", email: "a@b.c" }); + post = factories.post(); + post2 = factories.post(); + return Promise.join(post.save(), post2.save(), user.save()).then( + function () { + return post.addFollowers([user.id], user.id); + } + ); + }); - it('rejects if user cannot access the post', () => { - const data = {text: 't', postId: post2.id} - return validateCommentCreateData(user.id, data) - .then(() => expect.fail('should reject')) - .catch(e => expect(e.message).to.match(/post not found/)) - }) + it("rejects if user cannot access the post", () => { + const data = { text: "t", postId: post2.id }; + return validateCommentCreateData(user.id, data) + .then(() => expect.fail("should reject")) + .catch((e) => expect(e.message).to.match(/post not found/)); + }); - it('resolves if checks pass', () => { - const data = {text: 't', postId: post.id} - return validateCommentCreateData(user.id, data) - .catch(() => expect.fail('should resolve')) - }) + it("resolves if checks pass", () => { + const data = { text: "t", postId: post.id }; + return validateCommentCreateData(user.id, data).catch(() => + expect.fail("should resolve") + ); + }); - it('rejects if the comment is blank', () => { - return validateCommentCreateData(user.id, {text: ' ', postId: post.id}) - .then(() => expect.fail('should reject')) - .catch(e => expect(e.message).to.match(/blank comment/)) - }) -}) + it("rejects if the comment is blank", () => { + return validateCommentCreateData(user.id, { text: " ", postId: post.id }) + .then(() => expect.fail("should reject")) + .catch((e) => expect(e.message).to.match(/blank comment/)); + }); +}); -describe('canDeleteComment', () => { - var u1, u2, u3, c, community +describe("canDeleteComment", () => { + let u1, u2, u3, c, community; - before(async () => { - community = await factories.community().save() - await community.createGroup() - u1 = await factories.user().save() // creator - u2 = await factories.user().save() // moderator - u3 = await factories.user().save() // neither - const p = await factories.post().save() - c = await factories.comment().save() - await Promise.join( - c.save({user_id: u1.id}), - p.comments().create(c), - p.communities().attach(community), - u1.joinCommunity(community), - u2.joinCommunity(community), - u3.joinCommunity(community) - ) - return GroupMembership.setModeratorRole(u2.id, community) - }) + before(async () => { + community = await factories.community().save(); + await community.createGroup(); + u1 = await factories.user().save(); // creator + u2 = await factories.user().save(); // moderator + u3 = await factories.user().save(); // neither + const p = await factories.post().save(); + c = await factories.comment().save(); + await Promise.join( + c.save({ user_id: u1.id }), + p.comments().create(c), + p.communities().attach(community), + u1.joinCommunity(community), + u2.joinCommunity(community), + u3.joinCommunity(community) + ); + return GroupMembership.setModeratorRole(u2.id, community); + }); - it('allows the creator to delete', () => { - return canDeleteComment(u1.id, c) - .then(canDelete => { - expect(canDelete).to.be.true - }) - }) + it("allows the creator to delete", () => { + return canDeleteComment(u1.id, c).then((canDelete) => { + expect(canDelete).to.be.true; + }); + }); - it('allows a moderator of one of the comments communities to delete', () => { - return canDeleteComment(u2.id, c) - .then(canDelete => { - expect(canDelete).to.be.true - }) - }) + it("allows a moderator of one of the comments communities to delete", () => { + return canDeleteComment(u2.id, c).then((canDelete) => { + expect(canDelete).to.be.true; + }); + }); - it('does not allow anyone else to delete', () => { - return canDeleteComment(u3.id, c) - .then(canDelete => { - expect(canDelete).to.be.false - }) - }) -}) + it("does not allow anyone else to delete", () => { + return canDeleteComment(u3.id, c).then((canDelete) => { + expect(canDelete).to.be.false; + }); + }); +}); -describe('createMessage', () => { - var u1, u2, post +describe("createMessage", () => { + let u1, u2, post; - before(async () => { - u1 = await factories.user().save() // creator - u2 = await factories.user().save() // moderator - post = await factories.post({user_id: u1.id, active: true}).save() - await post.addFollowers([u1.id, u2.id]) - }) + before(async () => { + u1 = await factories.user().save(); // creator + u2 = await factories.user().save(); // moderator + post = await factories.post({ user_id: u1.id, active: true }).save(); + await post.addFollowers([u1.id, u2.id]); + }); - it('throws an error with a blocked user', async () => { - await createMessage(u1.id, {messageThreadId: post.id, text: 'la'}) - }) -}) + it("throws an error with a blocked user", async () => { + await createMessage(u1.id, { messageThreadId: post.id, text: "la" }); + }); +}); diff --git a/api/graphql/mutations/community.js b/api/graphql/mutations/community.js index 46c619869..3de5ebb1e 100644 --- a/api/graphql/mutations/community.js +++ b/api/graphql/mutations/community.js @@ -1,81 +1,97 @@ -import CommunityService from '../../services/CommunityService' -import convertGraphqlData from './convertGraphqlData' -import underlyingDeleteCommunityTopic from '../../models/community/deleteCommunityTopic' +import CommunityService from "../../services/CommunityService"; +import convertGraphqlData from "./convertGraphqlData"; +import underlyingDeleteCommunityTopic from "../../models/community/deleteCommunityTopic"; -export async function updateCommunity (userId, communityId, changes) { - const community = await getModeratedCommunity(userId, communityId) - return community.update(convertGraphqlData(changes)) +export async function updateCommunity(userId, communityId, changes) { + const community = await getModeratedCommunity(userId, communityId); + return community.update(convertGraphqlData(changes)); } -export async function addModerator (userId, personId, communityId) { - const community = await getModeratedCommunity(userId, communityId) - await GroupMembership.setModeratorRole(personId, community) - return community +export async function addModerator(userId, personId, communityId) { + const community = await getModeratedCommunity(userId, communityId); + await GroupMembership.setModeratorRole(personId, community); + return community; } -export async function removeModerator (userId, personId, communityId, isRemoveFromCommunity) { - const community = await getModeratedCommunity(userId, communityId) - if (isRemoveFromCommunity) { - await GroupMembership.removeModeratorRole(personId, community) - await CommunityService.removeMember(personId, communityId) - } else { - await GroupMembership.removeModeratorRole(personId, community) - } +export async function removeModerator( + userId, + personId, + communityId, + isRemoveFromCommunity +) { + const community = await getModeratedCommunity(userId, communityId); + if (isRemoveFromCommunity) { + await GroupMembership.removeModeratorRole(personId, community); + await CommunityService.removeMember(personId, communityId); + } else { + await GroupMembership.removeModeratorRole(personId, community); + } - return community + return community; } /** * As a moderator, removes member from a community. */ -export async function removeMember (loggedInUserId, userIdToRemove, communityId) { - const community = await getModeratedCommunity(loggedInUserId, communityId) - await CommunityService.removeMember(userIdToRemove, communityId) - return community +export async function removeMember( + loggedInUserId, + userIdToRemove, + communityId +) { + const community = await getModeratedCommunity(loggedInUserId, communityId); + await CommunityService.removeMember(userIdToRemove, communityId); + return community; } -export async function regenerateAccessCode (userId, communityId) { - const community = await getModeratedCommunity(userId, communityId) - const code = await Community.getNewAccessCode() - return community.save({beta_access_code: code}, {patch: true}) // eslint-disable-line camelcase +export async function regenerateAccessCode(userId, communityId) { + const community = await getModeratedCommunity(userId, communityId); + const code = await Community.getNewAccessCode(); + return community.save({ beta_access_code: code }, { patch: true }); // eslint-disable-line camelcase } -export async function createCommunity (userId, data) { - if (data.networkId) { - const canModerate = await NetworkMembership.hasModeratorRole(userId, data.networkId) - if (!canModerate) { - throw new Error("You don't have permission to add a community to this network") - } - } - return Community.create(userId, convertGraphqlData(data)) +export async function createCommunity(userId, data) { + if (data.networkId) { + const canModerate = await NetworkMembership.hasModeratorRole( + userId, + data.networkId + ); + if (!canModerate) { + throw new Error( + "You don't have permission to add a community to this network" + ); + } + } + return Community.create(userId, convertGraphqlData(data)); } -async function getModeratedCommunity (userId, communityId) { - const community = await Community.find(communityId) - if (!community) { - throw new Error('Community not found') - } +async function getModeratedCommunity(userId, communityId) { + const community = await Community.find(communityId); + if (!community) { + throw new Error("Community not found"); + } - const isModerator = await GroupMembership.hasModeratorRole(userId, community) - if (!isModerator) { - throw new Error("You don't have permission to moderate this community") - } + const isModerator = await GroupMembership.hasModeratorRole(userId, community); + if (!isModerator) { + throw new Error("You don't have permission to moderate this community"); + } - return community + return community; } -export async function deleteCommunityTopic (userId, communityTopicId) { - const communityTopic = await CommunityTag.where({id: communityTopicId}).fetch() +export async function deleteCommunityTopic(userId, communityTopicId) { + const communityTopic = await CommunityTag.where({ + id: communityTopicId, + }).fetch(); - await getModeratedCommunity(userId, communityTopic.get('community_id')) + await getModeratedCommunity(userId, communityTopic.get("community_id")); - await underlyingDeleteCommunityTopic(communityTopic) - return {success: true} + await underlyingDeleteCommunityTopic(communityTopic); + return { success: true }; } -export async function deleteCommunity (userId, communityId) { - await getModeratedCommunity(userId, communityId) +export async function deleteCommunity(userId, communityId) { + await getModeratedCommunity(userId, communityId); - await Community.deactivate(communityId) - return {success: true} + await Community.deactivate(communityId); + return { success: true }; } diff --git a/api/graphql/mutations/community.test.js b/api/graphql/mutations/community.test.js index ab377a9e4..b7866ec61 100644 --- a/api/graphql/mutations/community.test.js +++ b/api/graphql/mutations/community.test.js @@ -1,5 +1,5 @@ /* eslint-disable no-unused-expressions */ -import factories from '../../../test/setup/factories' +import factories from "../../../test/setup/factories"; import { createCommunity, @@ -9,192 +9,204 @@ import { removeMember, regenerateAccessCode, deleteCommunityTopic, - deleteCommunity - } from './community' + deleteCommunity, +} from "./community"; -describe('moderation', () => { - var user, community +describe("moderation", () => { + let user, community; before(function () { - user = factories.user() - community = factories.community() - return Promise.join(community.save(), user.save()) - .then(() => user.joinCommunity(community, GroupMembership.Role.MODERATOR)) - }) - - describe('updateCommunity', () => { - it('rejects if name is blank', () => { - const data = {name: ' '} + user = factories.user(); + community = factories.community(); + return Promise.join(community.save(), user.save()).then(() => + user.joinCommunity(community, GroupMembership.Role.MODERATOR) + ); + }); + + describe("updateCommunity", () => { + it("rejects if name is blank", () => { + const data = { name: " " }; return updateCommunity(user.id, community.id, data) - .then(() => expect.fail('should reject')) - .catch(e => expect(e.message).to.match(/Name cannot be blank/)) - }) - - it('rejects if user is not a moderator', () => { - const data = {name: ' '} - return updateCommunity('777', community.id, data) - .then(() => expect.fail('should reject')) - .catch(e => expect(e.message).to.match(/don't have permission/)) - }) - }) - - describe('addModerator', () => { - it('works for a non-member', async () => { - const user2 = await factories.user().save() - await addModerator(user.id, user2.id, community.id) - expect(await GroupMembership.hasModeratorRole(user2, community)) - }) - - it('works for an existing member', async () => { - const user2 = await factories.user().save() - await user2.joinCommunity(community) - await addModerator(user.id, user2.id, community.id) - expect(await GroupMembership.hasModeratorRole(user2, community)) - }) - }) - - describe('removeModerator', () => { - it('just removes moderator role', async () => { - const user2 = await factories.user().save() - await user2.joinCommunity(community, GroupMembership.Role.MODERATOR) - await removeModerator(user.id, user2.id, community.id) - expect(!await GroupMembership.hasModeratorRole(user2, community)) - - const membership = await GroupMembership.forPair(user2, community, - {includeInactive: true}).fetch() - expect(membership.get('active')).to.be.true - }) - - it('also removes from community when selected', async () => { - const user2 = await factories.user().save() - await user2.joinCommunity(community, GroupMembership.Role.MODERATOR) - await removeModerator(user.id, user2.id, community.id, true) - expect(!await GroupMembership.hasModeratorRole(user2, community)) - - const membership = await GroupMembership.forPair(user2, community, - {includeInactive: true}).fetch() - expect(membership.get('active')).to.be.false - }) - - it('throws an error if youre not a moderator', async () => { - const nonModeratorUser = await factories.user().save() - await nonModeratorUser.joinCommunity(community, GroupMembership.Role.DEFAULT) - - const user2 = await factories.user().save() - await user2.joinCommunity(community, GroupMembership.Role.MODERATOR) - - return expect(removeModerator(nonModeratorUser.id, user2.id, community.id, true)).to.eventually.be.rejected - }) - }) - - describe('removeMember', () => { - it('works', async () => { - const user2 = await factories.user().save() - await user2.joinCommunity(community, GroupMembership.Role.MODERATOR) - await removeMember(user.id, user2.id, community.id) - - const membership = await GroupMembership.forPair(user2, community, - {includeInactive: true}).fetch() - expect(membership.get('active')).to.be.false - }) - }) - - describe('regenerateAccessCode', () => { - it('works', async () => { - const code = community.get('beta_access_code') - await regenerateAccessCode(user.id, community.id) - await community.refresh() - expect(community.get('beta_access_code')).not.to.equal(code) - }) - }) -}) - -describe('createCommunity', () => { - let user, starterCommunity, starterPost, network + .then(() => expect.fail("should reject")) + .catch((e) => expect(e.message).to.match(/Name cannot be blank/)); + }); + + it("rejects if user is not a moderator", () => { + const data = { name: " " }; + return updateCommunity("777", community.id, data) + .then(() => expect.fail("should reject")) + .catch((e) => expect(e.message).to.match(/don't have permission/)); + }); + }); + + describe("addModerator", () => { + it("works for a non-member", async () => { + const user2 = await factories.user().save(); + await addModerator(user.id, user2.id, community.id); + expect(await GroupMembership.hasModeratorRole(user2, community)); + }); + + it("works for an existing member", async () => { + const user2 = await factories.user().save(); + await user2.joinCommunity(community); + await addModerator(user.id, user2.id, community.id); + expect(await GroupMembership.hasModeratorRole(user2, community)); + }); + }); + + describe("removeModerator", () => { + it("just removes moderator role", async () => { + const user2 = await factories.user().save(); + await user2.joinCommunity(community, GroupMembership.Role.MODERATOR); + await removeModerator(user.id, user2.id, community.id); + expect(!(await GroupMembership.hasModeratorRole(user2, community))); + + const membership = await GroupMembership.forPair(user2, community, { + includeInactive: true, + }).fetch(); + expect(membership.get("active")).to.be.true; + }); + + it("also removes from community when selected", async () => { + const user2 = await factories.user().save(); + await user2.joinCommunity(community, GroupMembership.Role.MODERATOR); + await removeModerator(user.id, user2.id, community.id, true); + expect(!(await GroupMembership.hasModeratorRole(user2, community))); + + const membership = await GroupMembership.forPair(user2, community, { + includeInactive: true, + }).fetch(); + expect(membership.get("active")).to.be.false; + }); + + it("throws an error if youre not a moderator", async () => { + const nonModeratorUser = await factories.user().save(); + await nonModeratorUser.joinCommunity( + community, + GroupMembership.Role.DEFAULT + ); + + const user2 = await factories.user().save(); + await user2.joinCommunity(community, GroupMembership.Role.MODERATOR); + + return expect( + removeModerator(nonModeratorUser.id, user2.id, community.id, true) + ).to.eventually.be.rejected; + }); + }); + + describe("removeMember", () => { + it("works", async () => { + const user2 = await factories.user().save(); + await user2.joinCommunity(community, GroupMembership.Role.MODERATOR); + await removeMember(user.id, user2.id, community.id); + + const membership = await GroupMembership.forPair(user2, community, { + includeInactive: true, + }).fetch(); + expect(membership.get("active")).to.be.false; + }); + }); + + describe("regenerateAccessCode", () => { + it("works", async () => { + const code = community.get("beta_access_code"); + await regenerateAccessCode(user.id, community.id); + await community.refresh(); + expect(community.get("beta_access_code")).not.to.equal(code); + }); + }); +}); + +describe("createCommunity", () => { + let user, starterCommunity, starterPost, network; before(async () => { - user = await factories.user().save() - network = await factories.network().save() - starterCommunity = await factories.community({slug: 'starter-posts'}).save() - starterPost = await factories.post().save() - await starterCommunity.posts().attach(starterPost.id) - await NetworkMembership.addModerator(user.id, network.id) - }) - - it('returns the new moderator membership', async () => { + user = await factories.user().save(); + network = await factories.network().save(); + starterCommunity = await factories + .community({ slug: "starter-posts" }) + .save(); + starterPost = await factories.post().save(); + await starterCommunity.posts().attach(starterPost.id); + await NetworkMembership.addModerator(user.id, network.id); + }); + + it("returns the new moderator membership", async () => { const membership = await createCommunity(user.id, { - name: 'Foo', - slug: 'foo', - description: 'Here be foo' - }) - - expect(membership).to.exist - expect(membership.get('role')).to.equal(GroupMembership.Role.MODERATOR) - const community = await membership.groupData().fetch() - expect(community).to.exist - expect(community.get('slug')).to.equal('foo') - const post = await community.posts().fetchOne() - expect(post).to.exist - expect(post.get('name')).to.equal(starterPost.get('name')) - }) + name: "Foo", + slug: "foo", + description: "Here be foo", + }); + + expect(membership).to.exist; + expect(membership.get("role")).to.equal(GroupMembership.Role.MODERATOR); + const community = await membership.groupData().fetch(); + expect(community).to.exist; + expect(community.get("slug")).to.equal("foo"); + const post = await community.posts().fetchOne(); + expect(post).to.exist; + expect(post.get("name")).to.equal(starterPost.get("name")); + }); it("rejects if can't moderate network", () => { - const data = {name: 'goose', slug: 'goose', networkId: network.id + 1} + const data = { name: "goose", slug: "goose", networkId: network.id + 1 }; return createCommunity(user.id, data) - .then(() => expect.fail('should reject')) - .catch(e => expect(e.message).to.match(/don't have permission/)) - }) + .then(() => expect.fail("should reject")) + .catch((e) => expect(e.message).to.match(/don't have permission/)); + }); - it('creates community in network if user can moderate', () => { - const data = {name: 'goose', slug: 'goose', networkId: network.id} + it("creates community in network if user can moderate", () => { + const data = { name: "goose", slug: "goose", networkId: network.id }; return createCommunity(user.id, data) - .then(membership => { - return membership.groupData().fetch() - }) - .then(community => { - expect(community).to.exist - expect(Number(community.get('network_id'))).to.equal(network.id) - }) - }) -}) - -describe('deleteCommunityTopic', () => { - var user, community + .then((membership) => { + return membership.groupData().fetch(); + }) + .then((community) => { + expect(community).to.exist; + expect(Number(community.get("network_id"))).to.equal(network.id); + }); + }); +}); + +describe("deleteCommunityTopic", () => { + let user, community; before(function () { - user = factories.user() - community = factories.community() - return Promise.join(community.save(), user.save()) - .then(() => user.joinCommunity(community, GroupMembership.Role.MODERATOR)) - }) - - it('deletes the topic', async () => { - const topic = await factories.tag().save() + user = factories.user(); + community = factories.community(); + return Promise.join(community.save(), user.save()).then(() => + user.joinCommunity(community, GroupMembership.Role.MODERATOR) + ); + }); + + it("deletes the topic", async () => { + const topic = await factories.tag().save(); const communityTopic = await CommunityTag.create({ community_id: community.id, - tag_id: topic.id - }) - await deleteCommunityTopic(user.id, communityTopic.id) - const searched = await CommunityTag.where({id: communityTopic.id}).fetch() - expect(searched).not.to.exist - }) -}) - - -describe('deleteCommunity', () => { - var user, community + tag_id: topic.id, + }); + await deleteCommunityTopic(user.id, communityTopic.id); + const searched = await CommunityTag.where({ + id: communityTopic.id, + }).fetch(); + expect(searched).not.to.exist; + }); +}); + +describe("deleteCommunity", () => { + let user, community; before(async () => { - user = await factories.user().save() - community = await factories.community().save() - await user.joinCommunity(community, GroupMembership.Role.MODERATOR) - }) - - it('makes the community inactive', async () => { - await deleteCommunity(user.id, community.id) - - const foundCommunity = await Community.find(community.id) - expect(foundCommunity.get('active')).to.be.false - }) -}) - + user = await factories.user().save(); + community = await factories.community().save(); + await user.joinCommunity(community, GroupMembership.Role.MODERATOR); + }); + + it("makes the community inactive", async () => { + await deleteCommunity(user.id, community.id); + + const foundCommunity = await Community.find(community.id); + expect(foundCommunity.get("active")).to.be.false; + }); +}); diff --git a/api/graphql/mutations/convertGraphqlData.js b/api/graphql/mutations/convertGraphqlData.js index 8a426a31c..cb2748300 100644 --- a/api/graphql/mutations/convertGraphqlData.js +++ b/api/graphql/mutations/convertGraphqlData.js @@ -1,13 +1,16 @@ -import { transform, snakeCase } from 'lodash' +import { transform, snakeCase } from "lodash"; -export default function convertGraphqlData (data) { +export default function convertGraphqlData(data) { if (data === null) { - return null + return null; } - return transform(data, (result, value, key) => { - result[snakeCase(key)] = typeof value === 'object' - ? convertGraphqlData(value) - : value - }, Array.isArray(data) ? [] : {}) + return transform( + data, + (result, value, key) => { + result[snakeCase(key)] = + typeof value === "object" ? convertGraphqlData(value) : value; + }, + Array.isArray(data) ? [] : {} + ); } diff --git a/api/graphql/mutations/convertGraphqlData.test.js b/api/graphql/mutations/convertGraphqlData.test.js index fbfe52366..94c2e5439 100644 --- a/api/graphql/mutations/convertGraphqlData.test.js +++ b/api/graphql/mutations/convertGraphqlData.test.js @@ -1,7 +1,7 @@ -import convertGraphqlData from './convertGraphqlData' +import convertGraphqlData from "./convertGraphqlData"; -describe('convertGraphqlData', () => { - it('returns null when given null as an argument', () => { - expect(convertGraphqlData(null)).to.equal(null) - }) -}) +describe("convertGraphqlData", () => { + it("returns null when given null as an argument", () => { + expect(convertGraphqlData(null)).to.equal(null); + }); +}); diff --git a/api/graphql/mutations/event.js b/api/graphql/mutations/event.js index d1f5be5bd..15fab90ee 100644 --- a/api/graphql/mutations/event.js +++ b/api/graphql/mutations/event.js @@ -1,49 +1,53 @@ -import { values, includes } from 'lodash/fp' +import { values, includes } from "lodash/fp"; export async function respondToEvent(userId, eventId, response) { - if (!includes(response, values(EventInvitation.RESPONSE))) { - throw new Error(`response must be one of ${values(EventInvitation.RESPONSE)}. received ${response}`) + throw new Error( + `response must be one of ${values( + EventInvitation.RESPONSE + )}. received ${response}` + ); } - const event = await Post.find(eventId) - if(!event) { - throw new Error('Event not found') + const event = await Post.find(eventId); + if (!event) { + throw new Error("Event not found"); } - var eventInvitation = await EventInvitation.find({userId, eventId}) + const eventInvitation = await EventInvitation.find({ userId, eventId }); if (eventInvitation) { - await eventInvitation.save({response}) + await eventInvitation.save({ response }); } else { await EventInvitation.create({ userId, inviterId: userId, eventId, - response - }) + response, + }); } - return {success: true} + return { success: true }; } -export async function invitePeopleToEvent (userId, eventId, inviteeIds) { - inviteeIds.forEach(async inviteeId => { - var eventInvitation = await EventInvitation.find({userId: inviteeId, eventId}) +export async function invitePeopleToEvent(userId, eventId, inviteeIds) { + inviteeIds.forEach(async (inviteeId) => { + const eventInvitation = await EventInvitation.find({ + userId: inviteeId, + eventId, + }); if (!eventInvitation) { - - console.log('creating for invitation for ', inviteeId) + console.log("creating for invitation for ", inviteeId); await EventInvitation.create({ userId: inviteeId, inviterId: userId, - eventId - }) - + eventId, + }); } - }) - - const event = await Post.find(eventId) + }); - await event.createInviteNotifications(userId, inviteeIds) + const event = await Post.find(eventId); - return event -} \ No newline at end of file + await event.createInviteNotifications(userId, inviteeIds); + + return event; +} diff --git a/api/graphql/mutations/event.test.js b/api/graphql/mutations/event.test.js index d248d5715..e2fae45a0 100644 --- a/api/graphql/mutations/event.test.js +++ b/api/graphql/mutations/event.test.js @@ -1,32 +1,36 @@ -import { respondToEvent } from './event' -import factories from '../../../test/setup/factories' +import { respondToEvent } from "./event"; +import factories from "../../../test/setup/factories"; -describe('respondToEvent', () => { - var user, event +describe("respondToEvent", () => { + let user, event; before(async () => { - user = await factories.user().save() - event = await factories.post({type: 'event'}).save() - }) + user = await factories.user().save(); + event = await factories.post({ type: "event" }).save(); + }); - it('creates an eventInvitation if none exists', async () => { - await respondToEvent(user.id, event.id, EventInvitation.RESPONSE.YES) + it("creates an eventInvitation if none exists", async () => { + await respondToEvent(user.id, event.id, EventInvitation.RESPONSE.YES); const eventInvitation = await EventInvitation.find({ userId: user.id, - eventId: event.id - }) - expect(eventInvitation).to.exist - expect(eventInvitation.get('response')).to.equal(EventInvitation.RESPONSE.YES) - }) + eventId: event.id, + }); + expect(eventInvitation).to.exist; + expect(eventInvitation.get("response")).to.equal( + EventInvitation.RESPONSE.YES + ); + }); - it('updates an existing eventInvitation', async () => { - await respondToEvent(user.id, event.id, EventInvitation.RESPONSE.NO) + it("updates an existing eventInvitation", async () => { + await respondToEvent(user.id, event.id, EventInvitation.RESPONSE.NO); const eventInvitation = await EventInvitation.find({ userId: user.id, - eventId: event.id - }) - expect(eventInvitation).to.exist - expect(eventInvitation.get('response')).to.equal(EventInvitation.RESPONSE.NO) - }) -}) + eventId: event.id, + }); + expect(eventInvitation).to.exist; + expect(eventInvitation.get("response")).to.equal( + EventInvitation.RESPONSE.NO + ); + }); +}); diff --git a/api/graphql/mutations/index.js b/api/graphql/mutations/index.js index 0faf0f25c..6264dd94d 100644 --- a/api/graphql/mutations/index.js +++ b/api/graphql/mutations/index.js @@ -1,9 +1,9 @@ -import { isEmpty, mapKeys, pick, snakeCase, size, trim } from 'lodash' +import { isEmpty, mapKeys, pick, snakeCase, size, trim } from "lodash"; import underlyingFindOrCreateThread, { - validateThreadData -} from '../../models/post/findOrCreateThread' -import underlyingFindLinkPreview from '../../models/linkPreview/findOrCreateByUrl' -import convertGraphqlData from './convertGraphqlData' + validateThreadData, +} from "../../models/post/findOrCreateThread"; +import underlyingFindLinkPreview from "../../models/linkPreview/findOrCreateByUrl"; +import convertGraphqlData from "./convertGraphqlData"; export { createComment, @@ -11,37 +11,32 @@ export { deleteComment, canDeleteComment, updateComment, - canUpdateComment -} from './comment' -export { - findOrCreateLocation -} from './location' + canUpdateComment, +} from "./comment"; +export { findOrCreateLocation } from "./location"; export { addCommunityToNetwork, addNetworkModeratorRole, removeCommunityFromNetwork, removeNetworkModeratorRole, updateCommunityHiddenSetting, - updateNetwork -} from './network' -export { registerDevice } from './mobile' -export { - createTopic, - subscribe -} from './topic' + updateNetwork, +} from "./network"; +export { registerDevice } from "./mobile"; +export { createTopic, subscribe } from "./topic"; export { createInvitation, expireInvitation, resendInvitation, reinviteAll, - useInvitation -} from './invitation' + useInvitation, +} from "./invitation"; export { acceptJoinRequest, createJoinRequest, declineJoinRequest, joinCommunity, -} from './join_request' +} from "./join_request"; export { updateCommunity, addModerator, @@ -50,8 +45,8 @@ export { removeModerator, removeMember, regenerateAccessCode, - createCommunity -} from './community' + createCommunity, +} from "./community"; export { createPost, fulfillPost, @@ -59,8 +54,8 @@ export { updatePost, vote, deletePost, - pinPost -} from './post' + pinPost, +} from "./post"; export { createProject, createProjectRole, @@ -68,137 +63,148 @@ export { addPeopleToProjectRole, joinProject, leaveProject, - processStripeToken -} from './project' -export { - respondToEvent, - invitePeopleToEvent -} from './event' + processStripeToken, +} from "./project"; +export { respondToEvent, invitePeopleToEvent } from "./event"; export { blockUser, unblockUser, updateStripeAccount, - registerStripeAccount -} from './user' -export { updateMembership } from './membership' -export { deleteSavedSearch, createSavedSearch } from './savedSearch' - -export function updateMe (userId, changes) { - return User.find(userId) - .then(user => user.validateAndSave(convertGraphqlData(changes))) + registerStripeAccount, +} from "./user"; +export { updateMembership } from "./membership"; +export { deleteSavedSearch, createSavedSearch } from "./savedSearch"; + +export function updateMe(userId, changes) { + return User.find(userId).then((user) => + user.validateAndSave(convertGraphqlData(changes)) + ); } -export function allowCommunityInvites (communityId, data) { - return Community.query().where('id', communityId).update({allow_community_invites: data}) - .then(() => ({success: true})) +export function allowCommunityInvites(communityId, data) { + return Community.query() + .where("id", communityId) + .update({ allow_community_invites: data }) + .then(() => ({ success: true })); } -export async function leaveCommunity (userId, communityId) { - const community = await Community.find(communityId) - const user = await User.find(userId) - return user.leaveCommunity(community) +export async function leaveCommunity(userId, communityId) { + const community = await Community.find(communityId); + const user = await User.find(userId); + return user.leaveCommunity(community); } -export function findOrCreateThread (userId, data) { - return validateThreadData(userId, data) - .then(() => underlyingFindOrCreateThread(userId, data.participantIds)) +export function findOrCreateThread(userId, data) { + return validateThreadData(userId, data).then(() => + underlyingFindOrCreateThread(userId, data.participantIds) + ); } -export function findOrCreateLinkPreviewByUrl (data) { - return underlyingFindLinkPreview(data.url) +export function findOrCreateLinkPreviewByUrl(data) { + return underlyingFindLinkPreview(data.url); } -export function updateCommunityTopic (id, data) { - const whitelist = mapKeys(pick(data, ['visibility', 'isDefault']), (v, k) => snakeCase(k)) - if (isEmpty(whitelist)) return Promise.resolve(null) +export function updateCommunityTopic(id, data) { + const whitelist = mapKeys(pick(data, ["visibility", "isDefault"]), (v, k) => + snakeCase(k) + ); + if (isEmpty(whitelist)) return Promise.resolve(null); - return CommunityTag.query().where({id}).update(whitelist) - .then(() => ({success: true})) + return CommunityTag.query() + .where({ id }) + .update(whitelist) + .then(() => ({ success: true })); } -export function updateCommunityTopicFollow (userId, { id, data }) { - const whitelist = mapKeys(pick(data, 'newPostCount'), (v, k) => snakeCase(k)) - if (isEmpty(whitelist)) return Promise.resolve(null) +export function updateCommunityTopicFollow(userId, { id, data }) { + const whitelist = mapKeys(pick(data, "newPostCount"), (v, k) => snakeCase(k)); + if (isEmpty(whitelist)) return Promise.resolve(null); - return CommunityTag.where({id}).fetch() - .then(ct => ct.tagFollow(userId).query().update(whitelist)) - .then(() => ({success: true})) + return CommunityTag.where({ id }) + .fetch() + .then((ct) => ct.tagFollow(userId).query().update(whitelist)) + .then(() => ({ success: true })); } -export function markActivityRead (userId, activityid) { - return Activity.find(activityid) - .then(a => { - if (a.get('reader_id') !== userId) return - return a.save({unread: false}) - }) +export function markActivityRead(userId, activityid) { + return Activity.find(activityid).then((a) => { + if (a.get("reader_id") !== userId) return; + return a.save({ unread: false }); + }); } -export function markAllActivitiesRead (userId) { - return Activity.query().where('reader_id', userId).update({unread: false}) - .then(() => ({success: true})) +export function markAllActivitiesRead(userId) { + return Activity.query() + .where("reader_id", userId) + .update({ unread: false }) + .then(() => ({ success: true })); } -export function unlinkAccount (userId, provider) { +export function unlinkAccount(userId, provider) { return User.find(userId) - .then(user => { - if (!user) throw new Error(`Couldn't find user with id ${userId}`) - return user.unlinkAccount(provider) - }) - .then(() => ({success: true})) + .then((user) => { + if (!user) throw new Error(`Couldn't find user with id ${userId}`); + return user.unlinkAccount(provider); + }) + .then(() => ({ success: true })); } -export async function addSkill (userId, name) { - name = trim(name) +export async function addSkill(userId, name) { + name = trim(name); if (isEmpty(name)) { - throw new Error('Skill cannot be blank') + throw new Error("Skill cannot be blank"); } else if (size(name) > 39) { - throw new Error('Skill must be less than 40 characters') + throw new Error("Skill must be less than 40 characters"); } - let skill + let skill; try { - skill = await Skill.forge({name}).save() + skill = await Skill.forge({ name }).save(); } catch (err) { - if (!err.message || !err.message.includes('duplicate')) { - throw err + if (!err.message || !err.message.includes("duplicate")) { + throw err; } - skill = await Skill.find(name) + skill = await Skill.find(name); } try { - await skill.users().attach(userId) + await skill.users().attach(userId); } catch (err) { - if (!err.message || !err.message.includes('duplicate')) { - throw err + if (!err.message || !err.message.includes("duplicate")) { + throw err; } } - return skill + return skill; } -export function removeSkill (userId, skillIdOrName) { +export function removeSkill(userId, skillIdOrName) { return Skill.find(skillIdOrName) - .then(skill => { - if (!skill) throw new Error(`Couldn't find skill with ID or name ${skillIdOrName}`) - return skill.users().detach(userId) - }) - .then(() => ({success: true})) + .then((skill) => { + if (!skill) + throw new Error(`Couldn't find skill with ID or name ${skillIdOrName}`); + return skill.users().detach(userId); + }) + .then(() => ({ success: true })); } -export function flagInappropriateContent (userId, { category, reason, linkData }) { - let link +export function flagInappropriateContent( + userId, + { category, reason, linkData } +) { + let link; // TODO use FlaggedItem.Type switch (trim(linkData.type)) { - case 'post': - link = Frontend.Route.post(linkData.id, linkData.slug) - break - case 'comment': - link = Frontend.Route.thread(linkData.id) - break - case 'member': - link = Frontend.Route.profile(linkData.id) - break + case "post": + link = Frontend.Route.post(linkData.id, linkData.slug); + break; + case "comment": + link = Frontend.Route.thread(linkData.id); + break; + case "member": + link = Frontend.Route.profile(linkData.id); + break; default: - return Promise.reject(new Error('Invalid Link Type')) + return Promise.reject(new Error("Invalid Link Type")); } return FlaggedItem.create({ @@ -207,21 +213,26 @@ export function flagInappropriateContent (userId, { category, reason, linkData } reason, link, object_id: linkData.id, - object_type: linkData.type + object_type: linkData.type, }) - .tap(flaggedItem => Queue.classMethod('FlaggedItem', 'notifyModerators', {id: flaggedItem.id})) - .then(() => ({success: true})) + .tap((flaggedItem) => + Queue.classMethod("FlaggedItem", "notifyModerators", { + id: flaggedItem.id, + }) + ) + .then(() => ({ success: true })); } -export async function removePost (userId, postId, communityIdOrSlug) { - const community = await Community.find(communityIdOrSlug) +export async function removePost(userId, postId, communityIdOrSlug) { + const community = await Community.find(communityIdOrSlug); return Promise.join( Post.find(postId), GroupMembership.hasModeratorRole(userId, community), (post, isModerator) => { - if (!post) throw new Error(`Couldn't find post with id ${postId}`) - if (!isModerator) throw new Error(`You don't have permission to remove this post`) - return post.removeFromCommunity(communityIdOrSlug) - }) - .then(() => ({success: true})) + if (!post) throw new Error(`Couldn't find post with id ${postId}`); + if (!isModerator) + throw new Error("You don't have permission to remove this post"); + return post.removeFromCommunity(communityIdOrSlug); + } + ).then(() => ({ success: true })); } diff --git a/api/graphql/mutations/index.test.js b/api/graphql/mutations/index.test.js index f347c4b6a..3d4028fb2 100644 --- a/api/graphql/mutations/index.test.js +++ b/api/graphql/mutations/index.test.js @@ -2,190 +2,204 @@ import { addSkill, removeSkill, flagInappropriateContent, - allowCommunityInvites -} from './index' -import root from 'root-path' -require(root('test/setup')) -const factories = require(root('test/setup/factories')) + allowCommunityInvites, +} from "./index"; +import root from "root-path"; +require(root("test/setup")); +const factories = require(root("test/setup/factories")); -describe('mutations', () => { - var u1, community, protocol, domain +describe("mutations", () => { + let u1, community, protocol, domain; before(() => { - protocol = process.env.PROTOCOL - domain = process.env.DOMAIN - process.env.PROTOCOL = 'https' - process.env.DOMAIN = 'www.hylo.com' - - community = factories.community() - u1 = factories.user() - return Promise.join( - community.save(), u1.save()) - .then(() => Promise.join( - u1.joinCommunity(community) - )) - }) + protocol = process.env.PROTOCOL; + domain = process.env.DOMAIN; + process.env.PROTOCOL = "https"; + process.env.DOMAIN = "www.hylo.com"; + + community = factories.community(); + u1 = factories.user(); + return Promise.join(community.save(), u1.save()).then(() => + Promise.join(u1.joinCommunity(community)) + ); + }); after(() => { - process.env.PROTOCOL = protocol - process.env.DOMAIN = domain - }) + process.env.PROTOCOL = protocol; + process.env.DOMAIN = domain; + }); - it('can add a skill', async () => { - const skill = await addSkill(u1.id, 'New Skill') - expect(skill.get('name')).to.equal('New Skill') - }) + it("can add a skill", async () => { + const skill = await addSkill(u1.id, "New Skill"); + expect(skill.get("name")).to.equal("New Skill"); + }); - it('sets allow community invites', async () => { - const results = await allowCommunityInvites(u1.id, false) - expect(results.success).to.equal(true) + it("sets allow community invites", async () => { + const results = await allowCommunityInvites(u1.id, false); + expect(results.success).to.equal(true); - const results2 = await allowCommunityInvites(u1.id, true) - expect(results2.success).to.equal(true) - }) + const results2 = await allowCommunityInvites(u1.id, true); + expect(results2.success).to.equal(true); + }); - it('fails when adding a skill with 0 length', async () => { + it("fails when adding a skill with 0 length", async () => { try { - await addSkill(u1.id, '') - expect.fail('should throw') + await addSkill(u1.id, ""); + expect.fail("should throw"); } catch (err) { - expect(err.message).to.include('blank') + expect(err.message).to.include("blank"); } - }) + }); - it('fails for skills larger than 40 characters', async () => { + it("fails for skills larger than 40 characters", async () => { try { - await addSkill(u1.id, '01234567890123456789012345678901234567890') - expect.fail('should throw') + await addSkill(u1.id, "01234567890123456789012345678901234567890"); + expect.fail("should throw"); } catch (err) { - expect(err.message).to.include('must be less') + expect(err.message).to.include("must be less"); } - }) + }); - it('removes a skill from a user', () => { - let skillToRemove - let name = 'toBeRemoved' + it("removes a skill from a user", () => { + let skillToRemove; + const name = "toBeRemoved"; return addSkill(u1.id, name) - .then(skill => { - skillToRemove = skill - return u1.skills().fetch() - }) - .then(skills => { - expect(skills.toJSON()).to.contain.a.thing.with.property('name', name) - return removeSkill(u1.id, skillToRemove.id) - }) - .then(response => { - expect(response).to.have.property('success', true) - return u1.skills().fetch() - }) - .then(skills => { - expect(skills.toJSON()).to.not.contain.a.thing.with.property('name', name) - }) - }) - - it('flags post with valid parameters', () => { - let data = { - category: 'spam', - reason: 'my post reason', + .then((skill) => { + skillToRemove = skill; + return u1.skills().fetch(); + }) + .then((skills) => { + expect(skills.toJSON()).to.contain.a.thing.with.property("name", name); + return removeSkill(u1.id, skillToRemove.id); + }) + .then((response) => { + expect(response).to.have.property("success", true); + return u1.skills().fetch(); + }) + .then((skills) => { + expect(skills.toJSON()).to.not.contain.a.thing.with.property( + "name", + name + ); + }); + }); + + it("flags post with valid parameters", () => { + const data = { + category: "spam", + reason: "my post reason", linkData: { id: 10, - type: 'post' - } - } + type: "post", + }, + }; return flagInappropriateContent(u1.id, data) - .then(result => { - expect(result).to.have.property('success', true) - return FlaggedItem.where('category', 'spam').fetch() - }) - .then(flaggedItem => { - expect(flaggedItem.toJSON()).to.have.property('reason', 'my post reason') - }) - }) - - it('flags comment with valid parameters', () => { - let data = { - category: 'inappropriate', - reason: 'my comment reason', + .then((result) => { + expect(result).to.have.property("success", true); + return FlaggedItem.where("category", "spam").fetch(); + }) + .then((flaggedItem) => { + expect(flaggedItem.toJSON()).to.have.property( + "reason", + "my post reason" + ); + }); + }); + + it("flags comment with valid parameters", () => { + const data = { + category: "inappropriate", + reason: "my comment reason", linkData: { id: 10, - type: 'comment' - } - } + type: "comment", + }, + }; return flagInappropriateContent(u1.id, data) - .then(result => { - expect(result).to.have.property('success', true) - return FlaggedItem.where('category', 'inappropriate').fetch() - }) - .then(flaggedItem => { - expect(flaggedItem.toJSON()).to.have.property('reason', 'my comment reason') - }) - }) - - it('flags member with valid parameters', () => { - let data = { - category: 'illegal', - reason: 'my member reason', + .then((result) => { + expect(result).to.have.property("success", true); + return FlaggedItem.where("category", "inappropriate").fetch(); + }) + .then((flaggedItem) => { + expect(flaggedItem.toJSON()).to.have.property( + "reason", + "my comment reason" + ); + }); + }); + + it("flags member with valid parameters", () => { + const data = { + category: "illegal", + reason: "my member reason", linkData: { id: 10, - type: 'member' - } - } + type: "member", + }, + }; return flagInappropriateContent(u1.id, data) - .then(result => { - expect(result).to.have.property('success', true) - return FlaggedItem.where('category', 'illegal').fetch() - }) - .then(flaggedItem => { - expect(flaggedItem.toJSON()).to.have.property('reason', 'my member reason') - }) - }) - - it('flags content with non-other category and empty reason', () => { - let data = { - category: 'abusive', - reason: '', + .then((result) => { + expect(result).to.have.property("success", true); + return FlaggedItem.where("category", "illegal").fetch(); + }) + .then((flaggedItem) => { + expect(flaggedItem.toJSON()).to.have.property( + "reason", + "my member reason" + ); + }); + }); + + it("flags content with non-other category and empty reason", () => { + const data = { + category: "abusive", + reason: "", linkData: { id: 10, - type: 'member' - } - } + type: "member", + }, + }; return flagInappropriateContent(u1.id, data) - .then(result => { - expect(result).to.have.property('success', true) - return FlaggedItem.where('category', 'abusive').fetch() - }) - .then(flaggedItem => { - expect(flaggedItem.toJSON()).to.have.property('reason', '') - }) - }) - - it('fails to flag unidentified content with valid parameters', (done) => { - let data = { - category: 'safety', - reason: 'my UFO reason', + .then((result) => { + expect(result).to.have.property("success", true); + return FlaggedItem.where("category", "abusive").fetch(); + }) + .then((flaggedItem) => { + expect(flaggedItem.toJSON()).to.have.property("reason", ""); + }); + }); + + it("fails to flag unidentified content with valid parameters", (done) => { + const data = { + category: "safety", + reason: "my UFO reason", linkData: { id: 10, - type: 'ufo' - } - } - - expect(flagInappropriateContent(u1.id, data)).to.eventually.be.rejectedWith(Error, 'Invalid Link Type').and.notify(done) - }) - - it('fails to flag inappropriate content with type: other and no reason', (done) => { - let data = { - category: 'other', - reason: '', + type: "ufo", + }, + }; + + expect(flagInappropriateContent(u1.id, data)) + .to.eventually.be.rejectedWith(Error, "Invalid Link Type") + .and.notify(done); + }); + + it("fails to flag inappropriate content with type: other and no reason", (done) => { + const data = { + category: "other", + reason: "", linkData: { id: 10, - type: 'post' - } - } - - expect(flagInappropriateContent(u1.id, data)).to.eventually.be.rejected.and.notify(done) - }) -}) + type: "post", + }, + }; + + expect( + flagInappropriateContent(u1.id, data) + ).to.eventually.be.rejected.and.notify(done); + }); +}); diff --git a/api/graphql/mutations/invitation.js b/api/graphql/mutations/invitation.js index cae51b0a6..753d0aa14 100644 --- a/api/graphql/mutations/invitation.js +++ b/api/graphql/mutations/invitation.js @@ -1,56 +1,65 @@ -import InvitationService from '../../services/InvitationService' +import InvitationService from "../../services/InvitationService"; -export async function createInvitation (userId, communityId, data) { - const community = await Community.find(communityId) +export async function createInvitation(userId, communityId, data) { + const community = await Community.find(communityId); return GroupMembership.hasModeratorRole(userId, community) - .then(ok => { - if (!ok) throw new Error("You don't have permission to create an invitation for this community") - }) - .then(() => Community.find(communityId)) - .then((community) => { - if (!community) throw new Error('Cannot find community to send invites for') - return InvitationService.create({ - sessionUserId: userId, - communityId, - emails: data.emails, - message: data.message, - moderator: data.isModerator || false, - subject: `Join me in ${community.get('name')} on Hylo!` + .then((ok) => { + if (!ok) + throw new Error( + "You don't have permission to create an invitation for this community" + ); }) - }) - .then(invitations => ({invitations})) + .then(() => Community.find(communityId)) + .then((community) => { + if (!community) + throw new Error("Cannot find community to send invites for"); + return InvitationService.create({ + sessionUserId: userId, + communityId, + emails: data.emails, + message: data.message, + moderator: data.isModerator || false, + subject: `Join me in ${community.get("name")} on Hylo!`, + }); + }) + .then((invitations) => ({ invitations })); } -export function expireInvitation (userId, invitationId) { +export function expireInvitation(userId, invitationId) { return InvitationService.checkPermission(userId, invitationId) - .then(ok => { - if (!ok) throw new Error("You don't have permission to modify this invitation") - }) - .then(() => InvitationService.expire(userId, invitationId)) - .then(() => ({success: true})) + .then((ok) => { + if (!ok) + throw new Error("You don't have permission to modify this invitation"); + }) + .then(() => InvitationService.expire(userId, invitationId)) + .then(() => ({ success: true })); } -export function resendInvitation (userId, invitationId) { +export function resendInvitation(userId, invitationId) { return InvitationService.checkPermission(userId, invitationId) - .then(ok => { - if (!ok) throw new Error("You don't have permission to modify this invitation") - }) - .then(() => InvitationService.resend(invitationId)) - .then(() => ({success: true})) + .then((ok) => { + if (!ok) + throw new Error("You don't have permission to modify this invitation"); + }) + .then(() => InvitationService.resend(invitationId)) + .then(() => ({ success: true })); } -export async function reinviteAll (userId, communityId) { - const community = await Community.find(communityId) +export async function reinviteAll(userId, communityId) { + const community = await Community.find(communityId); return GroupMembership.hasModeratorRole(userId, community) - .then(ok => { - if (!ok) throw new Error("You don't have permission to modify this invitation") - }) - .then(() => InvitationService.reinviteAll({sessionUserId: userId, communityId})) - .then(() => ({success: true})) + .then((ok) => { + if (!ok) + throw new Error("You don't have permission to modify this invitation"); + }) + .then(() => + InvitationService.reinviteAll({ sessionUserId: userId, communityId }) + ) + .then(() => ({ success: true })); } -export function useInvitation (userId, invitationToken, accessCode) { +export function useInvitation(userId, invitationToken, accessCode) { return InvitationService.use(userId, invitationToken, accessCode) - .then(membership => ({membership})) - .catch(error => ({error: error.message})) + .then((membership) => ({ membership })) + .catch((error) => ({ error: error.message })); } diff --git a/api/graphql/mutations/invitation.test.js b/api/graphql/mutations/invitation.test.js index da3c20990..df09422cb 100644 --- a/api/graphql/mutations/invitation.test.js +++ b/api/graphql/mutations/invitation.test.js @@ -1,19 +1,25 @@ -import factories from '../../../test/setup/factories' -import { createInvitation } from './invitation' +import factories from "../../../test/setup/factories"; +import { createInvitation } from "./invitation"; -describe('invitation mutation', () => { - var user, community +describe("invitation mutation", () => { + let user, community; before(function () { - user = factories.user() - community = factories.community() - return Promise.join(community.save(), user.save()) - .then(() => user.joinCommunity(community, GroupMembership.Role.MODERATOR)) - }) + user = factories.user(); + community = factories.community(); + return Promise.join(community.save(), user.save()).then(() => + user.joinCommunity(community, GroupMembership.Role.MODERATOR) + ); + }); - it('createInvitation successfully', () => { - const data = {emails: ['one@test.com', 'two@test.com'], message: 'test message', moderator: true} - return createInvitation(user.id, community.id, data) - .then((ret) => expect(ret.invitations).to.have.lengthOf(2)) - }) -}) + it("createInvitation successfully", () => { + const data = { + emails: ["one@test.com", "two@test.com"], + message: "test message", + moderator: true, + }; + return createInvitation(user.id, community.id, data).then((ret) => + expect(ret.invitations).to.have.lengthOf(2) + ); + }); +}); diff --git a/api/graphql/mutations/join_request.js b/api/graphql/mutations/join_request.js index d70bb67e3..242c15782 100644 --- a/api/graphql/mutations/join_request.js +++ b/api/graphql/mutations/join_request.js @@ -1,26 +1,31 @@ -export async function joinCommunity (communityId, userId) { - const user = await User.find(userId) - if(!user) throw new Error(`User id ${userId} not found`) - const community = await Community.find(communityId) - if(!community) throw new Error(`Community id ${communityId} not found`) - if (!!community) return user.joinCommunity(community).then(membership => membership) +export async function joinCommunity(communityId, userId) { + const user = await User.find(userId); + if (!user) throw new Error(`User id ${userId} not found`); + const community = await Community.find(communityId); + if (!community) throw new Error(`Community id ${communityId} not found`); + if (community) + return user.joinCommunity(community).then((membership) => membership); } -export async function createJoinRequest (communityId, userId) { +export async function createJoinRequest(communityId, userId) { return JoinRequest.create({ userId: userId, communityId, - }) - .then(request => ({ request })) + }).then((request) => ({ request })); } -export async function acceptJoinRequest (joinRequestId, communityId, userId, moderatorId) { - await joinCommunity(communityId, userId) - await JoinRequest.update(joinRequestId, { status: 1 }, moderatorId) - return await JoinRequest.find(joinRequestId) +export async function acceptJoinRequest( + joinRequestId, + communityId, + userId, + moderatorId +) { + await joinCommunity(communityId, userId); + await JoinRequest.update(joinRequestId, { status: 1 }, moderatorId); + return await JoinRequest.find(joinRequestId); } -export async function declineJoinRequest (joinRequestId) { - await JoinRequest.update(joinRequestId, { status: 2 }) - return await JoinRequest.find(joinRequestId) +export async function declineJoinRequest(joinRequestId) { + await JoinRequest.update(joinRequestId, { status: 2 }); + return await JoinRequest.find(joinRequestId); } diff --git a/api/graphql/mutations/location.js b/api/graphql/mutations/location.js index ae8e7b79e..c6e21ecc0 100644 --- a/api/graphql/mutations/location.js +++ b/api/graphql/mutations/location.js @@ -1,20 +1,22 @@ -import convertGraphqlData from './convertGraphqlData' +import convertGraphqlData from "./convertGraphqlData"; -export function findLocation (data) { - return Location.query({where: { - address_number: data.address_number || null, - address_street: data.address_street || null, - city: data.city || null, - locality: data.locality || null, - neighborhood: data.neighborhood || null, - postcode: data.postcode || null, - country: data.country || null - }}).fetch() +export function findLocation(data) { + return Location.query({ + where: { + address_number: data.address_number || null, + address_street: data.address_street || null, + city: data.city || null, + locality: data.locality || null, + neighborhood: data.neighborhood || null, + postcode: data.postcode || null, + country: data.country || null, + }, + }).fetch(); } -export function findOrCreateLocation (data) { - const convertedData = convertGraphqlData(data) - return findLocation(convertedData).then(location => location || Location.create(convertedData)) +export function findOrCreateLocation(data) { + const convertedData = convertGraphqlData(data); + return findLocation(convertedData).then( + (location) => location || Location.create(convertedData) + ); } - - diff --git a/api/graphql/mutations/membership.js b/api/graphql/mutations/membership.js index 3816a65f7..00e62a101 100644 --- a/api/graphql/mutations/membership.js +++ b/api/graphql/mutations/membership.js @@ -1,17 +1,28 @@ -import { isEmpty, mapKeys, pick, snakeCase } from 'lodash' +import { isEmpty, mapKeys, pick, snakeCase } from "lodash"; -export async function updateMembership (userId, { communityId, data, data: { settings } }) { - const whitelist = mapKeys(pick(data, [ - 'newPostCount' - ]), (v, k) => snakeCase(k)) - if (data.lastViewedAt) settings.lastReadAt = data.lastViewedAt // legacy - if (data.lastReadAt) settings.lastReadAt = data.lastReadAt - if (isEmpty(settings) && isEmpty(whitelist)) return Promise.resolve(null) +export async function updateMembership( + userId, + { communityId, data, data: { settings } } +) { + const whitelist = mapKeys(pick(data, ["newPostCount"]), (v, k) => + snakeCase(k) + ); + if (data.lastViewedAt) settings.lastReadAt = data.lastViewedAt; // legacy + if (data.lastReadAt) settings.lastReadAt = data.lastReadAt; + if (isEmpty(settings) && isEmpty(whitelist)) return Promise.resolve(null); - const membership = await GroupMembership.forIds(userId, communityId, Community).fetch() - if (!membership) throw new Error("Couldn't find membership for community with id", communityId) - if (!isEmpty(settings)) membership.addSetting(settings) - if (!isEmpty(whitelist)) membership.set(whitelist) - if (membership.changed) await membership.save() - return membership + const membership = await GroupMembership.forIds( + userId, + communityId, + Community + ).fetch(); + if (!membership) + throw new Error( + "Couldn't find membership for community with id", + communityId + ); + if (!isEmpty(settings)) membership.addSetting(settings); + if (!isEmpty(whitelist)) membership.set(whitelist); + if (membership.changed) await membership.save(); + return membership; } diff --git a/api/graphql/mutations/membership.test.js b/api/graphql/mutations/membership.test.js index a52836fd5..ae83df6f3 100644 --- a/api/graphql/mutations/membership.test.js +++ b/api/graphql/mutations/membership.test.js @@ -1,11 +1,11 @@ -import { updateMembership } from './membership' -import factories from '../../../test/setup/factories' +import { updateMembership } from "./membership"; +import factories from "../../../test/setup/factories"; -it('handles some values specially', async () => { - const user = await factories.user().save() - const community = await factories.community().save() - await community.addGroupMembers([user]) - const date = new Date() +it("handles some values specially", async () => { + const user = await factories.user().save(); + const community = await factories.community().save(); + await community.addGroupMembers([user]); + const date = new Date(); await updateMembership(user.id, { communityId: community.id, @@ -13,13 +13,13 @@ it('handles some values specially', async () => { newPostCount: 7, lastViewedAt: date, settings: { - sendPushNotifications: true - } - } - }) + sendPushNotifications: true, + }, + }, + }); - const membership = await GroupMembership.forPair(user, community).fetch() - expect(membership.get('new_post_count')).to.equal(7) - expect(membership.getSetting('sendPushNotifications')).to.equal(true) - expect(membership.getSetting('lastReadAt')).to.equal(date.toISOString()) -}) + const membership = await GroupMembership.forPair(user, community).fetch(); + expect(membership.get("new_post_count")).to.equal(7); + expect(membership.getSetting("sendPushNotifications")).to.equal(true); + expect(membership.getSetting("lastReadAt")).to.equal(date.toISOString()); +}); diff --git a/api/graphql/mutations/mobile.js b/api/graphql/mutations/mobile.js index 43ec0ed43..73f48e7e6 100644 --- a/api/graphql/mutations/mobile.js +++ b/api/graphql/mutations/mobile.js @@ -1,4 +1,5 @@ -export function registerDevice (userId, { playerId, platform, version }) { - return Device.upsert({userId, playerId, platform, version}) - .then(() => ({success: true})) +export function registerDevice(userId, { playerId, platform, version }) { + return Device.upsert({ userId, playerId, platform, version }).then(() => ({ + success: true, + })); } diff --git a/api/graphql/mutations/mobile.test.js b/api/graphql/mutations/mobile.test.js index c0383ee18..95b13b9e4 100644 --- a/api/graphql/mutations/mobile.test.js +++ b/api/graphql/mutations/mobile.test.js @@ -1,43 +1,46 @@ -import { registerDevice } from './mobile' -import factories from '../../../test/setup/factories' +import { registerDevice } from "./mobile"; +import factories from "../../../test/setup/factories"; -describe('registerDevice', () => { - var user, device +describe("registerDevice", () => { + let user, device; before(() => { - user = factories.user() - return user.save() - .then(() => { - device = Device.forge({user_id: user.id, player_id: 'foo'}) - return device.save() - }) - }) + user = factories.user(); + return user.save().then(() => { + device = Device.forge({ user_id: user.id, player_id: "foo" }); + return device.save(); + }); + }); - it('creates a new device', () => { + it("creates a new device", () => { return registerDevice(user.id, { - playerId: 'bar', platform: 'ios', version: '2' - }) - .then(response => { - expect(response.success).to.be.true - return Device.where('player_id', 'bar').fetch() + playerId: "bar", + platform: "ios", + version: "2", }) - .then(d => { - expect(d).to.exist - expect(d.get('user_id')).to.equal(user.id) - expect(d.get('platform')).to.equal('ios') - expect(d.get('version')).to.equal('2') - }) - }) + .then((response) => { + expect(response.success).to.be.true; + return Device.where("player_id", "bar").fetch(); + }) + .then((d) => { + expect(d).to.exist; + expect(d.get("user_id")).to.equal(user.id); + expect(d.get("platform")).to.equal("ios"); + expect(d.get("version")).to.equal("2"); + }); + }); - it('updates an existing device', () => { + it("updates an existing device", () => { return registerDevice(user.id, { - playerId: 'foo', platform: 'ios', version: '2' - }) - .then(() => device.refresh()) - .then(() => { - expect(device.get('user_id')).to.equal(user.id) - expect(device.get('platform')).to.equal('ios') - expect(device.get('version')).to.equal('2') + playerId: "foo", + platform: "ios", + version: "2", }) - }) -}) + .then(() => device.refresh()) + .then(() => { + expect(device.get("user_id")).to.equal(user.id); + expect(device.get("platform")).to.equal("ios"); + expect(device.get("version")).to.equal("2"); + }); + }); +}); diff --git a/api/graphql/mutations/network.js b/api/graphql/mutations/network.js index 54bcfe5ba..0ae4c73c6 100644 --- a/api/graphql/mutations/network.js +++ b/api/graphql/mutations/network.js @@ -1,60 +1,77 @@ -import validateNetworkData from '../../models/network/validateNetworkData' -import underlyingUpdateNetwork from '../../models/network/updateNetwork' -import convertGraphqlData from './convertGraphqlData' +import validateNetworkData from "../../models/network/validateNetworkData"; +import underlyingUpdateNetwork from "../../models/network/updateNetwork"; +import convertGraphqlData from "./convertGraphqlData"; // TODO: more integrated `isAdmin` handling for mutations? -export async function networkMutationPermissionCheck ({ userId, isAdmin = false }, networkId) { - if (isAdmin) return - if (!await NetworkMembership.hasModeratorRole(userId, networkId)) { - throw new Error("You don't have permission to modify that network.") +export async function networkMutationPermissionCheck( + { userId, isAdmin = false }, + networkId +) { + if (isAdmin) return; + if (!(await NetworkMembership.hasModeratorRole(userId, networkId))) { + throw new Error("You don't have permission to modify that network."); } } -export async function addCommunityToNetwork (authZ, { communityId, networkId }) { - await networkMutationPermissionCheck(authZ, networkId) - await Community - .where('id', communityId) - .save('network_id', networkId, { method: 'update', patch: true }) - return Network.find(networkId, { withRelated: [ 'communities' ] }) +export async function addCommunityToNetwork(authZ, { communityId, networkId }) { + await networkMutationPermissionCheck(authZ, networkId); + await Community.where("id", communityId).save("network_id", networkId, { + method: "update", + patch: true, + }); + return Network.find(networkId, { withRelated: ["communities"] }); } -export async function addNetworkModeratorRole (authZ, { personId, networkId }) { - await networkMutationPermissionCheck(authZ, networkId) - const hasModeratorRole = await NetworkMembership.hasModeratorRole(personId, networkId) +export async function addNetworkModeratorRole(authZ, { personId, networkId }) { + await networkMutationPermissionCheck(authZ, networkId); + const hasModeratorRole = await NetworkMembership.hasModeratorRole( + personId, + networkId + ); if (hasModeratorRole) { - throw new Error('That user already has moderator permissions for that network.') + throw new Error( + "That user already has moderator permissions for that network." + ); } - await NetworkMembership.addModerator(personId, networkId) - return Network.find(networkId, { withRelated: [ 'moderators' ] }) + await NetworkMembership.addModerator(personId, networkId); + return Network.find(networkId, { withRelated: ["moderators"] }); } -export async function removeCommunityFromNetwork (authZ, { communityId, networkId }) { - await networkMutationPermissionCheck(authZ, networkId) - await Community - .where('id', communityId) - .save('network_id', null, { method: 'update', patch: true }) - return Network.find(networkId, { withRelated: [ 'communities' ] }) +export async function removeCommunityFromNetwork( + authZ, + { communityId, networkId } +) { + await networkMutationPermissionCheck(authZ, networkId); + await Community.where("id", communityId).save("network_id", null, { + method: "update", + patch: true, + }); + return Network.find(networkId, { withRelated: ["communities"] }); } -export async function updateCommunityHiddenSetting (authZ, communityId, hidden) { - const community = await Community.find(communityId) - const networkId = community.get('network_id') - if (!networkId) throw new Error('This community is not part of a network.') - await networkMutationPermissionCheck(authZ, networkId) - return community.updateHidden(hidden) +export async function updateCommunityHiddenSetting(authZ, communityId, hidden) { + const community = await Community.find(communityId); + const networkId = community.get("network_id"); + if (!networkId) throw new Error("This community is not part of a network."); + await networkMutationPermissionCheck(authZ, networkId); + return community.updateHidden(hidden); } -export async function removeNetworkModeratorRole (authZ, { personId, networkId }) { - await networkMutationPermissionCheck(authZ, networkId) - await NetworkMembership - .where({ user_id: personId, network_id: networkId }) - .destroy() - return Network.find(networkId, { withRelated: [ 'moderators' ] }) +export async function removeNetworkModeratorRole( + authZ, + { personId, networkId } +) { + await networkMutationPermissionCheck(authZ, networkId); + await NetworkMembership.where({ + user_id: personId, + network_id: networkId, + }).destroy(); + return Network.find(networkId, { withRelated: ["moderators"] }); } -export function updateNetwork (authZ, { id, data }) { - const convertedData = convertGraphqlData(data) +export function updateNetwork(authZ, { id, data }) { + const convertedData = convertGraphqlData(data); return networkMutationPermissionCheck(authZ, id) - .then(() => validateNetworkData(authZ.userId, convertedData)) - .then(() => underlyingUpdateNetwork(authZ.userId, id, convertedData)) + .then(() => validateNetworkData(authZ.userId, convertedData)) + .then(() => underlyingUpdateNetwork(authZ.userId, id, convertedData)); } diff --git a/api/graphql/mutations/network.test.js b/api/graphql/mutations/network.test.js index 03fb8c955..4280759f3 100644 --- a/api/graphql/mutations/network.test.js +++ b/api/graphql/mutations/network.test.js @@ -1,131 +1,158 @@ -import * as mutations from './network' +import * as mutations from "./network"; -describe('network mutations', () => { - let community, network, user +describe("network mutations", () => { + let community, network, user; before(() => { - network = new Network({ name: 'Bargle' }) - user = new User({ email: 'flargle@bargle' }) - return Promise.all([ network.save(), user.save() ]) - }) + network = new Network({ name: "Bargle" }); + user = new User({ email: "flargle@bargle" }); + return Promise.all([network.save(), user.save()]); + }); after(() => { - return Promise.all([ network.destroy(), user.destroy() ]) - }) + return Promise.all([network.destroy(), user.destroy()]); + }); - describe('networkMutationPermissionCheck', () => { + describe("networkMutationPermissionCheck", () => { after(() => { - return NetworkMembership.where('user_id', user.id).destroy() - }) - - it('does not throw when user is admin', () => { - return expect(mutations.networkMutationPermissionCheck({ - isAdmin: true - })).not.to.be.rejected - }) - - it('does not throw when user is network moderator', async () => { - await NetworkMembership.addModerator(user.id, network.id) - return expect(mutations.networkMutationPermissionCheck({ - userId: user.id, - isAdmin: false - }, network.id)).not.to.be.rejected - }) - - it('throws when user is not admin or network moderator', () => { - return expect(mutations.networkMutationPermissionCheck({ - userId: user.id, - isAdmin: false - }, 99)).to.be.rejected - }) - }) - - describe('communities', () => { + return NetworkMembership.where("user_id", user.id).destroy(); + }); + + it("does not throw when user is admin", () => { + return expect( + mutations.networkMutationPermissionCheck({ + isAdmin: true, + }) + ).not.to.be.rejected; + }); + + it("does not throw when user is network moderator", async () => { + await NetworkMembership.addModerator(user.id, network.id); + return expect( + mutations.networkMutationPermissionCheck( + { + userId: user.id, + isAdmin: false, + }, + network.id + ) + ).not.to.be.rejected; + }); + + it("throws when user is not admin or network moderator", () => { + return expect( + mutations.networkMutationPermissionCheck( + { + userId: user.id, + isAdmin: false, + }, + 99 + ) + ).to.be.rejected; + }); + }); + + describe("communities", () => { beforeEach(async () => { - community = await new Community({ name: 'Wargle', slug: 'wargle' }).save() - }) + community = await new Community({ + name: "Wargle", + slug: "wargle", + }).save(); + }); afterEach(() => { - return community.destroy() - }) + return community.destroy(); + }); - describe('addCommunityToNetwork', () => { - it('sets the correct network_id', async () => { + describe("addCommunityToNetwork", () => { + it("sets the correct network_id", async () => { await mutations.addCommunityToNetwork( { userId: user.id, isAdmin: true }, { communityId: community.id, networkId: network.id } - ) - await community.fetch({ withRelated: [ 'network' ] }) - expect(community.relations.network.id).to.equal(network.id) - }) - }) - - describe('removeCommunityFromNetwork', () => { - it('removes the network relation', async () => { - await community - .save('network_id', network.id, { method: 'update', patch: true }) + ); + await community.fetch({ withRelated: ["network"] }); + expect(community.relations.network.id).to.equal(network.id); + }); + }); + + describe("removeCommunityFromNetwork", () => { + it("removes the network relation", async () => { + await community.save("network_id", network.id, { + method: "update", + patch: true, + }); await mutations.removeCommunityFromNetwork( { userId: user.id, isAdmin: true }, { communityId: community.id, networkId: network.id } - ) - await community.fetch({ withRelated: [ 'network' ] }) - expect(community.relations.network).to.equal(undefined) - }) - }) - }) - - describe('moderators', () => { + ); + await community.fetch({ withRelated: ["network"] }); + expect(community.relations.network).to.equal(undefined); + }); + }); + }); + + describe("moderators", () => { afterEach(() => { - return NetworkMembership.where('user_id', user.id).destroy() - }) - - describe('addNetworkModeratorRole', () => { - it('throws if already a moderator', async () => { - await NetworkMembership.addModerator(user.id, network.id) - return expect(mutations.addNetworkModeratorRole( - { userId: user.id }, - { personId: user.id, networkId: network.id } - )).to.be.rejectedWith(/already has moderator/) - }) - - it('adds a moderator', async () => { + return NetworkMembership.where("user_id", user.id).destroy(); + }); + + describe("addNetworkModeratorRole", () => { + it("throws if already a moderator", async () => { + await NetworkMembership.addModerator(user.id, network.id); + return expect( + mutations.addNetworkModeratorRole( + { userId: user.id }, + { personId: user.id, networkId: network.id } + ) + ).to.be.rejectedWith(/already has moderator/); + }); + + it("adds a moderator", async () => { await mutations.addNetworkModeratorRole( { isAdmin: true }, { personId: user.id, networkId: network.id } - ) - await network.fetch({ withRelated: [ 'moderators' ] }) - expect(network.relations.moderators.first().id).to.equal(user.id) - }) - }) - - describe('removeNetworkModeratorRole', () => { - it('removes a moderator', async () => { - await NetworkMembership.addModerator(user.id, network.id) + ); + await network.fetch({ withRelated: ["moderators"] }); + expect(network.relations.moderators.first().id).to.equal(user.id); + }); + }); + + describe("removeNetworkModeratorRole", () => { + it("removes a moderator", async () => { + await NetworkMembership.addModerator(user.id, network.id); await mutations.removeNetworkModeratorRole( { isAdmin: true }, { personId: user.id, networkId: network.id } - ) - await network.fetch({ withRelated: [ 'moderators' ] }) - expect(network.relations.moderators.length).to.equal(0) - }) - }) - - describe('updateCommunityHiddenSetting', () => { - var community + ); + await network.fetch({ withRelated: ["moderators"] }); + expect(network.relations.moderators.length).to.equal(0); + }); + }); + + describe("updateCommunityHiddenSetting", () => { + let community; before(async () => { community = await new Community({ - name: 'cc', slug: 'ccc', hidden: true, network_id: network.id }).save() - }) + name: "cc", + slug: "ccc", + hidden: true, + network_id: network.id, + }).save(); + }); after(() => { - return community.destroy() - }) - - it('updates the community hidden setting', async () => { - await mutations.updateCommunityHiddenSetting({isAdmin: true}, community.id, true) - const newCommunity = await Community.find(community.id) - expect(newCommunity.get('hidden')).to.equal(true) - }) - }) - }) -}) + return community.destroy(); + }); + + it("updates the community hidden setting", async () => { + await mutations.updateCommunityHiddenSetting( + { isAdmin: true }, + community.id, + true + ); + const newCommunity = await Community.find(community.id); + expect(newCommunity.get("hidden")).to.equal(true); + }); + }); + }); +}); diff --git a/api/graphql/mutations/post.js b/api/graphql/mutations/post.js index 9871ba11c..a6a9b370c 100644 --- a/api/graphql/mutations/post.js +++ b/api/graphql/mutations/post.js @@ -1,83 +1,89 @@ -import validatePostData from '../../models/post/validatePostData' -import underlyingCreatePost from '../../models/post/createPost' -import underlyingUpdatePost from '../../models/post/updatePost' +import validatePostData from "../../models/post/validatePostData"; +import underlyingCreatePost from "../../models/post/createPost"; +import underlyingUpdatePost from "../../models/post/updatePost"; -export function createPost (userId, data) { +export function createPost(userId, data) { return convertGraphqlPostData(data) - .tap(convertedData => validatePostData(userId, convertedData)) - .then(validatedData => underlyingCreatePost(userId, validatedData)) + .tap((convertedData) => validatePostData(userId, convertedData)) + .then((validatedData) => underlyingCreatePost(userId, validatedData)); } -export function updatePost (userId, { id, data }) { +export function updatePost(userId, { id, data }) { return convertGraphqlPostData(data) - .tap(convertedData => validatePostData(userId, convertedData)) - .then(validatedData => underlyingUpdatePost(userId, id, validatedData)) + .tap((convertedData) => validatePostData(userId, convertedData)) + .then((validatedData) => underlyingUpdatePost(userId, id, validatedData)); } -export function fulfillPost (userId, postId) { +export function fulfillPost(userId, postId) { return Post.find(postId) - .then(post => { - if (post.get('user_id') !== userId) { - throw new Error("You don't have permission to modify this post") + .then((post) => { + if (post.get("user_id") !== userId) { + throw new Error("You don't have permission to modify this post"); } - return post.fulfill() + return post.fulfill(); }) - .then(() => ({success: true})) + .then(() => ({ success: true })); } -export function unfulfillPost (userId, postId) { +export function unfulfillPost(userId, postId) { return Post.find(postId) - .then(post => { - if (post.get('user_id') !== userId) { - throw new Error("You don't have permission to modify this post") + .then((post) => { + if (post.get("user_id") !== userId) { + throw new Error("You don't have permission to modify this post"); } - return post.unfulfill() + return post.unfulfill(); }) - .then(() => ({success: true})) + .then(() => ({ success: true })); } -export function vote (userId, postId, isUpvote) { - return Post.find(postId) - .then(post => post.vote(userId, isUpvote)) +export function vote(userId, postId, isUpvote) { + return Post.find(postId).then((post) => post.vote(userId, isUpvote)); } -export function deletePost (userId, postId) { +export function deletePost(userId, postId) { return Post.find(postId) - .then(post => { - if (post.get('user_id') !== userId) { - throw new Error("You don't have permission to modify this post") - } - return Post.deactivate(postId) - }) - .then(() => ({success: true})) + .then((post) => { + if (post.get("user_id") !== userId) { + throw new Error("You don't have permission to modify this post"); + } + return Post.deactivate(postId); + }) + .then(() => ({ success: true })); } -export async function pinPost (userId, postId, communityId) { - const community = await Community.find(communityId) - return GroupMembership.hasModeratorRole(userId, community) - .then(isModerator => { - if (!isModerator) throw new Error("You don't have permission to modify this community") - return PostMembership.find(postId, communityId) - .then(postMembership => { - if (!postMembership) throw new Error("Couldn't find postMembership") - return postMembership.togglePinned() - }) - .then(() => ({success: true})) - }) +export async function pinPost(userId, postId, communityId) { + const community = await Community.find(communityId); + return GroupMembership.hasModeratorRole(userId, community).then( + (isModerator) => { + if (!isModerator) + throw new Error("You don't have permission to modify this community"); + return PostMembership.find(postId, communityId) + .then((postMembership) => { + if (!postMembership) throw new Error("Couldn't find postMembership"); + return postMembership.togglePinned(); + }) + .then(() => ({ success: true })); + } + ); } // converts input data from the way it's received in GraphQL to the format that // the legacy code expects -- this sort of thing can be removed/refactored once // hylo-redux is no longer in use -function convertGraphqlPostData (data) { - return Promise.resolve(Object.assign({ - name: data.title, - description: data.details, - link_preview_id: data.linkPreviewId, - community_ids: data.communityIds, - parent_post_id: data.parentPostId, - location_id: data.locationId, - location: data.location, - is_public: data.isPublic - }, data)) +function convertGraphqlPostData(data) { + return Promise.resolve( + Object.assign( + { + name: data.title, + description: data.details, + link_preview_id: data.linkPreviewId, + community_ids: data.communityIds, + parent_post_id: data.parentPostId, + location_id: data.locationId, + location: data.location, + is_public: data.isPublic, + }, + data + ) + ); } diff --git a/api/graphql/mutations/post.test.js b/api/graphql/mutations/post.test.js index 95244894a..02c3d810c 100644 --- a/api/graphql/mutations/post.test.js +++ b/api/graphql/mutations/post.test.js @@ -1,45 +1,49 @@ -import '../../../test/setup' -import factories from '../../../test/setup/factories' -import { pinPost } from './post' +import "../../../test/setup"; +import factories from "../../../test/setup/factories"; +import { pinPost } from "./post"; -describe('pinPost', () => { - var user, community, post +describe("pinPost", () => { + let user, community, post; before(function () { - user = factories.user() - community = factories.community() - post = factories.post() + user = factories.user(); + community = factories.community(); + post = factories.post(); return Promise.join(community.save(), user.save(), post.save()) - .then(() => community.posts().attach(post)) - .then(() => user.joinCommunity(community, GroupMembership.Role.MODERATOR)) - }) + .then(() => community.posts().attach(post)) + .then(() => + user.joinCommunity(community, GroupMembership.Role.MODERATOR) + ); + }); - it('sets pinned_at to current time if not set', () => { + it("sets pinned_at to current time if not set", () => { return pinPost(user.id, post.id, community.id) - .then(() => PostMembership.find(post.id, community.id)) - .then(postMembership => { - expect(postMembership.get('pinned_at').getTime()) - .to.be.closeTo(new Date().getTime(), 2000) - }) - }) + .then(() => PostMembership.find(post.id, community.id)) + .then((postMembership) => { + expect(postMembership.get("pinned_at").getTime()).to.be.closeTo( + new Date().getTime(), + 2000 + ); + }); + }); - it('sets pinned_at to null if set', () => { + it("sets pinned_at to null if set", () => { return pinPost(user.id, post.id, community.id) - .then(() => PostMembership.find(post.id, community.id)) - .then(postMembership => { - expect(postMembership.get('pinned_at')).to.equal(null) - }) - }) + .then(() => PostMembership.find(post.id, community.id)) + .then((postMembership) => { + expect(postMembership.get("pinned_at")).to.equal(null); + }); + }); - it('rejects if user is not a moderator', () => { - return pinPost('777', post.id, community.id) - .then(() => expect.fail('should reject')) - .catch(e => expect(e.message).to.match(/don't have permission/)) - }) + it("rejects if user is not a moderator", () => { + return pinPost("777", post.id, community.id) + .then(() => expect.fail("should reject")) + .catch((e) => expect(e.message).to.match(/don't have permission/)); + }); it("rejects if postMembership doesn't exist", () => { - return pinPost(user.id, '919191', community.id) - .then(() => expect.fail('should reject')) - .catch(e => expect(e.message).to.match(/Couldn't find postMembership/)) - }) -}) + return pinPost(user.id, "919191", community.id) + .then(() => expect.fail("should reject")) + .catch((e) => expect(e.message).to.match(/Couldn't find postMembership/)); + }); +}); diff --git a/api/graphql/mutations/project.js b/api/graphql/mutations/project.js index 05ede2284..d48736be2 100644 --- a/api/graphql/mutations/project.js +++ b/api/graphql/mutations/project.js @@ -1,113 +1,119 @@ -import { createPost } from './post' -import { uniq } from 'lodash/fp' -var stripe = require("stripe")(process.env.STRIPE_API_KEY); +import { createPost } from "./post"; +import { uniq } from "lodash/fp"; +const stripe = require("stripe")(process.env.STRIPE_API_KEY); -export function createProject (userId, data) { +export function createProject(userId, data) { // add creator as a member of project on creation - const memberIds = data.memberIds - ? uniq(data.memberIds.concat([userId])) - : [] - const projectData = Object.assign({}, data, {type: Post.Type.PROJECT, memberIds}) - return createPost(userId, projectData) + const memberIds = data.memberIds ? uniq(data.memberIds.concat([userId])) : []; + const projectData = Object.assign({}, data, { + type: Post.Type.PROJECT, + memberIds, + }); + return createPost(userId, projectData); } -async function getModeratedProject (userId, projectId) { - const project = await Post.find(projectId, {withRelated: 'user'}) +async function getModeratedProject(userId, projectId) { + const project = await Post.find(projectId, { withRelated: "user" }); if (!project) { - throw new Error('Project not found') + throw new Error("Project not found"); } if (!project.isProject()) { - throw new Error('Post with supplied id is not a project') + throw new Error("Post with supplied id is not a project"); } if (project.relations.user.id !== userId) { - throw new Error("You don't have permission to moderate this project") + throw new Error("You don't have permission to moderate this project"); } - return project + return project; } -export async function createProjectRole (userId, projectId, roleName) { - await getModeratedProject(userId, projectId) +export async function createProjectRole(userId, projectId, roleName) { + await getModeratedProject(userId, projectId); const existing = await ProjectRole.where({ post_id: projectId, - name: roleName - }).fetch() + name: roleName, + }).fetch(); if (existing) { - throw new Error('A role with that name already exists in this project') + throw new Error("A role with that name already exists in this project"); } return ProjectRole.forge({ post_id: projectId, - name: roleName + name: roleName, }) - .save() - .then(() => ({success: true})) + .save() + .then(() => ({ success: true })); } -export async function deleteProjectRole (userId, id) { - const projectRole = await ProjectRole.find(id, {withRelated: 'project'}) +export async function deleteProjectRole(userId, id) { + const projectRole = await ProjectRole.find(id, { withRelated: "project" }); if (!projectRole) { - throw new Error('Project Role not found') + throw new Error("Project Role not found"); } - await getModeratedProject(userId, projectRole.relations.project.id) - return projectRole.destroy() - .then() - .then(() => ({success: true})) + await getModeratedProject(userId, projectRole.relations.project.id); + return projectRole + .destroy() + .then() + .then(() => ({ success: true })); } -export async function addPeopleToProjectRole (userId, peopleIds, projectRoleId) { - const projectRole = await ProjectRole.find(projectRoleId, {withRelated: 'project'}) +export async function addPeopleToProjectRole(userId, peopleIds, projectRoleId) { + const projectRole = await ProjectRole.find(projectRoleId, { + withRelated: "project", + }); if (!projectRole) { - throw new Error('Project Role not found') + throw new Error("Project Role not found"); } - const project = await getModeratedProject(userId, projectRole.relations.project.id) + const project = await getModeratedProject( + userId, + projectRole.relations.project.id + ); if (!project) { - throw new Error('No associated project') + throw new Error("No associated project"); } - const checkForSharedCommunity = id => - Group.inSameGroup([userId, id], Community) - .then(doesShare => { - if (!doesShare) throw new Error(`no shared communities with user ${id}`) - }) + const checkForSharedCommunity = (id) => + Group.inSameGroup([userId, id], Community).then((doesShare) => { + if (!doesShare) throw new Error(`no shared communities with user ${id}`); + }); - return Promise.map(peopleIds, async id => { - await checkForSharedCommunity(id) - var gm = await GroupMembership.forPair(id, project).fetch() + return Promise.map(peopleIds, async (id) => { + await checkForSharedCommunity(id); + let gm = await GroupMembership.forPair(id, project).fetch(); if (!gm) { - await project.addGroupMembers([id]) - gm = await GroupMembership.forPair(id, project).fetch() + await project.addGroupMembers([id]); + gm = await GroupMembership.forPair(id, project).fetch(); } await gm.save({ - project_role_id: projectRoleId - }) - }) - .then(() => ({success: true})) + project_role_id: projectRoleId, + }); + }).then(() => ({ success: true })); } -export async function joinProject (projectId, userId) { - const project = await Post.find(projectId) - return project.addProjectMembers([userId]) - .then(() => ({success: true})) +export async function joinProject(projectId, userId) { + const project = await Post.find(projectId); + return project.addProjectMembers([userId]).then(() => ({ success: true })); } -export async function leaveProject (projectId, userId) { - const project = await Post.find(projectId) - return project.removeProjectMembers([userId]) - .then(() => ({success: true})) +export async function leaveProject(projectId, userId) { + const project = await Post.find(projectId); + return project.removeProjectMembers([userId]).then(() => ({ success: true })); } -export async function createStripePaymentNotifications (contribution, creatorId) { - const userId = contribution.get('user_id') - const postId = contribution.get('post_id') +export async function createStripePaymentNotifications( + contribution, + creatorId +) { + const userId = contribution.get("user_id"); + const postId = contribution.get("post_id"); const activities = [ { @@ -115,51 +121,64 @@ export async function createStripePaymentNotifications (contribution, creatorId) post_id: postId, actor_id: userId, project_contribution_id: contribution.id, - reason: `donation to` + reason: "donation to", }, { reader_id: creatorId, post_id: postId, actor_id: userId, project_contribution_id: contribution.id, - reason: `donation from` + reason: "donation from", }, - ] - return Activity.saveForReasons(activities) + ]; + return Activity.saveForReasons(activities); } -export async function processStripeToken (userId, projectId, token, amount) { - const applicationFeeFraction = 0.01 - const project = await Post.find(projectId) +export async function processStripeToken(userId, projectId, token, amount) { + const applicationFeeFraction = 0.01; + const project = await Post.find(projectId); if (!project) { - throw new Error (`Can't find project with that id`) + throw new Error("Can't find project with that id"); } - const contributor = await User.find(userId) - const projectCreator = await User.find(project.get('user_id'), {withRelated: 'stripeAccount'}) + const contributor = await User.find(userId); + const projectCreator = await User.find(project.get("user_id"), { + withRelated: "stripeAccount", + }); if (!projectCreator.relations.stripeAccount) { - throw new Error (`This user does not have a connected Stripe account`) + throw new Error("This user does not have a connected Stripe account"); } // amount is in dollars, chargeAmount is in cents - const chargeAmount = Number(amount) * 100 - const applicationFee = chargeAmount * applicationFeeFraction - await stripe.charges.create({ - amount: chargeAmount, - currency: 'usd', - description: `${contributor.get('name')} contributing to project ${project.get('name')} - project id: ${projectId}`, - source: token, - application_fee: applicationFee - }, { - stripe_account: projectCreator.relations.stripeAccount.get('stripe_account_external_id') - }) + const chargeAmount = Number(amount) * 100; + const applicationFee = chargeAmount * applicationFeeFraction; + await stripe.charges.create( + { + amount: chargeAmount, + currency: "usd", + description: `${contributor.get( + "name" + )} contributing to project ${project.get( + "name" + )} - project id: ${projectId}`, + source: token, + application_fee: applicationFee, + }, + { + stripe_account: projectCreator.relations.stripeAccount.get( + "stripe_account_external_id" + ), + } + ); // ProjectContribution stores the amount in cents, and everywhere else in the app it's in cents const contribution = await ProjectContribution.forge({ user_id: contributor.id, post_id: projectId, - amount: chargeAmount - }).save() + amount: chargeAmount, + }).save(); - return createStripePaymentNotifications(contribution, project.get('user_id')) - .then(() => ({success: true})) + return createStripePaymentNotifications( + contribution, + project.get("user_id") + ).then(() => ({ success: true })); } diff --git a/api/graphql/mutations/project.test.js b/api/graphql/mutations/project.test.js index 42018ea75..e7b1d5c0f 100644 --- a/api/graphql/mutations/project.test.js +++ b/api/graphql/mutations/project.test.js @@ -1,170 +1,190 @@ -import '../../../test/setup' -import factories from '../../../test/setup/factories' +import "../../../test/setup"; +import factories from "../../../test/setup/factories"; import { - createProject, createProjectRole, deleteProjectRole, addPeopleToProjectRole, - joinProject, leaveProject, createStripePaymentNotifications -} from './project' -import mockRequire from 'mock-require' - -describe('createProject', () => { - var user, user2, community + createProject, + createProjectRole, + deleteProjectRole, + addPeopleToProjectRole, + joinProject, + leaveProject, + createStripePaymentNotifications, +} from "./project"; +import mockRequire from "mock-require"; + +describe("createProject", () => { + let user, user2, community; before(function () { - user = factories.user() - user2 = factories.user() - community = factories.community() - return Promise.join(community.save(), user.save(), user2.save()) - .then(() => user.joinCommunity(community)) - }) - - it('creates a post with project type, adding members and creator as member', async () => { + user = factories.user(); + user2 = factories.user(); + community = factories.community(); + return Promise.join(community.save(), user.save(), user2.save()).then(() => + user.joinCommunity(community) + ); + }); + + it("creates a post with project type, adding members and creator as member", async () => { const data = { - title: 'abc', + title: "abc", communityIds: [community.id], - memberIds: [user2.id] - } - const post = await createProject(user.id, data) - const project = await Post.find(post.id) - expect(project.get('type')).to.equal(Post.Type.PROJECT) - const members = await project.members().fetch() - expect(members.length).to.equal(2) - expect(members.map(m => m.id).sort()).to.deep.equal([user.id, user2.id].sort()) - }) -}) - -describe('createProjectRole', () => { - var user, project + memberIds: [user2.id], + }; + const post = await createProject(user.id, data); + const project = await Post.find(post.id); + expect(project.get("type")).to.equal(Post.Type.PROJECT); + const members = await project.members().fetch(); + expect(members.length).to.equal(2); + expect(members.map((m) => m.id).sort()).to.deep.equal( + [user.id, user2.id].sort() + ); + }); +}); + +describe("createProjectRole", () => { + let user, project; before(async function () { - user = factories.user() - await user.save() - project = factories.post({type: Post.Type.PROJECT, user_id: user.id}) - await project.save() - }) - - it('creates a project role', async () => { - const roleName = 'Founder' - await createProjectRole(user.id, project.id, roleName) - const projectRole = await ProjectRole.where({name: roleName}).fetch() - expect(projectRole).to.exist - expect(projectRole.get('post_id')).to.equal(project.id) - }) -}) - -describe('deleteProjectRole', () => { - var user, project, projectRole + user = factories.user(); + await user.save(); + project = factories.post({ type: Post.Type.PROJECT, user_id: user.id }); + await project.save(); + }); + + it("creates a project role", async () => { + const roleName = "Founder"; + await createProjectRole(user.id, project.id, roleName); + const projectRole = await ProjectRole.where({ name: roleName }).fetch(); + expect(projectRole).to.exist; + expect(projectRole.get("post_id")).to.equal(project.id); + }); +}); + +describe("deleteProjectRole", () => { + let user, project, projectRole; before(async function () { - user = factories.user() - await user.save() - project = factories.post({type: Post.Type.PROJECT, user_id: user.id}) - await project.save() - projectRole = new ProjectRole({post_id: project.id, name: 'Founder'}) - await projectRole.save() - }) - - it('creates a project role', async () => { - await deleteProjectRole(user.id, projectRole.id) - const fetchedProjectRole = await ProjectRole.find(projectRole.id) - expect(fetchedProjectRole).not.to.exist - }) -}) - -describe('addPeopleToProjectRole', () => { - var user, user2, community, projectRole, project + user = factories.user(); + await user.save(); + project = factories.post({ type: Post.Type.PROJECT, user_id: user.id }); + await project.save(); + projectRole = new ProjectRole({ post_id: project.id, name: "Founder" }); + await projectRole.save(); + }); + + it("creates a project role", async () => { + await deleteProjectRole(user.id, projectRole.id); + const fetchedProjectRole = await ProjectRole.find(projectRole.id); + expect(fetchedProjectRole).not.to.exist; + }); +}); + +describe("addPeopleToProjectRole", () => { + let user, user2, community, projectRole, project; before(async function () { - user = factories.user() - await user.save() - user2 = factories.user() - await user2.save() - community = factories.community() - await community.save() - await user.joinCommunity(community) - await user2.joinCommunity(community) - project = factories.post({type: Post.Type.PROJECT, user_id: user.id}) - await project.save() - projectRole = new ProjectRole({post_id: project.id, name: 'Founder'}) - await projectRole.save() - }) - - it('sets the group memberships to the user ids', async () => { - await addPeopleToProjectRole(user.id, [user2.id], projectRole.id) - const gm = await GroupMembership.forPair(user2.id, project).fetch() - expect(gm.get('project_role_id')).to.equal(projectRole.id) - }) -}) - -describe('joinProject', () => { - var user, project + user = factories.user(); + await user.save(); + user2 = factories.user(); + await user2.save(); + community = factories.community(); + await community.save(); + await user.joinCommunity(community); + await user2.joinCommunity(community); + project = factories.post({ type: Post.Type.PROJECT, user_id: user.id }); + await project.save(); + projectRole = new ProjectRole({ post_id: project.id, name: "Founder" }); + await projectRole.save(); + }); + + it("sets the group memberships to the user ids", async () => { + await addPeopleToProjectRole(user.id, [user2.id], projectRole.id); + const gm = await GroupMembership.forPair(user2.id, project).fetch(); + expect(gm.get("project_role_id")).to.equal(projectRole.id); + }); +}); + +describe("joinProject", () => { + let user, project; before(async function () { - user = factories.user() - await user.save() - project = factories.post({type: Post.Type.PROJECT}) - await project.save() - }) - - it('adds a user to a project', async () => { - await joinProject(project.id, user.id) - const members = await project.members().fetch() - expect(members.length).to.equal(1) - expect(members.first().id).to.equal(user.id) - }) -}) - -describe('leaveProject', () => { - var user, project + user = factories.user(); + await user.save(); + project = factories.post({ type: Post.Type.PROJECT }); + await project.save(); + }); + + it("adds a user to a project", async () => { + await joinProject(project.id, user.id); + const members = await project.members().fetch(); + expect(members.length).to.equal(1); + expect(members.first().id).to.equal(user.id); + }); +}); + +describe("leaveProject", () => { + let user, project; before(async function () { - user = factories.user() - await user.save() - project = factories.post({type: Post.Type.PROJECT}) - await project.save() - await project.addProjectMembers([user.id]) - }) - - it('removes a user from a project', async () => { - await leaveProject(project.id, user.id) - const members = await project.members().fetch() - expect(members.length).to.equal(0) - }) -}) - -describe('processStripeToken', () => { - var creator, contributor, project, projectMutations, options + user = factories.user(); + await user.save(); + project = factories.post({ type: Post.Type.PROJECT }); + await project.save(); + await project.addProjectMembers([user.id]); + }); + + it("removes a user from a project", async () => { + await leaveProject(project.id, user.id); + const members = await project.members().fetch(); + expect(members.length).to.equal(0); + }); +}); + +describe("processStripeToken", () => { + let creator, contributor, project, projectMutations, options; before(async () => { - options = null + options = null; const mockStripe = () => ({ charges: { - create: ops => { options = ops } - } - }) - - mockRequire('stripe', mockStripe) - projectMutations = mockRequire.reRequire('./project') - - contributor = await factories.user().save() - const stripeAccount = await factories.stripeAccount().save() - creator = await factories.user({stripe_account_id: stripeAccount.id}).save() - project = await factories.post({user_id: creator.id}).save() - }) - - after(() => mockRequire.stopAll()) - - it('works', async () => { - const applicationFeeFraction = 0.01 - const token = 'fkljdfk' - const amount = 123 - await projectMutations.processStripeToken(contributor.id, project.id, token, amount) + create: (ops) => { + options = ops; + }, + }, + }); + + mockRequire("stripe", mockStripe); + projectMutations = mockRequire.reRequire("./project"); + + contributor = await factories.user().save(); + const stripeAccount = await factories.stripeAccount().save(); + creator = await factories + .user({ stripe_account_id: stripeAccount.id }) + .save(); + project = await factories.post({ user_id: creator.id }).save(); + }); + + after(() => mockRequire.stopAll()); + + it("works", async () => { + const applicationFeeFraction = 0.01; + const token = "fkljdfk"; + const amount = 123; + await projectMutations.processStripeToken( + contributor.id, + project.id, + token, + amount + ); expect(options).to.deep.equal({ amount: amount * 100, - currency: 'usd', + currency: "usd", source: token, application_fee: amount * 100 * applicationFeeFraction, - description: `${contributor.get('name')} contributing to project ${project.get('name')} - project id: ${project.id}`, - }) - }) -}) - + description: `${contributor.get( + "name" + )} contributing to project ${project.get("name")} - project id: ${ + project.id + }`, + }); + }); +}); diff --git a/api/graphql/mutations/savedSearch.js b/api/graphql/mutations/savedSearch.js index 55faaaf50..ce34fd374 100644 --- a/api/graphql/mutations/savedSearch.js +++ b/api/graphql/mutations/savedSearch.js @@ -1,7 +1,7 @@ -export function createSavedSearch (attributes) { - return SavedSearch.create(attributes) +export function createSavedSearch(attributes) { + return SavedSearch.create(attributes); } -export function deleteSavedSearch (savedSearchId) { - return SavedSearch.delete(savedSearchId) +export function deleteSavedSearch(savedSearchId) { + return SavedSearch.delete(savedSearchId); } diff --git a/api/graphql/mutations/topic.js b/api/graphql/mutations/topic.js index b92b552e6..cfef03821 100644 --- a/api/graphql/mutations/topic.js +++ b/api/graphql/mutations/topic.js @@ -1,36 +1,42 @@ -import { sanitize } from 'hylo-utils/text' +import { sanitize } from "hylo-utils/text"; -export async function topicMutationPermissionCheck (userId, communityId) { - const community = await Community.find(communityId) +export async function topicMutationPermissionCheck(userId, communityId) { + const community = await Community.find(communityId); if (!community) { - throw new Error('That community does not exist.') + throw new Error("That community does not exist."); } - if (!await GroupMembership.hasActiveMembership(userId, community)) { - throw new Error("You're not a member of that community.") + if (!(await GroupMembership.hasActiveMembership(userId, community))) { + throw new Error("You're not a member of that community."); } } -export async function createTopic (userId, topicName, communityId, isDefault, isSubscribing = true) { - await topicMutationPermissionCheck(userId, communityId) - const name = sanitize(topicName) - const invalidReason = Tag.validate(name) +export async function createTopic( + userId, + topicName, + communityId, + isDefault, + isSubscribing = true +) { + await topicMutationPermissionCheck(userId, communityId); + const name = sanitize(topicName); + const invalidReason = Tag.validate(name); if (invalidReason) { - throw new Error(invalidReason) + throw new Error(invalidReason); } - const topic = await Tag.findOrCreate(name) + const topic = await Tag.findOrCreate(name); await Tag.addToCommunity({ community_id: communityId, tag_id: topic.id, user_id: userId, is_default: isDefault, - isSubscribing - }) - return topic + isSubscribing, + }); + return topic; } -export async function subscribe (userId, topicId, communityId, isSubscribing) { - await topicMutationPermissionCheck(userId, communityId) - await TagFollow.subscribe(topicId, userId, communityId, isSubscribing) - return { success: true } +export async function subscribe(userId, topicId, communityId, isSubscribing) { + await topicMutationPermissionCheck(userId, communityId); + await TagFollow.subscribe(topicId, userId, communityId, isSubscribing); + return { success: true }; } diff --git a/api/graphql/mutations/topic.test.js b/api/graphql/mutations/topic.test.js index 491316ec5..b9578bd4f 100644 --- a/api/graphql/mutations/topic.test.js +++ b/api/graphql/mutations/topic.test.js @@ -1,85 +1,85 @@ -import * as mutations from './topic' -import setup from '../../../test/setup' -import factories from '../../../test/setup/factories' +import * as mutations from "./topic"; +import setup from "../../../test/setup"; +import factories from "../../../test/setup/factories"; -describe('topic mutations', () => { - let c1, c2, u1, u2 +describe("topic mutations", () => { + let c1, c2, u1, u2; before(async () => { - c1 = await factories.community().save() - c2 = await factories.community().save() - u1 = await factories.user().save() - u2 = await factories.user().save() - await u1.joinCommunity(c1) - }) + c1 = await factories.community().save(); + c2 = await factories.community().save(); + u1 = await factories.user().save(); + u2 = await factories.user().save(); + await u1.joinCommunity(c1); + }); - after(async () => setup.clearDb()) + after(async () => setup.clearDb()); - describe('topicMutationPermissionCheck', () => { - it('rejects when community does not exist', async () => { - const check = mutations.topicMutationPermissionCheck(u1.id, 9999) - await expect(check).to.be.rejectedWith(/community does not exist/) - }) + describe("topicMutationPermissionCheck", () => { + it("rejects when community does not exist", async () => { + const check = mutations.topicMutationPermissionCheck(u1.id, 9999); + await expect(check).to.be.rejectedWith(/community does not exist/); + }); - it('rejects when not a member of the community', async () => { - const check = mutations.topicMutationPermissionCheck(u1.id, c2.id) - await expect(check).to.be.rejectedWith(/not a member/) - }) + it("rejects when not a member of the community", async () => { + const check = mutations.topicMutationPermissionCheck(u1.id, c2.id); + await expect(check).to.be.rejectedWith(/not a member/); + }); - it('does not reject when user is a member', async () => { - const check = mutations.topicMutationPermissionCheck(u1.id, c1.id) - await expect(check).not.to.be.rejected - }) - }) + it("does not reject when user is a member", async () => { + const check = mutations.topicMutationPermissionCheck(u1.id, c1.id); + await expect(check).not.to.be.rejected; + }); + }); - describe('createTopic', () => { + describe("createTopic", () => { // validation is mostly tested in hylo-utils, so this just left here to show willing... - it('rejects on invalid topic names', async () => { + it("rejects on invalid topic names", async () => { const actual = mutations.createTopic( u1.id, - '0123456789 0123456789 0123456789 0123456789 0123456789', + "0123456789 0123456789 0123456789 0123456789 0123456789", c1.id - ) - await expect(actual).to.be.rejectedWith(/must not contain whitespace/) - }) + ); + await expect(actual).to.be.rejectedWith(/must not contain whitespace/); + }); - it('adds the topic to the community', async () => { - const topic = await mutations.createTopic(u1.id, 'wombats', c1.id) - await topic.refresh({ withRelated: [ 'communities' ] }) - const community = topic.relations.communities.first() - expect(community.id).to.equal(c1.id) - }) - }) + it("adds the topic to the community", async () => { + const topic = await mutations.createTopic(u1.id, "wombats", c1.id); + await topic.refresh({ withRelated: ["communities"] }); + const community = topic.relations.communities.first(); + expect(community.id).to.equal(c1.id); + }); + }); - describe('subscribe', async () => { - let t + describe("subscribe", async () => { + let t; beforeEach(async () => { - t = await factories.tag().save() + t = await factories.tag().save(); await Tag.addToCommunity({ community_id: c1.id, tag_id: t.id, - user_id: u2.id - }) - }) + user_id: u2.id, + }); + }); - it('adds the user to the topic', async () => { - await mutations.subscribe(u1.id, t.id, c1.id, true) - await u1.refresh({ withRelated: [ 'followedTags' ] }) - const tag = u1.relations.followedTags.find({ id: t.id }) - expect(tag).not.to.equal(undefined) - }) + it("adds the user to the topic", async () => { + await mutations.subscribe(u1.id, t.id, c1.id, true); + await u1.refresh({ withRelated: ["followedTags"] }); + const tag = u1.relations.followedTags.find({ id: t.id }); + expect(tag).not.to.equal(undefined); + }); - it('removes the user from the topic if isSubscribing falsy', async () => { + it("removes the user from the topic if isSubscribing falsy", async () => { await new TagFollow({ community_id: c1.id, tag_id: t.id, - user_id: u1.id - }).save() - await mutations.subscribe(u1.id, t.id, c1.id, false) - await u1.refresh({ withRelated: [ 'followedTags' ] }) - const hasFollow = u1.relations.followedTags.find({ id: t.id }) - expect(hasFollow).to.equal(undefined) - }) - }) -}) + user_id: u1.id, + }).save(); + await mutations.subscribe(u1.id, t.id, c1.id, false); + await u1.refresh({ withRelated: ["followedTags"] }); + const hasFollow = u1.relations.followedTags.find({ id: t.id }); + expect(hasFollow).to.equal(undefined); + }); + }); +}); diff --git a/api/graphql/mutations/user.js b/api/graphql/mutations/user.js index 4ed045c1e..2fc9983f2 100644 --- a/api/graphql/mutations/user.js +++ b/api/graphql/mutations/user.js @@ -1,42 +1,41 @@ -import request from 'request' +import request from "request"; -export function blockUser (userId, blockedUserId) { - return BlockedUser.create(userId, blockedUserId) - .then(() => ({success: true})) +export function blockUser(userId, blockedUserId) { + return BlockedUser.create(userId, blockedUserId).then(() => ({ + success: true, + })); } -export async function unblockUser (userId, blockedUserId) { - const blockedUser = await BlockedUser.find(userId, blockedUserId) - if (!blockedUser) throw new Error("user is not blocked") - return blockedUser.destroy() - .then(() => ({success: true})) +export async function unblockUser(userId, blockedUserId) { + const blockedUser = await BlockedUser.find(userId, blockedUserId); + if (!blockedUser) throw new Error("user is not blocked"); + return blockedUser.destroy().then(() => ({ success: true })); } -export async function updateStripeAccount (userId, accountId) { +export async function updateStripeAccount(userId, accountId) { // TODO: add validation on accountId - const user = await User.find(userId, {withRelated: 'stripeAccount'}) - user.updateStripeAccount(accountId) - .then(() => ({success: true})) + const user = await User.find(userId, { withRelated: "stripeAccount" }); + user.updateStripeAccount(accountId).then(() => ({ success: true })); } -export async function registerStripeAccount (userId, authorizationCode) { - const user = await User.find(userId, {withRelated: 'stripeAccount'}) +export async function registerStripeAccount(userId, authorizationCode) { + const user = await User.find(userId, { withRelated: "stripeAccount" }); const options = { - uri: 'https://connect.stripe.com/oauth/token', + uri: "https://connect.stripe.com/oauth/token", form: { client_secret: process.env.STRIPE_API_KEY, code: authorizationCode, - grant_type: 'authorization_code' + grant_type: "authorization_code", }, - json: true - } + json: true, + }; // TODO: this should be in a promise chain request.post(options, async (err, response, body) => { - const accountId = body.stripe_user_id - const refreshToken = body.refresh_token + const accountId = body.stripe_user_id; + const refreshToken = body.refresh_token; if (accountId && refreshToken) { - await user.updateStripeAccount(accountId, refreshToken) + await user.updateStripeAccount(accountId, refreshToken); } - }) - return Promise.resolve({success: true}) -} \ No newline at end of file + }); + return Promise.resolve({ success: true }); +} diff --git a/api/graphql/schema.graphql b/api/graphql/schema.graphql index ee7eff657..990d3d4b6 100644 --- a/api/graphql/schema.graphql +++ b/api/graphql/schema.graphql @@ -17,7 +17,12 @@ type Me { locationObject: Location memberships(first: Int, cursor: ID, order: String): [Membership] membershipsTotal: Int - messageThreads(first: Int, offset: Int, order: String, sortBy: String): MessageThreadQuerySet + messageThreads( + first: Int + offset: Int + order: String + sortBy: String + ): MessageThreadQuerySet messageThreadsTotal: Int name: String newNotificationCount: Int @@ -76,11 +81,11 @@ type Topic { postsTotal(communitySlug: String, networkSlug: String): Int followersTotal(communitySlug: String, networkSlug: String): Int communityTopics( - first: Int, + first: Int offset: Int - communitySlug: String, - isDefault: Boolean, - networkSlug: String, + communitySlug: String + isDefault: Boolean + networkSlug: String visibility: [Int] ): CommunityTopicQuerySet } @@ -103,16 +108,20 @@ type Person { comments(first: Int, offset: Int, order: String): CommentQuerySet memberships(first: Int, cursor: ID, order: String): [Membership] membershipsTotal: Int - moderatedCommunityMemberships(first: Int, cursor: ID, order: String): [Membership] + moderatedCommunityMemberships( + first: Int + cursor: ID + order: String + ): [Membership] moderatedCommunityMembershipsTotal: Int posts( - first: Int, - order: String, - sortBy: String, - offset: Int, - search: String, - filter: String, - topic: ID, + first: Int + order: String + sortBy: String + offset: Int + search: String + filter: String + topic: ID boundingBox: [PointInput] ): PostQuerySet skills(first: Int, cursor: ID): SkillQuerySet @@ -155,22 +164,22 @@ type Community { isAutoJoinable: Boolean publicMemberDirectory: Boolean members( - boundingBox: [PointInput], - first: Int, - order: String, - sortBy: String, - offset: Int, - search: String, + boundingBox: [PointInput] + first: Int + order: String + sortBy: String + offset: Int + search: String autocomplete: String ): PersonQuerySet posts( - first: Int, - order: String, - sortBy: String, - offset: Int, - search: String, - filter: String, - topic: ID, + first: Int + order: String + sortBy: String + offset: Int + search: String + filter: String + topic: ID boundingBox: [PointInput] ): PostQuerySet memberCount: Int @@ -180,20 +189,16 @@ type Community { moderators(first: Int, cursor: ID, order: String): PersonQuerySet pendingInvitations(first: Int, cursor: ID, order: String): InvitationQuerySet communityTopics( - first: Int, - sortBy: String, - order: String, - offset: Int, - autocomplete: String, - isDefault: Boolean, - subscribed: Boolean, + first: Int + sortBy: String + order: String + offset: Int + autocomplete: String + isDefault: Boolean + subscribed: Boolean visibility: Int ): CommunityTopicQuerySet - skills( - first: Int, - offset: Int, - autocomplete: String - ): SkillQuerySet, + skills(first: Int, offset: Int, autocomplete: String): SkillQuerySet allowCommunityInvites: Boolean } @@ -266,7 +271,11 @@ type Post { followers(first: Int, cursor: ID, order: String): [Person] followersTotal: Int members(first: Int, cursor: ID, order: String): PersonQuerySet - eventInvitations(first: Int, cursor: ID, order: String): EventInvitationQuerySet + eventInvitations( + first: Int + cursor: ID + order: String + ): EventInvitationQuerySet communities(first: Int, cursor: ID, order: String): [Community] communitiesTotal: Int comments(first: Int, cursor: ID, order: String): CommentQuerySet @@ -279,8 +288,8 @@ type Post { attachments(type: String): [Attachment] attachmentsTotal: Int postMemberships: [PostMembership] - postMembershipsTotal: Int, - topics: [Topic], + postMembershipsTotal: Int + topics: [Topic] topicsTotal: Int announcement: Boolean acceptContributions: Boolean @@ -438,94 +447,87 @@ type Query { me: Me person(id: ID): Person notifications( - first: Int, - order: String, - offset: Int, + first: Int + order: String + offset: Int resetCount: Boolean ): NotificationQuerySet community(id: ID, slug: String, updateLastViewed: Boolean): Community communities( - first: Int, - order: String, - sortBy: String, - offset: Int, - communityIds: [String], - networkSlugs: [String], - search: String, - autocomplete: String, - filter: String, - boundingBox: [PointInput], + first: Int + order: String + sortBy: String + offset: Int + communityIds: [String] + networkSlugs: [String] + search: String + autocomplete: String + filter: String + boundingBox: [PointInput] isPublic: Boolean ): CommunityQuerySet joinRequests(communityId: ID): JoinRequestQuerySet messageThread(id: ID): MessageThread post(id: ID): Post posts( - networkSlugs: [String], - first: Int, - order: String, - sortBy: String, - offset: Int, - search: String, - filter: String, - topic: ID, - boundingBox: [PointInput], + networkSlugs: [String] + first: Int + order: String + sortBy: String + offset: Int + search: String + filter: String + topic: ID + boundingBox: [PointInput] isPublic: Boolean ): PostQuerySet people( - boundingBox: [PointInput], - first: Int, - order: String, - sortBy: String, - offset: Int, - search: String, - autocomplete: String, - communityIds: [String], + boundingBox: [PointInput] + first: Int + order: String + sortBy: String + offset: Int + search: String + autocomplete: String + communityIds: [String] filter: String ): PersonQuerySet topic(id: ID, name: String): Topic - communityTopic( - communitySlug: String, - topicName: String - ): CommunityTopic + communityTopic(communitySlug: String, topicName: String): CommunityTopic topics( - communitySlug: String, - networkSlug: String, - autocomplete: String, - isDefault: Boolean, - visibility: [Int], - sortBy: String, - first: Int, + communitySlug: String + networkSlug: String + autocomplete: String + isDefault: Boolean + visibility: [Int] + sortBy: String + first: Int offset: Int ): TopicQuerySet - connections( - first: Int, - offset: Int - ): PersonConnectionQuerySet + connections(first: Int, offset: Int): PersonConnectionQuerySet communityTopics( - autocomplete: String, - isDefault: Boolean, - subscribed: Boolean, - visibility: [Int], - sortBy: String, - order: String, - first: Int, + autocomplete: String + isDefault: Boolean + subscribed: Boolean + visibility: [Int] + sortBy: String + order: String + first: Int offset: Int ): CommunityTopicQuerySet search( - term: String, - type: String, - first: Int, + term: String + type: String + first: Int offset: Int ): SearchResultQuerySet network(id: ID, slug: String): Network savedSearches(userId: ID): SavedSearchQuerySet - skills( - first: Int, - offset: Int, - autocomplete: String - ): SkillQuerySet, - checkInvitation(invitationToken: String, accessCode: String): CheckInvitationResult + skills(first: Int, offset: Int, autocomplete: String): SkillQuerySet + checkInvitation( + invitationToken: String + accessCode: String + ): CheckInvitationResult } input AttachmentInput { @@ -701,7 +703,12 @@ input UserSettingsInput { } type Mutation { - acceptJoinRequest(joinRequestId: ID, communityId: ID, userId: ID, moderatorId: ID): JoinRequest + acceptJoinRequest( + joinRequestId: ID + communityId: ID + userId: ID + moderatorId: ID + ): JoinRequest addCommunityToNetwork(communityId: ID, networkId: ID): Network addModerator(personId: ID, communityId: ID): Community addNetworkModeratorRole(personId: ID, networkId: ID): Network @@ -718,7 +725,12 @@ type Mutation { createProject(data: PostInput): Post createProjectRole(projectId: ID, roleName: String): GenericResult createSavedSearch(data: SavedSearchInput): SavedSearch - createTopic(topicName: String, communityId: ID, isDefault: Boolean, isSubscribing: Boolean): Topic + createTopic( + topicName: String + communityId: ID + isDefault: Boolean + isSubscribing: Boolean + ): Topic declineJoinRequest(joinRequestId: ID): JoinRequest deleteComment(id: ID): GenericResult deleteCommunity(id: ID): GenericResult @@ -742,12 +754,20 @@ type Mutation { pinPost(postId: ID, communityId: ID): GenericResult processStripeToken(postId: ID, token: String, amount: Int): GenericResult regenerateAccessCode(communityId: ID): Community - registerDevice(playerId: String, platform: String, version: String): GenericResult + registerDevice( + playerId: String + platform: String + version: String + ): GenericResult reinviteAll(communityId: ID): GenericResult registerStripeAccount(authorizationCode: String): GenericResult removeCommunityFromNetwork(communityId: ID, networkId: ID): Network removeMember(personId: ID, communityId: ID): Community - removeModerator(personId: ID, communityId: ID, isRemoveFromCommunity: Boolean): Community + removeModerator( + personId: ID + communityId: ID + isRemoveFromCommunity: Boolean + ): Community removeNetworkModeratorRole(personId: ID, networkId: ID): Network removePost(postId: ID, slug: String, communityId: ID): GenericResult removeSkill(id: ID, name: String): GenericResult @@ -761,13 +781,24 @@ type Mutation { updateCommunitySettings(id: ID, changes: CommunityInput): Community updateCommunityHiddenSetting(id: ID, hidden: Boolean): Community updateCommunityTopic(id: ID, data: CommunityTopicInput): GenericResult - updateCommunityTopicFollow(id: ID, data: CommunityTopicFollowInput): GenericResult + updateCommunityTopicFollow( + id: ID + data: CommunityTopicFollowInput + ): GenericResult updateMe(changes: MeInput): Me - updateMembership(communityId: ID, slug: String, data: MembershipInput): Membership + updateMembership( + communityId: ID + slug: String + data: MembershipInput + ): Membership updateNetwork(id: ID, data: NetworkInput): Network updatePost(id: ID, data: PostInput): Post updateStripeAccount(accountId: String): GenericResult - useInvitation(userId: ID, invitationToken: String, accessCode: String): InvitationUseResult, + useInvitation( + userId: ID + invitationToken: String + accessCode: String + ): InvitationUseResult vote(postId: ID, isUpvote: Boolean): Post } @@ -840,19 +871,37 @@ type Network { bannerUrl: String createdAt: String memberCount: Int - communities(first: Int, offset: Int, order: String, sortBy: String, search: String): CommunityQuerySet - members(boundingBox: [PointInput], first: Int, offset: Int, order: String, sortBy: String, search: String): PersonQuerySet - moderators(first: Int, offset: Int, order: String, sortBy: String): PersonQuerySet + communities( + first: Int + offset: Int + order: String + sortBy: String + search: String + ): CommunityQuerySet + members( + boundingBox: [PointInput] + first: Int + offset: Int + order: String + sortBy: String + search: String + ): PersonQuerySet + moderators( + first: Int + offset: Int + order: String + sortBy: String + ): PersonQuerySet isModerator: Boolean isAdmin: Boolean posts( - first: Int, - order: String, - sortBy: String, - offset: Int, - search: String, - filter: String, - topic: ID, + first: Int + order: String + sortBy: String + offset: Int + search: String + filter: String + topic: ID boundingBox: [PointInput] ): PostQuerySet } @@ -901,8 +950,8 @@ type SavedSearchQuerySet { } input InappropriateContentInput { - category: String, - reason: String, + category: String + reason: String linkData: LinkDataInput } diff --git a/api/graphql/searchQuerySet.js b/api/graphql/searchQuerySet.js index 998ac52b1..033d16175 100644 --- a/api/graphql/searchQuerySet.js +++ b/api/graphql/searchQuerySet.js @@ -1,29 +1,29 @@ -import { capitalize } from 'lodash' -import { isNull, isUndefined, omitBy } from 'lodash/fp' -import { PAGINATION_TOTAL_COLUMN_NAME } from '../../lib/graphql-bookshelf-bridge/util/applyPagination' +import { capitalize } from "lodash"; +import { isNull, isUndefined, omitBy } from "lodash/fp"; +import { PAGINATION_TOTAL_COLUMN_NAME } from "../../lib/graphql-bookshelf-bridge/util/applyPagination"; -export default function searchQuerySet (searchName, options) { - if (!searchName.startsWith('for')) { - searchName = 'for' + capitalize(searchName) +export default function searchQuerySet(searchName, options) { + if (!searchName.startsWith("for")) { + searchName = "for" + capitalize(searchName); } - return Search[searchName](sanitizeOptions(searchName, options)) + return Search[searchName](sanitizeOptions(searchName, options)); } -export function sanitizeOptions (name, options) { +export function sanitizeOptions(name, options) { if (options.first) { - options.limit = options.first - delete options.first + options.limit = options.first; + delete options.first; } return Object.assign( {}, defaultOptions, - omitBy(x => isNull(x) || isUndefined(x), options) - ) + omitBy((x) => isNull(x) || isUndefined(x), options) + ); } const defaultOptions = { totalColumnName: PAGINATION_TOTAL_COLUMN_NAME, offset: 0, - limit: 100 -} + limit: 100, +}; diff --git a/api/graphql/searchQuerySet.test.js b/api/graphql/searchQuerySet.test.js index 9f06fd64f..10091fb69 100644 --- a/api/graphql/searchQuerySet.test.js +++ b/api/graphql/searchQuerySet.test.js @@ -1,42 +1,44 @@ -import searchQuerySet, { sanitizeOptions } from './searchQuerySet' -import { mockify, unspyify } from '../../test/setup/helpers' +import searchQuerySet, { sanitizeOptions } from "./searchQuerySet"; +import { mockify, unspyify } from "../../test/setup/helpers"; -describe('searchQuerySet', () => { - before(() => mockify(Skill, 'search')) - after(() => unspyify(Skill, 'search')) +describe("searchQuerySet", () => { + before(() => mockify(Skill, "search")); + after(() => unspyify(Skill, "search")); - it('can search for skills', () => { - searchQuerySet('skills', {}) - expect(Skill.search).to.have.been.called() - }) -}) + it("can search for skills", () => { + searchQuerySet("skills", {}); + expect(Skill.search).to.have.been.called(); + }); +}); -describe('sanitizeOptions', () => { - it('sets default options', () => { - expect(sanitizeOptions('forPosts', {})).to.deep.equal({ +describe("sanitizeOptions", () => { + it("sets default options", () => { + expect(sanitizeOptions("forPosts", {})).to.deep.equal({ limit: 100, offset: 0, - totalColumnName: '__total' - }) - }) + totalColumnName: "__total", + }); + }); - it('does not override default options with null or undefined values', () => { - expect(sanitizeOptions('forPosts', { - offset: undefined, foo: false - })) - .to.deep.equal({ + it("does not override default options with null or undefined values", () => { + expect( + sanitizeOptions("forPosts", { + offset: undefined, + foo: false, + }) + ).to.deep.equal({ limit: 100, offset: 0, foo: false, - totalColumnName: '__total' - }) - }) + totalColumnName: "__total", + }); + }); - it('sets limit based on first', () => { - expect(sanitizeOptions('forSkills', {first: 5})).to.deep.equal({ + it("sets limit based on first", () => { + expect(sanitizeOptions("forSkills", { first: 5 })).to.deep.equal({ limit: 5, offset: 0, - totalColumnName: '__total' - }) - }) -}) + totalColumnName: "__total", + }); + }); +}); diff --git a/api/models/Activity.js b/api/models/Activity.js index 0ae4836eb..739b2ca05 100644 --- a/api/models/Activity.js +++ b/api/models/Activity.js @@ -1,295 +1,335 @@ -import { values, omit, filter, includes, isEmpty, get } from 'lodash' - -const isJustNewPost = activity => { - const reasons = activity.get('meta').reasons - return reasons.every(reason => reason.match(/^newPost/)) -} - -const isAnnouncement = activity => { - const reasons = activity.get('meta').reasons - return filter(reasons, reason => reason.match(/^announcement/)).length > 0 -} - -const isTopic = activity => { - const reasons = activity.get('meta').reasons - const t = filter(reasons, reason => reason.match(/^tag/)).length > 0 - return t -} - -const mergeByReader = activities => { - const fields = ['actor_id', 'community_id'] +import { values, omit, filter, includes, isEmpty, get } from "lodash"; + +const isJustNewPost = (activity) => { + const reasons = activity.get("meta").reasons; + return reasons.every((reason) => reason.match(/^newPost/)); +}; + +const isAnnouncement = (activity) => { + const reasons = activity.get("meta").reasons; + return filter(reasons, (reason) => reason.match(/^announcement/)).length > 0; +}; + +const isTopic = (activity) => { + const reasons = activity.get("meta").reasons; + const t = filter(reasons, (reason) => reason.match(/^tag/)).length > 0; + return t; +}; + +const mergeByReader = (activities) => { + const fields = ["actor_id", "community_id"]; const merged = activities.reduce((acc, activity) => { - const current = acc[activity.reader_id] + const current = acc[activity.reader_id]; if (acc[activity.reader_id]) { - fields.forEach(f => { - if (activity[f]) current[f] = activity[f] - }) - current.reasons.push(activity.reason) + fields.forEach((f) => { + if (activity[f]) current[f] = activity[f]; + }); + current.reasons.push(activity.reason); } else { acc[activity.reader_id] = Object.assign( - {reasons: [activity.reason]}, omit(activity, 'reason')) + { reasons: [activity.reason] }, + omit(activity, "reason") + ); } - return acc - }, {}) - return values(merged) -} + return acc; + }, {}); + return values(merged); +}; const removeForRelation = (model) => (id, trx) => { - const trxOpt = {transacting: trx} - return Activity.where(`${model}_id`, id).query() - .pluck('id').transacting(trx) - .then(ids => { - // TODO: New Activity count needs to be decremented - // if inApp medium is used-- see User#decNewNotificationCount - return Notification.where('activity_id', 'in', ids).destroy(trxOpt) - .then(() => Activity.where('id', 'in', ids).destroy(trxOpt)) - }) -} - -module.exports = bookshelf.Model.extend({ - tableName: 'activities', - - actor: function () { - return this.belongsTo(User, 'actor_id') + const trxOpt = { transacting: trx }; + return Activity.where(`${model}_id`, id) + .query() + .pluck("id") + .transacting(trx) + .then((ids) => { + // TODO: New Activity count needs to be decremented + // if inApp medium is used-- see User#decNewNotificationCount + return Notification.where("activity_id", "in", ids) + .destroy(trxOpt) + .then(() => Activity.where("id", "in", ids).destroy(trxOpt)); + }); +}; + +module.exports = bookshelf.Model.extend( + { + tableName: "activities", + + actor: function () { + return this.belongsTo(User, "actor_id"); + }, + + reader: function () { + return this.belongsTo(User, "reader_id"); + }, + + comment: function () { + return this.belongsTo(Comment); + }, + + contribution: function () { + return this.belongsTo(Contribution, "contribution_id"); + }, + + projectContribution: function () { + return this.belongsTo(ProjectContribution, "project_contribution_id"); + }, + + post: function () { + return this.belongsTo(Post); + }, + + parentComment: function () { + return this.belongsTo(Comment, "parent_comment_id"); + }, + + community: function () { + return this.belongsTo(Community); + }, + + notifications: function () { + return this.hasMany(Notification); + }, + + createNotifications: async function (trx) { + const relations = ["reader"]; + if (this.get("post_id")) { + relations.splice(0, 0, "post", "post.communities"); + } + if (this.get("contribution_id")) { + relations.splice( + 0, + 0, + "contribution", + "contribution.post", + "contribution.user" + ); + } + if (this.get("comment_id")) { + relations.splice( + 0, + 0, + "comment", + "comment.post", + "comment.post.communities" + ); + } + if (this.get("community_id")) { + relations.push("community"); + } + await this.load(relations, { transacting: trx }); + const notificationData = await Activity.generateNotificationMedia(this); + + return Promise.map(notificationData, (medium) => + new Notification({ + activity_id: this.id, + created_at: new Date(), + medium, + user_id: this.get("reader_id"), + }).save({}, { transacting: trx }) + ); + }, + + contributionAmount: async function () { + await this.load("projectContribution"); + if (!this.relations.projectContribution) return null; + return this.relations.projectContribution.get("amount"); + }, }, - - reader: function () { - return this.belongsTo(User, 'reader_id') - }, - - comment: function () { - return this.belongsTo(Comment) - }, - - contribution: function () { - return this.belongsTo(Contribution, 'contribution_id') - }, - - projectContribution: function () { - return this.belongsTo(ProjectContribution, 'project_contribution_id') - }, - - post: function () { - return this.belongsTo(Post) - }, - - parentComment: function () { - return this.belongsTo(Comment, 'parent_comment_id') - }, - - community: function () { - return this.belongsTo(Community) - }, - - notifications: function () { - return this.hasMany(Notification) - }, - - createNotifications: async function (trx) { - const relations = ['reader'] - if (this.get('post_id')) { - relations.splice(0, 0, 'post', 'post.communities') - } - if (this.get('contribution_id')) { - relations.splice(0, 0, 'contribution', 'contribution.post', 'contribution.user') - } - if (this.get('comment_id')) { - relations.splice(0, 0, 'comment', 'comment.post', 'comment.post.communities') - } - if (this.get('community_id')) { - relations.push('community') - } - await this.load(relations, {transacting: trx}) - const notificationData = await Activity.generateNotificationMedia(this) - - return Promise.map(notificationData, medium => - new Notification({ - activity_id: this.id, + { + Reason: { + Mention: "mention", // you are mentioned in a post or comment + Comment: "comment", // someone makes a comment on a post you follow + Contribution: "contribution", // someone add you as a contributor to a #request + FollowAdd: "followAdd", // you are added as a follower + Follow: "follow", // someone follows your post + Unfollow: "unfollow", // someone leaves your post + Announcement: "announcement", + }, + + find: function (id, options) { + return this.where({ id }).fetch(options); + }, + + filterInactiveContent: (q) => { + q.whereRaw("(comments.active = true or comments.id is null)").leftJoin( + "comments", + function () { + this.on("comments.id", "=", "activities.comment_id"); + } + ); + + q.whereRaw("(posts.active = true or posts.id is null)").leftJoin( + "posts", + function () { + this.on("posts.id", "=", "activities.post_id"); + } + ); + }, + + joinWithCommunity: (communityId, q) => { + q.where("communities_posts.community_id", communityId).join( + "communities_posts", + function () { + this.on("comments.post_id", "communities_posts.post_id").orOn( + "posts.id", + "communities_posts.post_id" + ); + } + ); + }, + + forComment: function (comment, userId, action) { + if (!action) { + action = includes(comment.mentions(), userId.toString()) + ? this.Reason.Mention + : this.Reason.Comment; + } + + return new Activity({ + reader_id: userId, + actor_id: comment.get("user_id"), + comment_id: comment.id, + post_id: comment.get("post_id"), + action: action, + created_at: comment.get("created_at"), + }); + }, + + forPostMention: function (post, userId) { + return new Activity({ + reader_id: userId, + actor_id: post.get("user_id"), + post_id: post.id, + meta: { reasons: [this.Reason.Mention] }, + created_at: post.get("created_at"), + }); + }, + + forFollowAdd: function (follow, userId) { + return new Activity({ + reader_id: userId, + actor_id: follow.get("added_by_id"), + post_id: follow.get("post_id"), + meta: { reasons: [this.Reason.FollowAdd] }, + created_at: follow.get("added_at"), + }); + }, + + forFollow: function (follow, userId) { + return new Activity({ + reader_id: userId, + actor_id: follow.get("user_id"), + post_id: follow.get("post_id"), + meta: { reasons: [this.Reason.Follow] }, + created_at: follow.get("added_at"), + }); + }, + + forUnfollow: function (post, unfollowerId) { + return new Activity({ + reader_id: post.get("user_id"), + actor_id: unfollowerId, + post_id: post.id, + meta: { reasons: [this.Reason.Unfollow] }, created_at: new Date(), - medium, - user_id: this.get('reader_id') - }).save({}, {transacting: trx})) - }, - - contributionAmount: async function () { - await this.load('projectContribution') - if (!this.relations.projectContribution) return null - return this.relations.projectContribution.get('amount') + }); + }, + + unreadCountForUser: function (user) { + return Activity.query() + .where({ reader_id: user.id, unread: true }) + .count() + .then((rows) => rows[0].count); + }, + + saveForReasonsOpts: function ({ activities }) { + return Activity.saveForReasons(activities); + }, + + saveForReasons: function (activities, trx) { + return Promise.map(mergeByReader(activities), (activity) => { + const attrs = Object.assign({}, omit(activity, "reasons"), { + meta: { reasons: activity.reasons }, + }); + + return Activity.createWithNotifications(attrs, trx); + }).tap(() => Queue.classMethod("Notification", "sendUnsent")); + }, + + communityIds: function (activity) { + if (activity.get("post_id")) { + return get(activity, "relations.post.relations.communities", []).map( + (c) => c.id + ); + } else if (activity.get("comment_id")) { + return get( + activity, + "relations.comment.relations.post.relations.communities", + [] + ).map((c) => c.id); + } else if (activity.get("community_id")) { + return [activity.relations.community.id]; + } + return []; + }, + + generateNotificationMedia: async function (activity) { + const reasons = activity.get("meta").reasons || []; + const isJoinRequestRelated = [ + "approvedJoinRequest", + "joinRequest", + ].includes(reasons[0]); + if (!isJoinRequestRelated) await activity.load("post.communities"); + + // TODO: rename 'notifications' to 'media' + const notifications = []; + const communities = Activity.communityIds(activity); + + const user = activity.relations.reader; + + const memberships = await user + .groupMembershipsForModel(Community) + .fetch({ withRelated: "group" }); + + const relevantMemberships = filter(memberships.models, (mem) => + includes(communities, mem.relations.group.get("group_data_id")) + ); + + const membershipsPermitting = (key) => + filter(relevantMemberships, (mem) => mem.getSetting(key)); + + const emailable = membershipsPermitting("sendEmail"); + const pushable = membershipsPermitting("sendPushNotifications"); + + if ( + (!isEmpty(emailable) && !isJustNewPost(activity)) || + isAnnouncement(activity) + ) { + notifications.push(Notification.MEDIUM.Email); + } + + if (isTopic(activity) || !isEmpty(pushable) || isAnnouncement(activity)) { + notifications.push(Notification.MEDIUM.Push); + } + + if (!isJustNewPost(activity) || isAnnouncement(activity)) { + notifications.push(Notification.MEDIUM.InApp); + } + + return notifications; + }, + + createWithNotifications: function (attributes, trx) { + return new Activity(Object.assign({ created_at: new Date() }, attributes)) + .save({}, { transacting: trx }) + .tap((activity) => activity.createNotifications(trx)); + }, + + removeForComment: removeForRelation("comment"), + + removeForPost: removeForRelation("post"), + + removeForContribution: removeForRelation("contribution"), } - -}, { - Reason: { - Mention: 'mention', // you are mentioned in a post or comment - Comment: 'comment', // someone makes a comment on a post you follow - Contribution: 'contribution', // someone add you as a contributor to a #request - FollowAdd: 'followAdd', // you are added as a follower - Follow: 'follow', // someone follows your post - Unfollow: 'unfollow', // someone leaves your post - Announcement: 'announcement' - }, - - find: function (id, options) { - return this.where({id}).fetch(options) - }, - - filterInactiveContent: q => { - q.whereRaw('(comments.active = true or comments.id is null)') - .leftJoin('comments', function () { - this.on('comments.id', '=', 'activities.comment_id') - }) - - q.whereRaw('(posts.active = true or posts.id is null)') - .leftJoin('posts', function () { - this.on('posts.id', '=', 'activities.post_id') - }) - }, - - joinWithCommunity: (communityId, q) => { - q.where('communities_posts.community_id', communityId) - .join('communities_posts', function () { - this.on('comments.post_id', 'communities_posts.post_id') - .orOn('posts.id', 'communities_posts.post_id') - }) - }, - - forComment: function (comment, userId, action) { - if (!action) { - action = includes(comment.mentions(), userId.toString()) - ? this.Reason.Mention - : this.Reason.Comment - } - - return new Activity({ - reader_id: userId, - actor_id: comment.get('user_id'), - comment_id: comment.id, - post_id: comment.get('post_id'), - action: action, - created_at: comment.get('created_at') - }) - }, - - forPostMention: function (post, userId) { - return new Activity({ - reader_id: userId, - actor_id: post.get('user_id'), - post_id: post.id, - meta: {reasons: [this.Reason.Mention]}, - created_at: post.get('created_at') - }) - }, - - forFollowAdd: function (follow, userId) { - return new Activity({ - reader_id: userId, - actor_id: follow.get('added_by_id'), - post_id: follow.get('post_id'), - meta: {reasons: [this.Reason.FollowAdd]}, - created_at: follow.get('added_at') - }) - }, - - forFollow: function (follow, userId) { - return new Activity({ - reader_id: userId, - actor_id: follow.get('user_id'), - post_id: follow.get('post_id'), - meta: {reasons: [this.Reason.Follow]}, - created_at: follow.get('added_at') - }) - }, - - forUnfollow: function (post, unfollowerId) { - return new Activity({ - reader_id: post.get('user_id'), - actor_id: unfollowerId, - post_id: post.id, - meta: {reasons: [this.Reason.Unfollow]}, - created_at: new Date() - }) - }, - - unreadCountForUser: function (user) { - return Activity.query().where({reader_id: user.id, unread: true}).count() - .then(rows => rows[0].count) - }, - - saveForReasonsOpts: function ({ activities }) { - return Activity.saveForReasons(activities) - }, - - saveForReasons: function (activities, trx) { - return Promise.map(mergeByReader(activities), activity => { - const attrs = Object.assign( - {}, - omit(activity, 'reasons'), - {meta: {reasons: activity.reasons}} - ) - - return Activity.createWithNotifications(attrs, trx) - }) - .tap(() => Queue.classMethod('Notification', 'sendUnsent')) - }, - - communityIds: function (activity) { - if (activity.get('post_id')) { - return get(activity, 'relations.post.relations.communities', []).map(c => c.id) - } else if (activity.get('comment_id')) { - return get(activity, 'relations.comment.relations.post.relations.communities', []).map(c => c.id) - } else if (activity.get('community_id')) { - return [activity.relations.community.id] - } - return [] - }, - - generateNotificationMedia: async function (activity) { - const reasons = activity.get('meta').reasons || [] - const isJoinRequestRelated = ['approvedJoinRequest', 'joinRequest'].includes(reasons[0]) - if (!isJoinRequestRelated) await activity.load('post.communities') - - // TODO: rename 'notifications' to 'media' - var notifications = [] - var communities = Activity.communityIds(activity) - - var user = activity.relations.reader - - const memberships = await user.groupMembershipsForModel(Community) - .fetch({withRelated: 'group'}) - - const relevantMemberships = filter(memberships.models, mem => - includes(communities, mem.relations.group.get('group_data_id'))) - - const membershipsPermitting = key => - filter(relevantMemberships, mem => mem.getSetting(key)) - - const emailable = membershipsPermitting('sendEmail') - const pushable = membershipsPermitting('sendPushNotifications') - - if ((!isEmpty(emailable) && !isJustNewPost(activity)) || isAnnouncement(activity)) { - notifications.push(Notification.MEDIUM.Email) - } - - if (isTopic(activity) || !isEmpty(pushable) || isAnnouncement(activity)) { - notifications.push(Notification.MEDIUM.Push) - } - - if (!isJustNewPost(activity) || isAnnouncement(activity)) { - notifications.push(Notification.MEDIUM.InApp) - } - - return notifications - }, - - createWithNotifications: function (attributes, trx) { - return new Activity(Object.assign({created_at: new Date()}, attributes)) - .save({}, {transacting: trx}) - .tap(activity => activity.createNotifications(trx)) - }, - - removeForComment: removeForRelation('comment'), - - removeForPost: removeForRelation('post'), - - removeForContribution: removeForRelation('contribution') - -}) +); diff --git a/api/models/BlockedUser.js b/api/models/BlockedUser.js index 461a26818..688ec4adc 100644 --- a/api/models/BlockedUser.js +++ b/api/models/BlockedUser.js @@ -1,51 +1,50 @@ /* eslint-disable camelcase */ -module.exports = bookshelf.Model.extend({ - tableName: 'blocked_users', +module.exports = bookshelf.Model.extend( + { + tableName: "blocked_users", - user: function () { - return this.belongsTo(User, 'user_id') - }, - - blockedUser: function () { - return this.belongsTo(User, 'blocked_user_id') - } + user: function () { + return this.belongsTo(User, "user_id"); + }, -}, { - - create: function (userId, blockedUserId) { - if (blockedUserId === User.AXOLOTL_ID) { - throw new Error('cannot block Hylo the Axolotl') - } - - if (userId === blockedUserId) { - throw new Error('blocked_user_id cannot equal user_id') - } - - if (!userId || !blockedUserId) { - throw new Error('must provice a user_id and blocked_user_id') - } - - return this.find(userId, blockedUserId) - .then(existing => { - if (existing) return existing - - return new BlockedUser({ - user_id: userId, - blocked_user_id: blockedUserId, - created_at: new Date(), - updated_at: new Date() - }) - .save() - }) + blockedUser: function () { + return this.belongsTo(User, "blocked_user_id"); + }, }, - - find: function (user_id, blocked_user_id) { - if (!user_id) throw new Error('Parameter user_id must be supplied.') - return BlockedUser.where({user_id, blocked_user_id}).fetch() - }, - - blockedFor: function (userId) { - return bookshelf.knex.raw(` + { + create: function (userId, blockedUserId) { + if (blockedUserId === User.AXOLOTL_ID) { + throw new Error("cannot block Hylo the Axolotl"); + } + + if (userId === blockedUserId) { + throw new Error("blocked_user_id cannot equal user_id"); + } + + if (!userId || !blockedUserId) { + throw new Error("must provice a user_id and blocked_user_id"); + } + + return this.find(userId, blockedUserId).then((existing) => { + if (existing) return existing; + + return new BlockedUser({ + user_id: userId, + blocked_user_id: blockedUserId, + created_at: new Date(), + updated_at: new Date(), + }).save(); + }); + }, + + find: function (user_id, blocked_user_id) { + if (!user_id) throw new Error("Parameter user_id must be supplied."); + return BlockedUser.where({ user_id, blocked_user_id }).fetch(); + }, + + blockedFor: function (userId) { + return bookshelf.knex.raw( + ` SELECT user_id FROM blocked_users WHERE blocked_user_id = ? @@ -53,6 +52,9 @@ module.exports = bookshelf.Model.extend({ SELECT blocked_user_id FROM blocked_users WHERE user_id = ? - `, [userId, userId]) + `, + [userId, userId] + ); + }, } -}) +); diff --git a/api/models/Comment.js b/api/models/Comment.js index eadf2aca3..9212b5fbf 100644 --- a/api/models/Comment.js +++ b/api/models/Comment.js @@ -1,144 +1,159 @@ /* eslint-disable camelcase */ -import { markdown } from 'hylo-utils/text' -import { notifyAboutMessage, sendDigests } from './comment/notifications' -import EnsureLoad from './mixins/EnsureLoad' - -module.exports = bookshelf.Model.extend(Object.assign({ - tableName: 'comments', - - user: function () { - return this.belongsTo(User) - }, - - post: function () { - return this.belongsTo(Post) - }, - - text: function () { - return this.get('text') - }, - - mentions: function () { - return RichText.getUserMentions(this.text()) - }, - - thanks: function () { - return this.hasMany(Thank) - }, - - community: async function () { - await this.relations.post.load(['communities']) - return this.relations.post.relations.communities.first() - }, - - tags: function () { - return this.belongsToMany(Tag).through(CommentTag) - }, - - activities: function () { - return this.hasMany(Activity) - }, - - comment: function () { - return this.belongsTo(Comment) - }, - - comments: function () { - return this.hasMany(Comment, 'comment_id').query({where: {active: true}}) - }, - - media: function (type) { - const relation = this.hasMany(Media) - return type ? relation.query({where: {type}}) : relation - }, - - getTagsInComments: function (opts) { - // this is part of the 'taggable' interface, shared with Post - return Promise.resolve([]) - }, - - createActivities: async function (trx) { - var toLoad = ['post'] - - await this.ensureLoad(toLoad) - const actorId = this.get('user_id') - const followers = await this.relations.post.followers().fetch() - const communityId = await this.community().id - const mentionedIds = RichText.getUserMentions(this.get('text')) - - const createActivity = reason => id => ({ - reader_id: id, - actor_id: actorId, - comment_id: this.id, - parent_comment_id: this.get('comment_id') || null, - post_id: this.relations.post.id, - community_id: communityId, - reason - }) - - const newCommentActivities = followers - .filter(u => u.id !== actorId) - .map(u => u.id) - .map(createActivity('newComment')) - - const mentionActivities = mentionedIds - .filter(u => u.id !== actorId) - .map(createActivity('commentMention')) - - return Activity.saveForReasons( - newCommentActivities.concat(mentionActivities), trx) - } -}, EnsureLoad), { - - find: function (id, options) { - return Comment.where({id: id}).fetch(options) - }, - - createdInTimeRange: function (collection, startTime, endTime) { - if (endTime === undefined) { - endTime = startTime - startTime = collection - collection = Comment - } - - return collection.query(function (qb) { - qb.whereRaw('comments.created_at between ? and ?', [startTime, endTime]) - qb.where('comments.active', true) - }) - }, - - cleanEmailText: (user, text, opts = { useMarkdown: true }) => { - text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n') - const name = user.get('name').toLowerCase() - const lines = text.split('\n') - - var cutoff - lines.forEach((line, index) => { - if (cutoff) return - line = line.trim() - - if (line.length > 0 && name.startsWith(line.toLowerCase().replace(/^[- ]*/, ''))) { - // line contains only the user's name - cutoff = index - // also remove the common pattern of two dashes above the name - if (index > 0 && lines[index - 1].match(/^-- ?$/)) { - cutoff = index - 1 - } - } else if (line.match(/^-{8}/)) { - // line contains our divider, possibly followed by the text, "Only text - // above the dashed line will be included." - cutoff = index - } else if (line.match(/(-{4,}.*dashed.line.*$)/)) { - // line contains the divider at the end - cutoff = index + 1 - lines[index] = line.replace(/(-{4,}.*dashed.line.*$)/, '') +import { markdown } from "hylo-utils/text"; +import { notifyAboutMessage, sendDigests } from "./comment/notifications"; +import EnsureLoad from "./mixins/EnsureLoad"; + +module.exports = bookshelf.Model.extend( + Object.assign( + { + tableName: "comments", + + user: function () { + return this.belongsTo(User); + }, + + post: function () { + return this.belongsTo(Post); + }, + + text: function () { + return this.get("text"); + }, + + mentions: function () { + return RichText.getUserMentions(this.text()); + }, + + thanks: function () { + return this.hasMany(Thank); + }, + + community: async function () { + await this.relations.post.load(["communities"]); + return this.relations.post.relations.communities.first(); + }, + + tags: function () { + return this.belongsToMany(Tag).through(CommentTag); + }, + + activities: function () { + return this.hasMany(Activity); + }, + + comment: function () { + return this.belongsTo(Comment); + }, + + comments: function () { + return this.hasMany(Comment, "comment_id").query({ + where: { active: true }, + }); + }, + + media: function (type) { + const relation = this.hasMany(Media); + return type ? relation.query({ where: { type } }) : relation; + }, + + getTagsInComments: function (opts) { + // this is part of the 'taggable' interface, shared with Post + return Promise.resolve([]); + }, + + createActivities: async function (trx) { + const toLoad = ["post"]; + + await this.ensureLoad(toLoad); + const actorId = this.get("user_id"); + const followers = await this.relations.post.followers().fetch(); + const communityId = await this.community().id; + const mentionedIds = RichText.getUserMentions(this.get("text")); + + const createActivity = (reason) => (id) => ({ + reader_id: id, + actor_id: actorId, + comment_id: this.id, + parent_comment_id: this.get("comment_id") || null, + post_id: this.relations.post.id, + community_id: communityId, + reason, + }); + + const newCommentActivities = followers + .filter((u) => u.id !== actorId) + .map((u) => u.id) + .map(createActivity("newComment")); + + const mentionActivities = mentionedIds + .filter((u) => u.id !== actorId) + .map(createActivity("commentMention")); + + return Activity.saveForReasons( + newCommentActivities.concat(mentionActivities), + trx + ); + }, + }, + EnsureLoad + ), + { + find: function (id, options) { + return Comment.where({ id: id }).fetch(options); + }, + + createdInTimeRange: function (collection, startTime, endTime) { + if (endTime === undefined) { + endTime = startTime; + startTime = collection; + collection = Comment; } - }) - const finalText = cutoff ? lines.slice(0, cutoff).join('\n') : text - return opts.useMarkdown ? markdown(finalText || '') : finalText - }, + return collection.query(function (qb) { + qb.whereRaw("comments.created_at between ? and ?", [ + startTime, + endTime, + ]); + qb.where("comments.active", true); + }); + }, + + cleanEmailText: (user, text, opts = { useMarkdown: true }) => { + text = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + const name = user.get("name").toLowerCase(); + const lines = text.split("\n"); + + let cutoff; + lines.forEach((line, index) => { + if (cutoff) return; + line = line.trim(); + + if ( + line.length > 0 && + name.startsWith(line.toLowerCase().replace(/^[- ]*/, "")) + ) { + // line contains only the user's name + cutoff = index; + // also remove the common pattern of two dashes above the name + if (index > 0 && lines[index - 1].match(/^-- ?$/)) { + cutoff = index - 1; + } + } else if (line.match(/^-{8}/)) { + // line contains our divider, possibly followed by the text, "Only text + // above the dashed line will be included." + cutoff = index; + } else if (line.match(/(-{4,}.*dashed.line.*$)/)) { + // line contains the divider at the end + cutoff = index + 1; + lines[index] = line.replace(/(-{4,}.*dashed.line.*$)/, ""); + } + }); - notifyAboutMessage, - sendDigests -}) + const finalText = cutoff ? lines.slice(0, cutoff).join("\n") : text; + return opts.useMarkdown ? markdown(finalText || "") : finalText; + }, + + notifyAboutMessage, + sendDigests, + } +); diff --git a/api/models/CommentTag.js b/api/models/CommentTag.js index 0aa1e23a2..3ee969398 100644 --- a/api/models/CommentTag.js +++ b/api/models/CommentTag.js @@ -1,11 +1,11 @@ module.exports = bookshelf.Model.extend({ - tableName: 'comments_tags', + tableName: "comments_tags", comment: function () { - return this.belongsTo(Comment) + return this.belongsTo(Comment); }, tag: function () { - return this.belongsTo(Tag) - } -}) + return this.belongsTo(Tag); + }, +}); diff --git a/api/models/Community.js b/api/models/Community.js index 059852896..4251eb85f 100644 --- a/api/models/Community.js +++ b/api/models/Community.js @@ -1,345 +1,448 @@ /* globals NexudusAccount */ -import Slack from '../services/Slack' -import randomstring from 'randomstring' -import HasSettings from './mixins/HasSettings' -import HasGroup from './mixins/HasGroup' -import { clone, flatten, isEqual, merge, pick, trim, defaults } from 'lodash' -import { applyPagination } from '../../lib/graphql-bookshelf-bridge/util' -import { COMMUNITY_AVATAR, COMMUNITY_BANNER } from '../../lib/uploader/types' - -const DEFAULT_BANNER = 'https://d3ngex8q79bk55.cloudfront.net/misc/default_community_banner.jpg' -const DEFAULT_AVATAR = 'https://d3ngex8q79bk55.cloudfront.net/misc/default_community_avatar.png' - -module.exports = bookshelf.Model.extend(merge({ - tableName: 'communities', - - creator: function () { - return this.belongsTo(User, 'created_by_id') - }, - - inactiveUsers: function () { - return this.belongsToMany(User, 'communities_users', 'community_id', 'user_id') - .query({where: {'communities_users.active': false}}) - }, - - invitations: function () { - return this.hasMany(Invitation) - }, - - leader: function () { - return this.belongsTo(User, 'leader_id') - }, - - locationObject: function () { - return this.belongsTo(Location, 'location_id') - }, - - tagFollows: function () { - return this.hasMany(TagFollow) - }, - - moderators: function () { - return this.groupMembers({role: GroupMembership.Role.MODERATOR}) - }, - - network: function () { - return this.belongsTo(Network) - }, - - users: function () { - return this.groupMembersWithPivots() - }, - - posts: function () { - return this.belongsToMany(Post).through(PostMembership) - .query({where: {'posts.active': true}}) - }, - - tags: function () { - return this.belongsToMany(Tag).through(CommunityTag).withPivot(['user_id', 'description']) - }, - - communityTags: function () { - return this.hasMany(CommunityTag) - }, - - comments: function () { - var communityId = this.id - return Comment.collection().query(q => { - q.where({ - 'communities_posts.community_id': communityId, - 'comments.active': true - }) - q.join('communities_posts', 'communities_posts.post_id', 'comments.post_id') - }) - }, - - skills: function () { - return Skill.collection().query(q => { - q.where({ - 'communities_users.community_id': this.id, - 'communities_users.active': true - }) - q.join('skills_users', 'skills_users.skill_id', 'skills.id') - q.join('communities_users', 'communities_users.user_id', 'skills_users.user_id') - }) - }, - - nexudusAccounts: function () { - this.hasMany(NexudusAccount) - }, - - createStarterPosts: function (transacting) { - var now = new Date() - var timeShift = {offer: 1, request: 2, resource: 1} - return Community.find('starter-posts', {withRelated: ['posts']}) - .tap(c => { - if (!c) throw new Error('Starter posts community not found') - }) - .then(c => c.relations.posts.models) - .then(posts => Promise.map(posts, post => { - if (post.get('type') === 'welcome') return - - var newPost = post.copy() - var time = new Date(now - (timeShift[post.get('type')] || 0) * 1000) - return newPost.save({created_at: time, updated_at: time}, {transacting}) - .then(() => Promise.all(flatten([ - this.posts().attach(newPost, {transacting}), - post.followers().fetch().then(followers => - newPost.addFollowers(followers.map(f => f.id), {transacting})) - ]))) - })) - }, - - updateChecklist: function () { - const { checklist } = this.get('settings') || {} - const completed = { - logo: true, banner: true, invite: true, topics: true, post: true - } - if (isEqual(checklist, completed)) return Promise.resolve(this) - - return this.load([ - {posts: q => q.limit(2)}, - {tags: q => q.limit(4)}, - {invitations: q => q.limit(1)} - ]) - .then(() => { - const { invitations, posts, tags } = this.relations - - const updatedChecklist = { - logo: this.get('avatar_url') !== DEFAULT_AVATAR, - banner: this.get('banner_url') !== DEFAULT_BANNER, - invite: invitations.length > 0, - topics: tags.length > 0, - post: !!posts.find(p => p.get('user_id') !== User.AXOLOTL_ID) - } - - return isEqual(checklist, updatedChecklist) - ? Promise.resolve(this) - : this.addSetting({checklist: updatedChecklist}, true) - }) - }, - - memberCount: function () { - return this.users().fetch().then(x => x.length) - }, - - numMembers: function () { - // FIXME investigate why num_members is not always accurate - // then remove memberCount and use num_members - return this.get('num_members') - }, - - addMembers: async function (userIds, opts) { - return this.addGroupMembers(userIds, {}, opts) - }, - - postCount: function () { - return Post.query(q => { - q.select(bookshelf.knex.raw('count(*)')) - q.join('communities_posts', 'posts.id', 'communities_posts.post_id') - q.where({'communities_posts.community_id': this.id, 'active': true}) - }) - .fetch() - .then(result => result.get('count')) - }, - - feedItems: function ({ first, cursor, order }) { - return this.posts().query(q => { - applyPagination(q, 'posts', { first, cursor, order }) - }).fetch().then(posts => - posts.map(p => createFeedItem({post: p}))) - }, - - update: function (changes) { - var whitelist = [ - 'banner_url', 'avatar_url', 'name', 'description', 'settings', - 'welcome_message', 'leader_id', 'beta_access_code', 'location', 'location_id', - 'slack_hook_url', 'slack_team', 'slack_configure_url', 'active', 'is_public', - 'is_auto_joinable', 'public_member_directory' - ] - - const attributes = pick(changes, whitelist) - const saneAttrs = clone(attributes) - - if (attributes.settings) { - saneAttrs.settings = merge({}, this.get('settings'), attributes.settings) - } - - this.set(saneAttrs) - return this.validate().then(() => this.save()) - }, - - updateHidden: function (hidden) { - return this.save({hidden}) - }, - - validate: function () { - if (!trim(this.get('name'))) { - return Promise.reject(new Error('Name cannot be blank')) - } - - return Promise.resolve() - }, - - reconcileNumMembers: async function () { - // FIXME this is not ideal, but the simple `.count()` methods don't work - // here because of the where clauses on join tables in `this.users` - const count = await this.users().fetch().then(x => x.length) - return this.save({num_members: count}, {patch: true}) - } +import Slack from "../services/Slack"; +import randomstring from "randomstring"; +import HasSettings from "./mixins/HasSettings"; +import HasGroup from "./mixins/HasGroup"; +import { clone, flatten, isEqual, merge, pick, trim, defaults } from "lodash"; +import { applyPagination } from "../../lib/graphql-bookshelf-bridge/util"; +import { COMMUNITY_AVATAR, COMMUNITY_BANNER } from "../../lib/uploader/types"; + +const DEFAULT_BANNER = + "https://d3ngex8q79bk55.cloudfront.net/misc/default_community_banner.jpg"; +const DEFAULT_AVATAR = + "https://d3ngex8q79bk55.cloudfront.net/misc/default_community_avatar.png"; + +module.exports = bookshelf.Model.extend( + merge( + { + tableName: "communities", + + creator: function () { + return this.belongsTo(User, "created_by_id"); + }, + + inactiveUsers: function () { + return this.belongsToMany( + User, + "communities_users", + "community_id", + "user_id" + ).query({ where: { "communities_users.active": false } }); + }, + + invitations: function () { + return this.hasMany(Invitation); + }, + + leader: function () { + return this.belongsTo(User, "leader_id"); + }, + + locationObject: function () { + return this.belongsTo(Location, "location_id"); + }, + + tagFollows: function () { + return this.hasMany(TagFollow); + }, + + moderators: function () { + return this.groupMembers({ role: GroupMembership.Role.MODERATOR }); + }, + + network: function () { + return this.belongsTo(Network); + }, + + users: function () { + return this.groupMembersWithPivots(); + }, + + posts: function () { + return this.belongsToMany(Post) + .through(PostMembership) + .query({ where: { "posts.active": true } }); + }, + + tags: function () { + return this.belongsToMany(Tag) + .through(CommunityTag) + .withPivot(["user_id", "description"]); + }, + + communityTags: function () { + return this.hasMany(CommunityTag); + }, + + comments: function () { + const communityId = this.id; + return Comment.collection().query((q) => { + q.where({ + "communities_posts.community_id": communityId, + "comments.active": true, + }); + q.join( + "communities_posts", + "communities_posts.post_id", + "comments.post_id" + ); + }); + }, + + skills: function () { + return Skill.collection().query((q) => { + q.where({ + "communities_users.community_id": this.id, + "communities_users.active": true, + }); + q.join("skills_users", "skills_users.skill_id", "skills.id"); + q.join( + "communities_users", + "communities_users.user_id", + "skills_users.user_id" + ); + }); + }, + + nexudusAccounts: function () { + this.hasMany(NexudusAccount); + }, + + createStarterPosts: function (transacting) { + const now = new Date(); + const timeShift = { offer: 1, request: 2, resource: 1 }; + return Community.find("starter-posts", { withRelated: ["posts"] }) + .tap((c) => { + if (!c) throw new Error("Starter posts community not found"); + }) + .then((c) => c.relations.posts.models) + .then((posts) => + Promise.map(posts, (post) => { + if (post.get("type") === "welcome") return; + + const newPost = post.copy(); + const time = new Date( + now - (timeShift[post.get("type")] || 0) * 1000 + ); + return newPost + .save({ created_at: time, updated_at: time }, { transacting }) + .then(() => + Promise.all( + flatten([ + this.posts().attach(newPost, { transacting }), + post + .followers() + .fetch() + .then((followers) => + newPost.addFollowers( + followers.map((f) => f.id), + { transacting } + ) + ), + ]) + ) + ); + }) + ); + }, + + updateChecklist: function () { + const { checklist } = this.get("settings") || {}; + const completed = { + logo: true, + banner: true, + invite: true, + topics: true, + post: true, + }; + if (isEqual(checklist, completed)) return Promise.resolve(this); + + return this.load([ + { posts: (q) => q.limit(2) }, + { tags: (q) => q.limit(4) }, + { invitations: (q) => q.limit(1) }, + ]).then(() => { + const { invitations, posts, tags } = this.relations; + + const updatedChecklist = { + logo: this.get("avatar_url") !== DEFAULT_AVATAR, + banner: this.get("banner_url") !== DEFAULT_BANNER, + invite: invitations.length > 0, + topics: tags.length > 0, + post: !!posts.find((p) => p.get("user_id") !== User.AXOLOTL_ID), + }; + + return isEqual(checklist, updatedChecklist) + ? Promise.resolve(this) + : this.addSetting({ checklist: updatedChecklist }, true); + }); + }, + + memberCount: function () { + return this.users() + .fetch() + .then((x) => x.length); + }, + + numMembers: function () { + // FIXME investigate why num_members is not always accurate + // then remove memberCount and use num_members + return this.get("num_members"); + }, + + addMembers: async function (userIds, opts) { + return this.addGroupMembers(userIds, {}, opts); + }, + + postCount: function () { + return Post.query((q) => { + q.select(bookshelf.knex.raw("count(*)")); + q.join("communities_posts", "posts.id", "communities_posts.post_id"); + q.where({ "communities_posts.community_id": this.id, active: true }); + }) + .fetch() + .then((result) => result.get("count")); + }, + + feedItems: function ({ first, cursor, order }) { + return this.posts() + .query((q) => { + applyPagination(q, "posts", { first, cursor, order }); + }) + .fetch() + .then((posts) => posts.map((p) => createFeedItem({ post: p }))); + }, + + update: function (changes) { + const whitelist = [ + "banner_url", + "avatar_url", + "name", + "description", + "settings", + "welcome_message", + "leader_id", + "beta_access_code", + "location", + "location_id", + "slack_hook_url", + "slack_team", + "slack_configure_url", + "active", + "is_public", + "is_auto_joinable", + "public_member_directory", + ]; + + const attributes = pick(changes, whitelist); + const saneAttrs = clone(attributes); + + if (attributes.settings) { + saneAttrs.settings = merge( + {}, + this.get("settings"), + attributes.settings + ); + } + + this.set(saneAttrs); + return this.validate().then(() => this.save()); + }, + + updateHidden: function (hidden) { + return this.save({ hidden }); + }, -}, HasSettings, HasGroup), { - find: function (key, opts = {}) { - if (!key) return Promise.resolve(null) - - let where = isNaN(Number(key)) - ? (opts.active ? {slug: key, active: true} : {slug: key}) - : (opts.active ? {id: key, active: true} : {id: key}) - return this.where(where).fetch(opts) - }, - - findActive: function (key, opts = {}) { - return this.find(key, merge({active: true}, opts)) - }, - - queryByAccessCode: function (accessCode) { - return this.query(qb => { - qb.whereRaw('lower(beta_access_code) = lower(?)', accessCode) - qb.where('active', true) - }) - }, - - copyAssets: function (opts) { - return Community.find(opts.communityId).then(c => Promise.join( - AssetManagement.copyAsset(c, COMMUNITY_AVATAR, 'avatar_url'), - AssetManagement.copyAsset(c, COMMUNITY_BANNER, 'banner_url') - )) - }, - - deactivate: function (communityId) { - return bookshelf.transaction(trx => - Promise.join( - Community.where('id', communityId).query() - .update({active: false}).transacting(trx), - Group.deactivate(communityId, Community, {transacting: trx}) - ) - ) - }, - - notifyAboutCreate: function (opts) { - return Community.find(opts.communityId, {withRelated: ['creator']}) - .then(c => { - var creator = c.relations.creator - var recipient = process.env.ASANA_NEW_COMMUNITIES_EMAIL - return Email.sendRawEmail(recipient, { - subject: c.get('name'), - body: `Community - ${c.get('name')} + validate: function () { + if (!trim(this.get("name"))) { + return Promise.reject(new Error("Name cannot be blank")); + } + + return Promise.resolve(); + }, + + reconcileNumMembers: async function () { + // FIXME this is not ideal, but the simple `.count()` methods don't work + // here because of the where clauses on join tables in `this.users` + const count = await this.users() + .fetch() + .then((x) => x.length); + return this.save({ num_members: count }, { patch: true }); + }, + }, + HasSettings, + HasGroup + ), + { + find: function (key, opts = {}) { + if (!key) return Promise.resolve(null); + + const where = isNaN(Number(key)) + ? opts.active + ? { slug: key, active: true } + : { slug: key } + : opts.active + ? { id: key, active: true } + : { id: key }; + return this.where(where).fetch(opts); + }, + + findActive: function (key, opts = {}) { + return this.find(key, merge({ active: true }, opts)); + }, + + queryByAccessCode: function (accessCode) { + return this.query((qb) => { + qb.whereRaw("lower(beta_access_code) = lower(?)", accessCode); + qb.where("active", true); + }); + }, + + copyAssets: function (opts) { + return Community.find(opts.communityId).then((c) => + Promise.join( + AssetManagement.copyAsset(c, COMMUNITY_AVATAR, "avatar_url"), + AssetManagement.copyAsset(c, COMMUNITY_BANNER, "banner_url") + ) + ); + }, + + deactivate: function (communityId) { + return bookshelf.transaction((trx) => + Promise.join( + Community.where("id", communityId) + .query() + .update({ active: false }) + .transacting(trx), + Group.deactivate(communityId, Community, { transacting: trx }) + ) + ); + }, + + notifyAboutCreate: function (opts) { + return Community.find(opts.communityId, { + withRelated: ["creator"], + }).then((c) => { + const creator = c.relations.creator; + const recipient = process.env.ASANA_NEW_COMMUNITIES_EMAIL; + return Email.sendRawEmail( + recipient, + { + subject: c.get("name"), + body: `Community + ${c.get("name")} Email - ${creator.get('email')} + ${creator.get("email")} First Name - ${creator.get('name').split(' ')[0]} + ${creator.get("name").split(" ")[0]} Last Name - ${creator.get('name').split(' ').slice(1).join(' ')} + ${creator.get("name").split(" ").slice(1).join(" ")} Community URL ${Frontend.Route.community(c)} Creator URL ${Frontend.Route.profile(creator)} - `.replace(/^\s+/gm, '').replace(/\n/g, '
\n') - }, { - sender: { - name: 'Hylobot', - address: 'dev+bot@hylo.com' - } - }) - }) - }, - - inNetworkWithUser: function (communityId, userId) { - return Community.query().where('id', communityId).pluck('network_id') - .then(([ networkId ]) => - networkId && Network.containsUser(networkId, userId)) - }, - - notifySlack: function (communityId, post) { - return Community.find(communityId) - .then(community => { - if (!community || !community.get('slack_hook_url')) return - var slackMessage = Slack.textForNewPost(post, community) - return Slack.send(slackMessage, community.get('slack_hook_url')) - }) - }, - - getNewAccessCode: function () { - const test = code => Community.where({beta_access_code: code}).count().then(Number) - const loop = () => { - const code = randomstring.generate({length: 10, charset: 'alphanumeric'}) - return test(code).then(count => count ? loop() : code) - } - return loop() - }, - - async create (userId, data) { - var attrs = defaults( - pick(data, - 'name', 'description', 'slug', 'category', - 'beta_access_code', 'banner_url', 'avatar_url', 'location_id', 'location', 'network_id'), - {'banner_url': DEFAULT_BANNER, 'avatar_url': DEFAULT_AVATAR}) - - // eslint-disable-next-line camelcase - const beta_access_code = attrs.beta_access_code || - await Community.getNewAccessCode() - - const community = new Community(merge(attrs, { - beta_access_code, - created_at: new Date(), - created_by_id: userId, - settings: {post_prompt_day: 0} - })) - - const memberships = await bookshelf.transaction(async trx => { - await community.save(null, {transacting: trx}) - await community.createStarterPosts(trx) - return community.addGroupMembers([userId], - {role: GroupMembership.Role.MODERATOR}, {transacting: trx}) - }) - - await Queue.classMethod('Community', 'notifyAboutCreate', - {communityId: community.id}) - - return memberships[0] - }, - - isSlugValid: function (slug) { - const regex = /^[0-9a-z-]{2,40}$/ - return regex.test(slug) + ` + .replace(/^\s+/gm, "") + .replace(/\n/g, "
\n"), + }, + { + sender: { + name: "Hylobot", + address: "dev+bot@hylo.com", + }, + } + ); + }); + }, + + inNetworkWithUser: function (communityId, userId) { + return Community.query() + .where("id", communityId) + .pluck("network_id") + .then( + ([networkId]) => networkId && Network.containsUser(networkId, userId) + ); + }, + + notifySlack: function (communityId, post) { + return Community.find(communityId).then((community) => { + if (!community || !community.get("slack_hook_url")) return; + const slackMessage = Slack.textForNewPost(post, community); + return Slack.send(slackMessage, community.get("slack_hook_url")); + }); + }, + + getNewAccessCode: function () { + const test = (code) => + Community.where({ beta_access_code: code }).count().then(Number); + const loop = () => { + const code = randomstring.generate({ + length: 10, + charset: "alphanumeric", + }); + return test(code).then((count) => (count ? loop() : code)); + }; + return loop(); + }, + + async create(userId, data) { + const attrs = defaults( + pick( + data, + "name", + "description", + "slug", + "category", + "beta_access_code", + "banner_url", + "avatar_url", + "location_id", + "location", + "network_id" + ), + { banner_url: DEFAULT_BANNER, avatar_url: DEFAULT_AVATAR } + ); + + // eslint-disable-next-line camelcase + const beta_access_code = + attrs.beta_access_code || (await Community.getNewAccessCode()); + + const community = new Community( + merge(attrs, { + beta_access_code, + created_at: new Date(), + created_by_id: userId, + settings: { post_prompt_day: 0 }, + }) + ); + + const memberships = await bookshelf.transaction(async (trx) => { + await community.save(null, { transacting: trx }); + await community.createStarterPosts(trx); + return community.addGroupMembers( + [userId], + { role: GroupMembership.Role.MODERATOR }, + { transacting: trx } + ); + }); + + await Queue.classMethod("Community", "notifyAboutCreate", { + communityId: community.id, + }); + + return memberships[0]; + }, + + isSlugValid: function (slug) { + const regex = /^[0-9a-z-]{2,40}$/; + return regex.test(slug); + }, } -}) +); -function createFeedItem ({ post }) { +function createFeedItem({ post }) { return { - type: 'post', - content: post - } + type: "post", + content: post, + }; } diff --git a/api/models/CommunityTag.js b/api/models/CommunityTag.js index 48e3d6ad5..7412517c5 100644 --- a/api/models/CommunityTag.js +++ b/api/models/CommunityTag.js @@ -1,84 +1,105 @@ /* eslint-disable camelcase */ -module.exports = bookshelf.Model.extend({ - tableName: 'communities_tags', - - owner: function () { - return this.belongsTo(User, 'user_id') - }, - - community: function () { - return this.belongsTo(Community) - }, - - tag: function () { - return this.belongsTo(Tag) - }, - - tagFollow: function (userId) { - return TagFollow.query(q => { - q.where({ - user_id: userId, - community_id: this.get('community_id'), - tag_id: this.get('tag_id') - }) - }) - }, - - isFollowed: function (userId) { - return this.tagFollow(userId).count().then(count => Number(count) > 0) - }, - - newPostCount: function (userId) { - return this.tagFollow(userId).query().select('new_post_count') - .then(rows => rows.length > 0 ? rows[0].new_post_count : 0) - }, - - postCount: function () { - return CommunityTag.taggedPostCount(this.get('community_id'), this.get('tag_id')) +module.exports = bookshelf.Model.extend( + { + tableName: "communities_tags", + + owner: function () { + return this.belongsTo(User, "user_id"); + }, + + community: function () { + return this.belongsTo(Community); + }, + + tag: function () { + return this.belongsTo(Tag); + }, + + tagFollow: function (userId) { + return TagFollow.query((q) => { + q.where({ + user_id: userId, + community_id: this.get("community_id"), + tag_id: this.get("tag_id"), + }); + }); + }, + + isFollowed: function (userId) { + return this.tagFollow(userId) + .count() + .then((count) => Number(count) > 0); + }, + + newPostCount: function (userId) { + return this.tagFollow(userId) + .query() + .select("new_post_count") + .then((rows) => (rows.length > 0 ? rows[0].new_post_count : 0)); + }, + + postCount: function () { + return CommunityTag.taggedPostCount( + this.get("community_id"), + this.get("tag_id") + ); + }, + + followerCount: function () { + return Tag.followersCount(this.get("tag_id"), { + communityId: this.get("community_id"), + }); + }, + + consolidateFollowerCount: function () { + return this.followerCount().then((num_followers) => { + if (num_followers === this.get("num_followers")) + return Promise.resolve(); + return this.save({ num_followers }); + }); + }, }, - - followerCount: function () { - return Tag.followersCount(this.get('tag_id'), { communityId: this.get('community_id') }) - }, - - consolidateFollowerCount: function () { - return this.followerCount() - .then(num_followers => { - if (num_followers === this.get('num_followers')) return Promise.resolve() - return this.save({num_followers}) - }) + { + create(attrs, { transacting } = {}) { + return this.forge( + Object.assign({ created_at: new Date(), updated_at: new Date() }, attrs) + ).save({}, { transacting }); + }, + + taggedPostCount(communityId, tagId) { + return bookshelf + .knex("posts_tags") + .join("posts", "posts.id", "posts_tags.post_id") + .join( + "communities_posts", + "communities_posts.post_id", + "posts_tags.post_id" + ) + .where("posts.active", true) + .where({ community_id: communityId, tag_id: tagId }) + .count() + .then((rows) => Number(rows[0].count)); + }, + + defaults(communityId, trx) { + return CommunityTag.where({ + community_id: communityId, + is_default: true, + }).fetchAll({ withRelated: "tag", transacting: trx }); + }, + + findByTagAndCommunity(topicName, communitySlug) { + return CommunityTag.query((q) => { + q.join( + "communities", + "communities.id", + "communities_tags.community_id" + ); + q.where("communities.slug", communitySlug); + q.join("tags", "tags.id", "communities_tags.tag_id"); + q.where("tags.name", topicName); + }).fetch(); + }, } - -}, { - - create (attrs, { transacting } = {}) { - return this.forge(Object.assign({created_at: new Date(), updated_at: new Date()}, attrs)) - .save({}, {transacting}) - }, - - taggedPostCount (communityId, tagId) { - return bookshelf.knex('posts_tags') - .join('posts', 'posts.id', 'posts_tags.post_id') - .join('communities_posts', 'communities_posts.post_id', 'posts_tags.post_id') - .where('posts.active', true) - .where({community_id: communityId, tag_id: tagId}) - .count() - .then(rows => Number(rows[0].count)) - }, - - defaults (communityId, trx) { - return CommunityTag.where({community_id: communityId, is_default: true}) - .fetchAll({withRelated: 'tag', transacting: trx}) - }, - - findByTagAndCommunity (topicName, communitySlug) { - return CommunityTag.query(q => { - q.join('communities', 'communities.id', 'communities_tags.community_id') - q.where('communities.slug', communitySlug) - q.join('tags', 'tags.id', 'communities_tags.tag_id') - q.where('tags.name', topicName) - }).fetch() - } - -}) +); diff --git a/api/models/Contribution.js b/api/models/Contribution.js index 78a421ae4..91105f3f0 100644 --- a/api/models/Contribution.js +++ b/api/models/Contribution.js @@ -1,71 +1,86 @@ -module.exports = bookshelf.Model.extend({ - tableName: 'contributions', +module.exports = bookshelf.Model.extend( + { + tableName: "contributions", - post: function () { - return this.belongsTo(Post, 'post_id') - }, - - user: function () { - return this.belongsTo(User, 'user_id').query({where: {active: true}}) - }, + post: function () { + return this.belongsTo(Post, "post_id"); + }, - createActivities: function (trx) { - return this.load(['post']) - .then(() => { - const contribution = { - reader_id: this.get('user_id'), - contribution_id: this.id, - post_id: this.relations.post.id, - actor_id: this.relations.post.get('user_id'), - reason: 'newContribution' - } - return Activity.saveForReasons([contribution], trx) - }) - } -}, { - find: (id, options) => Contribution.where({id}).fetch(options), + user: function () { + return this.belongsTo(User, "user_id").query({ where: { active: true } }); + }, - create: function(user_id, post_id, trx) { - return new Contribution({post_id, user_id, contributed_at: new Date()}) - .save(null, {transacting: trx}) - .then((contribution) => - Queue.classMethod('Contribution', 'createActivities', { - contributionId: contribution.id - })) + createActivities: function (trx) { + return this.load(["post"]).then(() => { + const contribution = { + reader_id: this.get("user_id"), + contribution_id: this.id, + post_id: this.relations.post.id, + actor_id: this.relations.post.get("user_id"), + reason: "newContribution", + }; + return Activity.saveForReasons([contribution], trx); + }); + }, }, + { + find: (id, options) => Contribution.where({ id }).fetch(options), - queryForUser: function (userId, communityIds) { - return Contribution.query(q => { - q.orderBy('contributed_at') - q.join('posts', 'posts.id', '=', 'contributions.post_id') + create: function (user_id, post_id, trx) { + return new Contribution({ post_id, user_id, contributed_at: new Date() }) + .save(null, { transacting: trx }) + .then((contribution) => + Queue.classMethod("Contribution", "createActivities", { + contributionId: contribution.id, + }) + ); + }, - q.where({'contributions.user_id': userId, 'posts.active': true}) + queryForUser: function (userId, communityIds) { + return Contribution.query((q) => { + q.orderBy("contributed_at"); + q.join("posts", "posts.id", "=", "contributions.post_id"); - if (communityIds) { - q.join('communities_posts', 'communities_posts.post_id', '=', 'posts.id') - q.join('communities', 'communities.id', '=', 'communities_posts.community_id') - q.whereIn('communities.id', communityIds) - } - }) - }, + q.where({ "contributions.user_id": userId, "posts.active": true }); - countForUser: function (user) { - return this.query().count() - .where({ - 'contributions.user_id': user.id, - 'posts.active': true - }) - .join('posts', function () { - this.on('posts.id', '=', 'contributions.post_id') - }) - .then(function (rows) { - return rows[0].count - }) - }, + if (communityIds) { + q.join( + "communities_posts", + "communities_posts.post_id", + "=", + "posts.id" + ); + q.join( + "communities", + "communities.id", + "=", + "communities_posts.community_id" + ); + q.whereIn("communities.id", communityIds); + } + }); + }, - createActivities: (opts) => - Contribution.find(opts.contributionId).then(contribution => - contribution && bookshelf.transaction(trx => - contribution.createActivities(trx))) + countForUser: function (user) { + return this.query() + .count() + .where({ + "contributions.user_id": user.id, + "posts.active": true, + }) + .join("posts", function () { + this.on("posts.id", "=", "contributions.post_id"); + }) + .then(function (rows) { + return rows[0].count; + }); + }, -}) + createActivities: (opts) => + Contribution.find(opts.contributionId).then( + (contribution) => + contribution && + bookshelf.transaction((trx) => contribution.createActivities(trx)) + ), + } +); diff --git a/api/models/Device.js b/api/models/Device.js index 023b34aa8..4d0df220c 100644 --- a/api/models/Device.js +++ b/api/models/Device.js @@ -1,63 +1,69 @@ -module.exports = bookshelf.Model.extend({ - tableName: 'devices', +module.exports = bookshelf.Model.extend( + { + tableName: "devices", - pushNotifications: function () { - return this.hasMany(PushNotification) - }, + pushNotifications: function () { + return this.hasMany(PushNotification); + }, - user: function () { - return this.belongsTo(User, 'user_id') - }, + user: function () { + return this.belongsTo(User, "user_id"); + }, - sendPushNotification: async function (alert, path) { - if (!this.get('enabled')) return + sendPushNotification: async function (alert, path) { + if (!this.get("enabled")) return; - // this will be replaced to a call to an alternative push api for old - // versions of the app - if (!this.get('version')) return + // this will be replaced to a call to an alternative push api for old + // versions of the app + if (!this.get("version")) return; - const user = await User.find(this.get('user_id')) - const count = await User.unseenThreadCount(user.id) - const push = await PushNotification.forge({ - alert: alert.substring(0, 255), - path, - badge_no: user.get('new_notification_count') + count, - device_id: this.id, - platform: this.get('platform'), - queued_at: new Date().toISOString() - }).save() - return push.send() - }, + const user = await User.find(this.get("user_id")); + const count = await User.unseenThreadCount(user.id); + const push = await PushNotification.forge({ + alert: alert.substring(0, 255), + path, + badge_no: user.get("new_notification_count") + count, + device_id: this.id, + platform: this.get("platform"), + queued_at: new Date().toISOString(), + }).save(); + return push.send(); + }, - resetNotificationCount: function () { - if (!this.get('enabled')) return - return PushNotification.forge({ - device_id: this.id, - alert: '', - path: '', - badge_no: 0, - platform: this.get('platform'), - queued_at: (new Date()).toISOString() - }) - .save({}) - .then(pushNotification => pushNotification.send()) - } -}, { - upsert: function ({ userId, playerId, platform, version }) { - return Device.where({player_id: playerId}).fetch() - .then(device => device - ? device.save({ - user_id: userId, - platform, - version, - updated_at: new Date() + resetNotificationCount: function () { + if (!this.get("enabled")) return; + return PushNotification.forge({ + device_id: this.id, + alert: "", + path: "", + badge_no: 0, + platform: this.get("platform"), + queued_at: new Date().toISOString(), }) - : Device.forge({ - user_id: userId, - player_id: playerId, - platform, - version, - created_at: new Date() - }).save()) + .save({}) + .then((pushNotification) => pushNotification.send()); + }, + }, + { + upsert: function ({ userId, playerId, platform, version }) { + return Device.where({ player_id: playerId }) + .fetch() + .then((device) => + device + ? device.save({ + user_id: userId, + platform, + version, + updated_at: new Date(), + }) + : Device.forge({ + user_id: userId, + player_id: playerId, + platform, + version, + created_at: new Date(), + }).save() + ); + }, } -}) +); diff --git a/api/models/EventInvitation.js b/api/models/EventInvitation.js index d77431c79..564b40945 100644 --- a/api/models/EventInvitation.js +++ b/api/models/EventInvitation.js @@ -1,63 +1,64 @@ /* eslint-disable camelcase */ -module.exports = bookshelf.Model.extend({ - tableName: 'event_invitations', +module.exports = bookshelf.Model.extend( + { + tableName: "event_invitations", - user: function () { - return this.belongsTo(User, 'user_id') - }, + user: function () { + return this.belongsTo(User, "user_id"); + }, - inviter: function () { - return this.belongsTo(User, 'inviter_id') - }, + inviter: function () { + return this.belongsTo(User, "inviter_id"); + }, - event: function () { - return this.belongsTo(Post, 'event_id') - } + event: function () { + return this.belongsTo(Post, "event_id"); + }, + }, + { + RESPONSE: { + YES: "yes", + NO: "no", + INTERESTED: "interested", + }, -}, { + create: function ({ userId, inviterId, eventId, response }, trxOpts) { + if (!userId) { + throw new Error("must provide a user_id"); + } - RESPONSE: { - YES: 'yes', - NO: 'no', - INTERESTED: 'interested' - }, + if (!eventId) { + throw new Error("must provide an event_id"); + } - create: function ({userId, inviterId, eventId, response}, trxOpts) { - if (!userId) { - throw new Error('must provide a user_id') - } + return this.find({ userId, inviterId, eventId }, trxOpts).then( + (existing) => { + if (existing) return existing; - if (!eventId) { - throw new Error('must provide an event_id') - } + return new EventInvitation({ + user_id: userId, + inviter_id: inviterId, + event_id: eventId, + response, + created_at: new Date(), + updated_at: new Date(), + }).save(null, trxOpts); + } + ); + }, - return this.find({userId, inviterId, eventId}, trxOpts) - .then(existing => { - if (existing) return existing + find: function ({ userId, inviterId, eventId }, opts) { + if (!userId) throw new Error("Parameter user_id must be supplied."); + if (!eventId) throw new Error("Parameter event_id must be supplied."); - return new EventInvitation({ + const conditions = { user_id: userId, - inviter_id: inviterId, event_id: eventId, - response, - created_at: new Date(), - updated_at: new Date() - }) - .save(null, trxOpts) - }) - }, - - find: function ({ userId, inviterId, eventId }, opts) { - if (!userId) throw new Error('Parameter user_id must be supplied.') - if (!eventId) throw new Error('Parameter event_id must be supplied.') - - const conditions = { - user_id: userId, - event_id: eventId - } + }; - if (inviterId) conditions.inviter_id = inviterId + if (inviterId) conditions.inviter_id = inviterId; - return EventInvitation.where(conditions).fetch(opts) + return EventInvitation.where(conditions).fetch(opts); + }, } -}) +); diff --git a/api/models/EventResponse.js b/api/models/EventResponse.js index 0c38da57d..5e861010f 100644 --- a/api/models/EventResponse.js +++ b/api/models/EventResponse.js @@ -1,20 +1,23 @@ -module.exports = bookshelf.Model.extend({ - tableName: 'event_responses', +module.exports = bookshelf.Model.extend( + { + tableName: "event_responses", - post: function () { - return this.belongsTo(Post) - }, + post: function () { + return this.belongsTo(Post); + }, - user: function () { - return this.belongsTo(User).query({where: {active: true}}) - } -}, { - create: function (postId, options) { - return new EventResponse({ - post_id: postId, - user_id: options.responderId, - response: options.response, - created_at: new Date() - }).save(null, _.pick(options, 'transacting')) + user: function () { + return this.belongsTo(User).query({ where: { active: true } }); + }, + }, + { + create: function (postId, options) { + return new EventResponse({ + post_id: postId, + user_id: options.responderId, + response: options.response, + created_at: new Date(), + }).save(null, _.pick(options, "transacting")); + }, } -}) +); diff --git a/api/models/FlaggedItem.js b/api/models/FlaggedItem.js index 89c094612..da17fe8f9 100644 --- a/api/models/FlaggedItem.js +++ b/api/models/FlaggedItem.js @@ -1,109 +1,138 @@ -import { values, isEmpty, trim } from 'lodash' -import { validateFlaggedItem } from 'hylo-utils/validators' -import { notifyModeratorsPost, notifyModeratorsMember, notifyModeratorsComment } from './flaggedItem/notifyUtils' - -module.exports = bookshelf.Model.extend({ - tableName: 'flagged_items', - - user: function () { - return this.belongsTo(User, 'user_id') - }, - - getObject: function () { - if (!this.get('object_id')) throw new Error('No object_id defined for Flagged Item') - switch (this.get('object_type')) { - case FlaggedItem.Type.POST: - return Post.find(this.get('object_id'), {withRelated: 'communities'}) - case FlaggedItem.Type.COMMENT: - return Comment.find(this.get('object_id'), {withRelated: 'post.communities'}) - case FlaggedItem.Type.MEMBER: - return User.find(this.get('object_id')) - default: - throw new Error('Unsupported type for Flagged Item', this.get('object_type')) - } - }, - - async getMessageText (community) { - const isPublic = !community ? true : false - const link = await this.getContentLink(community, isPublic) - - return `${this.relations.user.get('name')} flagged a ${this.get('object_type')} in ${community ? community.get('name') : 'Public'} for being ${this.get('category')}\n` + - `Message: ${this.get('reason')}\n` + - `${link}\n\n` - }, - - async getContentLink (community, isPublic) { - switch (this.get('object_type')) { - case FlaggedItem.Type.POST: - return Frontend.Route.post(this.get('object_id'), community, isPublic) - case FlaggedItem.Type.COMMENT: - const comment = await this.getObject() - return Frontend.Route.comment(comment, community, isPublic) - case FlaggedItem.Type.MEMBER: - return Frontend.Route.profile(this.get('object_id')) - default: - throw new Error('Unsupported type for Flagged Item', this.get('object_type')) - } - } - -}, { - Category: { - INAPPROPRIATE: 'inappropriate', - OFFENSIVE: 'offensive', - ABUSIVE: 'abusive', - ILLEGAL: 'illegal', - OTHER: 'other', - SAFETY: 'safety', - SPAM: 'spam' - }, - - Type: { - POST: 'post', - COMMENT: 'comment', - MEMBER: 'member' - }, - - find (id, opts = {}) { - return FlaggedItem.where({id}) - .fetch(opts) - }, - - create: function (attrs) { - const { category, link } = attrs - - let { reason } = attrs - - if (!values(this.Category).find(c => category === c)) { - return Promise.reject(new Error('Unknown category.')) - } - - // set reason to 'N/A' if not required (!other) and it's empty. - if (category !== 'other' && isEmpty(trim(reason))) { - reason = 'N/A' - } - - const invalidReason = validateFlaggedItem.reason(reason) - if (invalidReason) return Promise.reject(new Error(invalidReason)) - - if (process.env.NODE_ENV !== 'development') { - const invalidLink = validateFlaggedItem.link(link) - if (invalidLink) return Promise.reject(new Error(invalidLink)) - } - - return this.forge(attrs).save() +import { values, isEmpty, trim } from "lodash"; +import { validateFlaggedItem } from "hylo-utils/validators"; +import { + notifyModeratorsPost, + notifyModeratorsMember, + notifyModeratorsComment, +} from "./flaggedItem/notifyUtils"; + +module.exports = bookshelf.Model.extend( + { + tableName: "flagged_items", + + user: function () { + return this.belongsTo(User, "user_id"); + }, + + getObject: function () { + if (!this.get("object_id")) + throw new Error("No object_id defined for Flagged Item"); + switch (this.get("object_type")) { + case FlaggedItem.Type.POST: + return Post.find(this.get("object_id"), { + withRelated: "communities", + }); + case FlaggedItem.Type.COMMENT: + return Comment.find(this.get("object_id"), { + withRelated: "post.communities", + }); + case FlaggedItem.Type.MEMBER: + return User.find(this.get("object_id")); + default: + throw new Error( + "Unsupported type for Flagged Item", + this.get("object_type") + ); + } + }, + + async getMessageText(community) { + const isPublic = !community; + const link = await this.getContentLink(community, isPublic); + + return ( + `${this.relations.user.get("name")} flagged a ${this.get( + "object_type" + )} in ${ + community ? community.get("name") : "Public" + } for being ${this.get("category")}\n` + + `Message: ${this.get("reason")}\n` + + `${link}\n\n` + ); + }, + + async getContentLink(community, isPublic) { + switch (this.get("object_type")) { + case FlaggedItem.Type.POST: + return Frontend.Route.post( + this.get("object_id"), + community, + isPublic + ); + case FlaggedItem.Type.COMMENT: + const comment = await this.getObject(); + return Frontend.Route.comment(comment, community, isPublic); + case FlaggedItem.Type.MEMBER: + return Frontend.Route.profile(this.get("object_id")); + default: + throw new Error( + "Unsupported type for Flagged Item", + this.get("object_type") + ); + } + }, }, - - async notifyModerators ({ id }) { - const flaggedItem = await FlaggedItem.find(id, {withRelated: 'user'}) - switch (flaggedItem.get('object_type')) { - case FlaggedItem.Type.POST: - return notifyModeratorsPost(flaggedItem) - case FlaggedItem.Type.COMMENT: - return notifyModeratorsComment(flaggedItem) - case FlaggedItem.Type.MEMBER: - return notifyModeratorsMember(flaggedItem) - default: - throw new Error('Unsupported type for Flagged Item', flaggedItem.get('object_type')) - } + { + Category: { + INAPPROPRIATE: "inappropriate", + OFFENSIVE: "offensive", + ABUSIVE: "abusive", + ILLEGAL: "illegal", + OTHER: "other", + SAFETY: "safety", + SPAM: "spam", + }, + + Type: { + POST: "post", + COMMENT: "comment", + MEMBER: "member", + }, + + find(id, opts = {}) { + return FlaggedItem.where({ id }).fetch(opts); + }, + + create: function (attrs) { + const { category, link } = attrs; + + let { reason } = attrs; + + if (!values(this.Category).find((c) => category === c)) { + return Promise.reject(new Error("Unknown category.")); + } + + // set reason to 'N/A' if not required (!other) and it's empty. + if (category !== "other" && isEmpty(trim(reason))) { + reason = "N/A"; + } + + const invalidReason = validateFlaggedItem.reason(reason); + if (invalidReason) return Promise.reject(new Error(invalidReason)); + + if (process.env.NODE_ENV !== "development") { + const invalidLink = validateFlaggedItem.link(link); + if (invalidLink) return Promise.reject(new Error(invalidLink)); + } + + return this.forge(attrs).save(); + }, + + async notifyModerators({ id }) { + const flaggedItem = await FlaggedItem.find(id, { withRelated: "user" }); + switch (flaggedItem.get("object_type")) { + case FlaggedItem.Type.POST: + return notifyModeratorsPost(flaggedItem); + case FlaggedItem.Type.COMMENT: + return notifyModeratorsComment(flaggedItem); + case FlaggedItem.Type.MEMBER: + return notifyModeratorsMember(flaggedItem); + default: + throw new Error( + "Unsupported type for Flagged Item", + flaggedItem.get("object_type") + ); + } + }, } -}) +); diff --git a/api/models/FollowDeprecated.js b/api/models/FollowDeprecated.js index a0093ea01..79d5627fb 100644 --- a/api/models/FollowDeprecated.js +++ b/api/models/FollowDeprecated.js @@ -1,34 +1,47 @@ -module.exports = bookshelf.Model.extend({ - tableName: 'follows', +module.exports = bookshelf.Model.extend( + { + tableName: "follows", - post: function () { - return this.belongsTo(Post) - }, + post: function () { + return this.belongsTo(Post); + }, - user: function () { - return this.belongsTo(User).query({where: {active: true}}) - } -}, { - Role: { - DEFAULT: 0, - MODERATOR: 1 + user: function () { + return this.belongsTo(User).query({ where: { active: true } }); + }, }, + { + Role: { + DEFAULT: 0, + MODERATOR: 1, + }, - create: function (userId, postId, commentId, { transacting, addedById } = {}) { - return User.where({id: userId}).count() - .then(count => Number(count) === 0 ? null : Follow.forge({ - post_id: postId, - comment_id: commentId, - added_at: new Date(), - user_id: userId, - added_by_id: addedById - }).save(null, {transacting})) - }, + create: function ( + userId, + postId, + commentId, + { transacting, addedById } = {} + ) { + return User.where({ id: userId }) + .count() + .then((count) => + Number(count) === 0 + ? null + : Follow.forge({ + post_id: postId, + comment_id: commentId, + added_at: new Date(), + user_id: userId, + added_by_id: addedById, + }).save(null, { transacting }) + ); + }, - exists: function (userId, postId) { - return Follow.query() - .where({user_id: userId, post_id: postId}) - .count() - .then(rows => rows[0].count >= 1) + exists: function (userId, postId) { + return Follow.query() + .where({ user_id: userId, post_id: postId }) + .count() + .then((rows) => rows[0].count >= 1); + }, } -}) +); diff --git a/api/models/Group.js b/api/models/Group.js index 41c00f288..9f85a58df 100644 --- a/api/models/Group.js +++ b/api/models/Group.js @@ -1,172 +1,212 @@ -import { difference, intersection, sortBy, pick, omitBy, isUndefined } from 'lodash' +import { + difference, + intersection, + sortBy, + pick, + omitBy, + isUndefined, +} from "lodash"; import DataType, { - getDataTypeForInstance, getDataTypeForModel, getModelForDataType -} from './group/DataType' + getDataTypeForInstance, + getDataTypeForModel, + getModelForDataType, +} from "./group/DataType"; export const GROUP_ATTR_UPDATE_WHITELIST = [ - 'role', - 'project_role_id', - 'following', - 'settings', - 'active' -] - -module.exports = bookshelf.Model.extend({ - tableName: 'groups', - - groupData () { - // eslint-disable-next-line camelcase - const { group_data_type, group_data_id } = this.attributes - const model = getModelForDataType(group_data_type) - return model.query(q => q.where('id', group_data_id)) + "role", + "project_role_id", + "following", + "settings", + "active", +]; + +module.exports = bookshelf.Model.extend( + { + tableName: "groups", + + groupData() { + // eslint-disable-next-line camelcase + const { group_data_type, group_data_id } = this.attributes; + const model = getModelForDataType(group_data_type); + return model.query((q) => q.where("id", group_data_id)); + }, + + childGroups() { + return this.belongsToMany(Group).through( + GroupConnection, + "parent_group_id", + "child_group_id" + ); + }, + + parentGroups() { + return this.belongsToMany(Group).through( + GroupConnection, + "child_group_id", + "parent_group_id" + ); + }, + + members() { + return this.belongsToMany(User) + .through(GroupMembership) + .query((q) => + q.where({ + "group_memberships.active": true, + "users.active": true, + }) + ); + }, + + memberships(includeInactive = false) { + return this.hasMany(GroupMembership).query((q) => + includeInactive ? q : q.where("group_memberships.active", true) + ); + }, + + async updateMembers(usersOrIds, attrs, { transacting } = {}) { + const userIds = usersOrIds.map((x) => (x instanceof User ? x.id : x)); + + const existingMemberships = await this.memberships(true) + .query((q) => q.where("user_id", "in", userIds)) + .fetch(); + + const updatedAttribs = Object.assign( + {}, + { settings: {} }, + pick(omitBy(attrs, isUndefined), GROUP_ATTR_UPDATE_WHITELIST) + ); + + return Promise.map(existingMemberships.models, (ms) => + ms.updateAndSave(updatedAttribs, { transacting }) + ); + }, + + // if a group membership doesn't exist for a user id, create it. + // make sure the group memberships have the passed-in role and settings + // (merge on top of existing settings). + async addMembers(usersOrIds, attrs = {}, { transacting } = {}) { + const updatedAttribs = Object.assign( + {}, + { + role: GroupMembership.Role.DEFAULT, + active: true, + }, + pick(omitBy(attrs, isUndefined), GROUP_ATTR_UPDATE_WHITELIST) + ); + + const userIds = usersOrIds.map((x) => (x instanceof User ? x.id : x)); + const existingMemberships = await this.memberships(true) + .query((q) => q.where("user_id", "in", userIds)) + .fetch(); + const existingUserIds = existingMemberships.pluck("user_id"); + const newUserIds = difference(userIds, existingUserIds); + + await this.updateMembers(existingUserIds, updatedAttribs, { + transacting, + }); + + const updatedMemberships = await this.updateMembers( + existingUserIds, + updatedAttribs, + { transacting } + ); + const newMemberships = []; + + for (const id of newUserIds) { + const membership = await this.memberships().create( + Object.assign({}, updatedAttribs, { + user_id: id, + created_at: new Date(), + group_data_type: this.get("group_data_type"), + }), + { transacting } + ); + newMemberships.push(membership); + } + return updatedMemberships.concat(newMemberships); + }, + + async removeMembers(usersOrIds, { transacting } = {}) { + return this.updateMembers(usersOrIds, { active: false }, { transacting }); + }, }, - - childGroups () { - return this.belongsToMany(Group) - .through(GroupConnection, 'parent_group_id', 'child_group_id') - }, - - parentGroups () { - return this.belongsToMany(Group) - .through(GroupConnection, 'child_group_id', 'parent_group_id') - }, - - members () { - return this.belongsToMany(User).through(GroupMembership) - .query(q => q.where({ - 'group_memberships.active': true, - 'users.active': true - })) - }, - - memberships (includeInactive = false) { - return this.hasMany(GroupMembership) - .query(q => includeInactive ? q : q.where('group_memberships.active', true)) - }, - - async updateMembers (usersOrIds, attrs, { transacting } = {}) { - const userIds = usersOrIds.map(x => x instanceof User ? x.id : x) - - const existingMemberships = await this.memberships(true) - .query(q => q.where('user_id', 'in', userIds)).fetch() - - const updatedAttribs = Object.assign( - {}, - {settings: {}}, - pick(omitBy(attrs, isUndefined), GROUP_ATTR_UPDATE_WHITELIST) - ) - - return Promise.map(existingMemberships.models, ms => ms.updateAndSave(updatedAttribs, {transacting})) - }, - - // if a group membership doesn't exist for a user id, create it. - // make sure the group memberships have the passed-in role and settings - // (merge on top of existing settings). - async addMembers (usersOrIds, attrs = {}, { transacting } = {}) { - const updatedAttribs = Object.assign( - {}, - { - role: GroupMembership.Role.DEFAULT, - active: true - }, - pick(omitBy(attrs, isUndefined), GROUP_ATTR_UPDATE_WHITELIST) - ) - - const userIds = usersOrIds.map(x => x instanceof User ? x.id : x) - const existingMemberships = await this.memberships(true) - .query(q => q.where('user_id', 'in', userIds)).fetch() - const existingUserIds = existingMemberships.pluck('user_id') - const newUserIds = difference(userIds, existingUserIds) - - await this.updateMembers(existingUserIds, updatedAttribs, {transacting}) - - const updatedMemberships = await this.updateMembers(existingUserIds, updatedAttribs, {transacting}) - const newMemberships = [] - - for (let id of newUserIds) { - const membership = await this.memberships().create( - Object.assign({}, updatedAttribs, { - user_id: id, - created_at: new Date(), - group_data_type: this.get('group_data_type') - }), {transacting}) - newMemberships.push(membership) - } - return updatedMemberships.concat(newMemberships) - }, - - async removeMembers (usersOrIds, { transacting } = {}) { - return this.updateMembers(usersOrIds, {active: false}, {transacting}) - }, - -}, { - DataType, - - find (instanceOrId, { transacting } = {}) { - if (!instanceOrId) return null - - if (typeof instanceOrId === 'string' || typeof instanceOrId === 'number') { - return this.where('id', instanceOrId).fetch({transacting}) - } - - const type = getDataTypeForInstance(instanceOrId) - return this.findByIdAndType(instanceOrId.id, type, { transacting }) - }, - - findByIdAndType (id, typeOrModel, { transacting } = {}) { - return this.whereIdAndType(id, typeOrModel).fetch({transacting}) - }, - - whereIdAndType (id, typeOrModel) { - - const type = typeof typeOrModel === 'number' - ? typeOrModel - : getDataTypeForModel(typeOrModel) - - return this.where({group_data_type: type, group_data_id: id}) - }, - - async deactivate (dataId, type, opts = {}) { - const group = await Group.whereIdAndType(dataId, type).fetch() - await group.save({active: false}, opts) - return group.removeMembers(await group.members().fetch(), opts) - }, - - pluckIdsForMember (userOrId, typeOrModel, where) { - return GroupMembership.forIds(userOrId, null, typeOrModel, { - query: q => { - if (where) q.where(where) - q.join('groups', 'groups.id', 'group_memberships.group_id') - q.where('groups.active', true) - }, - multiple: true - }).query().pluck('group_data_id') - }, - - havingExactMembers (userIds, typeOrModel) { - const type = typeof typeOrModel === 'number' - ? typeOrModel - : getDataTypeForModel(typeOrModel) - - const { raw } = bookshelf.knex - userIds = sortBy(userIds, Number) - return this.query(q => { - q.join('group_memberships', 'groups.id', 'group_memberships.group_id') - q.where('group_memberships.active', true) - q.groupBy('groups.id') - q.having(raw(`array_agg(user_id order by user_id) = ?`, [userIds])) - q.where('groups.group_data_type', type) - }) - }, - - async allHaveMember (groupDataIds, userOrId, typeOrModel) { - const memberIds = await this.pluckIdsForMember(userOrId, typeOrModel) - return difference(groupDataIds, memberIds).length === 0 - }, - - async inSameGroup (userIds, typeOrModel) { - const groupIds = await Promise.all(userIds.map(id => - this.pluckIdsForMember(id, typeOrModel))) - return intersection(groupIds).length > 0 + { + DataType, + + find(instanceOrId, { transacting } = {}) { + if (!instanceOrId) return null; + + if ( + typeof instanceOrId === "string" || + typeof instanceOrId === "number" + ) { + return this.where("id", instanceOrId).fetch({ transacting }); + } + + const type = getDataTypeForInstance(instanceOrId); + return this.findByIdAndType(instanceOrId.id, type, { transacting }); + }, + + findByIdAndType(id, typeOrModel, { transacting } = {}) { + return this.whereIdAndType(id, typeOrModel).fetch({ transacting }); + }, + + whereIdAndType(id, typeOrModel) { + const type = + typeof typeOrModel === "number" + ? typeOrModel + : getDataTypeForModel(typeOrModel); + + return this.where({ group_data_type: type, group_data_id: id }); + }, + + async deactivate(dataId, type, opts = {}) { + const group = await Group.whereIdAndType(dataId, type).fetch(); + await group.save({ active: false }, opts); + return group.removeMembers(await group.members().fetch(), opts); + }, + + pluckIdsForMember(userOrId, typeOrModel, where) { + return GroupMembership.forIds(userOrId, null, typeOrModel, { + query: (q) => { + if (where) q.where(where); + q.join("groups", "groups.id", "group_memberships.group_id"); + q.where("groups.active", true); + }, + multiple: true, + }) + .query() + .pluck("group_data_id"); + }, + + havingExactMembers(userIds, typeOrModel) { + const type = + typeof typeOrModel === "number" + ? typeOrModel + : getDataTypeForModel(typeOrModel); + + const { raw } = bookshelf.knex; + userIds = sortBy(userIds, Number); + return this.query((q) => { + q.join("group_memberships", "groups.id", "group_memberships.group_id"); + q.where("group_memberships.active", true); + q.groupBy("groups.id"); + q.having(raw("array_agg(user_id order by user_id) = ?", [userIds])); + q.where("groups.group_data_type", type); + }); + }, + + async allHaveMember(groupDataIds, userOrId, typeOrModel) { + const memberIds = await this.pluckIdsForMember(userOrId, typeOrModel); + return difference(groupDataIds, memberIds).length === 0; + }, + + async inSameGroup(userIds, typeOrModel) { + const groupIds = await Promise.all( + userIds.map((id) => this.pluckIdsForMember(id, typeOrModel)) + ); + return intersection(groupIds).length > 0; + }, } -}) +); diff --git a/api/models/GroupConnection.js b/api/models/GroupConnection.js index f59a61f66..323d5c14b 100644 --- a/api/models/GroupConnection.js +++ b/api/models/GroupConnection.js @@ -1,16 +1,19 @@ -import HasSettings from './mixins/HasSettings' +import HasSettings from "./mixins/HasSettings"; -module.exports = bookshelf.Model.extend(Object.assign({ - tableName: 'group_connections', +module.exports = bookshelf.Model.extend( + Object.assign( + { + tableName: "group_connections", - parentGroup () { - return this.belongsTo(Group, 'parent_group_id') - }, + parentGroup() { + return this.belongsTo(Group, "parent_group_id"); + }, - childGroup () { - return this.belongsTo(Group, 'child_group_id') - } - -}, HasSettings), { - -}) + childGroup() { + return this.belongsTo(Group, "child_group_id"); + }, + }, + HasSettings + ), + {} +); diff --git a/api/models/GroupMembership.js b/api/models/GroupMembership.js index d461fff4c..f152e9d8d 100644 --- a/api/models/GroupMembership.js +++ b/api/models/GroupMembership.js @@ -1,134 +1,137 @@ -import HasSettings from './mixins/HasSettings' -import { isEmpty } from 'lodash' +import HasSettings from "./mixins/HasSettings"; +import { isEmpty } from "lodash"; import { isFollowing, queryForMember, whereGroupDataId, - whereUserId -} from './group/queryUtils' -import { - getDataTypeForModel, - getModelForDataType -} from './group/DataType' - -module.exports = bookshelf.Model.extend(Object.assign({ - tableName: 'group_memberships', - - group () { - return this.belongsTo(Group) - }, - - user () { - return this.belongsTo(User) - }, - - groupData () { - // This is the main reason for the denormalizing of group_data_type from - // groups onto group_memberships so far; if we're looking up the object for - // a given membership, we need to know what model to use. - // - // It remains to be seen whether this is a good enough need to justify the - // duplication of data. All the uses of this method so far are in contexts - // where we could pass the correct model in as an argument. - - const model = getModelForDataType(this.get('group_data_type')) - const { tableName } = model.forge() - return model.query(q => { - q.join('groups', `${tableName}.id`, 'groups.group_data_id') - q.where('groups.id', this.get('group_id')) - }) - }, - - async updateAndSave (attrs, { transacting } = {}) { - for (let key in attrs) { - if (key === 'settings') { - this.addSetting(attrs[key]) - } else { - this.set(key, attrs[key]) - } - } - - if (!isEmpty(this.changed)) return this.save(null, {transacting}) - return this - }, - - hasRole (role) { - return Number(role) === this.get('role') - } - -}, HasSettings), { - Role: { - DEFAULT: 0, - MODERATOR: 1 - }, - - whereUnread (userId, model, { afterTime } = {}) { - return this.query(q => { - q.join('groups', 'groups.id', 'group_memberships.group_id') - if (afterTime) q.where('groups.updated_at', '>', afterTime) - queryForMember(q, userId, model) - isFollowing(q) - - q.where(q2 => { - q2.whereRaw("(group_memberships.settings->>'lastReadAt') is null") - .orWhereRaw(`(group_memberships.settings->>'lastReadAt') + whereUserId, +} from "./group/queryUtils"; +import { getDataTypeForModel, getModelForDataType } from "./group/DataType"; + +module.exports = bookshelf.Model.extend( + Object.assign( + { + tableName: "group_memberships", + + group() { + return this.belongsTo(Group); + }, + + user() { + return this.belongsTo(User); + }, + + groupData() { + // This is the main reason for the denormalizing of group_data_type from + // groups onto group_memberships so far; if we're looking up the object for + // a given membership, we need to know what model to use. + // + // It remains to be seen whether this is a good enough need to justify the + // duplication of data. All the uses of this method so far are in contexts + // where we could pass the correct model in as an argument. + + const model = getModelForDataType(this.get("group_data_type")); + const { tableName } = model.forge(); + return model.query((q) => { + q.join("groups", `${tableName}.id`, "groups.group_data_id"); + q.where("groups.id", this.get("group_id")); + }); + }, + + async updateAndSave(attrs, { transacting } = {}) { + for (const key in attrs) { + if (key === "settings") { + this.addSetting(attrs[key]); + } else { + this.set(key, attrs[key]); + } + } + + if (!isEmpty(this.changed)) return this.save(null, { transacting }); + return this; + }, + + hasRole(role) { + return Number(role) === this.get("role"); + }, + }, + HasSettings + ), + { + Role: { + DEFAULT: 0, + MODERATOR: 1, + }, + + whereUnread(userId, model, { afterTime } = {}) { + return this.query((q) => { + q.join("groups", "groups.id", "group_memberships.group_id"); + if (afterTime) q.where("groups.updated_at", ">", afterTime); + queryForMember(q, userId, model); + isFollowing(q); + + q.where((q2) => { + q2.whereRaw("(group_memberships.settings->>'lastReadAt') is null") + .orWhereRaw(`(group_memberships.settings->>'lastReadAt') ::timestamp without time zone at time zone 'utc' - < groups.updated_at`) - }) - }) - }, - - forPair (userOrId, instance, opts = {}) { - const userId = userOrId instanceof User ? userOrId.id : userOrId - if (!userId) { - throw new Error("Can't call forPair without a user or user id") - } - if (!instance) { - throw new Error("Can't call forPair without an instance") - } - - return this.forIds(userId, instance.id, instance.constructor, opts) - }, - - // `usersOrIds` can be a single user or id, a list of either, or null - forIds (usersOrIds, instanceId, typeOrModel, opts = {}) { - const type = typeof typeOrModel === 'number' - ? typeOrModel - : getDataTypeForModel(typeOrModel) - - const queryRoot = opts.multiple ? this.collection() : this - - return queryRoot.query(q => { - q.where('group_memberships.group_data_type', type) - if (instanceId) { - q.join('groups', 'groups.id', 'group_memberships.group_id') + < groups.updated_at`); + }); + }); + }, + + forPair(userOrId, instance, opts = {}) { + const userId = userOrId instanceof User ? userOrId.id : userOrId; + if (!userId) { + throw new Error("Can't call forPair without a user or user id"); } - whereGroupDataId(q, instanceId) - whereUserId(q, usersOrIds) - if (!opts.includeInactive) q.where('group_memberships.active', true) - if (opts.query) opts.query(q) - }) - }, - - async hasActiveMembership (userOrId, instance) { - const gm = await this.forPair(userOrId, instance).fetch() - return !!gm && gm.get('active') - }, - - async hasModeratorRole (userOrId, instance) { - const gm = await this.forPair(userOrId, instance).fetch() - return gm && gm.hasRole(this.Role.MODERATOR) - }, - - async setModeratorRole (userId, instance) { - return instance.addGroupMembers([userId], {role: this.Role.MODERATOR}) - }, - - async removeModeratorRole (userId, instance) { - return instance.addGroupMembers([userId], {role: this.Role.DEFAULT}) - }, - - forMember (userOrId, model) { - return this.forIds(userOrId, null, model, {multiple: true}) + if (!instance) { + throw new Error("Can't call forPair without an instance"); + } + + return this.forIds(userId, instance.id, instance.constructor, opts); + }, + + // `usersOrIds` can be a single user or id, a list of either, or null + forIds(usersOrIds, instanceId, typeOrModel, opts = {}) { + const type = + typeof typeOrModel === "number" + ? typeOrModel + : getDataTypeForModel(typeOrModel); + + const queryRoot = opts.multiple ? this.collection() : this; + + return queryRoot.query((q) => { + q.where("group_memberships.group_data_type", type); + if (instanceId) { + q.join("groups", "groups.id", "group_memberships.group_id"); + } + whereGroupDataId(q, instanceId); + whereUserId(q, usersOrIds); + if (!opts.includeInactive) q.where("group_memberships.active", true); + if (opts.query) opts.query(q); + }); + }, + + async hasActiveMembership(userOrId, instance) { + const gm = await this.forPair(userOrId, instance).fetch(); + return !!gm && gm.get("active"); + }, + + async hasModeratorRole(userOrId, instance) { + const gm = await this.forPair(userOrId, instance).fetch(); + return gm && gm.hasRole(this.Role.MODERATOR); + }, + + async setModeratorRole(userId, instance) { + return instance.addGroupMembers([userId], { role: this.Role.MODERATOR }); + }, + + async removeModeratorRole(userId, instance) { + return instance.addGroupMembers([userId], { role: this.Role.DEFAULT }); + }, + + forMember(userOrId, model) { + return this.forIds(userOrId, null, model, { multiple: true }); + }, } -}) +); diff --git a/api/models/Invitation.js b/api/models/Invitation.js index 041d42bea..bb894c260 100644 --- a/api/models/Invitation.js +++ b/api/models/Invitation.js @@ -1,164 +1,175 @@ -import uuid from 'node-uuid' -import EnsureLoad from './mixins/EnsureLoad' - -module.exports = bookshelf.Model.extend(Object.assign({ - tableName: 'community_invites', - - community: function () { - return this.belongsTo(Community) - }, - - creator: function () { - return this.belongsTo(User, 'invited_by_id') - }, - - tag: function () { - return this.belongsTo(Tag) - }, - - user: function () { - return this.belongsTo(User, 'used_by_id') - }, - - expiredBy: function () { - return this.belongsTo(User, 'expired_by_id') - }, - - isUsed: function () { - return !!this.get('used_by_id') - }, - - isExpired: function () { - return !!this.get('expired_by_id') - }, - - tagName: function () { - return this.get('tag_id') - ? Tag.find({ id: this.get('tag_id') }).then(t => t.get('name')) - : Promise.resolve() - }, - - // this should always return the membership, regardless of whether the - // invitation is unused, whether the membership already exists, and whether - // the tag follow already exists - async use (userId, { transacting } = {}) { - const user = await User.find(userId, {transacting}) - const community = await this.community().fetch({transacting}) - const role = Number(this.get('role')) - const membership = - await GroupMembership.forPair(user, community).fetch({transacting}) || - await user.joinCommunity(community, role, {transacting}) - - if (!this.isUsed() && this.get('tag_id')) { - try { - await TagFollow.add({ - tagId: this.get('tag_id'), - userId, - communityId: this.get('community_id'), - transacting - }) - } catch (err) { - // do nothing if the tag follow already exists - if (!err.message || !err.message.includes('duplicate key value')) { - throw err +import uuid from "node-uuid"; +import EnsureLoad from "./mixins/EnsureLoad"; + +module.exports = bookshelf.Model.extend( + Object.assign( + { + tableName: "community_invites", + + community: function () { + return this.belongsTo(Community); + }, + + creator: function () { + return this.belongsTo(User, "invited_by_id"); + }, + + tag: function () { + return this.belongsTo(Tag); + }, + + user: function () { + return this.belongsTo(User, "used_by_id"); + }, + + expiredBy: function () { + return this.belongsTo(User, "expired_by_id"); + }, + + isUsed: function () { + return !!this.get("used_by_id"); + }, + + isExpired: function () { + return !!this.get("expired_by_id"); + }, + + tagName: function () { + return this.get("tag_id") + ? Tag.find({ id: this.get("tag_id") }).then((t) => t.get("name")) + : Promise.resolve(); + }, + + // this should always return the membership, regardless of whether the + // invitation is unused, whether the membership already exists, and whether + // the tag follow already exists + async use(userId, { transacting } = {}) { + const user = await User.find(userId, { transacting }); + const community = await this.community().fetch({ transacting }); + const role = Number(this.get("role")); + const membership = + (await GroupMembership.forPair(user, community).fetch({ + transacting, + })) || (await user.joinCommunity(community, role, { transacting })); + + if (!this.isUsed() && this.get("tag_id")) { + try { + await TagFollow.add({ + tagId: this.get("tag_id"), + userId, + communityId: this.get("community_id"), + transacting, + }); + } catch (err) { + // do nothing if the tag follow already exists + if (!err.message || !err.message.includes("duplicate key value")) { + throw err; + } + } } - } - } - - if (!this.isUsed()) { - await this.save({used_by_id: userId, used_at: new Date()}, - {patch: true, transacting}) - } - - return membership - }, - - expire: function (userId, opts = {}) { - const { transacting } = opts - return this.save({expired_by_id: userId, expired_at: new Date()}, - {patch: true, transacting}) - }, - - send: function () { - return this.ensureLoad(['creator', 'community', 'tag']) - .then(() => { - const { creator, community } = this.relations - const email = this.get('email') - - const data = { - subject: this.get('subject'), - message: this.get('message'), - inviter_name: creator.get('name'), - inviter_email: creator.get('email'), - community_name: community.get('name'), - invite_link: Frontend.Route.useInvitation(this.get('token'), email), - tracking_pixel_url: Analytics.pixelUrl('Invitation', { - recipient: email, - community: community.get('name') - }) - } - return this.save({ - sent_count: this.get('sent_count') + 1, - last_sent_at: new Date() - }) - .then(() => { - if (this.get('tag_id')) { - throw new Error('need to re-implement tag invitations') - } else { - return Email.sendInvitation(email, data) + + if (!this.isUsed()) { + await this.save( + { used_by_id: userId, used_at: new Date() }, + { patch: true, transacting } + ); } - }) - }) - } -}, EnsureLoad), { - - find: (idOrToken, opts) => { - if (!idOrToken) return Promise.resolve(null) - const attr = isNaN(Number(idOrToken)) ? 'token' : 'id' - return Invitation.where(attr, idOrToken).fetch(opts) - }, - - create: function (opts) { - return new Invitation({ - invited_by_id: opts.userId, - community_id: opts.communityId, - email: opts.email.toLowerCase(), - tag_id: opts.tagId, - role: GroupMembership.Role[opts.moderator ? 'MODERATOR' : 'DEFAULT'], - token: uuid.v4(), - created_at: new Date(), - subject: opts.subject, - message: opts.message - }).save() - }, - - createAndSend: function ({invitation}) { - return Invitation.find(invitation.id) - .then(invitation => + return membership; + }, + + expire: function (userId, opts = {}) { + const { transacting } = opts; + return this.save( + { expired_by_id: userId, expired_at: new Date() }, + { patch: true, transacting } + ); + }, + + send: function () { + return this.ensureLoad(["creator", "community", "tag"]).then(() => { + const { creator, community } = this.relations; + const email = this.get("email"); + + const data = { + subject: this.get("subject"), + message: this.get("message"), + inviter_name: creator.get("name"), + inviter_email: creator.get("email"), + community_name: community.get("name"), + invite_link: Frontend.Route.useInvitation(this.get("token"), email), + tracking_pixel_url: Analytics.pixelUrl("Invitation", { + recipient: email, + community: community.get("name"), + }), + }; + return this.save({ + sent_count: this.get("sent_count") + 1, + last_sent_at: new Date(), + }).then(() => { + if (this.get("tag_id")) { + throw new Error("need to re-implement tag invitations"); + } else { + return Email.sendInvitation(email, data); + } + }); + }); + }, + }, + EnsureLoad + ), + { + find: (idOrToken, opts) => { + if (!idOrToken) return Promise.resolve(null); + const attr = isNaN(Number(idOrToken)) ? "token" : "id"; + return Invitation.where(attr, idOrToken).fetch(opts); + }, + + create: function (opts) { + return new Invitation({ + invited_by_id: opts.userId, + community_id: opts.communityId, + email: opts.email.toLowerCase(), + tag_id: opts.tagId, + role: GroupMembership.Role[opts.moderator ? "MODERATOR" : "DEFAULT"], + token: uuid.v4(), + created_at: new Date(), + subject: opts.subject, + message: opts.message, + }).save(); + }, + + createAndSend: function ({ invitation }) { + return Invitation.find(invitation.id).then((invitation) => invitation.send() - ) - }, - - reinviteAll: function (opts) { - const { communityId } = opts - return Invitation.where({community_id: communityId, used_by_id: null, expired_by_id: null}) - .fetchAll({withRelated: ['creator', 'community', 'tag']}) - .then(invitations => - Promise.map(invitations.models, invitation => invitation.send())) - }, - - resendAllReady () { - return Invitation.query(q => { - const whereClause = "((sent_count=1 and last_sent_at < now() - interval '4 day') or " + - "(sent_count=2 and last_sent_at < now() - interval '9 day'))" - q.whereRaw(whereClause) - q.whereNull('used_by_id') - q.whereNull('expired_by_id') - }) - .fetchAll({withRelated: ['creator', 'community', 'tag']}) - .tap(invitations => Promise.map(invitations.models, i => i.send())) - .then(invitations => invitations.pluck('id')) + ); + }, + + reinviteAll: function (opts) { + const { communityId } = opts; + return Invitation.where({ + community_id: communityId, + used_by_id: null, + expired_by_id: null, + }) + .fetchAll({ withRelated: ["creator", "community", "tag"] }) + .then((invitations) => + Promise.map(invitations.models, (invitation) => invitation.send()) + ); + }, + + resendAllReady() { + return Invitation.query((q) => { + const whereClause = + "((sent_count=1 and last_sent_at < now() - interval '4 day') or " + + "(sent_count=2 and last_sent_at < now() - interval '9 day'))"; + q.whereRaw(whereClause); + q.whereNull("used_by_id"); + q.whereNull("expired_by_id"); + }) + .fetchAll({ withRelated: ["creator", "community", "tag"] }) + .tap((invitations) => Promise.map(invitations.models, (i) => i.send())) + .then((invitations) => invitations.pluck("id")); + }, } - -}) +); diff --git a/api/models/JoinRequest.js b/api/models/JoinRequest.js index 442f50b1e..94a27f3f0 100644 --- a/api/models/JoinRequest.js +++ b/api/models/JoinRequest.js @@ -1,83 +1,87 @@ -module.exports = bookshelf.Model.extend({ - tableName: 'join_requests', +module.exports = bookshelf.Model.extend( + { + tableName: "join_requests", - user: function () { - return this.belongsTo(User) + user: function () { + return this.belongsTo(User); + }, + + community: function () { + return this.belongsTo(Community); + }, }, + { + create: function (opts) { + return new JoinRequest({ + community_id: opts.communityId, + user_id: opts.userId, + created_at: new Date(), + status: 0, + }) + .save() + .tap(async (request) => { + JoinRequest.afterCreate(request); + }); + }, - community: function () { - return this.belongsTo(Community) - } -}, { + afterCreate: async function (request) { + await request.load(["community", "user"]); + const { community, user } = request.relations; - create: function (opts) { - return new JoinRequest({ - community_id: opts.communityId, - user_id: opts.userId, - created_at: new Date(), - status: 0, - }).save() - .tap(async request => { - JoinRequest.afterCreate(request) - }) - }, + const moderators = await community.moderators().fetch(); - afterCreate: async function (request) { - await request.load(['community', 'user']) - const { community, user } = request.relations + const announcees = moderators.map((moderator) => ({ + actor_id: user.id, + reader_id: moderator.id, + community_id: community.id, + reason: "joinRequest", + })); - const moderators = await community.moderators().fetch() + JoinRequest.sendNotification(announcees); + }, - const announcees = moderators.map(moderator => ({ - actor_id: user.id, - reader_id: moderator.id, - community_id: community.id, - reason: 'joinRequest' - })) - - JoinRequest.sendNotification(announcees) - }, + sendNotification: function (activities = [], opts) { + return Activity.saveForReasons(activities); + }, - sendNotification: function (activities = [], opts) { - return Activity.saveForReasons(activities) - }, + find: async function (id) { + if (!id) return Promise.resolve(null); + return JoinRequest.where({ id }).fetch(); + }, - find: async function (id) { - if (!id) return Promise.resolve(null) - return JoinRequest.where({id}).fetch() - }, + update: function (id, changes, moderatorId) { + const { status } = changes; - update: function (id, changes, moderatorId) { - const { status } = changes; + if (![0, 1, 2].includes(status)) { + return Promise.reject(new Error("Status is invalid")); + } - if (![0, 1, 2].includes(status)) { - return Promise.reject(new Error('Status is invalid')) - } + const attributes = { + updated_at: new Date(), + status, + }; - const attributes = { - updated_at: new Date(), - status - } + const isApproved = status === 1; - const isApproved = status === 1 + return JoinRequest.query() + .where({ id }) + .update(attributes) + .then(() => JoinRequest.find(id)) + .tap(async (request) => { + if (isApproved) { + await request.load(["community", "user"]); + const { community, user } = request.relations; - return JoinRequest.query().where({ id }).update(attributes) - .then(() => JoinRequest.find(id)) - .tap(async request => { - if (isApproved) { - await request.load(['community', 'user']) - const { community, user } = request.relations; + const approvedMember = { + actor_id: moderatorId, + reader_id: user.id, + community_id: community.id, + reason: "approvedJoinRequest", + }; - const approvedMember = { - actor_id: moderatorId, - reader_id: user.id, - community_id: community.id, - reason: 'approvedJoinRequest' + JoinRequest.sendNotification([approvedMember]); } - - JoinRequest.sendNotification([approvedMember]) - } - }) + }); + }, } - -}) +); diff --git a/api/models/LastReadDeprecated.js b/api/models/LastReadDeprecated.js index 0d6b47b34..76dc15a51 100644 --- a/api/models/LastReadDeprecated.js +++ b/api/models/LastReadDeprecated.js @@ -1,28 +1,38 @@ -module.exports = bookshelf.Model.extend({ - tableName: 'posts_users', +module.exports = bookshelf.Model.extend( + { + tableName: "posts_users", - post: function () { - return this.belongsTo(Post) - }, + post: function () { + return this.belongsTo(Post); + }, - user: function () { - return this.belongsTo(User).query({where: {active: true}}) - }, + user: function () { + return this.belongsTo(User).query({ where: { active: true } }); + }, - setToNow: function (trx) { - return this.save({ - last_read_at: new Date() - }, { patch: true, transacting: trx }) - } -}, { - findOrCreate: function (userId, postId, opts = {}) { - const { transacting } = opts - return this.query({where: {user_id: userId, post_id: postId}}) - .fetch() - .then(lastRead => lastRead || new this({ - post_id: postId, - last_read_at: opts.date || new Date(), - user_id: userId - }).save(null, {transacting})) + setToNow: function (trx) { + return this.save( + { + last_read_at: new Date(), + }, + { patch: true, transacting: trx } + ); + }, + }, + { + findOrCreate: function (userId, postId, opts = {}) { + const { transacting } = opts; + return this.query({ where: { user_id: userId, post_id: postId } }) + .fetch() + .then( + (lastRead) => + lastRead || + new this({ + post_id: postId, + last_read_at: opts.date || new Date(), + user_id: userId, + }).save(null, { transacting }) + ); + }, } -}) +); diff --git a/api/models/LinkPreview.js b/api/models/LinkPreview.js index aa728cd1b..a48203347 100644 --- a/api/models/LinkPreview.js +++ b/api/models/LinkPreview.js @@ -1,64 +1,74 @@ -import request from 'request' -import cheerio from 'cheerio' -import { merge } from 'lodash' -import getImageSize from '../services/GetImageSize' +import request from "request"; +import cheerio from "cheerio"; +import { merge } from "lodash"; +import getImageSize from "../services/GetImageSize"; -const httpget = url => new Promise((resolve, reject) => - request.get(url, {gzip: true}, (err, res, body) => - err ? reject(err) : resolve([res, body]))) +const httpget = (url) => + new Promise((resolve, reject) => + request.get(url, { gzip: true }, (err, res, body) => + err ? reject(err) : resolve([res, body]) + ) + ); -const parse = body => { - const $ = cheerio.load(body) - const metaContent = name => $(`meta[property="${name}"]`).attr('content') +const parse = (body) => { + const $ = cheerio.load(body); + const metaContent = (name) => $(`meta[property="${name}"]`).attr("content"); return { - title: metaContent('og:title') || $('title').text(), - description: metaContent('og:description'), - image_url: metaContent('og:image') - } -} - -const LinkPreview = bookshelf.Model.extend({ - tableName: 'link_previews' - -}, { - parse, + title: metaContent("og:title") || $("title").text(), + description: metaContent("og:description"), + image_url: metaContent("og:image"), + }; +}; - queue: url => { - return LinkPreview.forge({url, created_at: new Date()}).save() - .then(({ id }) => Queue.classMethod('LinkPreview', 'populate', {id}, 0)) - .catch(err => { - if (err.message && !err.message.includes('duplicate key value')) { - throw err - } - }) +const LinkPreview = bookshelf.Model.extend( + { + tableName: "link_previews", }, + { + parse, - populate: ({ id }) => { - const doneAttrs = () => ({updated_at: new Date(), done: true}) - return LinkPreview.find(id).then(preview => - httpget(preview.get('url')) - .catch(err => preview.save(doneAttrs()) && null) // eslint-disable-line handle-callback-err - .then(resp => { - if (!resp) return - const body = resp[1] - const attrs = merge(parse(body), doneAttrs()) + queue: (url) => { + return LinkPreview.forge({ url, created_at: new Date() }) + .save() + .then(({ id }) => + Queue.classMethod("LinkPreview", "populate", { id }, 0) + ) + .catch((err) => { + if (err.message && !err.message.includes("duplicate key value")) { + throw err; + } + }); + }, - return (attrs.image_url - ? getImageSize(attrs.image_url).catch(err => null) // eslint-disable-line handle-callback-err - : Promise.resolve()) - .then(size => { - if (!size) return - attrs.image_width = size.width - attrs.image_height = size.height - }) - .then(() => preview.save(attrs, {patch: true})) - })) - }, + populate: ({ id }) => { + const doneAttrs = () => ({ updated_at: new Date(), done: true }); + return LinkPreview.find(id).then((preview) => + httpget(preview.get("url")) + .catch((err) => preview.save(doneAttrs()) && null) // eslint-disable-line handle-callback-err + .then((resp) => { + if (!resp) return; + const body = resp[1]; + const attrs = merge(parse(body), doneAttrs()); + + return (attrs.image_url + ? getImageSize(attrs.image_url).catch((err) => null) // eslint-disable-line handle-callback-err + : Promise.resolve() + ) + .then((size) => { + if (!size) return; + attrs.image_width = size.width; + attrs.image_height = size.height; + }) + .then(() => preview.save(attrs, { patch: true })); + }) + ); + }, - find: (idOrUrl, opts) => { - const attr = isNaN(Number(idOrUrl)) ? 'url' : 'id' - return LinkPreview.where(attr, idOrUrl).fetch(opts) + find: (idOrUrl, opts) => { + const attr = isNaN(Number(idOrUrl)) ? "url" : "id"; + return LinkPreview.where(attr, idOrUrl).fetch(opts); + }, } -}) +); -module.exports = LinkPreview +module.exports = LinkPreview; diff --git a/api/models/LinkedAccount.js b/api/models/LinkedAccount.js index 053609142..7805a9282 100644 --- a/api/models/LinkedAccount.js +++ b/api/models/LinkedAccount.js @@ -1,74 +1,86 @@ -import bcrypt from 'bcrypt' -import Promise from 'bluebird' -import { get, isEmpty, merge, pick } from 'lodash' -const hash = Promise.promisify(bcrypt.hash, bcrypt) +import bcrypt from "bcrypt"; +import Promise from "bluebird"; +import { get, isEmpty, merge, pick } from "lodash"; +const hash = Promise.promisify(bcrypt.hash, bcrypt); -module.exports = bookshelf.Model.extend({ - tableName: 'linked_account', +module.exports = bookshelf.Model.extend( + { + tableName: "linked_account", - user: function () { - return this.belongsTo(User) - }, - - activeUser: function () { - return this.belongsTo(User).query({where: {active: true}}) - }, + user: function () { + return this.belongsTo(User); + }, - updatePassword: function (password, { transacting } = {}) { - return hash(password, 10) - .then(provider_user_id => - this.save({provider_user_id}, {patch: true, transacting})) - } + activeUser: function () { + return this.belongsTo(User).query({ where: { active: true } }); + }, -}, { - create: function (userId, { type, profile, password, token }, { transacting, updateUser } = {}) { - return (() => - type === 'password' - ? hash(password, 10) - : Promise.resolve(null))() - .then(hashed => new LinkedAccount({ - provider_key: type, - provider_user_id: hashed || token || profile.id, - user_id: userId - }).save({}, {transacting})) - .tap(() => updateUser && - this.updateUser(userId, {type, profile, transacting})) + updatePassword: function (password, { transacting } = {}) { + return hash(password, 10).then((provider_user_id) => + this.save({ provider_user_id }, { patch: true, transacting }) + ); + }, }, + { + create: function ( + userId, + { type, profile, password, token }, + { transacting, updateUser } = {} + ) { + return (() => + type === "password" ? hash(password, 10) : Promise.resolve(null))() + .then((hashed) => + new LinkedAccount({ + provider_key: type, + provider_user_id: hashed || token || profile.id, + user_id: userId, + }).save({}, { transacting }) + ) + .tap( + () => + updateUser && + this.updateUser(userId, { type, profile, transacting }) + ); + }, - tokenForUser: function (userId) { - return LinkedAccount.where({ - provider_key: 'token', - user_id: userId - }).fetch() - }, + tokenForUser: function (userId) { + return LinkedAccount.where({ + provider_key: "token", + user_id: userId, + }).fetch(); + }, - updateUser: function (userId, { type, profile, transacting } = {}) { - return User.find(userId, {transacting}) - .then(user => { - var avatarUrl = user.get('avatar_url') - var attributes = this.socialMediaAttributes(type, profile) - if (avatarUrl && !avatarUrl.match(/gravatar/)) { - attributes.avatar_url = avatarUrl - } - return !isEmpty(attributes) && User.query().where('id', userId) - .update(attributes) - .transacting(transacting) - }) - }, - - socialMediaAttributes: function (type, profile) { - switch (type) { - case 'facebook': - return { - facebook_url: profile.profileUrl || get(profile, '_json.link'), - avatar_url: `https://graph.facebook.com/${profile.id}/picture?type=large&access_token=${process.env.FACEBOOK_APP_ID}|${process.env.FACEBOOK_CLIENT_TOKEN}` - } - case 'linkedin': - return { - linkedin_url: profile._json.publicProfileUrl, - avatar_url: get(profile, 'photos.0.value') + updateUser: function (userId, { type, profile, transacting } = {}) { + return User.find(userId, { transacting }).then((user) => { + const avatarUrl = user.get("avatar_url"); + const attributes = this.socialMediaAttributes(type, profile); + if (avatarUrl && !avatarUrl.match(/gravatar/)) { + attributes.avatar_url = avatarUrl; } - } - return {} + return ( + !isEmpty(attributes) && + User.query() + .where("id", userId) + .update(attributes) + .transacting(transacting) + ); + }); + }, + + socialMediaAttributes: function (type, profile) { + switch (type) { + case "facebook": + return { + facebook_url: profile.profileUrl || get(profile, "_json.link"), + avatar_url: `https://graph.facebook.com/${profile.id}/picture?type=large&access_token=${process.env.FACEBOOK_APP_ID}|${process.env.FACEBOOK_CLIENT_TOKEN}`, + }; + case "linkedin": + return { + linkedin_url: profile._json.publicProfileUrl, + avatar_url: get(profile, "photos.0.value"), + }; + } + return {}; + }, } -}) +); diff --git a/api/models/Location.js b/api/models/Location.js index 694dd695b..8cbacc386 100644 --- a/api/models/Location.js +++ b/api/models/Location.js @@ -1,64 +1,95 @@ -import knexPostgis from 'knex-postgis'; -import wkx from 'wkx'; -var Buffer = require('buffer').Buffer; +import knexPostgis from "knex-postgis"; +import wkx from "wkx"; +const Buffer = require("buffer").Buffer; -module.exports = bookshelf.Model.extend({ - tableName: 'locations', +module.exports = bookshelf.Model.extend( + { + tableName: "locations", - format(attributes) { - const st = knexPostgis(bookshelf.knex); + format(attributes) { + const st = knexPostgis(bookshelf.knex); - // Make sure geometry columns go into the database correctly - const { bbox, center } = attributes - if (bbox && bbox[0] && bbox[1]) { - attributes.bbox = st.geomFromText('POLYGON((' + bbox[0].lng + ' ' + bbox[0].lat + ', ' + bbox[0].lng + ' ' + bbox[1].lat + ', ' + bbox[1].lng + ' ' + bbox[1].lat + ', ' + bbox[1].lng + ' ' + bbox[0].lat + ', ' + bbox[0].lng + ' ' + bbox[0].lat + '))', 4326) - } else { - delete attributes.bbox - } + // Make sure geometry columns go into the database correctly + const { bbox, center } = attributes; + if (bbox && bbox[0] && bbox[1]) { + attributes.bbox = st.geomFromText( + "POLYGON((" + + bbox[0].lng + + " " + + bbox[0].lat + + ", " + + bbox[0].lng + + " " + + bbox[1].lat + + ", " + + bbox[1].lng + + " " + + bbox[1].lat + + ", " + + bbox[1].lng + + " " + + bbox[0].lat + + ", " + + bbox[0].lng + + " " + + bbox[0].lat + + "))", + 4326 + ); + } else { + delete attributes.bbox; + } - if (center && center.lng && center.lat) { - attributes.center = st.geomFromText('POINT(' + center.lng + ' ' + center.lat + ')', 4326) - } else { - delete attributes.center - } + if (center && center.lng && center.lat) { + attributes.center = st.geomFromText( + "POINT(" + center.lng + " " + center.lat + ")", + 4326 + ); + } else { + delete attributes.center; + } - return attributes - }, + return attributes; + }, - parse(response) { - const st = knexPostgis(bookshelf.knex) + parse(response) { + const st = knexPostgis(bookshelf.knex); - // Convert geometry hex values into useful objects before returning to the client - if (typeof response.center == 'string') { - const b = new Buffer(response.center, 'hex') - const parsedCenter = wkx.Geometry.parse(b) - response.center = {lng: parsedCenter.x, lat: parsedCenter.y} - } + // Convert geometry hex values into useful objects before returning to the client + if (typeof response.center === "string") { + const b = new Buffer(response.center, "hex"); + const parsedCenter = wkx.Geometry.parse(b); + response.center = { lng: parsedCenter.x, lat: parsedCenter.y }; + } - if (typeof response.bbox == 'string') { - const b = new Buffer(response.bbox, 'hex'); - const parsedBbox = wkx.Geometry.parse(b); - response.bbox = parsedBbox.exteriorRing.map(point => {return { lng: point.x, lat: point.y }}) - } + if (typeof response.bbox === "string") { + const b = new Buffer(response.bbox, "hex"); + const parsedBbox = wkx.Geometry.parse(b); + response.bbox = parsedBbox.exteriorRing.map((point) => { + return { lng: point.x, lat: point.y }; + }); + } - return response - }, + return response; + }, - communities: function () { - return this.hasMany(Community) - }, + communities: function () { + return this.hasMany(Community); + }, - posts: function () { - return this.hasMany(Post) - }, + posts: function () { + return this.hasMany(Post); + }, - users: function () { - return this.hasMany(User) - } - -}, { - create (attrs, { transacting } = {}) { - return this.forge(Object.assign({created_at: new Date(), updated_at: new Date()}, attrs)) - .save({}, { transacting }) + users: function () { + return this.hasMany(User); + }, }, -}) + { + create(attrs, { transacting } = {}) { + return this.forge( + Object.assign({ created_at: new Date(), updated_at: new Date() }, attrs) + ).save({}, { transacting }); + }, + } +); diff --git a/api/models/Media.js b/api/models/Media.js index 2de418645..c20c3031f 100644 --- a/api/models/Media.js +++ b/api/models/Media.js @@ -1,59 +1,50 @@ /* eslint-disable camelcase */ -import GetImageSize from '../services/GetImageSize' -import request from 'request' -import { createAndAddSize } from './media/util' - -module.exports = bookshelf.Model.extend({ - tableName: 'media', - - post: function () { - return this.belongsTo(Post) - }, - - comment: function () { - return this.belongsTo(Comment) - }, - - updateMetadata: function (opts) { - const isVideo = this.get('type') === 'video' - var thumbnail_url = this.get('thumbnail_url') - - return Promise.resolve(isVideo && Media.generateThumbnailUrl(this.get('url'))) - .then(url => { - if (url) thumbnail_url = url - - const urlToMeasure = isVideo ? url : this.get('url') - return GetImageSize(urlToMeasure) - .then(({ width, height }) => - this.save({width, height, thumbnail_url}, Object.assign({patch: true}, opts))) - }) +import GetImageSize from "../services/GetImageSize"; +import request from "request"; +import { createAndAddSize } from "./media/util"; + +module.exports = bookshelf.Model.extend( + { + tableName: "media", + + post: function () { + return this.belongsTo(Post); + }, + + comment: function () { + return this.belongsTo(Comment); + }, + + updateMetadata: function (opts) { + const isVideo = this.get("type") === "video"; + let thumbnail_url = this.get("thumbnail_url"); + + return Promise.resolve( + isVideo && Media.generateThumbnailUrl(this.get("url")) + ).then((url) => { + if (url) thumbnail_url = url; + + const urlToMeasure = isVideo ? url : this.get("url"); + return GetImageSize(urlToMeasure).then(({ width, height }) => + this.save( + { width, height, thumbnail_url }, + Object.assign({ patch: true }, opts) + ) + ); + }); + }, + + createThumbnail: function ({ thumbnailSize, transacting }) { + return AssetManagement.resizeAsset(this, "url", "thumbnail_url", { + width: thumbnailSize, + height: thumbnailSize, + type: "comment", + transacting, + }); + }, }, - - createThumbnail: function ({ thumbnailSize, transacting }) { - return AssetManagement.resizeAsset(this, 'url', 'thumbnail_url', { - width: thumbnailSize, - height: thumbnailSize, - type: 'comment', - transacting - }) - } -}, { - - create: function ({ - post_id, - url, - type, - name, - thumbnail_url, - width, - height, - comment_id, - transacting, - thumbnailSize, - position - }) { - return Media.forge({ - created_at: new Date(), + { + create: function ({ post_id, url, type, @@ -62,66 +53,90 @@ module.exports = bookshelf.Model.extend({ width, height, comment_id, - position - }) - .save(null, { transacting }) - .tap(media => - thumbnailSize && media.createThumbnail({ thumbnailSize, transacting })) - }, - - createForSubject: function ({subjectType, subjectId, type, url, position = 0}, trx) { - const subjectIdKey = `${subjectType.toLowerCase()}_id` - - const mediaAttrs = { - [subjectIdKey]: subjectId, - type, - url, + transacting, + thumbnailSize, position, - transacting: trx - } - - switch (type) { - case 'image': - return createAndAddSize(mediaAttrs) - case 'file': - return Media.create(mediaAttrs) - case 'video': - return this.generateThumbnailUrl(url) - .then(thumbnail_url => createAndAddSize(Object.assign({}, mediaAttrs, { thumbnail_url }))) - } - }, - - createDoc: function (postId, doc, trx) { - return Media.create({ - post_id: postId, - url: doc.url, - type: 'gdoc', - name: doc.name, - thumbnail_url: doc.thumbnail_url, - transacting: trx - }) - }, - - generateThumbnailUrl: videoUrl => { - if (!videoUrl || videoUrl === '') return Promise.resolve() - - if (videoUrl.match(/youtu\.?be/)) { - const videoId = videoUrl.match(/(youtu.be\/|embed\/|\?v=)([A-Za-z0-9\-_]+)/)[2] - const url = `http://img.youtube.com/vi/${videoId}/hqdefault.jpg` - return Promise.resolve(url) - } - - if (videoUrl.match(/vimeo/)) { - const videoId = videoUrl.match(/vimeo\.com\/(\d+)/)[1] - const url = `http://vimeo.com/api/v2/video/${videoId}.json` - return new Promise((resolve, reject) => { - request(url, (err, resp, body) => { - if (err) reject(err) - resolve(JSON.parse(body)[0].thumbnail_large) - }) + }) { + return Media.forge({ + created_at: new Date(), + post_id, + url, + type, + name, + thumbnail_url, + width, + height, + comment_id, + position, }) - } - - return Promise.resolve() + .save(null, { transacting }) + .tap( + (media) => + thumbnailSize && + media.createThumbnail({ thumbnailSize, transacting }) + ); + }, + + createForSubject: function ( + { subjectType, subjectId, type, url, position = 0 }, + trx + ) { + const subjectIdKey = `${subjectType.toLowerCase()}_id`; + + const mediaAttrs = { + [subjectIdKey]: subjectId, + type, + url, + position, + transacting: trx, + }; + + switch (type) { + case "image": + return createAndAddSize(mediaAttrs); + case "file": + return Media.create(mediaAttrs); + case "video": + return this.generateThumbnailUrl(url).then((thumbnail_url) => + createAndAddSize(Object.assign({}, mediaAttrs, { thumbnail_url })) + ); + } + }, + + createDoc: function (postId, doc, trx) { + return Media.create({ + post_id: postId, + url: doc.url, + type: "gdoc", + name: doc.name, + thumbnail_url: doc.thumbnail_url, + transacting: trx, + }); + }, + + generateThumbnailUrl: (videoUrl) => { + if (!videoUrl || videoUrl === "") return Promise.resolve(); + + if (videoUrl.match(/youtu\.?be/)) { + const videoId = videoUrl.match( + /(youtu.be\/|embed\/|\?v=)([A-Za-z0-9\-_]+)/ + )[2]; + const url = `http://img.youtube.com/vi/${videoId}/hqdefault.jpg`; + return Promise.resolve(url); + } + + if (videoUrl.match(/vimeo/)) { + const videoId = videoUrl.match(/vimeo\.com\/(\d+)/)[1]; + const url = `http://vimeo.com/api/v2/video/${videoId}.json`; + return new Promise((resolve, reject) => { + request(url, (err, resp, body) => { + if (err) reject(err); + resolve(JSON.parse(body)[0].thumbnail_large); + }); + }); + } + + return Promise.resolve(); + }, } -}) +); diff --git a/api/models/MembershipDeprecated.js b/api/models/MembershipDeprecated.js index bf51f0d25..8c6db20b8 100644 --- a/api/models/MembershipDeprecated.js +++ b/api/models/MembershipDeprecated.js @@ -1,146 +1,176 @@ /* eslint-disable camelcase */ -import { difference, every, intersection, isEmpty, map, uniq, merge } from 'lodash' -import HasSettings from './mixins/HasSettings' - -module.exports = bookshelf.Model.extend(merge({ - tableName: 'communities_users', - - user: function () { - return this.belongsTo(User) - }, - - community: function () { - return this.belongsTo(Community) - }, - - deactivator: function () { - return this.belongsTo(User, 'deactivator_id') - }, - - hasModeratorRole: function () { - return this.get('role') === this.constructor.MODERATOR_ROLE - } -}, HasSettings), { - DEFAULT_ROLE: 0, - MODERATOR_ROLE: 1, - - find: function (user_id, community_id_or_slug, options) { - if (!user_id || !community_id_or_slug) return Promise.resolve(null) - - var fetch = function (community_id) { - var attrs = {user_id, community_id} - if (!options || !options.includeInactive) attrs.active = true - return this.where(attrs).fetch(options) - } - - if (isNaN(Number(community_id_or_slug))) { - return Community.find(community_id_or_slug) - .then(function (community) { - if (community) return fetch(community.id, options) +import { + difference, + every, + intersection, + isEmpty, + map, + uniq, + merge, +} from "lodash"; +import HasSettings from "./mixins/HasSettings"; + +module.exports = bookshelf.Model.extend( + merge( + { + tableName: "communities_users", + + user: function () { + return this.belongsTo(User); + }, + + community: function () { + return this.belongsTo(Community); + }, + + deactivator: function () { + return this.belongsTo(User, "deactivator_id"); + }, + + hasModeratorRole: function () { + return this.get("role") === this.constructor.MODERATOR_ROLE; + }, + }, + HasSettings + ), + { + DEFAULT_ROLE: 0, + MODERATOR_ROLE: 1, + + find: function (user_id, community_id_or_slug, options) { + if (!user_id || !community_id_or_slug) return Promise.resolve(null); + + const fetch = function (community_id) { + const attrs = { user_id, community_id }; + if (!options || !options.includeInactive) attrs.active = true; + return this.where(attrs).fetch(options); + }; + + if (isNaN(Number(community_id_or_slug))) { + return Community.find(community_id_or_slug).then(function (community) { + if (community) return fetch(community.id, options); + }); + } + + return fetch(community_id_or_slug); + }, + + create: function (userId, communityId, opts) { + if (!opts) opts = {}; + + return this.forge({ + user_id: userId, + community_id: communityId, + created_at: new Date(), + settings: { send_email: true, send_push_notifications: true }, + last_viewed_at: new Date(), + active: true, + role: opts.role || this.DEFAULT_ROLE, }) - } - - return fetch(community_id_or_slug) - }, - - create: function (userId, communityId, opts) { - if (!opts) opts = {} - - return this.forge({ - user_id: userId, - community_id: communityId, - created_at: new Date(), - settings: {send_email: true, send_push_notifications: true}, - last_viewed_at: new Date(), - active: true, - role: opts.role || this.DEFAULT_ROLE - }) - .save({}, {transacting: opts.transacting}) - .tap(() => User.followDefaultTags(userId, communityId, opts.transacting)) - .tap(() => Community.find(communityId, {transacting: opts.transacting}) - .tap(community => community.save({ - num_members: community.get('num_members') + 1 - }, {patch: true, transacting: opts.transacting})) - .then(community => Analytics.track({ - userId: userId, - event: 'Joined community', - properties: {id: communityId, slug: community.get('slug')} - }))) - }, - - setModeratorRole: function (user_id, community_id) { - return bookshelf.knex('communities_users').where({ - user_id: user_id, - community_id: community_id - }).update({role: this.MODERATOR_ROLE}) - }, - - removeModeratorRole: function (user_id, community_id) { - return bookshelf.knex('communities_users').where({ - user_id: user_id, - community_id: community_id - }).update({role: this.DEFAULT_ROLE}) - }, - - hasModeratorRole: function (user_id, community_id) { - return this.find(user_id, community_id) - .then(ms => ms && ms.hasModeratorRole()) - }, - - updateLastViewedAt: function (user_id, community_id) { - return bookshelf.knex('communities_users').where({ - user_id: user_id, - community_id: community_id - }).update({ - last_viewed_at: new Date(), - new_post_count: 0 - }) - }, - - // do all of the users have at least one community in common? - inSameCommunity: function (userIds) { - return this.sharedCommunityIds(userIds) - .then(ids => ids.length > 0) - }, - - sharedCommunityIds: function (userIds) { - userIds = uniq(userIds) - return bookshelf.knex - .select('community_id') - .count('*') - .from('communities_users') - .whereIn('user_id', userIds) - .groupBy('community_id') - .havingRaw('count(*) = ?', [userIds.length]) - .then(rows => map(rows, 'community_id')) - }, - - inSameNetwork: function (userId, otherUserId) { - return Network.idsForUser(userId) - .then(ids => { - if (isEmpty(ids)) return false - - return Network.idsForUser(otherUserId) - .then(otherIds => !isEmpty(intersection(ids, otherIds))) - }) - }, - - activeCommunityIds: function (user_id, moderator) { - if (!user_id) return Promise.resolve([]) - var query = {user_id: user_id, active: true} - if (moderator) { - query.role = this.MODERATOR_ROLE - } - return this.query() - .where(query) - .pluck('community_id') - }, - - lastViewed: userId => - Membership.query(q => { - q.where('user_id', userId) - q.limit(1) - q.orderBy('last_viewed_at', 'desc') - }) - -}) + .save({}, { transacting: opts.transacting }) + .tap(() => + User.followDefaultTags(userId, communityId, opts.transacting) + ) + .tap(() => + Community.find(communityId, { transacting: opts.transacting }) + .tap((community) => + community.save( + { + num_members: community.get("num_members") + 1, + }, + { patch: true, transacting: opts.transacting } + ) + ) + .then((community) => + Analytics.track({ + userId: userId, + event: "Joined community", + properties: { id: communityId, slug: community.get("slug") }, + }) + ) + ); + }, + + setModeratorRole: function (user_id, community_id) { + return bookshelf + .knex("communities_users") + .where({ + user_id: user_id, + community_id: community_id, + }) + .update({ role: this.MODERATOR_ROLE }); + }, + + removeModeratorRole: function (user_id, community_id) { + return bookshelf + .knex("communities_users") + .where({ + user_id: user_id, + community_id: community_id, + }) + .update({ role: this.DEFAULT_ROLE }); + }, + + hasModeratorRole: function (user_id, community_id) { + return this.find(user_id, community_id).then( + (ms) => ms && ms.hasModeratorRole() + ); + }, + + updateLastViewedAt: function (user_id, community_id) { + return bookshelf + .knex("communities_users") + .where({ + user_id: user_id, + community_id: community_id, + }) + .update({ + last_viewed_at: new Date(), + new_post_count: 0, + }); + }, + + // do all of the users have at least one community in common? + inSameCommunity: function (userIds) { + return this.sharedCommunityIds(userIds).then((ids) => ids.length > 0); + }, + + sharedCommunityIds: function (userIds) { + userIds = uniq(userIds); + return bookshelf.knex + .select("community_id") + .count("*") + .from("communities_users") + .whereIn("user_id", userIds) + .groupBy("community_id") + .havingRaw("count(*) = ?", [userIds.length]) + .then((rows) => map(rows, "community_id")); + }, + + inSameNetwork: function (userId, otherUserId) { + return Network.idsForUser(userId).then((ids) => { + if (isEmpty(ids)) return false; + + return Network.idsForUser(otherUserId).then( + (otherIds) => !isEmpty(intersection(ids, otherIds)) + ); + }); + }, + + activeCommunityIds: function (user_id, moderator) { + if (!user_id) return Promise.resolve([]); + const query = { user_id: user_id, active: true }; + if (moderator) { + query.role = this.MODERATOR_ROLE; + } + return this.query().where(query).pluck("community_id"); + }, + + lastViewed: (userId) => + Membership.query((q) => { + q.where("user_id", userId); + q.limit(1); + q.orderBy("last_viewed_at", "desc"); + }), + } +); diff --git a/api/models/Network.js b/api/models/Network.js index 59dd212fa..3dae1abf5 100644 --- a/api/models/Network.js +++ b/api/models/Network.js @@ -1,83 +1,105 @@ -import { includes } from 'lodash' -import HasGroup from './mixins/HasGroup' - -var knex = bookshelf.knex - -var networkIdsQuery = function (userId) { - const communityIdsQuery = Group.pluckIdsForMember(userId, Community) - - return knex.select().distinct('network_id').from('communities') - .whereIn('id', communityIdsQuery).whereRaw('network_id is not null') -} - -module.exports = bookshelf.Model.extend(Object.assign({ - tableName: 'networks', - - communities: function () { - return this.hasMany(Community).query({where: {'communities.active': true}}) - }, - - moderators: function () { - return this.belongsToMany(User, 'networks_users', 'network_id', 'user_id') - .query({where: {role: GroupMembership.Role.MODERATOR}}) - }, - - members: function () { - return User.collection().query(q => { - q.distinct() - q.join('group_memberships', 'users.id', 'group_memberships.user_id') - q.join('groups', 'group_memberships.group_id', 'groups.id') - q.join('communities', 'groups.group_data_id', 'communities.id') - q.where({ - 'group_memberships.active': true, - 'groups.group_data_type': Group.DataType.COMMUNITY, - 'communities.network_id': this.id - }) - }) - }, - - async memberCount () { - const communityIds = await Community.where({ - network_id: this.id, - active: true - }) - .query().pluck('id') - - return GroupMembership.forIds(null, communityIds, Community).query() - .select(bookshelf.knex.raw('count(distinct user_id) as total')) - .then(rows => Number(rows[0].total)) - }, - - posts: function () { - return this.belongsToMany(Post).through(PostNetworkMembership) - .query({where: {'posts.active': true}}) - } -}, HasGroup), { - - find: function (idOrSlug, options) { - if (isNaN(Number(idOrSlug))) { - return this.where({slug: idOrSlug}).fetch(options) - } - return this.where({id: idOrSlug}).fetch(options) - }, - - containsUser: function (networkId, userId) { - if (!networkId || !userId) return Promise.resolve(false) - return this.idsForUser(userId) - .then(ids => includes(ids, networkId.toString())) - }, - - activeCommunityIds: function (userId, rawQuery) { - var query = knex.select('id').from('communities') - .where(inner => - inner.whereIn('network_id', networkIdsQuery(userId)) - .andWhere('communities.hidden', false)) - - return rawQuery ? query : query.pluck('id') - }, - - idsForUser: function (userId) { - return networkIdsQuery(userId).pluck('network_id') +import { includes } from "lodash"; +import HasGroup from "./mixins/HasGroup"; + +const knex = bookshelf.knex; + +const networkIdsQuery = function (userId) { + const communityIdsQuery = Group.pluckIdsForMember(userId, Community); + + return knex + .select() + .distinct("network_id") + .from("communities") + .whereIn("id", communityIdsQuery) + .whereRaw("network_id is not null"); +}; + +module.exports = bookshelf.Model.extend( + Object.assign( + { + tableName: "networks", + + communities: function () { + return this.hasMany(Community).query({ + where: { "communities.active": true }, + }); + }, + + moderators: function () { + return this.belongsToMany( + User, + "networks_users", + "network_id", + "user_id" + ).query({ where: { role: GroupMembership.Role.MODERATOR } }); + }, + + members: function () { + return User.collection().query((q) => { + q.distinct(); + q.join("group_memberships", "users.id", "group_memberships.user_id"); + q.join("groups", "group_memberships.group_id", "groups.id"); + q.join("communities", "groups.group_data_id", "communities.id"); + q.where({ + "group_memberships.active": true, + "groups.group_data_type": Group.DataType.COMMUNITY, + "communities.network_id": this.id, + }); + }); + }, + + async memberCount() { + const communityIds = await Community.where({ + network_id: this.id, + active: true, + }) + .query() + .pluck("id"); + + return GroupMembership.forIds(null, communityIds, Community) + .query() + .select(bookshelf.knex.raw("count(distinct user_id) as total")) + .then((rows) => Number(rows[0].total)); + }, + + posts: function () { + return this.belongsToMany(Post) + .through(PostNetworkMembership) + .query({ where: { "posts.active": true } }); + }, + }, + HasGroup + ), + { + find: function (idOrSlug, options) { + if (isNaN(Number(idOrSlug))) { + return this.where({ slug: idOrSlug }).fetch(options); + } + return this.where({ id: idOrSlug }).fetch(options); + }, + + containsUser: function (networkId, userId) { + if (!networkId || !userId) return Promise.resolve(false); + return this.idsForUser(userId).then((ids) => + includes(ids, networkId.toString()) + ); + }, + + activeCommunityIds: function (userId, rawQuery) { + const query = knex + .select("id") + .from("communities") + .where((inner) => + inner + .whereIn("network_id", networkIdsQuery(userId)) + .andWhere("communities.hidden", false) + ); + + return rawQuery ? query : query.pluck("id"); + }, + + idsForUser: function (userId) { + return networkIdsQuery(userId).pluck("network_id"); + }, } - -}) +); diff --git a/api/models/NetworkMembership.js b/api/models/NetworkMembership.js index 4e7fa878c..116dba9bf 100644 --- a/api/models/NetworkMembership.js +++ b/api/models/NetworkMembership.js @@ -1,53 +1,74 @@ -module.exports = bookshelf.Model.extend({ - tableName: 'networks_users', +module.exports = bookshelf.Model.extend( + { + tableName: "networks_users", - user: function () { - return this.belongsTo(User) - }, - - network: function () { - return this.belongsTo(Network) - } -}, { - DEFAULT_ROLE: 0, - MODERATOR_ROLE: 1, - ADMIN_ROLE: 2, + user: function () { + return this.belongsTo(User); + }, - addModerator: function (userId, networkId, opts = {}) { - return addMemberWithRole(userId, networkId, NetworkMembership.MODERATOR_ROLE, opts) + network: function () { + return this.belongsTo(Network); + }, }, + { + DEFAULT_ROLE: 0, + MODERATOR_ROLE: 1, + ADMIN_ROLE: 2, - addAdmin: function (userId, networkId, opts = {}) { - return addMemberWithRole(userId, networkId, NetworkMembership.ADMIN_ROLE, opts) - }, + addModerator: function (userId, networkId, opts = {}) { + return addMemberWithRole( + userId, + networkId, + NetworkMembership.MODERATOR_ROLE, + opts + ); + }, - hasModeratorRole: function (userId, networkId) { - return NetworkMembership.where({ - user_id: userId, - network_id: networkId - }).fetch() - .then(membership => { - return !!membership && (membership.get('role') === NetworkMembership.MODERATOR_ROLE || - membership.get('role') === NetworkMembership.ADMIN_ROLE) - }) - }, + addAdmin: function (userId, networkId, opts = {}) { + return addMemberWithRole( + userId, + networkId, + NetworkMembership.ADMIN_ROLE, + opts + ); + }, + + hasModeratorRole: function (userId, networkId) { + return NetworkMembership.where({ + user_id: userId, + network_id: networkId, + }) + .fetch() + .then((membership) => { + return ( + !!membership && + (membership.get("role") === NetworkMembership.MODERATOR_ROLE || + membership.get("role") === NetworkMembership.ADMIN_ROLE) + ); + }); + }, - hasAdminRole: function (userId, networkId) { - return NetworkMembership.where({ - user_id: userId, - network_id: networkId - }).fetch() - .then(membership => { - return !!membership && membership.get('role') === NetworkMembership.ADMIN_ROLE - }) + hasAdminRole: function (userId, networkId) { + return NetworkMembership.where({ + user_id: userId, + network_id: networkId, + }) + .fetch() + .then((membership) => { + return ( + !!membership && + membership.get("role") === NetworkMembership.ADMIN_ROLE + ); + }); + }, } -}) +); -export function addMemberWithRole (userId, networkId, role, opts = {}) { +export function addMemberWithRole(userId, networkId, role, opts = {}) { return new NetworkMembership({ user_id: userId, network_id: networkId, created_at: new Date(), - role - }).save({}, opts) + role, + }).save({}, opts); } diff --git a/api/models/NexudusAccount.js b/api/models/NexudusAccount.js index 79f58aaf1..957f3edee 100644 --- a/api/models/NexudusAccount.js +++ b/api/models/NexudusAccount.js @@ -1,13 +1,14 @@ -module.exports = bookshelf.Model.extend({ - tableName: 'nexudus_accounts', +module.exports = bookshelf.Model.extend( + { + tableName: "nexudus_accounts", - community: function () { - return this.belongsTo(Community) - }, - - decryptedPassword: function () { - return this.get('password') - } -}, { + community: function () { + return this.belongsTo(Community); + }, -}) + decryptedPassword: function () { + return this.get("password"); + }, + }, + {} +); diff --git a/api/models/Notification.js b/api/models/Notification.js index 0a4c58e38..9abcfab10 100644 --- a/api/models/Notification.js +++ b/api/models/Notification.js @@ -1,604 +1,830 @@ -import url from 'url' -import { isEmpty } from 'lodash' -import { get, includes } from 'lodash/fp' -import decode from 'ent/decode' -import { refineOne } from './util/relations' -import rollbar from '../../lib/rollbar' -import { broadcast, userRoom } from '../services/Websockets' +import url from "url"; +import { isEmpty } from "lodash"; +import { get, includes } from "lodash/fp"; +import decode from "ent/decode"; +import { refineOne } from "./util/relations"; +import rollbar from "../../lib/rollbar"; +import { broadcast, userRoom } from "../services/Websockets"; const TYPE = { - Mention: 'mention', // you are mentioned in a post or comment - TagFollow: 'TagFollow', - NewPost: 'newPost', - Comment: 'comment', // someone makes a comment on a post you follow - Contribution: 'contribution', // you are added as a contributor - FollowAdd: 'followAdd', // you are added as a follower - Follow: 'follow', // someone follows your post - Unfollow: 'unfollow', // someone leaves your post - Welcome: 'welcome', // a welcome post - JoinRequest: 'joinRequest', - ApprovedJoinRequest: 'approvedJoinRequest', - Message: 'message', - Announcement: 'announcement', - DonationTo: 'donation to', - DonationFrom: 'donation from' -} + Mention: "mention", // you are mentioned in a post or comment + TagFollow: "TagFollow", + NewPost: "newPost", + Comment: "comment", // someone makes a comment on a post you follow + Contribution: "contribution", // you are added as a contributor + FollowAdd: "followAdd", // you are added as a follower + Follow: "follow", // someone follows your post + Unfollow: "unfollow", // someone leaves your post + Welcome: "welcome", // a welcome post + JoinRequest: "joinRequest", + ApprovedJoinRequest: "approvedJoinRequest", + Message: "message", + Announcement: "announcement", + DonationTo: "donation to", + DonationFrom: "donation from", +}; const MEDIUM = { InApp: 0, Push: 1, - Email: 2 -} - -module.exports = bookshelf.Model.extend({ - tableName: 'notifications', - - activity: function () { - return this.belongsTo(Activity) - }, - - post: function () { - return this.relations.activity.relations.post - }, - - comment: function () { - return this.relations.activity.relations.comment - }, - - reader: function () { - return this.relations.activity.relations.reader - }, - - actor: function () { - return this.relations.activity.relations.actor - }, - - projectContribution: function () { - return this.relations.activity.relations.projectContribution - }, - - send: function () { - var action - return this.shouldBeBlocked() - .then(shouldBeBlocked => { - if (shouldBeBlocked) { - this.destroy() - return Promise.resolve() + Email: 2, +}; + +module.exports = bookshelf.Model.extend( + { + tableName: "notifications", + + activity: function () { + return this.belongsTo(Activity); + }, + + post: function () { + return this.relations.activity.relations.post; + }, + + comment: function () { + return this.relations.activity.relations.comment; + }, + + reader: function () { + return this.relations.activity.relations.reader; + }, + + actor: function () { + return this.relations.activity.relations.actor; + }, + + projectContribution: function () { + return this.relations.activity.relations.projectContribution; + }, + + send: function () { + let action; + return this.shouldBeBlocked().then((shouldBeBlocked) => { + if (shouldBeBlocked) { + this.destroy(); + return Promise.resolve(); + } + switch (this.get("medium")) { + case MEDIUM.Push: + action = this.sendPush(); + break; + case MEDIUM.Email: + action = this.sendEmail(); + break; + case MEDIUM.InApp: + const userId = this.reader().id; + action = User.incNewNotificationCount(userId).then(() => + this.updateUserSocketRoom(userId) + ); + break; + } + if (action) { + return action.then(() => + this.save({ sent_at: new Date().toISOString() }) + ); + } else { + return Promise.resolve(); + } + }); + }, + + sendPush: function () { + switch ( + Notification.priorityReason(this.relations.activity.get("meta").reasons) + ) { + case "eventInvitation": + return this.sendEventInvitationPush(); + case "mention": + return this.sendPostPush("mention"); + case "commentMention": + return this.sendCommentPush("mention"); + case "newComment": + return this.sendCommentPush(); + case "newContribution": + return this.sendContributionPush(); + case "newPost": + return this.sendPostPush(); + case "joinRequest": + return this.sendJoinRequestPush(); + case "approvedJoinRequest": + return this.sendApprovedJoinRequestPush(); + case "announcement": + return this.sendPushAnnouncement(); + case "donation to": + return this.sendPushDonationTo(); + case "donation from": + return this.sendPushDonationFrom(); + default: + return Promise.resolve(); } - switch (this.get('medium')) { - case MEDIUM.Push: - action = this.sendPush() - break - case MEDIUM.Email: - action = this.sendEmail() - break - case MEDIUM.InApp: - const userId = this.reader().id - action = User.incNewNotificationCount(userId) - .then(() => this.updateUserSocketRoom(userId)) - break + }, + + sendEventInvitationPush: function () { + const post = this.post(); + const actor = this.actor(); + const communityIds = Activity.communityIds(this.relations.activity); + if (isEmpty(communityIds)) + throw new Error("no community ids in activity"); + return Community.find(communityIds[0]).then((community) => { + const path = url.parse(Frontend.Route.post(post, community)).path; + const alertText = PushNotification.textForEventInvitation(post, actor); + return this.reader().sendPushNotification(alertText, path); + }); + }, + + sendPushAnnouncement: function (version) { + const post = this.post(); + const communityIds = Activity.communityIds(this.relations.activity); + if (isEmpty(communityIds)) + throw new Error("no community ids in activity"); + return Community.find(communityIds[0]).then((community) => { + const path = url.parse(Frontend.Route.post(post, community)).path; + const alertText = PushNotification.textForAnnouncement(post); + return this.reader().sendPushNotification(alertText, path); + }); + }, + + sendPostPush: function (version) { + const post = this.post(); + const communityIds = Activity.communityIds(this.relations.activity); + if (isEmpty(communityIds)) + throw new Error("no community ids in activity"); + return Community.find(communityIds[0]).then((community) => { + const path = url.parse(Frontend.Route.post(post, community)).path; + const alertText = PushNotification.textForPost( + post, + community, + this.relations.activity.get("reader_id"), + version + ); + return this.reader().sendPushNotification(alertText, path); + }); + }, + + sendContributionPush: function (version) { + return this.load(["contribution", "contribution.post"]).then(() => { + const { contribution } = this.relations.activity.relations; + const path = url.parse(Frontend.Route.post(contribution.relations.post)) + .path; + const alertText = PushNotification.textForContribution( + contribution, + version + ); + return this.reader().sendPushNotification(alertText, path); + }); + }, + + sendCommentPush: function (version) { + const comment = this.comment(); + const path = url.parse(Frontend.Route.post(comment.relations.post)).path; + const alertText = PushNotification.textForComment(comment, version); + if (!this.reader().enabledNotification(TYPE.Comment, MEDIUM.Push)) { + return Promise.resolve(); } - if (action) { - return action - .then(() => this.save({'sent_at': (new Date()).toISOString()})) - } else { - return Promise.resolve() + return this.reader().sendPushNotification(alertText, path); + }, + + sendJoinRequestPush: function () { + const communityIds = Activity.communityIds(this.relations.activity); + if (isEmpty(communityIds)) + throw new Error("no community ids in activity"); + return Community.find(communityIds[0]).then((community) => { + const path = url.parse(Frontend.Route.communityJoinRequests(community)) + .path; + const alertText = PushNotification.textForJoinRequest( + community, + this.actor() + ); + return this.reader().sendPushNotification(alertText, path); + }); + }, + + sendApprovedJoinRequestPush: function () { + const communityIds = Activity.communityIds(this.relations.activity); + if (isEmpty(communityIds)) + throw new Error("no community ids in activity"); + return Community.find(communityIds[0]).then((community) => { + const path = url.parse(Frontend.Route.community(community)).path; + const alertText = PushNotification.textForApprovedJoinRequest( + community, + this.actor() + ); + return this.reader().sendPushNotification(alertText, path); + }); + }, + + sendPushDonationTo: async function () { + await this.load([ + "activity.reader", + "activity.projectContribution", + "activity.projectContribution.project", + "activity.projectContribution.user", + ]); + const projectContribution = this.projectContribution(); + const path = url.parse( + Frontend.Route.post(projectContribution.relations.project) + ).path; + const alertText = PushNotification.textForDonationTo(projectContribution); + return this.reader().sendPushNotification(alertText, path); + }, + + sendPushDonationFrom: async function () { + await this.load([ + "activity.reader", + "activity.projectContribution", + "activity.projectContribution.project", + "activity.projectContribution.user", + ]); + const projectContribution = this.projectContribution(); + const path = url.parse( + Frontend.Route.post(projectContribution.relations.project) + ).path; + const alertText = PushNotification.textForDonationFrom( + projectContribution + ); + return this.reader().sendPushNotification(alertText, path); + }, + + sendEmail: function () { + switch ( + Notification.priorityReason(this.relations.activity.get("meta").reasons) + ) { + case "mention": + return this.sendPostMentionEmail(); + case "joinRequest": + return this.sendJoinRequestEmail(); + case "approvedJoinRequest": + return this.sendApprovedJoinRequestEmail(); + case "announcement": + return this.sendAnnouncementEmail(); + case "donation to": + return this.sendDonationToEmail(); + case "donation from": + return this.sendDonationFromEmail(); + case "eventInvitation": + return this.sendEventInvitationEmail(); + default: + return Promise.resolve(); } - }) - }, - - sendPush: function () { - switch (Notification.priorityReason(this.relations.activity.get('meta').reasons)) { - case 'eventInvitation': - return this.sendEventInvitationPush() - case 'mention': - return this.sendPostPush('mention') - case 'commentMention': - return this.sendCommentPush('mention') - case 'newComment': - return this.sendCommentPush() - case 'newContribution': - return this.sendContributionPush() - case 'newPost': - return this.sendPostPush() - case 'joinRequest': - return this.sendJoinRequestPush() - case 'approvedJoinRequest': - return this.sendApprovedJoinRequestPush() - case 'announcement': - return this.sendPushAnnouncement() - case 'donation to': - return this.sendPushDonationTo() - case 'donation from': - return this.sendPushDonationFrom() - default: - return Promise.resolve() - } - }, - - sendEventInvitationPush: function () { - const post = this.post() - const actor = this.actor() - const communityIds = Activity.communityIds(this.relations.activity) - if (isEmpty(communityIds)) throw new Error('no community ids in activity') - return Community.find(communityIds[0]) - .then(community => { - var path = url.parse(Frontend.Route.post(post, community)).path - var alertText = PushNotification.textForEventInvitation(post, actor) - return this.reader().sendPushNotification(alertText, path) - }) - }, - - sendPushAnnouncement: function (version) { - var post = this.post() - var communityIds = Activity.communityIds(this.relations.activity) - if (isEmpty(communityIds)) throw new Error('no community ids in activity') - return Community.find(communityIds[0]) - .then(community => { - var path = url.parse(Frontend.Route.post(post, community)).path - var alertText = PushNotification.textForAnnouncement(post) - return this.reader().sendPushNotification(alertText, path) - }) - }, - - sendPostPush: function (version) { - var post = this.post() - var communityIds = Activity.communityIds(this.relations.activity) - if (isEmpty(communityIds)) throw new Error('no community ids in activity') - return Community.find(communityIds[0]) - .then(community => { - var path = url.parse(Frontend.Route.post(post, community)).path - var alertText = PushNotification.textForPost(post, community, this.relations.activity.get('reader_id'), version) - return this.reader().sendPushNotification(alertText, path) - }) - }, - - sendContributionPush: function (version) { - return this.load(['contribution', 'contribution.post']) - .then(() => { - const { contribution } = this.relations.activity.relations - var path = url.parse(Frontend.Route.post(contribution.relations.post)).path - var alertText = PushNotification.textForContribution(contribution, version) - return this.reader().sendPushNotification(alertText, path) - }) - }, - - sendCommentPush: function (version) { - var comment = this.comment() - var path = url.parse(Frontend.Route.post(comment.relations.post)).path - var alertText = PushNotification.textForComment(comment, version) - if (!this.reader().enabledNotification(TYPE.Comment, MEDIUM.Push)) { - return Promise.resolve() - } - return this.reader().sendPushNotification(alertText, path) - }, - - sendJoinRequestPush: function () { - var communityIds = Activity.communityIds(this.relations.activity) - if (isEmpty(communityIds)) throw new Error('no community ids in activity') - return Community.find(communityIds[0]) - .then(community => { - var path = url.parse(Frontend.Route.communityJoinRequests(community)).path - var alertText = PushNotification.textForJoinRequest(community, this.actor()) - return this.reader().sendPushNotification(alertText, path) - }) - }, - - sendApprovedJoinRequestPush: function () { - var communityIds = Activity.communityIds(this.relations.activity) - if (isEmpty(communityIds)) throw new Error('no community ids in activity') - return Community.find(communityIds[0]) - .then(community => { - var path = url.parse(Frontend.Route.community(community)).path - var alertText = PushNotification.textForApprovedJoinRequest(community, this.actor()) - return this.reader().sendPushNotification(alertText, path) - }) - }, - - sendPushDonationTo: async function () { - await this.load(['activity.reader', 'activity.projectContribution', 'activity.projectContribution.project', 'activity.projectContribution.user']) - var projectContribution = this.projectContribution() - var path = url.parse(Frontend.Route.post(projectContribution.relations.project)).path - var alertText = PushNotification.textForDonationTo(projectContribution) - return this.reader().sendPushNotification(alertText, path) - }, - - sendPushDonationFrom: async function () { - await this.load(['activity.reader', 'activity.projectContribution', 'activity.projectContribution.project', 'activity.projectContribution.user']) - var projectContribution = this.projectContribution() - var path = url.parse(Frontend.Route.post(projectContribution.relations.project)).path - var alertText = PushNotification.textForDonationFrom(projectContribution) - return this.reader().sendPushNotification(alertText, path) - }, - - sendEmail: function () { - switch (Notification.priorityReason(this.relations.activity.get('meta').reasons)) { - case 'mention': - return this.sendPostMentionEmail() - case 'joinRequest': - return this.sendJoinRequestEmail() - case 'approvedJoinRequest': - return this.sendApprovedJoinRequestEmail() - case 'announcement': - return this.sendAnnouncementEmail() - case 'donation to': - return this.sendDonationToEmail() - case 'donation from': - return this.sendDonationFromEmail() - case 'eventInvitation': - return this.sendEventInvitationEmail() - default: - return Promise.resolve() - } - }, - - sendAnnouncementEmail: function () { - var post = this.post() - var reader = this.reader() - var user = post.relations.user - var description = RichText.qualifyLinks(post.get('description')) - var replyTo = Email.postReplyAddress(post.id, reader.id) - - var communityIds = Activity.communityIds(this.relations.activity) - if (isEmpty(communityIds)) throw new Error('no community ids in activity') - return Community.find(communityIds[0]) - .then(community => reader.generateToken() - .then(token => Email.sendAnnouncementNotification({ - email: reader.get('email'), - sender: { - address: replyTo, - reply_to: replyTo, - name: `${user.get('name')} (via Hylo)` - }, - data: { - community_name: community.get('name'), - post_user_name: user.get('name'), - post_user_avatar_url: Frontend.Route.tokenLogin(reader, token, - user.get('avatar_url') + '?ctt=announcement_email&cti=' + reader.id), - post_user_profile_url: Frontend.Route.tokenLogin(reader, token, - Frontend.Route.profile(user) + '?ctt=announcement_email&cti=' + reader.id), - post_description: description, - post_title: decode(post.get('name')), - post_url: Frontend.Route.tokenLogin(reader, token, - Frontend.Route.post(post, community) + '?ctt=announcement_email&cti=' + reader.id), - unfollow_url: Frontend.Route.tokenLogin(reader, token, - Frontend.Route.unfollow(post, community) + '?ctt=announcement_email&cti=' + reader.id), - tracking_pixel_url: Analytics.pixelUrl('Announcement', {userId: reader.id}) + }, + + sendAnnouncementEmail: function () { + const post = this.post(); + const reader = this.reader(); + const user = post.relations.user; + const description = RichText.qualifyLinks(post.get("description")); + const replyTo = Email.postReplyAddress(post.id, reader.id); + + const communityIds = Activity.communityIds(this.relations.activity); + if (isEmpty(communityIds)) + throw new Error("no community ids in activity"); + return Community.find(communityIds[0]).then((community) => + reader.generateToken().then((token) => + Email.sendAnnouncementNotification({ + email: reader.get("email"), + sender: { + address: replyTo, + reply_to: replyTo, + name: `${user.get("name")} (via Hylo)`, + }, + data: { + community_name: community.get("name"), + post_user_name: user.get("name"), + post_user_avatar_url: Frontend.Route.tokenLogin( + reader, + token, + user.get("avatar_url") + + "?ctt=announcement_email&cti=" + + reader.id + ), + post_user_profile_url: Frontend.Route.tokenLogin( + reader, + token, + Frontend.Route.profile(user) + + "?ctt=announcement_email&cti=" + + reader.id + ), + post_description: description, + post_title: decode(post.get("name")), + post_url: Frontend.Route.tokenLogin( + reader, + token, + Frontend.Route.post(post, community) + + "?ctt=announcement_email&cti=" + + reader.id + ), + unfollow_url: Frontend.Route.tokenLogin( + reader, + token, + Frontend.Route.unfollow(post, community) + + "?ctt=announcement_email&cti=" + + reader.id + ), + tracking_pixel_url: Analytics.pixelUrl("Announcement", { + userId: reader.id, + }), + }, + }) + ) + ); + }, + + sendPostMentionEmail: function () { + const post = this.post(); + const reader = this.reader(); + const user = post.relations.user; + const description = RichText.qualifyLinks(post.get("description")); + const replyTo = Email.postReplyAddress(post.id, reader.id); + + const communityIds = Activity.communityIds(this.relations.activity); + if (isEmpty(communityIds)) + throw new Error("no community ids in activity"); + return Community.find(communityIds[0]).then((community) => + reader.generateToken().then((token) => + Email.sendPostMentionNotification({ + email: reader.get("email"), + sender: { + address: replyTo, + reply_to: replyTo, + name: `${user.get("name")} (via Hylo)`, + }, + data: { + community_name: community.get("name"), + post_user_name: user.get("name"), + post_user_avatar_url: Frontend.Route.tokenLogin( + reader, + token, + user.get("avatar_url") + + "?ctt=post_mention_email&cti=" + + reader.id + ), + post_user_profile_url: Frontend.Route.tokenLogin( + reader, + token, + Frontend.Route.profile(user) + + "?ctt=post_mention_email&cti=" + + reader.id + ), + post_description: description, + post_title: decode(post.get("name")), + post_type: "conversation", + post_url: Frontend.Route.tokenLogin( + reader, + token, + Frontend.Route.post(post) + + "?ctt=post_mention_email&cti=" + + reader.id + ), + unfollow_url: Frontend.Route.tokenLogin( + reader, + token, + Frontend.Route.unfollow(post, community) + + "?ctt=post_mention_email&cti=" + + reader.id + ), + tracking_pixel_url: Analytics.pixelUrl("Mention in Post", { + userId: reader.id, + }), + }, + }) + ) + ); + }, + + // version corresponds to names of versions in SendWithUs + sendCommentNotificationEmail: function (version) { + const comment = this.comment(); + const reader = this.reader(); + if (!comment) return; + + const post = comment.relations.post; + const commenter = comment.relations.user; + const text = RichText.qualifyLinks(comment.get("text")); + const replyTo = Email.postReplyAddress(post.id, reader.id); + const title = decode(post.get("name")); + + let postLabel = `"${title}"`; + if (post.get("type") === "welcome") { + const relatedUser = post.relations.relatedUsers.first(); + if (relatedUser.id === reader.id) { + postLabel = "your welcoming post"; + } else { + postLabel = `${relatedUser.get("name")}'s welcoming post`; } - }))) - }, - - sendPostMentionEmail: function () { - var post = this.post() - var reader = this.reader() - var user = post.relations.user - var description = RichText.qualifyLinks(post.get('description')) - var replyTo = Email.postReplyAddress(post.id, reader.id) - - var communityIds = Activity.communityIds(this.relations.activity) - if (isEmpty(communityIds)) throw new Error('no community ids in activity') - return Community.find(communityIds[0]) - .then(community => reader.generateToken() - .then(token => Email.sendPostMentionNotification({ - email: reader.get('email'), - sender: { - address: replyTo, - reply_to: replyTo, - name: `${user.get('name')} (via Hylo)` - }, - data: { - community_name: community.get('name'), - post_user_name: user.get('name'), - post_user_avatar_url: Frontend.Route.tokenLogin(reader, token, - user.get('avatar_url') + '?ctt=post_mention_email&cti=' + reader.id), - post_user_profile_url: Frontend.Route.tokenLogin(reader, token, - Frontend.Route.profile(user) + '?ctt=post_mention_email&cti=' + reader.id), - post_description: description, - post_title: decode(post.get('name')), - post_type: 'conversation', - post_url: Frontend.Route.tokenLogin(reader, token, - Frontend.Route.post(post) + '?ctt=post_mention_email&cti=' + reader.id), - unfollow_url: Frontend.Route.tokenLogin(reader, token, - Frontend.Route.unfollow(post, community) + '?ctt=post_mention_email&cti=' + reader.id), - tracking_pixel_url: Analytics.pixelUrl('Mention in Post', {userId: reader.id}) - } - }))) - }, - - // version corresponds to names of versions in SendWithUs - sendCommentNotificationEmail: function (version) { - const comment = this.comment() - const reader = this.reader() - if (!comment) return - - const post = comment.relations.post - const commenter = comment.relations.user - const text = RichText.qualifyLinks(comment.get('text')) - const replyTo = Email.postReplyAddress(post.id, reader.id) - const title = decode(post.get('name')) - - var postLabel = `"${title}"` - if (post.get('type') === 'welcome') { - var relatedUser = post.relations.relatedUsers.first() - if (relatedUser.id === reader.id) { - postLabel = 'your welcoming post' - } else { - postLabel = `${relatedUser.get('name')}'s welcoming post` } - } - - const communityIds = Activity.communityIds(this.relations.activity) - if (isEmpty(communityIds)) throw new Error('no community ids in activity') - return Community.find(communityIds[0]) - .then(community => reader.generateToken() - .then(token => Email.sendNewCommentNotification({ - version: version, - email: reader.get('email'), - sender: { - address: replyTo, - reply_to: replyTo, - name: `${commenter.get('name')} (via Hylo)` - }, - data: { - community_name: community.get('name'), - commenter_name: commenter.get('name'), - commenter_avatar_url: commenter.get('avatar_url'), - commenter_profile_url: Frontend.Route.tokenLogin(reader, token, - Frontend.Route.profile(commenter) + '?ctt=comment_email&cti=' + reader.id), - comment_text: text, - post_label: postLabel, - post_title: title, - comment_url: Frontend.Route.tokenLogin(reader, token, - Frontend.Route.post(post, community) + '?ctt=comment_email&cti=' + reader.id + `#comment-${comment.id}`), - unfollow_url: Frontend.Route.tokenLogin(reader, token, - Frontend.Route.unfollow(post, community)), - tracking_pixel_url: Analytics.pixelUrl('Comment', {userId: reader.id}) - } - }))) - }, - sendJoinRequestEmail: function () { - const actor = this.actor() - const reader = this.reader() - const communityIds = Activity.communityIds(this.relations.activity) - if (isEmpty(communityIds)) throw new Error('no community ids in activity') - return Community.find(communityIds[0]) - .then(community => reader.generateToken() - .then(token => Email.sendJoinRequestNotification({ - email: reader.get('email'), - sender: {name: community.get('name')}, + const communityIds = Activity.communityIds(this.relations.activity); + if (isEmpty(communityIds)) + throw new Error("no community ids in activity"); + return Community.find(communityIds[0]).then((community) => + reader.generateToken().then((token) => + Email.sendNewCommentNotification({ + version: version, + email: reader.get("email"), + sender: { + address: replyTo, + reply_to: replyTo, + name: `${commenter.get("name")} (via Hylo)`, + }, + data: { + community_name: community.get("name"), + commenter_name: commenter.get("name"), + commenter_avatar_url: commenter.get("avatar_url"), + commenter_profile_url: Frontend.Route.tokenLogin( + reader, + token, + Frontend.Route.profile(commenter) + + "?ctt=comment_email&cti=" + + reader.id + ), + comment_text: text, + post_label: postLabel, + post_title: title, + comment_url: Frontend.Route.tokenLogin( + reader, + token, + Frontend.Route.post(post, community) + + "?ctt=comment_email&cti=" + + reader.id + + `#comment-${comment.id}` + ), + unfollow_url: Frontend.Route.tokenLogin( + reader, + token, + Frontend.Route.unfollow(post, community) + ), + tracking_pixel_url: Analytics.pixelUrl("Comment", { + userId: reader.id, + }), + }, + }) + ) + ); + }, + + sendJoinRequestEmail: function () { + const actor = this.actor(); + const reader = this.reader(); + const communityIds = Activity.communityIds(this.relations.activity); + if (isEmpty(communityIds)) + throw new Error("no community ids in activity"); + return Community.find(communityIds[0]).then((community) => + reader.generateToken().then((token) => + Email.sendJoinRequestNotification({ + email: reader.get("email"), + sender: { name: community.get("name") }, + data: { + community_name: community.get("name"), + requester_name: actor.get("name"), + requester_avatar_url: actor.get("avatar_url"), + requester_profile_url: Frontend.Route.tokenLogin( + reader, + token, + Frontend.Route.profile(actor) + + `?ctt=comment_email&cti=${reader.id}&check-join-requests=1` + ), + settings_url: Frontend.Route.tokenLogin( + reader, + token, + Frontend.Route.communityJoinRequests(community) + ), + }, + }) + ) + ); + }, + + sendApprovedJoinRequestEmail: function () { + const actor = this.actor(); + const reader = this.reader(); + const communityIds = Activity.communityIds(this.relations.activity); + if (isEmpty(communityIds)) + throw new Error("no community ids in activity"); + return Community.find(communityIds[0]).then((community) => + reader.generateToken().then((token) => + Email.sendApprovedJoinRequestNotification({ + email: reader.get("email"), + sender: { name: community.get("name") }, + data: { + community_name: community.get("name"), + community_avatar_url: community.get("avatar_url"), + approver_name: actor.get("name"), + approver_avatar_url: actor.get("avatar_url"), + approver_profile_url: Frontend.Route.tokenLogin( + reader, + token, + Frontend.Route.profile(actor) + + "?ctt=comment_email&cti=" + + reader.id + ), + community_url: Frontend.Route.tokenLogin( + reader, + token, + Frontend.Route.community(community) + ), + }, + }) + ) + ); + }, + + sendDonationToEmail: async function () { + await this.load([ + "activity.actor", + "activity.post", + "activity.reader", + "activity.projectContribution", + "activity.projectContribution.project", + "activity.projectContribution.user", + ]); + const projectContribution = this.projectContribution(); + const project = this.post(); + const actor = this.actor(); + const reader = this.reader(); + const token = await reader.generateToken(); + return Email.sendDonationToEmail({ + email: reader.get("email"), + sender: { name: project.get("name") }, data: { - community_name: community.get('name'), - requester_name: actor.get('name'), - requester_avatar_url: actor.get('avatar_url'), - requester_profile_url: Frontend.Route.tokenLogin(reader, token, + project_title: project.get("name"), + project_url: Frontend.Route.tokenLogin( + reader, + token, + Frontend.Route.post(project) + + "?ctt=post_mention_email&cti=" + + reader.id + ), + contribution_amount: projectContribution.get("amount") / 100, + contributor_name: actor.get("name"), + contributor_avatar_url: actor.get("avatar_url"), + contributor_profile_url: Frontend.Route.tokenLogin( + reader, + token, Frontend.Route.profile(actor) + - `?ctt=comment_email&cti=${reader.id}&check-join-requests=1`), - settings_url: Frontend.Route.tokenLogin(reader, token, - Frontend.Route.communityJoinRequests(community)) - } - }))) - }, - - sendApprovedJoinRequestEmail: function () { - const actor = this.actor() - const reader = this.reader() - const communityIds = Activity.communityIds(this.relations.activity) - if (isEmpty(communityIds)) throw new Error('no community ids in activity') - return Community.find(communityIds[0]) - .then(community => reader.generateToken() - .then(token => Email.sendApprovedJoinRequestNotification({ - email: reader.get('email'), - sender: {name: community.get('name')}, - data: { - community_name: community.get('name'), - community_avatar_url: community.get('avatar_url'), - approver_name: actor.get('name'), - approver_avatar_url: actor.get('avatar_url'), - approver_profile_url: Frontend.Route.tokenLogin(reader, token, - Frontend.Route.profile(actor) + '?ctt=comment_email&cti=' + reader.id), - community_url: Frontend.Route.tokenLogin(reader, token, - Frontend.Route.community(community)) - } - }))) - }, - - sendDonationToEmail: async function () { - await this.load(['activity.actor', 'activity.post', 'activity.reader', 'activity.projectContribution', 'activity.projectContribution.project', 'activity.projectContribution.user']) - const projectContribution = this.projectContribution() - const project = this.post() - const actor = this.actor() - const reader = this.reader() - const token = await reader.generateToken() - return Email.sendDonationToEmail({ - email: reader.get('email'), - sender: {name: project.get('name')}, - data: { - project_title: project.get('name'), - project_url: Frontend.Route.tokenLogin(reader, token, - Frontend.Route.post(project) + '?ctt=post_mention_email&cti=' + reader.id), - contribution_amount: projectContribution.get('amount') / 100, - contributor_name: actor.get('name'), - contributor_avatar_url: actor.get('avatar_url'), - contributor_profile_url: Frontend.Route.tokenLogin(reader, token, - Frontend.Route.profile(actor) + '?ctt=comment_email&cti=' + reader.id), - } - }) - }, - - sendDonationFromEmail: async function () { - await this.load(['activity.actor', 'activity.post', 'activity.reader', 'activity.projectContribution', 'activity.projectContribution.project', 'activity.projectContribution.user']) - const projectContribution = this.projectContribution() - const project = this.post() - const actor = this.actor() - const reader = this.reader() - const token = await reader.generateToken() - return Email.sendDonationFromEmail({ - email: reader.get('email'), - sender: {name: project.get('name')}, - data: { - project_title: project.get('name'), - project_url: Frontend.Route.tokenLogin(reader, token, - Frontend.Route.post(project) + '?ctt=post_mention_email&cti=' + reader.id), - contribution_amount: projectContribution.get('amount') / 100, - contributor_name: actor.get('name'), - contributor_avatar_url: actor.get('avatar_url'), - contributor_profile_url: Frontend.Route.tokenLogin(reader, token, - Frontend.Route.profile(actor) + '?ctt=comment_email&cti=' + reader.id), - } - }) - }, - - sendEventInvitationEmail: function () { - var post = this.post() - var reader = this.reader() - var inviter = this.actor() - var description = RichText.qualifyLinks(post.get('description')) - var replyTo = Email.postReplyAddress(post.id, reader.id) - - var communityIds = Activity.communityIds(this.relations.activity) - if (isEmpty(communityIds)) throw new Error('no community ids in activity') - return Community.find(communityIds[0]) - .then(community => reader.generateToken() - .then(token => Email.sendEventInvitationEmail({ - email: reader.get('email'), - sender: { - address: replyTo, - reply_to: replyTo, - name: `${inviter.get('name')} (via Hylo)` + "?ctt=comment_email&cti=" + + reader.id + ), }, + }); + }, + + sendDonationFromEmail: async function () { + await this.load([ + "activity.actor", + "activity.post", + "activity.reader", + "activity.projectContribution", + "activity.projectContribution.project", + "activity.projectContribution.user", + ]); + const projectContribution = this.projectContribution(); + const project = this.post(); + const actor = this.actor(); + const reader = this.reader(); + const token = await reader.generateToken(); + return Email.sendDonationFromEmail({ + email: reader.get("email"), + sender: { name: project.get("name") }, data: { - community_name: community.get('name'), - post_user_name: inviter.get('name'), - post_user_avatar_url: Frontend.Route.tokenLogin(reader, token, - inviter.get('avatar_url') + '?ctt=post_mention_email&cti=' + reader.id), - post_user_profile_url: Frontend.Route.tokenLogin(reader, token, - Frontend.Route.profile(inviter) + '?ctt=post_mention_email&cti=' + reader.id), - post_description: description, - post_title: decode(post.get('name')), - post_type: 'event', - post_date: post.prettyEventDates(), - post_url: Frontend.Route.tokenLogin(reader, token, - Frontend.Route.post(post) + '?ctt=post_mention_email&cti=' + reader.id), - unfollow_url: Frontend.Route.tokenLogin(reader, token, - Frontend.Route.unfollow(post, community) + '?ctt=post_mention_email&cti=' + reader.id), - tracking_pixel_url: Analytics.pixelUrl('Mention in Post', {userId: reader.id}) - } - }))) - }, - - shouldBeBlocked: async function () { - if (!this.get('user_id')) return Promise.resolve(false) - - const blockedUserIds = (await BlockedUser.blockedFor(this.get('user_id'))).rows.map(r => r.user_id) - if (blockedUserIds.length === 0) return Promise.resolve(false) - - await this.load(['activity', 'activity.post', 'activity.post.user', 'activity.comment', 'activity.comment.user']) - const postCreatorId = get('relations.activity.relations.post.relations.user.id', this) - const commentCreatorId = get('relations.activity.relations.comment.relations.user.id', this) - const actorId = get('relations.activity.relations.actor.id', this) - - if (includes(postCreatorId, blockedUserIds) - || includes(commentCreatorId, blockedUserIds) - || includes(actorId, blockedUserIds)) { - return Promise.resolve(true) - } - return Promise.resolve(false) - }, - - updateUserSocketRoom: function (userId) { - const { activity } = this.relations - const { actor, comment, community, post } = activity.relations - const action = Notification.priorityReason(activity.get('meta').reasons) - - const payload = { - id: '' + this.id, - activity: Object.assign({}, - refineOne(activity, [ 'created_at', 'id', 'meta', 'unread' ]), - { - action, - actor: refineOne(actor, [ 'avatar_url', 'id', 'name' ]), - comment: refineOne(comment, [ 'id', 'text' ]), - community: refineOne(community, [ 'id', 'name', 'slug' ]), - post: refineOne( - post, - [ 'id', 'name', 'description' ], - { description: 'details', name: 'title' } - ) - } - ) - } - - broadcast(userRoom(userId), 'newNotification', payload) - } -}, { - MEDIUM, - TYPE, - - find: function (id, options) { - if (!id) return Promise.resolve(null) - return Notification.where({id: id}).fetch(options) - }, - - findUnsent: function (options = {}) { - const { raw } = bookshelf.knex - return Notification.query(q => { - q.where({sent_at: null}) - if (!options.includeOld) { - q.where('created_at', '>', raw("now() - interval '6 hour'")) + project_title: project.get("name"), + project_url: Frontend.Route.tokenLogin( + reader, + token, + Frontend.Route.post(project) + + "?ctt=post_mention_email&cti=" + + reader.id + ), + contribution_amount: projectContribution.get("amount") / 100, + contributor_name: actor.get("name"), + contributor_avatar_url: actor.get("avatar_url"), + contributor_profile_url: Frontend.Route.tokenLogin( + reader, + token, + Frontend.Route.profile(actor) + + "?ctt=comment_email&cti=" + + reader.id + ), + }, + }); + }, + + sendEventInvitationEmail: function () { + const post = this.post(); + const reader = this.reader(); + const inviter = this.actor(); + const description = RichText.qualifyLinks(post.get("description")); + const replyTo = Email.postReplyAddress(post.id, reader.id); + + const communityIds = Activity.communityIds(this.relations.activity); + if (isEmpty(communityIds)) + throw new Error("no community ids in activity"); + return Community.find(communityIds[0]).then((community) => + reader.generateToken().then((token) => + Email.sendEventInvitationEmail({ + email: reader.get("email"), + sender: { + address: replyTo, + reply_to: replyTo, + name: `${inviter.get("name")} (via Hylo)`, + }, + data: { + community_name: community.get("name"), + post_user_name: inviter.get("name"), + post_user_avatar_url: Frontend.Route.tokenLogin( + reader, + token, + inviter.get("avatar_url") + + "?ctt=post_mention_email&cti=" + + reader.id + ), + post_user_profile_url: Frontend.Route.tokenLogin( + reader, + token, + Frontend.Route.profile(inviter) + + "?ctt=post_mention_email&cti=" + + reader.id + ), + post_description: description, + post_title: decode(post.get("name")), + post_type: "event", + post_date: post.prettyEventDates(), + post_url: Frontend.Route.tokenLogin( + reader, + token, + Frontend.Route.post(post) + + "?ctt=post_mention_email&cti=" + + reader.id + ), + unfollow_url: Frontend.Route.tokenLogin( + reader, + token, + Frontend.Route.unfollow(post, community) + + "?ctt=post_mention_email&cti=" + + reader.id + ), + tracking_pixel_url: Analytics.pixelUrl("Mention in Post", { + userId: reader.id, + }), + }, + }) + ) + ); + }, + + shouldBeBlocked: async function () { + if (!this.get("user_id")) return Promise.resolve(false); + + const blockedUserIds = ( + await BlockedUser.blockedFor(this.get("user_id")) + ).rows.map((r) => r.user_id); + if (blockedUserIds.length === 0) return Promise.resolve(false); + + await this.load([ + "activity", + "activity.post", + "activity.post.user", + "activity.comment", + "activity.comment.user", + ]); + const postCreatorId = get( + "relations.activity.relations.post.relations.user.id", + this + ); + const commentCreatorId = get( + "relations.activity.relations.comment.relations.user.id", + this + ); + const actorId = get("relations.activity.relations.actor.id", this); + + if ( + includes(postCreatorId, blockedUserIds) || + includes(commentCreatorId, blockedUserIds) || + includes(actorId, blockedUserIds) + ) { + return Promise.resolve(true); } - q.where(function () { - this.where('failed_at', null) - .orWhere('failed_at', '<', raw("now() - interval '1 hour'")) - }) - q.limit(200) - }) - .fetchAll(options) + return Promise.resolve(false); + }, + + updateUserSocketRoom: function (userId) { + const { activity } = this.relations; + const { actor, comment, community, post } = activity.relations; + const action = Notification.priorityReason(activity.get("meta").reasons); + + const payload = { + id: "" + this.id, + activity: Object.assign( + {}, + refineOne(activity, ["created_at", "id", "meta", "unread"]), + { + action, + actor: refineOne(actor, ["avatar_url", "id", "name"]), + comment: refineOne(comment, ["id", "text"]), + community: refineOne(community, ["id", "name", "slug"]), + post: refineOne(post, ["id", "name", "description"], { + description: "details", + name: "title", + }), + } + ), + }; + + broadcast(userRoom(userId), "newNotification", payload); + }, }, - - sendUnsent: function () { - // FIXME empty out this withRelated list and just load things on demand when - // creating push notifications / emails - return Notification.findUnsent({withRelated: [ - 'activity', - 'activity.post', - 'activity.post.communities', - 'activity.post.user', - 'activity.comment', - 'activity.comment.media', - 'activity.comment.user', - 'activity.comment.post', - 'activity.comment.post.user', - 'activity.comment.post.relatedUsers', - 'activity.comment.post.communities', - 'activity.community', - 'activity.reader', - 'activity.actor' - ]}) - .then(ns => ns.length > 0 && - Promise.each(ns.models, - n => n.send().catch(err => { - rollbar.error(err, null, {notification: n.attributes}) - return n.save({failed_at: new Date()}, {patch: true}) - })) - .then(() => new Promise(resolve => { - setTimeout(() => resolve(Notification.sendUnsent()), 1000) - }))) - }, - - priorityReason: function (reasons) { - const orderedLabels = [ - 'donation to', 'donation from', 'announcement', 'eventInvitation', 'mention', 'commentMention', 'newComment', 'newContribution', 'tag', - 'newPost', 'follow', 'followAdd', 'unfollow', 'joinRequest', 'approvedJoinRequest' - ] - - const match = label => reasons.some(r => r.match(new RegExp('^' + label))) - return orderedLabels.find(match) || '' - }, - - removeOldNotifications: function () { - return Notification.query() - .whereRaw("created_at < now() - interval '1 month'") - .del() + { + MEDIUM, + TYPE, + + find: function (id, options) { + if (!id) return Promise.resolve(null); + return Notification.where({ id: id }).fetch(options); + }, + + findUnsent: function (options = {}) { + const { raw } = bookshelf.knex; + return Notification.query((q) => { + q.where({ sent_at: null }); + if (!options.includeOld) { + q.where("created_at", ">", raw("now() - interval '6 hour'")); + } + q.where(function () { + this.where("failed_at", null).orWhere( + "failed_at", + "<", + raw("now() - interval '1 hour'") + ); + }); + q.limit(200); + }).fetchAll(options); + }, + + sendUnsent: function () { + // FIXME empty out this withRelated list and just load things on demand when + // creating push notifications / emails + return Notification.findUnsent({ + withRelated: [ + "activity", + "activity.post", + "activity.post.communities", + "activity.post.user", + "activity.comment", + "activity.comment.media", + "activity.comment.user", + "activity.comment.post", + "activity.comment.post.user", + "activity.comment.post.relatedUsers", + "activity.comment.post.communities", + "activity.community", + "activity.reader", + "activity.actor", + ], + }).then( + (ns) => + ns.length > 0 && + Promise.each(ns.models, (n) => + n.send().catch((err) => { + rollbar.error(err, null, { notification: n.attributes }); + return n.save({ failed_at: new Date() }, { patch: true }); + }) + ).then( + () => + new Promise((resolve) => { + setTimeout(() => resolve(Notification.sendUnsent()), 1000); + }) + ) + ); + }, + + priorityReason: function (reasons) { + const orderedLabels = [ + "donation to", + "donation from", + "announcement", + "eventInvitation", + "mention", + "commentMention", + "newComment", + "newContribution", + "tag", + "newPost", + "follow", + "followAdd", + "unfollow", + "joinRequest", + "approvedJoinRequest", + ]; + + const match = (label) => + reasons.some((r) => r.match(new RegExp("^" + label))); + return orderedLabels.find(match) || ""; + }, + + removeOldNotifications: function () { + return Notification.query() + .whereRaw("created_at < now() - interval '1 month'") + .del(); + }, } -}) +); diff --git a/api/models/Post.js b/api/models/Post.js index cc79fd189..ebf9e03db 100644 --- a/api/models/Post.js +++ b/api/models/Post.js @@ -1,515 +1,661 @@ /* globals _ */ /* eslint-disable camelcase */ -import { filter, isNull, omitBy, uniqBy, isEmpty, intersection } from 'lodash/fp' -import { compact, flatten, some, uniq } from 'lodash' -import { postRoom, pushToSockets } from '../services/Websockets' -import { fulfill, unfulfill } from './post/fulfillPost' -import EnsureLoad from './mixins/EnsureLoad' -import HasGroup from './mixins/HasGroup' -import { countTotal } from '../../lib/util/knex' -import { refineMany, refineOne } from './util/relations' -import { isFollowing } from './group/queryUtils' -import html2text from '../../lib/htmlparser/html2text' -import ProjectMixin from './project/mixin' -import EventMixin from './event/mixin' - -const commentersQuery = (limit, post, currentUserId) => q => { - q.select('users.*', 'comments.user_id') - q.join('comments', 'comments.user_id', 'users.id') +import { + filter, + isNull, + omitBy, + uniqBy, + isEmpty, + intersection, +} from "lodash/fp"; +import { compact, flatten, some, uniq } from "lodash"; +import { postRoom, pushToSockets } from "../services/Websockets"; +import { fulfill, unfulfill } from "./post/fulfillPost"; +import EnsureLoad from "./mixins/EnsureLoad"; +import HasGroup from "./mixins/HasGroup"; +import { countTotal } from "../../lib/util/knex"; +import { refineMany, refineOne } from "./util/relations"; +import { isFollowing } from "./group/queryUtils"; +import html2text from "../../lib/htmlparser/html2text"; +import ProjectMixin from "./project/mixin"; +import EventMixin from "./event/mixin"; + +const commentersQuery = (limit, post, currentUserId) => (q) => { + q.select("users.*", "comments.user_id"); + q.join("comments", "comments.user_id", "users.id"); q.where({ - 'comments.post_id': post.id, - 'comments.active': true - }) + "comments.post_id": post.id, + "comments.active": true, + }); if (currentUserId) { - q.where('users.id', 'NOT IN', BlockedUser.blockedFor(currentUserId)) - q.orderBy(bookshelf.knex.raw(`case when user_id = ${currentUserId} then -1 else user_id end`)) + q.where("users.id", "NOT IN", BlockedUser.blockedFor(currentUserId)); + q.orderBy( + bookshelf.knex.raw( + `case when user_id = ${currentUserId} then -1 else user_id end` + ) + ); } - q.groupBy('users.id', 'comments.user_id') - if (limit) q.limit(limit) -} - -module.exports = bookshelf.Model.extend(Object.assign({ - // Instance Methods - - tableName: 'posts', - - user: function () { - return this.belongsTo(User) - }, + q.groupBy("users.id", "comments.user_id"); + if (limit) q.limit(limit); +}; + +module.exports = bookshelf.Model.extend( + Object.assign( + { + // Instance Methods + + tableName: "posts", + + user: function () { + return this.belongsTo(User); + }, + + communities: function () { + return this.belongsToMany(Community) + .through(PostMembership) + .query({ where: { "communities.active": true } }); + }, + + networks: function () { + return this.belongsToMany(Network).through(PostNetworkMembership); + }, + + followers: function () { + return this.groupMembers((q) => isFollowing(q)); + }, + + followersWithPivots: function () { + return this.groupMembersWithPivots().query((q) => isFollowing(q)); + }, + + contributions: function () { + return this.hasMany(Contribution, "post_id"); + }, + + postMemberships: function () { + return this.hasMany(PostMembership, "post_id"); + }, + + comments: function () { + return this.hasMany(Comment, "post_id"); + }, + + locationObject: function () { + return this.belongsTo(Location, "location_id"); + }, + + media: function (type) { + const relation = this.hasMany(Media); + return type ? relation.query({ where: { type } }) : relation; + }, + + votes: function () { + return this.hasMany(Vote); + }, + + projectContributions: function () { + return this.hasMany(ProjectContribution); + }, + + responders: function () { + return this.belongsToMany(User).through(EventResponse); + }, + + invitees: function () { + return this.belongsToMany(User).through(EventInvitation); + }, + + userVote: function (userId) { + return this.votes() + .query({ where: { user_id: userId } }) + .fetchOne(); + }, + + relatedUsers: function () { + return this.belongsToMany(User, "posts_about_users"); + }, + + tags: function () { + return this.belongsToMany(Tag).through(PostTag).withPivot("selected"); + }, + + // should only be one of these per post + selectedTags: function () { + return this.belongsToMany(Tag) + .through(PostTag) + .withPivot("selected") + .query({ where: { selected: true } }); + }, + + children: function () { + return this.hasMany(Post, "parent_post_id").query({ + where: { active: true }, + }); + }, + + parent: function () { + return this.belongsTo(Post, "parent_post_id"); + }, + + activities: function () { + return this.hasMany(Activity); + }, + + linkPreview: function () { + return this.belongsTo(LinkPreview); + }, + + getTagsInComments: function (opts) { + // this is part of the 'taggable' interface, shared with Comment + return this.load("comments.tags", opts).then(() => + uniqBy( + "id", + flatten(this.relations.comments.map((c) => c.relations.tags.models)) + ) + ); + }, + + getCommenters: function (first, currentUserId) { + return User.query( + commentersQuery(first, this, currentUserId) + ).fetchAll(); + }, + + getCommentersTotal: function (currentUserId) { + return countTotal( + User.query(commentersQuery(null, this, currentUserId)).query(), + "users" + ).then((result) => { + if (isEmpty(result)) { + return 0; + } else { + return result[0].total; + } + }); + }, + + getDetailsText: async function () { + return html2text(this.get("description")); + }, + + addFollowers: async function (userIds, opts) { + return this.addGroupMembers( + userIds, + { settings: { following: true } }, + opts + ); + }, + + removeFollowers: async function (userIds, opts) { + return this.updateGroupMembers( + userIds, + { settings: { following: false } }, + opts + ); + }, + + isPublic: function () { + return this.get("is_public"); + }, + + isWelcome: function () { + return this.get("type") === Post.Type.WELCOME; + }, + + isThread: function () { + return this.get("type") === Post.Type.THREAD; + }, + + unreadCountForUser: function (userId) { + return this.lastReadAtForUser(userId).then((date) => { + if (date > this.get("updated_at")) return 0; + return Aggregate.count( + this.comments().query((q) => q.where("created_at", ">", date)) + ); + }); + }, + + async markAsRead(userId) { + const gm = await GroupMembership.forPair(userId, this).fetch(); + return gm.addSetting({ lastReadAt: new Date() }, true); + }, + + async lastReadAtForUser(userId) { + const user = await this.groupMembersWithPivots() + .query((q) => q.where("users.id", userId)) + .fetchOne(); + return new Date((user && user.pivot.getSetting("lastReadAt")) || 0); + }, + + pushTypingToSockets: function ( + userId, + userName, + isTyping, + socketToExclude + ) { + pushToSockets( + postRoom(this.id), + "userTyping", + { userId, userName, isTyping }, + socketToExclude + ); + }, + + copy: function (attrs) { + const that = this.clone(); + _.merge(that.attributes, Post.newPostAttrs(), attrs); + delete that.id; + delete that.attributes.id; + that._previousAttributes = {}; + that.changed = {}; + return that; + }, + + createActivities: async function (trx) { + await this.load(["communities", "tags"], { transacting: trx }); + const { tags, communities } = this.relations; + + const tagFollows = await TagFollow.query((qb) => { + qb.whereIn("tag_id", tags.map("id")); + qb.whereIn("community_id", communities.map("id")); + }).fetchAll({ withRelated: ["tag"], transacting: trx }); + + const tagFollowers = tagFollows.map((tagFollow) => ({ + reader_id: tagFollow.get("user_id"), + post_id: this.id, + actor_id: this.get("user_id"), + community_id: tagFollow.get("community_id"), + reason: `tag: ${tagFollow.relations.tag.get("name")}`, + })); - communities: function () { - return this.belongsToMany(Community).through(PostMembership) - .query({where: {'communities.active': true}}) - }, - - networks: function () { - return this.belongsToMany(Network).through(PostNetworkMembership) - }, - - followers: function () { - return this.groupMembers(q => isFollowing(q)) - }, - - followersWithPivots: function () { - return this.groupMembersWithPivots().query(q => isFollowing(q)) - }, - - contributions: function () { - return this.hasMany(Contribution, 'post_id') - }, - - postMemberships: function () { - return this.hasMany(PostMembership, 'post_id') - }, - - comments: function () { - return this.hasMany(Comment, 'post_id') - }, - - locationObject: function () { - return this.belongsTo(Location, 'location_id') - }, - - media: function (type) { - const relation = this.hasMany(Media) - return type ? relation.query({where: {type}}) : relation - }, - - votes: function () { - return this.hasMany(Vote) - }, - - projectContributions: function () { - return this.hasMany(ProjectContribution) - }, - - responders: function () { - return this.belongsToMany(User).through(EventResponse) - }, - - invitees: function () { - return this.belongsToMany(User).through(EventInvitation) - }, - - userVote: function (userId) { - return this.votes().query({where: {user_id: userId}}).fetchOne() - }, - - relatedUsers: function () { - return this.belongsToMany(User, 'posts_about_users') - }, - - tags: function () { - return this.belongsToMany(Tag).through(PostTag).withPivot('selected') - }, - - // should only be one of these per post - selectedTags: function () { - return this.belongsToMany(Tag).through(PostTag).withPivot('selected') - .query({where: {selected: true}}) - }, - - children: function () { - return this.hasMany(Post, 'parent_post_id') - .query({where: {active: true}}) - }, - - parent: function () { - return this.belongsTo(Post, 'parent_post_id') - }, - - activities: function () { - return this.hasMany(Activity) - }, - - linkPreview: function () { - return this.belongsTo(LinkPreview) - }, - - getTagsInComments: function (opts) { - // this is part of the 'taggable' interface, shared with Comment - return this.load('comments.tags', opts) - .then(() => - uniqBy('id', flatten(this.relations.comments.map(c => c.relations.tags.models)))) - }, - - getCommenters: function (first, currentUserId) { - return User.query(commentersQuery(first, this, currentUserId)).fetchAll() - }, - - getCommentersTotal: function (currentUserId) { - return countTotal(User.query(commentersQuery(null, this, currentUserId)).query(), 'users') - .then(result => { - if (isEmpty(result)) { - return 0 - } else { - return result[0].total - } - }) - }, - - getDetailsText: async function () { - return html2text(this.get('description')) - }, - - addFollowers: async function (userIds, opts) { - return this.addGroupMembers(userIds, {settings: {following: true}}, opts) - }, - - removeFollowers: async function (userIds, opts) { - return this.updateGroupMembers(userIds, {settings: {following: false}}, opts) - }, - - isPublic: function () { - return this.get('is_public') - }, - - isWelcome: function () { - return this.get('type') === Post.Type.WELCOME - }, - - isThread: function () { - return this.get('type') === Post.Type.THREAD - }, - - unreadCountForUser: function (userId) { - return this.lastReadAtForUser(userId) - .then(date => { - if (date > this.get('updated_at')) return 0 - return Aggregate.count(this.comments().query(q => - q.where('created_at', '>', date))) - }) - }, - - async markAsRead (userId) { - const gm = await GroupMembership.forPair(userId, this).fetch() - return gm.addSetting({lastReadAt: new Date()}, true) - }, - - async lastReadAtForUser (userId) { - const user = await this.groupMembersWithPivots() - .query(q => q.where('users.id', userId)).fetchOne() - return new Date((user && user.pivot.getSetting('lastReadAt')) || 0) - }, - - pushTypingToSockets: function (userId, userName, isTyping, socketToExclude) { - pushToSockets(postRoom(this.id), 'userTyping', {userId, userName, isTyping}, socketToExclude) - }, - - copy: function (attrs) { - var that = this.clone() - _.merge(that.attributes, Post.newPostAttrs(), attrs) - delete that.id - delete that.attributes.id - that._previousAttributes = {} - that.changed = {} - return that - }, - - createActivities: async function (trx) { - await this.load(['communities', 'tags'], {transacting: trx}) - const { tags, communities } = this.relations - - const tagFollows = await TagFollow.query(qb => { - qb.whereIn('tag_id', tags.map('id')) - qb.whereIn('community_id', communities.map('id')) - }) - .fetchAll({withRelated: ['tag'], transacting: trx}) - - const tagFollowers = tagFollows.map(tagFollow => ({ - reader_id: tagFollow.get('user_id'), - post_id: this.id, - actor_id: this.get('user_id'), - community_id: tagFollow.get('community_id'), - reason: `tag: ${tagFollow.relations.tag.get('name')}` - })) - - const mentions = RichText.getUserMentions(this.get('description')) - const mentioned = mentions.map(userId => ({ - reader_id: userId, - post_id: this.id, - actor_id: this.get('user_id'), - reason: 'mention' - })) - - const eventInvitations = await EventInvitation.query(qb => { - qb.where('event_id', this.id) - }) - .fetchAll({transacting: trx}) - - const invitees = eventInvitations.map(eventInvitation => ({ - reader_id: eventInvitation.get('user_id'), - post_id: this.id, - actor_id: eventInvitation.get('inviter_id'), - reason: `eventInvitation` - })) - - let members = await Promise.all(communities.map(async community => { - const userIds = await community.users().fetch().then(u => u.pluck('id')) - const newPosts = userIds.map(userId => ({ - reader_id: userId, - post_id: this.id, - actor_id: this.get('user_id'), - community_id: community.id, - reason: `newPost: ${community.id}` - })) - - const isModerator = await GroupMembership.hasModeratorRole(this.get('user_id'), community) - if (this.get('announcement') && isModerator) { - const announcees = userIds.map(userId => ({ + const mentions = RichText.getUserMentions(this.get("description")); + const mentioned = mentions.map((userId) => ({ reader_id: userId, post_id: this.id, - actor_id: this.get('user_id'), - community_id: community.id, - reason: `announcement: ${community.id}` - })) - return newPosts.concat(announcees) - } - - return newPosts - })) - - members = flatten(members) - - const readers = filter(r => r.reader_id !== this.get('user_id'), - mentioned.concat(members).concat(tagFollowers).concat(invitees)) + actor_id: this.get("user_id"), + reason: "mention", + })); - return Activity.saveForReasons(readers, trx) - }, + const eventInvitations = await EventInvitation.query((qb) => { + qb.where("event_id", this.id); + }).fetchAll({ transacting: trx }); - fulfill, - - unfulfill, + const invitees = eventInvitations.map((eventInvitation) => ({ + reader_id: eventInvitation.get("user_id"), + post_id: this.id, + actor_id: eventInvitation.get("inviter_id"), + reason: "eventInvitation", + })); + + let members = await Promise.all( + communities.map(async (community) => { + const userIds = await community + .users() + .fetch() + .then((u) => u.pluck("id")); + const newPosts = userIds.map((userId) => ({ + reader_id: userId, + post_id: this.id, + actor_id: this.get("user_id"), + community_id: community.id, + reason: `newPost: ${community.id}`, + })); + + const isModerator = await GroupMembership.hasModeratorRole( + this.get("user_id"), + community + ); + if (this.get("announcement") && isModerator) { + const announcees = userIds.map((userId) => ({ + reader_id: userId, + post_id: this.id, + actor_id: this.get("user_id"), + community_id: community.id, + reason: `announcement: ${community.id}`, + })); + return newPosts.concat(announcees); + } + + return newPosts; + }) + ); + + members = flatten(members); + + const readers = filter( + (r) => r.reader_id !== this.get("user_id"), + mentioned.concat(members).concat(tagFollowers).concat(invitees) + ); + + return Activity.saveForReasons(readers, trx); + }, + + fulfill, + + unfulfill, + + vote: function (userId, isUpvote) { + return this.votes() + .query({ where: { user_id: userId } }) + .fetchOne() + .then((vote) => + bookshelf.transaction((trx) => { + const inc = (delta) => () => + this.save( + { num_votes: this.get("num_votes") + delta }, + { transacting: trx } + ); + + return vote && !isUpvote + ? vote.destroy({ transacting: trx }).then(inc(-1)) + : isUpvote && + new Vote({ + post_id: this.id, + user_id: userId, + }) + .save() + .then(inc(1)); + }) + ) + .then(() => this); + }, + + removeFromCommunity: function (idOrSlug) { + return PostMembership.find(this.id, idOrSlug).then((membership) => + membership.destroy() + ); + }, + + // Emulate the graphql request for a post in the feed so the feed can be + // updated via socket. Some fields omitted, linkPreview for example. + // TODO: if we were in a position to avoid duplicating the graphql layer + // here, that'd be grand. + getNewPostSocketPayload: function () { + const { communities, linkPreview, tags, user } = this.relations; + + const creator = refineOne(user, ["id", "name", "avatar_url"]); + const topics = refineMany(tags, ["id", "name"]); + + return Object.assign( + {}, + refineOne( + this, + [ + "created_at", + "description", + "id", + "name", + "num_votes", + "type", + "updated_at", + ], + { description: "details", name: "title", num_votes: "votesTotal" } + ), + { + // Shouldn't have commenters immediately after creation + commenters: [], + commentsTotal: 0, + communities: refineMany(communities, ["id", "name", "slug"]), + creator, + linkPreview: refineOne(linkPreview, [ + "id", + "image_url", + "title", + "url", + ]), + topics, + + // TODO: Once legacy site is decommissioned, these are no longer required. + creatorId: creator.id, + tags: topics, + } + ); + }, + + totalContributions: async function () { + await this.load("projectContributions"); + return this.relations.projectContributions.models.reduce( + (total, contribution) => total + contribution.get("amount"), + 0 + ); + }, + }, + EnsureLoad, + HasGroup, + ProjectMixin, + EventMixin + ), + { + // Class Methods + + Type: { + WELCOME: "welcome", + REQUEST: "request", + OFFER: "offer", + RESOURCE: "resource", + DISCUSSION: "discussion", + EVENT: "event", + PROJECT: "project", + THREAD: "thread", + }, + + // TODO Consider using Visibility property for more granular privacy + // as our work on Public Posts evolves + Visibility: { + DEFAULT: 0, + PUBLIC_READABLE: 1, + }, + + countForUser: function (user, type) { + const attrs = { user_id: user.id, active: true }; + if (type) attrs.type = type; + return this.query() + .count() + .where(attrs) + .then((rows) => rows[0].count); + }, + + groupedCountForUser: function (user) { + return this.query((q) => { + q.join("posts_tags", "posts.id", "posts_tags.post_id"); + q.join("tags", "tags.id", "posts_tags.tag_id"); + q.whereIn("tags.name", ["request", "offer", "resource"]); + q.groupBy("tags.name"); + q.where({ user_id: user.id, active: true }); + q.select("tags.name"); + }) + .query() + .count() + .then((rows) => + rows.reduce((m, n) => { + m[n.name] = n.count; + return m; + }, {}) + ); + }, + + isVisibleToUser: async function (postId, userId) { + if (!postId || !userId) return Promise.resolve(false); + + const post = await Post.find(postId); + if (post.isPublic()) return true; + + const pcids = await PostMembership.query() + .where({ post_id: postId }) + .pluck("community_id"); + const ucids = await Group.pluckIdsForMember(userId, Community); + if (intersection(pcids, ucids).length > 0) return true; + if (await post.isFollowed(userId)) return true; + + const sharesNetwork = await Community.query() + .whereIn("id", pcids) + .pluck("network_id") + .then((networkIds) => + Promise.map(compact(uniq(networkIds)), (id) => + Network.containsUser(id, userId) + ) + ) + .then((results) => some(results)); + + return sharesNetwork; + }, + + find: function (id, options) { + return Post.where({ id, active: true }).fetch(options); + }, + + createdInTimeRange: function (collection, startTime, endTime) { + if (endTime === undefined) { + endTime = startTime; + startTime = collection; + collection = Post; + } + return collection.query(function (qb) { + qb.whereRaw("posts.created_at between ? and ?", [startTime, endTime]); + qb.where("posts.active", true); + }); + }, + + newPostAttrs: () => ({ + created_at: new Date(), + updated_at: new Date(), + active: true, + num_comments: 0, + num_votes: 0, + }), + + create: function (attrs, opts) { + return Post.forge(_.merge(Post.newPostAttrs(), attrs)).save( + null, + _.pick(opts, "transacting") + ); + }, + + async updateFromNewComment({ postId, commentId }) { + const where = { post_id: postId, active: true }; + const now = new Date(); + + return Promise.all([ + Comment.query() + .where(where) + .orderBy("created_at", "desc") + .limit(2) + .pluck("id") + .then((ids) => + Promise.all([ + Comment.query().where("id", "in", ids).update("recent", true), + Comment.query() + .where("id", "not in", ids) + .where({ recent: true, post_id: postId }) + .update("recent", false), + ]) + ), + + // update num_comments and updated_at (only update the latter when + // creating a comment, not deleting one) + Aggregate.count(Comment.where(where)).then((count) => + Post.query() + .where("id", postId) + .update( + omitBy(isNull, { + num_comments: count, + updated_at: commentId ? now : null, + }) + ) + ), + + // bump updated_at on the post's group + commentId && + Group.whereIdAndType(postId, Post) + .query() + .update({ updated_at: now }), + + // when creating a comment, mark post as read for the commenter + commentId && + Comment.where("id", commentId) + .query() + .pluck("user_id") + .then(([userId]) => + Post.find(postId).then((post) => post.markAsRead(userId)) + ), + ]); + }, + + deactivate: (postId) => + bookshelf.transaction((trx) => + Promise.join( + Activity.removeForPost(postId, trx), + Post.where("id", postId) + .query() + .update({ active: false }) + .transacting(trx), + Group.whereIdAndType(postId, Post) + .query() + .update({ active: false }) + .transacting(trx) + ) + ), - vote: function (userId, isUpvote) { - return this.votes().query({where: {user_id: userId}}).fetchOne() - .then(vote => bookshelf.transaction(trx => { - var inc = delta => () => - this.save({num_votes: this.get('num_votes') + delta}, {transacting: trx}) + createActivities: (opts) => + Post.find(opts.postId).then( + (post) => + post && bookshelf.transaction((trx) => post.createActivities(trx)) + ), - return (vote && !isUpvote - ? vote.destroy({transacting: trx}).then(inc(-1)) - : isUpvote && new Vote({ - post_id: this.id, - user_id: userId - }).save().then(inc(1))) - })) - .then(() => this) - }, - - removeFromCommunity: function (idOrSlug) { - return PostMembership.find(this.id, idOrSlug) - .then(membership => membership.destroy()) - }, - - // Emulate the graphql request for a post in the feed so the feed can be - // updated via socket. Some fields omitted, linkPreview for example. - // TODO: if we were in a position to avoid duplicating the graphql layer - // here, that'd be grand. - getNewPostSocketPayload: function () { - const { communities, linkPreview, tags, user } = this.relations - - const creator = refineOne(user, [ 'id', 'name', 'avatar_url' ]) - const topics = refineMany(tags, [ 'id', 'name' ]) - - return Object.assign({}, - refineOne( - this, - [ 'created_at', 'description', 'id', 'name', 'num_votes', 'type', 'updated_at' ], - { 'description': 'details', 'name': 'title', 'num_votes': 'votesTotal' } + fixTypedPosts: () => + bookshelf.transaction((transacting) => + Tag.where("name", "in", ["request", "offer", "resource", "intention"]) + .fetchAll({ transacting }) + .then((tags) => + Post.query((q) => { + q.where("type", "in", [ + "request", + "offer", + "resource", + "intention", + ]); + }) + .fetchAll({ withRelated: ["selectedTags", "tags"], transacting }) + .then((posts) => + Promise.each(posts.models, (post) => { + const untype = () => + post.save({ type: null }, { patch: true, transacting }); + if (post.relations.selectedTags.first()) return untype(); + + const matches = (t) => t.get("name") === post.get("type"); + const existingTag = post.relations.tags.find(matches); + if (existingTag) { + return PostTag.query() + .where({ post_id: post.id, tag_id: existingTag.id }) + .update({ selected: true }) + .transacting(transacting) + .then(untype); + } + + return post + .selectedTags() + .attach(tags.find(matches).id, { transacting }) + .then(untype); + }) + ) + .then((promises) => promises.length) + ) ), - { - // Shouldn't have commenters immediately after creation - commenters: [], - commentsTotal: 0, - communities: refineMany(communities, [ 'id', 'name', 'slug' ]), - creator, - linkPreview: refineOne(linkPreview, [ 'id', 'image_url', 'title', 'url' ]), - topics, - - // TODO: Once legacy site is decommissioned, these are no longer required. - creatorId: creator.id, - tags: topics - } - ) - }, - totalContributions: async function () { - await this.load('projectContributions') - return this.relations.projectContributions.models.reduce((total, contribution) => total + contribution.get('amount'), 0) + notifySlack: ({ postId }) => + Post.find(postId, { + withRelated: ["communities", "user", "relatedUsers"], + }).then((post) => { + if (!post) return; + const slackCommunities = post.relations.communities.filter((c) => + c.get("slack_hook_url") + ); + return Promise.map(slackCommunities, (c) => + Community.notifySlack(c.id, post) + ); + }), } -}, EnsureLoad, HasGroup, ProjectMixin, EventMixin), { - // Class Methods - - Type: { - WELCOME: 'welcome', - REQUEST: 'request', - OFFER: 'offer', - RESOURCE: 'resource', - DISCUSSION: 'discussion', - EVENT: 'event', - PROJECT: 'project', - THREAD: 'thread' - }, - - // TODO Consider using Visibility property for more granular privacy - // as our work on Public Posts evolves - Visibility: { - DEFAULT: 0, - PUBLIC_READABLE: 1 - }, - - countForUser: function (user, type) { - const attrs = {user_id: user.id, active: true} - if (type) attrs.type = type - return this.query().count().where(attrs).then(rows => rows[0].count) - }, - - groupedCountForUser: function (user) { - return this.query(q => { - q.join('posts_tags', 'posts.id', 'posts_tags.post_id') - q.join('tags', 'tags.id', 'posts_tags.tag_id') - q.whereIn('tags.name', ['request', 'offer', 'resource']) - q.groupBy('tags.name') - q.where({user_id: user.id, active: true}) - q.select('tags.name') - }).query().count() - .then(rows => rows.reduce((m, n) => { - m[n.name] = n.count - return m - }, {})) - }, - - isVisibleToUser: async function (postId, userId) { - if (!postId || !userId) return Promise.resolve(false) - - const post = await Post.find(postId) - if (post.isPublic()) return true - - const pcids = await PostMembership.query() - .where({post_id: postId}).pluck('community_id') - const ucids = await Group.pluckIdsForMember(userId, Community) - if (intersection(pcids, ucids).length > 0) return true - if (await post.isFollowed(userId)) return true - - const sharesNetwork = await Community.query() - .whereIn('id', pcids).pluck('network_id') - .then(networkIds => - Promise.map(compact(uniq(networkIds)), id => - Network.containsUser(id, userId))) - .then(results => some(results)) - - return sharesNetwork - }, - - find: function (id, options) { - return Post.where({id, active: true}).fetch(options) - }, - - createdInTimeRange: function (collection, startTime, endTime) { - if (endTime === undefined) { - endTime = startTime - startTime = collection - collection = Post - } - return collection.query(function (qb) { - qb.whereRaw('posts.created_at between ? and ?', [startTime, endTime]) - qb.where('posts.active', true) - }) - }, - - newPostAttrs: () => ({ - created_at: new Date(), - updated_at: new Date(), - active: true, - num_comments: 0, - num_votes: 0 - }), - - create: function (attrs, opts) { - return Post.forge(_.merge(Post.newPostAttrs(), attrs)) - .save(null, _.pick(opts, 'transacting')) - }, - - async updateFromNewComment ({ postId, commentId }) { - const where = {post_id: postId, active: true} - const now = new Date() - - return Promise.all([ - Comment.query().where(where).orderBy('created_at', 'desc').limit(2) - .pluck('id').then(ids => Promise.all([ - Comment.query().where('id', 'in', ids).update('recent', true), - Comment.query().where('id', 'not in', ids) - .where({recent: true, post_id: postId}) - .update('recent', false) - ])), - - // update num_comments and updated_at (only update the latter when - // creating a comment, not deleting one) - Aggregate.count(Comment.where(where)).then(count => - Post.query().where('id', postId).update(omitBy(isNull, { - num_comments: count, - updated_at: commentId ? now : null - }))), - - // bump updated_at on the post's group - commentId && Group.whereIdAndType(postId, Post).query() - .update({updated_at: now}), - - // when creating a comment, mark post as read for the commenter - commentId && Comment.where('id', commentId).query().pluck('user_id') - .then(([ userId ]) => Post.find(postId) - .then(post => post.markAsRead(userId))) - ]) - }, - - deactivate: postId => - bookshelf.transaction(trx => - Promise.join( - Activity.removeForPost(postId, trx), - Post.where('id', postId).query() - .update({active: false}).transacting(trx), - Group.whereIdAndType(postId, Post).query() - .update({active: false}).transacting(trx) - )), - - createActivities: (opts) => - Post.find(opts.postId).then(post => post && - bookshelf.transaction(trx => post.createActivities(trx))), - - fixTypedPosts: () => - bookshelf.transaction(transacting => - Tag.where('name', 'in', ['request', 'offer', 'resource', 'intention']) - .fetchAll({transacting}) - .then(tags => Post.query(q => { - q.where('type', 'in', ['request', 'offer', 'resource', 'intention']) - }).fetchAll({withRelated: ['selectedTags', 'tags'], transacting}) - .then(posts => Promise.each(posts.models, post => { - const untype = () => post.save({type: null}, {patch: true, transacting}) - if (post.relations.selectedTags.first()) return untype() - - const matches = t => t.get('name') === post.get('type') - const existingTag = post.relations.tags.find(matches) - if (existingTag) { - return PostTag.query() - .where({post_id: post.id, tag_id: existingTag.id}) - .update({selected: true}).transacting(transacting) - .then(untype) - } - - return post.selectedTags().attach(tags.find(matches).id, {transacting}) - .then(untype) - })) - .then(promises => promises.length))), - - notifySlack: ({ postId }) => - Post.find(postId, {withRelated: ['communities', 'user', 'relatedUsers']}) - .then(post => { - if (!post) return - const slackCommunities = post.relations.communities.filter(c => c.get('slack_hook_url')) - return Promise.map(slackCommunities, c => Community.notifySlack(c.id, post)) - }) -}) +); diff --git a/api/models/PostMembership.js b/api/models/PostMembership.js index 4a2e2dbb6..c86bf6d87 100644 --- a/api/models/PostMembership.js +++ b/api/models/PostMembership.js @@ -1,35 +1,41 @@ -module.exports = bookshelf.Model.extend({ - tableName: 'communities_posts', +module.exports = bookshelf.Model.extend( + { + tableName: "communities_posts", - post: function () { - return this.belongsTo(Post) - }, + post: function () { + return this.belongsTo(Post); + }, - community: function () { - return this.belongsTo(Community) - }, + community: function () { + return this.belongsTo(Community); + }, - pinned: function () { - return !!this.get('pinned_at') - }, + pinned: function () { + return !!this.get("pinned_at"); + }, - togglePinned: function () { - if (this.pinned()) { - return this.save({pinned_at: null}) - } else { - return this.save({pinned_at: new Date()}) - } - } -}, { - find: function (postId, communityIdOrSlug, options) { - const fetch = cid => - PostMembership.where({post_id: postId, community_id: cid}).fetch(options) + togglePinned: function () { + if (this.pinned()) { + return this.save({ pinned_at: null }); + } else { + return this.save({ pinned_at: new Date() }); + } + }, + }, + { + find: function (postId, communityIdOrSlug, options) { + const fetch = (cid) => + PostMembership.where({ post_id: postId, community_id: cid }).fetch( + options + ); - if (isNaN(Number(communityIdOrSlug))) { - return Community.find(communityIdOrSlug) - .then(c => c && fetch(c.id, options)) - } + if (isNaN(Number(communityIdOrSlug))) { + return Community.find(communityIdOrSlug).then( + (c) => c && fetch(c.id, options) + ); + } - return fetch(communityIdOrSlug) + return fetch(communityIdOrSlug); + }, } -}) +); diff --git a/api/models/PostNetworkMembership.js b/api/models/PostNetworkMembership.js index d88544272..be99ac067 100644 --- a/api/models/PostNetworkMembership.js +++ b/api/models/PostNetworkMembership.js @@ -1,27 +1,29 @@ -module.exports = bookshelf.Model.extend({ - tableName: 'networks_posts', +module.exports = bookshelf.Model.extend( + { + tableName: "networks_posts", - post: function () { - return this.belongsTo(Post) - }, - - network: function () { - return this.belongsTo(Network) - } + post: function () { + return this.belongsTo(Post); + }, -}, { - - find: function (post_id, network_id_or_slug, options) { // eslint-disable-line - var fetch = network_id => // eslint-disable-line - PostNetworkMembership.where({post_id, network_id}).fetch(options) + network: function () { + return this.belongsTo(Network); + }, + }, + { + find: function (post_id, network_id_or_slug, options) { + // eslint-disable-line + var fetch = ( + network_id // eslint-disable-line + ) => PostNetworkMembership.where({ post_id, network_id }).fetch(options); - if (isNaN(Number(network_id_or_slug))) { - return Network.find(network_id_or_slug) - .then(function (network) { - if (network) return fetch(network.id, options) - }) - } + if (isNaN(Number(network_id_or_slug))) { + return Network.find(network_id_or_slug).then(function (network) { + if (network) return fetch(network.id, options); + }); + } - return fetch(network_id_or_slug) + return fetch(network_id_or_slug); + }, } -}) +); diff --git a/api/models/PostTag.js b/api/models/PostTag.js index fae9d5403..3ef237a2a 100644 --- a/api/models/PostTag.js +++ b/api/models/PostTag.js @@ -1,11 +1,11 @@ module.exports = bookshelf.Model.extend({ - tableName: 'posts_tags', + tableName: "posts_tags", post: function () { - return this.belongsTo(Post) + return this.belongsTo(Post); }, tag: function () { - return this.belongsTo(Tag) - } -}) + return this.belongsTo(Tag); + }, +}); diff --git a/api/models/ProjectContribution.js b/api/models/ProjectContribution.js index ecf8d49f5..e4616925d 100644 --- a/api/models/ProjectContribution.js +++ b/api/models/ProjectContribution.js @@ -1,13 +1,14 @@ -module.exports = bookshelf.Model.extend({ - tableName: 'project_contributions', +module.exports = bookshelf.Model.extend( + { + tableName: "project_contributions", - user: function () { - return this.belongsTo(User) - }, - - project: function () { - return this.belongsTo(Post) - } + user: function () { + return this.belongsTo(User); + }, -}, { -}) + project: function () { + return this.belongsTo(Post); + }, + }, + {} +); diff --git a/api/models/ProjectRole.js b/api/models/ProjectRole.js index f2ab60e74..aeadb3fd0 100644 --- a/api/models/ProjectRole.js +++ b/api/models/ProjectRole.js @@ -1,13 +1,16 @@ -module.exports = bookshelf.Model.extend({ - tableName: 'project_roles', +module.exports = bookshelf.Model.extend( + { + tableName: "project_roles", - project: function () { - return this.belongsTo(Post) - } -}, { - find: function (id, options) { - return ProjectRole.where({id}).fetch(options) + project: function () { + return this.belongsTo(Post); + }, }, + { + find: function (id, options) { + return ProjectRole.where({ id }).fetch(options); + }, - MEMBER_ROLE_NAME: 'member' -}) + MEMBER_ROLE_NAME: "member", + } +); diff --git a/api/models/PushNotification.js b/api/models/PushNotification.js index 9b221a8fc..06bf14a17 100644 --- a/api/models/PushNotification.js +++ b/api/models/PushNotification.js @@ -1,109 +1,121 @@ -import decode from 'ent/decode' -import truncate from 'trunc-html' - -module.exports = bookshelf.Model.extend({ - tableName: 'push_notifications', - - device: function () { - return this.belongsTo(Device) - }, - - send: async function (options) { - const platform = this.getPlatform() - const alert = this.get('alert') - const path = this.get('path') - const badgeNo = this.get('badge_no') - - await this.load('device') - const { device } = this.relations - const deviceToken = device.get('token') - const playerId = device.get('player_id') - const disabled = !process.env.PUSH_NOTIFICATIONS_ENABLED && ( - !process.env.PUSH_NOTIFICATIONS_TESTING_ENABLED || !device.get('tester') - ) - - await this.save({sent_at: new Date().toISOString(), disabled}, options) - if (!disabled) { - await OneSignal.notify({ - platform, deviceToken, playerId, alert, path, badgeNo - }) - } - return this - }, - - getPlatform: function () { - var platform = this.get('platform') - if (platform) { - return platform - } else { - return 'ios_macos' - } - } - -}, { - textForContribution: function (contribution) { - const post = contribution.relations.post - return `You have been added as a contributor to the request "${post.get('name')}"` - }, - - textForComment: function (comment, version) { - const person = comment.relations.user.get('name') - const { media } = comment.relations - if (media && media.length !== 0) { - return `${person} sent an image` - } - const blurb = decode(truncate(comment.get('text'), 140).text).trim() - const postName = comment.relations.post.get('name') - - return version === 'mention' - ? `${person} mentioned you: "${blurb}" (in "${postName}")` - : `${person}: "${blurb}" (in "${postName}")` - }, - - textForPost: function (post, community, userId, version) { - const person = post.relations.user.get('name') - const postName = decode(post.get('name')) - - return version === 'mention' - ? `${person} mentioned you in "${postName}"` - : `${person} posted "${postName}" in ${community.get('name')}` - }, - - textForAnnouncement: function (post) { - const person = post.relations.user.get('name') - const postName = decode(post.get('name')) - - return `${person} sent an announcement titled "${postName}"` - }, - - textForEventInvitation: function (post, actor) { - const postName = decode(post.get('name')) - - return `${actor.get('name')} invited you to "${postName}"` - }, - - textForJoinRequest: function (community, actor) { - return `${actor.get('name')} asked to join ${community.get('name')}` +import decode from "ent/decode"; +import truncate from "trunc-html"; + +module.exports = bookshelf.Model.extend( + { + tableName: "push_notifications", + + device: function () { + return this.belongsTo(Device); + }, + + send: async function (options) { + const platform = this.getPlatform(); + const alert = this.get("alert"); + const path = this.get("path"); + const badgeNo = this.get("badge_no"); + + await this.load("device"); + const { device } = this.relations; + const deviceToken = device.get("token"); + const playerId = device.get("player_id"); + const disabled = + !process.env.PUSH_NOTIFICATIONS_ENABLED && + (!process.env.PUSH_NOTIFICATIONS_TESTING_ENABLED || + !device.get("tester")); + + await this.save({ sent_at: new Date().toISOString(), disabled }, options); + if (!disabled) { + await OneSignal.notify({ + platform, + deviceToken, + playerId, + alert, + path, + badgeNo, + }); + } + return this; + }, + + getPlatform: function () { + const platform = this.get("platform"); + if (platform) { + return platform; + } else { + return "ios_macos"; + } + }, }, - - textForApprovedJoinRequest: function (community, actor) { - return `${actor.get('name')} approved your request to join ${community.get('name')}` - }, - - textForDonationTo: function (contribution) { - const project = contribution.relations.project - const postName = decode(project.get('name')) - const amount = contribution.get('amount') / 100 - - return `You contributed $${amount} to "${postName}"` - }, - - textForDonationFrom: function (contribution) { - const actor = contribution.relations.user - const project = contribution.relations.project - const postName = decode(project.get('name')) - - const amount = contribution.get('amount') / 100 - return `${actor.get('name')} contributed $${amount} to "${postName}"` + { + textForContribution: function (contribution) { + const post = contribution.relations.post; + return `You have been added as a contributor to the request "${post.get( + "name" + )}"`; + }, + + textForComment: function (comment, version) { + const person = comment.relations.user.get("name"); + const { media } = comment.relations; + if (media && media.length !== 0) { + return `${person} sent an image`; + } + const blurb = decode(truncate(comment.get("text"), 140).text).trim(); + const postName = comment.relations.post.get("name"); + + return version === "mention" + ? `${person} mentioned you: "${blurb}" (in "${postName}")` + : `${person}: "${blurb}" (in "${postName}")`; + }, + + textForPost: function (post, community, userId, version) { + const person = post.relations.user.get("name"); + const postName = decode(post.get("name")); + + return version === "mention" + ? `${person} mentioned you in "${postName}"` + : `${person} posted "${postName}" in ${community.get("name")}`; + }, + + textForAnnouncement: function (post) { + const person = post.relations.user.get("name"); + const postName = decode(post.get("name")); + + return `${person} sent an announcement titled "${postName}"`; + }, + + textForEventInvitation: function (post, actor) { + const postName = decode(post.get("name")); + + return `${actor.get("name")} invited you to "${postName}"`; + }, + + textForJoinRequest: function (community, actor) { + return `${actor.get("name")} asked to join ${community.get("name")}`; + }, + + textForApprovedJoinRequest: function (community, actor) { + return `${actor.get( + "name" + )} approved your request to join ${community.get("name")}`; + }, + + textForDonationTo: function (contribution) { + const project = contribution.relations.project; + const postName = decode(project.get("name")); + const amount = contribution.get("amount") / 100; + + return `You contributed $${amount} to "${postName}"`; + }, + + textForDonationFrom: function (contribution) { + const actor = contribution.relations.user; + const project = contribution.relations.project; + const postName = decode(project.get("name")); + + const amount = contribution.get("amount") / 100; + return `${actor.get("name")} contributed $${amount} to "${postName}"`; + }, } -}) +); diff --git a/api/models/SavedSearch.js b/api/models/SavedSearch.js index 51159b740..1c6a8d877 100644 --- a/api/models/SavedSearch.js +++ b/api/models/SavedSearch.js @@ -1,40 +1,53 @@ -import knexPostgis from 'knex-postgis' - -module.exports = bookshelf.Model.extend({ - tableName: 'saved_searches', - - boundingBox: async function() { - const st = knexPostgis(bookshelf.knex) - const data = await bookshelf.knex('saved_searches').where({ id: this.id }).select(st.asGeoJSON('bounding_box', 4326)) - const coordinates = JSON.parse(data[0].bounding_box).coordinates[0] - const boundingBox = [coordinates[0][0], coordinates[0][1], coordinates[2][0], coordinates[2][1]] - return boundingBox - }, - - community: async function () { - return this.get('context') === 'community' ? await Community.find(this.get('context_id')) : null - }, - - network: async function () { - return this.get('context') === 'network' ? await Network.find(this.get('context_id')) : null - }, - - topics: async function () { - const searchId = this.id - const query = `select t.* from saved_search_topics sst +import knexPostgis from "knex-postgis"; + +module.exports = bookshelf.Model.extend( + { + tableName: "saved_searches", + + boundingBox: async function () { + const st = knexPostgis(bookshelf.knex); + const data = await bookshelf + .knex("saved_searches") + .where({ id: this.id }) + .select(st.asGeoJSON("bounding_box", 4326)); + const coordinates = JSON.parse(data[0].bounding_box).coordinates[0]; + const boundingBox = [ + coordinates[0][0], + coordinates[0][1], + coordinates[2][0], + coordinates[2][1], + ]; + return boundingBox; + }, + + community: async function () { + return this.get("context") === "community" + ? await Community.find(this.get("context_id")) + : null; + }, + + network: async function () { + return this.get("context") === "network" + ? await Network.find(this.get("context_id")) + : null; + }, + + topics: async function () { + const searchId = this.id; + const query = `select t.* from saved_search_topics sst left join tags as t on sst.tag_id = t.id - where sst.saved_search_id = ${searchId}` - const result = await bookshelf.knex.raw(query) - return result.rows || [] - }, - - newPosts: async function() { - const searchId = this.id - const topics = await this.topics() - const searchText = this.get('search_text') - const contextQuery = this.getContextQuery() - - const query = ` + where sst.saved_search_id = ${searchId}`; + const result = await bookshelf.knex.raw(query); + return result.rows || []; + }, + + newPosts: async function () { + const searchId = this.id; + const topics = await this.topics(); + const searchText = this.get("search_text"); + const contextQuery = this.getContextQuery(); + + const query = ` with posts_with_locations as ( select p.id, p.description, p.name, p.type, p.is_public, loc.center as location, array_agg(t.tag_id)::integer[] as tag_ids from posts p left join locations loc on p.location_id = loc.id @@ -51,110 +64,153 @@ module.exports = bookshelf.Model.extend({ select p.id from posts_with_locations p where ST_Within(p.location, (select bounding_box from search limit 1))=true and p.id > (select last_post_id from search) - ${searchText ? `and (p.name ilike (select search_text from search) or p.description ilike (select search_text from search))` : ''} - ${topics.length > 0 ? `and p.tag_ids && (select tag_ids from search)` : ''} + ${ + searchText + ? "and (p.name ilike (select search_text from search) or p.description ilike (select search_text from search))" + : "" + } + ${topics.length > 0 ? "and p.tag_ids && (select tag_ids from search)" : ""} and CONCAT('{',p.type,'}')::varchar[] && (select post_types from search) ${contextQuery} order by p.id desc - ` - const result = await bookshelf.knex.raw(query) - const postIds = (result.rows || []).map(p => p.id) - const posts = await Post.query().where('id', 'in', postIds) - return posts - }, - - getContextQuery: function () { - const context = this.get('context') - const lastPostId = this.get('last_post_id') - const userId = this.get('user_id') - - let query - - switch (context) { - case 'all': - query = ` + `; + const result = await bookshelf.knex.raw(query); + const postIds = (result.rows || []).map((p) => p.id); + const posts = await Post.query().where("id", "in", postIds); + return posts; + }, + + getContextQuery: function () { + const context = this.get("context"); + const lastPostId = this.get("last_post_id"); + const userId = this.get("user_id"); + + let query; + + switch (context) { + case "all": + query = ` and p.id in (select p.id from posts p left join communities_posts cp on p.id = cp.post_id left join communities_users cu on cu.community_id = cp.community_id where cu.user_id=${userId} and p.id > ${lastPostId}) - ` - break - case 'public': - query = ` + `; + break; + case "public": + query = ` and p.is_public = true - ` - break - case 'community': - query = ` + `; + break; + case "community": + query = ` and p.id in (select p.id from posts p left join communities_posts cp on p.id = cp.post_id - where cp.community_id=${this.get('context_id')} + where cp.community_id=${this.get("context_id")} and p.id > ${lastPostId}) - ` - break - case 'network': - query = ` + `; + break; + case "network": + query = ` and p.id in (select p.id from posts p left join communities_posts cp on p.id = cp.post_id left join communities c on c.id = cp.community_id - where c.network_id=${this.get('context_id')} + where c.network_id=${this.get("context_id")} and p.id > ${lastPostId}) - ` - break - } - - return query - }, - - updateLastPost: async function(id, last_post_id) { - await SavedSearch.query().where({ id }).update({ last_post_id }) - return id - } -}, { - create: async function (params) { - const { boundingBox, communitySlug, context, lastPostId, name, networkSlug, postTypes, searchText, topicIds, userId } = params - - let community, network, context_id - - const validContexts = ['all', 'public', 'network', 'community'] - if (!validContexts.includes(context)) throw new Error(`Invalid context: ${context}`) - - if (context === 'community') { - community = await Community.find(communitySlug) - context_id = community.id - } - - if (context === 'network') { - network = await Network.find(networkSlug) - context_id = network.id - } - - const st = knexPostgis(bookshelf.knex) - const bounding_box = st.geomFromText('POLYGON((' + boundingBox[0].lng + ' ' + boundingBox[0].lat + ', ' + boundingBox[0].lng + ' ' + boundingBox[1].lat + ', ' + boundingBox[1].lng + ' ' + boundingBox[1].lat + ', ' + boundingBox[1].lng + ' ' + boundingBox[0].lat + ', ' + boundingBox[0].lng + ' ' + boundingBox[0].lat + '))', 4326) - - const attributes = { - user_id: userId, - name, - created_at: new Date(), - context_id, - context, - is_active: true, - search_text: searchText, - post_types: postTypes, - bounding_box, - last_post_id: lastPostId, - } - - const search = await this.forge(attributes).save() + `; + break; + } - topicIds.forEach(tag_id => SavedSearchTopic.create({ tag_id, saved_search_id: search.id })) + return query; + }, - return search + updateLastPost: async function (id, last_post_id) { + await SavedSearch.query().where({ id }).update({ last_post_id }); + return id; + }, }, - - delete: async function(id) { - await SavedSearch.query().where({ id }).update({ is_active: false }) - return id + { + create: async function (params) { + const { + boundingBox, + communitySlug, + context, + lastPostId, + name, + networkSlug, + postTypes, + searchText, + topicIds, + userId, + } = params; + + let community, network, context_id; + + const validContexts = ["all", "public", "network", "community"]; + if (!validContexts.includes(context)) + throw new Error(`Invalid context: ${context}`); + + if (context === "community") { + community = await Community.find(communitySlug); + context_id = community.id; + } + + if (context === "network") { + network = await Network.find(networkSlug); + context_id = network.id; + } + + const st = knexPostgis(bookshelf.knex); + const bounding_box = st.geomFromText( + "POLYGON((" + + boundingBox[0].lng + + " " + + boundingBox[0].lat + + ", " + + boundingBox[0].lng + + " " + + boundingBox[1].lat + + ", " + + boundingBox[1].lng + + " " + + boundingBox[1].lat + + ", " + + boundingBox[1].lng + + " " + + boundingBox[0].lat + + ", " + + boundingBox[0].lng + + " " + + boundingBox[0].lat + + "))", + 4326 + ); + + const attributes = { + user_id: userId, + name, + created_at: new Date(), + context_id, + context, + is_active: true, + search_text: searchText, + post_types: postTypes, + bounding_box, + last_post_id: lastPostId, + }; + + const search = await this.forge(attributes).save(); + + topicIds.forEach((tag_id) => + SavedSearchTopic.create({ tag_id, saved_search_id: search.id }) + ); + + return search; + }, + + delete: async function (id) { + await SavedSearch.query().where({ id }).update({ is_active: false }); + return id; + }, } -}) +); diff --git a/api/models/SavedSearchTopic.js b/api/models/SavedSearchTopic.js index d6d9732a9..c0f6facba 100644 --- a/api/models/SavedSearchTopic.js +++ b/api/models/SavedSearchTopic.js @@ -1,17 +1,20 @@ -import knexPostgis from 'knex-postgis'; +import knexPostgis from "knex-postgis"; -module.exports = bookshelf.Model.extend({ - tableName: 'saved_search_topics', -}, { - create: function (params) { - const { tag_id, saved_search_id } = params +module.exports = bookshelf.Model.extend( + { + tableName: "saved_search_topics", + }, + { + create: function (params) { + const { tag_id, saved_search_id } = params; - const attributes = { - tag_id, - saved_search_id, - created_at: new Date(), - } + const attributes = { + tag_id, + saved_search_id, + created_at: new Date(), + }; - return this.forge(attributes).save() + return this.forge(attributes).save(); + }, } -}) +); diff --git a/api/models/Skill.js b/api/models/Skill.js index 41db49460..b8eaa1d35 100644 --- a/api/models/Skill.js +++ b/api/models/Skill.js @@ -1,39 +1,50 @@ -import { myCommunityIds, myNetworkCommunityIds } from './util/queryFilters' +import { myCommunityIds, myNetworkCommunityIds } from "./util/queryFilters"; -module.exports = bookshelf.Model.extend({ - tableName: 'skills', +module.exports = bookshelf.Model.extend( + { + tableName: "skills", - users: function () { - return this.belongsToMany(User, 'skills_users') - } - -}, { - - find: function (nameOrId, opts = {}) { - if (!nameOrId) return Promise.resolve(null) - - if (isNaN(Number(nameOrId))) { - return this.query(qb => qb.whereRaw('lower(name) = lower(?)', nameOrId)) - .fetch(opts) - } - return this.where({id: nameOrId}).fetch(opts) + users: function () { + return this.belongsToMany(User, "skills_users"); + }, }, + { + find: function (nameOrId, opts = {}) { + if (!nameOrId) return Promise.resolve(null); - search: function ({ autocomplete, limit, offset, currentUserId }) { - return this.query(q => { - q.limit(limit) - q.offset(offset) - q.orderByRaw('upper("name") asc') - - if (autocomplete) { - q.whereRaw('name ilike ?', autocomplete + '%') + if (isNaN(Number(nameOrId))) { + return this.query((qb) => + qb.whereRaw("lower(name) = lower(?)", nameOrId) + ).fetch(opts); } - q.join('skills_users', 'skills_users.skill_id', 'skills.id') - q.join('communities_users', 'communities_users.user_id', 'skills_users.user_id') - q.where(function () { - this.whereIn('communities_users.community_id', myCommunityIds(currentUserId)) - .orWhereIn('communities_users.community_id', myNetworkCommunityIds(currentUserId)) - }) - }) + return this.where({ id: nameOrId }).fetch(opts); + }, + + search: function ({ autocomplete, limit, offset, currentUserId }) { + return this.query((q) => { + q.limit(limit); + q.offset(offset); + q.orderByRaw('upper("name") asc'); + + if (autocomplete) { + q.whereRaw("name ilike ?", autocomplete + "%"); + } + q.join("skills_users", "skills_users.skill_id", "skills.id"); + q.join( + "communities_users", + "communities_users.user_id", + "skills_users.user_id" + ); + q.where(function () { + this.whereIn( + "communities_users.community_id", + myCommunityIds(currentUserId) + ).orWhereIn( + "communities_users.community_id", + myNetworkCommunityIds(currentUserId) + ); + }); + }); + }, } -}) +); diff --git a/api/models/StripeAccount.js b/api/models/StripeAccount.js index 1155b74fd..d68309b9a 100644 --- a/api/models/StripeAccount.js +++ b/api/models/StripeAccount.js @@ -1,7 +1,7 @@ module.exports = bookshelf.Model.extend({ - tableName: 'stripe_accounts', + tableName: "stripe_accounts", - user: function() { - return this.hasOne(User) - } -}) + user: function () { + return this.hasOne(User); + }, +}); diff --git a/api/models/Tag.js b/api/models/Tag.js index 5394b54c7..4815959ff 100644 --- a/api/models/Tag.js +++ b/api/models/Tag.js @@ -1,264 +1,322 @@ /* eslint-disable camelcase */ -import { updateOrRemove } from '../../lib/util/knex' -import { includes, isUndefined } from 'lodash' -import { - filter, omitBy, some, uniq -} from 'lodash/fp' -import { validateTopicName } from 'hylo-utils/validators' -import { myCommunityIds } from './util/queryFilters' - -export const tagsInText = (text = '') => { - const re = /(?:^| |>)#([A-Za-z][\w_-]+)/g - var match - var tags = [] +import { updateOrRemove } from "../../lib/util/knex"; +import { includes, isUndefined } from "lodash"; +import { filter, omitBy, some, uniq } from "lodash/fp"; +import { validateTopicName } from "hylo-utils/validators"; +import { myCommunityIds } from "./util/queryFilters"; + +export const tagsInText = (text = "") => { + const re = /(?:^| |>)#([A-Za-z][\w_-]+)/g; + let match; + const tags = []; while ((match = re.exec(text)) != null) { - tags.push(match[1]) + tags.push(match[1]); } - return tags -} + return tags; +}; const addToTaggable = (taggable, name, userId, opts) => { - var association, getCommunities - var isPost = taggable.tableName === 'posts' + let association, getCommunities; + const isPost = taggable.tableName === "posts"; if (isPost) { // taggable is a post - association = 'communities' - getCommunities = post => post.relations.communities + association = "communities"; + getCommunities = (post) => post.relations.communities; } else { // taggable is a comment - association = 'post.communities' - getCommunities = comment => comment.relations.post.relations.communities + association = "post.communities"; + getCommunities = (comment) => comment.relations.post.relations.communities; } - const created_at = new Date() - const findTag = () => Tag.find({ name }, opts) - return taggable.load(association, opts).then(findTag) - // create the tag -- if creation fails, find the existing one - .then(tag => tag || - new Tag({name, created_at}).save({}, opts).catch(findTag)) - .tap(tag => - taggable.tags().attach(omitBy(isUndefined, { - tag_id: tag.id, - created_at - }), opts) - // userId here is the id of the user making the edit, which is not always - // the same as the user who created the taggable. we add the tag only to - // those communities of which the user making the edit is a member. - .then(() => userId && Group.pluckIdsForMember(userId, Community)) - .then(communityIds => { - if (!communityIds) return - const communities = filter(c => includes(communityIds, c.id), - getCommunities(taggable).models) - return Promise.map(communities, com => Tag.addToCommunity({ - community_id: com.id, - tag_id: tag.id, - user_id: taggable.get('user_id'), - isSubscribing: true - }, opts)) - })) -} + const created_at = new Date(); + const findTag = () => Tag.find({ name }, opts); + return ( + taggable + .load(association, opts) + .then(findTag) + // create the tag -- if creation fails, find the existing one + .then( + (tag) => + tag || new Tag({ name, created_at }).save({}, opts).catch(findTag) + ) + .tap((tag) => + taggable + .tags() + .attach( + omitBy(isUndefined, { + tag_id: tag.id, + created_at, + }), + opts + ) + // userId here is the id of the user making the edit, which is not always + // the same as the user who created the taggable. we add the tag only to + // those communities of which the user making the edit is a member. + .then(() => userId && Group.pluckIdsForMember(userId, Community)) + .then((communityIds) => { + if (!communityIds) return; + const communities = filter( + (c) => includes(communityIds, c.id), + getCommunities(taggable).models + ); + return Promise.map(communities, (com) => + Tag.addToCommunity( + { + community_id: com.id, + tag_id: tag.id, + user_id: taggable.get("user_id"), + isSubscribing: true, + }, + opts + ) + ); + }) + ) + ); +}; const removeFromTaggable = (taggable, tag, opts) => { - return taggable.tags().detach(tag.id, opts) -} + return taggable.tags().detach(tag.id, opts); +}; const updateForTaggable = ({ taggable, tagNames, userId, transacting }) => { - return taggable.load('tags', {transacting}) - .then(() => { - const toRemove = taggable.relations.tags.models - return Promise.map(toRemove, tag => removeFromTaggable(taggable, tag, {transacting})) - .then(() => Promise.map(uniq(tagNames), name => addToTaggable(taggable, name, userId, {transacting}))) - }) -} - -module.exports = bookshelf.Model.extend({ - tableName: 'tags', - - memberships: function () { - return this.hasMany(CommunityTag) - }, - - communities: function () { - return this.belongsToMany(Community).through(CommunityTag) - .withPivot(['user_id', 'description']) - }, - - communityTags: function () { - return this.hasMany(CommunityTag) - }, - - posts: function () { - return this.belongsToMany(Post).through(PostTag).withPivot('selected') - }, - - comments: function () { - return this.belongsToMany(Comment).through(CommentTag) - }, - - follows: function () { - return this.hasMany(TagFollow) - }, - - followForUserAndCommunity: function (userId, communityId) { - return this.hasOne(TagFollow).query({where: { - user_id: userId, - community_id: communityId - }}) - } - -}, { - addToCommunity: ({ community_id, tag_id, user_id, description, is_default, isSubscribing }, opts) => - CommunityTag.where({community_id, tag_id}).fetch(opts) - .tap(comTag => comTag || - CommunityTag.create({community_id, tag_id, user_id, description, is_default}, opts) - .catch(() => {})) - // the catch above is for the case where another user just created the - // CommunityTag (race condition): the save fails, but we don't care about - // the result - .then(comTag => comTag && comTag.save({updated_at: new Date(), is_default})) - .then(() => user_id && isSubscribing && - TagFollow.where({community_id, tag_id, user_id}).fetch(opts) - .then(follow => follow || - TagFollow.create({community_id, tag_id, user_id}, opts))), - - isValidTag: function (text) { - return !validateTopicName(text) - }, - - validate: function (text) { - return validateTopicName(text) - }, - - tagsInText, - - find: function ({ id, name }, options) { - if (id) { - return Tag.where({ id }).fetch(options) - } - if (name) { - return Tag.query(qb => qb.whereRaw('lower(name) = lower(?)', name)) - .fetch(options) - } - return Promise.resolve(null) - }, - - findOrCreate: function (name, options) { - return Tag.find({ name }, options) - .then(tag => { - if (tag) return tag - return new Tag({name}).save(null, options) - }) - }, - - updateForPost: function (post, tagNames, userId, trx) { - return updateForTaggable({ - taggable: post, - tagNames, - userId, - transacting: trx - }) - }, - - merge: (id1, id2) => { - return bookshelf.transaction(trx => { - const update = (table, uniqueCols) => - updateOrRemove(table, 'tag_id', id2, id1, uniqueCols, trx) - - return Promise.join( - update('posts_tags', ['post_id']), - update('communities_tags', ['community_id']), - update('tag_follows', ['community_id', 'user_id']) + return taggable.load("tags", { transacting }).then(() => { + const toRemove = taggable.relations.tags.models; + return Promise.map(toRemove, (tag) => + removeFromTaggable(taggable, tag, { transacting }) + ).then(() => + Promise.map(uniq(tagNames), (name) => + addToTaggable(taggable, name, userId, { transacting }) ) - .then(() => trx('tags').where('id', id2).del()) - }) - }, - - remove: id => { - return bookshelf.transaction(trx => { - const tables = ['tag_follows', 'communities_tags', 'posts_tags'] - return Promise.all(tables.map(t => trx(t).where('tag_id', id).del())) - .then(() => trx('tags').where('id', id).del()) - }) - }, - - taggedPostCount: (tagId, options = {}) => { - const { userId, communitySlug, networkSlug } = options - const q = PostTag.query() - - q.select(bookshelf.knex.raw('count(distinct posts_tags.post_id) AS count')) - q.join('posts', 'posts.id', 'posts_tags.post_id') - q.join('tags', 'tags.id', 'posts_tags.tag_id') - q.join('communities_tags', 'communities_tags.tag_id', 'tags.id') - q.join('communities_posts', 'communities_posts.post_id', 'posts.id') - q.join('communities', 'communities.id', 'communities_posts.community_id') - q.where('posts_tags.tag_id', tagId) - q.where('posts.active', true) - q.where('communities.active', true) - if (userId) { - q.where('communities.id', 'in', myCommunityIds(userId)) - } - if (networkSlug) { - q.join('networks', 'networks.id', 'communities.network_id') - q.where('networks.slug', networkSlug) - } - if (communitySlug) { - q.where('communities.slug', communitySlug) - } - q.groupBy('tags.id') - - return q.then(rows => { - if (rows.length === 0) return 0 - - return Number(rows[0].count) - }) + ); + }); +}; + +module.exports = bookshelf.Model.extend( + { + tableName: "tags", + + memberships: function () { + return this.hasMany(CommunityTag); + }, + + communities: function () { + return this.belongsToMany(Community) + .through(CommunityTag) + .withPivot(["user_id", "description"]); + }, + + communityTags: function () { + return this.hasMany(CommunityTag); + }, + + posts: function () { + return this.belongsToMany(Post).through(PostTag).withPivot("selected"); + }, + + comments: function () { + return this.belongsToMany(Comment).through(CommentTag); + }, + + follows: function () { + return this.hasMany(TagFollow); + }, + + followForUserAndCommunity: function (userId, communityId) { + return this.hasOne(TagFollow).query({ + where: { + user_id: userId, + community_id: communityId, + }, + }); + }, }, - - followersCount: (tagId, { userId, communityId, communitySlug, networkSlug }) => { - const q = TagFollow.query() - - q.join('communities', 'communities.id', 'tag_follows.community_id') - - if (userId) { - q.where('communities.id', 'in', myCommunityIds(userId)) - } - - if (communityId) { - q.where('tag_follows.community_id', communityId) - } - - if (communitySlug) { - q.where('communities.slug', communitySlug) - } - - if (networkSlug) { - q.join('networks', 'networks.id', 'communities.network_id') - q.where('networks.slug', networkSlug) - } - - q.where({ tag_id: tagId }) - q.where('communities.active', true) - q.count() - - return q.then(rows => Number(rows[0].count)) - }, - - nonexistent: (names, communityIds) => { - if (names.length === 0 || !communityIds || communityIds.length === 0) return Promise.resolve({}) - - const isCommunity = id => row => Number(row.community_id) === Number(id) - const sameTag = name => row => row.name.toLowerCase() === name.toLowerCase() - - return Tag.query().where('name', 'in', names) - .join('communities_tags', 'communities_tags.tag_id', 'tags.id') - .where('community_id', 'in', communityIds) - .select(['tags.name', 'community_id']) - .then(rows => { - return names.reduce((m, n) => { - const matching = filter(sameTag(n), rows) - const missing = filter(id => !some(isCommunity(id), matching), communityIds) - if (missing.length > 0) m[n] = missing - return m - }, {}) - }) + { + addToCommunity: ( + { community_id, tag_id, user_id, description, is_default, isSubscribing }, + opts + ) => + CommunityTag.where({ community_id, tag_id }) + .fetch(opts) + .tap( + (comTag) => + comTag || + CommunityTag.create( + { community_id, tag_id, user_id, description, is_default }, + opts + ).catch(() => {}) + ) + // the catch above is for the case where another user just created the + // CommunityTag (race condition): the save fails, but we don't care about + // the result + .then( + (comTag) => + comTag && comTag.save({ updated_at: new Date(), is_default }) + ) + .then( + () => + user_id && + isSubscribing && + TagFollow.where({ community_id, tag_id, user_id }) + .fetch(opts) + .then( + (follow) => + follow || + TagFollow.create({ community_id, tag_id, user_id }, opts) + ) + ), + + isValidTag: function (text) { + return !validateTopicName(text); + }, + + validate: function (text) { + return validateTopicName(text); + }, + + tagsInText, + + find: function ({ id, name }, options) { + if (id) { + return Tag.where({ id }).fetch(options); + } + if (name) { + return Tag.query((qb) => + qb.whereRaw("lower(name) = lower(?)", name) + ).fetch(options); + } + return Promise.resolve(null); + }, + + findOrCreate: function (name, options) { + return Tag.find({ name }, options).then((tag) => { + if (tag) return tag; + return new Tag({ name }).save(null, options); + }); + }, + + updateForPost: function (post, tagNames, userId, trx) { + return updateForTaggable({ + taggable: post, + tagNames, + userId, + transacting: trx, + }); + }, + + merge: (id1, id2) => { + return bookshelf.transaction((trx) => { + const update = (table, uniqueCols) => + updateOrRemove(table, "tag_id", id2, id1, uniqueCols, trx); + + return Promise.join( + update("posts_tags", ["post_id"]), + update("communities_tags", ["community_id"]), + update("tag_follows", ["community_id", "user_id"]) + ).then(() => trx("tags").where("id", id2).del()); + }); + }, + + remove: (id) => { + return bookshelf.transaction((trx) => { + const tables = ["tag_follows", "communities_tags", "posts_tags"]; + return Promise.all( + tables.map((t) => trx(t).where("tag_id", id).del()) + ).then(() => trx("tags").where("id", id).del()); + }); + }, + + taggedPostCount: (tagId, options = {}) => { + const { userId, communitySlug, networkSlug } = options; + const q = PostTag.query(); + + q.select( + bookshelf.knex.raw("count(distinct posts_tags.post_id) AS count") + ); + q.join("posts", "posts.id", "posts_tags.post_id"); + q.join("tags", "tags.id", "posts_tags.tag_id"); + q.join("communities_tags", "communities_tags.tag_id", "tags.id"); + q.join("communities_posts", "communities_posts.post_id", "posts.id"); + q.join("communities", "communities.id", "communities_posts.community_id"); + q.where("posts_tags.tag_id", tagId); + q.where("posts.active", true); + q.where("communities.active", true); + if (userId) { + q.where("communities.id", "in", myCommunityIds(userId)); + } + if (networkSlug) { + q.join("networks", "networks.id", "communities.network_id"); + q.where("networks.slug", networkSlug); + } + if (communitySlug) { + q.where("communities.slug", communitySlug); + } + q.groupBy("tags.id"); + + return q.then((rows) => { + if (rows.length === 0) return 0; + + return Number(rows[0].count); + }); + }, + + followersCount: ( + tagId, + { userId, communityId, communitySlug, networkSlug } + ) => { + const q = TagFollow.query(); + + q.join("communities", "communities.id", "tag_follows.community_id"); + + if (userId) { + q.where("communities.id", "in", myCommunityIds(userId)); + } + + if (communityId) { + q.where("tag_follows.community_id", communityId); + } + + if (communitySlug) { + q.where("communities.slug", communitySlug); + } + + if (networkSlug) { + q.join("networks", "networks.id", "communities.network_id"); + q.where("networks.slug", networkSlug); + } + + q.where({ tag_id: tagId }); + q.where("communities.active", true); + q.count(); + + return q.then((rows) => Number(rows[0].count)); + }, + + nonexistent: (names, communityIds) => { + if (names.length === 0 || !communityIds || communityIds.length === 0) + return Promise.resolve({}); + + const isCommunity = (id) => (row) => + Number(row.community_id) === Number(id); + const sameTag = (name) => (row) => + row.name.toLowerCase() === name.toLowerCase(); + + return Tag.query() + .where("name", "in", names) + .join("communities_tags", "communities_tags.tag_id", "tags.id") + .where("community_id", "in", communityIds) + .select(["tags.name", "community_id"]) + .then((rows) => { + return names.reduce((m, n) => { + const matching = filter(sameTag(n), rows); + const missing = filter( + (id) => !some(isCommunity(id), matching), + communityIds + ); + if (missing.length > 0) m[n] = missing; + return m; + }, {}); + }); + }, } -}) +); diff --git a/api/models/TagFollow.js b/api/models/TagFollow.js index 9e067d804..093ea20fe 100644 --- a/api/models/TagFollow.js +++ b/api/models/TagFollow.js @@ -1,95 +1,121 @@ /* eslint-disable camelcase */ -module.exports = bookshelf.Model.extend({ - tableName: 'tag_follows', +module.exports = bookshelf.Model.extend( + { + tableName: "tag_follows", - community: function () { - return this.belongsTo(Community) - }, - - tag: function () { - return this.belongsTo(Tag) - }, + community: function () { + return this.belongsTo(Community); + }, - user: function () { - return this.belongsTo(User) - } + tag: function () { + return this.belongsTo(Tag); + }, -}, { - create (attrs, { transacting } = {}) { - return this.forge(Object.assign({created_at: new Date()}, attrs)) - .save({}, {transacting}) + user: function () { + return this.belongsTo(User); + }, }, + { + create(attrs, { transacting } = {}) { + return this.forge(Object.assign({ created_at: new Date() }, attrs)).save( + {}, + { transacting } + ); + }, - // toggle is used by hylo-redux - toggle: function (tagId, userId, communityId) { - return TagFollow.where({ - community_id: communityId, - tag_id: tagId, - user_id: userId - }).fetch() - .then(tagFollow => tagFollow - ? TagFollow.remove({tagId, userId, communityId}) - : TagFollow.add({tagId, userId, communityId})) - }, + // toggle is used by hylo-redux + toggle: function (tagId, userId, communityId) { + return TagFollow.where({ + community_id: communityId, + tag_id: tagId, + user_id: userId, + }) + .fetch() + .then((tagFollow) => + tagFollow + ? TagFollow.remove({ tagId, userId, communityId }) + : TagFollow.add({ tagId, userId, communityId }) + ); + }, - // subscribe is used by hylo-evo - subscribe: function (tagId, userId, communityId, isSubscribing) { - return TagFollow.where({community_id: communityId, tag_id: tagId, user_id: userId}) - .fetch() - .then(tagFollow => { - if (tagFollow && !isSubscribing) { - return TagFollow.remove({tagId, userId, communityId}) - } else if (!tagFollow && isSubscribing) { - return TagFollow.add({tagId, userId, communityId}) - } - }) - }, + // subscribe is used by hylo-evo + subscribe: function (tagId, userId, communityId, isSubscribing) { + return TagFollow.where({ + community_id: communityId, + tag_id: tagId, + user_id: userId, + }) + .fetch() + .then((tagFollow) => { + if (tagFollow && !isSubscribing) { + return TagFollow.remove({ tagId, userId, communityId }); + } else if (!tagFollow && isSubscribing) { + return TagFollow.add({ tagId, userId, communityId }); + } + }); + }, - add: function ({tagId, userId, communityId, transacting}) { - const attrs = { - tag_id: tagId, - community_id: communityId, - user_id: userId - } + add: function ({ tagId, userId, communityId, transacting }) { + const attrs = { + tag_id: tagId, + community_id: communityId, + user_id: userId, + }; - return TagFollow.where({ - community_id: communityId, - tag_id: tagId, - user_id: userId - }).fetch({transacting}) - .then(follow => follow || - new TagFollow(attrs).save(null, {transacting}) - .tap(() => CommunityTag.query(q => { - q.where('community_id', communityId) - q.where('tag_id', tagId) - }).query().increment('num_followers').transacting(transacting))) - }, + return TagFollow.where({ + community_id: communityId, + tag_id: tagId, + user_id: userId, + }) + .fetch({ transacting }) + .then( + (follow) => + follow || + new TagFollow(attrs).save(null, { transacting }).tap(() => + CommunityTag.query((q) => { + q.where("community_id", communityId); + q.where("tag_id", tagId); + }) + .query() + .increment("num_followers") + .transacting(transacting) + ) + ); + }, - remove: function ({tagId, userId, communityId, transacting}) { - const attrs = { - tag_id: tagId, - community_id: communityId, - user_id: userId - } - return TagFollow.where(attrs) - .fetch() - .then(tagFollow => tagFollow && - tagFollow.destroy({transacting}) - .tap(() => CommunityTag.query(q => { - q.where('community_id', attrs.community_id) - q.where('tag_id', attrs.tag_id) - }).query().decrement('num_followers').transacting(transacting))) - }, + remove: function ({ tagId, userId, communityId, transacting }) { + const attrs = { + tag_id: tagId, + community_id: communityId, + user_id: userId, + }; + return TagFollow.where(attrs) + .fetch() + .then( + (tagFollow) => + tagFollow && + tagFollow.destroy({ transacting }).tap(() => + CommunityTag.query((q) => { + q.where("community_id", attrs.community_id); + q.where("tag_id", attrs.tag_id); + }) + .query() + .decrement("num_followers") + .transacting(transacting) + ) + ); + }, - findFollowers: function (community_id, tag_id, limit = 3) { - return TagFollow.query(q => { - q.where({community_id, tag_id}) - q.limit(limit) - }) - .fetchAll({withRelated: ['user', 'user.tags']}) - .then(tagFollows => { - return tagFollows.models.map(tf => tf.relations.user) - }) + findFollowers: function (community_id, tag_id, limit = 3) { + return TagFollow.query((q) => { + q.where({ community_id, tag_id }); + q.limit(limit); + }) + .fetchAll({ withRelated: ["user", "user.tags"] }) + .then((tagFollows) => { + return tagFollows.models.map((tf) => tf.relations.user); + }); + }, } -}) +); diff --git a/api/models/Thank.js b/api/models/Thank.js index c6dbce7b8..0709798c0 100644 --- a/api/models/Thank.js +++ b/api/models/Thank.js @@ -1,60 +1,72 @@ -module.exports = bookshelf.Model.extend({ - tableName: 'thanks', +module.exports = bookshelf.Model.extend( + { + tableName: "thanks", - comment: function () { - return this.belongsTo(Comment) - }, - - user: function () { - return this.belongsTo(User).query({where: {active: true}}) - }, + comment: function () { + return this.belongsTo(Comment); + }, - thankedBy: function () { - return this.belongsTo(User, 'thanked_by_id') - } + user: function () { + return this.belongsTo(User).query({ where: { active: true } }); + }, -}, { - queryForUser: function (userId, communityIds) { - return Thank.query(q => { - q.orderBy('date_thanked') - q.join('comments', 'comments.id', '=', 'thanks.comment_id') - q.join('posts', 'posts.id', '=', 'comments.post_id') + thankedBy: function () { + return this.belongsTo(User, "thanked_by_id"); + }, + }, + { + queryForUser: function (userId, communityIds) { + return Thank.query((q) => { + q.orderBy("date_thanked"); + q.join("comments", "comments.id", "=", "thanks.comment_id"); + q.join("posts", "posts.id", "=", "comments.post_id"); - q.where({ - 'comments.user_id': userId, - 'comments.active': true, - 'posts.active': true - }) + q.where({ + "comments.user_id": userId, + "comments.active": true, + "posts.active": true, + }); - if (communityIds) { - q.join('communities_posts', 'communities_posts.post_id', '=', 'posts.id') - q.join('communities', 'communities.id', '=', 'communities_posts.community_id') - q.whereIn('communities.id', communityIds) - } - }) - }, + if (communityIds) { + q.join( + "communities_posts", + "communities_posts.post_id", + "=", + "posts.id" + ); + q.join( + "communities", + "communities.id", + "=", + "communities_posts.community_id" + ); + q.whereIn("communities.id", communityIds); + } + }); + }, - countForUser: function (user) { - return this.query().count() - .where({ - 'thanks.user_id': user.id, - 'comments.active': true - }) - .join('comments', function () { - this.on('comments.id', '=', 'thanks.comment_id') - }) - .then(function (rows) { - return rows[0].count - }) - }, + countForUser: function (user) { + return this.query() + .count() + .where({ + "thanks.user_id": user.id, + "comments.active": true, + }) + .join("comments", function () { + this.on("comments.id", "=", "thanks.comment_id"); + }) + .then(function (rows) { + return rows[0].count; + }); + }, - create: function (comment, userId) { - return new Thank({ - thanked_by_id: userId, - comment_id: comment.get('id'), - user_id: comment.get('user_id'), - date_thanked: new Date() - }).save() + create: function (comment, userId) { + return new Thank({ + thanked_by_id: userId, + comment_id: comment.get("id"), + user_id: comment.get("user_id"), + date_thanked: new Date(), + }).save(); + }, } - -}) +); diff --git a/api/models/User.js b/api/models/User.js index fc00a0b37..dd19f6728 100644 --- a/api/models/User.js +++ b/api/models/User.js @@ -1,553 +1,663 @@ -import bcrypt from 'bcrypt' -import crypto from 'crypto' -import validator from 'validator' -import { get, has, isEmpty, merge, omit, pick, intersectionBy } from 'lodash' -import { validateUser } from 'hylo-utils/validators' -import HasSettings from './mixins/HasSettings' -import HasGroupMemberships from './user/HasGroupMemberships' -import { findThread } from './post/findOrCreateThread' - -module.exports = bookshelf.Model.extend(merge({ - tableName: 'users', - - activity: function () { - return this.hasMany(Activity, 'reader_id') - }, - - comments: function () { - return this.hasMany(Comment) - .query(q => { - q.where('posts.user_id', 'NOT IN', BlockedUser.blockedFor(this.id)) - q.where(function () { - this.where('posts.type', '!=', Post.Type.THREAD) - .orWhere('posts.type', null) - }) - }) - }, - - communities: function () { - return this.queryByGroupMembership(Community) - .query(q => q.where('active', true)) - }, - - contributions: function () { - return this.hasMany(Contribution) - }, - - devices: function () { - return this.hasMany(Device, 'user_id') - }, - - inAppNotifications: function () { - return this.hasMany(Notification) - .query({where: {'notifications.medium': Notification.MEDIUM.InApp}}) - }, - - followedTags: function () { - return this.belongsToMany(Tag).through(TagFollow) - }, - - tagFollows: function () { - return this.hasMany(TagFollow) - }, - - linkedAccounts: function () { - return this.hasMany(LinkedAccount) - }, - - locationObject: function () { - return this.belongsTo(Location, 'location_id') - }, - - memberships: function () { - return this.groupMembershipsForModel(Community) - }, - - moderatedCommunityMemberships: function () { - return this.groupMembershipsForModel(Community) - .query(q => { - q.where('group_memberships.role', GroupMembership.Role.MODERATOR) - }) - }, - - posts: function () { - return this.hasMany(Post).query(q => q.where(function () { - this.where('type', null).orWhere('type', '!=', Post.Type.THREAD) - })) - }, - - stripeAccount: function () { - return this.belongsTo(StripeAccount) - }, - - votes: function () { - return this.hasMany(Vote) - }, - - followedPosts () { - return this.queryByGroupMembership(Post, { - where: q => q.whereRaw(`(settings->>'following')::boolean = true`) - }) - .query(q => q.where('active', true)) - }, - - messageThreads: function () { - return this.followedPosts().query(q => q.where('type', Post.Type.THREAD)) - }, - - eventsInvitedTo: function () { - return this.belongsToMany(Post).through(EventInvitation) - }, - - sentInvitations: function () { - return this.hasMany(Invitation, 'invited_by_id') - }, - - skills: function () { - return this.belongsToMany(Skill, 'skills_users') - }, - - blockedUsers: function () { - return this.belongsToMany(User, 'blocked_users', 'user_id', 'blocked_user_id') - }, - - thanks: function () { - return this.hasMany(Thank) - }, - - intercomHash: function () { - return crypto.createHmac('sha256', process.env.INTERCOM_KEY) - .update(this.id) - .digest('hex') - }, - - joinCommunity: async function (community, role = GroupMembership.Role.DEFAULT, { transacting } = {}) { - const memberships = await community.addGroupMembers([this.id], - { - role, - settings: { - sendEmail: true, - sendPushNotifications: true - }}, - {transacting}) - await Community.query().where('id', community.id) - .increment('num_members').transacting(transacting) - await this.followDefaultTags(community.id, transacting) - await this.markInvitationsUsed(community.id, transacting) - return memberships[0] - }, - - leaveCommunity: async function (community) { - await community.removeGroupMembers([this.id]) - await Community.query().where('id', community.id).decrement('num_members') - }, - - // sanitize certain values before storing them - setSanely: function (attrs) { - const saneAttrs = omit(attrs, 'settings') - - if (!isEmpty(saneAttrs.twitter_name)) { - if (saneAttrs.twitter_name.match(/^\s*$/)) { - saneAttrs.twitter_name = null - } else if (saneAttrs.twitter_name.match(/^@/)) { - saneAttrs.twitter_name = saneAttrs.twitter_name.substring(1) - } - } - const urlAttrs = ['url', 'facebook_url', 'linkedin_url'] +import bcrypt from "bcrypt"; +import crypto from "crypto"; +import validator from "validator"; +import { get, has, isEmpty, merge, omit, pick, intersectionBy } from "lodash"; +import { validateUser } from "hylo-utils/validators"; +import HasSettings from "./mixins/HasSettings"; +import HasGroupMemberships from "./user/HasGroupMemberships"; +import { findThread } from "./post/findOrCreateThread"; + +module.exports = bookshelf.Model.extend( + merge( + { + tableName: "users", + + activity: function () { + return this.hasMany(Activity, "reader_id"); + }, - urlAttrs.forEach(key => { - const normalized = addProtocol(saneAttrs[key]) - if (!isEmpty(normalized)) { - saneAttrs[key] = normalized - } - }) - - if (attrs.settings) this.addSetting(attrs.settings) - - return this.set(saneAttrs) - }, - - encryptedEmail: function () { - return User.encryptEmail(this.get('email')) - }, - - generateTokenContents: function () { - return `crumbly:${this.id}:${this.get('email')}:${this.get('created_at')}` - }, - - generateToken: function () { - var hash = Promise.promisify(bcrypt.hash, bcrypt) - return hash(this.generateTokenContents(), 10) - }, - - generateTokenSync: function () { - return bcrypt.hashSync(this.generateTokenContents(), 10) - }, - - checkToken: function (token) { - var compare = Promise.promisify(bcrypt.compare, bcrypt) - return compare(this.generateTokenContents(), token) - }, - - sendPushNotification: function (alert, url) { - return this.devices().fetch() - .then(devices => Promise.map(devices.models, device => - device.sendPushNotification(alert, url))) - }, - - resetNotificationCount: function () { - return this.devices().fetch() - .then(devices => Promise.map(devices.models, device => - device.resetNotificationCount())) - }, - - followDefaultTags: function (communityId, trx) { - return this.constructor.followDefaultTags(this.id, communityId, trx) - }, - - hasNoAvatar: function () { - return this.get('avatar_url') === User.gravatar(this.get('email')) - }, - - markInvitationsUsed: function (communityId, trx) { - return Invitation.query() - .where('community_id', communityId) - .whereRaw('lower(email) = lower(?)', this.get('email')) - .update({used_by_id: this.id}).transacting(trx) - }, - - setPassword: function (password, { transacting } = {}) { - return LinkedAccount.where({user_id: this.id, provider_key: 'password'}) - .fetch({transacting}).then(account => account - ? account.updatePassword(password, {transacting}) - : LinkedAccount.create(this.id, {type: 'password', password, transacting})) - }, - - hasDevice: function () { - return this.load('devices') - .then(() => this.relations.devices.length > 0) - }, - - validateAndSave: function (changes) { - // TODO maybe throw an error if a non-whitelisted field is supplied (besides - // tags and password, which are used later) - var whitelist = pick(changes, [ - 'avatar_url', 'banner_url', 'bio', 'email', 'contact_email', 'contact_phone', - 'extra_info', 'facebook_url', 'intention', 'linkedin_url', 'location', 'location_id', - 'name', 'password', 'settings', 'tagline', 'twitter_name', 'url', 'work', - 'new_notification_count' - ]) - - return bookshelf.transaction(transacting => - validateUserAttributes(whitelist, {existingUser: this, transacting}) - // we refresh the user's data inside the transaction to avoid a race - // condition between two updates on the same user that depend upon - // existing data, e.g. when updating settings - .then(() => this.refresh({transacting})) - .then(() => this.setSanely(omit(whitelist, 'password'))) - .then(() => Promise.all([ - changes.password && this.setPassword(changes.password, {transacting}), - !isEmpty(this.changed) && this.save( - Object.assign({updated_at: new Date()}, this.changed), - {patch: true, transacting} - ) - ]))) - .then(() => this) - }, - - enabledNotification (type, medium) { - let setting - - switch (type) { - case Notification.TYPE.Message: - setting = this.getSetting('dm_notifications') - break - case Notification.TYPE.Comment: - setting = this.getSetting('comment_notifications') - break - default: - throw new Error(`unknown notification type: ${type}`) - } - - return setting === 'both' || - (setting === 'email' && medium === Notification.MEDIUM.Email) || - (setting === 'push' && medium === Notification.MEDIUM.Push) - }, - - disableAllNotifications () { - return this.addSetting({ - digest_frequency: 'never', - comment_notifications: 'none', - dm_notifications: 'none' - }, true) - }, - - unlinkAccount (provider) { - const fieldName = { - 'facebook': 'facebook_url', - 'linkedin': 'linkedin_url', - 'twitter': 'twitter_name' - }[provider] - - if (!fieldName) throw new Error(`${provider} not a supported provider`) - - return Promise.join( - LinkedAccount.query().where({'user_id': this.id, provider_key: provider}).del(), - this.save({[fieldName]: null}) - ) - }, - - getMessageThreadWith (userId) { - return findThread(this.id, [userId]) - }, - - unseenThreadCount () { - return User.unseenThreadCount(this.id) - }, - - async communitiesSharedWithPost (post) { - const myCommunities = await this.communities().fetch() - await post.load('communities') - return intersectionBy(post.relations.communities.models, myCommunities.models, 'id') - }, - - async communitiesSharedWithUser (user) { - const myCommunities = await this.communities().fetch() - const theirCommunities = await user.communities().fetch() - return intersectionBy(myCommunities.models, theirCommunities.models, 'id') - }, - - async updateStripeAccount (accountId, refreshToken = '') { - await this.load('stripeAccount') - const existingAccount = this.relations.stripeAccount - const newAccount = await StripeAccount.forge({ - stripe_account_external_id: accountId, - refresh_token: refreshToken - }).save() - return this.save({ - stripe_account_id: newAccount.id - }) - .then(() => { - if (existingAccount) { - return existingAccount.destroy() - } - }) - }, + comments: function () { + return this.hasMany(Comment).query((q) => { + q.where("posts.user_id", "NOT IN", BlockedUser.blockedFor(this.id)); + q.where(function () { + this.where("posts.type", "!=", Post.Type.THREAD).orWhere( + "posts.type", + null + ); + }); + }); + }, - hasStripeAccount () { - return !!this.get('stripe_account_id') - } + communities: function () { + return this.queryByGroupMembership(Community).query((q) => + q.where("active", true) + ); + }, + + contributions: function () { + return this.hasMany(Contribution); + }, + + devices: function () { + return this.hasMany(Device, "user_id"); + }, + + inAppNotifications: function () { + return this.hasMany(Notification).query({ + where: { "notifications.medium": Notification.MEDIUM.InApp }, + }); + }, + + followedTags: function () { + return this.belongsToMany(Tag).through(TagFollow); + }, + + tagFollows: function () { + return this.hasMany(TagFollow); + }, + + linkedAccounts: function () { + return this.hasMany(LinkedAccount); + }, + + locationObject: function () { + return this.belongsTo(Location, "location_id"); + }, + + memberships: function () { + return this.groupMembershipsForModel(Community); + }, + + moderatedCommunityMemberships: function () { + return this.groupMembershipsForModel(Community).query((q) => { + q.where("group_memberships.role", GroupMembership.Role.MODERATOR); + }); + }, + + posts: function () { + return this.hasMany(Post).query((q) => + q.where(function () { + this.where("type", null).orWhere("type", "!=", Post.Type.THREAD); + }) + ); + }, + + stripeAccount: function () { + return this.belongsTo(StripeAccount); + }, + + votes: function () { + return this.hasMany(Vote); + }, + + followedPosts() { + return this.queryByGroupMembership(Post, { + where: (q) => q.whereRaw("(settings->>'following')::boolean = true"), + }).query((q) => q.where("active", true)); + }, + + messageThreads: function () { + return this.followedPosts().query((q) => + q.where("type", Post.Type.THREAD) + ); + }, + + eventsInvitedTo: function () { + return this.belongsToMany(Post).through(EventInvitation); + }, + + sentInvitations: function () { + return this.hasMany(Invitation, "invited_by_id"); + }, + + skills: function () { + return this.belongsToMany(Skill, "skills_users"); + }, + + blockedUsers: function () { + return this.belongsToMany( + User, + "blocked_users", + "user_id", + "blocked_user_id" + ); + }, + + thanks: function () { + return this.hasMany(Thank); + }, + + intercomHash: function () { + return crypto + .createHmac("sha256", process.env.INTERCOM_KEY) + .update(this.id) + .digest("hex"); + }, + + joinCommunity: async function ( + community, + role = GroupMembership.Role.DEFAULT, + { transacting } = {} + ) { + const memberships = await community.addGroupMembers( + [this.id], + { + role, + settings: { + sendEmail: true, + sendPushNotifications: true, + }, + }, + { transacting } + ); + await Community.query() + .where("id", community.id) + .increment("num_members") + .transacting(transacting); + await this.followDefaultTags(community.id, transacting); + await this.markInvitationsUsed(community.id, transacting); + return memberships[0]; + }, -}, HasSettings, HasGroupMemberships), { - AXOLOTL_ID: '13986', + leaveCommunity: async function (community) { + await community.removeGroupMembers([this.id]); + await Community.query() + .where("id", community.id) + .decrement("num_members"); + }, + + // sanitize certain values before storing them + setSanely: function (attrs) { + const saneAttrs = omit(attrs, "settings"); + + if (!isEmpty(saneAttrs.twitter_name)) { + if (saneAttrs.twitter_name.match(/^\s*$/)) { + saneAttrs.twitter_name = null; + } else if (saneAttrs.twitter_name.match(/^@/)) { + saneAttrs.twitter_name = saneAttrs.twitter_name.substring(1); + } + } + const urlAttrs = ["url", "facebook_url", "linkedin_url"]; + + urlAttrs.forEach((key) => { + const normalized = addProtocol(saneAttrs[key]); + if (!isEmpty(normalized)) { + saneAttrs[key] = normalized; + } + }); + + if (attrs.settings) this.addSetting(attrs.settings); + + return this.set(saneAttrs); + }, + + encryptedEmail: function () { + return User.encryptEmail(this.get("email")); + }, + + generateTokenContents: function () { + return `crumbly:${this.id}:${this.get("email")}:${this.get( + "created_at" + )}`; + }, + + generateToken: function () { + const hash = Promise.promisify(bcrypt.hash, bcrypt); + return hash(this.generateTokenContents(), 10); + }, + + generateTokenSync: function () { + return bcrypt.hashSync(this.generateTokenContents(), 10); + }, + + checkToken: function (token) { + const compare = Promise.promisify(bcrypt.compare, bcrypt); + return compare(this.generateTokenContents(), token); + }, + + sendPushNotification: function (alert, url) { + return this.devices() + .fetch() + .then((devices) => + Promise.map(devices.models, (device) => + device.sendPushNotification(alert, url) + ) + ); + }, + + resetNotificationCount: function () { + return this.devices() + .fetch() + .then((devices) => + Promise.map(devices.models, (device) => + device.resetNotificationCount() + ) + ); + }, + + followDefaultTags: function (communityId, trx) { + return this.constructor.followDefaultTags(this.id, communityId, trx); + }, + + hasNoAvatar: function () { + return this.get("avatar_url") === User.gravatar(this.get("email")); + }, + + markInvitationsUsed: function (communityId, trx) { + return Invitation.query() + .where("community_id", communityId) + .whereRaw("lower(email) = lower(?)", this.get("email")) + .update({ used_by_id: this.id }) + .transacting(trx); + }, + + setPassword: function (password, { transacting } = {}) { + return LinkedAccount.where({ + user_id: this.id, + provider_key: "password", + }) + .fetch({ transacting }) + .then((account) => + account + ? account.updatePassword(password, { transacting }) + : LinkedAccount.create(this.id, { + type: "password", + password, + transacting, + }) + ); + }, - authenticate: Promise.method(function (email, password) { - var compare = Promise.promisify(bcrypt.compare, bcrypt) + hasDevice: function () { + return this.load("devices").then( + () => this.relations.devices.length > 0 + ); + }, - if (!email) throw new Error('no email provided') - if (!password) throw new Error('no password provided') + validateAndSave: function (changes) { + // TODO maybe throw an error if a non-whitelisted field is supplied (besides + // tags and password, which are used later) + const whitelist = pick(changes, [ + "avatar_url", + "banner_url", + "bio", + "email", + "contact_email", + "contact_phone", + "extra_info", + "facebook_url", + "intention", + "linkedin_url", + "location", + "location_id", + "name", + "password", + "settings", + "tagline", + "twitter_name", + "url", + "work", + "new_notification_count", + ]); + + return bookshelf + .transaction((transacting) => + validateUserAttributes(whitelist, { + existingUser: this, + transacting, + }) + // we refresh the user's data inside the transaction to avoid a race + // condition between two updates on the same user that depend upon + // existing data, e.g. when updating settings + .then(() => this.refresh({ transacting })) + .then(() => this.setSanely(omit(whitelist, "password"))) + .then(() => + Promise.all([ + changes.password && + this.setPassword(changes.password, { transacting }), + !isEmpty(this.changed) && + this.save( + Object.assign({ updated_at: new Date() }, this.changed), + { patch: true, transacting } + ), + ]) + ) + ) + .then(() => this); + }, - return User.query('whereRaw', 'lower(email) = lower(?)', email) - .fetch({withRelated: ['linkedAccounts']}) - .then(function (user) { - if (!user) throw new Error('email not found') + enabledNotification(type, medium) { + let setting; + + switch (type) { + case Notification.TYPE.Message: + setting = this.getSetting("dm_notifications"); + break; + case Notification.TYPE.Comment: + setting = this.getSetting("comment_notifications"); + break; + default: + throw new Error(`unknown notification type: ${type}`); + } + + return ( + setting === "both" || + (setting === "email" && medium === Notification.MEDIUM.Email) || + (setting === "push" && medium === Notification.MEDIUM.Push) + ); + }, - var account = user.relations.linkedAccounts.where({provider_key: 'password'})[0] - if (!account) { - var keys = user.relations.linkedAccounts.pluck('provider_key') - throw new Error(`password account not found. available: [${keys.join(',')}]`) + disableAllNotifications() { + return this.addSetting( + { + digest_frequency: "never", + comment_notifications: "none", + dm_notifications: "none", + }, + true + ); + }, + + unlinkAccount(provider) { + const fieldName = { + facebook: "facebook_url", + linkedin: "linkedin_url", + twitter: "twitter_name", + }[provider]; + + if (!fieldName) throw new Error(`${provider} not a supported provider`); + + return Promise.join( + LinkedAccount.query() + .where({ user_id: this.id, provider_key: provider }) + .del(), + this.save({ [fieldName]: null }) + ); + }, + + getMessageThreadWith(userId) { + return findThread(this.id, [userId]); + }, + + unseenThreadCount() { + return User.unseenThreadCount(this.id); + }, + + async communitiesSharedWithPost(post) { + const myCommunities = await this.communities().fetch(); + await post.load("communities"); + return intersectionBy( + post.relations.communities.models, + myCommunities.models, + "id" + ); + }, + + async communitiesSharedWithUser(user) { + const myCommunities = await this.communities().fetch(); + const theirCommunities = await user.communities().fetch(); + return intersectionBy( + myCommunities.models, + theirCommunities.models, + "id" + ); + }, + + async updateStripeAccount(accountId, refreshToken = "") { + await this.load("stripeAccount"); + const existingAccount = this.relations.stripeAccount; + const newAccount = await StripeAccount.forge({ + stripe_account_external_id: accountId, + refresh_token: refreshToken, + }).save(); + return this.save({ + stripe_account_id: newAccount.id, + }).then(() => { + if (existingAccount) { + return existingAccount.destroy(); + } + }); + }, + + hasStripeAccount() { + return !!this.get("stripe_account_id"); + }, + }, + HasSettings, + HasGroupMemberships + ), + { + AXOLOTL_ID: "13986", + + authenticate: Promise.method(function (email, password) { + const compare = Promise.promisify(bcrypt.compare, bcrypt); + + if (!email) throw new Error("no email provided"); + if (!password) throw new Error("no password provided"); + + return User.query("whereRaw", "lower(email) = lower(?)", email) + .fetch({ withRelated: ["linkedAccounts"] }) + .then(function (user) { + if (!user) throw new Error("email not found"); + + const account = user.relations.linkedAccounts.where({ + provider_key: "password", + })[0]; + if (!account) { + const keys = user.relations.linkedAccounts.pluck("provider_key"); + throw new Error( + `password account not found. available: [${keys.join(",")}]` + ); + } + + return compare(password, account.get("provider_user_id")).then( + function (match) { + if (!match) throw new Error("password does not match"); + + return user; + } + ); + }); + }), + + create: function (attributes, options = {}) { + const { transacting } = options; + const { account, community } = attributes; + const communityId = Number(get(community, "id")); + const digest_frequency = communityId === 2308 ? "weekly" : "daily"; // eslint-disable-line camelcase + + attributes = merge( + { + avatar_url: User.gravatar(attributes.email), + created_at: new Date(), + updated_at: new Date(), + settings: { + digest_frequency, + signup_in_progress: true, + dm_notifications: "both", + comment_notifications: "both", + }, + active: true, + }, + omit(attributes, "account", "community") + ); + + if (account) { + merge( + attributes, + LinkedAccount.socialMediaAttributes(account.type, account.profile) + ); } - return compare(password, account.get('provider_user_id')).then(function (match) { - if (!match) throw new Error('password does not match') + if (!attributes.name && attributes.email) { + attributes.name = attributes.email.split("@")[0].replace(/[._]/g, " "); + } - return user - }) - }) - }), - - create: function (attributes, options = {}) { - const { transacting } = options - const { account, community } = attributes - const communityId = Number(get(community, 'id')) - const digest_frequency = communityId === 2308 ? 'weekly' : 'daily' // eslint-disable-line camelcase - - attributes = merge({ - avatar_url: User.gravatar(attributes.email), - created_at: new Date(), - updated_at: new Date(), - settings: { - digest_frequency, - signup_in_progress: true, - dm_notifications: 'both', - comment_notifications: 'both' - }, - active: true - }, omit(attributes, 'account', 'community')) - - if (account) { - merge( - attributes, - LinkedAccount.socialMediaAttributes(account.type, account.profile) - ) - } - - if (!attributes.name && attributes.email) { - attributes.name = attributes.email.split('@')[0].replace(/[._]/g, ' ') - } - - return validateUserAttributes(attributes) - .then(() => new User(attributes).save({}, {transacting})) - .tap(user => Promise.join( - account && LinkedAccount.create(user.id, account, {transacting}), - community && community.addMembers([user.id], {transacting}), - community && user.markInvitationsUsed(community.id, transacting) - )) - }, - - find: function (id, options) { - if (!id) return Promise.resolve(null) - let q - if (isNaN(Number(id))) { - q = User.query(q => { - q.where(function () { - this.whereRaw('lower(email) = lower(?)', id) - .orWhere({name: id}) + return validateUserAttributes(attributes) + .then(() => new User(attributes).save({}, { transacting })) + .tap((user) => + Promise.join( + account && LinkedAccount.create(user.id, account, { transacting }), + community && community.addMembers([user.id], { transacting }), + community && user.markInvitationsUsed(community.id, transacting) + ) + ); + }, + + find: function (id, options) { + if (!id) return Promise.resolve(null); + let q; + if (isNaN(Number(id))) { + q = User.query((q) => { + q.where(function () { + this.whereRaw("lower(email) = lower(?)", id).orWhere({ name: id }); + }); + }); + } else { + q = User.where({ id }); + } + return q.where("active", true).fetch(options); + }, + + named: function (name) { + return User.where({ name: name }).fetch(); + }, + + createdInTimeRange: function (collection, startTime, endTime) { + if (endTime === undefined) { + endTime = startTime; + startTime = collection; + collection = User; + } + return collection.query(function (qb) { + qb.whereRaw("users.created_at between ? and ?", [startTime, endTime]); + qb.where("users.active", true); + }); + }, + + isEmailUnique: function (email, excludeEmail, { transacting } = {}) { + let query = bookshelf + .knex("users") + .whereRaw("lower(email) = lower(?)", email) + .count("*") + .transacting(transacting); + if (excludeEmail) query = query.andWhere("email", "!=", excludeEmail); + return query.then((rows) => Number(rows[0].count) === 0); + }, + + incNewNotificationCount: function (id) { + return User.query().where({ id }).increment("new_notification_count", 1); + }, + + resetNewNotificationCount: function (id) { + return User.query().where({ id }).update({ new_notification_count: 0 }); + }, + + gravatar: function (email) { + if (!email) email = ""; + const emailHash = crypto.createHash("md5").update(email).digest("hex"); + return `https://www.gravatar.com/avatar/${emailHash}?d=mm&s=140`; + }, + + encryptEmail: function (email) { + const plaintext = process.env.MAILGUN_EMAIL_SALT + email; + return `u=${PlayCrypto.encrypt(plaintext)}@${process.env.MAILGUN_DOMAIN}`; + }, + + decryptEmail: function (email) { + const pattern = new RegExp(`u=(\\w+)@${process.env.MAILGUN_DOMAIN}`); + const match = email.match(pattern); + const hash = match[1]; + const decrypted = PlayCrypto.decrypt(hash); + const unsalted = decrypted.replace( + new RegExp("^" + process.env.MAILGUN_EMAIL_SALT), + "" + ); + + return unsalted; + }, + + sendPushNotification: function (userId, alert, url) { + return User.find(userId).fetch().sendPushNotification(alert, url); + }, + + followTags: function (userId, communityId, tagIds, trx) { + return Promise.each(tagIds, (id) => + TagFollow.add({ + userId: userId, + communityId: communityId, + tagId: id, + transacting: trx, + }).catch((err) => { + if (!err.message.match(/duplicate key value/)) throw err; }) + ); + }, + + followDefaultTags: function (userId, communityId, trx) { + return CommunityTag.defaults(communityId, trx) + .then((defaultTags) => defaultTags.models.map((t) => t.get("tag_id"))) + .then((ids) => User.followTags(userId, communityId, ids, trx)); + }, + + resetTooltips: function (userId) { + return User.find(userId).then((user) => + user.removeSetting("viewedTooltips", true) + ); + }, + + unseenThreadCount: async function (userId) { + const { raw } = bookshelf.knex; + + const lastViewed = await User.where("id", userId) + .query() + .select(raw("settings->'last_viewed_messages_at' as time")) + .then((rows) => new Date(rows[0].time)); + + return GroupMembership.whereUnread(userId, Post, { + afterTime: lastViewed, }) - } else { - q = User.where({id}) - } - return q.where('active', true).fetch(options) - }, - - named: function (name) { - return User.where({name: name}).fetch() - }, - - createdInTimeRange: function (collection, startTime, endTime) { - if (endTime === undefined) { - endTime = startTime - startTime = collection - collection = User - } - return collection.query(function (qb) { - qb.whereRaw('users.created_at between ? and ?', [startTime, endTime]) - qb.where('users.active', true) - }) - }, - - isEmailUnique: function (email, excludeEmail, { transacting } = {}) { - var query = bookshelf.knex('users') - .whereRaw('lower(email) = lower(?)', email).count('*') - .transacting(transacting) - if (excludeEmail) query = query.andWhere('email', '!=', excludeEmail) - return query.then(rows => Number(rows[0].count) === 0) - }, - - incNewNotificationCount: function (id) { - return User.query().where({id}).increment('new_notification_count', 1) - }, - - resetNewNotificationCount: function (id) { - return User.query().where({id}).update({new_notification_count: 0}) - }, - - gravatar: function (email) { - if (!email) email = '' - var emailHash = crypto.createHash('md5').update(email).digest('hex') - return `https://www.gravatar.com/avatar/${emailHash}?d=mm&s=140` - }, - - encryptEmail: function (email) { - var plaintext = process.env.MAILGUN_EMAIL_SALT + email - return `u=${PlayCrypto.encrypt(plaintext)}@${process.env.MAILGUN_DOMAIN}` - }, - - decryptEmail: function (email) { - var pattern = new RegExp(`u=(\\w+)@${process.env.MAILGUN_DOMAIN}`) - var match = email.match(pattern) - var hash = match[1] - var decrypted = PlayCrypto.decrypt(hash) - var unsalted = decrypted.replace(new RegExp('^' + process.env.MAILGUN_EMAIL_SALT), '') - - return unsalted - }, - - sendPushNotification: function (userId, alert, url) { - return User.find(userId).fetch().sendPushNotification(alert, url) - }, - - followTags: function (userId, communityId, tagIds, trx) { - return Promise.each(tagIds, id => - TagFollow.add({ - userId: userId, - communityId: communityId, - tagId: id, - transacting: trx - }) - .catch(err => { - if (!err.message.match(/duplicate key value/)) throw err - })) - }, - - followDefaultTags: function (userId, communityId, trx) { - return CommunityTag.defaults(communityId, trx) - .then(defaultTags => defaultTags.models.map(t => t.get('tag_id'))) - .then(ids => User.followTags(userId, communityId, ids, trx)) - }, - - resetTooltips: function (userId) { - return User.find(userId) - .then(user => user.removeSetting('viewedTooltips', true)) - }, - - unseenThreadCount: async function (userId) { - const { raw } = bookshelf.knex - - const lastViewed = await User.where('id', userId).query() - .select(raw("settings->'last_viewed_messages_at' as time")) - .then(rows => new Date(rows[0].time)) - - return GroupMembership.whereUnread(userId, Post, {afterTime: lastViewed}) - .query(q => { - q.join('posts', 'groups.group_data_id', 'posts.id') - q.where('posts.type', Post.Type.THREAD) - q.where('num_comments', '>', 0) - }) - .count().then(c => Number(c)) + .query((q) => { + q.join("posts", "groups.group_data_id", "posts.id"); + q.where("posts.type", Post.Type.THREAD); + q.where("num_comments", ">", 0); + }) + .count() + .then((c) => Number(c)); + }, } -}) +); -function validateUserAttributes (attrs, { existingUser, transacting } = {}) { - if (has(attrs, 'password')) { - const invalidReason = validateUser.password(attrs.password) - if (invalidReason) return Promise.reject(new Error(invalidReason)) +function validateUserAttributes(attrs, { existingUser, transacting } = {}) { + if (has(attrs, "password")) { + const invalidReason = validateUser.password(attrs.password); + if (invalidReason) return Promise.reject(new Error(invalidReason)); } - if (has(attrs, 'name')) { - const invalidReason = validateUser.name(attrs.name) - if (invalidReason) return Promise.reject(new Error(invalidReason)) + if (has(attrs, "name")) { + const invalidReason = validateUser.name(attrs.name); + if (invalidReason) return Promise.reject(new Error(invalidReason)); } // for an existing user, the email field can be omitted. - if (existingUser && !has(attrs, 'email')) return Promise.resolve() - const oldEmail = existingUser ? existingUser.get('email') : null + if (existingUser && !has(attrs, "email")) return Promise.resolve(); + const oldEmail = existingUser ? existingUser.get("email") : null; if (!validator.isEmail(attrs.email)) { - return Promise.reject(new Error('invalid-email')) + return Promise.reject(new Error("invalid-email")); } - return User.isEmailUnique(attrs.email, oldEmail, {transacting}) - .then(unique => unique || Promise.reject(new Error('duplicate-email'))) + return User.isEmailUnique(attrs.email, oldEmail, { transacting }).then( + (unique) => unique || Promise.reject(new Error("duplicate-email")) + ); } -export function addProtocol (url) { - if (isEmpty(url)) return url - const regex = /^(http:\/\/|https:\/\/)/ +export function addProtocol(url) { + if (isEmpty(url)) return url; + const regex = /^(http:\/\/|https:\/\/)/; if (regex.test(url)) { - return url + return url; } else { - return 'https://' + url + return "https://" + url; } } diff --git a/api/models/UserConnection.js b/api/models/UserConnection.js index 0813181df..013b8d0cc 100644 --- a/api/models/UserConnection.js +++ b/api/models/UserConnection.js @@ -1,53 +1,56 @@ /* eslint-disable camelcase */ -module.exports = bookshelf.Model.extend({ - tableName: 'user_connections', +module.exports = bookshelf.Model.extend( + { + tableName: "user_connections", - user: function () { - return this.belongsTo(User, 'user_id') - }, + user: function () { + return this.belongsTo(User, "user_id"); + }, - otherUser: function () { - return this.belongsTo(User, 'other_user_id') - } -}, { - Type: { - MESSAGE: 'message' + otherUser: function () { + return this.belongsTo(User, "other_user_id"); + }, }, + { + Type: { + MESSAGE: "message", + }, - create: function (userId, otherUserId, type) { - if (!this.Type.hasOwnProperty(type.toUpperCase())) { - throw new Error('Invalid UserConnection type specified') - } - if (userId === otherUserId) { - throw new Error('other_user_id cannot equal user_id') - } - return new UserConnection({ - user_id: userId, - other_user_id: otherUserId, - type, - created_at: new Date(), - updated_at: new Date() - }) - .save(null, { returning: '*' }) - }, + create: function (userId, otherUserId, type) { + if (!this.Type.hasOwnProperty(type.toUpperCase())) { + throw new Error("Invalid UserConnection type specified"); + } + if (userId === otherUserId) { + throw new Error("other_user_id cannot equal user_id"); + } + return new UserConnection({ + user_id: userId, + other_user_id: otherUserId, + type, + created_at: new Date(), + updated_at: new Date(), + }).save(null, { returning: "*" }); + }, - createOrUpdate: function (userId, otherUserId, type) { - return this.find(userId, otherUserId, type) - .then(connection => { - if (connection) return connection.save( - { updated_at: new Date() }, - { returning: '*' } - ) - return this.create(userId, otherUserId, type) - }) - }, + createOrUpdate: function (userId, otherUserId, type) { + return this.find(userId, otherUserId, type).then((connection) => { + if (connection) { + return connection.save( + { updated_at: new Date() }, + { returning: "*" } + ); + } + return this.create(userId, otherUserId, type); + }); + }, - find: function (user_id, other_user_id, type) { - if (!user_id) throw new Error('Parameter user_id must be supplied.') - return UserConnection.where({ user_id, other_user_id, type }).fetch() - }, + find: function (user_id, other_user_id, type) { + if (!user_id) throw new Error("Parameter user_id must be supplied."); + return UserConnection.where({ user_id, other_user_id, type }).fetch(); + }, - isMessage: function () { - return this.get('type') === Post.Type.MESSAGE + isMessage: function () { + return this.get("type") === Post.Type.MESSAGE; + }, } -}) +); diff --git a/api/models/UserExternalData.js b/api/models/UserExternalData.js index 813144ca4..708abc2b2 100644 --- a/api/models/UserExternalData.js +++ b/api/models/UserExternalData.js @@ -1,24 +1,25 @@ -module.exports = bookshelf.Model.extend({ - tableName: 'user_external_data' - -}, { - find: function (userId, type, opts) { - return this.where({user_id: userId, type: type}).fetch(opts) +module.exports = bookshelf.Model.extend( + { + tableName: "user_external_data", }, + { + find: function (userId, type, opts) { + return this.where({ user_id: userId, type: type }).fetch(opts); + }, - store: function (userId, type, data) { - return this.find(userId, type).then(storage => { - if (!storage) { - storage = new UserExternalData({ - user_id: userId, - type: type, - created_at: new Date() - }) - } + store: function (userId, type, data) { + return this.find(userId, type).then((storage) => { + if (!storage) { + storage = new UserExternalData({ + user_id: userId, + type: type, + created_at: new Date(), + }); + } - storage.set({data: data, updated_at: new Date()}) - return storage.save() - }) + storage.set({ data: data, updated_at: new Date() }); + return storage.save(); + }); + }, } - -}) +); diff --git a/api/models/Vote.js b/api/models/Vote.js index 960ca44bc..dcfcd90e9 100644 --- a/api/models/Vote.js +++ b/api/models/Vote.js @@ -1,24 +1,28 @@ -module.exports = bookshelf.Model.extend({ - tableName: 'votes', +module.exports = bookshelf.Model.extend( + { + tableName: "votes", - post: function() { - return this.belongsTo(Post, 'post_id'); - }, - - user: function() { - return this.belongsTo(User, "user_id") - } + post: function () { + return this.belongsTo(Post, "post_id"); + }, -}, { - - /** - * @param userId User ID to check which posts they voted on - * @param postIds List of Post ID's to check against - * @returns a list of Vote's. - */ - forUserInPosts: function(userId, postIds) { - return bookshelf.knex("votes").where({ - user_id: userId - }).whereIn("post_id", postIds); + user: function () { + return this.belongsTo(User, "user_id"); + }, + }, + { + /** + * @param userId User ID to check which posts they voted on + * @param postIds List of Post ID's to check against + * @returns a list of Vote's. + */ + forUserInPosts: function (userId, postIds) { + return bookshelf + .knex("votes") + .where({ + user_id: userId, + }) + .whereIn("post_id", postIds); + }, } -}); +); diff --git a/api/models/comment/createComment.js b/api/models/comment/createComment.js index f0f17a5fc..52e43ecca 100644 --- a/api/models/comment/createComment.js +++ b/api/models/comment/createComment.js @@ -1,119 +1,146 @@ -import { sanitize } from 'hylo-utils/text' -import { flatten, difference, uniq } from 'lodash' -import { postRoom, pushToSockets, userRoom } from '../../services/Websockets' -import { refineOne, refineMany } from '../util/relations' +import { sanitize } from "hylo-utils/text"; +import { flatten, difference, uniq } from "lodash"; +import { postRoom, pushToSockets, userRoom } from "../../services/Websockets"; +import { refineOne, refineMany } from "../util/relations"; -export default async function createComment (commenterId, opts = {}) { - let { text, post } = opts - text = sanitize(text) +export default async function createComment(commenterId, opts = {}) { + let { text, post } = opts; + text = sanitize(text); - var attrs = { + const attrs = { text: text, created_at: new Date(), recent: true, user_id: commenterId, post_id: post.id, active: true, - created_from: opts.created_from || null - } - - var existingFollowers, isThread - const mentioned = RichText.getUserMentions(text) - - existingFollowers = await post.followers().fetch().then(f => f.pluck('id')) - isThread = post.get('type') === Post.Type.THREAD - - const newFollowers = difference(uniq(mentioned.concat(commenterId)), existingFollowers) - - return bookshelf.transaction(trx => - new Comment(attrs).save(null, {transacting: trx}) - .tap(createMedia(opts, trx))) - .tap(createOrUpdateConnections(commenterId, existingFollowers)) - .tap(comment => post.addFollowers(newFollowers)) - .tap(comment => Promise.all([ - notifySockets(comment, post, isThread), - - (isThread - ? Queue.classMethod('Comment', 'notifyAboutMessage', {commentId: comment.id}) - : comment.createActivities()), - - Queue.classMethod('Post', 'updateFromNewComment', { - postId: post.id, - commentId: comment.id - }) - ]) - .then(() => comment)) -} - -export const createMedia = (opts, trx) => comment => { - return Promise.all(flatten([ - opts.attachments && Promise.map(opts.attachments, (attachment, i) => - Media.createForSubject({ - subjectType: 'comment', - subjectId: comment.id, - type: attachment.attachmentType, - url: attachment.url, - position: i - }, trx)), - ])) + created_from: opts.created_from || null, + }; + + let existingFollowers, isThread; + const mentioned = RichText.getUserMentions(text); + + existingFollowers = await post + .followers() + .fetch() + .then((f) => f.pluck("id")); + isThread = post.get("type") === Post.Type.THREAD; + + const newFollowers = difference( + uniq(mentioned.concat(commenterId)), + existingFollowers + ); + + return bookshelf + .transaction((trx) => + new Comment(attrs) + .save(null, { transacting: trx }) + .tap(createMedia(opts, trx)) + ) + .tap(createOrUpdateConnections(commenterId, existingFollowers)) + .tap((comment) => post.addFollowers(newFollowers)) + .tap((comment) => + Promise.all([ + notifySockets(comment, post, isThread), + + isThread + ? Queue.classMethod("Comment", "notifyAboutMessage", { + commentId: comment.id, + }) + : comment.createActivities(), + Queue.classMethod("Post", "updateFromNewComment", { + postId: post.id, + commentId: comment.id, + }), + ]).then(() => comment) + ); } -function notifySockets (comment, post, isThread) { - if (isThread) return pushMessageToSockets(comment, post) - return pushCommentToSockets(comment) +export const createMedia = (opts, trx) => (comment) => { + return Promise.all( + flatten([ + opts.attachments && + Promise.map(opts.attachments, (attachment, i) => + Media.createForSubject( + { + subjectType: "comment", + subjectId: comment.id, + type: attachment.attachmentType, + url: attachment.url, + position: i, + }, + trx + ) + ), + ]) + ); +}; + +function notifySockets(comment, post, isThread) { + if (isThread) return pushMessageToSockets(comment, post); + return pushCommentToSockets(comment); } -export async function pushMessageToSockets (message, thread) { - const followers = await thread.followers().fetch().then(x => x.models) - const userIds = followers.map(x => x.id) - const excludingSender = userIds.filter(id => id !== message.get('user_id')) - - let response = refineOne(message, - ['id', 'text', 'created_at', 'user_id', 'post_id'], +export async function pushMessageToSockets(message, thread) { + const followers = await thread + .followers() + .fetch() + .then((x) => x.models); + const userIds = followers.map((x) => x.id); + const excludingSender = userIds.filter((id) => id !== message.get("user_id")); + + let response = refineOne( + message, + ["id", "text", "created_at", "user_id", "post_id"], { - user_id: 'creator', - post_id: 'messageThread' + user_id: "creator", + post_id: "messageThread", } - ) + ); - response.createdAt = response.createdAt && response.createdAt.toString() + response.createdAt = response.createdAt && response.createdAt.toString(); - let socketMessageName + let socketMessageName; - if (thread.get('num_comments') === 0) { + if (thread.get("num_comments") === 0) { response = Object.assign( { - participants: refineMany(followers, ['id', 'name', 'avatar_url']), - messages: [response] + participants: refineMany(followers, ["id", "name", "avatar_url"]), + messages: [response], }, - refineOne(thread, ['id', 'created_at', 'updated_at']) - ) - socketMessageName = 'newThread' + refineOne(thread, ["id", "created_at", "updated_at"]) + ); + socketMessageName = "newThread"; } else { - socketMessageName = 'messageAdded' + socketMessageName = "messageAdded"; } - return Promise.map(excludingSender, userId => - pushToSockets(userRoom(userId), socketMessageName, response)) + return Promise.map(excludingSender, (userId) => + pushToSockets(userRoom(userId), socketMessageName, response) + ); } -function pushCommentToSockets (comment) { - return comment.ensureLoad('user') - .then(() => pushToSockets( - postRoom(comment.get('post_id')), - 'commentAdded', - Object.assign({}, - refineOne(comment, ['id', 'text', 'created_at']), - { - creator: refineOne(comment.relations.user, ['id', 'name', 'avatar_url']), - post: comment.get('post_id') - } +function pushCommentToSockets(comment) { + return comment.ensureLoad("user").then(() => + pushToSockets( + postRoom(comment.get("post_id")), + "commentAdded", + Object.assign({}, refineOne(comment, ["id", "text", "created_at"]), { + creator: refineOne(comment.relations.user, [ + "id", + "name", + "avatar_url", + ]), + post: comment.get("post_id"), + }) ) - )) + ); } -const createOrUpdateConnections = (userId, existingFollowers) => comment => { +const createOrUpdateConnections = (userId, existingFollowers) => (comment) => { return existingFollowers - .filter(f => f !== userId) - .forEach(follower => UserConnection.createOrUpdate(userId, follower, 'message')) -} + .filter((f) => f !== userId) + .forEach((follower) => + UserConnection.createOrUpdate(userId, follower, "message") + ); +}; diff --git a/api/models/comment/deleteComment.js b/api/models/comment/deleteComment.js index 786c62f84..deb78371d 100644 --- a/api/models/comment/deleteComment.js +++ b/api/models/comment/deleteComment.js @@ -1,19 +1,32 @@ -export default function deleteComment (comment, userId) { - return bookshelf.transaction(trx => Promise.join( - Activity.removeForComment(comment.id, trx), +export default function deleteComment(comment, userId) { + return bookshelf.transaction((trx) => + Promise.join( + Activity.removeForComment(comment.id, trx), - Post.query().where('id', comment.get('post_id')) - .decrement('num_comments', 1).transacting(trx), + Post.query() + .where("id", comment.get("post_id")) + .decrement("num_comments", 1) + .transacting(trx), - Post.find(comment.get('post_id')) - .then(post => Tag.updateForPost(post, null, null, null, trx)), + Post.find(comment.get("post_id")).then((post) => + Tag.updateForPost(post, null, null, null, trx) + ), - comment.save({ - deactivated_by_id: userId, - deactivated_at: new Date(), - active: false, - recent: false - }, {patch: true}) - .tap(c => - Queue.classMethod('Post', 'updateFromNewComment', {postId: c.get('post_id')})))) + comment + .save( + { + deactivated_by_id: userId, + deactivated_at: new Date(), + active: false, + recent: false, + }, + { patch: true } + ) + .tap((c) => + Queue.classMethod("Post", "updateFromNewComment", { + postId: c.get("post_id"), + }) + ) + ) + ); } diff --git a/api/models/comment/notifications.js b/api/models/comment/notifications.js index b6f116a2c..8fc6463fd 100644 --- a/api/models/comment/notifications.js +++ b/api/models/comment/notifications.js @@ -1,140 +1,179 @@ /* eslint-disable camelcase */ /* globals RedisClient */ -import decode from 'ent/decode' -import truncate from 'trunc-html' -import { parse } from 'url' -import { compact, some, sum, uniq } from 'lodash/fp' - -export async function notifyAboutMessage ({ commentId }) { - const comment = await Comment.find(commentId, {withRelated: ['media']}) - const post = await Post.find(comment.get('post_id')) - const followers = await post.followersWithPivots().fetch() - - const { user_id, post_id, text } = comment.attributes - const recipients = followers.filter(u => u.id !== user_id) - const user = followers.find(u => u.id === user_id) - const alert = comment.relations.media.length !== 0 - ? `${user.get('name')} sent an image` - : `${user.get('name')}: ${decode(truncate(text, 140).text).trim()}` - const path = parse(Frontend.Route.thread({id: post_id})).path - - return Promise.map(recipients, async user => { +import decode from "ent/decode"; +import truncate from "trunc-html"; +import { parse } from "url"; +import { compact, some, sum, uniq } from "lodash/fp"; + +export async function notifyAboutMessage({ commentId }) { + const comment = await Comment.find(commentId, { withRelated: ["media"] }); + const post = await Post.find(comment.get("post_id")); + const followers = await post.followersWithPivots().fetch(); + + const { user_id, post_id, text } = comment.attributes; + const recipients = followers.filter((u) => u.id !== user_id); + const user = followers.find((u) => u.id === user_id); + const alert = + comment.relations.media.length !== 0 + ? `${user.get("name")} sent an image` + : `${user.get("name")}: ${decode(truncate(text, 140).text).trim()}`; + const path = parse(Frontend.Route.thread({ id: post_id })).path; + + return Promise.map(recipients, async (user) => { // don't notify if the user has read the thread recently and respect the // dm_notifications setting. - if (!user.enabledNotification(Notification.TYPE.Message, Notification.MEDIUM.Push)) return - - const lastReadAt = user.pivot.getSetting('lastReadAt') - if (!lastReadAt || comment.get('created_at') > new Date(lastReadAt)) { - return user.sendPushNotification(alert, path) + if ( + !user.enabledNotification( + Notification.TYPE.Message, + Notification.MEDIUM.Push + ) + ) + return; + + const lastReadAt = user.pivot.getSetting("lastReadAt"); + if (!lastReadAt || comment.get("created_at") > new Date(lastReadAt)) { + return user.sendPushNotification(alert, path); } - }) + }); } export const sendDigests = () => { - const redis = RedisClient.create() - const now = new Date() - const fallbackTime = () => new Date(now - 10 * 60000) - - return redis.getAsync(sendDigests.REDIS_TIMESTAMP_KEY) - .then(i => i ? new Date(Number(i)) : fallbackTime()) - .catch(() => fallbackTime()) - .then(time => - Post.where('updated_at', '>', time) - .fetchAll({withRelated: [ - {comments: q => { - q.where('created_at', '>', time) - q.orderBy('created_at', 'asc') - }}, - 'user', - 'comments.user', - 'comments.media' - ]})) - .then(posts => Promise.all(posts.map(async post => { - const { comments } = post.relations - if (comments.length === 0) return [] - - const followers = await post.followersWithPivots().fetch() - - return Promise.map(followers.models, user => { - // select comments not written by this user and newer than user's last - // read time. - let lastReadAt = user.pivot.getSetting('lastReadAt') - if (lastReadAt) lastReadAt = new Date(lastReadAt) - - const filtered = comments.filter(c => - c.get('created_at') > (lastReadAt || 0) && - c.get('user_id') !== user.id) - - if (filtered.length === 0) return - - const presentComment = comment => { - const presented = { - name: comment.relations.user.get('name'), - avatar_url: comment.relations.user.get('avatar_url') - } - return comment.relations.media.length !== 0 - ? Object.assign({}, presented, {image: comment.relations.media.first().pick('url', 'thumbnail_url')}) - : Object.assign({}, presented, {text: comment.get('text')}) - } - - if (post.get('type') === Post.Type.THREAD) { - if (!user.enabledNotification(Notification.TYPE.Message, Notification.MEDIUM.Email)) return - - const others = filtered.map(comment => comment.relations.user) - - const otherNames = uniq(others.map(other => other.get('name'))) - - const otherAvatarUrls = others.map(other => other.get('avatar_url')) - - var participantNames = otherNames.slice(0, otherNames.length - 1).join(', ') + - ' & ' + otherNames[otherNames.length - 1] - - return Email.sendMessageDigest({ - email: user.get('email'), - data: { - count: filtered.length, - participant_avatars: otherAvatarUrls[0], - participant_names: participantNames, - other_names: otherNames, - thread_url: Frontend.Route.thread(post), - messages: filtered.map(presentComment) - }, - sender: { - reply_to: Email.postReplyAddress(post.id, user.id) - } - }) - } else { - if (!user.enabledNotification(Notification.TYPE.Comment, Notification.MEDIUM.Email)) return - - const commentData = comments.map(presentComment) - const hasMention = ({ text }) => - RichText.getUserMentions(text).includes(user.id) - - return Email.sendCommentDigest({ - email: user.get('email'), - data: { - count: commentData.length, - post_title: truncate(post.get('name'), 140).text, - post_creator_avatar_url: post.relations.user.get('avatar_url'), - thread_url: Frontend.Route.post(post), - comments: commentData, - subject_prefix: some(hasMention, commentData) - ? 'You were mentioned in' - : 'New comments on' + const redis = RedisClient.create(); + const now = new Date(); + const fallbackTime = () => new Date(now - 10 * 60000); + + return redis + .getAsync(sendDigests.REDIS_TIMESTAMP_KEY) + .then((i) => (i ? new Date(Number(i)) : fallbackTime())) + .catch(() => fallbackTime()) + .then((time) => + Post.where("updated_at", ">", time).fetchAll({ + withRelated: [ + { + comments: (q) => { + q.where("created_at", ">", time); + q.orderBy("created_at", "asc"); + }, }, - sender: { - reply_to: Email.postReplyAddress(post.id, user.id) - } + "user", + "comments.user", + "comments.media", + ], + }) + ) + .then((posts) => + Promise.all( + posts.map(async (post) => { + const { comments } = post.relations; + if (comments.length === 0) return []; + + const followers = await post.followersWithPivots().fetch(); + + return Promise.map(followers.models, (user) => { + // select comments not written by this user and newer than user's last + // read time. + let lastReadAt = user.pivot.getSetting("lastReadAt"); + if (lastReadAt) lastReadAt = new Date(lastReadAt); + + const filtered = comments.filter( + (c) => + c.get("created_at") > (lastReadAt || 0) && + c.get("user_id") !== user.id + ); + + if (filtered.length === 0) return; + + const presentComment = (comment) => { + const presented = { + name: comment.relations.user.get("name"), + avatar_url: comment.relations.user.get("avatar_url"), + }; + return comment.relations.media.length !== 0 + ? Object.assign({}, presented, { + image: comment.relations.media + .first() + .pick("url", "thumbnail_url"), + }) + : Object.assign({}, presented, { text: comment.get("text") }); + }; + + if (post.get("type") === Post.Type.THREAD) { + if ( + !user.enabledNotification( + Notification.TYPE.Message, + Notification.MEDIUM.Email + ) + ) + return; + + const others = filtered.map((comment) => comment.relations.user); + + const otherNames = uniq(others.map((other) => other.get("name"))); + + const otherAvatarUrls = others.map((other) => + other.get("avatar_url") + ); + + const participantNames = + otherNames.slice(0, otherNames.length - 1).join(", ") + + " & " + + otherNames[otherNames.length - 1]; + + return Email.sendMessageDigest({ + email: user.get("email"), + data: { + count: filtered.length, + participant_avatars: otherAvatarUrls[0], + participant_names: participantNames, + other_names: otherNames, + thread_url: Frontend.Route.thread(post), + messages: filtered.map(presentComment), + }, + sender: { + reply_to: Email.postReplyAddress(post.id, user.id), + }, + }); + } else { + if ( + !user.enabledNotification( + Notification.TYPE.Comment, + Notification.MEDIUM.Email + ) + ) + return; + + const commentData = comments.map(presentComment); + const hasMention = ({ text }) => + RichText.getUserMentions(text).includes(user.id); + + return Email.sendCommentDigest({ + email: user.get("email"), + data: { + count: commentData.length, + post_title: truncate(post.get("name"), 140).text, + post_creator_avatar_url: post.relations.user.get( + "avatar_url" + ), + thread_url: Frontend.Route.post(post), + comments: commentData, + subject_prefix: some(hasMention, commentData) + ? "You were mentioned in" + : "New comments on", + }, + sender: { + reply_to: Email.postReplyAddress(post.id, user.id), + }, + }); + } + }).then((sends) => compact(sends).length); }) - } - }) - .then(sends => compact(sends).length) - }))) - .tap(() => redis.setAsync(sendDigests.REDIS_TIMESTAMP_KEY, now.getTime())) - .then(sum) -} + ) + ) + .tap(() => redis.setAsync(sendDigests.REDIS_TIMESTAMP_KEY, now.getTime())) + .then(sum); +}; // we keep track of the last time we sent comment digests in Redis, so that the // next time we send them, we can exclude any comments that were created before // the last send. -sendDigests.REDIS_TIMESTAMP_KEY = 'Comment.sendDigests.lastSentAt' +sendDigests.REDIS_TIMESTAMP_KEY = "Comment.sendDigests.lastSentAt"; diff --git a/api/models/comment/updateComment.js b/api/models/comment/updateComment.js index 59b563227..1acc835da 100644 --- a/api/models/comment/updateComment.js +++ b/api/models/comment/updateComment.js @@ -1,26 +1,35 @@ -import { difference, uniq } from 'lodash' -import { sanitize } from 'hylo-utils/text' -import { updateMedia } from './util' +import { difference, uniq } from "lodash"; +import { sanitize } from "hylo-utils/text"; +import { updateMedia } from "./util"; -export default async function updateComment (commenterId, id, params) { - if (!id) throw new Error('updateComment called with no ID') +export default async function updateComment(commenterId, id, params) { + if (!id) throw new Error("updateComment called with no ID"); - const comment = await Comment.find(id, {withRelated: 'post'}) + const comment = await Comment.find(id, { withRelated: "post" }); - if (!comment) throw new Error('cannot find comment with ID', id) + if (!comment) throw new Error("cannot find comment with ID", id); - let { text, attachments } = params + let { text, attachments } = params; - text = sanitize(text) + text = sanitize(text); - const attrs = { text } - const post = comment.relations.post - const mentioned = RichText.getUserMentions(text) - const existingFollowers = await post.followers().fetch().then(f => f.pluck('id')) - const newFollowers = difference(uniq(mentioned.concat(commenterId)), existingFollowers) + const attrs = { text }; + const post = comment.relations.post; + const mentioned = RichText.getUserMentions(text); + const existingFollowers = await post + .followers() + .fetch() + .then((f) => f.pluck("id")); + const newFollowers = difference( + uniq(mentioned.concat(commenterId)), + existingFollowers + ); - return bookshelf.transaction(trx => - comment.save(attrs, {transacting: trx}) - .tap(updateMedia(comment, attachments, trx))) - .tap(comment => post.addFollowers(newFollowers)) + return bookshelf + .transaction((trx) => + comment + .save(attrs, { transacting: trx }) + .tap(updateMedia(comment, attachments, trx)) + ) + .tap((comment) => post.addFollowers(newFollowers)); } diff --git a/api/models/comment/util.js b/api/models/comment/util.js index ed8bce0d6..f304074bb 100644 --- a/api/models/comment/util.js +++ b/api/models/comment/util.js @@ -1,30 +1,33 @@ +const delimiter = /-{3,}.Only.text.above.the.dashed.line.will.be.included.-{3,}(\.| )?/; -const delimiter = /-{3,}.Only.text.above.the.dashed.line.will.be.included.-{3,}(\.| )?/ - -export const repairedText = comment => { - var text = comment.get('text') - text = text.replace(delimiter, '') - text = text.replace(/

\s*<\/p>\n/g, '') - text = text.replace(/

\s?/g, '

') - return text -} - -export const repairText = comment => - comment.save({text: repairedText(comment)}, {patch: true}) +export const repairedText = (comment) => { + let text = comment.get("text"); + text = text.replace(delimiter, ""); + text = text.replace(/

\s*<\/p>\n/g, ""); + text = text.replace(/

\s?/g, "

"); + return text; +}; +export const repairText = (comment) => + comment.save({ text: repairedText(comment) }, { patch: true }); -export function updateMedia (comment, attachments, transacting) { - if (!attachments) return +export function updateMedia(comment, attachments, transacting) { + if (!attachments) return; - var media = comment.relations.media + const media = comment.relations.media; - return Promise.map(media, m => m.destroy({transacting})) - .then(() => Promise.map(attachments, (attachment, i) => - Media.createForSubject({ - subjectType: 'comment', - subjectId: comment.id, - type: attachment.type, - url: attachment.url, - position: i - }, transacting))) + return Promise.map(media, (m) => m.destroy({ transacting })).then(() => + Promise.map(attachments, (attachment, i) => + Media.createForSubject( + { + subjectType: "comment", + subjectId: comment.id, + type: attachment.type, + url: attachment.url, + position: i, + }, + transacting + ) + ) + ); } diff --git a/api/models/community/deleteCommunityTopic.js b/api/models/community/deleteCommunityTopic.js index fe6e5f818..be9e6ed0a 100644 --- a/api/models/community/deleteCommunityTopic.js +++ b/api/models/community/deleteCommunityTopic.js @@ -1,3 +1,3 @@ -export default function deleteCommunityTopic (communityTopic) { - return communityTopic.destroy() -} \ No newline at end of file +export default function deleteCommunityTopic(communityTopic) { + return communityTopic.destroy(); +} diff --git a/api/models/event/mixin.js b/api/models/event/mixin.js index 31b7303a3..8591fdf9f 100644 --- a/api/models/event/mixin.js +++ b/api/models/event/mixin.js @@ -1,80 +1,92 @@ -import { uniq, difference } from 'lodash/fp' -import moment from 'moment' +import { uniq, difference } from "lodash/fp"; +import moment from "moment"; export default { - isEvent () { - return this.get('type') === Post.Type.EVENT + isEvent() { + return this.get("type") === Post.Type.EVENT; }, eventInvitees: function () { - return this.belongsToMany(User).through(EventInvitation, 'event_id', 'user_id') - .withPivot('response') + return this.belongsToMany(User) + .through(EventInvitation, "event_id", "user_id") + .withPivot("response"); }, eventInvitations: function () { - return this.hasMany(EventInvitation, 'event_id') + return this.hasMany(EventInvitation, "event_id"); }, userEventInvitation: function (userId) { - return this.eventInvitations().query({where: {user_id: userId}}).fetchOne() + return this.eventInvitations() + .query({ where: { user_id: userId } }) + .fetchOne(); }, removeEventInvitees: async function (userIds, opts) { - return Promise.map(userIds, async userId => { - const invitation = await EventInvitation.find({userId, eventId: this.id}) - return invitation.destroy(opts) - }) + return Promise.map(userIds, async (userId) => { + const invitation = await EventInvitation.find({ + userId, + eventId: this.id, + }); + return invitation.destroy(opts); + }); }, addEventInvitees: async function (userIds, inviterId, opts) { - return Promise.map(uniq(userIds), async userId => { - const invitation = await EventInvitation.find({userId, eventId: this.id}) - if (invitation) return - return EventInvitation.create({ + return Promise.map(uniq(userIds), async (userId) => { + const invitation = await EventInvitation.find({ userId, - inviterId, - eventId: this.id - }, opts) - }) + eventId: this.id, + }); + if (invitation) return; + return EventInvitation.create( + { + userId, + inviterId, + eventId: this.id, + }, + opts + ); + }); }, updateEventInvitees: async function (userIds, inviterId, opts) { - const eventInviteeIds = (await this.eventInvitees().fetch()).pluck('id') - const toRemove = difference(eventInviteeIds, userIds) - const toAdd = difference(userIds, eventInviteeIds) + const eventInviteeIds = (await this.eventInvitees().fetch()).pluck("id"); + const toRemove = difference(eventInviteeIds, userIds); + const toAdd = difference(userIds, eventInviteeIds); - await this.removeEventInvitees(toRemove, opts) - return this.addEventInvitees(toAdd, inviterId, opts) + await this.removeEventInvitees(toRemove, opts); + return this.addEventInvitees(toAdd, inviterId, opts); }, prettyEventDates: async function () { - const start = moment(startTime) - const end = moment(endTime) - - const from = start.format('ddd, MMM D [at] h:mmA') - - var to = '' - + const start = moment(startTime); + const end = moment(endTime); + + const from = start.format("ddd, MMM D [at] h:mmA"); + + let to = ""; + if (endTime) { if (end.month() !== start.month()) { - to = end.format(' - ddd, MMM D [at] h:mmA') + to = end.format(" - ddd, MMM D [at] h:mmA"); } else if (end.day() !== start.day()) { - to = end.format(' - ddd D [at] h:mmA') + to = end.format(" - ddd D [at] h:mmA"); } else { - to = end.format(' - h:mmA') + to = end.format(" - h:mmA"); } } - - return from + to + + return from + to; }, - - createInviteNotifications: async function(userId, inviteeIds) { - const invitees = inviteeIds.map(inviteeId => ({ + + createInviteNotifications: async function (userId, inviteeIds) { + const invitees = inviteeIds.map((inviteeId) => ({ reader_id: inviteeId, post_id: this.id, actor_id: userId, - reason: `eventInvitation` - })) - return Activity.saveForReasons(invitees) - } -} + reason: "eventInvitation", + })); + return Activity.saveForReasons(invitees); + }, +}; diff --git a/api/models/flaggedItem/notifyUtils.js b/api/models/flaggedItem/notifyUtils.js index 79ff4755c..cfb135d2c 100644 --- a/api/models/flaggedItem/notifyUtils.js +++ b/api/models/flaggedItem/notifyUtils.js @@ -1,47 +1,51 @@ -import { sendMessageFromAxolotl } from '../../services/MessagingService' +import { sendMessageFromAxolotl } from "../../services/MessagingService"; -export async function notifyModeratorsPost (flaggedItem) { - const post = await flaggedItem.getObject() - const user = flaggedItem.relations.user - const communities = await user.communitiesSharedWithPost(post) - const isPublic = post.attributes.is_public - return sendToCommunities(flaggedItem, communities, isPublic) +export async function notifyModeratorsPost(flaggedItem) { + const post = await flaggedItem.getObject(); + const user = flaggedItem.relations.user; + const communities = await user.communitiesSharedWithPost(post); + const isPublic = post.attributes.is_public; + return sendToCommunities(flaggedItem, communities, isPublic); } -export async function notifyModeratorsComment (flaggedItem) { - const comment = await flaggedItem.getObject() - const post = comment.relations.post - const user = flaggedItem.relations.user - const communities = await user.communitiesSharedWithPost(post) - const isPublic = post.attributes.is_public - return sendToCommunities(flaggedItem, communities, isPublic) +export async function notifyModeratorsComment(flaggedItem) { + const comment = await flaggedItem.getObject(); + const post = comment.relations.post; + const user = flaggedItem.relations.user; + const communities = await user.communitiesSharedWithPost(post); + const isPublic = post.attributes.is_public; + return sendToCommunities(flaggedItem, communities, isPublic); } -export async function notifyModeratorsMember (flaggedItem) { - const member = await flaggedItem.getObject() - const user = flaggedItem.relations.user - const communities = await user.communitiesSharedWithUser(member) - return sendToCommunities(flaggedItem, communities, false) +export async function notifyModeratorsMember(flaggedItem) { + const member = await flaggedItem.getObject(); + const user = flaggedItem.relations.user; + const communities = await user.communitiesSharedWithUser(member); + return sendToCommunities(flaggedItem, communities, false); } -export async function sendToCommunities (flaggedItem, communities, isPublic) { +export async function sendToCommunities(flaggedItem, communities, isPublic) { const send = async (community, userIds) => { - const text = await flaggedItem.getMessageText(community) - return sendMessageFromAxolotl(userIds, text) - } + const text = await flaggedItem.getMessageText(community); + return sendMessageFromAxolotl(userIds, text); + }; - for (let community of communities) { - const moderators = await community.moderators().fetch() - await send(community, moderators.map(x => x.id)) + for (const community of communities) { + const moderators = await community.moderators().fetch(); + await send( + community, + moderators.map((x) => x.id) + ); } // Send to Hylo Admins if category is Illegal OR Post is Public - const shouldSendToAdmins = (process.env.HYLO_ADMINS && - flaggedItem.get('category') === FlaggedItem.Category.ILLEGAL) || (process.env.HYLO_ADMINS && - !!isPublic) + const shouldSendToAdmins = + (process.env.HYLO_ADMINS && + flaggedItem.get("category") === FlaggedItem.Category.ILLEGAL) || + (process.env.HYLO_ADMINS && !!isPublic); if (shouldSendToAdmins) { - const adminIds = process.env.HYLO_ADMINS.split(',').map(id => Number(id)) - await send(communities[0], adminIds) + const adminIds = process.env.HYLO_ADMINS.split(",").map((id) => Number(id)); + await send(communities[0], adminIds); } } diff --git a/api/models/group/DataType.js b/api/models/group/DataType.js index b61380523..5f739652d 100644 --- a/api/models/group/DataType.js +++ b/api/models/group/DataType.js @@ -1,9 +1,9 @@ -const POST = 0 -const COMMUNITY = 1 -const NETWORK = 2 -const TOPIC = 3 -const COMMENT = 4 -const COMMUNITY_AND_TOPIC = 5 +const POST = 0; +const COMMUNITY = 1; +const NETWORK = 2; +const TOPIC = 3; +const COMMENT = 4; +const COMMUNITY_AND_TOPIC = 5; const GroupDataType = { POST, @@ -11,29 +11,34 @@ const GroupDataType = { NETWORK, TOPIC, COMMENT, - COMMUNITY_AND_TOPIC -} + COMMUNITY_AND_TOPIC, +}; -export default GroupDataType +export default GroupDataType; -export function getModelForDataType (dataType) { +export function getModelForDataType(dataType) { switch (dataType) { - case POST: return Post - case COMMUNITY: return Community - case NETWORK: return Network - case TOPIC: return Tag - case COMMENT: return Comment + case POST: + return Post; + case COMMUNITY: + return Community; + case NETWORK: + return Network; + case TOPIC: + return Tag; + case COMMENT: + return Comment; } } -export function getDataTypeForInstance (instance) { - if (instance instanceof Post) return POST - if (instance instanceof Community) return COMMUNITY - if (instance instanceof Network) return NETWORK - if (instance instanceof Tag) return TOPIC - if (instance instanceof Comment) return COMMENT +export function getDataTypeForInstance(instance) { + if (instance instanceof Post) return POST; + if (instance instanceof Community) return COMMUNITY; + if (instance instanceof Network) return NETWORK; + if (instance instanceof Tag) return TOPIC; + if (instance instanceof Comment) return COMMENT; } -export function getDataTypeForModel (model) { - return getDataTypeForInstance(model.forge()) +export function getDataTypeForModel(model) { + return getDataTypeForInstance(model.forge()); } diff --git a/api/models/group/migration.js b/api/models/group/migration.js index 72245ec89..f47252894 100644 --- a/api/models/group/migration.js +++ b/api/models/group/migration.js @@ -1,139 +1,181 @@ /* eslint-disable camelcase */ -import { compact, isNil } from 'lodash' -import { getDataTypeForModel } from './DataType' +import { compact, isNil } from "lodash"; +import { getDataTypeForModel } from "./DataType"; -export async function makeGroups (model) { - const group_data_type = getDataTypeForModel(model) +export async function makeGroups(model) { + const group_data_type = getDataTypeForModel(model); // TODO: Need to ammend for Topic which doesn't have an active field - const rows = await model.query().select(['id', 'created_at', 'active']) - const rowsToInsert = rows.map(row => ({ + const rows = await model.query().select(["id", "created_at", "active"]); + const rowsToInsert = rows.map((row) => ({ group_data_type, group_data_id: row.id, active: row.active, - created_at: row.created_at - })) - await bookshelf.knex.batchInsert('groups', rowsToInsert) - return rowsToInsert.length + created_at: row.created_at, + })); + await bookshelf.knex.batchInsert("groups", rowsToInsert); + return rowsToInsert.length; } -export async function makeGroupMemberships ({ model, parent, copyColumns, selectColumns, settings, getSettings }) { - const { target, foreignKey } = getRelatedData(model, parent) - - let columns = ['user_id', foreignKey] +export async function makeGroupMemberships({ + model, + parent, + copyColumns, + selectColumns, + settings, + getSettings, +}) { + const { target, foreignKey } = getRelatedData(model, parent); + + let columns = ["user_id", foreignKey]; if (Array.isArray(copyColumns)) { - columns = columns.concat(copyColumns) + columns = columns.concat(copyColumns); } else if (copyColumns) { - columns = columns.concat(Object.keys(copyColumns)) + columns = columns.concat(Object.keys(copyColumns)); } if (selectColumns) { - columns = columns.concat(selectColumns) + columns = columns.concat(selectColumns); } - const rows = await model.query().select(columns) + const rows = await model.query().select(columns); - async function processRow (row) { - const { user_id, [foreignKey]: parentId } = row - const group_id = await getGroupId(target, parentId) + async function processRow(row) { + const { user_id, [foreignKey]: parentId } = row; + const group_id = await getGroupId(target, parentId); - const newRow = {user_id, group_id} + const newRow = { user_id, group_id }; if (getSettings) { - newRow.settings = getSettings(row) + newRow.settings = getSettings(row); } else { - newRow.settings = settings + newRow.settings = settings; } if (Array.isArray(copyColumns)) { - for (let k of copyColumns) newRow[k] = row[k] + for (const k of copyColumns) newRow[k] = row[k]; } else if (copyColumns) { - for (let k in copyColumns) newRow[copyColumns[k]] = row[k] + for (const k in copyColumns) newRow[copyColumns[k]] = row[k]; } - return newRow + return newRow; } - const rowsToInsert = await Promise.all(rows.map(processRow)) - await bookshelf.knex.batchInsert('group_memberships', rowsToInsert) - return rowsToInsert.length + const rowsToInsert = await Promise.all(rows.map(processRow)); + await bookshelf.knex.batchInsert("group_memberships", rowsToInsert); + return rowsToInsert.length; } -export async function deactivateMembershipsByGroupDataType (group_data_type) { - const parents = await Group.where({group_data_type, active: false}) - .fetchAll({withRelated: 'memberships'}) - const setInactive = group => Promise.map(group.relations.memberships.models, - membership => membership.save({active: false})) - await Promise.map(parents.models, setInactive) - return parents.length +export async function deactivateMembershipsByGroupDataType(group_data_type) { + const parents = await Group.where({ + group_data_type, + active: false, + }).fetchAll({ withRelated: "memberships" }); + const setInactive = (group) => + Promise.map(group.relations.memberships.models, (membership) => + membership.save({ active: false }) + ); + await Promise.map(parents.models, setInactive); + return parents.length; } -export async function reconcileNumMembersInCommunities () { - const communities = await Community.fetchAll() - await Promise.map(communities.models, c => c.reconcileNumMembers()) - return communities.length +export async function reconcileNumMembersInCommunities() { + const communities = await Community.fetchAll(); + await Promise.map(communities.models, (c) => c.reconcileNumMembers()); + return communities.length; } -export async function updateGroupMemberships ({ model, parent, getSettings, selectColumns }) { - const { target, foreignKey } = getRelatedData(model, parent) - const rows = await model.query().select(['user_id', foreignKey, ...selectColumns]) - - async function processRow (row) { - const { user_id, [foreignKey]: parentId } = row - const group_id = await getGroupId(target, parentId) - const gm = await GroupMembership.where({user_id, group_id}).fetch() - return gm.addSetting(getSettings(row), true) +export async function updateGroupMemberships({ + model, + parent, + getSettings, + selectColumns, +}) { + const { target, foreignKey } = getRelatedData(model, parent); + const rows = await model + .query() + .select(["user_id", foreignKey, ...selectColumns]); + + async function processRow(row) { + const { user_id, [foreignKey]: parentId } = row; + const group_id = await getGroupId(target, parentId); + const gm = await GroupMembership.where({ user_id, group_id }).fetch(); + return gm.addSetting(getSettings(row), true); } - for (let row of rows) await processRow(row) - return rows.length + for (const row of rows) await processRow(row); + return rows.length; } -export async function makeGroupConnectionsM2M ({ model, filter, child, parent }) { - const { - foreignKey: childFk, target: childModel - } = getRelatedData(model, child) - const { - foreignKey: parentFk, target: parentModel - } = getRelatedData(model, parent) +export async function makeGroupConnectionsM2M({ + model, + filter, + child, + parent, +}) { + const { foreignKey: childFk, target: childModel } = getRelatedData( + model, + child + ); + const { foreignKey: parentFk, target: parentModel } = getRelatedData( + model, + parent + ); return makeGroupConnections({ - model, filter, childFk, childModel, parentFk, parentModel - }) + model, + filter, + childFk, + childModel, + parentFk, + parentModel, + }); } -export async function makeGroupConnectionsFk ({ model, parent, filter }) { - const { foreignKey, target } = getRelatedData(model, parent) +export async function makeGroupConnectionsFk({ model, parent, filter }) { + const { foreignKey, target } = getRelatedData(model, parent); return makeGroupConnections({ model, filter, - childFk: 'id', + childFk: "id", childModel: model, parentFk: foreignKey, - parentModel: target - }) + parentModel: target, + }); } -async function makeGroupConnections ({ model, filter, childModel, childFk, parentModel, parentFk }) { - const query = filter ? model.query(filter).query() : model.query() - const rows = await query.select(childFk, parentFk) - const rowsToInsert = compact(await Promise.all(rows.map(async row => { - const parent_group_id = await getGroupId(parentModel, row[parentFk]) - if (!parent_group_id) return null - return checkRow({ - parent_group_id, - child_group_id: await getGroupId(childModel, row[childFk]), - parent_group_data_type: getDataTypeForModel(parentModel), - child_group_data_type: getDataTypeForModel(childModel) - }) - }))) - - await bookshelf.knex.batchInsert('group_connections', rowsToInsert) - return rowsToInsert.length +async function makeGroupConnections({ + model, + filter, + childModel, + childFk, + parentModel, + parentFk, +}) { + const query = filter ? model.query(filter).query() : model.query(); + const rows = await query.select(childFk, parentFk); + const rowsToInsert = compact( + await Promise.all( + rows.map(async (row) => { + const parent_group_id = await getGroupId(parentModel, row[parentFk]); + if (!parent_group_id) return null; + return checkRow({ + parent_group_id, + child_group_id: await getGroupId(childModel, row[childFk]), + parent_group_data_type: getDataTypeForModel(parentModel), + child_group_data_type: getDataTypeForModel(childModel), + }); + }) + ) + ); + + await bookshelf.knex.batchInsert("group_connections", rowsToInsert); + return rowsToInsert.length; } // This is not meant to be run. It's just here as an example of how to convert // different relations. The actual conversion process should take place in // knex migration files (see e.g. the post-group-memberships migration). -async function seed () { // eslint-disable-line no-unused-vars +async function seed() { + // eslint-disable-line no-unused-vars /* TODO @@ -143,50 +185,65 @@ async function seed () { // eslint-disable-line no-unused-vars tag_follows */ - console.log('Network:', await makeGroups(Network)) - console.log('Topic:', await makeGroups(Tag)) - console.log('Comment:', await makeGroups(Comment)) - - console.log('PostMembership:', await makeGroupConnectionsM2M({ - model: PostMembership, - child: 'post', - parent: 'community' - })) - - console.log('Community.network_id:', await makeGroupConnectionsFk({ - model: Community, - parent: 'network' - })) - - console.log('PostTag', await makeGroupConnectionsM2M({ - model: PostTag, - parent: 'post', - child: 'tag' - })) - - console.log('CommentTag', await makeGroupConnectionsM2M({ - model: CommentTag, - parent: 'comment', - child: 'tag' - })) + console.log("Network:", await makeGroups(Network)); + console.log("Topic:", await makeGroups(Tag)); + console.log("Comment:", await makeGroups(Comment)); + + console.log( + "PostMembership:", + await makeGroupConnectionsM2M({ + model: PostMembership, + child: "post", + parent: "community", + }) + ); + + console.log( + "Community.network_id:", + await makeGroupConnectionsFk({ + model: Community, + parent: "network", + }) + ); + + console.log( + "PostTag", + await makeGroupConnectionsM2M({ + model: PostTag, + parent: "post", + child: "tag", + }) + ); + + console.log( + "CommentTag", + await makeGroupConnectionsM2M({ + model: CommentTag, + parent: "comment", + child: "tag", + }) + ); } -async function getGroupId (model, dataId) { - return Group.query().where({ - group_data_type: getDataTypeForModel(model), - group_data_id: dataId - }).pluck('id').then(r => r[0]) +async function getGroupId(model, dataId) { + return Group.query() + .where({ + group_data_type: getDataTypeForModel(model), + group_data_id: dataId, + }) + .pluck("id") + .then((r) => r[0]); } -function getRelatedData (model, relationName) { - return model.forge()[relationName]().relatedData +function getRelatedData(model, relationName) { + return model.forge()[relationName]().relatedData; } -function checkRow (row) { - for (let k in row) { +function checkRow(row) { + for (const k in row) { if (isNil(row[k])) { - throw new Error('empty value in row!') + throw new Error("empty value in row!"); } } - return row + return row; } diff --git a/api/models/group/queryUtils.js b/api/models/group/queryUtils.js index 73478a885..d55654f7f 100644 --- a/api/models/group/queryUtils.js +++ b/api/models/group/queryUtils.js @@ -1,38 +1,38 @@ -import { getDataTypeForModel } from './DataType' -import { castArray, has } from 'lodash' +import { getDataTypeForModel } from "./DataType"; +import { castArray, has } from "lodash"; -export function isFollowing (q) { - q.whereRaw("(group_memberships.settings->>'following')::boolean = true") +export function isFollowing(q) { + q.whereRaw("(group_memberships.settings->>'following')::boolean = true"); } -export function isProjectMember (q) { - q.whereRaw("(group_memberships.project_role_id IS NOT NULL)") +export function isProjectMember(q) { + q.whereRaw("(group_memberships.project_role_id IS NOT NULL)"); } -export function whereUserId (q, usersOrIds) { - return whereId(q, usersOrIds, 'group_memberships.user_id') +export function whereUserId(q, usersOrIds) { + return whereId(q, usersOrIds, "group_memberships.user_id"); } -export function whereGroupDataId (q, instanceIds) { - return whereId(q, instanceIds, 'group_data_id') +export function whereGroupDataId(q, instanceIds) { + return whereId(q, instanceIds, "group_data_id"); } // handle a single value or a list of instances or ids; do nothing if no ids or // instances are passed -function whereId (q, instancesOrIds, columnName) { - if (!instancesOrIds) return - const ids = castArray(instancesOrIds).map(x => has(x, 'id') ? x.id : x) +function whereId(q, instancesOrIds, columnName) { + if (!instancesOrIds) return; + const ids = castArray(instancesOrIds).map((x) => (has(x, "id") ? x.id : x)); if (ids.length > 1) { - q.whereIn(columnName, ids) + q.whereIn(columnName, ids); } else { - q.where(columnName, ids[0]) + q.where(columnName, ids[0]); } } -export function queryForMember (q, userOrId, model) { - whereUserId(q, userOrId) +export function queryForMember(q, userOrId, model) { + whereUserId(q, userOrId); q.where({ - 'group_memberships.group_data_type': getDataTypeForModel(model), - 'group_memberships.active': true - }) + "group_memberships.group_data_type": getDataTypeForModel(model), + "group_memberships.active": true, + }); } diff --git a/api/models/index.js b/api/models/index.js index 26381f701..fbc13dbf7 100644 --- a/api/models/index.js +++ b/api/models/index.js @@ -1,32 +1,32 @@ -import { readdirSync } from 'fs' -import { basename, extname } from 'path' -import Bookshelf from 'bookshelf' -import Knex from 'knex' -import knexfile from '../../knexfile' -import Promise from 'bluebird' +import { readdirSync } from "fs"; +import { basename, extname } from "path"; +import Bookshelf from "bookshelf"; +import Knex from "knex"; +import knexfile from "../../knexfile"; +import Promise from "bluebird"; export const init = () => { // this could be removed, if desired, if all uses of bluebird's API were // removed from the models - global.Promise = Promise + global.Promise = Promise; - global.bookshelf = Bookshelf(Knex(knexfile[process.env.NODE_ENV])) - global.bookshelf.plugin('bookshelf-returning') + global.bookshelf = Bookshelf(Knex(knexfile[process.env.NODE_ENV])); + global.bookshelf.plugin("bookshelf-returning"); return readdirSync(__dirname) - .map(filename => { - if (extname(filename) !== '.js') return - var name = basename(filename, '.js') - if (!name.match(/^[A-Z]/)) return - const model = require('./' + name) - global[name] = model - return [name, model] - }) - .filter(x => !!x) - .reduce((props, [ name, model ]) => { - props[name] = model - return props - }, {}) -} + .map((filename) => { + if (extname(filename) !== ".js") return; + const name = basename(filename, ".js"); + if (!name.match(/^[A-Z]/)) return; + const model = require("./" + name); + global[name] = model; + return [name, model]; + }) + .filter((x) => !!x) + .reduce((props, [name, model]) => { + props[name] = model; + return props; + }, {}); +}; -export default {init} +export default { init }; diff --git a/api/models/linkPreview/findOrCreateByUrl.js b/api/models/linkPreview/findOrCreateByUrl.js index 79f0e7e27..356c0c961 100644 --- a/api/models/linkPreview/findOrCreateByUrl.js +++ b/api/models/linkPreview/findOrCreateByUrl.js @@ -1,7 +1,7 @@ -export default function findOrCreateByUrl (url) { - return LinkPreview.find(url).then(preview => { - if (!preview) return LinkPreview.queue(url) - if (!preview.get('done')) return - return preview - }) +export default function findOrCreateByUrl(url) { + return LinkPreview.find(url).then((preview) => { + if (!preview) return LinkPreview.queue(url); + if (!preview.get("done")) return; + return preview; + }); } diff --git a/api/models/media/util.js b/api/models/media/util.js index b1061ed5f..051bcd91c 100644 --- a/api/models/media/util.js +++ b/api/models/media/util.js @@ -1,37 +1,33 @@ -import { merge } from 'lodash' -import { pick } from 'lodash/fp' +import { merge } from "lodash"; +import { pick } from "lodash/fp"; -export const VALID_MEDIA_TYPES = [ - 'file', - 'image', - 'gdoc', - 'video' -] - -export const DEFAULT_MEDIA_TYPE = 'file' +export const VALID_MEDIA_TYPES = ["file", "image", "gdoc", "video"]; +export const DEFAULT_MEDIA_TYPE = "file"; export const createAndAddSize = function (attrs) { - const url = attrs.type === 'image' - ? attrs.url - : attrs.type === 'video' + const url = + attrs.type === "image" + ? attrs.url + : attrs.type === "video" ? attrs.thumbnail_url - : null + : null; if (url) { - return GetImageSize(url).then(dimensions => - Media.create(merge({}, attrs, pick(['width', 'height'], dimensions)))) + return GetImageSize(url).then((dimensions) => + Media.create(merge({}, attrs, pick(["width", "height"], dimensions))) + ); } - return Media.create(attrs) -} + return Media.create(attrs); +}; -export function getMediaTypeFromMimetype (mimetype) { - let baseMimetype = mimetype && mimetype.split('/')[0] +export function getMediaTypeFromMimetype(mimetype) { + const baseMimetype = mimetype && mimetype.split("/")[0]; // NOTE: This doesn't account for the currently unsupportd // legacy special cases of 'gdoc' or 'video' return VALID_MEDIA_TYPES.includes(baseMimetype) ? baseMimetype - : DEFAULT_MEDIA_TYPE + : DEFAULT_MEDIA_TYPE; } diff --git a/api/models/mixins/EnsureLoad.js b/api/models/mixins/EnsureLoad.js index 003decbb4..788dfcaeb 100644 --- a/api/models/mixins/EnsureLoad.js +++ b/api/models/mixins/EnsureLoad.js @@ -28,20 +28,22 @@ TODO: support multi-level loading, e.g. this.ensureLoad('person.jobs') */ -import { castArray } from 'lodash' +import { castArray } from "lodash"; export default { - ensureLoad (relations) { - const relationsToLoad = castArray(relations).filter(relation => { - const { relatedData: { type, foreignKey } } = this[relation]() + ensureLoad(relations) { + const relationsToLoad = castArray(relations).filter((relation) => { + const { + relatedData: { type, foreignKey }, + } = this[relation](); // if there is no id value for a belongsTo, skip - if (type === 'belongsTo' && !this.get(foreignKey)) return false + if (type === "belongsTo" && !this.get(foreignKey)) return false; // otherwise, skip if data has already been loaded - return !this.relations[relation] - }) + return !this.relations[relation]; + }); - return this.load(relationsToLoad) - } -} + return this.load(relationsToLoad); + }, +}; diff --git a/api/models/mixins/HasGroup.js b/api/models/mixins/HasGroup.js index 37abd06f8..924312d94 100644 --- a/api/models/mixins/HasGroup.js +++ b/api/models/mixins/HasGroup.js @@ -1,73 +1,74 @@ -import { getDataTypeForModel, getDataTypeForInstance } from '../group/DataType' +import { getDataTypeForModel, getDataTypeForInstance } from "../group/DataType"; export default { - async createGroup ({ transacting } = {}) { + async createGroup({ transacting } = {}) { return Group.forge({ group_data_id: this.id, group_data_type: getDataTypeForInstance(this), - created_at: new Date() - }).save(null, {transacting}) + created_at: new Date(), + }).save(null, { transacting }); }, - async group (opts) { - return await Group.find(this, opts) || this.createGroup(opts) + async group(opts) { + return (await Group.find(this, opts)) || this.createGroup(opts); }, - async addGroupMembers (...args) { - const dbOpts = args[2] - return this.group(dbOpts).then(group => group.addMembers(...args)) + async addGroupMembers(...args) { + const dbOpts = args[2]; + return this.group(dbOpts).then((group) => group.addMembers(...args)); }, - async removeGroupMembers (...args) { - const dbOpts = args[1] - return this.group(dbOpts).then(group => group.removeMembers(...args)) + async removeGroupMembers(...args) { + const dbOpts = args[1]; + return this.group(dbOpts).then((group) => group.removeMembers(...args)); }, - async updateGroupMembers (...args) { - const dbOpts = args[1] - return this.group(dbOpts).then(group => group.updateMembers(...args)) + async updateGroupMembers(...args) { + const dbOpts = args[1]; + return this.group(dbOpts).then((group) => group.updateMembers(...args)); }, - queryByGroupConnection (model, direction = 'parent') { + queryByGroupConnection(model, direction = "parent") { // TODO we can infer the correct direction in most cases rather than // requiring it to be specified - const dataType = getDataTypeForModel(model) - const [ fromCol, toCol ] = direction === 'parent' - ? ['child_group_id', 'parent_group_id'] - : ['parent_group_id', 'child_group_id'] + const dataType = getDataTypeForModel(model); + const [fromCol, toCol] = + direction === "parent" + ? ["child_group_id", "parent_group_id"] + : ["parent_group_id", "child_group_id"]; const subq = Group.query() - .join('group_connections as gc', 'groups.id', `gc.${fromCol}`) - .join('groups as g2', 'g2.id', `gc.${toCol}`) - .where({ - 'groups.group_data_id': this.id, - 'groups.group_data_type': getDataTypeForInstance(this), - 'g2.group_data_type': dataType, - 'gc.active': true - }) - .select('g2.group_data_id') - - return model.where('id', 'in', subq) + .join("group_connections as gc", "groups.id", `gc.${fromCol}`) + .join("groups as g2", "g2.id", `gc.${toCol}`) + .where({ + "groups.group_data_id": this.id, + "groups.group_data_type": getDataTypeForInstance(this), + "g2.group_data_type": dataType, + "gc.active": true, + }) + .select("g2.group_data_id"); + + return model.where("id", "in", subq); }, - groupMembers (where) { + groupMembers(where) { let subq = GroupMembership.query() - .join('groups', 'groups.id', 'group_memberships.group_id') - .where({ - group_data_id: this.id, - 'groups.group_data_type': getDataTypeForInstance(this), - 'group_memberships.active': true - }) - .select('user_id') - if (where) subq = subq.where(where) - - return User.collection().query(q => { - q.where('id', 'in', subq) - q.where('users.active', true) - }) + .join("groups", "groups.id", "group_memberships.group_id") + .where({ + group_data_id: this.id, + "groups.group_data_type": getDataTypeForInstance(this), + "group_memberships.active": true, + }) + .select("user_id"); + if (where) subq = subq.where(where); + + return User.collection().query((q) => { + q.where("id", "in", subq); + q.where("users.active", true); + }); }, - groupMembersWithPivots () { + groupMembersWithPivots() { // This method uses Bookshelf's `withPivot` to return instances with // join table columns attached. // @@ -90,36 +91,38 @@ export default { // A good reference for proxies: // https://ponyfoo.com/articles/es6-proxies-in-depth - const queryCalls = [] - const addQueryCall = cb => queryCalls.push(cb) && proxy - const tableName = User.forge().tableName - const tableNameFn = () => tableName + const queryCalls = []; + const addQueryCall = (cb) => queryCalls.push(cb) && proxy; + const tableName = User.forge().tableName; + const tableNameFn = () => tableName; const proxy = new Proxy(this, { - get (target, key) { - if (key === 'query') return addQueryCall - if (key === 'tableName') return tableNameFn + get(target, key) { + if (key === "query") return addQueryCall; + if (key === "tableName") return tableNameFn; // handle other keys here if it becomes necessary to fake any other // Bookshelf collection properties - if (typeof key === 'string' && key.startsWith('fetch')) { + if (typeof key === "string" && key.startsWith("fetch")) { return async (...args) => { - const group = await target.group() - let relation = group.members().withPivot(['created_at', 'role', 'settings']) - for (let cb of queryCalls) relation = relation.query(cb) - return relation[key](...args) - } + const group = await target.group(); + let relation = group + .members() + .withPivot(["created_at", "role", "settings"]); + for (const cb of queryCalls) relation = relation.query(cb); + return relation[key](...args); + }; } - } - }) + }, + }); - return proxy + return proxy; }, - async isFollowed (userId) { - const ms = await GroupMembership.forPair(userId, this).fetch() - return !!(ms && ms.getSetting('following')) - } + async isFollowed(userId) { + const ms = await GroupMembership.forPair(userId, this).fetch(); + return !!(ms && ms.getSetting("following")); + }, // proxy some instance methods of Group? -} +}; diff --git a/api/models/mixins/HasSettings.js b/api/models/mixins/HasSettings.js index 8617a555f..13a0d4990 100644 --- a/api/models/mixins/HasSettings.js +++ b/api/models/mixins/HasSettings.js @@ -1,29 +1,29 @@ -import { get, has, isUndefined, merge, unset } from 'lodash' +import { get, has, isUndefined, merge, unset } from "lodash"; export default { addSetting: function (value, save = false) { - this.set('settings', merge({}, this.get('settings'), value)) + this.set("settings", merge({}, this.get("settings"), value)); if (save) { - return this.save({settings: this.get('settings')}, {patch: true}) + return this.save({ settings: this.get("settings") }, { patch: true }); } - return this + return this; }, removeSetting: function (path, save = false) { - unset(this.get('settings'), path) + unset(this.get("settings"), path); if (save) { - return this.save({settings: this.get('settings')}, {patch: true}) + return this.save({ settings: this.get("settings") }, { patch: true }); } - return this + return this; }, getSetting: function (key) { - return get(this.get('settings'), key) + return get(this.get("settings"), key); }, hasSetting: function (key, value) { return isUndefined(value) - ? has(this.get('settings'), key) - : this.get('settings')[key] === value - } -} + ? has(this.get("settings"), key) + : this.get("settings")[key] === value; + }, +}; diff --git a/api/models/network/setupNetworkAttrs.js b/api/models/network/setupNetworkAttrs.js index 47ab1d4e0..cc3d0d1ad 100644 --- a/api/models/network/setupNetworkAttrs.js +++ b/api/models/network/setupNetworkAttrs.js @@ -1,22 +1,22 @@ -import { merge, pick } from 'lodash' -import { sanitize } from 'hylo-utils/text' +import { merge, pick } from "lodash"; +import { sanitize } from "hylo-utils/text"; -export default function setupNetworkAttrs (userId, params) { +export default function setupNetworkAttrs(userId, params) { const attrWhitelist = [ - 'name', - 'slug', - 'description', - 'avatar_url', - 'banner_url', - 'updated_at' - ] + "name", + "slug", + "description", + "avatar_url", + "banner_url", + "updated_at", + ]; const setupAttrs = { name: sanitize(params.name), description: sanitize(params.description), user_id: userId, - updated_at: new Date() - } - const rawAttrs = merge(params, setupAttrs) - const attrs = pick(rawAttrs, attrWhitelist) - return Promise.resolve(attrs) + updated_at: new Date(), + }; + const rawAttrs = merge(params, setupAttrs); + const attrs = pick(rawAttrs, attrWhitelist); + return Promise.resolve(attrs); } diff --git a/api/models/network/updateNetwork.js b/api/models/network/updateNetwork.js index ca6c688ce..933418aa4 100644 --- a/api/models/network/updateNetwork.js +++ b/api/models/network/updateNetwork.js @@ -1,61 +1,88 @@ -import { isEqual, difference, values, some } from 'lodash' -import setupNetworkAttrs from './setupNetworkAttrs' +import { isEqual, difference, values, some } from "lodash"; +import setupNetworkAttrs from "./setupNetworkAttrs"; -export default function updateNetwork (userId, id, params) { - if (!userId) throw new Error('updateNetwork called with no userID') - if (!id) throw new Error('updateNetwork called with no ID') - return setupNetworkAttrs(userId, params).then(attrs => - bookshelf.transaction(transacting => - Network.find(id, {withRelated: ['communities', 'moderators']}).then(network => { - return network.save(attrs, {patch: true, transacting}) - .tap(updatedNetwork => afterUpdatingNetwork(updatedNetwork, {params, userId, transacting})) - }) +export default function updateNetwork(userId, id, params) { + if (!userId) throw new Error("updateNetwork called with no userID"); + if (!id) throw new Error("updateNetwork called with no ID"); + return setupNetworkAttrs(userId, params).then((attrs) => + bookshelf.transaction((transacting) => + Network.find(id, { withRelated: ["communities", "moderators"] }).then( + (network) => { + return network + .save(attrs, { patch: true, transacting }) + .tap((updatedNetwork) => + afterUpdatingNetwork(updatedNetwork, { + params, + userId, + transacting, + }) + ); + } + ) ) - ) + ); } -export function afterUpdatingNetwork (network, opts) { +export function afterUpdatingNetwork(network, opts) { const { - params: { - community_ids, - moderator_ids - }, - transacting - } = opts + params: { community_ids, moderator_ids }, + transacting, + } = opts; return Promise.all([ - updateCommunities(network, community_ids && values(community_ids), transacting), // eslint-disable-line camelcase - updateModerators(network, moderator_ids && values(moderator_ids), transacting) // eslint-disable-line camelcase - ]) + updateCommunities( + network, + community_ids && values(community_ids), + transacting + ), // eslint-disable-line camelcase + updateModerators( + network, + moderator_ids && values(moderator_ids), + transacting + ), // eslint-disable-line camelcase + ]); } -export function updateCommunities (network, newCommunityIds, transacting) { - if (!newCommunityIds) return - const currentCommunityIds = network.relations.communities.pluck('id') +export function updateCommunities(network, newCommunityIds, transacting) { + if (!newCommunityIds) return; + const currentCommunityIds = network.relations.communities.pluck("id"); if (!isEqual(newCommunityIds, currentCommunityIds)) { - const communitiesToAdd = difference(newCommunityIds, currentCommunityIds) - const communitiesToRemove = difference(currentCommunityIds, newCommunityIds) + const communitiesToAdd = difference(newCommunityIds, currentCommunityIds); + const communitiesToRemove = difference( + currentCommunityIds, + newCommunityIds + ); return Promise.all([ // Add communities - some(communitiesToAdd) && Community.query().where('id', 'in', communitiesToAdd) - .update('network_id', network.id).transacting(transacting), + some(communitiesToAdd) && + Community.query() + .where("id", "in", communitiesToAdd) + .update("network_id", network.id) + .transacting(transacting), // Remove communities - some(communitiesToRemove) && Community.query().where('id', 'in', communitiesToRemove) - .update('network_id', null).transacting(transacting) - ]) + some(communitiesToRemove) && + Community.query() + .where("id", "in", communitiesToRemove) + .update("network_id", null) + .transacting(transacting), + ]); } } -export function updateModerators (network, newModeratorIds, transacting) { - if (!newModeratorIds) return - const currentModeratorIds = network.relations.moderators.pluck('id') +export function updateModerators(network, newModeratorIds, transacting) { + if (!newModeratorIds) return; + const currentModeratorIds = network.relations.moderators.pluck("id"); if (!isEqual(newModeratorIds, currentModeratorIds)) { - const opts = { transacting } - const moderators = network.moderators() - const moderatorsToAdd = difference(newModeratorIds, currentModeratorIds) - const moderatorsToRemove = difference(currentModeratorIds, newModeratorIds) + const opts = { transacting }; + const moderators = network.moderators(); + const moderatorsToAdd = difference(newModeratorIds, currentModeratorIds); + const moderatorsToRemove = difference(currentModeratorIds, newModeratorIds); return Promise.join( - Promise.map(moderatorsToAdd, userId => NetworkMembership.addModerator(userId, network.id, opts)), - Promise.map(moderatorsToRemove, userId => moderators.detach(userId, opts)) - ) + Promise.map(moderatorsToAdd, (userId) => + NetworkMembership.addModerator(userId, network.id, opts) + ), + Promise.map(moderatorsToRemove, (userId) => + moderators.detach(userId, opts) + ) + ); } } diff --git a/api/models/network/validateNetworkData.js b/api/models/network/validateNetworkData.js index f72cd67ef..d1e9517da 100644 --- a/api/models/network/validateNetworkData.js +++ b/api/models/network/validateNetworkData.js @@ -1,6 +1,6 @@ -export default function validateNetworkData (userId, data) { +export default function validateNetworkData(userId, data) { if (!data.name) { - throw new Error("Network name can't be blank") + throw new Error("Network name can't be blank"); } - return Promise.resolve(data) + return Promise.resolve(data); } diff --git a/api/models/network/validateNetworkData.test.js b/api/models/network/validateNetworkData.test.js index 963fa6f25..829046cd6 100644 --- a/api/models/network/validateNetworkData.test.js +++ b/api/models/network/validateNetworkData.test.js @@ -1,8 +1,8 @@ -import validateNetworkData from './validateNetworkData' +import validateNetworkData from "./validateNetworkData"; -describe('validateNetworkData', () => { - it('fails if no name is provided', () => { - const fn = () => validateNetworkData(null, {}) - expect(fn).to.throw(/Network name can't be blank/) - }) -}) +describe("validateNetworkData", () => { + it("fails if no name is provided", () => { + const fn = () => validateNetworkData(null, {}); + expect(fn).to.throw(/Network name can't be blank/); + }); +}); diff --git a/api/models/post/createPost.js b/api/models/post/createPost.js index 75de7985e..f389441e0 100644 --- a/api/models/post/createPost.js +++ b/api/models/post/createPost.js @@ -1,97 +1,154 @@ -import { flatten, merge, pick, uniq } from 'lodash' -import setupPostAttrs from './setupPostAttrs' -import updateChildren from './updateChildren' -import { updateNetworkMemberships } from './util' -import { communityRoom, pushToSockets } from '../../services/Websockets' - -export default function createPost (userId, params) { - return setupPostAttrs(userId, merge(Post.newPostAttrs(), params)) - .then(attrs => bookshelf.transaction(transacting => - Post.create(attrs, { transacting }) - .tap(post => afterCreatingPost(post, merge( - pick(params, 'community_ids', 'imageUrl', 'videoUrl', 'docs', 'topicNames', 'memberIds', 'eventInviteeIds', 'imageUrls', 'fileUrls', 'announcement', 'location', 'location_id'), - {children: params.requests, transacting} - )))).then(function(inserts) { - return inserts - }).catch(function(error) { - throw error - }) - ) +import { flatten, merge, pick, uniq } from "lodash"; +import setupPostAttrs from "./setupPostAttrs"; +import updateChildren from "./updateChildren"; +import { updateNetworkMemberships } from "./util"; +import { communityRoom, pushToSockets } from "../../services/Websockets"; + +export default function createPost(userId, params) { + return setupPostAttrs(userId, merge(Post.newPostAttrs(), params)).then( + (attrs) => + bookshelf + .transaction((transacting) => + Post.create(attrs, { transacting }).tap((post) => + afterCreatingPost( + post, + merge( + pick( + params, + "community_ids", + "imageUrl", + "videoUrl", + "docs", + "topicNames", + "memberIds", + "eventInviteeIds", + "imageUrls", + "fileUrls", + "announcement", + "location", + "location_id" + ), + { children: params.requests, transacting } + ) + ) + ) + ) + .then(function (inserts) { + return inserts; + }) + .catch(function (error) { + throw error; + }) + ); } -export function afterCreatingPost (post, opts) { - const userId = post.get('user_id') - const mentioned = RichText.getUserMentions(post.get('description')) - const followerIds = uniq(mentioned.concat(userId)) - const trx = opts.transacting - const trxOpts = pick(opts, 'transacting') - - return Promise.all(flatten([ - opts.community_ids && post.communities().attach(uniq(opts.community_ids), trxOpts), - - // Add mentioned users and creator as followers - post.addFollowers(followerIds, trxOpts), - - // Add creator to RSVPs - post.get('type') === 'event' && - EventInvitation.create({userId, inviterId: userId, eventId: post.id, response: EventInvitation.RESPONSE.YES}, trxOpts), - - // Add media, if any - // redux version - opts.imageUrl && Media.createForSubject({ - subjectType: 'post', - subjectId: post.id, - type: 'image', - url: opts.imageUrl - }, trx), - - // evo version - opts.imageUrls && Promise.map(opts.imageUrls, (url, i) => - Media.createForSubject({ - subjectType: 'post', - subjectId: post.id, - type: 'image', - url, - position: i - }, trx)), - - // evo version - opts.fileUrls && Promise.map(opts.fileUrls, (url, i) => - Media.createForSubject({ - subjectType: 'post', - subjectId: post.id, - type: 'file', - url, - position: i - }, trx)), - - opts.children && updateChildren(post, opts.children, trx), - - // google doc / video not currently used in evo - opts.videoUrl && Media.createForSubject({ - subjectType: 'post', - subjectId: post.id, - type: 'video', - url: opts.videoUrl - }, trx), - - opts.docs && Promise.map(opts.docs, (doc) => Media.createDoc(post.id, doc, trx)), - ])) - .then(() => post.updateProjectMembers(opts.memberIds || [], trxOpts)) - .then(() => post.updateEventInvitees(opts.eventInviteeIds || [], userId, trxOpts)) - .then(() => Tag.updateForPost(post, opts.topicNames, userId, trx)) - .then(() => updateTagsAndCommunities(post, trx)) - .then(() => updateNetworkMemberships(post, trx)) - .then(() => Queue.classMethod('Post', 'createActivities', {postId: post.id})) - .then(() => Queue.classMethod('Post', 'notifySlack', {postId: post.id})) +export function afterCreatingPost(post, opts) { + const userId = post.get("user_id"); + const mentioned = RichText.getUserMentions(post.get("description")); + const followerIds = uniq(mentioned.concat(userId)); + const trx = opts.transacting; + const trxOpts = pick(opts, "transacting"); + + return Promise.all( + flatten([ + opts.community_ids && + post.communities().attach(uniq(opts.community_ids), trxOpts), + + // Add mentioned users and creator as followers + post.addFollowers(followerIds, trxOpts), + + // Add creator to RSVPs + post.get("type") === "event" && + EventInvitation.create( + { + userId, + inviterId: userId, + eventId: post.id, + response: EventInvitation.RESPONSE.YES, + }, + trxOpts + ), + + // Add media, if any + // redux version + opts.imageUrl && + Media.createForSubject( + { + subjectType: "post", + subjectId: post.id, + type: "image", + url: opts.imageUrl, + }, + trx + ), + + // evo version + opts.imageUrls && + Promise.map(opts.imageUrls, (url, i) => + Media.createForSubject( + { + subjectType: "post", + subjectId: post.id, + type: "image", + url, + position: i, + }, + trx + ) + ), + + // evo version + opts.fileUrls && + Promise.map(opts.fileUrls, (url, i) => + Media.createForSubject( + { + subjectType: "post", + subjectId: post.id, + type: "file", + url, + position: i, + }, + trx + ) + ), + + opts.children && updateChildren(post, opts.children, trx), + + // google doc / video not currently used in evo + opts.videoUrl && + Media.createForSubject( + { + subjectType: "post", + subjectId: post.id, + type: "video", + url: opts.videoUrl, + }, + trx + ), + + opts.docs && + Promise.map(opts.docs, (doc) => Media.createDoc(post.id, doc, trx)), + ]) + ) + .then(() => post.updateProjectMembers(opts.memberIds || [], trxOpts)) + .then(() => + post.updateEventInvitees(opts.eventInviteeIds || [], userId, trxOpts) + ) + .then(() => Tag.updateForPost(post, opts.topicNames, userId, trx)) + .then(() => updateTagsAndCommunities(post, trx)) + .then(() => updateNetworkMemberships(post, trx)) + .then(() => + Queue.classMethod("Post", "createActivities", { postId: post.id }) + ) + .then(() => Queue.classMethod("Post", "notifySlack", { postId: post.id })); } -async function updateTagsAndCommunities (post, trx) { - await post.load([ - 'communities', 'linkPreview', 'networks', 'tags', 'user' - ], {transacting: trx}) +async function updateTagsAndCommunities(post, trx) { + await post.load(["communities", "linkPreview", "networks", "tags", "user"], { + transacting: trx, + }); - const { tags, communities } = post.relations + const { tags, communities } = post.relations; // NOTE: the payload object is released to many users, so it cannot be // subject to the usual permissions checks (which communities/networks @@ -99,38 +156,49 @@ async function updateTagsAndCommunities (post, trx) { // information, or (as below) we only post community data for the socket // room it's being pushed to. // TODO: eventually we will need to push to socket rooms for networks. - const payload = post.getNewPostSocketPayload() - const notifySockets = payload.communities.map(c => { + const payload = post.getNewPostSocketPayload(); + const notifySockets = payload.communities.map((c) => { pushToSockets( communityRoom(c.id), - 'newPost', - Object.assign({}, payload, { communities: [ c ] }) - ) - }) + "newPost", + Object.assign({}, payload, { communities: [c] }) + ); + }); - const updateCommunityTags = CommunityTag.query(q => { - q.whereIn('tag_id', tags.map('id')) - }).query().update({updated_at: new Date()}).transacting(trx) + const updateCommunityTags = CommunityTag.query((q) => { + q.whereIn("tag_id", tags.map("id")); + }) + .query() + .update({ updated_at: new Date() }) + .transacting(trx); return Promise.all([ notifySockets, updateCommunityTags, - TagFollow.query(q => { - q.whereIn('tag_id', tags.map('id')) - q.whereIn('community_id', communities.map('id')) - q.whereNot('user_id', post.get('user_id')) - }).query().increment('new_post_count').transacting(trx), - - GroupMembership.query(q => { - const groupIds = Group.query(q2 => { - q2.whereIn('group_data_id', communities.map('id')) - q2.where('group_data_type', Group.DataType.COMMUNITY) - }).query().pluck('id') - - q.whereIn('group_id', groupIds) - q.whereNot('group_memberships.user_id', post.get('user_id')) - q.where('group_memberships.active', true) - }).query().increment('new_post_count').transacting(trx) - ]) + TagFollow.query((q) => { + q.whereIn("tag_id", tags.map("id")); + q.whereIn("community_id", communities.map("id")); + q.whereNot("user_id", post.get("user_id")); + }) + .query() + .increment("new_post_count") + .transacting(trx), + + GroupMembership.query((q) => { + const groupIds = Group.query((q2) => { + q2.whereIn("group_data_id", communities.map("id")); + q2.where("group_data_type", Group.DataType.COMMUNITY); + }) + .query() + .pluck("id"); + + q.whereIn("group_id", groupIds); + q.whereNot("group_memberships.user_id", post.get("user_id")); + q.where("group_memberships.active", true); + }) + .query() + .increment("new_post_count") + .transacting(trx), + ]); } diff --git a/api/models/post/createPost.test.js b/api/models/post/createPost.test.js index 0011609ee..dcda57e56 100644 --- a/api/models/post/createPost.test.js +++ b/api/models/post/createPost.test.js @@ -1,73 +1,89 @@ -import { afterCreatingPost } from './createPost' -const rootPath = require('root-path') -const setup = require(rootPath('test/setup')) -const factories = require(rootPath('test/setup/factories')) -const { spyify, stubGetImageSize, unspyify } = require(rootPath('test/setup/helpers')) +import { afterCreatingPost } from "./createPost"; +const rootPath = require("root-path"); +const setup = require(rootPath("test/setup")); +const factories = require(rootPath("test/setup/factories")); +const { spyify, stubGetImageSize, unspyify } = require(rootPath( + "test/setup/helpers" +)); -describe('afterCreatingPost', () => { - var post - const videoUrl = 'https://www.youtube.com/watch?v=jsQ7yKwDPZk' +describe("afterCreatingPost", () => { + let post; + const videoUrl = "https://www.youtube.com/watch?v=jsQ7yKwDPZk"; before(() => - setup.clearDb() - .then(() => Promise.props({ - requestTag: Tag.forge({name: 'request'}).save(), - u1: new User({name: 'U1', email: 'a@b.c', active: true}).save() - })) - .then(props => { - post = factories.post({user_id: props.u1.id, description: 'wow!', link_preview_id: null}) - }) - ) + setup + .clearDb() + .then(() => + Promise.props({ + requestTag: Tag.forge({ name: "request" }).save(), + u1: new User({ name: "U1", email: "a@b.c", active: true }).save(), + }) + ) + .then((props) => { + post = factories.post({ + user_id: props.u1.id, + description: "wow!", + link_preview_id: null, + }); + }) + ); beforeEach(() => { - spyify(Queue, 'classMethod') - }) + spyify(Queue, "classMethod"); + }); - after(() => unspyify(Queue, 'classMethod')) + after(() => unspyify(Queue, "classMethod")); - it('works', () => { + it("works", () => { return Media.generateThumbnailUrl(videoUrl) - .then(url => stubGetImageSize(url)) - .then(() => bookshelf.transaction(trx => - post.save({}, {transacting: trx}) + .then((url) => stubGetImageSize(url)) .then(() => - afterCreatingPost(post, { - communities: [], - videoUrl, - children: [ - { - id: 'new-whatever', - name: 'bob', - description: 'is your uncle' - } - ], - transacting: trx - })))) - .then(() => post.load(['media', 'children'])) - .then(() => { - const video = post.relations.media.first() - expect(video).to.exist - expect(video.get('url')).to.equal(videoUrl) + bookshelf.transaction((trx) => + post.save({}, { transacting: trx }).then(() => + afterCreatingPost(post, { + communities: [], + videoUrl, + children: [ + { + id: "new-whatever", + name: "bob", + description: "is your uncle", + }, + ], + transacting: trx, + }) + ) + ) + ) + .then(() => post.load(["media", "children"])) + .then(() => { + const video = post.relations.media.first(); + expect(video).to.exist; + expect(video.get("url")).to.equal(videoUrl); - const child = post.relations.children.first() - expect(child).to.exist - expect(child.get('name')).to.equal('bob') - expect(child.get('description')).to.equal('is your uncle') + const child = post.relations.children.first(); + expect(child).to.exist; + expect(child.get("name")).to.equal("bob"); + expect(child.get("description")).to.equal("is your uncle"); - expect(Queue.classMethod).to.have.been.called - .with('Post', 'createActivities', {postId: post.id}) - }) - }) + expect(Queue.classMethod).to.have.been.called.with( + "Post", + "createActivities", + { postId: post.id } + ); + }); + }); - it('ignores duplicate community ids', () => { - const c = factories.community() - return c.save() - .then(() => post.save()) - .then(() => afterCreatingPost(post, {community_ids: [c.id, c.id]})) - .then(() => post.load('communities')) - .then(() => expect(post.relations.communities.length).to.equal(1)) - .catch(err => { - throw err - }) - }) -}) + it("ignores duplicate community ids", () => { + const c = factories.community(); + return c + .save() + .then(() => post.save()) + .then(() => afterCreatingPost(post, { community_ids: [c.id, c.id] })) + .then(() => post.load("communities")) + .then(() => expect(post.relations.communities.length).to.equal(1)) + .catch((err) => { + throw err; + }); + }); +}); diff --git a/api/models/post/findOrCreateThread.js b/api/models/post/findOrCreateThread.js index 507fc9259..65301e470 100644 --- a/api/models/post/findOrCreateThread.js +++ b/api/models/post/findOrCreateThread.js @@ -1,57 +1,60 @@ -import { pick } from 'lodash' -import { map, uniq } from 'lodash/fp' -import { isFollowing } from '../group/queryUtils' +import { pick } from "lodash"; +import { map, uniq } from "lodash/fp"; +import { isFollowing } from "../group/queryUtils"; -export function findThread (userIds) { +export function findThread(userIds) { const subquery = Group.havingExactMembers(userIds, Post) - .query(isFollowing) - .query().select('group_data_id') + .query(isFollowing) + .query() + .select("group_data_id"); - return Post.query(q => q.whereIn('id', subquery).where({type: Post.Type.THREAD})).fetch() + return Post.query((q) => + q.whereIn("id", subquery).where({ type: Post.Type.THREAD }) + ).fetch(); } -export default function findOrCreateThread (userId, participantIds) { - return findThread(uniq([userId].concat(participantIds))) - .then(post => post || createThread(userId, uniq(participantIds))) +export default function findOrCreateThread(userId, participantIds) { + return findThread(uniq([userId].concat(participantIds))).then( + (post) => post || createThread(userId, uniq(participantIds)) + ); } -export async function createThread (userId, participantIds) { - const attrs = await setupNewThreadAttrs(userId) - let thread - await bookshelf.transaction(async trx => { - thread = await Post.create(attrs, {transacting: trx}) - await afterSavingThread(thread, {participantIds, transacting: trx}) - }) - return thread +export async function createThread(userId, participantIds) { + const attrs = await setupNewThreadAttrs(userId); + let thread; + await bookshelf.transaction(async (trx) => { + thread = await Post.create(attrs, { transacting: trx }); + await afterSavingThread(thread, { participantIds, transacting: trx }); + }); + return thread; } -export function validateThreadData (userId, data) { - const { participantIds } = data +export function validateThreadData(userId, data) { + const { participantIds } = data; if (!(participantIds && participantIds.length)) { - throw new Error("participantIds can't be empty") + throw new Error("participantIds can't be empty"); } - const checkForSharedCommunity = id => - Group.inSameGroup([userId, id], Community) - .then(doesShare => { - if (!doesShare) throw new Error(`no shared communities with user ${id}`) - }) - return Promise.all(map(checkForSharedCommunity, participantIds)) + const checkForSharedCommunity = (id) => + Group.inSameGroup([userId, id], Community).then((doesShare) => { + if (!doesShare) throw new Error(`no shared communities with user ${id}`); + }); + return Promise.all(map(checkForSharedCommunity, participantIds)); } -function setupNewThreadAttrs (userId) { +function setupNewThreadAttrs(userId) { return Promise.resolve({ type: Post.Type.THREAD, visibility: Post.Visibility.DEFAULT, user_id: userId, - link_preview_id: null - }) + link_preview_id: null, + }); } -async function afterSavingThread (thread, opts) { - const userId = thread.get('user_id') - const participantIds = uniq([userId].concat(opts.participantIds)) - const trxOpts = pick(opts, 'transacting') +async function afterSavingThread(thread, opts) { + const userId = thread.get("user_id"); + const participantIds = uniq([userId].concat(opts.participantIds)); + const trxOpts = pick(opts, "transacting"); - const followers = await thread.addFollowers(participantIds, {}, trxOpts) - return followers + const followers = await thread.addFollowers(participantIds, {}, trxOpts); + return followers; } diff --git a/api/models/post/findOrCreateThread.test.js b/api/models/post/findOrCreateThread.test.js index 144e56fa8..f999ef01d 100644 --- a/api/models/post/findOrCreateThread.test.js +++ b/api/models/post/findOrCreateThread.test.js @@ -1,53 +1,66 @@ -import findOrCreateThread, { validateThreadData } from './findOrCreateThread' -import factories from '../../../test/setup/factories' +import findOrCreateThread, { validateThreadData } from "./findOrCreateThread"; +import factories from "../../../test/setup/factories"; -describe('findOrCreateThread', () => { - var u1, u2, u3 +describe("findOrCreateThread", () => { + let u1, u2, u3; before(async () => { - u1 = await factories.user().save() - u2 = await factories.user().save() - u3 = await factories.user().save() - }) + u1 = await factories.user().save(); + u2 = await factories.user().save(); + u3 = await factories.user().save(); + }); - it('finds or creates a thread', async () => { - let thread = await findOrCreateThread(u1.id, [u1.id, u2.id, u3.id]) - thread = await Post.find(thread.id) - expect(await thread.followers().fetch().then(x => x.length)).to.equal(3) + it("finds or creates a thread", async () => { + let thread = await findOrCreateThread(u1.id, [u1.id, u2.id, u3.id]); + thread = await Post.find(thread.id); + expect( + await thread + .followers() + .fetch() + .then((x) => x.length) + ).to.equal(3); - let thread2 = await findOrCreateThread(u2.id, [u1.id, u2.id, u3.id]) - expect(thread2.id).to.equal(thread.id) + const thread2 = await findOrCreateThread(u2.id, [u1.id, u2.id, u3.id]); + expect(thread2.id).to.equal(thread.id); - let thread3 = await findOrCreateThread(u2.id, [u2.id, u3.id]) - expect(thread3.id).not.to.equal(thread.id) - expect(await thread3.followers().fetch().then(x => x.length)).to.equal(2) - }) -}) + const thread3 = await findOrCreateThread(u2.id, [u2.id, u3.id]); + expect(thread3.id).not.to.equal(thread.id); + expect( + await thread3 + .followers() + .fetch() + .then((x) => x.length) + ).to.equal(2); + }); +}); -describe('validateThreadData', () => { - var user, userSharingCommunity, userNotInCommunity, community +describe("validateThreadData", () => { + let user, userSharingCommunity, userNotInCommunity, community; before(async () => { - community = await factories.community().save() - user = await factories.user().save() - userSharingCommunity = await factories.user().save() - userNotInCommunity = await factories.user().save() - await user.joinCommunity(community) - await userSharingCommunity.joinCommunity(community) - }) + community = await factories.community().save(); + user = await factories.user().save(); + userSharingCommunity = await factories.user().save(); + userNotInCommunity = await factories.user().save(); + await user.joinCommunity(community); + await userSharingCommunity.joinCommunity(community); + }); - it('fails if no participantIds are provided', () => { - const fn = () => validateThreadData(user.id, []) - expect(fn).to.throw(/participantIds can't be empty/) - }) - it('fails if there is a participantId for a user the creator shares no communities with', () => { - const data = {participantIds: [userSharingCommunity.id, userNotInCommunity.id]} - return validateThreadData(user.id, data) - .catch(function (e) { - expect(e.message).to.equal(`no shared communities with user ${userNotInCommunity.id}`) - }) - }) - it('continue the promise chain if user shares community with all participants', () => { - const data = {participantIds: [userSharingCommunity.id]} - expect(validateThreadData(user.id, data)).to.respondTo('then') - }) -}) + it("fails if no participantIds are provided", () => { + const fn = () => validateThreadData(user.id, []); + expect(fn).to.throw(/participantIds can't be empty/); + }); + it("fails if there is a participantId for a user the creator shares no communities with", () => { + const data = { + participantIds: [userSharingCommunity.id, userNotInCommunity.id], + }; + return validateThreadData(user.id, data).catch(function (e) { + expect(e.message).to.equal( + `no shared communities with user ${userNotInCommunity.id}` + ); + }); + }); + it("continue the promise chain if user shares community with all participants", () => { + const data = { participantIds: [userSharingCommunity.id] }; + expect(validateThreadData(user.id, data)).to.respondTo("then"); + }); +}); diff --git a/api/models/post/fulfillPost.js b/api/models/post/fulfillPost.js index e36271018..2b271d2d5 100644 --- a/api/models/post/fulfillPost.js +++ b/api/models/post/fulfillPost.js @@ -1,38 +1,36 @@ -export function fulfill (opts = {}) { - const { fulfilledAt, contributorIds } = opts - return bookshelf.transaction(transacting => { +export function fulfill(opts = {}) { + const { fulfilledAt, contributorIds } = opts; + return bookshelf.transaction((transacting) => { const fulfill = (post) => post.save( - {fulfilled_at: (fulfilledAt || new Date())}, - {patch: true, transacting} - ) + { fulfilled_at: fulfilledAt || new Date() }, + { patch: true, transacting } + ); const addContributors = (post) => - Promise.map( - contributorIds || [], - userId => Contribution.create(userId, this.id, transacting) - ) - return fulfill(this).tap(addContributors) - }) + Promise.map(contributorIds || [], (userId) => + Contribution.create(userId, this.id, transacting) + ); + return fulfill(this).tap(addContributors); + }); } -export function unfulfill () { - return bookshelf.transaction(transacting => { +export function unfulfill() { + return bookshelf.transaction((transacting) => { const unfulfill = (post) => - post.save({fulfilled_at: null}, {patch: true, transacting}) + post.save({ fulfilled_at: null }, { patch: true, transacting }); const loadContributions = (post) => - post.load(['contributions'], {transacting}) + post.load(["contributions"], { transacting }); const removeActivities = (post) => - Promise.map( - post.relations.contributions.models, - c => Activity.removeForContribution(c.id, transacting) - ) + Promise.map(post.relations.contributions.models, (c) => + Activity.removeForContribution(c.id, transacting) + ); const removeContributions = (post) => - Promise.map( - post.relations.contributions.models, - c => c.destroy({transacting}) - ) - return unfulfill(this).then(loadContributions) + Promise.map(post.relations.contributions.models, (c) => + c.destroy({ transacting }) + ); + return unfulfill(this) + .then(loadContributions) .tap(removeActivities) - .tap(removeContributions) - }) + .tap(removeContributions); + }); } diff --git a/api/models/post/migration.js b/api/models/post/migration.js index 876de0699..f6a5bfdc0 100644 --- a/api/models/post/migration.js +++ b/api/models/post/migration.js @@ -1,17 +1,21 @@ -import { compact } from 'lodash' +import { compact } from "lodash"; -export function restoreTypes (limit = 2000) { - const { REQUEST, OFFER, RESOURCE } = Post.Type +export function restoreTypes(limit = 2000) { + const { REQUEST, OFFER, RESOURCE } = Post.Type; - return Post.where('type', null).query(q => { - q.limit(limit) - }).fetchAll({withRelated: 'selectedTags'}) - .then(posts => Promise.map(posts.models, post => { - const tag = post.relations.selectedTags.first() + return Post.where("type", null) + .query((q) => { + q.limit(limit); + }) + .fetchAll({ withRelated: "selectedTags" }) + .then((posts) => + Promise.map(posts.models, (post) => { + const tag = post.relations.selectedTags.first(); - if (tag && [REQUEST, OFFER, RESOURCE].includes(tag.get('name'))) { - return post.save({type: tag.get('name')}, {patch: true}) - } - })) - .then(saves => compact(saves).length) + if (tag && [REQUEST, OFFER, RESOURCE].includes(tag.get("name"))) { + return post.save({ type: tag.get("name") }, { patch: true }); + } + }) + ) + .then((saves) => compact(saves).length); } diff --git a/api/models/post/setupPostAttrs.js b/api/models/post/setupPostAttrs.js index 5ba704a77..574187f05 100644 --- a/api/models/post/setupPostAttrs.js +++ b/api/models/post/setupPostAttrs.js @@ -1,23 +1,37 @@ -import { merge, pick } from 'lodash' -import { getOr } from 'lodash/fp' -import { sanitize } from 'hylo-utils/text' -import he from 'he'; +import { merge, pick } from "lodash"; +import { getOr } from "lodash/fp"; +import { sanitize } from "hylo-utils/text"; +import he from "he"; -export default function setupPostAttrs (userId, params) { - const attrs = merge({ - name: sanitize(he.encode(params.name)), - description: sanitize(params.description), - user_id: userId, - visibility: params.public ? Post.Visibility.PUBLIC_READABLE : Post.Visibility.DEFAULT, - link_preview_id: params.link_preview_id || getOr(null, 'id', params.linkPreview), - parent_post_id: params.parent_post_id, - updated_at: new Date(), - announcement: params.announcement, - accept_contributions: params.acceptContributions, - start_time: params.startTime ? new Date(Number(params.startTime)) : null, - end_time: params.endTime ? new Date(Number(params.endTime)) : null, - is_public: params.isPublic - }, pick(params, 'type', 'starts_at', 'ends_at', 'location_id', 'location', 'created_from')) +export default function setupPostAttrs(userId, params) { + const attrs = merge( + { + name: sanitize(he.encode(params.name)), + description: sanitize(params.description), + user_id: userId, + visibility: params.public + ? Post.Visibility.PUBLIC_READABLE + : Post.Visibility.DEFAULT, + link_preview_id: + params.link_preview_id || getOr(null, "id", params.linkPreview), + parent_post_id: params.parent_post_id, + updated_at: new Date(), + announcement: params.announcement, + accept_contributions: params.acceptContributions, + start_time: params.startTime ? new Date(Number(params.startTime)) : null, + end_time: params.endTime ? new Date(Number(params.endTime)) : null, + is_public: params.isPublic, + }, + pick( + params, + "type", + "starts_at", + "ends_at", + "location_id", + "location", + "created_from" + ) + ); - return Promise.resolve(attrs) + return Promise.resolve(attrs); } diff --git a/api/models/post/updateChildren.js b/api/models/post/updateChildren.js index 884c77983..b0ed65865 100644 --- a/api/models/post/updateChildren.js +++ b/api/models/post/updateChildren.js @@ -1,38 +1,47 @@ -import { includes, merge, omit, some } from 'lodash' -import { filter, map } from 'lodash/fp' +import { includes, merge, omit, some } from "lodash"; +import { filter, map } from "lodash/fp"; -export default function updateChildren (post, children, trx) { - const isNew = child => child.id.startsWith('new') - const created = filter(c => isNew(c) && !!c.name, children) - const updated = filter(c => !isNew(c) && !!c.name, children) - return post.load('children', {transacting: trx}) - .then(() => { - const existingIds = map('id', post.relations.children.models) - const removed = filter(id => !includes(map('id', updated), id), existingIds) +export default function updateChildren(post, children, trx) { + const isNew = (child) => child.id.startsWith("new"); + const created = filter((c) => isNew(c) && !!c.name, children); + const updated = filter((c) => !isNew(c) && !!c.name, children); + return post.load("children", { transacting: trx }).then(() => { + const existingIds = map("id", post.relations.children.models); + const removed = filter( + (id) => !includes(map("id", updated), id), + existingIds + ); return Promise.all([ // mark removed posts as inactive - some(removed) && Post.query().where('id', 'in', removed) - .update('active', false).transacting(trx), + some(removed) && + Post.query() + .where("id", "in", removed) + .update("active", false) + .transacting(trx), // update name and description for updated requests - Promise.map(updated, child => - Post.query().where('id', child.id) - .update(omit(child, 'id')).transacting(trx)), + Promise.map(updated, (child) => + Post.query() + .where("id", child.id) + .update(omit(child, "id")) + .transacting(trx) + ), // create new requests - some(created) && Tag.find({ name: 'request' }) - .then(tag => { - const attachment = {tag_id: tag.id, selected: true} - return Promise.map(created, child => { - const attrs = merge(omit(child, 'id'), { - parent_post_id: post.id, - user_id: post.get('user_id'), - is_project_request: true - }) - return Post.create(attrs, {transacting: trx}) - .then(post => post.tags().attach(attachment, {transacting: trx})) - }) - }) - ]) - }) + some(created) && + Tag.find({ name: "request" }).then((tag) => { + const attachment = { tag_id: tag.id, selected: true }; + return Promise.map(created, (child) => { + const attrs = merge(omit(child, "id"), { + parent_post_id: post.id, + user_id: post.get("user_id"), + is_project_request: true, + }); + return Post.create(attrs, { transacting: trx }).then((post) => + post.tags().attach(attachment, { transacting: trx }) + ); + }); + }), + ]); + }); } diff --git a/api/models/post/updateChildren.test.js b/api/models/post/updateChildren.test.js index 4fdcc1857..675f86da2 100644 --- a/api/models/post/updateChildren.test.js +++ b/api/models/post/updateChildren.test.js @@ -1,48 +1,58 @@ -import { times } from 'lodash' -const rootPath = require('root-path') -require(rootPath('test/setup')) -const factories = require(rootPath('test/setup/factories')) -import updateChildren from './updateChildren' +import { times } from "lodash"; +import updateChildren from "./updateChildren"; +const rootPath = require("root-path"); +require(rootPath("test/setup")); +const factories = require(rootPath("test/setup/factories")); -describe('updateChildren', () => { - var post, children +describe("updateChildren", () => { + let post, children; before(() => { - post = factories.post() - children = times(3, () => factories.post()) - return post.save() - .then(() => Promise.all(children.map(c => - c.save({parent_post_id: post.id})))) - }) + post = factories.post(); + children = times(3, () => factories.post()); + return post + .save() + .then(() => + Promise.all(children.map((c) => c.save({ parent_post_id: post.id }))) + ); + }); - it('creates, updates, and removes child posts', () => { + it("creates, updates, and removes child posts", () => { const childrenParam = [ - { // ignore - id: 'new-foo', - name: '' + { + // ignore + id: "new-foo", + name: "", }, - { // create - id: 'new-bar', - name: 'Yay!' + { + // create + id: "new-bar", + name: "Yay!", }, - { // update + { + // update id: children[0].id, - name: 'Another!' + name: "Another!", }, - { // remove + { + // remove id: children[1].id, - name: '' - } + name: "", + }, // remove children[2] by omission - ] + ]; return updateChildren(post, childrenParam) - .then(() => post.load('children')) - .then(() => { - const updated = post.relations.children - expect(updated.length).to.equal(2) - expect(updated.find(c => c.id !== children[0].id).get('name')).to.equal('Yay!') - expect(updated.find(c => c.id === children[0].id).get('name')).to.equal('Another!') - }) - }) -}) + .then(() => post.load("children")) + .then(() => { + const updated = post.relations.children; + expect(updated.length).to.equal(2); + expect( + updated.find((c) => c.id !== children[0].id).get("name") + ).to.equal("Yay!"); + expect( + updated.find((c) => c.id === children[0].id).get("name") + ).to.equal("Another!"); + }); + }); +}); diff --git a/api/models/post/updatePost.js b/api/models/post/updatePost.js index 97adf50db..f0d41c23f 100644 --- a/api/models/post/updatePost.js +++ b/api/models/post/updatePost.js @@ -1,53 +1,67 @@ -import setupPostAttrs from './setupPostAttrs' -import updateChildren from './updateChildren' +import setupPostAttrs from "./setupPostAttrs"; +import updateChildren from "./updateChildren"; import { updateCommunities, updateAllMedia, updateFollowers, - updateNetworkMemberships -} from './util' + updateNetworkMemberships, +} from "./util"; -export default function updatePost (userId, id, params) { - if (!id) throw new Error('updatePost called with no ID') - return setupPostAttrs(userId, params) - .then(attrs => bookshelf.transaction(transacting => - Post.find(id).then(post => { - if (!post) throw new Error('Post not found') - const updatableTypes = [ - Post.Type.OFFER, - Post.Type.PROJECT, - Post.Type.REQUEST, - Post.Type.RESOURCE, - Post.Type.DISCUSSION, - Post.Type.EVENT, - null - ] - if (!updatableTypes.includes(post.get('type'))) { - throw new Error("This post can't be modified") - } +export default function updatePost(userId, id, params) { + if (!id) throw new Error("updatePost called with no ID"); + return setupPostAttrs(userId, params).then((attrs) => + bookshelf.transaction((transacting) => + Post.find(id).then((post) => { + if (!post) throw new Error("Post not found"); + const updatableTypes = [ + Post.Type.OFFER, + Post.Type.PROJECT, + Post.Type.REQUEST, + Post.Type.RESOURCE, + Post.Type.DISCUSSION, + Post.Type.EVENT, + null, + ]; + if (!updatableTypes.includes(post.get("type"))) { + throw new Error("This post can't be modified"); + } - return post.save(attrs, {patch: true, transacting}) - .tap(updatedPost => afterUpdatingPost(updatedPost, {params, userId, transacting})) - }))) + return post + .save(attrs, { patch: true, transacting }) + .tap((updatedPost) => + afterUpdatingPost(updatedPost, { params, userId, transacting }) + ); + }) + ) + ); } -export function afterUpdatingPost (post, opts) { +export function afterUpdatingPost(post, opts) { const { params, params: { requests, community_ids, topicNames, memberIds, eventInviteeIds }, userId, - transacting - } = opts + transacting, + } = opts; - return post.ensureLoad(['communities']) - .then(() => Promise.all([ - updateChildren(post, requests, transacting), - updateCommunities(post, community_ids, transacting), - updateAllMedia(post, params, transacting), - Tag.updateForPost(post, topicNames, userId, transacting), - updateFollowers(post, transacting) - ])) - .then(() => memberIds && post.updateProjectMembers(memberIds, {transacting})) - .then(() => eventInviteeIds && post.updateEventInvitees(eventInviteeIds, userId, {transacting})) - .then(() => updateNetworkMemberships(post, transacting)) + return post + .ensureLoad(["communities"]) + .then(() => + Promise.all([ + updateChildren(post, requests, transacting), + updateCommunities(post, community_ids, transacting), + updateAllMedia(post, params, transacting), + Tag.updateForPost(post, topicNames, userId, transacting), + updateFollowers(post, transacting), + ]) + ) + .then( + () => memberIds && post.updateProjectMembers(memberIds, { transacting }) + ) + .then( + () => + eventInviteeIds && + post.updateEventInvitees(eventInviteeIds, userId, { transacting }) + ) + .then(() => updateNetworkMemberships(post, transacting)); } diff --git a/api/models/post/updatePost.test.js b/api/models/post/updatePost.test.js index c4783eee4..41ca43804 100644 --- a/api/models/post/updatePost.test.js +++ b/api/models/post/updatePost.test.js @@ -1,53 +1,53 @@ -import setup from '../../../test/setup' -import factories from '../../../test/setup/factories' -import updatePost, { afterUpdatingPost } from './updatePost' +import setup from "../../../test/setup"; +import factories from "../../../test/setup/factories"; +import updatePost, { afterUpdatingPost } from "./updatePost"; -describe('updatePost', () => { - let user, post +describe("updatePost", () => { + let user, post; before(() => { - user = factories.user() - return user.save() - .then(() => { - post = factories.post({type: Post.Type.THREAD, user_id: user.id}) - return post.save() - }) - }) - - it('prevents updating of certain post types', () => { - return updatePost(user.id, post.id, {name: 'foo'}) - .then(() => { - expect.fail('should reject') - }) - .catch(err => { - expect(err.message).to.equal("This post can't be modified") - }) - }) -}) - -describe('afterUpdatingPost', () => { - var u1, u2, post + user = factories.user(); + return user.save().then(() => { + post = factories.post({ type: Post.Type.THREAD, user_id: user.id }); + return post.save(); + }); + }); + + it("prevents updating of certain post types", () => { + return updatePost(user.id, post.id, { name: "foo" }) + .then(() => { + expect.fail("should reject"); + }) + .catch((err) => { + expect(err.message).to.equal("This post can't be modified"); + }); + }); +}); + +describe("afterUpdatingPost", () => { + let u1, u2, post; before(() => { - u1 = factories.user() - u2 = factories.user() - post = factories.post() - return setup.clearDb() - .then(() => Tag.forge({name: 'request'}).save()) - .then(() => Promise.join(u1.save(), u2.save())) - .then(() => post.save()) - .then(() => post.addFollowers([u1.id])) - }) - - it('adds new followers if there are new mentions', async () => { - const description = `hello person` - await post.save({description}, {patch: true}) - await afterUpdatingPost(post, {params: {}}) - - const followers = await post.followers().fetch() - expect(followers.pluck('id').sort()).to.deep.equal([u1.id, u2.id].sort()) - }) - - it('does not remove existing images if the imageUrls param is absent') - it('removes existing images if the imageUrls param is empty') -}) + u1 = factories.user(); + u2 = factories.user(); + post = factories.post(); + return setup + .clearDb() + .then(() => Tag.forge({ name: "request" }).save()) + .then(() => Promise.join(u1.save(), u2.save())) + .then(() => post.save()) + .then(() => post.addFollowers([u1.id])); + }); + + it("adds new followers if there are new mentions", async () => { + const description = `hello person`; + await post.save({ description }, { patch: true }); + await afterUpdatingPost(post, { params: {} }); + + const followers = await post.followers().fetch(); + expect(followers.pluck("id").sort()).to.deep.equal([u1.id, u2.id].sort()); + }); + + it("does not remove existing images if the imageUrls param is absent"); + it("removes existing images if the imageUrls param is empty"); +}); diff --git a/api/models/post/util.js b/api/models/post/util.js index 253cd9162..d358a572a 100644 --- a/api/models/post/util.js +++ b/api/models/post/util.js @@ -1,74 +1,84 @@ -import { difference, isEqual, compact, uniq } from 'lodash' +import { difference, isEqual, compact, uniq } from "lodash"; -function updateMedia (post, type, urls, transacting) { - if (!urls) return - var media = post.relations.media.filter(m => m.get('type') === type) +function updateMedia(post, type, urls, transacting) { + if (!urls) return; + const media = post.relations.media.filter((m) => m.get("type") === type); - return Promise.map(media, m => m.destroy({transacting})) - .then(() => Promise.map(urls, (url, i) => - Media.createForSubject({ - subjectType: 'post', - subjectId: post.id, - type, - url, - position: i - }, transacting))) + return Promise.map(media, (m) => m.destroy({ transacting })).then(() => + Promise.map(urls, (url, i) => + Media.createForSubject( + { + subjectType: "post", + subjectId: post.id, + type, + url, + position: i, + }, + transacting + ) + ) + ); } -export function updateAllMedia (post, { imageUrls, fileUrls }, trx) { - return (imageUrls || fileUrls ? post.load('media') : Promise.resolve()) - .tap(() => updateMedia(post, 'image', imageUrls, trx)) - .tap(() => updateMedia(post, 'file', fileUrls, trx)) +export function updateAllMedia(post, { imageUrls, fileUrls }, trx) { + return (imageUrls || fileUrls ? post.load("media") : Promise.resolve()) + .tap(() => updateMedia(post, "image", imageUrls, trx)) + .tap(() => updateMedia(post, "file", fileUrls, trx)); } -export function updateCommunities (post, newIds, trx) { - const oldIds = post.relations.communities.pluck('id') +export function updateCommunities(post, newIds, trx) { + const oldIds = post.relations.communities.pluck("id"); if (!isEqual(newIds, oldIds)) { - const opts = {transacting: trx} - const cs = post.communities() + const opts = { transacting: trx }; + const cs = post.communities(); return Promise.join( - Promise.map(difference(newIds, oldIds), id => cs.attach(id, opts)), - Promise.map(difference(oldIds, newIds), id => cs.detach(id, opts)) - ) + Promise.map(difference(newIds, oldIds), (id) => cs.attach(id, opts)), + Promise.map(difference(oldIds, newIds), (id) => cs.detach(id, opts)) + ); } } -export async function updateFollowers (post, transacting) { - const followerIds = await post.followers().fetch().then(f => f.pluck('id')) - const newMentionedIds = RichText.getUserMentions(post.get('description')) - .filter(id => !followerIds.includes(id)) +export async function updateFollowers(post, transacting) { + const followerIds = await post + .followers() + .fetch() + .then((f) => f.pluck("id")); + const newMentionedIds = RichText.getUserMentions( + post.get("description") + ).filter((id) => !followerIds.includes(id)); - return post.addFollowers(newMentionedIds, {transacting}) - .then(follows => { - const newFollowerIds = compact(follows).map(f => f.get('user_id')) + return post.addFollowers(newMentionedIds, { transacting }).then((follows) => { + const newFollowerIds = compact(follows).map((f) => f.get("user_id")); // this check removes any ids that don't correspond to valid users, which // can happen if the post mentioned a user and then that user was deleted - const validMentionedIds = newMentionedIds.filter(id => - newFollowerIds.includes(id)) + const validMentionedIds = newMentionedIds.filter((id) => + newFollowerIds.includes(id) + ); - const reasons = validMentionedIds.map(id => ({ + const reasons = validMentionedIds.map((id) => ({ reader_id: id, post_id: post.id, - actor_id: post.get('user_id'), - reason: 'mention' - })) - return Activity.saveForReasons(reasons, transacting) - }) + actor_id: post.get("user_id"), + reason: "mention", + })); + return Activity.saveForReasons(reasons, transacting); + }); } -export function updateNetworkMemberships (post, transacting) { - const opts = {transacting} +export function updateNetworkMemberships(post, transacting) { + const opts = { transacting }; - return post.load(['communities', 'networks'], opts) - .then(() => { - const newIds = compact(uniq(post.relations.communities.map(c => Number(c.get('network_id'))))).sort() - const oldIds = post.relations.networks.pluck('id').sort() + return post.load(["communities", "networks"], opts).then(() => { + const newIds = compact( + uniq(post.relations.communities.map((c) => Number(c.get("network_id")))) + ).sort(); + const oldIds = post.relations.networks.pluck("id").sort(); if (!isEqual(newIds, oldIds)) { - const ns = post.networks() + const ns = post.networks(); return Promise.join( - Promise.map(difference(newIds, oldIds), id => ns.attach(id, opts)), - Promise.map(difference(oldIds, newIds), id => ns.detach(id, opts)) - ) + Promise.map(difference(newIds, oldIds), (id) => ns.attach(id, opts)), + Promise.map(difference(oldIds, newIds), (id) => ns.detach(id, opts)) + ); } - }) + }); } diff --git a/api/models/post/validatePostData.js b/api/models/post/validatePostData.js index 69be945d1..eacaca3c1 100644 --- a/api/models/post/validatePostData.js +++ b/api/models/post/validatePostData.js @@ -1,23 +1,33 @@ -import { includes, isEmpty, trim } from 'lodash' +import { includes, isEmpty, trim } from "lodash"; -export default function validatePostData (userId, data) { +export default function validatePostData(userId, data) { if (!trim(data.name)) { - throw new Error('title can\'t be blank') + throw new Error("title can't be blank"); } - const allowedTypes = [Post.Type.REQUEST, Post.Type.OFFER, Post.Type.DISCUSSION, Post.Type.PROJECT, Post.Type.EVENT, Post.Type.RESOURCE] + const allowedTypes = [ + Post.Type.REQUEST, + Post.Type.OFFER, + Post.Type.DISCUSSION, + Post.Type.PROJECT, + Post.Type.EVENT, + Post.Type.RESOURCE, + ]; if (data.type && !includes(allowedTypes, data.type)) { - throw new Error('not a valid type') + throw new Error("not a valid type"); } if (isEmpty(data.community_ids)) { - throw new Error('no communities specified') + throw new Error("no communities specified"); } if (data.topicNames && data.topicNames.length > 3) { - throw new Error('too many topics in post, maximum 3') + throw new Error("too many topics in post, maximum 3"); } - - return Group.allHaveMember(data.community_ids, userId, Community) - .then(ok => ok ? Promise.resolve() : Promise.reject(new Error('unable to post to all those communities'))) + + return Group.allHaveMember(data.community_ids, userId, Community).then((ok) => + ok + ? Promise.resolve() + : Promise.reject(new Error("unable to post to all those communities")) + ); } diff --git a/api/models/post/validatePostData.test.js b/api/models/post/validatePostData.test.js index 7614e9352..8ad47c047 100644 --- a/api/models/post/validatePostData.test.js +++ b/api/models/post/validatePostData.test.js @@ -1,66 +1,77 @@ -import validatePostData from './validatePostData' +import validatePostData from "./validatePostData"; -describe('validatePostData', () => { - var user, inCommunity, notInCommunity +describe("validatePostData", () => { + let user, inCommunity, notInCommunity; before(function () { - inCommunity = new Community({slug: 'foo', name: 'Foo'}) - notInCommunity = new Community({slug: 'bar', name: 'Bar'}) - user = new User({name: 'Cat', email: 'a@b.c'}) + inCommunity = new Community({ slug: "foo", name: "Foo" }); + notInCommunity = new Community({ slug: "bar", name: "Bar" }); + user = new User({ name: "Cat", email: "a@b.c" }); return Promise.join( inCommunity.save(), notInCommunity.save(), user.save() ).then(function () { - return user.joinCommunity(inCommunity) - }) - }) + return user.joinCommunity(inCommunity); + }); + }); - it('fails if no name is provided', () => { - const fn = () => validatePostData(null, {}) - expect(fn).to.throw(/title can't be blank/) - }) + it("fails if no name is provided", () => { + const fn = () => validatePostData(null, {}); + expect(fn).to.throw(/title can't be blank/); + }); - it('fails if an invalid type is provided', () => { - const fn = () => validatePostData(null, {name: 't', type: 'thread'}) - expect(fn).to.throw(/not a valid type/) - }) + it("fails if an invalid type is provided", () => { + const fn = () => validatePostData(null, { name: "t", type: "thread" }); + expect(fn).to.throw(/not a valid type/); + }); - it('fails if no community_ids are provided', () => { - const fn = () => validatePostData(null, {name: 't'}) - expect(fn).to.throw(/no communities specified/) - }) + it("fails if no community_ids are provided", () => { + const fn = () => validatePostData(null, { name: "t" }); + expect(fn).to.throw(/no communities specified/); + }); - it('fails if there is a community_id for a community user is not a member of', () => { - const data = {name: 't', community_ids: [inCommunity.id, notInCommunity.id]} - return validatePostData(user.id, data) - .catch(function (e) { - expect(e.message).to.match(/unable to post to all those communities/) - }) - }) + it("fails if there is a community_id for a community user is not a member of", () => { + const data = { + name: "t", + community_ids: [inCommunity.id, notInCommunity.id], + }; + return validatePostData(user.id, data).catch(function (e) { + expect(e.message).to.match(/unable to post to all those communities/); + }); + }); - it('fails if a blank name is provided', () => { - const fn = () => validatePostData(null, {name: ' ', community_ids: [inCommunity.id]}) - expect(fn).to.throw(/title can't be blank/) - }) + it("fails if a blank name is provided", () => { + const fn = () => + validatePostData(null, { name: " ", community_ids: [inCommunity.id] }); + expect(fn).to.throw(/title can't be blank/); + }); - it('fails if there are more than 3 topicNames', () => { - const fn = () => validatePostData(null, { - name: 't', - community_ids: [inCommunity.id], - topicNames: ['la', 'ra', 'bar', 'far']}) - expect(fn).to.throw(/too many topics in post, maximum 3/) - }) + it("fails if there are more than 3 topicNames", () => { + const fn = () => + validatePostData(null, { + name: "t", + community_ids: [inCommunity.id], + topicNames: ["la", "ra", "bar", "far"], + }); + expect(fn).to.throw(/too many topics in post, maximum 3/); + }); - it('continues the promise chain if name is provided and user is member of communities', () => { - const data = {name: 't', community_ids: [inCommunity.id]} - return validatePostData(user.id, data) - .catch(() => expect.fail('should resolve')) - }) + it("continues the promise chain if name is provided and user is member of communities", () => { + const data = { name: "t", community_ids: [inCommunity.id] }; + return validatePostData(user.id, data).catch(() => + expect.fail("should resolve") + ); + }); - it('continues the promise chain if valid type is provided', () => { - const data = {name: 't', type: Post.Type.PROJECT, community_ids: [inCommunity.id]} - return validatePostData(user.id, data) - .catch(() => expect.fail('should resolve')) - }) -}) + it("continues the promise chain if valid type is provided", () => { + const data = { + name: "t", + type: Post.Type.PROJECT, + community_ids: [inCommunity.id], + }; + return validatePostData(user.id, data).catch(() => + expect.fail("should resolve") + ); + }); +}); diff --git a/api/models/project/mixin.js b/api/models/project/mixin.js index 46be9f1c4..4240736a3 100644 --- a/api/models/project/mixin.js +++ b/api/models/project/mixin.js @@ -1,62 +1,74 @@ -import { uniq } from 'lodash/fp' -import { isProjectMember } from '../group/queryUtils' +import { uniq } from "lodash/fp"; +import { isProjectMember } from "../group/queryUtils"; export default { - isProject () { - return this.get('type') === Post.Type.PROJECT + isProject() { + return this.get("type") === Post.Type.PROJECT; }, members: function () { - return this.groupMembers(q => isProjectMember(q)) + return this.groupMembers((q) => isProjectMember(q)); }, addProjectMembers: async function (usersOrIds, opts) { // need to fetchId for ProjectRole - const projectRole = await this.getOrCreateMemberProjectRole() - return this.addGroupMembers(usersOrIds, { - project_role_id: projectRole.id, - settings: {following: true} - }, opts) + const projectRole = await this.getOrCreateMemberProjectRole(); + return this.addGroupMembers( + usersOrIds, + { + project_role_id: projectRole.id, + settings: { following: true }, + }, + opts + ); }, removeProjectMembers: async function (usersOrIds, opts) { - return this.updateGroupMembers(usersOrIds, { - project_role_id: null, - settings: {following: false} - }, opts) + return this.updateGroupMembers( + usersOrIds, + { + project_role_id: null, + settings: { following: false }, + }, + opts + ); }, updateProjectMembers: async function (userIds, opts) { - const members = await this.members().fetch() - await this.removeGroupMembers(members, opts) - const memberRole = await this.getOrCreateMemberProjectRole(opts) - return Promise.map(uniq(userIds), async id => { - var gm = await GroupMembership.forPair(id, this, {includeInactive: true}).fetch(opts) + const members = await this.members().fetch(); + await this.removeGroupMembers(members, opts); + const memberRole = await this.getOrCreateMemberProjectRole(opts); + return Promise.map(uniq(userIds), async (id) => { + let gm = await GroupMembership.forPair(id, this, { + includeInactive: true, + }).fetch(opts); if (!gm) { - await this.addGroupMembers([id], {}, opts) - gm = await GroupMembership.forPair(id, this).fetch(opts) + await this.addGroupMembers([id], {}, opts); + gm = await GroupMembership.forPair(id, this).fetch(opts); } - gm.addSetting({following: true}) - return gm.save({ - project_role_id: memberRole.id, - active: true - }, opts) - }) + gm.addSetting({ following: true }); + return gm.save( + { + project_role_id: memberRole.id, + active: true, + }, + opts + ); + }); }, getOrCreateMemberProjectRole: async function (opts) { const memberRole = await ProjectRole.where({ name: ProjectRole.MEMBER_ROLE_NAME, - post_id: this.id - }).fetch(opts) + post_id: this.id, + }).fetch(opts); if (memberRole) { - return memberRole + return memberRole; } else { return ProjectRole.forge({ post_id: this.id, - name: ProjectRole.MEMBER_ROLE_NAME - }) - .save({}, opts) + name: ProjectRole.MEMBER_ROLE_NAME, + }).save({}, opts); } - } -} + }, +}; diff --git a/api/models/project/mixin.test.js b/api/models/project/mixin.test.js index 205841e89..c8a7ae272 100644 --- a/api/models/project/mixin.test.js +++ b/api/models/project/mixin.test.js @@ -1,63 +1,65 @@ -import root from 'root-path' -const setup = require(root('test/setup')) -const factories = require(root('test/setup/factories')) +import root from "root-path"; +const setup = require(root("test/setup")); +const factories = require(root("test/setup/factories")); -describe('Project Mixin', () => { - describe('addProjectMembers', () => { - var project, user +describe("Project Mixin", () => { + describe("addProjectMembers", () => { + let project, user; before(async function () { - user = factories.user() - await user.save() - project = factories.post({type: Post.Type.PROJECT}) - await project.save() - }) - - it('removes a user from a project', async () => { - await project.addProjectMembers([user.id]) - const members = await project.members().fetch() - expect(members.length).to.equal(1) - expect(members.first().id).to.equal(user.id) - }) - }) - - describe('removeProjectMembers', () => { - var user, project - + user = factories.user(); + await user.save(); + project = factories.post({ type: Post.Type.PROJECT }); + await project.save(); + }); + + it("removes a user from a project", async () => { + await project.addProjectMembers([user.id]); + const members = await project.members().fetch(); + expect(members.length).to.equal(1); + expect(members.first().id).to.equal(user.id); + }); + }); + + describe("removeProjectMembers", () => { + let user, project; + before(async function () { - user = factories.user() - await user.save() - project = factories.post({type: Post.Type.PROJECT}) - await project.save() - await project.addProjectMembers([user.id]) - }) - - it('removes a user from a project', async () => { - await project.removeProjectMembers([user.id]) - const members = await project.members().fetch() - expect(members.length).to.equal(0) - }) - }) - - describe('updateProjectMembers', () => { - var user1, user2, user3, project - + user = factories.user(); + await user.save(); + project = factories.post({ type: Post.Type.PROJECT }); + await project.save(); + await project.addProjectMembers([user.id]); + }); + + it("removes a user from a project", async () => { + await project.removeProjectMembers([user.id]); + const members = await project.members().fetch(); + expect(members.length).to.equal(0); + }); + }); + + describe("updateProjectMembers", () => { + let user1, user2, user3, project; + before(async function () { - user1 = factories.user() - await user1.save() - user2 = factories.user() - await user2.save() - user3 = factories.user() - await user3.save() - project = factories.post({type: Post.Type.PROJECT}) - await project.save() - await project.addProjectMembers([user1.id, user2.id]) - }) - - it('updates members of a project', async () => { - await project.updateProjectMembers([user2.id, user3.id]) - const members = await project.members().fetch() - expect(members.length).to.equal(2) - expect(members.map('id').sort()).to.deep.equal([user2.id, user3.id].sort()) - }) - }) -}) \ No newline at end of file + user1 = factories.user(); + await user1.save(); + user2 = factories.user(); + await user2.save(); + user3 = factories.user(); + await user3.save(); + project = factories.post({ type: Post.Type.PROJECT }); + await project.save(); + await project.addProjectMembers([user1.id, user2.id]); + }); + + it("updates members of a project", async () => { + await project.updateProjectMembers([user2.id, user3.id]); + const members = await project.members().fetch(); + expect(members.length).to.equal(2); + expect(members.map("id").sort()).to.deep.equal( + [user2.id, user3.id].sort() + ); + }); + }); +}); diff --git a/api/models/user/HasGroupMemberships.js b/api/models/user/HasGroupMemberships.js index d00ce0a54..a16a3c03b 100644 --- a/api/models/user/HasGroupMemberships.js +++ b/api/models/user/HasGroupMemberships.js @@ -2,18 +2,18 @@ export default { // note that this `where` argument is applied to the subquery; // to add clauses to the outer query, just use `.query` on the // result of this method - queryByGroupMembership (model, { where } = {}) { + queryByGroupMembership(model, { where } = {}) { let subq = this.groupMembershipsForModel(model) - .query() - .join('groups', 'groups.id', 'group_memberships.group_id') - .select('group_data_id') + .query() + .join("groups", "groups.id", "group_memberships.group_id") + .select("group_data_id"); - if (where) subq = subq.where(where) + if (where) subq = subq.where(where); - return model.collection().query(q => q.where('id', 'in', subq)) + return model.collection().query((q) => q.where("id", "in", subq)); }, - groupMembershipsForModel (model) { - return GroupMembership.forMember(this.id, model) - } -} + groupMembershipsForModel(model) { + return GroupMembership.forMember(this.id, model); + }, +}; diff --git a/api/models/util/queryFilters.js b/api/models/util/queryFilters.js index 3b70c40b8..270c39163 100644 --- a/api/models/util/queryFilters.js +++ b/api/models/util/queryFilters.js @@ -1,7 +1,7 @@ -export function myCommunityIds (userId) { - return Group.pluckIdsForMember(userId, Community) +export function myCommunityIds(userId) { + return Group.pluckIdsForMember(userId, Community); } -export function myNetworkCommunityIds (userId) { - return Network.activeCommunityIds(userId, true) +export function myNetworkCommunityIds(userId) { + return Network.activeCommunityIds(userId, true); } diff --git a/api/models/util/queryFilters.test.helpers.js b/api/models/util/queryFilters.test.helpers.js index 58596d161..54dae26bb 100644 --- a/api/models/util/queryFilters.test.helpers.js +++ b/api/models/util/queryFilters.test.helpers.js @@ -1,25 +1,25 @@ -export function myCommunityIdsSqlFragment (userId) { +export function myCommunityIdsSqlFragment(userId) { return `(select "group_data_id" from "group_memberships" inner join "groups" on "groups"."id" = "group_memberships"."group_id" where "group_memberships"."group_data_type" = ${Group.DataType.COMMUNITY} and "group_memberships"."user_id" = '${userId}' and "group_memberships"."active" = true - and "groups"."active" = true)` + and "groups"."active" = true)`; } -export function myNetworkCommunityIdsSqlFragment (userId, opts = {}) { +export function myNetworkCommunityIdsSqlFragment(userId, opts = {}) { const str = `select "id" from "communities" where ("network_id" in ( select distinct "network_id" from "communities" where "id" in ${myCommunityIdsSqlFragment(userId)} and network_id is not null) and "communities"."hidden" = false - )` - return opts.parens === false ? str : `(${str})` + )`; + return opts.parens === false ? str : `(${str})`; } -export function blockedUserSqlFragment (userId) { +export function blockedUserSqlFragment(userId) { return `"users"."id" not in ( SELECT user_id FROM blocked_users @@ -28,5 +28,5 @@ export function blockedUserSqlFragment (userId) { SELECT blocked_user_id FROM blocked_users WHERE user_id = '${userId}' - )` -} \ No newline at end of file + )`; +} diff --git a/api/models/util/queryFilters.test.js b/api/models/util/queryFilters.test.js index d7497f3b6..206d2c2a5 100644 --- a/api/models/util/queryFilters.test.js +++ b/api/models/util/queryFilters.test.js @@ -1,33 +1,44 @@ -import { myCommunityIds, myNetworkCommunityIds } from './queryFilters' -import { expectEqualQuery } from '../../../test/setup/helpers' +import { myCommunityIds, myNetworkCommunityIds } from "./queryFilters"; +import { expectEqualQuery } from "../../../test/setup/helpers"; import { - myCommunityIdsSqlFragment, myNetworkCommunityIdsSqlFragment -} from './queryFilters.test.helpers' + myCommunityIdsSqlFragment, + myNetworkCommunityIdsSqlFragment, +} from "./queryFilters.test.helpers"; -describe('myCommunityIds', () => { - it('produces the expected query clause', () => { - const query = Post.query(q => { - q.join('communities_posts', 'posts.id', 'communities_posts.community_id') - q.where('communities_posts.community_id', 'in', myCommunityIds('42')) - }) - expectEqualQuery(query, `select * from "posts" +describe("myCommunityIds", () => { + it("produces the expected query clause", () => { + const query = Post.query((q) => { + q.join("communities_posts", "posts.id", "communities_posts.community_id"); + q.where("communities_posts.community_id", "in", myCommunityIds("42")); + }); + expectEqualQuery( + query, + `select * from "posts" inner join "communities_posts" on "posts"."id" = "communities_posts"."community_id" where "communities_posts"."community_id" in - ${myCommunityIdsSqlFragment('42')}`) - }) -}) + ${myCommunityIdsSqlFragment("42")}` + ); + }); +}); -describe('myNetworkCommunityIds', () => { - it('produces the expected query clause', () => { - const query = Post.query(q => { - q.join('communities_posts', 'posts.id', 'communities_posts.community_id') - q.where('communities_posts.community_id', 'in', myNetworkCommunityIds('42')) - }) - expectEqualQuery(query, `select * from "posts" +describe("myNetworkCommunityIds", () => { + it("produces the expected query clause", () => { + const query = Post.query((q) => { + q.join("communities_posts", "posts.id", "communities_posts.community_id"); + q.where( + "communities_posts.community_id", + "in", + myNetworkCommunityIds("42") + ); + }); + expectEqualQuery( + query, + `select * from "posts" inner join "communities_posts" on "posts"."id" = "communities_posts"."community_id" where "communities_posts"."community_id" in - ${myNetworkCommunityIdsSqlFragment('42')}`) - }) -}) + ${myNetworkCommunityIdsSqlFragment("42")}` + ); + }); +}); diff --git a/api/models/util/relations.js b/api/models/util/relations.js index e27cce5f1..81e609d55 100644 --- a/api/models/util/relations.js +++ b/api/models/util/relations.js @@ -1,4 +1,4 @@ -import { camelCase, mapKeys } from 'lodash/fp' +import { camelCase, mapKeys } from "lodash/fp"; // Pick some fields from a `belongsTo` relation. Takes a relation and an array // of field names. Snake case will be rewritten to camelcase in output property @@ -15,20 +15,22 @@ import { camelCase, mapKeys } from 'lodash/fp' // { createdAt: '...', id: '1', 'title': 'Aardvarks' } export const refineOne = (relation, fields, rewrite) => { // A relation might simply not be set, so not a programmer error: - if (!relation) return null + if (!relation) return null; // Lack of field names is a problem though... - if (!Array.isArray(fields)) throw new Error('Expected an array of field names.') + if (!Array.isArray(fields)) + throw new Error("Expected an array of field names."); return mapKeys( - k => rewrite && rewrite[k] ? rewrite[k] : camelCase(k), + (k) => (rewrite && rewrite[k] ? rewrite[k] : camelCase(k)), relation.pick(fields) - ) -} + ); +}; // Pick some fields from a `belongsToMany` relation export const refineMany = (relation, fields, rewrite) => { - if (!relation) return [] - if (!Array.isArray(fields)) throw new Error('Expected an array of field names.') - return relation.map(entity => refineOne(entity, fields, rewrite)) -} + if (!relation) return []; + if (!Array.isArray(fields)) + throw new Error("Expected an array of field names."); + return relation.map((entity) => refineOne(entity, fields, rewrite)); +}; diff --git a/api/models/util/relations.test.js b/api/models/util/relations.test.js index 671f9f810..a512bfeeb 100644 --- a/api/models/util/relations.test.js +++ b/api/models/util/relations.test.js @@ -1,47 +1,45 @@ -import rootPath from 'root-path' -const factories = require(rootPath('test/setup/factories')) -import { refineMany, refineOne } from './relations' +import rootPath from "root-path"; +import { refineMany, refineOne } from "./relations"; +const factories = require(rootPath("test/setup/factories")); -describe('refineOne', () => { - let post +describe("refineOne", () => { + let post; beforeEach(() => { post = factories.post({ - floofle: 'flarfle', + floofle: "flarfle", id: 1, - i_am_snake_case: 'sssssssss', - morganflorfle: 'worble', - slumptifargle: 99 - }) - }) + i_am_snake_case: "sssssssss", + morganflorfle: "worble", + slumptifargle: 99, + }); + }); - it('returns the specified subset of fields', () => { - const expected = { floofle: 'flarfle' } - const actual = refineOne(post, [ 'floofle' ]) - expect(actual).to.deep.equal(expected) - }) + it("returns the specified subset of fields", () => { + const expected = { floofle: "flarfle" }; + const actual = refineOne(post, ["floofle"]); + expect(actual).to.deep.equal(expected); + }); - it('rewrites field names', () => { + it("rewrites field names", () => { const expected = { - aardvark: 'flarfle', + aardvark: "flarfle", id: 1, slumptifargle: 99, - } - const actual = refineOne( - post, - [ 'floofle', 'id', 'slumptifargle' ], - { floofle: 'aardvark' } - ) - expect(actual).to.deep.equal(expected) - }) + }; + const actual = refineOne(post, ["floofle", "id", "slumptifargle"], { + floofle: "aardvark", + }); + expect(actual).to.deep.equal(expected); + }); - it('converts snake_case to camelCase', () => { - const expected = { iAmSnakeCase: 'sssssssss' } - const actual = refineOne(post, [ 'i_am_snake_case' ]) - expect(actual).to.deep.equal(expected) - }) + it("converts snake_case to camelCase", () => { + const expected = { iAmSnakeCase: "sssssssss" }; + const actual = refineOne(post, ["i_am_snake_case"]); + expect(actual).to.deep.equal(expected); + }); - it('throws if fields is not an array', () => { - expect(() => refineOne(post)).to.throw() - }) -}) + it("throws if fields is not an array", () => { + expect(() => refineOne(post)).to.throw(); + }); +}); diff --git a/api/policies/accessTokenAuth.js b/api/policies/accessTokenAuth.js index faef9e920..ec7256800 100644 --- a/api/policies/accessTokenAuth.js +++ b/api/policies/accessTokenAuth.js @@ -1,5 +1,5 @@ -module.exports = function(req, res, next) { +module.exports = function (req, res, next) { AccessTokenAuth.checkAndSetAuthenticated(req) - .then(() => next()) - .catch(err => res.serverError(err)) -} + .then(() => next()) + .catch((err) => res.serverError(err)); +}; diff --git a/api/policies/checkAndDecodeToken.js b/api/policies/checkAndDecodeToken.js index 7913fc453..2d25950a5 100644 --- a/api/policies/checkAndDecodeToken.js +++ b/api/policies/checkAndDecodeToken.js @@ -1,9 +1,9 @@ -module.exports = function checkAndDecodeToken (req, res, next) { - const token = req.param('token') +module.exports = function checkAndDecodeToken(req, res, next) { + const token = req.param("token"); try { - res.locals.tokenData = Email.decodeFormToken(token) - next() + res.locals.tokenData = Email.decodeFormToken(token); + next(); } catch (e) { - res.badRequest(new Error('Invalid token: ' + token)) + res.badRequest(new Error("Invalid token: " + token)); } -} +}; diff --git a/api/policies/checkAndSetMembership.js b/api/policies/checkAndSetMembership.js index 6fbdcf435..d973e3053 100644 --- a/api/policies/checkAndSetMembership.js +++ b/api/policies/checkAndSetMembership.js @@ -1,28 +1,33 @@ -module.exports = async function checkAndSetMembership (req, res, next) { - if (Admin.isSignedIn(req)) return next() - if (res.locals.publicAccessAllowed) return next() +module.exports = async function checkAndSetMembership(req, res, next) { + if (Admin.isSignedIn(req)) return next(); + if (res.locals.publicAccessAllowed) return next(); - const communityId = req.param('communityId') + const communityId = req.param("communityId"); // if no community id is specified, continue. // this is for routes that can be limited to a specific community // or performed across all communities a user can access, e.g. search and // getting a user's list of followed tags. - if (!communityId || communityId === 'all') return next() + if (!communityId || communityId === "all") return next(); - const community = await Community.findActive(communityId) - if (!community) return res.notFound() - res.locals.community = community + const community = await Community.findActive(communityId); + if (!community) return res.notFound(); + res.locals.community = community; - const { userId } = req.session - const membership = await GroupMembership.forPair(userId, community).fetch() - if (membership) return next() + const { userId } = req.session; + const membership = await GroupMembership.forPair(userId, community).fetch(); + if (membership) return next(); - if (community.get('network_id') && req.session.userId) { - const inNetwork = await Network.containsUser(community.get('network_id'), req.session.userId) - if (inNetwork) return next() + if (community.get("network_id") && req.session.userId) { + const inNetwork = await Network.containsUser( + community.get("network_id"), + req.session.userId + ); + if (inNetwork) return next(); } - sails.log.debug(`policy: checkAndSetMembership: fail. user ${req.session.userId}, community ${community.id}`) - res.forbidden() -} + sails.log.debug( + `policy: checkAndSetMembership: fail. user ${req.session.userId}, community ${community.id}` + ); + res.forbidden(); +}; diff --git a/api/policies/checkAndSetPost.js b/api/policies/checkAndSetPost.js index 30cd7c74a..bc83fbb40 100644 --- a/api/policies/checkAndSetPost.js +++ b/api/policies/checkAndSetPost.js @@ -1,34 +1,36 @@ -module.exports = function checkAndSetPost (req, res, next) { - var postId = req.param('postId') +module.exports = function checkAndSetPost(req, res, next) { + const postId = req.param("postId"); - var fail = function (log, responseType, err) { - sails.log.debug(`policy: checkAndSetPost: ${log}`) - res[responseType || 'forbidden'](err) - } + const fail = function (log, responseType, err) { + sails.log.debug(`policy: checkAndSetPost: ${log}`); + res[responseType || "forbidden"](err); + }; if (isNaN(Number(postId))) { - return fail(`post id "${postId}" is invalid`, 'badRequest') + return fail(`post id "${postId}" is invalid`, "badRequest"); } - return Post.find(postId, {withRelated: 'communities'}) - .then(post => { - if (!post) return fail(`post ${postId} not found`, 'notFound') + return Post.find(postId, { withRelated: "communities" }) + .then((post) => { + if (!post) return fail(`post ${postId} not found`, "notFound"); - res.locals.post = post + res.locals.post = post; - var ok = Admin.isSignedIn(req) || - (res.locals.publicAccessAllowed && post.isPublic()) + const ok = + Admin.isSignedIn(req) || + (res.locals.publicAccessAllowed && post.isPublic()); - return (ok ? Promise.resolve(true) - : Post.isVisibleToUser(post.id, req.session.userId)) - .then(allowed => { - if (allowed) { - next() - } else { - fail('not allowed') - } - return null + return (ok + ? Promise.resolve(true) + : Post.isVisibleToUser(post.id, req.session.userId) + ).then((allowed) => { + if (allowed) { + next(); + } else { + fail("not allowed"); + } + return null; + }); }) - }) - .catch(err => fail(err.message, 'serverError', err)) -} + .catch((err) => fail(err.message, "serverError", err)); +}; diff --git a/api/policies/isAdmin.js b/api/policies/isAdmin.js index 9fcae390c..b7dcde567 100644 --- a/api/policies/isAdmin.js +++ b/api/policies/isAdmin.js @@ -1,6 +1,6 @@ -module.exports = function(req, res, next) { +module.exports = function (req, res, next) { if (Admin.isSignedIn(req)) { - sails.log.debug('isAdmin: ' + req.user.email); + sails.log.debug("isAdmin: " + req.user.email); next(); } else { if (res.forbidden) { @@ -10,7 +10,7 @@ module.exports = function(req, res, next) { // (see http.js), it needs to fall back to the standard API // for http.ServerResponse res.statusCode = 403; - res.end('Forbidden'); + res.end("Forbidden"); } } }; diff --git a/api/policies/isSocket.js b/api/policies/isSocket.js index 5251213ed..e7443502e 100644 --- a/api/policies/isSocket.js +++ b/api/policies/isSocket.js @@ -2,5 +2,5 @@ module.exports = function (req, res, next) { if (!req.isSocket) { return res.badRequest(); } - next() -} + next(); +}; diff --git a/api/policies/sessionAuth.js b/api/policies/sessionAuth.js index 832579708..f986d2870 100644 --- a/api/policies/sessionAuth.js +++ b/api/policies/sessionAuth.js @@ -1,21 +1,21 @@ -var fail = function (res) { - sails.log.debug('policy: sessionAuth: fail') +const fail = function (res) { + sails.log.debug("policy: sessionAuth: fail"); // sending Unauthorized instead of Forbidden so that this triggers // http-auth-interceptor in the Angular app - res.status(401) - res.send('Unauthorized') -} + res.status(401); + res.send("Unauthorized"); +}; module.exports = function (req, res, next) { if (UserSession.isLoggedIn(req)) { - return next() + return next(); } if (res.locals.publicAccessAllowed) { - sails.log.debug('policy: sessionAuth: publicAccessAllowed') - return next() + sails.log.debug("policy: sessionAuth: publicAccessAllowed"); + return next(); } - fail(res) -} + fail(res); +}; diff --git a/api/responses/badRequest.js b/api/responses/badRequest.js index d27a3046a..2c88302b0 100644 --- a/api/responses/badRequest.js +++ b/api/responses/badRequest.js @@ -1,4 +1,4 @@ -import error from './error' +import error from "./error"; /** * 400 (Bad Request) Handler @@ -17,4 +17,4 @@ import error from './error' * ``` */ -module.exports = error({statusCode: 400, statusText: 'Bad Request'}) +module.exports = error({ statusCode: 400, statusText: "Bad Request" }); diff --git a/api/responses/error.js b/api/responses/error.js index a02eb1d04..7966f4c98 100644 --- a/api/responses/error.js +++ b/api/responses/error.js @@ -1,64 +1,74 @@ -import rollbar from '../../lib/rollbar' +import rollbar from "../../lib/rollbar"; -module.exports = ({ statusCode, statusText, logData }) => function (data, options) { - // Get access to `req`, `res`, & `sails` - var req = this.req - var res = this.res - var sails = req._sails +module.exports = ({ statusCode, statusText, logData }) => + function (data, options) { + // Get access to `req`, `res`, & `sails` + const req = this.req; + const res = this.res; + const sails = req._sails; - // Set status code - res.status(statusCode) + // Set status code + res.status(statusCode); - // Log error to console - if (data !== undefined) { - sails.log.verbose(`Sending ${statusCode} ("${statusText}") response: \n`, data) - } else { - sails.log.verbose(`Sending ${statusCode} ("${statusText}") response`) - } + // Log error to console + if (data !== undefined) { + sails.log.verbose( + `Sending ${statusCode} ("${statusText}") response: \n`, + data + ); + } else { + sails.log.verbose(`Sending ${statusCode} ("${statusText}") response`); + } - // Only include errors in response if application environment - // is not set to 'production'. In production, we shouldn't - // send back any identifying information about errors. - if (sails.config.environment === 'production') { - if (statusCode === 500) rollbar.error(data, req) - data = undefined - } else if (logData) { - sails.log.error(data.stack.split('\n').slice(0, 8).join('\n')) - } + // Only include errors in response if application environment + // is not set to 'production'. In production, we shouldn't + // send back any identifying information about errors. + if (sails.config.environment === "production") { + if (statusCode === 500) rollbar.error(data, req); + data = undefined; + } else if (logData) { + sails.log.error(data.stack.split("\n").slice(0, 8).join("\n")); + } - // If the user-agent wants JSON, always respond with JSON - if (req.wantsJSON) { - return res.jsonx(data) - } + // If the user-agent wants JSON, always respond with JSON + if (req.wantsJSON) { + return res.jsonx(data); + } - // If second argument is a string, we take that to mean it refers to a view. - // If it was omitted, use an empty object (`{}`) - options = (typeof options === 'string') ? { view: options } : options || {} + // If second argument is a string, we take that to mean it refers to a view. + // If it was omitted, use an empty object (`{}`) + options = typeof options === "string" ? { view: options } : options || {}; - // If a view was provided in options, serve it. - // Otherwise try to guess an appropriate view, or if that doesn't - // work, just send JSON. - if (options.view) { - return res.view(options.view, {data}) - } else { - // If no second argument provided, try to serve the default view, - // but fall back to sending JSON(P) if any errors occur. - return res.view(statusCode.toString(), {data}, function (err, html) { - // If a view error occured, fall back to JSON(P). - if (err) { - // - // Additionally: - // • If the view was missing, ignore the error but provide a verbose log. - if (err.code === 'E_VIEW_FAILED') { - sails.log.verbose(`res.error(${statusCode}) :: Could not locate view for error page (sending JSON instead). Details: `, err) - } else { - // Otherwise, if this was a more serious error, log to the console with the details. - sails.log.warn(`res.error(${statusCode}) :: When attempting to render error page view, an error occured (sending JSON instead). Details: `, err) + // If a view was provided in options, serve it. + // Otherwise try to guess an appropriate view, or if that doesn't + // work, just send JSON. + if (options.view) { + return res.view(options.view, { data }); + } else { + // If no second argument provided, try to serve the default view, + // but fall back to sending JSON(P) if any errors occur. + return res.view(statusCode.toString(), { data }, function (err, html) { + // If a view error occured, fall back to JSON(P). + if (err) { + // + // Additionally: + // • If the view was missing, ignore the error but provide a verbose log. + if (err.code === "E_VIEW_FAILED") { + sails.log.verbose( + `res.error(${statusCode}) :: Could not locate view for error page (sending JSON instead). Details: `, + err + ); + } else { + // Otherwise, if this was a more serious error, log to the console with the details. + sails.log.warn( + `res.error(${statusCode}) :: When attempting to render error page view, an error occured (sending JSON instead). Details: `, + err + ); + } + return res.jsonx(data); } - return res.jsonx(data) - } - return res.send(html) - }) - } -} + return res.send(html); + }); + } + }; diff --git a/api/responses/forbidden.js b/api/responses/forbidden.js index 23481b357..61b133a7a 100644 --- a/api/responses/forbidden.js +++ b/api/responses/forbidden.js @@ -1,4 +1,4 @@ -import error from './error' +import error from "./error"; /** * 403 (Forbidden) Handler @@ -14,4 +14,4 @@ import error from './error' * ``` */ -module.exports = error({statusCode: 403, statusText: 'Forbidden'}) +module.exports = error({ statusCode: 403, statusText: "Forbidden" }); diff --git a/api/responses/notFound.js b/api/responses/notFound.js index 65bdbbce7..ab6779f2b 100644 --- a/api/responses/notFound.js +++ b/api/responses/notFound.js @@ -1,4 +1,4 @@ -import error from './error' +import error from "./error"; /** * 404 (Not Found) Handler @@ -19,4 +19,4 @@ import error from './error' * automatically. */ -module.exports = error({statusCode: 404, statusText: 'Not Found'}) +module.exports = error({ statusCode: 404, statusText: "Not Found" }); diff --git a/api/responses/ok.js b/api/responses/ok.js index 4351d2e3c..9e2a764ca 100644 --- a/api/responses/ok.js +++ b/api/responses/ok.js @@ -11,12 +11,11 @@ * - pass string to render specified view */ -module.exports = function sendOK (data, options) { - +module.exports = function sendOK(data, options) { // Get access to `req`, `res`, & `sails` - var req = this.req; - var res = this.res; - var sails = req._sails; + const req = this.req; + const res = this.res; + const sails = req._sails; sails.log.silly('res.ok() :: Sending 200 ("OK") response'); @@ -30,7 +29,7 @@ module.exports = function sendOK (data, options) { // If second argument is a string, we take that to mean it refers to a view. // If it was omitted, use an empty object (`{}`) - options = (typeof options === 'string') ? { view: options } : options || {}; + options = typeof options === "string" ? { view: options } : options || {}; // If a view was provided in options, serve it. // Otherwise try to guess an appropriate view, or if that doesn't @@ -41,8 +40,9 @@ module.exports = function sendOK (data, options) { // If no second argument provided, try to serve the implied view, // but fall back to sending JSON(P) if no view can be inferred. - else return res.guessView({ data: data }, function couldNotGuessView () { - return res.jsonx(data); - }); - + else { + return res.guessView({ data: data }, function couldNotGuessView() { + return res.jsonx(data); + }); + } }; diff --git a/api/responses/serverError.js b/api/responses/serverError.js index 6c58117b9..f2d18df13 100644 --- a/api/responses/serverError.js +++ b/api/responses/serverError.js @@ -1,4 +1,4 @@ -import error from './error' +import error from "./error"; /** * 500 (Server Error) Response @@ -14,4 +14,8 @@ import error from './error' * automatically. */ -module.exports = error({statusCode: 500, statusText: 'Server Error', logData: true}) +module.exports = error({ + statusCode: 500, + statusText: "Server Error", + logData: true, +}); diff --git a/api/services/AccessTokenAuth.js b/api/services/AccessTokenAuth.js index 248afcb72..8e1e9b8ab 100644 --- a/api/services/AccessTokenAuth.js +++ b/api/services/AccessTokenAuth.js @@ -1,24 +1,25 @@ -const crypto = require('crypto') -const Promise = require('bluebird') +const crypto = require("crypto"); +const Promise = require("bluebird"); -var AccessTokenAuth = module.exports = { - - generateToken: function() { - const randomBytes = Promise.promisify(crypto.randomBytes) - return randomBytes(24).then(buffer => buffer.toString('hex')) +const AccessTokenAuth = (module.exports = { + generateToken: function () { + const randomBytes = Promise.promisify(crypto.randomBytes); + return randomBytes(24).then((buffer) => buffer.toString("hex")); }, - checkAndSetAuthenticated: function(req) { - req.body = req.body || {} - const token = req.body.access_token || req.query.access_token || req.headers['x-access-token'] - if (!token) return Promise.resolve() + checkAndSetAuthenticated: function (req) { + req.body = req.body || {}; + const token = + req.body.access_token || + req.query.access_token || + req.headers["x-access-token"]; + if (!token) return Promise.resolve(); return User.query(function (qb) { - qb.leftJoin('linked_account', 'users.id', 'linked_account.user_id') - qb.where('linked_account.provider_user_id', '=', token) - qb.andWhere('linked_account.provider_key', '=', 'token') + qb.leftJoin("linked_account", "users.id", "linked_account.user_id"); + qb.where("linked_account.provider_user_id", "=", token); + qb.andWhere("linked_account.provider_key", "=", "token"); }) - .fetch() - .then(user => !user || UserSession.login(req, user, 'token')) - } - -} + .fetch() + .then((user) => !user || UserSession.login(req, user, "token")); + }, +}); diff --git a/api/services/Admin.js b/api/services/Admin.js index 7e1086ae2..8bab85e2e 100644 --- a/api/services/Admin.js +++ b/api/services/Admin.js @@ -1,5 +1,5 @@ module.exports = { isSignedIn: function (req) { - return req.user && !!(req.user.email || '').match(/@hylo\.com$/) - } -} + return req.user && !!(req.user.email || "").match(/@hylo\.com$/); + }, +}; diff --git a/api/services/Aggregate.js b/api/services/Aggregate.js index 8580d08d5..0eca0fb58 100644 --- a/api/services/Aggregate.js +++ b/api/services/Aggregate.js @@ -1,10 +1,11 @@ -import { pick } from 'lodash' +import { pick } from "lodash"; module.exports = { count: function (relation, options) { - const query = relation.query(qb => qb.count()) - const fn = (query.fetchOne || query.fetch).bind(query) - return fn(pick(options, 'transacting')) - .then(row => Number(row.get('count'))) - } -} + const query = relation.query((qb) => qb.count()); + const fn = (query.fetchOne || query.fetch).bind(query); + return fn(pick(options, "transacting")).then((row) => + Number(row.get("count")) + ); + }, +}; diff --git a/api/services/Analytics.js b/api/services/Analytics.js index 999f4f5c2..d965b2a25 100644 --- a/api/services/Analytics.js +++ b/api/services/Analytics.js @@ -1,47 +1,50 @@ -const sails = require('sails') -const uuid = require('node-uuid') -var instance +const sails = require("sails"); +const uuid = require("node-uuid"); +let instance; -if (process.env.NODE_ENV === 'test') { +if (process.env.NODE_ENV === "test") { instance = { track: function (opts) { - sails.log.verbose('Analytics.track: ' + JSON.stringify(opts)) - } - } + sails.log.verbose("Analytics.track: " + JSON.stringify(opts)); + }, + }; } else { - instance = require('analytics-node')(process.env.SEGMENT_KEY) + instance = require("analytics-node")(process.env.SEGMENT_KEY); } instance.pixelUrl = function (emailName, props) { - var prefix = 'https://api.segment.io/v1/pixel/track?data=' + const prefix = "https://api.segment.io/v1/pixel/track?data="; - var data = { + const data = { writeKey: process.env.SEGMENT_KEY, - event: 'Viewed Email: ' + emailName, - properties: props - } + event: "Viewed Email: " + emailName, + properties: props, + }; if (props.userId) { - data.userId = props.userId + data.userId = props.userId; } else { - data.anonymousId = uuid.v4() + data.anonymousId = uuid.v4(); } - var encodedData = new Buffer(JSON.stringify(data), 'utf8').toString('base64') - return prefix + encodedData -} + const encodedData = new Buffer(JSON.stringify(data), "utf8").toString( + "base64" + ); + return prefix + encodedData; +}; instance.trackSignup = function (userId, req) { - let properties = {platform: 'Web'} - if (req.headers['ios-version']) { - properties.platform = 'ios' - } else if (req.headers['android-version']) { - properties.platform = 'android' + const properties = { platform: "Web" }; + if (req.headers["ios-version"]) { + properties.platform = "ios"; + } else if (req.headers["android-version"]) { + properties.platform = "android"; } this.track({ userId, - event: 'Signup success', - properties}) -} + event: "Signup success", + properties, + }); +}; -module.exports = instance +module.exports = instance; diff --git a/api/services/AssetManagement.js b/api/services/AssetManagement.js index 076497eda..da4e98534 100644 --- a/api/services/AssetManagement.js +++ b/api/services/AssetManagement.js @@ -1,43 +1,53 @@ -import Promise from 'bluebird' -import request from 'request' -import sharp from 'sharp' -import { createS3StorageStream, safeBasename } from '../../lib/uploader/storage' +import Promise from "bluebird"; +import request from "request"; +import sharp from "sharp"; +import { + createS3StorageStream, + safeBasename, +} from "../../lib/uploader/storage"; module.exports = { copyAsset: function (instance, type, attr) { - const sourceUrl = instance.get(attr) - const filename = safeBasename(sourceUrl) - .replace(/((_\d+)?(\.\w{2,4}))?$/, `_${Date.now()}$3`) - - return runPipeline(sourceUrl, filename, type, instance.id) - .then(url => instance.save({[attr]: url}, {patch: true})) + const sourceUrl = instance.get(attr); + const filename = safeBasename(sourceUrl).replace( + /((_\d+)?(\.\w{2,4}))?$/, + `_${Date.now()}$3` + ); + + return runPipeline(sourceUrl, filename, type, instance.id).then((url) => + instance.save({ [attr]: url }, { patch: true }) + ); }, resizeAsset: function (instance, fromAttr, toAttr, settings = {}) { - const { width, height, type, transacting } = settings - const sourceUrl = instance.get(fromAttr) - const filename = safeBasename(sourceUrl) - .replace(/(\.\w{2,4})?$/, `_${width}x${height}$1`) - - return runPipeline(sourceUrl, filename, type, instance.id, stream => - stream.pipe(sharp().resize(width, height))) - .then(url => instance.save({[toAttr]: url}, {patch: true, transacting})) - } -} + const { width, height, type, transacting } = settings; + const sourceUrl = instance.get(fromAttr); + const filename = safeBasename(sourceUrl).replace( + /(\.\w{2,4})?$/, + `_${width}x${height}$1` + ); + + return runPipeline(sourceUrl, filename, type, instance.id, (stream) => + stream.pipe(sharp().resize(width, height)) + ).then((url) => + instance.save({ [toAttr]: url }, { patch: true, transacting }) + ); + }, +}; -function runPipeline (url, filename, type, id, pipeFn) { +function runPipeline(url, filename, type, id, pipeFn) { return new Promise((resolve, reject) => { - sails.log.info('from: ' + url) + sails.log.info("from: " + url); - let stream = request.get({url, encoding: null}) - if (pipeFn) stream = pipeFn(stream) - stream = stream.pipe(createS3StorageStream(type, id, {filename})) + let stream = request.get({ url, encoding: null }); + if (pipeFn) stream = pipeFn(stream); + stream = stream.pipe(createS3StorageStream(type, id, { filename })); - stream.on('finish', () => { - sails.log.info('to: ' + stream.url) - resolve(stream.url) - }) + stream.on("finish", () => { + sails.log.info("to: " + stream.url); + resolve(stream.url); + }); - stream.on('error', reject) - }) + stream.on("error", reject); + }); } diff --git a/api/services/CommunityService.js b/api/services/CommunityService.js index d1c66dda0..511b698d7 100644 --- a/api/services/CommunityService.js +++ b/api/services/CommunityService.js @@ -1,7 +1,7 @@ module.exports = { - async removeMember (userToRemoveId, communityId) { - const community = await Community.find(communityId) - const user = await User.find(userToRemoveId) - await user.leaveCommunity(community) - } -} + async removeMember(userToRemoveId, communityId) { + const community = await Community.find(communityId); + const user = await User.find(userToRemoveId); + await user.leaveCommunity(community); + }, +}; diff --git a/api/services/Email.js b/api/services/Email.js index f51a104ca..809e407e7 100644 --- a/api/services/Email.js +++ b/api/services/Email.js @@ -1,123 +1,181 @@ -const api = require('sendwithus')(process.env.SENDWITHUS_KEY) -const Promise = require('bluebird') -import { curry, merge } from 'lodash' -import { format } from 'util' +import { curry, merge } from "lodash"; +import { format } from "util"; +const api = require("sendwithus")(process.env.SENDWITHUS_KEY); +const Promise = require("bluebird"); -const sendEmail = opts => +const sendEmail = (opts) => new Promise((resolve, reject) => - api.send(opts, (err, resp) => err ? reject(err) : resolve(resp))) + api.send(opts, (err, resp) => (err ? reject(err) : resolve(resp))) + ); const defaultOptions = { sender: { address: process.env.EMAIL_SENDER, - name: 'Hylo' - } -} + name: "Hylo", + }, +}; const sendSimpleEmail = function (address, templateId, data, extraOptions) { - return sendEmail(merge({}, defaultOptions, { - email_id: templateId, - recipient: {address}, - email_data: data - }, extraOptions)) -} + return sendEmail( + merge( + {}, + defaultOptions, + { + email_id: templateId, + recipient: { address }, + email_data: data, + }, + extraOptions + ) + ); +}; const sendEmailWithOptions = curry((templateId, opts) => - sendEmail(merge({}, defaultOptions, { - email_id: templateId, - recipient: {address: opts.email}, - email_data: opts.data, - version_name: opts.version, - sender: opts.sender // expects {name, address} - }))) + sendEmail( + merge({}, defaultOptions, { + email_id: templateId, + recipient: { address: opts.email }, + email_data: opts.data, + version_name: opts.version, + sender: opts.sender, // expects {name, address} + }) + ) +); module.exports = { sendSimpleEmail, sendRawEmail: (email, data, extraOptions) => - sendSimpleEmail(email, 'tem_nt4RmzAfN4KyPZYxFJWpFE', data, extraOptions), + sendSimpleEmail(email, "tem_nt4RmzAfN4KyPZYxFJWpFE", data, extraOptions), - sendPasswordReset: opts => - sendSimpleEmail(opts.email, 'tem_mccpcJNEzS4822mAnDNmGT', opts.templateData), + sendPasswordReset: (opts) => + sendSimpleEmail( + opts.email, + "tem_mccpcJNEzS4822mAnDNmGT", + opts.templateData + ), sendInvitation: (email, data) => - sendEmailWithOptions('tem_ZXZuvouDYKKhCrdEWYbEp9', { + sendEmailWithOptions("tem_ZXZuvouDYKKhCrdEWYbEp9", { email, data, - version: 'DEV-152', + version: "DEV-152", sender: { name: `${data.inviter_name} (via Hylo)`, - reply_to: data.inviter_email - } + reply_to: data.inviter_email, + }, }), sendTagInvitation: (email, data) => - sendEmailWithOptions('tem_tmEEpPvtQ69wGkmf9njCx8', { + sendEmailWithOptions("tem_tmEEpPvtQ69wGkmf9njCx8", { email, data, - version: 'default', + version: "default", sender: { name: `${data.inviter_name} (via Hylo)`, - reply_to: data.inviter_email - } + reply_to: data.inviter_email, + }, }), - sendAnnouncementNotification: sendEmailWithOptions('tem_xMGgjc4cfHCYDr8gWRKwhdXF'), - sendNewCommentNotification: sendEmailWithOptions('tem_tP6JzrYzvvDXhgTNmtkxuW'), - sendPostMentionNotification: sendEmailWithOptions('tem_wXiqtyNzAr8EF4fqBna5WQ'), - sendJoinRequestNotification: sendEmailWithOptions('tem_9sW4aBxaLi5ve57bp7FGXZ'), - sendApprovedJoinRequestNotification: sendEmailWithOptions('tem_eMJADwteU3zPyjmuCAAYVK'), - sendDonationToEmail: sendEmailWithOptions('tem_bhptVWGW6k67tpFtqRDWKTHQ'), - sendDonationFromEmail: sendEmailWithOptions('tem_TCgS9xJykShS9mJjwj9Kd3v6'), - sendEventInvitationEmail: sendEmailWithOptions('tem_DxG3FjMdcvYh63rKvh7gDmmY'), - - sendMessageDigest: opts => - sendEmailWithOptions('tem_xwQCfpdRT9K6hvrRFqDdhBRK', - Object.assign({version: 'v2'}, opts)), - - sendCommentDigest: opts => - sendEmailWithOptions('tem_tP6JzrYzvvDXhgTNmtkxuW', - Object.assign({version: 'v2'}, opts)), + sendAnnouncementNotification: sendEmailWithOptions( + "tem_xMGgjc4cfHCYDr8gWRKwhdXF" + ), + sendNewCommentNotification: sendEmailWithOptions( + "tem_tP6JzrYzvvDXhgTNmtkxuW" + ), + sendPostMentionNotification: sendEmailWithOptions( + "tem_wXiqtyNzAr8EF4fqBna5WQ" + ), + sendJoinRequestNotification: sendEmailWithOptions( + "tem_9sW4aBxaLi5ve57bp7FGXZ" + ), + sendApprovedJoinRequestNotification: sendEmailWithOptions( + "tem_eMJADwteU3zPyjmuCAAYVK" + ), + sendDonationToEmail: sendEmailWithOptions("tem_bhptVWGW6k67tpFtqRDWKTHQ"), + sendDonationFromEmail: sendEmailWithOptions("tem_TCgS9xJykShS9mJjwj9Kd3v6"), + sendEventInvitationEmail: sendEmailWithOptions( + "tem_DxG3FjMdcvYh63rKvh7gDmmY" + ), + + sendMessageDigest: (opts) => + sendEmailWithOptions( + "tem_xwQCfpdRT9K6hvrRFqDdhBRK", + Object.assign({ version: "v2" }, opts) + ), + + sendCommentDigest: (opts) => + sendEmailWithOptions( + "tem_tP6JzrYzvvDXhgTNmtkxuW", + Object.assign({ version: "v2" }, opts) + ), postReplyAddress: function (postId, userId) { - var plaintext = format('%s%s|%s', process.env.MAILGUN_EMAIL_SALT, postId, userId) - return format('reply-%s@%s', PlayCrypto.encrypt(plaintext), process.env.MAILGUN_DOMAIN) + const plaintext = format( + "%s%s|%s", + process.env.MAILGUN_EMAIL_SALT, + postId, + userId + ); + return format( + "reply-%s@%s", + PlayCrypto.encrypt(plaintext), + process.env.MAILGUN_DOMAIN + ); }, decodePostReplyAddress: function (address) { - var salt = new RegExp(format('^%s', process.env.MAILGUN_EMAIL_SALT)) - var match = address.match(/reply-(.*?)@/) - var plaintext = PlayCrypto.decrypt(match[1]).replace(salt, '') - var ids = plaintext.split('|') + const salt = new RegExp(format("^%s", process.env.MAILGUN_EMAIL_SALT)); + const match = address.match(/reply-(.*?)@/); + const plaintext = PlayCrypto.decrypt(match[1]).replace(salt, ""); + const ids = plaintext.split("|"); - return {postId: ids[0], userId: ids[1]} + return { postId: ids[0], userId: ids[1] }; }, postCreationAddress: function (communityId, userId, type) { - var plaintext = format('%s%s|%s|', process.env.MAILGUN_EMAIL_SALT, communityId, userId, type) - return format('create-%s@%s', PlayCrypto.encrypt(plaintext), process.env.MAILGUN_DOMAIN) + const plaintext = format( + "%s%s|%s|", + process.env.MAILGUN_EMAIL_SALT, + communityId, + userId, + type + ); + return format( + "create-%s@%s", + PlayCrypto.encrypt(plaintext), + process.env.MAILGUN_DOMAIN + ); }, decodePostCreationAddress: function (address) { - var salt = new RegExp(format('^%s', process.env.MAILGUN_EMAIL_SALT)) - var match = address.match(/create-(.*?)@/) - var plaintext = PlayCrypto.decrypt(match[1]).replace(salt, '') - var decodedData = plaintext.split('|') - - return {communityId: decodedData[0], userId: decodedData[1], type: decodedData[2]} + const salt = new RegExp(format("^%s", process.env.MAILGUN_EMAIL_SALT)); + const match = address.match(/create-(.*?)@/); + const plaintext = PlayCrypto.decrypt(match[1]).replace(salt, ""); + const decodedData = plaintext.split("|"); + + return { + communityId: decodedData[0], + userId: decodedData[1], + type: decodedData[2], + }; }, formToken: function (communityId, userId) { - var plaintext = format('%s%s|%s|', process.env.MAILGUN_EMAIL_SALT, communityId, userId) - return PlayCrypto.encrypt(plaintext) + const plaintext = format( + "%s%s|%s|", + process.env.MAILGUN_EMAIL_SALT, + communityId, + userId + ); + return PlayCrypto.encrypt(plaintext); }, decodeFormToken: function (token) { - var salt = new RegExp(format('^%s', process.env.MAILGUN_EMAIL_SALT)) - var plaintext = PlayCrypto.decrypt(token).replace(salt, '') - var decodedData = plaintext.split('|') - - return {communityId: decodedData[0], userId: decodedData[1]} - } + const salt = new RegExp(format("^%s", process.env.MAILGUN_EMAIL_SALT)); + const plaintext = PlayCrypto.decrypt(token).replace(salt, ""); + const decodedData = plaintext.split("|"); -} + return { communityId: decodedData[0], userId: decodedData[1] }; + }, +}; diff --git a/api/services/Frontend.js b/api/services/Frontend.js index cf0e877bd..ab71f1cc3 100644 --- a/api/services/Frontend.js +++ b/api/services/Frontend.js @@ -1,4 +1,4 @@ -import { isString, isNumber, isEmpty } from 'lodash' +import { isString, isNumber, isEmpty } from "lodash"; /* @@ -8,154 +8,163 @@ throughout the code. */ -let prefix = `${process.env.PROTOCOL}://${process.env.DOMAIN}` -const isTesting = process.env.NODE_ENV === 'test' +let prefix = `${process.env.PROTOCOL}://${process.env.DOMAIN}`; +const isTesting = process.env.NODE_ENV === "test"; -var url = function () { +const url = function () { // allow these values to be changed in individual tests if (isTesting) { - prefix = `${process.env.PROTOCOL}://${process.env.DOMAIN}` + prefix = `${process.env.PROTOCOL}://${process.env.DOMAIN}`; } - var args = Array.prototype.slice.call(arguments) - args[0] = prefix + args[0] - return format.apply(null, args) -} + const args = Array.prototype.slice.call(arguments); + args[0] = prefix + args[0]; + return format.apply(null, args); +}; -var getModelId = function (model) { - let id +const getModelId = function (model) { + let id; // If it's a number, than we just passed the ID in straight if (isString(model) || isNumber(model)) { - id = model + id = model; } else if (model) { - id = model.id + id = model.id; } - return id -} + return id; +}; -var getSlug = function (community) { - let slug - if (isString(community)) { // In case we passed just the slug in instead of community object - slug = community +const getSlug = function (community) { + let slug; + if (isString(community)) { + // In case we passed just the slug in instead of community object + slug = community; } else if (community) { - slug = community.slug || community.get('slug') + slug = community.slug || community.get("slug"); } - return slug -} + return slug; +}; module.exports = { Route: { evo: { passwordSetting: function () { - return url('/settings/password') + return url("/settings/password"); }, paymentSettings: function (opts = {}) { switch (opts.registered) { - case 'success': - return url('/settings/payment?registered=success') - case 'error': - return url('/settings/payment?registered=error') + case "success": + return url("/settings/payment?registered=success"); + case "error": + return url("/settings/payment?registered=error"); default: - return url('/settings/payment') + return url("/settings/payment"); } - } + }, }, prefix, - root: () => url('/app'), + root: () => url("/app"), community: function (community) { - return url('/c/%s', getSlug(community)) + return url("/c/%s", getSlug(community)); }, comment: function (comment, community) { // TODO: update to use comment specific url when implemented in frontend - let communitySlug = getSlug(community) + const communitySlug = getSlug(community); - let communityUrl = isEmpty(communitySlug) ? '/all' : `/c/${communitySlug}` + const communityUrl = isEmpty(communitySlug) + ? "/all" + : `/c/${communitySlug}`; - const postId = comment.relations.post.id + const postId = comment.relations.post.id; - return url(`${communityUrl}/p/${postId}`) + return url(`${communityUrl}/p/${postId}`); }, communitySettings: function (community) { - return this.community(community) + '/settings' + return this.community(community) + "/settings"; }, communityJoinRequests: function (community) { - return this.communitySettings(community) + '/invite#join_requests' + return this.communitySettings(community) + "/invite#join_requests"; }, mapPost: function (post, context, slug) { - let contextUrl = '/all' - - if (context === 'public') { - contextUrl = '/public' - } else if (context === 'community') { - contextUrl = `/c/${slug}` - } else if (context === 'network') { - contextUrl = `/n/${slug}` + let contextUrl = "/all"; + + if (context === "public") { + contextUrl = "/public"; + } else if (context === "community") { + contextUrl = `/c/${slug}`; + } else if (context === "network") { + contextUrl = `/n/${slug}`; } - return url(`${contextUrl}/map/p/${getModelId(post)}`) + return url(`${contextUrl}/map/p/${getModelId(post)}`); }, profile: function (user) { - return url(`/m/${getModelId(user)}`) + return url(`/m/${getModelId(user)}`); }, post: function (post, community, isPublic) { - let communitySlug = getSlug(community) - let communityUrl = '/all' + const communitySlug = getSlug(community); + let communityUrl = "/all"; if (isPublic) { - communityUrl = '/public' + communityUrl = "/public"; } else if (!isEmpty(communitySlug)) { - communityUrl = `/c/${communitySlug}` + communityUrl = `/c/${communitySlug}`; } - return url(`${communityUrl}/p/${getModelId(post)}`) + return url(`${communityUrl}/p/${getModelId(post)}`); }, thread: function (post) { - return url(`/t/${getModelId(post)}`) + return url(`/t/${getModelId(post)}`); }, unfollow: function (post, community) { - return this.post(post, community) + '?action=unfollow' + return this.post(post, community) + "?action=unfollow"; }, userSettings: function () { - return url('/settings') + return url("/settings"); }, tokenLogin: function (user, token, nextUrl) { - return url('/noo/login/token?u=%s&t=%s&n=%s', - user.id, token, encodeURIComponent(nextUrl || '')) + return url( + "/noo/login/token?u=%s&t=%s&n=%s", + user.id, + token, + encodeURIComponent(nextUrl || "") + ); }, error: function (key) { - return url('/error?key=' + encodeURIComponent(key)) + return url("/error?key=" + encodeURIComponent(key)); }, useInvitation: function (token, email) { - return url('/h/use-invitation?token=%s&email=%s', token, email) + return url("/h/use-invitation?token=%s&email=%s", token, email); }, emailPostForm: function () { - return url('/noo/hook/postForm') + return url("/noo/hook/postForm"); }, emailBatchCommentForm: function () { - return url('/noo/hook/batchCommentForm') + return url("/noo/hook/batchCommentForm"); }, invitePath: function (community) { - return `/c/${getSlug(community)}/join/${community.get('beta_access_code')}` - } - } -} + return `/c/${getSlug(community)}/join/${community.get( + "beta_access_code" + )}`; + }, + }, +}; diff --git a/api/services/FullTextSearch.js b/api/services/FullTextSearch.js index e31c9677c..ac280a8f9 100644 --- a/api/services/FullTextSearch.js +++ b/api/services/FullTextSearch.js @@ -1,28 +1,29 @@ -import { compact, omit } from 'lodash' +import { compact, omit } from "lodash"; -const tableName = 'search_index' -const columnName = 'document' -const defaultLang = 'english' +const tableName = "search_index"; +const columnName = "document"; +const defaultLang = "english"; -const raw = (str, knex = bookshelf.knex) => knex.raw(str) +const raw = (str, knex = bookshelf.knex) => knex.raw(str); -const dropView = knex => raw(`drop materialized view ${tableName}`, knex) +const dropView = (knex) => raw(`drop materialized view ${tableName}`, knex); -const refreshView = () => raw(`refresh materialized view ${tableName}`) +const refreshView = () => raw(`refresh materialized view ${tableName}`); const createView = (lang, knex) => { - if (!lang) lang = defaultLang - var wv = (column, weight) => - `setweight(to_tsvector('${lang}', ${column}), '${weight}')` + if (!lang) lang = defaultLang; + const wv = (column, weight) => + `setweight(to_tsvector('${lang}', ${column}), '${weight}')`; - return raw(`create materialized view ${tableName} as ( + return raw( + `create materialized view ${tableName} as ( select p.id as post_id, null::bigint as user_id, null::bigint as comment_id, - ${wv('p.name', 'B')} || - ${wv("coalesce(p.description, '')", 'C')} || - ${wv('u.name', 'D')} as ${columnName} + ${wv("p.name", "B")} || + ${wv("coalesce(p.description, '')", "C")} || + ${wv("u.name", "D")} as ${columnName} from posts p join users u on u.id = p.user_id where p.active = true and u.active = true @@ -31,9 +32,9 @@ const createView = (lang, knex) => { null as post_id, u.id as user_id, null as comment_id, - ${wv('u.name', 'A')} || - ${wv("coalesce(string_agg(replace(s.name, '-', ' '), ' '), '')", 'C')} || - ${wv("coalesce(u.bio, '')", 'C')} as ${columnName} + ${wv("u.name", "A")} || + ${wv("coalesce(string_agg(replace(s.name, '-', ' '), ' '), '')", "C")} || + ${wv("coalesce(u.bio, '')", "C")} as ${columnName} from users u left join skills_users su on u.id = su.user_id left join skills s on su.skill_id = s.id @@ -44,83 +45,110 @@ const createView = (lang, knex) => { null as post_id, null as user_id, c.id as comment_id, - ${wv('c.text', 'C')} || - ${wv('u.name', 'D')} as ${columnName} + ${wv("c.text", "C")} || + ${wv("u.name", "D")} as ${columnName} from comments c join users u on u.id = c.user_id where c.active = true and u.active = true - )`, knex) - .then(() => raw(`create index idx_fts_search on ${tableName} - using gin(${columnName})`, knex)) -} + )`, + knex + ).then(() => + raw( + `create index idx_fts_search on ${tableName} + using gin(${columnName})`, + knex + ) + ); +}; const search = (opts) => { - var term = compact(opts.term.replace(/'/, '').split(' ')) - .map(w => w + ':*') - .join(' & ') + const term = compact(opts.term.replace(/'/, "").split(" ")) + .map((w) => w + ":*") + .join(" & "); - var lang = opts.lang || defaultLang - var tsquery = `to_tsquery('${lang}', '${term}')` - var rank = `ts_rank_cd(${columnName}, ${tsquery})` - var columns + const lang = opts.lang || defaultLang; + const tsquery = `to_tsquery('${lang}', '${term}')`; + const rank = `ts_rank_cd(${columnName}, ${tsquery})`; + let columns; // set opts.subquery if you are using this search method within one of the // services/Search methods, e.g. forUsers, and want to use the full-text // search index if (opts.subquery) { columns = { - person: 'user_id', - post: 'post_id', - comment: 'comment_id' - }[opts.type] + person: "user_id", + post: "post_id", + comment: "comment_id", + }[opts.type]; } else { - columns = raw(`post_id, comment_id, user_id, ${rank} as rank, count(*) over () as total`) + columns = raw( + `post_id, comment_id, user_id, ${rank} as rank, count(*) over () as total` + ); } - var query = bookshelf.knex - .select(columns) - .from(tableName) - .where(raw(`${columnName} @@ ${tsquery}`)) - .where(raw({ - person: 'user_id is not null', - post: 'post_id is not null', - comment: 'comment_id is not null' - }[opts.type] || true)) + let query = bookshelf.knex + .select(columns) + .from(tableName) + .where(raw(`${columnName} @@ ${tsquery}`)) + .where( + raw( + { + person: "user_id is not null", + post: "post_id is not null", + comment: "comment_id is not null", + }[opts.type] || true + ) + ); if (!opts.subquery) { - query = query.orderBy('rank', 'desc') + query = query.orderBy("rank", "desc"); } - return query -} + return query; +}; const searchInCommunities = (communityIds, opts) => { - const alias = 'search' - const columns = [`${alias}.post_id`, `${alias}.comment_id`, `${alias}.user_id`, 'rank', 'total'] + const alias = "search"; + const columns = [ + `${alias}.post_id`, + `${alias}.comment_id`, + `${alias}.user_id`, + "rank", + "total", + ]; return bookshelf.knex - .select(columns) - .from(search(omit(opts, 'limit', 'offset')).as(alias)) - .leftJoin('communities_users', 'communities_users.user_id', `${alias}.user_id`) - .leftJoin('comments', 'comments.id', `${alias}.comment_id`) - .leftJoin('communities_posts', function () { - this.on('communities_posts.post_id', `${alias}.post_id`) - .orOn('communities_posts.post_id', 'comments.post_id') - }) - .where(function () { - this.where('communities_users.community_id', 'in', communityIds) - .orWhere('communities_posts.community_id', 'in', communityIds) - }) - .groupBy(columns) - .orderBy('rank', 'desc') - .limit(opts.limit || 20) - .offset(opts.offset || 0) -} + .select(columns) + .from(search(omit(opts, "limit", "offset")).as(alias)) + .leftJoin( + "communities_users", + "communities_users.user_id", + `${alias}.user_id` + ) + .leftJoin("comments", "comments.id", `${alias}.comment_id`) + .leftJoin("communities_posts", function () { + this.on("communities_posts.post_id", `${alias}.post_id`).orOn( + "communities_posts.post_id", + "comments.post_id" + ); + }) + .where(function () { + this.where("communities_users.community_id", "in", communityIds).orWhere( + "communities_posts.community_id", + "in", + communityIds + ); + }) + .groupBy(columns) + .orderBy("rank", "desc") + .limit(opts.limit || 20) + .offset(opts.offset || 0); +}; module.exports = { createView, dropView, refreshView, search, - searchInCommunities -} + searchInCommunities, +}; diff --git a/api/services/GetImageSize.js b/api/services/GetImageSize.js index 764fc48d8..063463a31 100644 --- a/api/services/GetImageSize.js +++ b/api/services/GetImageSize.js @@ -1,15 +1,20 @@ -import imageSize from 'image-size' -import request from 'request' +import imageSize from "image-size"; +import request from "request"; module.exports = function (imageUrl) { return new Promise((resolve, reject) => { - request(imageUrl, {encoding: null}, (error, resp, body) => { - if (error) return reject(error) + request(imageUrl, { encoding: null }, (error, resp, body) => { + if (error) return reject(error); if (resp.statusCode !== 200) { - return reject('Get Image Size on ' + imageUrl + ' failed with status code: ' + resp.statusCode) + return reject( + "Get Image Size on " + + imageUrl + + " failed with status code: " + + resp.statusCode + ); } - resolve(imageSize(resp.body, 'binary')) - }) - }) -} + resolve(imageSize(resp.body, "binary")); + }); + }); +}; diff --git a/api/services/InvitationService.js b/api/services/InvitationService.js index 0af7e4b9c..c8b77b470 100644 --- a/api/services/InvitationService.js +++ b/api/services/InvitationService.js @@ -1,58 +1,70 @@ -import validator from 'validator' -import { markdown } from 'hylo-utils/text' -import { get, isEmpty, map, merge } from 'lodash/fp' +import validator from "validator"; +import { markdown } from "hylo-utils/text"; +import { get, isEmpty, map, merge } from "lodash/fp"; module.exports = { checkPermission: (userId, invitationId) => { - return Invitation.find(invitationId, {withRelated: 'community'}) - .then(invitation => { - if (!invitation) throw new Error('Invitation not found') - const { community } = invitation.relations - return GroupMembership.hasModeratorRole(userId, community) - }) + return Invitation.find(invitationId, { withRelated: "community" }).then( + (invitation) => { + if (!invitation) throw new Error("Invitation not found"); + const { community } = invitation.relations; + return GroupMembership.hasModeratorRole(userId, community); + } + ); }, findById: (invitationId) => { - return Invitation.find(invitationId) + return Invitation.find(invitationId); }, - find: ({communityId, limit, offset, pendingOnly = false, includeExpired = false}) => { + find: ({ + communityId, + limit, + offset, + pendingOnly = false, + includeExpired = false, + }) => { return Community.find(communityId) - .then(community => Invitation.query(qb => { - qb.limit(limit || 20) - qb.offset(offset || 0) - qb.where('community_id', community.get('id')) - qb.leftJoin('users', 'users.id', 'community_invites.used_by_id') - qb.select(bookshelf.knex.raw(` + .then((community) => + Invitation.query((qb) => { + qb.limit(limit || 20); + qb.offset(offset || 0); + qb.where("community_id", community.get("id")); + qb.leftJoin("users", "users.id", "community_invites.used_by_id"); + qb.select( + bookshelf.knex.raw(` community_invites.*, count(*) over () as total, users.id as joined_user_id, users.name as joined_user_name, users.avatar_url as joined_user_avatar_url - `)) - - pendingOnly && qb.whereNull('used_by_id') - - !includeExpired && qb.whereNull('expired_by_id') - - qb.orderBy('created_at', 'desc') - }).fetchAll({withRelated: 'user'})) - .then(invitations => ({ - total: invitations.length > 0 ? Number(invitations.first().get('total')) : 0, - items: invitations.map(i => { - var user = i.relations.user.pick('id', 'name', 'avatar_url') - if (isEmpty(user) && i.get('joined_user_id')) { - user = { - id: i.get('joined_user_id'), - name: i.get('joined_user_name'), - avatar_url: i.get('joined_user_avatar_url') + `) + ); + + pendingOnly && qb.whereNull("used_by_id"); + + !includeExpired && qb.whereNull("expired_by_id"); + + qb.orderBy("created_at", "desc"); + }).fetchAll({ withRelated: "user" }) + ) + .then((invitations) => ({ + total: + invitations.length > 0 ? Number(invitations.first().get("total")) : 0, + items: invitations.map((i) => { + let user = i.relations.user.pick("id", "name", "avatar_url"); + if (isEmpty(user) && i.get("joined_user_id")) { + user = { + id: i.get("joined_user_id"), + name: i.get("joined_user_name"), + avatar_url: i.get("joined_user_avatar_url"), + }; } - } - return merge(i.pick('id', 'email', 'created_at', 'last_sent_at'), { - user: !isEmpty(user) ? user : null - }) - }) - })) + return merge(i.pick("id", "email", "created_at", "last_sent_at"), { + user: !isEmpty(user) ? user : null, + }); + }), + })); }, /** @@ -66,47 +78,63 @@ module.exports = { * @param isModerator {Boolean} should invite as moderator (defaults: false) * @param subject */ - create: ({sessionUserId, communityId, tagName, userIds, emails = [], message, isModerator = false, subject}) => { + create: ({ + sessionUserId, + communityId, + tagName, + userIds, + emails = [], + message, + isModerator = false, + subject, + }) => { return Promise.join( - userIds && User.where('id', 'in', userIds).fetchAll(), + userIds && User.where("id", "in", userIds).fetchAll(), Community.find(communityId), tagName && Tag.find({ name: tagName }), (users, community, tag) => { - let concatenatedEmails = emails.concat(map(u => u.get('email'), get('models', users))) + const concatenatedEmails = emails.concat( + map((u) => u.get("email"), get("models", users)) + ); - return Promise.map(concatenatedEmails, email => { + return Promise.map(concatenatedEmails, (email) => { if (!validator.isEmail(email)) { - return {email, error: 'not a valid email address'} + return { email, error: "not a valid email address" }; } const opts = { email, userId: sessionUserId, - communityId: community.id - } + communityId: community.id, + }; if (tag) { - opts.tagId = tag.id + opts.tagId = tag.id; } else { - opts.message = markdown(message) - opts.moderator = isModerator - opts.subject = subject + opts.message = markdown(message); + opts.moderator = isModerator; + opts.subject = subject; } return Invitation.create(opts) - .tap(i => i.refresh({withRelated: ['creator', 'community', 'tag']})) - .then(invitation => { - return Queue.classMethod('Invitation', 'createAndSend', {invitation}) + .tap((i) => + i.refresh({ withRelated: ["creator", "community", "tag"] }) + ) + .then((invitation) => { + return Queue.classMethod("Invitation", "createAndSend", { + invitation, + }) .then(() => ({ email, id: invitation.id, createdAt: invitation.created_at, - lastSentAt: invitation.last_sent_at + lastSentAt: invitation.last_sent_at, })) - .catch(err => ({email, error: err.message})) - }) - }) - }) + .catch((err) => ({ email, error: err.message })); + }); + }); + } + ); }, /** @@ -118,80 +146,95 @@ module.exports = { * @param moderator {Boolean} should invite as moderator * @returns {*} */ - reinviteAll: ({sessionUserId, communityId, subject = '', message = '', isModerator = false}) => { - return Queue.classMethod('Invitation', 'reinviteAll', { + reinviteAll: ({ + sessionUserId, + communityId, + subject = "", + message = "", + isModerator = false, + }) => { + return Queue.classMethod("Invitation", "reinviteAll", { communityId, subject, message, moderator: isModerator, - userId: sessionUserId - }) + userId: sessionUserId, + }); }, expire: (userId, invitationId) => { - return Invitation.find(invitationId) - .then(invitation => { - if (!invitation) throw new Error('not found') + return Invitation.find(invitationId).then((invitation) => { + if (!invitation) throw new Error("not found"); - return invitation.expire(userId) - }) + return invitation.expire(userId); + }); }, resend: (invitationId) => { - return Invitation.find(invitationId) - .then(invitation => { - if (!invitation) throw new Error('not found') + return Invitation.find(invitationId).then((invitation) => { + if (!invitation) throw new Error("not found"); - return invitation.send() - }) + return invitation.send(); + }); }, check: (userId, token, accessCode) => { if (accessCode) { return Community.queryByAccessCode(accessCode) - .count() - .then(count => { - return {valid: count !== '0'} - }) + .count() + .then((count) => { + return { valid: count !== "0" }; + }); } if (token) { return Invitation.query() - .where({token, used_by_id: null}) - .count() - .then(result => { - return {valid: result[0].count !== '0'} - }) + .where({ token, used_by_id: null }) + .count() + .then((result) => { + return { valid: result[0].count !== "0" }; + }); } }, - async use (userId, token, accessCode) { - const user = await User.find(userId) + async use(userId, token, accessCode) { + const user = await User.find(userId); if (accessCode) { return Community.queryByAccessCode(accessCode) - .fetch() - .then(community => { - return GroupMembership.forPair(user, community, {includeInactive: true}).fetch() - .then(existingMembership => { - if (existingMembership) return existingMembership.get('active') - ? existingMembership - : existingMembership.save({active: true}, {patch: true}).then(membership => membership) - if (!!community) return user.joinCommunity(community).then(membership => membership) - }) - .catch(err => { - throw new Error(err.message) - }) - }) + .fetch() + .then((community) => { + return GroupMembership.forPair(user, community, { + includeInactive: true, + }) + .fetch() + .then((existingMembership) => { + if (existingMembership) { + return existingMembership.get("active") + ? existingMembership + : existingMembership + .save({ active: true }, { patch: true }) + .then((membership) => membership); + } + if (community) + return user + .joinCommunity(community) + .then((membership) => membership); + }) + .catch((err) => { + throw new Error(err.message); + }); + }); } if (token) { - return Invitation.where({token}).fetch() - .then(invitation => { - if (!invitation) throw new Error('not found') - if (invitation.isExpired()) throw new Error('expired') - return invitation.use(userId) - }) + return Invitation.where({ token }) + .fetch() + .then((invitation) => { + if (!invitation) throw new Error("not found"); + if (invitation.isExpired()) throw new Error("expired"); + return invitation.use(userId); + }); } - throw new Error('must provide either token or accessCode') - } -} + throw new Error("must provide either token or accessCode"); + }, +}; diff --git a/api/services/MessagingService.js b/api/services/MessagingService.js index 7b2a6ad02..614c96b88 100644 --- a/api/services/MessagingService.js +++ b/api/services/MessagingService.js @@ -1,12 +1,15 @@ -import findOrCreateThread from '../models/post/findOrCreateThread' -import createComment from '../models/comment/createComment' +import findOrCreateThread from "../models/post/findOrCreateThread"; +import createComment from "../models/comment/createComment"; const MessagingService = { - async sendMessageFromAxolotl (userIds, text) { - const thread = await findOrCreateThread(User.AXOLOTL_ID, userIds) - const message = await createComment(User.AXOLOTL_ID, {text, post: thread}) - return message - } -} + async sendMessageFromAxolotl(userIds, text) { + const thread = await findOrCreateThread(User.AXOLOTL_ID, userIds); + const message = await createComment(User.AXOLOTL_ID, { + text, + post: thread, + }); + return message; + }, +}; -module.exports = MessagingService +module.exports = MessagingService; diff --git a/api/services/Nexudus.js b/api/services/Nexudus.js index 14d2f0725..c10d673df 100644 --- a/api/services/Nexudus.js +++ b/api/services/Nexudus.js @@ -1,142 +1,167 @@ /* globals UserImport, NexudusAccount */ -import request from 'request' -var Promise = require('bluebird') -var get = Promise.promisify(request.get, request) -import { includes } from 'lodash' -import { filter, map, compact } from 'lodash/fp' +import request from "request"; +import { includes } from "lodash"; +import { filter, map, compact } from "lodash/fp"; +const Promise = require("bluebird"); +const get = Promise.promisify(request.get, request); // Nexudus app requires the following permissions // Business-Read, CoworkerContract-List, ProductNote-List, User-List, Coworker-List -const apiHost = 'https://spaces.nexudus.com/api' -const pageSize = 10000 +const apiHost = "https://spaces.nexudus.com/api"; +const pageSize = 10000; const avatarUrl = (id, subdomain) => - `https://${subdomain}.spaces.nexudus.com/en/coworker/getavatar/${id}?h=150&w=150` + `https://${subdomain}.spaces.nexudus.com/en/coworker/getavatar/${id}?h=150&w=150`; -const formatRecord = subdomain => record => { - if (!record.Email) return null - return ({ +const formatRecord = (subdomain) => (record) => { + if (!record.Email) return null; + return { name: record.FullName, email: record.Email.trim(), created_at: record.CreatedOn, updated_at: record.UpdatedOn, - avatar_url: avatarUrl(record.Id, subdomain) - }) -} + avatar_url: avatarUrl(record.Id, subdomain), + }; +}; -const logCount = label => ({ length }) => console.log(`${label}: ${length}`) +const logCount = (label) => ({ length }) => console.log(`${label}: ${length}`); // spaceId can be found by clicking through to a space's details page from // https://spaces.nexudus.com/Sys/Businesses; it is the number at the end of the // details page's URL, after "Edit" const API = function (username, password, spaceId, options = {}) { - this.username = username - this.password = password - this.spaceId = spaceId - this.options = options -} + this.username = username; + this.password = password; + this.spaceId = spaceId; + this.options = options; +}; API.prototype.get = function (path, qs) { return get({ - url: apiHost + '/' + path, + url: apiHost + "/" + path, json: true, - auth: {user: this.username, pass: this.password}, - qs + auth: { user: this.username, pass: this.password }, + qs, }).then(([res, body]) => { if (res.statusCode !== 200) { - throw new Error(res.statusCode) + throw new Error(res.statusCode); } - return body - }) -} + return body; + }); +}; API.prototype.getAllRecords = function (path, query) { - const getPage = page => - this.get(path, Object.assign({size: pageSize, page}, query)) - .then(({ CurrentPage, TotalPages, Records }) => - CurrentPage < TotalPages ? Records.concat(getPage(page + 1)) : Records) + const getPage = (page) => + this.get( + path, + Object.assign({ size: pageSize, page }, query) + ).then(({ CurrentPage, TotalPages, Records }) => + CurrentPage < TotalPages ? Records.concat(getPage(page + 1)) : Records + ); - return getPage(1) -} + return getPage(1); +}; API.prototype.getSpaceInfo = function () { - return this.get(`sys/businesses/${this.spaceId}`) -} + return this.get(`sys/businesses/${this.spaceId}`); +}; API.prototype.getActiveContracts = function () { - return this.getAllRecords('billing/coworkercontracts', {CoworkerContract_Active: true}) - .tap(logCount('contracts')) -} + return this.getAllRecords("billing/coworkercontracts", { + CoworkerContract_Active: true, + }).tap(logCount("contracts")); +}; API.prototype.getActiveCoworkers = function () { - return this.getAllRecords('spaces/coworkers', { + return this.getAllRecords("spaces/coworkers", { Coworker_Active: true, - Coworker_InvoicingBusiness: this.spaceId - }) - .tap(logCount('coworkers')) -} + Coworker_InvoicingBusiness: this.spaceId, + }).tap(logCount("coworkers")); +}; // members are users who are active and have an active contract API.prototype.fetchMembers = function () { - let contracts, coworkers - const intersects = record => - includes(map('CoworkerId', contracts), record.Id) - - return this.getSpaceInfo().tap(info => { this.subdomain = info.WebAddress }) - .then(() => this.getActiveContracts().tap(r1 => { contracts = r1 })) - .then(() => this.getActiveCoworkers().tap(r2 => { coworkers = r2 })) - .then(filter(intersects)) - .tap(logCount('active members')) - .then(records => compact(map(formatRecord(this.subdomain), records))) - .then(records => this.options.verbose ? {contracts, coworkers, records} : records) -} + let contracts, coworkers; + const intersects = (record) => + includes(map("CoworkerId", contracts), record.Id); + + return this.getSpaceInfo() + .tap((info) => { + this.subdomain = info.WebAddress; + }) + .then(() => + this.getActiveContracts().tap((r1) => { + contracts = r1; + }) + ) + .then(() => + this.getActiveCoworkers().tap((r2) => { + coworkers = r2; + }) + ) + .then(filter(intersects)) + .tap(logCount("active members")) + .then((records) => compact(map(formatRecord(this.subdomain), records))) + .then((records) => + this.options.verbose ? { contracts, coworkers, records } : records + ); +}; API.prototype.updateMembers = function () { return this.fetchMembers() - .tap(records => console.log('about to create users', records.length)) - .then(records => Promise.map(records, r => UserImport.createUser(r, this.options))) - .then(users => compact(users).length) -} + .tap((records) => console.log("about to create users", records.length)) + .then((records) => + Promise.map(records, (r) => UserImport.createUser(r, this.options)) + ) + .then((users) => compact(users).length); +}; API.prototype.updateMemberImages = function () { - this.options.verbose = true + this.options.verbose = true; return this.fetchMembers() - .then(({ contracts, coworkers, records }) => - Promise.map(records, r => { - const coworker = coworkers.find(c => c.Email.trim() === r.email) - return User.find(r.email) - .then(user => { - if (!user || !user.hasNoAvatar()) return - const avatar_url = avatarUrl(coworker.Id, this.subdomain) - console.log(`${user.id}: ${avatar_url}`) - return user.save({avatar_url}, {patch: true}) + .then(({ contracts, coworkers, records }) => + Promise.map(records, (r) => { + const coworker = coworkers.find((c) => c.Email.trim() === r.email); + return User.find(r.email).then((user) => { + if (!user || !user.hasNoAvatar()) return; + const avatar_url = avatarUrl(coworker.Id, this.subdomain); + console.log(`${user.id}: ${avatar_url}`); + return user.save({ avatar_url }, { patch: true }); + }); }) - })) - .then(users => compact(users).length) -} + ) + .then((users) => compact(users).length); +}; module.exports = { forAccount: function (nexudusAccount, opts = {}) { - const username = nexudusAccount.get('username') - const password = nexudusAccount.decryptedPassword() - const spaceId = nexudusAccount.get('space_id') - return new API(username, password, spaceId, opts) + const username = nexudusAccount.get("username"); + const password = nexudusAccount.decryptedPassword(); + const spaceId = nexudusAccount.get("space_id"); + return new API(username, password, spaceId, opts); }, forCommunity: function (community_id, opts) { - return NexudusAccount.where({community_id}).fetch() - .then(a => this.forAccount(a, opts)) + return NexudusAccount.where({ community_id }) + .fetch() + .then((a) => this.forAccount(a, opts)); }, updateAllCommunities: function (options) { - return NexudusAccount.where('autoupdate', true) - .fetchAll({withRelated: 'community'}) - .then(accounts => Promise.map(accounts.models, account => { - const { community } = account.relations - const api = this.forAccount(account, Object.assign({community}, options)) - return api.updateMembers() - .then(count => [account.get('community_id'), count]) - })) - } -} + return NexudusAccount.where("autoupdate", true) + .fetchAll({ withRelated: "community" }) + .then((accounts) => + Promise.map(accounts.models, (account) => { + const { community } = account.relations; + const api = this.forAccount( + account, + Object.assign({ community }, options) + ); + return api + .updateMembers() + .then((count) => [account.get("community_id"), count]); + }) + ); + }, +}; diff --git a/api/services/OneSignal.js b/api/services/OneSignal.js index e916aa7f0..d2d4f85f8 100644 --- a/api/services/OneSignal.js +++ b/api/services/OneSignal.js @@ -1,107 +1,124 @@ -import request from 'request' -import Promise from 'bluebird' -import { isNull, merge, omit } from 'lodash' -import { omitBy } from 'lodash/fp' -import rollbar from '../../lib/rollbar' +import request from "request"; +import Promise from "bluebird"; +import { isNull, merge, omit } from "lodash"; +import { omitBy } from "lodash/fp"; +import rollbar from "../../lib/rollbar"; -const HOST = 'https://onesignal.com' +const HOST = "https://onesignal.com"; -function iosBadgeUpdateParams ({ deviceToken, playerId, badgeNo }) { +function iosBadgeUpdateParams({ deviceToken, playerId, badgeNo }) { return omitBy(isNull, { include_ios_tokens: deviceToken ? [deviceToken] : null, include_player_ids: playerId ? [playerId] : null, - ios_badgeType: 'SetTo', + ios_badgeType: "SetTo", ios_badgeCount: badgeNo, - content_available: true - }) + content_available: true, + }); } -function iosNotificationParams ({ deviceToken, playerId, alert, path, badgeNo }) { - const coreParams = iosBadgeUpdateParams({deviceToken, playerId, badgeNo}) - return merge( - omit(coreParams, 'content_available'), - { - contents: {en: alert}, - data: {path} - } - ) +function iosNotificationParams({ + deviceToken, + playerId, + alert, + path, + badgeNo, +}) { + const coreParams = iosBadgeUpdateParams({ deviceToken, playerId, badgeNo }); + return merge(omit(coreParams, "content_available"), { + contents: { en: alert }, + data: { path }, + }); } -function androidNotificationParams ({ deviceToken, playerId, alert, path }) { +function androidNotificationParams({ deviceToken, playerId, alert, path }) { return omitBy(isNull, { include_android_reg_ids: deviceToken ? [deviceToken] : null, include_player_ids: playerId ? [playerId] : null, - contents: {en: alert}, - data: {alert, path} - }) + contents: { en: alert }, + data: { alert, path }, + }); } -function notificationParams ({ platform, deviceToken, playerId, alert, path, badgeNo, appId }) { +function notificationParams({ + platform, + deviceToken, + playerId, + alert, + path, + badgeNo, + appId, +}) { if (deviceToken && playerId) { - throw new Error("Can't pass both a device token and a player ID") + throw new Error("Can't pass both a device token and a player ID"); } - let params + let params; - if (platform.startsWith('ios')) { - if (path === '') { - params = iosBadgeUpdateParams({deviceToken, playerId, badgeNo}) + if (platform.startsWith("ios")) { + if (path === "") { + params = iosBadgeUpdateParams({ deviceToken, playerId, badgeNo }); } else { - params = iosNotificationParams({deviceToken, playerId, alert, path, badgeNo}) + params = iosNotificationParams({ + deviceToken, + playerId, + alert, + path, + badgeNo, + }); } } else { - params = androidNotificationParams({deviceToken, playerId, alert, path}) + params = androidNotificationParams({ deviceToken, playerId, alert, path }); } - params['app_id'] = appId || process.env.ONESIGNAL_APP_ID - return params + params.app_id = appId || process.env.ONESIGNAL_APP_ID; + return params; } const postToAPI = (name, path, params) => new Promise((resolve, reject) => { const opts = Object.assign({ - url: HOST + '/api/v1/' + path, - method: 'POST', - json: params - }) + url: HOST + "/api/v1/" + path, + method: "POST", + json: params, + }); request(opts, (error, resp, body) => { - if (error) return reject(error) + if (error) return reject(error); if (resp.statusCode !== 200) { - const error = new Error(`OneSignal.${name} failed`) - error.response = resp - return reject(error) + const error = new Error(`OneSignal.${name} failed`); + error.response = resp; + return reject(error); } - resolve(resp) - }) - }) + resolve(resp); + }); + }); module.exports = { // DEPRECATED register: (platform, deviceToken) => - postToAPI('register', 'players', { + postToAPI("register", "players", { app_id: process.env.ONESIGNAL_APP_ID, - device_type: platform === 'ios_macos' ? 0 : 1, + device_type: platform === "ios_macos" ? 0 : 1, identifier: deviceToken, - test_type: process.env.NODE_ENV === 'development' ? 1 : null + test_type: process.env.NODE_ENV === "development" ? 1 : null, }), notify: async (opts) => { - const { platform, deviceToken, playerId } = opts - const params = notificationParams(opts) + const { platform, deviceToken, playerId } = opts; + const params = notificationParams(opts); try { - return await postToAPI('notify', 'notifications', params) + return await postToAPI("notify", "notifications", params); } catch (e) { - const err = e instanceof Error ? e : new Error(e) + const err = e instanceof Error ? e : new Error(e); rollbar.error(err, null, { deviceToken, devicePlatform: platform, // 'platform' is a Rollbar reserved word playerId, - response: err.response - }) + response: err.response, + }); } - } -} + }, +}; diff --git a/api/services/PlayCrypto.js b/api/services/PlayCrypto.js index c2e258940..feeb7e924 100644 --- a/api/services/PlayCrypto.js +++ b/api/services/PlayCrypto.js @@ -1,22 +1,22 @@ -import crypto from 'crypto' +import crypto from "crypto"; -const encryptionType = 'aes-128-ecb' +const encryptionType = "aes-128-ecb"; -function key () { - return new Buffer(process.env.PLAY_APP_SECRET.substring(0, 16), 'utf-8') +function key() { + return new Buffer(process.env.PLAY_APP_SECRET.substring(0, 16), "utf-8"); } module.exports = { encrypt: function (text) { - var cipher = crypto.createCipheriv(encryptionType, key(), '') + const cipher = crypto.createCipheriv(encryptionType, key(), ""); - cipher.end(text) - return cipher.read().toString('hex') + cipher.end(text); + return cipher.read().toString("hex"); }, decrypt: function (code) { - var decipher = crypto.createDecipheriv(encryptionType, key(), '') - decipher.end(new Buffer(code, 'hex')) - return decipher.read().toString() - } -} + const decipher = crypto.createDecipheriv(encryptionType, key(), ""); + decipher.end(new Buffer(code, "hex")); + return decipher.read().toString(); + }, +}; diff --git a/api/services/PostManagement.js b/api/services/PostManagement.js index fa4fb6dea..32c5904dc 100644 --- a/api/services/PostManagement.js +++ b/api/services/PostManagement.js @@ -1,35 +1,35 @@ const removeComments = (postId, trx) => - trx('comments').where('post_id', postId).pluck('id') - .then(ids => { - if (ids.length === 0) return + trx("comments") + .where("post_id", postId) + .pluck("id") + .then((ids) => { + if (ids.length === 0) return; - const remove = (table, column = 'comment_id') => - trx(table).where(column, 'in', ids).del() + const remove = (table, column = "comment_id") => + trx(table).where(column, "in", ids).del(); - return Promise.all([ - remove('thanks'), - remove('comments_tags') - ]) - .then(() => remove('comments', 'id')) - }) + return Promise.all([remove("thanks"), remove("comments_tags")]).then(() => + remove("comments", "id") + ); + }); -export const removePost = postId => { - return bookshelf.transaction(trx => { - const remove = table => - trx(table).where('post_id', postId).del() +export const removePost = (postId) => { + return bookshelf.transaction((trx) => { + const remove = (table) => trx(table).where("post_id", postId).del(); - const unset = (table, col = 'post_id') => - trx(table).where(col, postId).update({[col]: null}) + const unset = (table, col = "post_id") => + trx(table) + .where(col, postId) + .update({ [col]: null }); return Promise.all([ removeComments(postId, trx), - remove('follows'), - remove('user_post_relevance'), - remove('posts_tags'), - remove('posts_users'), - remove('communities_posts'), - unset('posts', 'parent_post_id') - ]) - .then(() => trx('posts').where('id', postId).del()) - }) -} + remove("follows"), + remove("user_post_relevance"), + remove("posts_tags"), + remove("posts_users"), + remove("communities_posts"), + unset("posts", "parent_post_id"), + ]).then(() => trx("posts").where("id", postId).del()); + }); +}; diff --git a/api/services/Queue.js b/api/services/Queue.js index 5b0bd2b51..bf64d8b2a 100644 --- a/api/services/Queue.js +++ b/api/services/Queue.js @@ -1,44 +1,46 @@ -import { filter, merge } from 'lodash' -const kue = require('kue') -const Promise = require('bluebird') -const promisify = Promise.promisify -const rangeByState = promisify(kue.Job.rangeByState, kue.Job) +import { filter, merge } from "lodash"; +const kue = require("kue"); +const Promise = require("bluebird"); +const promisify = Promise.promisify; +const rangeByState = promisify(kue.Job.rangeByState, kue.Job); module.exports = { addJob: function (name, data, delay = 2000) { - var queue = require('kue').createQueue() + const queue = require("kue").createQueue(); // there's a delay here because the job could be queued while an object it // depends upon hasn't been saved yet; but this can and should be avoided - var job = queue.create(name, data) - .delay(delay) - .attempts(3) - .backoff({delay: 20000, type: 'exponential'}) + const job = queue + .create(name, data) + .delay(delay) + .attempts(3) + .backoff({ delay: 20000, type: "exponential" }); - return promisify(job.save, job)() + return promisify(job.save, job)(); }, classMethod: function (className, methodName, data, delay = 2000) { - data = merge({className, methodName}, data) - return this.addJob('classMethod', data, delay) + data = merge({ className, methodName }, data); + return this.addJob("classMethod", data, delay); }, removeOldJobs: function (state, size, days = 3) { - const now = new Date().getTime() - const removeIfOldEnough = job => { + const now = new Date().getTime(); + const removeIfOldEnough = (job) => { if (now - Number(job.created_at) > days * 86400000) { - return promisify(job.remove, job)().then(() => true) + return promisify(job.remove, job)().then(() => true); } - return false - } + return false; + }; - return rangeByState(state, 0, size - 1, 'asc') - .then(jobs => Promise.map(jobs, removeIfOldEnough)) - .then(results => filter(results).length) + return rangeByState(state, 0, size - 1, "asc") + .then((jobs) => Promise.map(jobs, removeIfOldEnough)) + .then((results) => filter(results).length); }, // just for development use clearAllPendingJobs: () => - Promise.map(['active', 'inactive', 'failed', 'delayed'], state => - Queue.removeOldJobs(state, 10000, 0)) -} + Promise.map(["active", "inactive", "failed", "delayed"], (state) => + Queue.removeOldJobs(state, 10000, 0) + ), +}; diff --git a/api/services/RedisClient.js b/api/services/RedisClient.js index 01b4df974..17960920c 100644 --- a/api/services/RedisClient.js +++ b/api/services/RedisClient.js @@ -1,9 +1,9 @@ -import redis from 'redis' -Promise.promisifyAll(redis.RedisClient.prototype) -Promise.promisifyAll(redis.Multi.prototype) +import redis from "redis"; +Promise.promisifyAll(redis.RedisClient.prototype); +Promise.promisifyAll(redis.Multi.prototype); module.exports = { create: function () { - return redis.createClient(process.env.REDIS_URL) - } -} + return redis.createClient(process.env.REDIS_URL); + }, +}; diff --git a/api/services/RequestValidation.js b/api/services/RequestValidation.js index d04223f2a..882eef505 100644 --- a/api/services/RequestValidation.js +++ b/api/services/RequestValidation.js @@ -1,27 +1,25 @@ -var moment = require('moment'); +const moment = require("moment"); module.exports = { + requireTimeRange: function (req, res) { + let valid = true; - requireTimeRange: function(req, res) { - var valid = true; - - _.each(['start_time', 'end_time'], function (attr) { - var value = req.param(attr); + _.each(["start_time", "end_time"], function (attr) { + const value = req.param(attr); if (!value) { - res.badRequest(attr + ' is missing'); + res.badRequest(attr + " is missing"); valid = false; return false; // break from each } if (!moment(value).isValid()) { - res.badRequest(attr + ' is not a valid ISO8601 date string'); + res.badRequest(attr + " is not a valid ISO8601 date string"); valid = false; return false; // break from each } }); return valid; - } - -} + }, +}; diff --git a/api/services/RichText.js b/api/services/RichText.js index 8ba8b859d..7dca92614 100644 --- a/api/services/RichText.js +++ b/api/services/RichText.js @@ -1,35 +1,39 @@ -var Cheerio = require('cheerio') +const Cheerio = require("cheerio"); // returns a set of unique ids of any @mentions found in the text -export const getUserMentions = text => { - if (!text) return [] - var $ = Cheerio.load(text) - return _.uniq($('a[data-user-id]').map(function () { - return $(this).data('user-id').toString() - }).get()) -} +export const getUserMentions = (text) => { + if (!text) return []; + const $ = Cheerio.load(text); + return _.uniq( + $("a[data-user-id]") + .map(function () { + return $(this).data("user-id").toString(); + }) + .get() + ); +}; export const qualifyLinks = (text, recipient, token, slug) => { - if (!text) return text + if (!text) return text; - var $ = Cheerio.load(text) - $('a').each(function () { - const $this = $(this) - const tag = $this.text().replace(/^#/, '') - var url = $this.attr('href') || '' + const $ = Cheerio.load(text); + $("a").each(function () { + const $this = $(this); + const tag = $this.text().replace(/^#/, ""); + let url = $this.attr("href") || ""; if (Tag.isValidTag(tag)) { if (slug) { - url = `${Frontend.Route.prefix}/c/${slug}/tag/${tag}` + url = `${Frontend.Route.prefix}/c/${slug}/tag/${tag}`; } else { - url = `${Frontend.Route.prefix}/tag/${tag}` + url = `${Frontend.Route.prefix}/tag/${tag}`; } } else if (!url.match(/^https?:\/\//)) { - url = Frontend.Route.prefix + url + url = Frontend.Route.prefix + url; if (recipient && token) { - url = Frontend.Route.tokenLogin(recipient, token, url) + url = Frontend.Route.tokenLogin(recipient, token, url); } } - $this.attr('href', url) - }) - return $.html() -} + $this.attr("href", url); + }); + return $.html(); +}; diff --git a/api/services/Search.js b/api/services/Search.js index 7edffe6fa..5f660471a 100644 --- a/api/services/Search.js +++ b/api/services/Search.js @@ -1,172 +1,194 @@ -import forUsers from './Search/forUsers' -import forPosts from './Search/forPosts' -import { countTotal } from '../../lib/util/knex' -import addTermToQueryBuilder from './Search/addTermToQueryBuilder' -import { filterAndSortCommunities } from './Search/util' -import { transform } from 'lodash' -import { flatten, flow, uniq, get } from 'lodash/fp' -import { myCommunityIds } from '../models/util/queryFilters' +import forUsers from "./Search/forUsers"; +import forPosts from "./Search/forPosts"; +import { countTotal } from "../../lib/util/knex"; +import addTermToQueryBuilder from "./Search/addTermToQueryBuilder"; +import { filterAndSortCommunities } from "./Search/util"; +import { transform } from "lodash"; +import { flatten, flow, uniq, get } from "lodash/fp"; +import { myCommunityIds } from "../models/util/queryFilters"; module.exports = { forPosts, forUsers, - forSkills: opts => Skill.search(opts), + forSkills: (opts) => Skill.search(opts), forCommunities: function (opts) { - return Community.query(qb => { + return Community.query((qb) => { if (opts.communities) { - qb.whereIn('communities.id', opts.communities) + qb.whereIn("communities.id", opts.communities); } if (opts.autocomplete) { - qb.whereRaw('communities.name ilike ?', opts.autocomplete + '%') + qb.whereRaw("communities.name ilike ?", opts.autocomplete + "%"); } if (opts.networkSlugs) { - qb.join('networks', 'communities.network_id', '=', 'networks.id') - qb.whereIn('networks.slug', opts.networkSlugs) + qb.join("networks", "communities.network_id", "=", "networks.id"); + qb.whereIn("networks.slug", opts.networkSlugs); } if (opts.networks) { - qb.whereIn('communities.network_id', opts.networks) + qb.whereIn("communities.network_id", opts.networks); } if (opts.slug) { - qb.whereIn('communities.slug', opts.slug) + qb.whereIn("communities.slug", opts.slug); } if (opts.is_public) { - qb.where('is_public', opts.is_public) + qb.where("is_public", opts.is_public); } - filterAndSortCommunities({ - search: opts.term, - sortBy: opts.sort, - boundingBox: opts.boundingBox}, qb) + filterAndSortCommunities( + { + search: opts.term, + sortBy: opts.sort, + boundingBox: opts.boundingBox, + }, + qb + ); // this counts total rows matching the criteria, disregarding limit, // which is useful for pagination - countTotal(qb, 'communities', opts.totalColumnName) + countTotal(qb, "communities", opts.totalColumnName); - qb.limit(opts.limit) - qb.offset(opts.offset) - qb.groupBy('communities.id') - }) + qb.limit(opts.limit); + qb.offset(opts.offset); + qb.groupBy("communities.id"); + }); }, forTags: function (opts) { - return Tag.query(q => { - q.join('communities_tags', 'communities_tags.tag_id', '=', 'tags.id') - q.join('communities', 'communities.id', '=', 'communities_tags.community_id') - q.where('communities.id', 'in', myCommunityIds(opts.userId)) - q.where('communities.active', true) + return Tag.query((q) => { + q.join("communities_tags", "communities_tags.tag_id", "=", "tags.id"); + q.join( + "communities", + "communities.id", + "=", + "communities_tags.community_id" + ); + q.where("communities.id", "in", myCommunityIds(opts.userId)); + q.where("communities.active", true); if (opts.communitySlug) { - q.where('communities.slug', '=', opts.communitySlug) + q.where("communities.slug", "=", opts.communitySlug); } if (opts.networkSlug) { - q.join('networks', 'networks.id', 'communities.network_id') - q.where('networks.slug', '=', opts.networkSlug) + q.join("networks", "networks.id", "communities.network_id"); + q.where("networks.slug", "=", opts.networkSlug); } if (opts.name) { - q.where('tags.name', opts.name) + q.where("tags.name", opts.name); } if (opts.autocomplete) { - q.whereRaw('tags.name ilike ?', opts.autocomplete + '%') + q.whereRaw("tags.name ilike ?", opts.autocomplete + "%"); } if (opts.isDefault) { - q.where('communities_tags.is_default', true) + q.where("communities_tags.is_default", true); } if (opts.visibility) { - q.where('communities_tags.visibility', 'in', opts.visibility) + q.where("communities_tags.visibility", "in", opts.visibility); } if (opts.sort) { - if (opts.sort === 'name') { - q.orderByRaw('lower(tags.name) ASC') - } else if (opts.sort === 'num_followers') { - q.select(bookshelf.knex.raw('sum(communities_tags.num_followers) as num_followers')) - q.orderBy('num_followers', 'desc') + if (opts.sort === "name") { + q.orderByRaw("lower(tags.name) ASC"); + } else if (opts.sort === "num_followers") { + q.select( + bookshelf.knex.raw( + "sum(communities_tags.num_followers) as num_followers" + ) + ); + q.orderBy("num_followers", "desc"); } else { - q.orderBy(opts.sort, 'asc') + q.orderBy(opts.sort, "asc"); } } - countTotal(q, 'tags', opts.totalColumnName) + countTotal(q, "tags", opts.totalColumnName); - q.groupBy('tags.id') - q.limit(opts.limit) - }) + q.groupBy("tags.id"); + q.limit(opts.limit); + }); }, fullTextSearch: function (userId, args) { - var items, total - args.limit = args.first + let items, total; + args.limit = args.first; return fetchAllCommunityIds(userId, args) - .then(communityIds => - FullTextSearch.searchInCommunities(communityIds, args)) - .then(items_ => { - items = items_ - total = get('0.total', items) - - var ids = transform(items, (ids, item) => { - var type = item.post_id ? 'posts' - : item.comment_id ? 'comments' : 'people' - - if (!ids[type]) ids[type] = [] - var id = item.post_id || item.comment_id || item.user_id - ids[type].push(id) - }, {}) + .then((communityIds) => + FullTextSearch.searchInCommunities(communityIds, args) + ) + .then((items_) => { + items = items_; + total = get("0.total", items); + + const ids = transform( + items, + (ids, item) => { + const type = item.post_id + ? "posts" + : item.comment_id + ? "comments" + : "people"; + + if (!ids[type]) ids[type] = []; + const id = item.post_id || item.comment_id || item.user_id; + ids[type].push(id); + }, + {} + ); return Promise.join( - ids.posts && Post.where('id', 'in', ids.posts).fetchAll(), - ids.comments && Comment.where('id', 'in', ids.comments).fetchAll(), - ids.people && User.where('id', 'in', ids.people).fetchAll(), + ids.posts && Post.where("id", "in", ids.posts).fetchAll(), + ids.comments && Comment.where("id", "in", ids.comments).fetchAll(), + ids.people && User.where("id", "in", ids.people).fetchAll(), (posts, comments, people) => items.map(presentResult(posts, comments, people)) - ) + ); }) - .then(models => ({models, total})) - } -} + .then((models) => ({ models, total })); + }, +}; const fetchAllCommunityIds = (userId, { communityIds, networkId }) => { - if (communityIds) return Promise.resolve(communityIds) + if (communityIds) return Promise.resolve(communityIds); if (networkId) { - return Network.find(networkId, {withRelated: 'communities'}) - .then(n => n.relations.communities.map(c => c.id)) + return Network.find(networkId, { withRelated: "communities" }).then((n) => + n.relations.communities.map((c) => c.id) + ); } return Promise.join( Network.activeCommunityIds(userId), Group.pluckIdsForMember(userId, Community) - ).then(flow(flatten, uniq)) -} + ).then(flow(flatten, uniq)); +}; -const obfuscate = text => Buffer.from(text).toString('hex') +const obfuscate = (text) => Buffer.from(text).toString("hex"); -const presentResult = (posts, comments, people) => item => { +const presentResult = (posts, comments, people) => (item) => { if (item.user_id) { return { id: obfuscate(`user_id-${item.user_id}`), - content: people.find(p => p.id === item.user_id) - } + content: people.find((p) => p.id === item.user_id), + }; } else if (item.post_id) { return { id: obfuscate(`post_id-${item.post_id}`), - content: posts.find(p => p.id === item.post_id) - } + content: posts.find((p) => p.id === item.post_id), + }; } else if (item.comment_id) { return { id: obfuscate(`comment_id-${item.comment_id}`), - content: comments.find(c => c.id === item.comment_id) - } + content: comments.find((c) => c.id === item.comment_id), + }; } - return null -} + return null; +}; diff --git a/api/services/Search/addTermToQueryBuilder.js b/api/services/Search/addTermToQueryBuilder.js index 0bab27bf2..c7c98fbaa 100644 --- a/api/services/Search/addTermToQueryBuilder.js +++ b/api/services/Search/addTermToQueryBuilder.js @@ -1,23 +1,24 @@ -import { chain, isEmpty, times } from 'lodash' +import { chain, isEmpty, times } from "lodash"; export default function (term, qb, { columns }) { const query = chain(term.split(/\s*\s/)) // split on whitespace - .map(word => word.replace(/[,;|:&()!\\]+/, '')) - .reject(isEmpty) - .map(word => word + ':*') // add prefix matching - .reduce((result, word) => { - // build the tsquery string using logical AND operands - result += ' & ' + word - return result - }).value() + .map((word) => word.replace(/[,;|:&()!\\]+/, "")) + .reject(isEmpty) + .map((word) => word + ":*") // add prefix matching + .reduce((result, word) => { + // build the tsquery string using logical AND operands + result += " & " + word; + return result; + }) + .value(); const statement = columns - .map(col => `(to_tsvector('english', ${col}) @@ to_tsquery(?))`) - .join(' or ') + .map((col) => `(to_tsvector('english', ${col}) @@ to_tsquery(?))`) + .join(" or "); - const values = times(columns.length, () => query) + const values = times(columns.length, () => query); qb.where(function () { - this.whereRaw(`(${statement})`, values) - }) + this.whereRaw(`(${statement})`, values); + }); } diff --git a/api/services/Search/forPosts.js b/api/services/Search/forPosts.js index b0a95918b..f5ad0d972 100644 --- a/api/services/Search/forPosts.js +++ b/api/services/Search/forPosts.js @@ -1,101 +1,119 @@ -import { get } from 'lodash' -import { countTotal } from '../../../lib/util/knex' -import { filterAndSortPosts } from './util' +import { get } from "lodash"; +import { countTotal } from "../../../lib/util/knex"; +import { filterAndSortPosts } from "./util"; -export default function forPosts (opts) { - - return Post.query(qb => { - qb.limit(opts.limit || 20) - qb.offset(opts.offset) - qb.where({'posts.active': true}) +export default function forPosts(opts) { + return Post.query((qb) => { + qb.limit(opts.limit || 20); + qb.offset(opts.offset); + qb.where({ "posts.active": true }); // this counts total rows matching the criteria, disregarding limit, // which is useful for pagination - countTotal(qb, 'posts', opts.totalColumnName) + countTotal(qb, "posts", opts.totalColumnName); if (opts.users) { - qb.whereIn('posts.user_id', opts.users) + qb.whereIn("posts.user_id", opts.users); } if (opts.excludeUsers) { - qb.whereNotIn('posts.user_id', opts.excludeUsers) + qb.whereNotIn("posts.user_id", opts.excludeUsers); } if (opts.type === Post.Type.THREAD || opts.follower) { - qb.join('follows', 'follows.post_id', '=', 'posts.id') + qb.join("follows", "follows.post_id", "=", "posts.id"); if (opts.type === Post.Type.THREAD) { - qb.where('follows.user_id', opts.follower) + qb.where("follows.user_id", opts.follower); } else if (opts.follower) { - qb.where('follows.user_id', opts.follower) - qb.whereRaw('(posts.user_id != ? or posts.user_id is null)', opts.follower) + qb.where("follows.user_id", opts.follower); + qb.whereRaw( + "(posts.user_id != ? or posts.user_id is null)", + opts.follower + ); } } - if (opts.type === 'event' && opts.filter === 'future') { - qb.whereRaw('(posts.start_time > now())') + if (opts.type === "event" && opts.filter === "future") { + qb.whereRaw("(posts.start_time > now())"); } - if (opts.type === 'project' && opts.filter === 'mine') { - qb.leftJoin('follows', 'posts.id', 'follows.post_id') + if (opts.type === "project" && opts.filter === "mine") { + qb.leftJoin("follows", "posts.id", "follows.post_id"); qb.where(function () { - this.where('posts.user_id', opts.currentUserId) - .orWhere('follows.user_id', opts.currentUserId) - }) + this.where("posts.user_id", opts.currentUserId).orWhere( + "follows.user_id", + opts.currentUserId + ); + }); } if (opts.start_time && opts.end_time) { - qb.whereRaw('((posts.created_at between ? and ?) or (posts.updated_at between ? and ?))', - [opts.start_time, opts.end_time, opts.start_time, opts.end_time]) + qb.whereRaw( + "((posts.created_at between ? and ?) or (posts.updated_at between ? and ?))", + [opts.start_time, opts.end_time, opts.start_time, opts.end_time] + ); } if (opts.visibility) { - qb.whereIn('visibility', opts.visibility) + qb.whereIn("visibility", opts.visibility); } if (opts.is_public) { - qb.where('is_public', opts.is_public) + qb.where("is_public", opts.is_public); } - filterAndSortPosts({ - search: opts.term, - sortBy: opts.sort, - topic: opts.topic, - type: opts.type, - boundingBox: opts.boundingBox, - showPinnedFirst: get(opts.communities, 'length') === 1 - }, qb) + filterAndSortPosts( + { + search: opts.term, + sortBy: opts.sort, + topic: opts.topic, + type: opts.type, + boundingBox: opts.boundingBox, + showPinnedFirst: get(opts.communities, "length") === 1, + }, + qb + ); if (opts.omit) { - qb.whereNotIn('posts.id', opts.omit) + qb.whereNotIn("posts.id", opts.omit); } if (opts.communities) { - qb.select('communities_posts.pinned') - qb.join('communities_posts', 'communities_posts.post_id', '=', 'posts.id') - qb.whereIn('communities_posts.community_id', opts.communities) - qb.groupBy(['posts.id', 'communities_posts.post_id', 'communities_posts.pinned']) + qb.select("communities_posts.pinned"); + qb.join( + "communities_posts", + "communities_posts.post_id", + "=", + "posts.id" + ); + qb.whereIn("communities_posts.community_id", opts.communities); + qb.groupBy([ + "posts.id", + "communities_posts.post_id", + "communities_posts.pinned", + ]); } if (opts.networkSlugs && opts.networkSlugs.length > 0) { - qb.join('networks_posts', 'networks_posts.post_id', '=', 'posts.id') - qb.join('networks', 'networks_posts.network_id', '=', 'networks.id') - qb.whereIn('networks.slug', opts.networkSlugs) - qb.groupBy(['posts.id', 'networks_posts.post_id']) + qb.join("networks_posts", "networks_posts.post_id", "=", "posts.id"); + qb.join("networks", "networks_posts.network_id", "=", "networks.id"); + qb.whereIn("networks.slug", opts.networkSlugs); + qb.groupBy(["posts.id", "networks_posts.post_id"]); } if (opts.networks) { - qb.join('networks_posts', 'networks_posts.post_id', '=', 'posts.id') - qb.whereIn('networks_posts.network_id', opts.networks) - qb.groupBy(['posts.id', 'networks_posts.post_id']) + qb.join("networks_posts", "networks_posts.post_id", "=", "posts.id"); + qb.whereIn("networks_posts.network_id", opts.networks); + qb.groupBy(["posts.id", "networks_posts.post_id"]); } if (opts.parent_post_id) { - qb.where('parent_post_id', opts.parent_post_id) - qb.where('is_project_request', false) + qb.where("parent_post_id", opts.parent_post_id); + qb.where("is_project_request", false); } if (!opts.parent_post_id && !opts.includeChildren) { - qb.where('parent_post_id', null) + qb.where("parent_post_id", null); } - }) + }); } diff --git a/api/services/Search/forUsers.js b/api/services/Search/forUsers.js index 232f38e83..b95db3b38 100644 --- a/api/services/Search/forUsers.js +++ b/api/services/Search/forUsers.js @@ -1,68 +1,76 @@ -import { countTotal } from '../../../lib/util/knex' -import { filterAndSortUsers } from './util' +import { countTotal } from "../../../lib/util/knex"; +import { filterAndSortUsers } from "./util"; export default function (opts) { - const { communities, network } = opts + const { communities, network } = opts; return User.query(function (qb) { - qb.limit(opts.limit || 1000) - qb.offset(opts.offset || 0) - qb.where('users.active', '=', true) + qb.limit(opts.limit || 1000); + qb.offset(opts.offset || 0); + qb.where("users.active", "=", true); - filterAndSortUsers({ - autocomplete: opts.autocomplete, - boundingBox: opts.boundingBox, - search: opts.term, - sortBy: opts.sort - }, qb) + filterAndSortUsers( + { + autocomplete: opts.autocomplete, + boundingBox: opts.boundingBox, + search: opts.term, + sortBy: opts.sort, + }, + qb + ); - if (opts.sort === 'join') { + if (opts.sort === "join") { if (!communities || communities.length !== 1) { - throw new Error('When sorting by join date, you must specify exactly one community.') + throw new Error( + "When sorting by join date, you must specify exactly one community." + ); } } - countTotal(qb, 'users', opts.totalColumnName) + countTotal(qb, "users", opts.totalColumnName); // TODO perhaps the group-related code below can be refactored into // a more general-purpose form? if (communities) { - qb.join('group_memberships', 'group_memberships.user_id', 'users.id') - qb.join('groups', 'groups.id', 'group_memberships.group_id') - qb.where('groups.group_data_id', 'in', opts.communities) + qb.join("group_memberships", "group_memberships.user_id", "users.id"); + qb.join("groups", "groups.id", "group_memberships.group_id"); + qb.where("groups.group_data_id", "in", opts.communities); qb.where({ - 'groups.group_data_type': Group.DataType.COMMUNITY, - 'group_memberships.active': true - }) + "groups.group_data_type": Group.DataType.COMMUNITY, + "group_memberships.active": true, + }); } if (network) { - qb.distinct() - qb.join('group_memberships', 'group_memberships.user_id', 'users.id') - qb.join('groups', 'groups.id', 'group_memberships.group_id') - qb.join('communities', 'communities.id', 'groups.group_data_id') + qb.distinct(); + qb.join("group_memberships", "group_memberships.user_id", "users.id"); + qb.join("groups", "groups.id", "group_memberships.group_id"); + qb.join("communities", "communities.id", "groups.group_data_id"); qb.where({ - 'groups.group_data_type': Group.DataType.COMMUNITY, - 'group_memberships.active': true, - 'communities.network_id': network - }) + "groups.group_data_type": Group.DataType.COMMUNITY, + "group_memberships.active": true, + "communities.network_id": network, + }); } if (opts.start_time && opts.end_time) { - qb.whereRaw('users.created_at between ? and ?', [opts.start_time, opts.end_time]) + qb.whereRaw("users.created_at between ? and ?", [ + opts.start_time, + opts.end_time, + ]); } if (opts.exclude) { - qb.whereNotIn('id', opts.exclude) + qb.whereNotIn("id", opts.exclude); } if (network || (communities && communities.length > 1)) { // prevent duplicates due to the joins - if (opts.sort === 'join') { - qb.groupBy(['users.id', 'group_memberships.created_at']) + if (opts.sort === "join") { + qb.groupBy(["users.id", "group_memberships.created_at"]); } else { - qb.groupBy('users.id') + qb.groupBy("users.id"); } } - }) + }); } diff --git a/api/services/Search/util.js b/api/services/Search/util.js index 614e11c80..83755ce6a 100644 --- a/api/services/Search/util.js +++ b/api/services/Search/util.js @@ -1,110 +1,151 @@ -import addTermToQueryBuilder from './addTermToQueryBuilder' -import { curry, includes, values } from 'lodash' +import addTermToQueryBuilder from "./addTermToQueryBuilder"; +import { curry, includes, values } from "lodash"; export const filterAndSortPosts = curry((opts, q) => { - const { search, sortBy = 'updated', topic, showPinnedFirst, type, boundingBox } = opts + const { + search, + sortBy = "updated", + topic, + showPinnedFirst, + type, + boundingBox, + } = opts; const sortColumns = { - votes: 'num_votes', - updated: 'posts.updated_at', - created: 'posts.created_at' - } + votes: "num_votes", + updated: "posts.updated_at", + created: "posts.created_at", + }; - const sort = sortColumns[sortBy] || values(sortColumns).find(v => v === sortBy) + const sort = + sortColumns[sortBy] || values(sortColumns).find((v) => v === sortBy); if (!sort) { - throw new Error(`Cannot sort by "${sortBy}"`) + throw new Error(`Cannot sort by "${sortBy}"`); } - const { DISCUSSION, REQUEST, OFFER, PROJECT, EVENT, RESOURCE } = Post.Type - - if (!type || type === 'all' || type === 'all+welcome') { - q.where(q2 => - q2.where('posts.type', 'in', [DISCUSSION, REQUEST, OFFER, PROJECT, EVENT, RESOURCE]) - .orWhere('posts.type', null)) + const { DISCUSSION, REQUEST, OFFER, PROJECT, EVENT, RESOURCE } = Post.Type; + + if (!type || type === "all" || type === "all+welcome") { + q.where((q2) => + q2 + .where("posts.type", "in", [ + DISCUSSION, + REQUEST, + OFFER, + PROJECT, + EVENT, + RESOURCE, + ]) + .orWhere("posts.type", null) + ); } else if (type === DISCUSSION) { - q.where(q2 => - q2.where({'posts.type': null}) - .orWhere({'posts.type': DISCUSSION})) + q.where((q2) => + q2.where({ "posts.type": null }).orWhere({ "posts.type": DISCUSSION }) + ); } else { if (!includes(values(Post.Type), type)) { - throw new Error(`unknown post type: "${type}"`) + throw new Error(`unknown post type: "${type}"`); } - q.where({'posts.type': opts.type}) + q.where({ "posts.type": opts.type }); } if (search) { addTermToQueryBuilder(search, q, { - columns: ['posts.name', 'posts.description'] - }) + columns: ["posts.name", "posts.description"], + }); } if (topic) { - if (/^\d+$/.test(topic)) { // topic ID - q.join('posts_tags', 'posts_tags.post_id', 'posts.id') - q.where('posts_tags.tag_id', topic) - } else { // topic name - q.join('posts_tags', 'posts_tags.post_id', 'posts.id') - q.join('tags', 'posts_tags.tag_id', 'tags.id') - q.where('tags.name', topic) + if (/^\d+$/.test(topic)) { + // topic ID + q.join("posts_tags", "posts_tags.post_id", "posts.id"); + q.where("posts_tags.tag_id", topic); + } else { + // topic name + q.join("posts_tags", "posts_tags.post_id", "posts.id"); + q.join("tags", "posts_tags.tag_id", "tags.id"); + q.where("tags.name", topic); } } if (boundingBox) { - q.join('locations', 'locations.id', '=', 'posts.location_id') - q.whereRaw('locations.center && ST_MakeEnvelope(?, ?, ?, ?, 4326)', [boundingBox[0].lng, boundingBox[0].lat, boundingBox[1].lng, boundingBox[1].lat]) + q.join("locations", "locations.id", "=", "posts.location_id"); + q.whereRaw("locations.center && ST_MakeEnvelope(?, ?, ?, ?, 4326)", [ + boundingBox[0].lng, + boundingBox[0].lat, + boundingBox[1].lng, + boundingBox[1].lat, + ]); } - if (sort === 'posts.updated_at' && showPinnedFirst) { - q.orderByRaw('communities_posts.pinned_at is null asc, communities_posts.pinned_at desc, posts.updated_at desc') + if (sort === "posts.updated_at" && showPinnedFirst) { + q.orderByRaw( + "communities_posts.pinned_at is null asc, communities_posts.pinned_at desc, posts.updated_at desc" + ); } else if (sort) { - q.orderBy(sort, 'desc') - } - -}) - -export const filterAndSortUsers = curry(({ autocomplete, boundingBox, search, sortBy }, q) => { - if (autocomplete) { - addTermToQueryBuilder(autocomplete, q, { - columns: ['users.name'] - }) + q.orderBy(sort, "desc"); } +}); + +export const filterAndSortUsers = curry( + ({ autocomplete, boundingBox, search, sortBy }, q) => { + if (autocomplete) { + addTermToQueryBuilder(autocomplete, q, { + columns: ["users.name"], + }); + } - if (search) { - q.where('users.id', 'in', FullTextSearch.search({ - term: search, - type: 'person', - subquery: true - })) - } + if (search) { + q.where( + "users.id", + "in", + FullTextSearch.search({ + term: search, + type: "person", + subquery: true, + }) + ); + } - if (sortBy && !['name', 'location', 'join'].includes(sortBy)) { - throw new Error(`Cannot sort by "${sortBy}"`) - } + if (sortBy && !["name", "location", "join"].includes(sortBy)) { + throw new Error(`Cannot sort by "${sortBy}"`); + } - if (sortBy === 'join') { - q.orderBy('group_memberships.created_at', 'desc') - } else { - q.orderBy(sortBy || 'name', 'asc') - } + if (sortBy === "join") { + q.orderBy("group_memberships.created_at", "desc"); + } else { + q.orderBy(sortBy || "name", "asc"); + } - if (boundingBox) { - q.join('locations', 'locations.id', '=', 'users.location_id') - q.whereRaw('locations.center && ST_MakeEnvelope(?, ?, ?, ?, 4326)', [boundingBox[0].lng, boundingBox[0].lat, boundingBox[1].lng, boundingBox[1].lat]) + if (boundingBox) { + q.join("locations", "locations.id", "=", "users.location_id"); + q.whereRaw("locations.center && ST_MakeEnvelope(?, ?, ?, ?, 4326)", [ + boundingBox[0].lng, + boundingBox[0].lat, + boundingBox[1].lng, + boundingBox[1].lat, + ]); + } } -}) +); export const filterAndSortCommunities = curry((opts, q) => { - const { search, sortBy = 'name', boundingBox } = opts + const { search, sortBy = "name", boundingBox } = opts; if (search) { addTermToQueryBuilder(search, q, { - columns: ['communities.name'] - }) + columns: ["communities.name"], + }); } if (boundingBox) { - q.join('locations', 'locations.id', '=', 'communities.location_id') - q.whereRaw('locations.center && ST_MakeEnvelope(?, ?, ?, ?, 4326)', [boundingBox[0].lng, boundingBox[0].lat, boundingBox[1].lng, boundingBox[1].lat]) + q.join("locations", "locations.id", "=", "communities.location_id"); + q.whereRaw("locations.center && ST_MakeEnvelope(?, ?, ?, ?, 4326)", [ + boundingBox[0].lng, + boundingBox[0].lat, + boundingBox[1].lng, + boundingBox[1].lat, + ]); } - q.orderBy(sortBy) -}) + q.orderBy(sortBy); +}); diff --git a/api/services/Search/util.test.js b/api/services/Search/util.test.js index 170a93494..ac8662c4c 100644 --- a/api/services/Search/util.test.js +++ b/api/services/Search/util.test.js @@ -1,92 +1,109 @@ -import { filterAndSortPosts, filterAndSortCommunities } from './util' -import { expectEqualQuery } from '../../../test/setup/helpers' +import { filterAndSortPosts, filterAndSortCommunities } from "./util"; +import { expectEqualQuery } from "../../../test/setup/helpers"; -describe('filterAndSortPosts', () => { - let relation, query +describe("filterAndSortPosts", () => { + let relation, query; beforeEach(() => { - relation = Post.collection() - relation.query(q => { - query = q - spy.on(q, 'join') - spy.on(q, 'where') - }) - }) + relation = Post.collection(); + relation.query((q) => { + query = q; + spy.on(q, "join"); + spy.on(q, "where"); + }); + }); - it('accepts old sort values', () => { + it("accepts old sort values", () => { expect(() => { - filterAndSortPosts({sortBy: 'posts.updated_at'}, query) - }).not.to.throw() - }) + filterAndSortPosts({ sortBy: "posts.updated_at" }, query); + }).not.to.throw(); + }); - it('accepts new sort values', () => { + it("accepts new sort values", () => { expect(() => { - filterAndSortPosts({sortBy: 'updated'}, query) - }).not.to.throw() - }) + filterAndSortPosts({ sortBy: "updated" }, query); + }).not.to.throw(); + }); - it('rejects bad sort values', () => { + it("rejects bad sort values", () => { expect(() => { - filterAndSortPosts({sortBy: 'foo'}, query) - }).to.throw() - }) + filterAndSortPosts({ sortBy: "foo" }, query); + }).to.throw(); + }); - it('allows topic IDs', () => { - filterAndSortPosts({topic: '122'}, query) - expect(query.join).to.have.been.called.with('posts_tags', 'posts_tags.post_id', 'posts.id') - expect(query.where).to.have.been.called.with('posts_tags.tag_id', '122') - }) + it("allows topic IDs", () => { + filterAndSortPosts({ topic: "122" }, query); + expect(query.join).to.have.been.called.with( + "posts_tags", + "posts_tags.post_id", + "posts.id" + ); + expect(query.where).to.have.been.called.with("posts_tags.tag_id", "122"); + }); - it('allows topic names', () => { - filterAndSortPosts({topic: 'design'}, query) - expect(query.join).to.have.been.called.twice + it("allows topic names", () => { + filterAndSortPosts({ topic: "design" }, query); + expect(query.join).to.have.been.called.twice; expect(query.join.__spy.calls[0]).to.deep.equal([ - 'posts_tags', 'posts_tags.post_id', 'posts.id' - ]) + "posts_tags", + "posts_tags.post_id", + "posts.id", + ]); expect(query.join.__spy.calls[1]).to.deep.equal([ - 'tags', 'posts_tags.tag_id', 'tags.id' - ]) - expect(query.where).to.have.been.called.with('tags.name', 'design') - }) + "tags", + "posts_tags.tag_id", + "tags.id", + ]); + expect(query.where).to.have.been.called.with("tags.name", "design"); + }); - it('rejects bad type values', () => { + it("rejects bad type values", () => { expect(() => { - filterAndSortPosts({type: 'blah'}, query) - }).to.throw(/unknown post type/) - }) + filterAndSortPosts({ type: "blah" }, query); + }).to.throw(/unknown post type/); + }); - it('includes basic types when filter is blank', () => { - filterAndSortPosts({}, query) - expectEqualQuery(relation, `select * from "posts" + it("includes basic types when filter is blank", () => { + filterAndSortPosts({}, query); + expectEqualQuery( + relation, + `select * from "posts" where ( "posts"."type" in ('discussion', 'request', 'offer', 'project', 'event', 'resource') or "posts"."type" is null ) - order by "posts"."updated_at" desc`) - }) + order by "posts"."updated_at" desc` + ); + }); - it('includes null-typed posts as discussions', () => { - filterAndSortPosts({type: 'discussion'}, query) - expectEqualQuery(relation, `select * from "posts" + it("includes null-typed posts as discussions", () => { + filterAndSortPosts({ type: "discussion" }, query); + expectEqualQuery( + relation, + `select * from "posts" where ( "posts"."type" is null or ("posts"."type" = 'discussion') ) - order by "posts"."updated_at" desc`) - }) -}) + order by "posts"."updated_at" desc` + ); + }); +}); -describe('filterAndSortCommunities', () => { - it('supports searching', () => { - const relation = Community.collection() - relation.query(q => { - filterAndSortCommunities({search: 'foo'}, q) - }) +describe("filterAndSortCommunities", () => { + it("supports searching", () => { + const relation = Community.collection(); + relation.query((q) => { + filterAndSortCommunities({ search: "foo" }, q); + }); - expectEqualQuery(relation, `select * from "communities" + expectEqualQuery( + relation, + `select * from "communities" where ( ((to_tsvector('english', communities.name) @@ to_tsquery('foo:*'))) ) - order by "name" asc`) - }) -}) + order by "name" asc` + ); + }); +}); diff --git a/api/services/Slack.js b/api/services/Slack.js index 6e1e18188..0deaa4439 100644 --- a/api/services/Slack.js +++ b/api/services/Slack.js @@ -1,28 +1,38 @@ -var format = require('util').format -var Promise = require('bluebird') -var request = require('request') -var post = Promise.promisify(request.post) +const format = require("util").format; +const Promise = require("bluebird"); +const request = require("request"); +const post = Promise.promisify(request.post); module.exports = { textForNewPost: function (post, community) { - var relatedUser - const creator = post.relations.user + let relatedUser; + const creator = post.relations.user; if (post.isWelcome()) { - relatedUser = post.relations.relatedUsers.first() - return format('<%s|%s> joined <%s|%s>', - Frontend.Route.profile(relatedUser), relatedUser.get('name'), - Frontend.Route.community(community), community.get('name')) + relatedUser = post.relations.relatedUsers.first(); + return format( + "<%s|%s> joined <%s|%s>", + Frontend.Route.profile(relatedUser), + relatedUser.get("name"), + Frontend.Route.community(community), + community.get("name") + ); } else { - return format('<%s|%s> posted <%s|%s> in <%s|%s>', - Frontend.Route.profile(creator), creator.get('name'), - Frontend.Route.post(post, community), post.get('name'), - Frontend.Route.community(community), community.get('name')) + return format( + "<%s|%s> posted <%s|%s> in <%s|%s>", + Frontend.Route.profile(creator), + creator.get("name"), + Frontend.Route.post(post, community), + post.get("name"), + Frontend.Route.community(community), + community.get("name") + ); } }, send: (message, uri) => - !process.env.DISABLE_SLACK_INTEGRATION && post({ + !process.env.DISABLE_SLACK_INTEGRATION && + post({ uri, - body: {text: message}, - json: true // Automatically stringifies the body to JSON - }) -} + body: { text: message }, + json: true, // Automatically stringifies the body to JSON + }), +}; diff --git a/api/services/TagManagement.js b/api/services/TagManagement.js index 0566a8040..8843e3959 100644 --- a/api/services/TagManagement.js +++ b/api/services/TagManagement.js @@ -1,25 +1,35 @@ -import { reduce, toPairs, trim } from 'lodash' -import { filter, sortBy } from 'lodash/fp' +import { reduce, toPairs, trim } from "lodash"; +import { filter, sortBy } from "lodash/fp"; -const cleanName = name => - trim(name.toLowerCase(), ' -').replace(/-{2,}/, '-') +const cleanName = (name) => + trim(name.toLowerCase(), " -").replace(/-{2,}/, "-"); export const cleanupAll = () => { - return Tag.query().select(['id', 'name']) - .then(rows => reduce(rows, (groups, row) => { - const name = cleanName(row.name) - if (!groups[name]) groups[name] = [] - groups[name].push(row) - return groups - }, {})) - .then(groups => Promise.map(toPairs(groups), ([name, tags]) => { - const primaryTag = sortBy('id', tags)[0] - if (tags.length === 1) return - console.log(`${name}: ${tags.length}`) + return Tag.query() + .select(["id", "name"]) + .then((rows) => + reduce( + rows, + (groups, row) => { + const name = cleanName(row.name); + if (!groups[name]) groups[name] = []; + groups[name].push(row); + return groups; + }, + {} + ) + ) + .then((groups) => + Promise.map(toPairs(groups), ([name, tags]) => { + const primaryTag = sortBy("id", tags)[0]; + if (tags.length === 1) return; + console.log(`${name}: ${tags.length}`); - const otherTags = filter(t => t.id !== primaryTag.id, tags) - return Promise.map(otherTags, t => Tag.merge(primaryTag.id, t.id)) - .then(() => Tag.where('id', primaryTag.id).query().update({name})) - })) - .then(() => 'ok') -} + const otherTags = filter((t) => t.id !== primaryTag.id, tags); + return Promise.map(otherTags, (t) => + Tag.merge(primaryTag.id, t.id) + ).then(() => Tag.where("id", primaryTag.id).query().update({ name })); + }) + ) + .then(() => "ok"); +}; diff --git a/api/services/UserImport.js b/api/services/UserImport.js index d35cb92c9..0772598af 100644 --- a/api/services/UserImport.js +++ b/api/services/UserImport.js @@ -10,214 +10,221 @@ */ -import { WritableStreamBuffer } from 'stream-buffers' -const request = require('request') -const fs = require('fs') -const csv = require('csv-parser') -const validator = require('validator') -const _ = require('lodash') - -const promisifyStream = stream => +import { WritableStreamBuffer } from "stream-buffers"; +const request = require("request"); +const fs = require("fs"); +const csv = require("csv-parser"); +const validator = require("validator"); +const _ = require("lodash"); + +const promisifyStream = (stream) => new Promise((resolve, reject) => { - stream.on('end', resolve) - stream.on('error', reject) - }) + stream.on("end", resolve); + stream.on("error", reject); + }); const getStream = ({ url, filename, stream }) => { - if (stream) return stream - if (url) return request(url) - return fs.createReadStream(filename) -} + if (stream) return stream; + if (url) return request(url); + return fs.createReadStream(filename); +}; -const getConverter = convert => { +const getConverter = (convert) => { // you can pass a function that maps from arbitrary values // to the field names that the User model expects if (!convert) { - return row => row - } else if (typeof convert === 'string') { - return converters[convert] + return (row) => row; + } else if (typeof convert === "string") { + return converters[convert]; } else { - return convert + return convert; } -} +}; const runWithCSVStream = function (stream, options, rowAction) { - stream.pipe(csv({headers: options.headers})).on('data', rowAction) - return promisifyStream(stream) -} + stream.pipe(csv({ headers: options.headers })).on("data", rowAction); + return promisifyStream(stream); +}; const runWithJSONStream = function (stream, options, rowAction) { - const buffer = new WritableStreamBuffer() - const promise = promisifyStream(stream) - stream.pipe(buffer) + const buffer = new WritableStreamBuffer(); + const promise = promisifyStream(stream); + stream.pipe(buffer); return promise.then(() => { - const data = JSON.parse(buffer.getContentsAsString()) - return Promise.map(data, rowAction) - }) -} + const data = JSON.parse(buffer.getContentsAsString()); + return Promise.map(data, rowAction); + }); +}; -export function createUser (attrs, options) { - if (!attrs) return - const { verbose, community, dryRun } = options - const { name, email } = attrs +export function createUser(attrs, options) { + if (!attrs) return; + const { verbose, community, dryRun } = options; + const { name, email } = attrs; if (!validator.isEmail(email)) { - console.error('invalid email for ' + name) - return + console.error("invalid email for " + name); + return; } - return User.isEmailUnique(email) - .then(unique => { + return User.isEmailUnique(email).then((unique) => { if (!unique) { - if (verbose) console.error('email already exists: ' + email) - return + if (verbose) console.error("email already exists: " + email); + return; } if (dryRun) { - console.log(`dry run: ${name} <${email}>`) - return + console.log(`dry run: ${name} <${email}>`); + return; } // TODO handle skills as tags - return User.create(_.merge(attrs, { - community: community, - settings: { - digest_frequency: 'weekly' - }, - created_at: new Date(), - updated_at: new Date() - })) - .tap(user => { - if (community && community.id === '9') { + return User.create( + _.merge(attrs, { + community: community, + settings: { + digest_frequency: "weekly", + }, + created_at: new Date(), + updated_at: new Date(), + }) + ).tap((user) => { + if (community && community.id === "9") { return Email.sendSimpleEmail( - user.get('email'), 'tem_GC822hsXScRMV23pddPNZM', - {recipient_name: name.split(' ')[0]}, - {sender: {name: 'Impact Hub Oakland'}} - ) + user.get("email"), + "tem_GC822hsXScRMV23pddPNZM", + { recipient_name: name.split(" ")[0] }, + { sender: { name: "Impact Hub Oakland" } } + ); } - }) - }) + }); + }); } export const converters = { idin: function (row) { return { - name: format('%s %s', row['First Name'], row['Last Name']), - email: row['Email'], - skills: _.compact(row['Areas of Expertise'].split(/; ?/)), - organizations: _.compact(row['Organization'].split(/; ?/)) - .concat(row['IDDS Attended'].split(/; ?/)) - .concat(row['Country of Residence']), - bio: row['Biography'], - work: row['Current Projects'] - } + name: format("%s %s", row["First Name"], row["Last Name"]), + email: row.Email, + skills: _.compact(row["Areas of Expertise"].split(/; ?/)), + organizations: _.compact(row.Organization.split(/; ?/)) + .concat(row["IDDS Attended"].split(/; ?/)) + .concat(row["Country of Residence"]), + bio: row.Biography, + work: row["Current Projects"], + }; }, sustainableHuman: function (row) { - var rawSkills = row['Skills'] - - var skills = _.compact(rawSkills.split(/[,#\n]/).map(s => { - if (!s) return - return s.trim() - .split(/(?=[A-Z])/g).map(w => w.toLowerCase()).join(' ') - .replace(/- /g, '-') - .replace(/ ([a-z])\b/g, '$1') - .replace(/ {2,}/g, ' ') - })) - - var attrs = _.pickBy({ - name: `${row['Name First']} ${row['Name Last']}`, - email: row['Email'], - bio: row['Short Bio (175 char)'], - intention: row['Intention'], - extra_info: row['Personal Website'], - facebook_url: row['Facebook URL'], - twitter_name: row['Twitter URL'].replace(/.*twitter.com\/@?/, ''), - linkedin_url: row['Linked In URL'], - skills - }) + const rawSkills = row.Skills; + + const skills = _.compact( + rawSkills.split(/[,#\n]/).map((s) => { + if (!s) return; + return s + .trim() + .split(/(?=[A-Z])/g) + .map((w) => w.toLowerCase()) + .join(" ") + .replace(/- /g, "-") + .replace(/ ([a-z])\b/g, "$1") + .replace(/ {2,}/g, " "); + }) + ); + + const attrs = _.pickBy({ + name: `${row["Name First"]} ${row["Name Last"]}`, + email: row.Email, + bio: row["Short Bio (175 char)"], + intention: row.Intention, + extra_info: row["Personal Website"], + facebook_url: row["Facebook URL"], + twitter_name: row["Twitter URL"].replace(/.*twitter.com\/@?/, ""), + linkedin_url: row["Linked In URL"], + skills, + }); // console.log(`${attrs.name}: ${skills.join(' _ ')}`) - return attrs - } -} + return attrs; + }, +}; export const operations = { sustainableHumanIntentions: function (row, options) { - var intention = row['Intention'].trim() - if (!intention) return false + const intention = row.Intention.trim(); + if (!intention) return false; - var charLimit = 140 - var limitIndex = intention.indexOf(' ', charLimit - 10) - var sentenceEnd = intention.indexOf('. ') - var title, details + const charLimit = 140; + const limitIndex = intention.indexOf(" ", charLimit - 10); + const sentenceEnd = intention.indexOf(". "); + let title, details; if (intention.length <= charLimit) { - title = intention - details = '' + title = intention; + details = ""; } else if (sentenceEnd > -1 && sentenceEnd < limitIndex) { - title = intention.substring(0, sentenceEnd + 1) - details = intention.substring(sentenceEnd + 2).trim() + title = intention.substring(0, sentenceEnd + 1); + details = intention.substring(sentenceEnd + 2).trim(); } else { - title = intention.substring(0, limitIndex) + '...' - details = '...' + intention.substring(limitIndex + 1) + title = intention.substring(0, limitIndex) + "..."; + details = "..." + intention.substring(limitIndex + 1); } if (details) { details = details - .replace(/\n{2,}/, '\n') - .replace(/^(.+)$/mg, '

$1

') + .replace(/\n{2,}/, "\n") + .replace(/^(.+)$/gm, "

$1

"); } - return User.find(row['Email']) - .then(user => { - var email = user.get('email') - console.log(`${email}\nTITLE: ${title}\nDETAILS: ${details}\n`) + return User.find(row.Email).then((user) => { + const email = user.get("email"); + console.log(`${email}\nTITLE: ${title}\nDETAILS: ${details}\n`); return Post.create({ name: title, description: details, user_id: user.id, - type: 'intention' - }) - .then(post => Promise.join( - options.community.posts().attach(post.id), - post.followers().attach(user.id) - )) - }) - } -} + type: "intention", + }).then((post) => + Promise.join( + options.community.posts().attach(post.id), + post.followers().attach(user.id) + ) + ); + }); + }, +}; -export const runImport = options => { - if (!options.community) throw new Error('No community specified') +export const runImport = (options) => { + if (!options.community) throw new Error("No community specified"); - const method = options.json ? runWithJSONStream : runWithCSVStream - return method(getStream(options), options, row => { - const convert = getConverter(options.convert) - return createUser(convert(row, options), options) - }) -} + const method = options.json ? runWithJSONStream : runWithCSVStream; + return method(getStream(options), options, (row) => { + const convert = getConverter(options.convert); + return createUser(convert(row, options), options); + }); +}; export const run = (options) => { - var promises = [] - return runWithCSVStream(getStream(options), options, row => { - var op = operations[options.operation] - promises.push(op(row, options)) - }) - .then(() => Promise.all(promises)) -} + const promises = []; + return runWithCSVStream(getStream(options), options, (row) => { + const op = operations[options.operation]; + promises.push(op(row, options)); + }).then(() => Promise.all(promises)); +}; if (require.main === module) { - var skiff = require('../../lib/skiff') + const skiff = require("../../lib/skiff"); skiff.lift({ - log: {level: 'warn'}, + log: { level: "warn" }, start: () => - Community.find('impact-hub-baltimore') - .then(community => runImport({ - json: true, - filename: './nexudus.json', - community - })) - .then(skiff.lower) - }) + Community.find("impact-hub-baltimore") + .then((community) => + runImport({ + json: true, + filename: "./nexudus.json", + community, + }) + ) + .then(skiff.lower), + }); } diff --git a/api/services/UserManagement.js b/api/services/UserManagement.js index a70628cdc..507f9393a 100644 --- a/api/services/UserManagement.js +++ b/api/services/UserManagement.js @@ -1,154 +1,174 @@ const userFieldsToCopy = [ - 'avatar_url', - 'banner_url', - 'bio', - 'email', - 'extra_info', - 'facebook_url', - 'first_name', - 'last_login_at', - 'last_name', - 'name', - 'intention', - 'linkedin_url', - 'twitter_name', - 'tagline', - 'work' -] + "avatar_url", + "banner_url", + "bio", + "email", + "extra_info", + "facebook_url", + "first_name", + "last_login_at", + "last_name", + "name", + "intention", + "linkedin_url", + "twitter_name", + "tagline", + "work", +]; // knex is passed as an argument here because it can be a transaction object // see http://knexjs.org/#Transactions const generateMergeQueries = function (userId, duplicateUserId, knex) { - var ps = [userId, duplicateUserId] - var psp = [userId, duplicateUserId, userId] - var updates = [] - var push = (q, values) => updates.push(knex.raw(q, values)) + const ps = [userId, duplicateUserId]; + const psp = [userId, duplicateUserId, userId]; + const updates = []; + const push = (q, values) => updates.push(knex.raw(q, values)); // simple updates - ;[ + [ // table name, user id column - ['devices', 'user_id'], - ['posts', 'user_id'], - ['posts_about_users', 'user_id'], - ['posts', 'deactivated_by_id'], - ['activities', 'actor_id'], - ['comments', 'user_id'], - ['comments', 'deactivated_by_id'], - ['follows', 'added_by_id'], - ['thanks', 'user_id'], - ['community_invites', 'invited_by_id'], - ['community_invites', 'used_by_id'], - ['user_external_data', 'user_id'], - ['communities', 'leader_id'] - ].forEach(args => { - var table = args[0] - var userCol = args[1] - push(`update ${table} set ${userCol} = ? where ${userCol} = ?`, ps) - }) + ["devices", "user_id"], + ["posts", "user_id"], + ["posts_about_users", "user_id"], + ["posts", "deactivated_by_id"], + ["activities", "actor_id"], + ["comments", "user_id"], + ["comments", "deactivated_by_id"], + ["follows", "added_by_id"], + ["thanks", "user_id"], + ["community_invites", "invited_by_id"], + ["community_invites", "used_by_id"], + ["user_external_data", "user_id"], + ["communities", "leader_id"], + ].forEach((args) => { + const table = args[0]; + const userCol = args[1]; + push(`update ${table} set ${userCol} = ? where ${userCol} = ?`, ps); + }); // updates where we have to avoid duplicate records - ;[ + [ // table name, user id column, column with unique value - ['communities_users', 'user_id', 'community_id'], - ['contributions', 'user_id', 'post_id'], - ['follows', 'user_id', 'post_id'], - ['linked_account', 'user_id', 'provider_user_id'], - ['votes', 'user_id', 'post_id'], - ['tag_follows', 'user_id', 'tag_id'], - ['communities_tags', 'user_id', 'tag_id'], - ['thanks', 'thanked_by_id', 'comment_id'], - ['posts_users', 'user_id', 'post_id'] - ].forEach(args => { - var table = args[0] - var userCol = args[1] - var uniqueCol = args[2] - push(`update ${table} set ${userCol} = ? ` + - `where ${userCol} = ? and ${uniqueCol} not in ` + - `(select ${uniqueCol} from ${table} where ${userCol} = ?)`, psp) - }) + ["communities_users", "user_id", "community_id"], + ["contributions", "user_id", "post_id"], + ["follows", "user_id", "post_id"], + ["linked_account", "user_id", "provider_user_id"], + ["votes", "user_id", "post_id"], + ["tag_follows", "user_id", "tag_id"], + ["communities_tags", "user_id", "tag_id"], + ["thanks", "thanked_by_id", "comment_id"], + ["posts_users", "user_id", "post_id"], + ].forEach((args) => { + const table = args[0]; + const userCol = args[1]; + const uniqueCol = args[2]; + push( + `update ${table} set ${userCol} = ? ` + + `where ${userCol} = ? and ${uniqueCol} not in ` + + `(select ${uniqueCol} from ${table} where ${userCol} = ?)`, + psp + ); + }); - return {updates, deletes: generateRemoveQueries(duplicateUserId, knex)} -} + return { updates, deletes: generateRemoveQueries(duplicateUserId, knex) }; +}; const generateRemoveQueries = function (userId, knex) { - var removals = [] - var push = (q, values) => removals.push(knex.raw(q, values)) + const removals = []; + const push = (q, values) => removals.push(knex.raw(q, values)); // clear columns without deleting rows - ;[ - ['comments', 'deactivated_by_id'], - ['communities', 'created_by_id'], - ['follows', 'added_by_id'], - ['communities_tags', 'user_id'] - ].forEach(args => { - var table = args[0] - var userCol = args[1] - push(`update ${table} set ${userCol} = null where ${userCol} = ?`, userId) - }) + [ + ["comments", "deactivated_by_id"], + ["communities", "created_by_id"], + ["follows", "added_by_id"], + ["communities_tags", "user_id"], + ].forEach((args) => { + const table = args[0]; + const userCol = args[1]; + push(`update ${table} set ${userCol} = null where ${userCol} = ?`, userId); + }); // cascading deletes - push('delete from thanks where comment_id in ' + - '(select id from comments where user_id = ?)', userId) - push('delete from notifications where activity_id in ' + - '(select id from activities where reader_id = ?)', userId) - push('delete from notifications where activity_id in ' + - '(select id from activities where actor_id = ?)', userId) + push( + "delete from thanks where comment_id in " + + "(select id from comments where user_id = ?)", + userId + ); + push( + "delete from notifications where activity_id in " + + "(select id from activities where reader_id = ?)", + userId + ); + push( + "delete from notifications where activity_id in " + + "(select id from activities where actor_id = ?)", + userId + ); // deletes - ;[ + [ // table, user id column - ['devices', 'user_id'], - ['communities_users', 'user_id'], - ['community_invites', 'invited_by_id'], - ['community_invites', 'used_by_id'], - ['contributions', 'user_id'], - ['follows', 'user_id'], - ['linked_account', 'user_id'], - ['user_post_relevance', 'user_id'], - ['activities', 'reader_id'], - ['activities', 'actor_id'], - ['votes', 'user_id'], - ['comments', 'user_id'], - ['user_external_data', 'user_id'], - ['tag_follows', 'user_id'], - ['posts_about_users', 'user_id'], - ['posts_users', 'user_id'], - ['thanks', 'thanked_by_id'], - ['users', 'id'] - ].forEach(args => { - var table = args[0] - var userCol = args[1] - push(`delete from ${table} where ${userCol} = ?`, userId) - }) + ["devices", "user_id"], + ["communities_users", "user_id"], + ["community_invites", "invited_by_id"], + ["community_invites", "used_by_id"], + ["contributions", "user_id"], + ["follows", "user_id"], + ["linked_account", "user_id"], + ["user_post_relevance", "user_id"], + ["activities", "reader_id"], + ["activities", "actor_id"], + ["votes", "user_id"], + ["comments", "user_id"], + ["user_external_data", "user_id"], + ["tag_follows", "user_id"], + ["posts_about_users", "user_id"], + ["posts_users", "user_id"], + ["thanks", "thanked_by_id"], + ["users", "id"], + ].forEach((args) => { + const table = args[0]; + const userCol = args[1]; + push(`delete from ${table} where ${userCol} = ?`, userId); + }); - return removals -} + return removals; +}; module.exports = { // this does not delete posts! - removeUser: userId => - bookshelf.knex.transaction(trx => - Promise.all(generateRemoveQueries(userId, trx))), + removeUser: (userId) => + bookshelf.knex.transaction((trx) => + Promise.all(generateRemoveQueries(userId, trx)) + ), mergeUsers: (userId, duplicateUserId) => { - var queries + let queries; - return bookshelf.knex.transaction(trx => { - queries = generateMergeQueries(userId, duplicateUserId, trx) + return bookshelf.knex + .transaction((trx) => { + queries = generateMergeQueries(userId, duplicateUserId, trx); - return Promise.join( - User.find(userId, {transacting: trx}), - User.find(duplicateUserId, {transacting: trx}) - ) - .spread((user, dupe) => { - userFieldsToCopy.forEach(f => user.get(f) || user.set(f, dupe.get(f))) + return Promise.join( + User.find(userId, { transacting: trx }), + User.find(duplicateUserId, { transacting: trx }) + ) + .spread((user, dupe) => { + userFieldsToCopy.forEach( + (f) => user.get(f) || user.set(f, dupe.get(f)) + ); - return _.isEmpty(user.changed) || - user.save(user.changed, {patch: true, transacting: trx}) + return ( + _.isEmpty(user.changed) || + user.save(user.changed, { patch: true, transacting: trx }) + ); + }) + .then(() => Promise.all(queries.updates)) + .then(() => Promise.all(queries.deletes)); }) - .then(() => Promise.all(queries.updates)) - .then(() => Promise.all(queries.deletes)) - }) - .then(() => queries.updates.concat(queries.deletes).map(q => q.toSQL())) - } -} + .then(() => + queries.updates.concat(queries.deletes).map((q) => q.toSQL()) + ); + }, +}; diff --git a/api/services/UserSession.js b/api/services/UserSession.js index 5286c73c1..d082e45b4 100644 --- a/api/services/UserSession.js +++ b/api/services/UserSession.js @@ -1,34 +1,34 @@ -import { omitBy, isNil } from 'lodash/fp' +import { omitBy, isNil } from "lodash/fp"; module.exports = { // logic for setting up the session when a user logs in login: function (req, user, providerKey) { - req.session.authenticated = true - req.session.userId = user.id - req.session.userProvider = providerKey - req.rollbar_person = user.pick('id', 'name', 'email') - req.session.version = this.version + req.session.authenticated = true; + req.session.userId = user.id; + req.session.userProvider = providerKey; + req.rollbar_person = user.pick("id", "name", "email"); + req.session.version = this.version; - if (providerKey === 'admin' || providerKey === 'token') return + if (providerKey === "admin" || providerKey === "token") return; - if (req.headers['ios-version'] || req.headers['android-version']) { + if (req.headers["ios-version"] || req.headers["android-version"]) { const properties = omitBy(isNil, { - iosVersion: req.headers['ios-version'], - androidVersion: req.headers['android-version'] - }) + iosVersion: req.headers["ios-version"], + androidVersion: req.headers["android-version"], + }); Analytics.track({ userId: user.id, - event: 'Login from mobile app', - properties - }) + event: "Login from mobile app", + properties, + }); } - return user.save({last_login_at: new Date()}, {patch: true}) + return user.save({ last_login_at: new Date() }, { patch: true }); }, isLoggedIn: function (req) { - return !!req.session.authenticated && req.session.version === this.version + return !!req.session.authenticated && req.session.version === this.version; }, // if you change the keys that are added to the session above, @@ -38,5 +38,5 @@ module.exports = { // note that if you want to delete a key from existing sessions, // you'll have to add "delete req.session.foo" // - version: '4' -} + version: "4", +}; diff --git a/api/services/Validation.js b/api/services/Validation.js index e3986ffbe..d252e2a95 100644 --- a/api/services/Validation.js +++ b/api/services/Validation.js @@ -2,28 +2,43 @@ module.exports = { validate: function (params, model, allowedColumns, allowedConstraints) { // prevent SQL injection if (!_.includes(allowedColumns, params.column)) { - return Promise.resolve({badRequest: format('invalid value "%s" for parameter "column"', params.column)}) + return Promise.resolve({ + badRequest: format( + 'invalid value "%s" for parameter "column"', + params.column + ), + }); } if (!params.value) { - return Promise.resolve({badRequest: 'missing required parameter "value"'}) + return Promise.resolve({ + badRequest: 'missing required parameter "value"', + }); } if (!_.includes(allowedConstraints, params.constraint)) { - return Promise.resolve({badRequest: format('invalid value "%s" for parameter "constraint"', params.constraint)}) + return Promise.resolve({ + badRequest: format( + 'invalid value "%s" for parameter "constraint"', + params.constraint + ), + }); } - var statement = format('lower(%s) = lower(?)', params.column) - return model.query().whereRaw(statement, params.value).count() - .then(function (rows) { - var data - if (params.constraint === 'unique') { - data = {unique: Number(rows[0].count) === 0} - } else if (params.constraint === 'exists') { - var exists = Number(rows[0].count) >= 1 - data = {exists: exists} - } - return data - }) - } -} + const statement = format("lower(%s) = lower(?)", params.column); + return model + .query() + .whereRaw(statement, params.value) + .count() + .then(function (rows) { + let data; + if (params.constraint === "unique") { + data = { unique: Number(rows[0].count) === 0 }; + } else if (params.constraint === "exists") { + const exists = Number(rows[0].count) >= 1; + data = { exists: exists }; + } + return data; + }); + }, +}; diff --git a/api/services/Websockets.js b/api/services/Websockets.js index 65aecbedf..0812a6187 100644 --- a/api/services/Websockets.js +++ b/api/services/Websockets.js @@ -1,69 +1,73 @@ -import { cyan } from 'chalk' -import rollbar from '../../lib/rollbar' -import emitter from 'socket.io-emitter' +import { cyan } from "chalk"; +import rollbar from "../../lib/rollbar"; +import emitter from "socket.io-emitter"; const validMessageTypes = [ - 'commentAdded', - 'messageAdded', - 'userTyping', - 'newThread', - 'newNotification', - 'newPost' -] + "commentAdded", + "messageAdded", + "userTyping", + "newThread", + "newNotification", + "newPost", +]; -var io +let io; -export function broadcast (room, messageType, payload, socketToExclude) { +export function broadcast(room, messageType, payload, socketToExclude) { if (sails.sockets) { - sails.sockets.broadcast(room, messageType, payload, socketToExclude) + sails.sockets.broadcast(room, messageType, payload, socketToExclude); } else { if (!io) { - io = emitter(process.env.REDIS_URL) - io.redis.on('error', err => { - rollbar.error(err, null, {room, messageType, payload}) - }) + io = emitter(process.env.REDIS_URL); + io.redis.on("error", (err) => { + rollbar.error(err, null, { room, messageType, payload }); + }); } - io.in(room).emit(messageType, payload) // TODO handle socketToExclude + io.in(room).emit(messageType, payload); // TODO handle socketToExclude } } -export function pushToSockets (room, messageType, payload, socketToExclude) { +export function pushToSockets(room, messageType, payload, socketToExclude) { if (!validMessageTypes.includes(messageType)) { - throw new Error(`unknown message type: ${messageType}`) + throw new Error(`unknown message type: ${messageType}`); } - sails.log.info(`${cyan('Websockets:')} pushToSockets: ${room}, ${messageType}`) - if (process.env.NODE_ENV === 'test') return Promise.resolve({room, messageType, payload}) - broadcast(room, messageType, payload, socketToExclude) - return Promise.resolve() + sails.log.info( + `${cyan("Websockets:")} pushToSockets: ${room}, ${messageType}` + ); + if (process.env.NODE_ENV === "test") + return Promise.resolve({ room, messageType, payload }); + broadcast(room, messageType, payload, socketToExclude); + return Promise.resolve(); } -const makeRoomAction = method => (req, res, type, id, options = {}) => { - const callback = options.callback || emptyResponse(res) - const room = roomTypes[type](id) - sails.log.info(`${cyan('Websockets:')} ${method}: ${room}`) - return sails.sockets[method](req, room, callback) -} +const makeRoomAction = (method) => (req, res, type, id, options = {}) => { + const callback = options.callback || emptyResponse(res); + const room = roomTypes[type](id); + sails.log.info(`${cyan("Websockets:")} ${method}: ${room}`); + return sails.sockets[method](req, room, callback); +}; -export const joinRoom = makeRoomAction('join') -export const leaveRoom = makeRoomAction('leave') +export const joinRoom = makeRoomAction("join"); +export const leaveRoom = makeRoomAction("leave"); -export function userRoom (userId) { - return `users/${userId}` +export function userRoom(userId) { + return `users/${userId}`; } -export function postRoom (postId) { - return `posts/${postId}` +export function postRoom(postId) { + return `posts/${postId}`; } -export function communityRoom (communityId) { - return `communities/${communityId}` +export function communityRoom(communityId) { + return `communities/${communityId}`; } const roomTypes = { user: userRoom, post: postRoom, - community: communityRoom -} + community: communityRoom, +}; -const emptyResponse = res => err => err ? res.serverError(err) : res.ok({}) +const emptyResponse = (res) => (err) => + err ? res.serverError(err) : res.ok({}); diff --git a/app.js b/app.js index d9c2e56a3..45ce3675c 100644 --- a/app.js +++ b/app.js @@ -18,73 +18,84 @@ * `node app.js --silent --port=80 --prod` */ -require('dotenv').load() -require('babel-register') +require("dotenv").load(); +require("babel-register"); if (process.env.NEW_RELIC_LICENSE_KEY) { - require('newrelic') + require("newrelic"); } -if (process.env.ROLLBAR_SERVER_TOKEN && process.env.NODE_ENV !== 'test') { - var rollbar = require('rollbar') +if (process.env.ROLLBAR_SERVER_TOKEN && process.env.NODE_ENV !== "test") { + const rollbar = require("rollbar"); rollbar.init({ accessToken: process.env.ROLLBAR_SERVER_TOKEN, captureUncaught: true, - captureUnhandledRejections: true - }) + captureUnhandledRejections: true, + }); } -const { merge } = require('lodash') -const chalk = require('chalk') -chalk.enabled = true -const { blue, yellow } = chalk +const { merge } = require("lodash"); +const chalk = require("chalk"); +chalk.enabled = true; +const { blue, yellow } = chalk; // Ensure we're in the project directory, so relative paths work as expected // no matter where we actually lift from. -process.chdir(__dirname) +process.chdir(__dirname); // Ensure a "sails" can be located: -;(function () { - var sails +(function () { + let sails; try { - sails = require('sails') + sails = require("sails"); } catch (e) { - console.error('To run an app using `node app.js`, you usually need to have a version of `sails` installed in the same directory as your app.') - console.error('To do that, run `npm install sails`') - console.error('') - console.error('Alternatively, if you have sails installed globally (i.e. you did `npm install -g sails`), you can use `sails lift`.') - console.error('When you run `sails lift`, your app will still use a local `./node_modules/sails` dependency if it exists,') - console.error("but if it doesn't, the app will run with the global sails instead!") - return + console.error( + "To run an app using `node app.js`, you usually need to have a version of `sails` installed in the same directory as your app." + ); + console.error("To do that, run `npm install sails`"); + console.error(""); + console.error( + "Alternatively, if you have sails installed globally (i.e. you did `npm install -g sails`), you can use `sails lift`." + ); + console.error( + "When you run `sails lift`, your app will still use a local `./node_modules/sails` dependency if it exists," + ); + console.error( + "but if it doesn't, the app will run with the global sails instead!" + ); + return; } // Try to get `rc` dependency - var rc + let rc; try { - rc = require('rc') + rc = require("rc"); } catch (e0) { try { - rc = require('sails/node_modules/rc') + rc = require("sails/node_modules/rc"); } catch (e1) { - console.error('Could not find dependency: `rc`.') - console.error('Your `.sailsrc` file(s) will be ignored.') - console.error('To resolve this, run:') - console.error('npm install rc --save') - rc = () => {} + console.error("Could not find dependency: `rc`."); + console.error("Your `.sailsrc` file(s) will be ignored."); + console.error("To resolve this, run:"); + console.error("npm install rc --save"); + rc = () => {}; } } // Start server - sails.log.info(yellow('Lifting...')) - sails.lift(merge(rc('sails'), { - log: {noShip: true} - }), function (err) { - if (err) { - sails.log.error(err.stack) - } else { - sails.log.info(blue('Aloft.')) + sails.log.info(yellow("Lifting...")); + sails.lift( + merge(rc("sails"), { + log: { noShip: true }, + }), + function (err) { + if (err) { + sails.log.error(err.stack); + } else { + sails.log.info(blue("Aloft.")); + } } - }) + ); - module.exports = sails -})() + module.exports = sails; +})(); diff --git a/app.json b/app.json index fad75f4b0..fdeb9395c 100644 --- a/app.json +++ b/app.json @@ -1,17 +1,11 @@ { "name": "hylo-node", - "scripts": { - }, + "scripts": {}, "env": { "COOKIE_DOMAIN": "" }, - "formation": { - }, - "addons": [ - "redisgreen", - "scheduler", - "heroku-postgresql" - ], + "formation": {}, + "addons": ["redisgreen", "scheduler", "heroku-postgresql"], "buildpacks": [ { "url": "https://github.com/mcollina/heroku-buildpack-graphicsmagick" diff --git a/config/blueprints.js b/config/blueprints.js index c42374353..a652a04f9 100644 --- a/config/blueprints.js +++ b/config/blueprints.js @@ -24,127 +24,125 @@ */ module.exports.blueprints = { - /*************************************************************************** - * * - * Action routes speed up the backend development workflow by * - * eliminating the need to manually bind routes. When enabled, GET, POST, * - * PUT, and DELETE routes will be generated for every one of a controller's * - * actions. * - * * - * If an `index` action exists, additional naked routes will be created for * - * it. Finally, all `actions` blueprints support an optional path * - * parameter, `id`, for convenience. * - * * - * `actions` are enabled by default, and can be OK for production-- * - * however, if you'd like to continue to use controller/action autorouting * - * in a production deployment, you must take great care not to * - * inadvertently expose unsafe/unintentional controller logic to GET * - * requests. * - * * - ***************************************************************************/ + * * + * Action routes speed up the backend development workflow by * + * eliminating the need to manually bind routes. When enabled, GET, POST, * + * PUT, and DELETE routes will be generated for every one of a controller's * + * actions. * + * * + * If an `index` action exists, additional naked routes will be created for * + * it. Finally, all `actions` blueprints support an optional path * + * parameter, `id`, for convenience. * + * * + * `actions` are enabled by default, and can be OK for production-- * + * however, if you'd like to continue to use controller/action autorouting * + * in a production deployment, you must take great care not to * + * inadvertently expose unsafe/unintentional controller logic to GET * + * requests. * + * * + ***************************************************************************/ actions: false, /*************************************************************************** - * * - * RESTful routes (`sails.config.blueprints.rest`) * - * * - * REST blueprints are the automatically generated routes Sails uses to * - * expose a conventional REST API on top of a controller's `find`, * - * `create`, `update`, and `destroy` actions. * - * * - * For example, a BoatController with `rest` enabled generates the * - * following routes: * - * ::::::::::::::::::::::::::::::::::::::::::::::::::::::: * - * GET /boat/:id? -> BoatController.find * - * POST /boat -> BoatController.create * - * PUT /boat/:id -> BoatController.update * - * DELETE /boat/:id -> BoatController.destroy * - * * - * `rest` blueprint routes are enabled by default, and are suitable for use * - * in a production scenario, as long you take standard security precautions * - * (combine w/ policies, etc.) * - * * - ***************************************************************************/ + * * + * RESTful routes (`sails.config.blueprints.rest`) * + * * + * REST blueprints are the automatically generated routes Sails uses to * + * expose a conventional REST API on top of a controller's `find`, * + * `create`, `update`, and `destroy` actions. * + * * + * For example, a BoatController with `rest` enabled generates the * + * following routes: * + * ::::::::::::::::::::::::::::::::::::::::::::::::::::::: * + * GET /boat/:id? -> BoatController.find * + * POST /boat -> BoatController.create * + * PUT /boat/:id -> BoatController.update * + * DELETE /boat/:id -> BoatController.destroy * + * * + * `rest` blueprint routes are enabled by default, and are suitable for use * + * in a production scenario, as long you take standard security precautions * + * (combine w/ policies, etc.) * + * * + ***************************************************************************/ rest: false, /*************************************************************************** - * * - * Shortcut routes are simple helpers to provide access to a * - * controller's CRUD methods from your browser's URL bar. When enabled, * - * GET, POST, PUT, and DELETE routes will be generated for the * - * controller's`find`, `create`, `update`, and `destroy` actions. * - * * - * `shortcuts` are enabled by default, but should be disabled in * - * production. * - * * - ***************************************************************************/ + * * + * Shortcut routes are simple helpers to provide access to a * + * controller's CRUD methods from your browser's URL bar. When enabled, * + * GET, POST, PUT, and DELETE routes will be generated for the * + * controller's`find`, `create`, `update`, and `destroy` actions. * + * * + * `shortcuts` are enabled by default, but should be disabled in * + * production. * + * * + ***************************************************************************/ shortcuts: false, /*************************************************************************** - * * - * An optional mount path for all blueprint routes on a controller, * - * including `rest`, `actions`, and `shortcuts`. This allows you to take * - * advantage of blueprint routing, even if you need to namespace your API * - * methods. * - * * - * (NOTE: This only applies to blueprint autoroutes, not manual routes from * - * `sails.config.routes`) * - * * - ***************************************************************************/ - - prefix: '', + * * + * An optional mount path for all blueprint routes on a controller, * + * including `rest`, `actions`, and `shortcuts`. This allows you to take * + * advantage of blueprint routing, even if you need to namespace your API * + * methods. * + * * + * (NOTE: This only applies to blueprint autoroutes, not manual routes from * + * `sails.config.routes`) * + * * + ***************************************************************************/ + + prefix: "", /*************************************************************************** - * * - * Whether to pluralize controller names in blueprint routes. * - * * - * (NOTE: This only applies to blueprint autoroutes, not manual routes from * - * `sails.config.routes`) * - * * - * For example, REST blueprints for `FooController` with `pluralize` * - * enabled: * - * GET /foos/:id? * - * POST /foos * - * PUT /foos/:id? * - * DELETE /foos/:id? * - * * - ***************************************************************************/ + * * + * Whether to pluralize controller names in blueprint routes. * + * * + * (NOTE: This only applies to blueprint autoroutes, not manual routes from * + * `sails.config.routes`) * + * * + * For example, REST blueprints for `FooController` with `pluralize` * + * enabled: * + * GET /foos/:id? * + * POST /foos * + * PUT /foos/:id? * + * DELETE /foos/:id? * + * * + ***************************************************************************/ // pluralize: false, /*************************************************************************** - * * - * Whether the blueprint controllers should populate model fetches with * - * data from other models which are linked by associations * - * * - * If you have a lot of data in one-to-many associations, leaving this on * - * may result in very heavy api calls * - * * - ***************************************************************************/ + * * + * Whether the blueprint controllers should populate model fetches with * + * data from other models which are linked by associations * + * * + * If you have a lot of data in one-to-many associations, leaving this on * + * may result in very heavy api calls * + * * + ***************************************************************************/ populate: false, /**************************************************************************** - * * - * Whether to run Model.watch() in the find and findOne blueprint actions. * - * Can be overridden on a per-model basis. * - * * - ****************************************************************************/ + * * + * Whether to run Model.watch() in the find and findOne blueprint actions. * + * Can be overridden on a per-model basis. * + * * + ****************************************************************************/ // autoWatch: true, /**************************************************************************** - * * - * The default number of records to show in the response from a "find" * - * action. Doubles as the default size of populated arrays if populate is * - * true. * - * * - ****************************************************************************/ - - defaultLimit: 10000 - + * * + * The default number of records to show in the response from a "find" * + * action. Doubles as the default size of populated arrays if populate is * + * true. * + * * + ****************************************************************************/ + + defaultLimit: 10000, }; diff --git a/config/bootstrap.js b/config/bootstrap.js index 362410e89..0482f9ac0 100644 --- a/config/bootstrap.js +++ b/config/bootstrap.js @@ -9,34 +9,36 @@ * http://sailsjs.org/#/documentation/reference/sails.config/sails.config.bootstrap.html */ -import util from 'util' -import models from '../api/models' -import queryMonitor from '../lib/util/queryMonitor' -import { red } from 'chalk' -require('dotenv').load() +import util from "util"; +import models from "../api/models"; +import queryMonitor from "../lib/util/queryMonitor"; +import { red } from "chalk"; +require("dotenv").load(); // very handy, these -global.format = util.format -global.Promise = require('bluebird') -global._ = require('lodash') // override Sails' old version of lodash +global.format = util.format; +global.Promise = require("bluebird"); +global._ = require("lodash"); // override Sails' old version of lodash module.exports.bootstrap = function (done) { - models.init() + models.init(); if (process.env.DEBUG_MEMORY) { - sails.log.info(red('memwatch: starting')) - var memwatch = require('memwatch-next') + sails.log.info(red("memwatch: starting")); + const memwatch = require("memwatch-next"); - memwatch.on('leak', info => sails.log.info(red('memwatch: memory leak!'), info)) + memwatch.on("leak", (info) => + sails.log.info(red("memwatch: memory leak!"), info) + ); - memwatch.on('stats', stats => { - sails.log.info(red('memwatch: stats:') + '\n' + util.inspect(stats)) - }) + memwatch.on("stats", (stats) => { + sails.log.info(red("memwatch: stats:") + "\n" + util.inspect(stats)); + }); } - if (process.env.DEBUG_SQL) queryMonitor(bookshelf.knex) + if (process.env.DEBUG_SQL) queryMonitor(bookshelf.knex); // It's very important to trigger this callback method when you are finished // with the bootstrap! (otherwise your server will never lift, since it's waiting on the bootstrap) - done() -} + done(); +}; diff --git a/config/connections.js b/config/connections.js index 29b95f661..887356cb6 100644 --- a/config/connections.js +++ b/config/connections.js @@ -19,6 +19,4 @@ * http://sailsjs.org/#/documentation/reference/sails.config/sails.config.connections.html */ -module.exports.connections = { - -}; +module.exports.connections = {}; diff --git a/config/cors.js b/config/cors.js index a0fcf6ee9..7f8e0d400 100644 --- a/config/cors.js +++ b/config/cors.js @@ -29,53 +29,51 @@ */ module.exports.cors = { - /*************************************************************************** - * * - * Allow CORS on all routes by default? If not, you must enable CORS on a * - * per-route basis by either adding a "cors" configuration object to the * - * route config, or setting "cors:true" in the route config to use the * - * default settings below. * - * * - ***************************************************************************/ + * * + * Allow CORS on all routes by default? If not, you must enable CORS on a * + * per-route basis by either adding a "cors" configuration object to the * + * route config, or setting "cors:true" in the route config to use the * + * default settings below. * + * * + ***************************************************************************/ allRoutes: true, /*************************************************************************** - * * - * Which domains which are allowed CORS access? This can be a * - * comma-delimited list of hosts (beginning with http:// or https://) or * - * "*" to allow all domains CORS access. * - * * - ***************************************************************************/ + * * + * Which domains which are allowed CORS access? This can be a * + * comma-delimited list of hosts (beginning with http:// or https://) or * + * "*" to allow all domains CORS access. * + * * + ***************************************************************************/ - origin: '*', + origin: "*", // process.env.CORS_ORIGIN || 'https://www.hylo.com', /*************************************************************************** - * * - * Allow cookies to be shared for CORS requests? * - * * - ***************************************************************************/ + * * + * Allow cookies to be shared for CORS requests? * + * * + ***************************************************************************/ credentials: true, /*************************************************************************** - * * - * Which methods should be allowed for CORS requests? This is only used in * - * response to preflight requests (see article linked above for more info) * - * * - ***************************************************************************/ + * * + * Which methods should be allowed for CORS requests? This is only used in * + * response to preflight requests (see article linked above for more info) * + * * + ***************************************************************************/ - methods: 'GET, POST, PUT, DELETE, OPTIONS, HEAD' + methods: "GET, POST, PUT, DELETE, OPTIONS, HEAD", /*************************************************************************** - * * - * Which headers should be allowed for CORS requests? This is only used in * - * response to preflight requests. * - * * - ***************************************************************************/ + * * + * Which headers should be allowed for CORS requests? This is only used in * + * response to preflight requests. * + * * + ***************************************************************************/ // headers: 'content-type' - -} +}; diff --git a/config/csrf.js b/config/csrf.js index 22e419a71..9477f892e 100644 --- a/config/csrf.js +++ b/config/csrf.js @@ -43,20 +43,20 @@ */ /**************************************************************************** -* * -* Enabled CSRF protection for your site? * -* * -****************************************************************************/ + * * + * Enabled CSRF protection for your site? * + * * + ****************************************************************************/ // module.exports.csrf = false; /**************************************************************************** -* * -* You may also specify more fine-grained settings for CSRF, including the * -* domains which are allowed to request the CSRF token via AJAX. These * -* settings override the general CORS settings in your config/cors.js file. * -* * -****************************************************************************/ + * * + * You may also specify more fine-grained settings for CSRF, including the * + * domains which are allowed to request the CSRF token via AJAX. These * + * settings override the general CORS settings in your config/cors.js file. * + * * + ****************************************************************************/ // module.exports.csrf = { // grantTokenViaAjax: true, diff --git a/config/customMiddleware.js b/config/customMiddleware.js index bd3d1078c..1ae0a5f23 100644 --- a/config/customMiddleware.js +++ b/config/customMiddleware.js @@ -1,31 +1,34 @@ -import { createRequestHandler } from '../api/graphql' -import bodyParser from 'body-parser' -import kue from 'kue' -import kueUI from 'kue-ui' -import isAdmin from '../api/policies/isAdmin' -import accessTokenAuth from '../api/policies/accessTokenAuth' -import cors from 'cors' -import { cors as corsConfig } from './cors' +import { createRequestHandler } from "../api/graphql"; +import bodyParser from "body-parser"; +import kue from "kue"; +import kueUI from "kue-ui"; +import isAdmin from "../api/policies/isAdmin"; +import accessTokenAuth from "../api/policies/accessTokenAuth"; +import cors from "cors"; +import { cors as corsConfig } from "./cors"; export default function (app) { - app.use(bodyParser.urlencoded({extended: true})) - app.use(bodyParser.json()) - app.enable('trust proxy') + app.use(bodyParser.urlencoded({ extended: true })); + app.use(bodyParser.json()); + app.enable("trust proxy"); kueUI.setup({ - apiURL: '/admin/kue/api', - baseURL: '/admin/kue' - }) + apiURL: "/admin/kue/api", + baseURL: "/admin/kue", + }); - app.use('/admin/kue', isAdmin) - app.use('/admin/kue/api', kue.app) - app.use('/admin/kue', kueUI.app) + app.use("/admin/kue", isAdmin); + app.use("/admin/kue/api", kue.app); + app.use("/admin/kue", kueUI.app); - app.use('/noo/graphql', cors({ - origin: '*', - methods: 'GET, POST, PUT, DELETE, OPTIONS, HEAD', - credentials: true - })) - app.use('/noo/graphql', accessTokenAuth) - app.use('/noo/graphql', createRequestHandler()) + app.use( + "/noo/graphql", + cors({ + origin: "*", + methods: "GET, POST, PUT, DELETE, OPTIONS, HEAD", + credentials: true, + }) + ); + app.use("/noo/graphql", accessTokenAuth); + app.use("/noo/graphql", createRequestHandler()); } diff --git a/config/env/development.js b/config/env/development.js index 371691626..e9ddce286 100644 --- a/config/env/development.js +++ b/config/env/development.js @@ -11,14 +11,11 @@ */ module.exports = { - /*************************************************************************** * Set the default database connection for models in the development * * environment (see config/connections.js and config/models.js ) * ***************************************************************************/ - // models: { // connection: 'someMongodbServer' // } - }; diff --git a/config/env/production.js b/config/env/production.js index 60dd60286..fedce3502 100644 --- a/config/env/production.js +++ b/config/env/production.js @@ -11,28 +11,21 @@ */ module.exports = { - /*************************************************************************** * Set the default database connection for models in the production * * environment (see config/connections.js and config/models.js ) * ***************************************************************************/ - // models: { // connection: 'someMysqlServer' // }, - /*************************************************************************** * Set the port in the production environment to 80 * ***************************************************************************/ - // port: 80, - /*************************************************************************** * Set the log level in production environment to "silent" * ***************************************************************************/ - // log: { // level: "silent" // } - }; diff --git a/config/globals.js b/config/globals.js index 53c14a3c2..6e29db403 100644 --- a/config/globals.js +++ b/config/globals.js @@ -9,55 +9,54 @@ * http://sailsjs.org/#/documentation/reference/sails.config/sails.config.globals.html */ module.exports.globals = { - /**************************************************************************** - * * - * Expose the lodash installed in Sails core as a global variable. If this * - * is disabled, like any other node module you can always run npm install * - * lodash --save, then var _ = require('lodash') at the top of any file. * - * * - ****************************************************************************/ + * * + * Expose the lodash installed in Sails core as a global variable. If this * + * is disabled, like any other node module you can always run npm install * + * lodash --save, then var _ = require('lodash') at the top of any file. * + * * + ****************************************************************************/ - _: false, + _: false, /**************************************************************************** - * * - * Expose the async installed in Sails core as a global variable. If this is * - * disabled, like any other node module you can always run npm install async * - * --save, then var async = require('async') at the top of any file. * - * * - ****************************************************************************/ + * * + * Expose the async installed in Sails core as a global variable. If this is * + * disabled, like any other node module you can always run npm install async * + * --save, then var async = require('async') at the top of any file. * + * * + ****************************************************************************/ - // async: true, + // async: true, /**************************************************************************** - * * - * Expose the sails instance representing your app. If this is disabled, you * - * can still get access via req._sails. * - * * - ****************************************************************************/ + * * + * Expose the sails instance representing your app. If this is disabled, you * + * can still get access via req._sails. * + * * + ****************************************************************************/ - // sails: true, + // sails: true, /**************************************************************************** - * * - * Expose each of your app's services as global variables (using their * - * "globalId"). E.g. a service defined in api/models/NaturalLanguage.js * - * would have a globalId of NaturalLanguage by default. If this is disabled, * - * you can still access your services via sails.services.* * - * * - ****************************************************************************/ + * * + * Expose each of your app's services as global variables (using their * + * "globalId"). E.g. a service defined in api/models/NaturalLanguage.js * + * would have a globalId of NaturalLanguage by default. If this is disabled, * + * you can still access your services via sails.services.* * + * * + ****************************************************************************/ - // services: true, + // services: true, /**************************************************************************** - * * - * Expose each of your app's models as global variables (using their * - * "globalId"). E.g. a model defined in api/models/User.js would have a * - * globalId of User by default. If this is disabled, you can still access * - * your models via sails.models.*. * - * * - ****************************************************************************/ - - // models: true + * * + * Expose each of your app's models as global variables (using their * + * "globalId"). E.g. a model defined in api/models/User.js would have a * + * globalId of User by default. If this is disabled, you can still access * + * your models via sails.models.*. * + * * + ****************************************************************************/ + + // models: true }; diff --git a/config/http.js b/config/http.js index dfecbaae9..0b77f25c9 100644 --- a/config/http.js +++ b/config/http.js @@ -11,82 +11,79 @@ * http://sailsjs.org/#/documentation/reference/sails.config/sails.config.http.html */ -import customMiddleware from './customMiddleware' -import { magenta } from 'chalk' +import customMiddleware from "./customMiddleware"; +import { magenta } from "chalk"; module.exports.http = { - /**************************************************************************** - * * - * Express middleware to use for every Sails request. To add custom * - * middleware to the mix, add a function to the middleware config object and * - * add its key to the "order" array. The $custom key is reserved for * - * backwards-compatibility with Sails v0.9.x apps that use the * - * `customMiddleware` config option. * - * * - ****************************************************************************/ + * * + * Express middleware to use for every Sails request. To add custom * + * middleware to the mix, add a function to the middleware config object and * + * add its key to the "order" array. The $custom key is reserved for * + * backwards-compatibility with Sails v0.9.x apps that use the * + * `customMiddleware` config option. * + * * + ****************************************************************************/ middleware: { - - passportInit: require('passport').initialize(), - passportSession: require('passport').session(), - rollbar: require('../lib/rollbar').errorHandler(), + passportInit: require("passport").initialize(), + passportSession: require("passport").session(), + rollbar: require("../lib/rollbar").errorHandler(), requestLogger: function (req, res, next) { - sails.log.info(magenta(`${req.method} ${req.url}`)) - next() + sails.log.info(magenta(`${req.method} ${req.url}`)); + next(); }, - /*************************************************************************** - * * - * The order in which middleware should be run for HTTP request. (the Sails * - * router is invoked by the "router" middleware below.) * - * * - ***************************************************************************/ + /*************************************************************************** + * * + * The order in which middleware should be run for HTTP request. (the Sails * + * router is invoked by the "router" middleware below.) * + * * + ***************************************************************************/ order: [ - 'startRequestTimer', - 'cookieParser', - 'session', - 'passportInit', - 'passportSession', - 'compress', - 'methodOverride', - 'poweredBy', - 'requestLogger', - '$custom', - 'router', - 'www', - 'favicon', - '404', - 'rollbar', - '500' - ] + "startRequestTimer", + "cookieParser", + "session", + "passportInit", + "passportSession", + "compress", + "methodOverride", + "poweredBy", + "requestLogger", + "$custom", + "router", + "www", + "favicon", + "404", + "rollbar", + "500", + ], - /*************************************************************************** - * * - * The body parser that will handle incoming multipart HTTP requests. By * - * default as of v0.10, Sails uses * - * [skipper](http://github.com/balderdashy/skipper). See * - * http://www.senchalabs.org/connect/multipart.html for other options. * - * * - ***************************************************************************/ + /*************************************************************************** + * * + * The body parser that will handle incoming multipart HTTP requests. By * + * default as of v0.10, Sails uses * + * [skipper](http://github.com/balderdashy/skipper). See * + * http://www.senchalabs.org/connect/multipart.html for other options. * + * * + ***************************************************************************/ // bodyParser: require('skipper') - }, customMiddleware, /*************************************************************************** - * * - * The number of seconds to cache flat files on disk being served by * - * Express static middleware (by default, these files are in `.tmp/public`) * - * * - * The HTTP static cache is only active in a 'production' environment, * - * since that's the only time Express will cache flat-files. * - * * - ***************************************************************************/ + * * + * The number of seconds to cache flat files on disk being served by * + * Express static middleware (by default, these files are in `.tmp/public`) * + * * + * The HTTP static cache is only active in a 'production' environment, * + * since that's the only time Express will cache flat-files. * + * * + ***************************************************************************/ // cache: 31557600000 -} +}; diff --git a/config/i18n.js b/config/i18n.js index 5f613fb00..180e7713c 100644 --- a/config/i18n.js +++ b/config/i18n.js @@ -1,6 +1,6 @@ /* eslint spaced-comment: 0 */ -var path = require('path') +const path = require("path"); /** * Internationalization / Localization Settings @@ -21,26 +21,26 @@ var path = require('path') module.exports.i18n = { /*************************************************************************** - * Which locales are supported? * - ***************************************************************************/ - locales: ['en'], //, 'es', 'fr', 'de'] + * Which locales are supported? * + ***************************************************************************/ + locales: ["en"], //, 'es', 'fr', 'de'] /**************************************************************************** - * What is the default locale for the site? Note that this setting will be * - * overridden for any request that sends an "Accept-Language" header (i.e. * - * most browsers), but it's still useful if you need to localize the * - * response for requests made by non-browser clients (e.g. cURL). * - ****************************************************************************/ - defaultLocale: 'en', + * What is the default locale for the site? Note that this setting will be * + * overridden for any request that sends an "Accept-Language" header (i.e. * + * most browsers), but it's still useful if you need to localize the * + * response for requests made by non-browser clients (e.g. cURL). * + ****************************************************************************/ + defaultLocale: "en", /**************************************************************************** - * Automatically add new keys to locale (translation) files when they are * - * encountered during a request? * - ****************************************************************************/ + * Automatically add new keys to locale (translation) files when they are * + * encountered during a request? * + ****************************************************************************/ updateFiles: false, /**************************************************************************** - * Path of directory to store locale (translation) files in. * - ****************************************************************************/ - directory: path.join(__dirname, 'locales') -} + * Path of directory to store locale (translation) files in. * + ****************************************************************************/ + directory: path.join(__dirname, "locales"), +}; diff --git a/config/kue.js b/config/kue.js index d47b02371..3ea11cf50 100644 --- a/config/kue.js +++ b/config/kue.js @@ -1,7 +1,7 @@ -var kue = require('kue'); +const kue = require("kue"); // get redis connection options from env -var redisInfo = require('parse-redis-url')().parse(process.env.REDIS_URL); +const redisInfo = require("parse-redis-url")().parse(process.env.REDIS_URL); // kue's expected options are a little non-standard: // https://github.com/learnboost/kue#redis-connection-settings @@ -10,5 +10,5 @@ redisInfo.db = redisInfo.database; kue.createQueue({ redis: redisInfo, - prefix: process.env.KUE_NAMESPACE || 'q' + prefix: process.env.KUE_NAMESPACE || "q", }); diff --git a/config/locales/_README.md b/config/locales/_README.md index 5f89b1548..d0d23d565 100644 --- a/config/locales/_README.md +++ b/config/locales/_README.md @@ -4,17 +4,21 @@ > http://links.sailsjs.org/docs/config/locales ## Locales + All locale files live under `config/locales`. Here is where you can add translations as JSON key-value pairs. The name of the file should match the language that you are supporting, which allows for automatic language detection based on request headers. Here is an example locale stringfile for the Spanish language (`config/locales/es.json`): + ```json { - "Hello!": "Hola!", - "Hello %s, how are you today?": "¿Hola %s, como estas?", + "Hello!": "Hola!", + "Hello %s, how are you today?": "¿Hola %s, como estas?" } ``` + ## Usage + Locales can be accessed in controllers/policies through `res.i18n()`, or in views through the `__(key)` or `i18n(key)` functions. Remember that the keys are case sensitive and require exact key matches, e.g. @@ -25,4 +29,5 @@ Remember that the keys are case sensitive and require exact key matches, e.g. ``` ## Configuration + Localization/internationalization config can be found in `config/i18n.js`, from where you can set your supported locales. diff --git a/config/locales/en.json b/config/locales/en.json index 0a06adac5..ee5240efd 100644 --- a/config/locales/en.json +++ b/config/locales/en.json @@ -1,6 +1,6 @@ { - "Welcome": "Welcome", - "A brand new app.": "A brand new app.", + "Welcome": "Welcome", + "A brand new app.": "A brand new app.", "invalid-email": "That email address is not valid.", "duplicate-email": "That email address is already in use." } diff --git a/config/locales/fr.json b/config/locales/fr.json index 972935c6a..b5997400f 100644 --- a/config/locales/fr.json +++ b/config/locales/fr.json @@ -1,4 +1,4 @@ { - "Welcome": "Bienvenue", - "A brand new app.": "Une toute nouvelle application." + "Welcome": "Bienvenue", + "A brand new app.": "Une toute nouvelle application." } diff --git a/config/log.js b/config/log.js index 03302aa6b..9a27a53a6 100644 --- a/config/log.js +++ b/config/log.js @@ -11,19 +11,16 @@ */ module.exports.log = { - /*************************************************************************** - * * - * Valid `level` configs: i.e. the minimum log level to capture with * - * sails.log.*() * - * * - * The order of precedence for log levels from lowest to highest is: * - * silly, verbose, info, debug, warn, error * - * * - * You may also set the level to "silent" to suppress all logs. * - * * - ***************************************************************************/ - - // level: 'info' - + * * + * Valid `level` configs: i.e. the minimum log level to capture with * + * sails.log.*() * + * * + * The order of precedence for log levels from lowest to highest is: * + * silly, verbose, info, debug, warn, error * + * * + * You may also set the level to "silent" to suppress all logs. * + * * + ***************************************************************************/ + // level: 'info' }; diff --git a/config/models.js b/config/models.js index f2181f59c..78b35b939 100644 --- a/config/models.js +++ b/config/models.js @@ -10,23 +10,21 @@ */ module.exports.models = { - /*************************************************************************** - * * - * Your app's default connection. i.e. the name of one of your app's * - * connections (see `config/connections.js`) * - * * - ***************************************************************************/ - connection: 'postgres', + * * + * Your app's default connection. i.e. the name of one of your app's * + * connections (see `config/connections.js`) * + * * + ***************************************************************************/ + connection: "postgres", /*************************************************************************** - * * - * How and whether Sails will attempt to automatically rebuild the * - * tables/collections/etc. in your schema. * - * * - * See http://sailsjs.org/#/documentation/concepts/ORM/model-settings.html * - * * - ***************************************************************************/ - migrate: 'safe' - + * * + * How and whether Sails will attempt to automatically rebuild the * + * tables/collections/etc. in your schema. * + * * + * See http://sailsjs.org/#/documentation/concepts/ORM/model-settings.html * + * * + ***************************************************************************/ + migrate: "safe", }; diff --git a/config/passport.js b/config/passport.js index f69476008..eda21a961 100644 --- a/config/passport.js +++ b/config/passport.js @@ -1,37 +1,46 @@ -var passport = require('passport') -var GoogleStrategy = require('passport-google-oauth').OAuth2Strategy -var GoogleTokenStrategy = require('passport-google-token').Strategy -var FacebookStrategy = require('passport-facebook').Strategy -var FacebookTokenStrategy = require('passport-facebook-token') -var LinkedinStrategy = require('passport-linkedin-oauth2').Strategy -var LinkedInTokenStrategy = require('passport-linkedin-token-oauth2').Strategy +const passport = require("passport"); +const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy; +const GoogleTokenStrategy = require("passport-google-token").Strategy; +const FacebookStrategy = require("passport-facebook").Strategy; +const FacebookTokenStrategy = require("passport-facebook-token"); +const LinkedinStrategy = require("passport-linkedin-oauth2").Strategy; +const LinkedInTokenStrategy = require("passport-linkedin-token-oauth2") + .Strategy; // ----------- // admin login -var adminStrategy = new GoogleStrategy({ - clientID: process.env.ADMIN_GOOGLE_CLIENT_ID, - clientSecret: process.env.ADMIN_GOOGLE_CLIENT_SECRET, - callbackURL: format('%s://%s%s', process.env.PROTOCOL, process.env.DOMAIN, '/noo/admin/login/oauth') -}, function (accessToken, refreshToken, profile, done) { - var email = profile.emails[0].value +const adminStrategy = new GoogleStrategy( + { + clientID: process.env.ADMIN_GOOGLE_CLIENT_ID, + clientSecret: process.env.ADMIN_GOOGLE_CLIENT_SECRET, + callbackURL: format( + "%s://%s%s", + process.env.PROTOCOL, + process.env.DOMAIN, + "/noo/admin/login/oauth" + ), + }, + function (accessToken, refreshToken, profile, done) { + const email = profile.emails[0].value; - if (email.match(/hylo\.com$/)) { - done(null, {email: email}) - } else { - done(null, false, {message: 'Not a hylo.com address.'}) + if (email.match(/hylo\.com$/)) { + done(null, { email: email }); + } else { + done(null, false, { message: "Not a hylo.com address." }); + } } -}) -adminStrategy.name = 'admin' -passport.use(adminStrategy) +); +adminStrategy.name = "admin"; +passport.use(adminStrategy); passport.serializeUser(function (user, done) { - done(null, user) -}) + done(null, user); +}); passport.deserializeUser(function (user, done) { - done(null, user) -}) + done(null, user); +}); // ----------- // user login @@ -45,74 +54,106 @@ passport.deserializeUser(function (user, done) { // use req.login to set req.user, and only the admin login is unconventional // -var url = function (path) { - return format('%s://%s%s', process.env.PROTOCOL, process.env.DOMAIN, path) -} +const url = function (path) { + return format("%s://%s%s", process.env.PROTOCOL, process.env.DOMAIN, path); +}; -var formatProfile = function (profile, accessToken, refreshToken) { +const formatProfile = function (profile, accessToken, refreshToken) { return _.merge(profile, { name: profile.displayName, - email: _.get(profile, 'emails.0.value'), + email: _.get(profile, "emails.0.value"), _json: { access_token: accessToken, - refresh_token: refreshToken - } - }) -} + refresh_token: refreshToken, + }, + }); +}; -var googleStrategy = new GoogleStrategy({ - clientID: process.env.GOOGLE_CLIENT_ID, - clientSecret: process.env.GOOGLE_CLIENT_SECRET, - callbackURL: url('/noo/login/google/oauth') -}, function (accessToken, refreshToken, profile, done) { - done(null, formatProfile(profile)) -}) -passport.use(googleStrategy) +const googleStrategy = new GoogleStrategy( + { + clientID: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + callbackURL: url("/noo/login/google/oauth"), + }, + function (accessToken, refreshToken, profile, done) { + done(null, formatProfile(profile)); + } +); +passport.use(googleStrategy); -var facebookStrategy = new FacebookStrategy({ - clientID: process.env.FACEBOOK_APP_ID, - clientSecret: process.env.FACEBOOK_APP_SECRET, - callbackURL: url('/noo/login/facebook/oauth'), - scope: ['public_profile', 'email', 'user_friends', 'user_about_me', 'user_likes', 'user_location'], - profileFields: ['id', 'displayName', 'email', 'link'] -}, function (accessToken, refreshToken, profile, done) { - done(null, formatProfile(profile, accessToken, refreshToken)) -}) -passport.use(facebookStrategy) +const facebookStrategy = new FacebookStrategy( + { + clientID: process.env.FACEBOOK_APP_ID, + clientSecret: process.env.FACEBOOK_APP_SECRET, + callbackURL: url("/noo/login/facebook/oauth"), + scope: [ + "public_profile", + "email", + "user_friends", + "user_about_me", + "user_likes", + "user_location", + ], + profileFields: ["id", "displayName", "email", "link"], + }, + function (accessToken, refreshToken, profile, done) { + done(null, formatProfile(profile, accessToken, refreshToken)); + } +); +passport.use(facebookStrategy); -var facebookTokenStrategy = new FacebookTokenStrategy({ - clientID: process.env.FACEBOOK_APP_ID, - clientSecret: process.env.FACEBOOK_APP_SECRET, - scope: ['public_profile', 'email', 'user_friends', 'user_about_me', 'user_likes', 'user_location'], - profileFields: ['id', 'displayName', 'email', 'link'] -}, function (accessToken, refreshToken, profile, done) { - done(null, formatProfile(profile, accessToken, refreshToken)) -}) -passport.use(facebookTokenStrategy) +const facebookTokenStrategy = new FacebookTokenStrategy( + { + clientID: process.env.FACEBOOK_APP_ID, + clientSecret: process.env.FACEBOOK_APP_SECRET, + scope: [ + "public_profile", + "email", + "user_friends", + "user_about_me", + "user_likes", + "user_location", + ], + profileFields: ["id", "displayName", "email", "link"], + }, + function (accessToken, refreshToken, profile, done) { + done(null, formatProfile(profile, accessToken, refreshToken)); + } +); +passport.use(facebookTokenStrategy); -var googleTokenStrategy = new GoogleTokenStrategy({ - clientID: process.env.GOOGLE_CLIENT_ID, - clientSecret: process.env.GOOGLE_CLIENT_SECRET -}, function (accessToken, refreshToken, profile, done) { - done(null, formatProfile(profile)) -}) -passport.use(googleTokenStrategy) +const googleTokenStrategy = new GoogleTokenStrategy( + { + clientID: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + }, + function (accessToken, refreshToken, profile, done) { + done(null, formatProfile(profile)); + } +); +passport.use(googleTokenStrategy); -var linkedinStrategy = new LinkedinStrategy({ - clientID: process.env.LINKEDIN_API_KEY, - clientSecret: process.env.LINKEDIN_API_SECRET, - callbackURL: url('/noo/login/linkedin/oauth'), - scope: ['r_emailaddress', 'r_basicprofile'], - state: true -}, function (accessToken, refreshToken, profile, done) { - done(null, formatProfile(profile)) -}) -passport.use(linkedinStrategy) +const linkedinStrategy = new LinkedinStrategy( + { + clientID: process.env.LINKEDIN_API_KEY, + clientSecret: process.env.LINKEDIN_API_SECRET, + callbackURL: url("/noo/login/linkedin/oauth"), + scope: ["r_emailaddress", "r_basicprofile"], + state: true, + }, + function (accessToken, refreshToken, profile, done) { + done(null, formatProfile(profile)); + } +); +passport.use(linkedinStrategy); -var linkedinTokenStrategy = new LinkedInTokenStrategy({ - clientID: process.env.LINKEDIN_API_KEY, - clientSecret: process.env.LINKEDIN_API_SECRET -}, function (accessToken, refreshToken, profile, done) { - done(null, formatProfile(profile)) -}) -passport.use(linkedinTokenStrategy) +const linkedinTokenStrategy = new LinkedInTokenStrategy( + { + clientID: process.env.LINKEDIN_API_KEY, + clientSecret: process.env.LINKEDIN_API_SECRET, + }, + function (accessToken, refreshToken, profile, done) { + done(null, formatProfile(profile)); + } +); +passport.use(linkedinTokenStrategy); diff --git a/config/policies.js b/config/policies.js index f8e473b4b..50a2b13fe 100644 --- a/config/policies.js +++ b/config/policies.js @@ -19,49 +19,49 @@ */ module.exports.policies = { - '*': false, - AdminController: ['isAdmin'], + "*": false, + AdminController: ["isAdmin"], MobileAppController: true, NexudusController: true, SessionController: true, SubscriptionController: true, - UploadController: ['sessionAuth'], + UploadController: ["sessionAuth"], AdminSessionController: { - create: true, - oauth: true, - destroy: true + create: true, + oauth: true, + destroy: true, }, CommentController: { createFromEmail: true, - createBatchFromEmailForm: ['checkAndDecodeToken'] + createBatchFromEmailForm: ["checkAndDecodeToken"], }, CommunityController: { - subscribe: ['isSocket', 'sessionAuth', 'checkAndSetMembership'], - unsubscribe: ['isSocket', 'sessionAuth', 'checkAndSetMembership'] + subscribe: ["isSocket", "sessionAuth", "checkAndSetMembership"], + unsubscribe: ["isSocket", "sessionAuth", "checkAndSetMembership"], }, PostController: { - updateLastRead: ['sessionAuth', 'checkAndSetPost'], - subscribe: ['isSocket', 'sessionAuth', 'checkAndSetPost'], - unsubscribe: ['isSocket', 'sessionAuth', 'checkAndSetPost'], - typing: ['isSocket', 'sessionAuth', 'checkAndSetPost'], - createFromEmailForm: ['checkAndDecodeToken'], + updateLastRead: ["sessionAuth", "checkAndSetPost"], + subscribe: ["isSocket", "sessionAuth", "checkAndSetPost"], + unsubscribe: ["isSocket", "sessionAuth", "checkAndSetPost"], + typing: ["isSocket", "sessionAuth", "checkAndSetPost"], + createFromEmailForm: ["checkAndDecodeToken"], // FIXME these two should go in UserController - subscribeToUpdates: ['isSocket', 'sessionAuth'], - unsubscribeFromUpdates: ['isSocket', 'sessionAuth'] + subscribeToUpdates: ["isSocket", "sessionAuth"], + unsubscribeFromUpdates: ["isSocket", "sessionAuth"], }, UserController: { status: true, create: true, - sendPasswordReset: true + sendPasswordReset: true, }, PaymentController: { - registerStripe: ['sessionAuth'] - } -} + registerStripe: ["sessionAuth"], + }, +}; diff --git a/config/routes.js b/config/routes.js index bd8556d28..e4d9dbc86 100644 --- a/config/routes.js +++ b/config/routes.js @@ -5,70 +5,81 @@ */ module.exports.routes = { - 'GET /noo/user/status': 'UserController.status', - 'POST /noo/user/password': 'UserController.sendPasswordReset', - 'POST /noo/user': 'UserController.create', + "GET /noo/user/status": "UserController.status", + "POST /noo/user/password": "UserController.sendPasswordReset", + "POST /noo/user": "UserController.create", - 'POST /noo/post/:postId/update-last-read': 'PostController.updateLastRead', + "POST /noo/post/:postId/update-last-read": "PostController.updateLastRead", - 'GET /noo/network/:networkId': 'NetworkController.findOne', - 'POST /noo/network': 'NetworkController.create', - 'POST /noo/network/validate': 'NetworkController.validate', - 'POST /noo/network/:networkId': 'NetworkController.update', + "GET /noo/network/:networkId": "NetworkController.findOne", + "POST /noo/network": "NetworkController.create", + "POST /noo/network/validate": "NetworkController.validate", + "POST /noo/network/:networkId": "NetworkController.update", - 'GET /noo/admin/login': 'AdminSessionController.create', - 'GET /noo/admin/login/oauth': 'AdminSessionController.oauth', - 'GET /noo/admin/logout': 'AdminSessionController.destroy', - 'GET /noo/admin/raw-metrics': 'AdminController.rawMetrics', - 'GET /noo/admin/login-as/:userId': 'AdminController.loginAsUser', + "GET /noo/admin/login": "AdminSessionController.create", + "GET /noo/admin/login/oauth": "AdminSessionController.oauth", + "GET /noo/admin/logout": "AdminSessionController.destroy", + "GET /noo/admin/raw-metrics": "AdminController.rawMetrics", + "GET /noo/admin/login-as/:userId": "AdminController.loginAsUser", - 'POST /noo/hook/comment': 'CommentController.createFromEmail', - 'GET /noo/hook/postForm': 'PostController.createFromEmailForm', - 'POST /noo/hook/postForm': 'PostController.createFromEmailForm', - 'GET /noo/hook/batchCommentForm': 'CommentController.createBatchFromEmailForm', - 'POST /noo/hook/batchCommentForm': 'CommentController.createBatchFromEmailForm', + "POST /noo/hook/comment": "CommentController.createFromEmail", + "GET /noo/hook/postForm": "PostController.createFromEmailForm", + "POST /noo/hook/postForm": "PostController.createFromEmailForm", + "GET /noo/hook/batchCommentForm": + "CommentController.createBatchFromEmailForm", + "POST /noo/hook/batchCommentForm": + "CommentController.createBatchFromEmailForm", - 'POST /noo/login': 'SessionController.create', - 'GET /noo/login/token': 'SessionController.createWithToken', - 'POST /noo/login/token': 'SessionController.createWithToken', - 'POST /noo/login/apple/oauth': 'SessionController.finishAppleOAuth', - 'GET /noo/login/google': 'SessionController.startGoogleOAuth', - 'GET /noo/login/google/oauth': 'SessionController.finishGoogleOAuth', - 'GET /noo/login/facebook': 'SessionController.startFacebookOAuth', - 'GET /noo/login/facebook/oauth': 'SessionController.finishFacebookOAuth', - 'GET /noo/login/linkedin': 'SessionController.startLinkedinOAuth', - 'GET /noo/login/linkedin/oauth': 'SessionController.finishLinkedinOAuth', - 'GET /noo/login/facebook-token/oauth': 'SessionController.finishFacebookTokenOAuth', - 'POST /noo/login/facebook-token/oauth': 'SessionController.finishFacebookTokenOAuth', - 'GET /noo/login/google-token/oauth': 'SessionController.finishGoogleTokenOAuth', - 'POST /noo/login/google-token/oauth': 'SessionController.finishGoogleTokenOAuth', - 'GET /noo/login/linkedin-token/oauth': 'SessionController.finishLinkedinTokenOAuth', - 'POST /noo/login/linkedin-token/oauth': 'SessionController.finishLinkedinTokenOAuth', - 'GET /noo/logout': 'SessionController.destroy', - 'DELETE /noo/session': 'SessionController.destroySession', + "POST /noo/login": "SessionController.create", + "GET /noo/login/token": "SessionController.createWithToken", + "POST /noo/login/token": "SessionController.createWithToken", + "POST /noo/login/apple/oauth": "SessionController.finishAppleOAuth", + "GET /noo/login/google": "SessionController.startGoogleOAuth", + "GET /noo/login/google/oauth": "SessionController.finishGoogleOAuth", + "GET /noo/login/facebook": "SessionController.startFacebookOAuth", + "GET /noo/login/facebook/oauth": "SessionController.finishFacebookOAuth", + "GET /noo/login/linkedin": "SessionController.startLinkedinOAuth", + "GET /noo/login/linkedin/oauth": "SessionController.finishLinkedinOAuth", + "GET /noo/login/facebook-token/oauth": + "SessionController.finishFacebookTokenOAuth", + "POST /noo/login/facebook-token/oauth": + "SessionController.finishFacebookTokenOAuth", + "GET /noo/login/google-token/oauth": + "SessionController.finishGoogleTokenOAuth", + "POST /noo/login/google-token/oauth": + "SessionController.finishGoogleTokenOAuth", + "GET /noo/login/linkedin-token/oauth": + "SessionController.finishLinkedinTokenOAuth", + "POST /noo/login/linkedin-token/oauth": + "SessionController.finishLinkedinTokenOAuth", + "GET /noo/logout": "SessionController.destroy", + "DELETE /noo/session": "SessionController.destroySession", - 'POST /noo/access-token': 'AccessTokenController.create', - 'DELETE /noo/access-token/revoke': 'AccessTokenController.destroy', + "POST /noo/access-token": "AccessTokenController.create", + "DELETE /noo/access-token/revoke": "AccessTokenController.destroy", - 'GET /noo/nexudus': 'NexudusController.create', + "GET /noo/nexudus": "NexudusController.create", - 'POST /noo/subscription': 'SubscriptionController.create', + "POST /noo/subscription": "SubscriptionController.create", - 'GET /noo/mobile/check-should-update': 'MobileAppController.checkShouldUpdate', - 'GET /noo/mobile/auto-update-info': 'MobileAppController.updateInfo', - 'POST /noo/mobile/logerror': 'MobileAppController.logError', + "GET /noo/mobile/check-should-update": + "MobileAppController.checkShouldUpdate", + "GET /noo/mobile/auto-update-info": "MobileAppController.updateInfo", + "POST /noo/mobile/logerror": "MobileAppController.logError", - 'GET /noo/payment/registerStripe': 'PaymentController.registerStripe', - 'POST /noo/payment/registerStripe': 'PaymentController.registerStripe', + "GET /noo/payment/registerStripe": "PaymentController.registerStripe", + "POST /noo/payment/registerStripe": "PaymentController.registerStripe", // websockets routes - 'POST /noo/community/:communityId/subscribe': 'CommunityController.subscribe', - 'POST /noo/community/:communityId/unsubscribe': 'CommunityController.unsubscribe', - 'POST /noo/post/:postId/subscribe': 'PostController.subscribe', // to comments - 'POST /noo/post/:postId/unsubscribe': 'PostController.unsubscribe', // from comments - 'POST /noo/post/:postId/typing': 'PostController.typing', - 'POST /noo/threads/subscribe': 'PostController.subscribeToUpdates', - 'POST /noo/threads/unsubscribe': 'PostController.unsubscribeFromUpdates', + "POST /noo/community/:communityId/subscribe": + "CommunityController.subscribe", + "POST /noo/community/:communityId/unsubscribe": + "CommunityController.unsubscribe", + "POST /noo/post/:postId/subscribe": "PostController.subscribe", // to comments + "POST /noo/post/:postId/unsubscribe": "PostController.unsubscribe", // from comments + "POST /noo/post/:postId/typing": "PostController.typing", + "POST /noo/threads/subscribe": "PostController.subscribeToUpdates", + "POST /noo/threads/unsubscribe": "PostController.unsubscribeFromUpdates", - 'POST /noo/upload': 'UploadController.create' -} + "POST /noo/upload": "UploadController.create", +}; diff --git a/config/session.js b/config/session.js index edf6900e2..c46ee0cab 100644 --- a/config/session.js +++ b/config/session.js @@ -12,63 +12,62 @@ * http://sailsjs.org/#/documentation/reference/sails.config/sails.config.session.html */ -var redisInfo = require('parse-redis-url')().parse(process.env.REDIS_URL); +const redisInfo = require("parse-redis-url")().parse(process.env.REDIS_URL); module.exports.session = { - /*************************************************************************** - * * - * Session secret is automatically generated when your new app is created * - * Replace at your own risk in production-- you will invalidate the cookies * - * of your users, forcing them to log in again. * - * * - ***************************************************************************/ + * * + * Session secret is automatically generated when your new app is created * + * Replace at your own risk in production-- you will invalidate the cookies * + * of your users, forcing them to log in again. * + * * + ***************************************************************************/ secret: process.env.COOKIE_SECRET, /*************************************************************************** - * * - * Set the session cookie expire time * - * The maxAge is set by milliseconds * - * * - ***************************************************************************/ + * * + * Set the session cookie expire time * + * The maxAge is set by milliseconds * + * * + ***************************************************************************/ key: process.env.COOKIE_NAME, // cookie name, instead of sails.sid cookie: { domain: process.env.COOKIE_DOMAIN, - maxAge: 60 * 86400000 // 60 days + maxAge: 60 * 86400000, // 60 days }, /*************************************************************************** - * * - * In production, uncomment the following lines to set up a shared redis * - * session store that can be shared across multiple Sails.js servers * - ***************************************************************************/ + * * + * In production, uncomment the following lines to set up a shared redis * + * session store that can be shared across multiple Sails.js servers * + ***************************************************************************/ - adapter: 'redis', + adapter: "redis", /*************************************************************************** - * * - * The following values are optional, if no options are set a redis * - * instance running on localhost is expected. Read more about options at: * - * https://github.com/visionmedia/connect-redis * - * * - * * - ***************************************************************************/ + * * + * The following values are optional, if no options are set a redis * + * instance running on localhost is expected. Read more about options at: * + * https://github.com/visionmedia/connect-redis * + * * + * * + ***************************************************************************/ host: redisInfo.host, port: redisInfo.port, ttl: 86400 * 60, db: 0, pass: redisInfo.password, - prefix: 'sess:' + prefix: "sess:", /*************************************************************************** - * * - * Uncomment the following lines to use your Mongo adapter as a session * - * store * - * * - ***************************************************************************/ + * * + * Uncomment the following lines to use your Mongo adapter as a session * + * store * + * * + ***************************************************************************/ // adapter: 'mongo', // host: 'localhost', @@ -77,18 +76,17 @@ module.exports.session = { // collection: 'sessions', /*************************************************************************** - * * - * Optional Values: * - * * - * # Note: url will override other connection settings url: * - * 'mongodb://user:pass@host:port/database/collection', * - * * - ***************************************************************************/ + * * + * Optional Values: * + * * + * # Note: url will override other connection settings url: * + * 'mongodb://user:pass@host:port/database/collection', * + * * + ***************************************************************************/ // username: '', // password: '', // auto_reconnect: false, // ssl: false, // stringify: true - }; diff --git a/config/sockets.js b/config/sockets.js index 98546c9c1..7bd785720 100644 --- a/config/sockets.js +++ b/config/sockets.js @@ -1,4 +1,4 @@ -var redisInfo = require('parse-redis-url')().parse(process.env.REDIS_URL); +const redisInfo = require("parse-redis-url")().parse(process.env.REDIS_URL); /** * WebSocket Server Settings @@ -13,28 +13,25 @@ var redisInfo = require('parse-redis-url')().parse(process.env.REDIS_URL); */ module.exports.sockets = { - /*************************************************************************** - * * - * This custom onDisconnect function will be run each time a socket * - * disconnects * - * * - ***************************************************************************/ - afterDisconnect: function(session, socket) { - + * * + * This custom onDisconnect function will be run each time a socket * + * disconnects * + * * + ***************************************************************************/ + afterDisconnect: function (session, socket) { // By default: do nothing. }, - /*************************************************************************** - * * - * `transports` * - * * - * A array of allowed transport methods which the clients will try to use. * - * The flashsocket transport is disabled by default You can enable * - * flashsockets by adding 'flashsocket' to this list: * - * * - ***************************************************************************/ + * * + * `transports` * + * * + * A array of allowed transport methods which the clients will try to use. * + * The flashsocket transport is disabled by default You can enable * + * flashsockets by adding 'flashsocket' to this list: * + * * + ***************************************************************************/ // transports: [ // 'websocket', // 'htmlfile', @@ -43,131 +40,130 @@ module.exports.sockets = { // ], /*************************************************************************** - * * - * Use this option to set the datastore socket.io will use to manage * - * rooms/sockets/subscriptions: default: memory * - * * - ***************************************************************************/ + * * + * Use this option to set the datastore socket.io will use to manage * + * rooms/sockets/subscriptions: default: memory * + * * + ***************************************************************************/ // adapter: 'memory', /*************************************************************************** - * * - * Node.js (and consequently Sails.js) apps scale horizontally. It's a * - * powerful, efficient approach, but it involves a tiny bit of planning. At * - * scale, you'll want to be able to copy your app onto multiple Sails.js * - * servers and throw them behind a load balancer. * - * * - * One of the big challenges of scaling an application is that these sorts * - * of clustered deployments cannot share memory, since they are on * - * physically different machines. On top of that, there is no guarantee * - * that a user will "stick" with the same server between requests (whether * - * HTTP or sockets), since the load balancer will route each request to the * - * Sails server with the most available resources. However that means that * - * all room/pubsub/socket processing and shared memory has to be offloaded * - * to a shared, remote messaging queue (usually Redis) * - * * - * Luckily, Socket.io (and consequently Sails.js) apps support Redis for * - * sockets by default. To enable a remote redis pubsub server, uncomment * - * the config below. * - * * - * Worth mentioning is that, if `adapter` config is `redis`, but host/port * - * is left unset, Sails will try to connect to redis running on localhost * - * via port 6379 * - * * - ***************************************************************************/ - - adapter: 'socket.io-redis', + * * + * Node.js (and consequently Sails.js) apps scale horizontally. It's a * + * powerful, efficient approach, but it involves a tiny bit of planning. At * + * scale, you'll want to be able to copy your app onto multiple Sails.js * + * servers and throw them behind a load balancer. * + * * + * One of the big challenges of scaling an application is that these sorts * + * of clustered deployments cannot share memory, since they are on * + * physically different machines. On top of that, there is no guarantee * + * that a user will "stick" with the same server between requests (whether * + * HTTP or sockets), since the load balancer will route each request to the * + * Sails server with the most available resources. However that means that * + * all room/pubsub/socket processing and shared memory has to be offloaded * + * to a shared, remote messaging queue (usually Redis) * + * * + * Luckily, Socket.io (and consequently Sails.js) apps support Redis for * + * sockets by default. To enable a remote redis pubsub server, uncomment * + * the config below. * + * * + * Worth mentioning is that, if `adapter` config is `redis`, but host/port * + * is left unset, Sails will try to connect to redis running on localhost * + * via port 6379 * + * * + ***************************************************************************/ + + adapter: "socket.io-redis", host: redisInfo.host, port: redisInfo.port, db: redisInfo.database, - pass: redisInfo.password + pass: redisInfo.password, /*************************************************************************** - * * - * `authorization` * - * * - * Global authorization for Socket.IO access, this is called when the * - * initial handshake is performed with the server. * - * * - * By default (`authorization: false`), when a socket tries to connect, * - * Sails allows it, every time. If no valid cookie was sent, a temporary * - * session will be created for the connecting socket. * - * * - * If `authorization: true`, before allowing a connection, Sails verifies * - * that a valid cookie was sent with the upgrade request. If the cookie * - * doesn't match any known user session, a new user session is created for * - * it. (In most cases, the user would already have a cookie since they * - * loaded the socket.io client and the initial HTML page.) * - * * - * However, in the case of cross-domain requests, it is possible to receive * - * a connection upgrade request WITHOUT A COOKIE (for certain transports) * - * In this case, there is no way to keep track of the requesting user * - * between requests, since there is no identifying information to link * - * him/her with a session. The sails.io.js client solves this by connecting * - * to a CORS endpoint first to get a 3rd party cookie (fortunately this * - * works, even in Safari), then opening the connection. * - * * - * You can also pass along a ?cookie query parameter to the upgrade url, * - * which Sails will use in the absense of a proper cookie e.g. (when * - * connection from the client): * - * io.connect('http://localhost:1337?cookie=smokeybear') * - * * - * (Un)fortunately, the user's cookie is (should!) not accessible in * - * client-side js. Using HTTP-only cookies is crucial for your app's * - * security. Primarily because of this situation, as well as a handful of * - * other advanced use cases, Sails allows you to override the authorization * - * behavior with your own custom logic by specifying a function, e.g: * - * * - * authorization: function authSocketConnectionAttempt(reqObj, cb) { * - * * - * // Any data saved in `handshake` is available in subsequent * - * requests from this as `req.socket.handshake.*` * - * * - * // to allow the connection, call `cb(null, true)` * - * // to prevent the connection, call `cb(null, false)` * - * // to report an error, call `cb(err)` * - * } * - * * - ***************************************************************************/ + * * + * `authorization` * + * * + * Global authorization for Socket.IO access, this is called when the * + * initial handshake is performed with the server. * + * * + * By default (`authorization: false`), when a socket tries to connect, * + * Sails allows it, every time. If no valid cookie was sent, a temporary * + * session will be created for the connecting socket. * + * * + * If `authorization: true`, before allowing a connection, Sails verifies * + * that a valid cookie was sent with the upgrade request. If the cookie * + * doesn't match any known user session, a new user session is created for * + * it. (In most cases, the user would already have a cookie since they * + * loaded the socket.io client and the initial HTML page.) * + * * + * However, in the case of cross-domain requests, it is possible to receive * + * a connection upgrade request WITHOUT A COOKIE (for certain transports) * + * In this case, there is no way to keep track of the requesting user * + * between requests, since there is no identifying information to link * + * him/her with a session. The sails.io.js client solves this by connecting * + * to a CORS endpoint first to get a 3rd party cookie (fortunately this * + * works, even in Safari), then opening the connection. * + * * + * You can also pass along a ?cookie query parameter to the upgrade url, * + * which Sails will use in the absense of a proper cookie e.g. (when * + * connection from the client): * + * io.connect('http://localhost:1337?cookie=smokeybear') * + * * + * (Un)fortunately, the user's cookie is (should!) not accessible in * + * client-side js. Using HTTP-only cookies is crucial for your app's * + * security. Primarily because of this situation, as well as a handful of * + * other advanced use cases, Sails allows you to override the authorization * + * behavior with your own custom logic by specifying a function, e.g: * + * * + * authorization: function authSocketConnectionAttempt(reqObj, cb) { * + * * + * // Any data saved in `handshake` is available in subsequent * + * requests from this as `req.socket.handshake.*` * + * * + * // to allow the connection, call `cb(null, true)` * + * // to prevent the connection, call `cb(null, false)` * + * // to report an error, call `cb(err)` * + * } * + * * + ***************************************************************************/ // authorization: false, /*************************************************************************** - * * - * Whether to run code which supports legacy usage for connected sockets * - * running the v0.9 version of the socket client SDK (i.e. sails.io.js). * - * Disabled in newly generated projects, but enabled as an implicit default * - * (i.e. legacy usage/v0.9 clients be supported if this property is set to * - * true, but also if it is removed from this configuration file or set to * - * `undefined`) * - * * - ***************************************************************************/ + * * + * Whether to run code which supports legacy usage for connected sockets * + * running the v0.9 version of the socket client SDK (i.e. sails.io.js). * + * Disabled in newly generated projects, but enabled as an implicit default * + * (i.e. legacy usage/v0.9 clients be supported if this property is set to * + * true, but also if it is removed from this configuration file or set to * + * `undefined`) * + * * + ***************************************************************************/ // 'backwardsCompatibilityFor0.9SocketClients': false, /*************************************************************************** - * * - * Whether to expose a 'get /__getcookie' route with CORS support that sets * - * a cookie (this is used by the sails.io.js socket client to get access to * - * a 3rd party cookie and to enable sessions). * - * * - * Warning: Currently in this scenario, CORS settings apply to interpreted * - * requests sent via a socket.io connection that used this cookie to * - * connect, even for non-browser clients! (e.g. iOS apps, toasters, node.js * - * unit tests) * - * * - ***************************************************************************/ + * * + * Whether to expose a 'get /__getcookie' route with CORS support that sets * + * a cookie (this is used by the sails.io.js socket client to get access to * + * a 3rd party cookie and to enable sessions). * + * * + * Warning: Currently in this scenario, CORS settings apply to interpreted * + * requests sent via a socket.io connection that used this cookie to * + * connect, even for non-browser clients! (e.g. iOS apps, toasters, node.js * + * unit tests) * + * * + ***************************************************************************/ // grant3rdPartyCookie: true, /*************************************************************************** - * * - * Match string representing the origins that are allowed to connect to the * - * Socket.IO server * - * * - ***************************************************************************/ + * * + * Match string representing the origins that are allowed to connect to the * + * Socket.IO server * + * * + ***************************************************************************/ // origins: '*:*', - }; diff --git a/config/views.js b/config/views.js index b1cc11e00..1cc912881 100644 --- a/config/views.js +++ b/config/views.js @@ -12,70 +12,66 @@ */ module.exports.views = { - /**************************************************************************** - * * - * View engine (aka template language) to use for your app's *server-side* * - * views * - * * - * Sails+Express supports all view engines which implement TJ Holowaychuk's * - * `consolidate.js`, including, but not limited to: * - * * - * ejs, jade, handlebars, mustache underscore, hogan, haml, haml-coffee, * - * dust atpl, eco, ect, jazz, jqtpl, JUST, liquor, QEJS, swig, templayed, * - * toffee, walrus, & whiskers * - * * - * For more options, check out the docs: * - * https://github.com/balderdashy/sails-wiki/blob/0.9/config.views.md#engine * - * * - ****************************************************************************/ + * * + * View engine (aka template language) to use for your app's *server-side* * + * views * + * * + * Sails+Express supports all view engines which implement TJ Holowaychuk's * + * `consolidate.js`, including, but not limited to: * + * * + * ejs, jade, handlebars, mustache underscore, hogan, haml, haml-coffee, * + * dust atpl, eco, ect, jazz, jqtpl, JUST, liquor, QEJS, swig, templayed, * + * toffee, walrus, & whiskers * + * * + * For more options, check out the docs: * + * https://github.com/balderdashy/sails-wiki/blob/0.9/config.views.md#engine * + * * + ****************************************************************************/ - engine: 'ejs', - + engine: "ejs", /**************************************************************************** - * * - * Layouts are simply top-level HTML templates you can use as wrappers for * - * your server-side views. If you're using ejs or jade, you can take * - * advantage of Sails' built-in `layout` support. * - * * - * When using a layout, when one of your views is served, it is injected * - * into the `body` partial defined in the layout. This lets you reuse header * - * and footer logic between views. * - * * - * NOTE: Layout support is only implemented for the `ejs` view engine! * - * For most other engines, it is not necessary, since they implement * - * partials/layouts themselves. In those cases, this config will be * - * silently ignored. * - * * - * The `layout` setting may be set to one of the following: * - * * - * If `false`, layouts will be disabled. Otherwise, if a string is * - * specified, it will be interpreted as the relative path to your layout * - * file from `views/` folder. (the file extension, ".ejs", should be * - * omitted) * - * * - ****************************************************************************/ + * * + * Layouts are simply top-level HTML templates you can use as wrappers for * + * your server-side views. If you're using ejs or jade, you can take * + * advantage of Sails' built-in `layout` support. * + * * + * When using a layout, when one of your views is served, it is injected * + * into the `body` partial defined in the layout. This lets you reuse header * + * and footer logic between views. * + * * + * NOTE: Layout support is only implemented for the `ejs` view engine! * + * For most other engines, it is not necessary, since they implement * + * partials/layouts themselves. In those cases, this config will be * + * silently ignored. * + * * + * The `layout` setting may be set to one of the following: * + * * + * If `false`, layouts will be disabled. Otherwise, if a string is * + * specified, it will be interpreted as the relative path to your layout * + * file from `views/` folder. (the file extension, ".ejs", should be * + * omitted) * + * * + ****************************************************************************/ - layout: 'layout' + layout: "layout", /**************************************************************************** - * * - * Using Multiple Layouts with EJS * - * * - * If you're using the default engine, `ejs`, Sails supports the use of * - * multiple `layout` files. To take advantage of this, before rendering a * - * view, override the `layout` local in your controller by setting * - * `res.locals.layout`. (this is handy if you parts of your app's UI look * - * completely different from each other) * - * * - * e.g. your default might be * - * layout: 'layouts/public' * - * * - * But you might override that in some of your controllers with: * - * layout: 'layouts/internal' * - * * - ****************************************************************************/ - - + * * + * Using Multiple Layouts with EJS * + * * + * If you're using the default engine, `ejs`, Sails supports the use of * + * multiple `layout` files. To take advantage of this, before rendering a * + * view, override the `layout` local in your controller by setting * + * `res.locals.layout`. (this is handy if you parts of your app's UI look * + * completely different from each other) * + * * + * e.g. your default might be * + * layout: 'layouts/public' * + * * + * But you might override that in some of your controllers with: * + * layout: 'layouts/internal' * + * * + ****************************************************************************/ }; diff --git a/console.js b/console.js index 03d31fc1d..bfa45d1d9 100755 --- a/console.js +++ b/console.js @@ -4,94 +4,101 @@ * n.b.: hella copy-pasted from sails/bin/sails-console.js */ -require('babel-register') -const _ = require('lodash') -const fs = require('fs') -const sails = require('sails') +require("babel-register"); +const _ = require("lodash"); +const fs = require("fs"); +const sails = require("sails"); -;(function () { +(function () { // Try to get `rc` dependency - var rc + let rc; try { - rc = require('rc') + rc = require("rc"); } catch (e0) { try { - rc = require('sails/node_modules/rc') + rc = require("sails/node_modules/rc"); } catch (e1) { - console.error('Could not find dependency: `rc`.') - console.error('Your `.sailsrc` file(s) will be ignored.') - console.error('To resolve this, run:') - console.error('npm install rc --save') - rc = () => ({}) + console.error("Could not find dependency: `rc`."); + console.error("Your `.sailsrc` file(s) will be ignored."); + console.error("To resolve this, run:"); + console.error("npm install rc --save"); + rc = () => ({}); } } // Start server - sails.lift(_.merge(rc('sails'), { - log: { - noShip: true - }, - // comment out all of this hook-disabling to test sockets from the console - hooks: { - http: false, - sockets: false, - views: false - } - }), function (err) { // eslint-disable-line - var repl = require('repl').start('sails> ') - try { - history(repl, require('path').join(sails.config.paths.tmp, '.node_history')) - } catch (e) {} - repl.on('exit', function () { - process.exit() - }) + sails.lift( + _.merge(rc("sails"), { + log: { + noShip: true, + }, + // comment out all of this hook-disabling to test sockets from the console + hooks: { + http: false, + sockets: false, + views: false, + }, + }), + function (err) { + // eslint-disable-line + const repl = require("repl").start("sails> "); + try { + history( + repl, + require("path").join(sails.config.paths.tmp, ".node_history") + ); + } catch (e) {} + repl.on("exit", function () { + process.exit(); + }); - if (process.env.PROMIREPL) { - require('promirepl').promirepl(repl) - } else { - require('async-repl/stubber')(repl) + if (process.env.PROMIREPL) { + require("promirepl").promirepl(repl); + } else { + require("async-repl/stubber")(repl); + } } - }) -})() + ); +})(); /** -* REPL History -* Pulled directly from https://github.com/tmpvar/repl.history -* with the slight tweak of setting historyIndex to -1 so that -* it works as expected. -*/ + * REPL History + * Pulled directly from https://github.com/tmpvar/repl.history + * with the slight tweak of setting historyIndex to -1 so that + * it works as expected. + */ -function history (repl, file) { +function history(repl, file) { try { - repl.rli.history = fs.readFileSync(file, 'utf-8').split('\n').reverse() - repl.rli.history.shift() - repl.rli.historyIndex = -1 + repl.rli.history = fs.readFileSync(file, "utf-8").split("\n").reverse(); + repl.rli.history.shift(); + repl.rli.historyIndex = -1; } catch (e) {} - var fd = fs.openSync(file, 'a') + const fd = fs.openSync(file, "a"); - repl.rli.addListener('line', function (code) { - if (code && code !== '.history') { - fs.write(fd, code + '\n', () => {}) + repl.rli.addListener("line", function (code) { + if (code && code !== ".history") { + fs.write(fd, code + "\n", () => {}); } else { - repl.rli.historyIndex++ - repl.rli.history.pop() + repl.rli.historyIndex++; + repl.rli.history.pop(); } - }) + }); - process.on('exit', function () { - fs.closeSync(fd) - }) + process.on("exit", function () { + fs.closeSync(fd); + }); - repl.commands['.history'] = { - help: 'Show the history', + repl.commands[".history"] = { + help: "Show the history", action: function () { - var out = [] + const out = []; repl.rli.history.forEach(function (v, k) { - out.push(v) - }) - repl.outputStream.write(out.reverse().join('\n') + '\n') - repl.displayPrompt() - } - } + out.push(v); + }); + repl.outputStream.write(out.reverse().join("\n") + "\n"); + repl.displayPrompt(); + }, + }; } diff --git a/cron.js b/cron.js index 8da536980..183f08aca 100644 --- a/cron.js +++ b/cron.js @@ -1,101 +1,103 @@ /* globals Nexudus */ -require('babel-register') -var skiff = require('./lib/skiff') // this must be required first -var moment = require('moment-timezone') -var rollbar = require('./lib/rollbar') -var sails = skiff.sails -var digest2 = require('./lib/community/digest2') -var Promise = require('bluebird') -var { red } = require('chalk') -const savedSearches = require('./lib/community/digest2/savedSearches') +require("babel-register"); +const skiff = require("./lib/skiff"); // this must be required first +const moment = require("moment-timezone"); +const rollbar = require("./lib/rollbar"); +const sails = skiff.sails; +const digest2 = require("./lib/community/digest2"); +const Promise = require("bluebird"); +const { red } = require("chalk"); +const savedSearches = require("./lib/community/digest2/savedSearches"); -const sendAndLogDigests = type => - digest2.sendAllDigests(type) - .tap(results => sails.log.debug(`Sent digests to: ${results}`)) +const sendAndLogDigests = (type) => + digest2 + .sendAllDigests(type) + .tap((results) => sails.log.debug(`Sent digests to: ${results}`)); -const sendSavedSearchDigests = userId => - savedSearches.sendAllDigests(userId) +const sendSavedSearchDigests = (userId) => savedSearches.sendAllDigests(userId); const resendInvites = () => - Invitation.resendAllReady() - .tap(results => sails.log.debug(`Resent the following invites: ${results}`)) + Invitation.resendAllReady().tap((results) => + sails.log.debug(`Resent the following invites: ${results}`) + ); // Currently Nexudus updates are disabled // const updateFromNexudus = opts => // Nexudus.updateAllCommunities(opts) // .then(report => sails.log.debug('Updated users from Nexudus:', report)) -const daily = now => { - const tasks = [] +const daily = (now) => { + const tasks = []; - sails.log.debug('Removing old kue jobs') - tasks.push(Queue.removeOldJobs('complete', 20000)) + sails.log.debug("Removing old kue jobs"); + tasks.push(Queue.removeOldJobs("complete", 20000)); - sails.log.debug('Removing old notifications') - tasks.push(Notification.removeOldNotifications()) + sails.log.debug("Removing old notifications"); + tasks.push(Notification.removeOldNotifications()); switch (now.day()) { case 3: - sails.log.debug('Sending weekly digests') - tasks.push(sendAndLogDigests('weekly')) - tasks.push(sendSavedSearchDigests('weekly')) - break + sails.log.debug("Sending weekly digests"); + tasks.push(sendAndLogDigests("weekly")); + tasks.push(sendSavedSearchDigests("weekly")); + break; } - return tasks -} + return tasks; +}; -const hourly = now => { +const hourly = (now) => { // Currently nexudus updates are disabled. To enable, uncomment here and definition at top of this file. // const tasks = [ // updateFromNexudus({dryRun: false}) // ] - const tasks = [] + const tasks = []; switch (now.hour()) { case 12: - sails.log.debug('Sending daily digests') - tasks.push(sendAndLogDigests('daily')) - tasks.push(sendSavedSearchDigests('daily')) - break + sails.log.debug("Sending daily digests"); + tasks.push(sendAndLogDigests("daily")); + tasks.push(sendSavedSearchDigests("daily")); + break; case 13: - sails.log.debug('Resending invites') - tasks.push(resendInvites()) - break + sails.log.debug("Resending invites"); + tasks.push(resendInvites()); + break; } - return tasks -} + return tasks; +}; -const every10minutes = now => { - sails.log.debug('Refreshing full-text search index') +const every10minutes = (now) => { + sails.log.debug("Refreshing full-text search index"); return [ FullTextSearch.refreshView(), - Comment.sendDigests() - .then(count => sails.log.debug(`Sent ${count} message digests`)) - ] -} + Comment.sendDigests().then((count) => + sails.log.debug(`Sent ${count} message digests`) + ), + ]; +}; -var runJob = Promise.method(name => { - const job = {hourly, daily, every10minutes}[name] - if (typeof job !== 'function') { - throw new Error(`Unknown job name: "${name}"`) +const runJob = Promise.method((name) => { + const job = { hourly, daily, every10minutes }[name]; + if (typeof job !== "function") { + throw new Error(`Unknown job name: "${name}"`); } - sails.log.debug(`Running ${name} job`) - const now = moment.tz('America/Los_Angeles') - return Promise.all(job(now)) -}) + sails.log.debug(`Running ${name} job`); + const now = moment.tz("America/Los_Angeles"); + return Promise.all(job(now)); +}); skiff.lift({ start: function (argv) { runJob(argv.interval) - .then(function () { - skiff.lower() - }) - .catch(function (err) { - sails.log.error(red(err.message)) - sails.log.error(err) - rollbar.error(err, () => skiff.lower()) - }) - } -}) + .then(function () { + skiff.lower(); + }) + .catch(function (err) { + sails.log.error(red(err.message)); + sails.log.error(err); + rollbar.error(err, () => skiff.lower()); + }); + }, +}); diff --git a/knexfile.js b/knexfile.js index ac861a098..195c36ce9 100644 --- a/knexfile.js +++ b/knexfile.js @@ -1,61 +1,59 @@ -const merge = require('lodash/merge') -require('dotenv').load() +const merge = require("lodash/merge"); +require("dotenv").load(); if (!process.env.DATABASE_URL) { - throw new Error('process.env.DATABASE_URL must be set') + throw new Error("process.env.DATABASE_URL must be set"); } -const url = require('url').parse(process.env.DATABASE_URL) -var user, password +const url = require("url").parse(process.env.DATABASE_URL); +let user, password; if (url.auth) { - const i = url.auth.indexOf(':') - user = url.auth.slice(0, i) - password = url.auth.slice(i + 1) + const i = url.auth.indexOf(":"); + user = url.auth.slice(0, i); + password = url.auth.slice(i + 1); } const defaults = { - client: 'pg', + client: "pg", connection: { host: url.hostname, port: url.port, - user: user || 'postgres', + user: user || "postgres", password: password, - database: url.pathname.substring(1) + database: url.pathname.substring(1), }, pool: { // https://github.com/Vincit/objection.js/issues/1137 min: 5, // default 2 max: 30, // default 10 // https://github.com/knex/knex/issues/2820#issuecomment-481710112 - propagateCreateError: false // default true (false NOT recommended) + propagateCreateError: false, // default true (false NOT recommended) }, migrations: { - tableName: 'knex_migrations' - } -} + tableName: "knex_migrations", + }, +}; module.exports = { test: defaults, development: defaults, - dummy: Object.assign({}, defaults, { seeds: { directory: './seeds/dummy' } }), + dummy: Object.assign({}, defaults, { seeds: { directory: "./seeds/dummy" } }), staging: defaults, - production: merge({connection: {ssl: true}}, defaults), - docker: Object.assign({}, - defaults, - { - connection: Object.assign({}, - defaults.connection, - { user: 'hylo', password: 'hylo', port: '5300' } - ) - } - ), - createUpdateTrigger: table => ` + production: merge({ connection: { ssl: true } }, defaults), + docker: Object.assign({}, defaults, { + connection: Object.assign({}, defaults.connection, { + user: "hylo", + password: "hylo", + port: "5300", + }), + }), + createUpdateTrigger: (table) => ` CREATE TRIGGER ${table}_updated_at BEFORE UPDATE ON ${table} FOR EACH ROW EXECUTE PROCEDURE on_update_timestamp(); `, - dropUpdateTrigger: table => ` + dropUpdateTrigger: (table) => ` DROP TRIGGER IF EXISTS ${table}_updated_at ON ${table} `, createUpdateFunction: () => ` @@ -69,5 +67,5 @@ module.exports = { `, dropUpdateFunction: () => ` DROP FUNCTION IF EXISTS on_update_timestamp() - ` -} + `, +}; diff --git a/lib/community/digest2/formatData.js b/lib/community/digest2/formatData.js index 21b1d8919..e80a5e77d 100644 --- a/lib/community/digest2/formatData.js +++ b/lib/community/digest2/formatData.js @@ -1,94 +1,126 @@ /* eslint-disable camelcase */ import { - curry, every, filter, find, isEmpty, map, pickBy, sortBy -} from 'lodash/fp' + curry, + every, + filter, + find, + isEmpty, + map, + pickBy, + sortBy, +} from "lodash/fp"; -import moment from 'moment' +import moment from "moment"; -const isTagged = name => post => { - const tag = post.relations.selectedTags.first() - return tag && tag.get('name') === name -} -const isRequest = isTagged('request') -const isOffer = isTagged('offer') -const isResource = isTagged('resource') -const isEvent = post => post.get('type') === 'event' -const isProject = post => post.get('type') === 'project' -const isOtherTag = post => !isRequest(post) && !isOffer(post) && !isEvent(post) && !isProject(post) && !isResource +const isTagged = (name) => (post) => { + const tag = post.relations.selectedTags.first(); + return tag && tag.get("name") === name; +}; +const isRequest = isTagged("request"); +const isOffer = isTagged("offer"); +const isResource = isTagged("resource"); +const isEvent = (post) => post.get("type") === "event"; +const isProject = (post) => post.get("type") === "project"; +const isOtherTag = (post) => + !isRequest(post) && + !isOffer(post) && + !isEvent(post) && + !isProject(post) && + !isResource; -export const presentAuthor = obj => - obj.relations.user.pick('id', 'name', 'avatar_url') +export const presentAuthor = (obj) => + obj.relations.user.pick("id", "name", "avatar_url"); const humanDate = (date) => { - if (!date) return null - return moment(date).format('ha - MMMM D, YYYY') -} + if (!date) return null; + return moment(date).format("ha - MMMM D, YYYY"); +}; const presentPost = curry((slug, post) => { - const { children, linkPreview } = post.relations - return pickBy(x => x, { + const { children, linkPreview } = post.relations; + return pickBy((x) => x, { id: post.id, - title: post.get('name'), - details: RichText.qualifyLinks(post.get('description'), null, null, slug), + title: post.get("name"), + details: RichText.qualifyLinks(post.get("description"), null, null, slug), user: presentAuthor(post), url: Frontend.Route.post(post, slug), - location: isEvent(post) && post.get('location'), - requests: children && children.map(p => p.get('name')), - when: isEvent(post) && humanDate(post.get('starts_at')), + location: isEvent(post) && post.get("location"), + requests: children && children.map((p) => p.get("name")), + when: isEvent(post) && humanDate(post.get("starts_at")), comments: [], - link_preview: linkPreview && linkPreview.id && - linkPreview.pick('title', 'description', 'url', 'image_url') - }) -}) + link_preview: + linkPreview && + linkPreview.id && + linkPreview.pick("title", "description", "url", "image_url"), + }); +}); const presentComment = curry((slug, comment) => ({ id: comment.id, - text: RichText.qualifyLinks(comment.get('text'), null, null, slug), - user: presentAuthor(comment) -})) + text: RichText.qualifyLinks(comment.get("text"), null, null, slug), + user: presentAuthor(comment), +})); const formatData = curry((community, data) => { - const slug = community.get('slug') - const requests = map(presentPost(slug), filter(isRequest, data.posts)) - const offers = map(presentPost(slug), filter(isOffer, data.posts)) - const resources = map(presentPost(slug), filter(isResource, data.posts)) - const events = map(presentPost(slug), filter(isEvent, data.posts)) - const projects = map(presentPost(slug), filter(isProject, data.posts)) - const conversations = map(presentPost(slug), filter(isOtherTag, data.posts)) - const postsWithNewComments = [] + const slug = community.get("slug"); + const requests = map(presentPost(slug), filter(isRequest, data.posts)); + const offers = map(presentPost(slug), filter(isOffer, data.posts)); + const resources = map(presentPost(slug), filter(isResource, data.posts)); + const events = map(presentPost(slug), filter(isEvent, data.posts)); + const projects = map(presentPost(slug), filter(isProject, data.posts)); + const conversations = map(presentPost(slug), filter(isOtherTag, data.posts)); + const postsWithNewComments = []; - const findFormattedPost = id => find(p => p.id === id, - requests.concat(offers).concat(conversations).concat(projects).concat(events).concat(resources).concat(postsWithNewComments)) + const findFormattedPost = (id) => + find( + (p) => p.id === id, + requests + .concat(offers) + .concat(conversations) + .concat(projects) + .concat(events) + .concat(resources) + .concat(postsWithNewComments) + ); - data.comments.forEach(comment => { - let post = findFormattedPost(comment.get('post_id')) + data.comments.forEach((comment) => { + let post = findFormattedPost(comment.get("post_id")); if (!post) { - post = presentPost(slug, comment.relations.post) - postsWithNewComments.push(post) + post = presentPost(slug, comment.relations.post); + postsWithNewComments.push(post); } - post.comments.push(presentComment(slug, comment)) - }) + post.comments.push(presentComment(slug, comment)); + }); - postsWithNewComments.forEach(post => { - post.comment_count = post.comments.length - }) + postsWithNewComments.forEach((post) => { + post.comment_count = post.comments.length; + }); const ret = { - requests: sortBy(p => -p.id, requests), - offers: sortBy(p => -p.id, offers), - resources: sortBy(p => -p.id, resources), - conversations: sortBy(p => -p.id, conversations), - events: sortBy(p => -p.id, events), - projects: sortBy(p => -p.id, projects), - postsWithNewComments: sortBy(p => -p.id, postsWithNewComments) - } + requests: sortBy((p) => -p.id, requests), + offers: sortBy((p) => -p.id, offers), + resources: sortBy((p) => -p.id, resources), + conversations: sortBy((p) => -p.id, conversations), + events: sortBy((p) => -p.id, events), + projects: sortBy((p) => -p.id, projects), + postsWithNewComments: sortBy((p) => -p.id, postsWithNewComments), + }; - if (every(isEmpty, [requests, offers, resources, conversations, events, projects])) { + if ( + every(isEmpty, [ + requests, + offers, + resources, + conversations, + events, + projects, + ]) + ) { // this is used in the email templates - ret.no_new_activity = true + ret.no_new_activity = true; } - return ret -}) + return ret; +}); -export default formatData +export default formatData; diff --git a/lib/community/digest2/index.js b/lib/community/digest2/index.js index 65066a1ca..680b53ddf 100644 --- a/lib/community/digest2/index.js +++ b/lib/community/digest2/index.js @@ -1,69 +1,90 @@ -import { compact, merge } from 'lodash' -import sampleData from './sampleData.json' -import formatData from './formatData' -import personalizeData from './personalizeData' +import { compact, merge } from "lodash"; +import sampleData from "./sampleData.json"; +import formatData from "./formatData"; +import personalizeData from "./personalizeData"; import { defaultTimeRange, getPostsAndComments, getRecipients, - shouldSendData -} from './util' + shouldSendData, +} from "./util"; -const DIGEST_TEMPLATE_ID = 'tem_eCnLj6q75A7Ruu9zsLgppN' -const SAVED_SEARCH_TEMPLATE_ID = 'tem_GqjMtFKdPHjPHvkqyHBD7C3P' - -const timePeriod = type => { +const DIGEST_TEMPLATE_ID = "tem_eCnLj6q75A7Ruu9zsLgppN"; +const SAVED_SEARCH_TEMPLATE_ID = "tem_GqjMtFKdPHjPHvkqyHBD7C3P"; + +const timePeriod = (type) => { switch (type) { - case 'daily': return 'yesterday' - case 'weekly': return 'last week' + case "daily": + return "yesterday"; + case "weekly": + return "last week"; } -} +}; export const prepareDigestData = (id, type, opts = {}) => { - let startTime = opts.startTime - let endTime = opts.endTime + let startTime = opts.startTime; + let endTime = opts.endTime; if (!opts.startTime) { - const range = defaultTimeRange(type) - startTime = range[0] - endTime = range[1] + const range = defaultTimeRange(type); + startTime = range[0]; + endTime = range[1]; } - return Community.find(id).then(c => + return Community.find(id).then((c) => getPostsAndComments(c, startTime, endTime) - .then(formatData(c)) - .then(data => merge({ - community_id: c.id, - community_name: c.get('name'), - community_avatar_url: c.get('avatar_url'), - community_url: Frontend.Route.community(c), - time_period: timePeriod(type) - }, data))) -} + .then(formatData(c)) + .then((data) => + merge( + { + community_id: c.id, + community_name: c.get("name"), + community_avatar_url: c.get("avatar_url"), + community_url: Frontend.Route.community(c), + time_period: timePeriod(type), + }, + data + ) + ) + ); +}; export const sendToUser = (user, data, opts = {}) => { - const versionName = 'v4' - const templateId = data.search ? SAVED_SEARCH_TEMPLATE_ID : DIGEST_TEMPLATE_ID - return personalizeData(user, data, merge(opts, {versionName})) - .then(data => - opts.dryRun || - Email.sendSimpleEmail(user.get('email'), templateId, data, { - sender: {name: data.community_name || 'Hylo'}, - version_name: versionName - })) -} + const versionName = "v4"; + const templateId = data.search + ? SAVED_SEARCH_TEMPLATE_ID + : DIGEST_TEMPLATE_ID; + return personalizeData(user, data, merge(opts, { versionName })).then( + (data) => + opts.dryRun || + Email.sendSimpleEmail(user.get("email"), templateId, data, { + sender: { name: data.community_name || "Hylo" }, + version_name: versionName, + }) + ); +}; export const sendDigest = (id, type, opts = {}) => { - return prepareDigestData(id, type, opts).then(data => - shouldSendData(data, id) - .then(ok => ok && getRecipients(id, type) - .then(users => Promise.each(users, user => sendToUser(user, data, opts))) - .then(users => users.length))) -} + return prepareDigestData(id, type, opts).then((data) => + shouldSendData(data, id).then( + (ok) => + ok && + getRecipients(id, type) + .then((users) => + Promise.each(users, (user) => sendToUser(user, data, opts)) + ) + .then((users) => users.length) + ) + ); +}; export const sendAllDigests = (type, opts) => - Community.where({active: true, daily_digest: true}).query().pluck('id') - .then(ids => Promise.map(ids, id => - sendDigest(id, type, opts).then(count => count && [id, count])) - .then(compact)) + Community.where({ active: true, daily_digest: true }) + .query() + .pluck("id") + .then((ids) => + Promise.map(ids, (id) => + sendDigest(id, type, opts).then((count) => count && [id, count]) + ).then(compact) + ); -export const sendSampleData = address => - Email.sendSimpleEmail(address, templateId, sampleData) +export const sendSampleData = (address) => + Email.sendSimpleEmail(address, templateId, sampleData); diff --git a/lib/community/digest2/personalizeData.js b/lib/community/digest2/personalizeData.js index aa0984e5b..ddf2193d6 100644 --- a/lib/community/digest2/personalizeData.js +++ b/lib/community/digest2/personalizeData.js @@ -1,110 +1,149 @@ -import { cloneDeep, compact, flatten, merge, pick, values } from 'lodash' -import { flatMap, flow, map, sortBy, uniqBy, includes, filter, get } from 'lodash/fp' -import qs from 'querystring' -import cheerio from 'cheerio' +import { cloneDeep, compact, flatten, merge, pick, values } from "lodash"; +import { + flatMap, + flow, + map, + sortBy, + uniqBy, + includes, + filter, + get, +} from "lodash/fp"; +import qs from "querystring"; +import cheerio from "cheerio"; const commaSeparate = (items, max) => { - const length = items.length + const length = items.length; switch (length) { - case 0: return '' - case 1: return items[0] - case 2: return `${items[0]} and ${items[1]}` + case 0: + return ""; + case 1: + return items[0]; + case 2: + return `${items[0]} and ${items[1]}`; default: - return `${items[0]}, ${items[1]}, and ${length - 2} other${length > 3 ? 's' : ''}` + return `${items[0]}, ${items[1]}, and ${length - 2} other${ + length > 3 ? "s" : "" + }`; } -} +}; const generateSubjectLine = (user, data) => { const names = flow( - map(p => p.user), + map((p) => p.user), compact, - uniqBy('id'), - sortBy(u => u.id === user.id ? 1 : 0), - map('name') - )(getPosts(data)) - return `New activity from ${commaSeparate(names, 2)}` -} - -const getPosts = data => - flatten(values(pick(data, 'requests', 'offers', 'resources', 'conversations', 'projects', 'events'))) - -const getComments = data => - flatMap('comments', getPosts(data)).filter(_ => !!_) //Filter out items that are undefined (a.k.a. for Saved Search posts) + uniqBy("id"), + sortBy((u) => (u.id === user.id ? 1 : 0)), + map("name") + )(getPosts(data)); + return `New activity from ${commaSeparate(names, 2)}`; +}; + +const getPosts = (data) => + flatten( + values( + pick( + data, + "requests", + "offers", + "resources", + "conversations", + "projects", + "events" + ) + ) + ); + +const getComments = (data) => + flatMap("comments", getPosts(data)).filter((_) => !!_); // Filter out items that are undefined (a.k.a. for Saved Search posts) const addParamsToLinks = (text, params) => { - if (!text) return - const doc = cheerio.load(text, {decodeEntities: false}) - const links = doc('a[href]') - if (links.length === 0) return text + if (!text) return; + const doc = cheerio.load(text, { decodeEntities: false }); + const links = doc("a[href]"); + if (links.length === 0) return text; links.each((i, el) => { - const a = doc(el) - const href = a.attr('href') + const a = doc(el); + const href = a.attr("href"); if (href && href.startsWith(Frontend.Route.prefix)) { - let newHref = href + params + let newHref = href + params; // if the original href has query params, fix the new value if (newHref.match(/\?/g).length > 1) { - const i = newHref.lastIndexOf('?') - newHref = newHref.slice(0, i) + '&' + newHref.slice(i + 1) + const i = newHref.lastIndexOf("?"); + newHref = newHref.slice(0, i) + "&" + newHref.slice(i + 1); } - a.attr('href', newHref) + a.attr("href", newHref); } - }) - return doc.html() -} + }); + return doc.html(); +}; const filterBlockedUserData = async (userId, data) => { - const clonedData = cloneDeep(data) - const blockedUserIds = (await BlockedUser.blockedFor(userId)).rows.map(r => r.user_id) - - const keys = ['conversations', 'requests', 'offers', 'events', 'projects', 'resources'] - for (let key of keys) { - clonedData[key] = filter(object => !includes(get('user.id', object), blockedUserIds), clonedData[key]) + const clonedData = cloneDeep(data); + const blockedUserIds = (await BlockedUser.blockedFor(userId)).rows.map( + (r) => r.user_id + ); + + const keys = [ + "conversations", + "requests", + "offers", + "events", + "projects", + "resources", + ]; + for (const key of keys) { + clonedData[key] = filter( + (object) => !includes(get("user.id", object), blockedUserIds), + clonedData[key] + ); } - return clonedData -} + return clonedData; +}; const personalizeData = async (user, data, opts = {}) => { - const filteredData = await filterBlockedUserData(user.id, data) - const clickthroughParams = '?' + qs.stringify({ - ctt: 'digest_email', - cti: user.id, - ctcn: data.community_name - }) - - - - getPosts(filteredData).forEach(post => { - post.url = post.url + clickthroughParams - post.reply_url = Email.postReplyAddress(post.id, user.id) + const filteredData = await filterBlockedUserData(user.id, data); + const clickthroughParams = + "?" + + qs.stringify({ + ctt: "digest_email", + cti: user.id, + ctcn: data.community_name, + }); + + getPosts(filteredData).forEach((post) => { + post.url = post.url + clickthroughParams; + post.reply_url = Email.postReplyAddress(post.id, user.id); if (post.details) { - post.details = addParamsToLinks(post.details, clickthroughParams) + post.details = addParamsToLinks(post.details, clickthroughParams); } - }) - - getComments(filteredData).forEach(comment => { - comment.text = addParamsToLinks(comment.text, clickthroughParams) - }) - - - - return Promise.props(merge(filteredData, { - subject: generateSubjectLine(user, data) + (opts.subjectSuffix || ''), - community_url: filteredData.community_url + clickthroughParams, - recipient: { - avatar_url: user.get('avatar_url'), - name: user.get('name') - }, - email_settings_url: Frontend.Route.userSettings() + clickthroughParams + '&expand=account', - tracking_pixel_url: Analytics.pixelUrl('Digest', { - userId: user.id, - community: data.community_name, - 'Email Version': opts.versionName - }), - post_creation_action_url: Frontend.Route.emailPostForm(), - reply_action_url: Frontend.Route.emailBatchCommentForm(), - form_token: Email.formToken(data.community_id, user.id) - })) -} - -export default personalizeData + }); + + getComments(filteredData).forEach((comment) => { + comment.text = addParamsToLinks(comment.text, clickthroughParams); + }); + + return Promise.props( + merge(filteredData, { + subject: generateSubjectLine(user, data) + (opts.subjectSuffix || ""), + community_url: filteredData.community_url + clickthroughParams, + recipient: { + avatar_url: user.get("avatar_url"), + name: user.get("name"), + }, + email_settings_url: + Frontend.Route.userSettings() + clickthroughParams + "&expand=account", + tracking_pixel_url: Analytics.pixelUrl("Digest", { + userId: user.id, + community: data.community_name, + "Email Version": opts.versionName, + }), + post_creation_action_url: Frontend.Route.emailPostForm(), + reply_action_url: Frontend.Route.emailBatchCommentForm(), + form_token: Email.formToken(data.community_id, user.id), + }) + ); +}; + +export default personalizeData; diff --git a/lib/community/digest2/savedSearches.js b/lib/community/digest2/savedSearches.js index 15eb35284..a275f95e9 100644 --- a/lib/community/digest2/savedSearches.js +++ b/lib/community/digest2/savedSearches.js @@ -1,86 +1,102 @@ -import { merge, pick, pickBy } from 'lodash/fp' -import { pluralize } from '../../util/normalize' -import { presentAuthor } from '../digest2/formatData' -import { sendToUser } from '../digest2' +import { merge, pick, pickBy } from "lodash/fp"; +import { pluralize } from "../../util/normalize"; +import { presentAuthor } from "../digest2/formatData"; +import { sendToUser } from "../digest2"; -import moment from 'moment' +import moment from "moment"; -const humanDate = date => moment(date).format('MMMM D, YYYY') +const humanDate = (date) => moment(date).format("MMMM D, YYYY"); const presentPost = async (p, context, slug) => { - const post = await Post.where({id: p.id}).fetch() - await post.load(['linkPreview','user']) - const { linkPreview } = post.relations - return pickBy(x => x, { + const post = await Post.where({ id: p.id }).fetch(); + await post.load(["linkPreview", "user"]); + const { linkPreview } = post.relations; + return pickBy((x) => x, { id: post.id, - title: post.get('name'), - details: RichText.qualifyLinks(post.get('description'), null, null, undefined), + title: post.get("name"), + details: RichText.qualifyLinks( + post.get("description"), + null, + null, + undefined + ), user: presentAuthor(post), url: Frontend.Route.mapPost(post, context, slug), - location: post.get('location'), - when: post.get('start_time') || post.get('end_time') - ? `${humanDate(post.get('start_time'))} ${humanDate(post.get('end_time')) ? `- ${humanDate(post.get('end_time'))}` : ''}` - : undefined, - link_preview: linkPreview && linkPreview.id && - linkPreview.pick('title', 'description', 'url', 'image_url') - }) -} + location: post.get("location"), + when: + post.get("start_time") || post.get("end_time") + ? `${humanDate(post.get("start_time"))} ${ + humanDate(post.get("end_time")) + ? `- ${humanDate(post.get("end_time"))}` + : "" + }` + : undefined, + link_preview: + linkPreview && + linkPreview.id && + linkPreview.pick("title", "description", "url", "image_url"), + }); +}; const getSlug = async (search, context) => { - let slug - if (context === 'network') { - const network = await search.network() - slug = network.get('slug') - } else if (context === 'community') { - const community = await search.community() - slug = community.get('slug') + let slug; + if (context === "network") { + const network = await search.network(); + slug = network.get("slug"); + } else if (context === "community") { + const community = await search.community(); + slug = community.get("slug"); } - return slug -} + return slug; +}; const prepareDigestData = async (searchId) => { - const search = await SavedSearch.where({ id: searchId }).fetch() - const context = search.get('context') - const slug = await getSlug(search, context) - const user = await User.where({ id: search.get('user_id') }).fetch() - const lastPostId = parseInt(search.get('last_post_id')) - const data = { search, user, lastPostId } - const posts = await search.newPosts() + const search = await SavedSearch.where({ id: searchId }).fetch(); + const context = search.get("context"); + const slug = await getSlug(search, context); + const user = await User.where({ id: search.get("user_id") }).fetch(); + const lastPostId = parseInt(search.get("last_post_id")); + const data = { search, user, lastPostId }; + const posts = await search.newPosts(); const promises = posts.map(async (p) => { - const key = pluralize(p.type) - const presented = await presentPost(p, context, slug) - data[key] = data[key] || [] - data[key].push(presented) - data.lastPostId = Math.max(data.lastPostId, parseInt(p.id)) - return data - }) - await Promise.all(promises) - return data -} + const key = pluralize(p.type); + const presented = await presentPost(p, context, slug); + data[key] = data[key] || []; + data[key].push(presented); + data.lastPostId = Math.max(data.lastPostId, parseInt(p.id)); + return data; + }); + await Promise.all(promises); + return data; +}; const shouldSendData = (data, user, type) => { - const postTypes = ['requests', 'offers', 'events', 'projects', 'resources'] - const hasNewPosts = Object.keys(pick(postTypes, data)).some(s => postTypes.includes(s)) - const userSettingMatchesType = user.get('settings')['digest_frequency'] === type - return hasNewPosts && userSettingMatchesType -} + const postTypes = ["requests", "offers", "events", "projects", "resources"]; + const hasNewPosts = Object.keys(pick(postTypes, data)).some((s) => + postTypes.includes(s) + ); + const userSettingMatchesType = user.get("settings").digest_frequency === type; + return hasNewPosts && userSettingMatchesType; +}; const sendDigest = async (searchId, type) => { - return await prepareDigestData(searchId).then(async data => { - const { lastPostId, user } = data - if (shouldSendData(data, user, type)) return merge(await sendToUser(user, data), { lastPostId }) - }) - .then(async (sent = {}) => { - const { lastPostId, success } = sent - if (success) { - const search = await SavedSearch.where({ id: searchId }).fetch() - return await search.updateLastPost(searchId, lastPostId) - } - }) -} + return await prepareDigestData(searchId) + .then(async (data) => { + const { lastPostId, user } = data; + if (shouldSendData(data, user, type)) + return merge(await sendToUser(user, data), { lastPostId }); + }) + .then(async (sent = {}) => { + const { lastPostId, success } = sent; + if (success) { + const search = await SavedSearch.where({ id: searchId }).fetch(); + return await search.updateLastPost(searchId, lastPostId); + } + }); +}; export const sendAllDigests = async (type) => { - const savedSearches = await SavedSearch.where({ is_active: true }).query() - const promises = savedSearches.map(s => sendDigest(s.id, type)) - await Promise.all(promises) -} + const savedSearches = await SavedSearch.where({ is_active: true }).query(); + const promises = savedSearches.map((s) => sendDigest(s.id, type)); + await Promise.all(promises); +}; diff --git a/lib/community/digest2/util.js b/lib/community/digest2/util.js index 79e85076a..115ecff05 100644 --- a/lib/community/digest2/util.js +++ b/lib/community/digest2/util.js @@ -1,33 +1,45 @@ -import moment from 'moment-timezone' -import { includes } from 'lodash' -import { get, pick, some } from 'lodash/fp' +import moment from "moment-timezone"; +import { includes } from "lodash"; +import { get, pick, some } from "lodash/fp"; -export const defaultTimezone = 'America/Los_Angeles' +export const defaultTimezone = "America/Los_Angeles"; -export const defaultTimeRange = type => { - const today = moment.tz(defaultTimezone).startOf('day').add(12, 'hours') +export const defaultTimeRange = (type) => { + const today = moment.tz(defaultTimezone).startOf("day").add(12, "hours"); switch (type) { - case 'daily': - return [today.clone().subtract(1, 'day'), today] - case 'weekly': - return [today.clone().subtract(7, 'day'), today] + case "daily": + return [today.clone().subtract(1, "day"), today]; + case "weekly": + return [today.clone().subtract(7, "day"), today]; } -} +}; -export const isValidPostType = q => +export const isValidPostType = (q) => q.where(function () { - this.where('posts.type', 'not in', ['welcome']) - .orWhere('posts.type', null) - }) + this.where("posts.type", "not in", ["welcome"]).orWhere("posts.type", null); + }); -export const relatedUserColumns = (relationName = 'user') => ({ - [relationName]: q => q.column('users.id', 'users.name', 'users.avatar_url') -}) +export const relatedUserColumns = (relationName = "user") => ({ + [relationName]: (q) => q.column("users.id", "users.name", "users.avatar_url"), +}); export const shouldSendData = (data, id) => Promise.resolve( - some(some(x => x), pick(['conversations', 'requests', 'offers', 'events', 'projects', 'resources'], data)) - ) + some( + some((x) => x), + pick( + [ + "conversations", + "requests", + "offers", + "events", + "projects", + "resources", + ], + data + ) + ) + ); export const getPostsAndComments = (community, startTime, endTime) => Promise.props({ @@ -35,41 +47,50 @@ export const getPostsAndComments = (community, startTime, endTime) => .query(isValidPostType) .fetch({ withRelated: [ - 'selectedTags', + "selectedTags", relatedUserColumns(), - 'children', - 'linkPreview' - ] + "children", + "linkPreview", + ], }) - .then(get('models')), + .then(get("models")), - comments: Comment.createdInTimeRange(community.comments(), startTime, endTime) - .query(q => { - isValidPostType(q) - q.join('posts', 'posts.id', 'comments.post_id') - q.where('posts.active', true) - q.orderBy('id', 'asc') + comments: Comment.createdInTimeRange( + community.comments(), + startTime, + endTime + ) + .query((q) => { + isValidPostType(q); + q.join("posts", "posts.id", "comments.post_id"); + q.where("posts.active", true); + q.orderBy("id", "asc"); }) - .fetch({withRelated: [ - 'post', - 'post.selectedTags', - relatedUserColumns(), - relatedUserColumns('post.user') - ]}) - .then(get('models')) - - }) + .fetch({ + withRelated: [ + "post", + "post.selectedTags", + relatedUserColumns(), + relatedUserColumns("post.user"), + ], + }) + .then(get("models")), + }); -export async function getRecipients (communityId, type) { - if (!includes(['daily', 'weekly'], type)) { - throw new Error(`invalid recipient type: ${type}`) +export async function getRecipients(communityId, type) { + if (!includes(["daily", "weekly"], type)) { + throw new Error(`invalid recipient type: ${type}`); } - const community = await Community.find(communityId) - const recipients = await community.users().query(q => { - q.whereRaw(`users.settings->>'digest_frequency' = '${type}'`) - q.whereRaw(`(group_memberships.settings->>'sendEmail')::boolean = true`) - }).fetch().then(get('models')) + const community = await Community.find(communityId); + const recipients = await community + .users() + .query((q) => { + q.whereRaw(`users.settings->>'digest_frequency' = '${type}'`); + q.whereRaw("(group_memberships.settings->>'sendEmail')::boolean = true"); + }) + .fetch() + .then(get("models")); - return recipients + return recipients; } diff --git a/lib/freshness.js b/lib/freshness.js index b333b4837..8006d6f78 100644 --- a/lib/freshness.js +++ b/lib/freshness.js @@ -1,16 +1,16 @@ -var checkFreshness = function (newPosts, cachedPosts) { - var difference = _.differenceBy(newPosts, cachedPosts, 'id') - return difference.length -} +const checkFreshness = function (newPosts, cachedPosts) { + const difference = _.differenceBy(newPosts, cachedPosts, "id"); + return difference.length; +}; -var createCheckFreshnessAction = (queryFunction, itemType) => (req, res) => { +const createCheckFreshnessAction = (queryFunction, itemType) => (req, res) => { return queryFunction(req, res) - .then(query => query.fetchAll()) - .then(items => checkFreshness(items.models, req.param(itemType))) - .then(count => res.ok({count})) -} + .then((query) => query.fetchAll()) + .then((items) => checkFreshness(items.models, req.param(itemType))) + .then((count) => res.ok({ count })); +}; module.exports = { createCheckFreshnessAction: createCheckFreshnessAction, - checkFreshness: checkFreshness -} + checkFreshness: checkFreshness, +}; diff --git a/lib/graphql-bookshelf-bridge/fetcher.js b/lib/graphql-bookshelf-bridge/fetcher.js index 83a93ee39..6cd69cb4b 100644 --- a/lib/graphql-bookshelf-bridge/fetcher.js +++ b/lib/graphql-bookshelf-bridge/fetcher.js @@ -1,146 +1,168 @@ -import applyPagination from './util/applyPagination' -import presentQuerySet from './util/presentQuerySet' -import initDataLoaders from './util/initDataLoaders' -import { isNull, isUndefined, mapKeys, omitBy, pick, result, snakeCase, toPairs } from 'lodash/fp' +import applyPagination from "./util/applyPagination"; +import presentQuerySet from "./util/presentQuerySet"; +import initDataLoaders from "./util/initDataLoaders"; +import { + isNull, + isUndefined, + mapKeys, + omitBy, + pick, + result, + snakeCase, + toPairs, +} from "lodash/fp"; export default class Fetcher { - constructor (models, options = {}) { - this.models = models + constructor(models, options = {}) { + this.models = models; // here we allow changing loader behavior to make testing easier - const setupLoaders = options.setupLoaders || initDataLoaders - this.loaders = setupLoaders(models) + const setupLoaders = options.setupLoaders || initDataLoaders; + this.loaders = setupLoaders(models); } - fetchRelation (relation, typename, fetchOpts, tap) { - let targetTableName, type, parentFk + fetchRelation(relation, typename, fetchOpts, tap) { + let targetTableName, type, parentFk; if (relation.relatedData) { - targetTableName = relation.relatedData.targetTableName - type = relation.relatedData.type - parentFk = relation.relatedData.parentFk + targetTableName = relation.relatedData.targetTableName; + type = relation.relatedData.type; + parentFk = relation.relatedData.parentFk; } else { // this is a hack to allow relations that are not "proper" bookshelf // relations, i.e. they don't use Model#hasMany() et al. it seems that // we could go much further and merge getters and relations into a single // concept in the graphql-bookshelf-bridge model spec language. TBD. - targetTableName = relation.tableName() - type = 'hasMany' // n.b. we're not supporting belongsTo yet + targetTableName = relation.tableName(); + type = "hasMany"; // n.b. we're not supporting belongsTo yet } - if (!typename) typename = this._getTypenameFromTableName(targetTableName) + if (!typename) typename = this._getTypenameFromTableName(targetTableName); - const loader = this.loaders[typename] + const loader = this.loaders[typename]; - if (type === 'belongsTo') { - if (!parentFk) return Promise.resolve() - return loader.load(parentFk) + if (type === "belongsTo") { + if (!parentFk) return Promise.resolve(); + return loader.load(parentFk); } // apply the filter that always applies to this target model, if any - const model = this._getModel(typename) + const model = this._getModel(typename); - if (model.filter) relation = model.filter(relation) + if (model.filter) relation = model.filter(relation); // apply the filter that applies to this specific pair of models, if any - if (fetchOpts.filter) relation = fetchOpts.filter(relation) + if (fetchOpts.filter) relation = fetchOpts.filter(relation); - if (type === 'hasOne') { - return this._loadOne(relation, { loader }) + if (type === "hasOne") { + return this._loadOne(relation, { loader }); } - const query = relation.query(q => { - applyPagination(q, targetTableName, fetchOpts) - }) + const query = relation.query((q) => { + applyPagination(q, targetTableName, fetchOpts); + }); - return this._loadMany(query, {loader, fetchOpts, tap}) + return this._loadMany(query, { loader, fetchOpts, tap }); } - fetchOne (typename, id, idColumn = 'id', args = false) { - if (!id) return Promise.resolve(null) + fetchOne(typename, id, idColumn = "id", args = false) { + if (!id) return Promise.resolve(null); - const { model, filter } = this._getModel(typename) - const tableName = model.collection().tableName() - let relation = model.where(`${tableName}.${idColumn}`, id) - if (filter) relation = filter(relation) + const { model, filter } = this._getModel(typename); + const tableName = model.collection().tableName(); + let relation = model.where(`${tableName}.${idColumn}`, id); + if (filter) relation = filter(relation); if (args) { - const whitelist = mapKeys((k) => snakeCase(k), pick(['isPublic'], args)) - relation = relation.where(whitelist) + const whitelist = mapKeys((k) => snakeCase(k), pick(["isPublic"], args)); + relation = relation.where(whitelist); } - return this.loaders.relations.load({relation}).then(instance => { - if (!instance) return - this.loaders[typename].prime(instance.id, instance) - return instance - }) + return this.loaders.relations.load({ relation }).then((instance) => { + if (!instance) return; + this.loaders[typename].prime(instance.id, instance); + return instance; + }); } - fetchMany (typename, args) { - const { fetchMany, filter } = this._getModel(typename) + fetchMany(typename, args) { + const { fetchMany, filter } = this._getModel(typename); const fetchOpts = { querySet: true, offset: args.offset, - first: args.first - } + first: args.first, + }; - let query = fetchMany(args) - if (filter) query = filter(query) - query = query.query(q => - applyPagination(q, result('tableName', query), fetchOpts)) + let query = fetchMany(args); + if (filter) query = filter(query); + query = query.query((q) => + applyPagination(q, result("tableName", query), fetchOpts) + ); return this._loadMany(query, { - method: 'fetchAll', + method: "fetchAll", loader: this.loaders[typename], - fetchOpts - }) + fetchOpts, + }); } - _getModel (typename) { + _getModel(typename) { if (!this.models[typename]) { - throw new Error(`missing model definition for ${typename}`) + throw new Error(`missing model definition for ${typename}`); } - return this.models[typename] + return this.models[typename]; } - _getTypenameFromTableName (tableName) { + _getTypenameFromTableName(tableName) { // TODO cache this - const matches = toPairs(this.models) - .filter(([typename, spec]) => spec.model.collection().tableName() === tableName) + const matches = toPairs(this.models).filter( + ([typename, spec]) => spec.model.collection().tableName() === tableName + ); if (matches.length > 1) { - const defaultTypeForTable = matches.find(([typename, spec]) => spec.isDefaultTypeForTable) - if (defaultTypeForTable) return defaultTypeForTable[0] - - throw new Error(`tableName "${tableName}" is ambiguous: ${matches.map(x => x[0]).join(', ')}`) + const defaultTypeForTable = matches.find( + ([typename, spec]) => spec.isDefaultTypeForTable + ); + if (defaultTypeForTable) return defaultTypeForTable[0]; + + throw new Error( + `tableName "${tableName}" is ambiguous: ${matches + .map((x) => x[0]) + .join(", ")}` + ); } if (matches.length === 0) { - throw new Error(`tableName "${tableName}" doesn't match any type`) + throw new Error(`tableName "${tableName}" doesn't match any type`); } - return matches[0][0] + return matches[0][0]; } - _loadMany (relation, { method, loader, tap, fetchOpts }) { - return this.loaders.relations.load({relation, method}) - .tap(tap) - .then(instances => { - // N.B. this caching doesn't take into account data added by withPivot - instances.each(x => loader.clear(x.id).prime(x.id, x)) - return loader.loadMany(instances.map('id')) - .then(models => { - if (!fetchOpts.querySet) return models - const cleanOpts = omitBy(x => isNull(x) || isUndefined(x), fetchOpts) - const optsWithDefaults = Object.assign({offset: 0, first: 20}, cleanOpts) - return presentQuerySet(models, optsWithDefaults) - }) - }) + _loadMany(relation, { method, loader, tap, fetchOpts }) { + return this.loaders.relations + .load({ relation, method }) + .tap(tap) + .then((instances) => { + // N.B. this caching doesn't take into account data added by withPivot + instances.each((x) => loader.clear(x.id).prime(x.id, x)); + return loader.loadMany(instances.map("id")).then((models) => { + if (!fetchOpts.querySet) return models; + const cleanOpts = omitBy( + (x) => isNull(x) || isUndefined(x), + fetchOpts + ); + const optsWithDefaults = Object.assign( + { offset: 0, first: 20 }, + cleanOpts + ); + return presentQuerySet(models, optsWithDefaults); + }); + }); } - _loadOne (relation, { loader }) { - return this.loaders.relations.load({relation}) - .then(instance => { - if (!instance) return null - loader.prime(instance.id, instance) - return loader.load(instance.id) - }) + _loadOne(relation, { loader }) { + return this.loaders.relations.load({ relation }).then((instance) => { + if (!instance) return null; + loader.prime(instance.id, instance); + return loader.load(instance.id); + }); } } diff --git a/lib/graphql-bookshelf-bridge/fetcher.test.js b/lib/graphql-bookshelf-bridge/fetcher.test.js index a756e48b1..e202dab5d 100644 --- a/lib/graphql-bookshelf-bridge/fetcher.test.js +++ b/lib/graphql-bookshelf-bridge/fetcher.test.js @@ -1,97 +1,101 @@ -import Fetcher from './fetcher' -import { mapValues } from 'lodash' -import Bookshelf from 'bookshelf' -import Knex from 'knex' -import knexfile from '../../knexfile' -import Promise from 'bluebird' +import Fetcher from "./fetcher"; +import { mapValues } from "lodash"; +import Bookshelf from "bookshelf"; +import Knex from "knex"; +import knexfile from "../../knexfile"; +import Promise from "bluebird"; -const setupLoaders = models => { +const setupLoaders = (models) => { const makeLoader = () => { return { - load: spy(id => Promise.resolve({id})), - loadMany: spy(ids => Promise.resolve(ids.map(id => ({id})))), - prime: spy(obj => Promise.resolve(obj)) - } - } + load: spy((id) => Promise.resolve({ id })), + loadMany: spy((ids) => Promise.resolve(ids.map((id) => ({ id })))), + prime: spy((obj) => Promise.resolve(obj)), + }; + }; - const loaders = mapValues(models, makeLoader) - const queryLog = [] + const loaders = mapValues(models, makeLoader); + const queryLog = []; loaders.relations = { log: queryLog, - load: spy(arg => { - queryLog.push(arg) - return Promise.resolve(arg.relation.model.collection()) - }) - } + load: spy((arg) => { + queryLog.push(arg); + return Promise.resolve(arg.relation.model.collection()); + }), + }; - return loaders -} + return loaders; +}; -describe('Fetcher', () => { - var Bike, Wheel, mockModels, fetcher +describe("Fetcher", () => { + let Bike, Wheel, mockModels, fetcher; before(() => { - global.oldBookshelf = global.bookshelf - global.bookshelf = Bookshelf(Knex(knexfile[process.env.NODE_ENV])) + global.oldBookshelf = global.bookshelf; + global.bookshelf = Bookshelf(Knex(knexfile[process.env.NODE_ENV])); Bike = bookshelf.Model.extend({ - tableName: 'bikes', + tableName: "bikes", - wheels () { - return this.hasMany(Wheel) - } - }) + wheels() { + return this.hasMany(Wheel); + }, + }); Wheel = bookshelf.Model.extend({ - tableName: 'wheels' - }) + tableName: "wheels", + }); mockModels = { Bike: { model: Bike, - relations: ['wheels'] + relations: ["wheels"], }, Wheel: { - model: Wheel - } - } + model: Wheel, + }, + }; - fetcher = new Fetcher(mockModels, {setupLoaders}) - }) + fetcher = new Fetcher(mockModels, { setupLoaders }); + }); after(() => { - global.bookshelf = global.oldBookshelf - }) + global.bookshelf = global.oldBookshelf; + }); - describe('fetchOne', () => { - it('short-circuits if id is falsy', () => { - return fetcher.fetchOne('Bike', null) - .then(result => expect(result).to.be.null) - }) - }) + describe("fetchOne", () => { + it("short-circuits if id is falsy", () => { + return fetcher + .fetchOne("Bike", null) + .then((result) => expect(result).to.be.null); + }); + }); - describe('relations', () => { - describe('sortBy', () => { - it('works as expected', () => { - const bike = new Bike({id: 1}) - return fetcher.fetchRelation(bike.wheels(), 'Wheel', { - sortBy: 'wheelName' - }) - .then(result => { - const { relation } = fetcher.loaders.relations.log[0] - const { sql } = relation.query().toSQL() + describe("relations", () => { + describe("sortBy", () => { + it("works as expected", () => { + const bike = new Bike({ id: 1 }); + return fetcher + .fetchRelation(bike.wheels(), "Wheel", { + sortBy: "wheelName", + }) + .then((result) => { + const { relation } = fetcher.loaders.relations.log[0]; + const { sql } = relation.query().toSQL(); - // note that there is nothing in this expected query that checks the - // results against the id of the bike. this is a limitation of the way - // Bookshelf handles relation queries; the `.query()` method does not - // contain the clauses that filter down to only those rows matching - // the other side of the relation. - // - // for our purposes here, this is fine; we're only checking that the - // total column and order-by clause are present. - expect(sql).to.equal('select wheels.*, count(*) over () as __total from "wheels" order by "wheel_name" asc') - }) - }) - }) - }) -}) + // note that there is nothing in this expected query that checks the + // results against the id of the bike. this is a limitation of the way + // Bookshelf handles relation queries; the `.query()` method does not + // contain the clauses that filter down to only those rows matching + // the other side of the relation. + // + // for our purposes here, this is fine; we're only checking that the + // total column and order-by clause are present. + expect(sql).to.equal( + 'select wheels.*, count(*) over () as __total from "wheels" order by "wheel_name" asc' + ); + }); + }); + }); + }); +}); diff --git a/lib/graphql-bookshelf-bridge/index.js b/lib/graphql-bookshelf-bridge/index.js index d14786692..aa313642d 100644 --- a/lib/graphql-bookshelf-bridge/index.js +++ b/lib/graphql-bookshelf-bridge/index.js @@ -1,13 +1,13 @@ -import Fetcher from './fetcher' -import makeResolvers from './makeResolvers' +import Fetcher from "./fetcher"; +import makeResolvers from "./makeResolvers"; export default function (models) { - const fetcher = new Fetcher(models) - const resolvers = makeResolvers(models, fetcher) + const fetcher = new Fetcher(models); + const resolvers = makeResolvers(models, fetcher); return { resolvers, fetchOne: fetcher.fetchOne.bind(fetcher), - fetchMany: fetcher.fetchMany.bind(fetcher) - } + fetchMany: fetcher.fetchMany.bind(fetcher), + }; } diff --git a/lib/graphql-bookshelf-bridge/makeResolvers.js b/lib/graphql-bookshelf-bridge/makeResolvers.js index f17f4bbd6..9872808fa 100644 --- a/lib/graphql-bookshelf-bridge/makeResolvers.js +++ b/lib/graphql-bookshelf-bridge/makeResolvers.js @@ -1,117 +1,138 @@ -import { camelCase, partialRight, pick, toPairs, transform } from 'lodash' -import { get } from 'lodash/fp' -import EventEmitter from 'events' -import { PAGINATION_TOTAL_COLUMN_NAME } from './util/applyPagination' - -export default function makeResolvers (models, fetcher) { - return transform(models, (result, spec, typename) => { - result[typename] = createResolverForModel(spec, fetcher) - }, {}) +import { camelCase, partialRight, pick, toPairs, transform } from "lodash"; +import { get } from "lodash/fp"; +import EventEmitter from "events"; +import { PAGINATION_TOTAL_COLUMN_NAME } from "./util/applyPagination"; + +export default function makeResolvers(models, fetcher) { + return transform( + models, + (result, spec, typename) => { + result[typename] = createResolverForModel(spec, fetcher); + }, + {} + ); } -export function createResolverForModel (spec, fetcher) { - const { attributes, getters, relations, model } = spec +export function createResolverForModel(spec, fetcher) { + const { attributes, getters, relations, model } = spec; return Object.assign( transform(attributes, resolveAttribute, {}), - transform(getters, (result, fn, attr) => { - result[attr] = fn - }, {}), - - transform(relations, (result, attr) => { - var graphqlName, bookshelfName, typename - var opts = {} - - if (typeof attr === 'string') { - graphqlName = attr - bookshelfName = attr - } else { - [ bookshelfName, opts ] = toPairs(attr)[0] - - // relations can be aliased: in your model definition, you can write - // e.g. `relations: [{users: {alias: 'members'}}]` to map `members` in - // your GraphQL schema to the `users` Bookshelf relation. - graphqlName = opts.alias || bookshelfName - - // this must be set when a relation's Bookshelf model is backing more - // than one GraphQL schema type. - typename = opts.typename - } - - let hasTotal - try { - hasTotal = !opts.querySet && - !['belongsTo', 'hasOne'].includes( - get('type', model.forge()[bookshelfName]().relatedData)) - } catch (err) { - throw new Error(`Couldn't find relation "${bookshelfName}" on ${model.forge().tableName}`) - } - - const emitterName = `__${graphqlName}__total_emitter` - - result[graphqlName] = async (instance, args) => { - const fetchOpts = Object.assign( - { - querySet: opts.querySet, - filter: opts.filter && partialRight(opts.filter, args) - }, - pick(args, 'first', 'cursor', 'order', 'sortBy', 'offset') - ) - - if (hasTotal) instance[emitterName] = new EventEmitter() - - // opts.arguments can be used to pass selected arguments from the - // GraphQL query to a relation method. opts.arguments can be a function - // that takes the hash of named arguments from a GraphQL query item and - // returns an array of arguments to pass to a relation method. - // - // e.g.: - // - // in query: - // drinks(size: "large", sugarContent: "low") - // - // in relations definition: - // drinks: { - // arguments: ({ size, sugarContent }) => [sugarContent, size] - // } - // - // in model: - // drinks: function (sugarContent, size) { ... } - // - const relation = opts.arguments - ? instance[bookshelfName].apply(instance, opts.arguments(args)) - : instance[bookshelfName]() - - const callback = hasTotal && (instances => { - if (!hasTotal) return - const total = instances.length > 0 - ? instances.first().get(PAGINATION_TOTAL_COLUMN_NAME) - : 0 - instance[emitterName].emit('hasTotal', total) - }) - - return fetcher.fetchRelation(relation, typename, fetchOpts, callback) - } - - // this "separate-totals style" is DEPRECATED - if (hasTotal) { - result[graphqlName + 'Total'] = instance => - new Promise((resolve, reject) => { - instance[emitterName].on('hasTotal', resolve) - setTimeout(() => reject(new Error('timeout')), 6000) - }) - } - }, {}) - ) + transform( + getters, + (result, fn, attr) => { + result[attr] = fn; + }, + {} + ), + + transform( + relations, + (result, attr) => { + let graphqlName, bookshelfName, typename; + let opts = {}; + + if (typeof attr === "string") { + graphqlName = attr; + bookshelfName = attr; + } else { + [bookshelfName, opts] = toPairs(attr)[0]; + + // relations can be aliased: in your model definition, you can write + // e.g. `relations: [{users: {alias: 'members'}}]` to map `members` in + // your GraphQL schema to the `users` Bookshelf relation. + graphqlName = opts.alias || bookshelfName; + + // this must be set when a relation's Bookshelf model is backing more + // than one GraphQL schema type. + typename = opts.typename; + } + + let hasTotal; + try { + hasTotal = + !opts.querySet && + !["belongsTo", "hasOne"].includes( + get("type", model.forge()[bookshelfName]().relatedData) + ); + } catch (err) { + throw new Error( + `Couldn't find relation "${bookshelfName}" on ${ + model.forge().tableName + }` + ); + } + + const emitterName = `__${graphqlName}__total_emitter`; + + result[graphqlName] = async (instance, args) => { + const fetchOpts = Object.assign( + { + querySet: opts.querySet, + filter: opts.filter && partialRight(opts.filter, args), + }, + pick(args, "first", "cursor", "order", "sortBy", "offset") + ); + + if (hasTotal) instance[emitterName] = new EventEmitter(); + + // opts.arguments can be used to pass selected arguments from the + // GraphQL query to a relation method. opts.arguments can be a function + // that takes the hash of named arguments from a GraphQL query item and + // returns an array of arguments to pass to a relation method. + // + // e.g.: + // + // in query: + // drinks(size: "large", sugarContent: "low") + // + // in relations definition: + // drinks: { + // arguments: ({ size, sugarContent }) => [sugarContent, size] + // } + // + // in model: + // drinks: function (sugarContent, size) { ... } + // + const relation = opts.arguments + ? instance[bookshelfName].apply(instance, opts.arguments(args)) + : instance[bookshelfName](); + + const callback = + hasTotal && + ((instances) => { + if (!hasTotal) return; + const total = + instances.length > 0 + ? instances.first().get(PAGINATION_TOTAL_COLUMN_NAME) + : 0; + instance[emitterName].emit("hasTotal", total); + }); + + return fetcher.fetchRelation(relation, typename, fetchOpts, callback); + }; + + // this "separate-totals style" is DEPRECATED + if (hasTotal) { + result[graphqlName + "Total"] = (instance) => + new Promise((resolve, reject) => { + instance[emitterName].on("hasTotal", resolve); + setTimeout(() => reject(new Error("timeout")), 6000); + }); + } + }, + {} + ) + ); } -export function resolveAttribute (result, attr) { +export function resolveAttribute(result, attr) { // `x` here is the "resolver obj argument": // https://www.apollographql.com/docs/graphql-tools/resolvers.html#Resolver-obj-argument - result[camelCase(attr)] = x => { - if (typeof x[attr] === 'function') return x[attr]() - if (x.hasOwnProperty(attr)) return x[attr] - if (x.get) return x.get(attr) - } + result[camelCase(attr)] = (x) => { + if (typeof x[attr] === "function") return x[attr](); + if (x.hasOwnProperty(attr)) return x[attr]; + if (x.get) return x.get(attr); + }; } diff --git a/lib/graphql-bookshelf-bridge/makeResolvers.test.js b/lib/graphql-bookshelf-bridge/makeResolvers.test.js index ee29c2539..3e385b458 100644 --- a/lib/graphql-bookshelf-bridge/makeResolvers.test.js +++ b/lib/graphql-bookshelf-bridge/makeResolvers.test.js @@ -1,115 +1,114 @@ /* eslint-disable no-unused-expressions */ -import { createResolverForModel, resolveAttribute } from './makeResolvers' +import { createResolverForModel, resolveAttribute } from "./makeResolvers"; -describe('createResolverForModel', () => { +describe("createResolverForModel", () => { const model = { relations: [ { wheels: { querySet: true, - typename: 'Wheel', + typename: "Wheel", filter: (relation, { type }) => - relation.query(q => q.where({type})) - } - } - ] - } + relation.query((q) => q.where({ type })), + }, + }, + ], + }; const mockQuery = { - where: spy(() => {}) - } + where: spy(() => {}), + }; const mockRelation = { - query: fn => fn(mockQuery) - } + query: (fn) => fn(mockQuery), + }; - let fetcher, fetchRelationCalls + let fetcher, fetchRelationCalls; beforeEach(() => { - fetchRelationCalls = [] + fetchRelationCalls = []; fetcher = { fetchRelation: spy(function () { - fetchRelationCalls.push(Array.prototype.slice.call(arguments)) - return Promise.resolve() - }) - } - }) - - it('sets up relation filtering', () => { - const resolver = createResolverForModel(model, fetcher) + fetchRelationCalls.push(Array.prototype.slice.call(arguments)); + return Promise.resolve(); + }), + }; + }); + + it("sets up relation filtering", () => { + const resolver = createResolverForModel(model, fetcher); const instance = { - wheels: spy(() => 'mock wheels relation') - } - return resolver.wheels(instance, {type: 'front'}) - .then(() => { - expect(fetcher.fetchRelation).to.have.been.called() - const call = fetchRelationCalls[0] - expect(call[0]).to.equal('mock wheels relation') - expect(call[1]).to.equal('Wheel') - expect(call[2].querySet).to.exist - - call[2].filter(mockRelation) - expect(mockQuery.where).to.have.been.called.with({type: 'front'}) - }) - }) - - it('throws an error if a relation cannot be found', () => { + wheels: spy(() => "mock wheels relation"), + }; + return resolver.wheels(instance, { type: "front" }).then(() => { + expect(fetcher.fetchRelation).to.have.been.called(); + const call = fetchRelationCalls[0]; + expect(call[0]).to.equal("mock wheels relation"); + expect(call[1]).to.equal("Wheel"); + expect(call[2].querySet).to.exist; + + call[2].filter(mockRelation); + expect(mockQuery.where).to.have.been.called.with({ type: "front" }); + }); + }); + + it("throws an error if a relation cannot be found", () => { const mockModel = { - forge () { + forge() { return { - tableName: 'mockModel' - } - } - } + tableName: "mockModel", + }; + }, + }; const badSpec = { model: mockModel, - relations: ['feet'] - } + relations: ["feet"], + }; expect(() => { - createResolverForModel(badSpec, fetcher) - }).to.throw(/Couldn't find relation "feet"/) - }) -}) + createResolverForModel(badSpec, fetcher); + }).to.throw(/Couldn't find relation "feet"/); + }); +}); -describe('resolveAttribute', () => { - let data +describe("resolveAttribute", () => { + let data; beforeEach(() => { data = { - very_special_attribute: 'hello', - very_special_method: () => 'hello again', + very_special_attribute: "hello", + very_special_method: () => "hello again", auto_refueling: false, get: function (key) { return { - foo: 'foo!' - }[key] - } - } - }) - - it('camel-cases the attribute name', () => { - const result = {} - resolveAttribute(result, 'very_special_attribute') - expect(result.verySpecialAttribute(data)).to.equal('hello') - }) - - it('executes the attribute if it is a function', () => { - const result = {} - resolveAttribute(result, 'very_special_method') - expect(result.verySpecialMethod(data)).to.equal('hello again') - }) - - it('returns a falsey value correctly', () => { - const result = {} - resolveAttribute(result, 'auto_refueling') - expect(result.autoRefueling(data)).to.equal(false) - }) - - it('tries to find the attribute in a Bookshelf model', () => { - const result = {} - resolveAttribute(result, 'foo') - expect(result.foo(data)).to.equal('foo!') - }) -}) + foo: "foo!", + }[key]; + }, + }; + }); + + it("camel-cases the attribute name", () => { + const result = {}; + resolveAttribute(result, "very_special_attribute"); + expect(result.verySpecialAttribute(data)).to.equal("hello"); + }); + + it("executes the attribute if it is a function", () => { + const result = {}; + resolveAttribute(result, "very_special_method"); + expect(result.verySpecialMethod(data)).to.equal("hello again"); + }); + + it("returns a falsey value correctly", () => { + const result = {}; + resolveAttribute(result, "auto_refueling"); + expect(result.autoRefueling(data)).to.equal(false); + }); + + it("tries to find the attribute in a Bookshelf model", () => { + const result = {}; + resolveAttribute(result, "foo"); + expect(result.foo(data)).to.equal("foo!"); + }); +}); diff --git a/lib/graphql-bookshelf-bridge/util/applyPagination.js b/lib/graphql-bookshelf-bridge/util/applyPagination.js index 7bcd6cb48..375e6d454 100644 --- a/lib/graphql-bookshelf-bridge/util/applyPagination.js +++ b/lib/graphql-bookshelf-bridge/util/applyPagination.js @@ -1,36 +1,36 @@ -import { countTotal } from '../../../lib/util/knex' -import { snakeCase } from 'lodash' +import { countTotal } from "../../../lib/util/knex"; +import { snakeCase } from "lodash"; -export const PAGINATION_TOTAL_COLUMN_NAME = '__total' +export const PAGINATION_TOTAL_COLUMN_NAME = "__total"; -export default function applyPagination (query, tableName, opts) { - const { first, cursor, order, offset, sortBy = 'id' } = opts +export default function applyPagination(query, tableName, opts) { + const { first, cursor, order, offset, sortBy = "id" } = opts; if (cursor && offset) { - throw new Error('Specifying both cursor and offset is not supported.') + throw new Error("Specifying both cursor and offset is not supported."); } - if (cursor && sortBy !== 'id') { - throw new Error('Specifying both cursor and sortBy is not supported.') + if (cursor && sortBy !== "id") { + throw new Error("Specifying both cursor and sortBy is not supported."); } // skip special sorts - if (!['join', 'votes', 'updated', 'created'].includes(sortBy)) { - query = query.orderBy(snakeCase(sortBy), order) + if (!["join", "votes", "updated", "created"].includes(sortBy)) { + query = query.orderBy(snakeCase(sortBy), order); } if (first) { - query = query.limit(first) + query = query.limit(first); } - query = countTotal(query, tableName, PAGINATION_TOTAL_COLUMN_NAME) + query = countTotal(query, tableName, PAGINATION_TOTAL_COLUMN_NAME); if (cursor) { - const op = order === 'asc' ? '>' : '<' - query = query.where(`${tableName}.${sortBy}`, op, cursor) + const op = order === "asc" ? ">" : "<"; + query = query.where(`${tableName}.${sortBy}`, op, cursor); } else if (offset) { - query.offset(offset) + query.offset(offset); } - return query + return query; } diff --git a/lib/graphql-bookshelf-bridge/util/index.js b/lib/graphql-bookshelf-bridge/util/index.js index 2d760a400..4f60a023d 100644 --- a/lib/graphql-bookshelf-bridge/util/index.js +++ b/lib/graphql-bookshelf-bridge/util/index.js @@ -1,3 +1,3 @@ -import applyPagination from './applyPagination' -import presentQuerySet from './presentQuerySet' -export { applyPagination, presentQuerySet } +import applyPagination from "./applyPagination"; +import presentQuerySet from "./presentQuerySet"; +export { applyPagination, presentQuerySet }; diff --git a/lib/graphql-bookshelf-bridge/util/initDataLoaders.js b/lib/graphql-bookshelf-bridge/util/initDataLoaders.js index 201405aa9..004f49cee 100644 --- a/lib/graphql-bookshelf-bridge/util/initDataLoaders.js +++ b/lib/graphql-bookshelf-bridge/util/initDataLoaders.js @@ -1,46 +1,52 @@ -import DataLoader from 'dataloader' -import { forIn } from 'lodash' -import uniqueQueryId from './uniqueQueryId' +import DataLoader from "dataloader"; +import { forIn } from "lodash"; +import uniqueQueryId from "./uniqueQueryId"; // Given a mapping of table names to Bookshelf model classes, prepare a // DataLoader for each model and a general-purpose DataLoader for other queries. -export default function initDataLoaders (spec) { - const loaders = {} +export default function initDataLoaders(spec) { + const loaders = {}; forIn(spec, ({ model }, typename) => { - loaders[typename] = makeModelLoader(model) - }) + loaders[typename] = makeModelLoader(model); + }); if (loaders.relations) { - throw new Error("Can't have a model DataLoader named 'relations'") + throw new Error("Can't have a model DataLoader named 'relations'"); } // general-purpose query cache, for relational SQL queries that aren't just // fetching objects by ID. loaders.relations = new DataLoader( - queries => Promise.map(queries, async ({ relation, method }) => { - if (relation && relation.relatedData && relation.relatedData.parentFk === '33723') { - // console.log('duinit') - // console.log('relation', relation) - // const rasalt = await relation.fetch() - // console.log('rasalt', rasalt) - return method ? relation[method]() : relation.fetch() - } else { - return method ? relation[method]() : relation.fetch() - } - }), - {cacheKeyFn: _ => Math.random().toString()} - ) + (queries) => + Promise.map(queries, async ({ relation, method }) => { + if ( + relation && + relation.relatedData && + relation.relatedData.parentFk === "33723" + ) { + // console.log('duinit') + // console.log('relation', relation) + // const rasalt = await relation.fetch() + // console.log('rasalt', rasalt) + return method ? relation[method]() : relation.fetch(); + } else { + return method ? relation[method]() : relation.fetch(); + } + }), + { cacheKeyFn: (_) => Math.random().toString() } + ); - return loaders + return loaders; } -export function makeModelLoader (model) { - const tableName = model.collection().tableName() - const idColumn = `${tableName}.id` - return new DataLoader(ids => - model.where(idColumn, 'in', ids).fetchAll().then(preserveOrdering(ids))) +export function makeModelLoader(model) { + const tableName = model.collection().tableName(); + const idColumn = `${tableName}.id`; + return new DataLoader((ids) => + model.where(idColumn, "in", ids).fetchAll().then(preserveOrdering(ids)) + ); } -const preserveOrdering = ids => objects => - ids.map(id => objects.find(x => String(x.id) === String(id))) +const preserveOrdering = (ids) => (objects) => + ids.map((id) => objects.find((x) => String(x.id) === String(id))); diff --git a/lib/graphql-bookshelf-bridge/util/initDataLoaders.test.js b/lib/graphql-bookshelf-bridge/util/initDataLoaders.test.js index 5df608382..e850af76a 100644 --- a/lib/graphql-bookshelf-bridge/util/initDataLoaders.test.js +++ b/lib/graphql-bookshelf-bridge/util/initDataLoaders.test.js @@ -1,44 +1,46 @@ -import { makeModelLoader } from './initDataLoaders' +import { makeModelLoader } from "./initDataLoaders"; const makeMockModel = () => ({ collection: () => ({ - tableName: () => 'mock_models' + tableName: () => "mock_models", }), where: spy(function () { return { - fetchAll: () => Promise.resolve(this.mockData) - } - }) -}) + fetchAll: () => Promise.resolve(this.mockData), + }; + }), +}); -describe('makeModelLoader', () => { - var loader, model +describe("makeModelLoader", () => { + let loader, model; beforeEach(() => { - model = makeMockModel() - loader = makeModelLoader(model) - }) - - it('works with database tables with integer ids', () => { - model.mockData = [{id: 1}, {id: 2}, {id: 3}] - - return loader.loadMany(['3', '1', '2']).then(results => { - expect(results).to.deep.equal([{id: 3}, {id: 1}, {id: 2}]) - }) - }) - - it('works with database tables with bigint ids', () => { - model.mockData = [{id: '1'}, {id: '2'}, {id: '3'}] - - return loader.loadMany(['3', '1', '2']).then(results => { - expect(results).to.deep.equal([{id: '3'}, {id: '1'}, {id: '2'}]) - }) - }) - - it('uses the correct table name', () => { - model.mockData = [] - return loader.loadMany(['1']).then(() => { - expect(model.where).to.have.been.called.with('mock_models.id', 'in', ['1']) - }) - }) -}) + model = makeMockModel(); + loader = makeModelLoader(model); + }); + + it("works with database tables with integer ids", () => { + model.mockData = [{ id: 1 }, { id: 2 }, { id: 3 }]; + + return loader.loadMany(["3", "1", "2"]).then((results) => { + expect(results).to.deep.equal([{ id: 3 }, { id: 1 }, { id: 2 }]); + }); + }); + + it("works with database tables with bigint ids", () => { + model.mockData = [{ id: "1" }, { id: "2" }, { id: "3" }]; + + return loader.loadMany(["3", "1", "2"]).then((results) => { + expect(results).to.deep.equal([{ id: "3" }, { id: "1" }, { id: "2" }]); + }); + }); + + it("uses the correct table name", () => { + model.mockData = []; + return loader.loadMany(["1"]).then(() => { + expect(model.where).to.have.been.called.with("mock_models.id", "in", [ + "1", + ]); + }); + }); +}); diff --git a/lib/graphql-bookshelf-bridge/util/presentQuerySet.js b/lib/graphql-bookshelf-bridge/util/presentQuerySet.js index 4b368192e..4853d47b0 100644 --- a/lib/graphql-bookshelf-bridge/util/presentQuerySet.js +++ b/lib/graphql-bookshelf-bridge/util/presentQuerySet.js @@ -1,25 +1,25 @@ -import { PAGINATION_TOTAL_COLUMN_NAME } from './applyPagination' +import { PAGINATION_TOTAL_COLUMN_NAME } from "./applyPagination"; -export default function presentQuerySet (models, options) { +export default function presentQuerySet(models, options) { // for backwards compatibility - const limit = options.first || options.limit - const offset = options.offset || 0 + const limit = options.first || options.limit; + const offset = options.offset || 0; if (!limit) { - throw new Error('presentQuerySet needs a "limit" or "first" option') + throw new Error('presentQuerySet needs a "limit" or "first" option'); } - var total = 0 + let total = 0; if (options.total) { - total = Number(options.total) + total = Number(options.total); } else if (models.length > 0) { - total = Number(models[0].get(PAGINATION_TOTAL_COLUMN_NAME)) + total = Number(models[0].get(PAGINATION_TOTAL_COLUMN_NAME)); } return { total, items: models, - hasMore: offset + limit < total - } + hasMore: offset + limit < total, + }; } diff --git a/lib/graphql-bookshelf-bridge/util/uniqueQueryId.js b/lib/graphql-bookshelf-bridge/util/uniqueQueryId.js index d78c9de6d..dbbb7e182 100644 --- a/lib/graphql-bookshelf-bridge/util/uniqueQueryId.js +++ b/lib/graphql-bookshelf-bridge/util/uniqueQueryId.js @@ -1,19 +1,30 @@ -import { pick } from 'lodash' -import { omitBy } from 'lodash/fp' -import { inspect } from 'util' -import { randomBytes } from 'crypto' +import { pick } from "lodash"; +import { omitBy } from "lodash/fp"; +import { inspect } from "util"; +import { randomBytes } from "crypto"; // a means of identifying duplicate Bookshelf queries. ideally we would compare // the final SQL query text, but this is surprisingly difficult to find for some // types of Bookshelf relations. so this is a hacked-together workaround. -export default function uniqueQueryID (query) { - const signature = omitBy(x => !x, pick(query.relatedData, [ - 'parentTableName', 'parentId', 'parentFk', - 'joinTableName', 'foreignKey', 'otherKey', - 'targetTableName', 'type' - ])) - if (!query._knex) { // query is invalid, don't cache - return randomBytes(4).toString('hex') +export default function uniqueQueryID(query) { + const signature = omitBy( + (x) => !x, + pick(query.relatedData, [ + "parentTableName", + "parentId", + "parentFk", + "joinTableName", + "foreignKey", + "otherKey", + "targetTableName", + "type", + ]) + ); + if (!query._knex) { + // query is invalid, don't cache + return randomBytes(4).toString("hex"); } - return inspect(signature) + inspect(pick(query._knex.toSQL(), 'sql', 'bindings')) + return ( + inspect(signature) + inspect(pick(query._knex.toSQL(), "sql", "bindings")) + ); } diff --git a/lib/htmlparser/html2text.js b/lib/htmlparser/html2text.js index e0423ec9b..a7ce56655 100644 --- a/lib/htmlparser/html2text.js +++ b/lib/htmlparser/html2text.js @@ -1,17 +1,13 @@ /** * Converts HYLO formatted html into text */ -import rehype from 'rehype' -import mapEntities from './rehype-map-entities' -import stringify from './rehype-as-string' -import { isEmpty, trim } from 'lodash/fp' +import rehype from "rehype"; +import mapEntities from "./rehype-map-entities"; +import stringify from "./rehype-as-string"; +import { isEmpty, trim } from "lodash/fp"; -export default async function html2text (html) { - if (isEmpty(trim(html))) return '' +export default async function html2text(html) { + if (isEmpty(trim(html))) return ""; - return rehype() - .use(mapEntities) - .use(stringify) - .process(html) - .then(String) + return rehype().use(mapEntities).use(stringify).process(html).then(String); } diff --git a/lib/htmlparser/html2text.test.js b/lib/htmlparser/html2text.test.js index 4a1405785..194179472 100644 --- a/lib/htmlparser/html2text.test.js +++ b/lib/htmlparser/html2text.test.js @@ -1,46 +1,50 @@ -import html2text from './html2text' +import html2text from "./html2text"; -describe('html2text', () => { - it('can parse crazy text', async () => { +describe("html2text", () => { + it("can parse crazy text", async () => { const html = `

Hylo Some Bold Text www.hylo.com Sam Frank

hello world Ray Marceauhh I'm #ray

#dadsf

-

One Line

Another Line

` - - const text = await html2text(html) - expect(text).to.equal(`www.hylo.com Some Bold Text \n\nwww.hylo.com [Sam Frank:24658]\n\n \nhello world [Ray Marceauhh:12] I'm #ray \n \n#dadsf\n\n\nOne Line\nAnother Line\n`) - }) - - it('works with mentions', async () => { - const html = '

Ray Marceauhh

' - const text = await html2text(html) - expect(text).to.equal('[Ray Marceauhh:12]\n') - }) - - it('works with topics', async () => { - const html = '#dadsf' - const text = await html2text(html) - expect(text).to.equal('#dadsf') - }) - - it('works with links', async () => { - const html = `

www.hylo.com s

` - const text = await html2text(html) - expect(text).to.equal('\nwww.hylo.com www.hylo.com\n') - }) - - it('works on empty strings', async () => { - const html = ' ' - const text = await html2text(html) - expect(text).to.equal('') - }) - - it('works on malformed html', async () => { - const html = '

<< turn off me>' - const text = await html2text(html) - expect(text).to.equal('<< turn off me>\n') - }) -}) +

One Line

Another Line

`; + + const text = await html2text(html); + expect(text).to.equal( + "www.hylo.com Some Bold Text \n\nwww.hylo.com [Sam Frank:24658]\n\n \nhello world [Ray Marceauhh:12] I'm #ray \n \n#dadsf\n\n\nOne Line\nAnother Line\n" + ); + }); + + it("works with mentions", async () => { + const html = + '

Ray Marceauhh

'; + const text = await html2text(html); + expect(text).to.equal("[Ray Marceauhh:12]\n"); + }); + + it("works with topics", async () => { + const html = '#dadsf'; + const text = await html2text(html); + expect(text).to.equal("#dadsf"); + }); + + it("works with links", async () => { + const html = + "

www.hylo.com s

"; + const text = await html2text(html); + expect(text).to.equal("\nwww.hylo.com www.hylo.com\n"); + }); + + it("works on empty strings", async () => { + const html = " "; + const text = await html2text(html); + expect(text).to.equal(""); + }); + + it("works on malformed html", async () => { + const html = "

<< turn off me>"; + const text = await html2text(html); + expect(text).to.equal("<< turn off me>\n"); + }); +}); diff --git a/lib/htmlparser/rehype-as-string.js b/lib/htmlparser/rehype-as-string.js index 2ed307b4c..0f4cba615 100644 --- a/lib/htmlparser/rehype-as-string.js +++ b/lib/htmlparser/rehype-as-string.js @@ -1,10 +1,10 @@ -'use strict' -import toString from 'hast-util-to-string' +"use strict"; +import toString from "hast-util-to-string"; -export default function asString () { - return transformer +export default function asString() { + return transformer; - function transformer (tree) { - return {type: 'text', value: toString(tree)} + function transformer(tree) { + return { type: "text", value: toString(tree) }; } } diff --git a/lib/htmlparser/rehype-map-entities.js b/lib/htmlparser/rehype-map-entities.js index 547584ea7..dda28ff2e 100644 --- a/lib/htmlparser/rehype-map-entities.js +++ b/lib/htmlparser/rehype-map-entities.js @@ -1,62 +1,70 @@ -'use strict' -import h from 'hastscript' -import is from 'hast-util-is-element' -import has from 'hast-util-has-property' -import { matches } from 'hast-util-select' -import toString from 'hast-util-to-string' -import visit from 'unist-util-visit' -import inspect from 'unist-util-inspect' +"use strict"; +import h from "hastscript"; +import is from "hast-util-is-element"; +import has from "hast-util-has-property"; +import { matches } from "hast-util-select"; +import toString from "hast-util-to-string"; +import visit from "unist-util-visit"; +import inspect from "unist-util-inspect"; -import { MENTION_ENTITY_TYPE, TOPIC_ENTITY_TYPE } from 'hylo-utils/constants' -import { get } from 'lodash/fp' +import { MENTION_ENTITY_TYPE, TOPIC_ENTITY_TYPE } from "hylo-utils/constants"; +import { get } from "lodash/fp"; -const ENTITY_TYPE_ATTRIBUTE_NAME = 'data-entity-type' +const ENTITY_TYPE_ATTRIBUTE_NAME = "data-entity-type"; -export default function mapEntities () { - return transformer +export default function mapEntities() { + return transformer; - function transformer (tree) { - visit(tree, visitor) + function transformer(tree) { + visit(tree, visitor); } - function visitor (node, index, parent) { - if (is(node, 'a')) { // is anchor 'a' tag - if (matches(`[${ENTITY_TYPE_ATTRIBUTE_NAME}=${MENTION_ENTITY_TYPE}]`, node)) { // Mentions - node.children = [mention(node)] - } else if (matches(`[${ENTITY_TYPE_ATTRIBUTE_NAME}=${TOPIC_ENTITY_TYPE}]`, node)) { // Topics - node.children = [topic(node)] - } else { // Plain link - node.children = [link(node)] + function visitor(node, index, parent) { + if (is(node, "a")) { + // is anchor 'a' tag + if ( + matches(`[${ENTITY_TYPE_ATTRIBUTE_NAME}=${MENTION_ENTITY_TYPE}]`, node) + ) { + // Mentions + node.children = [mention(node)]; + } else if ( + matches(`[${ENTITY_TYPE_ATTRIBUTE_NAME}=${TOPIC_ENTITY_TYPE}]`, node) + ) { + // Topics + node.children = [topic(node)]; + } else { + // Plain link + node.children = [link(node)]; } - return visit.SKIP + return visit.SKIP; } - if (is(node, 'p')) { - node.children.push(h('span', ['\n'])) + if (is(node, "p")) { + node.children.push(h("span", ["\n"])); } - if (is(node, 'br')) { - parent.children[index] = h('span', ['\n']) + if (is(node, "br")) { + parent.children[index] = h("span", ["\n"]); } - return true + return true; } - function mention (node) { - if (has(node, 'dataUserId')) { - const textContent = toString(node) - const userId = get('properties.dataUserId', node) - return h('span', `[${textContent}:${userId}]`) + function mention(node) { + if (has(node, "dataUserId")) { + const textContent = toString(node); + const userId = get("properties.dataUserId", node); + return h("span", `[${textContent}:${userId}]`); } else { - return h('span', `${toString(node)}`) + return h("span", `${toString(node)}`); } } - function topic (node) { - return h('span', `${toString(node)}`) + function topic(node) { + return h("span", `${toString(node)}`); } - function link (node) { - return h('span', `${get('properties.href', node) || toString(node)}`) + function link(node) { + return h("span", `${get("properties.href", node) || toString(node)}`); } } diff --git a/lib/migration.js b/lib/migration.js index ad30ad063..e0bde47d1 100644 --- a/lib/migration.js +++ b/lib/migration.js @@ -1,58 +1,90 @@ -import { reduce } from 'lodash' +import { reduce } from "lodash"; export const convertChildrenToProjectRequests = (parentPostId, trx) => { - return Post.query().where('parent_post_id', parentPostId) - .update({is_project_request: true}).transacting(trx) -} + return Post.query() + .where("parent_post_id", parentPostId) + .update({ is_project_request: true }) + .transacting(trx); +}; export const convertPostsForTagToChildren = (tagId, parentPostId, trxOpt) => { - return Post.query(qb => { - qb.join('posts_tags', 'posts_tags.post_id', '=', 'posts.id') - qb.where('posts_tags.tag_id', '=', tagId) + return Post.query((qb) => { + qb.join("posts_tags", "posts_tags.post_id", "=", "posts.id"); + qb.where("posts_tags.tag_id", "=", tagId); qb.where(function () { - this.where('type', '!=', 'project') - .orWhere('type', null) - }) + this.where("type", "!=", "project").orWhere("type", null); + }); }) - .fetchAll() - .then(({models}) => reduce(models, (promise, post) => promise - .then(() => post.save('parent_post_id', parentPostId, trxOpt)) - .then(() => PostMembership.query(qb => qb.where('post_id', post.id)).destroy(trxOpt)), // it will rely on the parent posts community membership - Promise.resolve()) - ) -} + .fetchAll() + .then(({ models }) => + reduce( + models, + (promise, post) => + promise + .then(() => post.save("parent_post_id", parentPostId, trxOpt)) + .then(() => + PostMembership.query((qb) => + qb.where("post_id", post.id) + ).destroy(trxOpt) + ), // it will rely on the parent posts community membership + Promise.resolve() + ) + ); +}; const findOfficialProjectTags = (projects) => reduce( projects, (promise, project) => - promise.then(() => - PostTag.where({post_id: project.id, selected: true}).fetch()) + promise + .then(() => + PostTag.where({ post_id: project.id, selected: true }).fetch() + ) // 'selected' would get set based on the tag set in the suggestedTagEditor for projects - .then(postTag => bookshelf.transaction(trx => { - console.log(`attempting to migrate tag for project '${project.get('name')}'`) - if (!postTag) { - console.log(`tag for project '${project.get('name')}' has already been migrated`) - return Promise.resolve() - } - const trxOpt = {transacting: trx} - const tagId = postTag.get('tag_id') - return convertChildrenToProjectRequests(project.id, trx) - .then(() => convertPostsForTagToChildren(tagId, project.id, trxOpt)) - .then(() => Promise.map( - [PostTag, TagFollow, CommentTag, CommunityTag], - model => model.query().where('tag_id', tagId).del().transacting(trx) - )) - .then(() => Tag.query(qb => qb.where('id', tagId)).destroy(trxOpt)) - .then(() => { - console.log(`tag for project '${project.get('name')}' has successfully been migrated`) - return Promise.resolve() - }) - })), + .then((postTag) => + bookshelf.transaction((trx) => { + console.log( + `attempting to migrate tag for project '${project.get("name")}'` + ); + if (!postTag) { + console.log( + `tag for project '${project.get( + "name" + )}' has already been migrated` + ); + return Promise.resolve(); + } + const trxOpt = { transacting: trx }; + const tagId = postTag.get("tag_id"); + return convertChildrenToProjectRequests(project.id, trx) + .then(() => + convertPostsForTagToChildren(tagId, project.id, trxOpt) + ) + .then(() => + Promise.map( + [PostTag, TagFollow, CommentTag, CommunityTag], + (model) => + model.query().where("tag_id", tagId).del().transacting(trx) + ) + ) + .then(() => + Tag.query((qb) => qb.where("id", tagId)).destroy(trxOpt) + ) + .then(() => { + console.log( + `tag for project '${project.get( + "name" + )}' has successfully been migrated` + ); + return Promise.resolve(); + }); + }) + ), Promise.resolve() - ) + ); export const convertProjectPostsToChildren = () => { - return Post.where('type', 'project').fetchAll() - .then(({ models }) => findOfficialProjectTags(models)) -} + return Post.where("type", "project") + .fetchAll() + .then(({ models }) => findOfficialProjectTags(models)); +}; diff --git a/lib/rollbar.js b/lib/rollbar.js index 76eb67af0..72b1b56d1 100644 --- a/lib/rollbar.js +++ b/lib/rollbar.js @@ -1,24 +1,25 @@ -var rollbar = require('rollbar') +const rollbar = require("rollbar"); -if (process.env.ROLLBAR_SERVER_TOKEN && process.env.NODE_ENV !== 'test') { +if (process.env.ROLLBAR_SERVER_TOKEN && process.env.NODE_ENV !== "test") { rollbar.init({ accessToken: process.env.ROLLBAR_SERVER_TOKEN, captureUncaught: true, - captureUnhandledRejections: true - }) - module.exports = rollbar + captureUnhandledRejections: true, + }); + module.exports = rollbar; } else { - console.log('Rollbar disabled (process.env.ROLLBAR_SERVER_TOKEN undefined)') + console.log("Rollbar disabled (process.env.ROLLBAR_SERVER_TOKEN undefined)"); module.exports = { disabled: true, - error (err, callback) { // eslint-disable-line + error(err, callback) { + // eslint-disable-line // do nothing - if (typeof callback === 'function') callback() + if (typeof callback === "function") callback(); }, - errorHandler () { - return null - } - } + errorHandler() { + return null; + }, + }; } diff --git a/lib/skiff.js b/lib/skiff.js index 9a57f6ed7..ac1764bf0 100644 --- a/lib/skiff.js +++ b/lib/skiff.js @@ -22,63 +22,67 @@ usage: */ -require('dotenv').load() -if (process.env.NEW_RELIC_LICENSE_KEY) require('newrelic') +require("dotenv").load(); +if (process.env.NEW_RELIC_LICENSE_KEY) require("newrelic"); -require('./rollbar') // must require this to initialize Rollbar -var argv = require('minimist')(process.argv) -var rc = require('rc') -var sails = require('sails') -var _ = require('lodash') +require("./rollbar"); // must require this to initialize Rollbar +const argv = require("minimist")(process.argv); +const rc = require("rc"); +const sails = require("sails"); +const _ = require("lodash"); module.exports = { sails, - lower: () => process.emit('SIGTERM'), + lower: () => process.emit("SIGTERM"), lift: (opts) => { - var log = msg => opts.silent || sails.log.info(msg) - if (!opts.stop) opts.stop = done => done() + const log = (msg) => opts.silent || sails.log.info(msg); + if (!opts.stop) opts.stop = (done) => done(); // set up graceful shutdown. // these have to be defined outside the "sails lift" callback, // otherwise they are overridden by Sails. - process.on('SIGINT', function () { - if (!opts.silent) console.log() - process.emit('SIGTERM') - return false - }) - .on('SIGTERM', function () { - log('Landing...'.yellow) - - opts.stop(function (err) { - sails.lower() - if (err) { - log('Done with errors'.red) - console.error(err.stack) - } else { - log('Done'.green) - } + process + .on("SIGINT", function () { + if (!opts.silent) console.log(); + process.emit("SIGTERM"); + return false; }) - }) + .on("SIGTERM", function () { + log("Landing...".yellow); - log('Lifting...'.yellow) + opts.stop(function (err) { + sails.lower(); + if (err) { + log("Done with errors".red); + console.error(err.stack); + } else { + log("Done".green); + } + }); + }); - sails.lift(_.merge(rc('sails'), { - log: _.merge({noShip: true}, opts.log), - hooks: {http: false, sockets: false, views: false} - }), function (err) { - if (err) { - console.error("Couldn't lift Sails: " + err) - console.error(err.stack) - process.exit(1) - } + log("Lifting...".yellow); - if (opts.start) { - log('Aloft.'.blue) - opts.start(argv) - } else { - log('opts.start was not set; nothing to do.'.red) - process.exit(1) + sails.lift( + _.merge(rc("sails"), { + log: _.merge({ noShip: true }, opts.log), + hooks: { http: false, sockets: false, views: false }, + }), + function (err) { + if (err) { + console.error("Couldn't lift Sails: " + err); + console.error(err.stack); + process.exit(1); + } + + if (opts.start) { + log("Aloft.".blue); + opts.start(argv); + } else { + log("opts.start was not set; nothing to do.".red); + process.exit(1); + } } - }) - } -} + ); + }, +}; diff --git a/lib/uploader/converter.js b/lib/uploader/converter.js index f0be5cdd0..ab3bd3b0d 100644 --- a/lib/uploader/converter.js +++ b/lib/uploader/converter.js @@ -1,25 +1,25 @@ -import sharp from 'sharp' -import { PassThrough } from 'stream' -import * as types from './types' +import sharp from "sharp"; +import { PassThrough } from "stream"; +import * as types from "./types"; const sizes = { - [types.USER_AVATAR]: {width: 200, height: 200}, - [types.USER_BANNER]: {width: 1600, height: 600}, - [types.COMMUNITY_AVATAR]: {width: 160, height: 160}, - [types.COMMUNITY_BANNER]: {width: 1600, height: 600}, - [types.NETWORK_AVATAR]: {width: 160, height: 160}, - [types.NETWORK_BANNER]: {width: 1600, height: 600}, + [types.USER_AVATAR]: { width: 200, height: 200 }, + [types.USER_BANNER]: { width: 1600, height: 600 }, + [types.COMMUNITY_AVATAR]: { width: 160, height: 160 }, + [types.COMMUNITY_BANNER]: { width: 1600, height: 600 }, + [types.NETWORK_AVATAR]: { width: 160, height: 160 }, + [types.NETWORK_BANNER]: { width: 1600, height: 600 }, // TODO keep the original size around but create thumbnails for different // purposes, e.g. viewing on a post card - [types.POST]: {width: 1200}, - [types.COMMENT]: {width: 1200} -} + [types.POST]: { width: 1200 }, + [types.COMMENT]: { width: 1200 }, +}; -export function createConverterStream (uploadType, id, { strategy, fileType }) { - const size = sizes[uploadType] - if (!size || !fileType || !fileType.mime.startsWith('image')) { - return new PassThrough() +export function createConverterStream(uploadType, id, { strategy, fileType }) { + const size = sizes[uploadType]; + if (!size || !fileType || !fileType.mime.startsWith("image")) { + return new PassThrough(); } // TODO create thumbnails now, instead of using Media.createThumbnail? pipe to @@ -27,7 +27,7 @@ export function createConverterStream (uploadType, id, { strategy, fileType }) { // each converter return sharp() - .resize(size.width, size.height) - .crop(strategy) - .withoutEnlargement() + .resize(size.width, size.height) + .crop(strategy) + .withoutEnlargement(); } diff --git a/lib/uploader/index.js b/lib/uploader/index.js index 366825683..ff8369529 100644 --- a/lib/uploader/index.js +++ b/lib/uploader/index.js @@ -1,105 +1,108 @@ -import getFileType from 'file-type' -import mime from 'mime-types' -import request from 'request' -import { PassThrough } from 'stream' +import getFileType from "file-type"; +import mime from "mime-types"; +import request from "request"; +import { PassThrough } from "stream"; -import { createConverterStream } from './converter' -import { createPostImporter } from './postImporter' -import { createS3StorageStream } from './storage' -import { validate } from './validation' +import { createConverterStream } from "./converter"; +import { createPostImporter } from "./postImporter"; +import { createS3StorageStream } from "./storage"; +import { validate } from "./validation"; -export function upload (args) { - let { type, id, userId, url, stream, onProgress, filename } = args +export function upload(args) { + let { type, id, userId, url, stream, onProgress, filename } = args; - return validate(args) - .then(() => { - let passthrough, converter, storage, didSetup, sourceHasError - const source = url ? request(url) : stream - if (!filename) filename = url + return validate(args).then(() => { + let passthrough, converter, storage, didSetup, sourceHasError; + const source = url ? request(url) : stream; + if (!filename) filename = url; - function setupStreams (data, resolve, reject) { - didSetup = true + function setupStreams(data, resolve, reject) { + didSetup = true; - if (type === 'importPosts') { - passthrough = createPostImporter(userId, id) - passthrough.on('end', (e) => { + if (type === "importPosts") { + passthrough = createPostImporter(userId, id); + passthrough.on("end", (e) => { // This returns to the front-end after the CSV has been read but before posts have been created const uploaderResult = { type, id, - mimetype: "text/csv" - } - return resolve(uploaderResult) - }) + mimetype: "text/csv", + }; + return resolve(uploaderResult); + }); } else { // this is used so we can get the file type from the first chunk of // data and still use `.pipe` -- you can't pipe a stream after getting // data from it - passthrough = new PassThrough() + passthrough = new PassThrough(); - const fileType = guessFileType(data, filename) - const mimetype = fileType && fileType.mime + const fileType = guessFileType(data, filename); + const mimetype = fileType && fileType.mime; - converter = createConverterStream(type, id, {fileType}) - converter.on('error', err => reject(err)) + converter = createConverterStream(type, id, { fileType }); + converter.on("error", (err) => reject(err)); - storage = createS3StorageStream(type, id, {userId, fileType, filename}) - storage.on('finish', () => { + storage = createS3StorageStream(type, id, { + userId, + fileType, + filename, + }); + storage.on("finish", () => { const uploaderResult = { type, id, url: storage.url, - mimetype - } + mimetype, + }; - return resolve(uploaderResult) - }) - storage.on('error', err => reject(err)) - if (onProgress) storage.on('progress', onProgress) + return resolve(uploaderResult); + }); + storage.on("error", (err) => reject(err)); + if (onProgress) storage.on("progress", onProgress); - passthrough.pipe(converter).pipe(storage) + passthrough.pipe(converter).pipe(storage); } } return new Promise((resolve, reject) => { - source.on('data', data => { - if (sourceHasError) return + source.on("data", (data) => { + if (sourceHasError) return; if (!didSetup) { try { - setupStreams(data, resolve, reject) + setupStreams(data, resolve, reject); } catch (err) { - reject(err) + reject(err); } } - if (passthrough) passthrough.write(data) - }) + if (passthrough) passthrough.write(data); + }); - source.on('error', err => { - sourceHasError = true - reject(err) - }) + source.on("error", (err) => { + sourceHasError = true; + reject(err); + }); - source.on('end', () => { - if (passthrough) passthrough.end() - }) - }) - }) + source.on("end", () => { + if (passthrough) passthrough.end(); + }); + }); + }); } -export function guessFileType (data, filename) { - let fileType +export function guessFileType(data, filename) { + let fileType; try { - fileType = getFileType(data) + fileType = getFileType(data); // Open Office documents exported from Google Docs are mis-identified by // getFileType so in the case of fileType returning a zip file type // (OO docs are zip files at the top level) we fallback to a mime-type // and extension identification based upon filename. - if (fileType.ext === 'zip') { - const mimetype = mime.lookup(filename) - const extension = mime.extension(mimetype) - fileType = {mime: mimetype, ext: extension} + if (fileType.ext === "zip") { + const mimetype = mime.lookup(filename); + const extension = mime.extension(mimetype); + fileType = { mime: mimetype, ext: extension }; } - return fileType + return fileType; } catch (err) {} } diff --git a/lib/uploader/index.test.js b/lib/uploader/index.test.js index 6ba7ffcaa..5d067fb3c 100644 --- a/lib/uploader/index.test.js +++ b/lib/uploader/index.test.js @@ -1,113 +1,121 @@ -import { upload, guessFileType } from './index' -import { Readable } from 'stream' +import { upload, guessFileType } from "./index"; +import { Readable } from "stream"; -describe('Uploader', () => { - it('rejects a call with no id', () => { - return upload({type: 'userAvatar', url: 'http://foo.com/foo.png'}) - .then(() => expect.fail('should reject')) - .catch(err => { - expect(err.message).to.equal('Validation error: No id') - }) - }) +describe("Uploader", () => { + it("rejects a call with no id", () => { + return upload({ type: "userAvatar", url: "http://foo.com/foo.png" }) + .then(() => expect.fail("should reject")) + .catch((err) => { + expect(err.message).to.equal("Validation error: No id"); + }); + }); - it('rejects a call with no url and no stream', () => { - return upload({type: 'userAvatar', id: 4}) - .then(() => expect.fail('should reject')) - .catch(err => { - expect(err.message).to.equal('Validation error: No url and no stream') - }) - }) + it("rejects a call with no url and no stream", () => { + return upload({ type: "userAvatar", id: 4 }) + .then(() => expect.fail("should reject")) + .catch((err) => { + expect(err.message).to.equal("Validation error: No url and no stream"); + }); + }); - it('rejects a call with an invalid type', () => { - return upload({type: 'foo', id: 7, url: 'http://foo.com/foo.png'}) - .then(() => expect.fail('should reject')) - .catch(err => { - expect(err.message).to.equal('Validation error: Invalid type') - }) - }) + it("rejects a call with an invalid type", () => { + return upload({ type: "foo", id: 7, url: "http://foo.com/foo.png" }) + .then(() => expect.fail("should reject")) + .catch((err) => { + expect(err.message).to.equal("Validation error: Invalid type"); + }); + }); - it('rejects a call changing a setting for another user', () => { + it("rejects a call changing a setting for another user", () => { return upload({ - type: 'userBanner', - userId: '6', - id: '7', - url: 'http://foo.com/foo.png' + type: "userBanner", + userId: "6", + id: "7", + url: "http://foo.com/foo.png", }) - .then(() => expect.fail('should reject')) - .catch(err => { - expect(err.message).to.equal('Validation error: Not allowed to change settings for another person') - }) - }) + .then(() => expect.fail("should reject")) + .catch((err) => { + expect(err.message).to.equal( + "Validation error: Not allowed to change settings for another person" + ); + }); + }); - it('rejects a call changing a non-moderated or nonexistent community', () => { + it("rejects a call changing a non-moderated or nonexistent community", () => { return upload({ - userId: '6', - type: 'communityBanner', - id: '7', - url: 'http://foo.com/foo.png' - }) - .then(() => expect.fail('should reject')) - .catch(err => { - expect(err.message).to.equal('Validation error: Not a moderator of this community') + userId: "6", + type: "communityBanner", + id: "7", + url: "http://foo.com/foo.png", }) - }) + .then(() => expect.fail("should reject")) + .catch((err) => { + expect(err.message).to.equal( + "Validation error: Not a moderator of this community" + ); + }); + }); - it('rejects a call changing a non-moderated or nonexistent network', () => { + it("rejects a call changing a non-moderated or nonexistent network", () => { return upload({ - userId: '6', - type: 'networkBanner', - id: '7', - url: 'http://foo.com/foo.png' + userId: "6", + type: "networkBanner", + id: "7", + url: "http://foo.com/foo.png", }) - .then(() => expect.fail('should reject')) - .catch(err => { - expect(err.message).to.equal('Validation error: Not a moderator of this network') - }) - }) + .then(() => expect.fail("should reject")) + .catch((err) => { + expect(err.message).to.equal( + "Validation error: Not a moderator of this network" + ); + }); + }); - it('rejects a call changing a nonexistent post', () => { + it("rejects a call changing a nonexistent post", () => { return upload({ - userId: '6', - type: 'post', - id: '1234567', - url: 'http://foo.com/foo.png' - }) - .then(() => expect.fail('should reject')) - .catch(err => { - expect(err.message).to.equal('Validation error: Not allowed to edit this post') + userId: "6", + type: "post", + id: "1234567", + url: "http://foo.com/foo.png", }) - }) + .then(() => expect.fail("should reject")) + .catch((err) => { + expect(err.message).to.equal( + "Validation error: Not allowed to edit this post" + ); + }); + }); - it('rejects if the source emits an error', () => { + it("rejects if the source emits an error", () => { const stream = new Readable({ - read (size) { - if (!this.readCount) this.readCount = 1 - this.readCount++ + read(size) { + if (!this.readCount) this.readCount = 1; + this.readCount++; if (this.readCount > 2) { - this.emit('error', new Error('wow')) + this.emit("error", new Error("wow")); } - setTimeout(() => this.push('i'), 5) - } - }) + setTimeout(() => this.push("i"), 5); + }, + }); return upload({ - userId: '10', - type: 'userBanner', - id: '10', - stream - }) - .then(() => expect.fail('should reject')) - .catch(err => { - expect(err.message).to.equal('wow') + userId: "10", + type: "userBanner", + id: "10", + stream, }) - }) + .then(() => expect.fail("should reject")) + .catch((err) => { + expect(err.message).to.equal("wow"); + }); + }); - it('properly identifies an Open Office document', () => { + it("properly identifies an Open Office document", () => { const docxBuffer = Buffer.from( - '504b03041400080808006f1c934b00000000000000000000000018000000786c2f64726177696e67732f64726177696e6731', - 'hex' - ) - const result = guessFileType(docxBuffer, 'any.docx') - expect(result.ext).to.equal('docx') - }) -}) + "504b03041400080808006f1c934b00000000000000000000000018000000786c2f64726177696e67732f64726177696e6731", + "hex" + ); + const result = guessFileType(docxBuffer, "any.docx"); + expect(result.ext).to.equal("docx"); + }); +}); diff --git a/lib/uploader/postImporter.js b/lib/uploader/postImporter.js index 238b6f21e..1416cbe0a 100644 --- a/lib/uploader/postImporter.js +++ b/lib/uploader/postImporter.js @@ -1,130 +1,162 @@ -import parse from 'csv-parse' -import request from 'request' -import { PassThrough } from 'stream' -import createPost from '../../api/models/post/createPost' -import { findOrCreateLocation } from '../../api/graphql/mutations/location' +import parse from "csv-parse"; +import request from "request"; +import { PassThrough } from "stream"; +import createPost from "../../api/models/post/createPost"; +import { findOrCreateLocation } from "../../api/graphql/mutations/location"; function createObjectFrom(record, userId, communityId) { return new Promise(async (resolve, reject) => { - let location + let location; try { - const locationData = await geocode(record.location) - location = await findOrCreateLocation(locationData) + const locationData = await geocode(record.location); + location = await findOrCreateLocation(locationData); } catch (e) { - sails.log.error("Error finding post location: " + e) - reject(e) - return + sails.log.error("Error finding post location: " + e); + reject(e); + return; } const postParams = { community_ids: [communityId], - description: record.description || '', + description: record.description || "", endTime: record.end_date ? new Date(record.end_date) : null, location: record.location, imageUrls: record.image_urls ? record.image_urls.split(/,?\s+/) : [], - isPublic: record.is_public ? ['true', 'yes'].includes(record.is_public.toLowerCase()) : false, + isPublic: record.is_public + ? ["true", "yes"].includes(record.is_public.toLowerCase()) + : false, location_id: location ? location.id : null, - name: record.title || '', + name: record.title || "", startTime: record.start_date ? new Date(record.start_date) : null, topicNames: record.topics ? record.topics.split(/,?\s+/) : [], - type: record.type ? record.type.toLowerCase() : 'discussion' - } + type: record.type ? record.type.toLowerCase() : "discussion", + }; try { - const post = await createPost(userId, postParams) - sails.log.info("Finished creating post", postParams) - resolve(post) - } catch(e) { - sails.log.error("Error importing post: " + e.message) - reject(e) + const post = await createPost(userId, postParams); + sails.log.info("Finished creating post", postParams); + resolve(post); + } catch (e) { + sails.log.error("Error importing post: " + e.message); + reject(e); } - }) + }); } -export function createPostImporter (userId, communityId) { - const parser = parse({ columns: header => header.map(column => column.toLowerCase()) }) +export function createPostImporter(userId, communityId) { + const parser = parse({ + columns: (header) => header.map((column) => column.toLowerCase()), + }); - parser.errors = [] - parser.numPostsCreated = 0 - const promiseFactories = [] + parser.errors = []; + parser.numPostsCreated = 0; + const promiseFactories = []; - parser.on('readable', () => { - let record = parser.read(); + parser.on("readable", () => { + const record = parser.read(); if (record === null) { - return + return; } const promiseFactory = () => createObjectFrom(record, userId, communityId); - promiseFactories.push( promiseFactory ); - }) + promiseFactories.push(promiseFactory); + }); - parser.on('error', (err) => { sails.log.error("Weird parser error, check out " + err)}) + parser.on("error", (err) => { + sails.log.error("Weird parser error, check out " + err); + }); - parser.on('end', () => { - var sequence = Promise.resolve(); + parser.on("end", () => { + let sequence = Promise.resolve(); // Loop over each promise factory and add on a promise to the end of the 'sequence' promise. - promiseFactories.forEach(promiseFactory => { + promiseFactories.forEach((promiseFactory) => { sequence = sequence .then(promiseFactory) - .then(result => { parser.numPostsCreated = parser.numPostsCreated + 1 }) - .catch(error => { parser.errors.push(error.message ? error.message : error) }) - }) + .then((result) => { + parser.numPostsCreated = parser.numPostsCreated + 1; + }) + .catch((error) => { + parser.errors.push(error.message ? error.message : error); + }); + }); // This will resolve after the entire chain is resolved - sequence.then(() => { sails.log.info("Succesfully imported " + parser.numPostsCreated + " posts.\n Errors: " + parser.errors.join("\n"))}) - }) - - return parser + sequence.then(() => { + sails.log.info( + "Succesfully imported " + + parser.numPostsCreated + + " posts.\n Errors: " + + parser.errors.join("\n") + ); + }); + }); + + return parser; } function geocode(address) { - if (!process.env.MAPBOX_TOKEN) return false + if (!process.env.MAPBOX_TOKEN) return false; - const url = 'https://api.mapbox.com/geocoding/v5/mapbox.places/' - + encodeURIComponent(address) + '.json?access_token=' - + process.env.MAPBOX_TOKEN + '&limit=1'; + const url = + "https://api.mapbox.com/geocoding/v5/mapbox.places/" + + encodeURIComponent(address) + + ".json?access_token=" + + process.env.MAPBOX_TOKEN + + "&limit=1"; return new Promise((resolve, reject) => { request({ url, json: true }, (err, response, body) => { if (err) { - reject('Error when geocoding "' + address + '": ' + err.message) + reject('Error when geocoding "' + address + '": ' + err.message); } else if (!body.features || body.features.length == 0) { - reject('Unable to find location "' + address + '"') + reject('Unable to find location "' + address + '"'); } else { - const result = body.features[0] - resolve(convertMapboxToLocation(result)) + const result = body.features[0]; + resolve(convertMapboxToLocation(result)); } - }) - }) + }); + }); } -function convertMapboxToLocation (mapboxResult) { - const context = mapboxResult.context - const neighborhoodObject = context && context.find(c => c.id.includes('neighborhood')) - const postcodeObject = context && context.find(c => c.id.includes('postcode')) - const placeObject = context && context.find(c => c.id.includes('place')) - const regionObject = context && context.find(c => c.id.includes('region')) - const countryObject = context && context.find(c => c.id.includes('country')) - - let city = placeObject ? placeObject.text : mapboxResult.place_type[0] === 'place' ? mapboxResult.text : '' - - let address_number = '' - let address_street = '' +function convertMapboxToLocation(mapboxResult) { + const context = mapboxResult.context; + const neighborhoodObject = + context && context.find((c) => c.id.includes("neighborhood")); + const postcodeObject = + context && context.find((c) => c.id.includes("postcode")); + const placeObject = context && context.find((c) => c.id.includes("place")); + const regionObject = context && context.find((c) => c.id.includes("region")); + const countryObject = + context && context.find((c) => c.id.includes("country")); + + const city = placeObject + ? placeObject.text + : mapboxResult.place_type[0] === "place" + ? mapboxResult.text + : ""; + + let address_number = ""; + let address_street = ""; if (mapboxResult.properties.address) { // For Points of Interest and landmarks Mapbox annoyingly stores the address in a single string inside properties - address_number = mapboxResult.properties.address.split(' ')[0] - address_street = mapboxResult.properties.address.split(' ')[1] - } else if (mapboxResult.place_type[0] === 'address') { - address_street = mapboxResult.text - address_number = mapboxResult.address + address_number = mapboxResult.properties.address.split(" ")[0]; + address_street = mapboxResult.properties.address.split(" ")[1]; + } else if (mapboxResult.place_type[0] === "address") { + address_street = mapboxResult.text; + address_number = mapboxResult.address; } return { accuracy: mapboxResult.properties.accuracy, address_number, address_street, - bbox: mapboxResult.bbox ? [{ lng: mapboxResult.bbox[0], lat: mapboxResult.bbox[1] }, { lng: mapboxResult.bbox[2], lat: mapboxResult.bbox[3] }] : null, + bbox: mapboxResult.bbox + ? [ + { lng: mapboxResult.bbox[0], lat: mapboxResult.bbox[1] }, + { lng: mapboxResult.bbox[2], lat: mapboxResult.bbox[3] }, + ] + : null, center: { lng: mapboxResult.center[0], lat: mapboxResult.center[1] }, city, country: countryObject && countryObject.short_code, @@ -133,7 +165,7 @@ function convertMapboxToLocation (mapboxResult) { // locality neighborhood: neighborhoodObject && neighborhoodObject.text, region: regionObject && regionObject.text, - postcode: postcodeObject && postcodeObject.text + postcode: postcodeObject && postcodeObject.text, // wikidata: String - } + }; } diff --git a/lib/uploader/storage.js b/lib/uploader/storage.js index 7b0d98557..d9fbfcbbd 100644 --- a/lib/uploader/storage.js +++ b/lib/uploader/storage.js @@ -1,106 +1,113 @@ -import path from 'path' -import aws from 'aws-sdk' -import { parse } from 'url' -import { PassThrough } from 'stream' -import mime from 'mime' -import sanitize from 'sanitize-filename' - -export function safeBasename (url = '') { - url = url.replace(/\?.*$/, '') - const name = sanitize(path.basename(url)) +import path from "path"; +import aws from "aws-sdk"; +import { parse } from "url"; +import { PassThrough } from "stream"; +import mime from "mime"; +import sanitize from "sanitize-filename"; + +export function safeBasename(url = "") { + url = url.replace(/\?.*$/, ""); + const name = sanitize(path.basename(url)); if (!name) { - const rand = Math.random().toString().substring(2, 6) - return `${Date.now()}_${rand}` + const rand = Math.random().toString().substring(2, 6); + return `${Date.now()}_${rand}`; } - return name + return name; } -export function createS3StorageStream (uploadType, id, { userId, fileType, filename }) { - ;[ - 'AWS_ACCESS_KEY_ID', - 'AWS_SECRET_ACCESS_KEY', - 'AWS_S3_BUCKET', - 'UPLOADER_PATH_PREFIX' - ].forEach(key => { +export function createS3StorageStream( + uploadType, + id, + { userId, fileType, filename } +) { + [ + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_S3_BUCKET", + "UPLOADER_PATH_PREFIX", + ].forEach((key) => { if (!process.env[key]) { - throw new Error(`missing process.env.${key}`) + throw new Error(`missing process.env.${key}`); } - }) - - const s3 = new aws.S3() - const wrapper = createWrapperStream() - - const upload = s3.upload({ - // even though we're already using a PassThrough stream, the upload doesn't - // work unless we use another PassThrough. ¯\_(ツ)_/¯ - Body: wrapper.pipe(new PassThrough()), - - ACL: 'public-read', - Bucket: process.env.AWS_S3_BUCKET, - ContentType: getMimetypeFromFileType(fileType, filename), - Key: makePath(uploadType, id, { userId, fileType, filename }) - }, (err, data) => { - if (err) return wrapper.emit('error', err) - wrapper.url = getFinalUrl(data.Location) - wrapper.triggerFinish() - }) - - wrapper.upload = upload - return wrapper + }); + + const s3 = new aws.S3(); + const wrapper = createWrapperStream(); + + const upload = s3.upload( + { + // even though we're already using a PassThrough stream, the upload doesn't + // work unless we use another PassThrough. ¯\_(ツ)_/¯ + Body: wrapper.pipe(new PassThrough()), + + ACL: "public-read", + Bucket: process.env.AWS_S3_BUCKET, + ContentType: getMimetypeFromFileType(fileType, filename), + Key: makePath(uploadType, id, { userId, fileType, filename }), + }, + (err, data) => { + if (err) return wrapper.emit("error", err); + wrapper.url = getFinalUrl(data.Location); + wrapper.triggerFinish(); + } + ); + + wrapper.upload = upload; + return wrapper; } // this is a modified PassThrough: // - the 'finish' event does not fire until `triggerFinish` is called // - it passes the 'progress' event listener to the S3 upload manager -function createWrapperStream () { - const stream = new PassThrough() - let onFinishCallbacks = [] +function createWrapperStream() { + const stream = new PassThrough(); + const onFinishCallbacks = []; - stream._realOn = stream.on + stream._realOn = stream.on; stream.on = function (eventName, callback) { - if (eventName === 'finish') { - return onFinishCallbacks.push(callback) + if (eventName === "finish") { + return onFinishCallbacks.push(callback); } - if (eventName === 'progress') { - return stream.upload.on('httpUploadProgress', callback) + if (eventName === "progress") { + return stream.upload.on("httpUploadProgress", callback); } - return stream._realOn(eventName, callback) - } + return stream._realOn(eventName, callback); + }; - stream.triggerFinish = () => onFinishCallbacks.forEach(fn => fn()) + stream.triggerFinish = () => onFinishCallbacks.forEach((fn) => fn()); - return stream + return stream; } -function getFinalUrl (url) { - if (!process.env.UPLOADER_HOST) return url - const u = parse(url) - u.host = process.env.UPLOADER_HOST - return u.format() +function getFinalUrl(url) { + if (!process.env.UPLOADER_HOST) return url; + const u = parse(url); + u.host = process.env.UPLOADER_HOST; + return u.format(); } -export function makePath (type, id, { userId, fileType, filename }) { - let basename = safeBasename(filename) +export function makePath(type, id, { userId, fileType, filename }) { + let basename = safeBasename(filename); if (fileType) { - basename = basename.replace(/(\.\w{2,4})?$/, '.' + fileType.ext) + basename = basename.replace(/(\.\w{2,4})?$/, "." + fileType.ext); } return path.join( process.env.UPLOADER_PATH_PREFIX, - 'user', - userId ? String(userId) : 'system', + "user", + userId ? String(userId) : "system", type, - id ? String(id) : 'new', + id ? String(id) : "new", basename - ) + ); } -function getMimetypeFromFileType (fileType, filename) { +function getMimetypeFromFileType(fileType, filename) { return fileType ? fileType.mime : filename - ? mime.lookup(filename) - : 'application/octet-stream' + ? mime.lookup(filename) + : "application/octet-stream"; } diff --git a/lib/uploader/storage.test.js b/lib/uploader/storage.test.js index 42e5fb652..ded943159 100644 --- a/lib/uploader/storage.test.js +++ b/lib/uploader/storage.test.js @@ -1,60 +1,68 @@ -import { makePath } from './storage' -import mockRequire from 'mock-require' +import { makePath } from "./storage"; +import mockRequire from "mock-require"; -describe('Uploader.storage.makePath', () => { - let tmpEnvVar +describe("Uploader.storage.makePath", () => { + let tmpEnvVar; beforeEach(() => { - tmpEnvVar = process.env.UPLOADER_PATH_PREFIX - process.env.UPLOADER_PATH_PREFIX = 'all-the-things' - }) + tmpEnvVar = process.env.UPLOADER_PATH_PREFIX; + process.env.UPLOADER_PATH_PREFIX = "all-the-things"; + }); afterEach(() => { - process.env.UPLOADER_PATH_PREFIX = tmpEnvVar - }) + process.env.UPLOADER_PATH_PREFIX = tmpEnvVar; + }); - it('stores a file based on the user who uploaded it', () => { - const fileType = {ext: 'png', mime: 'image/png'} - expect(makePath('communityAvatar', 17, {userId: 41, fileType})) - .to.match(/all-the-things\/user\/41\/communityAvatar\/17\/\d{13}_\d{4}\.png/) - }) + it("stores a file based on the user who uploaded it", () => { + const fileType = { ext: "png", mime: "image/png" }; + expect(makePath("communityAvatar", 17, { userId: 41, fileType })).to.match( + /all-the-things\/user\/41\/communityAvatar\/17\/\d{13}_\d{4}\.png/ + ); + }); - it('uses an existing filename if present', () => { - expect(makePath('post', 17, {userId: 41, filename: 'http://wow.com/foo.pdf?bar=1'})) - .to.equal('all-the-things/user/41/post/17/foo.pdf') - }) + it("uses an existing filename if present", () => { + expect( + makePath("post", 17, { + userId: 41, + filename: "http://wow.com/foo.pdf?bar=1", + }) + ).to.equal("all-the-things/user/41/post/17/foo.pdf"); + }); - it('uses default values', () => { - expect(makePath('post', null, {filename: 'foo.pdf'})) - .to.equal('all-the-things/user/system/post/new/foo.pdf') - }) -}) + it("uses default values", () => { + expect(makePath("post", null, { filename: "foo.pdf" })).to.equal( + "all-the-things/user/system/post/new/foo.pdf" + ); + }); +}); -describe('Uploader.storage.createS3StorageStream', () => { - let storage, listener +describe("Uploader.storage.createS3StorageStream", () => { + let storage, listener; before(() => { - mockRequire('aws-sdk', {S3: mockS3}) - storage = mockRequire.reRequire('./storage') - mockRequire.reRequire('aws-sdk') - }) + mockRequire("aws-sdk", { S3: mockS3 }); + storage = mockRequire.reRequire("./storage"); + mockRequire.reRequire("aws-sdk"); + }); - after(() => mockRequire.stopAll()) + after(() => mockRequire.stopAll()); it('allows listening for a "progress" event', () => { - const stream = storage.createS3StorageStream('comment', 1, {}) - stream.on('progress', listener) - expect(stream.upload.isMock).to.be.true - expect(stream.upload.on).to.have.been.called - .with('httpUploadProgress', listener) - }) -}) + const stream = storage.createS3StorageStream("comment", 1, {}); + stream.on("progress", listener); + expect(stream.upload.isMock).to.be.true; + expect(stream.upload.on).to.have.been.called.with( + "httpUploadProgress", + listener + ); + }); +}); class mockS3 { - upload () { + upload() { return { isMock: true, - on: spy() - } + on: spy(), + }; } } diff --git a/lib/uploader/types.js b/lib/uploader/types.js index 155e3d2a4..7c4e97b1d 100644 --- a/lib/uploader/types.js +++ b/lib/uploader/types.js @@ -1,9 +1,9 @@ -export const USER_AVATAR = 'userAvatar' -export const USER_BANNER = 'userBanner' -export const COMMUNITY_AVATAR = 'communityAvatar' -export const COMMUNITY_BANNER = 'communityBanner' -export const NETWORK_AVATAR = 'networkAvatar' -export const NETWORK_BANNER = 'networkBanner' -export const POST = 'post' -export const COMMENT = 'comment' -export const IMPORT_POSTS = 'importPosts' +export const USER_AVATAR = "userAvatar"; +export const USER_BANNER = "userBanner"; +export const COMMUNITY_AVATAR = "communityAvatar"; +export const COMMUNITY_BANNER = "communityBanner"; +export const NETWORK_AVATAR = "networkAvatar"; +export const NETWORK_BANNER = "networkBanner"; +export const POST = "post"; +export const COMMENT = "comment"; +export const IMPORT_POSTS = "importPosts"; diff --git a/lib/uploader/validation.js b/lib/uploader/validation.js index b1bda7b7d..85c71ac8b 100644 --- a/lib/uploader/validation.js +++ b/lib/uploader/validation.js @@ -1,48 +1,54 @@ -import { values } from 'lodash' -import * as types from './types' +import { values } from "lodash"; +import * as types from "./types"; -export function validate ({ type, id, userId, url, stream }) { +export function validate({ type, id, userId, url, stream }) { if (!values(types).includes(type)) { - return Promise.reject(new Error('Validation error: Invalid type')) + return Promise.reject(new Error("Validation error: Invalid type")); } if (!url && !stream) { - return Promise.reject(new Error('Validation error: No url and no stream')) + return Promise.reject(new Error("Validation error: No url and no stream")); } if (!id) { - return Promise.reject(new Error('Validation error: No id')) + return Promise.reject(new Error("Validation error: No id")); } - return hasPermission(userId, type, id) + return hasPermission(userId, type, id); } -async function hasPermission (userId, type, id) { - if (type.startsWith('user')) { - if (id === userId) return Promise.resolve() - return Promise.reject(new Error('Validation error: Not allowed to change settings for another person')) +async function hasPermission(userId, type, id) { + if (type.startsWith("user")) { + if (id === userId) return Promise.resolve(); + return Promise.reject( + new Error( + "Validation error: Not allowed to change settings for another person" + ) + ); } - if (type.startsWith('community')) { - const community = await Community.find(id) - if (!community || - !(await GroupMembership.hasModeratorRole(userId, community))) { - throw new Error('Validation error: Not a moderator of this community') + if (type.startsWith("community")) { + const community = await Community.find(id); + if ( + !community || + !(await GroupMembership.hasModeratorRole(userId, community)) + ) { + throw new Error("Validation error: Not a moderator of this community"); } } - if (type.startsWith('network')) { - return NetworkMembership.hasModeratorRole(userId, id) - .then(ok => { - if (!ok) throw new Error('Validation error: Not a moderator of this network') - }) + if (type.startsWith("network")) { + return NetworkMembership.hasModeratorRole(userId, id).then((ok) => { + if (!ok) + throw new Error("Validation error: Not a moderator of this network"); + }); } - if (type.startsWith('post')) { - if (id === 'new') return Promise.resolve() - return Post.find(id) - .then(post => { - if (!post || post.get('user_id') !== userId) throw new Error('Validation error: Not allowed to edit this post') - }) + if (type.startsWith("post")) { + if (id === "new") return Promise.resolve(); + return Post.find(id).then((post) => { + if (!post || post.get("user_id") !== userId) + throw new Error("Validation error: Not allowed to edit this post"); + }); } } diff --git a/lib/util.js b/lib/util.js index af33c3017..23826611b 100644 --- a/lib/util.js +++ b/lib/util.js @@ -1,15 +1,15 @@ -var socketIoEmitter = require('socket.io-emitter'), - redis = require('kue/node_modules/redis'), - redisInfo = require('parse-redis-url')().parse(process.env.REDIS_URL); +const socketIoEmitter = require("socket.io-emitter"); +const redis = require("kue/node_modules/redis"); +const redisInfo = require("parse-redis-url")().parse(process.env.REDIS_URL); module.exports = { - - redisClient: function() { - return redis.createClient(redisInfo.port, redisInfo.host, {auth_pass: redisInfo.password}); + redisClient: function () { + return redis.createClient(redisInfo.port, redisInfo.host, { + auth_pass: redisInfo.password, + }); }, - socketIo: function() { + socketIo: function () { return socketIoEmitter(this.redisClient()); - } - -}; \ No newline at end of file + }, +}; diff --git a/lib/util/controllers.js b/lib/util/controllers.js index 6a99cbac2..a95132ec0 100644 --- a/lib/util/controllers.js +++ b/lib/util/controllers.js @@ -1,20 +1,19 @@ -import { isEmpty, pick } from 'lodash' +import { isEmpty, pick } from "lodash"; export const throwErrorIfMissingTags = (tags, communityIds) => { - return Tag.nonexistent(tags, communityIds) - .then(nonexistent => { - if (isEmpty(nonexistent)) return + return Tag.nonexistent(tags, communityIds).then((nonexistent) => { + if (isEmpty(nonexistent)) return; - const error = new Error('some new tags are missing descriptions') - error.tagsMissingDescriptions = nonexistent - throw error - }) -} + const error = new Error("some new tags are missing descriptions"); + error.tagsMissingDescriptions = nonexistent; + throw error; + }); +}; export const handleMissingTagDescriptions = (err, res) => { if (err.tagsMissingDescriptions) { - res.status(422) - res.send(pick(err, 'tagsMissingDescriptions')) - return true + res.status(422); + res.send(pick(err, "tagsMissingDescriptions")); + return true; } -} +}; diff --git a/lib/util/knex.js b/lib/util/knex.js index 2f0d2af2d..0c68b3078 100644 --- a/lib/util/knex.js +++ b/lib/util/knex.js @@ -1,5 +1,5 @@ -import { difference, isEmpty, isEqual } from 'lodash' -import { find, flow, get, map, pick, some, values } from 'lodash/fp' +import { difference, isEmpty, isEqual } from "lodash"; +import { find, flow, get, map, pick, some, values } from "lodash/fp"; // update rows in `table` with `column`=`fromValue` to column=toValue. if a row // would cause a duplicate key error, delete it instead. @@ -10,36 +10,59 @@ import { find, flow, get, map, pick, some, values } from 'lodash/fp' // // `knex` can be a transaction object. // -export const updateOrRemove = (table, column, fromValue, toValue, uniqueCols, knex) => { - const uniqueValues = x => values(pick(uniqueCols, x)) - const sameUniqueValues = x => y => isEqual(uniqueValues(x), uniqueValues(y)) - let rowsToChange +export const updateOrRemove = ( + table, + column, + fromValue, + toValue, + uniqueCols, + knex +) => { + const uniqueValues = (x) => values(pick(uniqueCols, x)); + const sameUniqueValues = (x) => (y) => + isEqual(uniqueValues(x), uniqueValues(y)); + let rowsToChange; // find all the rows to be changed - return knex(table).where(column, fromValue) - .then(rows => { rowsToChange = rows }) - // find duplicate rows - .then(() => knex(table).where(column, toValue) - .whereIn(uniqueCols, map(uniqueValues, rowsToChange))) - .then(duplicates => { - const idsToRemove = flow( - map(dup => find(sameUniqueValues(dup), rowsToChange)), - map(get('id')) - )(duplicates) - const idsToUpdate = difference(map('id', rowsToChange), idsToRemove) + return ( + knex(table) + .where(column, fromValue) + .then((rows) => { + rowsToChange = rows; + }) + // find duplicate rows + .then(() => + knex(table) + .where(column, toValue) + .whereIn(uniqueCols, map(uniqueValues, rowsToChange)) + ) + .then((duplicates) => { + const idsToRemove = flow( + map((dup) => find(sameUniqueValues(dup), rowsToChange)), + map(get("id")) + )(duplicates); + const idsToUpdate = difference(map("id", rowsToChange), idsToRemove); - return Promise.join( - isEmpty(idsToRemove) || knex(table).whereIn('id', idsToRemove).del(), - isEmpty(idsToUpdate) || knex(table).whereIn('id', idsToUpdate).update(column, toValue) - ) - }) -} + return Promise.join( + isEmpty(idsToRemove) || knex(table).whereIn("id", idsToRemove).del(), + isEmpty(idsToUpdate) || + knex(table).whereIn("id", idsToUpdate).update(column, toValue) + ); + }) + ); +}; -export const countTotal = (q, table, columnName = 'total') => { - return q.select(bookshelf.knex.raw(`${table}.*, count(*) over () as ${columnName}`)) -} +export const countTotal = (q, table, columnName = "total") => { + return q.select( + bookshelf.knex.raw(`${table}.*, count(*) over () as ${columnName}`) + ); +}; -export function hasJoin (relation, tableName) { - return some(clause => clause.table === tableName, relation.query()._statements) || - get('throughTableName', relation.relatedData) === tableName +export function hasJoin(relation, tableName) { + return ( + some( + (clause) => clause.table === tableName, + relation.query()._statements + ) || get("throughTableName", relation.relatedData) === tableName + ); } diff --git a/lib/util/knex.test.js b/lib/util/knex.test.js index c217fd741..dc10586b8 100644 --- a/lib/util/knex.test.js +++ b/lib/util/knex.test.js @@ -1,27 +1,39 @@ -import { hasJoin } from './knex' +import { hasJoin } from "./knex"; -describe('hasJoin', () => { - it('returns false for a collection', () => { - expect(hasJoin(Post.collection(), 'communities_posts')).to.be.false - }) +describe("hasJoin", () => { + it("returns false for a collection", () => { + expect(hasJoin(Post.collection(), "communities_posts")).to.be.false; + }); - it('returns true for a collection with a manual join', () => { - expect(hasJoin(Post.collection().query(q => { - q.join('communities_posts', 'posts.id', 'communities_posts.id') - }), 'communities_posts')).to.be.true - }) + it("returns true for a collection with a manual join", () => { + expect( + hasJoin( + Post.collection().query((q) => { + q.join("communities_posts", "posts.id", "communities_posts.id"); + }), + "communities_posts" + ) + ).to.be.true; + }); - it('returns false for a relation', () => { - expect(hasJoin(Community.forge().posts(), 'communities_users')).to.be.false - }) + it("returns false for a relation", () => { + expect(hasJoin(Community.forge().posts(), "communities_users")).to.be.false; + }); - it('returns true for a relation', () => { - expect(hasJoin(Community.forge().posts(), 'communities_posts')).to.be.true - }) + it("returns true for a relation", () => { + expect(hasJoin(Community.forge().posts(), "communities_posts")).to.be.true; + }); - it('returns true for a relation with a manual join', () => { - expect(hasJoin(Community.forge().posts().query(q => { - q.join('posts_users', 'posts_users.post_id', 'posts.id') - }), 'posts_users')).to.be.true - }) -}) + it("returns true for a relation with a manual join", () => { + expect( + hasJoin( + Community.forge() + .posts() + .query((q) => { + q.join("posts_users", "posts_users.post_id", "posts.id"); + }), + "posts_users" + ) + ).to.be.true; + }); +}); diff --git a/lib/util/normalize.js b/lib/util/normalize.js index 4b1b88de0..eb0b98d84 100644 --- a/lib/util/normalize.js +++ b/lib/util/normalize.js @@ -1,59 +1,59 @@ -import { map, uniqBy } from 'lodash/fp' +import { map, uniqBy } from "lodash/fp"; -export const uniqize = buckets => - Object.keys(buckets).forEach(key => { - if (!(buckets[key] instanceof Object)) return - buckets[key] = uniqBy('id', buckets[key]) - }) +export const uniqize = (buckets) => + Object.keys(buckets).forEach((key) => { + if (!(buckets[key] instanceof Object)) return; + buckets[key] = uniqBy("id", buckets[key]); + }); const convertItem = (bucket, obj, attr) => { - if (!obj[attr]) return - bucket.push(obj[attr]) - obj[attr + '_id'] = obj[attr].id - delete obj[attr] -} + if (!obj[attr]) return; + bucket.push(obj[attr]); + obj[attr + "_id"] = obj[attr].id; + delete obj[attr]; +}; -export const pluralize = name => - name.endsWith('y') ? name.replace(/y$/, 'ies') : name + 's' +export const pluralize = (name) => + name.endsWith("y") ? name.replace(/y$/, "ies") : name + "s"; const convertList = (bucket, obj, attr) => { - const plural = pluralize(attr) - if (!obj[plural]) return - bucket.push.apply(bucket, obj[plural]) - obj[attr + '_ids'] = map('id', obj[plural]) - delete obj[plural] -} + const plural = pluralize(attr); + if (!obj[plural]) return; + bucket.push.apply(bucket, obj[plural]); + obj[attr + "_ids"] = map("id", obj[plural]); + delete obj[plural]; +}; export const normalizePost = (post, buckets, final) => { - if (!post) return - const { communities, people } = buckets - convertList(communities, post, 'community') - convertList(people, post, 'voter') - convertList(people, post, 'follower') - convertItem(people, post, 'user') + if (!post) return; + const { communities, people } = buckets; + convertList(communities, post, "community"); + convertList(people, post, "voter"); + convertList(people, post, "follower"); + convertItem(people, post, "user"); if (post.comments) { - post.comments.forEach(c => normalizeComment(c, buckets)) + post.comments.forEach((c) => normalizeComment(c, buckets)); } - if (final) uniqize(buckets) -} + if (final) uniqize(buckets); +}; export const normalizeComment = (comment, buckets, final) => { - const { people } = buckets - convertItem(people, comment, 'user') - convertList(people, comment, 'thank') - if (comment.post) convertItem(people, comment.post, 'user') - if (final) uniqize(buckets) -} + const { people } = buckets; + convertItem(people, comment, "user"); + convertList(people, comment, "thank"); + if (comment.post) convertItem(people, comment.post, "user"); + if (final) uniqize(buckets); +}; export const normalizeMemberships = (memberships, buckets, final) => { - if (!memberships) return - const { communities } = buckets - memberships.forEach(m => convertItem(communities, m, 'community')) - if (final) uniqize(buckets) -} - -export const normalizedSinglePostResponse = post => { - const data = {communities: [], people: []} - normalizePost(post, data, true) - return Object.assign(data, post) -} + if (!memberships) return; + const { communities } = buckets; + memberships.forEach((m) => convertItem(communities, m, "community")); + if (final) uniqize(buckets); +}; + +export const normalizedSinglePostResponse = (post) => { + const data = { communities: [], people: [] }; + normalizePost(post, data, true); + return Object.assign(data, post); +}; diff --git a/lib/util/queryMonitor.js b/lib/util/queryMonitor.js index e27d4411f..2b993ee3c 100644 --- a/lib/util/queryMonitor.js +++ b/lib/util/queryMonitor.js @@ -1,104 +1,110 @@ // Adapted from: // https://spin.atomicobject.com/2017/03/27/timing-queries-knexjs-nodejs/ -import now from 'performance-now' -import util from 'util' -import chalk from 'chalk' -import { pd } from 'pretty-data' +import now from "performance-now"; +import util from "util"; +import chalk from "chalk"; +import { pd } from "pretty-data"; -export default function queryMonitor (knex) { +export default function queryMonitor(knex) { // The map used to store the query times, where the query unique // identifier is the key. - const times = {} + const times = {}; // Used for keeping track of the order queries are executed. - let count = 0 - - knex.on('query', query => { - const uid = query.__knexQueryUid - times[uid] = { - position: count, - query, - startTime: now() - } - count = count + 1 - }) - .on('query-response', (response, query) => { - const uid = query.__knexQueryUid - times[uid].endTime = now() - const position = times[uid].position - - // Print the current query, if I'm able - printIfPossible(uid) - - // Check to see if queries further down the queue can be executed, in case - // they weren't able to be printed when they first responded. - printQueriesAfterGivenPosition(position) - }) - - function printIfPossible (uid) { - const { position } = times[uid] + let count = 0; + + knex + .on("query", (query) => { + const uid = query.__knexQueryUid; + times[uid] = { + position: count, + query, + startTime: now(), + }; + count = count + 1; + }) + .on("query-response", (response, query) => { + const uid = query.__knexQueryUid; + times[uid].endTime = now(); + const position = times[uid].position; + + // Print the current query, if I'm able + printIfPossible(uid); + + // Check to see if queries further down the queue can be executed, in case + // they weren't able to be printed when they first responded. + printQueriesAfterGivenPosition(position); + }); + + function printIfPossible(uid) { + const { position } = times[uid]; // Look for a query with a position one less than the current query - const previousTimeUid = Object.keys(times).find(key => - times[key].position === position - 1) + const previousTimeUid = Object.keys(times).find( + (key) => times[key].position === position - 1 + ); // If we didn't find it, it must have been printed already and we can safely // print ourselves. if (!previousTimeUid) { - printQueryWithTime(uid) + printQueryWithTime(uid); } } - function printQueriesAfterGivenPosition (position) { + function printQueriesAfterGivenPosition(position) { // Look for the next query in the queue - const nextTimeUid = Object.keys(times).find(key => - times[key].position === position + 1) + const nextTimeUid = Object.keys(times).find( + (key) => times[key].position === position + 1 + ); // If we find one and it is marked as finished, we can go ahead and print it if (nextTimeUid && !!times[nextTimeUid].endTime) { - const nextPosition = times[nextTimeUid].position - printQueryWithTime(nextTimeUid) + const nextPosition = times[nextTimeUid].position; + printQueryWithTime(nextTimeUid); // There might be more queries that need to printed, so we should keep // looking... - printQueriesAfterGivenPosition(nextPosition) + printQueriesAfterGivenPosition(nextPosition); } } - function printQueryWithTime (uid) { - const { startTime, endTime, query } = times[uid] - const elapsedTime = endTime - startTime + function printQueryWithTime(uid) { + const { startTime, endTime, query } = times[uid]; + const elapsedTime = endTime - startTime; - sails.log.info(presentTime(elapsedTime) + ' ' + pd.sql(presentQuery(query))) + sails.log.info( + presentTime(elapsedTime) + " " + pd.sql(presentQuery(query)) + ); // After I print out the query, I have no more use for it, so I delete it // from my map so it doesn't grow out of control. - delete times[uid] + delete times[uid]; } } -function presentQuery ({ bindings, sql }) { - const { blue, cyan, green, red, yellow } = chalk - var args = (bindings || []).map(s => { - if (s === null) return blue('null') - if (s === undefined) return red('undefined') - if (typeof (s) === 'object') return blue(JSON.stringify(s)) - return blue(s.toString()) - }) - args.unshift(sql.replace(/\?/g, '%s')) +function presentQuery({ bindings, sql }) { + const { blue, cyan, green, red, yellow } = chalk; + const args = (bindings || []).map((s) => { + if (s === null) return blue("null"); + if (s === undefined) return red("undefined"); + if (typeof s === "object") return blue(JSON.stringify(s)); + return blue(s.toString()); + }); + args.unshift(sql.replace(/\?/g, "%s")); // TODO fix missing limit and boolean values - return util.format.apply(util, args) - .replace(/^(select)/i, cyan('$1')) - .replace(/^(insert)/i, green('$1')) - .replace(/^(update)/i, yellow('$1')) - .replace(/^(delete)/i, red('$1')) + return util.format + .apply(util, args) + .replace(/^(select)/i, cyan("$1")) + .replace(/^(insert)/i, green("$1")) + .replace(/^(update)/i, yellow("$1")) + .replace(/^(delete)/i, red("$1")); } -function presentTime (time) { - let color - if (time < 10) color = 'gray' - else if (time < 40) color = 'yellow' - else color = 'red' - return chalk[color](time.toFixed(3) + 'ms') +function presentTime(time) { + let color; + if (time < 10) color = "gray"; + else if (time < 40) color = "yellow"; + else color = "red"; + return chalk[color](time.toFixed(3) + "ms"); } diff --git a/migrations/20170105161307_drop-old-user-columns.js b/migrations/20170105161307_drop-old-user-columns.js index d5152618a..c6eceaea1 100644 --- a/migrations/20170105161307_drop-old-user-columns.js +++ b/migrations/20170105161307_drop-old-user-columns.js @@ -1,12 +1,9 @@ - -exports.up = function(knex, Promise) { - return knex.schema.table('users', t => { - t.dropColumn('push_follow_preference') - t.dropColumn('push_new_post_preference') - t.dropColumn('send_email_preference') - }) +exports.up = function (knex, Promise) { + return knex.schema.table("users", (t) => { + t.dropColumn("push_follow_preference"); + t.dropColumn("push_new_post_preference"); + t.dropColumn("send_email_preference"); + }); }; -exports.down = function(knex, Promise) { - -}; +exports.down = function (knex, Promise) {}; diff --git a/migrations/20170111224824_add_is_project_request.js b/migrations/20170111224824_add_is_project_request.js index cddd9edae..70e0e9158 100644 --- a/migrations/20170111224824_add_is_project_request.js +++ b/migrations/20170111224824_add_is_project_request.js @@ -1,12 +1,11 @@ - -exports.up = function(knex, Promise) { - return knex.schema.table('posts', table => { - table.boolean('is_project_request').defaultTo(false) - }) +exports.up = function (knex, Promise) { + return knex.schema.table("posts", (table) => { + table.boolean("is_project_request").defaultTo(false); + }); }; -exports.down = function(knex, Promise) { - return knex.schema.table('posts', table => { - table.dropColumn('is_project_request') - }) +exports.down = function (knex, Promise) { + return knex.schema.table("posts", (table) => { + table.dropColumn("is_project_request"); + }); }; diff --git a/migrations/20170125154906_comment-media.js b/migrations/20170125154906_comment-media.js index 7b17dd11d..ec7581c06 100644 --- a/migrations/20170125154906_comment-media.js +++ b/migrations/20170125154906_comment-media.js @@ -1,10 +1,15 @@ - -exports.up = function(knex, Promise) { - return knex.schema.table('media', t => - t.bigInteger('comment_id').references('id').inTable('comments')) - .then(() => knex.raw('alter table media alter constraint media_comment_id_foreign deferrable initially deferred')) +exports.up = function (knex, Promise) { + return knex.schema + .table("media", (t) => + t.bigInteger("comment_id").references("id").inTable("comments") + ) + .then(() => + knex.raw( + "alter table media alter constraint media_comment_id_foreign deferrable initially deferred" + ) + ); }; -exports.down = function(knex, Promise) { - return knex.schema.table('media', t => t.dropColumn('comment_id')) +exports.down = function (knex, Promise) { + return knex.schema.table("media", (t) => t.dropColumn("comment_id")); }; diff --git a/migrations/20170310181527_fix-link-preview-url-column.js b/migrations/20170310181527_fix-link-preview-url-column.js index a448649df..76d9f2c25 100644 --- a/migrations/20170310181527_fix-link-preview-url-column.js +++ b/migrations/20170310181527_fix-link-preview-url-column.js @@ -1,8 +1,9 @@ - -exports.up = function(knex, Promise) { - return knex.raw('alter table link_previews alter column url type text') +exports.up = function (knex, Promise) { + return knex.raw("alter table link_previews alter column url type text"); }; -exports.down = function(knex, Promise) { - return knex.raw('alter table link_previews alter column url type character varying(255)') +exports.down = function (knex, Promise) { + return knex.raw( + "alter table link_previews alter column url type character varying(255)" + ); }; diff --git a/migrations/20170420171600_add_tagline_to_users.js b/migrations/20170420171600_add_tagline_to_users.js index 593bb230c..571d22067 100644 --- a/migrations/20170420171600_add_tagline_to_users.js +++ b/migrations/20170420171600_add_tagline_to_users.js @@ -1,11 +1,11 @@ exports.up = function (knex, Promise) { - return knex.schema.table('users', table => { - table.string('tagline') - }) -} + return knex.schema.table("users", (table) => { + table.string("tagline"); + }); +}; exports.down = function (knex, Promise) { - return knex.schema.table('users', table => { - table.dropColumn('tagline') - }) -} + return knex.schema.table("users", (table) => { + table.dropColumn("tagline"); + }); +}; diff --git a/migrations/20170504151228_more-unread-counts.js b/migrations/20170504151228_more-unread-counts.js index d0cf7aa30..b8bb8f49b 100644 --- a/migrations/20170504151228_more-unread-counts.js +++ b/migrations/20170504151228_more-unread-counts.js @@ -1,11 +1,11 @@ - exports.up = function (knex, Promise) { - return knex.schema.table('communities_users', t => { - t.integer('new_post_count').defaultTo(0) - }) -} + return knex.schema.table("communities_users", (t) => { + t.integer("new_post_count").defaultTo(0); + }); +}; exports.down = function (knex, Promise) { - return knex.schema.table('communities_users', t => - t.dropColumn('new_post_count')) -} + return knex.schema.table("communities_users", (t) => + t.dropColumn("new_post_count") + ); +}; diff --git a/migrations/20170508213407_user-connections.js b/migrations/20170508213407_user-connections.js index 78f2f0648..6d8ade1e5 100644 --- a/migrations/20170508213407_user-connections.js +++ b/migrations/20170508213407_user-connections.js @@ -1,14 +1,14 @@ exports.up = function (knex, Promise) { - return knex.schema.createTable('user_connections', table => { - table.increments().primary() - table.bigInteger('user_id').references('id').inTable('users') - table.bigInteger('other_user_id').references('id').inTable('users') - table.string('type') - table.timestamp('created_at') - table.timestamp('updated_at') - }) -} + return knex.schema.createTable("user_connections", (table) => { + table.increments().primary(); + table.bigInteger("user_id").references("id").inTable("users"); + table.bigInteger("other_user_id").references("id").inTable("users"); + table.string("type"); + table.timestamp("created_at"); + table.timestamp("updated_at"); + }); +}; exports.down = function (knex, Promise) { - return knex.schema.dropTable('user_connections') -} + return knex.schema.dropTable("user_connections"); +}; diff --git a/migrations/20170517082020_all-constraints-deferrable.js b/migrations/20170517082020_all-constraints-deferrable.js index eb17b7e98..67eb9ed94 100644 --- a/migrations/20170517082020_all-constraints-deferrable.js +++ b/migrations/20170517082020_all-constraints-deferrable.js @@ -1,30 +1,43 @@ const constraints = [ - { table: 'activities', constraint: 'activities_contribution_id_foreign' }, - { table: 'activities', constraint: 'activities_parent_comment_id_foreign' }, - { table: 'activities', constraint: 'activity_community_id_foreign' }, - { table: 'comments', constraint: 'comments_comment_id_foreign' }, - { table: 'communities_tags', constraint: 'communities_tags_owner_id_foreign' }, - { table: 'community_invites', constraint: 'community_invite_tag_id_foreign' }, - { table: 'event_responses', constraint: 'event_responses_post_id_foreign' }, - { table: 'event_responses', constraint: 'event_responses_user_id_foreign' }, - { table: 'posts', constraint: 'post_parent_post_id_foreign' }, - { table: 'user_connections', constraint: 'user_connections_other_user_id_foreign' }, - { table: 'user_connections', constraint: 'user_connections_user_id_foreign' }, - { table: 'user_external_data', constraint: 'user_external_data_user_id_foreign' } -] + { table: "activities", constraint: "activities_contribution_id_foreign" }, + { table: "activities", constraint: "activities_parent_comment_id_foreign" }, + { table: "activities", constraint: "activity_community_id_foreign" }, + { table: "comments", constraint: "comments_comment_id_foreign" }, + { + table: "communities_tags", + constraint: "communities_tags_owner_id_foreign", + }, + { table: "community_invites", constraint: "community_invite_tag_id_foreign" }, + { table: "event_responses", constraint: "event_responses_post_id_foreign" }, + { table: "event_responses", constraint: "event_responses_user_id_foreign" }, + { table: "posts", constraint: "post_parent_post_id_foreign" }, + { + table: "user_connections", + constraint: "user_connections_other_user_id_foreign", + }, + { table: "user_connections", constraint: "user_connections_user_id_foreign" }, + { + table: "user_external_data", + constraint: "user_external_data_user_id_foreign", + }, +]; exports.up = function (knex, Promise) { return Promise.all( - constraints.map(c => knex.raw( - `ALTER TABLE ${c.table} ALTER CONSTRAINT ${c.constraint} DEFERRABLE INITIALLY DEFERRED` - )) - ) -} + constraints.map((c) => + knex.raw( + `ALTER TABLE ${c.table} ALTER CONSTRAINT ${c.constraint} DEFERRABLE INITIALLY DEFERRED` + ) + ) + ); +}; exports.down = function (knex, Promise) { return Promise.all( - constraints.map(c => knex.raw( - `ALTER TABLE ${c.table} ALTER CONSTRAINT ${c.constraint} NOT DEFERRABLE` - )) - ) -} + constraints.map((c) => + knex.raw( + `ALTER TABLE ${c.table} ALTER CONSTRAINT ${c.constraint} NOT DEFERRABLE` + ) + ) + ); +}; diff --git a/migrations/20170519141924_tags-id-biginteger.js b/migrations/20170519141924_tags-id-biginteger.js index 9c1068f15..3ec09537e 100644 --- a/migrations/20170519141924_tags-id-biginteger.js +++ b/migrations/20170519141924_tags-id-biginteger.js @@ -1,14 +1,14 @@ -require('babel-register') -const FullTextSearch = require('../api/services/FullTextSearch') +require("babel-register"); +const FullTextSearch = require("../api/services/FullTextSearch"); exports.up = function (knex) { return FullTextSearch.dropView(knex) - .then(() => knex.raw('ALTER TABLE tags ALTER COLUMN id TYPE bigint')) - .then(() => FullTextSearch.createView(null, knex)) -} + .then(() => knex.raw("ALTER TABLE tags ALTER COLUMN id TYPE bigint")) + .then(() => FullTextSearch.createView(null, knex)); +}; exports.down = function (knex) { return FullTextSearch.dropView(knex) - .then(() => knex.raw('ALTER TABLE tags ALTER COLUMN id TYPE integer')) - .then(() => FullTextSearch.createView(null, knex)) -} + .then(() => knex.raw("ALTER TABLE tags ALTER COLUMN id TYPE integer")) + .then(() => FullTextSearch.createView(null, knex)); +}; diff --git a/migrations/20170531150817_int-notification-medium.js b/migrations/20170531150817_int-notification-medium.js index 01e585d64..626e144bd 100644 --- a/migrations/20170531150817_int-notification-medium.js +++ b/migrations/20170531150817_int-notification-medium.js @@ -1,26 +1,47 @@ - exports.up = function (knex, Promise) { - return knex.schema.table('notifications', t => { - t.integer('medium_int') - }) - .then(() => knex.raw("update notifications set medium_int = 0 where medium = 'in-app'")) - .then(() => knex.raw("update notifications set medium_int = 1 where medium = 'push'")) - .then(() => knex.raw("update notifications set medium_int = 2 where medium = 'email'")) - .then(() => knex.schema.table('notifications', t => { - t.dropColumn('medium') - t.renameColumn('medium_int', 'medium') - })) -} + return knex.schema + .table("notifications", (t) => { + t.integer("medium_int"); + }) + .then(() => + knex.raw( + "update notifications set medium_int = 0 where medium = 'in-app'" + ) + ) + .then(() => + knex.raw("update notifications set medium_int = 1 where medium = 'push'") + ) + .then(() => + knex.raw("update notifications set medium_int = 2 where medium = 'email'") + ) + .then(() => + knex.schema.table("notifications", (t) => { + t.dropColumn("medium"); + t.renameColumn("medium_int", "medium"); + }) + ); +}; exports.down = function (knex, Promise) { - return knex.schema.table('notifications', t => { - t.string('medium_str') - }) - .then(() => knex.raw("update notifications set medium_str = 'in-app' where medium = 0")) - .then(() => knex.raw("update notifications set medium_str = 'push' where medium = 1")) - .then(() => knex.raw("update notifications set medium_str = 'email' where medium = 2")) - .then(() => knex.schema.table('notifications', t => { - t.dropColumn('medium') - t.renameColumn('medium_str', 'medium') - })) -} + return knex.schema + .table("notifications", (t) => { + t.string("medium_str"); + }) + .then(() => + knex.raw( + "update notifications set medium_str = 'in-app' where medium = 0" + ) + ) + .then(() => + knex.raw("update notifications set medium_str = 'push' where medium = 1") + ) + .then(() => + knex.raw("update notifications set medium_str = 'email' where medium = 2") + ) + .then(() => + knex.schema.table("notifications", (t) => { + t.dropColumn("medium"); + t.renameColumn("medium_str", "medium"); + }) + ); +}; diff --git a/migrations/20170531152903_notification-user-id.js b/migrations/20170531152903_notification-user-id.js index 90e3fbaf0..52881ffc5 100644 --- a/migrations/20170531152903_notification-user-id.js +++ b/migrations/20170531152903_notification-user-id.js @@ -1,14 +1,16 @@ - exports.up = function (knex, Promise) { - return knex.schema.table('notifications', t => { - t.bigInteger('user_id').references('id').inTable('users') - }) - .then(() => knex.raw(`update notifications set user_id = activities.reader_id - from activities where activities.id = notifications.activity_id`)) -} + return knex.schema + .table("notifications", (t) => { + t.bigInteger("user_id").references("id").inTable("users"); + }) + .then(() => + knex.raw(`update notifications set user_id = activities.reader_id + from activities where activities.id = notifications.activity_id`) + ); +}; exports.down = function (knex, Promise) { - return knex.schema.table('notifications', t => { - t.dropColumn('user_id') - }) -} + return knex.schema.table("notifications", (t) => { + t.dropColumn("user_id"); + }); +}; diff --git a/migrations/20170531154531_notification-medium-0-index.js b/migrations/20170531154531_notification-medium-0-index.js index 6ad69e03c..beea44803 100644 --- a/migrations/20170531154531_notification-medium-0-index.js +++ b/migrations/20170531154531_notification-medium-0-index.js @@ -1,8 +1,9 @@ - exports.up = function (knex, Promise) { - return knex.raw('create index notifications_pk_medium_0 on notifications (id) where medium = 0') -} + return knex.raw( + "create index notifications_pk_medium_0 on notifications (id) where medium = 0" + ); +}; exports.down = function (knex, Promise) { - return knex.raw('drop index notifications_pk_medium_0') -} + return knex.raw("drop index notifications_pk_medium_0"); +}; diff --git a/migrations/20170605161512_rename-followers.js b/migrations/20170605161512_rename-followers.js index b7054f062..7b9f381de 100644 --- a/migrations/20170605161512_rename-followers.js +++ b/migrations/20170605161512_rename-followers.js @@ -1,11 +1,11 @@ exports.up = function (knex, Promise) { - return knex.schema.table('communities_tags', table => { - table.integer('num_followers').defaultTo(0) - }) -} + return knex.schema.table("communities_tags", (table) => { + table.integer("num_followers").defaultTo(0); + }); +}; exports.down = function (knex, Promise) { - return knex.schema.table('communities_tags', table => { - table.dropColumn('num_followers') - }) -} + return knex.schema.table("communities_tags", (table) => { + table.dropColumn("num_followers"); + }); +}; diff --git a/migrations/20170706170943_add-networks_users.js b/migrations/20170706170943_add-networks_users.js index 16003fbce..37d6c8d1f 100644 --- a/migrations/20170706170943_add-networks_users.js +++ b/migrations/20170706170943_add-networks_users.js @@ -1,14 +1,14 @@ exports.up = function (knex, Promise) { - return knex.schema.createTable('networks_users', table => { - table.increments().primary() - table.bigInteger('network_id').references('id').inTable('networks') - table.bigInteger('user_id').references('id').inTable('users') - table.integer('role') - table.timestamp('created_at') - table.timestamp('updated_at') - }) -} + return knex.schema.createTable("networks_users", (table) => { + table.increments().primary(); + table.bigInteger("network_id").references("id").inTable("networks"); + table.bigInteger("user_id").references("id").inTable("users"); + table.integer("role"); + table.timestamp("created_at"); + table.timestamp("updated_at"); + }); +}; exports.down = function (knex, Promise) { - return knex.schema.dropTable('networks_users') -} + return knex.schema.dropTable("networks_users"); +}; diff --git a/migrations/20170706175748_make-networks_users-deferable.js b/migrations/20170706175748_make-networks_users-deferable.js index 65091ac34..3d2ff5752 100644 --- a/migrations/20170706175748_make-networks_users-deferable.js +++ b/migrations/20170706175748_make-networks_users-deferable.js @@ -1,14 +1,21 @@ - exports.up = function (knex, Promise) { return Promise.join( - knex.raw('ALTER TABLE networks_users ALTER CONSTRAINT networks_users_network_id_foreign DEFERRABLE INITIALLY DEFERRED'), - knex.raw('ALTER TABLE networks_users ALTER CONSTRAINT networks_users_user_id_foreign DEFERRABLE INITIALLY DEFERRED') - ) -} + knex.raw( + "ALTER TABLE networks_users ALTER CONSTRAINT networks_users_network_id_foreign DEFERRABLE INITIALLY DEFERRED" + ), + knex.raw( + "ALTER TABLE networks_users ALTER CONSTRAINT networks_users_user_id_foreign DEFERRABLE INITIALLY DEFERRED" + ) + ); +}; exports.down = function (knex, Promise) { return Promise.join( - knex.raw('ALTER TABLE networks_users ALTER CONSTRAINT networks_users_network_id_foreign NOT DEFERRABLE'), - knex.raw('ALTER TABLE networks_users ALTER CONSTRAINT networks_users_user_id_foreign NOT DEFERRABLE') - ) -} + knex.raw( + "ALTER TABLE networks_users ALTER CONSTRAINT networks_users_network_id_foreign NOT DEFERRABLE" + ), + knex.raw( + "ALTER TABLE networks_users ALTER CONSTRAINT networks_users_user_id_foreign NOT DEFERRABLE" + ) + ); +}; diff --git a/migrations/20170713102854_add_network-posts.js b/migrations/20170713102854_add_network-posts.js index 02016d14b..e796400ab 100644 --- a/migrations/20170713102854_add_network-posts.js +++ b/migrations/20170713102854_add_network-posts.js @@ -1,15 +1,22 @@ exports.up = function (knex, Promise) { - return knex.schema.createTable('networks_posts', table => { - table.increments().primary() - table.bigInteger('network_id').references('id').inTable('networks') - table.bigInteger('post_id').references('id').inTable('posts') - }) - .then(() => Promise.join( - knex.raw('alter table networks_posts alter constraint networks_posts_network_id_foreign deferrable initially deferred'), - knex.raw('alter table networks_posts alter constraint networks_posts_post_id_foreign deferrable initially deferred') - )) -} + return knex.schema + .createTable("networks_posts", (table) => { + table.increments().primary(); + table.bigInteger("network_id").references("id").inTable("networks"); + table.bigInteger("post_id").references("id").inTable("posts"); + }) + .then(() => + Promise.join( + knex.raw( + "alter table networks_posts alter constraint networks_posts_network_id_foreign deferrable initially deferred" + ), + knex.raw( + "alter table networks_posts alter constraint networks_posts_post_id_foreign deferrable initially deferred" + ) + ) + ); +}; exports.down = function (knex, Promise) { - return knex.schema.dropTable('networks_posts') -} + return knex.schema.dropTable("networks_posts"); +}; diff --git a/migrations/20170717102600_add-member_count-to-communities.js b/migrations/20170717102600_add-member_count-to-communities.js index 3fc0768bb..95f8baeb1 100644 --- a/migrations/20170717102600_add-member_count-to-communities.js +++ b/migrations/20170717102600_add-member_count-to-communities.js @@ -1,11 +1,11 @@ exports.up = function (knex, Promise) { - return knex.schema.table('communities', table => { - table.integer('num_members').defaultTo(0) - }) -} + return knex.schema.table("communities", (table) => { + table.integer("num_members").defaultTo(0); + }); +}; exports.down = function (knex, Promise) { - return knex.schema.table('communities', table => { - table.dropColumn('num_members') - }) -} + return knex.schema.table("communities", (table) => { + table.dropColumn("num_members"); + }); +}; diff --git a/migrations/20170719150300_add_skills.js b/migrations/20170719150300_add_skills.js index 3d82f1303..e12ccfcef 100644 --- a/migrations/20170719150300_add_skills.js +++ b/migrations/20170719150300_add_skills.js @@ -1,10 +1,10 @@ exports.up = function (knex, Promise) { - return knex.schema.createTable('skills', table => { - table.increments().primary() - table.string('name') - }) -} + return knex.schema.createTable("skills", (table) => { + table.increments().primary(); + table.string("name"); + }); +}; exports.down = function (knex, Promise) { - return knex.schema.dropTable('skills') -} + return knex.schema.dropTable("skills"); +}; diff --git a/migrations/20170719151153_add_skills-users.js b/migrations/20170719151153_add_skills-users.js index 172f232d9..13e1f2154 100644 --- a/migrations/20170719151153_add_skills-users.js +++ b/migrations/20170719151153_add_skills-users.js @@ -1,15 +1,22 @@ exports.up = function (knex, Promise) { - return knex.schema.createTable('skills_users', table => { - table.increments().primary() - table.bigInteger('skill_id').references('id').inTable('skills') - table.bigInteger('user_id').references('id').inTable('users') - }) - .then(() => Promise.join( - knex.raw('alter table skills_users alter constraint skills_users_skill_id_foreign deferrable initially deferred'), - knex.raw('alter table skills_users alter constraint skills_users_user_id_foreign deferrable initially deferred') - )) -} + return knex.schema + .createTable("skills_users", (table) => { + table.increments().primary(); + table.bigInteger("skill_id").references("id").inTable("skills"); + table.bigInteger("user_id").references("id").inTable("users"); + }) + .then(() => + Promise.join( + knex.raw( + "alter table skills_users alter constraint skills_users_skill_id_foreign deferrable initially deferred" + ), + knex.raw( + "alter table skills_users alter constraint skills_users_user_id_foreign deferrable initially deferred" + ) + ) + ); +}; exports.down = function (knex, Promise) { - return knex.schema.dropTable('skills_users') -} + return knex.schema.dropTable("skills_users"); +}; diff --git a/migrations/20170719160630_make-skill-name-unique.js b/migrations/20170719160630_make-skill-name-unique.js index 7ca480e3f..55e764452 100644 --- a/migrations/20170719160630_make-skill-name-unique.js +++ b/migrations/20170719160630_make-skill-name-unique.js @@ -1,11 +1,11 @@ exports.up = function (knex, Promise) { - return knex.schema.alterTable('skills', table => { - table.unique('name') - }) -} + return knex.schema.alterTable("skills", (table) => { + table.unique("name"); + }); +}; exports.down = function (knex, Promise) { - return knex.schema.alterTable('skills', table => { - table.dropUnique('name') - }) -} + return knex.schema.alterTable("skills", (table) => { + table.dropUnique("name"); + }); +}; diff --git a/migrations/20170720113236_make-skills_users-unique.js b/migrations/20170720113236_make-skills_users-unique.js index 4c504a855..c3058fbbb 100644 --- a/migrations/20170720113236_make-skills_users-unique.js +++ b/migrations/20170720113236_make-skills_users-unique.js @@ -1,11 +1,11 @@ exports.up = function (knex, Promise) { - return knex.schema.alterTable('skills_users', table => { - table.unique(['skill_id', 'user_id']) - }) -} + return knex.schema.alterTable("skills_users", (table) => { + table.unique(["skill_id", "user_id"]); + }); +}; exports.down = function (knex, Promise) { - return knex.schema.alterTable('skills_users', table => { - table.dropUnique(['skill_id', 'user_id']) - }) -} + return knex.schema.alterTable("skills_users", (table) => { + table.dropUnique(["skill_id", "user_id"]); + }); +}; diff --git a/migrations/20170822102331_flagged_items.js b/migrations/20170822102331_flagged_items.js index 078206562..d1397db0e 100644 --- a/migrations/20170822102331_flagged_items.js +++ b/migrations/20170822102331_flagged_items.js @@ -1,15 +1,19 @@ exports.up = function (knex, Promise) { - return knex.schema.createTable('flagged_items', table => { - table.increments().primary() - table.bigInteger('user_id').references('id').inTable('users') - table.string('category') - table.text('reason') - table.string('link') - }) - .then(() => - knex.raw('alter table flagged_items alter constraint flagged_items_user_id_foreign deferrable initially deferred')) -} + return knex.schema + .createTable("flagged_items", (table) => { + table.increments().primary(); + table.bigInteger("user_id").references("id").inTable("users"); + table.string("category"); + table.text("reason"); + table.string("link"); + }) + .then(() => + knex.raw( + "alter table flagged_items alter constraint flagged_items_user_id_foreign deferrable initially deferred" + ) + ); +}; exports.down = function (knex, Promise) { - return knex.schema.dropTable('flagged_items') -} + return knex.schema.dropTable("flagged_items"); +}; diff --git a/migrations/20170822113856_expire-invitations.js b/migrations/20170822113856_expire-invitations.js index 34083bc15..b4f1c9be8 100644 --- a/migrations/20170822113856_expire-invitations.js +++ b/migrations/20170822113856_expire-invitations.js @@ -1,14 +1,13 @@ - exports.up = function (knex, Promise) { - return knex.schema.table('community_invites', table => { - table.bigInteger('expired_by_id').references('id').inTable('users') - table.timestamp('expired_at') - }) -} + return knex.schema.table("community_invites", (table) => { + table.bigInteger("expired_by_id").references("id").inTable("users"); + table.timestamp("expired_at"); + }); +}; exports.down = function (knex, Promise) { - return knex.schema.table('community_invites', t => { - t.dropColumn('expired_at') - t.dropColumn('expired_by_id') - }) -} + return knex.schema.table("community_invites", (t) => { + t.dropColumn("expired_at"); + t.dropColumn("expired_by_id"); + }); +}; diff --git a/migrations/20170911145855_media-position.js b/migrations/20170911145855_media-position.js index fefd612db..d2dd1f436 100644 --- a/migrations/20170911145855_media-position.js +++ b/migrations/20170911145855_media-position.js @@ -1,12 +1,11 @@ - exports.up = function (knex, Promise) { - return knex.schema.table('media', t => { - t.integer('position').defaultTo(0) - }) -} + return knex.schema.table("media", (t) => { + t.integer("position").defaultTo(0); + }); +}; exports.down = function (knex, Promise) { - return knex.schema.table('media', t => { - t.dropColumn('position') - }) -} + return knex.schema.table("media", (t) => { + t.dropColumn("position"); + }); +}; diff --git a/migrations/20170921132657_skip-push-notifications.js b/migrations/20170921132657_skip-push-notifications.js index 724bd21b1..2866d85b4 100644 --- a/migrations/20170921132657_skip-push-notifications.js +++ b/migrations/20170921132657_skip-push-notifications.js @@ -1,8 +1,9 @@ - exports.up = function (knex, Promise) { - return knex.schema.table('push_notifications', t => t.boolean('disabled')) -} + return knex.schema.table("push_notifications", (t) => t.boolean("disabled")); +}; exports.down = function (knex, Promise) { - return knex.schema.table('push_notifications', t => t.dropColumn('disabled')) -} + return knex.schema.table("push_notifications", (t) => + t.dropColumn("disabled") + ); +}; diff --git a/migrations/20170921143436_onesignal-support.js b/migrations/20170921143436_onesignal-support.js index b0a833368..e9dadbb71 100644 --- a/migrations/20170921143436_onesignal-support.js +++ b/migrations/20170921143436_onesignal-support.js @@ -1,20 +1,27 @@ - exports.up = function (knex, Promise) { - return knex.schema.raw('alter table devices alter id type bigint') - .then(() => knex.schema.table('devices', t => t.string('player_id'))) - .then(() => knex.schema.table('push_notifications', t => { - t.bigint('device_id').references('id').inTable('devices') - })) - .then(() => knex.raw(` + return knex.schema + .raw("alter table devices alter id type bigint") + .then(() => knex.schema.table("devices", (t) => t.string("player_id"))) + .then(() => + knex.schema.table("push_notifications", (t) => { + t.bigint("device_id").references("id").inTable("devices"); + }) + ) + .then(() => + knex.raw(` update push_notifications set device_id = (select id from devices where token = device_token) - `)) -} + `) + ); +}; exports.down = function (knex, Promise) { - return knex.schema.raw('alter table devices alter id type int') - .then(() => knex.schema.table('devices', t => t.dropColumn('player_id'))) - .then(() => knex.schema.table('push_notifications', t => { - t.dropColumn('device_id') - })) -} + return knex.schema + .raw("alter table devices alter id type int") + .then(() => knex.schema.table("devices", (t) => t.dropColumn("player_id"))) + .then(() => + knex.schema.table("push_notifications", (t) => { + t.dropColumn("device_id"); + }) + ); +}; diff --git a/migrations/20170925152137_remove-device-token.js b/migrations/20170925152137_remove-device-token.js index 146437a9c..a9c88c108 100644 --- a/migrations/20170925152137_remove-device-token.js +++ b/migrations/20170925152137_remove-device-token.js @@ -1,9 +1,7 @@ - exports.up = function (knex, Promise) { - return knex.schema.table('push_notifications', t => - t.dropColumn('device_token')) -} - -exports.down = function (knex, Promise) { + return knex.schema.table("push_notifications", (t) => + t.dropColumn("device_token") + ); +}; -} +exports.down = function (knex, Promise) {}; diff --git a/migrations/20170925165648_test-devices.js b/migrations/20170925165648_test-devices.js index 14910a916..bcc53c937 100644 --- a/migrations/20170925165648_test-devices.js +++ b/migrations/20170925165648_test-devices.js @@ -1,8 +1,7 @@ - exports.up = function (knex, Promise) { - return knex.schema.table('devices', t => t.boolean('tester')) -} + return knex.schema.table("devices", (t) => t.boolean("tester")); +}; exports.down = function (knex, Promise) { - return knex.schema.table('devices', t => t.dropColumn('tester')) -} + return knex.schema.table("devices", (t) => t.dropColumn("tester")); +}; diff --git a/migrations/20171014111135_link-preview-id-bigint.js b/migrations/20171014111135_link-preview-id-bigint.js index 9e93a054b..fadfdb098 100644 --- a/migrations/20171014111135_link-preview-id-bigint.js +++ b/migrations/20171014111135_link-preview-id-bigint.js @@ -1,2 +1,4 @@ -exports.up = knex => knex.raw('ALTER TABLE posts ALTER COLUMN link_preview_id TYPE bigint') -exports.down = knex => knex.raw('ALTER TABLE posts ALTER COLUMN link_preview_id TYPE integer') +exports.up = (knex) => + knex.raw("ALTER TABLE posts ALTER COLUMN link_preview_id TYPE bigint"); +exports.down = (knex) => + knex.raw("ALTER TABLE posts ALTER COLUMN link_preview_id TYPE integer"); diff --git a/migrations/20171116104134_add-pinned_at-to-communities_posts.js b/migrations/20171116104134_add-pinned_at-to-communities_posts.js index 0eeb16162..2f4ac8866 100644 --- a/migrations/20171116104134_add-pinned_at-to-communities_posts.js +++ b/migrations/20171116104134_add-pinned_at-to-communities_posts.js @@ -1,11 +1,11 @@ exports.up = function (knex, Promise) { - return knex.schema.table('communities_posts', table => { - table.timestamp('pinned_at') - }) -} + return knex.schema.table("communities_posts", (table) => { + table.timestamp("pinned_at"); + }); +}; exports.down = function (knex, Promise) { - return knex.schema.table('communities_posts', table => { - table.dropColumn('pinned_at') - }) -} + return knex.schema.table("communities_posts", (table) => { + table.dropColumn("pinned_at"); + }); +}; diff --git a/migrations/20171116145931_drop-pinned-from-communities_users.js b/migrations/20171116145931_drop-pinned-from-communities_users.js index f236878e6..1316c5022 100644 --- a/migrations/20171116145931_drop-pinned-from-communities_users.js +++ b/migrations/20171116145931_drop-pinned-from-communities_users.js @@ -1,11 +1,11 @@ exports.up = function (knex, Promise) { - return knex.schema.table('communities_posts', table => { - table.dropColumn('pinned') - }) -} + return knex.schema.table("communities_posts", (table) => { + table.dropColumn("pinned"); + }); +}; exports.down = function (knex, Promise) { - return knex.schema.table('communities_posts', table => { - table.boolean('pinned').defaultTo(false) - }) -} + return knex.schema.table("communities_posts", (table) => { + table.boolean("pinned").defaultTo(false); + }); +}; diff --git a/migrations/20171117161044_delete_networks_posts_dupes.js b/migrations/20171117161044_delete_networks_posts_dupes.js index 89d98b448..c8d89a3e6 100644 --- a/migrations/20171117161044_delete_networks_posts_dupes.js +++ b/migrations/20171117161044_delete_networks_posts_dupes.js @@ -1,4 +1,5 @@ -exports.up = knex => knex.raw(` +exports.up = (knex) => + knex.raw(` DELETE FROM networks_posts WHERE id IN ( SELECT id @@ -10,6 +11,6 @@ exports.up = knex => knex.raw(` ) t WHERE t.row_num > 1 ) -`) +`); -exports.down = knex => {} +exports.down = (knex) => {}; diff --git a/migrations/20171117161052_make_networks_posts_uniq.js b/migrations/20171117161052_make_networks_posts_uniq.js index a156a4868..9baadc8bc 100644 --- a/migrations/20171117161052_make_networks_posts_uniq.js +++ b/migrations/20171117161052_make_networks_posts_uniq.js @@ -1,5 +1,9 @@ -exports.up = knex => - knex.raw('ALTER TABLE networks_posts ADD CONSTRAINT network_id_post_id_key UNIQUE (network_id, post_id)') +exports.up = (knex) => + knex.raw( + "ALTER TABLE networks_posts ADD CONSTRAINT network_id_post_id_key UNIQUE (network_id, post_id)" + ); -exports.down = knex => - knex.raw('ALTER TABLE networks_posts DROP CONSTRAINT network_id_post_id_key') +exports.down = (knex) => + knex.raw( + "ALTER TABLE networks_posts DROP CONSTRAINT network_id_post_id_key" + ); diff --git a/migrations/20171130172630_groups.js b/migrations/20171130172630_groups.js index eb90a66c9..97276f0cc 100644 --- a/migrations/20171130172630_groups.js +++ b/migrations/20171130172630_groups.js @@ -1,46 +1,80 @@ - -function setDeferrable (knex, table, constraint) { - return knex.raw(`ALTER TABLE ${table} ALTER CONSTRAINT ${constraint} DEFERRABLE INITIALLY DEFERRED`) +function setDeferrable(knex, table, constraint) { + return knex.raw( + `ALTER TABLE ${table} ALTER CONSTRAINT ${constraint} DEFERRABLE INITIALLY DEFERRED` + ); } exports.up = function (knex, Promise) { - return knex.schema.createTable('groups', t => { - t.bigIncrements().primary() - t.integer('group_data_type').notNullable() - t.bigInteger('group_data_id') - t.boolean('active').defaultTo(true) - t.timestamps() - t.unique(['group_data_id', 'group_data_type']) - }) - .then(() => knex.schema.createTable('group_connections', t => { - t.bigIncrements().primary() - t.bigInteger('parent_group_id').references('id').inTable('groups').notNullable() - t.integer('parent_group_data_type').notNullable() - t.bigInteger('child_group_id').references('id').inTable('groups').notNullable() - t.integer('child_group_data_type').notNullable() - t.boolean('active').defaultTo(true) - t.integer('role') - t.jsonb('settings') - t.timestamps() - t.unique(['parent_group_id', 'child_group_id']) - })) - .then(() => setDeferrable(knex, 'group_connections', 'group_connections_child_group_id_foreign')) - .then(() => setDeferrable(knex, 'group_connections', 'group_connections_parent_group_id_foreign')) - .then(() => knex.schema.createTable('group_memberships', t => { - t.bigIncrements().primary() - t.bigInteger('group_id').references('id').inTable('groups').notNullable() - t.bigInteger('user_id').references('id').inTable('users').notNullable() - t.boolean('active').defaultTo(true) - t.integer('role') - t.jsonb('settings') - t.timestamps() - t.unique(['group_id', 'user_id']) - })) - .then(() => setDeferrable(knex, 'group_memberships', 'group_memberships_group_id_foreign')) -} + return knex.schema + .createTable("groups", (t) => { + t.bigIncrements().primary(); + t.integer("group_data_type").notNullable(); + t.bigInteger("group_data_id"); + t.boolean("active").defaultTo(true); + t.timestamps(); + t.unique(["group_data_id", "group_data_type"]); + }) + .then(() => + knex.schema.createTable("group_connections", (t) => { + t.bigIncrements().primary(); + t.bigInteger("parent_group_id") + .references("id") + .inTable("groups") + .notNullable(); + t.integer("parent_group_data_type").notNullable(); + t.bigInteger("child_group_id") + .references("id") + .inTable("groups") + .notNullable(); + t.integer("child_group_data_type").notNullable(); + t.boolean("active").defaultTo(true); + t.integer("role"); + t.jsonb("settings"); + t.timestamps(); + t.unique(["parent_group_id", "child_group_id"]); + }) + ) + .then(() => + setDeferrable( + knex, + "group_connections", + "group_connections_child_group_id_foreign" + ) + ) + .then(() => + setDeferrable( + knex, + "group_connections", + "group_connections_parent_group_id_foreign" + ) + ) + .then(() => + knex.schema.createTable("group_memberships", (t) => { + t.bigIncrements().primary(); + t.bigInteger("group_id") + .references("id") + .inTable("groups") + .notNullable(); + t.bigInteger("user_id").references("id").inTable("users").notNullable(); + t.boolean("active").defaultTo(true); + t.integer("role"); + t.jsonb("settings"); + t.timestamps(); + t.unique(["group_id", "user_id"]); + }) + ) + .then(() => + setDeferrable( + knex, + "group_memberships", + "group_memberships_group_id_foreign" + ) + ); +}; exports.down = function (knex, Promise) { - return knex.schema.dropTable('group_connections') - .then(() => knex.schema.dropTable('group_memberships')) - .then(() => knex.schema.dropTable('groups')) -} + return knex.schema + .dropTable("group_connections") + .then(() => knex.schema.dropTable("group_memberships")) + .then(() => knex.schema.dropTable("groups")); +}; diff --git a/migrations/20171201164352_add_id_and_object_type_to_flagged_content.js b/migrations/20171201164352_add_id_and_object_type_to_flagged_content.js index 131ce235d..18ddbfd9a 100644 --- a/migrations/20171201164352_add_id_and_object_type_to_flagged_content.js +++ b/migrations/20171201164352_add_id_and_object_type_to_flagged_content.js @@ -1,13 +1,13 @@ exports.up = function (knex, Promise) { - return knex.schema.table('flagged_items', table => { - table.bigInteger('object_id') - table.string('object_type') - }) -} + return knex.schema.table("flagged_items", (table) => { + table.bigInteger("object_id"); + table.string("object_type"); + }); +}; exports.down = function (knex, Promise) { - return knex.schema.table('communities_posts', table => { - table.dropColumn('object_id') - table.dropColumn('object_type') - }) -} + return knex.schema.table("communities_posts", (table) => { + table.dropColumn("object_id"); + table.dropColumn("object_type"); + }); +}; diff --git a/migrations/20171206150045_remove-tags-users.js b/migrations/20171206150045_remove-tags-users.js index 33ebd2731..da52a2d7b 100644 --- a/migrations/20171206150045_remove-tags-users.js +++ b/migrations/20171206150045_remove-tags-users.js @@ -1,7 +1,5 @@ - exports.up = function (knex, Promise) { - return knex.schema.dropTable('tags_users') -} + return knex.schema.dropTable("tags_users"); +}; -exports.down = function (knex, Promise) { -} +exports.down = function (knex, Promise) {}; diff --git a/migrations/20171218140833_post-group-memberships.js b/migrations/20171218140833_post-group-memberships.js index 53e0722e9..e366a2c57 100644 --- a/migrations/20171218140833_post-group-memberships.js +++ b/migrations/20171218140833_post-group-memberships.js @@ -1,30 +1,40 @@ /* globals FollowDeprecated, LastReadDeprecated */ -require('babel-register') -const models = require('../api/models') +require("babel-register"); +const models = require("../api/models"); const { - makeGroups, makeGroupMemberships, updateGroupMemberships -} = require('../api/models/group/migration') + makeGroups, + makeGroupMemberships, + updateGroupMemberships, +} = require("../api/models/group/migration"); exports.up = async function (knex, Promise) { - models.init() - console.log('Creating groups. This can take a while...') - console.log('Post:', await makeGroups(Post)) + models.init(); + console.log("Creating groups. This can take a while..."); + console.log("Post:", await makeGroups(Post)); - console.log('Follow:', await makeGroupMemberships({ - model: FollowDeprecated, - parent: 'post', - settings: {following: true}, - copyColumns: {added_at: 'created_at'} - })) + console.log( + "Follow:", + await makeGroupMemberships({ + model: FollowDeprecated, + parent: "post", + settings: { following: true }, + copyColumns: { added_at: "created_at" }, + }) + ); - console.log('LastRead:', await updateGroupMemberships({ - model: LastReadDeprecated, - parent: 'post', - getSettings: row => ({lastReadAt: row.last_read_at}), - selectColumns: ['last_read_at'] - })) -} + console.log( + "LastRead:", + await updateGroupMemberships({ + model: LastReadDeprecated, + parent: "post", + getSettings: (row) => ({ lastReadAt: row.last_read_at }), + selectColumns: ["last_read_at"], + }) + ); +}; exports.down = function (knex, Promise) { - return knex.raw('truncate table groups, group_connections, group_memberships') -} + return knex.raw( + "truncate table groups, group_connections, group_memberships" + ); +}; diff --git a/migrations/20171218190320_group-membership-new-post-count.js b/migrations/20171218190320_group-membership-new-post-count.js index d2a7f680c..7bcf31b40 100644 --- a/migrations/20171218190320_group-membership-new-post-count.js +++ b/migrations/20171218190320_group-membership-new-post-count.js @@ -7,12 +7,13 @@ exports.up = function (knex, Promise) { // when a new post is created, we increment new_post_count for all groups that // it is part of. and we are currently on postgres 9.4, which doesn't have // a good way of doing this for values in JSONB columns. - return knex.schema.table('group_memberships', t => { - t.integer('new_post_count') - }) -} + return knex.schema.table("group_memberships", (t) => { + t.integer("new_post_count"); + }); +}; exports.down = function (knex, Promise) { - return knex.schema.table('group_memberships', t => - t.dropColumn('new_post_count')) -} + return knex.schema.table("group_memberships", (t) => + t.dropColumn("new_post_count") + ); +}; diff --git a/migrations/20171218190321_community-group-memberships.js b/migrations/20171218190321_community-group-memberships.js index 67f17e83d..b77ddd3f2 100644 --- a/migrations/20171218190321_community-group-memberships.js +++ b/migrations/20171218190321_community-group-memberships.js @@ -1,37 +1,49 @@ -require('babel-register') -const models = require('../api/models') -const DataType = require('../api/models/group/DataType').default +require("babel-register"); +const models = require("../api/models"); +const DataType = require("../api/models/group/DataType").default; const { makeGroups, makeGroupMemberships, deactivateMembershipsByGroupDataType, - reconcileNumMembersInCommunities -} = require('../api/models/group/migration') -const { camelCase, mapKeys } = require('lodash') + reconcileNumMembersInCommunities, +} = require("../api/models/group/migration"); +const { camelCase, mapKeys } = require("lodash"); exports.up = async function (knex, Promise) { - models.init() - console.log('Creating groups. This can take a while...') - console.log('Community:', await makeGroups(Community)) + models.init(); + console.log("Creating groups. This can take a while..."); + console.log("Community:", await makeGroups(Community)); - console.log('Membership:', await makeGroupMemberships({ - model: MembershipDeprecated, // eslint-disable-line - parent: 'community', - copyColumns: ['role', 'active', 'created_at', 'new_post_count'], - selectColumns: ['settings', 'last_viewed_at'], - getSettings: row => Object.assign( - mapKeys(row.settings, (v, k) => camelCase(k)), - { - lastReadAt: row.last_viewed_at - } - ) - })) + console.log( + "Membership:", + await makeGroupMemberships({ + model: MembershipDeprecated, // eslint-disable-line + parent: "community", + copyColumns: ["role", "active", "created_at", "new_post_count"], + selectColumns: ["settings", "last_viewed_at"], + getSettings: (row) => + Object.assign( + mapKeys(row.settings, (v, k) => camelCase(k)), + { + lastReadAt: row.last_viewed_at, + } + ), + }) + ); - console.log('Deactivating Memberships:', await deactivateMembershipsByGroupDataType(DataType.COMMUNITY)) - console.log('Reconciling num_members in communities', await reconcileNumMembersInCommunities()) -} + console.log( + "Deactivating Memberships:", + await deactivateMembershipsByGroupDataType(DataType.COMMUNITY) + ); + console.log( + "Reconciling num_members in communities", + await reconcileNumMembersInCommunities() + ); +}; exports.down = async function (knex, Promise) { - await knex.raw('delete from group_memberships where group_id in (select id from groups where group_data_type = 1)') - await knex.raw('delete from groups where group_data_type = 1') -} + await knex.raw( + "delete from group_memberships where group_id in (select id from groups where group_data_type = 1)" + ); + await knex.raw("delete from groups where group_data_type = 1"); +}; diff --git a/migrations/20171219191717_group-membership-data-type.js b/migrations/20171219191717_group-membership-data-type.js index d70d0f587..0b4db18ca 100644 --- a/migrations/20171219191717_group-membership-data-type.js +++ b/migrations/20171219191717_group-membership-data-type.js @@ -1,12 +1,17 @@ - exports.up = async function (knex, Promise) { - await knex.schema.table('group_memberships', t => { - t.integer('group_data_type') - }) - await knex.raw('update group_memberships set group_data_type = (select group_data_type from groups where id = group_id)') - await knex.raw('alter table group_memberships alter column group_data_type set not null') -} + await knex.schema.table("group_memberships", (t) => { + t.integer("group_data_type"); + }); + await knex.raw( + "update group_memberships set group_data_type = (select group_data_type from groups where id = group_id)" + ); + await knex.raw( + "alter table group_memberships alter column group_data_type set not null" + ); +}; exports.down = function (knex, Promise) { - return knex.schema.table('group_memberships', t => t.dropColumn('group_data_type')) -} + return knex.schema.table("group_memberships", (t) => + t.dropColumn("group_data_type") + ); +}; diff --git a/migrations/20180213140321_add_hidden_to_community.js b/migrations/20180213140321_add_hidden_to_community.js index 375692586..bbe91cc65 100644 --- a/migrations/20180213140321_add_hidden_to_community.js +++ b/migrations/20180213140321_add_hidden_to_community.js @@ -1,11 +1,11 @@ exports.up = function (knex, Promise) { - return knex.schema.table('communities', table => { - table.boolean('hidden') - }) -} + return knex.schema.table("communities", (table) => { + table.boolean("hidden"); + }); +}; exports.down = function (knex, Promise) { - return knex.schema.table('communities', table => { - table.dropColumn('hidden') - }) -} + return knex.schema.table("communities", (table) => { + table.dropColumn("hidden"); + }); +}; diff --git a/migrations/20180215114910_community_defaults.js b/migrations/20180215114910_community_defaults.js index e5a5f63bd..a3d305ab3 100644 --- a/migrations/20180215114910_community_defaults.js +++ b/migrations/20180215114910_community_defaults.js @@ -1,10 +1,12 @@ -require('babel-register') +require("babel-register"); exports.up = async function (knex, Promise) { - await knex.raw('UPDATE communities SET banner_url = \'https://d3ngex8q79bk55.cloudfront.net/misc/default_community_banner.jpg\' WHERE banner_url IS NULL') - await knex.raw('UPDATE communities SET avatar_url = \'https://d3ngex8q79bk55.cloudfront.net/misc/default_community_avatar.png\' WHERE avatar_url IS NULL') -} + await knex.raw( + "UPDATE communities SET banner_url = 'https://d3ngex8q79bk55.cloudfront.net/misc/default_community_banner.jpg' WHERE banner_url IS NULL" + ); + await knex.raw( + "UPDATE communities SET avatar_url = 'https://d3ngex8q79bk55.cloudfront.net/misc/default_community_avatar.png' WHERE avatar_url IS NULL" + ); +}; -exports.down = function (knex, Promise) { - -} +exports.down = function (knex, Promise) {}; diff --git a/migrations/20180215142155_make_hidden_not_null.js b/migrations/20180215142155_make_hidden_not_null.js index b75cd67e6..9c6695755 100644 --- a/migrations/20180215142155_make_hidden_not_null.js +++ b/migrations/20180215142155_make_hidden_not_null.js @@ -1,13 +1,17 @@ exports.up = function (knex, Promise) { - return knex.schema.table('communities', table => - table.dropColumn('hidden')) - .then(() => knex.schema.table('communities', table => - table.boolean('hidden').notNullable().defaultTo(false))) -} + return knex.schema + .table("communities", (table) => table.dropColumn("hidden")) + .then(() => + knex.schema.table("communities", (table) => + table.boolean("hidden").notNullable().defaultTo(false) + ) + ); +}; exports.down = function (knex, Promise) { - return knex.schema.table('communities', table => - table.dropColumn('hidden')) - .then(() => knex.schema.table('communities', table => - table.boolean('hidden'))) -} + return knex.schema + .table("communities", (table) => table.dropColumn("hidden")) + .then(() => + knex.schema.table("communities", (table) => table.boolean("hidden")) + ); +}; diff --git a/migrations/20180315140159_add_announcement_to_post.js b/migrations/20180315140159_add_announcement_to_post.js index 6222ef6b2..6262b536d 100644 --- a/migrations/20180315140159_add_announcement_to_post.js +++ b/migrations/20180315140159_add_announcement_to_post.js @@ -1,10 +1,9 @@ - -exports.up = function(knex, Promise) { - return knex.schema.table('posts', t => { - t.boolean('announcement').defaultTo(false) - }) +exports.up = function (knex, Promise) { + return knex.schema.table("posts", (t) => { + t.boolean("announcement").defaultTo(false); + }); }; -exports.down = function(knex, Promise) { - return knex.schema.table('posts', t => t.dropColumn('announcement')) +exports.down = function (knex, Promise) { + return knex.schema.table("posts", (t) => t.dropColumn("announcement")); }; diff --git a/migrations/20180419113145_update-new-user-notification-settings.js b/migrations/20180419113145_update-new-user-notification-settings.js index 1ea748f00..f3211e187 100644 --- a/migrations/20180419113145_update-new-user-notification-settings.js +++ b/migrations/20180419113145_update-new-user-notification-settings.js @@ -1,33 +1,43 @@ -require('babel-register') -const models = require('../api/models') -const DataType = require('../api/models/group/DataType').default +require("babel-register"); +const models = require("../api/models"); +const DataType = require("../api/models/group/DataType").default; exports.up = async function (knex, Promise) { - models.init() - console.log('Updating new users notifications') + models.init(); + console.log("Updating new users notifications"); - const users = await User.where('created_at', '>', '2017-12-31') - .fetchAll() + const users = await User.where("created_at", ">", "2017-12-31").fetchAll(); - return Promise.map(users.models, user => user.addSetting({ - dm_notifications: 'both', - comment_notifications: 'both' - }, true) - .then(() => GroupMembership.where({ - group_data_type: DataType.COMMUNITY, - user_id: user.id - }).fetchAll()) - .then(memberships => Promise.map(memberships.models, - membership => { - return membership.addSetting({ - sendEmail: true, - sendPushNotifications: true - }, true) - } - ))) -} + return Promise.map(users.models, (user) => + user + .addSetting( + { + dm_notifications: "both", + comment_notifications: "both", + }, + true + ) + .then(() => + GroupMembership.where({ + group_data_type: DataType.COMMUNITY, + user_id: user.id, + }).fetchAll() + ) + .then((memberships) => + Promise.map(memberships.models, (membership) => { + return membership.addSetting( + { + sendEmail: true, + sendPushNotifications: true, + }, + true + ); + }) + ) + ); +}; exports.down = function (knex, Promise) { // this is a one way migration - return Promise.resolve() -} + return Promise.resolve(); +}; diff --git a/migrations/20180621105016_add_allow_community_invites_to_community.js b/migrations/20180621105016_add_allow_community_invites_to_community.js index 64de50ff1..df5ee34e0 100644 --- a/migrations/20180621105016_add_allow_community_invites_to_community.js +++ b/migrations/20180621105016_add_allow_community_invites_to_community.js @@ -1,12 +1,11 @@ - -exports.up = function(knex, Promise) { - return knex.schema.table('communities', table => { - table.boolean('allow_community_invites').defaultTo(false) - }) +exports.up = function (knex, Promise) { + return knex.schema.table("communities", (table) => { + table.boolean("allow_community_invites").defaultTo(false); + }); }; -exports.down = function(knex, Promise) { - return knex.schema.table('communities', table => { - table.dropColumn('allow_community_invites') - }) +exports.down = function (knex, Promise) { + return knex.schema.table("communities", (table) => { + table.dropColumn("allow_community_invites"); + }); }; diff --git a/migrations/20180802131435_make-constraint-deferabble.js b/migrations/20180802131435_make-constraint-deferabble.js index 2b4dc2e38..4b2afd11e 100644 --- a/migrations/20180802131435_make-constraint-deferabble.js +++ b/migrations/20180802131435_make-constraint-deferabble.js @@ -1,11 +1,11 @@ exports.up = function (knex, Promise) { return knex.raw( - `ALTER TABLE push_notifications ALTER CONSTRAINT push_notifications_device_id_foreign DEFERRABLE INITIALLY DEFERRED` - ) -} + "ALTER TABLE push_notifications ALTER CONSTRAINT push_notifications_device_id_foreign DEFERRABLE INITIALLY DEFERRED" + ); +}; exports.down = function (knex, Promise) { return knex.raw( - `ALTER TABLE push_notifications ALTER CONSTRAINT push_notifications_device_id_foreign NOT DEFERRABLE` - ) -} + "ALTER TABLE push_notifications ALTER CONSTRAINT push_notifications_device_id_foreign NOT DEFERRABLE" + ); +}; diff --git a/migrations/20180802134328_add_project_role_table.js b/migrations/20180802134328_add_project_role_table.js index a27b0fdfb..748c001e6 100644 --- a/migrations/20180802134328_add_project_role_table.js +++ b/migrations/20180802134328_add_project_role_table.js @@ -1,17 +1,33 @@ exports.up = function (knex, Promise) { - return knex.schema.createTable('project_roles', t => { - t.bigIncrements().primary() - t.string('name') - t.bigInteger('post_id').references('id').inTable('posts') - }) - .then(() => knex.raw('alter table project_roles alter constraint project_roles_post_id_foreign deferrable initially deferred')) - .then(() => knex.schema.table('group_memberships', t => - t.bigInteger('project_role_id').references('id').inTable('project_roles')) - .then(() => knex.raw('alter table group_memberships alter constraint group_memberships_project_role_id_foreign deferrable initially deferred')) - ) -} + return knex.schema + .createTable("project_roles", (t) => { + t.bigIncrements().primary(); + t.string("name"); + t.bigInteger("post_id").references("id").inTable("posts"); + }) + .then(() => + knex.raw( + "alter table project_roles alter constraint project_roles_post_id_foreign deferrable initially deferred" + ) + ) + .then(() => + knex.schema + .table("group_memberships", (t) => + t + .bigInteger("project_role_id") + .references("id") + .inTable("project_roles") + ) + .then(() => + knex.raw( + "alter table group_memberships alter constraint group_memberships_project_role_id_foreign deferrable initially deferred" + ) + ) + ); +}; exports.down = function (knex, Promise) { - return knex.schema.table('group_memberships', t => t.dropColumn('project_role_id')) - .then(() => knex.schema.dropTable('project_roles')) -} + return knex.schema + .table("group_memberships", (t) => t.dropColumn("project_role_id")) + .then(() => knex.schema.dropTable("project_roles")); +}; diff --git a/migrations/20180809144057_add_update_timestamp_function.js b/migrations/20180809144057_add_update_timestamp_function.js index 7e0a0684b..054a33cf2 100644 --- a/migrations/20180809144057_add_update_timestamp_function.js +++ b/migrations/20180809144057_add_update_timestamp_function.js @@ -1,12 +1,9 @@ -const { - createUpdateFunction, - dropUpdateFunction -} = require('../knexfile') +const { createUpdateFunction, dropUpdateFunction } = require("../knexfile"); exports.up = function (knex, Promise) { - return knex.raw(createUpdateFunction()) -} + return knex.raw(createUpdateFunction()); +}; exports.down = function (knex, Promise) { - return knex.raw(dropUpdateFunction()) -} + return knex.raw(dropUpdateFunction()); +}; diff --git a/migrations/20180809144351_add_update_timestamp_trigger_to_group_memberships.js b/migrations/20180809144351_add_update_timestamp_trigger_to_group_memberships.js index 923fffa82..dac8ab8ec 100644 --- a/migrations/20180809144351_add_update_timestamp_trigger_to_group_memberships.js +++ b/migrations/20180809144351_add_update_timestamp_trigger_to_group_memberships.js @@ -1,12 +1,9 @@ -const { - createUpdateTrigger, - dropUpdateTrigger -} = require('../knexfile') +const { createUpdateTrigger, dropUpdateTrigger } = require("../knexfile"); exports.up = function (knex, Promise) { - return knex.raw(createUpdateTrigger('group_memberships')) -} + return knex.raw(createUpdateTrigger("group_memberships")); +}; exports.down = function (knex, Promise) { - return knex.raw(dropUpdateTrigger('group_memberships')) -} + return knex.raw(dropUpdateTrigger("group_memberships")); +}; diff --git a/migrations/20180912123533_add_blocked_users_table.js b/migrations/20180912123533_add_blocked_users_table.js index 6f75fa289..f13977aba 100644 --- a/migrations/20180912123533_add_blocked_users_table.js +++ b/migrations/20180912123533_add_blocked_users_table.js @@ -1,14 +1,13 @@ exports.up = function (knex, Promise) { - return knex.schema.createTable('blocked_users', table => { - table.increments().primary() - table.bigInteger('user_id').references('id').inTable('users') - table.bigInteger('blocked_user_id').references('id').inTable('users') - table.timestamp('created_at') - table.timestamp('updated_at') - }) - } - - exports.down = function (knex, Promise) { - return knex.schema.dropTable('blocked_users') - } - \ No newline at end of file + return knex.schema.createTable("blocked_users", (table) => { + table.increments().primary(); + table.bigInteger("user_id").references("id").inTable("users"); + table.bigInteger("blocked_user_id").references("id").inTable("users"); + table.timestamp("created_at"); + table.timestamp("updated_at"); + }); +}; + +exports.down = function (knex, Promise) { + return knex.schema.dropTable("blocked_users"); +}; diff --git a/migrations/20180912132718_remove-triggers.js b/migrations/20180912132718_remove-triggers.js index b5de72e7f..9685f21cd 100644 --- a/migrations/20180912132718_remove-triggers.js +++ b/migrations/20180912132718_remove-triggers.js @@ -1,16 +1,18 @@ const { - createUpdateFunction, - dropUpdateFunction, - createUpdateTrigger, - dropUpdateTrigger - } = require('../knexfile') + createUpdateFunction, + dropUpdateFunction, + createUpdateTrigger, + dropUpdateTrigger, +} = require("../knexfile"); -exports.up = function(knex, Promise) { - return knex.raw(dropUpdateTrigger('group_memberships')) - .then(knex.raw(dropUpdateFunction())) +exports.up = function (knex, Promise) { + return knex + .raw(dropUpdateTrigger("group_memberships")) + .then(knex.raw(dropUpdateFunction())); }; -exports.down = function(knex, Promise) { - return knex.raw(createUpdateTrigger('group_memberships')) - .then(knex.raw(createUpdateFunction())) +exports.down = function (knex, Promise) { + return knex + .raw(createUpdateTrigger("group_memberships")) + .then(knex.raw(createUpdateFunction())); }; diff --git a/migrations/20180912135406_def-remove-functions.js b/migrations/20180912135406_def-remove-functions.js index ca1829762..ee2624704 100644 --- a/migrations/20180912135406_def-remove-functions.js +++ b/migrations/20180912135406_def-remove-functions.js @@ -1,12 +1,9 @@ -const { - createUpdateFunction, - dropUpdateFunction - } = require('../knexfile') +const { createUpdateFunction, dropUpdateFunction } = require("../knexfile"); -exports.up = function(knex, Promise) { - return knex.raw(dropUpdateFunction()) +exports.up = function (knex, Promise) { + return knex.raw(dropUpdateFunction()); }; -exports.down = function(knex, Promise) { - return knex.raw(createUpdateFunction()) +exports.down = function (knex, Promise) { + return knex.raw(createUpdateFunction()); }; diff --git a/migrations/20181102144015_add-contributions-flag-to-projects.js b/migrations/20181102144015_add-contributions-flag-to-projects.js index fdc416c92..a7c806d4d 100644 --- a/migrations/20181102144015_add-contributions-flag-to-projects.js +++ b/migrations/20181102144015_add-contributions-flag-to-projects.js @@ -1,12 +1,11 @@ - -exports.up = function(knex, Promise) { - return knex.schema.table('posts', table => { - table.boolean('accept_contributions').defaultTo(false) - }) +exports.up = function (knex, Promise) { + return knex.schema.table("posts", (table) => { + table.boolean("accept_contributions").defaultTo(false); + }); }; -exports.down = function(knex, Promise) { - return knex.schema.table('posts', table => { - table.dropColumn('accept_contributions') - }) +exports.down = function (knex, Promise) { + return knex.schema.table("posts", (table) => { + table.dropColumn("accept_contributions"); + }); }; diff --git a/migrations/20181102150142_add-stripe-account.js b/migrations/20181102150142_add-stripe-account.js index f7dddd727..b73096243 100644 --- a/migrations/20181102150142_add-stripe-account.js +++ b/migrations/20181102150142_add-stripe-account.js @@ -1,10 +1,10 @@ exports.up = function (knex, Promise) { - return knex.schema.createTable('stripe_accounts', t => { - t.bigIncrements().primary() - t.string('stripe_account_external_id') - }) -} + return knex.schema.createTable("stripe_accounts", (t) => { + t.bigIncrements().primary(); + t.string("stripe_account_external_id"); + }); +}; exports.down = function (knex, Promise) { - return knex.schema.dropTable('stripe_accounts') -} \ No newline at end of file + return knex.schema.dropTable("stripe_accounts"); +}; diff --git a/migrations/20181102150810_add-stripe-account-foreign-key.js b/migrations/20181102150810_add-stripe-account-foreign-key.js index 6562793e6..ddc736904 100644 --- a/migrations/20181102150810_add-stripe-account-foreign-key.js +++ b/migrations/20181102150810_add-stripe-account-foreign-key.js @@ -1,12 +1,14 @@ - -exports.up = function(knex, Promise) { - return knex.schema.table('users', table => { - table.bigInteger('stripe_account_id').references('id').inTable('stripe_accounts') - }) +exports.up = function (knex, Promise) { + return knex.schema.table("users", (table) => { + table + .bigInteger("stripe_account_id") + .references("id") + .inTable("stripe_accounts"); + }); }; -exports.down = function(knex, Promise) { - return knex.schema.table('users', table => { - table.dropColumn('stripe_account_id') - }) +exports.down = function (knex, Promise) { + return knex.schema.table("users", (table) => { + table.dropColumn("stripe_account_id"); + }); }; diff --git a/migrations/20181107142901_add-refresh-token-to-stripe-accounts.js b/migrations/20181107142901_add-refresh-token-to-stripe-accounts.js index cbccece63..8c5e47b81 100644 --- a/migrations/20181107142901_add-refresh-token-to-stripe-accounts.js +++ b/migrations/20181107142901_add-refresh-token-to-stripe-accounts.js @@ -1,12 +1,11 @@ - -exports.up = function(knex, Promise) { - return knex.schema.table('stripe_accounts', table => { - table.string('refresh_token') - }) +exports.up = function (knex, Promise) { + return knex.schema.table("stripe_accounts", (table) => { + table.string("refresh_token"); + }); }; -exports.down = function(knex, Promise) { - return knex.schema.table('stripe_accounts', table => { - table.dropColumn('refresh_token') - }) +exports.down = function (knex, Promise) { + return knex.schema.table("stripe_accounts", (table) => { + table.dropColumn("refresh_token"); + }); }; diff --git a/migrations/20181108152123_add-donations-to-posts.js b/migrations/20181108152123_add-donations-to-posts.js index 165c2fa10..46c553534 100644 --- a/migrations/20181108152123_add-donations-to-posts.js +++ b/migrations/20181108152123_add-donations-to-posts.js @@ -1,12 +1,11 @@ - -exports.up = function(knex, Promise) { - return knex.schema.table('posts', table => { - table.integer('total_contributions').defaultTo(0) - }) +exports.up = function (knex, Promise) { + return knex.schema.table("posts", (table) => { + table.integer("total_contributions").defaultTo(0); + }); }; -exports.down = function(knex, Promise) { - return knex.schema.table('posts', table => { - table.dropColumn('total_contributions') - }) +exports.down = function (knex, Promise) { + return knex.schema.table("posts", (table) => { + table.dropColumn("total_contributions"); + }); }; diff --git a/migrations/20181206142122_project-contributions.js b/migrations/20181206142122_project-contributions.js index ca1962d42..d9e3ff0fe 100644 --- a/migrations/20181206142122_project-contributions.js +++ b/migrations/20181206142122_project-contributions.js @@ -1,13 +1,13 @@ exports.up = function (knex, Promise) { - return knex.schema.createTable('project_contributions', table => { - table.increments().primary() - table.bigInteger('user_id').references('id').inTable('users') - table.bigInteger('post_id').references('id').inTable('posts') - table.integer('amount') - table.timestamps() - }) -} + return knex.schema.createTable("project_contributions", (table) => { + table.increments().primary(); + table.bigInteger("user_id").references("id").inTable("users"); + table.bigInteger("post_id").references("id").inTable("posts"); + table.integer("amount"); + table.timestamps(); + }); +}; exports.down = function (knex, Promise) { - return knex.schema.dropTable('project_contributions') -} + return knex.schema.dropTable("project_contributions"); +}; diff --git a/migrations/20181212122500_add-project-contribution-to-activity.js b/migrations/20181212122500_add-project-contribution-to-activity.js index 599d5a67e..897e75823 100644 --- a/migrations/20181212122500_add-project-contribution-to-activity.js +++ b/migrations/20181212122500_add-project-contribution-to-activity.js @@ -1,12 +1,14 @@ - -exports.up = function(knex, Promise) { - return knex.schema.table('activities', table => { - table.bigInteger('project_contribution_id').references('id').inTable('project_contributions') - }) +exports.up = function (knex, Promise) { + return knex.schema.table("activities", (table) => { + table + .bigInteger("project_contribution_id") + .references("id") + .inTable("project_contributions"); + }); }; -exports.down = function(knex, Promise) { - return knex.schema.table('activities', table => { - table.dropColumn('project_contribution_id') - }) +exports.down = function (knex, Promise) { + return knex.schema.table("activities", (table) => { + table.dropColumn("project_contribution_id"); + }); }; diff --git a/migrations/20181212165110_remove-totalContributions-column.js b/migrations/20181212165110_remove-totalContributions-column.js index 5e2c68878..76a5fd1fb 100644 --- a/migrations/20181212165110_remove-totalContributions-column.js +++ b/migrations/20181212165110_remove-totalContributions-column.js @@ -1,12 +1,11 @@ - -exports.up = function(knex, Promise) { - return knex.schema.table('posts', table => { - table.dropColumn('total_contributions') - }) +exports.up = function (knex, Promise) { + return knex.schema.table("posts", (table) => { + table.dropColumn("total_contributions"); + }); }; -exports.down = function(knex, Promise) { - return knex.schema.table('posts', table => { - table.integer('total_contributions').defaultTo(0) - }) +exports.down = function (knex, Promise) { + return knex.schema.table("posts", (table) => { + table.integer("total_contributions").defaultTo(0); + }); }; diff --git a/migrations/20190115135820_add-deferrable.js b/migrations/20190115135820_add-deferrable.js index 1abda19c4..59d8b61fe 100644 --- a/migrations/20190115135820_add-deferrable.js +++ b/migrations/20190115135820_add-deferrable.js @@ -1,10 +1,18 @@ - -exports.up = function(knex, Promise) { - return knex.raw('alter table activities alter constraint activities_project_contribution_id_foreign deferrable initially deferred') - .then(() => knex.raw('alter table project_contributions alter constraint project_contributions_post_id_foreign deferrable initially deferred')) - .then(() => knex.raw('alter table project_contributions alter constraint project_contributions_user_id_foreign deferrable initially deferred')) +exports.up = function (knex, Promise) { + return knex + .raw( + "alter table activities alter constraint activities_project_contribution_id_foreign deferrable initially deferred" + ) + .then(() => + knex.raw( + "alter table project_contributions alter constraint project_contributions_post_id_foreign deferrable initially deferred" + ) + ) + .then(() => + knex.raw( + "alter table project_contributions alter constraint project_contributions_user_id_foreign deferrable initially deferred" + ) + ); }; -exports.down = function(knex, Promise) { - -}; +exports.down = function (knex, Promise) {}; diff --git a/migrations/20190211150423_add-event-initations.js b/migrations/20190211150423_add-event-initations.js index bcc997d1d..dae4ed043 100644 --- a/migrations/20190211150423_add-event-initations.js +++ b/migrations/20190211150423_add-event-initations.js @@ -1,16 +1,15 @@ +exports.up = function (knex, Promise) { + return knex.schema.createTable("event_invitations", (table) => { + table.increments().primary(); + table.bigInteger("user_id").references("id").inTable("users"); + table.bigInteger("inviter_id").references("id").inTable("users"); + table.bigInteger("event_id").references("id").inTable("posts"); + table.string("response"); + table.timestamp("created_at"); + table.timestamp("updated_at"); + }); +}; -exports.up = function(knex, Promise) { - return knex.schema.createTable('event_invitations', table => { - table.increments().primary() - table.bigInteger('user_id').references('id').inTable('users') - table.bigInteger('inviter_id').references('id').inTable('users') - table.bigInteger('event_id').references('id').inTable('posts') - table.string('response') - table.timestamp('created_at') - table.timestamp('updated_at') - }) -} - -exports.down = function(knex, Promise) { - return knex.schema.dropTable('event_invitations') -} +exports.down = function (knex, Promise) { + return knex.schema.dropTable("event_invitations"); +}; diff --git a/migrations/20190215160543_add-start-and-end-time-to-post.js b/migrations/20190215160543_add-start-and-end-time-to-post.js index 5e4111f3a..422fe14e4 100644 --- a/migrations/20190215160543_add-start-and-end-time-to-post.js +++ b/migrations/20190215160543_add-start-and-end-time-to-post.js @@ -1,14 +1,13 @@ - -exports.up = function(knex, Promise) { - return knex.schema.table('posts', table => { - table.timestamp('start_time') - table.timestamp('end_time') - }) +exports.up = function (knex, Promise) { + return knex.schema.table("posts", (table) => { + table.timestamp("start_time"); + table.timestamp("end_time"); + }); }; -exports.down = function(knex, Promise) { - return knex.schema.table('posts', table => { - table.dropColumn('start_time') - table.dropColumn('end_time') - }) +exports.down = function (knex, Promise) { + return knex.schema.table("posts", (table) => { + table.dropColumn("start_time"); + table.dropColumn("end_time"); + }); }; diff --git a/migrations/20200503150423_add-locations-table.js b/migrations/20200503150423_add-locations-table.js index 24a3ee4d7..06773939a 100644 --- a/migrations/20200503150423_add-locations-table.js +++ b/migrations/20200503150423_add-locations-table.js @@ -1,30 +1,29 @@ +exports.up = function (knex, Promise) { + return knex.schema.createTable("locations", (table) => { + table.increments().primary(); -exports.up = function(knex, Promise) { - return knex.schema.createTable('locations', table => { - table.increments().primary() + table.specificType("center", "geometry(point, 4326)"); + table.specificType("bbox", "geometry(polygon, 4326)"); + table.specificType("geometry", "geometry(polygon, 4326)"); - table.specificType('center', 'geometry(point, 4326)'); - table.specificType('bbox', 'geometry(polygon, 4326)'); - table.specificType('geometry', 'geometry(polygon, 4326)'); + table.string("full_text"); + table.string("address_number"); + table.string("address_street"); + table.string("city"); + table.string("locality"); + table.string("region"); + table.string("neighborhood"); + table.string("postcode"); + table.string("country"); - table.string('full_text') - table.string('address_number') - table.string('address_street') - table.string('city') - table.string('locality') - table.string('region') - table.string('neighborhood') - table.string('postcode') - table.string('country') + table.string("accuracy"); + table.string("wikidata"); - table.string('accuracy') - table.string('wikidata') + table.timestamp("created_at"); + table.timestamp("updated_at"); + }); +}; - table.timestamp('created_at') - table.timestamp('updated_at') - }) -} - -exports.down = function(knex, Promise) { - return knex.schema.dropTable('locations') -} +exports.down = function (knex, Promise) { + return knex.schema.dropTable("locations"); +}; diff --git a/migrations/20200504144015_add-locations-to-tables.js b/migrations/20200504144015_add-locations-to-tables.js index 4a3ea467d..cbe2930a0 100644 --- a/migrations/20200504144015_add-locations-to-tables.js +++ b/migrations/20200504144015_add-locations-to-tables.js @@ -1,26 +1,39 @@ - -exports.up = function(knex, Promise) { - return knex.schema.table('users', table => { - table.bigInteger('location_id').references('id').inTable('locations') - table.renameColumn('location', 'location_text') - }).then(() => knex.schema.table('posts', table => { - table.bigInteger('location_id').references('id').inTable('locations') - table.renameColumn('location', 'location_text') - })).then(() => knex.schema.table('communities', table => { - table.bigInteger('location_id').references('id').inTable('locations') - table.renameColumn('location', 'location_text') - })) +exports.up = function (knex, Promise) { + return knex.schema + .table("users", (table) => { + table.bigInteger("location_id").references("id").inTable("locations"); + table.renameColumn("location", "location_text"); + }) + .then(() => + knex.schema.table("posts", (table) => { + table.bigInteger("location_id").references("id").inTable("locations"); + table.renameColumn("location", "location_text"); + }) + ) + .then(() => + knex.schema.table("communities", (table) => { + table.bigInteger("location_id").references("id").inTable("locations"); + table.renameColumn("location", "location_text"); + }) + ); }; -exports.down = function(knex, Promise) { - return knex.schema.table('users', table => { - table.dropColumn('location_id') - table.renameColumn('location_text', 'location') - }).then(() => knex.schema.table('posts', table => { - table.dropColumn('location_id') - table.renameColumn('location_text', 'location') - })).then(() => knex.schema.table('communities', table => { - table.dropColumn('location_id') - table.renameColumn('location_text', 'location') - })) +exports.down = function (knex, Promise) { + return knex.schema + .table("users", (table) => { + table.dropColumn("location_id"); + table.renameColumn("location_text", "location"); + }) + .then(() => + knex.schema.table("posts", (table) => { + table.dropColumn("location_id"); + table.renameColumn("location_text", "location"); + }) + ) + .then(() => + knex.schema.table("communities", (table) => { + table.dropColumn("location_id"); + table.renameColumn("location_text", "location"); + }) + ); }; diff --git a/migrations/20200513172754_add-public-posts.js b/migrations/20200513172754_add-public-posts.js index 042474993..a5d8e9709 100644 --- a/migrations/20200513172754_add-public-posts.js +++ b/migrations/20200513172754_add-public-posts.js @@ -1,12 +1,11 @@ - -exports.up = function(knex, Promise) { - return knex.schema.table('posts', table => { - table.boolean('is_public').defaultTo(false) - }) +exports.up = function (knex, Promise) { + return knex.schema.table("posts", (table) => { + table.boolean("is_public").defaultTo(false); + }); }; -exports.down = function(knex, Promise) { - return knex.schema.table('posts', table => { - table.dropColumn('is_public') - }) +exports.down = function (knex, Promise) { + return knex.schema.table("posts", (table) => { + table.dropColumn("is_public"); + }); }; diff --git a/migrations/20200522144015_add-location-indexes.js b/migrations/20200522144015_add-location-indexes.js index af3b6ff4c..bb7555c60 100644 --- a/migrations/20200522144015_add-location-indexes.js +++ b/migrations/20200522144015_add-location-indexes.js @@ -1,12 +1,9 @@ - -exports.up = function(knex, Promise) { +exports.up = function (knex, Promise) { return knex.raw( - `CREATE INDEX location_center_idx ON locations USING GIST (center);` - ) + "CREATE INDEX location_center_idx ON locations USING GIST (center);" + ); }; -exports.down = function(knex, Promise) { - return knex.raw( - `DROP INDEX location_center_idx;` - ) +exports.down = function (knex, Promise) { + return knex.raw("DROP INDEX location_center_idx;"); }; diff --git a/migrations/20200528144015_rename-locations-text-for-mobile.js b/migrations/20200528144015_rename-locations-text-for-mobile.js index ce0ad9b6b..e2a864452 100644 --- a/migrations/20200528144015_rename-locations-text-for-mobile.js +++ b/migrations/20200528144015_rename-locations-text-for-mobile.js @@ -1,20 +1,33 @@ - -exports.up = function(knex, Promise) { - return knex.schema.table('users', table => { - table.renameColumn('location_text', 'location') - }).then(() => knex.schema.table('posts', table => { - table.renameColumn('location_text', 'location') - })).then(() => knex.schema.table('communities', table => { - table.renameColumn('location_text', 'location') - })) +exports.up = function (knex, Promise) { + return knex.schema + .table("users", (table) => { + table.renameColumn("location_text", "location"); + }) + .then(() => + knex.schema.table("posts", (table) => { + table.renameColumn("location_text", "location"); + }) + ) + .then(() => + knex.schema.table("communities", (table) => { + table.renameColumn("location_text", "location"); + }) + ); }; -exports.down = function(knex, Promise) { - return knex.schema.table('users', table => { - table.renameColumn('location', 'location_text') - }).then(() => knex.schema.table('posts', table => { - table.renameColumn('location', 'location_text') - })).then(() => knex.schema.table('communities', table => { - table.renameColumn('location', 'location_text') - })) +exports.down = function (knex, Promise) { + return knex.schema + .table("users", (table) => { + table.renameColumn("location", "location_text"); + }) + .then(() => + knex.schema.table("posts", (table) => { + table.renameColumn("location", "location_text"); + }) + ) + .then(() => + knex.schema.table("communities", (table) => { + table.renameColumn("location", "location_text"); + }) + ); }; diff --git a/migrations/20200615111235_add-public-posts-index.js b/migrations/20200615111235_add-public-posts-index.js index f5cd0fb4a..ef3f910ec 100644 --- a/migrations/20200615111235_add-public-posts-index.js +++ b/migrations/20200615111235_add-public-posts-index.js @@ -1,12 +1,9 @@ - -exports.up = function(knex, Promise) { +exports.up = function (knex, Promise) { return knex.raw( - `CREATE INDEX public_posts_idx ON posts USING btree (is_public);` - ) + "CREATE INDEX public_posts_idx ON posts USING btree (is_public);" + ); }; -exports.down = function(knex, Promise) { - return knex.raw( - `DROP INDEX public_posts_idx;` - ) +exports.down = function (knex, Promise) { + return knex.raw("DROP INDEX public_posts_idx;"); }; diff --git a/migrations/20200617090839_add_community_privacy_settings.js b/migrations/20200617090839_add_community_privacy_settings.js index 22b00d631..6437aa1f3 100644 --- a/migrations/20200617090839_add_community_privacy_settings.js +++ b/migrations/20200617090839_add_community_privacy_settings.js @@ -1,20 +1,39 @@ - -exports.up = function(knex, Promise) { - return knex.schema.table('communities', table => { - table.boolean('is_public').defaultTo(false) - }).then(() => knex.schema.table('communities', table => { - table.boolean('is_auto_joinable').defaultTo(false) - })).then(() => knex.schema.table('communities', table => { - table.boolean('public_member_directory').defaultTo(false) - })).then(() => knex.raw(`CREATE INDEX public_communities_idx ON communities USING btree (is_public);`)) +exports.up = function (knex, Promise) { + return knex.schema + .table("communities", (table) => { + table.boolean("is_public").defaultTo(false); + }) + .then(() => + knex.schema.table("communities", (table) => { + table.boolean("is_auto_joinable").defaultTo(false); + }) + ) + .then(() => + knex.schema.table("communities", (table) => { + table.boolean("public_member_directory").defaultTo(false); + }) + ) + .then(() => + knex.raw( + "CREATE INDEX public_communities_idx ON communities USING btree (is_public);" + ) + ); }; -exports.down = function(knex, Promise) { - return knex.schema.table('communities', table => { - table.dropColumn('is_public') - }).then(() => knex.schema.table('communities', table => { - table.dropColumn('is_auto_joinable') - })).then(() => knex.schema.table('communities', table => { - table.dropColumn('public_member_directory') - })).then(() => knex.raw(`DROP INDEX public_communities_idx;`)) +exports.down = function (knex, Promise) { + return knex.schema + .table("communities", (table) => { + table.dropColumn("is_public"); + }) + .then(() => + knex.schema.table("communities", (table) => { + table.dropColumn("is_auto_joinable"); + }) + ) + .then(() => + knex.schema.table("communities", (table) => { + table.dropColumn("public_member_directory"); + }) + ) + .then(() => knex.raw("DROP INDEX public_communities_idx;")); }; diff --git a/migrations/20200618160543_add-visibility-to-communities-tag.js b/migrations/20200618160543_add-visibility-to-communities-tag.js index f8a70266e..c890c98e5 100644 --- a/migrations/20200618160543_add-visibility-to-communities-tag.js +++ b/migrations/20200618160543_add-visibility-to-communities-tag.js @@ -1,14 +1,17 @@ - -exports.up = function(knex, Promise) { - return knex.schema.table('communities_tags', table => { - table.integer('visibility').defaultTo(1) - }).then(() => knex.schema.table('communities_tags', table => { - table.index(['community_id', 'visibility']) - })) +exports.up = function (knex, Promise) { + return knex.schema + .table("communities_tags", (table) => { + table.integer("visibility").defaultTo(1); + }) + .then(() => + knex.schema.table("communities_tags", (table) => { + table.index(["community_id", "visibility"]); + }) + ); }; -exports.down = function(knex, Promise) { - return knex.schema.table('communities_tags', table => { - table.dropColumn('visibility') - }) -}; \ No newline at end of file +exports.down = function (knex, Promise) { + return knex.schema.table("communities_tags", (table) => { + table.dropColumn("visibility"); + }); +}; diff --git a/migrations/20200707214505_add_join_requests_status.js b/migrations/20200707214505_add_join_requests_status.js index 67f591410..776f8bf21 100644 --- a/migrations/20200707214505_add_join_requests_status.js +++ b/migrations/20200707214505_add_join_requests_status.js @@ -1,21 +1,21 @@ +exports.up = async function (knex, Promise) { + await knex.schema.table("join_requests", (table) => { + table.integer("status"); + table.index(["community_id", "status"]); + }); -exports.up = async function(knex, Promise) { - await knex.schema.table('join_requests', table => { - table.integer('status'); - table.index(['community_id', 'status']) - }) - - const now = new Date().toISOString() - await knex.raw(`UPDATE "join_requests" set status = 2, updated_at = '${now}'`) + const now = new Date().toISOString(); + await knex.raw( + `UPDATE "join_requests" set status = 2, updated_at = '${now}'` + ); }; - -exports.down = async function(knex, Promise) { - await knex.schema.table('join_requests', table => { - table.dropIndex(['community_id', 'status']) - table.dropColumn('status') - }) - const now = new Date().toISOString() - await knex.raw(`UPDATE "join_requests" set updated_at = '${now}'`) +exports.down = async function (knex, Promise) { + await knex.schema.table("join_requests", (table) => { + table.dropIndex(["community_id", "status"]); + table.dropColumn("status"); + }); + + const now = new Date().toISOString(); + await knex.raw(`UPDATE "join_requests" set updated_at = '${now}'`); }; - \ No newline at end of file diff --git a/migrations/20201006094045_add_contact_email_and_phone.js b/migrations/20201006094045_add_contact_email_and_phone.js index 69facb0e1..1d35ae82d 100644 --- a/migrations/20201006094045_add_contact_email_and_phone.js +++ b/migrations/20201006094045_add_contact_email_and_phone.js @@ -1,13 +1,13 @@ -exports.up = function(knex, Promise) { - return knex.schema.table('users', t => { - t.string('contact_email') - t.string('contact_phone') - }) -} +exports.up = function (knex, Promise) { + return knex.schema.table("users", (t) => { + t.string("contact_email"); + t.string("contact_phone"); + }); +}; -exports.down = function(knex, Promise) { - return knex.schema.table('users', t => { - t.dropColumn('contact_email') - t.dropColumn('contact_phone') - }) -} +exports.down = function (knex, Promise) { + return knex.schema.table("users", (t) => { + t.dropColumn("contact_email"); + t.dropColumn("contact_phone"); + }); +}; diff --git a/migrations/20201012153217_add-saved-searches-table.js b/migrations/20201012153217_add-saved-searches-table.js index 66284e30f..1e1c425be 100644 --- a/migrations/20201012153217_add-saved-searches-table.js +++ b/migrations/20201012153217_add-saved-searches-table.js @@ -1,23 +1,31 @@ +exports.up = function (knex, Promise) { + return knex.schema.createTable("saved_searches", (table) => { + table.increments().primary(); -exports.up = function(knex, Promise) { - return knex.schema.createTable('saved_searches', table => { - table.increments().primary() + table + .bigInteger("user_id") + .references("id") + .inTable("users") + .index() + .notNullable(); + table.string("name"); + table.string("context").notNullable(); + table + .bigInteger("context_id") + .comment( + 'If context is "community" or "network", this represents the community or network id' + ); + table.boolean("is_active").defaultTo(true); + table.string("search_text"); + table.specificType("post_types", "character varying(255)[]"); + table.specificType("bounding_box", "geometry(polygon, 4326)"); + table.bigInteger("last_post_id").references("id").inTable("posts"); - table.bigInteger('user_id').references('id').inTable('users').index().notNullable() - table.string('name') - table.string('context').notNullable() - table.bigInteger('context_id').comment('If context is \"community\" or \"network\", this represents the community or network id') - table.boolean('is_active').defaultTo(true) - table.string('search_text') - table.specificType('post_types', 'character varying(255)[]') - table.specificType('bounding_box', 'geometry(polygon, 4326)') - table.bigInteger('last_post_id').references('id').inTable('posts') + table.timestamp("created_at"); + table.timestamp("updated_at"); + }); +}; - table.timestamp('created_at') - table.timestamp('updated_at') - }) -} - -exports.down = function(knex, Promise) { - return knex.schema.dropTable('saved_searches') -} +exports.down = function (knex, Promise) { + return knex.schema.dropTable("saved_searches"); +}; diff --git a/migrations/20201012160014_add-saved-search-topics-table.js b/migrations/20201012160014_add-saved-search-topics-table.js index 213288d9a..762ef33b7 100644 --- a/migrations/20201012160014_add-saved-search-topics-table.js +++ b/migrations/20201012160014_add-saved-search-topics-table.js @@ -1,16 +1,20 @@ +exports.up = function (knex, Promise) { + return knex.schema.createTable("saved_search_topics", (table) => { + table.increments().primary(); -exports.up = function(knex, Promise) { - return knex.schema.createTable('saved_search_topics', table => { - table.increments().primary() + table.bigInteger("tag_id").references("id").inTable("tags").notNullable(); + table + .bigInteger("saved_search_id") + .references("id") + .inTable("saved_searches") + .index() + .notNullable(); - table.bigInteger('tag_id').references('id').inTable('tags').notNullable() - table.bigInteger('saved_search_id').references('id').inTable('saved_searches').index().notNullable() + table.timestamp("created_at"); + table.timestamp("updated_at"); + }); +}; - table.timestamp('created_at') - table.timestamp('updated_at') - }) -} - -exports.down = function(knex, Promise) { - return knex.schema.dropTable('saved_search_topics') -} +exports.down = function (knex, Promise) { + return knex.schema.dropTable("saved_search_topics"); +}; diff --git a/migrations/docker-compose.yml b/migrations/docker-compose.yml index b65fbf0a8..99e850274 100644 --- a/migrations/docker-compose.yml +++ b/migrations/docker-compose.yml @@ -2,10 +2,10 @@ postgres: container_name: hylopg image: postgres:9.4 ports: - - '5300:5432' + - "5300:5432" environment: - POSTGRES_USER: 'hylo' - POSTGRES_PASSWORD: 'hylo' - POSTGRES_DB: 'hylo' + POSTGRES_USER: "hylo" + POSTGRES_PASSWORD: "hylo" + POSTGRES_DB: "hylo" volumes: - .:/docker-entrypoint-initdb.d/ diff --git a/migrations/scripts/updateMigrationsTable.js b/migrations/scripts/updateMigrationsTable.js index ebe284b49..66c030329 100644 --- a/migrations/scripts/updateMigrationsTable.js +++ b/migrations/scripts/updateMigrationsTable.js @@ -1,17 +1,20 @@ -const environment = process.env.NODE_ENV || 'development' -const config = require('../../knexfile')[environment] -const knex = require('knex')(config) -const fs = require('fs') -const path = require('path') +const environment = process.env.NODE_ENV || "development"; +const config = require("../../knexfile")[environment]; +const knex = require("knex")(config); +const fs = require("fs"); +const path = require("path"); -process.stdout.write('Attempting to remove outdated migration filenames from database... ') -const migrations = fs.readdirSync(path.join(__dirname, '..')) -.filter(f => f.slice(-3) === '.js') +process.stdout.write( + "Attempting to remove outdated migration filenames from database... " +); +const migrations = fs + .readdirSync(path.join(__dirname, "..")) + .filter((f) => f.slice(-3) === ".js"); -var newestMigrationTime +let newestMigrationTime; if (migrations.length > 0) { - const timestamp = migrations[migrations.length - 1].split('_')[0] + const timestamp = migrations[migrations.length - 1].split("_")[0]; newestMigrationTime = new Date( timestamp.substring(0, 4), Number(timestamp.substring(4, 6)) - 1, @@ -19,15 +22,15 @@ if (migrations.length > 0) { timestamp.substring(8, 10), timestamp.substring(10, 12), timestamp.substring(12, 14) - ) + ); } else { - newestMigrationTime = new Date() + newestMigrationTime = new Date(); } -knex('knex_migrations') -.whereNotIn('name', migrations) -.where('migration_time', '<', newestMigrationTime) -.del() -.then(n => console.info(`${n} rows affected.`)) -.then(() => knex.destroy()) -.catch(console.error) +knex("knex_migrations") + .whereNotIn("name", migrations) + .where("migration_time", "<", newestMigrationTime) + .del() + .then((n) => console.info(`${n} rows affected.`)) + .then(() => knex.destroy()) + .catch(console.error); diff --git a/newrelic.js b/newrelic.js index 9bbbf1233..2e6044a19 100644 --- a/newrelic.js +++ b/newrelic.js @@ -8,18 +8,18 @@ exports.config = { /** * Array of application names. */ - app_name : ['hylo-node'], + app_name: ["hylo-node"], /** * Your New Relic license key. */ - license_key : process.env.NEW_RELIC_LICENSE_KEY, - logging : { + license_key: process.env.NEW_RELIC_LICENSE_KEY, + logging: { /** * Level at which to log. 'trace' is most useful to New Relic when diagnosing * issues with the agent, 'info' and higher will impose the least overhead on * production applications. */ - level : 'info', - filepath : 'stdout' - } + level: "info", + filepath: "stdout", + }, }; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..d2f4ecf18 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1651 @@ +{ + "name": "hylo-node", + "version": "1.3.8", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@babel/code-frame": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", + "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", + "dev": true, + "requires": { + "@babel/highlight": "^7.10.4" + } + }, + "@babel/helper-validator-identifier": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz", + "integrity": "sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw==", + "dev": true + }, + "@babel/highlight": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.10.4.tgz", + "integrity": "sha512-i6rgnR/YgPEQzZZnbTHHuZdlE8qyoBNalD6F+q4vAFlcMEcqmkoG+mPqJYJCo63qPf74+Y1UZsl3l6f7/RIkmA==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.10.4", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + }, + "dependencies": { + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + } + } + }, + "@eslint/eslintrc": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.2.1.tgz", + "integrity": "sha512-XRUeBZ5zBWLYgSANMpThFddrZZkEbGHgUdt5UJjZfnlN9BGCiUBrf+nvbRupSjMvqzwnQN0qwCmOxITt1cfywA==", + "dev": true, + "requires": { + "ajv": "^6.12.4", + "debug": "^4.1.1", + "espree": "^7.3.0", + "globals": "^12.1.0", + "ignore": "^4.0.6", + "import-fresh": "^3.2.1", + "js-yaml": "^3.13.1", + "lodash": "^4.17.19", + "minimatch": "^3.0.4", + "strip-json-comments": "^3.1.1" + }, + "dependencies": { + "lodash": { + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==", + "dev": true + } + } + }, + "@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=", + "dev": true + }, + "acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true + }, + "acorn-jsx": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.1.tgz", + "integrity": "sha512-K0Ptm/47OKfQRpNQ2J/oIN/3QYiK6FwW+eJbILhsdxh2WTLdl+30o8aGdTbm5JbffpFFAg/g+zi1E+jvJha5ng==", + "dev": true + }, + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "ansi-colors": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", + "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==", + "dev": true + }, + "ansi-regex": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", + "dev": true + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "requires": { + "sprintf-js": "~1.0.2" + } + }, + "array-includes": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.1.tgz", + "integrity": "sha512-c2VXaCHl7zPsvpkFsw4nxvFie4fh1ur9bpcgsVkIjqn0H/Xwdg+7fv3n2r/isyS8EBj5b06M9kHyZuIr4El6WQ==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.0", + "is-string": "^1.0.5" + } + }, + "array.prototype.flat": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.2.4.tgz", + "integrity": "sha512-4470Xi3GAPAjZqFcljX2xzckv1qeKPizoNkiS0+O4IoPR2ZNpcjE0pkhdihlDouK+x6QOast26B4Q/O9DJnwSg==", + "dev": true, + "requires": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3", + "es-abstract": "^1.18.0-next.1" + }, + "dependencies": { + "es-abstract": { + "version": "1.18.0-next.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0-next.1.tgz", + "integrity": "sha512-I4UGspA0wpZXWENrdA0uHbnhte683t3qT/1VFH9aX2dA5PPSf6QW5HHXf5HImaqPmjXaVeVk4RGWnaylmV7uAA==", + "dev": true, + "requires": { + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1", + "is-callable": "^1.2.2", + "is-negative-zero": "^2.0.0", + "is-regex": "^1.1.1", + "object-inspect": "^1.8.0", + "object-keys": "^1.1.1", + "object.assign": "^4.1.1", + "string.prototype.trimend": "^1.0.1", + "string.prototype.trimstart": "^1.0.1" + } + } + } + }, + "astral-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-1.0.0.tgz", + "integrity": "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==", + "dev": true + }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", + "dev": true + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "call-bind": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.0.tgz", + "integrity": "sha512-AEXsYIyyDY3MCzbwdhzG3Jx1R0J2wetQyUynn6dYHAO+bg8l1k7jwZtRv4ryryFs7EP+NDlikJlVe59jr0cM2w==", + "dev": true, + "requires": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.0" + } + }, + "callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true + }, + "contains-path": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/contains-path/-/contains-path-0.1.0.tgz", + "integrity": "sha1-/ozxhP9mcLa67wGp1IYaXL7EEgo=", + "dev": true + }, + "cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, + "debug": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.2.0.tgz", + "integrity": "sha512-IX2ncY78vDTjZMFUdmsvIRFY2Cf4FnD0wRs+nQwJU8Lu99/tPFdb0VybiiMTPe3I6rQmwsqQqRBvxU+bZ/I8sg==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "deep-is": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", + "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=", + "dev": true + }, + "define-properties": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", + "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==", + "dev": true, + "requires": { + "object-keys": "^1.0.12" + } + }, + "doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "requires": { + "esutils": "^2.0.2" + } + }, + "emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "dev": true + }, + "enquirer": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz", + "integrity": "sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==", + "dev": true, + "requires": { + "ansi-colors": "^4.1.1" + } + }, + "error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "requires": { + "is-arrayish": "^0.2.1" + } + }, + "es-abstract": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.7.tgz", + "integrity": "sha512-VBl/gnfcJ7OercKA9MVaegWsBHFjV492syMudcnQZvt/Dw8ezpcOHYZXa/J96O8vx+g4x65YKhxOwDUh63aS5g==", + "dev": true, + "requires": { + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1", + "is-callable": "^1.2.2", + "is-regex": "^1.1.1", + "object-inspect": "^1.8.0", + "object-keys": "^1.1.1", + "object.assign": "^4.1.1", + "string.prototype.trimend": "^1.0.1", + "string.prototype.trimstart": "^1.0.1" + } + }, + "es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "dev": true, + "requires": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + } + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + }, + "eslint": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.13.0.tgz", + "integrity": "sha512-uCORMuOO8tUzJmsdRtrvcGq5qposf7Rw0LwkTJkoDbOycVQtQjmnhZSuLQnozLE4TmAzlMVV45eCHmQ1OpDKUQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@eslint/eslintrc": "^0.2.1", + "ajv": "^6.10.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.0.1", + "doctrine": "^3.0.0", + "enquirer": "^2.3.5", + "eslint-scope": "^5.1.1", + "eslint-utils": "^2.1.0", + "eslint-visitor-keys": "^2.0.0", + "espree": "^7.3.0", + "esquery": "^1.2.0", + "esutils": "^2.0.2", + "file-entry-cache": "^5.0.1", + "functional-red-black-tree": "^1.0.1", + "glob-parent": "^5.0.0", + "globals": "^12.1.0", + "ignore": "^4.0.6", + "import-fresh": "^3.0.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "js-yaml": "^3.13.1", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash": "^4.17.19", + "minimatch": "^3.0.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.1", + "progress": "^2.0.0", + "regexpp": "^3.1.0", + "semver": "^7.2.1", + "strip-ansi": "^6.0.0", + "strip-json-comments": "^3.1.0", + "table": "^5.2.3", + "text-table": "^0.2.0", + "v8-compile-cache": "^2.0.3" + }, + "dependencies": { + "lodash": { + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==", + "dev": true + }, + "semver": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.2.tgz", + "integrity": "sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==", + "dev": true + } + } + }, + "eslint-config-standard": { + "version": "16.0.2", + "resolved": "https://registry.npmjs.org/eslint-config-standard/-/eslint-config-standard-16.0.2.tgz", + "integrity": "sha512-fx3f1rJDsl9bY7qzyX8SAtP8GBSk6MfXFaTfaGgk12aAYW4gJSyRm7dM790L6cbXv63fvjY4XeSzXnb4WM+SKw==", + "dev": true + }, + "eslint-import-resolver-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.4.tgz", + "integrity": "sha512-ogtf+5AB/O+nM6DIeBUNr2fuT7ot9Qg/1harBfBtaP13ekEWFQEEMP94BCB7zaNW3gyY+8SHYF00rnqYwXKWOA==", + "dev": true, + "requires": { + "debug": "^2.6.9", + "resolve": "^1.13.1" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + } + } + }, + "eslint-module-utils": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.6.0.tgz", + "integrity": "sha512-6j9xxegbqe8/kZY8cYpcp0xhbK0EgJlg3g9mib3/miLaExuuwc3n5UEfSnU6hWMbT0FAYVvDbL9RrRgpUeQIvA==", + "dev": true, + "requires": { + "debug": "^2.6.9", + "pkg-dir": "^2.0.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + } + } + }, + "eslint-plugin-es": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-es/-/eslint-plugin-es-3.0.1.tgz", + "integrity": "sha512-GUmAsJaN4Fc7Gbtl8uOBlayo2DqhwWvEzykMHSCZHU3XdJ+NSzzZcVhXh3VxX5icqQ+oQdIEawXX8xkR3mIFmQ==", + "dev": true, + "requires": { + "eslint-utils": "^2.0.0", + "regexpp": "^3.0.0" + } + }, + "eslint-plugin-import": { + "version": "2.22.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.22.1.tgz", + "integrity": "sha512-8K7JjINHOpH64ozkAhpT3sd+FswIZTfMZTjdx052pnWrgRCVfp8op9tbjpAk3DdUeI/Ba4C8OjdC0r90erHEOw==", + "dev": true, + "requires": { + "array-includes": "^3.1.1", + "array.prototype.flat": "^1.2.3", + "contains-path": "^0.1.0", + "debug": "^2.6.9", + "doctrine": "1.5.0", + "eslint-import-resolver-node": "^0.3.4", + "eslint-module-utils": "^2.6.0", + "has": "^1.0.3", + "minimatch": "^3.0.4", + "object.values": "^1.1.1", + "read-pkg-up": "^2.0.0", + "resolve": "^1.17.0", + "tsconfig-paths": "^3.9.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "doctrine": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-1.5.0.tgz", + "integrity": "sha1-N53Ocw9hZvds76TmcHoVmwLFpvo=", + "dev": true, + "requires": { + "esutils": "^2.0.2", + "isarray": "^1.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + } + } + }, + "eslint-plugin-node": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-node/-/eslint-plugin-node-11.1.0.tgz", + "integrity": "sha512-oUwtPJ1W0SKD0Tr+wqu92c5xuCeQqB3hSCHasn/ZgjFdA9iDGNkNf2Zi9ztY7X+hNuMib23LNGRm6+uN+KLE3g==", + "dev": true, + "requires": { + "eslint-plugin-es": "^3.0.0", + "eslint-utils": "^2.0.0", + "ignore": "^5.1.1", + "minimatch": "^3.0.4", + "resolve": "^1.10.1", + "semver": "^6.1.0" + }, + "dependencies": { + "ignore": { + "version": "5.1.8", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz", + "integrity": "sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==", + "dev": true + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "eslint-plugin-promise": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-4.2.1.tgz", + "integrity": "sha512-VoM09vT7bfA7D+upt+FjeBO5eHIJQBUWki1aPvB+vbNiHS3+oGIJGIeyBtKQTME6UPXXy3vV07OL1tHd3ANuDw==", + "dev": true + }, + "eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "requires": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + } + }, + "eslint-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz", + "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==", + "dev": true, + "requires": { + "eslint-visitor-keys": "^1.1.0" + }, + "dependencies": { + "eslint-visitor-keys": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", + "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", + "dev": true + } + } + }, + "eslint-visitor-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.0.0.tgz", + "integrity": "sha512-QudtT6av5WXels9WjIM7qz1XD1cWGvX4gGXvp/zBn9nXG02D0utdU3Em2m/QjTnrsk6bBjmCygl3rmj118msQQ==", + "dev": true + }, + "espree": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-7.3.0.tgz", + "integrity": "sha512-dksIWsvKCixn1yrEXO8UosNSxaDoSYpq9reEjZSbHLpT5hpaCAKTLBwq0RHtLrIr+c0ByiYzWT8KTMRzoRCNlw==", + "dev": true, + "requires": { + "acorn": "^7.4.0", + "acorn-jsx": "^5.2.0", + "eslint-visitor-keys": "^1.3.0" + }, + "dependencies": { + "eslint-visitor-keys": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", + "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", + "dev": true + } + } + }, + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true + }, + "esquery": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.3.1.tgz", + "integrity": "sha512-olpvt9QG0vniUBZspVRN6lwB7hOZoTRtT+jzR+tS4ffYx2mzbw+z0XCOk44aaLYKApNX5nMm+E+P6o25ip/DHQ==", + "dev": true, + "requires": { + "estraverse": "^5.1.0" + }, + "dependencies": { + "estraverse": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz", + "integrity": "sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==", + "dev": true + } + } + }, + "esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "requires": { + "estraverse": "^5.2.0" + }, + "dependencies": { + "estraverse": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz", + "integrity": "sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==", + "dev": true + } + } + }, + "estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true + }, + "esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true + }, + "fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", + "dev": true + }, + "file-entry-cache": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-5.0.1.tgz", + "integrity": "sha512-bCg29ictuBaKUwwArK4ouCaqDgLZcysCFLmM/Yn/FDoqndh/9vNuQfXRDvTuXKLxfD/JtZQGKFT8MGcJBK644g==", + "dev": true, + "requires": { + "flat-cache": "^2.0.1" + } + }, + "find-up": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", + "integrity": "sha1-RdG35QbHF93UgndaK3eSCjwMV6c=", + "dev": true, + "requires": { + "locate-path": "^2.0.0" + } + }, + "flat-cache": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-2.0.1.tgz", + "integrity": "sha512-LoQe6yDuUMDzQAEH8sgmh4Md6oZnc/7PjtwjNFSzveXqSHt6ka9fPBuso7IGf9Rz4uqnSnWiFH2B/zj24a5ReA==", + "dev": true, + "requires": { + "flatted": "^2.0.0", + "rimraf": "2.6.3", + "write": "1.0.3" + } + }, + "flatted": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-2.0.2.tgz", + "integrity": "sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA==", + "dev": true + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true + }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "dev": true + }, + "functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", + "dev": true + }, + "get-intrinsic": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.0.1.tgz", + "integrity": "sha512-ZnWP+AmS1VUaLgTRy47+zKtjTxz+0xMpx3I52i+aalBK1QP19ggLF3Db89KJX7kjfOfP2eoa01qc++GwPgufPg==", + "dev": true, + "requires": { + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1" + } + }, + "glob-parent": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.1.tgz", + "integrity": "sha512-FnI+VGOpnlGHWZxthPGR+QhR78fuiK0sNLkHQv+bL9fQi57lNNdquIbna/WrfROrolq8GK5Ek6BiMwqL/voRYQ==", + "dev": true, + "requires": { + "is-glob": "^4.0.1" + } + }, + "globals": { + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-12.4.0.tgz", + "integrity": "sha512-BWICuzzDvDoH54NHKCseDanAhE3CeDorgDL5MT6LMXXj2WCnd9UC2szdk4AWLfjdgNBCXLUanXYcpBBKOSWGwg==", + "dev": true, + "requires": { + "type-fest": "^0.8.1" + } + }, + "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==", + "dev": true + }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, + "requires": { + "function-bind": "^1.1.1" + } + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "has-symbols": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz", + "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==", + "dev": true + }, + "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==", + "dev": true + }, + "ignore": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", + "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", + "dev": true + }, + "import-fresh": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.2.2.tgz", + "integrity": "sha512-cTPNrlvJT6twpYy+YmKUKrTSjWFs3bjYjAhCwm+z4EOCubZxAuO+hHpRN64TqjEaYSHs7tJAE0w1CKMGmsG/lw==", + "dev": true, + "requires": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + } + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", + "dev": true + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, + "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==", + "dev": true + }, + "is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", + "dev": true + }, + "is-callable": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.2.tgz", + "integrity": "sha512-dnMqspv5nU3LoewK2N/y7KLtxtakvTuaCsU9FU50/QDmdbHNy/4/JuRtMHqRU22o3q+W89YQndQEeCVwK+3qrA==", + "dev": true + }, + "is-core-module": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.1.0.tgz", + "integrity": "sha512-YcV7BgVMRFRua2FqQzKtTDMz8iCuLEyGKjr70q8Zm1yy2qKcurbFEd79PAdHV77oL3NrAaOVQIbMmiHQCHB7ZA==", + "dev": true, + "requires": { + "has": "^1.0.3" + } + }, + "is-date-object": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.2.tgz", + "integrity": "sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g==", + "dev": true + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + }, + "is-glob": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", + "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-negative-zero": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.0.tgz", + "integrity": "sha1-lVOxIbD6wohp2p7UWeIMdUN4hGE=", + "dev": true + }, + "is-regex": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.1.tgz", + "integrity": "sha512-1+QkEcxiLlB7VEyFtyBg94e08OAsvq7FUBgApTq/w2ymCLyKJgDPsybBENVtA7XCQEgEXxKPonG+mvYRxh/LIg==", + "dev": true, + "requires": { + "has-symbols": "^1.0.1" + } + }, + "is-string": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.5.tgz", + "integrity": "sha512-buY6VNRjhQMiF1qWDouloZlQbRhDPCebwxSjxMjxgemYT46YMd2NR0/H+fBhEfWX4A/w9TBJ+ol+okqJKFE6vQ==", + "dev": true + }, + "is-symbol": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz", + "integrity": "sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==", + "dev": true, + "requires": { + "has-symbols": "^1.0.1" + } + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "dev": true + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "js-yaml": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.0.tgz", + "integrity": "sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", + "dev": true + }, + "json5": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz", + "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==", + "dev": true, + "requires": { + "minimist": "^1.2.0" + }, + "dependencies": { + "minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "dev": true + } + } + }, + "levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "requires": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + } + }, + "load-json-file": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz", + "integrity": "sha1-eUfkIUmvgNaWy/eXvKq8/h/inKg=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^2.2.0", + "pify": "^2.0.0", + "strip-bom": "^3.0.0" + } + }, + "locate-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", + "integrity": "sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=", + "dev": true, + "requires": { + "p-locate": "^2.0.0", + "path-exists": "^3.0.0" + } + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "mkdirp": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", + "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", + "dev": true, + "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==", + "dev": true + } + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", + "dev": true + }, + "normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "requires": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + }, + "dependencies": { + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + } + } + }, + "object-inspect": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.8.0.tgz", + "integrity": "sha512-jLdtEOB112fORuypAyl/50VRVIBIdVQOSUUGQHzJ4xBSbit81zRarz7GThkEFZy1RceYrWYcPcBFPQwHyAc1gA==", + "dev": true + }, + "object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true + }, + "object.assign": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz", + "integrity": "sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3", + "has-symbols": "^1.0.1", + "object-keys": "^1.1.1" + } + }, + "object.values": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.1.tgz", + "integrity": "sha512-WTa54g2K8iu0kmS/us18jEmdv1a4Wi//BZ/DTVYEcH0XhLM5NYdpDHja3gt57VrZLcNAO2WGA+KpWsDBaHt6eA==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.0-next.1", + "function-bind": "^1.1.1", + "has": "^1.0.3" + } + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "optionator": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", + "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", + "dev": true, + "requires": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.3" + } + }, + "p-limit": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", + "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", + "dev": true, + "requires": { + "p-try": "^1.0.0" + } + }, + "p-locate": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", + "integrity": "sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=", + "dev": true, + "requires": { + "p-limit": "^1.1.0" + } + }, + "p-try": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", + "integrity": "sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=", + "dev": true + }, + "parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "requires": { + "callsites": "^3.0.0" + } + }, + "parse-json": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz", + "integrity": "sha1-9ID0BDTvgHQfhGkJn43qGPVaTck=", + "dev": true, + "requires": { + "error-ex": "^1.2.0" + } + }, + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", + "dev": true + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true + }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true + }, + "path-parse": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", + "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", + "dev": true + }, + "path-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-2.0.0.tgz", + "integrity": "sha1-8BLMuEFbcJb8LaoQVMPXI4lZTHM=", + "dev": true, + "requires": { + "pify": "^2.0.0" + } + }, + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + }, + "pkg-dir": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-2.0.0.tgz", + "integrity": "sha1-9tXREJ4Z1j7fQo4L1X4Sd3YVM0s=", + "dev": true, + "requires": { + "find-up": "^2.1.0" + } + }, + "prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true + }, + "prettier": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.1.2.tgz", + "integrity": "sha512-16c7K+x4qVlJg9rEbXl7HEGmQyZlG4R9AgP+oHKRMsMsuk8s+ATStlf1NpDqyBI1HpVyfjLOeMhH2LvuNvV5Vg==", + "dev": true + }, + "progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true + }, + "punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "dev": true + }, + "read-pkg": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-2.0.0.tgz", + "integrity": "sha1-jvHAYjxqbbDcZxPEv6xGMysjaPg=", + "dev": true, + "requires": { + "load-json-file": "^2.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^2.0.0" + } + }, + "read-pkg-up": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-2.0.0.tgz", + "integrity": "sha1-a3KoBImE4MQeeVEP1en6mbO1Sb4=", + "dev": true, + "requires": { + "find-up": "^2.0.0", + "read-pkg": "^2.0.0" + } + }, + "regexpp": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.1.0.tgz", + "integrity": "sha512-ZOIzd8yVsQQA7j8GCSlPGXwg5PfmA1mrq0JP4nGhh54LaKN3xdai/vHUDu74pKwV8OxseMS65u2NImosQcSD0Q==", + "dev": true + }, + "resolve": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.19.0.tgz", + "integrity": "sha512-rArEXAgsBG4UgRGcynxWIWKFvh/XZCcS8UJdHhwy91zwAvCZIbcs+vAbflgBnNjYMs/i/i+/Ux6IZhML1yPvxg==", + "dev": true, + "requires": { + "is-core-module": "^2.1.0", + "path-parse": "^1.0.6" + } + }, + "resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true + }, + "rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "dev": true, + "requires": { + "glob": "^7.1.3" + }, + "dependencies": { + "glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "dev": true, + "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" + } + } + } + }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true + }, + "slice-ansi": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-2.1.0.tgz", + "integrity": "sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.0", + "astral-regex": "^1.0.0", + "is-fullwidth-code-point": "^2.0.0" + } + }, + "spdx-correct": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.1.tgz", + "integrity": "sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==", + "dev": true, + "requires": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-exceptions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", + "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==", + "dev": true + }, + "spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "requires": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-license-ids": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.6.tgz", + "integrity": "sha512-+orQK83kyMva3WyPf59k1+Y525csj5JejicWut55zeTWANuN17qSiSLUXWtzHeNWORSvT7GLDJ/E/XiIWoXBTw==", + "dev": true + }, + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", + "dev": true + }, + "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==", + "dev": true, + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-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==", + "dev": true + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + } + } + }, + "string.prototype.trimend": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.2.tgz", + "integrity": "sha512-8oAG/hi14Z4nOVP0z6mdiVZ/wqjDtWSLygMigTzAb+7aPEDTleeFf+WrF+alzecxIRkckkJVn+dTlwzJXORATw==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.18.0-next.1" + }, + "dependencies": { + "es-abstract": { + "version": "1.18.0-next.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0-next.1.tgz", + "integrity": "sha512-I4UGspA0wpZXWENrdA0uHbnhte683t3qT/1VFH9aX2dA5PPSf6QW5HHXf5HImaqPmjXaVeVk4RGWnaylmV7uAA==", + "dev": true, + "requires": { + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1", + "is-callable": "^1.2.2", + "is-negative-zero": "^2.0.0", + "is-regex": "^1.1.1", + "object-inspect": "^1.8.0", + "object-keys": "^1.1.1", + "object.assign": "^4.1.1", + "string.prototype.trimend": "^1.0.1", + "string.prototype.trimstart": "^1.0.1" + } + } + } + }, + "string.prototype.trimstart": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.2.tgz", + "integrity": "sha512-7F6CdBTl5zyu30BJFdzSTlSlLPwODC23Od+iLoVH8X6+3fvDPPuBVVj9iaB1GOsSTSIgVfsfm27R2FGrAPznWg==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.18.0-next.1" + }, + "dependencies": { + "es-abstract": { + "version": "1.18.0-next.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0-next.1.tgz", + "integrity": "sha512-I4UGspA0wpZXWENrdA0uHbnhte683t3qT/1VFH9aX2dA5PPSf6QW5HHXf5HImaqPmjXaVeVk4RGWnaylmV7uAA==", + "dev": true, + "requires": { + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1", + "is-callable": "^1.2.2", + "is-negative-zero": "^2.0.0", + "is-regex": "^1.1.1", + "object-inspect": "^1.8.0", + "object-keys": "^1.1.1", + "object.assign": "^4.1.1", + "string.prototype.trimend": "^1.0.1", + "string.prototype.trimstart": "^1.0.1" + } + } + } + }, + "strip-ansi": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", + "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.0" + } + }, + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", + "dev": true + }, + "strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + }, + "table": { + "version": "5.4.6", + "resolved": "https://registry.npmjs.org/table/-/table-5.4.6.tgz", + "integrity": "sha512-wmEc8m4fjnob4gt5riFRtTu/6+4rSe12TpAELNSqHMfF3IqnA+CH37USM6/YR3qRZv7e56kAEAtd6nKZaxe0Ug==", + "dev": true, + "requires": { + "ajv": "^6.10.2", + "lodash": "^4.17.14", + "slice-ansi": "^2.1.0", + "string-width": "^3.0.0" + }, + "dependencies": { + "lodash": { + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==", + "dev": true + } + } + }, + "text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", + "dev": true + }, + "tsconfig-paths": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.9.0.tgz", + "integrity": "sha512-dRcuzokWhajtZWkQsDVKbWyY+jgcLC5sqJhg2PSgf4ZkH2aHPvaOY8YWGhmjb68b5qqTfasSsDO9k7RUiEmZAw==", + "dev": true, + "requires": { + "@types/json5": "^0.0.29", + "json5": "^1.0.1", + "minimist": "^1.2.0", + "strip-bom": "^3.0.0" + }, + "dependencies": { + "minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "dev": true + } + } + }, + "type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "requires": { + "prelude-ls": "^1.2.1" + } + }, + "type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true + }, + "uri-js": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.0.tgz", + "integrity": "sha512-B0yRTzYdUCCn9n+F4+Gh4yIDtMQcaJsmYBDsTSG8g/OejKBodLQ2IHfN3bM7jUsRXndopT7OIXWdYqc1fjmV6g==", + "dev": true, + "requires": { + "punycode": "^2.1.0" + } + }, + "v8-compile-cache": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.2.0.tgz", + "integrity": "sha512-gTpR5XQNKFwOd4clxfnhaqvfqMpqEwr4tOtCyz4MtYZX2JYhfr1JvBFKdS+7K/9rfpZR3VLX+YWBbKoxCgS43Q==", + "dev": true + }, + "validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "requires": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "word-wrap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", + "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", + "dev": true + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true + }, + "write": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/write/-/write-1.0.3.tgz", + "integrity": "sha512-/lg70HAjtkUgWPVZhZcm+T4hkL8Zbtp1nFNOn3lRrxnlv50SRBv7cR7RqR+GMsd3hUXy9hWBo4CHTbFTcOYwig==", + "dev": true, + "requires": { + "mkdirp": "^0.5.1" + } + } + } +} diff --git a/package.json b/package.json index 57d064f04..ecde0a433 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,11 @@ "chai-datetime": "^1.5.0", "chai-spies": "^1.0.0", "chai-things": "^0.2.0", + "eslint": "^7.13.0", + "eslint-config-standard": "^16.0.2", + "eslint-plugin-import": "^2.22.1", + "eslint-plugin-node": "^11.1.0", + "eslint-plugin-promise": "^4.2.1", "faker": "^4.1.0", "i18n": "^0.8.3", "mocha": "^5.0.0", @@ -29,6 +34,7 @@ "nock": "^7.0.2", "nodemon": "^1.12.1", "nyc": "^10.0", + "prettier": "2.1.2", "socket.io-client": "^2.0.4" }, "dependencies": { @@ -137,7 +143,11 @@ "test": "[ \"$NODE_ENV\" != production ] && mocha -r test/setup/core -r babel-core/register", "cover": "./node_modules/.bin/nyc --cache -a -r lcov ./node_modules/.bin/mocha -r test/setup/core --require babel-core/register -R dot", "knex": "knex", - "update-migrations": "node ./migrations/scripts/updateMigrationsTable" + "update-migrations": "node ./migrations/scripts/updateMigrationsTable", + "eslint-fix": "eslint --fix --ignore-path .gitignore .", + "eslint": "eslint --ignore-path .gitignore .", + "prettier": "prettier --write .", + "prettier-check": "prettier --check ." }, "main": "app.js", "repository": { diff --git a/seeds/communities.js b/seeds/communities.js index ecc235bb3..94dfdc26c 100644 --- a/seeds/communities.js +++ b/seeds/communities.js @@ -1,10 +1,15 @@ -'use strict' +"use strict"; exports.seed = function (knex, Promise) { - return knex('communities_posts').del() - .then(() => knex('communities_users').del()) - .then(() => knex('communities').del()) // Deletes ALL existing entries - .then(() => knex('communities') - .insert({id: 1, name: 'starter-posts', slug: 'starter-posts'}) - ) -} + return knex("communities_posts") + .del() + .then(() => knex("communities_users").del()) + .then(() => knex("communities").del()) // Deletes ALL existing entries + .then(() => + knex("communities").insert({ + id: 1, + name: "starter-posts", + slug: "starter-posts", + }) + ); +}; diff --git a/seeds/dummy/dummy.js b/seeds/dummy/dummy.js index b49a43a4a..025a141f7 100644 --- a/seeds/dummy/dummy.js +++ b/seeds/dummy/dummy.js @@ -1,104 +1,125 @@ -const bcrypt = require('bcrypt') -const faker = require('faker') -const promisify = require('bluebird').promisify -const hash = promisify(bcrypt.hash, bcrypt) -const readline = require('readline') +const bcrypt = require("bcrypt"); +const faker = require("faker"); +const promisify = require("bluebird").promisify; +const hash = promisify(bcrypt.hash, bcrypt); +const readline = require("readline"); const n = { communities: 50, posts: 1000, tags: 20, users: 1000, - threads: 100 -} + threads: 100, +}; // Add your test account details here. You'll be randomly assigned to a community, // and you'll also be added to the one specified below. // (You can create your own later if you want). -const name = 'Test Account' -const email = 'test@hylo.com' -const password = 'hylo' -const community = 'Test Community' -const communitySlug = 'test' -let provider_user_id = '' +const name = "Test Account"; +const email = "test@hylo.com"; +const password = "hylo"; +const community = "Test Community"; +const communitySlug = "test"; +let provider_user_id = ""; -function warning () { +function warning() { const rl = readline.createInterface({ input: process.stdin, - output: process.stdout - }) + output: process.stdout, + }); return new Promise((resolve, reject) => { - rl.question(` + rl.question( + ` WARNING: Running the dummy seed will COMPLETELY WIPE anything you cared about in the database. If you're sure that's what you want, type 'yes'. Anything else will result in this script terminating. - `, answer => { - if (answer !== 'yes') { - console.log('Exiting.') - process.exit() + `, + (answer) => { + if (answer !== "yes") { + console.log("Exiting."); + process.exit(); + } + console.log("\nOk, you asked for it...\n"); + rl.close(); + return resolve(); } - console.log('\nOk, you asked for it...\n') - rl.close() - return resolve() - }) - }) + ); + }); } -exports.seed = (knex, Promise) => warning() - .then(() => truncateAll(knex)) - .then(() => seed('users', knex, Promise)) - .then(() => hash(password, 10)) - .then(hash => { provider_user_id = hash }) - .then(() => knex('users') - .insert({ - name, - email, - active: true, - avatar_url: faker.internet.avatar(), - email_validated: true, - created_at: knex.fn.now() +exports.seed = (knex, Promise) => + warning() + .then(() => truncateAll(knex)) + .then(() => seed("users", knex, Promise)) + .then(() => hash(password, 10)) + .then((hash) => { + provider_user_id = hash; }) - .returning('id')) - .then(([ user_id ]) => knex('linked_account') - .insert({ - user_id, - provider_user_id, - provider_key: 'password' - })) - .then(() => knex('tags').insert([ - {name: 'offer'}, - {name: 'request'}, - {name: 'intention'} - ])) - .then(() => seed('tags', knex, Promise)) - .then(() => seed('networks', knex, Promise)) - .then(() => knex('communities').insert({ name: 'starter-posts', slug: 'starter-posts' })) - .then(() => knex('communities').insert({ name: community, slug: communitySlug })) - .then(() => seed('communities', knex, Promise)) - .then(() => seed('posts', knex, Promise)) - .then(() => Promise.all([ - knex('users').where('email', email).first('id'), - knex('communities').where('slug', communitySlug).first('id') - ])) - .then(([ user, community ]) => knex('communities_users').insert({ - active: true, - user_id: user.id, - community_id: community.id, - created_at: knex.fn.now(), - role: 1, - settings: '{ "send_email": true, "send_push_notifications": true }' - })) - .then(() => addUsersToCommunities(knex, Promise)) - .then(() => createThreads(knex, Promise)) - .then(() => seedMessages(knex, Promise)) - .then(() => addPostsToCommunities(knex, Promise)) - .catch(err => { - let report = err.message - if (err.message.includes('unique constraint')) { - report = -` + .then(() => + knex("users") + .insert({ + name, + email, + active: true, + avatar_url: faker.internet.avatar(), + email_validated: true, + created_at: knex.fn.now(), + }) + .returning("id") + ) + .then(([user_id]) => + knex("linked_account").insert({ + user_id, + provider_user_id, + provider_key: "password", + }) + ) + .then(() => + knex("tags").insert([ + { name: "offer" }, + { name: "request" }, + { name: "intention" }, + ]) + ) + .then(() => seed("tags", knex, Promise)) + .then(() => seed("networks", knex, Promise)) + .then(() => + knex("communities").insert({ + name: "starter-posts", + slug: "starter-posts", + }) + ) + .then(() => + knex("communities").insert({ name: community, slug: communitySlug }) + ) + .then(() => seed("communities", knex, Promise)) + .then(() => seed("posts", knex, Promise)) + .then(() => + Promise.all([ + knex("users").where("email", email).first("id"), + knex("communities").where("slug", communitySlug).first("id"), + ]) + ) + .then(([user, community]) => + knex("communities_users").insert({ + active: true, + user_id: user.id, + community_id: community.id, + created_at: knex.fn.now(), + role: 1, + settings: '{ "send_email": true, "send_push_notifications": true }', + }) + ) + .then(() => addUsersToCommunities(knex, Promise)) + .then(() => createThreads(knex, Promise)) + .then(() => seedMessages(knex, Promise)) + .then(() => addPostsToCommunities(knex, Promise)) + .catch((err) => { + let report = err.message; + if (err.message.includes("unique constraint")) { + report = ` Error during seeding. This isn't uncommon: Faker generates a limited number of unique names, @@ -106,14 +127,15 @@ exports.seed = (knex, Promise) => warning() seed until it passes (sometimes four or five tries are required). ${err.message} -` - } - console.error(report) - }) +`; + } + console.error(report); + }); -function truncateAll (knex) { - return knex.raw('TRUNCATE TABLE users CASCADE') - .then(() => knex.raw('TRUNCATE TABLE tags CASCADE')) +function truncateAll(knex) { + return knex + .raw("TRUNCATE TABLE users CASCADE") + .then(() => knex.raw("TRUNCATE TABLE tags CASCADE")); } const fake = { @@ -121,166 +143,186 @@ const fake = { networks: fakeNetwork, posts: fakePost, tags: fakeTag, - users: fakeUser -} + users: fakeUser, +}; -function randomIndex (length) { - return Math.floor(Math.random() * length) +function randomIndex(length) { + return Math.floor(Math.random() * length); } -function moderatorOrMember () { +function moderatorOrMember() { // Role 1 is moderator - return Math.random() > 0.9 ? 1 : 0 + return Math.random() > 0.9 ? 1 : 0; } -function addUsersToCommunities (knex, Promise) { - console.info(' --> communities_users') - return knex('users').select('id') - .then(users => Promise.all(users.map(({ id }) => fakeMembership(id, knex)))) +function addUsersToCommunities(knex, Promise) { + console.info(" --> communities_users"); + return knex("users") + .select("id") + .then((users) => + Promise.all(users.map(({ id }) => fakeMembership(id, knex))) + ); } -function addPostsToCommunities (knex, Promise) { - console.info(' --> communities_posts') - return knex('posts') - .select([ 'id as post_id', 'user_id' ]) - .whereNull('type') - .then(posts => Promise.all( - posts.map(({ post_id, user_id }) => knex('communities_users') - .where('communities_users.user_id', user_id) - .first('community_id') - .then(({ community_id }) => knex('communities_posts') - .insert({ post_id, community_id })) - ))) +function addPostsToCommunities(knex, Promise) { + console.info(" --> communities_posts"); + return knex("posts") + .select(["id as post_id", "user_id"]) + .whereNull("type") + .then((posts) => + Promise.all( + posts.map(({ post_id, user_id }) => + knex("communities_users") + .where("communities_users.user_id", user_id) + .first("community_id") + .then(({ community_id }) => + knex("communities_posts").insert({ post_id, community_id }) + ) + ) + ) + ); } -function seed (entity, knex, Promise) { - console.info(` --> ${entity}`) +function seed(entity, knex, Promise) { + console.info(` --> ${entity}`); return Promise.all( - [ ...new Array(n[entity]) ].map( - () => fake[entity](knex, Promise).then(row => knex(entity).insert(row)) + [...new Array(n[entity])].map(() => + fake[entity](knex, Promise).then((row) => knex(entity).insert(row)) ) - ) + ); } -function createThreads (knex, Promise) { - console.info(' --> threads') - return knex('communities').where('slug', communitySlug).first('id') - .then(community => Promise.all( - [ ...new Array(n.threads) ].map(() => fakeThread(community.id, knex, Promise)) - )) +function createThreads(knex, Promise) { + console.info(" --> threads"); + return knex("communities") + .where("slug", communitySlug) + .first("id") + .then((community) => + Promise.all( + [...new Array(n.threads)].map(() => + fakeThread(community.id, knex, Promise) + ) + ) + ); } -function seedMessages (knex, Promise) { - console.info(' --> messages') - return knex('follows') - .join('posts', 'posts.id', 'follows.post_id') - .select('follows.post_id as post', 'follows.user_id as user') - .where('posts.type', 'thread') - .then(follows => { - const created = faker.date.past() +function seedMessages(knex, Promise) { + console.info(" --> messages"); + return knex("follows") + .join("posts", "posts.id", "follows.post_id") + .select("follows.post_id as post", "follows.user_id as user") + .where("posts.type", "thread") + .then((follows) => { + const created = faker.date.past(); return Promise.all( // Add five messages to each followed thread - [ ...follows, ...follows, ...follows, ...follows, ...follows ] - .map(follow => { - created.setHours(created.getHours() + 1) - return knex('comments') - .insert({ - text: faker.lorem.paragraph(), - post_id: follow.post, - user_id: follow.user, - created_at: created.toUTCString(), - active: true - }) - }) + [...follows, ...follows, ...follows, ...follows, ...follows].map( + (follow) => { + created.setHours(created.getHours() + 1); + return knex("comments").insert({ + text: faker.lorem.paragraph(), + post_id: follow.post, + user_id: follow.user, + created_at: created.toUTCString(), + active: true, + }); + } ) - }) + ); + }); } // Grab random row or rows from table -function sample (entity, where, knex, limit = 1) { - return knex(entity) - .where(where) - .select() - .orderByRaw('random()') - .limit(limit) +function sample(entity, where, knex, limit = 1) { + return knex(entity).where(where).select().orderByRaw("random()").limit(limit); } -function fakeThread (communityId, knex, Promise) { +function fakeThread(communityId, knex, Promise) { const whereInCommunity = knex.raw(` users.id IN ( SELECT user_id FROM communities_users WHERE community_id = ${communityId} ) - `) + `); - const randomUsers = sample('users', whereInCommunity, knex, randomIndex(5) + 2) + const randomUsers = sample( + "users", + whereInCommunity, + knex, + randomIndex(5) + 2 + ); return randomUsers - .then(users => knex('posts') - .insert({ - created_at: faker.date.past(), - type: 'thread', - user_id: users[0].id, - active: true - }) - .returning(['id', 'user_id'])) - .then(([ post ]) => Promise.all( - randomUsers.map(user => - knex('follows').insert({ - post_id: post.id, - user_id: user.id, - added_at: faker.date.past() + .then((users) => + knex("posts") + .insert({ + created_at: faker.date.past(), + type: "thread", + user_id: users[0].id, + active: true, }) - .returning('user_id')) - )) + .returning(["id", "user_id"]) + ) + .then(([post]) => + Promise.all( + randomUsers.map((user) => + knex("follows") + .insert({ + post_id: post.id, + user_id: user.id, + added_at: faker.date.past(), + }) + .returning("user_id") + ) + ) + ); } -function fakeCommunity (knex) { - const name = faker.random.words() +function fakeCommunity(knex) { + const name = faker.random.words(); return Promise.all([ - sample('users', true, knex), - sample('networks', true, knex) - ]) - .then(([ users, networks ]) => ({ - name, - avatar_url: faker.internet.avatar(), - background_url: faker.image.imageUrl(), - beta_access_code: faker.random.uuid(), - description: faker.lorem.paragraph(), - slug: faker.helpers.slugify(name).toLowerCase(), - daily_digest: faker.random.boolean(), - membership_fee: faker.random.number(), - plan_guid: faker.random.uuid(), - banner_url: faker.internet.url(), - category: faker.random.uuid(), - created_at: faker.date.past(), - created_by_id: users[0].id, - leader_id: users[0].id, - welcome_message: faker.lorem.paragraph(), - network_id: networks[0].id, - location: faker.address.country(), - slack_hook_url: faker.internet.url(), - slack_team: faker.internet.url(), - slack_configure_url: faker.internet.url() - })) + sample("users", true, knex), + sample("networks", true, knex), + ]).then(([users, networks]) => ({ + name, + avatar_url: faker.internet.avatar(), + background_url: faker.image.imageUrl(), + beta_access_code: faker.random.uuid(), + description: faker.lorem.paragraph(), + slug: faker.helpers.slugify(name).toLowerCase(), + daily_digest: faker.random.boolean(), + membership_fee: faker.random.number(), + plan_guid: faker.random.uuid(), + banner_url: faker.internet.url(), + category: faker.random.uuid(), + created_at: faker.date.past(), + created_by_id: users[0].id, + leader_id: users[0].id, + welcome_message: faker.lorem.paragraph(), + network_id: networks[0].id, + location: faker.address.country(), + slack_hook_url: faker.internet.url(), + slack_team: faker.internet.url(), + slack_configure_url: faker.internet.url(), + })); } -function fakeMembership (user_id, knex) { - return sample('communities', true, knex) - .then(([ community ]) => knex('communities_users') - .insert({ - active: true, - community_id: community.id, - created_at: knex.fn.now(), - role: moderatorOrMember(), - settings: '{ "send_email": true, "send_push_notifications": true }', - user_id - })) +function fakeMembership(user_id, knex) { + return sample("communities", true, knex).then(([community]) => + knex("communities_users").insert({ + active: true, + community_id: community.id, + created_at: knex.fn.now(), + role: moderatorOrMember(), + settings: '{ "send_email": true, "send_push_notifications": true }', + user_id, + }) + ); } -function fakeNetwork (_, Promise) { - const name = faker.random.words() - const past = faker.date.past() +function fakeNetwork(_, Promise) { + const name = faker.random.words(); + const past = faker.date.past(); return Promise.resolve({ name, @@ -289,32 +331,31 @@ function fakeNetwork (_, Promise) { banner_url: faker.image.imageUrl(), slug: faker.helpers.slugify(name).toLowerCase(), created_at: past, - updated_at: past - }) + updated_at: past, + }); } -function fakePost (knex, Promise) { - return sample('users', true, knex) - .then(([ user ]) => ({ - name: faker.lorem.sentence(), - description: faker.lorem.paragraph(), - created_at: faker.date.past(), - user_id: user.id, - active: true - })) +function fakePost(knex, Promise) { + return sample("users", true, knex).then(([user]) => ({ + name: faker.lorem.sentence(), + description: faker.lorem.paragraph(), + created_at: faker.date.past(), + user_id: user.id, + active: true, + })); } -function fakeTag (_, Promise) { - const past = faker.date.past() +function fakeTag(_, Promise) { + const past = faker.date.past(); return Promise.resolve({ - name: faker.random.word().split(' ')[0].toLowerCase(), + name: faker.random.word().split(" ")[0].toLowerCase(), created_at: past, - updated_at: past - }) + updated_at: past, + }); } -function fakeUser (_, Promise) { +function fakeUser(_, Promise) { return Promise.resolve({ email: faker.internet.email(), name: `${faker.name.firstName()} ${faker.name.lastName()}`, @@ -335,6 +376,6 @@ function fakeUser (_, Promise) { extra_info: faker.lorem.paragraph(), updated_at: faker.date.past(), location: faker.address.country(), - url: faker.internet.url() - }) + url: faker.internet.url(), + }); } diff --git a/seeds/migrations.js b/seeds/migrations.js index fc9a3f98a..9c7e1ea71 100644 --- a/seeds/migrations.js +++ b/seeds/migrations.js @@ -1,24 +1,30 @@ -const fs = require('fs') -const path = require('path') +const fs = require("fs"); +const path = require("path"); exports.seed = function (knex, Promise) { - return knex('knex_migrations').del() + return knex("knex_migrations") + .del() .then(() => { - const files = fs.readdirSync(path.join(__dirname, '..', 'migrations')) - return addMigrations(knex, Promise, files.filter(f => f.slice(-3) === '.js')) - }) -} + const files = fs.readdirSync(path.join(__dirname, "..", "migrations")); + return addMigrations( + knex, + Promise, + files.filter((f) => f.slice(-3) === ".js") + ); + }); +}; // Add all migrations in directory to knex_migrations (any .js file in the // directory is assumed to be a migration). -function addMigrations (knex, Promise, migrations) { +function addMigrations(knex, Promise, migrations) { return Promise.reduce( migrations, - (_, name) => knex('knex_migrations').insert({ - name, - batch: 1, - migration_time: knex.fn.now() - }), + (_, name) => + knex("knex_migrations").insert({ + name, + batch: 1, + migration_time: knex.fn.now(), + }), [] - ) + ); } diff --git a/seeds/tags.js b/seeds/tags.js index fc44e9bb6..891f13f9b 100644 --- a/seeds/tags.js +++ b/seeds/tags.js @@ -1,10 +1,13 @@ -'use strict' +"use strict"; exports.seed = function (knex, Promise) { - return knex('tags').del() - .then(() => knex('tags').insert([ - {name: 'offer'}, - {name: 'request'}, - {name: 'intention'} - ])) -} + return knex("tags") + .del() + .then(() => + knex("tags").insert([ + { name: "offer" }, + { name: "request" }, + { name: "intention" }, + ]) + ); +}; diff --git a/tasks/README.md b/tasks/README.md index 78d2f5174..1bca0a145 100644 --- a/tasks/README.md +++ b/tasks/README.md @@ -1,17 +1,14 @@ # About the `tasks` folder -The `tasks` directory is a suite of Grunt tasks and their configurations, bundled for your convenience. The Grunt integration is mainly useful for bundling front-end assets, (like stylesheets, scripts, & markup templates) but it can also be used to run all kinds of development tasks, from browserify compilation to database migrations. +The `tasks` directory is a suite of Grunt tasks and their configurations, bundled for your convenience. The Grunt integration is mainly useful for bundling front-end assets, (like stylesheets, scripts, & markup templates) but it can also be used to run all kinds of development tasks, from browserify compilation to database migrations. If you haven't used [Grunt](http://gruntjs.com/) before, be sure to check out the [Getting Started](http://gruntjs.com/getting-started) guide, as it explains how to create a [Gruntfile](http://gruntjs.com/sample-gruntfile) as well as install and use Grunt plugins. Once you're familiar with that process, read on! - ### How does this work? The asset pipeline bundled in Sails is a set of Grunt tasks configured with conventional defaults designed to make your project more consistent and productive. -The entire front-end asset workflow in Sails is completely customizable-- while it provides some suggestions out of the box, Sails makes no pretense that it can anticipate all of the needs you'll encounter building the browser-based/front-end portion of your application. Who's to say you're even building an app for a browser? - - +The entire front-end asset workflow in Sails is completely customizable-- while it provides some suggestions out of the box, Sails makes no pretense that it can anticipate all of the needs you'll encounter building the browser-based/front-end portion of your application. Who's to say you're even building an app for a browser? ### What tasks does Sails run automatically? @@ -33,22 +30,18 @@ Runs the `build` task (`tasks/register/build.js`). Runs the `buildProd` task (`tasks/register/buildProd.js`). - ### Can I customize this for SASS, Angular, client-side Jade templates, etc? You can modify, omit, or replace any of these Grunt tasks to fit your requirements. You can also add your own Grunt tasks- just add a `someTask.js` file in the `grunt/config` directory to configure the new task, then register it with the appropriate parent task(s) (see files in `grunt/register/*.js`). - ### Do I have to use Grunt? Nope! To disable Grunt integration in Sails, just delete your Gruntfile or disable the Grunt hook. - ### What if I'm not building a web frontend? That's ok! A core tenant of Sails is client-agnosticism-- it's especially designed for building APIs used by all sorts of clients; native Android/iOS/Cordova, serverside SDKs, etc. You can completely disable Grunt by following the instructions above. -If you still want to use Grunt for other purposes, but don't want any of the default web front-end stuff, just delete your project's `assets` folder and remove the front-end oriented tasks from the `grunt/register` and `grunt/config` folders. You can also run `sails new myCoolApi --no-frontend` to omit the `assets` folder and front-end-oriented Grunt tasks for future projects. You can also replace your `sails-generate-frontend` module with alternative community generators, or create your own. This allows `sails new` to create the boilerplate for native iOS apps, Android apps, Cordova apps, SteroidsJS apps, etc. - +If you still want to use Grunt for other purposes, but don't want any of the default web front-end stuff, just delete your project's `assets` folder and remove the front-end oriented tasks from the `grunt/register` and `grunt/config` folders. You can also run `sails new myCoolApi --no-frontend` to omit the `assets` folder and front-end-oriented Grunt tasks for future projects. You can also replace your `sails-generate-frontend` module with alternative community generators, or create your own. This allows `sails new` to create the boilerplate for native iOS apps, Android apps, Cordova apps, SteroidsJS apps, etc. diff --git a/tasks/config/autotest.js b/tasks/config/autotest.js index b9037e303..7279e35e8 100644 --- a/tasks/config/autotest.js +++ b/tasks/config/autotest.js @@ -1,25 +1,30 @@ -const gaze = require('gaze') -const minimist = require('minimist') -const child = require('child_process') -const debounce = require('lodash/debounce') +const gaze = require("gaze"); +const minimist = require("minimist"); +const child = require("child_process"); +const debounce = require("lodash/debounce"); module.exports = function (grunt) { - grunt.registerTask('autotest', function () { - this.async() + grunt.registerTask("autotest", function () { + this.async(); - const argv = minimist(process.argv) - const file = argv.file || argv.f || '' - const cmd = `npm test -s -- -b -R min ${file}` + const argv = minimist(process.argv); + const file = argv.file || argv.f || ""; + const cmd = `npm test -s -- -b -R min ${file}`; - gaze([ - 'api/**/*', - 'config/**/*', - 'lib/**/*', - 'test/**/*' - ], function (_, watcher) { - this.on('all', debounce(() => { - child.spawn('bash', ['-c', cmd], {stdio: 'inherit'}) - }, 2000, true)) - }) - }) -} + gaze(["api/**/*", "config/**/*", "lib/**/*", "test/**/*"], function ( + _, + watcher + ) { + this.on( + "all", + debounce( + () => { + child.spawn("bash", ["-c", cmd], { stdio: "inherit" }); + }, + 2000, + true + ) + ); + }); + }); +}; diff --git a/tasks/pipeline.js b/tasks/pipeline.js index 267c08b10..a96413efc 100644 --- a/tasks/pipeline.js +++ b/tasks/pipeline.js @@ -8,33 +8,26 @@ * for matching multiple files.) */ - - // CSS files to inject in order // // (if you're using LESS with the built-in default config, you'll want // to change `assets/styles/importer.less` instead.) -var cssFilesToInject = [ +const cssFilesToInject = [ // 'styles/**/*.css' ]; - // Client-side javascript files to inject in order // (uses Grunt-style wildcard/glob/splat expressions) -var jsFilesToInject = [ - +const jsFilesToInject = [ // Load sails.io before everything else // 'js/dependencies/sails.io.js', - // Dependencies like jQuery, or Angular are brought in here // 'js/dependencies/**/*.js', - // All of the rest of your client-side js files // will be injected here in no particular order. // 'js/**/*.js' ]; - // Client-side HTML templates are injected using the sources below // The ordering of these templates shouldn't matter. // (uses Grunt-style wildcard/glob/splat expressions) @@ -44,21 +37,19 @@ var jsFilesToInject = [ // with the linker, no problem-- you'll just want to make sure the precompiled // templates get spit out to the same file. Be sure and check out `tasks/README.md` // for information on customizing and installing new tasks. -var templateFilesToInject = [ - 'templates/**/*.html' -]; - - +const templateFilesToInject = ["templates/**/*.html"]; // Prefix relative paths to source files so they point to the proper locations // (i.e. where the other Grunt tasks spit them out, or in some cases, where // they reside in the first place) -module.exports.cssFilesToInject = cssFilesToInject.map(function(path) { - return '.tmp/public/' + path; +module.exports.cssFilesToInject = cssFilesToInject.map(function (path) { + return ".tmp/public/" + path; }); -module.exports.jsFilesToInject = jsFilesToInject.map(function(path) { - return '.tmp/public/' + path; +module.exports.jsFilesToInject = jsFilesToInject.map(function (path) { + return ".tmp/public/" + path; }); -module.exports.templateFilesToInject = templateFilesToInject.map(function(path) { - return 'assets/' + path; +module.exports.templateFilesToInject = templateFilesToInject.map(function ( + path +) { + return "assets/" + path; }); diff --git a/test/index.js b/test/index.js index 44be8282e..8eccedb4b 100644 --- a/test/index.js +++ b/test/index.js @@ -1,4 +1,5 @@ -var root = require('root-path') -var glob = require('glob') -glob.sync('{api,lib,test}/**/*.test.js').forEach(file => - require(root(file.replace(/\.js$/, '')))) +const root = require("root-path"); +const glob = require("glob"); +glob + .sync("{api,lib,test}/**/*.test.js") + .forEach((file) => require(root(file.replace(/\.js$/, "")))); diff --git a/test/setup/core.js b/test/setup/core.js index 6f2ee1ce2..4c1054054 100644 --- a/test/setup/core.js +++ b/test/setup/core.js @@ -1,13 +1,13 @@ -process.env.NODE_ENV = 'test' +process.env.NODE_ENV = "test"; // just set up the test globals, not the test database -const chai = require('chai') +const chai = require("chai"); -chai.use(require('chai-things')) -chai.use(require('chai-spies')) -chai.use(require('chai-as-promised')) -chai.use(require('chai-datetime')) +chai.use(require("chai-things")); +chai.use(require("chai-spies")); +chai.use(require("chai-as-promised")); +chai.use(require("chai-datetime")); -global.spy = chai.spy -global.expect = chai.expect +global.spy = chai.spy; +global.expect = chai.expect; diff --git a/test/setup/factories.js b/test/setup/factories.js index afb8c16e1..52ecea9c2 100644 --- a/test/setup/factories.js +++ b/test/setup/factories.js @@ -1,119 +1,184 @@ -import { extend, merge, pick, reduce } from 'lodash' -import { spy } from 'chai' -import i18n from 'i18n' -import { ReadableStreamBuffer } from 'stream-buffers' -import faker from 'faker' +import { extend, merge, pick, reduce } from "lodash"; +import { spy } from "chai"; +import i18n from "i18n"; +import { ReadableStreamBuffer } from "stream-buffers"; +import faker from "faker"; module.exports = { - blockedUser: attrs => { - return new BlockedUser(merge({ - created_at: new Date(), - updated_at: new Date() - }, attrs)) + blockedUser: (attrs) => { + return new BlockedUser( + merge( + { + created_at: new Date(), + updated_at: new Date(), + }, + attrs + ) + ); }, - community: attrs => { - return new Community(merge({ - name: faker.random.words(6), - slug: faker.lorem.slug(), - beta_access_code: faker.random.alphaNumeric(6) - }, attrs)) + community: (attrs) => { + return new Community( + merge( + { + name: faker.random.words(6), + slug: faker.lorem.slug(), + beta_access_code: faker.random.alphaNumeric(6), + }, + attrs + ) + ); }, - invitation: attrs => { - return new Invitation(merge({ - token: faker.random.alphaNumeric(36), - email: faker.internet.email(), - created_at: new Date() - }, attrs)) + invitation: (attrs) => { + return new Invitation( + merge( + { + token: faker.random.alphaNumeric(36), + email: faker.internet.email(), + created_at: new Date(), + }, + attrs + ) + ); }, - post: attrs => { - return new Post(merge({ - active: true, - name: faker.random.words(4) - }, attrs)) + post: (attrs) => { + return new Post( + merge( + { + active: true, + name: faker.random.words(4), + }, + attrs + ) + ); }, - comment: attrs => { - return new Comment(merge({ - active: true, - text: faker.lorem.sentences(2) - }, attrs)) + comment: (attrs) => { + return new Comment( + merge( + { + active: true, + text: faker.lorem.sentences(2), + }, + attrs + ) + ); }, - user: attrs => { - return new User(merge({ - name: faker.name.findName(), - active: true, - email: faker.internet.email() - }, attrs)) + user: (attrs) => { + return new User( + merge( + { + name: faker.name.findName(), + active: true, + email: faker.internet.email(), + }, + attrs + ) + ); }, - userConnection: attrs => { - return new UserConnection(merge({ - type: 'message', - created_at: new Date(), - updated_at: new Date() - }, attrs)) + userConnection: (attrs) => { + return new UserConnection( + merge( + { + type: "message", + created_at: new Date(), + updated_at: new Date(), + }, + attrs + ) + ); }, - network: attrs => { - return new Network(merge({ - name: faker.company.companyName(), - slug: faker.lorem.slug(5) - }, attrs)) + network: (attrs) => { + return new Network( + merge( + { + name: faker.company.companyName(), + slug: faker.lorem.slug(5), + }, + attrs + ) + ); }, - tag: attrs => { - return new Tag(merge({ - name: faker.random.words(3).replace(/ /g, '-').toLowerCase() - }, attrs)) + tag: (attrs) => { + return new Tag( + merge( + { + name: faker.random.words(3).replace(/ /g, "-").toLowerCase(), + }, + attrs + ) + ); }, - media: attrs => { - return new Media(merge({ - position: 0, - type: 'image', - url: faker.internet.url() - }, attrs)) + media: (attrs) => { + return new Media( + merge( + { + position: 0, + type: "image", + url: faker.internet.url(), + }, + attrs + ) + ); }, - skill: attrs => { - return new Skill(merge({ - name: faker.lorem.word() - }, attrs)) + skill: (attrs) => { + return new Skill( + merge( + { + name: faker.lorem.word(), + }, + attrs + ) + ); }, - device: attrs => { - return new Device(merge({ - token: faker.random.uuid() - }, attrs)) + device: (attrs) => { + return new Device( + merge( + { + token: faker.random.uuid(), + }, + attrs + ) + ); }, - activity: attrs => { - return new Activity(attrs) + activity: (attrs) => { + return new Activity(attrs); }, - notification: attrs => { - return new Notification(attrs) + notification: (attrs) => { + return new Notification(attrs); }, - stripeAccount: attrs => { - return new StripeAccount(merge({ - stripe_account_external_id: faker.random.uuid(), - refresh_token: faker.random.uuid() - }, attrs)) + stripeAccount: (attrs) => { + return new StripeAccount( + merge( + { + stripe_account_external_id: faker.random.uuid(), + refresh_token: faker.random.uuid(), + }, + attrs + ) + ); }, mock: { request: function () { return { allParams: function () { - return this.params + return this.params; }, param: function (name) { - return this.params[name] + return this.params[name]; }, session: {}, query: {}, @@ -121,81 +186,98 @@ module.exports = { params: {}, headers: {}, __: function (key) { - i18n.init(this) - return i18n.__(key) + i18n.init(this); + return i18n.__(key); }, login: function (userId) { extend(this.session, { authenticated: true, version: UserSession.version, - userId: userId - }) + userId: userId, + }); }, pipe: function (out) { - const buf = new ReadableStreamBuffer() - buf.put(this.body) - buf.stop() - return buf.pipe(out) - } - } + const buf = new ReadableStreamBuffer(); + buf.put(this.body); + buf.stop(); + return buf.pipe(out); + }, + }; }, response: function () { - var self + let self; - const setBody = () => spy(data => { self.body = data }) + const setBody = () => + spy((data) => { + self.body = data; + }); self = { ok: setBody(), - serverError: spy(err => { - self.statusCode = 500 - self.body = err + serverError: spy((err) => { + self.statusCode = 500; + self.body = err; }), badRequest: setBody(), notFound: setBody(), forbidden: setBody(), - status: spy(value => { - self.statusCode = value - return self + status: spy((value) => { + self.statusCode = value; + return self; }), send: setBody(), - redirect: spy(url => { - self.redirected = url + redirect: spy((url) => { + self.redirected = url; }), view: spy((template, attrs) => { - self.viewTemplate = template - self.viewAttrs = attrs + self.viewTemplate = template; + self.viewAttrs = attrs; }), locals: {}, headers: {}, setHeader: spy((key, val) => { - self.headers[key] = val - }) - } - return self + self.headers[key] = val; + }), + }; + return self; }, - model: attrs => { - return merge({ - get: function (name) { return this[name] }, - pick: function () { return pick(this, arguments) }, - load: () => Promise.resolve(), - toJSON: () => { - return Object.assign(reduce(attrs.relations, (m, v, k) => { - m[k] = v.toJSON() - return m - }, {}), attrs) - }, - attributes: attrs - }, attrs) + model: (attrs) => { + return merge( + { + get: function (name) { + return this[name]; + }, + pick: function () { + return pick(this, arguments); + }, + load: () => Promise.resolve(), + toJSON: () => { + return Object.assign( + reduce( + attrs.relations, + (m, v, k) => { + m[k] = v.toJSON(); + return m; + }, + {} + ), + attrs + ); + }, + attributes: attrs, + }, + attrs + ); }, - collection: list => { + collection: (list) => { return { first: () => list[0], - toJSON: () => list.map(model => model.toJSON()), - map: fn => list.map(model => fn(model)) - } - } - } -} + toJSON: () => list.map((model) => model.toJSON()), + map: (fn) => list.map((model) => fn(model)), + }; + }, + }, +}; diff --git a/test/setup/helpers.js b/test/setup/helpers.js index b8efac37c..da02386a4 100644 --- a/test/setup/helpers.js +++ b/test/setup/helpers.js @@ -1,43 +1,56 @@ -import nock from 'nock' +import nock from "nock"; -const isSpy = (func) => !!func.__spy +const isSpy = (func) => !!func.__spy; export const spyify = (object, methodName, callback = () => {}) => { - if (!isSpy(object[methodName])) object['_original' + methodName] = object[methodName] + if (!isSpy(object[methodName])) + object["_original" + methodName] = object[methodName]; object[methodName] = spy(function () { - const ret = object['_original' + methodName](...arguments) - if (callback) return callback(...arguments, ret) - return ret - }) -} + const ret = object["_original" + methodName](...arguments); + if (callback) return callback(...arguments, ret); + return ret; + }); +}; export const mockify = (object, methodName, func) => { - if (!isSpy(object[methodName])) object['_original' + methodName] = object[methodName] - object[methodName] = spy(func) -} + if (!isSpy(object[methodName])) + object["_original" + methodName] = object[methodName]; + object[methodName] = spy(func); +}; export const unspyify = (object, methodName) => { - if (object['_original' + methodName]) { - object[methodName] = object['_original' + methodName] + if (object["_original" + methodName]) { + object[methodName] = object["_original" + methodName]; } -} +}; export const wait = (millis, callback) => - new Promise(resolve => setTimeout(() => - resolve(callback ? callback() : null), millis)) + new Promise((resolve) => + setTimeout(() => resolve(callback ? callback() : null), millis) + ); // this is data for a 1x1 png -const pixel = new Buffer('89504e470d0a1a0a0000000d494844520000000100000001010300000025db56ca00000003504c5445ff4d005c35387f0000000174524e53ccd23456fd0000000a49444154789c636200000006000336377ca80000000049454e44ae426082', 'hex') - -export const stubGetImageSize = url => { - const u = require('url').parse(url) - const host = `${u.protocol}//${u.host}` +const pixel = new Buffer( + "89504e470d0a1a0a0000000d494844520000000100000001010300000025db56ca00000003504c5445ff4d005c35387f0000000174524e53ccd23456fd0000000a49444154789c636200000006000336377ca80000000049454e44ae426082", + "hex" +); + +export const stubGetImageSize = (url) => { + const u = require("url").parse(url); + const host = `${u.protocol}//${u.host}`; // console.log(`stubbing ${host}${u.pathname}`) - return nock(host).get(u.pathname).reply(200, pixel) -} - -export function expectEqualQuery (actual, expected, { isCollection = true } = {}) { - const reformatted = expected.replace(/\n\s*/g, ' ').replace(/\( /g, '(').replace(/ \)/g, ')') - const query = isCollection ? actual.query() : actual - expect(query.toString()).to.equal(reformatted) + return nock(host).get(u.pathname).reply(200, pixel); +}; + +export function expectEqualQuery( + actual, + expected, + { isCollection = true } = {} +) { + const reformatted = expected + .replace(/\n\s*/g, " ") + .replace(/\( /g, "(") + .replace(/ \)/g, ")"); + const query = isCollection ? actual.query() : actual; + expect(query.toString()).to.equal(reformatted); } diff --git a/test/setup/index.js b/test/setup/index.js index 274c491b1..1b77001fd 100644 --- a/test/setup/index.js +++ b/test/setup/index.js @@ -1,87 +1,100 @@ -process.env.NODE_ENV = 'test' +import nock from "nock"; +import "./core"; -import nock from 'nock' -import './core' -var skiff = require('../../lib/skiff') -var fs = require('fs') -var path = require('path') -var Promise = require('bluebird') -var root = require('root-path') +process.env.NODE_ENV = "test"; +const skiff = require("../../lib/skiff"); +const fs = require("fs"); +const path = require("path"); +const Promise = require("bluebird"); +const root = require("root-path"); -var TestSetup = function () { - this.tables = [] - this.initialized = false -} +const TestSetup = function () { + this.tables = []; + this.initialized = false; +}; -var setup = new TestSetup() +const setup = new TestSetup(); before(function (done) { - this.timeout(30000) + this.timeout(30000); - var i18n = require('i18n') - i18n.configure(require(root('config/i18n')).i18n) - global.sails = skiff.sails + const i18n = require("i18n"); + i18n.configure(require(root("config/i18n")).i18n); + global.sails = skiff.sails; skiff.lift({ - log: {level: process.env.LOG_LEVEL || 'warn'}, + log: { level: process.env.LOG_LEVEL || "warn" }, silent: true, start: function () { - const { database } = bookshelf.knex.client.connectionSettings + const { database } = bookshelf.knex.client.connectionSettings; if (!database.match(/^test|test$/)) { - const error = new Error(`Invalid test database name "${database}". It must start or end with "test".`) - return done(error) + const error = new Error( + `Invalid test database name "${database}". It must start or end with "test".` + ); + return done(error); } - setup.initialized = true + setup.initialized = true; // add controllers to the global namespace; they would otherwise be excluded // since the sails "http" module is not being loaded in the test env - fs.readdirSync(root('api/controllers')).forEach(function (filename) { - if (path.extname(filename) === '.js') { - var modelName = path.basename(filename, '.js') - global[modelName] = require(root('api/controllers/' + modelName)) + fs.readdirSync(root("api/controllers")).forEach(function (filename) { + if (path.extname(filename) === ".js") { + const modelName = path.basename(filename, ".js"); + global[modelName] = require(root("api/controllers/" + modelName)); } - }) + }); - setup.createSchema() - .then(() => done()) - .catch(done) - } - }) -}) + setup + .createSchema() + .then(() => done()) + .catch(done); + }, + }); +}); -after(skiff.lower) +after(skiff.lower); -afterEach(() => nock.cleanAll()) +afterEach(() => nock.cleanAll()); TestSetup.prototype.createSchema = function () { - if (!this.initialized) throw new Error('not initialized') - return bookshelf.transaction(trx => { - return bookshelf.knex.raw('drop schema public cascade').transacting(trx) - .then(() => bookshelf.knex.raw('create schema public').transacting(trx)) - .then(() => { - var script = fs.readFileSync(root('migrations/schema.sql')).toString() - return script.split(/\n/) - .filter(line => !line.startsWith('--')) - .join(' ') - .replace(/\s+/g, ' ') - .split(/;\s?/) - .map(line => line.trim()) - .filter(line => line !== '') - }) - .each(command => { - if (command.startsWith('CREATE TABLE')) { - this.tables.push(command.split(' ')[2]) - } - return bookshelf.knex.raw(command).transacting(trx) - }) - }) // transaction -} + if (!this.initialized) throw new Error("not initialized"); + return bookshelf.transaction((trx) => { + return bookshelf.knex + .raw("drop schema public cascade") + .transacting(trx) + .then(() => bookshelf.knex.raw("create schema public").transacting(trx)) + .then(() => { + const script = fs + .readFileSync(root("migrations/schema.sql")) + .toString(); + return script + .split(/\n/) + .filter((line) => !line.startsWith("--")) + .join(" ") + .replace(/\s+/g, " ") + .split(/;\s?/) + .map((line) => line.trim()) + .filter((line) => line !== ""); + }) + .each((command) => { + if (command.startsWith("CREATE TABLE")) { + this.tables.push(command.split(" ")[2]); + } + return bookshelf.knex.raw(command).transacting(trx); + }); + }); // transaction +}; TestSetup.prototype.clearDb = function () { - if (!this.initialized) throw new Error('not initialized') - return bookshelf.knex.transaction(trx => trx.raw('set constraints all deferred') - .then(() => Promise.map(this.tables, table => trx.raw('delete from ' + table)))) -} + if (!this.initialized) throw new Error("not initialized"); + return bookshelf.knex.transaction((trx) => + trx + .raw("set constraints all deferred") + .then(() => + Promise.map(this.tables, (table) => trx.raw("delete from " + table)) + ) + ); +}; -module.exports = setup +module.exports = setup; diff --git a/test/setup/models.js b/test/setup/models.js index 4cd521e79..d0f7407b5 100644 --- a/test/setup/models.js +++ b/test/setup/models.js @@ -1,3 +1,3 @@ // just load models if they're not already loaded -import { init } from '../../api/models' -before(() => global.bookshelf || init()) +import { init } from "../../api/models"; +before(() => global.bookshelf || init()); diff --git a/test/unit/controllers/CommentController.test.js b/test/unit/controllers/CommentController.test.js index cbb55effb..7e53b9056 100644 --- a/test/unit/controllers/CommentController.test.js +++ b/test/unit/controllers/CommentController.test.js @@ -1,112 +1,138 @@ -const rootPath = require('root-path') -const setup = require(rootPath('test/setup')) -const factories = require(rootPath('test/setup/factories')) -const CommentController = require(rootPath('api/controllers/CommentController')) +const rootPath = require("root-path"); +const setup = require(rootPath("test/setup")); +const factories = require(rootPath("test/setup/factories")); +const CommentController = require(rootPath( + "api/controllers/CommentController" +)); -describe('CommentController', function () { - var fixtures, req, res +describe("CommentController", function () { + let fixtures, req, res; before(() => - setup.clearDb().then(() => Promise.props({ - u1: factories.user().save(), - u2: factories.user().save(), - u3: factories.user().save(), - p1: factories.post().save(), - p2: factories.post().save(), - c1: factories.community().save(), - cm1: factories.comment().save() - })) - .then(props => { - fixtures = props - }) - .then(() => Promise.join( - fixtures.p1.communities().attach(fixtures.c1.id), - fixtures.p1.comments().create(fixtures.cm1), - fixtures.c1.addGroupMembers([fixtures.u1.id]) - ))) + setup + .clearDb() + .then(() => + Promise.props({ + u1: factories.user().save(), + u2: factories.user().save(), + u3: factories.user().save(), + p1: factories.post().save(), + p2: factories.post().save(), + c1: factories.community().save(), + cm1: factories.comment().save(), + }) + ) + .then((props) => { + fixtures = props; + }) + .then(() => + Promise.join( + fixtures.p1.communities().attach(fixtures.c1.id), + fixtures.p1.comments().create(fixtures.cm1), + fixtures.c1.addGroupMembers([fixtures.u1.id]) + ) + ) + ); beforeEach(() => { - req = factories.mock.request() - res = factories.mock.response() - }) + req = factories.mock.request(); + res = factories.mock.response(); + }); - describe('#createFromEmail', function () { + describe("#createFromEmail", function () { beforeEach(() => { - req.params['stripped-text'] = 'foo bar baz' - req.params['To'] = 'wa' - }) + req.params["stripped-text"] = "foo bar baz"; + req.params.To = "wa"; + }); - it('raises an error with an invalid address', function () { - const send = spy(() => {}) - res.status = spy(() => ({send})) - CommentController.createFromEmail(req, res) - expect(res.status).to.have.been.called.with(422) - expect(send).to.have.been.called.with('Invalid reply address: wa') - }) + it("raises an error with an invalid address", function () { + const send = spy(() => {}); + res.status = spy(() => ({ send })); + CommentController.createFromEmail(req, res); + expect(res.status).to.have.been.called.with(422); + expect(send).to.have.been.called.with("Invalid reply address: wa"); + }); - it('creates a comment with created_from=email', function () { - Analytics.track = spy(Analytics.track) - req.params.To = Email.postReplyAddress(fixtures.p1.id, fixtures.u3.id) + it("creates a comment with created_from=email", function () { + Analytics.track = spy(Analytics.track); + req.params.To = Email.postReplyAddress(fixtures.p1.id, fixtures.u3.id); return CommentController.createFromEmail(req, res) - .then(function () { - expect(Analytics.track).to.have.been.called() - expect(res.ok).to.have.been.called() - return fixtures.p1.comments().fetch() - }) - .then(function (comments) { - var comment = comments.find(c => c.get('post_id') === fixtures.p1.id) - expect(comment).to.exist - expect(comment.get('text')).to.equal('

foo bar baz

\n') - expect(comment.get('user_id')).to.equal(fixtures.u3.id) - expect(comment.get('created_from')).to.equal('email') - }) - }) + .then(function () { + expect(Analytics.track).to.have.been.called(); + expect(res.ok).to.have.been.called(); + return fixtures.p1.comments().fetch(); + }) + .then(function (comments) { + const comment = comments.find( + (c) => c.get("post_id") === fixtures.p1.id + ); + expect(comment).to.exist; + expect(comment.get("text")).to.equal("

foo bar baz

\n"); + expect(comment.get("user_id")).to.equal(fixtures.u3.id); + expect(comment.get("created_from")).to.equal("email"); + }); + }); it("doesn't use markdown when the comment is for a thread", () => { - req.params.To = Email.postReplyAddress(fixtures.p1.id, fixtures.u3.id) - return fixtures.p1.save({type: Post.Type.THREAD}, {patch: true}) - .then(() => CommentController.createFromEmail(req, res)) - .then(() => fixtures.p1.comments().fetch()) - .then(comments => { - var comment = comments.find(c => c.get('post_id') === fixtures.p1.id) - expect(comment).to.exist - expect(comment.get('text')).to.equal('foo bar baz') - }) - }) - }) + req.params.To = Email.postReplyAddress(fixtures.p1.id, fixtures.u3.id); + return fixtures.p1 + .save({ type: Post.Type.THREAD }, { patch: true }) + .then(() => CommentController.createFromEmail(req, res)) + .then(() => fixtures.p1.comments().fetch()) + .then((comments) => { + const comment = comments.find( + (c) => c.get("post_id") === fixtures.p1.id + ); + expect(comment).to.exist; + expect(comment.get("text")).to.equal("foo bar baz"); + }); + }); + }); - describe('createBatchFromEmailForm', () => { - var p1, p2, p3 + describe("createBatchFromEmailForm", () => { + let p1, p2, p3; beforeEach(() => { - p1 = factories.post({user_id: fixtures.u1.id}) - p2 = factories.post({user_id: fixtures.u2.id}) - p3 = factories.post({user_id: fixtures.u1.id}) - res.serverError = spy() + p1 = factories.post({ user_id: fixtures.u1.id }); + p2 = factories.post({ user_id: fixtures.u2.id }); + p3 = factories.post({ user_id: fixtures.u1.id }); + res.serverError = spy(); res.locals.tokenData = { communityId: fixtures.c1.id, - userId: fixtures.u1.id - } - return Promise.join(p1.save(), p2.save(), p3.save()) - .then(() => Promise.join( - p1.communities().attach(fixtures.c1), - p2.communities().attach(fixtures.c1), - p3.communities().attach(fixtures.c1))) - }) + userId: fixtures.u1.id, + }; + return Promise.join(p1.save(), p2.save(), p3.save()).then(() => + Promise.join( + p1.communities().attach(fixtures.c1), + p2.communities().attach(fixtures.c1), + p3.communities().attach(fixtures.c1) + ) + ); + }); - it('creates comments', () => { - req.params[`post-${p1.id}`] = 'Reply to first post' - req.params[`post-${p2.id}`] = 'Reply to second post' + it("creates comments", () => { + req.params[`post-${p1.id}`] = "Reply to first post"; + req.params[`post-${p2.id}`] = "Reply to second post"; return CommentController.createBatchFromEmailForm(req, res) - .then(() => Promise.join(p1.load('comments'), p2.load('comments'), p3.load('comments'))) - .then(() => { - expect(p1.relations.comments.length).to.equal(1) - expect(p1.relations.comments.first().get('text')).to.equal('

Reply to first post

\n') - expect(p2.relations.comments.length).to.equal(1) - expect(p2.relations.comments.first().get('text')).to.equal('

Reply to second post

\n') - expect(p3.relations.comments.length).to.equal(0) - }) - }) - }) -}) + .then(() => + Promise.join( + p1.load("comments"), + p2.load("comments"), + p3.load("comments") + ) + ) + .then(() => { + expect(p1.relations.comments.length).to.equal(1); + expect(p1.relations.comments.first().get("text")).to.equal( + "

Reply to first post

\n" + ); + expect(p2.relations.comments.length).to.equal(1); + expect(p2.relations.comments.first().get("text")).to.equal( + "

Reply to second post

\n" + ); + expect(p3.relations.comments.length).to.equal(0); + }); + }); + }); +}); diff --git a/test/unit/controllers/MobileAppController.test.js b/test/unit/controllers/MobileAppController.test.js index a3562328e..c79878ea5 100644 --- a/test/unit/controllers/MobileAppController.test.js +++ b/test/unit/controllers/MobileAppController.test.js @@ -1,118 +1,125 @@ -var root = require('root-path') -require(root('test/setup')) -var factories = require(root('test/setup/factories')) -var MobileAppController = require(root('api/controllers/MobileAppController')) - -const mockIosStoreUrl = 'http://get-my-ios-app.com/lol' -const mockAndroidStoreUrl = 'http://get-my-android-app.com/lol' - -describe('MobileAppController', () => { - var req, res, tmpVar1, tmpVar2, tmpVar3 +const root = require("root-path"); +require(root("test/setup")); +const factories = require(root("test/setup/factories")); +const MobileAppController = require(root( + "api/controllers/MobileAppController" +)); + +const mockIosStoreUrl = "http://get-my-ios-app.com/lol"; +const mockAndroidStoreUrl = "http://get-my-android-app.com/lol"; + +describe("MobileAppController", () => { + let req, res, tmpVar1, tmpVar2, tmpVar3; beforeEach(() => { - req = factories.mock.request() - res = factories.mock.response() - tmpVar1 = process.env.IOS_APP_STORE_URL - tmpVar2 = process.env.ANDROID_APP_STORE_URL - tmpVar3 = process.env.MINIMUM_SUPPORTED_MOBILE_VERSION - process.env.IOS_APP_STORE_URL = mockIosStoreUrl - process.env.ANDROID_APP_STORE_URL = mockAndroidStoreUrl - process.env.MINIMUM_SUPPORTED_MOBILE_VERSION = '2.0.0' - }) + req = factories.mock.request(); + res = factories.mock.response(); + tmpVar1 = process.env.IOS_APP_STORE_URL; + tmpVar2 = process.env.ANDROID_APP_STORE_URL; + tmpVar3 = process.env.MINIMUM_SUPPORTED_MOBILE_VERSION; + process.env.IOS_APP_STORE_URL = mockIosStoreUrl; + process.env.ANDROID_APP_STORE_URL = mockAndroidStoreUrl; + process.env.MINIMUM_SUPPORTED_MOBILE_VERSION = "2.0.0"; + }); afterEach(() => { - process.env.IOS_APP_STORE_URL = tmpVar1 - process.env.ANDROID_APP_STORE_URL = tmpVar2 - process.env.MINIMUM_SUPPORTED_MOBILE_VERSION = tmpVar3 - }) - - describe('updateInfo', () => { - it('returns the force update object', () => { - var expected = { - type: 'force', - title: 'A new version of the app is available', - message: 'The version you are using is no longer compatible with the site. Please go to the App Store now to update', - iTunesItemIdentifier: '1002185140' - } - MobileAppController.updateInfo({}, res) - expect(res.body).to.deep.equal(expected) - }) - }) - - describe('checkShouldUpdate', () => { - it('returns the expected object for ios suggest update', () => { - var expected = { - type: 'suggest', - title: 'An update is available', - message: 'The version you are using is no longer up to date. Please go to the App Store to update.', - link: mockIosStoreUrl - } - - req.params = {'ios-version': 'test-suggest'} - MobileAppController.checkShouldUpdate(req, res) - expect(res.body).to.deep.equal(expected) - }) - - it('returns the expected object for ios force update', () => { - var expected = { - type: 'force', - title: 'A new version of the app is available', - message: 'The version you are using is no longer supported. Please go to the App Store now to update.', - link: mockIosStoreUrl - } - - req.params = {'ios-version': '1.9'} - MobileAppController.checkShouldUpdate(req, res) - expect(res.body).to.deep.equal(expected) - }) - - it('returns the expected object for android suggest update', () => { - var expected = { - type: 'suggest', - title: 'An update is available', - message: 'The version you are using is no longer up to date. Please go to the Play Store to update.', - link: mockAndroidStoreUrl - } - - req.params = {'android-version': 'test-suggest'} - MobileAppController.checkShouldUpdate(req, res) - expect(res.body).to.deep.equal(expected) - }) - - it('returns the expected object for android force update', () => { - var expected = { - type: 'force', - title: 'A new version of the app is available', - message: 'The version you are using is no longer supported. Please go to the Play Store now to update.', - link: mockAndroidStoreUrl - } - - req.params = {'android-version': '1.9'} - MobileAppController.checkShouldUpdate(req, res) - expect(res.body).to.deep.equal(expected) - }) - - it('returns success for android version 2.0', () => { - req.params = {'android-version': '2.0'} - MobileAppController.checkShouldUpdate(req, res) - expect(res.body.success).to.equal(true) - }) - - it('returns success for ios version 2.0', () => { - req.params = {'ios-version': '2.0'} - MobileAppController.checkShouldUpdate(req, res) - expect(res.body.success).to.equal(true) - }) - - it('returns success for android version > 2.0', () => { - req.params = {'android-version': '2.0.1'} - MobileAppController.checkShouldUpdate(req, res) - expect(res.body.success).to.equal(true) - }) - - it('returns success for ios version > 2.0', () => { - req.params = {'ios-version': '2.0.1'} - MobileAppController.checkShouldUpdate(req, res) - expect(res.body.success).to.equal(true) - }) - }) -}) + process.env.IOS_APP_STORE_URL = tmpVar1; + process.env.ANDROID_APP_STORE_URL = tmpVar2; + process.env.MINIMUM_SUPPORTED_MOBILE_VERSION = tmpVar3; + }); + + describe("updateInfo", () => { + it("returns the force update object", () => { + const expected = { + type: "force", + title: "A new version of the app is available", + message: + "The version you are using is no longer compatible with the site. Please go to the App Store now to update", + iTunesItemIdentifier: "1002185140", + }; + MobileAppController.updateInfo({}, res); + expect(res.body).to.deep.equal(expected); + }); + }); + + describe("checkShouldUpdate", () => { + it("returns the expected object for ios suggest update", () => { + const expected = { + type: "suggest", + title: "An update is available", + message: + "The version you are using is no longer up to date. Please go to the App Store to update.", + link: mockIosStoreUrl, + }; + + req.params = { "ios-version": "test-suggest" }; + MobileAppController.checkShouldUpdate(req, res); + expect(res.body).to.deep.equal(expected); + }); + + it("returns the expected object for ios force update", () => { + const expected = { + type: "force", + title: "A new version of the app is available", + message: + "The version you are using is no longer supported. Please go to the App Store now to update.", + link: mockIosStoreUrl, + }; + + req.params = { "ios-version": "1.9" }; + MobileAppController.checkShouldUpdate(req, res); + expect(res.body).to.deep.equal(expected); + }); + + it("returns the expected object for android suggest update", () => { + const expected = { + type: "suggest", + title: "An update is available", + message: + "The version you are using is no longer up to date. Please go to the Play Store to update.", + link: mockAndroidStoreUrl, + }; + + req.params = { "android-version": "test-suggest" }; + MobileAppController.checkShouldUpdate(req, res); + expect(res.body).to.deep.equal(expected); + }); + + it("returns the expected object for android force update", () => { + const expected = { + type: "force", + title: "A new version of the app is available", + message: + "The version you are using is no longer supported. Please go to the Play Store now to update.", + link: mockAndroidStoreUrl, + }; + + req.params = { "android-version": "1.9" }; + MobileAppController.checkShouldUpdate(req, res); + expect(res.body).to.deep.equal(expected); + }); + + it("returns success for android version 2.0", () => { + req.params = { "android-version": "2.0" }; + MobileAppController.checkShouldUpdate(req, res); + expect(res.body.success).to.equal(true); + }); + + it("returns success for ios version 2.0", () => { + req.params = { "ios-version": "2.0" }; + MobileAppController.checkShouldUpdate(req, res); + expect(res.body.success).to.equal(true); + }); + + it("returns success for android version > 2.0", () => { + req.params = { "android-version": "2.0.1" }; + MobileAppController.checkShouldUpdate(req, res); + expect(res.body.success).to.equal(true); + }); + + it("returns success for ios version > 2.0", () => { + req.params = { "ios-version": "2.0.1" }; + MobileAppController.checkShouldUpdate(req, res); + expect(res.body.success).to.equal(true); + }); + }); +}); diff --git a/test/unit/controllers/PaymentController.test.js b/test/unit/controllers/PaymentController.test.js index f5c609638..f08cb5ad6 100644 --- a/test/unit/controllers/PaymentController.test.js +++ b/test/unit/controllers/PaymentController.test.js @@ -1,35 +1,42 @@ -const rootPath = require('root-path') -const setup = require(rootPath('test/setup')) -const factories = require(rootPath('test/setup/factories')) -require(rootPath('api/controllers/PaymentController')) -import mock from 'mock-require' +import mock from "mock-require"; +const rootPath = require("root-path"); +const setup = require(rootPath("test/setup")); +const factories = require(rootPath("test/setup/factories")); +require(rootPath("api/controllers/PaymentController")); - -describe('PaymentController', () => { - var req, res, registerStripeAccount, PaymentController - const userId = 1234 - const code = 'abcd' +describe("PaymentController", () => { + let req, res, registerStripeAccount, PaymentController; + const userId = 1234; + const code = "abcd"; before(() => { - registerStripeAccount = spy(() => new Promise((resolve) => { resolve() })) - mock('../../../api/graphql/mutations/user', { - registerStripeAccount - }) + registerStripeAccount = spy( + () => + new Promise((resolve) => { + resolve(); + }) + ); + mock("../../../api/graphql/mutations/user", { + registerStripeAccount, + }); - PaymentController = mock.reRequire('../../../api/controllers/PaymentController') - req = factories.mock.request() - req.session = {userId} - req.params.code = code - res = factories.mock.response() - }) + PaymentController = mock.reRequire( + "../../../api/controllers/PaymentController" + ); + req = factories.mock.request(); + req.session = { userId }; + req.params.code = code; + res = factories.mock.response(); + }); - describe('#registerStripe', () => { - it('calls registerStripeAccount and redirects', () => { - return PaymentController.registerStripe(req, res) - .then(() => { - expect(registerStripeAccount).to.have.been.called.with(userId, code) - expect(res.redirect).to.have.been.called.with(Frontend.Route.evo.paymentSettings({registered: 'success'})) - }) - }) - }) -}) \ No newline at end of file + describe("#registerStripe", () => { + it("calls registerStripeAccount and redirects", () => { + return PaymentController.registerStripe(req, res).then(() => { + expect(registerStripeAccount).to.have.been.called.with(userId, code); + expect(res.redirect).to.have.been.called.with( + Frontend.Route.evo.paymentSettings({ registered: "success" }) + ); + }); + }); + }); +}); diff --git a/test/unit/controllers/PostController.test.js b/test/unit/controllers/PostController.test.js index 6037af72d..50db2bb25 100644 --- a/test/unit/controllers/PostController.test.js +++ b/test/unit/controllers/PostController.test.js @@ -1,104 +1,110 @@ -import { stubGetImageSize } from '../../setup/helpers' -import nock from 'nock' -const root = require('root-path') -const setup = require(root('test/setup')) -const factories = require(root('test/setup/factories')) -const PostController = require(root('api/controllers/PostController')) +import { stubGetImageSize } from "../../setup/helpers"; +import nock from "nock"; +const root = require("root-path"); +const setup = require(root("test/setup")); +const factories = require(root("test/setup/factories")); +const PostController = require(root("api/controllers/PostController")); -const testImageUrl = 'http://cdn.hylo.com/misc/hylo-logo-teal-on-transparent.png' -const testImageUrl2 = 'http://cdn.hylo.com/misc/hylo-logo-white-on-teal-circle.png' +const testImageUrl = + "http://cdn.hylo.com/misc/hylo-logo-teal-on-transparent.png"; +const testImageUrl2 = + "http://cdn.hylo.com/misc/hylo-logo-white-on-teal-circle.png"; -describe('PostController', () => { - var fixtures, req, res +describe("PostController", () => { + let fixtures, req, res; before(() => - setup.clearDb() - .then(() => Promise.props({ - u1: new User({name: 'U1', email: 'a@b.c'}).save(), - u2: new User({name: 'U2', email: 'b@b.c', active: true}).save(), - u3: new User({name: 'U3', email: 'c@b.c'}).save(), - p1: new Post({name: 'P1'}).save(), - c1: new Community({name: 'C1', slug: 'c1'}).save() - })) - .then(props => { - fixtures = props - }) - .then(() => fixtures.u2.joinCommunity(fixtures.c1)) - .then(() => fixtures.u1.joinCommunity(fixtures.c1))) + setup + .clearDb() + .then(() => + Promise.props({ + u1: new User({ name: "U1", email: "a@b.c" }).save(), + u2: new User({ name: "U2", email: "b@b.c", active: true }).save(), + u3: new User({ name: "U3", email: "c@b.c" }).save(), + p1: new Post({ name: "P1" }).save(), + c1: new Community({ name: "C1", slug: "c1" }).save(), + }) + ) + .then((props) => { + fixtures = props; + }) + .then(() => fixtures.u2.joinCommunity(fixtures.c1)) + .then(() => fixtures.u1.joinCommunity(fixtures.c1)) + ); beforeEach(() => { - stubGetImageSize(testImageUrl) - stubGetImageSize(testImageUrl2) - req = factories.mock.request() - res = factories.mock.response() - req.login(fixtures.u1.id) - }) + stubGetImageSize(testImageUrl); + stubGetImageSize(testImageUrl2); + req = factories.mock.request(); + res = factories.mock.response(); + req.login(fixtures.u1.id); + }); - before(() => nock.disableNetConnect()) - after(() => nock.enableNetConnect()) + before(() => nock.disableNetConnect()); + after(() => nock.enableNetConnect()); - describe('.createFromEmailForm', () => { - before(() => Tag.forge({name: 'request'}).save()) + describe(".createFromEmailForm", () => { + before(() => Tag.forge({ name: "request" }).save()); - it('works', () => { + it("works", () => { Object.assign(req.params, { - type: 'request', - name: 'a penguin', - description: 'I just love the tuxedo' - }) + type: "request", + name: "a penguin", + description: "I just love the tuxedo", + }); res.locals.tokenData = { communityId: fixtures.c1.id, - userId: fixtures.u1.id - } + userId: fixtures.u1.id, + }; return PostController.createFromEmailForm(req, res) - .then(() => { - const postId = res.redirected.match(/p\/(\d+)/)[1] - return Post.find(postId, {withRelated: ['tags', 'communities']}) - }) - .then(post => { - expect(post.get('name')).to.equal("I'm looking for a penguin") - expect(post.get('description')).to.equal('I just love the tuxedo') - expect(post.get('user_id')).to.equal(fixtures.u1.id) - expect(post.get('created_from')).to.equal('email_form') - const tag = post.relations.tags.first() - expect(tag.get('name')).to.equal('request') - const community = post.relations.communities.first() - expect(community.id).to.equal(fixtures.c1.id) - }) - }) + .then(() => { + const postId = res.redirected.match(/p\/(\d+)/)[1]; + return Post.find(postId, { withRelated: ["tags", "communities"] }); + }) + .then((post) => { + expect(post.get("name")).to.equal("I'm looking for a penguin"); + expect(post.get("description")).to.equal("I just love the tuxedo"); + expect(post.get("user_id")).to.equal(fixtures.u1.id); + expect(post.get("created_from")).to.equal("email_form"); + const tag = post.relations.tags.first(); + expect(tag.get("name")).to.equal("request"); + const community = post.relations.communities.first(); + expect(community.id).to.equal(fixtures.c1.id); + }); + }); - describe('for an inactive community', () => { - let c2 + describe("for an inactive community", () => { + let c2; beforeEach(() => { Object.assign(req.params, { - type: 'request', - name: 'a zebra', - description: 'I just love the stripes' - }) - c2 = factories.community() - c2.set('active', false) - return c2.save() - }) + type: "request", + name: "a zebra", + description: "I just love the stripes", + }); + c2 = factories.community(); + c2.set("active", false); + return c2.save(); + }); - it('does not work', () => { + it("does not work", () => { res.locals.tokenData = { communityId: c2.id, - userId: fixtures.u1.id - } + userId: fixtures.u1.id, + }; - return PostController.createFromEmailForm(req, res) - .then(() => { - expect(res.redirected).to.exist - const url = require('url').parse(res.redirected, true) + return PostController.createFromEmailForm(req, res).then(() => { + expect(res.redirected).to.exist; + const url = require("url").parse(res.redirected, true); expect(url.query).to.deep.equal({ - notification: 'Your post was not created. That community no longer exists.', - error: '1' - }) - }) - }) - }) - }) -}) + notification: + "Your post was not created. That community no longer exists.", + error: "1", + }); + }); + }); + }); + }); +}); diff --git a/test/unit/controllers/SessionController.test.js b/test/unit/controllers/SessionController.test.js index beeed857f..c752ad01c 100644 --- a/test/unit/controllers/SessionController.test.js +++ b/test/unit/controllers/SessionController.test.js @@ -1,326 +1,362 @@ -const setup = require('../../setup') -const SessionController = require('../../../api/controllers/SessionController') -var factories = require('../../setup/factories') -var passport = require('passport') +const setup = require("../../setup"); +const SessionController = require("../../../api/controllers/SessionController"); +const factories = require("../../setup/factories"); +const passport = require("passport"); -describe('SessionController.findUser', () => { - var u1, u2 - var findUser = SessionController.findUser +describe("SessionController.findUser", () => { + let u1, u2; + const findUser = SessionController.findUser; before(() => { - u1 = factories.user() - u2 = factories.user() - return Promise.all([u1.save(), u2.save()]) - }) - - describe('with no directly linked user', () => { - it('picks a user with matching email address', () => { - return findUser('facebook', u2.get('email'), 'foo') - .then(user => { - expect(user.id).to.equal(u2.id) - }) - }) - }) - - describe('with a directly linked user', () => { + u1 = factories.user(); + u2 = factories.user(); + return Promise.all([u1.save(), u2.save()]); + }); + + describe("with no directly linked user", () => { + it("picks a user with matching email address", () => { + return findUser("facebook", u2.get("email"), "foo").then((user) => { + expect(user.id).to.equal(u2.id); + }); + }); + }); + + describe("with a directly linked user", () => { before(() => { - return LinkedAccount.create(u1.id, {type: 'facebook', profile: {id: 'foo'}}) - }) + return LinkedAccount.create(u1.id, { + type: "facebook", + profile: { id: "foo" }, + }); + }); after(() => { - return LinkedAccount.query().where('user_id', u1.id).del() - }) - - it('returns that user, not one with a matching email address', () => { - return findUser('facebook', u2.get('email'), 'foo') - .then(user => { - expect(user.id).to.equal(u1.id) - }) - }) - }) -}) - -describe('SessionController.upsertLinkedAccount', () => { - var user, req, profile - const upsertLinkedAccount = SessionController.upsertLinkedAccount - const facebookUrl = 'http://facebook.com/foo' + return LinkedAccount.query().where("user_id", u1.id).del(); + }); + + it("returns that user, not one with a matching email address", () => { + return findUser("facebook", u2.get("email"), "foo").then((user) => { + expect(user.id).to.equal(u1.id); + }); + }); + }); +}); + +describe("SessionController.upsertLinkedAccount", () => { + let user, req, profile; + const upsertLinkedAccount = SessionController.upsertLinkedAccount; + const facebookUrl = "http://facebook.com/foo"; before(() => { profile = { - id: 'foo', + id: "foo", _json: { - link: facebookUrl - } - } - user = factories.user() - return user.save() - .then(() => { - req = {session: {userId: user.id}} - }) - }) - - describe('with a directly linked user ', () => { + link: facebookUrl, + }, + }; + user = factories.user(); + return user.save().then(() => { + req = { session: { userId: user.id } }; + }); + }); + + describe("with a directly linked user ", () => { before(() => { - return LinkedAccount.create(user.id, {type: 'facebook', profile: {id: profile.id}}) - }) + return LinkedAccount.create(user.id, { + type: "facebook", + profile: { id: profile.id }, + }); + }); after(() => { - return LinkedAccount.query().where('user_id', user.id).del() - }) - - it('updates the user facebook_url', () => { - return upsertLinkedAccount(req, 'facebook', profile) - .then(() => user.refresh()) - .then(() => { - expect(user.get('facebook_url')).to.equal(facebookUrl) - }) - }) - }) -}) - -describe('SessionController', function () { - var req, res, cat + return LinkedAccount.query().where("user_id", user.id).del(); + }); + + it("updates the user facebook_url", () => { + return upsertLinkedAccount(req, "facebook", profile) + .then(() => user.refresh()) + .then(() => { + expect(user.get("facebook_url")).to.equal(facebookUrl); + }); + }); + }); +}); + +describe("SessionController", function () { + let req, res, cat; before(() => { - req = factories.mock.request() - res = factories.mock.response() - }) + req = factories.mock.request(); + res = factories.mock.response(); + }); - describe('.create', function () { + describe(".create", function () { before(() => { _.extend(req, { params: { - email: 'iam@cat.org', - password: 'password' - } - }) + email: "iam@cat.org", + password: "password", + }, + }); - cat = new User({name: 'Cat', email: 'iam@cat.org', active: true}) + cat = new User({ name: "Cat", email: "iam@cat.org", active: true }); return cat.save().then(() => new LinkedAccount({ - provider_user_id: '$2a$10$UPh85nJvMSrm6gMPqYIS.OPhLjAMbZiFnlpjq1xrtoSBTyV6fMdJS', - provider_key: 'password', - user_id: cat.id - }).save()) - }) - - it('works with a valid username and password', function () { + provider_user_id: + "$2a$10$UPh85nJvMSrm6gMPqYIS.OPhLjAMbZiFnlpjq1xrtoSBTyV6fMdJS", + provider_key: "password", + user_id: cat.id, + }).save() + ); + }); + + it("works with a valid username and password", function () { return SessionController.create(req, res) - .then(() => User.find(cat.id)) - .then(user => { - expect(res.status).not.to.have.been.called() - expect(res.ok).to.have.been.called() - expect(req.session.userId).to.equal(cat.id) - expect(user.get('last_login_at').getTime()).to.be.closeTo(new Date().getTime(), 2000) - }) - }) - }) - - describe('.createWithToken', () => { - var user, token + .then(() => User.find(cat.id)) + .then((user) => { + expect(res.status).not.to.have.been.called(); + expect(res.ok).to.have.been.called(); + expect(req.session.userId).to.equal(cat.id); + expect(user.get("last_login_at").getTime()).to.be.closeTo( + new Date().getTime(), + 2000 + ); + }); + }); + }); + + describe(".createWithToken", () => { + let user, token; before(() => { - UserSession.login = spy(UserSession.login) - user = factories.user() - return user.save({created_at: new Date()}) - .then(() => user.generateToken()) - .then(t => token = t) - }) - - it('logs a user in and redirects (Web/GET request)', () => { - _.extend(req.params, {u: user.id, t: token}) - req.method = 'GET' - - return SessionController.createWithToken(req, res) - .then(() => { - expect(UserSession.login).to.have.been.called() - expect(res.redirect).to.have.been.called() - expect(res.redirected).to.equal(Frontend.Route.evo.passwordSetting()) - }) - }) + UserSession.login = spy(UserSession.login); + user = factories.user(); + return user + .save({ created_at: new Date() }) + .then(() => user.generateToken()) + .then((t) => (token = t)); + }); + + it("logs a user in and redirects (Web/GET request)", () => { + _.extend(req.params, { u: user.id, t: token }); + req.method = "GET"; + + return SessionController.createWithToken(req, res).then(() => { + expect(UserSession.login).to.have.been.called(); + expect(res.redirect).to.have.been.called(); + expect(res.redirected).to.equal(Frontend.Route.evo.passwordSetting()); + }); + }); it("logs a user in doesn't redirect (API/POST request)", () => { - _.extend(req.params, {u: user.id, t: token}) - req.method = 'POST' - - res = factories.mock.response() - return SessionController.createWithToken(req, res) - .then(() => { - expect(UserSession.login).to.have.been.called() - expect(res.redirect).not.to.have.been.called() - expect(res.ok).to.have.been.called() - }) - }) - - it('rejects an invalid token', () => { - var error - _.extend(req.params, {u: user.id, t: token + 'x'}) - res.send = spy(function (msg) { error = msg }) - - return SessionController.createWithToken(req, res) - .then(() => { - expect(res.send).to.have.been.called() - expect(error).to.equal('Link expired') - }) - }) - }) - - describe('.finishFacebookOAuth', () => { - var req, res, origPassportAuthenticate - - var mockProfile = { - displayName: 'Lawrence Wang', - email: 'l@lw.io', - emails: [ { value: 'l@lw.io' } ], - gender: 'male', - id: '100101', - name: 'Lawrence Wang', - profileUrl: 'http://www.facebook.com/100101', - provider: 'facebook' - } - - const expectMatchMockProfile = userId => { - return User.find(userId, {withRelated: 'linkedAccounts'}) - .then(user => { - var account = user.relations.linkedAccounts.first() - expect(account).to.exist - expect(account.get('provider_key')).to.equal('facebook') - expect(user.get('facebook_url')).to.equal(mockProfile.profileUrl) - expect(user.get('avatar_url')).to.equal('https://graph.facebook.com/100101/picture?type=large') - return user - }) - } + _.extend(req.params, { u: user.id, t: token }); + req.method = "POST"; + + res = factories.mock.response(); + return SessionController.createWithToken(req, res).then(() => { + expect(UserSession.login).to.have.been.called(); + expect(res.redirect).not.to.have.been.called(); + expect(res.ok).to.have.been.called(); + }); + }); + + it("rejects an invalid token", () => { + let error; + _.extend(req.params, { u: user.id, t: token + "x" }); + res.send = spy(function (msg) { + error = msg; + }); + + return SessionController.createWithToken(req, res).then(() => { + expect(res.send).to.have.been.called(); + expect(error).to.equal("Link expired"); + }); + }); + }); + + describe(".finishFacebookOAuth", () => { + let req, res, origPassportAuthenticate; + + const mockProfile = { + displayName: "Lawrence Wang", + email: "l@lw.io", + emails: [{ value: "l@lw.io" }], + gender: "male", + id: "100101", + name: "Lawrence Wang", + profileUrl: "http://www.facebook.com/100101", + provider: "facebook", + }; + + const expectMatchMockProfile = (userId) => { + return User.find(userId, { withRelated: "linkedAccounts" }).then( + (user) => { + const account = user.relations.linkedAccounts.first(); + expect(account).to.exist; + expect(account.get("provider_key")).to.equal("facebook"); + expect(user.get("facebook_url")).to.equal(mockProfile.profileUrl); + expect(user.get("avatar_url")).to.equal( + "https://graph.facebook.com/100101/picture?type=large" + ); + return user; + } + ); + }; before(() => { - origPassportAuthenticate = passport.authenticate - }) + origPassportAuthenticate = passport.authenticate; + }); beforeEach(() => { - req = factories.mock.request() - res = factories.mock.response() + req = factories.mock.request(); + res = factories.mock.response(); - UserSession.login = spy(UserSession.login) - User.create = spy(User.create) + UserSession.login = spy(UserSession.login); + User.create = spy(User.create); passport.authenticate = spy(function (strategy, callback) { - return () => callback(null, mockProfile) - }) + return () => callback(null, mockProfile); + }); - return setup.clearDb() - }) + return setup.clearDb(); + }); afterEach(() => { - passport.authenticate = origPassportAuthenticate - }) + passport.authenticate = origPassportAuthenticate; + }); - it('creates a new user', () => { + it("creates a new user", () => { return SessionController.finishFacebookOAuth(req, res) - .then(() => { - expect(UserSession.login).to.have.been.called() - expect(User.create).to.have.been.called() - expect(res.view).to.have.been.called() - expect(res.viewTemplate).to.equal('popupDone') - expect(res.viewAttrs.error).not.to.exist - - return User.find('l@lw.io', {withRelated: 'linkedAccounts'}) - }) - .then(user => { - expect(user).to.exist - expect(user.get('facebook_url')).to.equal('http://www.facebook.com/100101') - var account = user.relations.linkedAccounts.find(a => a.get('provider_key') === 'facebook') - expect(account).to.exist - }) - }) - - describe('with no email in the auth response', () => { - beforeEach(() => { - var profile = _.merge(_.cloneDeep(mockProfile), {email: null, emails: null}) - passport.authenticate = spy((strategy, callback) => () => callback(null, profile)) - }) - - it('sets an error in the view parameters', () => { - return SessionController.finishFacebookOAuth(req, res) .then(() => { - expect(UserSession.login).not.to.have.been.called() - expect(res.view).to.have.been.called() - expect(res.viewTemplate).to.equal('popupDone') - expect(res.viewAttrs.error).to.equal('no email') - }) - }) - }) + expect(UserSession.login).to.have.been.called(); + expect(User.create).to.have.been.called(); + expect(res.view).to.have.been.called(); + expect(res.viewTemplate).to.equal("popupDone"); + expect(res.viewAttrs.error).not.to.exist; - describe('with no user in the auth response', () => { + return User.find("l@lw.io", { withRelated: "linkedAccounts" }); + }) + .then((user) => { + expect(user).to.exist; + expect(user.get("facebook_url")).to.equal( + "http://www.facebook.com/100101" + ); + const account = user.relations.linkedAccounts.find( + (a) => a.get("provider_key") === "facebook" + ); + expect(account).to.exist; + }); + }); + + describe("with no email in the auth response", () => { + beforeEach(() => { + const profile = _.merge(_.cloneDeep(mockProfile), { + email: null, + emails: null, + }); + passport.authenticate = spy((strategy, callback) => () => + callback(null, profile) + ); + }); + + it("sets an error in the view parameters", () => { + return SessionController.finishFacebookOAuth(req, res).then(() => { + expect(UserSession.login).not.to.have.been.called(); + expect(res.view).to.have.been.called(); + expect(res.viewTemplate).to.equal("popupDone"); + expect(res.viewAttrs.error).to.equal("no email"); + }); + }); + }); + + describe("with no user in the auth response", () => { beforeEach(() => { passport.authenticate = spy(function (strategy, callback) { - return () => callback(null, null) - }) - }) + return () => callback(null, null); + }); + }); - it('sets an error in the view parameters', () => { - return SessionController.finishFacebookOAuth(req, res) - .then(() => { - expect(res.view).to.have.been.called() - expect(res.viewAttrs.error).to.equal('no user') - }) - }) - }) + it("sets an error in the view parameters", () => { + return SessionController.finishFacebookOAuth(req, res).then(() => { + expect(res.view).to.have.been.called(); + expect(res.viewAttrs.error).to.equal("no user"); + }); + }); + }); - describe('for an existing user', () => { - var user + describe("for an existing user", () => { + let user; beforeEach(() => { - user = factories.user() - mockProfile.email = user.get('email') - return user.save() - }) - - it('creates a new linked account', () => { - return SessionController.finishFacebookOAuth(req, res) - .then(() => expectMatchMockProfile(user.id)) - }) - - describe('with an existing Facebook account', () => { - beforeEach(() => LinkedAccount.create(user.id, {type: 'facebook', profile: {id: 'foo'}})) - - it('leaves the existing account unchanged', () => { - return SessionController.finishFacebookOAuth(req, res) - .then(() => user.load('linkedAccounts')) - .then(user => { - expect(user.relations.linkedAccounts.length).to.equal(1) - var account = user.relations.linkedAccounts.first() - expect(account.get('provider_user_id')).to.equal('foo') + user = factories.user(); + mockProfile.email = user.get("email"); + return user.save(); + }); + + it("creates a new linked account", () => { + return SessionController.finishFacebookOAuth(req, res).then(() => + expectMatchMockProfile(user.id) + ); + }); + + describe("with an existing Facebook account", () => { + beforeEach(() => + LinkedAccount.create(user.id, { + type: "facebook", + profile: { id: "foo" }, }) - }) - }) - }) + ); - describe('for a logged-in user', () => { - var user + it("leaves the existing account unchanged", () => { + return SessionController.finishFacebookOAuth(req, res) + .then(() => user.load("linkedAccounts")) + .then((user) => { + expect(user.relations.linkedAccounts.length).to.equal(1); + const account = user.relations.linkedAccounts.first(); + expect(account.get("provider_user_id")).to.equal("foo"); + }); + }); + }); + }); + + describe("for a logged-in user", () => { + let user; beforeEach(() => { - user = factories.user() - return user.save().then(() => req.login(user.id)) - }) - - it('creates a new linked account even if the email does not match', () => { - return SessionController.finishFacebookOAuth(req, res) - .then(() => expectMatchMockProfile(user.id)) - }) - - describe('with a linked account that belongs to a different user', () => { - var account + user = factories.user(); + return user.save().then(() => req.login(user.id)); + }); + + it("creates a new linked account even if the email does not match", () => { + return SessionController.finishFacebookOAuth(req, res).then(() => + expectMatchMockProfile(user.id) + ); + }); + + describe("with a linked account that belongs to a different user", () => { + let account; beforeEach(() => { - return factories.user().save() - .then(u2 => LinkedAccount.create(u2.id, {type: 'facebook', profile: {id: mockProfile.id}})) - .tap(a => account = a) - }) - - it('changes ownership', () => { + return factories + .user() + .save() + .then((u2) => + LinkedAccount.create(u2.id, { + type: "facebook", + profile: { id: mockProfile.id }, + }) + ) + .tap((a) => (account = a)); + }); + + it("changes ownership", () => { return SessionController.finishFacebookOAuth(req, res) - .then(() => expectMatchMockProfile(user.id)) - .then(user => expect(user.relations.linkedAccounts.first().id).to.equal(account.id)) - }) - }) - }) - }) -}) + .then(() => expectMatchMockProfile(user.id)) + .then((user) => + expect(user.relations.linkedAccounts.first().id).to.equal( + account.id + ) + ); + }); + }); + }); + }); +}); diff --git a/test/unit/controllers/UploadController.test.js b/test/unit/controllers/UploadController.test.js index 8b3381041..8a17d0700 100644 --- a/test/unit/controllers/UploadController.test.js +++ b/test/unit/controllers/UploadController.test.js @@ -1,79 +1,79 @@ /* globals UploadController */ -var root = require('root-path') -require(root('test/setup')) -var factories = require(root('test/setup/factories')) +const root = require("root-path"); +require(root("test/setup")); +const factories = require(root("test/setup/factories")); -describe('UploadController', () => { - var req, res +describe("UploadController", () => { + let req, res; beforeEach(() => { - req = factories.mock.request() - res = factories.mock.response() - }) + req = factories.mock.request(); + res = factories.mock.response(); + }); - describe('uploading via x-www-form-urlencoded', () => { + describe("uploading via x-www-form-urlencoded", () => { beforeEach(() => { - req.headers['content-type'] = 'application/x-www-form-urlencoded' - }) + req.headers["content-type"] = "application/x-www-form-urlencoded"; + }); - it('returns an error if the request has no url', () => { - req.body = 'type=userAvatar' - return UploadController.create(req, res) - .then(() => { - expect(res.body.error).to.equal('Validation error: No url and no stream') - }) - }) + it("returns an error if the request has no url", () => { + req.body = "type=userAvatar"; + return UploadController.create(req, res).then(() => { + expect(res.body.error).to.equal( + "Validation error: No url and no stream" + ); + }); + }); - it('returns an error if the request has a bad type', () => { - req.body = 'url=http://foo.com/foo.png' - return UploadController.create(req, res) - .then(() => { - expect(res.body.error).to.equal('Validation error: Invalid type') - }) - }) - }) + it("returns an error if the request has a bad type", () => { + req.body = "url=http://foo.com/foo.png"; + return UploadController.create(req, res).then(() => { + expect(res.body.error).to.equal("Validation error: Invalid type"); + }); + }); + }); - describe('uploading via json', () => { + describe("uploading via json", () => { beforeEach(() => { - req.headers['content-type'] = 'application/json' - }) + req.headers["content-type"] = "application/json"; + }); - it('returns an error if the request has no url', () => { - req.params.type = 'userAvatar' - return UploadController.create(req, res) - .then(() => { - expect(res.body.error).to.equal('Validation error: No url and no stream') - }) - }) + it("returns an error if the request has no url", () => { + req.params.type = "userAvatar"; + return UploadController.create(req, res).then(() => { + expect(res.body.error).to.equal( + "Validation error: No url and no stream" + ); + }); + }); - it('returns an error if the request has a bad type', () => { - req.params.url = 'http://foo.com/foo.png' - return UploadController.create(req, res) - .then(() => { - expect(res.body.error).to.equal('Validation error: Invalid type') - }) - }) - }) + it("returns an error if the request has a bad type", () => { + req.params.url = "http://foo.com/foo.png"; + return UploadController.create(req, res).then(() => { + expect(res.body.error).to.equal("Validation error: Invalid type"); + }); + }); + }); - describe('uploading via file', () => { + describe("uploading via file", () => { beforeEach(() => { - req.headers['content-type'] = 'multipart/form-data; boundary=125b0ae93a754d0ba988b98b397d587f' - }) + req.headers["content-type"] = + "multipart/form-data; boundary=125b0ae93a754d0ba988b98b397d587f"; + }); - it('parses a multipart request', () => { - req.body = testMultipartBody - req.session.userId = '42' - return UploadController.create(req, res) - .then(() => { + it("parses a multipart request", () => { + req.body = testMultipartBody; + req.session.userId = "42"; + return UploadController.create(req, res).then(() => { // this error is thrown by createS3StorageStream; the fact that it is // thrown confirms that busboy was able to parse the request and set up // the stream pipeline. - expect(res.body.code).to.equal('InvalidAccessKeyId') - }) - }) - }) -}) + expect(res.body.code).to.equal("InvalidAccessKeyId"); + }); + }); + }); +}); // the spec requires the use of CRLF (\r\n), not just \n // https://www.w3.org/Protocols/rfc1341/7_2_Multipart.html @@ -92,4 +92,4 @@ hello world\r we come in peace\r \r --125b0ae93a754d0ba988b98b397d587f--\r -` +`; diff --git a/test/unit/controllers/UserController.test.js b/test/unit/controllers/UserController.test.js index c55534fa8..398353fa7 100644 --- a/test/unit/controllers/UserController.test.js +++ b/test/unit/controllers/UserController.test.js @@ -1,73 +1,82 @@ -var root = require('root-path') -var setup = require(root('test/setup')) -var factories = require(root('test/setup/factories')) -var UserController = require(root('api/controllers/UserController')) +const root = require("root-path"); +const setup = require(root("test/setup")); +const factories = require(root("test/setup/factories")); +const UserController = require(root("api/controllers/UserController")); -describe('UserController', function () { - var req, res +describe("UserController", function () { + let req, res; beforeEach(function () { - req = factories.mock.request() - res = factories.mock.response() - return setup.clearDb() - }) + req = factories.mock.request(); + res = factories.mock.response(); + return setup.clearDb(); + }); - describe('.create', function () { - var community + describe(".create", function () { + let community; beforeEach(function () { Object.assign(res, { - send: console.error - }) + send: console.error, + }); - UserSession.login = spy(UserSession.login) - User.create = spy(User.create) + UserSession.login = spy(UserSession.login); + User.create = spy(User.create); - community = new Community({beta_access_code: 'foo', name: 'foo', slug: 'foo'}) - return community.save() - }) + community = new Community({ + beta_access_code: "foo", + name: "foo", + slug: "foo", + }); + return community.save(); + }); - it('works with a username and password', function () { + it("works with a username and password", function () { Object.assign(req.params, { - email: 'foo@bar.com', - password: 'password!', - code: 'foo', - login: true - }) + email: "foo@bar.com", + password: "password!", + code: "foo", + login: true, + }); - return UserController.create(req, res).then(function () { - expect(res.status).not.to.have.been.called() - expect(User.create).to.have.been.called() - expect(UserSession.login).to.have.been.called() - expect(res.ok).to.have.been.called() + return UserController.create(req, res) + .then(function () { + expect(res.status).not.to.have.been.called(); + expect(User.create).to.have.been.called(); + expect(UserSession.login).to.have.been.called(); + expect(res.ok).to.have.been.called(); - return User.where({email: 'foo@bar.com'}).fetch() - }) - .then(user => { - expect(user.get('last_login_at').getTime()).to.be.closeTo(new Date().getTime(), 2000) - }) - }) - }) + return User.where({ email: "foo@bar.com" }).fetch(); + }) + .then((user) => { + expect(user.get("last_login_at").getTime()).to.be.closeTo( + new Date().getTime(), + 2000 + ); + }); + }); + }); - describe('with an existing user', function () { - var u1, u2 + describe("with an existing user", function () { + let u1, u2; beforeEach(function () { - u1 = factories.user({settings: {leftNavIsOpen: true, currentCommunityId: '7'}}) - u2 = factories.user() - return Promise.join(u1.save(), u2.save()) - }) + u1 = factories.user({ + settings: { leftNavIsOpen: true, currentCommunityId: "7" }, + }); + u2 = factories.user(); + return Promise.join(u1.save(), u2.save()); + }); - describe('.create', () => { - it('halts on duplicate email', function () { - Object.assign(req.params, {email: u2.get('email')}) + describe(".create", () => { + it("halts on duplicate email", function () { + Object.assign(req.params, { email: u2.get("email") }); - return UserController.create(req, res) - .then(() => { - expect(res.statusCode).to.equal(422) - expect(res.body).to.equal(req.__('duplicate-email')) - }) - }) - }) - }) -}) + return UserController.create(req, res).then(() => { + expect(res.statusCode).to.equal(422); + expect(res.body).to.equal(req.__("duplicate-email")); + }); + }); + }); + }); +}); diff --git a/test/unit/models/Activity.test.js b/test/unit/models/Activity.test.js index 247cd50f5..e65cbbb49 100644 --- a/test/unit/models/Activity.test.js +++ b/test/unit/models/Activity.test.js @@ -1,322 +1,363 @@ /* eslint-disable no-unused-expressions */ -import { mapValues } from 'lodash' -const root = require('root-path') -const setup = require(root('test/setup')) -const factories = require(root('test/setup/factories')) +import { mapValues } from "lodash"; +const root = require("root-path"); +const setup = require(root("test/setup")); +const factories = require(root("test/setup/factories")); -const makeGettable = obj => Object.assign({get: key => obj[key], load: () => {}}, obj) +const makeGettable = (obj) => + Object.assign({ get: (key) => obj[key], load: () => {} }, obj); -function mockUser (memberships) { +function mockUser(memberships) { return { - groupMembershipsForModel () { + groupMembershipsForModel() { return { - fetch: () => Promise.resolve({ - models: memberships.map(attrs => { - const membership = GroupMembership.forge(attrs) - membership.relations = mapValues(attrs.relations, makeGettable) - return membership - }) - }) - } - } - } + fetch: () => + Promise.resolve({ + models: memberships.map((attrs) => { + const membership = GroupMembership.forge(attrs); + membership.relations = mapValues(attrs.relations, makeGettable); + return membership; + }), + }), + }; + }, + }; } -describe('Activity', function () { - describe('.generateNotificationMedia', () => { - it('returns an in-app notification from a mention', async () => { +describe("Activity", function () { + describe(".generateNotificationMedia", () => { + it("returns an in-app notification from a mention", async () => { const memberships = [ { settings: {}, relations: { - group: {group_data_id: 1} - } - } - ] + group: { group_data_id: 1 }, + }, + }, + ]; const activity = makeGettable({ - meta: {reasons: ['mention']}, + meta: { reasons: ["mention"] }, post_id: 1, relations: { post: { relations: { - communities: [{id: 1}] - } + communities: [{ id: 1 }], + }, }, - reader: mockUser(memberships) - } - }) + reader: mockUser(memberships), + }, + }); - const expected = [Notification.MEDIUM.InApp] + const expected = [Notification.MEDIUM.InApp]; - const actual = await Activity.generateNotificationMedia(activity) - expect(actual).to.deep.equal(expected) - }) + const actual = await Activity.generateNotificationMedia(activity); + expect(actual).to.deep.equal(expected); + }); it("doesn't return an email for a newPost", async () => { const memberships = [ { - settings: {sendEmail: true}, + settings: { sendEmail: true }, relations: { - group: {group_data_id: 1} - } - } - ] + group: { group_data_id: 1 }, + }, + }, + ]; const activity = makeGettable({ - meta: {reasons: ['newPost: 1']}, + meta: { reasons: ["newPost: 1"] }, post_id: 1, relations: { post: { relations: { - communities: [{id: 1}, {id: 2}] - } + communities: [{ id: 1 }, { id: 2 }], + }, }, - reader: mockUser(memberships) - } - }) + reader: mockUser(memberships), + }, + }); - const expected = [] - const actual = await Activity.generateNotificationMedia(activity) - expect(actual).to.deep.equal(expected) - }) + const expected = []; + const actual = await Activity.generateNotificationMedia(activity); + expect(actual).to.deep.equal(expected); + }); - it('returns just a push for a newPost', async () => { + it("returns just a push for a newPost", async () => { const memberships = [ { - settings: {sendPushNotifications: true}, + settings: { sendPushNotifications: true }, relations: { - group: {group_data_id: 1} - } - } - ] + group: { group_data_id: 1 }, + }, + }, + ]; const activity = makeGettable({ - meta: {reasons: ['newPost: 1']}, + meta: { reasons: ["newPost: 1"] }, post_id: 1, relations: { post: { relations: { - communities: [{id: 1}, {id: 2}] - } + communities: [{ id: 1 }, { id: 2 }], + }, }, - reader: mockUser(memberships) - } - }) + reader: mockUser(memberships), + }, + }); - const expected = [ - Notification.MEDIUM.Push - ] + const expected = [Notification.MEDIUM.Push]; - const actual = await Activity.generateNotificationMedia(activity) - expect(actual).to.deep.equal(expected) - }) + const actual = await Activity.generateNotificationMedia(activity); + expect(actual).to.deep.equal(expected); + }); - it('returns a push and an email for different communities', async () => { + it("returns a push and an email for different communities", async () => { const memberships = [ { - settings: {sendEmail: true}, + settings: { sendEmail: true }, relations: { - group: {group_data_id: 1} - } + group: { group_data_id: 1 }, + }, }, { - settings: {sendPushNotifications: true}, + settings: { sendPushNotifications: true }, relations: { - group: {group_data_id: 2} - } - } - ] + group: { group_data_id: 2 }, + }, + }, + ]; const activity = makeGettable({ - meta: {reasons: ['mention']}, + meta: { reasons: ["mention"] }, post_id: 1, relations: { post: { relations: { - communities: [{id: 1}, {id: 2}] - } + communities: [{ id: 1 }, { id: 2 }], + }, }, - reader: mockUser(memberships) - } - }) + reader: mockUser(memberships), + }, + }); const expected = [ Notification.MEDIUM.Email, Notification.MEDIUM.Push, - Notification.MEDIUM.InApp - ] + Notification.MEDIUM.InApp, + ]; - const actual = await Activity.generateNotificationMedia(activity) - expect(actual).to.deep.equal(expected) - }) - }) + const actual = await Activity.generateNotificationMedia(activity); + expect(actual).to.deep.equal(expected); + }); + }); - describe('#createWithNotifications', () => { - var fixtures + describe("#createWithNotifications", () => { + let fixtures; before(() => - setup.clearDb() - .then(() => Promise.props({ - u1: factories.user().save(), - u2: factories.user().save(), - c1: factories.community().save(), - c2: factories.community().save(), - p1: factories.post().save(), - p2: factories.post().save() - })) - .then(props => { fixtures = props }) - .then(() => Promise.join( - fixtures.c1.posts().attach(fixtures.p1), - fixtures.c1.posts().attach(fixtures.p2), - fixtures.c2.posts().attach(fixtures.p2), - fixtures.u1.joinCommunity(fixtures.c1), - fixtures.u1.joinCommunity(fixtures.c2) - ))) + setup + .clearDb() + .then(() => + Promise.props({ + u1: factories.user().save(), + u2: factories.user().save(), + c1: factories.community().save(), + c2: factories.community().save(), + p1: factories.post().save(), + p2: factories.post().save(), + }) + ) + .then((props) => { + fixtures = props; + }) + .then(() => + Promise.join( + fixtures.c1.posts().attach(fixtures.p1), + fixtures.c1.posts().attach(fixtures.p2), + fixtures.c2.posts().attach(fixtures.p2), + fixtures.u1.joinCommunity(fixtures.c1), + fixtures.u1.joinCommunity(fixtures.c2) + ) + ) + ); - it('creates an in-app notification from a mention', () => { + it("creates an in-app notification from a mention", () => { return Activity.createWithNotifications({ post_id: fixtures.p1.id, reader_id: fixtures.u1.id, actor_id: fixtures.u2.id, - meta: {reasons: ['mention']} + meta: { reasons: ["mention"] }, }) - .then(activity => - Notification.where({activity_id: activity.id, medium: Notification.MEDIUM.InApp}) - .fetch()) - .then(notification => { - expect(notification).to.exist - expect(notification.get('sent_at')).to.be.null - expect(notification.get('user_id')).to.equal(fixtures.u1.id) - }) - }) + .then((activity) => + Notification.where({ + activity_id: activity.id, + medium: Notification.MEDIUM.InApp, + }).fetch() + ) + .then((notification) => { + expect(notification).to.exist; + expect(notification.get("sent_at")).to.be.null; + expect(notification.get("user_id")).to.equal(fixtures.u1.id); + }); + }); - it('creates a push notification when the community setting is true', async () => { + it("creates a push notification when the community setting is true", async () => { await fixtures.c1.addGroupMembers([fixtures.u1.id], { - settings: {sendPushNotifications: true} - }) + settings: { sendPushNotifications: true }, + }); const activity = await Activity.createWithNotifications({ post_id: fixtures.p1.id, reader_id: fixtures.u1.id, actor_id: fixtures.u2.id, - meta: {reasons: ['mention']} - }) + meta: { reasons: ["mention"] }, + }); const notification = await Notification.where({ - activity_id: activity.id, medium: Notification.MEDIUM.Push - }).fetch() - expect(notification).to.exist - expect(notification.get('sent_at')).to.be.null - }) + activity_id: activity.id, + medium: Notification.MEDIUM.Push, + }).fetch(); + expect(notification).to.exist; + expect(notification.get("sent_at")).to.be.null; + }); - it('creates an email notification when the community setting is true', () => { - return fixtures.c1.addGroupMembers([fixtures.u1.id], { - settings: {sendEmail: true} - }) - .then(() => Activity.createWithNotifications({ - post_id: fixtures.p1.id, - reader_id: fixtures.u1.id, - actor_id: fixtures.u2.id, - meta: {reasons: ['mention']} - })) - .then(activity => - Notification.where({activity_id: activity.id, medium: Notification.MEDIUM.Email}) - .fetch()) - .then(notification => { - expect(notification).to.exist - expect(notification.get('sent_at')).to.be.null - }) - }) + it("creates an email notification when the community setting is true", () => { + return fixtures.c1 + .addGroupMembers([fixtures.u1.id], { + settings: { sendEmail: true }, + }) + .then(() => + Activity.createWithNotifications({ + post_id: fixtures.p1.id, + reader_id: fixtures.u1.id, + actor_id: fixtures.u2.id, + meta: { reasons: ["mention"] }, + }) + ) + .then((activity) => + Notification.where({ + activity_id: activity.id, + medium: Notification.MEDIUM.Email, + }).fetch() + ) + .then((notification) => { + expect(notification).to.exist; + expect(notification.get("sent_at")).to.be.null; + }); + }); it("doesn't create a push notification when the community setting is false", () => { - return fixtures.c1.addGroupMembers([fixtures.u1.id], { - settings: {sendPushNotifications: false} - }) - .then(() => Activity.createWithNotifications({ - post_id: fixtures.p1.id, - reader_id: fixtures.u1.id, - actor_id: fixtures.u2.id, - meta: {reasons: ['mention']} - })) - .then(activity => - Notification.where({activity_id: activity.id, medium: Notification.MEDIUM.Push}) - .fetch()) - .then(notification => { - expect(notification).not.to.exist - }) - }) + return fixtures.c1 + .addGroupMembers([fixtures.u1.id], { + settings: { sendPushNotifications: false }, + }) + .then(() => + Activity.createWithNotifications({ + post_id: fixtures.p1.id, + reader_id: fixtures.u1.id, + actor_id: fixtures.u2.id, + meta: { reasons: ["mention"] }, + }) + ) + .then((activity) => + Notification.where({ + activity_id: activity.id, + medium: Notification.MEDIUM.Push, + }).fetch() + ) + .then((notification) => { + expect(notification).not.to.exist; + }); + }); it("doesn't create an email when the community setting is false", () => { - return fixtures.c1.addGroupMembers([fixtures.u1.id], { - settings: {sendEmail: false} - }) - .then(() => Activity.createWithNotifications({ - post_id: fixtures.p1.id, - reader_id: fixtures.u1.id, - actor_id: fixtures.u2.id, - meta: {reasons: ['mention']} - })) - .then(activity => - Notification.where({activity_id: activity.id, medium: Notification.MEDIUM.Push}) - .fetch()) - .then(notification => { - expect(notification).not.to.exist - }) - }) + return fixtures.c1 + .addGroupMembers([fixtures.u1.id], { + settings: { sendEmail: false }, + }) + .then(() => + Activity.createWithNotifications({ + post_id: fixtures.p1.id, + reader_id: fixtures.u1.id, + actor_id: fixtures.u2.id, + meta: { reasons: ["mention"] }, + }) + ) + .then((activity) => + Notification.where({ + activity_id: activity.id, + medium: Notification.MEDIUM.Push, + }).fetch() + ) + .then((notification) => { + expect(notification).not.to.exist; + }); + }); it("doesn't create in-app or email for new posts ", () => { - return fixtures.c1.addGroupMembers([fixtures.u1.id], { - settings: {sendPushNotifications: true} - }) - .then(() => Activity.createWithNotifications({ - post_id: fixtures.p1.id, - reader_id: fixtures.u1.id, - actor_id: fixtures.u2.id, - meta: {reasons: [`newPost: ${fixtures.c1.id}`]} - })) - .then(activity => - Promise.join( - Notification.where({activity_id: activity.id, medium: Notification.MEDIUM.InApp}) - .fetch(), - Notification.where({activity_id: activity.id, medium: Notification.MEDIUM.Email}) - .fetch(), - Notification.where({activity_id: activity.id, medium: Notification.MEDIUM.Push}) - .fetch(), - (inApp, email, push) => { - expect(inApp).not.to.exist - expect(email).not.to.exist - expect(push).to.exist - })) - }) - }) + return fixtures.c1 + .addGroupMembers([fixtures.u1.id], { + settings: { sendPushNotifications: true }, + }) + .then(() => + Activity.createWithNotifications({ + post_id: fixtures.p1.id, + reader_id: fixtures.u1.id, + actor_id: fixtures.u2.id, + meta: { reasons: [`newPost: ${fixtures.c1.id}`] }, + }) + ) + .then((activity) => + Promise.join( + Notification.where({ + activity_id: activity.id, + medium: Notification.MEDIUM.InApp, + }).fetch(), + Notification.where({ + activity_id: activity.id, + medium: Notification.MEDIUM.Email, + }).fetch(), + Notification.where({ + activity_id: activity.id, + medium: Notification.MEDIUM.Push, + }).fetch(), + (inApp, email, push) => { + expect(inApp).not.to.exist; + expect(email).not.to.exist; + expect(push).to.exist; + } + ) + ); + }); + }); - describe('#forComment', function () { - var comment + describe("#forComment", function () { + let comment; before(function () { comment = new Comment({ - id: '4', - user_id: '5', - post_id: '6', - text: 'foo' - }) - }) + id: "4", + user_id: "5", + post_id: "6", + text: "foo", + }); + }); - it('works', function () { - var activity = Activity.forComment(comment, '7') + it("works", function () { + const activity = Activity.forComment(comment, "7"); - expect(activity.get('comment_id')).to.equal('4') - expect(activity.get('actor_id')).to.equal('5') - expect(activity.get('post_id')).to.equal('6') - expect(activity.get('action')).to.equal('comment') - }) + expect(activity.get("comment_id")).to.equal("4"); + expect(activity.get("actor_id")).to.equal("5"); + expect(activity.get("post_id")).to.equal("6"); + expect(activity.get("action")).to.equal("comment"); + }); it('sets action = "mention" for mentions', function () { - comment.set('text', 'yo Bob!') - var activity = Activity.forComment(comment, '7') + comment.set("text", 'yo Bob!'); + const activity = Activity.forComment(comment, "7"); - expect(activity.get('comment_id')).to.equal('4') - expect(activity.get('actor_id')).to.equal('5') - expect(activity.get('post_id')).to.equal('6') - expect(activity.get('action')).to.equal('mention') - }) - }) -}) + expect(activity.get("comment_id")).to.equal("4"); + expect(activity.get("actor_id")).to.equal("5"); + expect(activity.get("post_id")).to.equal("6"); + expect(activity.get("action")).to.equal("mention"); + }); + }); +}); diff --git a/test/unit/models/BlockedUser.test.js b/test/unit/models/BlockedUser.test.js index 60c025924..0580911eb 100644 --- a/test/unit/models/BlockedUser.test.js +++ b/test/unit/models/BlockedUser.test.js @@ -1,65 +1,75 @@ -const root = require('root-path') -const setup = require(root('test/setup')) -const factories = require(root('test/setup/factories')) +const root = require("root-path"); +const setup = require(root("test/setup")); +const factories = require(root("test/setup/factories")); -describe('BlockedUser', () => { - let u - let u2 +describe("BlockedUser", () => { + let u; + let u2; beforeEach(() => { - u = factories.user() - u2 = factories.user() - return setup.clearDb() - .then(() => Promise.all([u.save(), u2.save()])) - }) + u = factories.user(); + u2 = factories.user(); + return setup.clearDb().then(() => Promise.all([u.save(), u2.save()])); + }); - describe('create', () => { - it('throws if other_user_id is user_id', () => { - expect(() => BlockedUser.create('1', '1')) - .to.throw(/blocked_user_id cannot equal user_id/) - }) + describe("create", () => { + it("throws if other_user_id is user_id", () => { + expect(() => BlockedUser.create("1", "1")).to.throw( + /blocked_user_id cannot equal user_id/ + ); + }); - it('throws if user_id is null', () => { - expect(() => BlockedUser.create(null, '1')) - .to.throw(/must provice a user_id and blocked_user_id/) - }) + it("throws if user_id is null", () => { + expect(() => BlockedUser.create(null, "1")).to.throw( + /must provice a user_id and blocked_user_id/ + ); + }); - it('creates a BlockedUser', () => { - const u2 = factories.user() - return u2.save() + it("creates a BlockedUser", () => { + const u2 = factories.user(); + return u2 + .save() .then(() => { - return BlockedUser.create(u.get('id'), u2.get('id')) + return BlockedUser.create(u.get("id"), u2.get("id")); }) - .then(blockedUser => blockedUser.load('blockedUser')) + .then((blockedUser) => blockedUser.load("blockedUser")) .then(({ relations }) => { - const name = relations.blockedUser.get('name') - expect(name).to.equal(u2.get('name')) - }) - }) - }) + const name = relations.blockedUser.get("name"); + expect(name).to.equal(u2.get("name")); + }); + }); + }); - describe('find', () => { - it('throws if user_id is missing', () => { - expect(() => BlockedUser.find()) - .to.throw(/Parameter user_id must be supplied/) - }) + describe("find", () => { + it("throws if user_id is missing", () => { + expect(() => BlockedUser.find()).to.throw( + /Parameter user_id must be supplied/ + ); + }); - it('resolves with null if no matching BlockedUser exists', () => { - const user_id = u.get('id') - const blocked_user_id = u2.get('id') - const c = factories.blockedUser({ user_id: blocked_user_id, blocked_user_id: user_id }) - return c.save() - .then(() => BlockedUser.find(user_id, blocked_user_id, 'message')) - .then(blockedUser => expect(blockedUser).to.equal(null)) - }) + it("resolves with null if no matching BlockedUser exists", () => { + const user_id = u.get("id"); + const blocked_user_id = u2.get("id"); + const c = factories.blockedUser({ + user_id: blocked_user_id, + blocked_user_id: user_id, + }); + return c + .save() + .then(() => BlockedUser.find(user_id, blocked_user_id, "message")) + .then((blockedUser) => expect(blockedUser).to.equal(null)); + }); - it('finds a BlockedUser if a match exists', () => { - const user_id = u.get('id') - const blocked_user_id = u2.get('id') - const c = factories.blockedUser({ user_id, blocked_user_id }) - return c.save() + it("finds a BlockedUser if a match exists", () => { + const user_id = u.get("id"); + const blocked_user_id = u2.get("id"); + const c = factories.blockedUser({ user_id, blocked_user_id }); + return c + .save() .then(() => BlockedUser.find(user_id, blocked_user_id)) - .then(blockedUser => expect(blockedUser.get('id')).to.equal(c.get('id'))) - }) - }) -}) + .then((blockedUser) => + expect(blockedUser.get("id")).to.equal(c.get("id")) + ); + }); + }); +}); diff --git a/test/unit/models/Comment.test.js b/test/unit/models/Comment.test.js index d2e8636d4..87f340de9 100644 --- a/test/unit/models/Comment.test.js +++ b/test/unit/models/Comment.test.js @@ -1,182 +1,213 @@ /* globals RedisClient */ -import { times } from 'lodash' -import setup from '../../setup' -import factories from '../../setup/factories' -import { mockify, unspyify } from '../../setup/helpers' - -const user = factories.mock.model({name: 'Bob Anatharamchar'}) -const user2 = factories.mock.model({name: 'Mina Shah'}) - -describe('Comment', () => { - describe('cleanEmailText', () => { - it('wraps content in

tags and handles weird newlines', () => { - const text = 'Ok then\r\nAll right\r\rSo it shall be' - expect(Comment.cleanEmailText(user, text)).to.equal('

Ok then
All right

\n

So it shall be

\n') - }) +import { times } from "lodash"; +import setup from "../../setup"; +import factories from "../../setup/factories"; +import { mockify, unspyify } from "../../setup/helpers"; + +const user = factories.mock.model({ name: "Bob Anatharamchar" }); +const user2 = factories.mock.model({ name: "Mina Shah" }); + +describe("Comment", () => { + describe("cleanEmailText", () => { + it("wraps content in

tags and handles weird newlines", () => { + const text = "Ok then\r\nAll right\r\rSo it shall be"; + expect(Comment.cleanEmailText(user, text)).to.equal( + "

Ok then
All right

\n

So it shall be

\n" + ); + }); it("cuts off at the sender's name", () => { - const text = "Wow!\rThat's great!\rBob A" - expect(Comment.cleanEmailText(user, text)).to.equal('

Wow!
That's great!

\n') - }) + const text = "Wow!\rThat's great!\rBob A"; + expect(Comment.cleanEmailText(user, text)).to.equal( + "

Wow!
That's great!

\n" + ); + }); it("cuts off at the sender's name preceded by dashes", () => { - const text = "Wow!\rThat's great!\r--Bob A" - expect(Comment.cleanEmailText(user, text)).to.equal('

Wow!
That's great!

\n') - }) - - it('removes a common signature pattern with two dashes', () => { - const text = "Let's do it.\r\r-- \rMina" - expect(Comment.cleanEmailText(user2, text)).to.equal('

Let's do it.

\n') - }) - - it('removes our inserted divider', () => { - const text = 'Meow!\n-------- Only text above the dashed line will be included --------\nwhatever' - expect(Comment.cleanEmailText(user2, text)).to.equal('

Meow!

\n') - }) - - it('removes even a mangled divider', () => { - const text = 'yoyo\nMeow!-----+Only+text+above+the+dashed+line+will+be+included lol\nok' - expect(Comment.cleanEmailText(user2, text)).to.equal('

yoyo
Meow!

\n') - }) - }) - - describe('sendDigests', () => { - var u1, u2, post, comments, log, now + const text = "Wow!\rThat's great!\r--Bob A"; + expect(Comment.cleanEmailText(user, text)).to.equal( + "

Wow!
That's great!

\n" + ); + }); + + it("removes a common signature pattern with two dashes", () => { + const text = "Let's do it.\r\r-- \rMina"; + expect(Comment.cleanEmailText(user2, text)).to.equal( + "

Let's do it.

\n" + ); + }); + + it("removes our inserted divider", () => { + const text = + "Meow!\n-------- Only text above the dashed line will be included --------\nwhatever"; + expect(Comment.cleanEmailText(user2, text)).to.equal("

Meow!

\n"); + }); + + it("removes even a mangled divider", () => { + const text = + "yoyo\nMeow!-----+Only+text+above+the+dashed+line+will+be+included lol\nok"; + expect(Comment.cleanEmailText(user2, text)).to.equal( + "

yoyo
Meow!

\n" + ); + }); + }); + + describe("sendDigests", () => { + let u1, u2, post, comments, log, now; beforeEach(async () => { - now = new Date() - log = [] - comments = [] - - u1 = factories.user({avatar_url: 'foo.png', settings: {dm_notifications: 'both'}}) - u2 = factories.user({avatar_url: 'bar.png', settings: {dm_notifications: 'both'}}) - post = factories.post({type: Post.Type.THREAD, updated_at: now}) - - await Promise.join(u1.save(), u2.save(), post.save()) - await post.addFollowers([u1.id, u2.id]) - ;[u1.id, u2.id].forEach(userId => - times(2, i => comments.push(factories.comment({ - post_id: post.id, - user_id: userId, - created_at: new Date(now - (5 - i) * 60000) - })))) - await Promise.all(comments.map(c => c.save())) - await RedisClient.create().delAsync(Comment.sendDigests.REDIS_TIMESTAMP_KEY) - }) - - afterEach(() => setup.clearDb()) - - describe('with a message thread', () => { + now = new Date(); + log = []; + comments = []; + + u1 = factories.user({ + avatar_url: "foo.png", + settings: { dm_notifications: "both" }, + }); + u2 = factories.user({ + avatar_url: "bar.png", + settings: { dm_notifications: "both" }, + }); + post = factories.post({ type: Post.Type.THREAD, updated_at: now }); + + await Promise.join(u1.save(), u2.save(), post.save()); + await post.addFollowers([u1.id, u2.id]); + [u1.id, u2.id].forEach((userId) => + times(2, (i) => + comments.push( + factories.comment({ + post_id: post.id, + user_id: userId, + created_at: new Date(now - (5 - i) * 60000), + }) + ) + ) + ); + await Promise.all(comments.map((c) => c.save())); + await RedisClient.create().delAsync( + Comment.sendDigests.REDIS_TIMESTAMP_KEY + ); + }); + + afterEach(() => setup.clearDb()); + + describe("with a message thread", () => { beforeEach(() => - mockify(Email, 'sendMessageDigest', args => log.push(args))) + mockify(Email, "sendMessageDigest", (args) => log.push(args)) + ); - afterEach(() => unspyify(Email, 'sendMessageDigest')) + afterEach(() => unspyify(Email, "sendMessageDigest")); - it('sends a digest of recent messages', () => { - return Comment.sendDigests() - .then(count => { - expect(count).to.equal(2) - expect(Email.sendMessageDigest).to.have.been.called.exactly(2) + it("sends a digest of recent messages", () => { + return Comment.sendDigests().then((count) => { + expect(count).to.equal(2); + expect(Email.sendMessageDigest).to.have.been.called.exactly(2); - const send1 = log.find(l => l.email === u1.get('email')) - expect(send1.data.messages) - .to.deep.equal([ + const send1 = log.find((l) => l.email === u1.get("email")); + expect(send1.data.messages).to.deep.equal([ + { + text: comments[2].get("text"), + name: u2.get("name"), + avatar_url: u2.get("avatar_url"), + }, + { + text: comments[3].get("text"), + name: u2.get("name"), + avatar_url: u2.get("avatar_url"), + }, + ]); + + const send2 = log.find((l) => l.email === u2.get("email")); + expect(send2.data.messages).to.deep.equal([ + { + text: comments[0].get("text"), + name: u1.get("name"), + avatar_url: u1.get("avatar_url"), + }, { - text: comments[2].get('text'), - name: u2.get('name'), - avatar_url: u2.get('avatar_url') - }, { - text: comments[3].get('text'), - name: u2.get('name'), - avatar_url: u2.get('avatar_url') - }]) - - const send2 = log.find(l => l.email === u2.get('email')) - expect(send2.data.messages) - .to.deep.equal([ + text: comments[1].get("text"), + name: u1.get("name"), + avatar_url: u1.get("avatar_url"), + }, + ]); + }); + }); + + it("respects lastReadAt", async () => { + const ms1 = await GroupMembership.forPair(u1, post).fetch(); + await ms1.addSetting({ lastReadAt: new Date(now - 4.5 * 60000) }, true); + + const ms2 = await GroupMembership.forPair(u2, post).fetch(); + await ms2.addSetting({ lastReadAt: now }, true); + + return Comment.sendDigests().then((count) => { + expect(count).to.equal(1); + expect(Email.sendMessageDigest).to.have.been.called.exactly(1); + + expect(log[0].email).to.equal(u1.get("email")); + expect(log[0].data.messages).to.deep.equal([ { - text: comments[0].get('text'), - name: u1.get('name'), - avatar_url: u1.get('avatar_url') - }, { - text: comments[1].get('text'), - name: u1.get('name'), - avatar_url: u1.get('avatar_url') - }]) - }) - }) - - it('respects lastReadAt', async () => { - const ms1 = await GroupMembership.forPair(u1, post).fetch() - await ms1.addSetting({lastReadAt: new Date(now - 4.5 * 60000)}, true) - - const ms2 = await GroupMembership.forPair(u2, post).fetch() - await ms2.addSetting({lastReadAt: now}, true) - - return Comment.sendDigests() - .then(count => { - expect(count).to.equal(1) - expect(Email.sendMessageDigest).to.have.been.called.exactly(1) - - expect(log[0].email).to.equal(u1.get('email')) - expect(log[0].data.messages) - .to.deep.equal([{ - text: comments[3].get('text'), - name: u2.get('name'), - avatar_url: u2.get('avatar_url') - }]) - }) - }) - - it('respects dm_notifications setting', () => { - return u1.save({settings: {dm_notifications: 'push'}}) - .then(() => Comment.sendDigests()) - .then(count => { - expect(count).to.equal(1) - expect(Email.sendMessageDigest).to.have.been.called.exactly(1) - - expect(log[0].email).to.equal(u2.get('email')) - expect(log[0].data.messages) - .to.deep.equal([{ - name: u1.get('name'), - avatar_url: u1.get('avatar_url'), - text: comments[0].get('text') - }, { - name: u1.get('name'), - avatar_url: u1.get('avatar_url'), - text: comments[1].get('text') - }]) - }) - }) - }) - - describe('with post comments', () => { + text: comments[3].get("text"), + name: u2.get("name"), + avatar_url: u2.get("avatar_url"), + }, + ]); + }); + }); + + it("respects dm_notifications setting", () => { + return u1 + .save({ settings: { dm_notifications: "push" } }) + .then(() => Comment.sendDigests()) + .then((count) => { + expect(count).to.equal(1); + expect(Email.sendMessageDigest).to.have.been.called.exactly(1); + + expect(log[0].email).to.equal(u2.get("email")); + expect(log[0].data.messages).to.deep.equal([ + { + name: u1.get("name"), + avatar_url: u1.get("avatar_url"), + text: comments[0].get("text"), + }, + { + name: u1.get("name"), + avatar_url: u1.get("avatar_url"), + text: comments[1].get("text"), + }, + ]); + }); + }); + }); + + describe("with post comments", () => { beforeEach(() => { - mockify(Email, 'sendCommentDigest', args => log.push(args)) + mockify(Email, "sendCommentDigest", (args) => log.push(args)); return Promise.all([ - post.save({type: null}, {patch: true}), - u1.addSetting({comment_notifications: 'email'}, true), - u2.addSetting({comment_notifications: 'email'}, true) - ]) - }) - - afterEach(() => unspyify(Email, 'sendCommentDigest')) - - it('changes the subject if the digest contains a mention', () => { - const text = `hello buddy!` - return comments[1].save({text}, {patch: true}) - .then(() => Comment.sendDigests()) - .then(count => { - expect(count).to.equal(2) - expect(Email.sendCommentDigest).to.have.been.called.exactly(2) - - const send1 = log.find(l => l.email === u1.get('email')) - expect(send1.data.subject_prefix).to.match(/New comments/) - - const send2 = log.find(l => l.email === u2.get('email')) - expect(send2.data.subject_prefix).to.match(/You were mentioned/) - }) - }) - }) - }) -}) + post.save({ type: null }, { patch: true }), + u1.addSetting({ comment_notifications: "email" }, true), + u2.addSetting({ comment_notifications: "email" }, true), + ]); + }); + + afterEach(() => unspyify(Email, "sendCommentDigest")); + + it("changes the subject if the digest contains a mention", () => { + const text = `hello buddy!`; + return comments[1] + .save({ text }, { patch: true }) + .then(() => Comment.sendDigests()) + .then((count) => { + expect(count).to.equal(2); + expect(Email.sendCommentDigest).to.have.been.called.exactly(2); + + const send1 = log.find((l) => l.email === u1.get("email")); + expect(send1.data.subject_prefix).to.match(/New comments/); + + const send2 = log.find((l) => l.email === u2.get("email")); + expect(send2.data.subject_prefix).to.match(/You were mentioned/); + }); + }); + }); + }); +}); diff --git a/test/unit/models/Community.test.js b/test/unit/models/Community.test.js index 240671bfd..54c703053 100644 --- a/test/unit/models/Community.test.js +++ b/test/unit/models/Community.test.js @@ -1,103 +1,126 @@ /* eslint-disable no-unused-expressions */ -const root = require('root-path') -require(root('test/setup')) -const factories = require(root('test/setup/factories')) -const { mockify, unspyify } = require(root('test/setup/helpers')) - -describe('Community', () => { - it('can be created', function () { - var community = new Community({slug: 'foo', name: 'foo', beta_access_code: 'foo!'}) +const root = require("root-path"); +require(root("test/setup")); +const factories = require(root("test/setup/factories")); +const { mockify, unspyify } = require(root("test/setup/helpers")); + +describe("Community", () => { + it("can be created", function () { + const community = new Community({ + slug: "foo", + name: "foo", + beta_access_code: "foo!", + }); return community.save().then(() => { - expect(community.id).to.exist - }) - }) + expect(community.id).to.exist; + }); + }); - it('creates with default banner and avatar', async () => { + it("creates with default banner and avatar", async () => { const data = { - 'name': 'my community', - 'description': 'a community description', - 'slug': 'comm1' - } - - const user = await new User({name: 'username', email: 'john@foo.com', active: true}).save() - await new Community({slug: 'starter-posts', name: 'starter-posts', beta_access_code: 'aasdfkjh3##Sasdfsdfedss'}).save() - - const community = await Community.create(user.id, data) - - const savedCommunity = await Community.find('comm1') - expect(savedCommunity.get('banner_url')).to.equal('https://d3ngex8q79bk55.cloudfront.net/misc/default_community_banner.jpg') - expect(savedCommunity.get('avatar_url')).to.equal('https://d3ngex8q79bk55.cloudfront.net/misc/default_community_avatar.png') - }) - - describe('.find', () => { - it('ignores a blank id', () => { - return Community.find(null).then(i => expect(i).to.be.null) - }) - }) - - describe('.queryByAccessCode', () => { - let community + name: "my community", + description: "a community description", + slug: "comm1", + }; + + const user = await new User({ + name: "username", + email: "john@foo.com", + active: true, + }).save(); + await new Community({ + slug: "starter-posts", + name: "starter-posts", + beta_access_code: "aasdfkjh3##Sasdfsdfedss", + }).save(); + + const community = await Community.create(user.id, data); + + const savedCommunity = await Community.find("comm1"); + expect(savedCommunity.get("banner_url")).to.equal( + "https://d3ngex8q79bk55.cloudfront.net/misc/default_community_banner.jpg" + ); + expect(savedCommunity.get("avatar_url")).to.equal( + "https://d3ngex8q79bk55.cloudfront.net/misc/default_community_avatar.png" + ); + }); + + describe(".find", () => { + it("ignores a blank id", () => { + return Community.find(null).then((i) => expect(i).to.be.null); + }); + }); + + describe(".queryByAccessCode", () => { + let community; before(() => { - return factories.community({active: true}) - .save() - .then(c => { community = c }) - }) - - it('finds and fetches a community by accessCode', () => { - const communityId = community.get('id') - const accessCode = community.get('beta_access_code') + return factories + .community({ active: true }) + .save() + .then((c) => { + community = c; + }); + }); + + it("finds and fetches a community by accessCode", () => { + const communityId = community.get("id"); + const accessCode = community.get("beta_access_code"); return Community.queryByAccessCode(accessCode) - .fetch() - .then(c => { - return expect(c.id).to.equal(communityId) - }) - }) - }) - - describe('.isSlugValid', () => { - it('rejects invalid slugs', () => { - expect(Community.isSlugValid('a b')).to.be.false - expect(Community.isSlugValid('IAM')).to.be.false - expect(Community.isSlugValid('wow!')).to.be.false - expect(Community.isSlugValid('uh_')).to.be.false - expect(Community.isSlugValid('a')).to.be.false - expect(Community.isSlugValid('abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdx')).to.be.false - }) - }) - - describe('.reconcileNumMembers', () => { - let community + .fetch() + .then((c) => { + return expect(c.id).to.equal(communityId); + }); + }); + }); + + describe(".isSlugValid", () => { + it("rejects invalid slugs", () => { + expect(Community.isSlugValid("a b")).to.be.false; + expect(Community.isSlugValid("IAM")).to.be.false; + expect(Community.isSlugValid("wow!")).to.be.false; + expect(Community.isSlugValid("uh_")).to.be.false; + expect(Community.isSlugValid("a")).to.be.false; + expect(Community.isSlugValid("abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdx")) + .to.be.false; + }); + }); + + describe(".reconcileNumMembers", () => { + let community; before(async () => { - community = await factories.community().save() + community = await factories.community().save(); await community.addGroupMembers([ await factories.user().save(), - await factories.user({active: false}).save() - ]) - }) + await factories.user({ active: false }).save(), + ]); + }); - it('sets num_members correctly', async () => { - await community.reconcileNumMembers() - expect(community.get('num_members')).to.equal(1) - }) - }) + it("sets num_members correctly", async () => { + await community.reconcileNumMembers(); + expect(community.get("num_members")).to.equal(1); + }); + }); - describe('.deactivate', () => { + describe(".deactivate", () => { before(() => { - mockify(Group, 'deactivate') - }) + mockify(Group, "deactivate"); + }); after(() => { - unspyify(Group, 'deactivate') - }) - - it('sets active to false and calls Group.deactivate', async () => { - const community = await factories.community({active: true}).save() - await Community.deactivate(community.id) - await community.refresh() - expect(community.get('active')).to.equal(false) - expect(Group.deactivate).to.have.been.called.with(community.id, Community) - }) - }) -}) + unspyify(Group, "deactivate"); + }); + + it("sets active to false and calls Group.deactivate", async () => { + const community = await factories.community({ active: true }).save(); + await Community.deactivate(community.id); + await community.refresh(); + expect(community.get("active")).to.equal(false); + expect(Group.deactivate).to.have.been.called.with( + community.id, + Community + ); + }); + }); +}); diff --git a/test/unit/models/Device.test.js b/test/unit/models/Device.test.js index 27c462883..72b7b004f 100644 --- a/test/unit/models/Device.test.js +++ b/test/unit/models/Device.test.js @@ -1,55 +1,58 @@ -import '../../setup' -import factories from '../../setup/factories' -import { mockify, unspyify } from '../../setup/helpers' +import "../../setup"; +import factories from "../../setup/factories"; +import { mockify, unspyify } from "../../setup/helpers"; -describe('Device', () => { - let user, device +describe("Device", () => { + let user, device; beforeEach(() => { - mockify(OneSignal, 'notify', () => {}) - user = factories.user({new_notification_count: 6}) - return user.save() - .then(() => { + mockify(OneSignal, "notify", () => {}); + user = factories.user({ new_notification_count: 6 }); + return user.save().then(() => { device = factories.device({ user_id: user.id, enabled: true, - version: '2' - }) - return device.save() - }) - }) + version: "2", + }); + return device.save(); + }); + }); - afterEach(() => unspyify(OneSignal, 'notify')) + afterEach(() => unspyify(OneSignal, "notify")); - describe('.resetNotificationCount', () => { - it('sends a push notification with badge_no = 0', () => { - return device.resetNotificationCount() - .then(() => device.pushNotifications().fetchOne()) - .then(push => expect(push.get('badge_no')).to.equal(0)) - }) - }) + describe(".resetNotificationCount", () => { + it("sends a push notification with badge_no = 0", () => { + return device + .resetNotificationCount() + .then(() => device.pushNotifications().fetchOne()) + .then((push) => expect(push.get("badge_no")).to.equal(0)); + }); + }); - describe('.sendPushNotification', () => { - it('creates a PushNotification with the right badge number', () => { - return device.sendPushNotification('hello!', '/hello/world?amaze=yes') - .then(() => PushNotification.where({device_id: device.id}).fetch()) - .then(push => { - expect(push).to.exist - const queuedAt = push.get('queued_at') - expect(queuedAt).to.exist - expect(new Date() - new Date(queuedAt)).to.be.below(2000) - expect(push.get('badge_no')).to.equal(6) - }) - }) + describe(".sendPushNotification", () => { + it("creates a PushNotification with the right badge number", () => { + return device + .sendPushNotification("hello!", "/hello/world?amaze=yes") + .then(() => PushNotification.where({ device_id: device.id }).fetch()) + .then((push) => { + expect(push).to.exist; + const queuedAt = push.get("queued_at"); + expect(queuedAt).to.exist; + expect(new Date() - new Date(queuedAt)).to.be.below(2000); + expect(push.get("badge_no")).to.equal(6); + }); + }); - it('truncates a long alert', () => { - const tooLong = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce aliquam lorem in condimentum ultricies. Nunc et purus mollis magna scelerisque placerat. Mauris vel convallis massa, id efficitur ex. Duis eget blandit lorem. Aliquam bibendum velit erat, non viverra leo congue id. Maecenas at elit risus. Aenean elit arcu, varius id porta et, laoreet eu nulla. Donec in ante scelerisque, condimentum nisi a, luctus nisi. Fusce finibus auctor metus vel vulputate. Etiam eget turpis auctor, fringilla velit vel, mollis arcu. Nulla at nisl eget nulla bibendum vehicula.' - return device.sendPushNotification(tooLong, '/hello/world?amaze=yes') - .then(() => PushNotification.where({device_id: device.id}).fetch()) - .then(push => { - expect(push).to.exist - expect(push.get('alert')).to.equal(tooLong.substring(0, 255)) - }) - }) - }) -}) + it("truncates a long alert", () => { + const tooLong = + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce aliquam lorem in condimentum ultricies. Nunc et purus mollis magna scelerisque placerat. Mauris vel convallis massa, id efficitur ex. Duis eget blandit lorem. Aliquam bibendum velit erat, non viverra leo congue id. Maecenas at elit risus. Aenean elit arcu, varius id porta et, laoreet eu nulla. Donec in ante scelerisque, condimentum nisi a, luctus nisi. Fusce finibus auctor metus vel vulputate. Etiam eget turpis auctor, fringilla velit vel, mollis arcu. Nulla at nisl eget nulla bibendum vehicula."; + return device + .sendPushNotification(tooLong, "/hello/world?amaze=yes") + .then(() => PushNotification.where({ device_id: device.id }).fetch()) + .then((push) => { + expect(push).to.exist; + expect(push.get("alert")).to.equal(tooLong.substring(0, 255)); + }); + }); + }); +}); diff --git a/test/unit/models/FlaggedItem.test.js b/test/unit/models/FlaggedItem.test.js index 1f020921b..0c9786418 100644 --- a/test/unit/models/FlaggedItem.test.js +++ b/test/unit/models/FlaggedItem.test.js @@ -1,264 +1,289 @@ -import setup from '../../setup' -import factories from '../../setup/factories' -import mockRequire from 'mock-require' +import setup from "../../setup"; +import factories from "../../setup/factories"; +import mockRequire from "mock-require"; -describe('FlaggedItem', () => { +describe("FlaggedItem", () => { const item = { - category: 'abusive', - reason: 'Said wombats were not cute. Just mean.', - link: 'https://www.hylo.com/c/wombats/p/12345' - } + category: "abusive", + reason: "Said wombats were not cute. Just mean.", + link: "https://www.hylo.com/c/wombats/p/12345", + }; - describe('create', () => { - it('rejects an unrecognised category', () => { - const p = FlaggedItem.create(Object.assign({}, item, { category: 'flarglearglestein' })) - return expect(p).to.be.rejectedWith(/Unknown category/) - }) + describe("create", () => { + it("rejects an unrecognised category", () => { + const p = FlaggedItem.create( + Object.assign({}, item, { category: "flarglearglestein" }) + ); + return expect(p).to.be.rejectedWith(/Unknown category/); + }); // These are mostly re-testing the validators, but shouldn't hurt to be thorough... - it('rejects a non-Hylo URL', () => { - const p = FlaggedItem.create(Object.assign({}, item, { link: 'https://google.com' })) - return expect(p).to.be.rejectedWith(/valid Hylo URL/) - }) + it("rejects a non-Hylo URL", () => { + const p = FlaggedItem.create( + Object.assign({}, item, { link: "https://google.com" }) + ); + return expect(p).to.be.rejectedWith(/valid Hylo URL/); + }); - it('accepts a Hylo subdomain', () => { - const p = FlaggedItem.create(Object.assign({}, item, { link: 'https://legacy.hylo.com' })) - return expect(p).not.to.be.rejected - }) + it("accepts a Hylo subdomain", () => { + const p = FlaggedItem.create( + Object.assign({}, item, { link: "https://legacy.hylo.com" }) + ); + return expect(p).not.to.be.rejected; + }); - it('rejects on a missing URL', () => { - const p = FlaggedItem.create(Object.assign({}, item, { link: undefined })) - return expect(p).to.be.rejectedWith(/Link must be a string/) - }) + it("rejects on a missing URL", () => { + const p = FlaggedItem.create( + Object.assign({}, item, { link: undefined }) + ); + return expect(p).to.be.rejectedWith(/Link must be a string/); + }); - it('rejects on a missing reason', () => { - const p = FlaggedItem.create(Object.assign({}, item, { category: 'other', reason: undefined })) - return expect(p).to.be.rejectedWith(/Reason must be a string/) - }) + it("rejects on a missing reason", () => { + const p = FlaggedItem.create( + Object.assign({}, item, { category: "other", reason: undefined }) + ); + return expect(p).to.be.rejectedWith(/Reason must be a string/); + }); - it('rejects on a huge reason', () => { - const reason = new Array(6000).join('z') - const p = FlaggedItem.create(Object.assign({}, item, { reason })) - return expect(p).to.be.rejectedWith(/Reason must be no more than/) - }) - }) + it("rejects on a huge reason", () => { + const reason = new Array(6000).join("z"); + const p = FlaggedItem.create(Object.assign({}, item, { reason })); + return expect(p).to.be.rejectedWith(/Reason must be no more than/); + }); + }); - describe('getObject', () => { - var post, comment, user + describe("getObject", () => { + let post, comment, user; before(() => { - post = factories.post() - comment = factories.comment() - user = factories.user() - return Promise.join( - post.save(), comment.save(), user.save() - ) - }) + post = factories.post(); + comment = factories.comment(); + user = factories.user(); + return Promise.join(post.save(), comment.save(), user.save()); + }); - it('returns a post', async () => { + it("returns a post", async () => { const flaggedItem = await FlaggedItem.create({ object_type: FlaggedItem.Type.POST, object_id: post.id, category: FlaggedItem.Category.SPAM, - link: 'www.hylo.com/p/1' - }) - const object = await flaggedItem.getObject() - expect(object.id).to.equal(post.id) - expect(object instanceof Post).to.be.true - }) + link: "www.hylo.com/p/1", + }); + const object = await flaggedItem.getObject(); + expect(object.id).to.equal(post.id); + expect(object instanceof Post).to.be.true; + }); - it('returns a user', async () => { + it("returns a user", async () => { const flaggedItem = await FlaggedItem.create({ object_type: FlaggedItem.Type.MEMBER, object_id: user.id, category: FlaggedItem.Category.SPAM, - link: 'www.hylo.com/p/1' - }) - const object = await flaggedItem.getObject() - expect(object.id).to.equal(user.id) - expect(object instanceof User).to.be.true - }) + link: "www.hylo.com/p/1", + }); + const object = await flaggedItem.getObject(); + expect(object.id).to.equal(user.id); + expect(object instanceof User).to.be.true; + }); - it('returns a user', async () => { + it("returns a user", async () => { const flaggedItem = await FlaggedItem.create({ object_type: FlaggedItem.Type.COMMENT, object_id: comment.id, category: FlaggedItem.Category.SPAM, - link: 'www.hylo.com/p/1' - }) - const object = await flaggedItem.getObject() - expect(object.id).to.equal(comment.id) - expect(object instanceof Comment).to.be.true - }) + link: "www.hylo.com/p/1", + }); + const object = await flaggedItem.getObject(); + expect(object.id).to.equal(comment.id); + expect(object instanceof Comment).to.be.true; + }); - it('throws an error when object_type is bad', () => { + it("throws an error when object_type is bad", () => { return FlaggedItem.forge({ - object_type: 'unsupported type', - object_id: 1 - }).save() - .then(flaggedItem => flaggedItem.getObject()) - .then(() => expect.fail('should reject')) - .catch(e => expect(e.message).to.match(/Unsupported type for Flagged Item/)) - }) - }) + object_type: "unsupported type", + object_id: 1, + }) + .save() + .then((flaggedItem) => flaggedItem.getObject()) + .then(() => expect.fail("should reject")) + .catch((e) => + expect(e.message).to.match(/Unsupported type for Flagged Item/) + ); + }); + }); - describe('getMessageText', () => { - var post, user, community + describe("getMessageText", () => { + let post, user, community; before(() => { - post = factories.post() - community = factories.community() - user = factories.user() - return Promise.join( - post.save(), community.save(), user.save() - ) - }) + post = factories.post(); + community = factories.community(); + user = factories.user(); + return Promise.join(post.save(), community.save(), user.save()); + }); - it('creates the message', async () => { - const reason = 'real spam' + it("creates the message", async () => { + const reason = "real spam"; const flaggedItem = await FlaggedItem.create({ object_type: FlaggedItem.Type.POST, object_id: post.id, category: FlaggedItem.Category.SPAM, - link: 'www.hylo.com/p/1', + link: "www.hylo.com/p/1", reason, - user_id: user.id - }) - await flaggedItem.load('user') + user_id: user.id, + }); + await flaggedItem.load("user"); const expected = [ - `${user.get('name')} flagged a ${FlaggedItem.Type.POST} in ${community.get('name')} for being ${FlaggedItem.Category.SPAM}`, - `Message: ${reason}` - ] - const message = await flaggedItem.getMessageText(community) - const lines = message.split('\n') - expect(lines[0]).to.equal(expected[0]) - expect(lines[1]).to.equal(expected[1]) - }) - }) + `${user.get("name")} flagged a ${ + FlaggedItem.Type.POST + } in ${community.get("name")} for being ${FlaggedItem.Category.SPAM}`, + `Message: ${reason}`, + ]; + const message = await flaggedItem.getMessageText(community); + const lines = message.split("\n"); + expect(lines[0]).to.equal(expected[0]); + expect(lines[1]).to.equal(expected[1]); + }); + }); - describe('getContentLink', () => { - var post, comment, commentParent, user, community + describe("getContentLink", () => { + let post, comment, commentParent, user, community; before(() => { - post = factories.post() - commentParent = factories.post() - comment = factories.comment() - user = factories.user() - community = factories.community() + post = factories.post(); + commentParent = factories.post(); + comment = factories.comment(); + user = factories.user(); + community = factories.community(); return Promise.join( - post.save(), comment.save(), user.save(), community.save(), commentParent.save() - ) - .then(() => { - commentParent.comments().create(comment) - }) - }) + post.save(), + comment.save(), + user.save(), + community.save(), + commentParent.save() + ).then(() => { + commentParent.comments().create(comment); + }); + }); - it('makes a post link', async () => { + it("makes a post link", async () => { const flaggedItem = await FlaggedItem.create({ object_type: FlaggedItem.Type.POST, object_id: post.id, category: FlaggedItem.Category.SPAM, - link: 'www.hylo.com/p/1' - }) - const link = await flaggedItem.getContentLink(community) - expect(link).to.equal(Frontend.Route.post(post.id, community)) - }) + link: "www.hylo.com/p/1", + }); + const link = await flaggedItem.getContentLink(community); + expect(link).to.equal(Frontend.Route.post(post.id, community)); + }); - it('makes a user link', async () => { + it("makes a user link", async () => { const flaggedItem = await FlaggedItem.create({ object_type: FlaggedItem.Type.MEMBER, object_id: user.id, category: FlaggedItem.Category.SPAM, - link: 'www.hylo.com/p/1' - }) - const link = await flaggedItem.getContentLink(community) - expect(link).to.equal(Frontend.Route.profile(user.id, community)) - }) + link: "www.hylo.com/p/1", + }); + const link = await flaggedItem.getContentLink(community); + expect(link).to.equal(Frontend.Route.profile(user.id, community)); + }); - it('makes a comment link', async () => { + it("makes a comment link", async () => { const flaggedItem = await FlaggedItem.create({ object_type: FlaggedItem.Type.COMMENT, object_id: comment.id, category: FlaggedItem.Category.SPAM, - link: 'www.hylo.com/p/1' - }) - const link = await flaggedItem.getContentLink(community) - expect(link).to.equal(Frontend.Route.post(commentParent.id, community)) - }) + link: "www.hylo.com/p/1", + }); + const link = await flaggedItem.getContentLink(community); + expect(link).to.equal(Frontend.Route.post(commentParent.id, community)); + }); - it('throws an error when object_type is bad', () => { + it("throws an error when object_type is bad", () => { return FlaggedItem.forge({ - object_type: 'unsupported type', - object_id: 1 - }).save() - .then(flaggedItem => flaggedItem.getContentLink(community)) - .then(() => expect.fail('should reject')) - .catch(e => expect(e.message).to.match(/Unsupported type for Flagged Item/)) - }) - }) + object_type: "unsupported type", + object_id: 1, + }) + .save() + .then((flaggedItem) => flaggedItem.getContentLink(community)) + .then(() => expect.fail("should reject")) + .catch((e) => + expect(e.message).to.match(/Unsupported type for Flagged Item/) + ); + }); + }); - describe('notifyModerators', () => { - var notifyModeratorsPost, notifyModeratorsComment, notifyModeratorsMember, FlaggedItem + describe("notifyModerators", () => { + let notifyModeratorsPost, + notifyModeratorsComment, + notifyModeratorsMember, + FlaggedItem; beforeEach(() => { - notifyModeratorsPost = spy() - notifyModeratorsMember = spy() - notifyModeratorsComment = spy() - mockRequire('../../../api/models/flaggedItem/notifyUtils', { + notifyModeratorsPost = spy(); + notifyModeratorsMember = spy(); + notifyModeratorsComment = spy(); + mockRequire("../../../api/models/flaggedItem/notifyUtils", { notifyModeratorsPost, notifyModeratorsMember, - notifyModeratorsComment - }) - FlaggedItem = mockRequire.reRequire('../../../api/models/FlaggedItem') - }) + notifyModeratorsComment, + }); + FlaggedItem = mockRequire.reRequire("../../../api/models/FlaggedItem"); + }); - it('calls post function on post', async () => { + it("calls post function on post", async () => { const flaggedItem = await FlaggedItem.create({ object_type: FlaggedItem.Type.POST, object_id: 1, category: FlaggedItem.Category.SPAM, - link: 'www.hylo.com/p/1' - }) + link: "www.hylo.com/p/1", + }); - await FlaggedItem.notifyModerators({id: flaggedItem.id}) - expect(notifyModeratorsPost).to.have.been.called() - expect(notifyModeratorsComment).not.to.have.been.called() - }) + await FlaggedItem.notifyModerators({ id: flaggedItem.id }); + expect(notifyModeratorsPost).to.have.been.called(); + expect(notifyModeratorsComment).not.to.have.been.called(); + }); - it('calls comment function on comment', async () => { + it("calls comment function on comment", async () => { const flaggedItem = await FlaggedItem.create({ object_type: FlaggedItem.Type.COMMENT, object_id: 1, category: FlaggedItem.Category.SPAM, - link: 'www.hylo.com/p/1' - }) + link: "www.hylo.com/p/1", + }); - await FlaggedItem.notifyModerators({id: flaggedItem.id}) - expect(notifyModeratorsComment).to.have.been.called() - expect(notifyModeratorsMember).not.to.have.been.called() - }) + await FlaggedItem.notifyModerators({ id: flaggedItem.id }); + expect(notifyModeratorsComment).to.have.been.called(); + expect(notifyModeratorsMember).not.to.have.been.called(); + }); - it('calls member function on member', async () => { + it("calls member function on member", async () => { const flaggedItem = await FlaggedItem.create({ object_type: FlaggedItem.Type.MEMBER, object_id: 1, category: FlaggedItem.Category.SPAM, - link: 'www.hylo.com/p/1' - }) + link: "www.hylo.com/p/1", + }); - await FlaggedItem.notifyModerators({id: flaggedItem.id}) - expect(notifyModeratorsMember).to.have.been.called() - expect(notifyModeratorsPost).not.to.have.been.called() - }) + await FlaggedItem.notifyModerators({ id: flaggedItem.id }); + expect(notifyModeratorsMember).to.have.been.called(); + expect(notifyModeratorsPost).not.to.have.been.called(); + }); - it('throws an error when object_type is bad', () => { + it("throws an error when object_type is bad", () => { return FlaggedItem.forge({ - object_type: 'unsupported type', - object_id: 1 - }).save() - .then(flaggedItem => FlaggedItem.notifyModerators({id: flaggedItem.id})) - .then(() => expect.fail('should reject')) - .catch(e => expect(e.message).to.match(/Unsupported type for Flagged Item/)) - }) - }) -}) + object_type: "unsupported type", + object_id: 1, + }) + .save() + .then((flaggedItem) => + FlaggedItem.notifyModerators({ id: flaggedItem.id }) + ) + .then(() => expect.fail("should reject")) + .catch((e) => + expect(e.message).to.match(/Unsupported type for Flagged Item/) + ); + }); + }); +}); diff --git a/test/unit/models/Group.test.js b/test/unit/models/Group.test.js index a3475ad96..3cc71772d 100644 --- a/test/unit/models/Group.test.js +++ b/test/unit/models/Group.test.js @@ -1,87 +1,92 @@ -import factories from '../../setup/factories' +import factories from "../../setup/factories"; -describe('Group', () => { - describe('addMembers', () => { - let group, u1, u2, gm1 +describe("Group", () => { + describe("addMembers", () => { + let group, u1, u2, gm1; beforeEach(async () => { - group = await Group.forge({group_data_type: 0}).save() - u1 = await factories.user().save() - u2 = await factories.user().save() + group = await Group.forge({ group_data_type: 0 }).save(); + u1 = await factories.user().save(); + u2 = await factories.user().save(); gm1 = await group.memberships().create({ user_id: u1.id, - settings: {here: true}, - group_data_type: 0 - }) - }) + settings: { here: true }, + group_data_type: 0, + }); + }); - it('merges new settings to existing memberships and creates new ones', async () => { - const results = await group.addMembers([u1.id, u2.id], {role: 1, settings: {there: true}}) - expect(results.length).to.equal(2) + it("merges new settings to existing memberships and creates new ones", async () => { + const results = await group.addMembers([u1.id, u2.id], { + role: 1, + settings: { there: true }, + }); + expect(results.length).to.equal(2); - await gm1.refresh() - expect(gm1.get('settings')).to.deep.equal({here: true, there: true}) - expect(gm1.get('role')).to.equal(1) + await gm1.refresh(); + expect(gm1.get("settings")).to.deep.equal({ here: true, there: true }); + expect(gm1.get("role")).to.equal(1); - const gm2 = await group.memberships() - .query(q => q.where('user_id', u2.id)).fetchOne() - expect(gm2.get('settings')).to.deep.equal({there: true}) - expect(gm2.get('role')).to.equal(1) - }) - }) + const gm2 = await group + .memberships() + .query((q) => q.where("user_id", u2.id)) + .fetchOne(); + expect(gm2.get("settings")).to.deep.equal({ there: true }); + expect(gm2.get("role")).to.equal(1); + }); + }); - describe('groupData', () => { - it('returns a related post', async () => { - const post = await factories.post().save() - const group = await post.createGroup() - const post2 = await group.groupData().fetch() - expect(post2.id).to.equal(post.id) - }) - }) + describe("groupData", () => { + it("returns a related post", async () => { + const post = await factories.post().save(); + const group = await post.createGroup(); + const post2 = await group.groupData().fetch(); + expect(post2.id).to.equal(post.id); + }); + }); - describe('removeMembers', () => { - it('removes child members', async () => { - const community = await factories.community().save() - const group = await community.createGroup() - const user1 = await factories.user().save() - const user2 = await factories.user().save() - await group.addMembers([user1, user2]) - await group.removeMembers(await group.members().fetch()) - const postRemoveMembers = await group.members().fetch() - expect(postRemoveMembers.length).to.equal(0) - }) - }) + describe("removeMembers", () => { + it("removes child members", async () => { + const community = await factories.community().save(); + const group = await community.createGroup(); + const user1 = await factories.user().save(); + const user2 = await factories.user().save(); + await group.addMembers([user1, user2]); + await group.removeMembers(await group.members().fetch()); + const postRemoveMembers = await group.members().fetch(); + expect(postRemoveMembers.length).to.equal(0); + }); + }); - describe('updateMembers', () => { - it('updates members', async () => { - const community = await factories.community().save() - const group = await community.createGroup() - const user1 = await factories.user().save() - const user2 = await factories.user().save() - const projectRole = await ProjectRole.forge({name: 'test role'}).save() - const role = 1 - const project_role_id = projectRole.id - const updates = { role, project_role_id } - await group.addMembers([user1, user2]) - await group.updateMembers([user1, user2], updates) - const updatedMemberships = await group.memberships().fetch() - updatedMemberships.models.forEach(membership => { - expect(membership.get('project_role_id')).to.equal(project_role_id) - expect(membership.get('role')).to.equal(role) - }) - }) - }) + describe("updateMembers", () => { + it("updates members", async () => { + const community = await factories.community().save(); + const group = await community.createGroup(); + const user1 = await factories.user().save(); + const user2 = await factories.user().save(); + const projectRole = await ProjectRole.forge({ name: "test role" }).save(); + const role = 1; + const project_role_id = projectRole.id; + const updates = { role, project_role_id }; + await group.addMembers([user1, user2]); + await group.updateMembers([user1, user2], updates); + const updatedMemberships = await group.memberships().fetch(); + updatedMemberships.models.forEach((membership) => { + expect(membership.get("project_role_id")).to.equal(project_role_id); + expect(membership.get("role")).to.equal(role); + }); + }); + }); - describe('deactivate', () => { - it('deactivates all child members', async () => { - const community = await factories.community().save() - const group = await community.createGroup() - const user1 = await factories.user().save() - const user2 = await factories.user().save() - await group.addMembers([user1, user2]) - await Group.deactivate(community.id, Community) - const postDeactivationMembers = await group.members().fetch() - expect(postDeactivationMembers.length).to.equal(0) - }) - }) -}) + describe("deactivate", () => { + it("deactivates all child members", async () => { + const community = await factories.community().save(); + const group = await community.createGroup(); + const user1 = await factories.user().save(); + const user2 = await factories.user().save(); + await group.addMembers([user1, user2]); + await Group.deactivate(community.id, Community); + const postDeactivationMembers = await group.members().fetch(); + expect(postDeactivationMembers.length).to.equal(0); + }); + }); +}); diff --git a/test/unit/models/GroupMembership.test.js b/test/unit/models/GroupMembership.test.js index a9eba9540..e157c7cda 100644 --- a/test/unit/models/GroupMembership.test.js +++ b/test/unit/models/GroupMembership.test.js @@ -1,59 +1,63 @@ -const root = require('root-path') -const setup = require(root('test/setup')) -const { spyify, unspyify } = require(root('test/setup/helpers')) -const factories = require(root('test/setup/factories')) +const root = require("root-path"); +const setup = require(root("test/setup")); +const { spyify, unspyify } = require(root("test/setup/helpers")); +const factories = require(root("test/setup/factories")); -describe('GroupMembership', () => { - before(async () => setup.clearDb()) +describe("GroupMembership", () => { + before(async () => setup.clearDb()); - describe('forPair', () => { - let c, u + describe("forPair", () => { + let c, u; before(async () => { - c = await factories.community().save() - u = await factories.user().save() - }) - - it('should throw if no user', () => { - expect(() => GroupMembership.forPair()).to.throw(/user or user id/) - }) - - it('should throw if no instance', () => { - expect(() => GroupMembership.forPair(u)).to.throw(/without an instance/) - }) - - it('should invoke forIds with the correct ids and model', async () => { - spyify(GroupMembership, 'forIds') - await GroupMembership.forPair(u, c) - expect(GroupMembership.forIds).to.have.been.called.with(u.id, c.id, c.constructor) - unspyify(GroupMembership, 'forIds') - }) - }) - - describe('hasActiveMembership', () => { - let u, c1, c2, gm + c = await factories.community().save(); + u = await factories.user().save(); + }); + + it("should throw if no user", () => { + expect(() => GroupMembership.forPair()).to.throw(/user or user id/); + }); + + it("should throw if no instance", () => { + expect(() => GroupMembership.forPair(u)).to.throw(/without an instance/); + }); + + it("should invoke forIds with the correct ids and model", async () => { + spyify(GroupMembership, "forIds"); + await GroupMembership.forPair(u, c); + expect(GroupMembership.forIds).to.have.been.called.with( + u.id, + c.id, + c.constructor + ); + unspyify(GroupMembership, "forIds"); + }); + }); + + describe("hasActiveMembership", () => { + let u, c1, c2, gm; before(async () => { - u = await factories.user().save() - c1 = await factories.community().save() - c2 = await factories.community().save() - gm = await u.joinCommunity(c1) - }) - - it('returns true if user is a member', async () => { - const actual = await GroupMembership.hasActiveMembership(u, c1) - expect(actual).to.equal(true) - }) - - it('returns false if user is not a member', async () => { - const actual = await GroupMembership.hasActiveMembership(u, c2) - expect(actual).to.equal(false) - }) - - it('returns false if user is an inactive member', async () => { - await gm.updateAndSave({ active: false }) - const actual = await GroupMembership.hasActiveMembership(u, c1) - expect(actual).to.equal(false) - }) - }) -}) + u = await factories.user().save(); + c1 = await factories.community().save(); + c2 = await factories.community().save(); + gm = await u.joinCommunity(c1); + }); + + it("returns true if user is a member", async () => { + const actual = await GroupMembership.hasActiveMembership(u, c1); + expect(actual).to.equal(true); + }); + + it("returns false if user is not a member", async () => { + const actual = await GroupMembership.hasActiveMembership(u, c2); + expect(actual).to.equal(false); + }); + + it("returns false if user is an inactive member", async () => { + await gm.updateAndSave({ active: false }); + const actual = await GroupMembership.hasActiveMembership(u, c1); + expect(actual).to.equal(false); + }); + }); +}); diff --git a/test/unit/models/Invitation.test.js b/test/unit/models/Invitation.test.js index 13cc6c62b..7fc6a3378 100644 --- a/test/unit/models/Invitation.test.js +++ b/test/unit/models/Invitation.test.js @@ -1,246 +1,279 @@ /* eslint-disable no-unused-expressions */ -import { spyify, unspyify } from '../../setup/helpers' -import { sortBy } from 'lodash/fp' -var root = require('root-path') -var setup = require(root('test/setup')) -var factories = require(root('test/setup/factories')) +import { spyify, unspyify } from "../../setup/helpers"; +import { sortBy } from "lodash/fp"; +const root = require("root-path"); +const setup = require(root("test/setup")); +const factories = require(root("test/setup/factories")); -describe('Invitation', function () { - before(() => setup.clearDb()) +describe("Invitation", function () { + before(() => setup.clearDb()); - describe('.find', () => { - it('ignores a blank id', () => { - return Invitation.find(null).then(i => expect(i).to.be.null) - }) - }) + describe(".find", () => { + it("ignores a blank id", () => { + return Invitation.find(null).then((i) => expect(i).to.be.null); + }); + }); - describe('#use', function () { - var user, community, tag, invitation1, invitation2, inviter + describe("#use", function () { + let user, community, tag, invitation1, invitation2, inviter; before(async () => { - inviter = await factories.user().save() - user = await factories.user().save() - community = await factories.community().save() - tag = await new Tag({name: 'taginvitationtest'}).save() + inviter = await factories.user().save(); + user = await factories.user().save(); + community = await factories.community().save(); + tag = await new Tag({ name: "taginvitationtest" }).save(); invitation1 = await Invitation.create({ userId: inviter.id, communityId: community.id, - email: 'foo@comcom.com', - moderator: true - }) + email: "foo@comcom.com", + moderator: true, + }); invitation2 = await Invitation.create({ userId: inviter.id, communityId: community.id, - email: 'foo@comcom.com', + email: "foo@comcom.com", moderator: true, - tag_id: tag.id - }) - }) - - it('creates a membership and marks itself used', async () => { - await bookshelf.transaction(trx => invitation1.use(user.id, {transacting: trx})) - expect(invitation1.get('used_by_id')).to.equal(user.id) - expect(invitation1.get('used_at').getTime()).to.be.closeTo(new Date().getTime(), 2000) - const isModerator = await GroupMembership.hasModeratorRole(user, community) - expect(isModerator).to.be.true - }) - - it('creates a tag_follow when it has a tag_id', function () { - return bookshelf.transaction(trx => invitation2.use(user.id, {transacting: trx})) - .then(TagFollow.where({ - user_id: user.id, - community_id: community.id, - tag_id: tag.id - }).fetch()) - .then(tagFollow => expect(tagFollow).to.exist) - }) - }) - - describe('.reinviteAll', () => { - var community, c2, user, inviter + tag_id: tag.id, + }); + }); + + it("creates a membership and marks itself used", async () => { + await bookshelf.transaction((trx) => + invitation1.use(user.id, { transacting: trx }) + ); + expect(invitation1.get("used_by_id")).to.equal(user.id); + expect(invitation1.get("used_at").getTime()).to.be.closeTo( + new Date().getTime(), + 2000 + ); + const isModerator = await GroupMembership.hasModeratorRole( + user, + community + ); + expect(isModerator).to.be.true; + }); + + it("creates a tag_follow when it has a tag_id", function () { + return bookshelf + .transaction((trx) => invitation2.use(user.id, { transacting: trx })) + .then( + TagFollow.where({ + user_id: user.id, + community_id: community.id, + tag_id: tag.id, + }).fetch() + ) + .then((tagFollow) => expect(tagFollow).to.exist); + }); + }); + + describe(".reinviteAll", () => { + let community, c2, user, inviter; before(() => { - community = factories.community() - c2 = factories.community() - user = factories.user() - inviter = factories.user() - spyify(Email, 'sendInvitation', () => Promise.resolve({})) - return Promise.join(inviter.save(), user.save(), community.save(), c2.save()) - .then(() => { + community = factories.community(); + c2 = factories.community(); + user = factories.user(); + inviter = factories.user(); + spyify(Email, "sendInvitation", () => Promise.resolve({})); + return Promise.join( + inviter.save(), + user.save(), + community.save(), + c2.save() + ).then(() => { return Promise.join( Invitation.create({ communityId: community.id, userId: inviter.id, - email: 'foo@bar.com' + email: "foo@bar.com", }), Invitation.create({ communityId: community.id, userId: inviter.id, - email: 'bar@baz.com' + email: "bar@baz.com", }), Invitation.create({ communityId: c2.id, userId: inviter.id, - email: 'baz@foo.com' + email: "baz@foo.com", }) - ) - }) - }) + ); + }); + }); - after(() => unspyify(Email, 'sendInvitation')) + after(() => unspyify(Email, "sendInvitation")); - it('calls Email.sendInvitation twice', () => { + it("calls Email.sendInvitation twice", () => { return Invitation.reinviteAll({ userId: inviter.id, - communityId: community.id - }) - .then(() => { - expect(Email.sendInvitation).to.have.been.called.exactly(2) - }) - }) - }) - - describe('createAndSend', () => { - var community, user, inviter, invEmail, invData + communityId: community.id, + }).then(() => { + expect(Email.sendInvitation).to.have.been.called.exactly(2); + }); + }); + }); + + describe("createAndSend", () => { + let community, user, inviter, invEmail, invData; before(() => { - community = factories.community() - user = factories.user() - inviter = factories.user() - spyify(Email, 'sendInvitation', (email, data) => { - invEmail = email - invData = data - return Promise.resolve({}) - }) - return Promise.join(inviter.save(), user.save(), community.save()) - }) - - after(() => unspyify(Email, 'sendInvitation')) - - it('creates an invite and calls Email.sendInvitation', async () => { - const subject = 'The invite subject' - const message = 'The invite message' - const email = 'foo@comcom.com' + community = factories.community(); + user = factories.user(); + inviter = factories.user(); + spyify(Email, "sendInvitation", (email, data) => { + invEmail = email; + invData = data; + return Promise.resolve({}); + }); + return Promise.join(inviter.save(), user.save(), community.save()); + }); + + after(() => unspyify(Email, "sendInvitation")); + + it("creates an invite and calls Email.sendInvitation", async () => { + const subject = "The invite subject"; + const message = "The invite message"; + const email = "foo@comcom.com"; const invitation = await Invitation.create({ userId: inviter.id, communityId: community.id, email, moderator: true, subject, - message - }) + message, + }); // console.log('invitation in test', invitation) - return Invitation.createAndSend({invitation}) - .then(() => Invitation.where({email: email, community_id: community.id}).fetch()) - .then(invitation => { - expect(invitation).to.exist - expect(invitation.get('subject')).to.equal(subject) - expect(invitation.get('message')).to.equal(message) - }) - .then(() => { - expect(Email.sendInvitation).to.have.been.called.exactly(1) - expect(invEmail).to.equal(email) - expect(invData).to.contain({ - subject, - message, - inviter_name: inviter.get('name'), - inviter_email: inviter.get('email'), - community_name: community.get('name') + return Invitation.createAndSend({ invitation }) + .then(() => + Invitation.where({ email: email, community_id: community.id }).fetch() + ) + .then((invitation) => { + expect(invitation).to.exist; + expect(invitation.get("subject")).to.equal(subject); + expect(invitation.get("message")).to.equal(message); }) - }) - }) - }) + .then(() => { + expect(Email.sendInvitation).to.have.been.called.exactly(1); + expect(invEmail).to.equal(email); + expect(invData).to.contain({ + subject, + message, + inviter_name: inviter.get("name"), + inviter_email: inviter.get("email"), + community_name: community.get("name"), + }); + }); + }); + }); - describe('.resendAllReady', () => { - var community, c2, inviter, user + describe(".resendAllReady", () => { + let community, c2, inviter, user; before(() => { - community = factories.community() - c2 = factories.community() - inviter = factories.user() - user = factories.user() - const day = 1000 * 60 * 60 * 24 - const now = new Date() - return Promise.join(inviter.save(), community.save(), c2.save(), user.save()) - .then(() => { + community = factories.community(); + c2 = factories.community(); + inviter = factories.user(); + user = factories.user(); + const day = 1000 * 60 * 60 * 24; + const now = new Date(); + return Promise.join( + inviter.save(), + community.save(), + c2.save(), + user.save() + ).then(() => { const attributes = [ { - email: 'a@sendme.com', + email: "a@sendme.com", sent_count: 1, - last_sent_at: new Date(now - 4.1 * day) + last_sent_at: new Date(now - 4.1 * day), }, { - email: 'b@sendme.com', + email: "b@sendme.com", sent_count: 2, - last_sent_at: new Date(now - 9.1 * day) + last_sent_at: new Date(now - 9.1 * day), }, { - email: 'a@used.com', + email: "a@used.com", sent_count: 1, last_sent_at: new Date(now - 10 * day), - used_by_id: user.id + used_by_id: user.id, }, { - email: 'a@notyet.com', + email: "a@notyet.com", sent_count: 1, - last_sent_at: new Date(now - 3 * day) + last_sent_at: new Date(now - 3 * day), }, { - email: 'b@notyet.com', + email: "b@notyet.com", sent_count: 2, - last_sent_at: new Date(now - 8 * day) - } - ] + last_sent_at: new Date(now - 8 * day), + }, + ]; - const userId = inviter.id - return Promise.map(attributes, ({ email, sent_count, last_sent_at, used_by_id }) => - Invitation.create({communityId: community.id, userId, email}) - .then(i => i.save({sent_count, last_sent_at, used_by_id}, {patch: true}))) - }) - }) + const userId = inviter.id; + return Promise.map( + attributes, + ({ email, sent_count, last_sent_at, used_by_id }) => + Invitation.create({ + communityId: community.id, + userId, + email, + }).then((i) => + i.save({ sent_count, last_sent_at, used_by_id }, { patch: true }) + ) + ); + }); + }); - it('sends the invitations that are ready and unused', function () { - this.timeout(10000) - const now = new Date().getTime() + it("sends the invitations that are ready and unused", function () { + this.timeout(10000); + const now = new Date().getTime(); return Invitation.resendAllReady() - .then(() => Invitation.where({community_id: community.id}).fetchAll()) - .then(invitations => { - const expected = sortBy('email', [ - { - email: 'a@sendme.com', - sent_count: 2 - }, - { - email: 'b@sendme.com', - sent_count: 3 - }, - { - email: 'a@used.com', - sent_count: 1 - }, - { - email: 'a@notyet.com', - sent_count: 1 - }, - { - email: 'b@notyet.com', - sent_count: 2 - } - ]) - - expect(sortBy('email', invitations.map(i => ({ - email: i.get('email'), - sent_count: i.get('sent_count') - })))).to.deep.equal(expected) - - invitations.forEach(i => { - const email = i.get('email') - const lastSentAt = i.get('last_sent_at').getTime() - if (email.match('@sendme.com')) { - expect(lastSentAt).to.be.closeTo(now, 2000) - } else { - expect(lastSentAt).not.to.be.closeTo(now, 2000) - } - }) - }) - }) - }) -}) + .then(() => Invitation.where({ community_id: community.id }).fetchAll()) + .then((invitations) => { + const expected = sortBy("email", [ + { + email: "a@sendme.com", + sent_count: 2, + }, + { + email: "b@sendme.com", + sent_count: 3, + }, + { + email: "a@used.com", + sent_count: 1, + }, + { + email: "a@notyet.com", + sent_count: 1, + }, + { + email: "b@notyet.com", + sent_count: 2, + }, + ]); + + expect( + sortBy( + "email", + invitations.map((i) => ({ + email: i.get("email"), + sent_count: i.get("sent_count"), + })) + ) + ).to.deep.equal(expected); + + invitations.forEach((i) => { + const email = i.get("email"); + const lastSentAt = i.get("last_sent_at").getTime(); + if (email.match("@sendme.com")) { + expect(lastSentAt).to.be.closeTo(now, 2000); + } else { + expect(lastSentAt).not.to.be.closeTo(now, 2000); + } + }); + }); + }); + }); +}); diff --git a/test/unit/models/LinkPreview.test.js b/test/unit/models/LinkPreview.test.js index 2c9e3b373..6a9ba1dff 100644 --- a/test/unit/models/LinkPreview.test.js +++ b/test/unit/models/LinkPreview.test.js @@ -1,77 +1,81 @@ /* globals LinkPreview */ -import nock from 'nock' -import { spyify, unspyify } from '../../setup/helpers' -require('../../setup') +import nock from "nock"; +import { spyify, unspyify } from "../../setup/helpers"; +require("../../setup"); const mockDoc = ` -` +`; -describe('LinkPreview', () => { - describe('parse', () => { - it('reads Open Graph tags', () => { +describe("LinkPreview", () => { + describe("parse", () => { + it("reads Open Graph tags", () => { expect(LinkPreview.parse(mockDoc)).to.deep.equal({ - title: 'wow!', - image_url: 'http://fake.host/wow.png', - description: "it's amazing" - }) - }) + title: "wow!", + image_url: "http://fake.host/wow.png", + description: "it's amazing", + }); + }); - it('handles missing tags', () => { + it("handles missing tags", () => { const doc = ` wow! - ` + `; expect(LinkPreview.parse(doc)).to.deep.equal({ - title: 'wow!', + title: "wow!", image_url: undefined, - description: undefined - }) - }) - }) + description: undefined, + }); + }); + }); - describe('populate', () => { - const url = 'http://foo.com/bar' - var preview + describe("populate", () => { + const url = "http://foo.com/bar"; + let preview; beforeEach(() => { - nock('http://foo.com').get('/bar').reply(200, mockDoc) - preview = LinkPreview.forge({url}) - return preview.save() - }) + nock("http://foo.com").get("/bar").reply(200, mockDoc); + preview = LinkPreview.forge({ url }); + return preview.save(); + }); - it('works', () => { - return LinkPreview.populate({id: preview.id}) - .then(preview => { - expect(preview.get('title')).to.equal('wow!') - }) - }) - }) + it("works", () => { + return LinkPreview.populate({ id: preview.id }).then((preview) => { + expect(preview.get("title")).to.equal("wow!"); + }); + }); + }); - describe('queue', () => { - const url = 'http://foo.com/bar2' + describe("queue", () => { + const url = "http://foo.com/bar2"; - beforeEach(() => spyify(Queue, 'classMethod')) - afterEach(() => unspyify(Queue, 'classMethod')) + beforeEach(() => spyify(Queue, "classMethod")); + afterEach(() => unspyify(Queue, "classMethod")); - it('works for a new url', () => { + it("works for a new url", () => { return LinkPreview.queue(url) - .then(() => LinkPreview.find(url)) - .then(preview => { - expect(preview).to.exist + .then(() => LinkPreview.find(url)) + .then((preview) => { + expect(preview).to.exist; - expect(Queue.classMethod).to.have.been.called - .with('LinkPreview', 'populate', {id: preview.id}, 0) - }) - }) + expect(Queue.classMethod).to.have.been.called.with( + "LinkPreview", + "populate", + { id: preview.id }, + 0 + ); + }); + }); - it('does nothing for an existing url', () => { - const url3 = 'http://foo.com/bar3' - return LinkPreview.forge({url: url3}).save() - .then(() => LinkPreview.queue(url3)) - .then(() => expect(Queue.classMethod).not.to.have.been.called()) - }) - }) -}) + it("does nothing for an existing url", () => { + const url3 = "http://foo.com/bar3"; + return LinkPreview.forge({ url: url3 }) + .save() + .then(() => LinkPreview.queue(url3)) + .then(() => expect(Queue.classMethod).not.to.have.been.called()); + }); + }); +}); diff --git a/test/unit/models/LinkedAccount.test.js b/test/unit/models/LinkedAccount.test.js index 983981379..ca5a1a3a9 100644 --- a/test/unit/models/LinkedAccount.test.js +++ b/test/unit/models/LinkedAccount.test.js @@ -1,44 +1,43 @@ -require('../../setup') -import factories from '../../setup/factories' +import factories from "../../setup/factories"; +require("../../setup"); -describe('LinkedAccount', () => { - describe('updateUser', () => { - var user +describe("LinkedAccount", () => { + describe("updateUser", () => { + let user; before(() => { - user = factories.user() - return user.save() - }) + user = factories.user(); + return user.save(); + }); - it('fails gracefully if no attributes are found', () => { - return LinkedAccount.updateUser(user.id, {}) - }) - - it('can store an access token', function () { + it("fails gracefully if no attributes are found", () => { + return LinkedAccount.updateUser(user.id, {}); + }); + + it("can store an access token", function () { return LinkedAccount.create(user.id, { - type: 'token', - token: '1234' - }) - .then(linkedAccount => { - expect(linkedAccount).to.exist - expect(linkedAccount.get('provider_key')).to.equal('token') - expect(linkedAccount.get('provider_user_id')).to.equal('1234') - expect(linkedAccount.get('user_id')).to.equal(user.id) - }) - }) - - it('can find an access token linked account from userId', function () { + type: "token", + token: "1234", + }).then((linkedAccount) => { + expect(linkedAccount).to.exist; + expect(linkedAccount.get("provider_key")).to.equal("token"); + expect(linkedAccount.get("provider_user_id")).to.equal("1234"); + expect(linkedAccount.get("user_id")).to.equal(user.id); + }); + }); + + it("can find an access token linked account from userId", function () { return LinkedAccount.create(user.id, { - type: 'token', - token: '1234' - }) - .then(() => LinkedAccount.tokenForUser(user.id)) - .then(linkedAccount => { - expect(linkedAccount).to.exist - expect(linkedAccount.get('provider_key')).to.equal('token') - expect(linkedAccount.get('provider_user_id')).to.equal('1234') - expect(linkedAccount.get('user_id')).to.equal(user.id) + type: "token", + token: "1234", }) - }) - }) -}) + .then(() => LinkedAccount.tokenForUser(user.id)) + .then((linkedAccount) => { + expect(linkedAccount).to.exist; + expect(linkedAccount.get("provider_key")).to.equal("token"); + expect(linkedAccount.get("provider_user_id")).to.equal("1234"); + expect(linkedAccount.get("user_id")).to.equal(user.id); + }); + }); + }); +}); diff --git a/test/unit/models/Media.test.js b/test/unit/models/Media.test.js index 77ff7cb01..3dc650760 100644 --- a/test/unit/models/Media.test.js +++ b/test/unit/models/Media.test.js @@ -1,33 +1,33 @@ -var root = require('root-path') -require(root('test/setup')) -var factories = require(root('test/setup/factories')) +const root = require("root-path"); +require(root("test/setup")); +const factories = require(root("test/setup/factories")); -describe('Media', () => { - describe('.createForSubject', () => { - var post +describe("Media", () => { + describe(".createForSubject", () => { + let post; beforeEach(() => { - post = factories.post() - return post.save() - }) + post = factories.post(); + return post.save(); + }); - it('works as expected', function () { - this.timeout(5000) + it("works as expected", function () { + this.timeout(5000); return Media.createForSubject({ - subjectType: 'post', + subjectType: "post", subjectId: post.id, - type: 'video', - url: 'https://vimeo.com/70509133', - position: 7 + type: "video", + url: "https://vimeo.com/70509133", + position: 7, }) - .tap(video => video.load('post')) - .then(video => { - expect(video.id).to.exist - expect(video.get('width')).to.equal(640) - expect(video.get('height')).to.equal(360) - expect(video.get('position')).to.equal(7) - expect(video.relations.post).to.exist - expect(video.relations.post.id).to.equal(post.id) - }) - }) - }) -}) + .tap((video) => video.load("post")) + .then((video) => { + expect(video.id).to.exist; + expect(video.get("width")).to.equal(640); + expect(video.get("height")).to.equal(360); + expect(video.get("position")).to.equal(7); + expect(video.relations.post).to.exist; + expect(video.relations.post.id).to.equal(post.id); + }); + }); + }); +}); diff --git a/test/unit/models/Network.test.js b/test/unit/models/Network.test.js index 9e9a47789..95e12819c 100644 --- a/test/unit/models/Network.test.js +++ b/test/unit/models/Network.test.js @@ -1,34 +1,35 @@ -import '../../setup' -import factories from '../../setup/factories' -import { expectEqualQuery } from '../../setup/helpers' -import { - myNetworkCommunityIdsSqlFragment -} from '../../../api/models/util/queryFilters.test.helpers' +import "../../setup"; +import factories from "../../setup/factories"; +import { expectEqualQuery } from "../../setup/helpers"; +import { myNetworkCommunityIdsSqlFragment } from "../../../api/models/util/queryFilters.test.helpers"; -describe('Network', () => { - describe('.activeCommunityIds', () => { - it('generates correct SQL', () => { - expectEqualQuery(Network.activeCommunityIds('42', true), - myNetworkCommunityIdsSqlFragment('42', {parens: false}), {isCollection: false}) - }) - }) +describe("Network", () => { + describe(".activeCommunityIds", () => { + it("generates correct SQL", () => { + expectEqualQuery( + Network.activeCommunityIds("42", true), + myNetworkCommunityIdsSqlFragment("42", { parens: false }), + { isCollection: false } + ); + }); + }); - describe('.memberCount', () => { - let network + describe(".memberCount", () => { + let network; before(async () => { - network = await factories.network().save() - const c1 = await factories.community({network_id: network.id}).save() - const c2 = await factories.community({network_id: network.id}).save() - const u1 = await factories.user().save() - const u2 = await factories.user().save() - const u3 = await factories.user().save() - await c1.addGroupMembers([u1, u2]) - await c2.addGroupMembers([u2, u3]) - }) + network = await factories.network().save(); + const c1 = await factories.community({ network_id: network.id }).save(); + const c2 = await factories.community({ network_id: network.id }).save(); + const u1 = await factories.user().save(); + const u2 = await factories.user().save(); + const u3 = await factories.user().save(); + await c1.addGroupMembers([u1, u2]); + await c2.addGroupMembers([u2, u3]); + }); - it('works', async () => { - expect(await network.memberCount()).to.equal(3) - }) - }) -}) + it("works", async () => { + expect(await network.memberCount()).to.equal(3); + }); + }); +}); diff --git a/test/unit/models/NetworkMembership.test.js b/test/unit/models/NetworkMembership.test.js index 803a33dc5..5cde3b7c3 100644 --- a/test/unit/models/NetworkMembership.test.js +++ b/test/unit/models/NetworkMembership.test.js @@ -1,99 +1,105 @@ -var root = require('root-path') -require(root('test/setup')) -var factories = require(root('test/setup/factories')) +const root = require("root-path"); +require(root("test/setup")); +const factories = require(root("test/setup/factories")); -describe('NetworkMembership', () => { - describe('.addModerator', () => { - var u, n +describe("NetworkMembership", () => { + describe(".addModerator", () => { + let u, n; before(() => { - u = factories.user() - n = factories.network() + u = factories.user(); + n = factories.network(); - return Promise.join(u.save(), n.save()) - }) + return Promise.join(u.save(), n.save()); + }); - it('adds the NetworkMembership', () => { + it("adds the NetworkMembership", () => { return NetworkMembership.addModerator(u.id, n.id) - .then(() => NetworkMembership.where({ - user_id: u.id, - network_id: n.id - }).fetch()) - .then(networkMembership => { - expect(networkMembership).to.exist - expect(networkMembership.get('role')) - .to.equal(NetworkMembership.MODERATOR_ROLE) - }) - }) - }) - - describe('.addAdmin', () => { - var u, n + .then(() => + NetworkMembership.where({ + user_id: u.id, + network_id: n.id, + }).fetch() + ) + .then((networkMembership) => { + expect(networkMembership).to.exist; + expect(networkMembership.get("role")).to.equal( + NetworkMembership.MODERATOR_ROLE + ); + }); + }); + }); + + describe(".addAdmin", () => { + let u, n; before(() => { - u = factories.user() - n = factories.network() + u = factories.user(); + n = factories.network(); - return Promise.join(u.save(), n.save()) - }) + return Promise.join(u.save(), n.save()); + }); - it('adds the NetworkMembership', () => { + it("adds the NetworkMembership", () => { return NetworkMembership.addAdmin(u.id, n.id) - .then(() => NetworkMembership.where({ - user_id: u.id, - network_id: n.id - }).fetch()) - .then(networkMembership => { - expect(networkMembership).to.exist - expect(networkMembership.get('role')) - .to.equal(NetworkMembership.ADMIN_ROLE) - }) - }) - }) - - describe('.hasModeratorRole', () => { - var u, n + .then(() => + NetworkMembership.where({ + user_id: u.id, + network_id: n.id, + }).fetch() + ) + .then((networkMembership) => { + expect(networkMembership).to.exist; + expect(networkMembership.get("role")).to.equal( + NetworkMembership.ADMIN_ROLE + ); + }); + }); + }); + + describe(".hasModeratorRole", () => { + let u, n; before(() => { - u = factories.user() - n = factories.network() + u = factories.user(); + n = factories.network(); - return Promise.join(u.save(), n.save()) - }) + return Promise.join(u.save(), n.save()); + }); - it('returns false with no membership, true with moderator membership', () => { + it("returns false with no membership, true with moderator membership", () => { return NetworkMembership.hasModeratorRole(u.id, n.id) - .then(isMod => { - expect(isMod).to.be.false - }) - .then(() => NetworkMembership.addModerator(u.id, n.id)) - .then(() => NetworkMembership.hasModeratorRole(u.id, n.id)) - .then(isMod => { - expect(isMod).to.be.true - }) - }) - }) - - describe('.hasAdminRole', () => { - var u, n + .then((isMod) => { + expect(isMod).to.be.false; + }) + .then(() => NetworkMembership.addModerator(u.id, n.id)) + .then(() => NetworkMembership.hasModeratorRole(u.id, n.id)) + .then((isMod) => { + expect(isMod).to.be.true; + }); + }); + }); + + describe(".hasAdminRole", () => { + let u, n; before(() => { - u = factories.user() - n = factories.network() + u = factories.user(); + n = factories.network(); - return Promise.join(u.save(), n.save()) - }) + return Promise.join(u.save(), n.save()); + }); - it('returns false with no membership, true with moderator membership', () => { + it("returns false with no membership, true with moderator membership", () => { return NetworkMembership.hasAdminRole(u.id, n.id) - .then(isMod => { - expect(isMod).to.be.false - }) - .then(() => NetworkMembership.addAdmin(u.id, n.id)) - .then(() => NetworkMembership.hasModeratorRole(u.id, n.id)) - .then(isMod => { - expect(isMod).to.be.true - }) - }) - }) -}) + .then((isMod) => { + expect(isMod).to.be.false; + }) + .then(() => NetworkMembership.addAdmin(u.id, n.id)) + .then(() => NetworkMembership.hasModeratorRole(u.id, n.id)) + .then((isMod) => { + expect(isMod).to.be.true; + }); + }); + }); +}); diff --git a/test/unit/models/Notification.test.js b/test/unit/models/Notification.test.js index 632c6962e..287c9d5cf 100644 --- a/test/unit/models/Notification.test.js +++ b/test/unit/models/Notification.test.js @@ -1,339 +1,421 @@ -import ioClient from 'socket.io-client' -import io from 'socket.io' -import redis from 'socket.io-redis' -import url from 'url' +import ioClient from "socket.io-client"; +import io from "socket.io"; +import redis from "socket.io-redis"; +import url from "url"; -import '../../setup' -import factories from '../../setup/factories' -import { spyify, unspyify, mockify } from '../../setup/helpers' -import { userRoom } from '../../../api/services/Websockets' +import "../../setup"; +import factories from "../../setup/factories"; +import { spyify, unspyify, mockify } from "../../setup/helpers"; +import { userRoom } from "../../../api/services/Websockets"; -const { model } = factories.mock +const { model } = factories.mock; const destroyAllPushNotifications = () => { - return PushNotification.fetchAll() - .then(pns => pns.map(pn => pn.destroy())) -} + return PushNotification.fetchAll().then((pns) => + pns.map((pn) => pn.destroy()) + ); +}; const relations = [ - 'activity', - 'activity.post', - 'activity.post.user', - 'activity.post.communities', - 'activity.comment', - 'activity.comment.user', - 'activity.comment.post', - 'activity.comment.post.communities', - 'activity.community', - 'activity.reader', - 'activity.actor' -] + "activity", + "activity.post", + "activity.post.user", + "activity.post.communities", + "activity.comment", + "activity.comment.user", + "activity.comment.post", + "activity.comment.post.communities", + "activity.community", + "activity.reader", + "activity.actor", +]; // Alleviate some code duplication: grab the notification for an activity and // load its relations const preloadNotification = (activity, medium) => new Activity(activity) .save() - .then(a => new Notification({ - activity_id: a.id, - medium - }).save()) - .then(n => n.load(relations)) + .then((a) => + new Notification({ + activity_id: a.id, + medium, + }).save() + ) + .then((n) => n.load(relations)); -describe('Notification', function () { - let activities, activity, actor, comment, community, device, post, reader +describe("Notification", function () { + let activities, activity, actor, comment, community, device, post, reader; before(() => { - return factories.user({avatar_url: 'http://joe.com/headshot.jpg', name: 'Joe'}).save() - .then(u => { actor = u }) - .then(() => factories.post({name: 'My Post', user_id: actor.id, description: 'The body of the post'}).save()) - .then(p => { post = p }) - .then(() => new Comment({text: 'hi', user_id: actor.id, post_id: post.id}).save()) - .then(c => { comment = c }) - .then(() => factories.community({name: 'My Community', slug: 'my-community'}).save()) - .then(c => { community = c }) + return factories + .user({ avatar_url: "http://joe.com/headshot.jpg", name: "Joe" }) + .save() + .then((u) => { + actor = u; + }) + .then(() => + factories + .post({ + name: "My Post", + user_id: actor.id, + description: "The body of the post", + }) + .save() + ) + .then((p) => { + post = p; + }) + .then(() => + new Comment({ text: "hi", user_id: actor.id, post_id: post.id }).save() + ) + .then((c) => { + comment = c; + }) + .then(() => + factories + .community({ name: "My Community", slug: "my-community" }) + .save() + ) + .then((c) => { + community = c; + }) .then(() => community.posts().attach(post)) - .then(() => factories.user({email: 'readersemail@hylo.com'}).save()) - .then(u => { reader = u }) - .then(() => new Device({ - user_id: reader.id, - token: 'eieio', - version: 20, - enabled: true - }).save()) - .then(d => { device = d }) - .then(() => new Activity({ - post_id: post.id - }).save()) - .then(a => { - activity = a + .then(() => factories.user({ email: "readersemail@hylo.com" }).save()) + .then((u) => { + reader = u; + }) + .then(() => + new Device({ + user_id: reader.id, + token: "eieio", + version: 20, + enabled: true, + }).save() + ) + .then((d) => { + device = d; + }) + .then(() => + new Activity({ + post_id: post.id, + }).save() + ) + .then((a) => { + activity = a; activities = { approvedJoinRequest: { - meta: {reasons: ['approvedJoinRequest']}, + meta: { reasons: ["approvedJoinRequest"] }, reader_id: reader.id, actor_id: actor.id, - community_id: community.id + community_id: community.id, }, newComment: { comment_id: comment.id, - meta: {reasons: ['newComment']}, + meta: { reasons: ["newComment"] }, reader_id: reader.id, - actor_id: actor.id + actor_id: actor.id, }, commentMention: { comment_id: comment.id, - meta: {reasons: ['commentMention']}, + meta: { reasons: ["commentMention"] }, reader_id: reader.id, - actor_id: actor.id + actor_id: actor.id, }, joinRequest: { - meta: {reasons: ['joinRequest']}, + meta: { reasons: ["joinRequest"] }, reader_id: reader.id, actor_id: actor.id, - community_id: community.id + community_id: community.id, }, mention: { post_id: post.id, - meta: {reasons: ['mention']}, + meta: { reasons: ["mention"] }, reader_id: reader.id, - actor_id: actor.id + actor_id: actor.id, }, newPost: { post_id: post.id, - meta: {reasons: [`newPost: ${community.id}`]}, + meta: { reasons: [`newPost: ${community.id}`] }, reader_id: reader.id, actor_id: actor.id, - community_id: community.id - } - } - }) - }) + community_id: community.id, + }, + }; + }); + }); - beforeEach(() => destroyAllPushNotifications()) + beforeEach(() => destroyAllPushNotifications()); - describe('.send', () => { - beforeEach(() => mockify(OneSignal, 'notify')) - afterEach(() => unspyify(OneSignal, 'notify')) + describe(".send", () => { + beforeEach(() => mockify(OneSignal, "notify")); + afterEach(() => unspyify(OneSignal, "notify")); - it('sends a push for a new post', () => { + it("sends a push for a new post", () => { return preloadNotification(activities.newPost, Notification.MEDIUM.Push) - .then(notification => notification.send()) - .then(() => PushNotification.where({device_id: device.id}).fetchAll()) - .then(pns => { - expect(pns.length).to.equal(1) - var pn = pns.first() - expect(pn.get('alert')).to.equal('Joe posted "My Post" in My Community') - }) - }) - - it('sends a push for a mention in a post', () => { + .then((notification) => notification.send()) + .then(() => PushNotification.where({ device_id: device.id }).fetchAll()) + .then((pns) => { + expect(pns.length).to.equal(1); + const pn = pns.first(); + expect(pn.get("alert")).to.equal( + 'Joe posted "My Post" in My Community' + ); + }); + }); + + it("sends a push for a mention in a post", () => { return preloadNotification(activities.mention, Notification.MEDIUM.Push) - .then(notification => notification.send()) - .then(() => PushNotification.where({device_id: device.id}).fetchAll()) - .then(pns => { - expect(pns.length).to.equal(1) - var pn = pns.first() - expect(pn.get('alert')).to.equal('Joe mentioned you in "My Post"') - }) - }) - - describe('with a user with push notifications for comments enabled', () => { - it('sends no push for a comment', () => { - return preloadNotification(activities.newComment, Notification.MEDIUM.Push) - .then(notification => notification.send()) - .then(() => PushNotification.where({device_id: device.id}).fetchAll()) - .then(pns => expect(pns.length).to.equal(0)) - }) - }) - - describe('to a user with push notifications for comments enabled', () => { - beforeEach(() => reader.addSetting({comment_notifications: 'push'}, true)) - afterEach(() => reader.removeSetting('comment_notifications', true)) - - it('sends a push for a comment', () => { - return preloadNotification(activities.newComment, Notification.MEDIUM.Push) - .then(notification => notification.send()) - .then(() => PushNotification.where({device_id: device.id}).fetchAll()) - .then(pns => { - expect(pns.length).to.equal(1) - var pn = pns.first() - expect(pn.get('alert')).to.equal(`Joe: "${comment.get('text')}" (in "My Post")`) - }) - }) - - it('sends a push for a mention in a comment', () => { - return preloadNotification(activities.commentMention, Notification.MEDIUM.Push) - .then(notification => notification.send()) - .then(() => PushNotification.where({device_id: device.id}).fetchAll()) - .then(pns => { - expect(pns.length).to.equal(1) - var pn = pns.first() - expect(pn.get('alert')).to.equal('Joe mentioned you: "hi" (in "My Post")') - }) - }) - }) - - it('sends a push for a join request', () => { - return preloadNotification(activities.joinRequest, Notification.MEDIUM.Push) - .then(notification => notification.send()) - .then(() => PushNotification.where({device_id: device.id}).fetchAll()) - .then(pns => { - expect(pns.length).to.equal(1) - var pn = pns.first() - expect(pn.get('alert')).to.equal('Joe asked to join My Community') - }) - }) - - it('sends a push for an approved join request', () => { - return preloadNotification(activities.approvedJoinRequest, Notification.MEDIUM.Push) - .then(notification => notification.send()) - .then(() => PushNotification.where({device_id: device.id}).fetchAll()) - .then(pns => { - expect(pns.length).to.equal(1) - var pn = pns.first() - expect(pn.get('alert')).to.equal('Joe approved your request to join My Community') - }) - }) - - it('sends an email for a mention in a post', () => { - spyify(Email, 'sendPostMentionNotification', opts => { + .then((notification) => notification.send()) + .then(() => PushNotification.where({ device_id: device.id }).fetchAll()) + .then((pns) => { + expect(pns.length).to.equal(1); + const pn = pns.first(); + expect(pn.get("alert")).to.equal('Joe mentioned you in "My Post"'); + }); + }); + + describe("with a user with push notifications for comments enabled", () => { + it("sends no push for a comment", () => { + return preloadNotification( + activities.newComment, + Notification.MEDIUM.Push + ) + .then((notification) => notification.send()) + .then(() => + PushNotification.where({ device_id: device.id }).fetchAll() + ) + .then((pns) => expect(pns.length).to.equal(0)); + }); + }); + + describe("to a user with push notifications for comments enabled", () => { + beforeEach(() => + reader.addSetting({ comment_notifications: "push" }, true) + ); + afterEach(() => reader.removeSetting("comment_notifications", true)); + + it("sends a push for a comment", () => { + return preloadNotification( + activities.newComment, + Notification.MEDIUM.Push + ) + .then((notification) => notification.send()) + .then(() => + PushNotification.where({ device_id: device.id }).fetchAll() + ) + .then((pns) => { + expect(pns.length).to.equal(1); + const pn = pns.first(); + expect(pn.get("alert")).to.equal( + `Joe: "${comment.get("text")}" (in "My Post")` + ); + }); + }); + + it("sends a push for a mention in a comment", () => { + return preloadNotification( + activities.commentMention, + Notification.MEDIUM.Push + ) + .then((notification) => notification.send()) + .then(() => + PushNotification.where({ device_id: device.id }).fetchAll() + ) + .then((pns) => { + expect(pns.length).to.equal(1); + const pn = pns.first(); + expect(pn.get("alert")).to.equal( + 'Joe mentioned you: "hi" (in "My Post")' + ); + }); + }); + }); + + it("sends a push for a join request", () => { + return preloadNotification( + activities.joinRequest, + Notification.MEDIUM.Push + ) + .then((notification) => notification.send()) + .then(() => PushNotification.where({ device_id: device.id }).fetchAll()) + .then((pns) => { + expect(pns.length).to.equal(1); + const pn = pns.first(); + expect(pn.get("alert")).to.equal("Joe asked to join My Community"); + }); + }); + + it("sends a push for an approved join request", () => { + return preloadNotification( + activities.approvedJoinRequest, + Notification.MEDIUM.Push + ) + .then((notification) => notification.send()) + .then(() => PushNotification.where({ device_id: device.id }).fetchAll()) + .then((pns) => { + expect(pns.length).to.equal(1); + const pn = pns.first(); + expect(pn.get("alert")).to.equal( + "Joe approved your request to join My Community" + ); + }); + }); + + it("sends an email for a mention in a post", () => { + spyify(Email, "sendPostMentionNotification", (opts) => { expect(opts).to.contain({ - email: 'readersemail@hylo.com' - }) + email: "readersemail@hylo.com", + }); expect(opts.sender).to.contain({ - name: 'Joe (via Hylo)' - }) + name: "Joe (via Hylo)", + }); expect(opts.data).to.contain({ - community_name: 'My Community', - post_user_name: 'Joe', - post_description: 'The body of the post', - post_title: 'My Post' - }) - }) + community_name: "My Community", + post_user_name: "Joe", + post_description: "The body of the post", + post_title: "My Post", + }); + }); return preloadNotification(activities.mention, Notification.MEDIUM.Email) - .then(notification => notification.send()) + .then((notification) => notification.send()) .then(() => { - expect(Email.sendPostMentionNotification).to.have.been.called() + expect(Email.sendPostMentionNotification).to.have.been.called(); }) - .then(() => unspyify(Email, 'sendPostMentionNotification')) - }) + .then(() => unspyify(Email, "sendPostMentionNotification")); + }); - it('sends no email for a comment', () => { - spyify(Email, 'sendNewCommentNotification') + it("sends no email for a comment", () => { + spyify(Email, "sendNewCommentNotification"); - return preloadNotification(activities.newComment, Notification.MEDIUM.Email) - .then(notification => notification.send()) + return preloadNotification( + activities.newComment, + Notification.MEDIUM.Email + ) + .then((notification) => notification.send()) .then(() => { - expect(Email.sendNewCommentNotification).not.to.have.been.called() + expect(Email.sendNewCommentNotification).not.to.have.been.called(); }) - .finally(() => unspyify(Email, 'sendNewCommentNotification')) - }) + .finally(() => unspyify(Email, "sendNewCommentNotification")); + }); - it('sends no email for a mention in a comment', () => { - spyify(Email, 'sendNewCommentNotification') + it("sends no email for a mention in a comment", () => { + spyify(Email, "sendNewCommentNotification"); - return preloadNotification(activities.commentMention, Notification.MEDIUM.Email) - .then(notification => notification.send()) + return preloadNotification( + activities.commentMention, + Notification.MEDIUM.Email + ) + .then((notification) => notification.send()) .then(() => { - expect(Email.sendNewCommentNotification).not.to.have.been.called() + expect(Email.sendNewCommentNotification).not.to.have.been.called(); }) - .then(() => unspyify(Email, 'sendNewCommentNotification')) - }) + .then(() => unspyify(Email, "sendNewCommentNotification")); + }); - it('sends an email for a joinRequest', () => { - spyify(Email, 'sendJoinRequestNotification', opts => { + it("sends an email for a joinRequest", () => { + spyify(Email, "sendJoinRequestNotification", (opts) => { expect(opts).to.contain({ - email: 'readersemail@hylo.com' - }) + email: "readersemail@hylo.com", + }); expect(opts.sender).to.contain({ - name: 'My Community' - }) + name: "My Community", + }); expect(opts.data).to.contain({ - community_name: 'My Community', - requester_name: 'Joe' - }) - }) - - return preloadNotification(activities.joinRequest, Notification.MEDIUM.Email) - .then(notification => notification.send()) + community_name: "My Community", + requester_name: "Joe", + }); + }); + + return preloadNotification( + activities.joinRequest, + Notification.MEDIUM.Email + ) + .then((notification) => notification.send()) .then(() => { - expect(Email.sendJoinRequestNotification).to.have.been.called() + expect(Email.sendJoinRequestNotification).to.have.been.called(); }) - .then(() => unspyify(Email, 'sendJoinRequestNotification')) - }) + .then(() => unspyify(Email, "sendJoinRequestNotification")); + }); - it('sends an email for an approvedJoinRequest', () => { - spyify(Email, 'sendApprovedJoinRequestNotification', opts => { + it("sends an email for an approvedJoinRequest", () => { + spyify(Email, "sendApprovedJoinRequestNotification", (opts) => { expect(opts).to.contain({ - email: 'readersemail@hylo.com' - }) + email: "readersemail@hylo.com", + }); expect(opts.sender).to.contain({ - name: 'My Community' - }) + name: "My Community", + }); expect(opts.data).to.contain({ - community_name: 'My Community', - approver_name: 'Joe' + community_name: "My Community", + approver_name: "Joe", + }); + }); + + return preloadNotification( + activities.approvedJoinRequest, + Notification.MEDIUM.Email + ) + .then((notification) => notification.send()) + .then(() => { + expect( + Email.sendApprovedJoinRequestNotification + ).to.have.been.called(); }) - }) + .then(() => unspyify(Email, "sendApprovedJoinRequestNotification")); + }); + }); - return preloadNotification(activities.approvedJoinRequest, Notification.MEDIUM.Email) - .then(notification => notification.send()) - .then(() => { - expect(Email.sendApprovedJoinRequestNotification).to.have.been.called() - }) - .then(() => unspyify(Email, 'sendApprovedJoinRequestNotification')) - }) - }) - - describe('#findUnsent', () => { - it('returns the unsent', () => { + describe("#findUnsent", () => { + it("returns the unsent", () => { return Promise.join( new Notification({ activity_id: activity.id, medium: Notification.MEDIUM.Email, - sent_at: (new Date()).toISOString(), - created_at: new Date() + sent_at: new Date().toISOString(), + created_at: new Date(), }).save(), new Notification({ activity_id: activity.id, medium: Notification.MEDIUM.Push, - created_at: new Date() + created_at: new Date(), }).save(), new Notification({ activity_id: activity.id, medium: Notification.MEDIUM.InApp, - created_at: new Date() - }).save()) - .then(() => Notification.findUnsent()) - .then(notifications => { - expect(notifications.length).to.equal(2) - expect(notifications.pluck('medium').sort()).to.deep.equal([ - Notification.MEDIUM.Push, - Notification.MEDIUM.InApp - ].sort()) - }) - }) - }) - - describe('sendCommentNotificationEmail', () => { - var args, community + created_at: new Date(), + }).save() + ) + .then(() => Notification.findUnsent()) + .then((notifications) => { + expect(notifications.length).to.equal(2); + expect(notifications.pluck("medium").sort()).to.deep.equal( + [Notification.MEDIUM.Push, Notification.MEDIUM.InApp].sort() + ); + }); + }); + }); + + describe("sendCommentNotificationEmail", () => { + let args, community; beforeEach(() => { - spyify(Email, 'sendNewCommentNotification', x => { args = x }) - community = factories.community() - return community.save() - }) + spyify(Email, "sendNewCommentNotification", (x) => { + args = x; + }); + community = factories.community(); + return community.save(); + }); - afterEach(() => unspyify(Email, 'sendNewCommentNotification')) + afterEach(() => unspyify(Email, "sendNewCommentNotification")); - it('sets the correct email attributes', () => { - const note = new Notification() + it("sets the correct email attributes", () => { + const note = new Notification(); note.relations = { activity: model({ @@ -341,49 +423,52 @@ describe('Notification', function () { relations: { comment: model({ id: 5, - text: 'I have an opinion', + text: "I have an opinion", relations: { post: model({ - name: 'hello world', + name: "hello world", relations: { - communities: [community] - } + communities: [community], + }, }), user: model({ id: 2, - name: 'Ka Mentor' - }) - } + name: "Ka Mentor", + }), + }, }), reader: new User({ id: 1, - name: 'Reader Person', - email: 'ilovenotifications@foo.com', - created_at: new Date() - }) - } - }) - } - - return note.sendCommentNotificationEmail() - .then(() => { - expect(Email.sendNewCommentNotification).to.have.been.called() - expect(args.data.post_label).to.equal('"hello world"') - }) - }) - }) - - describe('.priorityReason', () => { - it('picks higher-priority reasons', () => { - expect(Notification.priorityReason([ - 'approvedJoinRequest: yay', 'followAdd: your face', 'newPost: yes' - ])).to.equal('newPost') - }) - - it('returns the empty string as a fallthrough', () => { - expect(Notification.priorityReason(['wat', 'lol'])).to.equal('') - }) - }) + name: "Reader Person", + email: "ilovenotifications@foo.com", + created_at: new Date(), + }), + }, + }), + }; + + return note.sendCommentNotificationEmail().then(() => { + expect(Email.sendNewCommentNotification).to.have.been.called(); + expect(args.data.post_label).to.equal('"hello world"'); + }); + }); + }); + + describe(".priorityReason", () => { + it("picks higher-priority reasons", () => { + expect( + Notification.priorityReason([ + "approvedJoinRequest: yay", + "followAdd: your face", + "newPost: yes", + ]) + ).to.equal("newPost"); + }); + + it("returns the empty string as a fallthrough", () => { + expect(Notification.priorityReason(["wat", "lol"])).to.equal(""); + }); + }); // The workflow here is roughly: // - create a socket server (attached to Redis via the adapter) @@ -394,201 +479,218 @@ describe('Notification', function () { // - end each fixture with the call to `updateUserSocketRoom` // - take down the socket server // Nothing is mocked here, so may not be the most rapid tests in the world. - describe('updateUserSocketRoom', () => { - const ioServer = io.listen(3333) - let notification, socketActivity, socketClient, socketServer + describe("updateUserSocketRoom", () => { + const ioServer = io.listen(3333); + let notification, socketActivity, socketClient, socketServer; before(() => { - socketServer = ioServer.adapter(redis(process.env.REDIS_URL)) - socketServer.on('connection', s => { - s.join(userRoom(reader.id)) - }) - }) + socketServer = ioServer.adapter(redis(process.env.REDIS_URL)); + socketServer.on("connection", (s) => { + s.join(userRoom(reader.id)); + }); + }); after(() => { - ioServer.close() - }) - - beforeEach(done => { - socketClient = ioClient.connect('http://localhost:3333', { - transports: [ 'websocket' ], - 'force new connection': true - }) - socketClient.on('connect', () => { - return preloadNotification(socketActivity, Notification.MEDIUM.InApp) - .then(n => { - notification = n - done() - }) - }) - }) + ioServer.close(); + }); + + beforeEach((done) => { + socketClient = ioClient.connect("http://localhost:3333", { + transports: ["websocket"], + "force new connection": true, + }); + socketClient.on("connect", () => { + return preloadNotification( + socketActivity, + Notification.MEDIUM.InApp + ).then((n) => { + notification = n; + done(); + }); + }); + }); afterEach(() => { if (socketClient.connected) { - socketClient.disconnect() + socketClient.disconnect(); } - }) + }); // TODO: Feels like a good place for snapshots... - describe('new posts', () => { + describe("new posts", () => { before(() => { - socketActivity = activities.newPost - }) + socketActivity = activities.newPost; + }); - it('updates socket room with the correct action', done => { - socketClient.on('newNotification', data => { - expect(data.activity.action).to.equal('newPost') - done() - }) + it("updates socket room with the correct action", (done) => { + socketClient.on("newNotification", (data) => { + expect(data.activity.action).to.equal("newPost"); + done(); + }); - notification.updateUserSocketRoom(reader.id) - }) + notification.updateUserSocketRoom(reader.id); + }); - it('updates socket room with the correct actor', done => { - socketClient.on('newNotification', data => { + it("updates socket room with the correct actor", (done) => { + socketClient.on("newNotification", (data) => { const expected = { - avatarUrl: 'http://joe.com/headshot.jpg', - name: 'Joe', - id: actor.id - } - const actual = data.activity.actor - expect(actual).to.deep.equal(expected) - done() - }) - - notification.updateUserSocketRoom(reader.id) - }) - - it('updates socket room with the correct post', done => { - socketClient.on('newNotification', data => { + avatarUrl: "http://joe.com/headshot.jpg", + name: "Joe", + id: actor.id, + }; + const actual = data.activity.actor; + expect(actual).to.deep.equal(expected); + done(); + }); + + notification.updateUserSocketRoom(reader.id); + }); + + it("updates socket room with the correct post", (done) => { + socketClient.on("newNotification", (data) => { const expected = { - details: 'The body of the post', + details: "The body of the post", id: post.id, - title: 'My Post' - } - const actual = data.activity.post - expect(actual).to.deep.equal(expected) - done() - }) - - notification.updateUserSocketRoom(reader.id) - }) - - it('updates socket room with the correct community', done => { - socketClient.on('newNotification', data => { + title: "My Post", + }; + const actual = data.activity.post; + expect(actual).to.deep.equal(expected); + done(); + }); + + notification.updateUserSocketRoom(reader.id); + }); + + it("updates socket room with the correct community", (done) => { + socketClient.on("newNotification", (data) => { const expected = { id: community.id, - name: 'My Community', - slug: 'my-community' - } - const actual = data.activity.community - expect(actual).to.deep.equal(expected) - done() - }) - - notification.updateUserSocketRoom(reader.id) - }) - }) - - describe('post mentions', () => { + name: "My Community", + slug: "my-community", + }; + const actual = data.activity.community; + expect(actual).to.deep.equal(expected); + done(); + }); + + notification.updateUserSocketRoom(reader.id); + }); + }); + + describe("post mentions", () => { before(() => { - socketActivity = activities.mention - }) + socketActivity = activities.mention; + }); - it('updates socket room with the correct action', done => { - socketClient.on('newNotification', data => { - expect(data.activity.action).to.equal('mention') - done() - }) + it("updates socket room with the correct action", (done) => { + socketClient.on("newNotification", (data) => { + expect(data.activity.action).to.equal("mention"); + done(); + }); - notification.updateUserSocketRoom(reader.id) - }) - }) + notification.updateUserSocketRoom(reader.id); + }); + }); - describe('comment mentions', () => { + describe("comment mentions", () => { before(() => { - socketActivity = activities.commentMention - }) + socketActivity = activities.commentMention; + }); - it('updates socket room with the correct action', done => { - socketClient.on('newNotification', data => { - expect(data.activity.action).to.equal('commentMention') - done() - }) + it("updates socket room with the correct action", (done) => { + socketClient.on("newNotification", (data) => { + expect(data.activity.action).to.equal("commentMention"); + done(); + }); - notification.updateUserSocketRoom(reader.id) - }) - }) + notification.updateUserSocketRoom(reader.id); + }); + }); - describe('join requests', () => { + describe("join requests", () => { before(() => { - socketActivity = activities.joinRequest - }) + socketActivity = activities.joinRequest; + }); - it('updates socket room with the correct action', done => { - socketClient.on('newNotification', data => { - expect(data.activity.action).to.equal('joinRequest') - done() - }) + it("updates socket room with the correct action", (done) => { + socketClient.on("newNotification", (data) => { + expect(data.activity.action).to.equal("joinRequest"); + done(); + }); - notification.updateUserSocketRoom(reader.id) - }) - }) + notification.updateUserSocketRoom(reader.id); + }); + }); - describe('approved join requests', () => { + describe("approved join requests", () => { before(() => { - socketActivity = activities.approvedJoinRequest - }) + socketActivity = activities.approvedJoinRequest; + }); - it('updates socket room with the correct action', done => { - socketClient.on('newNotification', data => { - expect(data.activity.action).to.equal('approvedJoinRequest') - done() - }) + it("updates socket room with the correct action", (done) => { + socketClient.on("newNotification", (data) => { + expect(data.activity.action).to.equal("approvedJoinRequest"); + done(); + }); - notification.updateUserSocketRoom(reader.id) - }) - }) + notification.updateUserSocketRoom(reader.id); + }); + }); - describe('comments', () => { + describe("comments", () => { before(() => { - socketActivity = activities.newComment - }) - - it('updates socket room with the correct action', done => { - socketClient.on('newNotification', data => { - expect(data.activity.action).to.equal('newComment') - done() - }) - - notification.updateUserSocketRoom(reader.id) - }) - }) - }) - describe('sendPushAnnouncement', () => { - var post, notification, reader, community, activity, user, alertText, path + socketActivity = activities.newComment; + }); + + it("updates socket room with the correct action", (done) => { + socketClient.on("newNotification", (data) => { + expect(data.activity.action).to.equal("newComment"); + done(); + }); + + notification.updateUserSocketRoom(reader.id); + }); + }); + }); + describe("sendPushAnnouncement", () => { + let post, notification, reader, community, activity, user, alertText, path; before(async () => { - reader = await factories.user().save() - user = await factories.user().save() - community = await factories.community().save() - post = await factories.post({user_id: user.id}).save() - await community.posts().attach(post) - activity = await factories.activity({post_id: post.id, reader_id: reader.id}).save() - notification = await factories.notification({activity_id: activity.id}).save() - await post.load('user') - await notification.load(['activity', 'activity.post.communities', 'activity.reader', 'activity.post.user']) - notification.relations.activity.relations.reader.sendPushNotification = spy((inAlertText, inPath) => { - alertText = inAlertText - path = inPath - }) - }) - - it('calls sendPushNotification with the correct params', async () => { - await notification.sendPushAnnouncement() - expect(notification.relations.activity.relations.reader.sendPushNotification).to.have.been.called() - expect(alertText).to.equal(PushNotification.textForAnnouncement(post)) - expect(path).to.equal(url.parse(Frontend.Route.post(post, community)).path) - }) - }) -}) + reader = await factories.user().save(); + user = await factories.user().save(); + community = await factories.community().save(); + post = await factories.post({ user_id: user.id }).save(); + await community.posts().attach(post); + activity = await factories + .activity({ post_id: post.id, reader_id: reader.id }) + .save(); + notification = await factories + .notification({ activity_id: activity.id }) + .save(); + await post.load("user"); + await notification.load([ + "activity", + "activity.post.communities", + "activity.reader", + "activity.post.user", + ]); + notification.relations.activity.relations.reader.sendPushNotification = spy( + (inAlertText, inPath) => { + alertText = inAlertText; + path = inPath; + } + ); + }); + + it("calls sendPushNotification with the correct params", async () => { + await notification.sendPushAnnouncement(); + expect( + notification.relations.activity.relations.reader.sendPushNotification + ).to.have.been.called(); + expect(alertText).to.equal(PushNotification.textForAnnouncement(post)); + expect(path).to.equal( + url.parse(Frontend.Route.post(post, community)).path + ); + }); + }); +}); diff --git a/test/unit/models/Post.test.js b/test/unit/models/Post.test.js index 83a3164cf..edef45f08 100644 --- a/test/unit/models/Post.test.js +++ b/test/unit/models/Post.test.js @@ -1,63 +1,74 @@ /* eslint-disable no-unused-expressions */ -import root from 'root-path' -const setup = require(root('test/setup')) -const factories = require(root('test/setup/factories')) - -describe('Post', function () { - it('getDetailsText', async () => { - await setup.clearDb() - const post = await factories.post({description: `

hello John Doe #MOO

`}).save() - const text = await post.getDetailsText() - expect(text).to.equal('hello [John Doe:334] #MOO\n') - }) +import root from "root-path"; +const setup = require(root("test/setup")); +const factories = require(root("test/setup/factories")); + +describe("Post", function () { + it("getDetailsText", async () => { + await setup.clearDb(); + const post = await factories + .post({ + description: + "

hello John Doe #MOO

", + }) + .save(); + const text = await post.getDetailsText(); + expect(text).to.equal("hello [John Doe:334] #MOO\n"); + }); - describe('#addFollowers', function () { - var u1, u2, post + describe("#addFollowers", function () { + let u1, u2, post; beforeEach(async () => { - await setup.clearDb() - u1 = await factories.user().save() - u2 = await factories.user().save() - post = await factories.post({user_id: u1.id}).save() - }) - - it('adds a follower, ignoring duplicates', async () => { - await post.addFollowers([u2.id]) - - let followers = await post.followers().fetch() - expect(followers.length).to.equal(1) - const follower = followers.first() - expect(follower.id).to.equal(u2.id) - - await post.addFollowers([u2.id, u1.id]) - - followers = await post.followers().fetch() - expect(followers.length).to.equal(2) - }) - - it('queries for lastReadAt correctly', async () => { - await post.addFollowers([u1.id]) - - expect((await post.lastReadAtForUser(u1.id)).getTime()).to.be.closeTo(new Date(0).getTime(), 2000) - await post.markAsRead(u1.id) - expect((await post.lastReadAtForUser(u1.id)).getTime()).to.be.closeTo(new Date().getTime(), 2000) - }) - }) - - describe('#getCommenters', function () { - var u1, u2, u3, u4, u5, u6, u7, u8, post + await setup.clearDb(); + u1 = await factories.user().save(); + u2 = await factories.user().save(); + post = await factories.post({ user_id: u1.id }).save(); + }); + + it("adds a follower, ignoring duplicates", async () => { + await post.addFollowers([u2.id]); + + let followers = await post.followers().fetch(); + expect(followers.length).to.equal(1); + const follower = followers.first(); + expect(follower.id).to.equal(u2.id); + + await post.addFollowers([u2.id, u1.id]); + + followers = await post.followers().fetch(); + expect(followers.length).to.equal(2); + }); + + it("queries for lastReadAt correctly", async () => { + await post.addFollowers([u1.id]); + + expect((await post.lastReadAtForUser(u1.id)).getTime()).to.be.closeTo( + new Date(0).getTime(), + 2000 + ); + await post.markAsRead(u1.id); + expect((await post.lastReadAtForUser(u1.id)).getTime()).to.be.closeTo( + new Date().getTime(), + 2000 + ); + }); + }); + + describe("#getCommenters", function () { + let u1, u2, u3, u4, u5, u6, u7, u8, post; before(() => { return setup.clearDb().then(function () { - u1 = new User({email: 'a@post.c'}) - u2 = new User({email: 'b@post.b'}) - u3 = new User({email: 'c@post.c'}) - u4 = new User({email: 'd@post.d'}) - u5 = new User({email: 'e@post.e'}) - u6 = new User({email: 'f@post.f'}) - u7 = new User({email: 'g@post.g'}) - u8 = new User({email: 'h@post.h'}) - post = new Post() + u1 = new User({ email: "a@post.c" }); + u2 = new User({ email: "b@post.b" }); + u3 = new User({ email: "c@post.c" }); + u4 = new User({ email: "d@post.d" }); + u5 = new User({ email: "e@post.e" }); + u6 = new User({ email: "f@post.f" }); + u7 = new User({ email: "g@post.g" }); + u8 = new User({ email: "h@post.h" }); + post = new Post(); return Promise.join( u1.save(), u2.save(), @@ -67,310 +78,341 @@ describe('Post', function () { u6.save(), u7.save(), u8.save() - ).then(function () { - post.set('user_id', u1.id) - return post.save() - }).then(function () { - return Promise.map([u1, u2, u3, u4, u5, u6, u7, u8], (u) => { - const c = new Comment({ - user_id: u.id, - post_id: post.id, - active: true - }) - return c.save() + ) + .then(function () { + post.set("user_id", u1.id); + return post.save(); }) - }) - }) - }) - - it('includes the current user always, regardless of when they commented', function () { + .then(function () { + return Promise.map([u1, u2, u3, u4, u5, u6, u7, u8], (u) => { + const c = new Comment({ + user_id: u.id, + post_id: post.id, + active: true, + }); + return c.save(); + }); + }); + }); + }); + + it("includes the current user always, regardless of when they commented", function () { return Promise.join( post.getCommenters(1, u1.id).then(function (results) { - expect(results.length).to.equal(1) - expect(results._byId[u1.id]).to.not.be.undefined + expect(results.length).to.equal(1); + expect(results._byId[u1.id]).to.not.be.undefined; }), post.getCommenters(1, u2.id).then(function (results) { - expect(results.length).to.equal(1) - expect(results._byId[u2.id]).to.not.be.undefined + expect(results.length).to.equal(1); + expect(results._byId[u2.id]).to.not.be.undefined; }), post.getCommenters(3, u1.id).then(function (results) { - expect(results.length).to.equal(3) - expect(results._byId[u1.id]).to.not.be.undefined + expect(results.length).to.equal(3); + expect(results._byId[u1.id]).to.not.be.undefined; }), post.getCommenters(3, u2.id).then(function (results) { - expect(results.length).to.equal(3) - expect(results._byId[u2.id]).to.not.be.undefined + expect(results.length).to.equal(3); + expect(results._byId[u2.id]).to.not.be.undefined; }) - ) - }) - }) + ); + }); + }); - describe('#isVisibleToUser', () => { - var post, c1, c2, user + describe("#isVisibleToUser", () => { + let post, c1, c2, user; beforeEach(() => { - post = new Post({name: 'hello', active: true}) - user = factories.user({name: 'Cat'}) - c1 = factories.community({active: true}) - c2 = factories.community({active: true}) + post = new Post({ name: "hello", active: true }); + user = factories.user({ name: "Cat" }); + c1 = factories.community({ active: true }); + c2 = factories.community({ active: true }); return Promise.join(post.save(), user.save(), c1.save(), c2.save()) - .then(() => user.joinCommunity(c1)) - .then(() => post.communities().attach(c2.id)) - }) - - it('is true if the post is public', () => { - return post.save({visibility: Post.Visibility.PUBLIC_READABLE}, {patch: true}) - .then(() => Post.isVisibleToUser(post.id, user.id)) - .then(visible => expect(visible).to.be.true) - }) - - it('is false if the user is not connected by community', () => { - return Post.isVisibleToUser(post.id, user.id) - .then(visible => expect(visible).to.be.false) - }) - - it('is true if the user and post share a community', () => { - return c2.addGroupMembers([user.id]) - .then(() => Post.isVisibleToUser(post.id, user.id)) - .then(visible => expect(visible).to.be.true) - }) + .then(() => user.joinCommunity(c1)) + .then(() => post.communities().attach(c2.id)); + }); + + it("is true if the post is public", () => { + return post + .save({ visibility: Post.Visibility.PUBLIC_READABLE }, { patch: true }) + .then(() => Post.isVisibleToUser(post.id, user.id)) + .then((visible) => expect(visible).to.be.true); + }); + + it("is false if the user is not connected by community", () => { + return Post.isVisibleToUser(post.id, user.id).then( + (visible) => expect(visible).to.be.false + ); + }); + + it("is true if the user and post share a community", () => { + return c2 + .addGroupMembers([user.id]) + .then(() => Post.isVisibleToUser(post.id, user.id)) + .then((visible) => expect(visible).to.be.true); + }); it("is false if the user has a disabled membership in the post's community", async () => { - await c2.addGroupMembers([user.id]) - await c2.removeGroupMembers([user.id]) - const visible = await Post.isVisibleToUser(post.id, user.id) - expect(visible).to.be.false - }) - - it('is true if the user and post share a network', () => { - var network = new Network() - return network.save() - .then(() => Promise.join( - c1.save({network_id: network.id}, {patch: true}), - c2.save({network_id: network.id}, {patch: true}) - )) - .then(() => Post.isVisibleToUser(post.id, user.id)) - .then(visible => expect(visible).to.be.true) - }) - - it('is true if the user is following the post', () => { - return post.addFollowers([user.id]) - .then(() => Post.isVisibleToUser(post.id, user.id)) - .then(visible => expect(visible).to.be.true) - }) - }) - - describe('.createdInTimeRange', () => { - var post + await c2.addGroupMembers([user.id]); + await c2.removeGroupMembers([user.id]); + const visible = await Post.isVisibleToUser(post.id, user.id); + expect(visible).to.be.false; + }); + + it("is true if the user and post share a network", () => { + const network = new Network(); + return network + .save() + .then(() => + Promise.join( + c1.save({ network_id: network.id }, { patch: true }), + c2.save({ network_id: network.id }, { patch: true }) + ) + ) + .then(() => Post.isVisibleToUser(post.id, user.id)) + .then((visible) => expect(visible).to.be.true); + }); + + it("is true if the user is following the post", () => { + return post + .addFollowers([user.id]) + .then(() => Post.isVisibleToUser(post.id, user.id)) + .then((visible) => expect(visible).to.be.true); + }); + }); + + describe(".createdInTimeRange", () => { + let post; before(() => { post = new Post({ - name: 'foo', + name: "foo", created_at: new Date(), - active: true - }) - return post.save() - }) + active: true, + }); + return post.save(); + }); - it('works', () => { - var now = new Date() + it("works", () => { + const now = new Date(); return Post.createdInTimeRange(new Date(now - 10000), now) - .fetch().then(p => { - expect(p).to.exist - expect(p.id).to.equal(post.id) - }) - }) - }) + .fetch() + .then((p) => { + expect(p).to.exist; + expect(p.id).to.equal(post.id); + }); + }); + }); - describe('.copy', () => { - var post + describe(".copy", () => { + let post; before(() => { - post = factories.post() - return post.save() - }) - - it('creates a copy of the post with changed attributes', () => { - var p2 = post.copy({ - description: 'foo' - }) - - return p2.save() - .then(() => { - expect(p2.id).to.exist - expect(p2.id).not.to.equal(post.id) - expect(p2.get('description')).to.equal('foo') - expect(p2.get('name')).to.equal(post.get('name')) - }) - }) - }) - - describe('.deactivate', () => { - var post + post = factories.post(); + return post.save(); + }); + + it("creates a copy of the post with changed attributes", () => { + const p2 = post.copy({ + description: "foo", + }); + + return p2.save().then(() => { + expect(p2.id).to.exist; + expect(p2.id).not.to.equal(post.id); + expect(p2.get("description")).to.equal("foo"); + expect(p2.get("name")).to.equal(post.get("name")); + }); + }); + }); + + describe(".deactivate", () => { + let post; beforeEach(async () => { - post = await factories.post().save() - await post.createGroup() - const activity = await new Activity({post_id: post.id}).save() - await new Notification({activity_id: activity.id}).save() - const comment = await factories.comment({post_id: post.id}).save() - const activity2 = new Activity({comment_id: comment.id}).save() - await new Notification({activity_id: activity2.id}).save() - }) - - it('handles notifications, comments, activity, and group', async () => { - await Post.deactivate(post.id) - await post.refresh() + post = await factories.post().save(); + await post.createGroup(); + const activity = await new Activity({ post_id: post.id }).save(); + await new Notification({ activity_id: activity.id }).save(); + const comment = await factories.comment({ post_id: post.id }).save(); + const activity2 = new Activity({ comment_id: comment.id }).save(); + await new Notification({ activity_id: activity2.id }).save(); + }); + + it("handles notifications, comments, activity, and group", async () => { + await Post.deactivate(post.id); + await post.refresh(); await post.load([ - 'comments', - 'activities', - 'activities.notifications', - 'comments.activities', - 'comments.activities.notifications' - ]) - expect(post.relations.activities.length).to.equal(0) - expect(post.relations.comments.first().activities.length).to.equal(0) - expect(post.get('active')).to.be.false - expect(await Group.find(post).then(g => g.get('active'))).to.be.false - }) - }) - - describe('.createActivities', () => { - var u, u2, u3, c + "comments", + "activities", + "activities.notifications", + "comments.activities", + "comments.activities.notifications", + ]); + expect(post.relations.activities.length).to.equal(0); + expect(post.relations.comments.first().activities.length).to.equal(0); + expect(post.get("active")).to.be.false; + expect(await Group.find(post).then((g) => g.get("active"))).to.be.false; + }); + }); + + describe(".createActivities", () => { + let u, u2, u3, c; before(async () => { - u = await factories.user().save() - u2 = await factories.user().save() - u3 = await factories.user().save() - c = await factories.community().save() - await u2.joinCommunity(c) - await u3.joinCommunity(c) - }) - - it('creates activity for community members', () => { - var post = factories.post({user_id: u.id}) - return post.save() - .then(() => post.communities().attach(c.id)) - .then(() => post.createActivities()) - .then(() => Activity.where({post_id: post.id}).fetchAll()) - .then(activities => { - expect(activities.length).to.equal(2) - expect(activities.pluck('reader_id').sort()).to.deep.equal([u2.id, u3.id].sort()) - activities.forEach(activity => { - expect(activity.get('actor_id')).to.equal(u.id) - expect(activity.get('meta')).to.deep.equal({reasons: [`newPost: ${c.id}`]}) - expect(activity.get('unread')).to.equal(true) - }) - }) - }) - - it('creates an activity for a mention', () => { - var post = factories.post({ + u = await factories.user().save(); + u2 = await factories.user().save(); + u3 = await factories.user().save(); + c = await factories.community().save(); + await u2.joinCommunity(c); + await u3.joinCommunity(c); + }); + + it("creates activity for community members", () => { + const post = factories.post({ user_id: u.id }); + return post + .save() + .then(() => post.communities().attach(c.id)) + .then(() => post.createActivities()) + .then(() => Activity.where({ post_id: post.id }).fetchAll()) + .then((activities) => { + expect(activities.length).to.equal(2); + expect(activities.pluck("reader_id").sort()).to.deep.equal( + [u2.id, u3.id].sort() + ); + activities.forEach((activity) => { + expect(activity.get("actor_id")).to.equal(u.id); + expect(activity.get("meta")).to.deep.equal({ + reasons: [`newPost: ${c.id}`], + }); + expect(activity.get("unread")).to.equal(true); + }); + }); + }); + + it("creates an activity for a mention", () => { + const post = factories.post({ user_id: u.id, - description: `

Yo u3, how goes it

` - }) - return post.save() - .then(() => post.communities().attach(c.id)) - .then(() => post.createActivities()) - .then(() => Activity.where({post_id: post.id, reader_id: u3.id}).fetchAll()) - .then(activities => { - expect(activities.length).to.equal(1) - const activity = activities.first() - expect(activity).to.exist - expect(activity.get('actor_id')).to.equal(u.id) - expect(activity.get('meta')).to.deep.equal({reasons: ['mention', `newPost: ${c.id}`]}) - expect(activity.get('unread')).to.equal(true) - }) - }) - - it('creates an activity for a tag follower', () => { - var post = factories.post({ - user_id: u.id - }) - - return new Tag({name: 'FollowThisTag'}).save() - .tap(tag => u3.followedTags().attach({tag_id: tag.id, community_id: c.id})) - .then(() => post.save()) - .then(() => Tag.updateForPost(post, ['FollowThisTag'])) - .then(() => post.communities().attach(c.id)) - .then(() => post.createActivities()) - .then(() => Activity.where({post_id: post.id, reader_id: u3.id}).fetchAll()) - .then(activities => { - expect(activities.length).to.equal(1) - const activity = activities.first() - expect(activity).to.exist - expect(activity.get('actor_id')).to.equal(u.id) - expect(activity.get('meta')).to.deep.equal({reasons: [`newPost: ${c.id}`, 'tag: FollowThisTag']}) - expect(activity.get('unread')).to.equal(true) - }) - }) - }) - - describe('#updateFromNewComment', () => { - var post, user + description: `

Yo u3, how goes it

`, + }); + return post + .save() + .then(() => post.communities().attach(c.id)) + .then(() => post.createActivities()) + .then(() => + Activity.where({ post_id: post.id, reader_id: u3.id }).fetchAll() + ) + .then((activities) => { + expect(activities.length).to.equal(1); + const activity = activities.first(); + expect(activity).to.exist; + expect(activity.get("actor_id")).to.equal(u.id); + expect(activity.get("meta")).to.deep.equal({ + reasons: ["mention", `newPost: ${c.id}`], + }); + expect(activity.get("unread")).to.equal(true); + }); + }); + + it("creates an activity for a tag follower", () => { + const post = factories.post({ + user_id: u.id, + }); + + return new Tag({ name: "FollowThisTag" }) + .save() + .tap((tag) => + u3.followedTags().attach({ tag_id: tag.id, community_id: c.id }) + ) + .then(() => post.save()) + .then(() => Tag.updateForPost(post, ["FollowThisTag"])) + .then(() => post.communities().attach(c.id)) + .then(() => post.createActivities()) + .then(() => + Activity.where({ post_id: post.id, reader_id: u3.id }).fetchAll() + ) + .then((activities) => { + expect(activities.length).to.equal(1); + const activity = activities.first(); + expect(activity).to.exist; + expect(activity.get("actor_id")).to.equal(u.id); + expect(activity.get("meta")).to.deep.equal({ + reasons: [`newPost: ${c.id}`, "tag: FollowThisTag"], + }); + expect(activity.get("unread")).to.equal(true); + }); + }); + }); + + describe("#updateFromNewComment", () => { + let post, user; before(async () => { - user = await factories.user().save() - post = await factories.post().save() - await post.addFollowers([user.id]) - }) + user = await factories.user().save(); + post = await factories.post().save(); + await post.addFollowers([user.id]); + }); - it('updates several attributes', async () => { + it("updates several attributes", async () => { const comment = factories.comment({ post_id: post.id, created_at: new Date(), - user_id: user.id - }) - - await comment.save() - await Post.updateFromNewComment({postId: post.id, commentId: comment.id}) - await post.refresh() - expect(post.get('num_comments')).to.equal(1) - - const gm = await GroupMembership.forPair(user, post).fetch() - const group = await gm.group().fetch() + user_id: user.id, + }); + + await comment.save(); + await Post.updateFromNewComment({ + postId: post.id, + commentId: comment.id, + }); + await post.refresh(); + expect(post.get("num_comments")).to.equal(1); + + const gm = await GroupMembership.forPair(user, post).fetch(); + const group = await gm.group().fetch(); const timestamps = [ - post.get('updated_at'), - new Date(gm.getSetting('lastReadAt')), - group.get('updated_at') - ] - const now = new Date().getTime() - for (let date of timestamps) { - expect(date.getTime()).to.be.closeTo(now, 2000) + post.get("updated_at"), + new Date(gm.getSetting("lastReadAt")), + group.get("updated_at"), + ]; + const now = new Date().getTime(); + for (const date of timestamps) { + expect(date.getTime()).to.be.closeTo(now, 2000); } - }) - }) + }); + }); - describe('#unreadCountForUser', () => { - var post, user, user2 + describe("#unreadCountForUser", () => { + let post, user, user2; before(async () => { - post = factories.post() - user = factories.user() - user2 = factories.user() - await Promise.join(post.save(), user.save(), user2.save()) - - const lastReadDate = new Date() - const earlier = new Date(lastReadDate.getTime() - 60000) - const later = new Date(lastReadDate.getTime() + 60000) - await factories.comment({post_id: post.id, created_at: earlier}).save() - await factories.comment({post_id: post.id, created_at: later}).save() - await factories.comment({post_id: post.id, created_at: later}).save() - - await post.addFollowers([user.id]) - const gm = await GroupMembership.forPair(user, post).fetch() - await gm.addSetting({lastReadAt: lastReadDate}, true) - - return post.save({updated_at: later}, {patch: true}) - }) - - it('returns the number of unread messages (comments)', () => { - return post.unreadCountForUser(user.id) - .then(count => expect(count).to.equal(2)) - }) - - it('returns the total number of messages (comments) with no read timestamps', () => { - return post.unreadCountForUser(user2.id) - .then(count => expect(count).to.equal(3)) - }) - }) -}) + post = factories.post(); + user = factories.user(); + user2 = factories.user(); + await Promise.join(post.save(), user.save(), user2.save()); + + const lastReadDate = new Date(); + const earlier = new Date(lastReadDate.getTime() - 60000); + const later = new Date(lastReadDate.getTime() + 60000); + await factories.comment({ post_id: post.id, created_at: earlier }).save(); + await factories.comment({ post_id: post.id, created_at: later }).save(); + await factories.comment({ post_id: post.id, created_at: later }).save(); + + await post.addFollowers([user.id]); + const gm = await GroupMembership.forPair(user, post).fetch(); + await gm.addSetting({ lastReadAt: lastReadDate }, true); + + return post.save({ updated_at: later }, { patch: true }); + }); + + it("returns the number of unread messages (comments)", () => { + return post + .unreadCountForUser(user.id) + .then((count) => expect(count).to.equal(2)); + }); + + it("returns the total number of messages (comments) with no read timestamps", () => { + return post + .unreadCountForUser(user2.id) + .then((count) => expect(count).to.equal(3)); + }); + }); +}); diff --git a/test/unit/models/PostMembership.test.js b/test/unit/models/PostMembership.test.js index 6d74bf339..49f9cf9a0 100644 --- a/test/unit/models/PostMembership.test.js +++ b/test/unit/models/PostMembership.test.js @@ -1,30 +1,34 @@ -var setup = require(require('root-path')('test/setup')) +const setup = require(require("root-path")("test/setup")); -describe('PostMembership', function () { - var post, community +describe("PostMembership", function () { + let post, community; - describe('.find', function () { + describe(".find", function () { before(function () { - community = new Community({slug: 'foo', name: 'Foo'}) - post = new Post({name: 'Sup', description: 'details'}) - return setup.clearDb() - .then(() => Promise.join(community.save(), post.save())) - .then(() => post.communities().attach(community)) - }) + community = new Community({ slug: "foo", name: "Foo" }); + post = new Post({ name: "Sup", description: "details" }); + return setup + .clearDb() + .then(() => Promise.join(community.save(), post.save())) + .then(() => post.communities().attach(community)); + }); - it('works with a community id', function () { - return PostMembership.find(post.id, community.id) - .then(membership => expect(membership).to.exist) - }) + it("works with a community id", function () { + return PostMembership.find(post.id, community.id).then( + (membership) => expect(membership).to.exist + ); + }); - it('works with a community slug', function () { - return PostMembership.find(post.id, community.get('slug')) - .then(membership => expect(membership).to.exist) - }) + it("works with a community slug", function () { + return PostMembership.find(post.id, community.get("slug")).then( + (membership) => expect(membership).to.exist + ); + }); - it('returns nothing for a blank post id', function () { - return PostMembership.find(null, community.id) - .then(membership => expect(membership).not.to.exist) - }) - }) -}) + it("returns nothing for a blank post id", function () { + return PostMembership.find(null, community.id).then( + (membership) => expect(membership).not.to.exist + ); + }); + }); +}); diff --git a/test/unit/models/PushNotification.test.js b/test/unit/models/PushNotification.test.js index ec0f75a27..ea2c1c313 100644 --- a/test/unit/models/PushNotification.test.js +++ b/test/unit/models/PushNotification.test.js @@ -1,142 +1,144 @@ -import factories from '../../setup/factories' -import { mockify, unspyify } from '../../setup/helpers' +import factories from "../../setup/factories"; +import { mockify, unspyify } from "../../setup/helpers"; -describe('PushNotification', () => { - var device, pushNotification, tmpEnvVar, notifyCall +describe("PushNotification", () => { + let device, pushNotification, tmpEnvVar, notifyCall; before(() => { - tmpEnvVar = process.env.PUSH_NOTIFICATIONS_ENABLED - device = factories.device() + tmpEnvVar = process.env.PUSH_NOTIFICATIONS_ENABLED; + device = factories.device(); - return device.save() - }) + return device.save(); + }); beforeEach(async () => { - notifyCall = null - mockify(OneSignal, 'notify', spy(opts => { - notifyCall = opts - })) + notifyCall = null; + mockify( + OneSignal, + "notify", + spy((opts) => { + notifyCall = opts; + }) + ); pushNotification = new PushNotification({ - alert: 'hi', - path: '/p', + alert: "hi", + path: "/p", badge_no: 7, - platform: 'ios_macos' - }) - await pushNotification.set('device_id', device.id) - await pushNotification.save() - }) + platform: "ios_macos", + }); + await pushNotification.set("device_id", device.id); + await pushNotification.save(); + }); after(() => { - process.env.PUSH_NOTIFICATIONS_ENABLED = tmpEnvVar - unspyify(OneSignal, 'notify') - }) + process.env.PUSH_NOTIFICATIONS_ENABLED = tmpEnvVar; + unspyify(OneSignal, "notify"); + }); - describe('without PUSH_NOTIFICATIONS_ENABLED', () => { - var user, post + describe("without PUSH_NOTIFICATIONS_ENABLED", () => { + let user, post; before(() => { - delete process.env.PUSH_NOTIFICATIONS_ENABLED - }) + delete process.env.PUSH_NOTIFICATIONS_ENABLED; + }); beforeEach(async () => { - var username = 'username' - var postname = 'My Post' - user = await factories.user({name: username}).save() - post = await factories.post({user_id: user.id, name: postname}).save() - }) - - it('returns correct text with textForAnnouncement', async () => { - await post.load('user') - const person = post.relations.user.get('name') - const postName = post.get('name') - var expected = `${person} sent an announcement titled "${postName}"` - expect(PushNotification.textForAnnouncement(post)).to.equal(expected) - }) - - it('sets sent_at and disabled', function () { - return pushNotification.send() - .then(result => { - return pushNotification.fetch() - .then(pn => { - expect(pn.get('sent_at')).not.to.equal(null) - expect(pn.get('disabled')).to.be.true - }) - }) - }) - - describe('with PUSH_NOTIFICATIONS_TESTING_ENABLED', () => { - var tmpEnvVar2 + const username = "username"; + const postname = "My Post"; + user = await factories.user({ name: username }).save(); + post = await factories.post({ user_id: user.id, name: postname }).save(); + }); + + it("returns correct text with textForAnnouncement", async () => { + await post.load("user"); + const person = post.relations.user.get("name"); + const postName = post.get("name"); + const expected = `${person} sent an announcement titled "${postName}"`; + expect(PushNotification.textForAnnouncement(post)).to.equal(expected); + }); + + it("sets sent_at and disabled", function () { + return pushNotification.send().then((result) => { + return pushNotification.fetch().then((pn) => { + expect(pn.get("sent_at")).not.to.equal(null); + expect(pn.get("disabled")).to.be.true; + }); + }); + }); + + describe("with PUSH_NOTIFICATIONS_TESTING_ENABLED", () => { + let tmpEnvVar2; before(() => { - tmpEnvVar2 = process.env.PUSH_NOTIFICATIONS_TESTING_ENABLED - process.env.PUSH_NOTIFICATIONS_TESTING_ENABLED = true - }) + tmpEnvVar2 = process.env.PUSH_NOTIFICATIONS_TESTING_ENABLED; + process.env.PUSH_NOTIFICATIONS_TESTING_ENABLED = true; + }); after(() => { - process.env.PUSH_NOTIFICATIONS_TESTING_ENABLED = tmpEnvVar2 - }) - - it('sets sent_at and disabled for a non-test device', async () => { - await pushNotification.send() - const pn = await pushNotification.fetch() - expect(pn.get('sent_at')).not.to.equal(null) - expect(pn.get('disabled')).to.be.true - expect(OneSignal.notify).not.to.have.been.called() - }) - - it('sends for a test device', async () => { - await device.save({tester: true}, {patch: true}) - const result = await pushNotification.send() - const pn = await pushNotification.fetch() - expect(pn.get('sent_at')).not.to.equal(null) - expect(pn.get('disabled')).to.be.false - expect(OneSignal.notify).to.have.been.called() - }) - }) - }) - - describe('with PUSH_NOTIFICATIONS_ENABLED', () => { + process.env.PUSH_NOTIFICATIONS_TESTING_ENABLED = tmpEnvVar2; + }); + + it("sets sent_at and disabled for a non-test device", async () => { + await pushNotification.send(); + const pn = await pushNotification.fetch(); + expect(pn.get("sent_at")).not.to.equal(null); + expect(pn.get("disabled")).to.be.true; + expect(OneSignal.notify).not.to.have.been.called(); + }); + + it("sends for a test device", async () => { + await device.save({ tester: true }, { patch: true }); + const result = await pushNotification.send(); + const pn = await pushNotification.fetch(); + expect(pn.get("sent_at")).not.to.equal(null); + expect(pn.get("disabled")).to.be.false; + expect(OneSignal.notify).to.have.been.called(); + }); + }); + }); + + describe("with PUSH_NOTIFICATIONS_ENABLED", () => { before(() => { - process.env.PUSH_NOTIFICATIONS_ENABLED = true - }) + process.env.PUSH_NOTIFICATIONS_ENABLED = true; + }); - it('sends for a non-test device with token', async () => { - await device.save({token: 'foo'}, {patch: true}) - const result = await pushNotification.send() - const pn = await pushNotification.fetch() + it("sends for a non-test device with token", async () => { + await device.save({ token: "foo" }, { patch: true }); + const result = await pushNotification.send(); + const pn = await pushNotification.fetch(); - expect(pn.get('sent_at')).not.to.equal(null) - expect(pn.get('disabled')).to.be.false - expect(OneSignal.notify).to.have.been.called() + expect(pn.get("sent_at")).not.to.equal(null); + expect(pn.get("disabled")).to.be.false; + expect(OneSignal.notify).to.have.been.called(); expect(notifyCall).to.deep.equal({ - platform: 'ios_macos', - deviceToken: 'foo', + platform: "ios_macos", + deviceToken: "foo", playerId: null, - alert: 'hi', - path: '/p', - badgeNo: 7 - }) - }) + alert: "hi", + path: "/p", + badgeNo: 7, + }); + }); - it('sends for a non-test device with player id', async () => { - await device.save({token: null, player_id: 'bar'}, {patch: true}) - const result = await pushNotification.send() - const pn = await pushNotification.fetch() + it("sends for a non-test device with player id", async () => { + await device.save({ token: null, player_id: "bar" }, { patch: true }); + const result = await pushNotification.send(); + const pn = await pushNotification.fetch(); - expect(pn.get('sent_at')).not.to.equal(null) - expect(pn.get('disabled')).to.be.false - expect(OneSignal.notify).to.have.been.called() + expect(pn.get("sent_at")).not.to.equal(null); + expect(pn.get("disabled")).to.be.false; + expect(OneSignal.notify).to.have.been.called(); expect(notifyCall).to.deep.equal({ - platform: 'ios_macos', + platform: "ios_macos", deviceToken: null, - playerId: 'bar', - alert: 'hi', - path: '/p', - badgeNo: 7 - }) - }) - }) -}) + playerId: "bar", + alert: "hi", + path: "/p", + badgeNo: 7, + }); + }); + }); +}); diff --git a/test/unit/models/Skill.test.js b/test/unit/models/Skill.test.js index 6fc606fe2..58210729c 100644 --- a/test/unit/models/Skill.test.js +++ b/test/unit/models/Skill.test.js @@ -1,37 +1,42 @@ -import { expectEqualQuery } from '../../setup/helpers' +import { expectEqualQuery } from "../../setup/helpers"; import { - myCommunityIdsSqlFragment, myNetworkCommunityIdsSqlFragment -} from '../../../api/models/util/queryFilters.test.helpers' + myCommunityIdsSqlFragment, + myNetworkCommunityIdsSqlFragment, +} from "../../../api/models/util/queryFilters.test.helpers"; -describe('Skill.find', () => { - it('returns nothing for a null id', () => { - return Skill.find(null) - .then(skill => expect(skill).to.be.null) - }) -}) +describe("Skill.find", () => { + it("returns nothing for a null id", () => { + return Skill.find(null).then((skill) => expect(skill).to.be.null); + }); +}); -describe('Skill.search', () => { - let myId = '42' +describe("Skill.search", () => { + const myId = "42"; - it('produces the expected query', () => { + it("produces the expected query", () => { const query = Skill.search({ - autocomplete: 'go', + autocomplete: "go", currentUserId: myId, limit: 10, - offset: 20 - }) + offset: 20, + }); - expectEqualQuery(query, `select * from "skills" + expectEqualQuery( + query, + `select * from "skills" inner join "skills_users" on "skills_users"."skill_id" = "skills"."id" inner join "communities_users" on "communities_users"."user_id" = "skills_users"."user_id" where name ilike 'go%' and ( "communities_users"."community_id" in ${myCommunityIdsSqlFragment(myId)} - or "communities_users"."community_id" in ${myNetworkCommunityIdsSqlFragment(myId)} + or "communities_users"."community_id" in ${myNetworkCommunityIdsSqlFragment( + myId + )} ) order by upper("name") asc limit 10 - offset 20`) - }) -}) + offset 20` + ); + }); +}); diff --git a/test/unit/models/Tag.test.js b/test/unit/models/Tag.test.js index 7c2d5bab7..2d96e51e5 100644 --- a/test/unit/models/Tag.test.js +++ b/test/unit/models/Tag.test.js @@ -1,314 +1,382 @@ -import { sortBy } from 'lodash' -var root = require('root-path') -var setup = require(root('test/setup')) -var factories = require(root('test/setup/factories')) +import { sortBy } from "lodash"; +const root = require("root-path"); +const setup = require(root("test/setup")); +const factories = require(root("test/setup/factories")); -describe('Tag', () => { - var u, c1 +describe("Tag", () => { + let u, c1; beforeEach(() => { - u = factories.user() - c1 = factories.community() - return setup.clearDb() - .then(() => Promise.join(u.save(), c1.save())) - .then(() => u.joinCommunity(c1)) - }) + u = factories.user(); + c1 = factories.community(); + return setup + .clearDb() + .then(() => Promise.join(u.save(), c1.save())) + .then(() => u.joinCommunity(c1)); + }); - describe('updateForPost', () => { - it('creates a tag from topicNames param', () => { - var post = new Post({ - name: 'New Tagged Post', - description: 'no tags in the body' - }) - return post.save() - .then(post => Tag.updateForPost(post, ['newtagone'])) - .then(() => Tag.find({ name: 'newtagone' }, {withRelated: ['posts']})) - .then(tag => { - expect(tag).to.exist - expect(tag.get('name')).to.equal('newtagone') - expect(tag.relations.posts.length).to.equal(1) - expect(tag.relations.posts.models[0].get('name')).to.equal('New Tagged Post') - }) - }) + describe("updateForPost", () => { + it("creates a tag from topicNames param", () => { + const post = new Post({ + name: "New Tagged Post", + description: "no tags in the body", + }); + return post + .save() + .then((post) => Tag.updateForPost(post, ["newtagone"])) + .then(() => Tag.find({ name: "newtagone" }, { withRelated: ["posts"] })) + .then((tag) => { + expect(tag).to.exist; + expect(tag.get("name")).to.equal("newtagone"); + expect(tag.relations.posts.length).to.equal(1); + expect(tag.relations.posts.models[0].get("name")).to.equal( + "New Tagged Post" + ); + }); + }); - it('attaches an existing tag from topicNames param', () => { - var post = new Post({ - name: 'New Tagged Post Two', - description: 'no tags in the body' - }) - return new Tag({name: 'newtagtwo'}).save() - .then(() => post.save()) - .then(post => Tag.updateForPost(post, ['newtagtwo'])) - .then(() => Tag.find({ name: 'newtagtwo' }, {withRelated: ['posts']})) - .then(tag => { - expect(tag).to.exist - expect(tag.get('name')).to.equal('newtagtwo') - expect(tag.relations.posts.length).to.equal(1) - expect(tag.relations.posts.models[0].get('name')).to.equal('New Tagged Post Two') - }) - }) + it("attaches an existing tag from topicNames param", () => { + const post = new Post({ + name: "New Tagged Post Two", + description: "no tags in the body", + }); + return new Tag({ name: "newtagtwo" }) + .save() + .then(() => post.save()) + .then((post) => Tag.updateForPost(post, ["newtagtwo"])) + .then(() => Tag.find({ name: "newtagtwo" }, { withRelated: ["posts"] })) + .then((tag) => { + expect(tag).to.exist; + expect(tag.get("name")).to.equal("newtagtwo"); + expect(tag.relations.posts.length).to.equal(1); + expect(tag.relations.posts.models[0].get("name")).to.equal( + "New Tagged Post Two" + ); + }); + }); - it('ignores duplicate tags', () => { - var post = new Post({ - name: 'Tagged Post With Dups' - }) - return new Tag({name: 'duplicated'}).save() - .then(() => post.save()) - .then(post => Tag.updateForPost(post, ['duplicated', 'duplicated'])) - .then(() => Tag.find({ name: 'duplicated' }, {withRelated: ['posts']})) - .then(tag => { - expect(tag).to.exist - expect(tag.get('name')).to.equal('duplicated') - expect(tag.relations.posts.length).to.equal(1) - expect(tag.relations.posts.models[0].get('name')).to.equal('Tagged Post With Dups') - }) - }) + it("ignores duplicate tags", () => { + const post = new Post({ + name: "Tagged Post With Dups", + }); + return new Tag({ name: "duplicated" }) + .save() + .then(() => post.save()) + .then((post) => Tag.updateForPost(post, ["duplicated", "duplicated"])) + .then(() => + Tag.find({ name: "duplicated" }, { withRelated: ["posts"] }) + ) + .then((tag) => { + expect(tag).to.exist; + expect(tag.get("name")).to.equal("duplicated"); + expect(tag.relations.posts.length).to.equal(1); + expect(tag.relations.posts.models[0].get("name")).to.equal( + "Tagged Post With Dups" + ); + }); + }); - it('removes a tag', () => { - var post = new Post({ - name: 'New Tagged Post Five' - }) - var tag = new Tag({name: 'newtagfive'}) + it("removes a tag", () => { + const post = new Post({ + name: "New Tagged Post Five", + }); + const tag = new Tag({ name: "newtagfive" }); return Promise.join(post.save(), tag.save(), (post, tag) => - new PostTag({post_id: post.id, tag_id: tag.id}).save()) - .then(() => Tag.updateForPost(post, ['newtagsix'])) - .then(() => Promise.join( - Tag.find({ name: 'newtagfive' }, {withRelated: ['posts']}), - Tag.find({ name: 'newtagsix' }, {withRelated: ['posts']}), - (removed, added) => { - expect(removed).to.exist - expect(removed.get('name')).to.equal('newtagfive') - expect(removed.relations.posts.length).to.equal(0) - expect(added).to.exist - expect(added.get('name')).to.equal('newtagsix') - expect(added.relations.posts.length).to.equal(1) - expect(added.relations.posts.models[0].get('name')).to.equal('New Tagged Post Five') - })) - }) + new PostTag({ post_id: post.id, tag_id: tag.id }).save() + ) + .then(() => Tag.updateForPost(post, ["newtagsix"])) + .then(() => + Promise.join( + Tag.find({ name: "newtagfive" }, { withRelated: ["posts"] }), + Tag.find({ name: "newtagsix" }, { withRelated: ["posts"] }), + (removed, added) => { + expect(removed).to.exist; + expect(removed.get("name")).to.equal("newtagfive"); + expect(removed.relations.posts.length).to.equal(0); + expect(added).to.exist; + expect(added.get("name")).to.equal("newtagsix"); + expect(added.relations.posts.length).to.equal(1); + expect(added.relations.posts.models[0].get("name")).to.equal( + "New Tagged Post Five" + ); + } + ) + ); + }); - it('associates tags with communities of which the user is a member', () => { - var post = new Post({ - name: 'New Tagged Post', - description: 'no tags in the body', - user_id: u.id - }) - var c2 = factories.community() + it("associates tags with communities of which the user is a member", () => { + const post = new Post({ + name: "New Tagged Post", + description: "no tags in the body", + user_id: u.id, + }); + const c2 = factories.community(); return Promise.join(post.save(), c2.save()) - .then(() => post.communities().attach(c1.id)) - .then(() => post.communities().attach(c2.id)) - .then(() => Tag.updateForPost(post, ['newtagnine'], u.id)) - .then(() => Tag.find({ name: 'newtagnine' }, {withRelated: ['communities']})) - .then(tag => { - expect(tag).to.exist - expect(tag.get('name')).to.equal('newtagnine') - expect(tag.relations.communities.length).to.equal(1) - var communities = sortBy(tag.relations.communities.models, c => c.get('name')) - expect(communities[0].get('name')).to.equal(c1.get('name')) - expect(communities[0].pivot.get('user_id')).to.equal(u.id) - }) - }) + .then(() => post.communities().attach(c1.id)) + .then(() => post.communities().attach(c2.id)) + .then(() => Tag.updateForPost(post, ["newtagnine"], u.id)) + .then(() => + Tag.find({ name: "newtagnine" }, { withRelated: ["communities"] }) + ) + .then((tag) => { + expect(tag).to.exist; + expect(tag.get("name")).to.equal("newtagnine"); + expect(tag.relations.communities.length).to.equal(1); + const communities = sortBy(tag.relations.communities.models, (c) => + c.get("name") + ); + expect(communities[0].get("name")).to.equal(c1.get("name")); + expect(communities[0].pivot.get("user_id")).to.equal(u.id); + }); + }); - it('preserves existing tag owner', () => { - var user = factories.user() - var owner = factories.user() - var post = new Post({ - name: 'New Tagged Post', - description: 'no tags in the body' - }) - var tag = new Tag({name: 'newtagten'}) - var c2 = factories.community({name: 'Community Four'}) - return user.save() - .then(user => post.save({user_id: user.id})) - .then(() => Promise.join(post.save(), tag.save())) - .then(() => c2.save()) - .then(() => owner.save()) - .then(owner => new CommunityTag({community_id: c1.id, tag_id: tag.id, user_id: owner.id}).save()) - .then(() => post.communities().attach(c1.id)) - .then(() => post.communities().attach(c2.id)) - .then(() => Tag.updateForPost(post, ['newtagten'], user.id)) - .then(() => Tag.find({ name: 'newtagten' }, {withRelated: ['communities']})) - .then(tag => { - expect(tag).to.exist - expect(tag.get('name')).to.equal('newtagten') - expect(tag.relations.communities.length).to.equal(1) - var communities = sortBy(tag.relations.communities.models, 'id') - expect(communities[0].get('name')).to.equal(c1.get('name')) - expect(communities[0].pivot.get('user_id')).to.equal(owner.id) - }) - }) + it("preserves existing tag owner", () => { + const user = factories.user(); + const owner = factories.user(); + const post = new Post({ + name: "New Tagged Post", + description: "no tags in the body", + }); + const tag = new Tag({ name: "newtagten" }); + const c2 = factories.community({ name: "Community Four" }); + return user + .save() + .then((user) => post.save({ user_id: user.id })) + .then(() => Promise.join(post.save(), tag.save())) + .then(() => c2.save()) + .then(() => owner.save()) + .then((owner) => + new CommunityTag({ + community_id: c1.id, + tag_id: tag.id, + user_id: owner.id, + }).save() + ) + .then(() => post.communities().attach(c1.id)) + .then(() => post.communities().attach(c2.id)) + .then(() => Tag.updateForPost(post, ["newtagten"], user.id)) + .then(() => + Tag.find({ name: "newtagten" }, { withRelated: ["communities"] }) + ) + .then((tag) => { + expect(tag).to.exist; + expect(tag.get("name")).to.equal("newtagten"); + expect(tag.relations.communities.length).to.equal(1); + const communities = sortBy(tag.relations.communities.models, "id"); + expect(communities[0].get("name")).to.equal(c1.get("name")); + expect(communities[0].pivot.get("user_id")).to.equal(owner.id); + }); + }); - it('creates TagFollow for tag creator', () => { - var post = factories.post({ - name: 'New Tagged Post', - description: 'no tags in the body', - user_id: u.id - }) - return post.save() - .then(() => post.save()) - .then(() => post.communities().attach(c1.id)) - .then(() => Tag.updateForPost(post, ['newtageleven'], u.id)) - .then(() => Tag.find({ name: 'newtageleven' })) - .then(tag => TagFollow.where({tag_id: tag.id, user_id: u.id, community_id: c1.id}).fetch()) - .then(tagFollow => expect(tagFollow).to.exist) - }) - }) + it("creates TagFollow for tag creator", () => { + const post = factories.post({ + name: "New Tagged Post", + description: "no tags in the body", + user_id: u.id, + }); + return post + .save() + .then(() => post.save()) + .then(() => post.communities().attach(c1.id)) + .then(() => Tag.updateForPost(post, ["newtageleven"], u.id)) + .then(() => Tag.find({ name: "newtageleven" })) + .then((tag) => + TagFollow.where({ + tag_id: tag.id, + user_id: u.id, + community_id: c1.id, + }).fetch() + ) + .then((tagFollow) => expect(tagFollow).to.exist); + }); + }); - describe('.merge', () => { - var t1, t2, t3, p1, p2, c + describe(".merge", () => { + let t1, t2, t3, p1, p2, c; beforeEach(() => { - const k = bookshelf.knex - t1 = new Tag({name: 't1'}) - t2 = new Tag({name: 't2'}) - t3 = new Tag({name: 't3'}) - p1 = factories.post() - p2 = factories.post() - c = factories.community() + const k = bookshelf.knex; + t1 = new Tag({ name: "t1" }); + t2 = new Tag({ name: "t2" }); + t3 = new Tag({ name: "t3" }); + p1 = factories.post(); + p2 = factories.post(); + c = factories.community(); - return Promise.all([t1, t2, t3, p1, p2, c].map(x => x.save())) - .then(() => Promise.all([ - k('posts_tags').insert({tag_id: t1.id, post_id: p1.id}), - k('posts_tags').insert({tag_id: t2.id, post_id: p1.id}), - k('posts_tags').insert({tag_id: t2.id, post_id: p2.id}), - k('posts_tags').insert({tag_id: t3.id, post_id: p2.id}), - k('communities_tags').insert({tag_id: t2.id, community_id: c.id}), - k('communities_tags').insert({tag_id: t3.id, community_id: c.id}), - k('tag_follows').insert({tag_id: t1.id, community_id: c.id, user_id: u.id}), - k('tag_follows').insert({tag_id: t2.id, community_id: c.id, user_id: u.id}) - ])) - }) + return Promise.all( + [t1, t2, t3, p1, p2, c].map((x) => x.save()) + ).then(() => + Promise.all([ + k("posts_tags").insert({ tag_id: t1.id, post_id: p1.id }), + k("posts_tags").insert({ tag_id: t2.id, post_id: p1.id }), + k("posts_tags").insert({ tag_id: t2.id, post_id: p2.id }), + k("posts_tags").insert({ tag_id: t3.id, post_id: p2.id }), + k("communities_tags").insert({ tag_id: t2.id, community_id: c.id }), + k("communities_tags").insert({ tag_id: t3.id, community_id: c.id }), + k("tag_follows").insert({ + tag_id: t1.id, + community_id: c.id, + user_id: u.id, + }), + k("tag_follows").insert({ + tag_id: t2.id, + community_id: c.id, + user_id: u.id, + }), + ]) + ); + }); - it('removes rows that would cause duplicates and updates the rest', function () { + it("removes rows that would cause duplicates and updates the rest", function () { return Tag.merge(t1.id, t2.id) - .then(() => t1.load(['posts', 'communities', 'follows'])) - .then(() => { - expect(t1.relations.posts.map('id').sort()).to.deep.equal([p1.id, p2.id].sort()) - expect(t1.relations.communities.map('id')).to.deep.equal([c.id]) + .then(() => t1.load(["posts", "communities", "follows"])) + .then(() => { + expect(t1.relations.posts.map("id").sort()).to.deep.equal( + [p1.id, p2.id].sort() + ); + expect(t1.relations.communities.map("id")).to.deep.equal([c.id]); - const follows = t1.relations.follows - expect(follows.length).to.equal(1) - expect(follows.first().pick('community_id', 'user_id')).to.deep.equal({ - community_id: c.id, user_id: u.id + const follows = t1.relations.follows; + expect(follows.length).to.equal(1); + expect(follows.first().pick("community_id", "user_id")).to.deep.equal( + { + community_id: c.id, + user_id: u.id, + } + ); }) - }) - .then(() => Tag.find(t2)) - .then(tag => expect(tag).not.to.exist) - .then(() => p2.load('tags')) - .then(() => { - expect(p2.relations.tags.map('id').sort()).to.deep.equal([t1.id, t3.id].sort()) - }) - }) - }) + .then(() => Tag.find(t2)) + .then((tag) => expect(tag).not.to.exist) + .then(() => p2.load("tags")) + .then(() => { + expect(p2.relations.tags.map("id").sort()).to.deep.equal( + [t1.id, t3.id].sort() + ); + }); + }); + }); - describe('.taggedPostCount', () => { - var t1, p1, p2, c + describe(".taggedPostCount", () => { + let t1, p1, p2, c; beforeEach(() => { - const k = bookshelf.knex - t1 = new Tag({name: 't1'}) - p1 = factories.post() - p2 = factories.post({active: false}) - c = factories.community() + const k = bookshelf.knex; + t1 = new Tag({ name: "t1" }); + p1 = factories.post(); + p2 = factories.post({ active: false }); + c = factories.community(); - return Promise.all([t1, p1, p2, c].map(x => x.save())) - .then(() => Promise.all([ - k('posts_tags').insert({tag_id: t1.id, post_id: p1.id}), - k('posts_tags').insert({tag_id: t1.id, post_id: p2.id}), - k('communities_tags').insert({tag_id: t1.id, community_id: c.id}) - ])) - }) + return Promise.all([t1, p1, p2, c].map((x) => x.save())).then(() => + Promise.all([ + k("posts_tags").insert({ tag_id: t1.id, post_id: p1.id }), + k("posts_tags").insert({ tag_id: t1.id, post_id: p2.id }), + k("communities_tags").insert({ tag_id: t1.id, community_id: c.id }), + ]) + ); + }); it("doesn't count inactive posts in the count", function () { - return Tag.taggedPostCount(t1.id) - .then(count => { - expect(count).to.equal(1) - }) - }) - }) + return Tag.taggedPostCount(t1.id).then((count) => { + expect(count).to.equal(1); + }); + }); + }); - describe('.followersCount', () => { - var t1, c, c2 + describe(".followersCount", () => { + let t1, c, c2; beforeEach(() => { - const k = bookshelf.knex - t1 = new Tag({name: 't1'}) - c = factories.community() - c2 = factories.community() + const k = bookshelf.knex; + t1 = new Tag({ name: "t1" }); + c = factories.community(); + c2 = factories.community(); - return Promise.all([t1, c1, c2].map(x => x.save())) - .then(() => Promise.all([ - k('communities_tags').insert({tag_id: t1.id, community_id: c.id}), - k('tag_follows').insert({tag_id: t1.id, community_id: c.id, user_id: u.id}) - ])) - }) + return Promise.all([t1, c1, c2].map((x) => x.save())).then(() => + Promise.all([ + k("communities_tags").insert({ tag_id: t1.id, community_id: c.id }), + k("tag_follows").insert({ + tag_id: t1.id, + community_id: c.id, + user_id: u.id, + }), + ]) + ); + }); - it('correctly counts the number of followers across the whole site', function () { - return Tag.followersCount(t1.id) - .then(count => { - expect(count).to.equal(1) - }) - }) + it("correctly counts the number of followers across the whole site", function () { + return Tag.followersCount(t1.id).then((count) => { + expect(count).to.equal(1); + }); + }); - it('correctly counts the number of followers for a given community', function () { - return Tag.followersCount(t1.id, { communityId: c2.id }) - .then(count => { - expect(count).to.equal(0) - }) - }) - }) + it("correctly counts the number of followers for a given community", function () { + return Tag.followersCount(t1.id, { communityId: c2.id }).then((count) => { + expect(count).to.equal(0); + }); + }); + }); - describe('.nonexistent', () => { - var cx, cy, t1, t2, t3 + describe(".nonexistent", () => { + let cx, cy, t1, t2, t3; beforeEach(() => { - cx = factories.community() - cy = factories.community() - t1 = Tag.forge({name: 'tag1'}) - t2 = Tag.forge({name: 'tag2'}) - t3 = Tag.forge({name: 'tag3'}) - return Promise.join(cx.save(), cy.save(), t1.save(), t2.save(), t3.save()) - .then(() => Promise.join( - t1.communities().attach({user_id: u.id, community_id: cy.id}), - t2.communities().attach({user_id: u.id, community_id: cx.id}), - t3.communities().attach([ - {user_id: u.id, community_id: cx.id}, - {user_id: u.id, community_id: cy.id} - ]) - )) - }) + cx = factories.community(); + cy = factories.community(); + t1 = Tag.forge({ name: "tag1" }); + t2 = Tag.forge({ name: "tag2" }); + t3 = Tag.forge({ name: "tag3" }); + return Promise.join( + cx.save(), + cy.save(), + t1.save(), + t2.save(), + t3.save() + ).then(() => + Promise.join( + t1.communities().attach({ user_id: u.id, community_id: cy.id }), + t2.communities().attach({ user_id: u.id, community_id: cx.id }), + t3.communities().attach([ + { user_id: u.id, community_id: cx.id }, + { user_id: u.id, community_id: cy.id }, + ]) + ) + ); + }); it("returns a map of names to the communities they are missing from, filtered by a user's memberships", () => { - return Tag.nonexistent(['tag1', 'tag2', 'tag3'], [cx.id, cy.id]) - .then(results => { - expect(results).to.deep.equal({ - tag1: [cx.id], - tag2: [cy.id] - }) - }) - }) - }) + return Tag.nonexistent(["tag1", "tag2", "tag3"], [cx.id, cy.id]).then( + (results) => { + expect(results).to.deep.equal({ + tag1: [cx.id], + tag2: [cy.id], + }); + } + ); + }); + }); - describe('.tagsInText', () => { - it('finds hashtags', () => { - const text = '#foo #bar #baz' - expect(Tag.tagsInText(text)).to.deep.equal(['foo', 'bar', 'baz']) - }) + describe(".tagsInText", () => { + it("finds hashtags", () => { + const text = "#foo #bar #baz"; + expect(Tag.tagsInText(text)).to.deep.equal(["foo", "bar", "baz"]); + }); - it('does not interpret a hash fragment in a URL as a tag', () => { - expect(Tag.tagsInText('hey http://foo.com/bar#bam ok')).to.be.empty - }) + it("does not interpret a hash fragment in a URL as a tag", () => { + expect(Tag.tagsInText("hey http://foo.com/bar#bam ok")).to.be.empty; + }); - it('finds a hashtag inside an anchor tag', () => { - const text = 'hey #whoa #nah' - expect(Tag.tagsInText(text)).to.deep.equal(['whoa', 'nah']) - }) - }) + it("finds a hashtag inside an anchor tag", () => { + const text = "hey #whoa #nah"; + expect(Tag.tagsInText(text)).to.deep.equal(["whoa", "nah"]); + }); + }); - describe('.remove', () => { - it('works', () => { - return Tag.forge({name: 'foo'}).save() - .then(tag => Tag.remove(tag.id)) - .then(() => Tag.find({ name: 'foo' })) - .then(tag => expect(tag).to.be.null) - }) - }) -}) + describe(".remove", () => { + it("works", () => { + return Tag.forge({ name: "foo" }) + .save() + .then((tag) => Tag.remove(tag.id)) + .then(() => Tag.find({ name: "foo" })) + .then((tag) => expect(tag).to.be.null); + }); + }); +}); diff --git a/test/unit/models/TagFollow.test.js b/test/unit/models/TagFollow.test.js index b6cb1b07f..b2e80b615 100644 --- a/test/unit/models/TagFollow.test.js +++ b/test/unit/models/TagFollow.test.js @@ -1,126 +1,139 @@ -import root from 'root-path' -const setup = require(root('test/setup')) -const factories = require(root('test/setup/factories')) +import root from "root-path"; +const setup = require(root("test/setup")); +const factories = require(root("test/setup/factories")); -describe('TagFollow', () => { - var tag, community, user, attrs +describe("TagFollow", () => { + let tag, community, user, attrs; beforeEach(async function () { - await setup.clearDb() - tag = await factories.tag().save() - community = await factories.community().save() - user = await factories.user().save() + await setup.clearDb(); + tag = await factories.tag().save(); + community = await factories.community().save(); + user = await factories.user().save(); attrs = { tag_id: tag.id, user_id: user.id, - community_id: community.id - } - }) + community_id: community.id, + }; + }); - describe('#toggle', () => { + describe("#toggle", () => { it("creates a TagFollow when there isn't one", () => { return TagFollow.toggle(tag.id, user.id, community.id) - .then(() => TagFollow.where(attrs).fetch()) - .then(tagFollow => { - expect(tagFollow).to.exist - }) - }) + .then(() => TagFollow.where(attrs).fetch()) + .then((tagFollow) => { + expect(tagFollow).to.exist; + }); + }); - it('deletes a TagFollow when there is one', () => { - return new TagFollow(attrs).save() - .then(() => TagFollow.toggle(tag.id, user.id, community.id)) - .then(() => TagFollow.where(attrs).fetch()) - .then(tagFollow => { - expect(tagFollow).not.to.exist - }) - }) - }) + it("deletes a TagFollow when there is one", () => { + return new TagFollow(attrs) + .save() + .then(() => TagFollow.toggle(tag.id, user.id, community.id)) + .then(() => TagFollow.where(attrs).fetch()) + .then((tagFollow) => { + expect(tagFollow).not.to.exist; + }); + }); + }); - describe('#subscribe', () => { + describe("#subscribe", () => { it("creates a TagFollow when there isn't one only if isSubscribing", () => { return TagFollow.subscribe(tag.id, user.id, community.id, false) - .then(() => TagFollow.where(attrs).fetch()) - .then(tagFollow => { - expect(tagFollow).not.to.exist - }) - .then(() => TagFollow.subscribe(tag.id, user.id, community.id, true)) - .then(() => TagFollow.where(attrs).fetch()) - .then(tagFollow => { - expect(tagFollow).to.exist - }) - }) + .then(() => TagFollow.where(attrs).fetch()) + .then((tagFollow) => { + expect(tagFollow).not.to.exist; + }) + .then(() => TagFollow.subscribe(tag.id, user.id, community.id, true)) + .then(() => TagFollow.where(attrs).fetch()) + .then((tagFollow) => { + expect(tagFollow).to.exist; + }); + }); - it('deletes a TagFollow when there is one only if not isSubscribing', () => { - return new TagFollow(attrs).save() - .then(() => TagFollow.subscribe(tag.id, user.id, community.id, true)) - .then(() => TagFollow.where(attrs).fetch()) - .then(tagFollow => { - expect(tagFollow).to.exist - }) - .then(() => TagFollow.subscribe(tag.id, user.id, community.id, false)) - .then(() => TagFollow.where(attrs).fetch()) - .then(tagFollow => { - expect(tagFollow).not.to.exist - }) - }) - }) + it("deletes a TagFollow when there is one only if not isSubscribing", () => { + return new TagFollow(attrs) + .save() + .then(() => TagFollow.subscribe(tag.id, user.id, community.id, true)) + .then(() => TagFollow.where(attrs).fetch()) + .then((tagFollow) => { + expect(tagFollow).to.exist; + }) + .then(() => TagFollow.subscribe(tag.id, user.id, community.id, false)) + .then(() => TagFollow.where(attrs).fetch()) + .then((tagFollow) => { + expect(tagFollow).not.to.exist; + }); + }); + }); - describe('#add', () => { - it('creates a TagFollow and updates CommunityTag followers', () => { + describe("#add", () => { + it("creates a TagFollow and updates CommunityTag followers", () => { return new CommunityTag({ community_id: community.id, tag_id: tag.id, - num_followers: 5 - }).save() - .then(() => TagFollow.add({ - tagId: tag.id, - userId: user.id, - communityId: community.id - })) - .then(() => TagFollow.where({ - tag_id: tag.id, - community_id: community.id, - user_id: user.id - }).fetch()) - .then(tagFollow => { - expect(tagFollow).to.exist - }) - .then(() => CommunityTag.where({ - community_id: community.id, - tag_id: tag.id - }).fetch()) - .then(communityTag => { - expect(communityTag.get('num_followers')).to.equal(6) + num_followers: 5, }) - }) - }) + .save() + .then(() => + TagFollow.add({ + tagId: tag.id, + userId: user.id, + communityId: community.id, + }) + ) + .then(() => + TagFollow.where({ + tag_id: tag.id, + community_id: community.id, + user_id: user.id, + }).fetch() + ) + .then((tagFollow) => { + expect(tagFollow).to.exist; + }) + .then(() => + CommunityTag.where({ + community_id: community.id, + tag_id: tag.id, + }).fetch() + ) + .then((communityTag) => { + expect(communityTag.get("num_followers")).to.equal(6); + }); + }); + }); - describe('#remove', () => { - it('destroys a TagFollow and updates CommunityTag followers', () => { + describe("#remove", () => { + it("destroys a TagFollow and updates CommunityTag followers", () => { return Promise.join( new CommunityTag({ community_id: community.id, tag_id: tag.id, - num_followers: 5 + num_followers: 5, }).save(), new TagFollow(attrs).save() ) - .then(() => TagFollow.remove({ - tagId: tag.id, - userId: user.id, - communityId: community.id - })) - .then(() => TagFollow.where(attrs).fetch()) - .then(tagFollow => { - expect(tagFollow).not.to.exist - }) - .then(() => CommunityTag.where({ - community_id: community.id, - tag_id: tag.id - }).fetch()) - .then(communityTag => { - expect(communityTag.get('num_followers')).to.equal(4) - }) - }) - }) -}) + .then(() => + TagFollow.remove({ + tagId: tag.id, + userId: user.id, + communityId: community.id, + }) + ) + .then(() => TagFollow.where(attrs).fetch()) + .then((tagFollow) => { + expect(tagFollow).not.to.exist; + }) + .then(() => + CommunityTag.where({ + community_id: community.id, + tag_id: tag.id, + }).fetch() + ) + .then((communityTag) => { + expect(communityTag.get("num_followers")).to.equal(4); + }); + }); + }); +}); diff --git a/test/unit/models/User.test.js b/test/unit/models/User.test.js index 86c49ed6d..c9878dfad 100644 --- a/test/unit/models/User.test.js +++ b/test/unit/models/User.test.js @@ -1,507 +1,599 @@ /* eslint-disable no-unused-expressions */ -import '../../setup' -import bcrypt from 'bcrypt' -import crypto from 'crypto' -import factories from '../../setup/factories' -import { wait } from '../../setup/helpers' -import { times } from 'lodash' +import "../../setup"; +import bcrypt from "bcrypt"; +import crypto from "crypto"; +import factories from "../../setup/factories"; +import { wait } from "../../setup/helpers"; +import { times } from "lodash"; -describe('User', function () { - var cat +describe("User", function () { + let cat; before(function () { - cat = new User({name: 'Cat', email: 'Iam@cat.org', active: true}) - return cat.save() - }) - - it('can be found', function () { - return User.find('Cat').then(function (user) { - expect(user).to.exist - expect(user.get('name')).to.equal('Cat') - }) - }) - - it('can be found with case-insensitive email match', function () { - return User.find('iAm@cAt.org').then(user => { - expect(user).to.exist - expect(user.get('name')).to.equal('Cat') - }) - }) - - it('can be found with ID', function () { - return User.find(cat.id).then(user => { - expect(user).to.exist - expect(user.get('name')).to.equal('Cat') - }) - }) - - it('cannot be found if inactive', () => { - const dog = new User({name: 'Dog', email: 'iam@dog.org'}) - let dogId - return dog.save() - .tap(dog => { dogId = dog.id }) - .then(() => User.find('Dog')) - .then(dog => expect(dog).not.to.exist) - .then(() => User.find('iam@dog.org')) - .then(dog => expect(dog).not.to.exist) - .then(() => User.find(dogId)) - .then(dog => expect(dog).not.to.exist) - }) - - it('can join communities', function () { - var community1 = new Community({name: 'House', slug: 'house'}) - var community2 = new Community({name: 'Yard', slug: 'yard'}) - - return Promise.join( - community1.save(), - community2.save() - ) - .then(() => Promise.join( - cat.joinCommunity(community1), - cat.joinCommunity(community2) - )) - .then(() => cat.communities().fetch()) - .then(function (communities) { - expect(communities).to.exist - expect(communities.models).to.exist - expect(communities.models).not.to.be.empty - var names = communities.models.map(c => c.get('name')).sort() - expect(names[0]).to.equal('House') - expect(names[1]).to.equal('Yard') - }) - .then(() => GroupMembership.forPair(cat, community1).fetch()) - .then(membership => { - expect(membership).to.exist - const settings = membership.get('settings') - expect(settings.sendEmail).to.equal(true) - expect(settings.sendPushNotifications).to.equal(true) - }) - }) - - it('can become moderator', function () { - var street = new Community({name: 'Street', slug: 'street'}) - - return street.save() - .then(() => cat.joinCommunity(street, GroupMembership.Role.MODERATOR)) - .then(() => GroupMembership.forPair(cat, street).fetch()) - .then(membership => { - expect(membership).to.exist - expect(membership.get('role')).to.equal(1) - }) - }) - - describe('#setSanely', function () { + cat = new User({ name: "Cat", email: "Iam@cat.org", active: true }); + return cat.save(); + }); + + it("can be found", function () { + return User.find("Cat").then(function (user) { + expect(user).to.exist; + expect(user.get("name")).to.equal("Cat"); + }); + }); + + it("can be found with case-insensitive email match", function () { + return User.find("iAm@cAt.org").then((user) => { + expect(user).to.exist; + expect(user.get("name")).to.equal("Cat"); + }); + }); + + it("can be found with ID", function () { + return User.find(cat.id).then((user) => { + expect(user).to.exist; + expect(user.get("name")).to.equal("Cat"); + }); + }); + + it("cannot be found if inactive", () => { + const dog = new User({ name: "Dog", email: "iam@dog.org" }); + let dogId; + return dog + .save() + .tap((dog) => { + dogId = dog.id; + }) + .then(() => User.find("Dog")) + .then((dog) => expect(dog).not.to.exist) + .then(() => User.find("iam@dog.org")) + .then((dog) => expect(dog).not.to.exist) + .then(() => User.find(dogId)) + .then((dog) => expect(dog).not.to.exist); + }); + + it("can join communities", function () { + const community1 = new Community({ name: "House", slug: "house" }); + const community2 = new Community({ name: "Yard", slug: "yard" }); + + return Promise.join(community1.save(), community2.save()) + .then(() => + Promise.join( + cat.joinCommunity(community1), + cat.joinCommunity(community2) + ) + ) + .then(() => cat.communities().fetch()) + .then(function (communities) { + expect(communities).to.exist; + expect(communities.models).to.exist; + expect(communities.models).not.to.be.empty; + const names = communities.models.map((c) => c.get("name")).sort(); + expect(names[0]).to.equal("House"); + expect(names[1]).to.equal("Yard"); + }) + .then(() => GroupMembership.forPair(cat, community1).fetch()) + .then((membership) => { + expect(membership).to.exist; + const settings = membership.get("settings"); + expect(settings.sendEmail).to.equal(true); + expect(settings.sendPushNotifications).to.equal(true); + }); + }); + + it("can become moderator", function () { + const street = new Community({ name: "Street", slug: "street" }); + + return street + .save() + .then(() => cat.joinCommunity(street, GroupMembership.Role.MODERATOR)) + .then(() => GroupMembership.forPair(cat, street).fetch()) + .then((membership) => { + expect(membership).to.exist; + expect(membership.get("role")).to.equal(1); + }); + }); + + describe("#setSanely", function () { it("doesn't assume that any particular field is set", function () { - new User().setSanely({}) - }) + new User().setSanely({}); + }); - it('sanitizes twitter usernames', function () { - var user = new User() + it("sanitizes twitter usernames", function () { + const user = new User(); - user.setSanely({twitter_name: '@user'}) - expect(user.get('twitter_name')).to.equal('user') + user.setSanely({ twitter_name: "@user" }); + expect(user.get("twitter_name")).to.equal("user"); - user.setSanely({twitter_name: ' '}) - expect(user.get('twitter_name')).to.be.null - }) + user.setSanely({ twitter_name: " " }); + expect(user.get("twitter_name")).to.be.null; + }); it("doesn't add url, facebook_url or linkedin_url if not provided", function () { - var user = new User() + const user = new User(); - user.setSanely({}) + user.setSanely({}); - expect(user.get('url')).to.equal(undefined) - expect(user.get('facebook_url')).to.equal(undefined) - expect(user.get('linkedin_url')).to.equal(undefined) - }) + expect(user.get("url")).to.equal(undefined); + expect(user.get("facebook_url")).to.equal(undefined); + expect(user.get("linkedin_url")).to.equal(undefined); + }); - it('adds protocol to url, facebook_url and linkedin_url', function () { - var user = new User() + it("adds protocol to url, facebook_url and linkedin_url", function () { + const user = new User(); user.setSanely({ - url: 'myawesomesite.com', - facebook_url: 'www.facebook.com/user/123', - linkedin_url: 'linkedin.com/user/123' - }) - - expect(user.get('url')).to.equal('https://myawesomesite.com') - expect(user.get('facebook_url')).to.equal('https://www.facebook.com/user/123') - expect(user.get('linkedin_url')).to.equal('https://linkedin.com/user/123') - - user.setSanely({linkedin_url: 'http://linkedin.com/user/123'}) - expect(user.get('linkedin_url')).to.equal('http://linkedin.com/user/123') - }) - - it('preserves existing settings keys', () => { - var user = new User({ + url: "myawesomesite.com", + facebook_url: "www.facebook.com/user/123", + linkedin_url: "linkedin.com/user/123", + }); + + expect(user.get("url")).to.equal("https://myawesomesite.com"); + expect(user.get("facebook_url")).to.equal( + "https://www.facebook.com/user/123" + ); + expect(user.get("linkedin_url")).to.equal( + "https://linkedin.com/user/123" + ); + + user.setSanely({ linkedin_url: "http://linkedin.com/user/123" }); + expect(user.get("linkedin_url")).to.equal("http://linkedin.com/user/123"); + }); + + it("preserves existing settings keys", () => { + const user = new User({ settings: { - a: 'eh', - b: 'bee', - c: {sea: true} - } - }) + a: "eh", + b: "bee", + c: { sea: true }, + }, + }); user.setSanely({ settings: { - b: 'buh', - c: {see: true} - } - }) - expect(user.get('settings')).to.deep.equal({ - a: 'eh', - b: 'buh', + b: "buh", + c: { see: true }, + }, + }); + expect(user.get("settings")).to.deep.equal({ + a: "eh", + b: "buh", c: { sea: true, - see: true - } - }) - }) - }) - - describe('#communitiesSharedWithPost', () => { - var user, post, c1, c2, c3, c4 + see: true, + }, + }); + }); + }); + + describe("#communitiesSharedWithPost", () => { + let user, post, c1, c2, c3, c4; before(() => { - user = factories.user() - post = factories.post() - c1 = factories.community() - c2 = factories.community() - c3 = factories.community() - c4 = factories.community() + user = factories.user(); + post = factories.post(); + c1 = factories.community(); + c2 = factories.community(); + c3 = factories.community(); + c4 = factories.community(); return Promise.join( - user.save(), post.save(), c1.save(), c2.save(), c3.save(), c4.save()) - .then(() => post.communities().attach([c1, c2, c3])) - .then(() => user.joinCommunity(c2)) - .then(() => user.joinCommunity(c3)) - .then(() => user.joinCommunity(c4)) - }) - - it('returns the shared communities', () => { - return user.communitiesSharedWithPost(post) - .then(cs => { - expect(cs.length).to.equal(2) - expect(cs.map(c => c.id).sort()).to.deep.equal([c2.id, c3.id].sort()) - }) - }) - }) - - describe('.authenticate', function () { + user.save(), + post.save(), + c1.save(), + c2.save(), + c3.save(), + c4.save() + ) + .then(() => post.communities().attach([c1, c2, c3])) + .then(() => user.joinCommunity(c2)) + .then(() => user.joinCommunity(c3)) + .then(() => user.joinCommunity(c4)); + }); + + it("returns the shared communities", () => { + return user.communitiesSharedWithPost(post).then((cs) => { + expect(cs.length).to.equal(2); + expect(cs.map((c) => c.id).sort()).to.deep.equal([c2.id, c3.id].sort()); + }); + }); + }); + + describe(".authenticate", function () { before(function () { return new LinkedAccount({ - provider_user_id: '$2a$10$UPh85nJvMSrm6gMPqYIS.OPhLjAMbZiFnlpjq1xrtoSBTyV6fMdJS', - provider_key: 'password', - user_id: cat.id - }).save() - }) - - it('accepts a valid password', function () { - return expect(User.authenticate('iam@cat.org', 'password')) - .to.eventually.satisfy(function (user) { - return user && user.id === cat.id && user.name === cat.name - }) - }) - - it('rejects an invalid password', function () { - return expect(User.authenticate('iam@cat.org', 'pawsword')).to.be.rejected - }) - }) - - describe('.create', function () { - var catPic = 'http://i.imgur.com/Kwe1K7k.jpg' - var community + provider_user_id: + "$2a$10$UPh85nJvMSrm6gMPqYIS.OPhLjAMbZiFnlpjq1xrtoSBTyV6fMdJS", + provider_key: "password", + user_id: cat.id, + }).save(); + }); + + it("accepts a valid password", function () { + return expect( + User.authenticate("iam@cat.org", "password") + ).to.eventually.satisfy(function (user) { + return user && user.id === cat.id && user.name === cat.name; + }); + }); + + it("rejects an invalid password", function () { + return expect(User.authenticate("iam@cat.org", "pawsword")).to.be + .rejected; + }); + }); + + describe(".create", function () { + const catPic = "http://i.imgur.com/Kwe1K7k.jpg"; + let community; before(function () { - community = new Community({name: 'foo', slug: 'foo'}) - return community.save() - }) + community = new Community({ name: "foo", slug: "foo" }); + return community.save(); + }); - it('rejects an invalid email address', () => { + it("rejects an invalid email address", () => { return User.create({ - email: 'foo@bar@com', + email: "foo@bar@com", community, - account: {type: 'password', password: 'password'}, - name: 'foo bar' + account: { type: "password", password: "password" }, + name: "foo bar", }) - .then(user => expect.fail()) - .catch(err => expect(err.message).to.equal('invalid-email')) - }) + .then((user) => expect.fail()) + .catch((err) => expect(err.message).to.equal("invalid-email")); + }); - it('rejects a blank email address', () => { + it("rejects a blank email address", () => { return User.create({ email: null, community, - account: {type: 'password', password: 'password'} - }) - .then(user => expect.fail()) - .catch(err => expect(err.message).to.equal('invalid-email')) - }) - - it('works with a password', function () { - return bookshelf.transaction(function (trx) { - return User.create({ - email: 'foo@bar.com', - community: community, - account: {type: 'password', password: 'password!'}, - name: 'foo bar' - }, {transacting: trx}) - }) - .then(function (user) { - expect(user.id).to.exist - expect(user.get('active')).to.be.true - expect(user.get('name')).to.equal('foo bar') - expect(user.get('avatar_url')).to.equal(User.gravatar('foo@bar.com')) - expect(user.get('created_at').getTime()).to.be.closeTo(new Date().getTime(), 2000) - expect(user.get('settings').digest_frequency).to.equal('daily') - expect(user.get('settings').dm_notifications).to.equal('both') - expect(user.get('settings').comment_notifications).to.equal('both') - - return Promise.join( - LinkedAccount.where({user_id: user.id}).fetch().then(function (account) { - expect(account).to.exist - expect(account.get('provider_key')).to.equal('password') - expect(bcrypt.compareSync('password!', account.get('provider_user_id'))).to.be.true - }), - GroupMembership.forPair(user, community).fetch() - .then(membership => expect(membership).to.exist) - ) - }) - }) - - it('works with google', function () { - return bookshelf.transaction(function (trx) { - return User.create({ - email: 'foo2.moo2_wow@bar.com', - community: community, - account: {type: 'google', profile: {id: 'foo'}} - }, {transacting: trx}) - }) - .then(function (user) { - expect(user.id).to.exist - expect(user.get('active')).to.be.true - expect(user.get('name')).to.equal('foo2 moo2 wow') - expect(user.get('settings').digest_frequency).to.equal('daily') - - return Promise.join( - LinkedAccount.where({user_id: user.id}).fetch().then(function (account) { - expect(account).to.exist - expect(account.get('provider_key')).to.equal('google') - expect(account.get('provider_user_id')).to.equal('foo') - }), - GroupMembership.forPair(user, community).fetch() - .then(membership => expect(membership).to.exist) - ) - }) - }) - - it('works with facebook', function () { - return bookshelf.transaction(function (trx) { - return User.create({ - email: 'foo3@bar.com', - community: community, - account: { - type: 'facebook', - profile: { - id: 'foo', - profileUrl: 'http://www.facebook.com/foo' - } - } - }, {transacting: trx}) + account: { type: "password", password: "password" }, }) - .then(user => User.find(user.id)) - .then(user => { - expect(user.id).to.exist - expect(user.get('active')).to.be.true - expect(user.get('facebook_url')).to.equal('http://www.facebook.com/foo') - expect(user.get('avatar_url')).to.equal('https://graph.facebook.com/foo/picture?type=large') - expect(user.get('settings').digest_frequency).to.equal('daily') - - return Promise.join( - LinkedAccount.where({user_id: user.id}).fetch().then(function (account) { - expect(account).to.exist - expect(account.get('provider_key')).to.equal('facebook') - expect(account.get('provider_user_id')).to.equal('foo') - }), - GroupMembership.forPair(user, community).fetch() - .then(membership => expect(membership).to.exist) + .then((user) => expect.fail()) + .catch((err) => expect(err.message).to.equal("invalid-email")); + }); + + it("works with a password", function () { + return bookshelf + .transaction(function (trx) { + return User.create( + { + email: "foo@bar.com", + community: community, + account: { type: "password", password: "password!" }, + name: "foo bar", + }, + { transacting: trx } + ); + }) + .then(function (user) { + expect(user.id).to.exist; + expect(user.get("active")).to.be.true; + expect(user.get("name")).to.equal("foo bar"); + expect(user.get("avatar_url")).to.equal(User.gravatar("foo@bar.com")); + expect(user.get("created_at").getTime()).to.be.closeTo( + new Date().getTime(), + 2000 + ); + expect(user.get("settings").digest_frequency).to.equal("daily"); + expect(user.get("settings").dm_notifications).to.equal("both"); + expect(user.get("settings").comment_notifications).to.equal("both"); + + return Promise.join( + LinkedAccount.where({ user_id: user.id }) + .fetch() + .then(function (account) { + expect(account).to.exist; + expect(account.get("provider_key")).to.equal("password"); + expect( + bcrypt.compareSync( + "password!", + account.get("provider_user_id") + ) + ).to.be.true; + }), + GroupMembership.forPair(user, community) + .fetch() + .then((membership) => expect(membership).to.exist) + ); + }); + }); + + it("works with google", function () { + return bookshelf + .transaction(function (trx) { + return User.create( + { + email: "foo2.moo2_wow@bar.com", + community: community, + account: { type: "google", profile: { id: "foo" } }, + }, + { transacting: trx } + ); + }) + .then(function (user) { + expect(user.id).to.exist; + expect(user.get("active")).to.be.true; + expect(user.get("name")).to.equal("foo2 moo2 wow"); + expect(user.get("settings").digest_frequency).to.equal("daily"); + + return Promise.join( + LinkedAccount.where({ user_id: user.id }) + .fetch() + .then(function (account) { + expect(account).to.exist; + expect(account.get("provider_key")).to.equal("google"); + expect(account.get("provider_user_id")).to.equal("foo"); + }), + GroupMembership.forPair(user, community) + .fetch() + .then((membership) => expect(membership).to.exist) + ); + }); + }); + + it("works with facebook", function () { + return bookshelf + .transaction(function (trx) { + return User.create( + { + email: "foo3@bar.com", + community: community, + account: { + type: "facebook", + profile: { + id: "foo", + profileUrl: "http://www.facebook.com/foo", + }, + }, + }, + { transacting: trx } + ); + }) + .then((user) => User.find(user.id)) + .then((user) => { + expect(user.id).to.exist; + expect(user.get("active")).to.be.true; + expect(user.get("facebook_url")).to.equal( + "http://www.facebook.com/foo" + ); + expect(user.get("avatar_url")).to.equal( + "https://graph.facebook.com/foo/picture?type=large" + ); + expect(user.get("settings").digest_frequency).to.equal("daily"); + + return Promise.join( + LinkedAccount.where({ user_id: user.id }) + .fetch() + .then(function (account) { + expect(account).to.exist; + expect(account.get("provider_key")).to.equal("facebook"); + expect(account.get("provider_user_id")).to.equal("foo"); + }), + GroupMembership.forPair(user, community) + .fetch() + .then((membership) => expect(membership).to.exist) + ); + }); + }); + + it("works with linkedin", function () { + return bookshelf + .transaction(function (trx) { + return User.create( + { + email: "foo4@bar.com", + community: community, + account: { + type: "linkedin", + profile: { + id: "foo", + photos: [{ value: catPic }], + _json: { + publicProfileUrl: "https://www.linkedin.com/in/foobar", + }, + }, + }, + }, + { transacting: trx } + ); + }) + .then((user) => User.find(user.id)) + .then((user) => { + expect(user.id).to.exist; + expect(user.get("active")).to.be.true; + expect(user.get("linkedin_url")).to.equal( + "https://www.linkedin.com/in/foobar" + ); + expect(user.get("avatar_url")).to.equal(catPic); + expect(user.get("settings").digest_frequency).to.equal("daily"); + + return Promise.join( + LinkedAccount.where({ user_id: user.id }) + .fetch() + .then(function (account) { + expect(account).to.exist; + expect(account.get("provider_key")).to.equal("linkedin"); + expect(account.get("provider_user_id")).to.equal("foo"); + }), + GroupMembership.forPair(user, community) + .fetch() + .then((membership) => expect(membership).to.exist) + ); + }); + }); + }); + + describe("#followDefaultTags", function () { + it("creates TagFollows for the default tags of a community", () => { + const c1 = factories.community(); + return c1 + .save() + .then(() => Tag.forge({ name: "hello" }).save()) + .then((tag) => + CommunityTag.create({ + tag_id: tag.id, + community_id: c1.id, + is_default: true, + }) ) - }) - }) - - it('works with linkedin', function () { - return bookshelf.transaction(function (trx) { - return User.create({ - email: 'foo4@bar.com', - community: community, - account: { - type: 'linkedin', - profile: { - id: 'foo', - photos: [{value: catPic}], - _json: { - publicProfileUrl: 'https://www.linkedin.com/in/foobar' - } - } - } - }, {transacting: trx}) - }) - .then(user => User.find(user.id)) - .then(user => { - expect(user.id).to.exist - expect(user.get('active')).to.be.true - expect(user.get('linkedin_url')).to.equal('https://www.linkedin.com/in/foobar') - expect(user.get('avatar_url')).to.equal(catPic) - expect(user.get('settings').digest_frequency).to.equal('daily') - - return Promise.join( - LinkedAccount.where({user_id: user.id}).fetch().then(function (account) { - expect(account).to.exist - expect(account.get('provider_key')).to.equal('linkedin') - expect(account.get('provider_user_id')).to.equal('foo') - }), - GroupMembership.forPair(user, community).fetch() - .then(membership => expect(membership).to.exist) - ) - }) - }) - }) - - describe('#followDefaultTags', function () { - it('creates TagFollows for the default tags of a community', () => { - var c1 = factories.community() - return c1.save() - .then(() => Tag.forge({name: 'hello'}).save()) - .then(tag => CommunityTag.create({tag_id: tag.id, community_id: c1.id, is_default: true})) - .then(() => User.followDefaultTags(cat.id, c1.id)) - .then(() => cat.load('followedTags')) - .then(() => { - expect(cat.relations.followedTags.length).to.equal(1) - var tagNames = cat.relations.followedTags.map(t => t.get('name')) - expect(tagNames[0]).to.equal('hello') - }) - }) - }) - - describe('.unseenThreadCount', () => { - var doge, post, post2 + .then(() => User.followDefaultTags(cat.id, c1.id)) + .then(() => cat.load("followedTags")) + .then(() => { + expect(cat.relations.followedTags.length).to.equal(1); + const tagNames = cat.relations.followedTags.map((t) => t.get("name")); + expect(tagNames[0]).to.equal("hello"); + }); + }); + }); + + describe(".unseenThreadCount", () => { + let doge, post, post2; before(async () => { - doge = factories.user() - ;[ post, post2 ] = times(2, () => factories.post({type: Post.Type.THREAD})) + doge = factories.user(); + [post, post2] = times(2, () => + factories.post({ type: Post.Type.THREAD }) + ); - await doge.save() - return Promise.map([post, post2], p => - p.save().then(() => p.addFollowers([cat.id, doge.id]))) - }) + await doge.save(); + return Promise.map([post, post2], (p) => + p.save().then(() => p.addFollowers([cat.id, doge.id])) + ); + }); - it('works as expected', async function () { - this.timeout(5000) + it("works as expected", async function () { + this.timeout(5000); const addMessages = (p, num = 1, creator = doge) => wait(100) - .then(() => Promise.all(times(num, () => - Comment.forge({ - post_id: p.id, - user_id: creator.id, - text: 'arf', - active: true - }).save()))) - .then(comments => Post.updateFromNewComment({ - postId: p.id, - commentId: comments.slice(-1)[0].id - })) - - const n = await User.unseenThreadCount(cat.id) - expect(n).to.equal(0) + .then(() => + Promise.all( + times(num, () => + Comment.forge({ + post_id: p.id, + user_id: creator.id, + text: "arf", + active: true, + }).save() + ) + ) + ) + .then((comments) => + Post.updateFromNewComment({ + postId: p.id, + commentId: comments.slice(-1)[0].id, + }) + ); + + const n = await User.unseenThreadCount(cat.id); + expect(n).to.equal(0); // four messages but two threads - await addMessages(post, 2) - await addMessages(post2, 2) - await User.unseenThreadCount(cat.id).then(n => expect(n).to.equal(2)) - await User.unseenThreadCount(doge.id).then(n => expect(n).to.equal(0)) + await addMessages(post, 2); + await addMessages(post2, 2); + await User.unseenThreadCount(cat.id).then((n) => expect(n).to.equal(2)); + await User.unseenThreadCount(doge.id).then((n) => expect(n).to.equal(0)); // mark one thread as read - await post.markAsRead(cat.id) - await User.unseenThreadCount(cat.id).then(n => expect(n).to.equal(1)) + await post.markAsRead(cat.id); + await User.unseenThreadCount(cat.id).then((n) => expect(n).to.equal(1)); // another new message - await addMessages(post) - await User.unseenThreadCount(cat.id).then(n => expect(n).to.equal(2)) + await addMessages(post); + await User.unseenThreadCount(cat.id).then((n) => expect(n).to.equal(2)); // dropdown was opened - await cat.addSetting({last_viewed_messages_at: new Date()}, true) - await User.unseenThreadCount(cat.id).then(n => expect(n).to.equal(0)) + await cat.addSetting({ last_viewed_messages_at: new Date() }, true); + await User.unseenThreadCount(cat.id).then((n) => expect(n).to.equal(0)); // new message after dropdown was opened - await addMessages(post2) - await User.unseenThreadCount(cat.id).then(n => expect(n).to.equal(1)) + await addMessages(post2); + await User.unseenThreadCount(cat.id).then((n) => expect(n).to.equal(1)); // cat responds - await addMessages(post, 2, cat) - await addMessages(post2, 2, cat) - await User.unseenThreadCount(cat.id).then(n => expect(n).to.equal(0)) - await User.unseenThreadCount(doge.id).then(n => expect(n).to.equal(2)) - }) - }) - - describe('.comments', () => { + await addMessages(post, 2, cat); + await addMessages(post2, 2, cat); + await User.unseenThreadCount(cat.id).then((n) => expect(n).to.equal(0)); + await User.unseenThreadCount(doge.id).then((n) => expect(n).to.equal(2)); + }); + }); + + describe(".comments", () => { beforeEach(() => { - return factories.post({type: Post.Type.THREAD}).save() - .then(post => factories.comment({ - post_id: post.id, - user_id: cat.id - }).save()) - .then(() => factories.post().save()) - .then(post => factories.comment({ - post_id: post.id, - user_id: cat.id - }).save()) - }) - - it('does not include messages', () => { - return cat.comments().fetch() - .then(comments => expect(comments.length).to.equal(1)) - }) - }) - - describe('.gravatar', () => { - it('handles a blank email', () => { - expect(User.gravatar(null)).to.equal('https://www.gravatar.com/avatar/d41d8cd98f00b204e9800998ecf8427e?d=mm&s=140') - }) - }) - - describe('#communitiesSharedWithUser', () => { - it('returns shared', async () => { - const user1 = await factories.user().save() - const user2 = await factories.user().save() - const community1 = await factories.community().save() - await community1.createGroup() - const community2 = await factories.community().save() - await community2.createGroup() - const community3 = await factories.community().save() - await community3.createGroup() - const community4 = await factories.community().save() - await community4.createGroup() + return factories + .post({ type: Post.Type.THREAD }) + .save() + .then((post) => + factories + .comment({ + post_id: post.id, + user_id: cat.id, + }) + .save() + ) + .then(() => factories.post().save()) + .then((post) => + factories + .comment({ + post_id: post.id, + user_id: cat.id, + }) + .save() + ); + }); + + it("does not include messages", () => { + return cat + .comments() + .fetch() + .then((comments) => expect(comments.length).to.equal(1)); + }); + }); + + describe(".gravatar", () => { + it("handles a blank email", () => { + expect(User.gravatar(null)).to.equal( + "https://www.gravatar.com/avatar/d41d8cd98f00b204e9800998ecf8427e?d=mm&s=140" + ); + }); + }); + + describe("#communitiesSharedWithUser", () => { + it("returns shared", async () => { + const user1 = await factories.user().save(); + const user2 = await factories.user().save(); + const community1 = await factories.community().save(); + await community1.createGroup(); + const community2 = await factories.community().save(); + await community2.createGroup(); + const community3 = await factories.community().save(); + await community3.createGroup(); + const community4 = await factories.community().save(); + await community4.createGroup(); await Promise.join( user1.joinCommunity(community1), user1.joinCommunity(community2), user1.joinCommunity(community3), user2.joinCommunity(community2), user2.joinCommunity(community3), - user2.joinCommunity(community4)) - const sharedCommunities = await user1.communitiesSharedWithUser(user2) - expect(sharedCommunities.length).to.equal(2) - expect(sharedCommunities.map(c => c.id).sort()).to.deep.equal([community2.id, community3.id].sort()) - }) - }) - - describe('#intercomHash', () => { - it('returns an HMAC', async () => { - const user = await factories.user().save() - process.env.INTERCOM_KEY = '12345' - const hash = crypto.createHmac('sha256', process.env.INTERCOM_KEY) - .update(user.id) - .digest('hex') - expect(user.intercomHash()).to.equal(hash) - }) - }) -}) + user2.joinCommunity(community4) + ); + const sharedCommunities = await user1.communitiesSharedWithUser(user2); + expect(sharedCommunities.length).to.equal(2); + expect(sharedCommunities.map((c) => c.id).sort()).to.deep.equal( + [community2.id, community3.id].sort() + ); + }); + }); + + describe("#intercomHash", () => { + it("returns an HMAC", async () => { + const user = await factories.user().save(); + process.env.INTERCOM_KEY = "12345"; + const hash = crypto + .createHmac("sha256", process.env.INTERCOM_KEY) + .update(user.id) + .digest("hex"); + expect(user.intercomHash()).to.equal(hash); + }); + }); +}); diff --git a/test/unit/models/UserConnection.test.js b/test/unit/models/UserConnection.test.js index d01096231..b64a94b08 100644 --- a/test/unit/models/UserConnection.test.js +++ b/test/unit/models/UserConnection.test.js @@ -1,87 +1,102 @@ -const root = require('root-path') -const setup = require(root('test/setup')) -const factories = require(root('test/setup/factories')) +const root = require("root-path"); +const setup = require(root("test/setup")); +const factories = require(root("test/setup/factories")); -describe('UserConnection', () => { - let u - let u2 +describe("UserConnection", () => { + let u; + let u2; beforeEach(() => { - u = factories.user() - u2 = factories.user() - return setup.clearDb() - .then(() => Promise.all([u.save(), u2.save()])) - }) + u = factories.user(); + u2 = factories.user(); + return setup.clearDb().then(() => Promise.all([u.save(), u2.save()])); + }); - describe('create', () => { - it('throws if type is invalid', () => { - expect(() => UserConnection.create('1', '2', 'flargleargle')) - .to.throw(/Invalid UserConnection type/) - }) + describe("create", () => { + it("throws if type is invalid", () => { + expect(() => UserConnection.create("1", "2", "flargleargle")).to.throw( + /Invalid UserConnection type/ + ); + }); - it('throws if other_user_id is user_id', () => { - expect(() => UserConnection.create('1', '1', 'message')) - .to.throw(/other_user_id cannot equal user_id/) - }) + it("throws if other_user_id is user_id", () => { + expect(() => UserConnection.create("1", "1", "message")).to.throw( + /other_user_id cannot equal user_id/ + ); + }); - it('creates a UserConnection', () => { - const u2 = factories.user() - return u2.save() + it("creates a UserConnection", () => { + const u2 = factories.user(); + return u2 + .save() .then(() => { - return UserConnection.create(u.get('id'), u2.get('id'), 'message') + return UserConnection.create(u.get("id"), u2.get("id"), "message"); }) - .then(connection => connection.load('otherUser')) + .then((connection) => connection.load("otherUser")) .then(({ relations }) => { - const name = relations.otherUser.get('name') - expect(name).to.equal(u2.get('name')) - }) - }) - }) + const name = relations.otherUser.get("name"); + expect(name).to.equal(u2.get("name")); + }); + }); + }); - describe('createOrUpdate', () => { - it('creates a connection if one does not already exist', () => { - return UserConnection.createOrUpdate(u.get('id'), u2.get('id'), 'message') + describe("createOrUpdate", () => { + it("creates a connection if one does not already exist", () => { + return UserConnection.createOrUpdate(u.get("id"), u2.get("id"), "message") .then(() => UserConnection.fetchAll()) - .then(connections => expect(connections.length).to.equal(1)) - }) + .then((connections) => expect(connections.length).to.equal(1)); + }); - it('updates a connection if one already exists', () => { - const user_id = u.get('id') - const other_user_id = u2.get('id') - const c = factories.userConnection({ user_id, other_user_id }) - return c.save() - .then(() => UserConnection.createOrUpdate(user_id, other_user_id, 'message')) + it("updates a connection if one already exists", () => { + const user_id = u.get("id"); + const other_user_id = u2.get("id"); + const c = factories.userConnection({ user_id, other_user_id }); + return c + .save() + .then(() => + UserConnection.createOrUpdate(user_id, other_user_id, "message") + ) .then(() => UserConnection.fetchAll()) - .then(connections => { - expect(connections.length).to.equal(1) - const connection = connections.first() - expect(connection.get('updated_at')).not.to.equal(c.get('updated_at')) - }) - }) - }) + .then((connections) => { + expect(connections.length).to.equal(1); + const connection = connections.first(); + expect(connection.get("updated_at")).not.to.equal( + c.get("updated_at") + ); + }); + }); + }); - describe('find', () => { - it('throws if user_id is missing', () => { - expect(() => UserConnection.find()) - .to.throw(/Parameter user_id must be supplied/) - }) + describe("find", () => { + it("throws if user_id is missing", () => { + expect(() => UserConnection.find()).to.throw( + /Parameter user_id must be supplied/ + ); + }); - it('resolves with null if no matching connection exists', () => { - const user_id = u.get('id') - const other_user_id = u2.get('id') - const c = factories.userConnection({ user_id: other_user_id, other_user_id: user_id }) - return c.save() - .then(() => UserConnection.find(user_id, other_user_id, 'message')) - .then(connection => expect(connection).to.equal(null)) - }) + it("resolves with null if no matching connection exists", () => { + const user_id = u.get("id"); + const other_user_id = u2.get("id"); + const c = factories.userConnection({ + user_id: other_user_id, + other_user_id: user_id, + }); + return c + .save() + .then(() => UserConnection.find(user_id, other_user_id, "message")) + .then((connection) => expect(connection).to.equal(null)); + }); - it('finds a connection if a match exists', () => { - const user_id = u.get('id') - const other_user_id = u2.get('id') - const c = factories.userConnection({ user_id, other_user_id }) - return c.save() - .then(() => UserConnection.find(user_id, other_user_id, 'message')) - .then(connection => expect(connection.get('id')).to.equal(c.get('id'))) - }) - }) -}) + it("finds a connection if a match exists", () => { + const user_id = u.get("id"); + const other_user_id = u2.get("id"); + const c = factories.userConnection({ user_id, other_user_id }); + return c + .save() + .then(() => UserConnection.find(user_id, other_user_id, "message")) + .then((connection) => + expect(connection.get("id")).to.equal(c.get("id")) + ); + }); + }); +}); diff --git a/test/unit/models/comment/createComment.test.js b/test/unit/models/comment/createComment.test.js index 08b69ba60..4e334e9b2 100644 --- a/test/unit/models/comment/createComment.test.js +++ b/test/unit/models/comment/createComment.test.js @@ -1,56 +1,50 @@ -import { - pushMessageToSockets -} from '../../../../api/models/comment/createComment' -import { - createThread -} from '../../../../api/models/post/findOrCreateThread' -import setup from '../../../setup' -import factories from '../../../setup/factories' +import { pushMessageToSockets } from "../../../../api/models/comment/createComment"; +import { createThread } from "../../../../api/models/post/findOrCreateThread"; +import setup from "../../../setup"; +import factories from "../../../setup/factories"; -describe('comment/createComment', () => { - before(() => setup.clearDb()) +describe("comment/createComment", () => { + before(() => setup.clearDb()); - describe('pushMessageToSockets', () => { - var user, user2, thread + describe("pushMessageToSockets", () => { + let user, user2, thread; before(async () => { - user = await factories.user().save() - user2 = await factories.user().save() - thread = await createThread(user.id, [user2.id]) - }) + user = await factories.user().save(); + user2 = await factories.user().save(); + thread = await createThread(user.id, [user2.id]); + }); - it('sends newThread event if first message', () => { + it("sends newThread event if first message", () => { const message = new Comment({ - text: 'hi', + text: "hi", post_id: thread.id, - user_id: user.id - }) - return pushMessageToSockets(message, thread) - .then(promises => { - expect(promises.length).to.equal(1) - const { room, messageType, payload } = promises[0] - expect(room).to.equal(`users/${user2.id}`) - expect(messageType).to.equal('newThread') - expect(payload.id).to.equal(thread.id) - }) - }) + user_id: user.id, + }); + return pushMessageToSockets(message, thread).then((promises) => { + expect(promises.length).to.equal(1); + const { room, messageType, payload } = promises[0]; + expect(room).to.equal(`users/${user2.id}`); + expect(messageType).to.equal("newThread"); + expect(payload.id).to.equal(thread.id); + }); + }); - it('sends messageAdded event if not first message', () => { + it("sends messageAdded event if not first message", () => { const message = new Comment({ - text: 'hi', + text: "hi", post_id: thread.id, - user_id: user.id - }) - thread.set({num_comments: 2}) - return pushMessageToSockets(message, thread) - .then(promises => { - expect(promises.length).to.equal(1) - const { room, messageType, payload } = promises[0] - expect(room).to.equal(`users/${user2.id}`) - expect(messageType).to.equal('messageAdded') - expect(payload.messageThread).to.equal(thread.id) - expect(payload.text).to.equal('hi') - }) - }) - }) -}) + user_id: user.id, + }); + thread.set({ num_comments: 2 }); + return pushMessageToSockets(message, thread).then((promises) => { + expect(promises.length).to.equal(1); + const { room, messageType, payload } = promises[0]; + expect(room).to.equal(`users/${user2.id}`); + expect(messageType).to.equal("messageAdded"); + expect(payload.messageThread).to.equal(thread.id); + expect(payload.text).to.equal("hi"); + }); + }); + }); +}); diff --git a/test/unit/models/comment/notifications.test.js b/test/unit/models/comment/notifications.test.js index a8f0b6956..76918d1d5 100644 --- a/test/unit/models/comment/notifications.test.js +++ b/test/unit/models/comment/notifications.test.js @@ -1,35 +1,38 @@ -import { notifyAboutMessage } from '../../../../api/models/comment/notifications' -import factories from '../../../setup/factories' -import { compact } from 'lodash' +import { notifyAboutMessage } from "../../../../api/models/comment/notifications"; +import factories from "../../../setup/factories"; +import { compact } from "lodash"; -describe('notifyAboutMessage', () => { - let comment, device +describe("notifyAboutMessage", () => { + let comment, device; before(async () => { - const u1 = await factories.user().save() // should receive - const u2 = await factories.user().save() // recently read - const u3 = await factories.user().save() // notifications disabled - const u4 = await factories.user().save() // commenter + const u1 = await factories.user().save(); // should receive + const u2 = await factories.user().save(); // recently read + const u3 = await factories.user().save(); // notifications disabled + const u4 = await factories.user().save(); // commenter - const post = await factories.post({type: Post.Type.THREAD}).save() - await post.addFollowers([u1.id, u2.id, u3.id, u4.id]) - comment = await factories.comment({ - user_id: u4.id, post_id: post.id - }).save() + const post = await factories.post({ type: Post.Type.THREAD }).save(); + await post.addFollowers([u1.id, u2.id, u3.id, u4.id]); + comment = await factories + .comment({ + user_id: u4.id, + post_id: post.id, + }) + .save(); - await u1.addSetting({dm_notifications: 'push'}, true) + await u1.addSetting({ dm_notifications: "push" }, true); device = await u1.devices().create({ enabled: true, - version: 1 - }) - await post.markAsRead(u2.id) - }) + version: 1, + }); + await post.markAsRead(u2.id); + }); - it('sends push notifications', async () => { - const results = await notifyAboutMessage({commentId: comment.id}) - expect(compact(results).length).to.equal(1) - const sent = results.find(x => x && x[0])[0] - expect(sent.get('device_id')).to.equal(device.id) - expect(sent.get('alert')).to.contain(comment.get('text')) - }) -}) + it("sends push notifications", async () => { + const results = await notifyAboutMessage({ commentId: comment.id }); + expect(compact(results).length).to.equal(1); + const sent = results.find((x) => x && x[0])[0]; + expect(sent.get("device_id")).to.equal(device.id); + expect(sent.get("alert")).to.contain(comment.get("text")); + }); +}); diff --git a/test/unit/models/flaggedItem/notifyUtils.test.js b/test/unit/models/flaggedItem/notifyUtils.test.js index baf7cb9b4..8cebbb2d8 100644 --- a/test/unit/models/flaggedItem/notifyUtils.test.js +++ b/test/unit/models/flaggedItem/notifyUtils.test.js @@ -1,10 +1,10 @@ -import '../../../setup' -import factories from '../../../setup/factories' -import mockRequire from 'mock-require' -const model = factories.mock.model +import "../../../setup"; +import factories from "../../../setup/factories"; +import mockRequire from "mock-require"; +const model = factories.mock.model; -describe('sendToCommunities', () => { - var argUserIds, +describe("sendToCommunities", () => { + let argUserIds, argText, sendToCommunities, oldHyloAdmins, @@ -12,169 +12,165 @@ describe('sendToCommunities', () => { modIds1, modIds2, c1, - c2 + c2; before(() => { - mockRequire.stopAll() - mockRequire('../../../../api/services/MessagingService', { + mockRequire.stopAll(); + mockRequire("../../../../api/services/MessagingService", { sendMessageFromAxolotl: spy((userIds, text) => { - for (let i of userIds) argUserIds.push(i) - argText.push(text) - return 'Bob the result' - }) - }) - sendToCommunities = mockRequire.reRequire('../../../../api/models/flaggedItem/notifyUtils').sendToCommunities - oldHyloAdmins = process.env.HYLO_ADMINS - process.env.HYLO_ADMINS = '11,22' - }) + for (const i of userIds) argUserIds.push(i); + argText.push(text); + return "Bob the result"; + }), + }); + sendToCommunities = mockRequire.reRequire( + "../../../../api/models/flaggedItem/notifyUtils" + ).sendToCommunities; + oldHyloAdmins = process.env.HYLO_ADMINS; + process.env.HYLO_ADMINS = "11,22"; + }); beforeEach(async () => { - argUserIds = [] - argText = [] - - c1 = await factories.community().save() - c2 = await factories.community().save() - const u1 = await factories.user().save() - const u2 = await factories.user().save() - const u3 = await factories.user().save() - await c1.addGroupMembers([u1, u2], {role: GroupMembership.Role.MODERATOR}) - await c2.addGroupMembers([u2, u3], {role: GroupMembership.Role.MODERATOR}) - - communities = [c1, c2] - modIds1 = [u1.id, u2.id] - modIds2 = [u2.id, u3.id] - }) + argUserIds = []; + argText = []; + + c1 = await factories.community().save(); + c2 = await factories.community().save(); + const u1 = await factories.user().save(); + const u2 = await factories.user().save(); + const u3 = await factories.user().save(); + await c1.addGroupMembers([u1, u2], { + role: GroupMembership.Role.MODERATOR, + }); + await c2.addGroupMembers([u2, u3], { + role: GroupMembership.Role.MODERATOR, + }); + + communities = [c1, c2]; + modIds1 = [u1.id, u2.id]; + modIds2 = [u2.id, u3.id]; + }); after(() => { - process.env.HYLO_ADMINS = oldHyloAdmins - }) + process.env.HYLO_ADMINS = oldHyloAdmins; + }); - it('sends a message from axolotl to the community moderators', () => { - const message = 'this is the message being sent to' + it("sends a message from axolotl to the community moderators", () => { + const message = "this is the message being sent to"; const flaggedItem = model({ category: FlaggedItem.Category.SPAM, - getMessageText: c => Promise.resolve(`${message} ${c.id}`) - }) - - return sendToCommunities(flaggedItem, communities) - .then(result => { - expect(argUserIds.sort()).to.deep.equal(modIds1.concat(modIds2).sort()) - expect(argText).to.deep.equal([`${message} ${c1.id}`, `${message} ${c2.id}`]) - }) - }) - - it('sends illegal content to HYLO ADMINS as well', () => { - const message = 'this is the message being sent to' + getMessageText: (c) => Promise.resolve(`${message} ${c.id}`), + }); + + return sendToCommunities(flaggedItem, communities).then((result) => { + expect(argUserIds.sort()).to.deep.equal(modIds1.concat(modIds2).sort()); + expect(argText).to.deep.equal([ + `${message} ${c1.id}`, + `${message} ${c2.id}`, + ]); + }); + }); + + it("sends illegal content to HYLO ADMINS as well", () => { + const message = "this is the message being sent to"; const flaggedItem = model({ category: FlaggedItem.Category.ILLEGAL, - getMessageText: c => Promise.resolve(`${message} ${c.id}`) - }) + getMessageText: (c) => Promise.resolve(`${message} ${c.id}`), + }); - var expectedText = [`${message} ${c1.id}`, `${message} ${c2.id}`] + const expectedText = [`${message} ${c1.id}`, `${message} ${c2.id}`]; - const hyloAdminIds = process.env.HYLO_ADMINS.split(',').map(id => Number(id)) - var expectedUserIds = modIds1.concat(modIds2).concat(hyloAdminIds).sort() - expectedText.push(`${message} ${c1.id}`) + const hyloAdminIds = process.env.HYLO_ADMINS.split(",").map((id) => + Number(id) + ); + const expectedUserIds = modIds1.concat(modIds2).concat(hyloAdminIds).sort(); + expectedText.push(`${message} ${c1.id}`); - return sendToCommunities(flaggedItem, communities) - .then(result => { - expect(argUserIds.sort()).to.deep.equal(expectedUserIds) - expect(argText).to.deep.equal(expectedText) - }) - }) -}) + return sendToCommunities(flaggedItem, communities).then((result) => { + expect(argUserIds.sort()).to.deep.equal(expectedUserIds); + expect(argText).to.deep.equal(expectedText); + }); + }); +}); // for these it would be less redundant to just mock sendToCommunities and test // that it was called with the right args. However, you can't do that with mock-require // because it is in the same file as the functions we're testing -const notifyUtilsPath = '../../../../api/models/flaggedItem/notifyUtils' +const notifyUtilsPath = "../../../../api/models/flaggedItem/notifyUtils"; -describe('notifying moderators', () => { - var argUserIds, - argText, - flaggedItem, - modIds1, - modIds2 +describe("notifying moderators", () => { + let argUserIds, argText, flaggedItem, modIds1, modIds2; before(() => { - mockRequire.stopAll() - mockRequire('../../../../api/services/MessagingService', { + mockRequire.stopAll(); + mockRequire("../../../../api/services/MessagingService", { sendMessageFromAxolotl: spy((userIds, text) => { - argUserIds.push(userIds) - argText.push(text) - return 'Bob the result' - }) - }) + argUserIds.push(userIds); + argText.push(text); + return "Bob the result"; + }), + }); - modIds1 = [1, 2] - modIds2 = [2, 3] + modIds1 = [1, 2]; + modIds2 = [2, 3]; const mockCommunities = [ model({ id: 1, moderators: () => ({ - fetch: async () => modIds1.map(id => ({id})) - }) + fetch: async () => modIds1.map((id) => ({ id })), + }), }), model({ id: 2, moderators: () => ({ - fetch: async () => modIds2.map(id => ({id})) - }) - }) - ] + fetch: async () => modIds2.map((id) => ({ id })), + }), + }), + ]; flaggedItem = model({ - getObject: () => ({relations: {}}), - getMessageText: c => `the message ${c.id}`, + getObject: () => ({ relations: {} }), + getMessageText: (c) => `the message ${c.id}`, relations: { user: model({ communitiesSharedWithPost: () => mockCommunities, - communitiesSharedWithUser: () => mockCommunities - }) - } - }) - }) + communitiesSharedWithUser: () => mockCommunities, + }), + }, + }); + }); beforeEach(() => { - argUserIds = [] - argText = [] - }) - - it('works for a post', () => { - const notifyModeratorsPost = mockRequire.reRequire(notifyUtilsPath).notifyModeratorsPost - return notifyModeratorsPost(flaggedItem) - .then(result => { - expect(argUserIds).to.deep.equal([modIds1, modIds2]) - expect(argText).to.deep.equal([ - 'the message 1', - 'the message 2' - ]) - }) - }) - - it('works for a comment', () => { - const notifyModeratorsComment = mockRequire.reRequire(notifyUtilsPath).notifyModeratorsComment - return notifyModeratorsComment(flaggedItem) - .then(result => { - expect(argUserIds).to.deep.equal([modIds1, modIds2]) - expect(argText).to.deep.equal([ - 'the message 1', - 'the message 2' - ]) - }) - }) - - it('works for a member', () => { - const notifyModeratorsMember = mockRequire.reRequire(notifyUtilsPath).notifyModeratorsMember - return notifyModeratorsMember(flaggedItem) - .then(result => { - expect(argUserIds).to.deep.equal([modIds1, modIds2]) - expect(argText).to.deep.equal([ - 'the message 1', - 'the message 2' - ]) - }) - }) -}) + argUserIds = []; + argText = []; + }); + + it("works for a post", () => { + const notifyModeratorsPost = mockRequire.reRequire(notifyUtilsPath) + .notifyModeratorsPost; + return notifyModeratorsPost(flaggedItem).then((result) => { + expect(argUserIds).to.deep.equal([modIds1, modIds2]); + expect(argText).to.deep.equal(["the message 1", "the message 2"]); + }); + }); + + it("works for a comment", () => { + const notifyModeratorsComment = mockRequire.reRequire(notifyUtilsPath) + .notifyModeratorsComment; + return notifyModeratorsComment(flaggedItem).then((result) => { + expect(argUserIds).to.deep.equal([modIds1, modIds2]); + expect(argText).to.deep.equal(["the message 1", "the message 2"]); + }); + }); + + it("works for a member", () => { + const notifyModeratorsMember = mockRequire.reRequire(notifyUtilsPath) + .notifyModeratorsMember; + return notifyModeratorsMember(flaggedItem).then((result) => { + expect(argUserIds).to.deep.equal([modIds1, modIds2]); + expect(argText).to.deep.equal(["the message 1", "the message 2"]); + }); + }); +}); diff --git a/test/unit/models/post/request.test.js b/test/unit/models/post/request.test.js index ff0134802..2004b7df7 100644 --- a/test/unit/models/post/request.test.js +++ b/test/unit/models/post/request.test.js @@ -1,80 +1,98 @@ -import root from 'root-path' -const setup = require(root('test/setup')) -const factories = require(root('test/setup/factories')) -import { spyify, unspyify } from '../../../setup/helpers' +import root from "root-path"; +import { spyify, unspyify } from "../../../setup/helpers"; +const setup = require(root("test/setup")); +const factories = require(root("test/setup/factories")); -describe('post/request', () => { - let author, contributor1, contributor2, post, community, fulfilledAt +describe("post/request", () => { + let author, contributor1, contributor2, post, community, fulfilledAt; beforeEach(() => { - fulfilledAt = new Date() - return setup.clearDb().then(() => Promise.props({ - author: factories.user().save(), - community: factories.community().save(), - post: factories.post().save(), - contributor1: factories.user().save(), - contributor2: factories.user().save() - })) - .tap(fixtures => Promise.all([ - fixtures.post.save('user_id', fixtures.author.get('id')), - fixtures.post.communities().attach(fixtures.community) - ]) - ) - .then(fixtures => { - return { author, contributor1, contributor2, post, community } = fixtures - }) - }) + fulfilledAt = new Date(); + return setup + .clearDb() + .then(() => + Promise.props({ + author: factories.user().save(), + community: factories.community().save(), + post: factories.post().save(), + contributor1: factories.user().save(), + contributor2: factories.user().save(), + }) + ) + .tap((fixtures) => + Promise.all([ + fixtures.post.save("user_id", fixtures.author.get("id")), + fixtures.post.communities().attach(fixtures.community), + ]) + ) + .then((fixtures) => { + return ({ + author, + contributor1, + contributor2, + post, + community, + } = fixtures); + }); + }); - describe('#fulfillRequest', () => { + describe("#fulfillRequest", () => { beforeEach(() => { - spyify(Queue, 'classMethod') - return post.fulfillRequest({ - fulfilledAt, - contributorIds: [contributor1.id, contributor2.id] - }) - .then(post => post.fetch({withRelated: 'contributions'})) - }) + spyify(Queue, "classMethod"); + return post + .fulfillRequest({ + fulfilledAt, + contributorIds: [contributor1.id, contributor2.id], + }) + .then((post) => post.fetch({ withRelated: "contributions" })); + }); - after(() => unspyify(Queue, 'classMethod')) + after(() => unspyify(Queue, "classMethod")); - it('should add fulfilled time', () => { - expect(post.get('fulfilled_at')).to.equalDate(fulfilledAt) - }) + it("should add fulfilled time", () => { + expect(post.get("fulfilled_at")).to.equalDate(fulfilledAt); + }); - it('should add contributors', () => { - expect(post.relations.contributions).to.be.length(2) - expect(post.relations.contributions.map((c) => c.get('user_id'))) - .to.include.members([contributor1.id, contributor2.id]) - }) + it("should add contributors", () => { + expect(post.relations.contributions).to.be.length(2); + expect( + post.relations.contributions.map((c) => c.get("user_id")) + ).to.include.members([contributor1.id, contributor2.id]); + }); - it('should add activities and notifications to contributors', () => { + it("should add activities and notifications to contributors", () => { post.relations.contributions.each((c) => - expect(Queue.classMethod).to.have.been.called - .with('Contribution', 'createActivities', {contributionId: c.id})) - }) - }) + expect(Queue.classMethod).to.have.been.called.with( + "Contribution", + "createActivities", + { contributionId: c.id } + ) + ); + }); + }); - describe('#unfulfillRequest', () => { + describe("#unfulfillRequest", () => { beforeEach(() => - post.fulfillRequest({ - fulfilledAt, - contributorIds: [contributor1.id, contributor2.id] - }) - .then(post => post.fetch({withRelated: 'contributions'})) - ) + post + .fulfillRequest({ + fulfilledAt, + contributorIds: [contributor1.id, contributor2.id], + }) + .then((post) => post.fetch({ withRelated: "contributions" })) + ); - it('should remove fulfilled time', () => { - expect(post.get('fulfilled_at')).to.equalDate(fulfilledAt) - return post.unfulfillRequest().then(() => - expect(post.get('fulfilled_at')).to.not.exist - ) - }) + it("should remove fulfilled time", () => { + expect(post.get("fulfilled_at")).to.equalDate(fulfilledAt); + return post + .unfulfillRequest() + .then(() => expect(post.get("fulfilled_at")).to.not.exist); + }); - it('should remove contributors', () => { - expect(post.relations.contributions).to.be.length(2) - return post.unfulfillRequest().then(() => - expect(post.relations.contributors).to.be.undefined - ) - }) - }) -}) + it("should remove contributors", () => { + expect(post.relations.contributions).to.be.length(2); + return post + .unfulfillRequest() + .then(() => expect(post.relations.contributors).to.be.undefined); + }); + }); +}); diff --git a/test/unit/models/post/util.test.js b/test/unit/models/post/util.test.js index 309e2e00b..d98ad466e 100644 --- a/test/unit/models/post/util.test.js +++ b/test/unit/models/post/util.test.js @@ -1,49 +1,50 @@ -import root from 'root-path' -const setup = require(root('test/setup')) -const factories = require(root('test/setup/factories')) -import { - updateNetworkMemberships -} from '../../../../api/models/post/util' +import root from "root-path"; +import { updateNetworkMemberships } from "../../../../api/models/post/util"; +const setup = require(root("test/setup")); +const factories = require(root("test/setup/factories")); -describe('updateNetworkMemberships', () => { - var c1, c2, n1, n2, n3, post +describe("updateNetworkMemberships", () => { + let c1, c2, n1, n2, n3, post; before(() => - setup.clearDb() - .then(() => { - n1 = factories.network() - n2 = factories.network() - n3 = factories.network() - c1 = factories.community() - c2 = factories.community() - post = factories.post() + setup.clearDb().then(() => { + n1 = factories.network(); + n2 = factories.network(); + n3 = factories.network(); + c1 = factories.community(); + c2 = factories.community(); + post = factories.post(); return Promise.join( n1.save(), n2.save(), n3.save(), c1.save(), c2.save(), - post.save()) - .then(() => Promise.join( - n1.communities().create(c1), - n2.communities().create(c2), - post.networks().attach(n2), - post.networks().attach(n3), - post.communities().attach(c1), - post.communities().attach(c2) - )) - })) + post.save() + ).then(() => + Promise.join( + n1.communities().create(c1), + n2.communities().create(c2), + post.networks().attach(n2), + post.networks().attach(n3), + post.communities().attach(c1), + post.communities().attach(c2) + ) + ); + }) + ); - it('updates the network memberships', () => { - return updateNetworkMemberships(post) - .then(() => Promise.join( - PostNetworkMembership.find(post.id, n1.id), - PostNetworkMembership.find(post.id, n2.id), - PostNetworkMembership.find(post.id, n3.id), - (pnm1, pnm2, pnm3) => { - expect(pnm1).to.exist - expect(pnm2).to.exist - expect(pnm3).not.to.exist - } - )) - }) -}) + it("updates the network memberships", () => { + return updateNetworkMemberships(post).then(() => + Promise.join( + PostNetworkMembership.find(post.id, n1.id), + PostNetworkMembership.find(post.id, n2.id), + PostNetworkMembership.find(post.id, n3.id), + (pnm1, pnm2, pnm3) => { + expect(pnm1).to.exist; + expect(pnm2).to.exist; + expect(pnm3).not.to.exist; + } + ) + ); + }); +}); diff --git a/test/unit/policies/checkAndDecodeToken.test.js b/test/unit/policies/checkAndDecodeToken.test.js index c62c73a48..a44364252 100644 --- a/test/unit/policies/checkAndDecodeToken.test.js +++ b/test/unit/policies/checkAndDecodeToken.test.js @@ -1,33 +1,35 @@ -const rootPath = require('root-path') -require(rootPath('test/setup')) -const checkAndDecodeToken = require(rootPath('api/policies/checkAndDecodeToken')) -const factories = require(rootPath('test/setup/factories')) +const rootPath = require("root-path"); +require(rootPath("test/setup")); +const checkAndDecodeToken = require(rootPath( + "api/policies/checkAndDecodeToken" +)); +const factories = require(rootPath("test/setup/factories")); -describe('checkAndDecodeToken', function () { - var req, res, next +describe("checkAndDecodeToken", function () { + let req, res, next; beforeEach(() => { - req = factories.mock.request() - res = factories.mock.response() - res.badRequest = spy() - next = spy() - }) + req = factories.mock.request(); + res = factories.mock.response(); + res.badRequest = spy(); + next = spy(); + }); - it('rejects a bad token', () => { - req.params.token = 'abadtoken' - checkAndDecodeToken(req, res, next) - expect(res.badRequest).to.have.been.called() - expect(next).not.to.have.been.called() - }) + it("rejects a bad token", () => { + req.params.token = "abadtoken"; + checkAndDecodeToken(req, res, next); + expect(res.badRequest).to.have.been.called(); + expect(next).not.to.have.been.called(); + }); - it('decodes a good token', () => { - const communityId = '123' - const userId = '321' - req.params.token = Email.formToken(communityId, userId) + it("decodes a good token", () => { + const communityId = "123"; + const userId = "321"; + req.params.token = Email.formToken(communityId, userId); - checkAndDecodeToken(req, res, next) - expect(res.locals.tokenData.communityId).to.equal(communityId) - expect(res.locals.tokenData.userId).to.equal(userId) - expect(next).to.have.been.called() - }) -}) + checkAndDecodeToken(req, res, next); + expect(res.locals.tokenData.communityId).to.equal(communityId); + expect(res.locals.tokenData.userId).to.equal(userId); + expect(next).to.have.been.called(); + }); +}); diff --git a/test/unit/policies/checkAndSetMembership.test.js b/test/unit/policies/checkAndSetMembership.test.js index 9e97ea7ea..dc1c8ef90 100644 --- a/test/unit/policies/checkAndSetMembership.test.js +++ b/test/unit/policies/checkAndSetMembership.test.js @@ -1,57 +1,59 @@ -var root = require('root-path') -require(root('test/setup')) -var factories = require(root('test/setup/factories')) -var checkAndSetMembership = require(root('api/policies/checkAndSetMembership')) +const root = require("root-path"); +require(root("test/setup")); +const factories = require(root("test/setup/factories")); +const checkAndSetMembership = require(root( + "api/policies/checkAndSetMembership" +)); -describe('checkAndSetMembership', () => { - var user, community, req, res +describe("checkAndSetMembership", () => { + let user, community, req, res; before(async () => { - const network = await factories.network().save() - community = await factories.community({network_id: network.id}).save() - user = await factories.user().save() - }) + const network = await factories.network().save(); + community = await factories.community({ network_id: network.id }).save(); + user = await factories.user().save(); + }); beforeEach(() => { - req = factories.mock.request() - req.params.communityId = community.id - res = factories.mock.response() - }) + req = factories.mock.request(); + req.params.communityId = community.id; + res = factories.mock.response(); + }); it("doesn't set res.locals.membership if publicAccessAllowed", () => { - req.user = {email: 'lawrence@nothylo.com'} - res.locals.publicAccessAllowed = true - var next = spy(() => {}) - - return checkAndSetMembership(req, res, next) - .then(() => { - expect(next).to.have.been.called() - }) - }) - - it('allows public access for logged in users', () => { - req.user = {email: 'lawrence@nothylo.com'} - req.session.userId = 1 - res.locals.publicAccessAllowed = true - var next = spy(() => {}) - - return checkAndSetMembership(req, res, next) - .then(() => { + req.user = { email: "lawrence@nothylo.com" }; + res.locals.publicAccessAllowed = true; + const next = spy(() => {}); + + return checkAndSetMembership(req, res, next).then(() => { + expect(next).to.have.been.called(); + }); + }); + + it("allows public access for logged in users", () => { + req.user = { email: "lawrence@nothylo.com" }; + req.session.userId = 1; + res.locals.publicAccessAllowed = true; + const next = spy(() => {}); + + return checkAndSetMembership(req, res, next).then(() => { + expect(next).to.have.been.called(); + }); + }); + + it("returns false if the user is not logged in", () => { + req.session.userId = user.id; + return checkAndSetMembership(req, res).then(() => + expect(res.forbidden).to.have.been.called() + ); + }); + + it("returns true if the user is in the community", async () => { + req.session.userId = user.id; + const next = spy(); + await community.addGroupMembers([user.id]); + return checkAndSetMembership(req, res, next).then(() => expect(next).to.have.been.called() - }) - }) - - it('returns false if the user is not logged in', () => { - req.session.userId = user.id - return checkAndSetMembership(req, res) - .then(() => expect(res.forbidden).to.have.been.called()) - }) - - it('returns true if the user is in the community', async () => { - req.session.userId = user.id - const next = spy() - await community.addGroupMembers([user.id]) - return checkAndSetMembership(req, res, next) - .then(() => expect(next).to.have.been.called()) - }) -}) + ); + }); +}); diff --git a/test/unit/policies/checkAndSetPost.test.js b/test/unit/policies/checkAndSetPost.test.js index 7e21e2dfb..d318bc43f 100644 --- a/test/unit/policies/checkAndSetPost.test.js +++ b/test/unit/policies/checkAndSetPost.test.js @@ -1,82 +1,87 @@ -var setup = require(require('root-path')('test/setup')) -var checkAndSetPost = require(require('root-path')('api/policies/checkAndSetPost')) -describe('checkAndSetPost', function () { - var fixtures, req, res, next +const setup = require(require("root-path")("test/setup")); +const checkAndSetPost = require(require("root-path")( + "api/policies/checkAndSetPost" +)); +describe("checkAndSetPost", function () { + let fixtures, req, res, next; before(function () { - return setup.clearDb().then(function () { - return Promise.props({ - u1: new User({name: 'U1', email: 'a@b.c'}).save(), - c1: new Community({name: 'C1', slug: 'c1'}).save(), - c2: new Community({name: 'C2', slug: 'c2'}).save(), - p1: new Post({name: 'P1', active: true}).save(), - p2: new Post({name: 'P2', active: true}).save() + return setup + .clearDb() + .then(function () { + return Promise.props({ + u1: new User({ name: "U1", email: "a@b.c" }).save(), + c1: new Community({ name: "C1", slug: "c1" }).save(), + c2: new Community({ name: "C2", slug: "c2" }).save(), + p1: new Post({ name: "P1", active: true }).save(), + p2: new Post({ name: "P2", active: true }).save(), + }); }) - }) - .then(function (props) { - fixtures = props - return Promise.props({ - pc1: props.c1.posts().attach(props.p1.id), - pc2: props.c2.posts().attach(props.p2.id) + .then(function (props) { + fixtures = props; + return Promise.props({ + pc1: props.c1.posts().attach(props.p1.id), + pc2: props.c2.posts().attach(props.p2.id), + }); }) - }) - .then(() => fixtures.c1.addGroupMembers([fixtures.u1.id])) - }) + .then(() => fixtures.c1.addGroupMembers([fixtures.u1.id])); + }); - describe('with a userId', function () { + describe("with a userId", function () { before(function () { req = { - session: {userId: fixtures.u1.id} - } - }) + session: { userId: fixtures.u1.id }, + }; + }); beforeEach(function () { - next = spy() - }) + next = spy(); + }); - it('returns 404 given a null postId request param', () => { + it("returns 404 given a null postId request param", () => { req.param = function (name) { - if (name === 'postId') return null - } + if (name === "postId") return null; + }; res = { locals: {}, - notFound: spy(function () {}) - } + notFound: spy(function () {}), + }; - return checkAndSetPost(req, res, next) - .then(() => expect(res.notFound).to.have.been.called()) - }) + return checkAndSetPost(req, res, next).then(() => + expect(res.notFound).to.have.been.called() + ); + }); - it('allows access to a joined community', () => { + it("allows access to a joined community", () => { req.param = function (name) { - if (name === 'postId') return fixtures.p1.id - } + if (name === "postId") return fixtures.p1.id; + }; res = { locals: {}, - forbidden: spy(function () {}) - } + forbidden: spy(function () {}), + }; - return checkAndSetPost(req, res, next) - .then(() => expect(next).to.have.been.called()) - }) + return checkAndSetPost(req, res, next).then(() => + expect(next).to.have.been.called() + ); + }); - it('denies access to other communities', () => { + it("denies access to other communities", () => { req.param = function (name) { - if (name === 'postId') return fixtures.p2.id - } + if (name === "postId") return fixtures.p2.id; + }; res = { locals: {}, - forbidden: spy(function () {}) - } + forbidden: spy(function () {}), + }; - return checkAndSetPost(req, res, next) - .then(() => { - expect(next).to.not.have.been.called() - expect(res.forbidden).to.have.been.called() - }) - }) - }) -}) + return checkAndSetPost(req, res, next).then(() => { + expect(next).to.not.have.been.called(); + expect(res.forbidden).to.have.been.called(); + }); + }); + }); +}); diff --git a/test/unit/services/AccessTokenAuth.test.js b/test/unit/services/AccessTokenAuth.test.js index 188fa7076..3e522c37e 100644 --- a/test/unit/services/AccessTokenAuth.test.js +++ b/test/unit/services/AccessTokenAuth.test.js @@ -1,80 +1,79 @@ -var setup = require('../../setup') -import factories from '../../setup/factories' +import factories from "../../setup/factories"; +const setup = require("../../setup"); -describe('AccessTokenAuth', function () { - describe('.generateToken', () => { - it('generates a 48 character string', () => { - return AccessTokenAuth.generateToken() - .then(token => { - expect(token.length).to.equal(48) - }) - }) - }) +describe("AccessTokenAuth", function () { + describe(".generateToken", () => { + it("generates a 48 character string", () => { + return AccessTokenAuth.generateToken().then((token) => { + expect(token.length).to.equal(48); + }); + }); + }); - describe('.checkAndSetAuthenticated', () => { - var userWithToken, generatedToken = '1234' + describe(".checkAndSetAuthenticated", () => { + let userWithToken; + const generatedToken = "1234"; before(() => { - setup.clearDb() - UserSession.login = spy(UserSession.login) - return factories.user() - .save() - .then(u => { - userWithToken = u - return LinkedAccount.create(u.id, {type: 'token',token: generatedToken}) - }) - }) + setup.clearDb(); + UserSession.login = spy(UserSession.login); + return factories + .user() + .save() + .then((u) => { + userWithToken = u; + return LinkedAccount.create(u.id, { + type: "token", + token: generatedToken, + }); + }); + }); - it('will set the user based on the access token from the req.body', () => { - var req = factories.mock.request() - req.body.access_token = generatedToken - AccessTokenAuth.checkAndSetAuthenticated(req) - .then(() => { - expect(UserSession.login).to.have.been.called() - expect(req.session.userId).to.equal(userWithToken.id) - expect(req.session.authenticated).to.be.true - }) - }) - - it('will set the user based on the access token from the query params', () => { - var req = factories.mock.request() - req.query.access_token = generatedToken - AccessTokenAuth.checkAndSetAuthenticated(req) - .then(() => { - expect(UserSession.login).to.have.been.called() - expect(req.session.userId).to.equal(userWithToken.id) - expect(req.session.authenticated).to.be.true - }) - }) - - it('will set the user based on the x-access-token from the request header', () => { - var req = factories.mock.request() - req.headers['x-access-token'] = generatedToken - AccessTokenAuth.checkAndSetAuthenticated(req) - .then(() => { - expect(UserSession.login).to.have.been.called() - expect(req.session.userId).to.equal(userWithToken.id) - expect(req.session.authenticated).to.be.true - }) - }) - - it('does nothing if no token is specified', () => { - var req = factories.mock.request() - AccessTokenAuth.checkAndSetAuthenticated(req) - .then(() => { - expect(req.session.authenticated).to.equal(undefined) - expect(req.session.userId).to.equal(undefined) - }) - }) - - it('does nothing if no user is found with that token', () => { - var req = factories.mock.request() - req.query.access_token = '4321' - AccessTokenAuth.checkAndSetAuthenticated(req) - .then(() => { - expect(req.session.authenticated).to.equal(undefined) - expect(req.session.userId).to.equal(undefined) - }) - }) - }) -}) \ No newline at end of file + it("will set the user based on the access token from the req.body", () => { + const req = factories.mock.request(); + req.body.access_token = generatedToken; + AccessTokenAuth.checkAndSetAuthenticated(req).then(() => { + expect(UserSession.login).to.have.been.called(); + expect(req.session.userId).to.equal(userWithToken.id); + expect(req.session.authenticated).to.be.true; + }); + }); + + it("will set the user based on the access token from the query params", () => { + const req = factories.mock.request(); + req.query.access_token = generatedToken; + AccessTokenAuth.checkAndSetAuthenticated(req).then(() => { + expect(UserSession.login).to.have.been.called(); + expect(req.session.userId).to.equal(userWithToken.id); + expect(req.session.authenticated).to.be.true; + }); + }); + + it("will set the user based on the x-access-token from the request header", () => { + const req = factories.mock.request(); + req.headers["x-access-token"] = generatedToken; + AccessTokenAuth.checkAndSetAuthenticated(req).then(() => { + expect(UserSession.login).to.have.been.called(); + expect(req.session.userId).to.equal(userWithToken.id); + expect(req.session.authenticated).to.be.true; + }); + }); + + it("does nothing if no token is specified", () => { + const req = factories.mock.request(); + AccessTokenAuth.checkAndSetAuthenticated(req).then(() => { + expect(req.session.authenticated).to.equal(undefined); + expect(req.session.userId).to.equal(undefined); + }); + }); + + it("does nothing if no user is found with that token", () => { + const req = factories.mock.request(); + req.query.access_token = "4321"; + AccessTokenAuth.checkAndSetAuthenticated(req).then(() => { + expect(req.session.authenticated).to.equal(undefined); + expect(req.session.userId).to.equal(undefined); + }); + }); + }); +}); diff --git a/test/unit/services/AssetManagement.test.js b/test/unit/services/AssetManagement.test.js index 3c9a4b8ca..b264a6fc3 100644 --- a/test/unit/services/AssetManagement.test.js +++ b/test/unit/services/AssetManagement.test.js @@ -1,32 +1,40 @@ -require('../../setup') -const factories = require('../../setup/factories') -const defaultAvatarUrl = 'http://hylo-app.s3.amazonaws.com/misc/default_community_avatar.png' +require("../../setup"); +const factories = require("../../setup/factories"); +const defaultAvatarUrl = + "http://hylo-app.s3.amazonaws.com/misc/default_community_avatar.png"; -describe('AssetManagement', () => { - var community, origBucket +describe("AssetManagement", () => { + let community, origBucket; before(() => { - origBucket = process.env.AWS_S3_BUCKET - process.env.AWS_S3_BUCKET = '' - community = factories.community() - return community.save({avatar_url: defaultAvatarUrl}) - }) + origBucket = process.env.AWS_S3_BUCKET; + process.env.AWS_S3_BUCKET = ""; + community = factories.community(); + return community.save({ avatar_url: defaultAvatarUrl }); + }); after(() => { - process.env.AWS_S3_BUCKET = origBucket - }) + process.env.AWS_S3_BUCKET = origBucket; + }); - describe('copyAsset', () => { - it('throws an error if misconfigured', function () { - const promise = AssetManagement.copyAsset(community, 'community', 'avatar_url') - return expect(promise).to.eventually.be.rejected - }) - }) + describe("copyAsset", () => { + it("throws an error if misconfigured", function () { + const promise = AssetManagement.copyAsset( + community, + "community", + "avatar_url" + ); + return expect(promise).to.eventually.be.rejected; + }); + }); - describe('resizeAsset', () => { - it('throws an error if misconfigured', function () { - const promise = AssetManagement.resizeAsset(community, 'avatar_url', {width: 200, height: 200}) - return expect(promise).to.eventually.be.rejected - }) - }) -}) + describe("resizeAsset", () => { + it("throws an error if misconfigured", function () { + const promise = AssetManagement.resizeAsset(community, "avatar_url", { + width: 200, + height: 200, + }); + return expect(promise).to.eventually.be.rejected; + }); + }); +}); diff --git a/test/unit/services/CommunityService.test.js b/test/unit/services/CommunityService.test.js index 6b566c812..c94ccbb16 100644 --- a/test/unit/services/CommunityService.test.js +++ b/test/unit/services/CommunityService.test.js @@ -1,33 +1,35 @@ -var root = require('root-path') -var setup = require(root('test/setup')) -var factories = require(root('test/setup/factories')) -var CommunityService = require(root('api/services/CommunityService')) +const root = require("root-path"); +const setup = require(root("test/setup")); +const factories = require(root("test/setup/factories")); +const CommunityService = require(root("api/services/CommunityService")); -describe('CommunityService', function () { - let u1, u2, c1 +describe("CommunityService", function () { + let u1, u2, c1; before(async () => { - await setup.clearDb() - u1 = await factories.user({name: 'moderator'}).save() - u2 = await factories.user().save({name: 'user'}) - c1 = await factories.community({num_members: 0}).save() - await u1.joinCommunity(c1, GroupMembership.Role.MODERATOR) - await u2.joinCommunity(c1) - }) + await setup.clearDb(); + u1 = await factories.user({ name: "moderator" }).save(); + u2 = await factories.user().save({ name: "user" }); + c1 = await factories.community({ num_members: 0 }).save(); + await u1.joinCommunity(c1, GroupMembership.Role.MODERATOR); + await u2.joinCommunity(c1); + }); - it('removes a member from a community', () => { + it("removes a member from a community", () => { return Group.allHaveMember([c1.id], u2.id, Community) - .then(result => { - expect(result).to.equal(true) - return CommunityService.removeMember(u2.id, c1.id, u1.id) - }) - .then(() => Promise.props({ - inCommunity: Group.allHaveMember([c1.id], u2.id, Community), - refreshedCommunity: c1.refresh() - })) - .then(props => { - expect(props.inCommunity).to.equal(false) - expect(props.refreshedCommunity.get('num_members')).to.equal(1) - }) - }) -}) + .then((result) => { + expect(result).to.equal(true); + return CommunityService.removeMember(u2.id, c1.id, u1.id); + }) + .then(() => + Promise.props({ + inCommunity: Group.allHaveMember([c1.id], u2.id, Community), + refreshedCommunity: c1.refresh(), + }) + ) + .then((props) => { + expect(props.inCommunity).to.equal(false); + expect(props.refreshedCommunity.get("num_members")).to.equal(1); + }); + }); +}); diff --git a/test/unit/services/Email.test.js b/test/unit/services/Email.test.js index 99377d486..24ba5b5ef 100644 --- a/test/unit/services/Email.test.js +++ b/test/unit/services/Email.test.js @@ -1,27 +1,31 @@ -require(require('root-path')('test/setup')) +require(require("root-path")("test/setup")); -describe('Email', function () { - describe('reply address', () => { +describe("Email", function () { + describe("reply address", () => { // this expects dev environment variables: // MAILGUN_EMAIL_SALT=FFFFAAAA123456789 // MAILGUN_DOMAIN=mg.hylo.com // PLAY_APP_SECRET=quxgrault12345678 - const postId = '7823' - const userId = '5942' - const email = 'reply-8c26a271fe72895d4e3c20a6893d9c0ee9c9041235c9ce207c0a627196396807@mg.hylo.com' + const postId = "7823"; + const userId = "5942"; + const email = + "reply-8c26a271fe72895d4e3c20a6893d9c0ee9c9041235c9ce207c0a627196396807@mg.hylo.com"; - describe('.postReplyAddress', () => { - it('encrypts the post and user ids', () => { - expect(Email.postReplyAddress(postId, userId)).to.equal(email) - }) - }) + describe(".postReplyAddress", () => { + it("encrypts the post and user ids", () => { + expect(Email.postReplyAddress(postId, userId)).to.equal(email); + }); + }); - describe('.decodePostReplyAddress', () => { - it('works with human-readable formats', () => { - var address = `"${email}" <${email}>` + describe(".decodePostReplyAddress", () => { + it("works with human-readable formats", () => { + const address = `"${email}" <${email}>`; - expect(Email.decodePostReplyAddress(address)).to.deep.equal({postId, userId}) - }) - }) - }) -}) + expect(Email.decodePostReplyAddress(address)).to.deep.equal({ + postId, + userId, + }); + }); + }); + }); +}); diff --git a/test/unit/services/FullTextSearch.test.js b/test/unit/services/FullTextSearch.test.js index 24b0ec4e6..a3da48548 100644 --- a/test/unit/services/FullTextSearch.test.js +++ b/test/unit/services/FullTextSearch.test.js @@ -1,20 +1,21 @@ -require('../../setup') +require("../../setup"); -describe('FullTextSearch', () => { - it('sets up, refreshes, and drops the materialied view', function () { - this.timeout(5000) +describe("FullTextSearch", () => { + it("sets up, refreshes, and drops the materialied view", function () { + this.timeout(5000); return FullTextSearch.dropView() - .then(() => FullTextSearch.createView()) - .then(() => FullTextSearch.refreshView()) - .then(() => FullTextSearch.dropView()) - }) + .then(() => FullTextSearch.createView()) + .then(() => FullTextSearch.refreshView()) + .then(() => FullTextSearch.dropView()); + }); - describe('.searchInCommunities', () => { - it('produces the expected SQL', () => { - const opts = {limit: 10, offset: 20, term: 'zounds', type: 'person'} - const query = FullTextSearch.searchInCommunities([3, 5], opts).toString() + describe(".searchInCommunities", () => { + it("produces the expected SQL", () => { + const opts = { limit: 10, offset: 20, term: "zounds", type: "person" }; + const query = FullTextSearch.searchInCommunities([3, 5], opts).toString(); - expect(query).to.equal(` + expect(query).to.equal( + ` select "search"."post_id", "search"."comment_id", "search"."user_id", "rank", "total" from (select post_id, comment_id, user_id, ts_rank_cd(document, to_tsquery('english', 'zounds:*')) as rank, @@ -35,7 +36,10 @@ describe('FullTextSearch', () => { order by "rank" desc limit 10 offset 20 - `.replace(/(\n\s*)/g, ' ').trim()) - }) - }) -}) + ` + .replace(/(\n\s*)/g, " ") + .trim() + ); + }); + }); +}); diff --git a/test/unit/services/GetImageSize.test.js b/test/unit/services/GetImageSize.test.js index 63c42ba15..b76119ba7 100644 --- a/test/unit/services/GetImageSize.test.js +++ b/test/unit/services/GetImageSize.test.js @@ -1,14 +1,15 @@ -var root = require('root-path') -require(root('test/setup')) -var GetImageSize = require(root('api/services/GetImageSize')) +const root = require("root-path"); +require(root("test/setup")); +const GetImageSize = require(root("api/services/GetImageSize")); -describe('GetImageSize', () => { - it('gets the size', function () { - this.timeout(5000) - return GetImageSize('http://cdn.hylo.com/misc/hylo-logo-teal-on-transparent.png') - .then(dimensions => { - expect(dimensions.width).to.equal(300) - expect(dimensions.height).to.equal(300) - }) - }) -}) +describe("GetImageSize", () => { + it("gets the size", function () { + this.timeout(5000); + return GetImageSize( + "http://cdn.hylo.com/misc/hylo-logo-teal-on-transparent.png" + ).then((dimensions) => { + expect(dimensions.width).to.equal(300); + expect(dimensions.height).to.equal(300); + }); + }); +}); diff --git a/test/unit/services/InvitationService.test.js b/test/unit/services/InvitationService.test.js index fd3682900..c265cd603 100644 --- a/test/unit/services/InvitationService.test.js +++ b/test/unit/services/InvitationService.test.js @@ -1,151 +1,162 @@ -import { markdown } from 'hylo-utils/text' -var root = require('root-path') -require(root('test/setup')) -const factories = require(root('test/setup/factories')) -const { mockify } = require(root('test/setup/helpers')) -var InvitationService = require(root('api/services/InvitationService')) +import { markdown } from "hylo-utils/text"; +const root = require("root-path"); +require(root("test/setup")); +const factories = require(root("test/setup/factories")); +const { mockify } = require(root("test/setup/helpers")); +const InvitationService = require(root("api/services/InvitationService")); -describe('InvitationService', () => { - var community, inviter, invitee, invitation +describe("InvitationService", () => { + let community, inviter, invitee, invitation; before(() => { - inviter = factories.user() - invitee = factories.user() - community = factories.community() - return Promise.join(inviter.save(), invitee.save(), community.save()) - }) + inviter = factories.user(); + invitee = factories.user(); + community = factories.community(); + return Promise.join(inviter.save(), invitee.save(), community.save()); + }); - describe('check', () => { + describe("check", () => { before(() => { - invitation = factories.invitation() + invitation = factories.invitation(); return invitation.save({ invited_by_id: inviter.id, - community_id: community.id - }) - }) + community_id: community.id, + }); + }); - it('should find a community by a valid accessCode', () => { - return InvitationService.check(invitee.get('id'), null, community.get('beta_access_code')) - .then(result => - expect(result.valid).to.equal(true) - ) - }) + it("should find a community by a valid accessCode", () => { + return InvitationService.check( + invitee.get("id"), + null, + community.get("beta_access_code") + ).then((result) => expect(result.valid).to.equal(true)); + }); - it('should find a community by a valid token', () => { - const userId = invitee.get('id') - const token = invitation.get('token') - return InvitationService.check(userId, token, null) - .then(result => + it("should find a community by a valid token", () => { + const userId = invitee.get("id"); + const token = invitation.get("token"); + return InvitationService.check(userId, token, null).then((result) => expect(result.valid).to.equal(true) - ) - }) + ); + }); - it('should find a community by accessCode if both an accessCode and token are provided', () => { - const userId = invitee.get('id') - const accessCode = community.get('beta_access_code') - const token = 'INVALIDTOKEN' - InvitationService.check(userId, token, accessCode).then(result => + it("should find a community by accessCode if both an accessCode and token are provided", () => { + const userId = invitee.get("id"); + const accessCode = community.get("beta_access_code"); + const token = "INVALIDTOKEN"; + InvitationService.check(userId, token, accessCode).then((result) => expect(result.valid).to.equal(true) - ) - }) - }) + ); + }); + }); - describe('use', () => { + describe("use", () => { before(() => { - invitation = factories.invitation() + invitation = factories.invitation(); return invitation.save({ - invited_by_id: inviter.get('id'), - community_id: community.get('id') - }) - }) + invited_by_id: inviter.get("id"), + community_id: community.get("id"), + }); + }); - it('should join the invitee to community if beta_access_code is valid', () => { - const accessCode = community.get('beta_access_code') - return InvitationService.use(invitee.get('id'), null, accessCode) - .then(membership => - expect(membership.attributes).to.contain({ - user_id: invitee.get('id'), - active: true - }) - ) - }) + it("should join the invitee to community if beta_access_code is valid", () => { + const accessCode = community.get("beta_access_code"); + return InvitationService.use(invitee.get("id"), null, accessCode).then( + (membership) => + expect(membership.attributes).to.contain({ + user_id: invitee.get("id"), + active: true, + }) + ); + }); - it('should join the invitee to community if token is valid', () => { - const userId = invitee.get('id') - const token = invitation.get('token') - return InvitationService.use(userId, token, null) - .then(membership => + it("should join the invitee to community if token is valid", () => { + const userId = invitee.get("id"); + const token = invitation.get("token"); + return InvitationService.use(userId, token, null).then((membership) => expect(membership.attributes).to.contain({ - user_id: invitee.get('id'), - active: true + user_id: invitee.get("id"), + active: true, }) - ) - }) + ); + }); - it('should join the invitee to community by accessCode if both an accessCode and token are provided', () => { - const userId = invitee.get('id') - const token = invitation.get('token') - const accessCode = community.get('beta_access_code') - return InvitationService.use(userId, token, accessCode) - .then(membership => { - return invitation.refresh() - .then(updatedInvitation => { - expect(updatedInvitation.get('used_by_id')).to.equal(invitee.get('id')) - return expect(membership.attributes).to.contain({ - user_id: invitee.get('id'), - active: true - }) - }) - }) - }) - }) + it("should join the invitee to community by accessCode if both an accessCode and token are provided", () => { + const userId = invitee.get("id"); + const token = invitation.get("token"); + const accessCode = community.get("beta_access_code"); + return InvitationService.use(userId, token, accessCode).then( + (membership) => { + return invitation.refresh().then((updatedInvitation) => { + expect(updatedInvitation.get("used_by_id")).to.equal( + invitee.get("id") + ); + return expect(membership.attributes).to.contain({ + user_id: invitee.get("id"), + active: true, + }); + }); + } + ); + }); + }); - describe('create', () => { - let queuedCalls = [] - const subject = 'Join us' - const message = "You'll like it. It's safe." + describe("create", () => { + const queuedCalls = []; + const subject = "Join us"; + const message = "You'll like it. It's safe."; before(() => { - mockify(Queue, 'classMethod', (cls, method, opts) => - Promise.resolve(queuedCalls.push([cls, method, opts]))) - }) + mockify(Queue, "classMethod", (cls, method, opts) => + Promise.resolve(queuedCalls.push([cls, method, opts])) + ); + }); - it.skip('rejects invalid emails and sends to the rest', () => { + it.skip("rejects invalid emails and sends to the rest", () => { return InvitationService.create({ sessionUserId: inviter.id, communityId: community.id, - emails: ['foo', 'bar', 'foo@foo.com', 'bar@bar.com'], + emails: ["foo", "bar", "foo@foo.com", "bar@bar.com"], subject, - message - }) - .then(results => { + message, + }).then((results) => { expect(results).to.deep.equal([ - {email: 'foo', error: 'not a valid email address'}, - {email: 'bar', error: 'not a valid email address'}, - {email: 'foo@foo.com', lastSentAt: undefined, createdAt: undefined, id: results[2].id}, - {email: 'bar@bar.com', lastSentAt: undefined, createdAt: undefined, id: results[3].id} - ]) + { email: "foo", error: "not a valid email address" }, + { email: "bar", error: "not a valid email address" }, + { + email: "foo@foo.com", + lastSentAt: undefined, + createdAt: undefined, + id: results[2].id, + }, + { + email: "bar@bar.com", + lastSentAt: undefined, + createdAt: undefined, + id: results[3].id, + }, + ]); - expect(Queue.classMethod).to.have.been.called.exactly(2) - const firstInvitation = queuedCalls[0][2].invitation - const secondInvitation = queuedCalls[1][2].invitation + expect(Queue.classMethod).to.have.been.called.exactly(2); + const firstInvitation = queuedCalls[0][2].invitation; + const secondInvitation = queuedCalls[1][2].invitation; expect(queuedCalls).to.deep.equal([ [ - 'Invitation', - 'createAndSend', + "Invitation", + "createAndSend", { - invitation: firstInvitation - } + invitation: firstInvitation, + }, ], [ - 'Invitation', - 'createAndSend', + "Invitation", + "createAndSend", { - invitation: secondInvitation - } - ] - ]) - }) - }) - }) -}) + invitation: secondInvitation, + }, + ], + ]); + }); + }); + }); +}); diff --git a/test/unit/services/OneSignal.test.js b/test/unit/services/OneSignal.test.js index e0b6319ac..e6d9fcacd 100644 --- a/test/unit/services/OneSignal.test.js +++ b/test/unit/services/OneSignal.test.js @@ -1,113 +1,118 @@ -import mockRequire from 'mock-require' +import mockRequire from "mock-require"; -describe('OneSignal.notify', () => { - let notify, options +describe("OneSignal.notify", () => { + let notify, options; const oldFixture = { json: { - app_id: 'fake_app_id', - contents: {en: 'hello'}, - data: {path: '/p/1'}, - include_ios_tokens: ['foo'], + app_id: "fake_app_id", + contents: { en: "hello" }, + data: { path: "/p/1" }, + include_ios_tokens: ["foo"], ios_badgeCount: 7, - ios_badgeType: 'SetTo' + ios_badgeType: "SetTo", }, - method: 'POST', - url: 'https://onesignal.com/api/v1/notifications' - } + method: "POST", + url: "https://onesignal.com/api/v1/notifications", + }; const newFixture = { json: { - app_id: 'fake_app_id', - contents: {en: 'hello'}, - data: {path: '/p/1'}, - include_player_ids: ['foo'], + app_id: "fake_app_id", + contents: { en: "hello" }, + data: { path: "/p/1" }, + include_player_ids: ["foo"], ios_badgeCount: 7, - ios_badgeType: 'SetTo' + ios_badgeType: "SetTo", }, - method: 'POST', - url: 'https://onesignal.com/api/v1/notifications' - } + method: "POST", + url: "https://onesignal.com/api/v1/notifications", + }; beforeEach(() => { - options = null - mockRequire('request', spy(opts => { options = opts })) - notify = mockRequire.reRequire('../../../api/services/OneSignal').notify - }) + options = null; + mockRequire( + "request", + spy((opts) => { + options = opts; + }) + ); + notify = mockRequire.reRequire("../../../api/services/OneSignal").notify; + }); it('handles legacy platform value "ios_macos"', () => { notify({ - platform: 'ios_macos', - deviceToken: 'foo', - alert: 'hello', - path: '/p/1', + platform: "ios_macos", + deviceToken: "foo", + alert: "hello", + path: "/p/1", badgeNo: 7, - appId: 'fake_app_id' - }) - expect(options).to.deep.equal(oldFixture) - }) + appId: "fake_app_id", + }); + expect(options).to.deep.equal(oldFixture); + }); it('handles platform value "ios" with player id', () => { notify({ - platform: 'ios', - playerId: 'foo', - alert: 'hello', - path: '/p/1', + platform: "ios", + playerId: "foo", + alert: "hello", + path: "/p/1", badgeNo: 7, - appId: 'fake_app_id' - }) - expect(options).to.deep.equal(newFixture) - }) + appId: "fake_app_id", + }); + expect(options).to.deep.equal(newFixture); + }); it('handles platform value "android" with device token', () => { notify({ - platform: 'android', - deviceToken: 'foo', - alert: 'hello', - path: '/p/1', + platform: "android", + deviceToken: "foo", + alert: "hello", + path: "/p/1", badgeNo: 7, - appId: 'fake_app_id' - }) + appId: "fake_app_id", + }); expect(options).to.deep.equal({ json: { - app_id: 'fake_app_id', - contents: {en: 'hello'}, - data: {alert: 'hello', path: '/p/1'}, - include_android_reg_ids: ['foo'] + app_id: "fake_app_id", + contents: { en: "hello" }, + data: { alert: "hello", path: "/p/1" }, + include_android_reg_ids: ["foo"], }, - method: 'POST', - url: 'https://onesignal.com/api/v1/notifications' - }) - }) + method: "POST", + url: "https://onesignal.com/api/v1/notifications", + }); + }); it('handles platform value "android" with player id', () => { notify({ - platform: 'android', - playerId: 'foo', - alert: 'hello', - path: '/p/1', + platform: "android", + playerId: "foo", + alert: "hello", + path: "/p/1", badgeNo: 7, - appId: 'fake_app_id' - }) + appId: "fake_app_id", + }); expect(options).to.deep.equal({ json: { - app_id: 'fake_app_id', - contents: {en: 'hello'}, - data: {alert: 'hello', path: '/p/1'}, - include_player_ids: ['foo'] + app_id: "fake_app_id", + contents: { en: "hello" }, + data: { alert: "hello", path: "/p/1" }, + include_player_ids: ["foo"], }, - method: 'POST', - url: 'https://onesignal.com/api/v1/notifications' - }) - }) + method: "POST", + url: "https://onesignal.com/api/v1/notifications", + }); + }); - it('rejects a call with both device token and player id', () => { + it("rejects a call with both device token and player id", () => { return notify({ - platform: 'android', - deviceToken: 'foo', - playerId: 'foo' + platform: "android", + deviceToken: "foo", + playerId: "foo", }) - .then(() => expect.fail('should throw')) - .catch(err => expect(err.message).to.match(/Can't pass both/)) - }) -}) + .then(() => expect.fail("should throw")) + .catch((err) => expect(err.message).to.match(/Can't pass both/)); + }); +}); diff --git a/test/unit/services/PlayCrypto.test.js b/test/unit/services/PlayCrypto.test.js index 38099a3ff..744306357 100644 --- a/test/unit/services/PlayCrypto.test.js +++ b/test/unit/services/PlayCrypto.test.js @@ -1,9 +1,9 @@ -require(require('root-path')('test/setup')); +require(require("root-path")("test/setup")); -describe('PlayCrypto', function() { - - it('is reversible', function() { - expect(PlayCrypto.decrypt(PlayCrypto.encrypt('foobarbaz'))).to.equal('foobarbaz'); +describe("PlayCrypto", function () { + it("is reversible", function () { + expect(PlayCrypto.decrypt(PlayCrypto.encrypt("foobarbaz"))).to.equal( + "foobarbaz" + ); }); - -}) \ No newline at end of file +}); diff --git a/test/unit/services/PostManagement.test.js b/test/unit/services/PostManagement.test.js index f9e83cefc..3eb2c5632 100644 --- a/test/unit/services/PostManagement.test.js +++ b/test/unit/services/PostManagement.test.js @@ -1,25 +1,26 @@ -import '../../setup' -import factories from '../../setup/factories' -import { removePost } from '../../../api/services/PostManagement' +import "../../setup"; +import factories from "../../setup/factories"; +import { removePost } from "../../../api/services/PostManagement"; -describe('PostManagement', () => { - describe('removePost', () => { - var post, user +describe("PostManagement", () => { + describe("removePost", () => { + let post, user; beforeEach(() => { - user = factories.user() - return user.save() - .then(() => { - post = factories.post({user_id: user.id}) - return post.save() - }) - .then(() => factories.comment({post_id: post.id}).save()) - }) + user = factories.user(); + return user + .save() + .then(() => { + post = factories.post({ user_id: user.id }); + return post.save(); + }) + .then(() => factories.comment({ post_id: post.id }).save()); + }); - it('works', () => { + it("works", () => { return removePost(post.id) - .then(() => Post.find(post.id)) - .then(p => expect(p).not.to.exist) - }) - }) -}) + .then(() => Post.find(post.id)) + .then((p) => expect(p).not.to.exist); + }); + }); +}); diff --git a/test/unit/services/RichText.test.js b/test/unit/services/RichText.test.js index e019301e7..38402f694 100644 --- a/test/unit/services/RichText.test.js +++ b/test/unit/services/RichText.test.js @@ -1,29 +1,33 @@ -require('../../setup') -const Frontend = require('../../../api/services/Frontend') -const prefix = Frontend.Route.prefix +require("../../setup"); +const Frontend = require("../../../api/services/Frontend"); +const prefix = Frontend.Route.prefix; -describe('RichText', function () { - describe('.qualifyLinks', function () { - it('turns data-user-id links into fully-qualified links', function () { - var text = '

#hashtag, #anotherhashtag, https://www.metafilter.com/wooooo

' + +describe("RichText", function () { + describe(".qualifyLinks", function () { + it("turns data-user-id links into fully-qualified links", function () { + const text = + "

#hashtag, #anotherhashtag, https://www.metafilter.com/wooooo

" + '

a paragraph, and of course @Minda Myers ' + - '@Ray Hylo #boom.

danke

' + '@Ray Hylo #boom.

danke

'; - var expected = '

#hashtag, #anotherhashtag, https://www.metafilter.com/wooooo

' + - `

a paragraph, and of course @Minda Myers ` + - `@Ray Hylo #boom.

danke

` + const expected = + "

#hashtag, #anotherhashtag, https://www.metafilter.com/wooooo

" + + `

a paragraph, and of course @Minda Myers ` + + `@Ray Hylo #boom.

danke

`; - expect(RichText.qualifyLinks(text)).to.equal(expected) - }) + expect(RichText.qualifyLinks(text)).to.equal(expected); + }); - it('links hashtags inside anchor tags', () => { - const text = '

#hashtag

' - const communityUrl = `${prefix}/c/foo/tag/hashtag` - const nonCommunityUrl = `${prefix}/tag/hashtag` - const expected = url => `

#hashtag

` + it("links hashtags inside anchor tags", () => { + const text = "

#hashtag

"; + const communityUrl = `${prefix}/c/foo/tag/hashtag`; + const nonCommunityUrl = `${prefix}/tag/hashtag`; + const expected = (url) => `

#hashtag

`; - expect(RichText.qualifyLinks(text, null, null, 'foo')).to.equal(expected(communityUrl)) - expect(RichText.qualifyLinks(text)).to.equal(expected(nonCommunityUrl)) - }) - }) -}) + expect(RichText.qualifyLinks(text, null, null, "foo")).to.equal( + expected(communityUrl) + ); + expect(RichText.qualifyLinks(text)).to.equal(expected(nonCommunityUrl)); + }); + }); +}); diff --git a/test/unit/services/Search.test.js b/test/unit/services/Search.test.js index f37bd0671..5daef9b90 100644 --- a/test/unit/services/Search.test.js +++ b/test/unit/services/Search.test.js @@ -1,15 +1,17 @@ -import moment from 'moment-timezone' -import { expectEqualQuery } from '../../setup/helpers' -import setup from '../../setup' - -describe('Search', function () { - describe('.forPosts', function () { - it('produces the expected SQL for a complex query', function () { - var startTime = moment('2015-03-24 19:54:12-04:00') - var endTime = moment('2015-03-31 19:54:12-04:00') - var tz = moment.tz.guess() - var startTimeAsString = startTime.tz(tz).format('YYYY-MM-DD HH:mm:ss.SSS') - var endTimeAsString = endTime.tz(tz).format('YYYY-MM-DD HH:mm:ss.SSS') +import moment from "moment-timezone"; +import { expectEqualQuery } from "../../setup/helpers"; +import setup from "../../setup"; + +describe("Search", function () { + describe(".forPosts", function () { + it("produces the expected SQL for a complex query", function () { + const startTime = moment("2015-03-24 19:54:12-04:00"); + const endTime = moment("2015-03-31 19:54:12-04:00"); + const tz = moment.tz.guess(); + const startTimeAsString = startTime + .tz(tz) + .format("YYYY-MM-DD HH:mm:ss.SSS"); + const endTimeAsString = endTime.tz(tz).format("YYYY-MM-DD HH:mm:ss.SSS"); const search = Search.forPosts({ limit: 5, @@ -17,14 +19,16 @@ describe('Search', function () { users: [42, 41], communities: [9, 12], follower: 37, - term: 'milk toast', - type: 'request', + term: "milk toast", + type: "request", start_time: startTime.toDate(), end_time: endTime.toDate(), - sort: 'posts.updated_at' - }) + sort: "posts.updated_at", + }); - expectEqualQuery(search, `select posts.*, count(*) over () as total, "communities_posts"."pinned" + expectEqualQuery( + search, + `select posts.*, count(*) over () as total, "communities_posts"."pinned" from "posts" inner join "follows" on "follows"."post_id" = "posts"."id" inner join "communities_posts" on "communities_posts"."post_id" = "posts"."id" @@ -42,113 +46,152 @@ describe('Search', function () { group by "posts"."id", "communities_posts"."post_id", "communities_posts"."pinned" order by "posts"."updated_at" desc limit 5 - offset 7`) - }) + offset 7` + ); + }); - it('includes only basic post types by default', () => { - var query = Search.forPosts({communities: 9}).query().toString() - expect(query).to.contain('("posts"."type" in (\'discussion\', \'request\', \'offer\', \'resource\', \'project\', \'event\') or "posts"."type" is null)') - }) + it("includes only basic post types by default", () => { + const query = Search.forPosts({ communities: 9 }).query().toString(); + expect(query).to.contain( + "(\"posts\".\"type\" in ('discussion', 'request', 'offer', 'resource', 'project', 'event') or \"posts\".\"type\" is null)" + ); + }); it('includes only basic post types when type is "all"', () => { - var query = Search.forPosts({communities: 9, type: 'all'}).query().toString() - expect(query).to.contain('("posts"."type" in (\'discussion\', \'request\', \'offer\', \'resource\', \'project\', \'event\') or "posts"."type" is null)') - }) - - it('accepts an option to change the name of the total column', () => { - const query = Search.forPosts({totalColumnName: 'wowee'}).query().toString() - expect(query).to.contain('count(*) over () as wowee') - }) - }) - - describe('.forUsers', () => { - var cat, dog, catdog, house, mouse, mouseCommunity, network + const query = Search.forPosts({ communities: 9, type: "all" }) + .query() + .toString(); + expect(query).to.contain( + "(\"posts\".\"type\" in ('discussion', 'request', 'offer', 'resource', 'project', 'event') or \"posts\".\"type\" is null)" + ); + }); + + it("accepts an option to change the name of the total column", () => { + const query = Search.forPosts({ totalColumnName: "wowee" }) + .query() + .toString(); + expect(query).to.contain("count(*) over () as wowee"); + }); + }); + + describe(".forUsers", () => { + let cat, dog, catdog, house, mouse, mouseCommunity, network; before(() => { - cat = new User({name: 'Mister Cat', email: 'iam@cat.org', active: true}) - dog = new User({name: 'Mister Dog', email: 'iam@dog.org', active: true}) - mouse = new User({name: 'Mister Mouse', email: 'iam@mouse.org', active: true}) - catdog = new User({name: 'Cat Dog', email: 'iam@catdog.org', active: true}) - house = new Community({name: 'House', slug: 'House'}) - mouseCommunity = new Community({name: 'MouseCommunity', slug: 'MouseCommunity'}) - network = new Network({name: 'network', slug: 'network'}) - - return setup.clearDb() - .then(() => cat.save()) - .then(() => dog.save()) - .then(() => catdog.save()) - .then(() => mouse.save()) - .then(() => network.save()) - .then(() => mouseCommunity.save({network_id: network.id})) - .then(() => house.save()) - .then(() => cat.joinCommunity(house)) - .then(() => mouse.joinCommunity(mouseCommunity)) - .then(() => FullTextSearch.dropView().catch(err => {})) // eslint-disable-line handle-callback-err - .then(() => FullTextSearch.createView()) - }) - - function userSearchTests (key) { - it('finds members based on name', () => { - return Search.forUsers({[key]: 'mister'}).fetchAll().then(users => { - expect(users.length).to.equal(3) - }) - }) - - it('doesn\'t find members by letters in the middle or end of their name', () => { - return Search.forUsers({[key]: 'ister'}).fetchAll().then(users => { - expect(users.length).to.equal(0) - }) - }) - - it('finds members by the beginning letters of their first or last name', () => { - return Search.forUsers({[key]: 'Cat'}).fetchAll().then(users => { - expect(users.length).to.equal(2) - }) - }) + cat = new User({ + name: "Mister Cat", + email: "iam@cat.org", + active: true, + }); + dog = new User({ + name: "Mister Dog", + email: "iam@dog.org", + active: true, + }); + mouse = new User({ + name: "Mister Mouse", + email: "iam@mouse.org", + active: true, + }); + catdog = new User({ + name: "Cat Dog", + email: "iam@catdog.org", + active: true, + }); + house = new Community({ name: "House", slug: "House" }); + mouseCommunity = new Community({ + name: "MouseCommunity", + slug: "MouseCommunity", + }); + network = new Network({ name: "network", slug: "network" }); + + return setup + .clearDb() + .then(() => cat.save()) + .then(() => dog.save()) + .then(() => catdog.save()) + .then(() => mouse.save()) + .then(() => network.save()) + .then(() => mouseCommunity.save({ network_id: network.id })) + .then(() => house.save()) + .then(() => cat.joinCommunity(house)) + .then(() => mouse.joinCommunity(mouseCommunity)) + .then(() => FullTextSearch.dropView().catch((err) => {})) // eslint-disable-line handle-callback-err + .then(() => FullTextSearch.createView()); + }); + + function userSearchTests(key) { + it("finds members based on name", () => { + return Search.forUsers({ [key]: "mister" }) + .fetchAll() + .then((users) => { + expect(users.length).to.equal(3); + }); + }); + + it("doesn't find members by letters in the middle or end of their name", () => { + return Search.forUsers({ [key]: "ister" }) + .fetchAll() + .then((users) => { + expect(users.length).to.equal(0); + }); + }); + + it("finds members by the beginning letters of their first or last name", () => { + return Search.forUsers({ [key]: "Cat" }) + .fetchAll() + .then((users) => { + expect(users.length).to.equal(2); + }); + }); } - describe('for autocomplete', () => { - userSearchTests('autocomplete') - }) - - describe('with a term', () => { - userSearchTests('term') - }) - - describe('for a community', () => { - it('finds members', () => { - return Search.forUsers({term: 'mister', communities: [house.id]}).fetchAll() - .then(users => { - expect(users.length).to.equal(1) - expect(users.first().get('name')).to.equal('Mister Cat') - }) - }) - - it('excludes inactive members', async () => { - await cat.leaveCommunity(house) + describe("for autocomplete", () => { + userSearchTests("autocomplete"); + }); + + describe("with a term", () => { + userSearchTests("term"); + }); + + describe("for a community", () => { + it("finds members", () => { + return Search.forUsers({ term: "mister", communities: [house.id] }) + .fetchAll() + .then((users) => { + expect(users.length).to.equal(1); + expect(users.first().get("name")).to.equal("Mister Cat"); + }); + }); + + it("excludes inactive members", async () => { + await cat.leaveCommunity(house); const users = await Search.forUsers({ - term: 'mister', communities: [house.id] - }).fetchAll() - expect(users.length).to.equal(0) - }) - }) - - describe('for a network', () => { - it('finds members', () => { - return Search.forUsers({term: 'mister', network: network.id}).fetchAll() - .then(users => { - expect(users.length).to.equal(1) - expect(users.first().get('name')).to.equal('Mister Mouse') - }) - }) - - it('excludes inactive members', async () => { - await mouse.leaveCommunity(mouseCommunity) + term: "mister", + communities: [house.id], + }).fetchAll(); + expect(users.length).to.equal(0); + }); + }); + + describe("for a network", () => { + it("finds members", () => { + return Search.forUsers({ term: "mister", network: network.id }) + .fetchAll() + .then((users) => { + expect(users.length).to.equal(1); + expect(users.first().get("name")).to.equal("Mister Mouse"); + }); + }); + + it("excludes inactive members", async () => { + await mouse.leaveCommunity(mouseCommunity); const users = await Search.forUsers({ - term: 'mister', network: network.id - }).fetchAll() - expect(users.length).to.equal(0) - }) - }) - }) -}) + term: "mister", + network: network.id, + }).fetchAll(); + expect(users.length).to.equal(0); + }); + }); + }); +}); diff --git a/test/unit/services/UserManagement.test.js b/test/unit/services/UserManagement.test.js index 4a8d56ed8..386128e1e 100644 --- a/test/unit/services/UserManagement.test.js +++ b/test/unit/services/UserManagement.test.js @@ -1,49 +1,47 @@ -require('../../setup') -import factories from '../../setup/factories' -import UserManagement from '../../../api/services/UserManagement' +import factories from "../../setup/factories"; +import UserManagement from "../../../api/services/UserManagement"; +require("../../setup"); -describe('UserManagement', () => { - var user +describe("UserManagement", () => { + let user; beforeEach(() => { - user = factories.user() - return user.save() - .then(() => Device.forge({user_id: user.id}).save()) - }) + user = factories.user(); + return user.save().then(() => Device.forge({ user_id: user.id }).save()); + }); - describe('removeUser', () => { - it('works', () => { + describe("removeUser", () => { + it("works", () => { return UserManagement.removeUser(user.id) - .then(() => User.find(user.id)) - .then(user => expect(user).not.to.exist) - }) - }) + .then(() => User.find(user.id)) + .then((user) => expect(user).not.to.exist); + }); + }); - describe('mergeUsers', () => { - var user2, post + describe("mergeUsers", () => { + let user2, post; beforeEach(() => { - user2 = factories.user({bio: 'bio'}) - return user2.save() - .then(() => { - post = factories.post({user_id: user2.id}) - return post.save() - }) - }) + user2 = factories.user({ bio: "bio" }); + return user2.save().then(() => { + post = factories.post({ user_id: user2.id }); + return post.save(); + }); + }); - it('works', () => { + it("works", () => { return UserManagement.mergeUsers(user.id, user2.id) - .then(() => User.find(user2.id)) - .then(user => expect(user).not.to.exist) - .then(() => User.find(user.id, {withRelated: 'posts'})) - .then(u => { - expect(u.get('name')).to.equal(user.get('name')) - expect(u.get('bio')).to.equal(user2.get('bio')) + .then(() => User.find(user2.id)) + .then((user) => expect(user).not.to.exist) + .then(() => User.find(user.id, { withRelated: "posts" })) + .then((u) => { + expect(u.get("name")).to.equal(user.get("name")); + expect(u.get("bio")).to.equal(user2.get("bio")); - const p = u.relations.posts.first() - expect(p).to.exist - expect(p.get('name')).to.equal(post.get('name')) - }) - }) - }) -}) + const p = u.relations.posts.first(); + expect(p).to.exist; + expect(p.get("name")).to.equal(post.get("name")); + }); + }); + }); +}); diff --git a/test/unit/services/digest2.test.js b/test/unit/services/digest2.test.js index d9520f10a..56e199299 100644 --- a/test/unit/services/digest2.test.js +++ b/test/unit/services/digest2.test.js @@ -1,267 +1,269 @@ -import moment from 'moment-timezone' -import formatData from '../../../lib/community/digest2/formatData' -import personalizeData from '../../../lib/community/digest2/personalizeData' -import { defaultTimezone, shouldSendData, getRecipients } from '../../../lib/community/digest2/util' -import { sendDigest, sendAllDigests } from '../../../lib/community/digest2' -import factories from '../../setup/factories' -import { spyify, unspyify } from '../../setup/helpers' -import { merge, omit } from 'lodash' -require('../../setup') -const model = factories.mock.model -const collection = factories.mock.collection +import moment from "moment-timezone"; +import formatData from "../../../lib/community/digest2/formatData"; +import personalizeData from "../../../lib/community/digest2/personalizeData"; +import { + defaultTimezone, + shouldSendData, + getRecipients, +} from "../../../lib/community/digest2/util"; +import { sendDigest, sendAllDigests } from "../../../lib/community/digest2"; +import factories from "../../setup/factories"; +import { spyify, unspyify } from "../../setup/helpers"; +import { merge, omit } from "lodash"; +require("../../setup"); +const model = factories.mock.model; +const collection = factories.mock.collection; const u1 = model({ id: 1, - name: 'Foo', - avatar_url: 'http://google.com/foo.png' -}) + name: "Foo", + avatar_url: "http://google.com/foo.png", +}); const u2 = model({ id: 2, - name: 'Bar', - avatar_url: 'http://facebook.com/bar.png' -}) + name: "Bar", + avatar_url: "http://facebook.com/bar.png", +}); const u3 = model({ id: 3, - name: 'Baz', - avatar_url: 'http://apple.com/baz.png' -}) + name: "Baz", + avatar_url: "http://apple.com/baz.png", +}); const u4 = model({ id: 4, - name: 'Mr. Man', - avatar_url: 'http://cnn.com/man.png' -}) + name: "Mr. Man", + avatar_url: "http://cnn.com/man.png", +}); -const community = model({slug: 'foo'}) +const community = model({ slug: "foo" }); const linkPreview = model({ - id: '1', - title: 'Funny explosion video', - url: 'http://youtube.com/kapow', - image_url: 'http://img.youtube.com/vi/kapow/hqdefault.jpg', - description: "You'll never guess what happens next." -}) - -describe('community digest v2', () => { - describe('formatData', () => { - it('organizes new posts and comments', () => { + id: "1", + title: "Funny explosion video", + url: "http://youtube.com/kapow", + image_url: "http://img.youtube.com/vi/kapow/hqdefault.jpg", + description: "You'll never guess what happens next.", +}); + +describe("community digest v2", () => { + describe("formatData", () => { + it("organizes new posts and comments", () => { const data = { comments: [ model({ id: 12, - text: 'I have two!', + text: "I have two!", post_id: 5, relations: { user: u3, - post: model({id: 5, name: 'Old Post, New Comments', relations: {user: u4}}) - } + post: model({ + id: 5, + name: "Old Post, New Comments", + relations: { user: u4 }, + }), + }, }), model({ id: 13, - text: 'No, you are wrong', + text: "No, you are wrong", post_id: 8, relations: { user: u3, - post: model({id: 8, name: 'Old Post, New Comments', relations: {user: u4}}) - } + post: model({ + id: 8, + name: "Old Post, New Comments", + relations: { user: u4 }, + }), + }, }), model({ id: 13, - text: 'No, you are still wrong', + text: "No, you are still wrong", post_id: 8, relations: { user: u3, - post: model({id: 8, name: 'Old Post, New Comments', relations: {user: u4}}) - } - }) - + post: model({ + id: 8, + name: "Old Post, New Comments", + relations: { user: u4 }, + }), + }, + }), ], posts: [ model({ id: 5, - name: 'Do you have a dollar?', + name: "Do you have a dollar?", relations: { - selectedTags: collection([ - model({name: 'request'}) - ]), - user: u1 - } + selectedTags: collection([model({ name: "request" })]), + user: u1, + }, }), model({ id: 7, - name: 'Kapow!', + name: "Kapow!", relations: { selectedTags: collection([]), linkPreview, - user: u2 - } + user: u2, + }, }), model({ id: 6, - name: 'I have cookies!', + name: "I have cookies!", relations: { - selectedTags: collection([ - model({name: 'offer'}) - ]), - user: u2 - } + selectedTags: collection([model({ name: "offer" })]), + user: u2, + }, }), model({ id: 76, - name: 'An event', - type: 'event', - location: 'Home', - starts_at: new Date('December 17, 1995 18:30:00'), + name: "An event", + type: "event", + location: "Home", + starts_at: new Date("December 17, 1995 18:30:00"), relations: { - selectedTags: collection([ - model({name: 'other'}) - ]), - user: u2 - } + selectedTags: collection([model({ name: "other" })]), + user: u2, + }, }), model({ id: 77, - name: 'A project with requests', - type: 'project', + name: "A project with requests", + type: "project", relations: { - selectedTags: collection([ - model({name: 'other'}) - ]), + selectedTags: collection([model({ name: "other" })]), user: u2, children: collection([ - model({name: 'I need things'}), - model({name: 'and love'}), - model({name: 'and more things'}) - ]) - } - }) - ] - } + model({ name: "I need things" }), + model({ name: "and love" }), + model({ name: "and more things" }), + ]), + }, + }), + ], + }; const expected = { requests: [ { id: 5, - title: 'Do you have a dollar?', + title: "Do you have a dollar?", user: u1.attributes, - url: Frontend.Route.post({id: 5}, community), + url: Frontend.Route.post({ id: 5 }, community), comments: [ { id: 12, - text: 'I have two!', + text: "I have two!", user: { - avatar_url: 'http://apple.com/baz.png', + avatar_url: "http://apple.com/baz.png", id: 3, - name: 'Baz' - } - } - ] - } + name: "Baz", + }, + }, + ], + }, ], offers: [ { id: 6, - title: 'I have cookies!', + title: "I have cookies!", user: u2.attributes, - url: Frontend.Route.post({id: 6}, community), - comments: [] - } + url: Frontend.Route.post({ id: 6 }, community), + comments: [], + }, ], conversations: [ { id: 7, - title: 'Kapow!', + title: "Kapow!", user: u2.attributes, - url: Frontend.Route.post({id: 7}, community), + url: Frontend.Route.post({ id: 7 }, community), comments: [], - link_preview: omit(linkPreview.attributes, 'id') - } + link_preview: omit(linkPreview.attributes, "id"), + }, ], postsWithNewComments: [ { id: 8, - title: 'Old Post, New Comments', - url: Frontend.Route.post({id: 8}, community), + title: "Old Post, New Comments", + url: Frontend.Route.post({ id: 8 }, community), comments: [ { id: 13, - text: 'No, you are wrong', + text: "No, you are wrong", user: { - avatar_url: 'http://apple.com/baz.png', + avatar_url: "http://apple.com/baz.png", id: 3, - name: 'Baz' - } + name: "Baz", + }, }, { id: 13, - text: 'No, you are still wrong', + text: "No, you are still wrong", user: { - avatar_url: 'http://apple.com/baz.png', + avatar_url: "http://apple.com/baz.png", id: 3, - name: 'Baz' - } - } + name: "Baz", + }, + }, ], comment_count: 2, user: { - avatar_url: 'http://cnn.com/man.png', + avatar_url: "http://cnn.com/man.png", id: 4, - name: 'Mr. Man' - } - } + name: "Mr. Man", + }, + }, ], events: [ { id: 76, - title: 'An event', - location: 'Home', - when: '6pm - December 17, 1995', + title: "An event", + location: "Home", + when: "6pm - December 17, 1995", user: u2.attributes, - url: Frontend.Route.post({id: 76}, community), - comments: [] - } + url: Frontend.Route.post({ id: 76 }, community), + comments: [], + }, ], projects: [ { id: 77, - title: 'A project with requests', + title: "A project with requests", user: u2.attributes, - url: Frontend.Route.post({id: 77}, community), + url: Frontend.Route.post({ id: 77 }, community), comments: [], - requests: [ - 'I need things', - 'and love', - 'and more things' - ] - } - ] - } - - expect(formatData(community, data)).to.deep.equal(expected) - }) - - it('makes sure links are fully qualified', () => { + requests: ["I need things", "and love", "and more things"], + }, + ], + }; + + expect(formatData(community, data)).to.deep.equal(expected); + }); + + it("makes sure links are fully qualified", () => { const data = { posts: [ model({ id: 1, - name: 'Foo!', - description: '

Edward West & ' + + name: "Foo!", + description: + '

Edward West & ' + 'Julia Pope #oakland

', relations: { - selectedTags: collection([ - model({name: 'request'}) - ]), - user: u1 - } - }) + selectedTags: collection([model({ name: "request" })]), + user: u1, + }, + }), ], - comments: [] - } + comments: [], + }; - const prefix = Frontend.Route.prefix + const prefix = Frontend.Route.prefix; expect(formatData(community, data)).to.deep.equal({ offers: [], @@ -269,23 +271,24 @@ describe('community digest v2', () => { requests: [ { id: 1, - title: 'Foo!', - details: `

Edward West & ` + + title: "Foo!", + details: + `

Edward West & ` + `Julia Pope ` + `#oakland

`, user: u1.attributes, - url: Frontend.Route.post({id: 1}, community), - comments: [] - } + url: Frontend.Route.post({ id: 1 }, community), + comments: [], + }, ], postsWithNewComments: [], projects: [], - events: [] - }) - }) + events: [], + }); + }); - it('sets the no_new_activity key if there is no data', () => { - const data = {posts: [], comments: []} + it("sets the no_new_activity key if there is no data", () => { + const data = { posts: [], comments: [] }; expect(formatData(community, data)).to.deep.equal({ offers: [], @@ -294,175 +297,212 @@ describe('community digest v2', () => { postsWithNewComments: [], projects: [], events: [], - no_new_activity: true - }) - }) - }) + no_new_activity: true, + }); + }); + }); - describe('personalizeData', () => { - var user + describe("personalizeData", () => { + let user; before(() => { - user = factories.user({avatar_url: 'http://google.com/logo.png'}) - return user.save() - }) + user = factories.user({ avatar_url: "http://google.com/logo.png" }); + return user.save(); + }); - it('adds expected user-specific attributes', () => { - const { prefix } = Frontend.Route + it("adds expected user-specific attributes", () => { + const { prefix } = Frontend.Route; const data = { - community_id: '77', - community_name: 'foo', - community_url: 'https://www.hylo.com/c/foo', + community_id: "77", + community_name: "foo", + community_url: "https://www.hylo.com/c/foo", requests: [], events: [], projects: [], offers: [ { id: 1, - title: 'Hi', + title: "Hi", user: u4.attributes, comments: [], - url: 'https://www.hylo.com/p/1' - } + url: "https://www.hylo.com/p/1", + }, ], conversations: [ { id: 2, - title: 'Ya', + title: "Ya", user: u3.attributes, - details: '

foo@bar.com and ' + + details: + '

foo@bar.com and ' + `Person

`, comments: [ - {id: 3, user: user.pick('id', 'avatar_url'), text: 'Na'}, - {id: 4, user: u2.attributes, text: `Woa Bob`} + { id: 3, user: user.pick("id", "avatar_url"), text: "Na" }, + { + id: 4, + user: u2.attributes, + text: `Woa Bob`, + }, ], - url: 'https://www.hylo.com/p/2' - } - ] - } - - return personalizeData(user, data).then(newData => { - const ctParams = `?ctt=digest_email&cti=${user.id}&ctcn=foo` - expect(newData).to.deep.equal(merge({}, data, { - offers: [ - { - id: 1, - title: 'Hi', - user: u4.attributes, - reply_url: Email.postReplyAddress(1, user.id), - url: 'https://www.hylo.com/p/1' + ctParams - } - ], - conversations: [ - { - id: 2, - title: 'Ya', - user: u3.attributes, - details: '

foo@bar.com and ' + - `Person

`, - reply_url: Email.postReplyAddress(2, user.id), - url: 'https://www.hylo.com/p/2' + ctParams, - comments: [ - {id: 3, user: user.pick('id', 'avatar_url'), text: 'Na'}, - {id: 4, user: u2.attributes, text: `Woa Bob`} - ] - } - ], - recipient: { - name: user.get('name'), - avatar_url: user.get('avatar_url') + url: "https://www.hylo.com/p/2", }, - email_settings_url: Frontend.Route.userSettings() + ctParams + '&expand=account', - post_creation_action_url: Frontend.Route.emailPostForm(), - reply_action_url: Frontend.Route.emailBatchCommentForm(), - form_token: Email.formToken(77, user.id), - tracking_pixel_url: Analytics.pixelUrl('Digest', {userId: user.id, community: 'foo'}), - subject: `New activity from ${u4.name} and ${u3.name}`, - community_url: 'https://www.hylo.com/c/foo' + ctParams - })) - }) - }) - }) - - describe('shouldSendData', () => { - it('is false if the data is empty', () => { - const data = {requests: [], offers: [], conversations: []} - return shouldSendData(data).then(val => expect(val).to.be.false) - }) - - it('is true if there is some data', () => { - const data = {conversations: [{id: 'foo'}]} - return shouldSendData(data).then(val => expect(val).to.be.true) - }) + ], + }; + + return personalizeData(user, data).then((newData) => { + const ctParams = `?ctt=digest_email&cti=${user.id}&ctcn=foo`; + expect(newData).to.deep.equal( + merge({}, data, { + offers: [ + { + id: 1, + title: "Hi", + user: u4.attributes, + reply_url: Email.postReplyAddress(1, user.id), + url: "https://www.hylo.com/p/1" + ctParams, + }, + ], + conversations: [ + { + id: 2, + title: "Ya", + user: u3.attributes, + details: + '

foo@bar.com and ' + + `Person

`, + reply_url: Email.postReplyAddress(2, user.id), + url: "https://www.hylo.com/p/2" + ctParams, + comments: [ + { id: 3, user: user.pick("id", "avatar_url"), text: "Na" }, + { + id: 4, + user: u2.attributes, + text: `Woa Bob`, + }, + ], + }, + ], + recipient: { + name: user.get("name"), + avatar_url: user.get("avatar_url"), + }, + email_settings_url: + Frontend.Route.userSettings() + ctParams + "&expand=account", + post_creation_action_url: Frontend.Route.emailPostForm(), + reply_action_url: Frontend.Route.emailBatchCommentForm(), + form_token: Email.formToken(77, user.id), + tracking_pixel_url: Analytics.pixelUrl("Digest", { + userId: user.id, + community: "foo", + }), + subject: `New activity from ${u4.name} and ${u3.name}`, + community_url: "https://www.hylo.com/c/foo" + ctParams, + }) + ); + }); + }); + }); + + describe("shouldSendData", () => { + it("is false if the data is empty", () => { + const data = { requests: [], offers: [], conversations: [] }; + return shouldSendData(data).then((val) => expect(val).to.be.false); + }); + + it("is true if there is some data", () => { + const data = { conversations: [{ id: "foo" }] }; + return shouldSendData(data).then((val) => expect(val).to.be.true); + }); describe("when the community's post_prompt_day is today", () => { - var community + let community; beforeEach(() => { - community = factories.community() - community.addSetting({post_prompt_day: moment.tz(defaultTimezone).day()}) - return community.save() - }) - - it('is false -- feature disabled', () => - shouldSendData({}, community.id).then(val => expect(val).to.be.false)) - }) + community = factories.community(); + community.addSetting({ + post_prompt_day: moment.tz(defaultTimezone).day(), + }); + return community.save(); + }); + + it("is false -- feature disabled", () => + shouldSendData({}, community.id).then( + (val) => expect(val).to.be.false + )); + }); describe("when the community's post_prompt_day is not today", () => { - var community + let community; beforeEach(() => { - community = factories.community() - community.addSetting({post_prompt_day: moment.tz(defaultTimezone).day() + 1}) - return community.save() - }) - - it('is false', () => - shouldSendData({}, community.id).then(val => expect(val).to.be.false)) - }) - }) - - describe('sendAllDigests', () => { - var args, u1, u2, community, post + community = factories.community(); + community.addSetting({ + post_prompt_day: moment.tz(defaultTimezone).day() + 1, + }); + return community.save(); + }); + + it("is false", () => + shouldSendData({}, community.id).then( + (val) => expect(val).to.be.false + )); + }); + }); + + describe("sendAllDigests", () => { + let args, u1, u2, community, post; before(async () => { - spyify(Email, 'sendSimpleEmail', function () { args = arguments }) - const six = moment.tz(defaultTimezone).startOf('day').add(6, 'hours') - - u1 = await factories.user({ - active: true, - settings: {digest_frequency: 'daily'}, - avatar_url: 'av1' - }).save() - u2 = await factories.user({avatar_url: 'av2'}).save() - community = await factories.community({ - daily_digest: true, avatar_url: 'foo' - }).save() - - post = await factories.post({created_at: six, user_id: u2.id}).save() - await post.communities().attach(community.id) - await community.addGroupMembers([u1.id], { - settings: {sendEmail: true} - }) - }) - - after(() => unspyify(Email, 'sendSimpleEmail')) - - it('calls SendWithUs with expected data', function () { - this.timeout(10000) - const clickthroughParams = `?ctt=digest_email&cti=${u1.id}&ctcn=${encodeURIComponent(community.get('name'))}` + spyify(Email, "sendSimpleEmail", function () { + args = arguments; + }); + const six = moment.tz(defaultTimezone).startOf("day").add(6, "hours"); + + u1 = await factories + .user({ + active: true, + settings: { digest_frequency: "daily" }, + avatar_url: "av1", + }) + .save(); + u2 = await factories.user({ avatar_url: "av2" }).save(); + community = await factories + .community({ + daily_digest: true, + avatar_url: "foo", + }) + .save(); - return sendAllDigests('daily').then(result => { - expect(result).to.deep.equal([[community.id, 1]]) - expect(Email.sendSimpleEmail).to.have.been.called() - expect(args[0]).to.equal(u1.get('email')) + post = await factories.post({ created_at: six, user_id: u2.id }).save(); + await post.communities().attach(community.id); + await community.addGroupMembers([u1.id], { + settings: { sendEmail: true }, + }); + }); + + after(() => unspyify(Email, "sendSimpleEmail")); + + it("calls SendWithUs with expected data", function () { + this.timeout(10000); + const clickthroughParams = `?ctt=digest_email&cti=${ + u1.id + }&ctcn=${encodeURIComponent(community.get("name"))}`; + + return sendAllDigests("daily").then((result) => { + expect(result).to.deep.equal([[community.id, 1]]); + expect(Email.sendSimpleEmail).to.have.been.called(); + expect(args[0]).to.equal(u1.get("email")); expect(args[2]).to.deep.equal({ community_id: community.id, - community_name: community.get('name'), - community_avatar_url: community.get('avatar_url'), - community_url: Frontend.Route.community(community) + clickthroughParams, - time_period: 'yesterday', - subject: `New activity from ${u2.get('name')}`, + community_name: community.get("name"), + community_avatar_url: community.get("avatar_url"), + community_url: + Frontend.Route.community(community) + clickthroughParams, + time_period: "yesterday", + subject: `New activity from ${u2.get("name")}`, requests: [], offers: [], postsWithNewComments: [], @@ -471,71 +511,78 @@ describe('community digest v2', () => { conversations: [ { id: post.id, - title: post.get('name'), + title: post.get("name"), reply_url: Email.postReplyAddress(post.id, u1.id), url: Frontend.Route.post(post, community) + clickthroughParams, - user: u2.pick('id', 'avatar_url', 'name'), + user: u2.pick("id", "avatar_url", "name"), comments: [], - requests: [] - } + requests: [], + }, ], - recipient: u1.pick('avatar_url', 'name'), + recipient: u1.pick("avatar_url", "name"), post_creation_action_url: Frontend.Route.emailPostForm(), reply_action_url: Frontend.Route.emailBatchCommentForm(), form_token: Email.formToken(community.id, u1.id), - tracking_pixel_url: Analytics.pixelUrl('Digest', { + tracking_pixel_url: Analytics.pixelUrl("Digest", { userId: u1.id, - community: community.get('name'), - 'Email Version': 'v4' + community: community.get("name"), + "Email Version": "v4", }), - email_settings_url: Frontend.Route.userSettings() + clickthroughParams + '&expand=account' - }) - }) - }) - }) - - describe('sendDigest', () => { - var community + email_settings_url: + Frontend.Route.userSettings() + + clickthroughParams + + "&expand=account", + }); + }); + }); + }); + + describe("sendDigest", () => { + let community; beforeEach(() => { - community = factories.community() - return community.save() - }) + community = factories.community(); + return community.save(); + }); - describe('when there is no data and post_prompt_day matches', () => { + describe("when there is no data and post_prompt_day matches", () => { beforeEach(() => { - community.addSetting({post_prompt_day: moment.tz(defaultTimezone).day()}) - return community.save() - }) - - it('does not send -- feature disabled', () => { - return sendDigest(community.id, 'daily') - .then(result => expect(result).to.equal(false)) - }) - }) - - describe('when there is no data and post_prompt_day does not match', () => { - it('does not send', () => { - return sendDigest(community.id, 'daily') - .then(result => expect(result).to.be.false) - }) - }) - }) -}) - -describe('getRecipients', () => { - var c, uIn1, uOut1, uOut2, uOut3, uOut4, uOut5, uIn2 + community.addSetting({ + post_prompt_day: moment.tz(defaultTimezone).day(), + }); + return community.save(); + }); + + it("does not send -- feature disabled", () => { + return sendDigest(community.id, "daily").then((result) => + expect(result).to.equal(false) + ); + }); + }); + + describe("when there is no data and post_prompt_day does not match", () => { + it("does not send", () => { + return sendDigest(community.id, "daily").then( + (result) => expect(result).to.be.false + ); + }); + }); + }); +}); + +describe("getRecipients", () => { + let c, uIn1, uOut1, uOut2, uOut3, uOut4, uOut5, uIn2; before(async () => { - const settings = {digest_frequency: 'daily'} - uIn1 = factories.user({settings}) - uOut1 = factories.user({active: false, settings}) // inactive user - uOut2 = factories.user({settings}) // inactive membership - uOut3 = factories.user({settings}) // send_email = false - uOut4 = factories.user({settings: {digest_frequency: 'weekly'}}) // digest_frequency = 'weekly' - uOut5 = factories.user({settings}) // not in the community - uIn2 = factories.user({settings}) - c = factories.community() + const settings = { digest_frequency: "daily" }; + uIn1 = factories.user({ settings }); + uOut1 = factories.user({ active: false, settings }); // inactive user + uOut2 = factories.user({ settings }); // inactive membership + uOut3 = factories.user({ settings }); // send_email = false + uOut4 = factories.user({ settings: { digest_frequency: "weekly" } }); // digest_frequency = 'weekly' + uOut5 = factories.user({ settings }); // not in the community + uIn2 = factories.user({ settings }); + c = factories.community(); await Promise.join( uIn1.save(), uOut1.save(), @@ -545,22 +592,22 @@ describe('getRecipients', () => { uOut5.save(), uIn2.save(), c.save() - ) + ); await c.addGroupMembers([uIn1, uOut1, uOut2, uOut4, uIn2], { - settings: {sendEmail: true} - }) - - await c.addGroupMembers([uOut3], {settings: {sendEmail: false}}) - await c.removeGroupMembers([uOut2]) - }) - - it('only returns active members with email turned on and the right digest type', () => { - return getRecipients(c.id, 'daily') - .then(models => { - expect(models.length).to.equal(2) - expect(models.map(m => m.id).sort()) - .to.deep.equal([uIn1.id, uIn2.id].sort()) - }) - }) -}) + settings: { sendEmail: true }, + }); + + await c.addGroupMembers([uOut3], { settings: { sendEmail: false } }); + await c.removeGroupMembers([uOut2]); + }); + + it("only returns active members with email turned on and the right digest type", () => { + return getRecipients(c.id, "daily").then((models) => { + expect(models.length).to.equal(2); + expect(models.map((m) => m.id).sort()).to.deep.equal( + [uIn1.id, uIn2.id].sort() + ); + }); + }); +}); diff --git a/worker.js b/worker.js index 233881763..8488ca70c 100644 --- a/worker.js +++ b/worker.js @@ -1,13 +1,13 @@ -require('babel-register') // this must be first -const skiff = require('./lib/skiff') // this must be second -require('./config/kue') // this must be third +require("babel-register"); // this must be first +const skiff = require("./lib/skiff"); // this must be second +require("./config/kue"); // this must be third -const Promise = require('bluebird') -const lodash = require('lodash') -const rollbar = require('./lib/rollbar') -const sails = skiff.sails -const { omit, throttle } = lodash -const kue = require('kue') +const Promise = require("bluebird"); +const lodash = require("lodash"); +const rollbar = require("./lib/rollbar"); +const sails = skiff.sails; +const { omit, throttle } = lodash; +const kue = require("kue"); // define new jobs here. // each job should return a promise. @@ -17,72 +17,78 @@ const kue = require('kue') // const jobDefinitions = { test: Promise.method(function (job) { - console.log(new Date().toString().magenta) - throw new Error('whoops!') + console.log(new Date().toString().magenta); + throw new Error("whoops!"); }), classMethod: function (job) { - const { id, data, data: { className, methodName } } = job - sails.log.debug(`Job ${id}: ${className}.${methodName}`) - const fn = global[className][methodName] + const { + id, + data, + data: { className, methodName }, + } = job; + sails.log.debug(`Job ${id}: ${className}.${methodName}`); + const fn = global[className][methodName]; // we wrap the method call in a promise so that if it throws an error // immediately, e.g. if the method is not a function, the catch below will // handle it - return Promise.resolve() - .then(() => fn(omit(data, 'className', 'methodName'))) - } -} + return Promise.resolve().then(() => + fn(omit(data, "className", "methodName")) + ); + }, +}; -let queue = kue.createQueue() -queue.on('error', handleRedisError) +const queue = kue.createQueue(); +queue.on("error", handleRedisError); -function setupQueue (name, handler) { +function setupQueue(name, handler) { queue.process(name, 10, async (job, ctx, done) => { // put common behavior for all jobs here - var label = `Job ${job.id}: ` - sails.log.debug(label + name) + const label = `Job ${job.id}: `; + sails.log.debug(label + name); try { - await handler(job) - sails.log.debug(label + 'done') - done() + await handler(job); + sails.log.debug(label + "done"); + done(); } catch (err) { - const data = {jobId: job.id, jobData: job.data} - const error = typeof err === 'string' - ? new Error(err) - : (err || new Error('kue job failed without error')) - sails.log.error(label + error.message.red, error) - rollbar.error(error, null, data) - done(error) + const data = { jobId: job.id, jobData: job.data }; + const error = + typeof err === "string" + ? new Error(err) + : err || new Error("kue job failed without error"); + sails.log.error(label + error.message.red, error); + rollbar.error(error, null, data); + done(error); } - }) + }); } -const throttledLog = throttle(error => { +const throttledLog = throttle((error) => { if (rollbar.disabled) { - sails.log.error(error.message) + sails.log.error(error.message); } else { - rollbar.error(error) + rollbar.error(error); } -}, 30000) +}, 30000); -function handleRedisError (err) { - if (err && err.message && err.message.includes('Redis connection')) { - throttledLog(err) +function handleRedisError(err) { + if (err && err.message && err.message.includes("Redis connection")) { + throttledLog(err); } } setTimeout(() => { skiff.lift({ start: () => { - for (let name in jobDefinitions) { - setupQueue(name, jobDefinitions[name]) + for (const name in jobDefinitions) { + setupQueue(name, jobDefinitions[name]); } }, - stop: done => { - queue.shutdown(5000, done) - } - }) -}, Number(process.env.DELAY_START || 0) * 1000) + stop: (done) => { + queue.shutdown(5000, done); + }, + }); +}, Number(process.env.DELAY_START || 0) * 1000);