merge master

This commit is contained in:
riley
2017-06-09 15:27:44 -06:00
94 changed files with 1880 additions and 749 deletions
+24
View File
@@ -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
+4
View File
@@ -44,6 +44,10 @@ sign and verify tokens via a `HS256` algorithm.
Refer to the wiki page on [Configuration Loading](https://github.com/coralproject/talk/wiki/Configuration-Loading) for
alternative methods of loading configuration during development.
### Running Migrations
We have a migration tool that can be run using `bin/cli migration run`. This will detect new migrations available and prompt you to backup your database before proceeding with the migration. Migrations are required with major version releases.
### Using Trust
Talk ships with core components we call "Trust". This allows Talk to automate certain actions based on previous user behavior.
+1
View File
@@ -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);
+101
View File
@@ -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 <name>')
.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();
}
+44 -1
View File
@@ -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.
+139 -149
View File
@@ -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);
});
@@ -26,8 +26,7 @@ const fm = new IntrospectionFragmentMatcher({
{name: 'SetUserStatusResponse'},
{name: 'SuspendUserResponse'},
{name: 'SetCommentStatusResponse'},
{name: 'AddCommentTagResponse'},
{name: 'RemoveCommentTagResponse'},
{name: 'ModifyTagResponse'},
{name: 'IgnoreUserResponse'},
{name: 'StopIgnoringUserResponse'}
]
@@ -7,6 +7,7 @@ import Content from 'coral-plugin-commentcontent/CommentContent';
import PubDate from 'coral-plugin-pubdate/PubDate';
import {ReplyBox, ReplyButton} from 'coral-plugin-replies';
import FlagComment from 'coral-plugin-flags/FlagComment';
import {can} from 'coral-framework/services/perms';
import {TransitionGroup} from 'react-transition-group';
import cn from 'classnames';
@@ -26,8 +27,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 +181,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,
@@ -219,6 +220,18 @@ export default class Comment extends React.Component {
this.setState(resetCursors);
};
showReplyBox = () => {
if (!this.props.currentUser) {
this.props.showSignInDialog();
return;
}
if (can(this.props.currentUser, 'INTERACT_WITH_COMMUNITY')) {
this.props.setActiveReplyBox(this.props.comment.id);
return;
}
return;
}
// getVisibileReplies returns a list containing comments
// which were authored by current user or comes before the `idCursor`.
getVisibileReplies() {
@@ -286,11 +299,11 @@ export default class Comment extends React.Component {
deleteAction,
disableReply,
maxCharCount,
addCommentTag,
addNotification,
charCountEnable,
showSignInDialog,
removeCommentTag,
addTag,
removeTag,
liveUpdates,
commentIsIgnored,
commentClassNames = []
@@ -333,18 +346,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'
);
@@ -477,10 +492,9 @@ export default class Comment extends React.Component {
{!disableReply &&
<ActionButton>
<ReplyButton
onClick={() => setActiveReplyBox(comment.id)}
onClick={this.showReplyBox}
parentCommentId={parentId || comment.id}
currentUserId={currentUser && currentUser.id}
banned={false}
/>
</ActionButton>}
<Slot
@@ -547,8 +561,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}
@@ -129,10 +129,10 @@ class Stream extends React.Component {
postDontAgree,
deleteAction,
showSignInDialog,
addCommentTag,
addTag,
ignoreUser,
auth: {loggedIn, user},
removeCommentTag,
removeTag,
pluginProps,
editName
} = this.props;
@@ -223,6 +223,7 @@ class Stream extends React.Component {
activeReplyBox={this.props.activeReplyBox}
addNotification={addNotification}
depth={0}
disableReply={!open}
postComment={this.props.postComment}
asset={asset}
currentUser={user}
@@ -264,8 +265,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 +299,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,
@@ -41,7 +41,10 @@ class SuspendedAccount extends Component {
<span>{
canEditName ?
t('framework.edit_name.msg')
: t('framework.banned_account_msg')
:
<span>
<b>{t('framework.banned_account_header')}</b><br/> {t('framework.banned_account_body')}
</span>
}</span>
{
canEditName ?
@@ -28,7 +28,9 @@ export default withFragments({
created_at
status
tags {
name
tag {
name
}
}
user {
id
@@ -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);
+23 -6
View File
@@ -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
+21 -6
View File
@@ -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(
@@ -1,4 +1,5 @@
import * as actions from '../constants/stream';
import * as authActions from 'coral-framework/constants/auth';
function getQueryVariable(variable) {
let query = window.location.search.substring(1);
@@ -16,7 +17,6 @@ function getQueryVariable(variable) {
const initialState = {
activeReplyBox: '',
commentCountCache: -1,
assetId: getQueryVariable('asset_id'),
assetUrl: getQueryVariable('asset_url'),
commentId: getQueryVariable('comment_id'),
@@ -25,16 +25,16 @@ const initialState = {
export default function stream(state = initialState, action) {
switch (action.type) {
case authActions.LOGOUT:
return {
...state,
activeReplyBox: '',
};
case actions.SET_ACTIVE_REPLY_BOX:
return {
...state,
activeReplyBox: action.id,
};
case actions.SET_COMMENT_COUNT_CACHE:
return {
...state,
commentCountCache: action.amount,
};
case actions.VIEW_ALL_COMMENTS:
return {
...state,
+8 -9
View File
@@ -119,7 +119,7 @@ hr {
.coral-plugin-questionbox-info {
top: 0;
border: 0;
background: rgb(213,213,213);
background: #F0F0F0;
color: black;
width: 100%;
text-align: left;
@@ -134,26 +134,25 @@ hr {
.coral-plugin-questionbox-icon.bubble{
position: absolute;
top: 11px;
top: 8px;
left: 10px;
color: #949393;
font-size: 20px;
color: #9E9E9E;
font-size: 24px;
z-index: 0;
}
.coral-plugin-questionbox-icon.person{
z-index: 2;
top: 20px;
left: 15px;
top: 12px;
left: 12px;
position: absolute;
font-size: 24px;
color: white;
font-size: 33px;
color: #262626;
}
.coral-plugin-questionbox-box {
position: relative;
border: 0;
background: black;
color: white;
padding: 20px;
margin-left: 0px !important;
+9
View File
@@ -4,6 +4,7 @@ import * as actions from '../constants/auth';
import * as Storage from '../helpers/storage';
import coralApi, {base} from '../helpers/request';
import {resetWebsocket} from 'coral-framework/services/client';
import t from 'coral-framework/services/i18n';
export const showSignInDialog = () => (dispatch, getState) => {
@@ -117,6 +118,7 @@ const signInFailure = (error) => ({
export const handleAuthToken = (token) => (dispatch) => {
Storage.setItem('exp', jwtDecode(token).exp);
Storage.setItem('token', token);
dispatch({type: 'HANDLE_AUTH_TOKEN'});
};
@@ -277,6 +279,10 @@ export const logout = () => (dispatch) => {
if (!bowser.safari && !bowser.ios) {
Storage.removeItem('token');
}
// Reset the websocket.
resetWebsocket();
dispatch({type: actions.LOGOUT});
});
};
@@ -307,6 +313,9 @@ export const checkLogin = () => (dispatch) => {
dispatch(checkLoginSuccess(result.user));
// Reset the websocket.
resetWebsocket();
// Display create username dialog if necessary.
if (result.user.canEditName && result.user.status !== 'BANNED') {
dispatch(showCreateUsernameDialog());
@@ -1,6 +1,8 @@
.message {
background: #D8D8D8;
padding: 25px;
margin-bottom: 8px;
background-color: white;
border: 1px #F0F0F0 solid;
text-align: center;
font-weight: 100;
}
+15 -19
View File
@@ -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',
)
};
+67 -14
View File
@@ -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(
});
}}),
});
+5 -8
View File
@@ -208,18 +208,15 @@ export default (reaction) => (WrappedComponent) => {
}
);
const mapStateToProps = (state) => ({
user: state.auth.toJS().user,
});
const mapDispatchToProps = (dispatch) =>
bindActionCreators({showSignInDialog}, dispatch);
const enhance = compose(
withFragments({
root: gql`
fragment ${capitalize(reaction)}Button_root on RootQuery {
me {
status
}
}
`,
comment: gql`
fragment ${capitalize(reaction)}Button_comment on Comment {
action_summaries {
@@ -232,7 +229,7 @@ export default (reaction) => (WrappedComponent) => {
}
}`
}),
connect(null, mapDispatchToProps),
connect(mapStateToProps, mapDispatchToProps),
withDeleteReaction,
withPostReaction
);
+39 -3
View File
@@ -1,8 +1,31 @@
import ApolloClient, {addTypename} from 'apollo-client';
import {networkInterface} from './transport';
import {SubscriptionClient, addGraphQLSubscriptions} from 'subscriptions-transport-ws';
import {SUBSCRIPTION_END} from 'subscriptions-transport-ws/dist/messageTypes';
import {getAuthToken} from '../helpers/request';
let client;
let client, wsClient = null, wsClientToken = null;
export function resetWebsocket() {
if (wsClient === null) {
// Nothing to reset!
return;
}
// Unsubscribe from all the active subscriptions.
Object.keys(wsClient.subscriptions).forEach((id) => {
// Create the message.
let message = {id: parseInt(id), type: SUBSCRIPTION_END};
// Send the unsubscribe message.
wsClient.client.send(JSON.stringify(message));
});
// Close the client, this will trigger a reconnect.
wsClient.close();
}
export function getClient() {
if (client) {
@@ -10,8 +33,21 @@ export function getClient() {
}
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
const wsClient = new SubscriptionClient(`${protocol}://${location.host}/api/v1/live`, {
reconnect: true
wsClient = new SubscriptionClient(`${protocol}://${location.host}/api/v1/live`, {
reconnect: true,
connectionParams: {
get token() {
wsClientToken = getAuthToken();
// Try to get the token from localStorage. If it isn't here, it may
// be passed as a cookie.
// NOTE: THIS IS ONLY EVER EVALUATED ONCE, IN ORDER TO SEND A DIFFERNT
// TOKEN YOU MUST DISCONNECT AND RECONNECT THE WEBSOCKET CLIENT.
return wsClientToken;
}
}
});
const networkInterfaceWithSubscriptions = addGraphQLSubscriptions(
+6
View File
@@ -23,7 +23,13 @@ export const can = (user, ...perms) => {
return false;
}
const banned = user.status === 'BANNED';
const suspended = user.suspension.until && new Date(user.suspension.until) > new Date();
return perms.every((perm) => {
if (perm === 'INTERACT_WITH_COMMUNITY') {
return !banned && !suspended;
}
const role = roles[perm];
if (typeof role === 'undefined') {
throw new Error(`${perm} is not a valid role`);
+14
View File
@@ -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;
}
+2 -4
View File
@@ -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';
@@ -7,3 +7,7 @@ export const removeTag = (idx) => ({
type: 'REMOVE_TAG',
idx
});
export const clearTags = () => ({
type: 'CLEAR_TAGS',
});
@@ -1,2 +1,3 @@
export const ADD_TAG = 'ADD_TAG';
export const REMOVE_TAG = 'REMOVE_TAG';
export const CLEAR_TAGS = 'CLEAR_TAGS';
+3 -1
View File
@@ -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;
}
+7 -4
View File
@@ -1,6 +1,7 @@
import React, {Component} from 'react';
import t from 'coral-framework/services/i18n';
import {can} from 'coral-framework/services/perms';
import {PopupMenu, Button} from 'coral-ui';
import onClickOutside from 'react-onclickoutside';
@@ -32,10 +33,12 @@ class FlagButton extends Component {
this.props.showSignInDialog();
return;
}
if (this.state.showMenu) {
this.closeMenu();
} else {
this.setState({showMenu: true});
if (can(currentUser, 'INTERACT_WITH_COMMUNITY')) {
if (this.state.showMenu) {
this.closeMenu();
} else {
this.setState({showMenu: true});
}
}
}
+5 -2
View File
@@ -9,6 +9,10 @@ class ReplyBox extends Component {
document.getElementById('replyText').focus();
}
cancelReply = () => {
this.props.setActiveReplyBox('');
};
render() {
const {
styles,
@@ -18,7 +22,6 @@ class ReplyBox extends Component {
addNotification,
parentId,
commentPostedHandler,
setActiveReplyBox,
maxCharCount,
charCountEnable
} = this.props;
@@ -28,7 +31,7 @@ class ReplyBox extends Component {
charCountEnable={charCountEnable}
commentPostedHandler={commentPostedHandler}
parentId={parentId}
cancelButtonClicked={setActiveReplyBox}
cancelButtonClicked={this.cancelReply}
addNotification={addNotification}
authorId={authorId}
assetId={assetId}
+2 -3
View File
@@ -6,21 +6,20 @@ import classnames from 'classnames';
const name = 'coral-plugin-replies';
const ReplyButton = ({banned, onClick}) => {
const ReplyButton = ({onClick}) => {
return (
<button
className={classnames(`${name}-reply-button`)}
onClick={onClick}>
{t('reply')}
<i className={`${name}-icon material-icons`}
aria-hidden={true}>{banned ? 'BANNED' : 'reply'}</i>
aria-hidden={true}>reply</i>
</button>
);
};
ReplyButton.propTypes = {
onClick: PropTypes.func.isRequired,
banned: PropTypes.bool.isRequired
};
export default ReplyButton;
+2
View File
@@ -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.
-1
View File
@@ -1,5 +1,4 @@
const SettingsService = require('../../services/settings');
const util = require('./util');
/**
+22
View File
@@ -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))
}
});
+56 -40
View File
@@ -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);
}
+2
View File
@@ -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.
+39
View File
@@ -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;
};
+5
View File
@@ -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;
+5
View File
@@ -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;
+4
View File
@@ -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,
+5 -6
View File
@@ -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;
+5
View File
@@ -0,0 +1,5 @@
const Tag = {
};
module.exports = Tag;
+11
View File
@@ -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;
+4
View File
@@ -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;
+18
View File
@@ -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
};
+23 -5
View File
@@ -41,16 +41,34 @@ const createSubscriptionManager = (server) => new SubscriptionServer({
pubsub,
setupFunctions,
}),
onConnect: ({token}, connection) => {
// Attach the token from the connection options if it was provided.
if (token) {
// Attach it to the upgrade request.
connection.upgradeReq.headers['authorization'] = `Bearer ${token}`;
}
},
onSubscribe: (parsedMessage, baseParams, connection) => {
// Cache the upgrade request.
let upgradeReq = connection.upgradeReq;
// Attach the context per request.
baseParams.context = () => deserializeUser(connection.upgradeReq)
.then((req) => new Context(req, pubsub))
.catch((err) => {
console.error(err);
baseParams.context = async () => {
let req;
try {
req = await deserializeUser(upgradeReq);
} catch (e) {
console.error(e);
return new Context({}, pubsub);
});
}
return new Context(req, pubsub);
};
return baseParams;
}
+95 -41
View File
@@ -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
@@ -120,6 +112,50 @@ input AssetsQuery {
# Limit the number of results to be returned
limit: Int = 10
################################################################################
## 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!
>>>>>>> master
}
################################################################################
@@ -233,7 +269,7 @@ type Comment {
body: String!
# the tags on the comment
tags: [Tag]
tags: [TagLink!]
# the user who authored the comment.
user: User
@@ -517,6 +553,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
}
@@ -553,7 +592,7 @@ type ValidationUserError implements UserError {
}
################################################################################
## Queries;
## Queries
################################################################################
# Establishes the ordering of the content by their created_at time stamp.
@@ -635,7 +674,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
@@ -646,7 +685,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.
@@ -662,8 +701,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.
@@ -679,7 +723,7 @@ input CreateCommentInput {
body: String!
# Tags
tags: [TAG_TYPE]
tags: [String]
}
@@ -707,7 +751,7 @@ type CreateFlagResponse implements Response {
flag: FlagAction
# An array of errors relating to the mutation that occurred.
errors: [UserError]
errors: [UserError!]
}
@@ -720,7 +764,7 @@ type CreateDontAgreeResponse implements Response {
dontagree: DontAgreeAction
# An array of errors relating to the mutation that occurred.
errors: [UserError]
errors: [UserError!]
}
input CreateDontAgreeInput {
@@ -766,7 +810,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
@@ -774,7 +818,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
@@ -782,7 +826,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
@@ -790,7 +834,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
@@ -798,33 +842,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.
@@ -841,7 +895,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.
@@ -874,11 +928,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
+5 -4
View File
@@ -87,7 +87,7 @@ en:
custom_css_url_desc: "URL of a CSS stylesheet that will override default Embed Stream styles. Can be internal or external."
dashboard: Dashboard
days: Days
description: "As an admin you may customize the settings for the comment stream for this asset"
description: "As an admin, you can customize the settings for the comment stream for this story:"
domain_list_text: "Enter the domains you would like to permit for Talk e.g. your local staging and production environments (ex. localhost:3000 staging.domain.com domain.com)."
domain_list_title: "Permitted Domains"
edit_comment_timeframe_heading: "Edit Comment Timeframe"
@@ -101,13 +101,13 @@ en:
enable_premod: "Enable Premoderation"
enable_premod_description: "Moderators must approve any comment before its published."
enable_premod_links_description: "Moderators must approve any comment containing a link before its published."
enable_questionbox: "Ask readers a question"
enable_questionbox: "Ask Readers a Question"
enable_questionbox_description: "This question will appear at the top of this comment stream. Ask readers about a certain issue in the article or pose discussion questions etc."
hours: Hours
include_comment_stream: "Include Comment Stream Description for Readers"
include_comment_stream_desc: "Write a message to be added to the top of your comment stream. Pose a topic include community guidelines etc."
include_text: "Include your text here."
include_question_here: "Write your question here."
include_question_here: "Write your question here:"
moderate: Moderate
moderation_settings: "Moderation Settings"
open: "Open"
@@ -202,7 +202,8 @@ en:
flag_reason: "Reason for reporting (Optional)"
flag_username: "Report username"
framework:
banned_account_msg: "Your account is currently banned. This means that you cannot Like, Report, or write comments. Please contact us if you have any questions."
banned_account_header: "Your account is currently banned."
banned_account_body: "This means that you cannot Like, Report, or write comments."
because_you_ignored: "Because you ignored the following commenters, their comments are hidden."
comment: comment
comment_is_ignored: "This comment is hidden because you ignored this user."
+2 -1
View File
@@ -202,7 +202,8 @@ es:
flag_reason: "Razón por la que hacer este reporte (Opcional)"
flag_username: "Reportar el nombre de usuario"
framework:
banned_account_msg: "Tu cuenta se encuentra suspendida. Esto significa que no puedes gustar, marcar o escribir comentarios."
banned_account_header: "Tu cuenta se encuentra suspendida."
banned_account_body: "Esto significa que no puedes gustar, marcar o escribir comentarios."
comment: "comentario"
comment_is_ignored: "Este comentario está escondido porque has ignorado al usuario."
comments: "comentarios"
+83
View File
@@ -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();
}
};
+2 -11
View File
@@ -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: {
+4
View File
@@ -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: {},
+15 -31
View File
@@ -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;
});
+4
View File
@@ -0,0 +1,4 @@
module.exports = [
'LIKE',
'FLAG'
];
+6
View File
@@ -0,0 +1,6 @@
module.exports = [
'ACCEPTED',
'REJECTED',
'PREMOD',
'NONE'
];
+5
View File
@@ -0,0 +1,5 @@
module.exports = [
'ASSETS',
'COMMENTS',
'USERS'
];
+4
View File
@@ -0,0 +1,4 @@
module.exports = [
'PRE',
'POST'
];
+5
View File
@@ -0,0 +1,5 @@
module.exports = [
'ADMIN',
'MODERATOR',
'STAFF'
];
+6
View File
@@ -0,0 +1,6 @@
module.exports = [
'ACTIVE',
'BANNED',
'PENDING',
'APPROVED' // Indicates that the users' username has been approved
];
+10
View File
@@ -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;
+53
View File
@@ -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;
+31
View File
@@ -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;
+4 -7
View File
@@ -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;
+10 -16
View File
@@ -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;
+7 -1
View File
@@ -1,6 +1,6 @@
{
"name": "talk",
"version": "1.8.0",
"version": "1.9.1",
"description": "A better commenting experience from Mozilla, The New York Times, and the Washington Post. https://coralproject.net",
"main": "app.js",
"scripts": {
@@ -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",
+1
View File
@@ -1 +1,2 @@
export {t} from 'coral-framework/services/i18n';
export {can} from 'coral-framework/services/perms';
+7 -4
View File
@@ -1,13 +1,16 @@
{
"server": [
"coral-plugin-respect",
"coral-plugin-auth",
"coral-plugin-like",
"coral-plugin-facebook-auth",
"coral-plugin-auth"
"coral-plugin-respect",
"coral-plugin-offtopic",
"coral-plugin-facebook-auth"
],
"client": [
"coral-plugin-respect",
"coral-plugin-like",
"coral-plugin-auth"
"coral-plugin-auth",
"coral-plugin-offtopic",
"coral-plugin-viewing-options"
]
}
@@ -30,7 +30,6 @@ export const FakeComment = ({username, created_at, body}) => (
onClick={() => {}}
parentCommentId={'commentID'}
currentUserId={{}}
banned={false}
/>
</div>
<div className="commentActionsRight comment__action-container">
@@ -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 {
@@ -1,8 +1,8 @@
import React from 'react';
import {Icon} from 'coral-ui';
import styles from './styles.css';
import t from 'coral-framework/services/i18n';
import {withReaction} from 'plugin-api/beta/client/hocs';
import {t, can} from 'plugin-api/beta/client/services';
class LoveButton extends React.Component {
handleClick = () => {
@@ -10,18 +10,18 @@ class LoveButton extends React.Component {
postReaction,
deleteReaction,
showSignInDialog,
alreadyReacted
alreadyReacted,
user,
} = this.props;
const {root: {me}} = this.props;
// If the current user does not exist, trigger sign in dialog.
if (!me) {
if (!user) {
showSignInDialog();
return;
}
// If the current user is banned, do nothing.
if (me.status === 'BANNED') {
// If the current user is suspended, do nothing.
if (!can(user, 'INTERACT_WITH_COMMUNITY')) {
return;
}
@@ -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 {
@@ -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) => (
<span>
+12 -4
View File
@@ -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()
}
]
};
@@ -1,4 +0,0 @@
## Extending TAG_TYPE by adding OFF_TOPIC Tag
enum TAG_TYPE {
OFF_TOPIC
}
@@ -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 {
+1 -1
View File
@@ -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 = {
-72
View File
@@ -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)
+1 -1
View File
@@ -1,4 +1,4 @@
const debug = require('debug')('talk:trust');
const debug = require('debug')('talk:services:karma');
const UserModel = require('../models/user');
/**
+180
View File
@@ -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;
+1
View File
@@ -59,3 +59,4 @@ require('../models/asset');
require('../models/comment');
require('../models/setting');
require('../models/user');
require('./migration');
+1 -1
View File
@@ -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');
+1 -1
View File
@@ -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');
+41 -43
View File
@@ -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
};
}
};
+11 -21
View File
@@ -1,4 +1,4 @@
const passport = require('./passport');
const cookieParser = require('cookie-parser')();
const authentication = require('../middleware/authentication');
// Session data does not automatically attach to websocket req objects.
@@ -7,32 +7,22 @@ const authentication = require('../middleware/authentication');
const deserializeUser = (req) => {
return new Promise((resolve, reject) => {
// This uses the authentication connect middleware to establish the session
// user details from the headers.
authentication(req, null, (err) => {
// Parse the cookies (if they exist?).
cookieParser(req, null, (err) => {
if (err) {
return reject(err);
}
if ('session' in req && 'passport' in req.session && 'user' in req.session.passport) {
passport.deserializeUser(req.session.passport.user, (err, user) => {
if (err) {
return reject(err);
}
req.user = user;
return resolve(req);
});
}
// Remove the user from the request (if there was one)
if (req.user) {
delete req.user;
}
// This uses the authentication connect middleware to establish the
// current user.
authentication(req, null, (err) => {
if (err) {
return reject(err);
}
// Resolve with the request (user removed possibly).
return resolve(req);
return resolve(req);
});
});
});
};
+215
View File
@@ -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;
+2 -2
View File
@@ -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.
+5 -6
View File
@@ -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);
});
@@ -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;
});
});
});
});
+65
View File
@@ -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'}]);
});
});
});
});
+13 -5
View File
@@ -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);
}
});
});
@@ -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'}]);
});
});
});
-58
View File
@@ -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', () => {
+107
View File
@@ -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);
}
});
});
});
+16
View File
@@ -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"