Merge pull request #522 from coralproject/142993479-tags

Get tags into a separate structure that can be applied to other models
This commit is contained in:
Kim Gardner
2017-06-08 15:16:33 -04:00
committed by GitHub
75 changed files with 1715 additions and 670 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
+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'}
]
@@ -26,8 +26,8 @@ import {getActionSummary, iPerformedThisAction} from 'coral-framework/utils';
import {getEditableUntilDate} from './util';
import styles from './Comment.css';
const isStaff = (tags) => !tags.every((t) => t.name !== 'STAFF');
const hasTag = (tags, lookupTag) => !!tags.filter((tag) => tag.name === lookupTag).length;
const isStaff = (tags) => !tags.every((t) => t.tag.name !== 'STAFF');
const hasTag = (tags, lookupTag) => !!tags.filter((t) => t.tag.name === lookupTag).length;
const hasComment = (nodes, id) => nodes.some((node) => node.id === id);
// resetCursors will return the id cursors of the first and second newest comment in
@@ -180,10 +180,10 @@ export default class Comment extends React.Component {
commentIsIgnored: React.PropTypes.func,
// dispatch action to add a tag to a comment
addCommentTag: React.PropTypes.func,
addTag: React.PropTypes.func,
// dispatch action to remove a tag from a comment
removeCommentTag: React.PropTypes.func,
removeTag: React.PropTypes.func,
// dispatch action to ignore another user
ignoreUser: React.PropTypes.func,
@@ -286,11 +286,11 @@ export default class Comment extends React.Component {
deleteAction,
disableReply,
maxCharCount,
addCommentTag,
addNotification,
charCountEnable,
showSignInDialog,
removeCommentTag,
addTag,
removeTag,
liveUpdates,
commentIsIgnored,
commentClassNames = []
@@ -333,18 +333,20 @@ export default class Comment extends React.Component {
const addBestTag = notifyOnError(
() =>
addCommentTag({
addTag({
id: comment.id,
tag: BEST_TAG
name: BEST_TAG,
assetId: asset.id
}),
() => 'Failed to tag comment as best'
);
const removeBestTag = notifyOnError(
() =>
removeCommentTag({
removeTag({
id: comment.id,
tag: BEST_TAG
name: BEST_TAG,
assetId: asset.id
}),
() => 'Failed to remove best comment tag'
);
@@ -547,8 +549,8 @@ export default class Comment extends React.Component {
currentUser={currentUser}
postFlag={postFlag}
deleteAction={deleteAction}
addCommentTag={addCommentTag}
removeCommentTag={removeCommentTag}
addTag={addTag}
removeTag={removeTag}
ignoreUser={ignoreUser}
charCountEnable={charCountEnable}
maxCharCount={maxCharCount}
@@ -129,10 +129,10 @@ class Stream extends React.Component {
postDontAgree,
deleteAction,
showSignInDialog,
addCommentTag,
addTag,
ignoreUser,
auth: {loggedIn, user},
removeCommentTag,
removeTag,
pluginProps,
editName
} = this.props;
@@ -264,8 +264,8 @@ class Stream extends React.Component {
currentUser={user}
postFlag={postFlag}
postDontAgree={postDontAgree}
addCommentTag={addCommentTag}
removeCommentTag={removeCommentTag}
addTag={addTag}
removeTag={removeTag}
ignoreUser={ignoreUser}
commentIsIgnored={commentIsIgnored}
loadMore={this.props.loadNewReplies}
@@ -298,10 +298,10 @@ Stream.propTypes = {
postComment: PropTypes.func.isRequired,
// dispatch action to add a tag to a comment
addCommentTag: PropTypes.func,
addTag: PropTypes.func,
// dispatch action to remove a tag from a comment
removeCommentTag: PropTypes.func,
removeTag: PropTypes.func,
// dispatch action to ignore another user
ignoreUser: React.PropTypes.func,
@@ -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(
+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(
});
}}),
});
+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;
}
+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
};
+96 -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
@@ -112,6 +104,51 @@ input UsersQuery {
sort: SORT_ORDER = REVERSE_CHRONOLOGICAL
}
################################################################################
## Tags
################################################################################
# Used to represent the item type for a tag.
enum TAGGABLE_ITEM_TYPE {
# The action references a entity of type Asset.
ASSETS
# The action references a entity of type Comment.
COMMENTS
# The action references a entity of type User.
USERS
}
# Tag represents the underlying Tag that can be either stored in a global list
# or added uniquely to the entity.
type Tag {
# The actual name of the tag entry.
name: String!
# The time that this Tag was created.
created_at: Date!
}
# TagLink is used to associate a given Tag with a Model via a TagLink.
type TagLink {
# The underlying Tag that is either duplicated from the global list or created
# uniquely for this specific model.
tag: Tag!
# The user that assigned the tag. This TagLink could have been created by the
# system, in which case this will be null. It could also be null if the
# current user is not an Admin/Moderator.
assigned_by: User
# The date that the TagLink was created.
created_at: Date!
}
################################################################################
## Comments
################################################################################
@@ -223,7 +260,7 @@ type Comment {
body: String!
# the tags on the comment
tags: [Tag]
tags: [TagLink!]
# the user who authored the comment.
user: User
@@ -507,6 +544,9 @@ type Asset {
# The date that the asset was created.
created_at: Date
# the tags on the asset
tags: [TagLink!]
# The author(s) of the asset.
author: String
}
@@ -543,7 +583,7 @@ type ValidationUserError implements UserError {
}
################################################################################
## Queries;
## Queries
################################################################################
# Establishes the ordering of the content by their created_at time stamp.
@@ -625,7 +665,7 @@ type RootQuery {
interface Response {
# An array of errors relating to the mutation that occurred.
errors: [UserError]
errors: [UserError!]
}
# CreateCommentResponse is returned with the comment that was created and any
@@ -636,7 +676,7 @@ type CreateCommentResponse implements Response {
comment: Comment
# An array of errors relating to the mutation that occurred.
errors: [UserError]
errors: [UserError!]
}
# Used to represent the item type for an action.
@@ -652,8 +692,13 @@ enum ACTION_ITEM_TYPE {
USERS
}
enum TAG_TYPE {
STAFF
input CreateLikeInput {
# The item's id for which we are to create a like.
item_id: ID!
# The type of the item for which we are to create the like.
item_type: ACTION_ITEM_TYPE!
}
# CreateCommentInput is the input content used to create a new comment.
@@ -669,7 +714,7 @@ input CreateCommentInput {
body: String!
# Tags
tags: [TAG_TYPE]
tags: [String]
}
@@ -697,7 +742,7 @@ type CreateFlagResponse implements Response {
flag: FlagAction
# An array of errors relating to the mutation that occurred.
errors: [UserError]
errors: [UserError!]
}
@@ -710,7 +755,7 @@ type CreateDontAgreeResponse implements Response {
dontagree: DontAgreeAction
# An array of errors relating to the mutation that occurred.
errors: [UserError]
errors: [UserError!]
}
input CreateDontAgreeInput {
@@ -756,7 +801,7 @@ input RejectUsernameInput {
type DeleteActionResponse implements Response {
# An array of errors relating to the mutation that occurred.
errors: [UserError]
errors: [UserError!]
}
# SetUserStatusResponse is the response returned with possibly some errors
@@ -764,7 +809,7 @@ type DeleteActionResponse implements Response {
type SetUserStatusResponse implements Response {
# An array of errors relating to the mutation that occurred.
errors: [UserError]
errors: [UserError!]
}
# SuspendUserResponse is the response returned with possibly some errors
@@ -772,7 +817,7 @@ type SetUserStatusResponse implements Response {
type SuspendUserResponse implements Response {
# An array of errors relating to the mutation that occurred.
errors: [UserError]
errors: [UserError!]
}
# RejectUsernameResponse is the response returned with possibly some errors
@@ -780,7 +825,7 @@ type SuspendUserResponse implements Response {
type RejectUsernameResponse implements Response {
# An array of errors relating to the mutation that occurred.
errors: [UserError]
errors: [UserError!]
}
# SetCommentStatusResponse is the response returned with possibly some errors
@@ -788,33 +833,43 @@ type RejectUsernameResponse implements Response {
type SetCommentStatusResponse implements Response {
# An array of errors relating to the mutation that occurred.
errors: [UserError]
errors: [UserError!]
}
# Response to addCommentTag mutation
type AddCommentTagResponse implements Response {
# An array of errors relating to the mutation that occured.
comment: Comment
errors: [UserError]
# ModifyTagInput is the input used to modify a tag.
input ModifyTagInput {
# name is the actual tag to add to the model.
name: String!
# id is the ID of the model in question that we are modifying the tag of.
id: ID!
# item_type is the type of item that we are modifying the tag if.
item_type: TAGGABLE_ITEM_TYPE!
# asset_id is used when the item_type is `COMMENTS`, the is needed to rectify
# the settings to get the asset specific tags/settings.
asset_id: ID
}
# Response to removeCommentTag mutation
type RemoveCommentTagResponse implements Response {
# Response to the addTag or removeTag mutations.
type ModifyTagResponse implements Response {
# An array of errors relating to the mutation that occured.
comment: Comment
errors: [UserError]
errors: [UserError!]
}
# Response to ignoreUser mutation
type IgnoreUserResponse implements Response {
# An array of errors relating to the mutation that occured.
errors: [UserError]
errors: [UserError!]
}
# Response to stopIgnoringUser mutation
type StopIgnoringUserResponse implements Response {
# An array of errors relating to the mutation that occured.
errors: [UserError]
errors: [UserError!]
}
# Input to editComment mutation.
@@ -831,7 +886,7 @@ type EditCommentResponse implements Response {
comment: Comment
# An array of errors relating to the mutation that occured.
errors: [UserError]
errors: [UserError!]
}
# All mutations for the application are defined on this object.
@@ -864,11 +919,11 @@ type RootMutation {
# Sets Comment status. Requires the `ADMIN` role.
setCommentStatus(id: ID!, status: COMMENT_STATUS!): SetCommentStatusResponse
# Add tag to comment.
addCommentTag(id: ID!, tag: String!): AddCommentTagResponse
# Add a tag.
addTag(tag: ModifyTagInput!): ModifyTagResponse!
# Remove tag from comment.
removeCommentTag(id: ID!, tag: String!): RemoveCommentTagResponse
# Removes a tag.
removeTag(tag: ModifyTagInput!): ModifyTagResponse!
# Ignore comments by another user
ignoreUser(id: ID!): IgnoreUserResponse
+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;
+6
View File
@@ -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",
+4 -4
View File
@@ -1,10 +1,10 @@
{
"server": [
"coral-plugin-respect",
"coral-plugin-like",
"coral-plugin-facebook-auth",
"coral-plugin-auth",
"coral-plugin-offtopic"
"coral-plugin-like",
"coral-plugin-respect",
"coral-plugin-offtopic",
"coral-plugin-facebook-auth"
],
"client": [
"coral-plugin-respect",
@@ -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 {
@@ -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
};
}
};
+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"