diff --git a/PLUGINS.md b/PLUGINS.md index cf1eade31..8bac10aba 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -342,6 +342,30 @@ module.exports = { } ``` +#### Field: `tags` + +The tags hook allows a plugin to define tags that are code controlled (added +or enabled by code). Below is an example pulled from the core off topic plugin +on how to create a hook for the `OFF_TOPIC` name: + +```js +[ + { + name: 'OFF_TOPIC', + permissions: { + public: true, + self: true, + roles: [] + }, + models: ['COMMENTS'], + created_at: new Date() + } +] +``` + +You can refer to `models/schema/tag.js` for the available schema to match when +creating models to enable/disable specific features. + #### Field: `passport` ```js diff --git a/bin/cli b/bin/cli index 7e27fa85b..1f3d50ad2 100755 --- a/bin/cli +++ b/bin/cli @@ -13,6 +13,7 @@ program .command('setup', 'setup the application') .command('jobs', 'work with the job queues') .command('users', 'work with the application auth') + .command('migration', 'provides utilities for migrating the database') .command('plugins', 'provides utilities for interacting with the plugin system') .parse(process.argv); diff --git a/bin/cli-migration b/bin/cli-migration new file mode 100755 index 000000000..036ac8b02 --- /dev/null +++ b/bin/cli-migration @@ -0,0 +1,101 @@ +#!/usr/bin/env node + +/** + * Module dependencies. + */ + +const program = require('./commander'); +const util = require('./util'); +const inquirer = require('inquirer'); +const mongoose = require('../services/mongoose'); +const MigrationService = require('../services/migration'); + +// Register shutdown hooks. +util.onshutdown([ + () => mongoose.disconnect() +]); + +async function createMigration(name) { + try { + + // Create the migration. + await MigrationService.create(name); + + util.shutdown(); + } catch (e) { + console.error(e); + util.shutdown(1); + } +} + +async function runMigrations() { + + try { + + let {backedUp} = await inquirer.prompt([ + { + type: 'confirm', + name: 'backedUp', + message: 'Did you perform a database backup', + default: false + } + ]); + + if (!backedUp) { + throw new Error('Please backup your databases prior to migrations occuring'); + } + + // Get the migrations to run. + let migrations = await MigrationService.listPending(); + + console.log('Now going to run the following migrations:\n'); + + for (let {filename} of migrations) { + console.log(`\tmigrations/${filename}`); + } + + let {confirm} = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirm', + message: 'Proceed with migrations', + default: false + } + ]); + + if (confirm) { + + // Run the migrations. + await MigrationService.run(migrations); + } else { + console.warn('Skipping migrations'); + } + + util.shutdown(); + } catch (e) { + console.error(e); + util.shutdown(1); + } +} + +//============================================================================== +// Setting up the program command line arguments. +//============================================================================== + +program + .command('create ') + .description('creates a new migration') + .action(createMigration); + +program + .command('run') + .description('runs all pending migrations') + .action(runMigrations); + +program.parse(process.argv); + +// If there is no command listed, output help. +if (process.argv.length <= 2) { + program.outputHelp(); + util.shutdown(); +} diff --git a/bin/cli-serve b/bin/cli-serve index 7978c6c85..f2a6d3592 100755 --- a/bin/cli-serve +++ b/bin/cli-serve @@ -2,9 +2,13 @@ const program = require('./commander'); const app = require('../app'); +const debug = require('debug')('talk:cli:serve'); +const errors = require('../errors'); const {createServer} = require('http'); const scraper = require('../services/scraper'); const mailer = require('../services/mailer'); +const MigrationService = require('../services/migration'); +const SetupService = require('../services/setup'); const kue = require('../services/kue'); const mongoose = require('../services/mongoose'); const util = require('./util'); @@ -87,7 +91,46 @@ function onListening() { /** * Start the app. */ -function startApp(program) { +async function startApp(program) { + + try { + + // Check to see if the application is installed. If the application + // has been installed, then it will throw errors.ErrSettingsNotInit, this + // just means we don't have to check that the migrations have run. + await SetupService.isAvailable(); + + debug('setup is currently available, migrations not being checked'); + + } catch (e) { + + // Check the error. + switch (e) { + case errors.ErrInstallLock, errors.ErrSettingsInit: + + debug('setup is not currently available, migrations now being checked'); + + // The error was expected, just continue. + break; + default: + + // The error was not expected, throw the error! + throw e; + } + + // Now try and check the migration status. + try { + + // Verify that the minimum migration version is met. + await MigrationService.verify(); + + } catch (e) { + console.error(e); + process.exit(1); + } + + debug('migrations do not have to be run'); + } /** * Listen on provided port, on all network interfaces. diff --git a/bin/cli-setup b/bin/cli-setup index 1d6de4090..9ecdecc95 100755 --- a/bin/cli-setup +++ b/bin/cli-setup @@ -8,6 +8,7 @@ const program = require('./commander'); const inquirer = require('inquirer'); const mongoose = require('../services/mongoose'); const SettingModel = require('../models/setting'); +const MODERATION_OPTIONS = require('../models/enum/moderation_options'); const SettingsService = require('../services/settings'); const SetupService = require('../services/setup'); const UsersService = require('../services/users'); @@ -32,166 +33,155 @@ program // Setup the application //============================================================================== -const performSetup = () => { - - if (program.defaults) { - return SettingsService - .init() - .then(() => { - console.log('Settings created.'); - console.log('\nTalk is now installed!'); - util.shutdown(); - }) - .catch((err) => { - console.error(err); - util.shutdown(1); - }); - } +const performSetup = async () => { // Get the current settings, we are expecing an error here. - return SettingsService - .retrieve() - .then(() => { + try { - // We should NOT have gotten a settings object, this means that the - // application is already setup. Error out here. - throw errors.ErrSettingsInit; + // Try to get the settings. + await SettingsService.retrieve(); - }) - .catch((err) => { + // We should NOT have gotten a settings object, this means that the + // application is already setup. Error out here. + throw errors.ErrSettingsInit; - // If the error is `not init`, then we're good, otherwise, it's something - // else. - if (err !== errors.ErrSettingsNotInit) { - throw err; + } catch (e) { + + // If the error is `not init`, then we're good, otherwise, it's something + // else. + if (e !== errors.ErrSettingsNotInit) { + throw e; + } + } + + if (program.defaults) { + await SettingsService.init(); + + console.log('Settings created.'); + console.log('\nTalk is now installed!'); + + return; + } + + // Create the base settings model. + let settings = new SettingModel(); + + console.log('\nWe\'ll ask you some questions in order to setup your installation of Talk.\n'); + + let answers = await inquirer.prompt([ + { + type: 'input', + name: 'organizationName', + message: 'Organization Name', + default: settings.organizationName, + validate: (input) => { + if (input && input.length > 0) { + return true; + } + + return 'Organization Name is required.'; } + }, + { + type: 'list', + choices: MODERATION_OPTIONS, + name: 'moderation', + default: settings.moderation, + message: 'Select a moderation mode' + }, + { + type: 'confirm', + name: 'requireEmailConfirmation', + default: settings.requireEmailConfirmation, + message: 'Should emails always be confirmed' + } + ]); - }) - .then(() => { + // Update the settings that were changed. + Object.keys(answers).forEach((key) => { + if (answers[key] !== undefined) { + settings[key] = answers[key]; + } + }); - // Create the base settings model. - let settings = new SettingModel(); + console.log('\nWe\'ll ask you some questions about your first admin user.\n'); - console.log('We\'ll ask you some questions in order to setup your installation of Talk.\n'); - - return inquirer.prompt([ - { - type: 'input', - name: 'organizationName', - message: 'Organization Name', - default: settings.organizationName, - validate: (input) => { - if (input && input.length > 0) { - return true; - } - - return 'Organization Name is required.'; - } - }, - { - type: 'list', - choices: SettingModel.MODERATION_OPTIONS, - name: 'moderation', - default: settings.moderation, - message: 'Select a moderation mode' - }, - { - type: 'confirm', - name: 'requireEmailConfirmation', - default: settings.requireEmailConfirmation, - message: 'Should emails always be confirmed' - } - ]) - .then((answers) => { - - // Update the settings that were changed. - Object.keys(answers).forEach((key) => { - if (answers[key] !== undefined) { - settings[key] = answers[key]; - } - }); - - console.log('\nWe\'ll ask you some questions about your first admin user.\n'); - - return inquirer.prompt([ - { - type: 'input', - name: 'username', - message: 'Username', - filter: (username) => { - return UsersService - .isValidUsername(username, false) - .catch((err) => { - throw err.message; - }); - } - }, - { - name: 'email', - message: 'Email', - format: 'email', - validate: (value) => { - if (value && value.length >= 3) { - return true; - } - - return 'Email is required'; - } - }, - { - name: 'password', - message: 'Password', - type: 'password', - filter: (password) => { - return UsersService - .isValidPassword(password) - .catch((err) => { - throw err.message; - }); - } - }, - { - name: 'confirmPassword', - message: 'Confirm Password', - type: 'password', - filter: (confirmPassword) => { - return UsersService - .isValidPassword(confirmPassword) - .catch((err) => { - throw err.message; - }); - } - }, - ]); - }) - .then((user) => { - - if (user.password !== user.confirmPassword) { - return Promise.reject(new Error('Passwords do not match')); + let user = await inquirer.prompt([ + { + type: 'input', + name: 'username', + message: 'Username', + filter: (username) => { + return UsersService + .isValidUsername(username, false) + .catch((err) => { + throw err.message; + }); + } + }, + { + name: 'email', + message: 'Email', + format: 'email', + validate: (value) => { + if (value && value.length >= 3) { + return true; } - return SetupService.setup({ - settings: settings.toObject(), - user: { - email: user.email, - username: user.username, - password: user.password - } - }); - }); - }) - .then(({user}) => { - console.log('Settings created.'); - console.log(`User ${user.id} created.`); - console.log('\nTalk is now installed!'); - console.log('\nWe recommend adding TALK_INSTALL_LOCK=TRUE to your environment to turn off the dynamic setup.'); - util.shutdown(); - }) - .catch((err) => { - console.error(err); - util.shutdown(1); - }); + return 'Email is required'; + } + }, + { + name: 'password', + message: 'Password', + type: 'password', + filter: (password) => { + return UsersService + .isValidPassword(password) + .catch((err) => { + throw err.message; + }); + } + }, + { + name: 'confirmPassword', + message: 'Confirm Password', + type: 'password', + filter: (confirmPassword) => { + return UsersService + .isValidPassword(confirmPassword) + .catch((err) => { + throw err.message; + }); + } + }, + ]); + + if (user.password !== user.confirmPassword) { + return Promise.reject(new Error('Passwords do not match')); + } + + let {user: newUser} = await SetupService.setup({ + settings: settings.toObject(), + user: { + email: user.email, + username: user.username, + password: user.password + } + }); + + console.log('Settings created.'); + console.log(`User ${newUser.id} created.`); + console.log('\nTalk is now installed!'); + console.log('\nWe recommend adding TALK_INSTALL_LOCK=TRUE to your environment to turn off the dynamic setup.'); }; // Start tthe setup process. -performSetup(); +performSetup() + .then(() => { + util.shutdown(); + }) + .catch((e) => { + console.error(e); + util.shutdown(1); + }); diff --git a/client/coral-admin/src/services/fragmentMatcher.js b/client/coral-admin/src/services/fragmentMatcher.js index 3b57de0b1..231c0fa2b 100644 --- a/client/coral-admin/src/services/fragmentMatcher.js +++ b/client/coral-admin/src/services/fragmentMatcher.js @@ -26,8 +26,7 @@ const fm = new IntrospectionFragmentMatcher({ {name: 'SetUserStatusResponse'}, {name: 'SuspendUserResponse'}, {name: 'SetCommentStatusResponse'}, - {name: 'AddCommentTagResponse'}, - {name: 'RemoveCommentTagResponse'}, + {name: 'ModifyTagResponse'}, {name: 'IgnoreUserResponse'}, {name: 'StopIgnoringUserResponse'} ] diff --git a/client/coral-embed-stream/src/components/Comment.js b/client/coral-embed-stream/src/components/Comment.js index b5eca4ff2..4233325c5 100644 --- a/client/coral-embed-stream/src/components/Comment.js +++ b/client/coral-embed-stream/src/components/Comment.js @@ -26,8 +26,8 @@ import {getActionSummary, iPerformedThisAction} from 'coral-framework/utils'; import {getEditableUntilDate} from './util'; import styles from './Comment.css'; -const isStaff = (tags) => !tags.every((t) => t.name !== 'STAFF'); -const hasTag = (tags, lookupTag) => !!tags.filter((tag) => tag.name === lookupTag).length; +const isStaff = (tags) => !tags.every((t) => t.tag.name !== 'STAFF'); +const hasTag = (tags, lookupTag) => !!tags.filter((t) => t.tag.name === lookupTag).length; const hasComment = (nodes, id) => nodes.some((node) => node.id === id); // resetCursors will return the id cursors of the first and second newest comment in @@ -180,10 +180,10 @@ export default class Comment extends React.Component { commentIsIgnored: React.PropTypes.func, // dispatch action to add a tag to a comment - addCommentTag: React.PropTypes.func, + addTag: React.PropTypes.func, // dispatch action to remove a tag from a comment - removeCommentTag: React.PropTypes.func, + removeTag: React.PropTypes.func, // dispatch action to ignore another user ignoreUser: React.PropTypes.func, @@ -286,11 +286,11 @@ export default class Comment extends React.Component { deleteAction, disableReply, maxCharCount, - addCommentTag, addNotification, charCountEnable, showSignInDialog, - removeCommentTag, + addTag, + removeTag, liveUpdates, commentIsIgnored, commentClassNames = [] @@ -333,18 +333,20 @@ export default class Comment extends React.Component { const addBestTag = notifyOnError( () => - addCommentTag({ + addTag({ id: comment.id, - tag: BEST_TAG + name: BEST_TAG, + assetId: asset.id }), () => 'Failed to tag comment as best' ); const removeBestTag = notifyOnError( () => - removeCommentTag({ + removeTag({ id: comment.id, - tag: BEST_TAG + name: BEST_TAG, + assetId: asset.id }), () => 'Failed to remove best comment tag' ); @@ -547,8 +549,8 @@ export default class Comment extends React.Component { currentUser={currentUser} postFlag={postFlag} deleteAction={deleteAction} - addCommentTag={addCommentTag} - removeCommentTag={removeCommentTag} + addTag={addTag} + removeTag={removeTag} ignoreUser={ignoreUser} charCountEnable={charCountEnable} maxCharCount={maxCharCount} diff --git a/client/coral-embed-stream/src/components/Stream.js b/client/coral-embed-stream/src/components/Stream.js index 586d28f23..91e696652 100644 --- a/client/coral-embed-stream/src/components/Stream.js +++ b/client/coral-embed-stream/src/components/Stream.js @@ -129,10 +129,10 @@ class Stream extends React.Component { postDontAgree, deleteAction, showSignInDialog, - addCommentTag, + addTag, ignoreUser, auth: {loggedIn, user}, - removeCommentTag, + removeTag, pluginProps, editName } = this.props; @@ -264,8 +264,8 @@ class Stream extends React.Component { currentUser={user} postFlag={postFlag} postDontAgree={postDontAgree} - addCommentTag={addCommentTag} - removeCommentTag={removeCommentTag} + addTag={addTag} + removeTag={removeTag} ignoreUser={ignoreUser} commentIsIgnored={commentIsIgnored} loadMore={this.props.loadNewReplies} @@ -298,10 +298,10 @@ Stream.propTypes = { postComment: PropTypes.func.isRequired, // dispatch action to add a tag to a comment - addCommentTag: PropTypes.func, + addTag: PropTypes.func, // dispatch action to remove a tag from a comment - removeCommentTag: PropTypes.func, + removeTag: PropTypes.func, // dispatch action to ignore another user ignoreUser: React.PropTypes.func, diff --git a/client/coral-embed-stream/src/containers/Comment.js b/client/coral-embed-stream/src/containers/Comment.js index 8122b1900..f5682bdd1 100644 --- a/client/coral-embed-stream/src/containers/Comment.js +++ b/client/coral-embed-stream/src/containers/Comment.js @@ -28,7 +28,9 @@ export default withFragments({ created_at status tags { - name + tag { + name + } } user { id diff --git a/client/coral-embed-stream/src/containers/Stream.js b/client/coral-embed-stream/src/containers/Stream.js index fcca0c053..f6e1fd9ec 100644 --- a/client/coral-embed-stream/src/containers/Stream.js +++ b/client/coral-embed-stream/src/containers/Stream.js @@ -5,7 +5,7 @@ import {bindActionCreators} from 'redux'; import {ADDTL_COMMENTS_ON_LOAD_MORE} from '../constants/stream'; import { withPostComment, withPostFlag, withPostDontAgree, withDeleteAction, - withAddCommentTag, withRemoveCommentTag, withIgnoreUser, withEditComment, + withAddTag, withRemoveTag, withIgnoreUser, withEditComment, } from 'coral-framework/graphql/mutations'; import {notificationActions, authActions} from 'coral-framework'; @@ -307,10 +307,9 @@ export default compose( withPostComment, withPostFlag, withPostDontAgree, - withAddCommentTag, - withRemoveCommentTag, + withAddTag, + withRemoveTag, withIgnoreUser, withDeleteAction, withEditComment, )(StreamContainer); - diff --git a/client/coral-embed-stream/src/graphql/index.js b/client/coral-embed-stream/src/graphql/index.js index d9d4be12e..7ca22a5f4 100644 --- a/client/coral-embed-stream/src/graphql/index.js +++ b/client/coral-embed-stream/src/graphql/index.js @@ -18,8 +18,8 @@ const extension = { } } `, - RemoveCommentTagResponse: gql` - fragment CoralEmbedStream_RemoveCommentTagResponse on RemoveCommentTagResponse { + RemoveTagResponse: gql` + fragment CoralEmbedStream_RemoveTagResponse on RemoveTagResponse { comment { id tags { @@ -28,8 +28,8 @@ const extension = { } } `, - AddCommentTagResponse: gql` - fragment CoralEmbedStream_AddCommentTagResponse on AddCommentTagResponse { + AddTagResponse: gql` + fragment CoralEmbedStream_AddTagResponse on AddTagResponse { comment { id tags { @@ -74,7 +74,13 @@ const extension = { status replyCount tags { - name + tag { + name + created_at + } + assigned_by { + id + } } user { id @@ -144,7 +150,18 @@ const extension = { parent_id, asset_id, action_summaries: [], - tags: tags.map((t) => ({name: t, __typename: 'Tag'})), + tags: tags.map((tag) => ({ + tag: { + name: tag, + created_at: new Date().toISOString(), + __typename: 'Tag' + }, + assigned_by: { + id: auth.toJS().user.id, + __typename: 'User' + }, + __typename: 'TagLink' + })), status: null, replyCount: 0, parent: parent_id diff --git a/client/coral-embed-stream/src/index.js b/client/coral-embed-stream/src/index.js index 821399488..16c510c29 100644 --- a/client/coral-embed-stream/src/index.js +++ b/client/coral-embed-stream/src/index.js @@ -19,14 +19,29 @@ loadPluginsTranslations(); injectPluginsReducers(); injectReducers(reducers); +function inIframe() { + try { + return window.self !== window.top; + } catch (e) { + return true; + } +} + +function init(config = {}) { + store.dispatch(addExternalConfig(config)); + store.dispatch(checkLogin()); +} + // Don't run this in the popup. if (!window.opener) { - pym.sendMessage('getConfig'); - - pym.onMessage('config', (config) => { - store.dispatch(addExternalConfig(JSON.parse(config))); - store.dispatch(checkLogin()); - }); + if (inIframe()) { + pym.sendMessage('getConfig'); + pym.onMessage('config', (config) => { + init(JSON.parse(config)); + }); + } else { + init(); + } } render( diff --git a/client/coral-framework/graphql/fragments.js b/client/coral-framework/graphql/fragments.js index 9c396cbaa..3c64090bd 100644 --- a/client/coral-framework/graphql/fragments.js +++ b/client/coral-framework/graphql/fragments.js @@ -1,23 +1,19 @@ -import {gql} from 'react-apollo'; -import * as mutations from './mutations'; - -function createDefaultResponseFragments() { - const names = Object.keys(mutations).map((key) => key.replace('with', '')); - const result = {}; - names.forEach((name) => { - const response = `${name}Response`; - result[response] = gql` - fragment Coral_${response} on ${response} { - errors { - translation_key - } - } - `; - }); - return result; -} +import {createDefaultResponseFragments} from '../utils'; // fragments defined here are automatically registered. export default { - ...createDefaultResponseFragments() + ...createDefaultResponseFragments( + 'SetCommentStatusResponse', + 'SuspendUserResponse', + 'RejectUsernameResponse', + 'SetUserStatusResponse', + 'PostCommentResponse', + 'EditCommentResponse', + 'PostFlagResponse', + 'CreateDontAgreeResponse', + 'DeleteActionResponse', + 'ModifyTagResponse', + 'IgnoreUserResponse', + 'StopIgnoringUserResponse', + ) }; diff --git a/client/coral-framework/graphql/mutations.js b/client/coral-framework/graphql/mutations.js index d9c3cfe45..3b9ef01f4 100644 --- a/client/coral-framework/graphql/mutations.js +++ b/client/coral-framework/graphql/mutations.js @@ -173,39 +173,93 @@ export const withDeleteAction = withMutation( }}), }); -export const withAddCommentTag = withMutation( +const COMMENT_FRAGMENT = gql` + fragment CoralBest_UpdateFragment on Comment { + tags { + tag { + name + } + } + } + `; + +export const withAddTag = withMutation( gql` - mutation AddCommentTag($id: ID!, $tag: String!) { - addCommentTag(id:$id, tag:$tag) { - ...AddCommentTagResponse + mutation AddTag($id: ID!, $asset_id: ID!, $name: String!) { + addTag(tag: {name: $name, id: $id, item_type: COMMENTS, asset_id: $asset_id}) { + ...ModifyTagResponse } } `, { props: ({mutate}) => ({ - addCommentTag: ({id, tag}) => { + addTag: ({id, name, assetId}) => { return mutate({ variables: { id, - tag - } + name, + asset_id: assetId + }, + optimisticResponse: { + addTag: { + __typename: 'ModifyTagResponse', + errors: null, + } + }, + update: (proxy) => { + const fragmentId = `Comment_${id}`; + + // Read the data from our cache for this query. + const data = proxy.readFragment({fragment: COMMENT_FRAGMENT, id: fragmentId}); + + data.tags.push({ + tag: { + __typename: 'Tag', + name: 'BEST' + }, + __typename: 'TagLink' + }); + + // Write our data back to the cache. + proxy.writeFragment({fragment: COMMENT_FRAGMENT, id: fragmentId, data}); + }, }); }}), }); -export const withRemoveCommentTag = withMutation( +export const withRemoveTag = withMutation( gql` - mutation RemoveCommentTag($id: ID!, $tag: String!) { - removeCommentTag(id:$id, tag:$tag) { - ...RemoveCommentTagResponse + mutation RemoveTag($id: ID!, $asset_id: ID!, $name: String!) { + removeTag(tag: {name: $name, id: $id, item_type: COMMENTS, asset_id: $asset_id}) { + ...ModifyTagResponse } } `, { props: ({mutate}) => ({ - removeCommentTag: ({id, tag}) => { + removeTag: ({id, name, assetId}) => { return mutate({ variables: { id, - tag + name, + asset_id: assetId + }, + optimisticResponse: { + removeTag: { + __typename: 'ModifyTagResponse', + errors: null, + } + }, + update: (proxy) => { + const fragmentId = `Comment_${id}`; + + // Read the data from our cache for this query. + const data = proxy.readFragment({fragment: COMMENT_FRAGMENT, id: fragmentId}); + + const idx = data.tags.findIndex((i) => i.tag.name === 'BEST'); + + data.tags = [...data.tags.slice(0, idx), ...data.tags.slice(idx + 1)]; + + // Write our data back to the cache. + proxy.writeFragment({fragment: COMMENT_FRAGMENT, id: fragmentId, data}); } }); }}), @@ -246,4 +300,3 @@ export const withStopIgnoringUser = withMutation( }); }}), }); - diff --git a/client/coral-framework/utils/index.js b/client/coral-framework/utils/index.js index ca2dd5ab2..0b79b7b02 100644 --- a/client/coral-framework/utils/index.js +++ b/client/coral-framework/utils/index.js @@ -112,3 +112,17 @@ export function getResponseErrors(mutationResult) { }); return result.length ? result : false; } + +export function createDefaultResponseFragments(...names) { + const result = {}; + names.forEach((response) => { + result[response] = gql` + fragment Coral_${response} on ${response} { + errors { + translation_key + } + } + `; + }); + return result; +} diff --git a/client/coral-plugin-best/BestButton.js b/client/coral-plugin-best/BestButton.js index febf26991..59204bbb8 100644 --- a/client/coral-plugin-best/BestButton.js +++ b/client/coral-plugin-best/BestButton.js @@ -7,10 +7,8 @@ import classnames from 'classnames'; // tag string for best comments export const BEST_TAG = 'BEST'; -export const commentIsBest = ({tags} = {}) => { - const isBest = Array.isArray(tags) && tags.some((t) => t.name === BEST_TAG); - return isBest; -}; + +export const commentIsBest = ({tags} = {}) => tags.some((t) => t.tag.name === BEST_TAG); const name = 'coral-plugin-best'; diff --git a/client/coral-plugin-commentbox/actions.js b/client/coral-plugin-commentbox/actions.js index 6de4c6e2c..acc84f60b 100644 --- a/client/coral-plugin-commentbox/actions.js +++ b/client/coral-plugin-commentbox/actions.js @@ -7,3 +7,7 @@ export const removeTag = (idx) => ({ type: 'REMOVE_TAG', idx }); + +export const clearTags = () => ({ + type: 'CLEAR_TAGS', +}); diff --git a/client/coral-plugin-commentbox/constants.js b/client/coral-plugin-commentbox/constants.js index b1290f02e..4a80bfe4f 100644 --- a/client/coral-plugin-commentbox/constants.js +++ b/client/coral-plugin-commentbox/constants.js @@ -1,2 +1,3 @@ export const ADD_TAG = 'ADD_TAG'; export const REMOVE_TAG = 'REMOVE_TAG'; +export const CLEAR_TAGS = 'CLEAR_TAGS'; diff --git a/client/coral-plugin-commentbox/reducer.js b/client/coral-plugin-commentbox/reducer.js index d9065fe87..e84f35e93 100644 --- a/client/coral-plugin-commentbox/reducer.js +++ b/client/coral-plugin-commentbox/reducer.js @@ -1,4 +1,4 @@ -import {ADD_TAG, REMOVE_TAG} from './constants'; +import {ADD_TAG, REMOVE_TAG, CLEAR_TAGS} from './constants'; const initialState = { tags: [] @@ -19,6 +19,8 @@ export default function commentBox (state = initialState, action) { ...state.tags.slice(action.idx + 1) ] }; + case CLEAR_TAGS : + return initialState; default : return state; } diff --git a/graph/loaders/index.js b/graph/loaders/index.js index 26727940f..8bc170c6d 100644 --- a/graph/loaders/index.js +++ b/graph/loaders/index.js @@ -6,6 +6,7 @@ const Assets = require('./assets'); const Comments = require('./comments'); const Metrics = require('./metrics'); const Settings = require('./settings'); +const Tags = require('./tags'); const Users = require('./users'); const plugins = require('../../services/plugins'); @@ -18,6 +19,7 @@ let loaders = [ Comments, Metrics, Settings, + Tags, Users, // Load the plugin loaders from the manager. diff --git a/graph/loaders/settings.js b/graph/loaders/settings.js index 77ece165f..83545821d 100644 --- a/graph/loaders/settings.js +++ b/graph/loaders/settings.js @@ -1,5 +1,4 @@ const SettingsService = require('../../services/settings'); - const util = require('./util'); /** diff --git a/graph/loaders/tags.js b/graph/loaders/tags.js new file mode 100644 index 000000000..23ab6fd43 --- /dev/null +++ b/graph/loaders/tags.js @@ -0,0 +1,22 @@ +const DataLoader = require('dataloader'); +const TagsService = require('../../services/tags'); + +/** + * Get all the tags for the context for the dataloader. + */ +const genAll = (context, queries) => { + return Promise.all(queries.map(({id, item_type, asset_id}) => { + return TagsService.getAll({id, item_type, asset_id}); + })); +}; + +/** + * Creates a set of loaders based on a GraphQL context. + * @param {Object} context the context of the GraphQL request + * @return {Object} object of loaders + */ +module.exports = (context) => ({ + Tags: { + getAll: new DataLoader((queries) => genAll(context, queries)) + } +}); diff --git a/graph/mutators/comment.js b/graph/mutators/comment.js index 88c391078..247eb264e 100644 --- a/graph/mutators/comment.js +++ b/graph/mutators/comment.js @@ -1,22 +1,70 @@ -const debug = require('debug')('talk:graph:mutators:comment'); const errors = require('../../errors'); const ActionModel = require('../../models/action'); const AssetsService = require('../../services/assets'); const ActionsService = require('../../services/actions'); +const TagsService = require('../../services/tags'); const CommentsService = require('../../services/comments'); const KarmaService = require('../../services/karma'); const linkify = require('linkify-it')(); - const Wordlist = require('../../services/wordlist'); const { CREATE_COMMENT, SET_COMMENT_STATUS, ADD_COMMENT_TAG, - REMOVE_COMMENT_TAG, EDIT_COMMENT } = require('../../perms/constants'); +const debug = require('debug')('talk:graph:mutators:tags'); +const plugins = require('../../services/plugins'); + +const pluginTags = plugins.get('server', 'tags').reduce((acc, {plugin, tags}) => { + debug(`added plugin '${plugin.name}'`); + + acc = acc.concat(tags); + + return acc; +}, []); + +const resolveTagsForComment = async ({user, loaders: {Tags}}, {asset_id, tags = []}) => { + const item_type = 'COMMENTS'; + + // Handle Tags + if (tags.length) { + + // Get the global list of tags from the dataloader. + let globalTags = await Tags.getAll.load({ + item_type, + asset_id + }); + if (!Array.isArray(globalTags)) { + globalTags = []; + } + + globalTags = globalTags.concat(pluginTags); + + // Merge in the tags for the given comment. + tags = tags.map((name) => { + + // Resolve the TagLink that we can use for the comment. + let {tagLink} = TagsService.resolveLink(user, globalTags, {name, item_type}); + + // Return the tagLink for tag insertion. + return tagLink; + }); + } + + // Add the staff tag for comments created as a staff member. + if (user.can(ADD_COMMENT_TAG)) { + tags.push(TagsService.newTagLink(user, { + name: 'STAFF', + item_type + })); + } + + return tags; +}; + /** * adjustKarma will adjust the affected user's karma depending on the moderators * action. @@ -102,15 +150,11 @@ const adjustKarma = (Comments, id, status) => async () => { * @param {String} [status='NONE'] the status of the new comment * @return {Promise} resolves to the created comment */ -const createComment = async ({user, loaders: {Comments}, pubsub}, {body, asset_id, parent_id = null, tags = []}, status = 'NONE') => { +const createComment = async (context, {tags = [], body, asset_id, parent_id = null}, status = 'NONE') => { + const {user, loaders: {Comments}, pubsub} = context; - // Building array of tags - tags = tags.map((tag) => ({name: tag})); - - // If admin or moderator, adding STAFF tag - if (user.isStaff()) { - tags.push({name: 'STAFF'}); - } + // Resolve the tags for the comment. + tags = await resolveTagsForComment(context, {asset_id, tags}); let comment = await CommentsService.publicCreate({ body, @@ -305,24 +349,6 @@ const setStatus = async ({user, loaders: {Comments}}, {id, status}) => { return comment; }; -/** - * Adds a tag to a Comment - * @param {String} id identifier of the comment (uuid) - * @param {String} tag name of the tag - */ -const addCommentTag = ({user, loaders: {Comments}}, {id, tag}) => { - return CommentsService.addTag(id, tag, user.id); -}; - -/** - * Removes a tag from a Comment - * @param {String} id identifier of the comment (uuid) - * @param {String} tag name of the tag - */ -const removeCommentTag = ({user, loaders: {Comments}}, {id, tag}) => { - return CommentsService.removeTag(id, tag); -}; - /** * Edit a Comment * @param {String} id identifier of the comment (uuid) @@ -354,9 +380,7 @@ module.exports = (context) => { Comment: { create: () => Promise.reject(errors.ErrNotAuthorized), setStatus: () => Promise.reject(errors.ErrNotAuthorized), - addCommentTag: () => Promise.reject(errors.ErrNotAuthorized), - removeCommentTag: () => Promise.reject(errors.ErrNotAuthorized), - edit: () => Promise.reject(errors.ErrNotAuthorized), + edit: () => Promise.reject(errors.ErrNotAuthorized) } }; @@ -368,14 +392,6 @@ module.exports = (context) => { mutators.Comment.setStatus = (action) => setStatus(context, action); } - if (context.user && context.user.can(ADD_COMMENT_TAG)) { - mutators.Comment.addCommentTag = (action) => addCommentTag(context, action); - } - - if (context.user && context.user.can(REMOVE_COMMENT_TAG)) { - mutators.Comment.removeCommentTag = (action) => removeCommentTag(context, action); - } - if (context.user && context.user.can(EDIT_COMMENT)) { mutators.Comment.edit = (action) => edit(context, action); } diff --git a/graph/mutators/index.js b/graph/mutators/index.js index 9975bedde..0076a5e8f 100644 --- a/graph/mutators/index.js +++ b/graph/mutators/index.js @@ -3,6 +3,7 @@ const debug = require('debug')('talk:graph:mutators'); const Comment = require('./comment'); const Action = require('./action'); +const Tag = require('./tag'); const User = require('./user'); const plugins = require('../../services/plugins'); @@ -12,6 +13,7 @@ let mutators = [ // Load in the core mutators. Comment, Action, + Tag, User, // Load the plugin mutators from the manager. diff --git a/graph/mutators/tag.js b/graph/mutators/tag.js new file mode 100644 index 000000000..dc2f96a68 --- /dev/null +++ b/graph/mutators/tag.js @@ -0,0 +1,39 @@ +const TagsService = require('../../services/tags'); +const errors = require('../../errors'); +const {ADD_COMMENT_TAG, REMOVE_COMMENT_TAG} = require('../../perms/constants'); + +/** + * Modifies the targeted model with the specified operation to add/remove a tag. + */ +const modify = async ({user, loaders: {Tags}}, operation, {name, id, item_type, asset_id}) => { + + // Get the global list of tags from the dataloader. + const tags = await Tags.getAll.load({id, item_type, asset_id}); + + // Resolve the TagLink that should be used to insert to the user. This will + // addtionally return with an ownership property that can be used to determine + // that the user who adds this tag must also be the owner of the resource. + let {tagLink, ownership} = TagsService.resolveLink(user, tags, {name, item_type}); + + // Actually modify the tag on the model. + return operation(id, item_type, tagLink, ownership); +}; + +module.exports = (context) => { + let mutators = { + Tag: { + add: () => Promise.reject(errors.ErrNotAuthorized), + remove: () => Promise.reject(errors.ErrNotAuthorized) + } + }; + + if (context.user && context.user.can(ADD_COMMENT_TAG)) { + mutators.Tag.add = (tag) => modify(context, TagsService.add, tag); + } + + if (context.user && context.user.can(REMOVE_COMMENT_TAG)) { + mutators.Tag.remove = (tag) => modify(context, TagsService.remove, tag); + } + + return mutators; +}; diff --git a/graph/resolvers/asset.js b/graph/resolvers/asset.js index 41710ba35..47e0a9541 100644 --- a/graph/resolvers/asset.js +++ b/graph/resolvers/asset.js @@ -1,3 +1,5 @@ +const {decorateWithTags} = require('./util'); + const Asset = { recentComments({id}, _, {loaders: {Comments}}) { return Comments.genRecentComments.load(id); @@ -48,4 +50,7 @@ const Asset = { } }; +// Decorate the Asset type resolver with a tags field. +decorateWithTags(Asset); + module.exports = Asset; diff --git a/graph/resolvers/comment.js b/graph/resolvers/comment.js index 33ec0ef60..0fd3a730c 100644 --- a/graph/resolvers/comment.js +++ b/graph/resolvers/comment.js @@ -1,3 +1,5 @@ +const {decorateWithTags} = require('./util'); + const Comment = { parent({parent_id}, _, {loaders: {Comments}}) { if (parent_id == null) { @@ -57,4 +59,7 @@ const Comment = { } }; +// Decorate the Comment type resolver with a tags field. +decorateWithTags(Comment); + module.exports = Comment; diff --git a/graph/resolvers/index.js b/graph/resolvers/index.js index cbeb6dbf9..f2707bcc8 100644 --- a/graph/resolvers/index.js +++ b/graph/resolvers/index.js @@ -16,6 +16,8 @@ const RootMutation = require('./root_mutation'); const RootQuery = require('./root_query'); const Settings = require('./settings'); const Subscription = require('./subscription'); +const TagLink = require('./tag_link'); +const Tag = require('./tag'); const UserError = require('./user_error'); const User = require('./user'); const ValidationUserError = require('./validation_user_error'); @@ -39,6 +41,8 @@ let resolvers = { RootQuery, Settings, Subscription, + TagLink, + Tag, UserError, User, ValidationUserError, diff --git a/graph/resolvers/root_mutation.js b/graph/resolvers/root_mutation.js index e17cdcc8a..f192405cc 100644 --- a/graph/resolvers/root_mutation.js +++ b/graph/resolvers/root_mutation.js @@ -1,5 +1,4 @@ const wrapResponse = require('../helpers/response'); -const CommentsService = require('../../services/comments'); const RootMutation = { createComment(_, {comment}, {mutators: {Comment}}) { @@ -35,12 +34,12 @@ const RootMutation = { setCommentStatus(_, {id, status}, {mutators: {Comment}}) { return wrapResponse(null)(Comment.setStatus({id, status})); }, - addCommentTag(_, {id, tag}, {mutators: {Comment}}) { - return wrapResponse('comment')(Comment.addCommentTag({id, tag}).then(() => CommentsService.findById(id))); - }, - removeCommentTag(_, {id, tag}, {mutators: {Comment}}) { - return wrapResponse('comment')(Comment.removeCommentTag({id, tag}).then(() => CommentsService.findById(id))); + addTag(_, {tag}, {mutators: {Tag}}) { + return wrapResponse(null)(Tag.add(tag)); }, + removeTag(_, {tag}, {mutators: {Tag}}) { + return wrapResponse(null)(Tag.remove(tag)); + } }; module.exports = RootMutation; diff --git a/graph/resolvers/tag.js b/graph/resolvers/tag.js new file mode 100644 index 000000000..e1cd7922d --- /dev/null +++ b/graph/resolvers/tag.js @@ -0,0 +1,5 @@ +const Tag = { + +}; + +module.exports = Tag; diff --git a/graph/resolvers/tag_link.js b/graph/resolvers/tag_link.js new file mode 100644 index 000000000..af3b92eb3 --- /dev/null +++ b/graph/resolvers/tag_link.js @@ -0,0 +1,11 @@ +const {SEARCH_OTHER_USERS} = require('../../perms/constants'); + +const TagLink = { + assigned_by({assigned_by}, _, {user, loaders: {Users}}) { + if (user && user.can(SEARCH_OTHER_USERS) && assigned_by != null) { + return Users.getByID.load(assigned_by); + } + } +}; + +module.exports = TagLink; diff --git a/graph/resolvers/user.js b/graph/resolvers/user.js index 0d8fdcbf3..5f6fc52a1 100644 --- a/graph/resolvers/user.js +++ b/graph/resolvers/user.js @@ -1,3 +1,4 @@ +const {decorateWithTags} = require('./util'); const KarmaService = require('../../services/karma'); const { SEARCH_ACTIONS, @@ -78,4 +79,7 @@ const User = { } }; +// Decorate the User type resolver with a tags field. +decorateWithTags(User); + module.exports = User; diff --git a/graph/resolvers/util.js b/graph/resolvers/util.js new file mode 100644 index 000000000..ef3c70865 --- /dev/null +++ b/graph/resolvers/util.js @@ -0,0 +1,18 @@ +const {ADD_COMMENT_TAG} = require('../../perms/constants'); + +/** + * Decorates the typeResolver with the tags field. + */ +const decorateWithTags = (typeResolver) => { + typeResolver.tags = ({tags = []}, _, {user}) => { + if (user && user.can(ADD_COMMENT_TAG)) { + return tags; + } + + return tags.filter((t) => t.tag.permissions.public); + }; +}; + +module.exports = { + decorateWithTags +}; diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index 187e5bba5..117b7e077 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -69,6 +69,9 @@ type User { # the current profiles of the user. profiles: [UserProfile] + # the tags on the user + tags: [TagLink!] + # determines whether the user can edit their username canEditName: Boolean @@ -87,17 +90,6 @@ type User { status: USER_STATUS } -type Tag { - # the actual tag for the comment. - name: String! - - # the user that assigned the tag. If NULL then the system automatically tagged it. - assigned_by: String - - # the time when the tag was assigned. - created_at: Date! -} - # UsersQuery allows the ability to query users by a specific fields. input UsersQuery { action_type: ACTION_TYPE @@ -112,6 +104,51 @@ input UsersQuery { sort: SORT_ORDER = REVERSE_CHRONOLOGICAL } + +################################################################################ +## Tags +################################################################################ + +# Used to represent the item type for a tag. +enum TAGGABLE_ITEM_TYPE { + + # The action references a entity of type Asset. + ASSETS + + # The action references a entity of type Comment. + COMMENTS + + # The action references a entity of type User. + USERS +} + +# Tag represents the underlying Tag that can be either stored in a global list +# or added uniquely to the entity. +type Tag { + + # The actual name of the tag entry. + name: String! + + # The time that this Tag was created. + created_at: Date! +} + +# TagLink is used to associate a given Tag with a Model via a TagLink. +type TagLink { + + # The underlying Tag that is either duplicated from the global list or created + # uniquely for this specific model. + tag: Tag! + + # The user that assigned the tag. This TagLink could have been created by the + # system, in which case this will be null. It could also be null if the + # current user is not an Admin/Moderator. + assigned_by: User + + # The date that the TagLink was created. + created_at: Date! +} + ################################################################################ ## Comments ################################################################################ @@ -223,7 +260,7 @@ type Comment { body: String! # the tags on the comment - tags: [Tag] + tags: [TagLink!] # the user who authored the comment. user: User @@ -507,6 +544,9 @@ type Asset { # The date that the asset was created. created_at: Date + # the tags on the asset + tags: [TagLink!] + # The author(s) of the asset. author: String } @@ -543,7 +583,7 @@ type ValidationUserError implements UserError { } ################################################################################ -## Queries; +## Queries ################################################################################ # Establishes the ordering of the content by their created_at time stamp. @@ -625,7 +665,7 @@ type RootQuery { interface Response { # An array of errors relating to the mutation that occurred. - errors: [UserError] + errors: [UserError!] } # CreateCommentResponse is returned with the comment that was created and any @@ -636,7 +676,7 @@ type CreateCommentResponse implements Response { comment: Comment # An array of errors relating to the mutation that occurred. - errors: [UserError] + errors: [UserError!] } # Used to represent the item type for an action. @@ -652,8 +692,13 @@ enum ACTION_ITEM_TYPE { USERS } -enum TAG_TYPE { - STAFF +input CreateLikeInput { + + # The item's id for which we are to create a like. + item_id: ID! + + # The type of the item for which we are to create the like. + item_type: ACTION_ITEM_TYPE! } # CreateCommentInput is the input content used to create a new comment. @@ -669,7 +714,7 @@ input CreateCommentInput { body: String! # Tags - tags: [TAG_TYPE] + tags: [String] } @@ -697,7 +742,7 @@ type CreateFlagResponse implements Response { flag: FlagAction # An array of errors relating to the mutation that occurred. - errors: [UserError] + errors: [UserError!] } @@ -710,7 +755,7 @@ type CreateDontAgreeResponse implements Response { dontagree: DontAgreeAction # An array of errors relating to the mutation that occurred. - errors: [UserError] + errors: [UserError!] } input CreateDontAgreeInput { @@ -756,7 +801,7 @@ input RejectUsernameInput { type DeleteActionResponse implements Response { # An array of errors relating to the mutation that occurred. - errors: [UserError] + errors: [UserError!] } # SetUserStatusResponse is the response returned with possibly some errors @@ -764,7 +809,7 @@ type DeleteActionResponse implements Response { type SetUserStatusResponse implements Response { # An array of errors relating to the mutation that occurred. - errors: [UserError] + errors: [UserError!] } # SuspendUserResponse is the response returned with possibly some errors @@ -772,7 +817,7 @@ type SetUserStatusResponse implements Response { type SuspendUserResponse implements Response { # An array of errors relating to the mutation that occurred. - errors: [UserError] + errors: [UserError!] } # RejectUsernameResponse is the response returned with possibly some errors @@ -780,7 +825,7 @@ type SuspendUserResponse implements Response { type RejectUsernameResponse implements Response { # An array of errors relating to the mutation that occurred. - errors: [UserError] + errors: [UserError!] } # SetCommentStatusResponse is the response returned with possibly some errors @@ -788,33 +833,43 @@ type RejectUsernameResponse implements Response { type SetCommentStatusResponse implements Response { # An array of errors relating to the mutation that occurred. - errors: [UserError] + errors: [UserError!] } -# Response to addCommentTag mutation -type AddCommentTagResponse implements Response { - # An array of errors relating to the mutation that occured. - comment: Comment - errors: [UserError] +# ModifyTagInput is the input used to modify a tag. +input ModifyTagInput { + + # name is the actual tag to add to the model. + name: String! + + # id is the ID of the model in question that we are modifying the tag of. + id: ID! + + # item_type is the type of item that we are modifying the tag if. + item_type: TAGGABLE_ITEM_TYPE! + + # asset_id is used when the item_type is `COMMENTS`, the is needed to rectify + # the settings to get the asset specific tags/settings. + asset_id: ID } -# Response to removeCommentTag mutation -type RemoveCommentTagResponse implements Response { +# Response to the addTag or removeTag mutations. +type ModifyTagResponse implements Response { + # An array of errors relating to the mutation that occured. - comment: Comment - errors: [UserError] + errors: [UserError!] } # Response to ignoreUser mutation type IgnoreUserResponse implements Response { # An array of errors relating to the mutation that occured. - errors: [UserError] + errors: [UserError!] } # Response to stopIgnoringUser mutation type StopIgnoringUserResponse implements Response { # An array of errors relating to the mutation that occured. - errors: [UserError] + errors: [UserError!] } # Input to editComment mutation. @@ -831,7 +886,7 @@ type EditCommentResponse implements Response { comment: Comment # An array of errors relating to the mutation that occured. - errors: [UserError] + errors: [UserError!] } # All mutations for the application are defined on this object. @@ -864,11 +919,11 @@ type RootMutation { # Sets Comment status. Requires the `ADMIN` role. setCommentStatus(id: ID!, status: COMMENT_STATUS!): SetCommentStatusResponse - # Add tag to comment. - addCommentTag(id: ID!, tag: String!): AddCommentTagResponse + # Add a tag. + addTag(tag: ModifyTagInput!): ModifyTagResponse! - # Remove tag from comment. - removeCommentTag(id: ID!, tag: String!): RemoveCommentTagResponse + # Removes a tag. + removeTag(tag: ModifyTagInput!): ModifyTagResponse! # Ignore comments by another user ignoreUser(id: ID!): IgnoreUserResponse diff --git a/migrations/1496771633_tags.js b/migrations/1496771633_tags.js new file mode 100644 index 000000000..be3fa2577 --- /dev/null +++ b/migrations/1496771633_tags.js @@ -0,0 +1,83 @@ +const CommentModel = require('../models/comment'); + +module.exports = { + async up() { + + // Find all comments that have tags. + let comments = await CommentModel.aggregate([ + {$match: { + tags: { + $exists: true, + $ne: [] + } + }}, + {$project: { + id: true, + tags: true + }} + ]); + + // If no comments were found, nothing needes to be done! + if (comments.length <= 0) { + return; + } + + // Create a new batch operation. + let batch = CommentModel.collection.initializeUnorderedBulkOp(); + + // Loop over the comments retrieved, updating the tag structure. + for (let {id, tags} of comments) { + + // OLD + // + // [ + // { + // name: 'OFF_TOPIC', + // assigned_by: '', + // created_at: new Date() + // } + // ] + + // NEW + // + // [ + // { + // tag: { + // name: 'OFF_TOPIC', + // permissions: { + // public: true, + // self: false, + // roles: [] + // }, + // models: ['COMMENTS'], + // created_at: new Date() + // }, + // assigned_by: '', + // created_at: new Date() + // } + // ] + + // Remap the tag structure. + tags = tags.map(({name, assigned_by, created_at}) => ({ + tag: { + name, + permissions: { + public: true, + self: name === 'OFF_TOPIC', // at the time of migration, only off topic tags were self assigning + roles: [] + }, + models: ['COMMENTS'], + created_at + }, + assigned_by, + created_at + })); + + // Execute the batch operation. + batch.find({id}).updateOne({$set: {tags}}); + } + + // Execute the batch update operation. + await batch.execute(); + } +}; diff --git a/models/action.js b/models/action.js index 36d83574e..8aa7322e3 100644 --- a/models/action.js +++ b/models/action.js @@ -1,17 +1,8 @@ const mongoose = require('../services/mongoose'); const uuid = require('uuid'); const Schema = mongoose.Schema; - -const ACTION_TYPES = [ - 'LIKE', - 'FLAG' -]; - -const ITEM_TYPES = [ - 'ASSETS', - 'COMMENTS', - 'USERS' -]; +const ACTION_TYPES = require('./enum/action_types'); +const ITEM_TYPES = require('./enum/item_types'); const ActionSchema = new Schema({ id: { diff --git a/models/asset.js b/models/asset.js index 51ff42705..1aeec34f3 100644 --- a/models/asset.js +++ b/models/asset.js @@ -1,6 +1,7 @@ const mongoose = require('../services/mongoose'); const Schema = mongoose.Schema; const uuid = require('uuid'); +const TagLinkSchema = require('./schema/tag_link'); const AssetSchema = new Schema({ id: { @@ -47,6 +48,9 @@ const AssetSchema = new Schema({ default: null }, + // Tags are added by the self or by administrators. + tags: [TagLinkSchema], + // Additional metadata stored on the field. metadata: { default: {}, diff --git a/models/comment.js b/models/comment.js index 1ffeb3fd6..352172e57 100644 --- a/models/comment.js +++ b/models/comment.js @@ -1,13 +1,8 @@ const mongoose = require('../services/mongoose'); const Schema = mongoose.Schema; +const TagLinkSchema = require('./schema/tag_link'); const uuid = require('uuid'); - -const STATUSES = [ - 'ACCEPTED', - 'REJECTED', - 'PREMOD', - 'NONE' -]; +const COMMENT_STATUS = require('./enum/comment_status'); /** * The Mongo schema for a Comment Status. @@ -16,7 +11,7 @@ const STATUSES = [ const StatusSchema = new Schema({ type: { type: String, - enum: STATUSES, + enum: COMMENT_STATUS, }, // The User ID of the user that assigned the status. @@ -30,27 +25,6 @@ const StatusSchema = new Schema({ _id: false }); -/** - * The Mongo schema for a Comment Tag. - * @type {Schema} - */ -const TagSchema = new Schema({ - name: String, - - // The User ID of the user that assigned the status. - assigned_by: { - type: String, - default: null - }, - - created_at: { - type: Date, - default: Date - } -}, { - _id: false -}); - /** * A record of old body values for a Comment */ @@ -89,12 +63,14 @@ const CommentSchema = new Schema({ status_history: [StatusSchema], status: { type: String, - enum: STATUSES, + enum: COMMENT_STATUS, default: 'NONE' }, - tags: [TagSchema], parent_id: String, + // Tags are added by the self or by administrators. + tags: [TagLinkSchema], + // Additional metadata stored on the field. metadata: { default: {}, @@ -110,6 +86,14 @@ const CommentSchema = new Schema({ }, }); +// Add the indexes for the id of the comment. +CommentSchema.index({ + 'id': 1 +}, { + unique: true, + background: false +}); + CommentSchema.virtual('edited').get(function() { return this.body_history.length > 1; }); diff --git a/models/enum/action_types.js b/models/enum/action_types.js new file mode 100644 index 000000000..3a27d3b85 --- /dev/null +++ b/models/enum/action_types.js @@ -0,0 +1,4 @@ +module.exports = [ + 'LIKE', + 'FLAG' +]; diff --git a/models/enum/comment_status.js b/models/enum/comment_status.js new file mode 100644 index 000000000..64633e9de --- /dev/null +++ b/models/enum/comment_status.js @@ -0,0 +1,6 @@ +module.exports = [ + 'ACCEPTED', + 'REJECTED', + 'PREMOD', + 'NONE' +]; diff --git a/models/enum/item_types.js b/models/enum/item_types.js new file mode 100644 index 000000000..e95aff82d --- /dev/null +++ b/models/enum/item_types.js @@ -0,0 +1,5 @@ +module.exports = [ + 'ASSETS', + 'COMMENTS', + 'USERS' +]; diff --git a/models/enum/moderation_options.js b/models/enum/moderation_options.js new file mode 100644 index 000000000..0331d59e0 --- /dev/null +++ b/models/enum/moderation_options.js @@ -0,0 +1,4 @@ +module.exports = [ + 'PRE', + 'POST' +]; diff --git a/models/enum/user_roles.js b/models/enum/user_roles.js new file mode 100644 index 000000000..098eb0960 --- /dev/null +++ b/models/enum/user_roles.js @@ -0,0 +1,5 @@ +module.exports = [ + 'ADMIN', + 'MODERATOR', + 'STAFF' +]; diff --git a/models/enum/user_status.js b/models/enum/user_status.js new file mode 100644 index 000000000..cec930904 --- /dev/null +++ b/models/enum/user_status.js @@ -0,0 +1,6 @@ +module.exports = [ + 'ACTIVE', + 'BANNED', + 'PENDING', + 'APPROVED' // Indicates that the users' username has been approved +]; diff --git a/models/migration.js b/models/migration.js new file mode 100644 index 000000000..648c5594b --- /dev/null +++ b/models/migration.js @@ -0,0 +1,10 @@ +const mongoose = require('../services/mongoose'); +const Schema = mongoose.Schema; + +const MigrationSchema = new Schema({ + version: Number +}); + +const Migration = mongoose.model('Migration', MigrationSchema); + +module.exports = Migration; diff --git a/models/schema/tag.js b/models/schema/tag.js new file mode 100644 index 000000000..2d97185a0 --- /dev/null +++ b/models/schema/tag.js @@ -0,0 +1,53 @@ +const mongoose = require('../../services/mongoose'); +const Schema = mongoose.Schema; + +const ITEM_TYPES = require('../enum/item_types'); +const USER_ROLES = require('../enum/user_roles'); + +/** + * The Mongo schema for a Tag. + * @type {Schema} + */ +const TagSchema = new Schema({ + + // The actual name of the tag. + name: String, + + // Contains permission data. + permissions: { + + // Determines if this tag is public or not. + public: { + type: Boolean, + default: true + }, + + // Determines if the owner of the Model can add/remove this tag on their own + // resources. + self: Boolean, + + // Determines other roles that are allowed to set this tag on other + // resources. + roles: [{ + type: String, + enum: USER_ROLES, + default: [] + }] + }, + + // A list of all the model types that this tag can be added to. + models: [{ + type: String, + enum: ITEM_TYPES + }], + + // The date for when the tag was created. + created_at: { + type: Date, + default: Date + } +}, { + _id: false +}); + +module.exports = TagSchema; diff --git a/models/schema/tag_link.js b/models/schema/tag_link.js new file mode 100644 index 000000000..1cc1dbd08 --- /dev/null +++ b/models/schema/tag_link.js @@ -0,0 +1,31 @@ +const mongoose = require('../../services/mongoose'); +const Schema = mongoose.Schema; +const TagSchema = require('./tag'); + +/** + * The Mongo schema for linking a Tag to a Model. + * @type {Schema} + */ +const TagLinkSchema = new Schema({ + + // A deep copy of the tag that is the origin for this link. If the ID matches + // with existing tags in the global/asset context then content will be + // substituted. + tag: TagSchema, + + // The User ID of the user that assigned the status. + assigned_by: { + type: String, + default: null + }, + + // The date when the tag was added to the model. + created_at: { + type: Date, + default: Date + } +}, { + _id: false +}); + +module.exports = TagLinkSchema; diff --git a/models/setting.js b/models/setting.js index ecd9dfd26..ca8dfa456 100644 --- a/models/setting.js +++ b/models/setting.js @@ -1,10 +1,7 @@ const mongoose = require('../services/mongoose'); const Schema = mongoose.Schema; - -const MODERATION_OPTIONS = [ - 'PRE', - 'POST' -]; +const TagSchema = require('./schema/tag'); +const MODERATION_OPTIONS = require('./enum/moderation_options'); /** * SettingSchema manages application settings that get used on front and backend. @@ -95,7 +92,8 @@ const SettingSchema = new Schema({ type: Number, min: [0, 'Edit Comment Window length must be greater than zero'], default: 30 * 1000, - } + }, + tags: [TagSchema] }, { timestamps: { createdAt: 'created_at', @@ -127,4 +125,3 @@ SettingSchema.method('merge', function(src) { const Setting = mongoose.model('Setting', SettingSchema); module.exports = Setting; -module.exports.MODERATION_OPTIONS = MODERATION_OPTIONS; diff --git a/models/user.js b/models/user.js index 27cecaef9..fb05a5e28 100644 --- a/models/user.js +++ b/models/user.js @@ -1,27 +1,20 @@ const mongoose = require('../services/mongoose'); const bcrypt = require('bcrypt'); +const Schema = mongoose.Schema; const uuid = require('uuid'); +const TagLinkSchema = require('./schema/tag_link'); const intersection = require('lodash/intersection'); const can = require('../perms'); // USER_ROLES is the array of roles that is permissible as a user role. -const USER_ROLES = [ - 'ADMIN', - 'MODERATOR', - 'STAFF' -]; +const USER_ROLES = require('./enum/user_roles'); // USER_STATUS is the list of statuses that are permitted for the user status. -const USER_STATUS = [ - 'ACTIVE', - 'BANNED', - 'PENDING', - 'APPROVED' // Indicates that the users' username has been approved -]; +const USER_STATUS = require('./enum/user_status'); // ProfileSchema is the mongoose schema defined as the representation of a // User's profile stored in MongoDB. -const ProfileSchema = new mongoose.Schema({ +const ProfileSchema = new Schema({ // ID provides the identifier for the user profile, in the case of a local // provider, the id would be an email, in the case of a social provider, @@ -44,7 +37,7 @@ const ProfileSchema = new mongoose.Schema({ // used by the `local` provider to indicate when the email address was // confirmed. metadata: { - type: mongoose.Schema.Types.Mixed + type: Schema.Types.Mixed } }, { _id: false @@ -52,7 +45,7 @@ const ProfileSchema = new mongoose.Schema({ // UserSchema is the mongoose schema defined as the representation of a User in // MongoDB. -const UserSchema = new mongoose.Schema({ +const UserSchema = new Schema({ // This ID represents the most unique identifier for a user, it is generated // when the user is created as a random uuid. @@ -136,6 +129,9 @@ const UserSchema = new mongoose.Schema({ type: String, }], + // Tags are added by the self or by administrators. + tags: [TagLinkSchema], + // Additional metadata stored on the field. metadata: { default: {}, @@ -205,5 +201,3 @@ UserSchema.method('can', function(...actions) { const UserModel = mongoose.model('User', UserSchema); module.exports = UserModel; -module.exports.USER_ROLES = USER_ROLES; -module.exports.USER_STATUS = USER_STATUS; diff --git a/package.json b/package.json index 42ab06003..d78ba8713 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,11 @@ "embed-start": "NODE_ENV=development yarn build && ./bin/cli serve --jobs", "heroku-postbuild": "./bin/cli plugins reconcile && yarn build" }, + "talk": { + "migration": { + "minVersion": 1496771633 + } + }, "config": { "pre-git": { "commit-msg": [], @@ -110,6 +115,7 @@ "resolve": "^1.3.2", "semver": "^5.3.0", "simplemde": "^1.11.2", + "snake-case": "^2.1.0", "subscriptions-transport-ws": "^0.5.5-alpha.0", "timekeeper": "^1.0.0", "uuid": "^3.0.1", diff --git a/plugins.default.json b/plugins.default.json index 2ed256e7b..42515f354 100644 --- a/plugins.default.json +++ b/plugins.default.json @@ -1,10 +1,10 @@ { "server": [ - "coral-plugin-respect", - "coral-plugin-like", - "coral-plugin-facebook-auth", "coral-plugin-auth", - "coral-plugin-offtopic" + "coral-plugin-like", + "coral-plugin-respect", + "coral-plugin-offtopic", + "coral-plugin-facebook-auth" ], "client": [ "coral-plugin-respect", diff --git a/plugins/coral-plugin-like/server/typeDefs.graphql b/plugins/coral-plugin-like/server/typeDefs.graphql index 1a1100754..40c600f2f 100644 --- a/plugins/coral-plugin-like/server/typeDefs.graphql +++ b/plugins/coral-plugin-like/server/typeDefs.graphql @@ -60,7 +60,7 @@ type CreateLikeResponse implements Response { like: LikeAction # An array of errors relating to the mutation that occurred. - errors: [UserError] + errors: [UserError!] } type RootMutation { diff --git a/plugins/coral-plugin-love/server/typeDefs.graphql b/plugins/coral-plugin-love/server/typeDefs.graphql index e22b91a2d..edc45e20b 100644 --- a/plugins/coral-plugin-love/server/typeDefs.graphql +++ b/plugins/coral-plugin-love/server/typeDefs.graphql @@ -60,7 +60,7 @@ type CreateLoveResponse implements Response { love: LoveAction # An array of errors relating to the mutation that occurred. - errors: [UserError] + errors: [UserError!] } type RootMutation { diff --git a/plugins/coral-plugin-offtopic/client/components/OffTopicTag.js b/plugins/coral-plugin-offtopic/client/components/OffTopicTag.js index e8029ce5e..0e028c6bf 100644 --- a/plugins/coral-plugin-offtopic/client/components/OffTopicTag.js +++ b/plugins/coral-plugin-offtopic/client/components/OffTopicTag.js @@ -1,11 +1,8 @@ import React from 'react'; import styles from './styles.css'; - import {t} from 'plugin-api/beta/client/services'; -const isOffTopic = (tags) => { - return !!tags.filter((tag) => tag.name === 'OFF_TOPIC').length; -}; +const isOffTopic = (tags) => !!tags.filter((t) => t.tag.name === 'OFF_TOPIC').length; export default (props) => ( diff --git a/plugins/coral-plugin-offtopic/index.js b/plugins/coral-plugin-offtopic/index.js index 51ad1c24e..eae6a2ef7 100644 --- a/plugins/coral-plugin-offtopic/index.js +++ b/plugins/coral-plugin-offtopic/index.js @@ -1,6 +1,14 @@ -const {readFileSync} = require('fs'); -const path = require('path'); - module.exports = { - typeDefs: readFileSync(path.join(__dirname, 'server/typeDefs.graphql'), 'utf8') + tags: [ + { + name: 'OFF_TOPIC', + permissions: { + public: true, + self: true, + roles: [] + }, + models: ['COMMENTS'], + created_at: new Date() + } + ] }; diff --git a/plugins/coral-plugin-offtopic/server/typeDefs.graphql b/plugins/coral-plugin-offtopic/server/typeDefs.graphql deleted file mode 100644 index 48ba577cf..000000000 --- a/plugins/coral-plugin-offtopic/server/typeDefs.graphql +++ /dev/null @@ -1,4 +0,0 @@ -## Extending TAG_TYPE by adding OFF_TOPIC Tag -enum TAG_TYPE { - OFF_TOPIC -} diff --git a/plugins/coral-plugin-respect/server/typeDefs.graphql b/plugins/coral-plugin-respect/server/typeDefs.graphql index 6639aa454..56734e543 100644 --- a/plugins/coral-plugin-respect/server/typeDefs.graphql +++ b/plugins/coral-plugin-respect/server/typeDefs.graphql @@ -44,7 +44,7 @@ type CreateRespectResponse implements Response { respect: RespectAction # An array of errors relating to the mutation that occurred. - errors: [UserError] + errors: [UserError!] } type RootMutation { diff --git a/services/cache.js b/services/cache.js index 92da6df0a..25642e1d0 100644 --- a/services/cache.js +++ b/services/cache.js @@ -1,5 +1,5 @@ const redis = require('./redis'); -const debug = require('debug')('talk:cache'); +const debug = require('debug')('talk:services:cache'); const crypto = require('crypto'); const cache = module.exports = { diff --git a/services/comments.js b/services/comments.js index def557c96..3f18d55e3 100644 --- a/services/comments.js +++ b/services/comments.js @@ -108,78 +108,6 @@ module.exports = class CommentsService { return comment; } - /** - * Adds a tag if it doesn't already exist on the comment. - * @throws if tag is already added to the comment - * @throws if tag name is not in ALLOWED_TAGS - * @param {String} id the id of the comment to tag - * @param {String} name the name of the tag to add - * @param {String} assigned_by the user id for the user who added the tag - */ - static addTag(id, name, assigned_by) { - - // Disabling allowed tags until we are able to extend them - // if (ALLOWED_TAGS.find((t) => t.name === name) == null) { - // return Promise.reject(new Error('tag not allowed')); - // } - - const filter = { - id, - 'tags.name': {$ne: name}, - }; - const update = { - $push: {tags: { - name, - assigned_by, - created_at: new Date() - }} - }; - return CommentModel.update(filter, update) - .then(({nModified}) => { - switch (nModified) { - case 0: - - // either the tag was already there, or the comment doesn't exist with that id... - throw new Error('Could not add tag to comment. Either the comment doesn\'t exist or the tag is already present.'); - case 1: - - // tag added - return; - default: - - // this should never happen because no multi parameter and unique index on id - } - }); - } - - /** - * Removes a tag from a comment - * @throws if the tag is not on the comment - * @param {String} id the id of the comment to tag - * @param {String} name the name of the tag to add - */ - static removeTag(id, name) { - const filter = { - id, - 'tags.name': name, - }; - const update = {$pull: {tags: {name}}}; - return CommentModel.update(filter, update) - .then(({nModified}) => { - switch (nModified) { - case 0: - throw new Error('Could not remove tag from comment. Either the comment doesn\'t exist or the tag is not present'); - case 1: - - // tag removed - return; - default: - - // this should never happen because no multi parameter and unique index on id - } - }); - } - /** * Finds a comment by the id. * @param {String} id identifier of comment (uuid) diff --git a/services/karma.js b/services/karma.js index ea932c86a..c76bcebb3 100644 --- a/services/karma.js +++ b/services/karma.js @@ -1,4 +1,4 @@ -const debug = require('debug')('talk:trust'); +const debug = require('debug')('talk:services:karma'); const UserModel = require('../models/user'); /** diff --git a/services/migration.js b/services/migration.js new file mode 100644 index 000000000..8676b91be --- /dev/null +++ b/services/migration.js @@ -0,0 +1,180 @@ +const MigrationModel = require('../models/migration'); +const fs = require('fs'); +const path = require('path'); +const Joi = require('joi'); +const debug = require('debug')('talk:services:migration'); +const sc = require('snake-case'); +const {talk: {migration: {minVersion}}} = require('../package.json'); + +const migrationTemplate = `module.exports = { + async up() { + + } +}; + +`; + +class MigrationService { + + /** + * Creates a new migration file. + * + * @param {String} name name of the migration + */ + static async create(name) { + if (!name || typeof name !== 'string' || name.length === 0) { + throw new Error('name must be defined'); + } + + // Create a new Migration based on the current time. + let version = Math.round(Date.now() / 1000, 0); + let filename = path.join(__dirname, '..', 'migrations', `${version}_${sc(name)}.js`); + fs.writeFileSync(filename, migrationTemplate, 'utf8'); + + console.log(`Created migration ${version} in ${filename}`); + } + + /** + * Returns a list of all pending migrations. + */ + static async listPending() { + + // Get all the migration files. + let migrationFiles = fs.readdirSync(path.join(__dirname, '..', 'migrations')); + + // Ensure that all migrations follow this format. + const migrationSchema = Joi.object({ + up: Joi.func().required(), + down: Joi.func() + }); + + // Extract the version from the filename with this regex. + const versionRe = /(\d+)_([\S_]+)\.js/; + + // Get the latest version. + let latestVersion = await MigrationService.latestVersion(); + + // Parse the migrations from the file listing. + let migrations = migrationFiles + .map((filename) => { + + // Parse the version from the filename. + let matches = filename.match(versionRe); + if (matches.length !== 3) { + return null; + } + let version = parseInt(matches[1]); + + // Don't rerun migrations from versions already ran. + if (version <= latestVersion) { + return null; + } + + // Read the migration from the filesystem. + let migration = require(`../migrations/${filename}`); + Joi.assert(migration, migrationSchema, `Migration ${filename} does did not pass validation`); + + return { + filename, + version, + migration + }; + }) + .filter((migration) => migration !== null) + .sort((a, b) => { + if (a.version < b.version) { + return -1; + } + + if (a.version > b.version) { + return 1; + } + + return 0; + }); + + return migrations; + } + + /** + * Runs an list of migrations. + * + * @param {Array} migrations a list of migrations returned by `listPending` + */ + static async run(migrations) { + if (migrations.length === 0) { + console.log('No migrations to run!'); + return; + } + + for (let {filename, version, migration} of migrations) { + try { + console.log(`Starting migration ${filename}`); + await migration.up(); + console.log(`Finished migration ${filename}`); + } catch (e) { + console.error(`Migration ${filename} failed`); + throw e; + } + + try { + console.log(`Recording migration ${filename}`); + + // Record that the migration was finished. + let m = new MigrationModel({version}); + await m.save(); + + console.log(`Finished recording migration ${filename}`); + } catch (e) { + console.error(`Migration ${filename} could not be recorded`); + throw e; + } + } + + console.log(`Database now at migration version ${migrations[migrations.length - 1].version}`); + } + + /** + * Returns the latest migration version number that has been applied to the + * database, null if none were found. + */ + static async latestVersion() { + + // Load the latest migration details from the database. + let latestMigration = await MigrationModel + .find({}) + .sort({version: -1}) + .limit(1) + .exec(); + + // If there weren't any migrations at all, then error out. + if (latestMigration.length === 0) { + return null; + } + + // If the latest migration does not match the required version, then error + // out. + return latestMigration[0].version; + } + + static async verify() { + + // If the requiredVersion isn't specified or is 0, then don't complain. + if (typeof minVersion !== 'number' || minVersion === 0) { + return; + } + + // If the latest migration does not match the required version, then error + // out. + let latestVersion = await MigrationService.latestVersion(); + if (!latestVersion || latestVersion < minVersion) { + throw new Error(`A database migration is required, version required ${minVersion}, found ${latestVersion}. Please run \`./bin/cli migration run\``); + } + + debug(`minimum migration version ${minVersion} was met with version ${latestVersion}`); + + return latestVersion; + } +} + +module.exports = MigrationService; diff --git a/services/mongoose.js b/services/mongoose.js index acda2f564..c697e38ef 100644 --- a/services/mongoose.js +++ b/services/mongoose.js @@ -59,3 +59,4 @@ require('../models/asset'); require('../models/comment'); require('../models/setting'); require('../models/user'); +require('./migration'); diff --git a/services/passport.js b/services/passport.js index d42dfa295..1d031c39c 100644 --- a/services/passport.js +++ b/services/passport.js @@ -7,7 +7,7 @@ const JWT = require('jsonwebtoken'); const LocalStrategy = require('passport-local').Strategy; const errors = require('../errors'); const uuid = require('uuid'); -const debug = require('debug')('talk:passport'); +const debug = require('debug')('talk:services:passport'); const {createClient} = require('./redis'); const bowser = require('bowser'); const ms = require('ms'); diff --git a/services/redis.js b/services/redis.js index c049b2d00..c6506eb3c 100644 --- a/services/redis.js +++ b/services/redis.js @@ -1,5 +1,5 @@ const redis = require('redis'); -const debug = require('debug')('talk:redis'); +const debug = require('debug')('talk:services:redis'); const { REDIS_URL } = require('../config'); diff --git a/services/setup.js b/services/setup.js index 233021c17..053a85789 100644 --- a/services/setup.js +++ b/services/setup.js @@ -1,5 +1,6 @@ const UsersService = require('./users'); const SettingsService = require('./settings'); +const MigrationService = require('./migration'); const SettingsModel = require('../models/setting'); const errors = require('../errors'); const { @@ -15,34 +16,32 @@ module.exports = class SetupService { /** * This returns a promise which resolves if the setup is available. */ - static isAvailable() { + static async isAvailable() { // Check if we have an install lock present. if (INSTALL_LOCK) { - return Promise.reject(errors.ErrInstallLock); + throw errors.ErrInstallLock; } - // Get the current settings, we are expecing an error here. - return SettingsService - .retrieve() - .then(() => { + try { - // We should NOT have gotten a settings object, this means that the - // application is already setup. Error out here. - return Promise.reject(errors.ErrSettingsInit); + // Get the current settings, we are expecing an error here. + await SettingsService.retrieve(); + + // We should NOT have gotten a settings object, this means that the + // application is already setup. Error out here. + throw errors.ErrSettingsInit; + } catch (e) { - }) - .catch((err) => { + // If the error is `not init`, then we're good, otherwise, it's something + // else. + if (e !== errors.ErrSettingsNotInit) { + throw e; + } - // If the error is `not init`, then we're good, otherwise, it's something - // else. - if (err !== errors.ErrSettingsNotInit) { - return Promise.reject(err); - } - - // Allow the request to keep going here. - return; - }); + // Allow the request to keep going here. + return; + } } /** @@ -69,35 +68,34 @@ module.exports = class SetupService { /** * This will perform the setup. */ - static setup({settings, user: {email, password, username}}) { + static async setup({settings, user: {email, password, username}}) { // Validate the settings first. - return SetupService - .validate({settings, user: {email, password, username}}) - .then(() => { - return SettingsService.update(settings); - }) - .then((settings) => { + await SetupService.validate({settings, user: {email, password, username}}); - // Settings are created! Create the user. + // Get the migrations to run. + let migrations = await MigrationService.listPending(); - // Create the user. - return UsersService - .createLocalUser(email, password, username) + // Perform all migrations. + await MigrationService.run(migrations); - // Grant them administrative privileges and confirm the email account. - .then((user) => { + settings = await SettingsService.update(settings); - return Promise.all([ - UsersService.addRoleToUser(user.id, 'ADMIN'), - UsersService.confirmEmail(user.id, email) - ]) - .then(() => ({ - settings, - user - })); - }); - }); + // Settings are created! Create the user. + + // Create the user. + let user = await UsersService.createLocalUser(email, password, username); + + // Grant them administrative privileges and confirm the email account. + await Promise.all([ + UsersService.addRoleToUser(user.id, 'ADMIN'), + UsersService.confirmEmail(user.id, email) + ]); + + return { + settings, + user + }; } }; diff --git a/services/tags.js b/services/tags.js new file mode 100644 index 000000000..fbe6b58ec --- /dev/null +++ b/services/tags.js @@ -0,0 +1,215 @@ +const CommentModel = require('../models/comment'); +const AssetModel = require('../models/asset'); +const UserModel = require('../models/user'); + +const AssetsService = require('./assets'); +const SettingsService = require('./settings'); +const {ADD_COMMENT_TAG} = require('../perms/constants'); + +const errors = require('../errors'); + +const updateModel = async (item_type, query, update) => { + + // Get the model to update with. + let Model; + switch (item_type) { + case 'COMMENTS': + Model = CommentModel; + break; + case 'ASSETS': + Model = AssetModel; + break; + case 'USERS': + Model = UserModel; + break; + default: + throw new Error(`item_type ${item_type} is not a valid item_type to update a tag on`); + } + + // Execute the update operation. + return Model.update(query, update); +}; + +const ownershipQuery = async (item_type, link, query) => { + switch (item_type) { + case 'COMMENTS': + query['author_id'] = link.assigned_by; + break; + case 'USERS': + query['id'] = link.assigned_by; + break; + } +}; + +class TagsService { + + /** + * Retrives a global tag from the settings based on the input_type. + */ + static async getAll({id, item_type, asset_id = null}) { + + // Extract the settings from the database. + let settings; + switch (item_type) { + case 'COMMENTS': + settings = await AssetsService.rectifySettings(AssetsService.findById(asset_id)); + break; + case 'ASSETS': + settings = await AssetsService.rectifySettings(AssetsService.findById(id)); + break; + case 'USERS': + settings = await SettingsService.retrieve(); + break; + default: + settings = await SettingsService.retrieve(); + break; + } + + // Extract the tags from the settings object. + let {tags = []} = settings; + + // Return the first tag that matches the requested form. + return tags; + } + + /** + * Resolves the tagLink and ownership verification requirements that should be + * used when trying to perform tag adding/removing operations. + */ + static resolveLink(user, tags, {name, item_type}) { + + // Try to find the tag in the global list. This will contain the permission + // information if it's found. + let tag = tags.find((tag) => { + return tag.name === name && Array.isArray(tag.models) && tag.models.includes(item_type); + }); + + // Create the new tagLink that will be created to interact to the comment. + let tagLink = { + tag, + assigned_by: user.id, + created_at: new Date() + }; + + // If the tag was found, we need to ensure that the current user can indeed + // modify this tag on the comment. + if (tag) { + + // If the tag has roles defined, and the current user has at least one of + // the required roles, then modify the tag without checking for ownership. + if (tag.permissions && tag.permissions.roles && tag.permissions.roles.some((role) => user.roles.include(role))) { + return {tagLink, ownership: false}; + } + + // If the permissions allow for self assignment, then ensure that the query + // is compose with that in mind. + if (tag.permissions && tag.permissions.self) { + + // Otherwise, we assume that we have to check to see that the user indeed + // owns the resource before allowing the tag to get modified. + return {tagLink, ownership: true}; + } + + throw errors.ErrNotAuthorized; + } + + // Only admin/moderators can modify unique tags, these are tags that are not + // in the global list. + if (!user.can(ADD_COMMENT_TAG)) { + throw errors.ErrNotAuthorized; + } + + // Generate the tag in the event now that we have to create the tag for this + // specific comment. + tagLink = TagsService.newTagLink(user, {name, item_type}); + + // Actually modify the tag on the model. + return {tagLink, ownership: false}; + } + + static newTag({name, item_type}) { + return { + name, + permissions: { + public: true, + self: false, + roles: [] + }, + models: [item_type], + created_at: new Date() + }; + } + + /** + * Creates a new TagLink based on the input user and the tag data. + */ + static newTagLink(user, tag) { + return { + tag: TagsService.newTag(tag), + assigned_by: user.id, + created_at: new Date() + }; + } + + /** + * Adds a TagLink to a Model, optionally checking for ownership. + */ + static async add(id, item_type, link, ownershipCheck) { + + // Compose the query to find the comment. + const query = { + id, + 'tags.tag.name': { + $ne: link.tag.name + } + }; + + // If ownership verification is required, ensure that the person that is + // assigning the tag is the same person that owns the comment. + if (ownershipCheck) { + + // Modify the query to support an ownership verification. + ownershipQuery(item_type, link, query); + } + + // Get the Model to perform the update. + return updateModel(item_type, query, { + $push: { + tags: link + } + }); + } + + /** + * Removes a TagLink to a Model, optionally checking for ownership. + */ + static async remove(id, item_type, link, ownershipCheck) { + + // Compose the query to find the comment. + const query = { + id, + 'tags.tag.name': { + $eq: link.tag.name + } + }; + + // If ownership verification is required, ensure that the person that is + // assigning the tag is the same person that owns the comment. + if (ownershipCheck) { + + // Modify the query to support an ownership verification. + ownershipQuery(item_type, link, query); + } + + // Get the Model to perform the update. + return updateModel(item_type, query, { + $pull: { + tags: { + name: link.tag.name + } + } + }); + } +} + +module.exports = TagsService; diff --git a/services/users.js b/services/users.js index 9bca10ea5..599ef8b33 100644 --- a/services/users.js +++ b/services/users.js @@ -15,8 +15,8 @@ const redis = require('./redis'); const redisClient = redis.createClient(); const UserModel = require('../models/user'); -const USER_STATUS = require('../models/user').USER_STATUS; -const USER_ROLES = require('../models/user').USER_ROLES; +const USER_STATUS = require('../models/enum/user_status'); +const USER_ROLES = require('../models/enum/user_roles'); const RECAPTCHA_WINDOW_SECONDS = 60 * 10; // 10 minutes. const RECAPTCHA_INCORRECT_TRIGGER = 5; // after 3 incorrect attempts, recaptcha will be required. diff --git a/test/server/graph/loaders/metrics.js b/test/server/graph/loaders/metrics.js index a24c18f79..3cc5c4565 100644 --- a/test/server/graph/loaders/metrics.js +++ b/test/server/graph/loaders/metrics.js @@ -23,11 +23,11 @@ describe('graph.loaders.Metrics', () => { describe('different comment states', () => { - beforeEach(() => CommentModel.create([ - {id: '1', body: 'a new comment!'}, - {id: '2', body: 'a new comment!'}, - {id: '3', body: 'a new comment!'} - ])); + beforeEach(() =>[ + CommentModel.create({id: '1', body: 'a new comment!'}), + CommentModel.create({id: '2', body: 'a new comment!'}), + CommentModel.create({id: '3', body: 'a new comment!'}) + ]); [ {flagged: 0, actions: []}, @@ -52,7 +52,6 @@ describe('graph.loaders.Metrics', () => { to: (new Date()).setMinutes((new Date()).getMinutes() + 5) }) .then(({data, errors}) => { - console.log(errors); expect(errors).to.be.undefined; expect(data.flagged).to.have.length(flagged); }); diff --git a/test/server/graph/mutations/addCommentTag.js b/test/server/graph/mutations/addCommentTag.js deleted file mode 100644 index 5d2a5b46c..000000000 --- a/test/server/graph/mutations/addCommentTag.js +++ /dev/null @@ -1,63 +0,0 @@ -const expect = require('chai').expect; -const {graphql} = require('graphql'); - -const schema = require('../../../../graph/schema'); -const Context = require('../../../../graph/context'); -const UserModel = require('../../../../models/user'); -const SettingsService = require('../../../../services/settings'); -const CommentsService = require('../../../../services/comments'); - -describe('graph.mutations.addCommentTag', () => { - let comment; - beforeEach(async () => { - await SettingsService.init(); - comment = await CommentsService.publicCreate({body: `hello there! ${String(Math.random()).slice(2)}`}); - }); - - const query = ` - mutation AddCommentTag ($id: ID!, $tag: String!) { - addCommentTag(id:$id, tag:$tag) { - comment { - tags { - name - } - } - errors { - translation_key - } - } - } - `; - - it('moderators can add tags to comments', async () => { - const user = new UserModel({roles: ['MODERATOR' ]}); - const context = new Context({user}); - const response = await graphql(schema, query, {}, context, {id: comment.id, tag: 'BEST'}); - if (response.errors && response.errors.length) { - console.error(response.errors); - } - expect(response.errors).to.be.empty; - expect(response.data.addCommentTag.comment.tags).to.deep.equal([{name: 'BEST'}]); - }); - - describe('users who cant add tags', () => { - Object.entries({ - 'anonymous': undefined, - 'regular commenter': new UserModel({}), - 'staff': new UserModel({roles: ['STAFF']}), - 'banned moderator': new UserModel({roles: ['MODERATOR'], status: 'BANNED'}) - }).forEach(([ userDescription, user ]) => { - it(userDescription, async function () { - const context = new Context({user}); - const response = await graphql(schema, query, {}, context, {id: comment.id, tag: 'BEST'}); - if (response.errors && response.errors.length) { - console.error(response.errors); - } - expect(response.errors).to.be.empty; - expect(response.data.addCommentTag.errors).to.deep.equal([{'translation_key':'NOT_AUTHORIZED'}]); - expect(response.data.addCommentTag.comment).to.be.null; - }); - }); - }); - -}); diff --git a/test/server/graph/mutations/addTag.js b/test/server/graph/mutations/addTag.js new file mode 100644 index 000000000..a17d83847 --- /dev/null +++ b/test/server/graph/mutations/addTag.js @@ -0,0 +1,65 @@ +const expect = require('chai').expect; +const {graphql} = require('graphql'); + +const schema = require('../../../../graph/schema'); +const Context = require('../../../../graph/context'); +const AssetModel = require('../../../../models/asset'); +const UserModel = require('../../../../models/user'); +const SettingsService = require('../../../../services/settings'); +const CommentsService = require('../../../../services/comments'); + +describe('graph.mutations.addTag', () => { + let comment, asset; + beforeEach(async () => { + await SettingsService.init(); + + asset = new AssetModel({url: 'http://new.test.com/'}); + await asset.save(); + + comment = await CommentsService.publicCreate({asset_id: asset.id, body: `hello there! ${String(Math.random()).slice(2)}`}); + }); + + const query = ` + mutation AddCommentTag ($id: ID!, $asset_id: ID!, $name: String!) { + addTag(tag: {name: $name, id: $id, item_type: COMMENTS, asset_id: $asset_id}) { + errors { + translation_key + } + } + } + `; + + it('moderators can add tags to comments', async () => { + const user = new UserModel({roles: ['MODERATOR']}); + const context = new Context({user}); + const res = await graphql(schema, query, {}, context, {id: comment.id, asset_id: asset.id, name: 'BEST'}, 'AddCommentTag'); + if (res.errors && res.errors.length) { + console.error(res.errors); + } + + console.log('res.errors', res.errors); + expect(res.errors).to.be.empty; + + let {tags} = await CommentsService.findById(comment.id); + expect(tags).to.have.length(1); + }); + + describe('users who cant add tags', () => { + Object.entries({ + 'anonymous': undefined, + 'regular commenter': new UserModel({}), + 'banned moderator': new UserModel({roles: ['MODERATOR'], status: 'BANNED'}) + }).forEach(([ userDescription, user ]) => { + it(userDescription, async () => { + const context = new Context({user}); + const res = await graphql(schema, query, {}, context, {id: comment.id, asset_id: asset.id, name: 'BEST'}, 'AddCommentTag'); + if (res.errors && res.errors.length) { + console.error(res.errors); + } + expect(res.errors).to.be.empty; + expect(res.data.addTag.errors).to.deep.equal([{'translation_key':'NOT_AUTHORIZED'}]); + }); + }); + }); + +}); diff --git a/test/server/graph/mutations/createComment.js b/test/server/graph/mutations/createComment.js index cf8481094..e32943720 100644 --- a/test/server/graph/mutations/createComment.js +++ b/test/server/graph/mutations/createComment.js @@ -3,11 +3,14 @@ const {graphql} = require('graphql'); const schema = require('../../../../graph/schema'); const Context = require('../../../../graph/context'); + const UserModel = require('../../../../models/user'); const AssetModel = require('../../../../models/asset'); -const SettingsService = require('../../../../services/settings'); const ActionModel = require('../../../../models/action'); +const SettingsService = require('../../../../services/settings'); +const CommentsService = require('../../../../services/comments'); + describe('graph.mutations.createComment', () => { beforeEach(() => SettingsService.init()); @@ -18,7 +21,9 @@ describe('graph.mutations.createComment', () => { id status tags { - name + tag { + name + } } } errors { @@ -220,11 +225,14 @@ describe('graph.mutations.createComment', () => { expect(data.createComment).to.have.property('comment').not.null; expect(data.createComment).to.have.property('errors').null; + return CommentsService.findById(data.createComment.comment.id); + }) + .then(({tags}) => { if (tag) { - expect(data.createComment.comment).to.have.property('tags').length(1); - expect(data.createComment.comment.tags[0]).to.have.property('name', tag); + expect(tags).to.have.length(1); + expect(tags[0].tag.name).to.have.equal(tag); } else { - expect(data.createComment.comment).to.have.property('tags').length(0); + expect(tags).length(0); } }); }); diff --git a/test/server/graph/mutations/removeCommentTag.js b/test/server/graph/mutations/removeTag.js similarity index 50% rename from test/server/graph/mutations/removeCommentTag.js rename to test/server/graph/mutations/removeTag.js index 2b23d28d8..a1521c2ce 100644 --- a/test/server/graph/mutations/removeCommentTag.js +++ b/test/server/graph/mutations/removeTag.js @@ -4,24 +4,27 @@ const {graphql} = require('graphql'); const schema = require('../../../../graph/schema'); const Context = require('../../../../graph/context'); const UserModel = require('../../../../models/user'); +const SettingModel = require('../../../../models/setting'); + +const AssetModel = require('../../../../models/asset'); const SettingsService = require('../../../../services/settings'); const CommentsService = require('../../../../services/comments'); +const TagsService = require('../../../../services/tags'); -describe('graph.mutations.removeCommentTag', () => { - let comment; +describe('graph.mutations.removeTag', () => { + let asset, comment; beforeEach(async () => { await SettingsService.init(); - comment = await CommentsService.publicCreate({body: `hello there! ${String(Math.random()).slice(2)}`}); + + asset = new AssetModel({url: 'http://new.test.com/'}); + await asset.save(); + + comment = await CommentsService.publicCreate({asset_id: asset.id, body: `hello there! ${String(Math.random()).slice(2)}`}); }); const query = ` - mutation RemoveCommentTag ($id: ID!, $tag: String!) { - removeCommentTag(id:$id, tag:$tag) { - comment { - tags { - name - } - } + mutation RemoveTag($id: ID!, $asset_id: ID!, $name: String!) { + removeTag(tag: {name: $name, id: $id, item_type: COMMENTS, asset_id: $asset_id}) { errors { translation_key } @@ -30,38 +33,53 @@ describe('graph.mutations.removeCommentTag', () => { `; it('moderators can add remove tags from comments', async () => { - const user = new UserModel({roles: ['MODERATOR' ]}); + const user = new UserModel({roles: ['MODERATOR']}); const context = new Context({user}); // add a tag first - await CommentsService.addTag(comment.id, 'BEST'); - const response = await graphql(schema, query, {}, context, {id: comment.id, tag: 'BEST'}); + await TagsService.add(comment.id, 'COMMENTS', {tag: {name: 'BEST'}}, false); + + const response = await graphql(schema, query, {}, context, {id: comment.id, asset_id: asset.id, name: 'BEST'}); if (response.errors && response.errors.length) { console.error(response.errors); } expect(response.errors).to.be.empty; - expect(response.data.removeCommentTag.errors).to.be.null; - expect(response.data.removeCommentTag.comment.tags).to.deep.equal([]); + expect(response.data.removeTag.errors).to.be.null; + + let retrievedComment = await CommentsService.findById(comment.id); + + expect(retrievedComment.tags).to.have.length(0); }); describe('users who cant remove tags', () => { + + before(() => SettingModel.findOneAndUpdate({id: 1}, { + $push: { + tags: { + id: 'BEST', + models: ['COMMENTS'] + } + } + })); + Object.entries({ 'anonymous': undefined, 'regular commenter': new UserModel({}), 'banned moderator': new UserModel({roles: ['MODERATOR'], status: 'BANNED'}) - }).forEach(([ userDescription, user ]) => { + }).forEach(([userDescription, user]) => { it(userDescription, async function () { const context = new Context({user}); // add a tag first - await CommentsService.addTag(comment.id, 'BEST'); - const response = await graphql(schema, query, {}, context, {id: comment.id, tag: 'BEST'}); + await TagsService.add(comment.id, 'COMMENTS', {tag: {name: 'BEST'}}, false); + + const response = await graphql(schema, query, {}, context, {id: comment.id, asset_id: asset.id, name: 'BEST'}); if (response.errors && response.errors.length) { console.error(response.errors); } expect(response.errors).to.be.empty; - expect(response.data.removeCommentTag.errors).to.deep.equal([{'translation_key':'NOT_AUTHORIZED'}]); - expect(response.data.removeCommentTag.comment).to.be.null; + + expect(response.data.removeTag.errors).to.deep.equal([{'translation_key':'NOT_AUTHORIZED'}]); }); }); }); diff --git a/test/server/services/comments.js b/test/server/services/comments.js index 8d7d5585f..36e77b3ea 100644 --- a/test/server/services/comments.js +++ b/test/server/services/comments.js @@ -219,64 +219,6 @@ describe('services.CommentsService', () => { }); - describe('#addTag', () => { - it('adds a tag', async () => { - const commentId = comments[0].id; - const tagName = 'BEST'; - const userId = users[0].id; - await CommentsService.addTag(commentId, tagName, userId); - const updatedComment = await CommentsService.findById(commentId); - expect(updatedComment.tags.length).to.equal(1); - expect(updatedComment.tags[0].name).to.equal(tagName); - expect(updatedComment.tags[0].assigned_by).to.equal(userId); - expect(updatedComment.tags[0].created_at).to.be.an.instanceof(Date); - }); - it('can\'t add a tag to comment id that doesn\'t exist', async () => { - const commentId = 'fakenews'; - const tagName = 'BEST'; - const userId = users[0].id; - await expect(CommentsService.addTag(commentId, tagName, userId)).to.be.rejected; - }); - it('can\'t add same tag.name twice', async () => { - const commentId = comments[0].id; - const tagName = 'BEST'; - const userId = users[0].id; - - // first time - await CommentsService.addTag(commentId, tagName, userId); - - // second time should fail - await expect(CommentsService.addTag(commentId, tagName, userId)).to.be.rejected; - }); - }); - - describe('#removeTag', () => { - it('removes a tag', async () => { - const commentId = comments[0].id; - const tagName = 'BEST'; - await CommentsService.addTag(commentId, tagName, users[0].id); - const updatedComment = await CommentsService.findById(commentId); - expect(updatedComment.tags.length).to.equal(1); - - // ok now to remove it - await CommentsService.removeTag(commentId, tagName); - const updatedComment2 = await CommentsService.findById(commentId); - expect(updatedComment2.tags.length).to.equal(0); - }); - it('throws if removing a tag that isn\'t there', async () => { - const commentId = comments[0].id; - - // just make sure it has no tags to start - const updatedComment2 = await CommentsService.findById(commentId); - expect(updatedComment2.tags.length).to.equal(0); - - const tagName = 'BEST'; - - // ok now to remove it - await expect(CommentsService.removeTag(commentId, tagName)).to.be.rejected; - }); - }); - describe('#changeStatus', () => { it('should change the status of a comment from no status', () => { diff --git a/test/server/services/tags.js b/test/server/services/tags.js new file mode 100644 index 000000000..7cdad829e --- /dev/null +++ b/test/server/services/tags.js @@ -0,0 +1,107 @@ +const CommentsService = require('../../../services/comments'); +const TagsService = require('../../../services/tags'); +const UsersService = require('../../../services/users'); +const SettingsService = require('../../../services/settings'); + +const CommentModel = require('../../../models/comment'); + +const expect = require('chai').use(require('chai-as-promised')).expect; + +describe('services.TagsService', () => { + let comment, user; + beforeEach(async () => { + await SettingsService.init(); + user = await UsersService.createLocalUser('stampi@gmail.com', '1Coral!!', 'Stampi'); + comment = await CommentModel.create({ + id: '1', + body: 'comment 10', + asset_id: '123', + status_history: [], + parent_id: null, + author_id: user.id + }); + }); + + describe('#add', () => { + it('adds a tag', async () => { + const id = comment.id; + const name = 'BEST'; + const assigned_by = user.id; + + await TagsService.add(id, 'COMMENTS', { + tag: { + name + }, + assigned_by + }); + + const {tags} = await CommentsService.findById(id); + expect(tags.length).to.equal(1); + expect(tags[0].tag.name).to.equal(name); + expect(tags[0].assigned_by).to.equal(assigned_by); + }); + + it('can\'t add same tag.id twice', async () => { + const id = comment.id; + const name = 'BEST'; + const assigned_by = user.id; + + await TagsService.add(id, 'COMMENTS', { + tag: { + name + }, + assigned_by + }); + + { + let {tags} = await CommentsService.findById(id); + expect(tags.length).to.equal(1); + } + + await TagsService.add(id, 'COMMENTS', { + tag: { + name + }, + assigned_by + }); + + { + let {tags} = await CommentsService.findById(id); + expect(tags.length).to.equal(1); + } + }); + }); + + describe('#remove', () => { + it('removes a tag', async () => { + const id = comment.id; + const name = 'BEST'; + const assigned_by = user.id; + + await TagsService.add(id, 'COMMENTS', { + tag: { + name + }, + assigned_by + }); + + { + const {tags} = await CommentsService.findById(id); + expect(tags.length).to.equal(1); + } + + // ok now to remove it + await TagsService.remove(id, 'COMMENTS', { + tag: { + name + }, + assigned_by + }); + + { + const {tags} = await CommentsService.findById(id); + expect(tags.length).to.equal(0); + } + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 4242faec0..7fcee99c5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5060,6 +5060,10 @@ loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0: dependencies: js-tokens "^3.0.0" +lower-case@^1.1.1: + version "1.1.4" + resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-1.1.4.tgz#9a2cabd1b9e8e0ae993a4bf7d5875c39c42e8eac" + lowercase-keys@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.0.tgz#4e3366b39e7f5457e35f1324bdf6f88d0bfc7306" @@ -5445,6 +5449,12 @@ nightwatch@^0.9.11: proxy-agent "2.0.0" q "1.4.1" +no-case@^2.2.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/no-case/-/no-case-2.3.1.tgz#7aeba1c73a52184265554b7dc03baf720df80081" + dependencies: + lower-case "^1.1.1" + nocache@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/nocache/-/nocache-2.0.0.tgz#202b48021a0c4cbde2df80de15a17443c8b43980" @@ -7565,6 +7575,12 @@ smtp-connection@2.12.0: httpntlm "1.6.1" nodemailer-shared "1.1.0" +snake-case@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/snake-case/-/snake-case-2.1.0.tgz#41bdb1b73f30ec66a04d4e2cad1b76387d4d6d9f" + dependencies: + no-case "^2.2.0" + sntp@1.x.x: version "1.0.9" resolved "https://registry.yarnpkg.com/sntp/-/sntp-1.0.9.tgz#6541184cc90aeea6c6e7b35e2659082443c66198"