Merge branch 'master' into dont-agree

This commit is contained in:
David Jay
2017-02-21 15:02:33 -05:00
committed by GitHub
52 changed files with 726 additions and 859 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ ENV TALK_PORT 5000
EXPOSE 5000 EXPOSE 5000
# Install app dependencies # Install app dependencies
COPY package.json /usr/src/app/ COPY package.json yarn.lock /usr/src/app/
RUN yarn install --production RUN yarn install --production
# Bundle app source # Bundle app source
+2 -1
View File
@@ -120,7 +120,7 @@ const performSetup = () => {
message: 'Username', message: 'Username',
filter: (username) => { filter: (username) => {
return UsersService return UsersService
.isValidDisplayName(username, false) .isValidUsername(username, false)
.catch((err) => { .catch((err) => {
throw err.message; throw err.message;
}); });
@@ -184,6 +184,7 @@ const performSetup = () => {
console.log('Settings created.'); console.log('Settings created.');
console.log(`User ${user.id} created.`); console.log(`User ${user.id} created.`);
console.log('\nTalk is now installed!'); 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(); util.shutdown();
}) })
.catch((err) => { .catch((err) => {
+1 -1
View File
@@ -79,7 +79,7 @@ function getUserCreateAnswers(options) {
message: 'Username', message: 'Username',
filter: (username) => { filter: (username) => {
return UsersService return UsersService
.isValidDisplayName(username) .isValidUsername(username)
.catch((err) => { .catch((err) => {
throw err.message; throw err.message;
}); });
+1 -1
View File
@@ -16,7 +16,7 @@ export const checkLogin = () => dispatch => {
}) })
.catch(error => { .catch(error => {
console.error(error); console.error(error);
dispatch(checkLoginFailure(`${error.message}`)); dispatch(checkLoginFailure(`${error.translation_key}`));
}); });
}; };
+2 -2
View File
@@ -81,7 +81,7 @@ export const submitUser = () => (dispatch, getState) => {
}) })
.catch(error => { .catch(error => {
console.error(error); console.error(error);
dispatch(installFailure(`${error.message}`)); dispatch(installFailure(`${error.translation_key}`));
}); });
}); });
}; };
@@ -104,6 +104,6 @@ export const checkInstall = next => dispatch => {
}) })
.catch(error => { .catch(error => {
console.error(error); console.error(error);
dispatch(checkInstallFailure(`${error.message}`)); dispatch(checkInstallFailure(`${error.translation_key}`));
}); });
}; };
@@ -23,8 +23,8 @@ const FlagWidget = ({assets}) => {
{ {
assets.length assets.length
? assets.map((asset, index) => { ? assets.map((asset, index) => {
const flagCount = asset.action_summaries.find(s => s.__typename === 'FlagAssetActionSummary').actionCount; const flagSummary = asset.action_summaries.find(s => s.__typename === 'FlagAssetActionSummary');
const likeCount = asset.action_summaries.find(s => s.__typename === 'LikeAssetActionSummary').actionCount; const likeSummary = asset.action_summaries.find(s => s.__typename === 'LikeAssetActionSummary');
return ( return (
<tr key={asset.id}> <tr key={asset.id}>
<td>{index + 1}.</td> <td>{index + 1}.</td>
@@ -32,8 +32,8 @@ const FlagWidget = ({assets}) => {
<Link to={`/admin/moderate/flagged/${asset.id}`}>{asset.title}</Link> <Link to={`/admin/moderate/flagged/${asset.id}`}>{asset.title}</Link>
<p className={styles.lede}>{asset.author} - Published: {new Date(asset.created_at).toLocaleDateString()}</p> <p className={styles.lede}>{asset.author} - Published: {new Date(asset.created_at).toLocaleDateString()}</p>
</td> </td>
<td>{likeCount}</td> <td>{flagSummary ? flagSummary.actionCount : 0}</td>
<td>{flagCount}</td> <td>{likeSummary ? likeSummary.actionCount : 0}</td>
<td>{asset.commentCount}</td> <td>{asset.commentCount}</td>
</tr> </tr>
); );
@@ -14,11 +14,17 @@ export default ({handleLogout, restricted = false}) => (
<div> <div>
<Navigation className={styles.nav}> <Navigation className={styles.nav}>
<IndexLink <IndexLink
className={styles.navLink}
to="/admin/dashboard"
activeClassName={styles.active}>
{lang.t('configure.dashboard')}
</IndexLink>
<Link
className={styles.navLink} className={styles.navLink}
to="/admin/moderate" to="/admin/moderate"
activeClassName={styles.active}> activeClassName={styles.active}>
{lang.t('configure.moderate')} {lang.t('configure.moderate')}
</IndexLink> </Link>
<Link className={styles.navLink} <Link className={styles.navLink}
to="/admin/streams" to="/admin/streams"
activeClassName={styles.active}> activeClassName={styles.active}>
@@ -35,12 +41,6 @@ export default ({handleLogout, restricted = false}) => (
activeClassName={styles.active}> activeClassName={styles.active}>
{lang.t('configure.configure')} {lang.t('configure.configure')}
</Link> </Link>
<Link
className={styles.navLink}
to="/admin/dashboard"
activeClassName={styles.active}>
{lang.t('configure.dashboard')}
</Link>
</Navigation> </Navigation>
<div className={styles.rightPanel}> <div className={styles.rightPanel}>
<ul> <ul>
@@ -70,8 +70,10 @@ class ModerationContainer extends Component {
<div> <div>
<ModerationHeader asset={asset} /> <ModerationHeader asset={asset} />
<ModerationMenu <ModerationMenu
activeTab={activeTab}
asset={asset} asset={asset}
premodCount={data.premodCount}
rejectedCount={data.rejectedCount}
flaggedCount={data.flaggedCount}
/> />
<ModerationQueue <ModerationQueue
data={data} data={data}
@@ -0,0 +1,14 @@
.count {
display: inline-block;
background: #989797;
margin: 2px;
vertical-align: middle;
padding: 4px 7px;
border-radius: 2px;
margin-left: 10px;
line-height: 20px;
box-sizing: border-box;
height: 27px;
right: 0;
margin-top: -2px;
}
@@ -0,0 +1,12 @@
import React, {PropTypes} from 'react';
import styles from './CommentCount.css';
const CommentCount = props => (
<span className={styles.count}>{props.count}</span>
);
CommentCount.propTypes = {
count: PropTypes.number.isRequired
};
export default CommentCount;
@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import {Link} from 'react-router'; import {Link} from 'react-router';
import {Icon} from 'coral-ui';
import styles from './styles.css'; import styles from './styles.css';
const ModerationHeader = props => ( const ModerationHeader = props => (
@@ -9,7 +10,10 @@ const ModerationHeader = props => (
props.asset ? props.asset ?
<div className={`mdl-tabs__tab-bar ${styles.moderateAsset}`}> <div className={`mdl-tabs__tab-bar ${styles.moderateAsset}`}>
<Link className="mdl-tabs__tab" to="/admin/moderate">All Streams</Link> <Link className="mdl-tabs__tab" to="/admin/moderate">All Streams</Link>
<a className="mdl-tabs__tab">{props.asset.title}</a> <a className="mdl-tabs__tab">
{props.asset.title}
<a href={props.asset.url} className={styles.settingsButton}><Icon name="settings"/></a>
</a>
<Link className="mdl-tabs__tab" to="/admin/streams">Select Stream</Link> <Link className="mdl-tabs__tab" to="/admin/streams">Select Stream</Link>
</div> </div>
: :
@@ -1,4 +1,5 @@
import React from 'react'; import React, {PropTypes} from 'react';
import CommentCount from './CommentCount';
import styles from './styles.css'; import styles from './styles.css';
import I18n from 'coral-framework/modules/i18n/i18n'; import I18n from 'coral-framework/modules/i18n/i18n';
import translations from 'coral-admin/src/translations.json'; import translations from 'coral-admin/src/translations.json';
@@ -6,38 +7,36 @@ import {Link} from 'react-router';
const lang = new I18n(translations); const lang = new I18n(translations);
const ModerationMenu = (props) => ( const ModerationMenu = ({asset, premodCount, rejectedCount, flaggedCount}) => {
<div className='mdl-tabs'> const premodPath = asset ? `/admin/moderate/premod/${asset.id}` : '/admin/moderate/premod';
<div className={`mdl-tabs__tab-bar ${styles.tabBar}`}> const rejectPath = asset ? `/admin/moderate/rejected/${asset.id}` : '/admin/moderate/rejected';
{ const flagPath = asset ? `/admin/moderate/flagged/${asset.id}` : '/admin/moderate/flagged';
props.asset ? ( return (
<div> <div className='mdl-tabs'>
<Link to={`/admin/moderate/premod/${props.asset.id}`} className={`mdl-tabs__tab ${styles.tab}`} activeClassName={styles.active}> <div className={`mdl-tabs__tab-bar ${styles.tabBar}`}>
{lang.t('modqueue.premod')} <div>
</Link> <Link to={premodPath} className={`mdl-tabs__tab ${styles.tab}`} activeClassName={styles.active}>
<Link to={`/admin/moderate/rejected/${props.asset.id}`} className={`mdl-tabs__tab ${styles.tab}`} activeClassName={styles.active}> {lang.t('modqueue.premod')} <CommentCount count={premodCount} />
{lang.t('modqueue.rejected')} </Link>
</Link> <Link to={rejectPath} className={`mdl-tabs__tab ${styles.tab}`} activeClassName={styles.active}>
<Link to={`/admin/moderate/flagged/${props.asset.id}`} className={`mdl-tabs__tab ${styles.tab}`} activeClassName={styles.active}> {lang.t('modqueue.rejected')} <CommentCount count={rejectedCount} />
{lang.t('modqueue.flagged')} </Link>
</Link> <Link to={flagPath} className={`mdl-tabs__tab ${styles.tab}`} activeClassName={styles.active}>
</div> {lang.t('modqueue.flagged')} <CommentCount count={flaggedCount} />
) : ( </Link>
<div> </div>
<Link to='/admin/moderate/premod' className={`mdl-tabs__tab ${styles.tab}`} activeClassName={styles.active}> </div>
{lang.t('modqueue.premod')}
</Link>
<Link to='/admin/moderate/rejected' className={`mdl-tabs__tab ${styles.tab}`} activeClassName={styles.active}>
{lang.t('modqueue.rejected')}
</Link>
<Link to='/admin/moderate/flagged' className={`mdl-tabs__tab ${styles.tab}`} activeClassName={styles.active}>
{lang.t('modqueue.flagged')}
</Link>
</div>
)
}
</div> </div>
</div> );
); };
ModerationMenu.propTypes = {
premodCount: PropTypes.number.isRequired,
rejectedCount: PropTypes.number.isRequired,
flaggedCount: PropTypes.number.isRequired,
asset: PropTypes.shape({
id: PropTypes.string
})
};
export default ModerationMenu; export default ModerationMenu;
@@ -77,6 +77,14 @@ span {
color: white; color: white;
margin-bottom: -1px; margin-bottom: -1px;
.settingsButton {
i {
vertical-align: middle;
margin-left: 10px;
margin-top: -4px;
}
}
.moderateAsset { .moderateAsset {
a { a {
-webkit-box-flex: 1; -webkit-box-flex: 1;
@@ -20,7 +20,7 @@ export const mostFlags = graphql(MOST_FLAGS, {
}); });
export const modQueueQuery = graphql(MOD_QUEUE_QUERY, { export const modQueueQuery = graphql(MOD_QUEUE_QUERY, {
options: ({params: {id = ''}}) => { options: ({params: {id = null}}) => {
return { return {
variables: { variables: {
asset_id: id asset_id: id
@@ -1,6 +1,6 @@
#import "../fragments/commentView.graphql" #import "../fragments/commentView.graphql"
query ModQueue ($asset_id: ID!) { query ModQueue ($asset_id: ID) {
premod: comments(query: { premod: comments(query: {
statuses: [PREMOD], statuses: [PREMOD],
asset_id: $asset_id asset_id: $asset_id
@@ -29,5 +29,19 @@ query ModQueue ($asset_id: ID!) {
assets: assets { assets: assets {
id id
title title
url
} }
premodCount: commentCount(query: {
statuses: [PREMOD],
asset_id: $asset_id
})
rejectedCount: commentCount(query: {
statuses: [REJECTED],
asset_id: $asset_id
})
flaggedCount: commentCount(query: {
action_type: FLAG,
asset_id: $asset_id,
statuses: [NONE, PREMOD]
})
} }
-1
View File
@@ -145,7 +145,6 @@ class Comment extends React.Component {
? <ReplyBox ? <ReplyBox
commentPostedHandler={() => { commentPostedHandler={() => {
setActiveReplyBox(''); setActiveReplyBox('');
refetch();
}} }}
setActiveReplyBox={setActiveReplyBox} setActiveReplyBox={setActiveReplyBox}
parentId={parentId || comment.id} parentId={parentId || comment.id}
+2 -3
View File
@@ -23,7 +23,7 @@ import CommentBox from 'coral-plugin-commentbox/CommentBox';
import UserBox from 'coral-sign-in/components/UserBox'; import UserBox from 'coral-sign-in/components/UserBox';
import SignInContainer from 'coral-sign-in/containers/SignInContainer'; import SignInContainer from 'coral-sign-in/containers/SignInContainer';
import SuspendedAccount from 'coral-framework/components/SuspendedAccount'; import SuspendedAccount from 'coral-framework/components/SuspendedAccount';
import ChangeDisplayNameContainer from '../../coral-sign-in/containers/ChangeDisplayNameContainer'; import ChangeUsernameContainer from '../../coral-sign-in/containers/ChangeUsernameContainer';
import SettingsContainer from 'coral-settings/containers/SettingsContainer'; import SettingsContainer from 'coral-settings/containers/SettingsContainer';
import RestrictedContent from 'coral-framework/components/RestrictedContent'; import RestrictedContent from 'coral-framework/components/RestrictedContent';
import ConfigureStreamContainer from 'coral-configure/containers/ConfigureStreamContainer'; import ConfigureStreamContainer from 'coral-configure/containers/ConfigureStreamContainer';
@@ -128,7 +128,6 @@ class Embed extends Component {
{ {
user user
? <CommentBox ? <CommentBox
commentPostedHandler={refetch}
addNotification={this.props.addNotification} addNotification={this.props.addNotification}
postItem={this.props.postItem} postItem={this.props.postItem}
appendItemArray={this.props.appendItemArray} appendItemArray={this.props.appendItemArray}
@@ -147,7 +146,7 @@ class Embed extends Component {
: <p>{asset.settings.closedMessage}</p> : <p>{asset.settings.closedMessage}</p>
} }
{!loggedIn && <SignInContainer requireEmailConfirmation={asset.settings.requireEmailConfirmation} offset={signInOffset}/>} {!loggedIn && <SignInContainer requireEmailConfirmation={asset.settings.requireEmailConfirmation} offset={signInOffset}/>}
{loggedIn && user && <ChangeDisplayNameContainer loggedIn={loggedIn} offset={signInOffset} user={user} />} {loggedIn && user && <ChangeUsernameContainer loggedIn={loggedIn} offset={signInOffset} user={user} />}
<Stream <Stream
refetch={refetch} refetch={refetch}
addNotification={this.props.addNotification} addNotification={this.props.addNotification}
+15 -15
View File
@@ -8,25 +8,25 @@ import coralApi, {base} from '../helpers/response';
export const showSignInDialog = (offset = 0) => ({type: actions.SHOW_SIGNIN_DIALOG, offset}); export const showSignInDialog = (offset = 0) => ({type: actions.SHOW_SIGNIN_DIALOG, offset});
export const hideSignInDialog = () => ({type: actions.HIDE_SIGNIN_DIALOG}); export const hideSignInDialog = () => ({type: actions.HIDE_SIGNIN_DIALOG});
export const createDisplayNameRequest = () => ({type: actions.CREATE_DISPLAYNAME_REQUEST}); export const createUsernameRequest = () => ({type: actions.CREATE_USERNAME_REQUEST});
export const showCreateDisplayNameDialog = () => ({type: actions.SHOW_CREATEDISPLAYNAME_DIALOG}); export const showCreateUsernameDialog = () => ({type: actions.SHOW_CREATEUSERNAME_DIALOG});
export const hideCreateDisplayNameDialog = () => ({type: actions.HIDE_CREATEDISPLAYNAME_DIALOG}); export const hideCreateUsernameDialog = () => ({type: actions.HIDE_CREATEUSERNAME_DIALOG});
const createDisplayNameSuccess = () => ({type: actions.CREATEDISPLAYNAME_SUCCESS}); const createUsernameSuccess = () => ({type: actions.CREATE_USERNAME_SUCCESS});
const createDisplayNameFailure = error => ({type: actions.CREATEDISPLAYNAME_FAILURE, error}); const createUsernameFailure = error => ({type: actions.CREATE_USERNAME_FAILURE, error});
export const updateDisplayName = ({username}) => ({type: actions.UPDATE_DISPLAYNAME, username}); export const updateUsername = ({username}) => ({type: actions.UPDATE_USERNAME, username});
export const createDisplayName = (userId, formData) => dispatch => { export const createUsername = (userId, formData) => dispatch => {
dispatch(createDisplayNameRequest()); dispatch(createUsernameRequest());
coralApi('/account/username', {method: 'PUT', body: formData}) coralApi('/account/username', {method: 'PUT', body: formData})
.then(() => { .then(() => {
dispatch(createDisplayNameSuccess()); dispatch(createUsernameSuccess());
dispatch(hideCreateDisplayNameDialog()); dispatch(hideCreateUsernameDialog());
dispatch(updateDisplayName(formData)); dispatch(updateUsername(formData));
}) })
.catch(error => { .catch(error => {
dispatch(createDisplayNameFailure(lang.t(`error.${error.message}`))); dispatch(createUsernameFailure(lang.t(`error.${error.translation_key}`)));
}); });
}; };
@@ -95,14 +95,14 @@ export const fetchSignUpFacebook = () => dispatch => {
export const facebookCallback = (err, data) => dispatch => { export const facebookCallback = (err, data) => dispatch => {
if (err) { if (err) {
signInFacebookFailure(err); dispatch(signInFacebookFailure(err));
return; return;
} }
try { try {
const user = JSON.parse(data); const user = JSON.parse(data);
dispatch(signInFacebookSuccess(user)); dispatch(signInFacebookSuccess(user));
dispatch(hideSignInDialog()); dispatch(hideSignInDialog());
dispatch(showCreateDisplayNameDialog()); dispatch(showCreateUsernameDialog());
} catch (err) { } catch (err) {
dispatch(signInFacebookFailure(err)); dispatch(signInFacebookFailure(err));
return; return;
@@ -177,7 +177,7 @@ export const checkLogin = () => dispatch => {
}) })
.catch(error => { .catch(error => {
console.error(error); console.error(error);
dispatch(checkLoginFailure(`${error.message}`)); dispatch(checkLoginFailure(`${error.translation_key}`));
}); });
}; };
@@ -26,7 +26,7 @@ class SuspendedAccount extends Component {
editName(username) editName(username)
.then(() => location.reload()) .then(() => location.reload())
.catch((error) => { .catch((error) => {
this.setState({alert: lang.t(`error.${error.message}`)}); this.setState({alert: lang.t(`error.${error.translation_key}`)});
}); });
} else { } else {
this.setState({alert: lang.t('editName.error')}); this.setState({alert: lang.t('editName.error')});
+7 -7
View File
@@ -4,12 +4,12 @@ export const CLEAN_STATE = 'CLEAN_STATE';
export const SHOW_SIGNIN_DIALOG = 'SHOW_SIGNIN_DIALOG'; export const SHOW_SIGNIN_DIALOG = 'SHOW_SIGNIN_DIALOG';
export const HIDE_SIGNIN_DIALOG = 'HIDE_SIGNIN_DIALOG'; export const HIDE_SIGNIN_DIALOG = 'HIDE_SIGNIN_DIALOG';
export const CREATE_DISPLAYNAME_REQUEST = 'CREATE_DISPLAYNAME_REQUEST'; export const CREATE_USERNAME_REQUEST = 'CREATE_USERNAME_REQUEST';
export const CREATEDISPLAYNAME_SUCCESS = 'CREATEDISPLAYNAME_SUCCESS'; export const CREATE_USERNAME_SUCCESS = 'CREATE_USERNAME_SUCCESS';
export const CREATEDISPLAYNAME_FAILURE = 'CREATEDISPLAYNAME_FAILURE'; export const CREATE_USERNAME_FAILURE = 'CREATE_USERNAME_FAILURE';
export const CREATE_DISPLAYNAME = 'CREATE_DISPLAYNAME'; export const CREATE_USERNAME = 'CREATE_USERNAME';
export const SHOW_CREATEDISPLAYNAME_DIALOG = 'SHOW_CREATEDISPLAYNAME_DIALOG'; export const SHOW_CREATEUSERNAME_DIALOG = 'SHOW_CREATEUSERNAME_DIALOG';
export const HIDE_CREATEDISPLAYNAME_DIALOG = 'HIDE_CREATEDISPLAYNAME_DIALOG'; export const HIDE_CREATEUSERNAME_DIALOG = 'HIDE_CREATEUSERNAME_DIALOG';
export const FETCH_SIGNUP_REQUEST = 'FETCH_SIGNUP_REQUEST'; export const FETCH_SIGNUP_REQUEST = 'FETCH_SIGNUP_REQUEST';
export const FETCH_SIGNUP_FAILURE = 'FETCH_SIGNUP_FAILURE'; export const FETCH_SIGNUP_FAILURE = 'FETCH_SIGNUP_FAILURE';
@@ -44,4 +44,4 @@ export const CHECK_CSRF_TOKEN = 'CHECK_CSRF_TOKEN';
export const VERIFY_EMAIL_REQUEST = 'VERIFY_EMAIL_REQUEST'; export const VERIFY_EMAIL_REQUEST = 'VERIFY_EMAIL_REQUEST';
export const VERIFY_EMAIL_SUCCESS = 'VERIFY_EMAIL_SUCCESS'; export const VERIFY_EMAIL_SUCCESS = 'VERIFY_EMAIL_SUCCESS';
export const VERIFY_EMAIL_FAILURE = 'VERIFY_EMAIL_FAILURE'; export const VERIFY_EMAIL_FAILURE = 'VERIFY_EMAIL_FAILURE';
export const UPDATE_DISPLAYNAME = 'UPDATE_DISPLAYNAME'; export const UPDATE_USERNAME = 'UPDATE_USERNAME';
+1 -1
View File
@@ -5,4 +5,4 @@ export const COMMENTS_BY_USER_REQUEST = 'COMMENTS_BY_USER_REQUEST';
export const COMMENTS_BY_USER_SUCCESS = 'COMMENTS_BY_USER_SUCCESS'; export const COMMENTS_BY_USER_SUCCESS = 'COMMENTS_BY_USER_SUCCESS';
export const COMMENTS_BY_USER_FAILURE = 'COMMENTS_BY_USER_FAILURE'; export const COMMENTS_BY_USER_FAILURE = 'COMMENTS_BY_USER_FAILURE';
export const LOGOUT_SUCCESS = 'LOGOUT_SUCCESS'; export const LOGOUT_SUCCESS = 'LOGOUT_SUCCESS';
export const UPDATE_DISPLAYNAME = 'UPDATE_DISPLAYNAME'; export const UPDATE_USERNAME = 'UPDATE_USERNAME';
@@ -11,16 +11,72 @@ export const postComment = graphql(POST_COMMENT, {
options: () => ({ options: () => ({
fragments: commentView fragments: commentView
}), }),
props: ({mutate}) => ({ props: ({ownProps, mutate}) => ({
postItem: ({asset_id, body, parent_id} /* , type */ ) => { postItem: ({asset_id, body, parent_id}) =>
return mutate({ mutate({
variables: { variables: {
asset_id, asset_id,
body, body,
parent_id parent_id
},
optimisticResponse: {
createComment: {
comment: {
user: {
id: ownProps.auth.user.id,
name: ownProps.auth.user.username
},
created_at: new Date().toISOString(),
body,
parent_id,
asset_id,
action_summaries: [],
tags: [],
status: null,
id: `${Date.now()}_temp_id`
}
}
},
updateQueries: {
AssetQuery: (oldData, {mutationResult:{data:{createComment:{comment}}}}) => {
if (oldData.asset.moderation === 'PRE') {
return oldData;
}
let updatedAsset;
// If posting a reply
if (parent_id) {
updatedAsset = {
...oldData,
asset: {
...oldData.asset,
comments: oldData.asset.comments.map((oldComment) => {
return oldComment.id === parent_id
? {...oldComment, replies: [...oldComment.replies, comment]}
: oldComment;
})
}
};
} else {
// If posting a top-level comment
updatedAsset = {
...oldData,
asset: {
...oldData.asset,
commentCount: oldData.asset.commentCount + 1,
comments: [comment, ...oldData.asset.comments]
}
};
}
return updatedAsset;
}
} }
}); })
}}), }),
}); });
export const postLike = graphql(POST_LIKE, { export const postLike = graphql(POST_LIKE, {
@@ -4,6 +4,10 @@ mutation CreateComment ($asset_id: ID!, $parent_id: ID, $body: String!) {
createComment(asset_id:$asset_id, parent_id:$parent_id, body:$body) { createComment(asset_id:$asset_id, parent_id:$parent_id, body:$body) {
comment { comment {
...commentView ...commentView
replyCount
replies {
...commentView
}
} }
errors { errors {
translation_key translation_key
+9 -10
View File
@@ -7,7 +7,7 @@ const initialState = Map({
isAdmin: false, isAdmin: false,
user: null, user: null,
showSignInDialog: false, showSignInDialog: false,
showCreateDisplayNameDialog: false, showCreateUsernameDialog: false,
view: 'SIGNIN', view: 'SIGNIN',
error: '', error: '',
passwordRequestSuccess: null, passwordRequestSuccess: null,
@@ -43,19 +43,19 @@ export default function auth (state = initialState, action) {
emailVerificationLoading: false, emailVerificationLoading: false,
successSignUp: false successSignUp: false
})); }));
case actions.SHOW_CREATEDISPLAYNAME_DIALOG : case actions.SHOW_CREATEUSERNAME_DIALOG :
return state return state
.set('showCreateDisplayNameDialog', true); .set('showCreateUsernameDialog', true);
case actions.HIDE_CREATEDISPLAYNAME_DIALOG : case actions.HIDE_CREATEUSERNAME_DIALOG :
return state.merge(Map({ return state.merge(Map({
showCreateDisplayNameDialog: false showCreateUsernameDialog: false
})); }));
case actions.CREATEDISPLAYNAME_SUCCESS : case actions.CREATE_USERNAME_SUCCESS :
return state.merge(Map({ return state.merge(Map({
showCreateDisplayNameDialog: false, showCreateUsernameDialog: false,
error: '' error: ''
})); }));
case actions.CREATEDISPLAYNAME_FAILURE : case actions.CREATE_USERNAME_FAILURE :
return state return state
.set('error', action.error); .set('error', action.error);
case actions.CHANGE_VIEW : case actions.CHANGE_VIEW :
@@ -130,8 +130,7 @@ export default function auth (state = initialState, action) {
return state return state
.set('passwordRequestFailure', 'There was an error sending your password reset email. Please try again soon!') .set('passwordRequestFailure', 'There was an error sending your password reset email. Please try again soon!')
.set('passwordRequestSuccess', null); .set('passwordRequestSuccess', null);
case actions.UPDATE_DISPLAYNAME: case actions.UPDATE_USERNAME:
console.log('Action', action);
return state return state
.setIn(['user', 'username'], action.username); .setIn(['user', 'username'], action.username);
case actions.VERIFY_EMAIL_FAILURE: case actions.VERIFY_EMAIL_FAILURE:
+15 -11
View File
@@ -9,13 +9,13 @@
"msg": "Your account is currently suspended because your username has been deemed inappropriate. To restore your account, please enter a new username. You may contact moderator@fakeurl.com for more information.", "msg": "Your account is currently suspended because your username has been deemed inappropriate. To restore your account, please enter a new username. You may contact moderator@fakeurl.com for more information.",
"label": "New Username", "label": "New Username",
"button": "Submit", "button": "Submit",
"error": "Display names can contain letters, numbers and _ only" "error": "Usernames can contain letters, numbers and _ only"
}, },
"error": { "error": {
"emailNotVerified": "Email address {0} not verified.", "emailNotVerified": "Email address {0} not verified.",
"email": "Not a valid E-Mail", "email": "Not a valid E-Mail",
"password": "Password must be at least 8 characters", "password": "Password must be at least 8 characters",
"username": "Display names can contain letters, numbers and _ only", "username": "Usernames can contain letters, numbers and _ only",
"confirmPassword": "Passwords don't match. Please, check again", "confirmPassword": "Passwords don't match. Please, check again",
"organizationName": "Organization name must only contain letters or numbers.", "organizationName": "Organization name must only contain letters or numbers.",
"emailPasswordError": "Email and/or password combination incorrect.", "emailPasswordError": "Email and/or password combination incorrect.",
@@ -23,11 +23,13 @@
"PASSWORD_REQUIRED": "Must input a password", "PASSWORD_REQUIRED": "Must input a password",
"PASSWORD_LENGTH": "Password is too short", "PASSWORD_LENGTH": "Password is too short",
"EMAIL_IN_USE": "Email address already in use", "EMAIL_IN_USE": "Email address already in use",
"EMAIL_DISPLAY_NAME_IN_USE": "Email address or username already in use", "EMAIL_USERNAME_IN_USE": "Email address or username already in use",
"DISPLAYNAME_IN_USE": "Display name already in use", "USERNAME_IN_USE": "Username already in use",
"DISPLAY_NAME_REQUIRED": "Must input a username", "USERNAME_REQUIRED": "Must input a username",
"NO_SPECIAL_CHARACTERS": "Display names can contain letters, numbers and _ only", "NO_SPECIAL_CHARACTERS": "Usernames can contain letters, numbers and _ only",
"PROFANITY_ERROR": "Display names must not contain profanity. Please contact the administrator if you believe this to be in error." "PROFANITY_ERROR": "Usernames must not contain profanity. Please contact the administrator if you believe this to be in error.",
"NOT_AUTHORIZED": "Not authorized.",
"EDIT_USERNAME_NOT_AUTHORIZED": "You do not have permission to update your username."
} }
}, },
"es": { "es": {
@@ -49,11 +51,13 @@
"PASSWORD_REQUIRED": "Debe ingresar una contraseña", "PASSWORD_REQUIRED": "Debe ingresar una contraseña",
"PASSWORD_LENGTH": "La contraseña es muy corta", "PASSWORD_LENGTH": "La contraseña es muy corta",
"EMAIL_IN_USE": "La dirección de correo electrónico se encuentra en uso", "EMAIL_IN_USE": "La dirección de correo electrónico se encuentra en uso",
"EMAIL_DISPLAY_NAME_IN_USE": "Correo o Nombre en uso.", "EMAIL_USERNAME_IN_USE": "Correo o Nombre en uso.",
"DISPLAYNAME_IN_USE": "Nombre en uso.", "USERNAME_IN_USE": "Nombre en uso.",
"DISPLAY_NAME_REQUIRED": "Debe ingresar un nombre", "USERNAME_REQUIRED": "Debe ingresar un nombre",
"NO_SPECIAL_CHARACTERS": "Los nombres pueden contener letras, números y _", "NO_SPECIAL_CHARACTERS": "Los nombres pueden contener letras, números y _",
"PROFANITY_ERROR": "Los nombres no pueden contener blasfemias. Por favor contacte al administrador si cree que esto es un error" "PROFANITY_ERROR": "Los nombres no pueden contener blasfemias. Por favor contacte al administrador si cree que esto es un error",
"NOT_AUTHORIZED": "Acción no autorizada.",
"EDIT_USERNAME_NOT_AUTHORIZED": "No tiene permiso para editar el nombre de usuario."
} }
} }
} }
@@ -8,10 +8,10 @@ import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../translations'; import translations from '../translations';
const lang = new I18n(translations); const lang = new I18n(translations);
const CreateDisplayNameDialog = ({open, handleClose, offset, formData, handleSubmitDisplayName, handleChange, ...props}) => ( const CreateUsernameDialog = ({open, handleClose, offset, formData, handleSubmitUsername, handleChange, ...props}) => (
<Dialog <Dialog
className={styles.dialog} className={styles.dialog}
id="createDisplayNameDialog" id="createUsernameDialog"
open={open} open={open}
style={{ style={{
position: 'relative', position: 'relative',
@@ -27,7 +27,7 @@ const CreateDisplayNameDialog = ({open, handleClose, offset, formData, handleSub
<div> <div>
<label htmlFor="username">{lang.t('createdisplay.yourusername')}</label> <label htmlFor="username">{lang.t('createdisplay.yourusername')}</label>
{ props.auth.error && <Alert>{props.auth.error}</Alert> } { props.auth.error && <Alert>{props.auth.error}</Alert> }
<form id="saveDisplayName" onSubmit={handleSubmitDisplayName}> <form id="saveUsername" onSubmit={handleSubmitUsername}>
<TextField <TextField
id="username" id="username"
type="string" type="string"
@@ -45,4 +45,4 @@ const CreateDisplayNameDialog = ({open, handleClose, offset, formData, handleSub
</Dialog> </Dialog>
); );
export default CreateDisplayNameDialog; export default CreateUsernameDialog;
@@ -4,21 +4,21 @@ import {connect} from 'react-redux';
import validate from 'coral-framework/helpers/validate'; import validate from 'coral-framework/helpers/validate';
import errorMsj from 'coral-framework/helpers/error'; import errorMsj from 'coral-framework/helpers/error';
import CreateDisplayNameDialog from '../components/CreateDisplayNameDialog'; import CreateUsernameDialog from '../components/CreateUsernameDialog';
import I18n from 'coral-framework/modules/i18n/i18n'; import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../translations'; import translations from '../translations';
const lang = new I18n(translations); const lang = new I18n(translations);
import { import {
showCreateDisplayNameDialog, showCreateUsernameDialog,
hideCreateDisplayNameDialog, hideCreateUsernameDialog,
invalidForm, invalidForm,
validForm, validForm,
createDisplayName createUsername
} from '../../coral-framework/actions/auth'; } from '../../coral-framework/actions/auth';
class ChangeDisplayNameContainer extends Component { class ChangeUsernameContainer extends Component {
initialState = { initialState = {
formData: { formData: {
username: '', username: '',
@@ -31,7 +31,7 @@ class ChangeDisplayNameContainer extends Component {
super(props); super(props);
this.state = this.initialState; this.state = this.initialState;
this.handleChange = this.handleChange.bind(this); this.handleChange = this.handleChange.bind(this);
this.handleSubmitDisplayName = this.handleSubmitDisplayName.bind(this); this.handleSubmitUsername = this.handleSubmitUsername.bind(this);
this.handleClose = this.handleClose.bind(this); this.handleClose = this.handleClose.bind(this);
this.addError = this.addError.bind(this); this.addError = this.addError.bind(this);
} }
@@ -81,13 +81,13 @@ class ChangeDisplayNameContainer extends Component {
this.setState({showErrors: show}); this.setState({showErrors: show});
} }
handleSubmitDisplayName(e) { handleSubmitUsername(e) {
e.preventDefault(); e.preventDefault();
const {errors} = this.state; const {errors} = this.state;
const {validForm, invalidForm} = this.props; const {validForm, invalidForm} = this.props;
this.displayErrors(); this.displayErrors();
if (this.isCompleted() && !Object.keys(errors).length) { if (this.isCompleted() && !Object.keys(errors).length) {
this.props.createDisplayName(this.props.user.id, this.state.formData); this.props.createUsername(this.props.user.id, this.state.formData);
validForm(); validForm();
} else { } else {
invalidForm(lang.t('createdisplay.checkTheForm')); invalidForm(lang.t('createdisplay.checkTheForm'));
@@ -95,19 +95,19 @@ class ChangeDisplayNameContainer extends Component {
} }
handleClose() { handleClose() {
this.props.hideCreateDisplayNameDialog(); this.props.hideCreateUsernameDialog();
} }
render() { render() {
const {loggedIn, auth, offset} = this.props; const {loggedIn, auth, offset} = this.props;
return ( return (
<div> <div>
<CreateDisplayNameDialog <CreateUsernameDialog
open={auth.showCreateDisplayNameDialog && auth.fromSignUp} open={auth.showCreateUsernameDialog && auth.fromSignUp}
offset={offset} offset={offset}
handleClose={this.handleClose} handleClose={this.handleClose}
loggedIn={loggedIn} loggedIn={loggedIn}
handleSubmitDisplayName={this.handleSubmitDisplayName} handleSubmitUsername={this.handleSubmitUsername}
{...this} {...this}
{...this.state} {...this.state}
{...this.props} {...this.props}
@@ -122,9 +122,9 @@ const mapStateToProps = state => ({
}); });
const mapDispatchToProps = dispatch => ({ const mapDispatchToProps = dispatch => ({
createDisplayName: (userid, formData) => dispatch(createDisplayName(userid, formData)), createUsername: (userid, formData) => dispatch(createUsername(userid, formData)),
showCreateDisplayNameDialog: () => dispatch(showCreateDisplayNameDialog()), showCreateUsernameDialog: () => dispatch(showCreateUsernameDialog()),
hideCreateDisplayNameDialog: () => dispatch(hideCreateDisplayNameDialog()), hideCreateUsernameDialog: () => dispatch(hideCreateUsernameDialog()),
invalidForm: error => dispatch(invalidForm(error)), invalidForm: error => dispatch(invalidForm(error)),
validForm: () => dispatch(validForm()) validForm: () => dispatch(validForm())
}); });
@@ -132,4 +132,4 @@ const mapDispatchToProps = dispatch => ({
export default connect( export default connect(
mapStateToProps, mapStateToProps,
mapDispatchToProps mapDispatchToProps
)(ChangeDisplayNameContainer); )(ChangeUsernameContainer);
+3 -3
View File
@@ -26,7 +26,7 @@ export default {
emailORusernameInUse: 'Email address or Username already in use', emailORusernameInUse: 'Email address or Username already in use',
requiredField: 'This field is required', requiredField: 'This field is required',
passwordsDontMatch: 'Passwords don\'t match.', passwordsDontMatch: 'Passwords don\'t match.',
specialCharacters: 'Display names can contain letters, numbers and _ only', specialCharacters: 'Usernames can contain letters, numbers and _ only',
checkTheForm: 'Invalid Form. Please, check the fields' checkTheForm: 'Invalid Form. Please, check the fields'
}, },
'createdisplay': { 'createdisplay': {
@@ -37,8 +37,8 @@ export default {
requiredField: 'Required field', requiredField: 'Required field',
errorCreate: 'Error when changing username', errorCreate: 'Error when changing username',
checkTheForm: 'Invalid Form. Please, check the fields', checkTheForm: 'Invalid Form. Please, check the fields',
specialCharacters: 'Display names can contain letters, numbers and _ only' specialCharacters: 'Usernames can contain letters, numbers and _ only'
}, }
}, },
es: { es: {
'signIn': { 'signIn': {
+15 -7
View File
@@ -54,8 +54,8 @@ const ErrEmailTaken = new APIError('Email address already in use', {
status: 400 status: 400
}); });
const ErrDisplayTaken = new APIError('Username already in use', { const ErrUsernameTaken = new APIError('Username already in use', {
translation_key: 'DISPLAYNAME_IN_USE', translation_key: 'USERNAME_IN_USE',
status: 400 status: 400
}); });
@@ -64,8 +64,8 @@ const ErrSpecialChars = new APIError('No special characters are allowed in a use
status: 400 status: 400
}); });
const ErrMissingDisplay = new APIError('A username is required to create a user', { const ErrMissingUsername = new APIError('A username is required to create a user', {
translation_key: 'DISPLAY_NAME_REQUIRED', translation_key: 'USERNAME_REQUIRED',
status: 400 status: 400
}); });
@@ -80,7 +80,8 @@ const ErrMissingToken = new APIError('token is required', {
class ErrAssetCommentingClosed extends APIError { class ErrAssetCommentingClosed extends APIError {
constructor(closedMessage = null) { constructor(closedMessage = null) {
super('asset commenting is closed', { super('asset commenting is closed', {
status: 400 status: 400,
translation_key: 'COMMENTING_CLOSED'
}, { }, {
// Include the closedMessage in the metadata piece of the error. // Include the closedMessage in the metadata piece of the error.
@@ -141,6 +142,12 @@ const ErrInstallLock = new APIError('install lock active', {
status: 500 status: 500
}); });
// ErrPermissionUpdateUsername is returned when the user does not have permission to update their username.
const ErrPermissionUpdateUsername = new APIError('You do not have permission to update your username.', {
translation_key: 'EDIT_USERNAME_NOT_AUTHORIZED',
status: 500
});
module.exports = { module.exports = {
ExtendableError, ExtendableError,
APIError, APIError,
@@ -151,14 +158,15 @@ module.exports = {
ErrMissingToken, ErrMissingToken,
ErrEmailTaken, ErrEmailTaken,
ErrSpecialChars, ErrSpecialChars,
ErrMissingDisplay, ErrMissingUsername,
ErrContainsProfanity, ErrContainsProfanity,
ErrDisplayTaken, ErrUsernameTaken,
ErrAssetCommentingClosed, ErrAssetCommentingClosed,
ErrNotFound, ErrNotFound,
ErrInvalidAssetURL, ErrInvalidAssetURL,
ErrAuthentication, ErrAuthentication,
ErrNotAuthorized, ErrNotAuthorized,
ErrPermissionUpdateUsername,
ErrSettingsInit, ErrSettingsInit,
ErrInstallLock ErrInstallLock
}; };
+47 -1
View File
@@ -68,6 +68,38 @@ const getCountsByParentID = (context, parent_ids) => {
.then((results) => results.map((result) => result ? result.count : 0)); .then((results) => results.map((result) => result ? result.count : 0));
}; };
/**
* Retrieves the count of comments based on the passed in query.
* @param {Object} context graph context
* @param {Object} query query to execute against the comments collection
* to compute the counts
* @return {Promise} resolves to the counts of the comments from the
* query
*/
const getCommentCountByQuery = (context, {ids, statuses, asset_id, parent_id}) => {
let query = CommentModel.find();
if (ids) {
query = query.where({id: {$in: ids}});
}
if (statuses) {
query = query.where({status: {$in: statuses}});
}
if (asset_id != null) {
query = query.where({asset_id});
}
if (parent_id !== undefined) {
query = query.where({parent_id});
}
return CommentModel
.find(query)
.count();
};
/** /**
* Retrieves comments based on the passed in query that is filtered by the * Retrieves comments based on the passed in query that is filtered by the
* current used passed in via the context. * current used passed in via the context.
@@ -101,6 +133,7 @@ const getCommentsByQuery = ({user}, {ids, statuses, asset_id, parent_id, author_
}); });
} }
// Only let an admin request any user or the current user request themself.
if (user && (user.hasRoles('ADMIN') || user.id === author_id) && author_id != null) { if (user && (user.hasRoles('ADMIN') || user.id === author_id) && author_id != null) {
comments = comments.where({author_id}); comments = comments.where({author_id});
} }
@@ -136,7 +169,13 @@ const getCommentsByQuery = ({user}, {ids, statuses, asset_id, parent_id, author_
.limit(limit); .limit(limit);
}; };
const genRecentReplies = (_, ids) => { /**
* Gets the recent replies.
* @param {Object} context graph context
* @param {Array<String>} ids ids of parent ids
* @return {Promise} resolves to recent replies
*/
const genRecentReplies = (context, ids) => {
return CommentModel.aggregate([ return CommentModel.aggregate([
// get all the replies for the comments in question // get all the replies for the comments in question
@@ -180,6 +219,12 @@ const genRecentReplies = (_, ids) => {
.then(util.arrayJoinBy(ids, 'parent_id')); .then(util.arrayJoinBy(ids, 'parent_id'));
}; };
/**
* Gets the recent comments.
* @param {Object} context graph context
* @param {Array<String>} ids ids of asset ids
* @return {Promise} resolves to recent comments from assets
*/
const genRecentComments = (_, ids) => { const genRecentComments = (_, ids) => {
return CommentModel.aggregate([ return CommentModel.aggregate([
@@ -233,6 +278,7 @@ const genRecentComments = (_, ids) => {
module.exports = (context) => ({ module.exports = (context) => ({
Comments: { Comments: {
getByQuery: (query) => getCommentsByQuery(context, query), getByQuery: (query) => getCommentsByQuery(context, query),
getCountByQuery: (query) => getCommentCountByQuery(context, query),
countByAssetID: new util.SharedCacheDataLoader('Comments.countByAssetID', 3600, (ids) => getCountsByAssetID(context, ids)), countByAssetID: new util.SharedCacheDataLoader('Comments.countByAssetID', 3600, (ids) => getCountsByAssetID(context, ids)),
countByParentID: new util.SharedCacheDataLoader('Comments.countByParentID', 3600, (ids) => getCountsByParentID(context, ids)), countByParentID: new util.SharedCacheDataLoader('Comments.countByParentID', 3600, (ids) => getCountsByParentID(context, ids)),
genRecentReplies: new DataLoader((ids) => genRecentReplies(context, ids)), genRecentReplies: new DataLoader((ids) => genRecentReplies(context, ids)),
+9 -8
View File
@@ -15,11 +15,18 @@ const Wordlist = require('../../services/wordlist');
* @return {Promise} resolves to the created comment * @return {Promise} resolves to the created comment
*/ */
const createComment = ({user, loaders: {Comments}}, {body, asset_id, parent_id = null}, status = 'NONE') => { const createComment = ({user, loaders: {Comments}}, {body, asset_id, parent_id = null}, status = 'NONE') => {
let tags = [];
if (user.hasRoles('ADMIN') || user.hasRoles('MODERATOR')) {
tags = [{name: 'STAFF'}];
}
return CommentsService.publicCreate({ return CommentsService.publicCreate({
body, body,
asset_id, asset_id,
parent_id, parent_id,
status, status,
tags,
author_id: user.id author_id: user.id
}) })
.then((comment) => { .then((comment) => {
@@ -36,12 +43,6 @@ const createComment = ({user, loaders: {Comments}}, {body, asset_id, parent_id =
} }
} }
if (user.hasRoles('ADMIN')) {
return CommentsService
.addTag(comment.id, 'STAFF', user.id)
.then(() => comment);
}
return comment; return comment;
}); });
}; };
@@ -140,12 +141,12 @@ const createPublicComment = (context, commentInput) => {
// Otherwise just return the new comment. // Otherwise just return the new comment.
// TODO: Check why the wordlist is undefined // TODO: Check why the wordlist is undefined
if (wordlist != null) { if (wordlist != null && wordlist.suspect != null) {
// TODO: this is kind of fragile, we should refactor this to resolve // TODO: this is kind of fragile, we should refactor this to resolve
// all these const's that we're using like 'COMMENTS', 'FLAG' to be // all these const's that we're using like 'COMMENTS', 'FLAG' to be
// defined in a checkable schema. // defined in a checkable schema.
return context.mutators.Action.createAction(null, { return context.mutators.Action.create({
item_id: comment.id, item_id: comment.id,
item_type: 'COMMENTS', item_type: 'COMMENTS',
action_type: 'FLAG', action_type: 'FLAG',
+17 -3
View File
@@ -1,3 +1,6 @@
const {Error: {ValidationError}} = require('mongoose');
const errors = require('../../errors');
/** /**
* Wraps up a promise to return an object with the resolution of the promise * Wraps up a promise to return an object with the resolution of the promise
* keyed at `key` or an error caught at `errors`. * keyed at `key` or an error caught at `errors`.
@@ -9,9 +12,20 @@ const wrapResponse = (key) => (promise) => {
res[key] = value; res[key] = value;
} }
return res; return res;
}).catch((err) => ({ }).catch((err) => {
errors: [err]
})); if (err instanceof errors.APIError) {
return {
errors: [err]
};
} else if (err instanceof ValidationError) {
// TODO: wrap this with one of our internal errors.
throw err;
}
throw err;
});
}; };
const RootMutation = { const RootMutation = {
+17
View File
@@ -39,6 +39,23 @@ const RootQuery = {
return Comments.getByQuery(query); return Comments.getByQuery(query);
}, },
commentCount(_, {query: {action_type, statuses, asset_id, parent_id}}, {user, loaders: {Actions, Comments}}) {
if (user == null || !user.hasRoles('ADMIN')) {
return null;
}
if (action_type) {
return Actions.getByTypes({action_type, item_type: 'COMMENTS'})
.then((ids) => {
// Perform the query using the available resolver.
return Comments.getCountByQuery({ids, statuses, asset_id, parent_id});
});
}
return Comments.getCountByQuery({statuses, asset_id, parent_id});
},
metrics(_, {from, to, sort, limit = 10}, {user, loaders: {Metrics}}) { metrics(_, {from, to, sort, limit = 10}, {user, loaders: {Metrics}}) {
if (user == null || !user.hasRoles('ADMIN')) { if (user == null || !user.hasRoles('ADMIN')) {
return null; return null;
+45 -19
View File
@@ -27,7 +27,7 @@ type User {
# The ID of the User. # The ID of the User.
id: ID! id: ID!
# username of a user. # Username of a user.
username: String! username: String!
# Action summaries against the user. # Action summaries against the user.
@@ -99,10 +99,40 @@ enum ACTION_TYPE {
# CommentsQuery allows the ability to query comments by a specific methods. # CommentsQuery allows the ability to query comments by a specific methods.
input CommentsQuery { input CommentsQuery {
# current status of a comment. # Current status of a comment. Requires the `ADMIN` role.
statuses: [COMMENT_STATUS!] statuses: [COMMENT_STATUS!]
# asset that a comment is on. # Asset that a comment is on.
asset_id: ID
# The parent of the comment that we want to retrieve.
parent_id: ID
# Comments returned will only be ones which have at least one action of this
# type. Requires the `ADMIN` role.
action_type: ACTION_TYPE
# Limit the number of results to be returned.
limit: Int = 10
# Skip results from the last created_at timestamp.
cursor: Date
# Filter by a specific tag name.
tag: [String]
# Sort the results by created_at.
sort: SORT_ORDER = REVERSE_CHRONOLOGICAL
}
# CommentCountQuery allows the ability to query comment counts by specific
# methods.
input CommentCountQuery {
# Current status of a comment. Requires the `ADMIN` role.
statuses: [COMMENT_STATUS!]
# Asset that a comment is on.
asset_id: ID asset_id: ID
# the parent of the comment that we want to retrieve. # the parent of the comment that we want to retrieve.
@@ -112,17 +142,8 @@ input CommentsQuery {
# type. # type.
action_type: ACTION_TYPE action_type: ACTION_TYPE
# limit the number of results to be returned. # Filter by a specific tag name.
limit: Int = 10
# skip results from the last created_at timestamp.
cursor: Date
# filter by a specific tag name.
tag: [String] tag: [String]
# sort the results by created_at.
sort: SORT_ORDER = REVERSE_CHRONOLOGICAL
} }
# Comment is the base representation of user interaction in Talk. # Comment is the base representation of user interaction in Talk.
@@ -149,7 +170,7 @@ type Comment {
# The count of replies on a comment. # The count of replies on a comment.
replyCount: Int replyCount: Int
# Actions completed on the parent. # Actions completed on the parent. Requires the `ADMIN` role.
actions: [Action] actions: [Action]
# Action summaries against a comment. # Action summaries against a comment.
@@ -388,7 +409,7 @@ type Asset {
closedAt: Date closedAt: Date
# Summary of all Actions against all entities associated with the Asset. # Summary of all Actions against all entities associated with the Asset.
# (likes, flags, etc.) # (likes, flags, etc.). Requires the `ADMIN` role.
action_summaries: [AssetActionSummary] action_summaries: [AssetActionSummary]
# The date that the asset was created. # The date that the asset was created.
@@ -456,7 +477,7 @@ type RootQuery {
# Site wide settings and defaults. # Site wide settings and defaults.
settings: Settings settings: Settings
# All assets. # All assets. Requires the `ADMIN` role.
assets: [Asset] assets: [Asset]
# Find or create an asset by url, or just find with the ID. # Find or create an asset by url, or just find with the ID.
@@ -465,7 +486,12 @@ type RootQuery {
# Comments returned based on a query. # Comments returned based on a query.
comments(query: CommentsQuery!): [Comment] comments(query: CommentsQuery!): [Comment]
# The currently logged in user based on the request. # Returne the count of comments satisfied by the query. Note that this edge is
# expensive as it is not batched. Requires the `ADMIN` role.
commentCount(query: CommentCountQuery!): Int
# The currently logged in user based on the request. Requires any logged in
# role.
me: User me: User
# Metrics related to user actions are saturated into the assets returned. The # Metrics related to user actions are saturated into the assets returned. The
@@ -623,10 +649,10 @@ type RootMutation {
# Delete an action based on the action id. # Delete an action based on the action id.
deleteAction(id: ID!): DeleteActionResponse deleteAction(id: ID!): DeleteActionResponse
# Sets User status # Sets User status. Requires the `ADMIN` role.
setUserStatus(id: ID!, status: USER_STATUS!): SetUserStatusResponse setUserStatus(id: ID!, status: USER_STATUS!): SetUserStatusResponse
# Sets Comment status # Sets Comment status. Requires the `ADMIN` role.
setCommentStatus(id: ID!, status: COMMENT_STATUS!): SetCommentStatusResponse setCommentStatus(id: ID!, status: COMMENT_STATUS!): SetCommentStatusResponse
} }
+4 -1
View File
@@ -43,7 +43,10 @@ const TagSchema = new Schema({
default: null default: null
}, },
created_at: Date created_at: {
type: Date,
default: Date
}
}, { }, {
_id: false _id: false
}); });
+2 -2
View File
@@ -49,7 +49,6 @@
}, },
"homepage": "https://github.com/coralproject/talk#readme", "homepage": "https://github.com/coralproject/talk#readme",
"dependencies": { "dependencies": {
"apollo-client": "^0.8.3",
"bcrypt": "^0.8.7", "bcrypt": "^0.8.7",
"body-parser": "^1.15.2", "body-parser": "^1.15.2",
"cli-table": "^0.3.1", "cli-table": "^0.3.1",
@@ -66,7 +65,6 @@
"graphql": "^0.8.2", "graphql": "^0.8.2",
"graphql-errors": "^2.1.0", "graphql-errors": "^2.1.0",
"graphql-server-express": "^0.5.0", "graphql-server-express": "^0.5.0",
"graphql-tag": "^1.2.3",
"graphql-tools": "^0.9.0", "graphql-tools": "^0.9.0",
"helmet": "^3.1.0", "helmet": "^3.1.0",
"inquirer": "^3.0.1", "inquirer": "^3.0.1",
@@ -88,6 +86,7 @@
"uuid": "^2.0.3" "uuid": "^2.0.3"
}, },
"devDependencies": { "devDependencies": {
"apollo-client": "^0.8.3",
"autoprefixer": "^6.5.2", "autoprefixer": "^6.5.2",
"babel-core": "^6.21.0", "babel-core": "^6.21.0",
"babel-eslint": "^7.1.0", "babel-eslint": "^7.1.0",
@@ -122,6 +121,7 @@
"exports-loader": "^0.6.3", "exports-loader": "^0.6.3",
"fetch-mock": "^5.5.0", "fetch-mock": "^5.5.0",
"graphql-docs": "^0.2.0", "graphql-docs": "^0.2.0",
"graphql-tag": "^1.2.3",
"hammerjs": "^2.0.8", "hammerjs": "^2.0.8",
"ignore-styles": "^5.0.1", "ignore-styles": "^5.0.1",
"immutable": "^3.8.1", "immutable": "^3.8.1",
+1 -1
View File
@@ -123,7 +123,7 @@ router.put('/username', authorization.needed(), (req, res, next) => {
}) })
.catch(error => { .catch(error => {
if (error.code === 11000) { if (error.code === 11000) {
next(errors.ErrDisplayTaken); next(errors.ErrUsernameTaken);
} else { } else {
next(error); next(error);
} }
-20
View File
@@ -1,20 +0,0 @@
const express = require('express');
const ActionsService = require('../../../services/actions');
const router = express.Router();
router.delete('/:action_id', (req, res, next) => {
ActionsService
.findOneAndRemove({
id: req.params.action_id,
user_id: req.user.id
})
.then(() => {
res.status(204).end();
})
.catch(error => {
next(error);
});
});
module.exports = router;
-130
View File
@@ -1,130 +0,0 @@
const express = require('express');
const errors = require('../../../errors');
const CommentModel = require('../../../models/comment');
const CommentsService = require('../../../services/comments');
const AssetsService = require('../../../services/assets');
const UsersService = require('../../../services/users');
const ActionsService = require('../../../services/actions');
const authorization = require('../../../middleware/authorization');
const _ = require('lodash');
const router = express.Router();
router.get('/', (req, res, next) => {
const {
status = null,
action_type = null,
asset_id = null,
user_id = null
} = req.query;
// everything on this route requires admin privileges besides listing comments for owner of said comments
if (!authorization.has(req.user, 'ADMIN') && !user_id) {
next(errors.ErrNotAuthorized);
return;
}
// if the user is not an admin, only return comment list for the owner of the comments
if (req.user.id !== user_id && !authorization.has(req.user, 'ADMIN')) {
next(errors.ErrNotAuthorized);
return;
}
/**
* This adds the asset_id requirement to the query if the asset_id is defined.
*/
const assetIDWrap = (query) => {
if (asset_id) {
query = query.where('asset_id', asset_id);
}
return query;
};
let query;
// the check for user_id MUST be first here.
// otherwise this will be a vulnerability if you pass user_id and something else,
// the app will return admin-level data without the proper checks
if (user_id) {
query = CommentsService.findByUserId(user_id, authorization.has(req.user, 'ADMIN'));
} else if (status) {
query = assetIDWrap(CommentsService.findByStatus(status === 'NEW' ? 'NONE' : status));
} else if (action_type) {
query = CommentsService
.findIdsByActionType(action_type)
.then((ids) => assetIDWrap(CommentModel.find({
id: {
$in: ids
}
})));
} else {
query = assetIDWrap(CommentsService.all());
}
query.then((comments) => {
return Promise.all([
comments,
AssetsService.findMultipleById(comments.map(comment => comment.asset_id)),
UsersService.findByIdArray(_.uniq(comments.map((comment) => comment.author_id))),
ActionsService.getActionSummariesFromComments(asset_id, comments, req.user ? req.user.id : false)
]);
})
.then(([comments, assets, users, actions]) =>
res.status(200).json({
comments,
assets,
users,
actions
}))
.catch((err) => {
next(err);
});
});
router.get('/:comment_id', authorization.needed('ADMIN'), (req, res, next) => {
CommentsService
.findById(req.params.comment_id)
.then(comment => {
if (!comment) {
res.status(404).end();
return;
}
res.status(200).json(comment);
})
.catch((err) => {
next(err);
});
});
router.delete('/:comment_id', authorization.needed('ADMIN'), (req, res, next) => {
CommentsService
.removeById(req.params.comment_id)
.then(() => {
res.status(204).end();
})
.catch((err) => {
next(err);
});
});
router.put('/:comment_id/status', authorization.needed('ADMIN'), (req, res, next) => {
const {
status
} = req.body;
CommentsService
.pushStatus(req.params.comment_id, status, req.user.id)
.then(() => {
res.status(204).end();
})
.catch((err) => {
next(err);
});
});
module.exports = router;
-4
View File
@@ -5,10 +5,6 @@ const router = express.Router();
router.use('/assets', authorization.needed('ADMIN'), require('./assets')); router.use('/assets', authorization.needed('ADMIN'), require('./assets'));
router.use('/settings', authorization.needed('ADMIN'), require('./settings')); router.use('/settings', authorization.needed('ADMIN'), require('./settings'));
router.use('/queue', authorization.needed('ADMIN'), require('./queue'));
router.use('/comments', authorization.needed(), require('./comments'));
router.use('/actions', authorization.needed(), require('./actions'));
router.use('/auth', require('./auth')); router.use('/auth', require('./auth'));
router.use('/users', require('./users')); router.use('/users', require('./users'));
-101
View File
@@ -1,101 +0,0 @@
const express = require('express');
const CommentsService = require('../../../services/comments');
const CommentModel = require('../../../models/comment');
const UsersService = require('../../../services/users');
const ActionsService = require('../../../services/actions');
const authorization = require('../../../middleware/authorization');
const _ = require('lodash');
const router = express.Router();
function gatherActionsAndUsers (comments) {
return Promise.all([
comments,
UsersService.findByIdArray(_.uniq(comments.map((comment) => comment.author_id))),
ActionsService.getActionSummaries(_.uniq([
...comments.map((comment) => comment.id),
...comments.map((comment) => comment.author_id)
]))
]);
}
//==============================================================================
// Get Routes
//==============================================================================
// Returns back all the comments that are in the moderation queue. The moderation queue is pre or post moderated,
// depending on the settings. The :moderation overwrites this settings.
// Pre-moderation: New comments are shown in the moderator queues immediately.
// Post-moderation: New comments do not appear in moderation queues unless they are flagged by other users.
router.get('/comments/premod', authorization.needed('ADMIN'), (req, res, next) => {
const {asset_id} = req.query;
CommentsService.moderationQueue('PREMOD', asset_id)
.then(gatherActionsAndUsers)
.then(([comments, users, actions]) => {
res.json({comments, users, actions});
})
.catch(error => {
next(error);
});
});
router.get('/comments/rejected', authorization.needed('ADMIN'), (req, res, next) => {
const {asset_id} = req.query;
CommentsService.moderationQueue('REJECTED', asset_id)
.then(gatherActionsAndUsers)
.then(([comments, users, actions]) => {
res.json({comments, users, actions});
})
.catch(error => {
next(error);
});
});
router.get('/comments/flagged', authorization.needed('ADMIN'), (req, res, next) => {
const {asset_id} = req.query;
const assetIDWrap = (query) => {
if (asset_id) {
query = query.where('asset_id', asset_id);
}
return query;
};
CommentsService.findIdsByActionType('FLAG')
.then(ids => assetIDWrap(CommentModel.find({
id: {$in: ids}
})))
.then(gatherActionsAndUsers)
.then(([comments, users, actions]) => {
res.json({comments, users, actions});
})
.catch(error => {
next(error);
});
});
// Returns back all the users that are in the moderation queue.
router.get('/users/flagged', (req, res, next) => {
UsersService.moderationQueue()
.then((users) => {
return Promise.all([
users,
ActionsService.getActionSummaries(users.map((user) => user.id))
]);
})
.then(([users, actions]) => {
res.json({
users,
actions
});
})
.catch(error => {
next(error);
});
});
module.exports = router;
+1 -2
View File
@@ -136,7 +136,7 @@ router.post('/', (req, res, next) => {
res.status(201).json(user); res.status(201).json(user);
}); });
}) })
.catch(err => { .catch((err) => {
next(err); next(err);
}); });
}); });
@@ -163,7 +163,6 @@ router.post('/:user_id/actions', authorization.needed(), (req, res, next) => {
res.status(201).json(action); res.status(201).json(action);
}) })
.catch((err) => { .catch((err) => {
console.log('Error', err);
next(err); next(err);
}); });
}); });
+7 -17
View File
@@ -29,27 +29,17 @@ module.exports = class CommentsService {
} }
const { const {
body,
asset_id,
parent_id,
status = 'NONE', status = 'NONE',
author_id
} = comment; } = comment;
comment = new CommentModel({ comment.status_history = status ? [{
body, type: status,
asset_id, created_at: new Date()
parent_id, }] : [];
status_history: status ? [{
type: status,
created_at: new Date()
}] : [],
tags: [],
status,
author_id
});
return comment.save(); let commentModel = new CommentModel(comment);
return commentModel.save();
} }
/** /**
-2
View File
@@ -103,8 +103,6 @@ if (process.env.TALK_FACEBOOK_APP_ID && process.env.TALK_FACEBOOK_APP_SECRET &&
clientSecret: process.env.TALK_FACEBOOK_APP_SECRET, clientSecret: process.env.TALK_FACEBOOK_APP_SECRET,
callbackURL: `${process.env.TALK_ROOT_URL}/api/v1/auth/facebook/callback`, callbackURL: `${process.env.TALK_ROOT_URL}/api/v1/auth/facebook/callback`,
// TODO: remove displayName reference when we have steps in the FE to handle
// the username create flow.
profileFields: ['id', 'displayName', 'picture.type(large)'] profileFields: ['id', 'displayName', 'picture.type(large)']
}, (accessToken, refreshToken, profile, done) => { }, (accessToken, refreshToken, profile, done) => {
UsersService UsersService
+1 -1
View File
@@ -57,7 +57,7 @@ module.exports = class SetupService {
// Verify other properties of the user. // Verify other properties of the user.
return Promise.all([ return Promise.all([
UsersService.isValidDisplayName(username, false), UsersService.isValidUsername(username, false),
UsersService.isValidPassword(password), UsersService.isValidPassword(password),
settingsModel.validate() settingsModel.validate()
]); ]);
+7 -7
View File
@@ -109,7 +109,7 @@ module.exports = class UsersService {
* @param {Object} profile - User social/external profile * @param {Object} profile - User social/external profile
* @param {Function} done [description] * @param {Function} done [description]
*/ */
static findOrCreateExternalUser({id, provider, displayname}) { static findOrCreateExternalUser({id, provider, displayName}) {
return UserModel return UserModel
.findOne({ .findOne({
profiles: { profiles: {
@@ -124,7 +124,7 @@ module.exports = class UsersService {
return user; return user;
} }
let username = UsersService.castUsername(displayname); let username = UsersService.castUsername(displayName);
// The user was not found, lets create them! // The user was not found, lets create them!
user = new UserModel({ user = new UserModel({
@@ -176,11 +176,11 @@ module.exports = class UsersService {
* @param {Boolean} checkAgainstWordlist enables cheching against the wordlist * @param {Boolean} checkAgainstWordlist enables cheching against the wordlist
* @return {Promise} * @return {Promise}
*/ */
static isValidUserName(username, checkAgainstWordlist = true) { static isValidUsername(username, checkAgainstWordlist = true) {
const onlyLettersNumbersUnderscore = /^[A-Za-z0-9_]+$/; const onlyLettersNumbersUnderscore = /^[A-Za-z0-9_]+$/;
if (!username) { if (!username) {
return Promise.reject(errors.ErrMissingDisplay); return Promise.reject(errors.ErrMissingUsername);
} }
if (!onlyLettersNumbersUnderscore.test(username)) { if (!onlyLettersNumbersUnderscore.test(username)) {
@@ -230,7 +230,7 @@ module.exports = class UsersService {
username = username.trim(); username = username.trim();
return Promise.all([ return Promise.all([
UsersService.isValidUserName(username), UsersService.isValidUsername(username),
UsersService.isValidPassword(password) UsersService.isValidPassword(password)
]) ])
.then(() => { // username is valid .then(() => { // username is valid
@@ -257,7 +257,7 @@ module.exports = class UsersService {
if (err) { if (err) {
if (err.code === 11000) { if (err.code === 11000) {
if (err.message.match('Username')) { if (err.message.match('Username')) {
return reject(errors.ErrDisplayTaken); return reject(errors.ErrUsernameTaken);
} }
return reject(errors.ErrEmailTaken); return reject(errors.ErrEmailTaken);
} }
@@ -688,7 +688,7 @@ module.exports = class UsersService {
} }
}).then((result) => { }).then((result) => {
return result.nModified > 0 ? result : return result.nModified > 0 ? result :
Promise.reject(new Error('You do not have permission to update your username.')); Promise.reject(errors.ErrPermissionUpdateUsername);
}); });
} }
}; };
+2 -2
View File
@@ -19,7 +19,7 @@ const embedStreamCommands = {
.setValue('@signInDialogEmail', user.email) .setValue('@signInDialogEmail', user.email)
.setValue('@signInDialogPassword', user.pass) .setValue('@signInDialogPassword', user.pass)
.setValue('@signUpDialogConfirmPassword', user.pass) .setValue('@signUpDialogConfirmPassword', user.pass)
.setValue('@signUpDialogDisplayName', user.username) .setValue('@signUpDialogUsername', user.username)
.waitForElementVisible('@signUpButton') .waitForElementVisible('@signUpButton')
.click('@signUpButton') .click('@signUpButton')
.waitForElementVisible('@signInViewTrigger') .waitForElementVisible('@signInViewTrigger')
@@ -95,7 +95,7 @@ module.exports = {
signUpDialogConfirmPassword: { signUpDialogConfirmPassword: {
selector: '#signInDialog #confirmPassword' selector: '#signInDialog #confirmPassword'
}, },
signUpDialogDisplayName: { signUpDialogUsername: {
selector: '#signInDialog #username' selector: '#signInDialog #username'
}, },
logInButton: { logInButton: {
+64
View File
@@ -0,0 +1,64 @@
const expect = require('chai').expect;
const User = require('../../models/user');
const Context = require('../../graph/context');
const errors = require('../../errors');
describe('graph.Context', () => {
describe('#constructor: with a user', () => {
let c;
beforeEach(() => {
c = new Context({user: new User({id: '1'})});
});
it('creates a context with a user', (done) => {
expect(c).to.have.property('user');
expect(c.user).to.have.property('id', '1');
done();
});
it('does have access to mutators', () => {
return c.mutators.Action.create({
item_id: '1',
item_type: 'COMMENTS',
action_type: 'LIKE'
})
.then((action) => {
expect(action).to.have.property('item_id', '1');
expect(action).to.have.property('item_type', 'COMMENTS');
expect(action).to.have.property('action_type', 'LIKE');
});
});
});
describe('#constructor: without a user', () => {
let c;
beforeEach(() => {
c = new Context({user: undefined});
});
it('creates a context without a user', (done) => {
expect(c).to.not.have.property('user');
done();
});
it('does not have access to mutators', () => {
return c.mutators.Action.create({
item_id: '1',
item_type: 'COMMENTS',
action_type: 'LIKE'
})
.then((action) => {
expect(action).to.be.null;
})
.catch((err) => {
expect(err).to.be.equal(errors.ErrNotAuthorized);
});
});
});
});
+233
View File
@@ -0,0 +1,233 @@
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 AssetModel = require('../../../models/asset');
const SettingsService = require('../../../services/settings');
const ActionModel = require('../../../models/action');
describe('graph.mutations.createComment', () => {
beforeEach(() => SettingsService.init());
const query = `
mutation CreateComment($body: String = "Here's my comment!") {
createComment(asset_id: "123", body: $body) {
comment {
id
status
tags {
name
}
}
errors {
translation_key
}
}
}
`;
describe('context with different user properties', () => {
beforeEach(() => AssetModel.create({id: '123'}));
[
{user: null, error: 'NOT_AUTHORIZED'},
{user: new UserModel({}), error: null}
].forEach(({user, error}) => {
describe(user != null ? 'with user' : 'without user', () => {
it(error ? 'does not create the comment' : 'creates the comment', () => {
const context = new Context({user});
return graphql(schema, query, {}, context)
.then(({data, errors}) => {
expect(errors).to.be.undefined;
if (error) {
expect(data.createComment).to.have.property('comment').null;
expect(data.createComment).to.have.property('errors').not.null;
expect(data.createComment.errors[0]).to.have.property('translation_key', error);
} else {
expect(data.createComment).to.have.property('comment').not.null;
expect(data.createComment).to.have.property('errors').null;
}
});
});
});
});
});
describe('users with different statuses', () => {
beforeEach(() => AssetModel.create({id: '123'}));
[
{user: new UserModel({status: 'ACTIVE'}), error: null},
{user: new UserModel({status: 'BANNED'}), error: 'NOT_AUTHORIZED'},
{user: new UserModel({status: 'PENDING'}), error: null},
{user: new UserModel({status: 'APPROVED'}), error: null}
].forEach(({user, error}) => {
describe(`user.status=${user.status}`, () => {
it(error ? 'does not create the comment' : 'creates the comment', () => {
const context = new Context({user});
return graphql(schema, query, {}, context)
.then(({data, errors}) => {
expect(errors).to.be.undefined;
if (error) {
expect(data.createComment).to.have.property('comment').null;
expect(data.createComment).to.have.property('errors').not.null;
expect(data.createComment.errors[0]).to.have.property('translation_key', error);
} else {
expect(data.createComment).to.have.property('comment').not.null;
expect(data.createComment).to.have.property('errors').null;
}
});
});
});
});
});
describe('assets with different statuses', () => {
[
{asset: new AssetModel({id: '123', closedAt: new Date((new Date()).getTime() + (10 * 86400000))}), error: null},
{asset: new AssetModel({id: '123', closedAt: new Date((new Date()).getTime() - (10 * 86400000))}), error: 'COMMENTING_CLOSED'}
].forEach(({asset, error}) => {
describe(`asset.isClosed=${asset.isClosed}`, () => {
beforeEach(() => asset.save());
it(error ? 'does not create the comment' : 'creates the comment', () => {
const context = new Context({user: new UserModel({status: 'ACTIVE'})});
return graphql(schema, query, {}, context)
.then(({data, errors}) => {
expect(errors).to.be.undefined;
if (error) {
expect(data.createComment).to.have.property('comment').null;
expect(data.createComment).to.have.property('errors').not.null;
expect(data.createComment.errors[0]).to.have.property('translation_key', error);
} else {
expect(data.createComment).to.have.property('comment').not.null;
expect(data.createComment).to.have.property('errors').null;
}
});
});
});
});
});
describe('comments made with different asset moderation settings', () => {
[
{moderation: 'PRE', status: 'PREMOD'},
{moderation: 'POST', status: 'NONE'}
].forEach(({moderation, status}) => {
describe(`moderation=${moderation}`, () => {
beforeEach(() => AssetModel.create({id: '123', settings: {moderation}}));
it(`creates comment with status=${status}`, () => {
const context = new Context({user: new UserModel({status: 'ACTIVE'})});
return graphql(schema, query, {}, context)
.then(({data, errors}) => {
expect(errors).to.be.undefined;
expect(data.createComment).to.have.property('comment').not.null;
expect(data.createComment).to.have.property('errors').null;
expect(data.createComment.comment).to.have.property('status', status);
});
});
});
});
});
describe('comments with/without banned words', () => {
beforeEach(() => Promise.all([
AssetModel.create({id: '123'}),
SettingsService.update({wordlist: {banned: ['WORST'], suspect: ['EH']}})
]));
[
{message: 'comment does not contain banned/suspect words', body: 'This is such a nice comment!', status: 'NONE', flagged: false},
{message: 'comment contains banned words', body: 'This is the WORST comment!', status: 'REJECTED', flagged: false},
{message: 'comment contains suspect words', body: 'This is the EH comment!', status: 'NONE', flagged: true}
].forEach(({message, body, status, flagged}) => {
describe(message, () => {
it(`should create a comment with status=${status} and it ${flagged ? 'should' : 'should not'} be flagged`, () => {
const context = new Context({user: new UserModel({status: 'ACTIVE'})});
return graphql(schema, query, {}, context, {
body
})
.then(({data, errors}) => {
expect(errors).to.be.undefined;
expect(data.createComment).to.have.property('comment').not.null;
expect(data.createComment.comment).to.have.property('status', status);
expect(data.createComment).to.have.property('errors').null;
return ActionModel.find({
item_id: data.createComment.comment.id,
action_type: 'FLAG'
});
})
.then((actions) => {
if (flagged) {
expect(actions).to.have.length(1);
} else {
expect(actions).to.have.length(0);
}
});
});
});
});
});
describe('users with different roles', () => {
beforeEach(() => AssetModel.create({id: '123'}));
[
{roles: [], tag: null},
{roles: ['ADMIN'], tag: 'STAFF'},
{roles: ['MODERATOR'], tag: 'STAFF'},
{roles: ['ADMIN', 'MODERATOR'], tag: 'STAFF'}
].forEach(({roles, tag}) => {
describe(`user.roles=${JSON.stringify(roles)}`, () => {
it(`creates comment ${tag ? `with tag=${tag}` : 'without tags'}`, () => {
const context = new Context({user: new UserModel({roles})});
return graphql(schema, query, {}, context)
.then(({data, errors}) => {
expect(errors).to.be.undefined;
expect(data.createComment).to.have.property('comment').not.null;
expect(data.createComment).to.have.property('errors').null;
if (tag) {
expect(data.createComment.comment).to.have.property('tags').length(1);
expect(data.createComment.comment.tags[0]).to.have.property('name', tag);
} else {
expect(data.createComment.comment).to.have.property('tags').length(0);
}
});
});
});
});
});
});
-269
View File
@@ -1,269 +0,0 @@
const passport = require('../../../passport');
const app = require('../../../../app');
const chai = require('chai');
const expect = chai.expect;
// Setup chai.
chai.should();
chai.use(require('chai-http'));
const CommentModel = require('../../../../models/comment');
const ActionModel = require('../../../../models/action');
const CommentsService = require('../../../../services/comments');
const UsersService = require('../../../../services/users');
const SettingsService = require('../../../../services/settings');
const settings = {id: '1', moderation: 'PRE', wordlist: {banned: ['bad words'], suspect: ['suspect words']}};
describe('/api/v1/comments', () => {
// Ensure that the settings are always available.
beforeEach(() => SettingsService.init(settings));
describe('#get', () => {
const comments = [{
body: 'comment 10',
asset_id: 'asset',
author_id: '123'
}, {
body: 'comment 20',
asset_id: 'asset',
author_id: '456'
}, {
body: 'comment 20',
asset_id: 'asset',
author_id: '456',
status: 'REJECTED',
status_history: [{
type: 'REJECTED'
}]
}, {
body: 'comment 30',
asset_id: '456',
status: 'ACCEPTED',
status_history: [{
type: 'ACCEPTED'
}]
}];
const users = [{
username: 'Ana',
email: 'ana@gmail.com',
password: '123456789'
}, {
username: 'Maria',
email: 'maria@gmail.com',
password: '123456789'
}];
const actions = [{
action_type: 'FLAG',
item_id: 'abc',
item_type: 'COMMENTS'
}, {
action_type: 'LIKE',
item_id: 'hij',
item_type: 'COMMENTS'
}];
beforeEach(() => {
return Promise.all([
CommentModel.create(comments).then((newComments) => {
newComments.forEach((comment, i) => {
comments[i].id = comment.id;
});
actions[0].item_id = comments[0].id;
actions[1].item_id = comments[1].id;
return ActionModel.create(actions);
}),
UsersService.createLocalUsers(users)
]);
});
it('should return only the owners published comments if the user is not an admin', () => {
return chai.request(app)
.get('/api/v1/comments?user_id=456')
.set(passport.inject({id: '456', roles: []}))
.then(res => {
expect(res).to.have.status(200);
expect(res.body.comments).to.have.length(1);
expect(res.body.comments[0]).to.have.property('author_id', '456');
});
});
it('should fail if a non-admin requests comments not owned by them', () => {
return chai.request(app)
.get('/api/v1/comments?user_id=456')
.set(passport.inject({id: '123', roles: []}))
.then((res) => {
expect(res).to.be.empty;
})
.catch((err) => {
expect(err).to.have.status(401);
});
});
it('should return all the comments', () => {
return chai.request(app)
.get('/api/v1/comments')
.set(passport.inject({roles: ['ADMIN']}))
.then((res) => {
expect(res).to.have.status(200);
});
});
it('should return all the rejected comments', () => {
return chai.request(app)
.get('/api/v1/comments?status=REJECTED')
.set(passport.inject({roles: ['ADMIN']}))
.then((res) => {
expect(res).to.have.status(200);
expect(res.body).to.have.property('comments');
expect(res.body.comments).to.have.length(1);
expect(res.body.comments[0]).to.have.property('id', comments[2].id);
});
});
it('should return all the approved comments', () => {
return chai.request(app)
.get('/api/v1/comments?status=ACCEPTED')
.set(passport.inject({roles: ['ADMIN']}))
.then((res) => {
expect(res).to.have.status(200);
expect(res.body.comments).to.have.length(1);
expect(res.body.comments[0]).to.have.property('id', comments[3].id);
});
});
it('should return all the new comments', () => {
return chai.request(app)
.get('/api/v1/comments?status=NEW')
.set(passport.inject({roles: ['ADMIN']}))
.then((res) => {
expect(res).to.have.status(200);
expect(res.body.comments).to.have.length(2);
});
});
it('should return all the flagged comments', () => {
return chai.request(app)
.get('/api/v1/comments?action_type=FLAG')
.set(passport.inject({roles: ['ADMIN']}))
.then((res) => {
expect(res).to.have.status(200);
expect(res.body.comments).to.have.length(1);
expect(res.body.comments[0]).to.have.property('id', comments[0].id);
});
});
});
});
describe('/api/v1/comments/:comment_id', () => {
const comments = [{
id: 'abc',
body: 'comment 10',
asset_id: 'asset',
author_id: '123'
}, {
id: 'def',
body: 'comment 20',
asset_id: 'asset',
author_id: '456'
}, {
id: 'hij',
body: 'comment 30',
asset_id: '456'
}];
const users = [{
username: 'Ana',
email: 'ana@gmail.com',
password: '123456789'
}, {
username: 'Maria',
email: 'maria@gmail.com',
password: '123456789'
}];
const actions = [{
action_type: 'FLAG',
item_id: 'abc',
item_type: 'COMMENTS'
}, {
action_type: 'LIKE',
item_id: 'hij',
item_type: 'COMMENTS'
}];
beforeEach(() => {
return SettingsService.init(settings).then(() => {
return Promise.all([
CommentModel.create(comments),
UsersService.createLocalUsers(users),
ActionModel.create(actions)
]);
});
});
describe('#get', () => {
it('should return the right comment for the comment_id', () => {
return chai.request(app)
.get('/api/v1/comments/abc')
.set(passport.inject({roles: ['ADMIN']}))
.then((res) => {
expect(res).to.have.status(200);
expect(res).to.have.property('body');
expect(res.body).to.have.property('body', 'comment 10');
});
});
});
describe('#delete', () => {
it('it should remove comment', () => {
return chai.request(app)
.delete('/api/v1/comments/abc')
.set(passport.inject({roles: ['ADMIN']}))
.then((res) => {
expect(res).to.have.status(204);
return CommentsService.findById('abc');
})
.then((comment) => {
expect(comment).to.be.null;
});
});
});
describe('#put', () => {
it('it should update status', function() {
return chai.request(app)
.put('/api/v1/comments/abc/status')
.set(passport.inject({roles: ['ADMIN']}))
.send({status: 'accepted'})
.then((res) => {
expect(res).to.have.status(204);
expect(res.body).to.be.empty;
});
});
it('it should not allow a non-ADMIN to update status', () => {
return chai.request(app)
.put('/api/v1/comments/abc/status')
.set(passport.inject({roles: []}))
.send({status: 'accepted'})
.then((res) => {
expect(res).to.be.empty;
})
.catch((err) => {
expect(err).to.have.property('status', 401);
});
});
});
});
-123
View File
@@ -1,123 +0,0 @@
const passport = require('../../../passport');
const app = require('../../../../app');
const chai = require('chai');
const expect = chai.expect;
// Setup chai.
chai.should();
chai.use(require('chai-http'));
const Comment = require('../../../../models/comment');
const Action = require('../../../../models/action');
const UsersService = require('../../../../services/users');
const SettingsService = require('../../../../services/settings');
const settings = {id: '1', moderation: 'PRE', wordlist: {banned: ['banned'], suspect: ['suspect']}};
describe('/api/v1/queue', () => {
const comments = [{
id: 'abc',
body: 'comment 10',
asset_id: 'asset',
author_id: '123',
status: 'REJECTED',
status_history: [{
type: 'REJECTED'
}]
}, {
id: 'def',
body: 'comment 20',
asset_id: 'asset',
author_id: '456',
status: 'PREMOD',
status_history: [{
type: 'PREMOD'
}]
}, {
id: 'hij',
body: 'comment 30',
asset_id: '456',
status: 'ACCEPTED',
status_history: [{
type: 'ACCEPTED'
}]
}];
const users = [{
username: 'Ana',
email: 'ana@gmail.com',
password: '123456789'
}, {
username: 'Maria',
email: 'maria@gmail.com',
password: '123456789'
}];
const actions = [{
action_type: 'FLAG',
item_id: 'abc',
item_type: 'COMMENTS'
}, {
action_type: 'LIKE',
item_id: 'hij',
item_type: 'COMMENTS'
}, {
action_type: 'FLAG',
item_id: '123',
item_type: 'USERS'
}];
beforeEach(() => {
return SettingsService.init(settings).then(() => {
return UsersService.createLocalUsers(users)
.then((u) => {
comments[0].author_id = u[0].id;
comments[1].author_id = u[1].id;
comments[2].author_id = u[1].id;
return Promise.all([
Comment.create(comments),
u,
...u.map((user) => UsersService.setStatus(user.id, 'PENDING'))
]);
})
.then(([c, u]) => {
actions[0].item_id = c[0].id;
actions[1].item_id = c[1].id;
actions[2].item_id = u[0].id;
return Promise.all([
Action.create(actions),
SettingsService.init(settings)
]);
});
});
});
it('should return all the pending comments, users and actions', () => {
return chai.request(app)
.get('/api/v1/queue/comments/premod')
.set(passport.inject({roles: ['ADMIN']}))
.then((res) => {
expect(res).to.have.status(200);
expect(res.body.comments).to.have.length(1);
expect(res.body.comments[0]).to.have.property('body');
expect(res.body.users[0]).to.have.property('username');
expect(res.body.actions[0]).to.have.property('action_type');
});
});
it('should return all pending users and actions', function(done){
chai.request(app)
.get('/api/v1/queue/users/flagged')
.set(passport.inject({roles: ['ADMIN']}))
.end(function(err, res){
expect(err).to.be.null;
expect(res).to.have.status(200);
expect(res.body.users[0]).to.have.property('username');
expect(res.body.actions[0]).to.have.property('action_type');
done();
});
});
});
+8 -8
View File
@@ -175,11 +175,11 @@ anymatch@^1.3.0:
arrify "^1.0.0" arrify "^1.0.0"
micromatch "^2.1.5" micromatch "^2.1.5"
apollo-client@^0.7.3: apollo-client@^0.8.3:
version "0.7.3" version "0.8.6"
resolved "https://registry.yarnpkg.com/apollo-client/-/apollo-client-0.7.3.tgz#f27702409ce4b90c3adbd78d8434c3e8d762c7dd" resolved "https://registry.yarnpkg.com/apollo-client/-/apollo-client-0.8.6.tgz#9aa18b03ec338f0a3804122df7f77493a10b72a0"
dependencies: dependencies:
graphql-anywhere "^2.0.0" graphql-anywhere "^2.1.0"
graphql-tag "^1.1.1" graphql-tag "^1.1.1"
redux "^3.4.0" redux "^3.4.0"
symbol-observable "^1.0.2" symbol-observable "^1.0.2"
@@ -3303,7 +3303,7 @@ graceful-fs@~2.0.0:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/graceful-readlink/-/graceful-readlink-1.0.1.tgz#4cafad76bc62f02fa039b2f94e9a3dd3a391a725" resolved "https://registry.yarnpkg.com/graceful-readlink/-/graceful-readlink-1.0.1.tgz#4cafad76bc62f02fa039b2f94e9a3dd3a391a725"
graphql-anywhere@^2.0.0: graphql-anywhere@^2.0.0, graphql-anywhere@^2.1.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.yarnpkg.com/graphql-anywhere/-/graphql-anywhere-2.1.0.tgz#888c0a1718db3ff866b313070747777380560f69" resolved "https://registry.yarnpkg.com/graphql-anywhere/-/graphql-anywhere-2.1.0.tgz#888c0a1718db3ff866b313070747777380560f69"
@@ -6375,9 +6375,9 @@ react-addons-test-utils@15.3.2:
version "15.3.2" version "15.3.2"
resolved "https://registry.yarnpkg.com/react-addons-test-utils/-/react-addons-test-utils-15.3.2.tgz#c09a44f583425a4a9c1b38444d7a6c3e6f0f41f6" resolved "https://registry.yarnpkg.com/react-addons-test-utils/-/react-addons-test-utils-15.3.2.tgz#c09a44f583425a4a9c1b38444d7a6c3e6f0f41f6"
react-apollo@^0.8.1: react-apollo@^0.10.0:
version "0.8.3" version "0.10.1"
resolved "https://registry.yarnpkg.com/react-apollo/-/react-apollo-0.8.3.tgz#e799bdc913948bb487dfa90921b2ea8d08464816" resolved "https://registry.yarnpkg.com/react-apollo/-/react-apollo-0.10.1.tgz#97fd50855f8575672aa68330b9c64a201cd13343"
dependencies: dependencies:
graphql-anywhere "^2.0.0" graphql-anywhere "^2.0.0"
hoist-non-react-statics "^1.2.0" hoist-non-react-statics "^1.2.0"