diff --git a/.dockerignore b/.dockerignore
index 3c3629e64..f05b1f265 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1 +1,2 @@
node_modules
+test
diff --git a/.eslintrc.json b/.eslintrc.json
index 8b737cbd2..34b46b83b 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -17,7 +17,7 @@
"no-template-curly-in-string": [1],
"no-unsafe-negation": [1],
"array-callback-return": [1],
- "eqeqeq": [2],
+ "eqeqeq": [2, "smart"],
"no-eval": [2],
"no-global-assign": [2],
"no-implied-eval": [2],
diff --git a/.gitignore b/.gitignore
index 2ec55accd..777dcc7f9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,13 +3,13 @@ npm-debug.log*
dist
!dist/coral-admin
dist/coral-admin/bundle.js
-tests/e2e/reports
+test/e2e/reports
.DS_Store
*.iml
*.swp
dump.rdb
.env
-gaba.cfg
+*.cfg
.idea/
coverage/
yarn.lock
diff --git a/.nodemon.json b/.nodemon.json
new file mode 100644
index 000000000..4289167f7
--- /dev/null
+++ b/.nodemon.json
@@ -0,0 +1,4 @@
+{
+ "verbose": true,
+ "ignore": ["test/*", "client/*", "dist/*"]
+}
diff --git a/.travis-yml b/.travis-yml
deleted file mode 100644
index 85242354a..000000000
--- a/.travis-yml
+++ /dev/null
@@ -1,3 +0,0 @@
-language: node_js
-node_js:
- - 4
diff --git a/app.js b/app.js
index 9cdad4ea3..c04e0cb4a 100644
--- a/app.js
+++ b/app.js
@@ -10,6 +10,8 @@ const RedisStore = require('connect-redis')(session);
const redis = require('./services/redis');
const csrf = require('csurf');
const errors = require('./errors');
+const graph = require('./graph');
+const apollo = require('graphql-server-express');
const app = express();
@@ -49,7 +51,7 @@ const session_opts = {
name: 'talk.sid',
cookie: {
secure: false,
- maxAge: 18000000, // 30 minutes for expiry.
+ maxAge: 36000000, // 1 hour for expiry.
},
store: new RedisStore({
ttl: 1800,
@@ -77,6 +79,23 @@ app.use(session(session_opts));
app.use(passport.initialize());
app.use(passport.session());
+//==============================================================================
+// GraphQL Router
+//==============================================================================
+
+// GraphQL endpoint.
+app.use('/api/v1/graph/ql', apollo.graphqlExpress(graph.createGraphOptions));
+
+// Only include the graphiql tool if we aren't in production mode.
+if (app.get('env') !== 'production') {
+
+ // Interactive graphiql interface.
+ app.use('/api/v1/graph/iql', apollo.graphiqlExpress({
+ endpointURL: '/api/v1/graph/ql'
+ }));
+
+}
+
//==============================================================================
// CSRF MIDDLEWARE
//==============================================================================
diff --git a/bin/cli-assets b/bin/cli-assets
index 008460f9e..f7d6a0bbb 100755
--- a/bin/cli-assets
+++ b/bin/cli-assets
@@ -8,7 +8,7 @@ const program = require('commander');
const pkg = require('../package.json');
const parseDuration = require('parse-duration');
const Table = require('cli-table');
-const Asset = require('../models/asset');
+const AssetModel = require('../models/asset');
const mongoose = require('../services/mongoose');
const scraper = require('../services/scraper');
const util = require('../util');
@@ -22,7 +22,7 @@ util.onshutdown([
* Lists all the assets registered in the database.
*/
function listAssets() {
- Asset
+ AssetModel
.find({})
.sort({'created_at': 1})
.then((asset) => {
@@ -56,7 +56,7 @@ function refreshAssets(ageString) {
const ageMs = parseDuration(ageString);
const age = new Date(now - ageMs);
- Asset.find({
+ AssetModel.find({
$or: [
{
scraped: {
diff --git a/bin/cli-settings b/bin/cli-settings
index 5fdfbc38f..7854942c0 100755
--- a/bin/cli-settings
+++ b/bin/cli-settings
@@ -6,7 +6,7 @@
const program = require('commander');
const mongoose = require('../services/mongoose');
-const Setting = require('../models/setting');
+const SettingsService = require('../services/settings');
const util = require('../util');
// Register the shutdown criteria.
@@ -22,10 +22,16 @@ program
.command('init')
.description('initilizes the talk settings')
.action(() => {
- const defaults = {id: '1', moderation: 'pre'};
+ const defaults = {
+ moderation: 'PRE',
+ wordlist: {
+ banned: [],
+ suspect: []
+ }
+ };
- Setting
- .update({id: '1'}, {$setOnInsert: defaults}, {upsert: true})
+ SettingsService
+ .init(defaults)
.then(() => {
console.log('Created settings object.');
util.shutdown();
diff --git a/bin/cli-users b/bin/cli-users
index fd9e30c7f..a7e9cdbda 100755
--- a/bin/cli-users
+++ b/bin/cli-users
@@ -7,7 +7,8 @@
const program = require('commander');
const pkg = require('../package.json');
const prompt = require('prompt');
-const User = require('../models/user');
+const UsersService = require('../services/users');
+const UserModel = require('../models/user');
const mongoose = require('../services/mongoose');
const util = require('../util');
const Table = require('cli-table');
@@ -76,16 +77,22 @@ function createUser(options) {
});
})
.then((result) => {
- return User.createLocalUser(result.email.trim(), result.password.trim(), result.displayName.trim())
+ return UsersService.createLocalUser(result.email.trim(), result.password.trim(), result.displayName.trim())
.then((user) => {
console.log(`Created user ${user.id}.`);
- if (result.role && result.role.length > 0) {
- return User
- .addRoleToUser(user.id, result.role.trim())
+ let role = result.role ? result.role.trim() : null;
+
+ if (role && role.length > 0) {
+ return UsersService
+ .addRoleToUser(user.id, role)
.then(() => {
console.log(`Added the admin ${result.role.trim()} to User ${user.id}.`);
util.shutdown();
+ })
+ .catch((err) => {
+ console.error(err);
+ util.shutdown();
});
} else {
util.shutdown();
@@ -102,7 +109,7 @@ function createUser(options) {
* Deletes a user.
*/
function deleteUser(userID) {
- User
+ UserModel
.findOneAndRemove({
id: userID
})
@@ -148,7 +155,7 @@ function passwd(userID) {
return;
}
- User
+ UsersService
.changePassword(userID, result.password)
.then(() => {
console.log('Password changed.');
@@ -168,7 +175,7 @@ function updateUser(userID, options) {
const updates = [];
if (options.email && typeof options.email === 'string' && options.email.length > 0) {
- let q = User.update({
+ let q = UserModel.update({
'id': userID,
'profiles.provider': 'local'
}, {
@@ -181,7 +188,7 @@ function updateUser(userID, options) {
}
if (options.name && typeof options.name === 'string' && options.name.length > 0) {
- let q = User.update({
+ let q = UserModel.update({
'id': userID
}, {
$set: {
@@ -208,7 +215,7 @@ function updateUser(userID, options) {
* Lists all the users registered in the database.
*/
function listUsers() {
- User
+ UsersService
.all()
.then((users) => {
let table = new Table({
@@ -248,7 +255,7 @@ function listUsers() {
* @param {String} srcUserID id of the user to which is the source of the merge
*/
function mergeUsers(dstUserID, srcUserID) {
- User
+ UsersService
.mergeUsers(dstUserID, srcUserID)
.then(() => {
console.log(`User ${srcUserID} was merged into user ${dstUserID}.`);
@@ -266,7 +273,7 @@ function mergeUsers(dstUserID, srcUserID) {
* @param {String} role the role to add
*/
function addRole(userID, role) {
- User
+ UsersService
.addRoleToUser(userID, role)
.then(() => {
console.log(`Added the ${role} role to User ${userID}.`);
@@ -284,7 +291,7 @@ function addRole(userID, role) {
* @param {String} role the role to remove
*/
function removeRole(userID, role) {
- User
+ UsersService
.removeRoleFromUser(userID, role)
.then(() => {
console.log(`Removed the ${role} role from User ${userID}.`);
@@ -301,8 +308,8 @@ function removeRole(userID, role) {
* @param {String} userID id of the user to ban
*/
function ban(userID) {
- User
- .setStatus(userID, 'banned', '')
+ UsersService
+ .setStatus(userID, 'BANNED')
.then(() => {
console.log(`Banned the User ${userID}.`);
util.shutdown();
@@ -318,8 +325,8 @@ function ban(userID) {
* @param {String} userUD id of the user to remove the role from
*/
function unban(userID) {
- User
- .setStatus(userID, 'active', '')
+ UsersService
+ .setStatus(userID, 'ACTIVE')
.then(() => {
console.log(`Unban the User ${userID}.`);
util.shutdown();
@@ -335,7 +342,7 @@ function unban(userID) {
* @param {String} userID the ID of a user to disable
*/
function disableUser(userID) {
- User
+ UsersService
.disableUser(userID)
.then(() => {
console.log(`User ${userID} was disabled.`);
@@ -352,7 +359,7 @@ function disableUser(userID) {
* @param {String} userID the ID of a user to enable
*/
function enableUser(userID) {
- User
+ UsersService
.enableUser(userID)
.then(() => {
console.log(`User ${userID} was enabled.`);
diff --git a/circle.yml b/circle.yml
index b9cea6fed..160bc7bb9 100644
--- a/circle.yml
+++ b/circle.yml
@@ -3,12 +3,24 @@ machine:
version: 7
services:
- docker
+ - redis
+
+dependencies:
+ post:
+ # Lint the project here, before tests are ran.
+ - npm run lint
+
+database:
+ post:
+ # Initialize the settings in the database, this will create indicies for the
+ # database.
+ - ./bin/cli settings init
+ - sleep 2
test:
override:
- - MOCHA_FILE=$CIRCLE_TEST_REPORTS/junit/test-results.xml ./node_modules/.bin/mocha tests --reporter mocha-junit-reporter
- - npm run lint
- - npm run build
+ # Run the tests using the junit reporter.
+ - MOCHA_FILE=$CIRCLE_TEST_REPORTS/junit/test-results.xml NPM_PACKAGE_CONFIG_MOCHA_REPORTER=mocha-junit-reporter npm run test
deployment:
release:
diff --git a/client/coral-admin/src/actions/auth.js b/client/coral-admin/src/actions/auth.js
index 2d466966f..8bc0d4e4c 100644
--- a/client/coral-admin/src/actions/auth.js
+++ b/client/coral-admin/src/actions/auth.js
@@ -11,7 +11,7 @@ export const checkLogin = () => dispatch => {
dispatch(checkLoginRequest());
coralApi('/auth')
.then(result => {
- const isAdmin = !!result.user.roles.filter(i => i === 'admin').length;
+ const isAdmin = !!result.user.roles.filter(i => i === 'ADMIN').length;
dispatch(checkLoginSuccess(result.user, isAdmin));
})
.catch(error => {
diff --git a/client/coral-admin/src/actions/comments.js b/client/coral-admin/src/actions/comments.js
index 07b2224f3..df68b72c4 100644
--- a/client/coral-admin/src/actions/comments.js
+++ b/client/coral-admin/src/actions/comments.js
@@ -65,9 +65,9 @@ export const fetchFlaggedQueue = () => {
dispatch({type: commentTypes.COMMENTS_MODERATION_QUEUE_FETCH_REQUEST});
return coralApi('/queue/comments/flagged')
- .then(comments => {
- comments.forEach(comment => comment.flagged = true);
- return comments;
+ .then(results => {
+ results.comments.forEach(comment => comment.flagged = true);
+ return results;
})
.then(addUsersCommentsActions.bind(this, dispatch));
};
diff --git a/client/coral-admin/src/components/BanUserDialog.css b/client/coral-admin/src/components/BanUserDialog.css
index dfac4f194..054c343dd 100644
--- a/client/coral-admin/src/components/BanUserDialog.css
+++ b/client/coral-admin/src/components/BanUserDialog.css
@@ -1,17 +1,25 @@
.dialog {
- border: none;
- box-shadow: 0 9px 46px 8px rgba(0, 0, 0, 0.14), 0 11px 15px -7px rgba(0, 0, 0, 0.12), 0 24px 38px 3px rgba(0, 0, 0, 0.2);
- width: 280px;
- top: 10px;
-}
+ border: none;
+ box-shadow: 0 9px 46px 8px rgba(0, 0, 0, 0.14), 0 11px 15px -7px rgba(0, 0, 0, 0.12), 0 24px 38px 3px rgba(0, 0, 0, 0.2);
+ width: 500px;
+ top: 50%;
+ transform: translateY(-50%);
+ height: 184px;
+ padding: 20px;
-.header {
- margin-bottom: 20px;
-}
+ h2 {
+ color: black;
+ font-size: 1.76em;
+ font-weight: 500;
+ margin: 0;
+ }
-.header h1, .separator h1{
- text-align: center;
- font-size: 1.2em;
+ h3 {
+ color: black;
+ font-size: 1.4em;
+ font-weight: 500;
+ margin: 0;
+ }
}
.formField {
@@ -143,5 +151,14 @@ input.error{
}
.cancel {
- margin: 10px 0;
+ margin-right: 10px;
+ width: 47%;
+}
+
+.ban {
+ width: 47%;
+}
+
+.buttons {
+ margin: 20px 0;
}
diff --git a/client/coral-admin/src/components/BanUserDialog.js b/client/coral-admin/src/components/BanUserDialog.js
index f1d289fe6..c393df21d 100644
--- a/client/coral-admin/src/components/BanUserDialog.js
+++ b/client/coral-admin/src/components/BanUserDialog.js
@@ -19,23 +19,23 @@ const BanUserDialog = ({open, handleClose, onClickBanUser, user = {}}) => (
×
-
+
{lang.t('bandialog.ban_user')}
-
+
-
+
{lang.t('bandialog.are_you_sure', user.userName)}
-
+
{lang.t('bandialog.note')}
- handleClose()} full>
+ handleClose()} raised>
{lang.t('bandialog.cancel')}
- onClickBanUser('banned', user.userId, user.commentId)} full>
+ onClickBanUser(user.userId, user.commentId)} raised>
{lang.t('bandialog.yes_ban_user')}
diff --git a/client/coral-admin/src/components/Comment.js b/client/coral-admin/src/components/Comment.js
index 40b4c070c..bbc6078b7 100644
--- a/client/coral-admin/src/components/Comment.js
+++ b/client/coral-admin/src/components/Comment.js
@@ -7,9 +7,8 @@ import styles from './ModerationList.css';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../translations.json';
-import {Icon} from 'react-mdl';
import Highlighter from 'react-highlight-words';
-import {FabButton, Button} from 'coral-ui';
+import {FabButton, Button, Icon} from 'coral-ui';
const linkify = new Linkify();
@@ -20,22 +19,19 @@ const Comment = props => {
const links = linkify.getMatches(comment.body);
return (
-
+
-
person
{author.displayName || lang.t('comment.anon')}
{timeago().format(comment.createdAt || (Date.now() - props.index * 60 * 1000), lang.getLocale().replace('-', '_'))}
{comment.flagged ?
{lang.t('comment.flagged')}
: null}
-
+
{links ?
Contains Link : null}
{props.modActions.map((action, i) => getActionButton(action, i, props))}
-
-
{authorStatus === 'banned' ?
{lang.t('comment.banned_user')} : null}
@@ -65,15 +61,19 @@ const getActionButton = (option, i, props) => {
}
if (option === 'ban') {
return (
-
props.onClickShowBanDialog(author.id, author.displayName, comment.id)}
- key={i}
- >
- {lang.t('comment.ban_user')}
-
+
+ props.onClickShowBanDialog(author.id, author.displayName, comment.id)}
+ key={i}
+ raised
+ >
+
+ {lang.t('comment.ban_user')}
+
+
);
}
const menuOption = menuOptionsMap[option];
diff --git a/client/coral-admin/src/components/ModerationList.css b/client/coral-admin/src/components/ModerationList.css
index 3f695095f..fbcbce7d7 100644
--- a/client/coral-admin/src/components/ModerationList.css
+++ b/client/coral-admin/src/components/ModerationList.css
@@ -36,13 +36,33 @@
.listItem {
border-bottom: 1px solid #e0e0e0;
- padding: 16px;
font-size: 16px;
+ width: 100%;
+ max-width: 660px;
+ min-width: 400px;
+ margin: 0 auto;
+ padding: 16px 14px;
+ position: relative;
+ transition: box-shadow 200ms;
+
+
+ &:hover {
+ box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23);
+ }
&:last-child {
border-bottom: none;
}
+ .sideActions {
+ position: absolute;
+ right: 0;
+ height: 100%;
+ top: 0;
+ padding: 40px 18px;
+ box-sizing: border-box;
+ }
+
.itemHeader {
display: flex;
align-items: center;
@@ -72,25 +92,20 @@
.created {
color: #666;
- font-size: 10px;
- margin-left: 10px;
+ font-size: 13px;
+ margin-left: 40px;
}
.actionButton {
- transform: scale(.7);
+ transform: scale(.8);
margin: 0;
}
.body {
margin-top: 20px;
flex: 1;
- font-size: 1em;
- color: rgba(0,0,0,.54);
- }
-
- .actions {
- margin-left: 10px;
- display: flex;
+ font-size: 0.88em;
+ color: black;
}
.flagged {
@@ -150,3 +165,20 @@
margin-right: 5px;
}
}
+
+.ban {
+ display: block;
+ text-align: center;
+ margin-top: 5px;
+}
+
+.banButton {
+ width: 114px;
+ letter-spacing: 1px;
+
+ i {
+ vertical-align: middle;
+ margin-right: 10px;
+ font-size: 14px;
+ }
+}
diff --git a/client/coral-admin/src/components/ui/Header.css b/client/coral-admin/src/components/ui/Header.css
index a0d7dd30d..8fbdedc0a 100644
--- a/client/coral-admin/src/components/ui/Header.css
+++ b/client/coral-admin/src/components/ui/Header.css
@@ -1,40 +1,42 @@
.header {
- background: #505050;
+ background-color: transparent;
+ box-shadow: none;
+ min-height: 58px;
}
.header > div {
- position: relative;
- padding: 0;
- width: 1170px;
- margin: 0 auto;
-}
-
-.active {
- background: #232323;
+ background-color: #696969;
+ position: relative;
+ padding: 0;
+ min-width: 1280px;
+ margin: 0 auto;
+ box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12);
+ height: 58px;
}
.rightPanel {
- position: absolute;
- right: 0;
- width: 170px;
+ position: absolute;
+ right: 0;
+ width: 170px;
+ height: 100%;
}
.rightPanel ul {
list-style: none;
- line-height: 38px;
+ margin-right: 20px;
}
.rightPanel li {
display: inline-block;
float: right;
margin-left: 15px;
+ font-size: 15px;
+ font-weight: 500;
+ line-height: 33px;
}
.rightPanel .settings {
- vertical-align: middle;
- border-radius: 3px;
- border: solid 1px #9e9e9e;
- line-height: 10px;
+ line-height: 0;
}
.rightPanel .settings > div {
@@ -45,3 +47,25 @@
background: rgba(158, 158, 158, 0.69);
cursor: pointer;
}
+
+.navLink {
+ padding: 0 20px;
+ font-size: 15px;
+ font-weight: 500;
+ background-color: transparent;
+ transition: background-color 200ms;
+
+ &.active {
+ background-color: #232323;
+ }
+}
+
+.nav {
+ overflow: hidden;
+ height: 58px !important;
+}
+
+.nav .navLink {
+ padding: 0 20px;
+ letter-spacing: 0.4px;
+}
diff --git a/client/coral-admin/src/components/ui/Header.js b/client/coral-admin/src/components/ui/Header.js
index 4c76424cc..4e2b391c8 100644
--- a/client/coral-admin/src/components/ui/Header.js
+++ b/client/coral-admin/src/components/ui/Header.js
@@ -9,7 +9,7 @@ import {Logo} from './Logo';
export default ({handleLogout}) => (
-
+
{lang.t('configure.moderate')}
(
-
+
Talk
diff --git a/client/coral-admin/src/containers/Community/Community.css b/client/coral-admin/src/containers/Community/Community.css
index b19d0261d..6c101fcaf 100644
--- a/client/coral-admin/src/containers/Community/Community.css
+++ b/client/coral-admin/src/containers/Community/Community.css
@@ -1,17 +1,96 @@
-.roleButton {
- display: block;
+.container {
+ padding: 10px;
+ display: flex;
+ padding-bottom: 200px;
}
-.searchInput {
- display: block;
- padding-left: 40px;
- width: auto;
+.leftColumn {
+ padding: 42px 56px;
+ width: 234px;
+}
+
+.mainContent {
+ width: calc(100% - 300px);
+ padding: 34px 14px;
+ box-sizing: border-box;
+}
+
+.roleButton {
+ display: block;
}
.searchBox {
- background: white;
+ width: 100%;
+ padding: 9px;
+ border: 1px solid #ccc;
+ border-radius: 2px;
+ display: flex;
+ background: white;
+ box-sizing: border-box;
+ height: 40px;
+
+ i {
+ color: #A1A1A1
+ }
+
+ input {
+ display: block;
+ width: 100%;
+ height: 100%;
+ border: none;
+ font-size: 16px;
+ padding: 0 2px 0 15px;
+ box-sizing: border-box;
+ }
}
.email {
- display: block;
+ display: block;
+}
+
+.dataTable {
+ width: 100%;
+ border-left: none;
+ border-right: none;
+
+ th {
+ font-size: 1.1em;
+ }
+
+ th:nth-child(2), th:nth-child(3) {
+ width: 100px;
+ }
+}
+
+.selectField {
+ position: relative;
+ width: 150px;
+ height: 36px;
+ background: #2c2c2c;
+ padding: 10px 15px;
+ box-sizing: border-box;
+ color: white;
+ border-radius: 2px;
+ box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12);
+
+ > div {
+ padding: 0;
+ }
+
+ i {
+ position: absolute;
+ top: 7px;
+ right: 7px;
+ }
+
+ input {
+ padding: 0;
+ font-size: 13px;
+ letter-spacing: 0.7px;
+ font-weight: 400;
+ }
+
+ &:hover {
+ cursor: pointer;
+ }
}
diff --git a/client/coral-admin/src/containers/Community/Community.js b/client/coral-admin/src/containers/Community/Community.js
index 286a7f364..d63dd1e96 100644
--- a/client/coral-admin/src/containers/Community/Community.js
+++ b/client/coral-admin/src/containers/Community/Community.js
@@ -1,13 +1,12 @@
import React from 'react';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../../translations.json';
-import {Grid, Cell} from 'react-mdl';
import styles from './Community.css';
import Table from './Table';
import Loading from './Loading';
import NoResults from './NoResults';
-import Pager from 'coral-ui/components/Pager';
+import {Pager} from 'coral-ui';
const lang = new I18n(translations);
@@ -33,17 +32,17 @@ const tableHeaders = [
const Community = ({isFetching, commenters, ...props}) => {
const hasResults = !isFetching && !!commenters.length;
return (
-
-
+
+
);
};
diff --git a/client/coral-admin/src/containers/Community/Table.js b/client/coral-admin/src/containers/Community/Table.js
index 1d81b1b2e..254550e74 100644
--- a/client/coral-admin/src/containers/Community/Table.js
+++ b/client/coral-admin/src/containers/Community/Table.js
@@ -52,19 +52,21 @@ class Table extends Component {
this.onCommenterStatusChange(row.id, status)}>
- {lang.t('community.active')}
- {lang.t('community.banned')}
+ {lang.t('community.active')}
+ {lang.t('community.banned')}
this.onRoleChange(row.id, role)}>
.
- {lang.t('community.moderator')}
- {lang.t('community.admin')}
+ {lang.t('community.moderator')}
+ {lang.t('community.admin')}
diff --git a/client/coral-admin/src/containers/Configure/CommentSettings.js b/client/coral-admin/src/containers/Configure/CommentSettings.js
index 6000c1d43..0ccb5f820 100644
--- a/client/coral-admin/src/containers/Configure/CommentSettings.js
+++ b/client/coral-admin/src/containers/Configure/CommentSettings.js
@@ -3,15 +3,8 @@ import {SelectField, Option} from 'react-mdl-selectfield';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../../translations.json';
import styles from './Configure.css';
-import {
- List,
- ListItem,
- ListItemContent,
- ListItemAction,
- Textfield,
- Checkbox,
- Icon
-} from 'react-mdl';
+import {Textfield, Checkbox} from 'react-mdl';
+import {Card, Icon, Spinner} from 'coral-ui';
const TIMESTAMPS = {
weeks: 60 * 60 * 24 * 7,
@@ -35,7 +28,7 @@ const updateCharCount = (updateSettings, settingsError) => (event) => {
};
const updateModeration = (updateSettings, mod) => () => {
- const moderation = mod === 'pre' ? 'post' : 'pre';
+ const moderation = mod === 'PRE' ? 'POST' : 'PRE';
updateSettings({moderation});
};
@@ -71,35 +64,32 @@ const updateClosedTimeout = (updateSettings, ts, isMeasure) => (event) => {
const CommentSettings = ({fetchingSettings, title, updateSettings, settingsError, settings, errors}) => {
if (fetchingSettings) {
-
- /* maybe a spinner here at some point */
- return Loading settings... ;
+ return Loading settings...;
}
return (
-
+
{title}
-
-
-
+
+
-
-
+ checked={settings.moderation === 'PRE'} />
+
+
{lang.t('configure.enable-pre-moderation')}
-
+
{lang.t('configure.enable-pre-moderation-text')}
-
-
-
-
+
+
+
+
-
-
+
+
{lang.t('configure.comment-count-header')}
{lang.t('configure.comment-count-text-pre')}
@@ -118,32 +108,44 @@ const CommentSettings = ({fetchingSettings, title, updateSettings, settingsError
}
-
-
-
-
+
+
+
+
-
-
+
+
{lang.t('configure.include-comment-stream')}
{lang.t('configure.include-comment-stream-desc')}
-
-
-
-
+
+
+
+
+
+ {lang.t('configure.closed-comments-desc')}
+
-
-
-
-
+
+
+
+
+
{lang.t('configure.close-after')}
{lang.t('configure.weeks')}
-
-
-
-
- {lang.t('configure.closed-comments-desc')}
-
-
-
-
+
+
);
};
diff --git a/client/coral-admin/src/containers/Configure/Configure.css b/client/coral-admin/src/containers/Configure/Configure.css
index 2b5c8d578..1f23af30b 100644
--- a/client/coral-admin/src/containers/Configure/Configure.css
+++ b/client/coral-admin/src/containers/Configure/Configure.css
@@ -1,26 +1,29 @@
.container {
- padding: 10px;
display: flex;
+
+ h3 {
+ color: black;
+ font-size: 1.76em;
+ font-weight: 500;
+ }
}
.leftColumn {
- width: 300px;
+ padding: 42px 56px;
+ width: 234px;
}
.mainContent {
- width: calc(70% - 300px)
-}
-
-.settingOption {
- cursor: pointer;
+ width: calc(100% - 300px);
+ padding: 10px 14px;
+ box-sizing: border-box;
+ max-width: 718px;
}
.configSetting {
- border: 1px solid #ccc;
- border-radius: 4px;
- height: 95px;
- margin-bottom: 10px;
+ margin-bottom: 20px;
align-items: flex-start;
+ min-height: 100px;
}
.settingsError {
@@ -42,13 +45,13 @@
}
.configSettingInfoBox {
- border: 1px solid #ccc;
- border-radius: 4px;
- margin-bottom: 10px;
+ min-height: 100px;
+ margin-bottom: 20px;
cursor: pointer;
width: auto;
height: auto;
text-align: left;
+ overflow: visible;
}
.configSettingInfoBox p {
@@ -77,7 +80,7 @@
}
.charCountTexfieldEnabled {
- border-color: #4caf50;
+ border-color: #00796b;
}
.charCountTexfield:focus {
@@ -85,11 +88,12 @@
}
.changedSave {
- background-color:#4caf50;
+ background-color: #00796B;
+ color: white;
}
.copiedText {
- color: #008000;
+ color: #00796b;
float: right;
padding: 12px;
font-size: 14px;
@@ -97,6 +101,7 @@
.copyButton {
float: right;
+ width: 200px;
}
.embedInput {
@@ -113,8 +118,12 @@
}
#bannedWordlist, #suspectWordlist {
+ width: 100%;
+ padding: 10px;
+
+ input {
width: 100%;
- padding: 10px;
+ }
}
.wordlistHeader {
@@ -124,7 +133,7 @@
}
.enabledSetting {
- border-left-color: #4caf50;
+ border-left-color: #00796b;
border-left-style: solid;
border-left-width: 7px;
}
@@ -136,3 +145,23 @@
.hidden {
display: none;
}
+
+.saveBox {
+ margin-top: 38px;
+}
+
+.commentSettingsSection {
+ padding-bottom: 200px;
+ .action {
+ display: inline-block;
+ position: absolute;
+ top: 0;
+ left: 0;
+ padding: 20px;
+ }
+
+ .content {
+ display: inline-block;
+ padding-left: 30px;
+ }
+}
diff --git a/client/coral-admin/src/containers/Configure/Configure.js b/client/coral-admin/src/containers/Configure/Configure.js
index 9fc24ad53..8ad6cacfd 100644
--- a/client/coral-admin/src/containers/Configure/Configure.js
+++ b/client/coral-admin/src/containers/Configure/Configure.js
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, {Component} from 'react';
import {connect} from 'react-redux';
import {
fetchSettings,
@@ -6,13 +6,8 @@ import {
saveSettingsToServer,
updateWordlist,
} from '../../actions/settings';
-import {
- List,
- ListItem,
- ListItemContent,
- Button,
- Icon
-} from 'react-mdl';
+
+import {Button, List, Item} from 'coral-ui';
import styles from './Configure.css';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../../translations.json';
@@ -21,7 +16,7 @@ import CommentSettings from './CommentSettings';
import Wordlist from './Wordlist';
import has from 'lodash/has';
-class Configure extends React.Component {
+class Configure extends Component {
constructor (props) {
super(props);
@@ -30,6 +25,8 @@ class Configure extends React.Component {
changed: false,
errors: {}
};
+
+ this.changeSection = this.changeSection.bind(this);
}
componentWillMount = () => {
@@ -41,7 +38,7 @@ class Configure extends React.Component {
this.setState({changed: false});
}
- changeSection = (activeSection) => () => {
+ changeSection(activeSection) {
this.setState({activeSection});
}
@@ -99,7 +96,8 @@ class Configure extends React.Component {
}
render () {
- const section = this.getSection(this.state.activeSection);
+ const {activeSection} = this.state;
+ const section = this.getSection(activeSection);
const showSave = Object.keys(this.state.errors).reduce(
(bool, error) => this.state.errors[error] ? false : bool, this.state.changed);
@@ -107,37 +105,40 @@ class Configure extends React.Component {
return (
-
-
- {lang.t('configure.comment-settings')}
-
-
- {lang.t('configure.embed-comment-stream')}
-
-
- {lang.t('configure.wordlist')}
-
+
+ -
+ {lang.t('configure.comment-settings')}
+
+ -
+ {lang.t('configure.embed-comment-stream')}
+
+ -
+ {lang.t('configure.wordlist')}
+
+
{
showSave ?
-
- {lang.t('configure.save-changes')}
-
- :
- {lang.t('configure.save-changes')}
-
+
+ {lang.t('configure.save-changes')}
+
+ :
+
+ {lang.t('configure.save-changes')}
+
}
+
diff --git a/client/coral-admin/src/containers/Configure/EmbedLink.js b/client/coral-admin/src/containers/Configure/EmbedLink.js
index 374d78ea3..105b531c5 100644
--- a/client/coral-admin/src/containers/Configure/EmbedLink.js
+++ b/client/coral-admin/src/containers/Configure/EmbedLink.js
@@ -2,11 +2,7 @@ import React, {Component} from 'react';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../../translations.json';
import styles from './Configure.css';
-import {
- List,
- ListItem,
- Button
-} from 'react-mdl';
+import {Button, Card} from 'coral-ui';
class EmbedLink extends Component {
@@ -34,16 +30,16 @@ class EmbedLink extends Component {
return (
{this.props.title}
-
-
+
+
{lang.t('configure.copy-and-paste')}
+
);
}
diff --git a/client/coral-admin/src/containers/Configure/Wordlist.js b/client/coral-admin/src/containers/Configure/Wordlist.js
index 595b5ea42..1a808acc6 100644
--- a/client/coral-admin/src/containers/Configure/Wordlist.js
+++ b/client/coral-admin/src/containers/Configure/Wordlist.js
@@ -2,15 +2,13 @@ import React from 'react';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../../translations.json';
import TagsInput from 'react-tagsinput';
-
import styles from './Configure.css';
-
-import {Card} from 'react-mdl';
+import {Card} from 'coral-ui';
const Wordlist = ({suspectWords, bannedWords, onChangeWordlist}) => (
{lang.t('configure.banned-words-title')}
-
+
{lang.t('configure.banned-word-header')}
{lang.t('configure.banned-word-text')}
(
inputProps={{placeholder: 'word or phrase'}}
addOnPaste={true}
pasteSplit={data => data.split(',').map(d => d.trim())}
- onChange={tags => onChangeWordlist('banned', tags)} />
+ onChange={tags => onChangeWordlist('banned', tags)}
+ />
{lang.t('configure.suspect-words-title')}
-
+
{lang.t('configure.suspect-word-header')}
{lang.t('configure.suspect-word-text')}
comments.byId[id].status === 'premod');
- const rejectedIds = comments.ids.filter(id => comments.byId[id].status === 'rejected');
- const flaggedIds = comments.ids.filter(id => comments.byId[id].flagged === true);
- const userActionIds = actions.ids.filter(id => actions.byId[id].item_type === 'users');
+ const premodIds = comments.ids.filter(id => comments.byId[id].status === 'PREMOD');
+ const rejectedIds = comments.ids.filter(id => comments.byId[id].status === 'REJECTED');
+ const flaggedIds = comments.ids.filter(id =>
+ comments.byId[id].flagged === true &&
+ comments.byId[id].status !== 'REJECTED' &&
+ comments.byId[id].status !== 'ACCEPTED'
+ );
+ const userActionIds = actions.ids.filter(id => actions.byId[id].item_type === 'USERS');
return (
span {
+ color: white;
+}
+
+.active:after {
+ background: transparent !important;
}
.showShortcuts {
diff --git a/client/coral-admin/src/containers/ModerationQueue/ModerationQueue.js b/client/coral-admin/src/containers/ModerationQueue/ModerationQueue.js
index 8098ca281..3d4451efe 100644
--- a/client/coral-admin/src/containers/ModerationQueue/ModerationQueue.js
+++ b/client/coral-admin/src/containers/ModerationQueue/ModerationQueue.js
@@ -12,114 +12,177 @@ const lang = new I18n(translations);
export default (props) => (
-
+
-
-
+ {
+ props.activeTab === 'all' &&
+
+
+
+
+ }
-
-
-
-
-
-
+
+ {
+ props.activeTab === 'pending' &&
+
+
+
+
+ }
-
-
+ {
+ props.activeTab === 'account' &&
+
+
+
+
+ }
-
-
+ {
+ props.activeTab === 'flagged' &&
+
+
+
+
+ }
+
+
+ {
+ props.activeTab === 'rejected' &&
+
+
+
+
+ }
+
+
diff --git a/client/coral-admin/src/containers/Streams/Streams.css b/client/coral-admin/src/containers/Streams/Streams.css
index c64de7974..26fb4f74f 100644
--- a/client/coral-admin/src/containers/Streams/Streams.css
+++ b/client/coral-admin/src/containers/Streams/Streams.css
@@ -4,11 +4,14 @@
}
.leftColumn {
- width: 200px;
+ padding: 42px 56px;
+ width: 234px;
}
.mainContent {
- width: calc(90% - 200px);
+ width: calc(100% - 300px);
+ padding: 34px 14px;
+ box-sizing: border-box;
}
.searchIcon {
@@ -18,11 +21,12 @@
}
.searchBox {
- padding: 3px;
+ width: 100%;
+ padding: 9px;
border: 1px solid #ccc;
- border-radius: 3px;
- width: 90%;
+ border-radius: 2px;
display: flex;
+ background: white;
}
.searchBoxInput {
@@ -48,6 +52,16 @@
.streamsTable {
width: 100%;
+ border-left: none;
+ border-right: none;
+
+ th {
+ font-size: 1.1em;
+ }
+
+ th.status {
+ width: 100px;
+ }
}
.radio {
@@ -55,18 +69,20 @@
}
.statusMenu {
- border-radius: 3px;
+ border-radius: 2px;
width: 10em;
text-align: center;
float: right;
- border: 1px solid #ccc;
color: #fff;
cursor: pointer;
+ letter-spacing: 0.7px;
+ font-weight: 400;
+ box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12);
}
.statusMenuOpen {
padding: 10px;
- background-color: #4caf50;
+ background-color: #268D81;
}
.statusMenuIcon {
@@ -75,9 +91,17 @@
.statusMenuClosed {
padding: 10px;
- background-color: #000;
+ background-color: #262626;
}
.hidden {
display: none;
}
+
+.radioGroup {
+ margin-top: 5px;
+ span {
+ margin-bottom: 7px;
+ display: inline-block;
+ }
+}
diff --git a/client/coral-admin/src/containers/Streams/Streams.js b/client/coral-admin/src/containers/Streams/Streams.js
index a154516dd..3c4b143b8 100644
--- a/client/coral-admin/src/containers/Streams/Streams.js
+++ b/client/coral-admin/src/containers/Streams/Streams.js
@@ -103,61 +103,63 @@ class Streams extends Component {
render () {
const {search, sort, filter} = this.state;
const {assets} = this.props;
-
- return
-
-
-
-
-
-
-
- {lang.t('streams.filter-streams')}
- {lang.t('streams.stream-status')}
+ return (
+
+
+
+
+
+
+ {lang.t('streams.filter-streams')}
+ {lang.t('streams.stream-status')}
+ onChange={this.onSettingChange('filter')}
+ className={styles.radioGroup}
+ >
{lang.t('streams.all')}
{lang.t('streams.open')}
{lang.t('streams.closed')}
{lang.t('streams.sort-by')}
-
- {lang.t('streams.newest')}
- {lang.t('streams.oldest')}
-
+
+ {lang.t('streams.newest')}
+ {lang.t('streams.oldest')}
+
+
+
+ assets.byId[id])}>
+ {lang.t('streams.article')}
+
+ {lang.t('streams.pubdate')}
+
+
+ {lang.t('streams.status')}
+
+
+
+
-
-
- assets.byId[id])}>
- {lang.t('streams.article')}
-
- {lang.t('streams.pubdate')}
-
-
- {lang.t('streams.status')}
-
-
-
-
- ;
+ );
}
}
diff --git a/client/coral-admin/src/translations.json b/client/coral-admin/src/translations.json
index b7cab3250..a89b53523 100644
--- a/client/coral-admin/src/translations.json
+++ b/client/coral-admin/src/translations.json
@@ -53,13 +53,13 @@
"include-comment-stream": "Include Comment Stream Description for Readers.",
"include-comment-stream-desc": "Write a message to be added to the top of your comment stream. Pose a topic, include community guidelines, etc.",
"include-text": "Include your text here.",
- "comment-settings": "Comment Settings",
- "embed-comment-stream": "Embed Comment Stream",
+ "comment-settings": "Settings",
+ "embed-comment-stream": "Embed Stream",
"banned-word-header": "Write the banned words list",
"suspect-word-header": "Write the suspect words list",
"banned-word-text": "Comments which contain these words or phrases (not case-sensitive) will be automatically removed from the comment stream. Type a word and press Enter or Tab to add. Optionally paste a comma-separated list.",
"suspect-word-text": "Comments which contain these words or phrases (not case-sensitive) will be highlighted in the comment stream. Type a word and press Enter or Tab to add. Optionally paste a comma-separated list.",
- "wordlist": "Banned & Suspect Words",
+ "wordlist": "Banned Words",
"banned-words-title": "Banned words list",
"suspect-words-title": "Suspect words list",
"save-changes": "Save Changes",
diff --git a/client/coral-configure/containers/ConfigureStreamContainer.js b/client/coral-configure/containers/ConfigureStreamContainer.js
index 71da184ab..90835ab3f 100644
--- a/client/coral-configure/containers/ConfigureStreamContainer.js
+++ b/client/coral-configure/containers/ConfigureStreamContainer.js
@@ -2,7 +2,7 @@ import React, {Component} from 'react';
import {connect} from 'react-redux';
import {I18n} from '../../coral-framework';
-import {updateOpenStatus, updateConfiguration} from '../../coral-framework/actions/config';
+import {updateOpenStatus, updateConfiguration} from '../../coral-framework/actions/asset';
import CloseCommentsInfo from '../components/CloseCommentsInfo';
import ConfigureCommentStream from '../components/ConfigureCommentStream';
@@ -13,8 +13,10 @@ class ConfigureStreamContainer extends Component {
constructor (props) {
super(props);
+ console.log('moderation', props.asset.settings.moderation);
+
this.state = {
- premod: props.config.moderation === 'pre',
+ premod: props.asset.settings.moderation === 'PRE',
premodLinks: false
};
@@ -26,7 +28,7 @@ class ConfigureStreamContainer extends Component {
handleApply () {
const {premod, changed} = this.state;
const newConfig = {
- moderation: premod ? 'pre' : 'post'
+ moderation: premod ? 'PRE' : 'POST'
};
if (changed) {
this.props.updateConfiguration(newConfig);
@@ -47,18 +49,17 @@ class ConfigureStreamContainer extends Component {
}
toggleStatus () {
- this.props.updateStatus(this.props.config.status === 'open' ? 'closed' : 'open');
+ this.props.updateStatus(this.props.asset.closedAt === null ? 'closed' : 'open');
}
getClosedIn () {
- const {closedTimeout} = this.props.config;
+ const {closedTimeout} = this.props.asset.settings;
const {created_at} = this.props.asset;
return lang.timeago(new Date(created_at).getTime() + (1000 * closedTimeout));
}
render () {
- const {status} = this.props;
-
+ const status = this.props.asset.closedAt === null ? 'open' : 'closed';
return (
({
- config: state.config.toJS(),
- asset: state.items
- .get('assets')
- .first()
- .toJS()
+ asset: state.asset.toJS()
});
const mapDispatchToProps = dispatch => ({
diff --git a/client/coral-embed-stream/src/Comment.css b/client/coral-embed-stream/src/Comment.css
new file mode 100644
index 000000000..8b1378917
--- /dev/null
+++ b/client/coral-embed-stream/src/Comment.css
@@ -0,0 +1 @@
+
diff --git a/client/coral-embed-stream/src/Comment.js b/client/coral-embed-stream/src/Comment.js
new file mode 100644
index 000000000..f734bce0b
--- /dev/null
+++ b/client/coral-embed-stream/src/Comment.js
@@ -0,0 +1,171 @@
+// this component will
+// render its children
+// render a like button
+// render a permalink button
+// render a reply button
+// render a flag button
+// translate things?
+
+import React, {PropTypes} from 'react';
+import PermalinkButton from 'coral-plugin-permalinks/PermalinkButton';
+
+import AuthorName from 'coral-plugin-author-name/AuthorName';
+import Content from 'coral-plugin-commentcontent/CommentContent';
+import PubDate from 'coral-plugin-pubdate/PubDate';
+import {ReplyBox, ReplyButton} from 'coral-plugin-replies';
+import FlagComment from 'coral-plugin-flags/FlagComment';
+import LikeButton from 'coral-plugin-likes/LikeButton';
+
+const getAction = (type, comment) => comment.actions.filter((a) => a.type === type)[0];
+
+class Comment extends React.Component {
+
+ constructor(props) {
+ super(props);
+ this.state = {replyBoxVisible: false};
+ }
+
+ static propTypes = {
+ refetch: PropTypes.func.isRequired,
+ showSignInDialog: PropTypes.func.isRequired,
+ postAction: PropTypes.func.isRequired,
+ deleteAction: PropTypes.func.isRequired,
+ parentId: PropTypes.string,
+ addNotification: PropTypes.func.isRequired,
+ postItem: PropTypes.func.isRequired,
+ depth: PropTypes.number.isRequired,
+ asset: PropTypes.shape({
+ id: PropTypes.string,
+ title: PropTypes.string,
+ url: PropTypes.string
+ }).isRequired,
+ currentUser: PropTypes.shape({
+ id: PropTypes.string.isRequired
+ }),
+ comment: PropTypes.shape({
+ depth: PropTypes.number,
+ actions: PropTypes.array.isRequired,
+ body: PropTypes.string.isRequired,
+ id: PropTypes.string.isRequired,
+ replies: PropTypes.arrayOf(
+ PropTypes.shape({
+ body: PropTypes.string.isRequired,
+ id: PropTypes.string.isRequired
+ })
+ ),
+ user: PropTypes.shape({
+ id: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired
+ }).isRequired
+ }).isRequired
+ }
+
+ onReplyBoxClick = () => {
+ if (this.props.currentUser) {
+ this.setState({replyBoxVisible: !this.state.replyBoxVisible});
+ } else {
+ const offset = document.getElementById(`c_${this.props.comment.id}`).getBoundingClientRect().top - 75;
+ this.props.showSignInDialog(offset);
+ }
+ }
+
+ render () {
+ const {
+ comment,
+ parentId,
+ currentUser,
+ asset,
+ depth,
+ postItem,
+ refetch,
+ addNotification,
+ showSignInDialog,
+ postAction,
+ deleteAction
+ } = this.props;
+
+ const like = getAction('LIKE', comment);
+ const flag = getAction('FLAG', comment);
+
+ return (
+
+ );
+ }
+}
+
+export default Comment;
diff --git a/client/coral-embed-stream/src/CommentStream.js b/client/coral-embed-stream/src/CommentStream.js
deleted file mode 100644
index bd60f8c32..000000000
--- a/client/coral-embed-stream/src/CommentStream.js
+++ /dev/null
@@ -1,331 +0,0 @@
-import React, {Component, PropTypes} from 'react';
-import Pym from 'pym.js';
-import {connect} from 'react-redux';
-
-import {
- itemActions,
- Notification,
- notificationActions,
- authActions
-} from '../../coral-framework';
-
-import CommentBox from '../../coral-plugin-commentbox/CommentBox';
-import InfoBox from '../../coral-plugin-infobox/InfoBox';
-import Content from '../../coral-plugin-commentcontent/CommentContent';
-import PubDate from '../../coral-plugin-pubdate/PubDate';
-import Count from '../../coral-plugin-comment-count/CommentCount';
-import AuthorName from '../../coral-plugin-author-name/AuthorName';
-import {ReplyBox, ReplyButton} from '../../coral-plugin-replies';
-import FlagComment from '../../coral-plugin-flags/FlagComment';
-import LikeButton from '../../coral-plugin-likes/LikeButton';
-import PermalinkButton from '../../coral-plugin-permalinks/PermalinkButton';
-import SignInContainer from '../../coral-sign-in/containers/SignInContainer';
-import UserBox from '../../coral-sign-in/components/UserBox';
-
-import {TabBar, Tab, TabContent, Spinner} from '../../coral-ui';
-import SettingsContainer from '../../coral-settings/containers/SettingsContainer';
-import RestrictedContent from '../../coral-framework/components/RestrictedContent';
-import SuspendedAccount from '../../coral-framework/components/SuspendedAccount';
-
-import ConfigureStreamContainer from '../../coral-configure/containers/ConfigureStreamContainer';
-
-const {addItem, updateItem, postItem, getStream, postAction, deleteAction, appendItemArray} = itemActions;
-const {addNotification, clearNotification} = notificationActions;
-const {logout, showSignInDialog} = authActions;
-
-class CommentStream extends Component {
-
- constructor (props) {
- super(props);
-
- this.state = {
- activeTab: 0
- };
-
- this.changeTab = this.changeTab.bind(this);
- }
-
- changeTab (tab) {
- this.setState({
- activeTab: tab
- });
- }
-
- static propTypes = {
- items: PropTypes.object.isRequired,
- addItem: PropTypes.func.isRequired,
- updateItem: PropTypes.func.isRequired
- }
-
- componentDidMount () {
-
- // Set up messaging between embedded Iframe an parent component
- this.pym = new Pym.Child({polling: 100});
-
- let path = this.pym.parentUrl.split('#')[0];
-
- if (!path) {
- path = window.location.href.split('#')[0];
- }
-
- this.props.getStream(path || window.location);
- this.path = path;
-
- this.pym.sendMessage('childReady');
-
- this.pym.onMessage('DOMContentLoaded', hash => {
- const commentId = hash.replace('#', 'c_');
- let count = 0;
- const interval = setInterval(() => {
- if (document.getElementById(commentId)) {
- window.clearInterval(interval);
- this.pym.scrollParentToChildEl(commentId);
- }
-
- if (++count > 100) { // ~10 seconds
- // give up waiting for the comments to load.
- // it would be weird for the page to jump after that long.
- window.clearInterval(interval);
- }
- }, 100);
- });
- }
-
- render () {
- const rootItemId = this.props.items.assets && Object.keys(this.props.items.assets)[0];
- const rootItem = this.props.items.assets && this.props.items.assets[rootItemId];
- const {actions, users, comments} = this.props.items;
- const {status, moderation, closedMessage, charCount, charCountEnable} = this.props.config;
- const {loggedIn, isAdmin, user, showSignInDialog, signInOffset} = this.props.auth;
- const {activeTab} = this.state;
- const banned = (this.props.userData.status === 'banned');
-
- const expandForLogin = showSignInDialog ? {
- minHeight: document.body.scrollHeight + 150
- } : {};
- return
- {
- rootItem
- ?
-
-
- Settings
- Configure Stream
-
- {loggedIn && }
-
- {
- status === 'open'
- ?
- : {closedMessage}
- }
- {!loggedIn && }
- {
- rootItem.comments && rootItem.comments.map((commentId) => {
- const comment = comments[commentId];
- return ;
- })
- }
-
-
-
-
-
-
-
-
-
-
-
-
- :
-
- }
- ;
- }
-}
-
-const mapStateToProps = state => ({
- config: state.config.toJS(),
- items: state.items.toJS(),
- notification: state.notification.toJS(),
- auth: state.auth.toJS(),
- userData: state.user.toJS()
-});
-
-const mapDispatchToProps = (dispatch) => ({
- addItem: (item, item_id) => dispatch(addItem(item, item_id)),
- updateItem: (id, property, value, itemType) => dispatch(updateItem(id, property, value, itemType)),
- postItem: (data, type, id) => dispatch(postItem(data, type, id)),
- getStream: (rootId) => dispatch(getStream(rootId)),
- addNotification: (type, text) => dispatch(addNotification(type, text)),
- clearNotification: () => dispatch(clearNotification()),
- postAction: (item, itemType, action) => dispatch(postAction(item, itemType, action)),
- showSignInDialog: (offset) => dispatch(showSignInDialog(offset)),
- deleteAction: (item, action, user, itemType) => dispatch(deleteAction(item, action, user, itemType)),
- appendItemArray: (item, property, value, addToFront, itemType) => dispatch(appendItemArray(item, property, value, addToFront, itemType)),
- handleSignInDialog: () => dispatch(authActions.showSignInDialog()),
- logout: () => dispatch(logout())
-});
-
-export default connect(mapStateToProps, mapDispatchToProps)(CommentStream);
diff --git a/client/coral-embed-stream/src/Embed.js b/client/coral-embed-stream/src/Embed.js
new file mode 100644
index 000000000..5922266fa
--- /dev/null
+++ b/client/coral-embed-stream/src/Embed.js
@@ -0,0 +1,205 @@
+import React, {Component} from 'react';
+import {compose} from 'react-apollo';
+import {connect} from 'react-redux';
+import {isEqual} from 'lodash';
+
+import {TabBar, Tab, TabContent, Spinner} from '../../coral-ui';
+
+const {logout, showSignInDialog} = authActions;
+const {addNotification, clearNotification} = notificationActions;
+const {fetchAssetSuccess} = assetActions;
+
+import {queryStream} from './graphql/queries';
+import {postComment, postAction, deleteAction} from './graphql/mutations';
+import {Notification, notificationActions, authActions, assetActions, pym} from 'coral-framework';
+
+import Stream from './Stream';
+import InfoBox from 'coral-plugin-infobox/InfoBox';
+import Count from 'coral-plugin-comment-count/CommentCount';
+import CommentBox from 'coral-plugin-commentbox/CommentBox';
+import UserBox from '../../coral-sign-in/components/UserBox';
+import SignInContainer from '../../coral-sign-in/containers/SignInContainer';
+import SuspendedAccount from '../../coral-framework/components/SuspendedAccount';
+import SettingsContainer from '../../coral-settings/containers/SettingsContainer';
+import RestrictedContent from '../../coral-framework/components/RestrictedContent';
+import ConfigureStreamContainer from '../../coral-configure/containers/ConfigureStreamContainer';
+
+class Embed extends Component {
+
+ constructor (props) {
+ super(props);
+
+ this.state = {
+ activeTab: 0,
+ showSignInDialog: false
+ };
+
+ this.changeTab = this.changeTab.bind(this);
+ }
+
+ changeTab (tab) {
+ this.setState({
+ activeTab: tab
+ });
+ }
+
+ static propTypes = {
+ data: React.PropTypes.shape({
+ loading: React.PropTypes.bool,
+ error: React.PropTypes.object
+ }).isRequired
+ }
+
+ componentDidMount () {
+
+ // stream id, logged in user, settings
+
+ // Set up messaging between embedded Iframe an parent component
+
+ // this.props.getStream(path || window.location);
+ // this.path = window.location.href.split('#')[0];
+ //
+
+ pym.sendMessage('childReady');
+
+ pym.onMessage('DOMContentLoaded', hash => {
+ const commentId = hash.replace('#', 'c_');
+ let count = 0;
+ const interval = setInterval(() => {
+ if (document.getElementById(commentId)) {
+ window.clearInterval(interval);
+ pym.scrollParentToChildEl(commentId);
+ }
+
+ if (++count > 100) { // ~10 seconds
+ // give up waiting for the comments to load.
+ // it would be weird for the page to jump after that long.
+ window.clearInterval(interval);
+ }
+ }, 100);
+ });
+
+ }
+
+ componentWillReceiveProps (nextProps) {
+ const {loadAsset} = this.props;
+ if(!isEqual(nextProps.data.asset, this.props.data.asset)) {
+ loadAsset(nextProps.data.asset);
+ }
+ }
+
+ render () {
+ const {activeTab} = this.state;
+ const {loading, asset, refetch} = this.props.data;
+ const {loggedIn, isAdmin, user, showSignInDialog, signInOffset} = this.props.auth;
+
+ const expandForLogin = showSignInDialog ? {
+ minHeight: document.body.scrollHeight + 200
+ } : {};
+
+ return
+ {
+ loading ?
+ :
+
+
+ Settings
+ Configure Stream
+
+ {loggedIn && }
+
+ {
+ asset.closedAt === null
+ ?
+ : {asset.settings.closedMessage}
+ }
+ {!loggedIn && }
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ ;
+ }
+}
+
+const mapStateToProps = state => ({
+ items: state.items.toJS(),
+ notification: state.notification.toJS(),
+ auth: state.auth.toJS(),
+ userData: state.user.toJS()
+});
+
+const mapDispatchToProps = dispatch => ({
+ loadAsset: (asset) => dispatch(fetchAssetSuccess(asset)),
+ addNotification: (type, text) => dispatch(addNotification(type, text)),
+ clearNotification: () => dispatch(clearNotification()),
+ showSignInDialog: (offset) => dispatch(showSignInDialog(offset)),
+ logout: () => dispatch(logout()),
+ dispatch: d => dispatch(d)
+});
+
+export default compose(
+ connect(mapStateToProps, mapDispatchToProps),
+ postComment,
+ postAction,
+ deleteAction,
+ queryStream
+)(Embed);
diff --git a/client/coral-embed-stream/src/Stream.js b/client/coral-embed-stream/src/Stream.js
new file mode 100644
index 000000000..73842a947
--- /dev/null
+++ b/client/coral-embed-stream/src/Stream.js
@@ -0,0 +1,49 @@
+import React, {PropTypes} from 'react';
+import Comment from './Comment';
+
+const Stream = ({
+ comments,
+ currentUser,
+ asset,
+ postItem,
+ addNotification,
+ postAction,
+ deleteAction,
+ showSignInDialog,
+ refetch
+}) => {
+ return (
+
+ {
+ comments.map(comment => {
+ return ;
+ })
+ }
+
+ );
+};
+
+Stream.propTypes = {
+ refetch: PropTypes.func.isRequired,
+ addNotification: PropTypes.func.isRequired,
+ postItem: PropTypes.func.isRequired,
+ asset: PropTypes.object.isRequired,
+ comments: PropTypes.array.isRequired,
+ currentUser: PropTypes.shape({
+ displayName: PropTypes.string,
+ id: PropTypes.string
+ })
+};
+
+export default Stream;
diff --git a/client/coral-embed-stream/src/graphql/mutations/deleteAction.graphql b/client/coral-embed-stream/src/graphql/mutations/deleteAction.graphql
new file mode 100644
index 000000000..bfce8cf6a
--- /dev/null
+++ b/client/coral-embed-stream/src/graphql/mutations/deleteAction.graphql
@@ -0,0 +1,3 @@
+mutation deleteAction ($id: ID!) {
+ deleteAction(id:$id)
+}
diff --git a/client/coral-embed-stream/src/graphql/mutations/index.js b/client/coral-embed-stream/src/graphql/mutations/index.js
new file mode 100644
index 000000000..383862455
--- /dev/null
+++ b/client/coral-embed-stream/src/graphql/mutations/index.js
@@ -0,0 +1,39 @@
+import {graphql} from 'react-apollo';
+import POST_COMMENT from './postComment.graphql';
+import POST_ACTION from './postAction.graphql';
+import DELETE_ACTION from './deleteAction.graphql';
+
+export const postComment = graphql(POST_COMMENT, {
+ props: ({mutate}) => ({
+ postItem: ({asset_id, body, parent_id} /* , type */ ) => {
+ return mutate({
+ variables: {
+ asset_id,
+ body,
+ parent_id
+ }
+ });
+ }}),
+});
+
+export const postAction = graphql(POST_ACTION, {
+ props: ({mutate}) => ({
+ postAction: (action) => {
+ return mutate({
+ variables: {
+ action
+ }
+ });
+ }}),
+});
+
+export const deleteAction = graphql(DELETE_ACTION, {
+ props: ({mutate}) => ({
+ deleteAction: (id) => {
+ return mutate({
+ variables: {
+ id
+ }
+ });
+ }}),
+});
diff --git a/client/coral-embed-stream/src/graphql/mutations/postAction.graphql b/client/coral-embed-stream/src/graphql/mutations/postAction.graphql
new file mode 100644
index 000000000..fff737fa8
--- /dev/null
+++ b/client/coral-embed-stream/src/graphql/mutations/postAction.graphql
@@ -0,0 +1,5 @@
+mutation CreateAction ($action: CreateActionInput!) {
+ createAction(action:$action) {
+ id
+ }
+}
diff --git a/client/coral-embed-stream/src/graphql/mutations/postComment.graphql b/client/coral-embed-stream/src/graphql/mutations/postComment.graphql
new file mode 100644
index 000000000..a9496f1fa
--- /dev/null
+++ b/client/coral-embed-stream/src/graphql/mutations/postComment.graphql
@@ -0,0 +1,23 @@
+
+ fragment commentView on Comment {
+ id
+ body
+ status
+ user {
+ name: displayName
+ }
+ actions {
+ type: action_type
+ count
+ current: current_user {
+ id
+ created_at
+ }
+ }
+ }
+
+mutation CreateComment ($asset_id: ID!, $parent_id: ID, $body: String!) {
+ createComment(asset_id:$asset_id, parent_id:$parent_id, body:$body) {
+ ...commentView
+ }
+}
diff --git a/client/coral-embed-stream/src/graphql/queries/index.js b/client/coral-embed-stream/src/graphql/queries/index.js
new file mode 100644
index 000000000..ae01ea9f4
--- /dev/null
+++ b/client/coral-embed-stream/src/graphql/queries/index.js
@@ -0,0 +1,9 @@
+import {graphql} from 'react-apollo';
+import STREAM_QUERY from './streamQuery.graphql';
+import pym from 'coral-framework/PymConnection';
+
+let url = pym.parentUrl.split('#')[0] || 'http://localhost:3000/';
+
+export const queryStream = graphql(STREAM_QUERY, {
+ options: {variables: {asset_url: url}}
+});
diff --git a/client/coral-embed-stream/src/graphql/queries/streamQuery.graphql b/client/coral-embed-stream/src/graphql/queries/streamQuery.graphql
new file mode 100644
index 000000000..64bb46b47
--- /dev/null
+++ b/client/coral-embed-stream/src/graphql/queries/streamQuery.graphql
@@ -0,0 +1,47 @@
+
+fragment commentView on Comment {
+ id
+ body
+ created_at
+ user {
+ id
+ name: displayName
+ settings {
+ bio
+ }
+ }
+ actions {
+ type: action_type
+ count
+ current: current_user {
+ id
+ created_at
+ }
+ }
+}
+
+query AssetQuery($asset_url: String!) {
+ asset(url: $asset_url) {
+ id
+ title
+ url
+ closedAt
+ created_at
+ settings {
+ moderation
+ infoBoxEnable
+ infoBoxContent
+ closeTimeout
+ closedMessage
+ charCountEnable
+ charCount
+ requireEmailConfirmation
+ }
+ comments {
+ ...commentView
+ replies {
+ ...commentView
+ }
+ }
+ }
+}
diff --git a/client/coral-embed-stream/src/index.js b/client/coral-embed-stream/src/index.js
index de14edbc7..f75146f0a 100644
--- a/client/coral-embed-stream/src/index.js
+++ b/client/coral-embed-stream/src/index.js
@@ -1,11 +1,15 @@
import React from 'react';
import {render} from 'react-dom';
-import CommentStream from './CommentStream';
-import {Provider} from 'react-redux';
-import {store} from '../../coral-framework';
+import {ApolloProvider} from 'react-apollo';
+
+import {client} from 'coral-framework/client';
+import store from 'coral-framework/store';
+
+import Embed from './Embed';
render(
-
-
-
- , document.querySelector('#coralStream'));
+
+
+
+ , document.querySelector('#coralStream')
+);
diff --git a/client/coral-framework/PymConnection.js b/client/coral-framework/PymConnection.js
new file mode 100644
index 000000000..ca592b824
--- /dev/null
+++ b/client/coral-framework/PymConnection.js
@@ -0,0 +1,9 @@
+import Pym from '../../node_modules/pym.js';
+
+const pym = new Pym.Child({polling: 100});
+export default pym;
+
+export const link = (url) => (e) => {
+ e.preventDefault();
+ pym.sendMessage('navigate', url);
+};
diff --git a/client/coral-framework/actions/config.js b/client/coral-framework/actions/asset.js
similarity index 80%
rename from client/coral-framework/actions/config.js
rename to client/coral-framework/actions/asset.js
index 77e7f5af5..b8474857c 100644
--- a/client/coral-framework/actions/config.js
+++ b/client/coral-framework/actions/asset.js
@@ -1,20 +1,21 @@
+import * as actions from '../constants/asset';
import coralApi from '../helpers/response';
-import * as actions from '../constants/config';
import {addNotification} from '../actions/notification';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from './../translations';
const lang = new I18n(translations);
+export const fetchAssetRequest = () => ({type: actions.FETCH_ASSET_REQUEST});
+export const fetchAssetSuccess = asset => ({type: actions.FETCH_ASSET_SUCCESS, asset});
+export const fetchAssetFailure = error => ({type: actions.FETCH_ASSET_FAILURE, error});
+
const updateConfigRequest = () => ({type: actions.UPDATE_CONFIG_REQUEST});
const updateConfigSuccess = config => ({type: actions.UPDATE_CONFIG_SUCCESS, config});
const updateConfigFailure = () => ({type: actions.UPDATE_CONFIG_FAILURE});
export const updateConfiguration = newConfig => (dispatch, getState) => {
- const assetId = getState().items.get('assets')
- .keySeq()
- .toArray()[0];
-
+ const assetId = getState().asset.toJS().id;
dispatch(updateConfigRequest());
coralApi(`/assets/${assetId}/settings`, {method: 'PUT', body: newConfig})
.then(() => {
@@ -25,12 +26,8 @@ export const updateConfiguration = newConfig => (dispatch, getState) => {
};
export const updateOpenStream = closedBody => (dispatch, getState) => {
- const assetId = getState().items.get('assets')
- .keySeq()
- .toArray()[0];
-
+ const assetId = getState().asset.toJS().id;
dispatch(updateConfigRequest());
-
coralApi(`/assets/${assetId}/status`, {method: 'PUT', body: closedBody})
.then(() => {
dispatch(addNotification('success', lang.t('successUpdateSettings')));
diff --git a/client/coral-framework/actions/auth.js b/client/coral-framework/actions/auth.js
index 6931c0835..7bf85eba1 100644
--- a/client/coral-framework/actions/auth.js
+++ b/client/coral-framework/actions/auth.js
@@ -27,7 +27,7 @@ export const fetchSignIn = (formData) => (dispatch) => {
dispatch(signInRequest());
coralApi('/auth/local', {method: 'POST', body: formData})
.then(({user}) => {
- const isAdmin = !!user.roles.filter(i => i === 'admin').length;
+ const isAdmin = !!user.roles.filter(i => i === 'ADMIN').length;
dispatch(signInSuccess(user, isAdmin));
dispatch(hideSignInDialog());
dispatch(addItem(user, 'users'));
@@ -132,7 +132,7 @@ export const checkLogin = () => dispatch => {
throw new Error('Not logged in');
}
- const isAdmin = !!result.user.roles.filter(i => i === 'admin').length;
+ const isAdmin = !!result.user.roles.filter(i => i === 'ADMIN').length;
dispatch(checkLoginSuccess(result.user, isAdmin));
})
.catch(error => {
diff --git a/client/coral-framework/actions/items.js b/client/coral-framework/actions/items.js
index 48418b2a2..e289e9f60 100644
--- a/client/coral-framework/actions/items.js
+++ b/client/coral-framework/actions/items.js
@@ -189,17 +189,34 @@ export function getItemsArray (ids) {
* The newly put item to the item store
*/
-export function postItem (item, type, id) {
- return (dispatch) => {
- if (id) {
- item.id = id;
+export function postItem (item, type, id, mutate) {
+ console.log(
+ item,
+ type,
+ id,
+ mutate
+ );
+ mutate({
+ variables: {
+ asset_id: id,
+ body: item,
+ parent_id: null
}
- return coralApi(`/${type}`, {method: 'POST', body: item})
- .then((json) => {
- dispatch(addItem({...item, id:json.id}, type));
- return json;
- });
- };
+ }).then(({data}) => {
+ console.log('it workt');
+ console.log(data);
+ });
+
+ // return (dispatch) => {
+ // if (id) {
+ // item.id = id;
+ // }
+ // return coralApi(`/${type}`, {method: 'POST', body: item})
+ // .then((json) => {
+ // dispatch(addItem({...item, id:json.id}, type));
+ // return json;
+ // });
+ // };
}
/*
diff --git a/client/coral-framework/actions/user.js b/client/coral-framework/actions/user.js
index aeb8d0989..021c48eeb 100644
--- a/client/coral-framework/actions/user.js
+++ b/client/coral-framework/actions/user.js
@@ -30,19 +30,27 @@ export const saveBio = (user_id, formData) => dispatch => {
* @returns Promise
*/
export const fetchCommentsByUserId = userId => {
- return (dispatch) => {
+ return (dispatch, getState) => {
dispatch({type: actions.COMMENTS_BY_USER_REQUEST});
return coralApi(`/comments?user_id=${userId}`)
.then(({comments, assets}) => {
+ const state = getState();
comments.forEach(comment => dispatch(addItem(comment, 'comments')));
+ assets.forEach(asset => {
+ const prevAsset = state.items.getIn(['assets', asset.id]);
- assets.forEach(asset => dispatch(addItem(asset, 'assets')));
+ if (prevAsset) {
+
+ // Include data such as hydrated comments from assets already in the system.
+ dispatch(addItem({...prevAsset.toJS(), ...asset}, 'assets'));
+ } else {
+ dispatch(addItem(asset, 'assets'));
+ }
+ });
dispatch({type: actions.COMMENTS_BY_USER_SUCCESS, comments: comments.map(comment => comment.id)});
dispatch({type: assetActions.MULTIPLE_ASSETS_SUCCESS, assets: assets.map(asset => asset.id)});
})
- .catch(error => {
- dispatch({type: actions.COMMENTS_BY_USER_FAILURE, error});
- });
+ .catch(error => dispatch({type: actions.COMMENTS_BY_USER_FAILURE, error}));
};
};
diff --git a/client/coral-framework/client.js b/client/coral-framework/client.js
new file mode 100644
index 000000000..40a539634
--- /dev/null
+++ b/client/coral-framework/client.js
@@ -0,0 +1,13 @@
+import ApolloClient, {addTypename} from 'apollo-client';
+import getNetworkInterface from './transport';
+
+export const client = new ApolloClient({
+ queryTransformer: addTypename,
+ dataIdFromObject: (result) => {
+ if (result.id && result.__typename) { // eslint-disable-line no-underscore-dangle
+ return result.__typename + result.id; // eslint-disable-line no-underscore-dangle
+ }
+ return null;
+ },
+ networkInterface: getNetworkInterface()
+});
diff --git a/client/coral-framework/constants/asset.js b/client/coral-framework/constants/asset.js
new file mode 100644
index 000000000..bfeb98441
--- /dev/null
+++ b/client/coral-framework/constants/asset.js
@@ -0,0 +1,13 @@
+export const FETCH_ASSET_REQUEST = 'FETCH_ASSET_REQUEST';
+export const FETCH_ASSET_FAILURE = 'FETCH_ASSET_FAILURE';
+export const FETCH_ASSET_SUCCESS = 'FETCH_ASSET_SUCCESS';
+
+export const UPDATE_CONFIG_REQUEST = 'UPDATE_CONFIG_REQUEST';
+export const UPDATE_CONFIG_SUCCESS = 'UPDATE_CONFIG_SUCCESS';
+export const UPDATE_CONFIG_FAILURE = 'UPDATE_CONFIG_FAILURE';
+
+export const UPDATE_CONFIG = 'UPDATE_CONFIG';
+
+export const OPEN_COMMENTS = 'OPEN_COMMENTS';
+export const CLOSE_COMMENTS = 'CLOSE_COMMENTS';
+export const ADD_ITEM = 'ADD_ITEM';
diff --git a/client/coral-framework/helpers/validate.js b/client/coral-framework/helpers/validate.js
index 80efb1b1f..6e5db3662 100644
--- a/client/coral-framework/helpers/validate.js
+++ b/client/coral-framework/helpers/validate.js
@@ -2,5 +2,5 @@ export default {
email: email => (/^([A-Za-z0-9_\-\.])+\@([A-Za-z0-9_\-\.])+\.([A-Za-z]{2,4})$/.test(email)),
password: pass => (/^(?=.{8,}).*$/.test(pass)),
confirmPassword: () => true,
- displayName: displayName => (/^[a-z0-9_]+$/.test(displayName))
+ displayName: displayName => (/^[a-zA-Z0-9_]+$/.test(displayName))
};
diff --git a/client/coral-framework/index.js b/client/coral-framework/index.js
index 9a679d969..c21136d27 100644
--- a/client/coral-framework/index.js
+++ b/client/coral-framework/index.js
@@ -4,7 +4,8 @@ import * as itemActions from './actions/items';
import I18n from './modules/i18n/i18n';
import * as notificationActions from './actions/notification';
import * as authActions from './actions/auth';
-import * as configActions from './actions/config';
+import * as assetActions from './actions/asset';
+import pym from './PymConnection';
export {
Notification,
@@ -13,5 +14,6 @@ export {
I18n,
notificationActions,
authActions,
- configActions
+ assetActions,
+ pym
};
diff --git a/client/coral-framework/reducers/asset.js b/client/coral-framework/reducers/asset.js
new file mode 100644
index 000000000..495307aeb
--- /dev/null
+++ b/client/coral-framework/reducers/asset.js
@@ -0,0 +1,36 @@
+import {Map} from 'immutable';
+import * as actions from '../constants/asset';
+
+const initialState = Map({
+ closedAt: null,
+ settings: null,
+ title: null,
+ url: null,
+ features: Map({}),
+ status: 'open',
+ moderation: null
+});
+
+export default function asset (state = initialState, action) {
+ switch (action.type) {
+ case actions.FETCH_ASSET_SUCCESS :
+ return state
+ .merge(action.asset);
+ case actions.UPDATE_CONFIG:
+ return state
+ .merge(action.config);
+ case actions.UPDATE_CONFIG_SUCCESS:
+ return state
+ .merge(action.config);
+ case actions.OPEN_COMMENTS:
+ return state
+ .set('status', 'open')
+ .set('closedAt', null);
+ case actions.CLOSE_COMMENTS:
+ return state
+ .set('status', 'closed')
+ .set('closedAt', Date.now());
+ default:
+ return state;
+ }
+}
diff --git a/client/coral-framework/reducers/config.js b/client/coral-framework/reducers/config.js
deleted file mode 100644
index 362608d6d..000000000
--- a/client/coral-framework/reducers/config.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import {Map} from 'immutable';
-import * as actions from '../constants/config';
-
-const initialState = Map({
- features: Map({}),
- status: 'open',
- moderation: null
-});
-
-export default (state = initialState, action) => {
- switch(action.type) {
- case actions.UPDATE_CONFIG:
- return state
- .merge(Map(action.config));
- case actions.UPDATE_CONFIG_SUCCESS:
- return state
- .merge(Map(action.config));
- case actions.OPEN_COMMENTS:
- return state
- .set('status', 'open');
- case actions.CLOSE_COMMENTS:
- return state
- .set('status', 'closed');
- case actions.ADD_ITEM:
- return action.item_type === 'assets' ? state.set('status', (action.item && action.item.closedAt && new Date(action.item.closedAt).getTime() <= new Date().getTime()) ? 'closed' : 'open') : state;
- default:
- return state;
- }
-};
diff --git a/client/coral-framework/reducers/index.js b/client/coral-framework/reducers/index.js
index a2f439028..8c1457bfe 100644
--- a/client/coral-framework/reducers/index.js
+++ b/client/coral-framework/reducers/index.js
@@ -1,20 +1,13 @@
-/* @flow */
-
-import {combineReducers} from 'redux';
-import config from './config';
-import items from './items';
-import notification from './notification';
import auth from './auth';
import user from './user';
+import asset from './asset';
+import items from './items';
+import notification from './notification';
-/**
- * Expose the combined main reducer
- */
-
-export default combineReducers({
- config,
- items,
- notification,
+export default {
auth,
- user
-});
+ user,
+ asset,
+ items,
+ notification
+};
diff --git a/client/coral-framework/store.js b/client/coral-framework/store.js
index 8c3f9edc1..439569ee1 100644
--- a/client/coral-framework/store.js
+++ b/client/coral-framework/store.js
@@ -1,9 +1,24 @@
-import {createStore, applyMiddleware} from 'redux';
+import {createStore, combineReducers, applyMiddleware, compose} from 'redux';
import thunk from 'redux-thunk';
import mainReducer from './reducers';
+import {client} from './client';
+
+const middlewares = [
+ applyMiddleware(client.middleware()),
+ applyMiddleware(thunk)
+];
+
+if (window.devToolsExtension) {
+
+ // we can't have the last argument of compose() be undefined
+ middlewares.push(window.devToolsExtension());
+}
export default createStore(
- mainReducer,
- window.devToolsExtension && window.devToolsExtension(),
- applyMiddleware(thunk)
+ combineReducers({
+ ...mainReducer,
+ apollo: client.reducer()
+ }),
+ {},
+ compose(...middlewares)
);
diff --git a/client/coral-framework/subscriptions.js b/client/coral-framework/subscriptions.js
new file mode 100644
index 000000000..f514ea18b
--- /dev/null
+++ b/client/coral-framework/subscriptions.js
@@ -0,0 +1,14 @@
+import {print} from 'graphql-tag/printer';
+
+// quick way to add the subscribe and unsubscribe functions to the network interface
+const addGraphQLSubscriptions = (networkInterface, wsClient) => Object.assign(networkInterface, {
+ subscribe: (request, handler) => wsClient.subscribe({
+ query: print(request.query),
+ variables: request.variables,
+ }, handler),
+ unsubscribe: (id) => {
+ wsClient.unsubscribe(id);
+ },
+});
+
+export default addGraphQLSubscriptions;
diff --git a/client/coral-framework/translations.json b/client/coral-framework/translations.json
index 112f7db1a..5bf9b40a9 100644
--- a/client/coral-framework/translations.json
+++ b/client/coral-framework/translations.json
@@ -14,6 +14,8 @@
"PASSWORD_REQUIRED": "Must input a password",
"PASSWORD_LENGTH": "Password is too short",
"EMAIL_IN_USE": "Email address already in use",
+ "EMAIL_DISPLAY_NAME_IN_USE": "Email address or display name already in use",
+ "DISPLAYNAME_IN_USE": "Display name already in use",
"DISPLAY_NAME_REQUIRED": "Must input a display name",
"NO_SPECIAL_CHARACTERS": "Display names 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."
@@ -34,6 +36,8 @@
"PASSWORD_REQUIRED": "Debe ingresar una contraseña",
"PASSWORD_LENGTH": "La contraseña es muy corta",
"EMAIL_IN_USE": "La dirección de correo electrónico se encuentra en uso",
+ "EMAIL_DISPLAY_NAME_IN_USE": "Correo o Nombre en uso.",
+ "DISPLAYNAME_IN_USE": "Nombre en uso.",
"DISPLAY_NAME_REQUIRED": "Debe ingresar un nombre",
"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"
diff --git a/client/coral-framework/transport.js b/client/coral-framework/transport.js
new file mode 100644
index 000000000..2bd6ac636
--- /dev/null
+++ b/client/coral-framework/transport.js
@@ -0,0 +1,11 @@
+import {createNetworkInterface} from 'apollo-client';
+
+export default function getNetworkInterface(apiUrl = '/api/v1/graph/ql', headers = {}) {
+ return new createNetworkInterface({
+ uri: apiUrl,
+ opts: {
+ credentials: 'same-origin',
+ headers,
+ },
+ });
+}
diff --git a/client/coral-plugin-author-name/AuthorName.js b/client/coral-plugin-author-name/AuthorName.js
index bf970b3c4..fcf464403 100644
--- a/client/coral-plugin-author-name/AuthorName.js
+++ b/client/coral-plugin-author-name/AuthorName.js
@@ -36,8 +36,8 @@ export default class AuthorName extends Component {
onMouseOver={this.handleMouseOver}
onMouseLeave={this.handleMouseLeave}
>
- {author && author.displayName}
- { showTooltip &&
+ {author && author.name}
+ { showTooltip && author.settings.bio &&
{author.settings.bio}
diff --git a/client/coral-plugin-comment-count/CommentCount.js b/client/coral-plugin-comment-count/CommentCount.js
index 87f656c22..764984634 100644
--- a/client/coral-plugin-comment-count/CommentCount.js
+++ b/client/coral-plugin-comment-count/CommentCount.js
@@ -1,29 +1,18 @@
-import React from 'react';
+import React, {PropTypes} from 'react';
import {I18n} from '../coral-framework';
import translations from './translations.json';
-import has from 'lodash/has';
-import reduce from 'lodash/reduce';
const name = 'coral-plugin-comment-count';
-const CommentCount = ({items, id}) => {
- let count = 0;
- if (has(items, `assets.${id}.comments`)) {
- count += items.assets[id].comments.length;
- }
-
- // lodash reduce works on {}
- count += reduce(items.comments, (accum, comment) => {
- if (comment.children) {
- accum += comment.children.length;
- }
- return accum;
- }, 0);
-
+const CommentCount = ({count}) => {
return
{`${count} ${count === 1 ? lang.t('comment') : lang.t('comment-plural')}`}
;
};
+CommentCount.propTypes = {
+ count: PropTypes.number.isRequired
+};
+
export default CommentCount;
const lang = new I18n(translations);
diff --git a/client/coral-plugin-commentbox/CommentBox.js b/client/coral-plugin-commentbox/CommentBox.js
index fe67b22e0..f368953a7 100644
--- a/client/coral-plugin-commentbox/CommentBox.js
+++ b/client/coral-plugin-commentbox/CommentBox.js
@@ -8,11 +8,15 @@ const name = 'coral-plugin-commentbox';
class CommentBox extends Component {
static propTypes = {
- postItem: PropTypes.func,
- updateItem: PropTypes.func,
- id: PropTypes.string,
- comments: PropTypes.array,
- reply: PropTypes.bool,
+
+ // updateItem: PropTypes.func,
+ // comments: PropTypes.array,
+ commentPostedHandler: PropTypes.func,
+ postItem: PropTypes.func.isRequired,
+ assetId: PropTypes.string.isRequired,
+ parentId: PropTypes.string,
+ authorId: PropTypes.string.isRequired,
+ isReply: PropTypes.bool.isRequired,
canPost: PropTypes.bool,
currentUser: PropTypes.object
}
@@ -24,73 +28,83 @@ class CommentBox extends Component {
postComment = () => {
const {
+
+ // child_id,
+ // updateItem,
+ // appendItemArray,
+ commentPostedHandler,
postItem,
- updateItem,
- id,
- parent_id,
- child_id,
+ assetId,
+ parentId,
addNotification,
- appendItemArray,
- premod,
- author
+ authorId
} = this.props;
let comment = {
body: this.state.body,
- asset_id: id,
- author_id: author.id
+ asset_id: assetId,
+ author_id: authorId,
+ parent_id: parentId
};
- let related;
- let parent_type;
- if (parent_id) {
- comment.parent_id = parent_id;
- related = 'children';
- parent_type = 'comments';
- } else {
- related = 'comments';
- parent_type = 'assets';
- }
- if (child_id || parent_id) {
- updateItem(child_id || parent_id, 'showReply', false, 'comments');
- }
+
+ // let related;
+ // let parent_type;
+ // if (parent_id) {
+ // comment.parent_id = parent_id;
+ // related = 'children';
+ // parent_type = 'comments';
+ // } else {
+ // related = 'comments';
+ // parent_type = 'assets';
+ // }
+ // if (child_id || parent_id) {
+ // updateItem(child_id || parent_id, 'showReply', false, 'comments');
+ // }
if (this.props.charCount && this.state.body.length > this.props.charCount) {
return;
}
postItem(comment, 'comments')
- .then((postedComment) => {
- const commentId = postedComment.id;
- if (postedComment.status === 'rejected') {
+ .then(({data}) => {
+ const postedComment = data.createComment;
+
+ // const commentId = postedComment.id;
+ if (postedComment.status === 'REJECTED') {
addNotification('error', lang.t('comment-post-banned-word'));
- } else if (premod === 'pre') {
+ } else if (postedComment.status === 'PREMOD') {
addNotification('success', lang.t('comment-post-notif-premod'));
} else {
- appendItemArray(parent_id || id, related, commentId, !parent_id, parent_type);
+
+ // appendItemArray(parent_id || id, related, commentId, !parent_id, parent_type);
addNotification('success', 'Your comment has been posted.');
}
+
+ if (commentPostedHandler) {
+ commentPostedHandler();
+ }
})
.catch((err) => console.error(err));
this.setState({body: ''});
}
render () {
- const {styles, reply, author, charCount} = this.props;
+ const {styles, isReply, authorId, charCount} = this.props;
const length = this.state.body.length;
return
- {reply ? lang.t('reply') : lang.t('comment')}
+ {isReply ? lang.t('reply') : lang.t('comment')}
@@ -101,7 +115,7 @@ class CommentBox extends Component {
}
- { author && (
+ { authorId && (
charCount) ? 'lightGrey' : 'darkGrey'}
className={`${name}-button`}
diff --git a/client/coral-plugin-flags/FlagBio.js b/client/coral-plugin-flags/FlagBio.js
index 01e715dca..2639ab8cf 100644
--- a/client/coral-plugin-flags/FlagBio.js
+++ b/client/coral-plugin-flags/FlagBio.js
@@ -9,7 +9,7 @@ const getPopupMenu = [
() => {
return {
header: lang.t('step-2-header'),
- itemType: 'users',
+ itemType: 'USERS',
field: 'bio',
options: [
{val: 'This bio is offensive', text: lang.t('bio-offensive')},
diff --git a/client/coral-plugin-flags/FlagButton.js b/client/coral-plugin-flags/FlagButton.js
index 1584f28e8..e376d8ff5 100644
--- a/client/coral-plugin-flags/FlagButton.js
+++ b/client/coral-plugin-flags/FlagButton.js
@@ -14,22 +14,31 @@ class FlagButton extends Component {
reason: '',
note: '',
step: 0,
- posted: false
+ localPost: null,
+ localDelete: false
}
// When the "report" button is clicked expand the menu
onReportClick = () => {
- if (!this.props.currentUser) {
+ const {currentUser, flag, deleteAction} = this.props;
+ const {localPost, localDelete} = this.state;
+ const flagged = (flag && flag.current && !localDelete) || localPost;
+ if (!currentUser) {
const offset = document.getElementById(`c_${this.props.id}`).getBoundingClientRect().top - 75;
this.props.showSignInDialog(offset);
return;
}
- this.setState({showMenu: !this.state.showMenu});
+ if (flagged) {
+ this.setState((prev) => prev.localPost ? {...prev, localPost: null, step: 0} : {...prev, localDelete: true});
+ deleteAction(localPost || flag.current.id);
+ } else {
+ this.setState({showMenu: !this.state.showMenu});
+ }
}
onPopupContinue = () => {
- const {postAction, addItem, updateItem, flag, id, author_id} = this.props;
- const {itemType, field, reason, step, note, posted} = this.state;
+ const {postAction, id, author_id} = this.props;
+ const {itemType, reason, step, localPost} = this.state;
// Proceed to the next step or close the menu if we've reached the end
if (step + 1 >= this.props.getPopupMenu.length) {
@@ -39,33 +48,32 @@ class FlagButton extends Component {
}
// If itemType and reason are both set, post the action
- if (itemType && reason && !posted) {
+ if (itemType && reason && !localPost) {
// Set the text from the "other" field if it exists.
let item_id;
switch(itemType) {
- case 'comments':
+ case 'COMMENTS':
item_id = id;
break;
- case 'users':
+ case 'USERS':
item_id = author_id;
break;
}
- const action = {
- action_type: `flag_${field}`,
- metadata: {
- field,
- reason,
- note
+
+ // Note: Action metadata has been temporarily removed.
+ if (itemType === 'COMMENTS') {
+ this.setState({localPost: 'temp'});
+ }
+ postAction({
+ item_id,
+ item_type: itemType,
+ action_type: 'FLAG'
+ }).then(({data}) => {
+ if (itemType === 'COMMENTS') {
+ this.setState({localPost: data.createAction.id});
}
- };
- postAction(item_id, itemType, action)
- .then((action) => {
- let id = `${action.action_type}_${action.item_id}`;
- addItem({id, current_user: action, count: flag ? flag.count + 1 : 1}, 'actions');
- updateItem(action.item_id, action.action_type, id, action.item_type);
- this.setState({posted: true});
- });
+ });
}
}
@@ -98,7 +106,8 @@ class FlagButton extends Component {
render () {
const {flag, getPopupMenu} = this.props;
- const flagged = flag && flag.current_user;
+ const {localPost, localDelete} = this.state;
+ const flagged = (flag && flag.current && !localDelete) || localPost;
const popupMenu = getPopupMenu[this.state.step](this.state.itemType);
return
@@ -106,7 +115,7 @@ class FlagButton extends Component {
{
flagged
? {lang.t('reported')}
- : {lang.t('report')}
+ : {lang.t('report')}
}
{
- const options = itemType === 'comments' ?
+ const options = itemType === 'COMMENTS' ?
[
{val: 'I don\'t agree with this comment', text: lang.t('no-agree-comment')},
{val: 'This comment is offensive', text: lang.t('comment-offensive')},
diff --git a/client/coral-plugin-history/Comment.js b/client/coral-plugin-history/Comment.js
index 036753623..df61b9e7d 100644
--- a/client/coral-plugin-history/Comment.js
+++ b/client/coral-plugin-history/Comment.js
@@ -1,12 +1,11 @@
import React, {PropTypes} from 'react';
-
import styles from './Comment.css';
const Comment = props => {
return (
diff --git a/client/coral-plugin-history/CommentHistory.js b/client/coral-plugin-history/CommentHistory.js
index c18846066..add7c121a 100644
--- a/client/coral-plugin-history/CommentHistory.js
+++ b/client/coral-plugin-history/CommentHistory.js
@@ -11,6 +11,7 @@ const CommentHistory = props => {
return ;
})}
diff --git a/client/coral-plugin-likes/LikeButton.js b/client/coral-plugin-likes/LikeButton.js
index fae30c8c7..cc2c6d83e 100644
--- a/client/coral-plugin-likes/LikeButton.js
+++ b/client/coral-plugin-likes/LikeButton.js
@@ -1,52 +1,76 @@
-import React from 'react';
+import React, {Component, PropTypes} from 'react';
import {I18n} from '../coral-framework';
import translations from './translations.json';
const name = 'coral-plugin-likes';
-const LikeButton = ({like, id, postAction, deleteAction, addItem, showSignInDialog, updateItem, currentUser, banned}) => {
- const liked = like && like.current_user;
- const onLikeClick = () => {
- if (!currentUser) {
- const offset = document.getElementById(`c_${id}`).getBoundingClientRect().top - 75;
- showSignInDialog(offset);
- return;
- }
- if (banned) {
- return;
- }
- if (!liked) {
- const action = {
- action_type: 'like'
- };
- postAction(id, 'comments', action)
- .then((action) => {
- let id = `${action.action_type}_${action.item_id}`;
- addItem({id, current_user: action, count: like ? like.count + 1 : 1}, 'actions');
- updateItem(action.item_id, action.action_type, id, 'comments');
- });
- } else {
- deleteAction(liked.id)
- .then(() => {
- updateItem(like.id, 'count', like.count - 1, 'actions');
- updateItem(like.id, 'current_user', false, 'actions');
- });
- }
- };
+class LikeButton extends Component {
- return
-
- {
- liked
- ? {lang.t('liked')}
- : {lang.t('like')}
+ static propTypes = {
+ like: PropTypes.shape({
+ current: PropTypes.obect,
+ count: PropTypes.number
+ }),
+ id: PropTypes.string,
+ postAction: PropTypes.func.isRequired,
+ deleteAction: PropTypes.func.isRequired,
+ showSignInDialog: PropTypes.func.isRequired,
+ currentUser: PropTypes.shape({
+ banned: PropTypes.boolean
+ }),
+ }
+
+ state = {
+ localPost: null, // Set to the ID of an action if one is posted
+ localDelete: false // Set to true is the user deletes an action, unless localPost is already set.
+ }
+
+ render() {
+ const {like, id, postAction, deleteAction, showSignInDialog, currentUser} = this.props;
+ const {localPost, localDelete} = this.state;
+ const liked = (like && like.current && !localDelete) || localPost;
+ let count = like ? like.count : 0;
+ if (localPost) {count += 1;}
+ if (localDelete) {count -= 1;}
+
+ const onLikeClick = () => {
+ if (!currentUser) {
+ const offset = document.getElementById(`c_${id}`).getBoundingClientRect().top - 75;
+ showSignInDialog(offset);
+ return;
}
- thumb_up
- {like && like.count > 0 && like.count}
-
- ;
-};
+ if (currentUser.banned) {
+ return;
+ }
+ if (!liked) {
+ this.setState({localPost: 'temp', localDelete: false});
+ postAction({
+ item_id: id,
+ item_type: 'COMMENTS',
+ action_type: 'LIKE'
+ }).then(({data}) => {
+ this.setState({localPost: data.createAction.id});
+ });
+ } else {
+ this.setState((prev) => prev.localPost ? {...prev, localPost: null} : {...prev, localDelete: true});
+ deleteAction(localPost || like.current.id);
+ }
+ };
+
+ return
+
+ {
+ liked
+ ? {lang.t('liked')}
+ : {lang.t('like')}
+ }
+ thumb_up
+ {count > 0 && count}
+
+ ;
+ }
+}
export default LikeButton;
diff --git a/client/coral-plugin-replies/ReplyBox.js b/client/coral-plugin-replies/ReplyBox.js
index 6614aed72..e7ef83086 100644
--- a/client/coral-plugin-replies/ReplyBox.js
+++ b/client/coral-plugin-replies/ReplyBox.js
@@ -1,17 +1,28 @@
-import React from 'react';
+import React, {PropTypes} from 'react';
import CommentBox from '../coral-plugin-commentbox/CommentBox';
const name = 'coral-plugin-replies';
-const ReplyBox = (props) =>
- {
- props.showReply &&
- }
- ;
+const ReplyBox = ({styles, postItem, assetId, authorId, addNotification, parentId, commentPostedHandler}) => (
+
+
+
+);
+
+ReplyBox.propTypes = {
+ commentPostedHandler: PropTypes.func,
+ parentId: PropTypes.string,
+ addNotification: PropTypes.func.isRequired,
+ authorId: PropTypes.string.isRequired,
+ postItem: PropTypes.func.isRequired,
+ assetId: PropTypes.string.isRequired
+};
export default ReplyBox;
diff --git a/client/coral-plugin-replies/ReplyButton.js b/client/coral-plugin-replies/ReplyButton.js
index c44012cdb..68374fd2f 100644
--- a/client/coral-plugin-replies/ReplyButton.js
+++ b/client/coral-plugin-replies/ReplyButton.js
@@ -1,21 +1,25 @@
-import React from 'react';
+import React, {PropTypes} from 'react';
import {I18n} from '../coral-framework';
import translations from './translations.json';
const name = 'coral-plugin-replies';
-const ReplyButton = (props) => {
- if (props.banned) {
- return;
- }
- props.updateItem(props.id, 'showReply', !props.showReply, 'comments');
- }}>
- {lang.t('reply')}
- reply
- ;
+const ReplyButton = ({banned, onClick}) => {
+ return (
+
+ {lang.t('reply')}
+ {banned ? 'BANNED' : 'reply'}
+
+ );
+};
+
+ReplyButton.propTypes = {
+ onClick: PropTypes.func.isRequired,
+ banned: PropTypes.bool.isRequired
+};
export default ReplyButton;
diff --git a/client/coral-plugin-stream/RileysAwesomeCommentBox.js b/client/coral-plugin-stream/RileysAwesomeCommentBox.js
new file mode 100644
index 000000000..6ccd028ba
--- /dev/null
+++ b/client/coral-plugin-stream/RileysAwesomeCommentBox.js
@@ -0,0 +1,58 @@
+import React, {Component} from 'react';
+import {graphql} from 'react-apollo';
+import gql from 'graphql-tag';
+
+export class RileysAwesomeCommentBox extends Component {
+
+ postComment() {
+ console.log(this.props);
+ console.log('postComment', this.props.asset_id);
+ this.props.mutate({
+ variables: {
+ asset_id: this.props.asset_id,
+ body: this.textarea.value,
+ parent_id: null
+ }
+ }).then(({data}) => {
+ console.log('it workt');
+ console.log(data);
+ });
+ }
+
+ render() {
+ return
+
+ POST
+ ;
+ }
+}
+
+const postComment = gql`
+ fragment commentView on Comment {
+ id
+ body
+ user {
+ name: displayName
+ }
+ actions {
+ type: action_type
+ count
+ current: current_user {
+ id
+ created_at
+ }
+ }
+ }
+
+ mutation CreateComment ($asset_id: ID!, $parent_id: ID, $body: String!) {
+ createComment(asset_id:$asset_id, parent_id:$parent_id, body:$body) {
+ ...commentView
+ }
+ }
+`;
+
+const RileysAwesomeCommentBoxWithData = graphql(
+ postComment
+)(RileysAwesomeCommentBox);
+
+export default RileysAwesomeCommentBoxWithData;
diff --git a/client/coral-plugin-stream/Stream.js b/client/coral-plugin-stream/Stream.js
new file mode 100644
index 000000000..2ec84af24
--- /dev/null
+++ b/client/coral-plugin-stream/Stream.js
@@ -0,0 +1,97 @@
+import React, {Component} from 'react';
+import {graphql} from 'react-apollo';
+import gql from 'graphql-tag';
+import {fetchSignIn} from 'coral-framework/actions/auth';
+import RileysAwesomeCommentBox from 'coral-plugin-stream/RileysAwesomeCommentBox';
+
+const assetID = '6187a94b-0b6d-4a96-ac6b-62b529cd8410';
+
+// MyComponent is a "presentational" or apollo-unaware component,
+// It could be a simple React class:
+class Stream extends Component {
+
+ constructor(props) {
+ super(props);
+ }
+
+ logMeIn() {
+ fetchSignIn({email: 'your@example.com', password: 'dfasidfaisdufoiausdfoiuaspdoifas'})(() => {});
+ }
+
+ render() {
+ console.log(this.props);
+ const {data} = this.props;
+ return
+ Login or whatever
+ {
+ data.loading
+ ? 'loading!'
+ :
+
+ Asset ID: {data.asset.id}
+
+ {
+ data.asset.comments.map(comment => {
+ return
+ {comment.body} [{comment.id}]
+
+ {
+ comment.replies.map(reply => {
+ return {reply.body} ;
+ })
+ }
+
+ ;
+ })
+ }
+
+
+ }
+ ;
+ }
+}
+
+// Initialize GraphQL queries or mutations with the `gql` tag
+const StreamQuery = gql`fragment commentView on Comment {
+ id
+ body
+ user {
+ name: displayName
+ }
+ actions {
+ type: action_type
+ count
+ current: current_user {
+ id
+ created_at
+ }
+ }
+}
+
+query AssetQuery($asset_id: ID!) {
+ asset(id: $asset_id) {
+ id
+ title
+ url
+ comments {
+ ...commentView
+ replies {
+ ...commentView
+ }
+ }
+ }
+}`;
+
+// We then can use `graphql` to pass the query results returned by MyQuery
+// to MyComponent as a prop (and update them as the results change)
+const StreamWithData = graphql(
+ StreamQuery, {
+ options: {
+ variables: {
+ asset_id: assetID
+ }
+ }
+ }
+)(Stream);
+
+export default StreamWithData;
diff --git a/client/coral-settings/components/NotLoggedIn.js b/client/coral-settings/components/NotLoggedIn.js
index d7bf467e8..8266a745e 100644
--- a/client/coral-settings/components/NotLoggedIn.js
+++ b/client/coral-settings/components/NotLoggedIn.js
@@ -1,10 +1,15 @@
import React from 'react';
import styles from './NotLoggedIn.css';
+import SignInContainer from '../../coral-sign-in/containers/SignInContainer';
export default ({showSignInDialog}) => (
+
From the Settings Page you can
diff --git a/client/coral-settings/components/SettingsHeader.js b/client/coral-settings/components/SettingsHeader.js
index b55f008ad..1ff12bdef 100644
--- a/client/coral-settings/components/SettingsHeader.js
+++ b/client/coral-settings/components/SettingsHeader.js
@@ -4,7 +4,11 @@ import styles from './SettingsHeader.css';
export default ({userData}) => (
{userData.displayName}
- {userData.profiles.map(profile => profile.id)}
+
+ {
+
+ // Hiding display of users ID unless there's a use case for it.
+ // {userData.profiles.map(profile => profile.id)}
+ }
);
-
diff --git a/client/coral-settings/containers/SettingsContainer.js b/client/coral-settings/containers/SettingsContainer.js
index e7b930255..001190f1d 100644
--- a/client/coral-settings/containers/SettingsContainer.js
+++ b/client/coral-settings/containers/SettingsContainer.js
@@ -3,6 +3,7 @@ import {connect} from 'react-redux';
import {saveBio, fetchCommentsByUserId} from 'coral-framework/actions/user';
+import {link} from 'coral-framework/PymConnection';
import BioContainer from './BioContainer';
import NotLoggedIn from '../components/NotLoggedIn';
import {TabBar, Tab, TabContent} from '../../coral-ui';
@@ -62,6 +63,7 @@ class SettingsContainer extends Component {
user.myComments.length && user.myAssets.length
? items.assets[id])} />
: {lang.t('user-no-comment')}
}
diff --git a/client/coral-sign-in/components/SignDialog.js b/client/coral-sign-in/components/SignDialog.js
index 508a8a48f..6243472b4 100644
--- a/client/coral-sign-in/components/SignDialog.js
+++ b/client/coral-sign-in/components/SignDialog.js
@@ -3,7 +3,7 @@ import {Dialog} from 'coral-ui';
import styles from './styles.css';
import SignInContent from './SignInContent';
-import SingUpContent from './SignUpContent';
+import SignUpContent from './SignUpContent';
import ForgotContent from './ForgotContent';
const SignDialog = ({open, view, handleClose, offset, ...props}) => (
@@ -17,7 +17,7 @@ const SignDialog = ({open, view, handleClose, offset, ...props}) => (
}}>
×
{view === 'SIGNIN' && }
- {view === 'SIGNUP' && }
+ {view === 'SIGNUP' && }
{view === 'FORGOT' && }
);
diff --git a/client/coral-sign-in/components/SignUpContent.js b/client/coral-sign-in/components/SignUpContent.js
index 2f9e9afe1..4144b1d2f 100644
--- a/client/coral-sign-in/components/SignUpContent.js
+++ b/client/coral-sign-in/components/SignUpContent.js
@@ -56,7 +56,7 @@ const SignUpContent = ({handleChange, formData, ...props}) => (
onChange={handleChange}
minLength="8"
/>
- { !props.errors.password && Password must be at least 8 characters. }
+ { props.errors.password && Password must be at least 8 characters. }
(
+const Button = ({cStyle = 'local', children, className, raised = false, full = false, icon = '', ...props}) => (
+ {icon && }
{children}
);
diff --git a/client/coral-ui/components/Card.css b/client/coral-ui/components/Card.css
new file mode 100644
index 000000000..d4b5b1e91
--- /dev/null
+++ b/client/coral-ui/components/Card.css
@@ -0,0 +1,35 @@
+.base {
+ display: -webkit-flex;
+ display: -ms-flexbox;
+ display: flex;
+ -webkit-flex-direction: column;
+ -ms-flex-direction: column;
+ flex-direction: column;
+ font-size: 16px;
+ font-weight: 400;
+ min-height: 200px;
+ overflow: hidden;
+ width: 330px;
+ z-index: 1;
+ position: relative;
+ background: #fff;
+ border-radius: 2px;
+ box-sizing: border-box;
+
+ width: 100%;
+ padding: 20px;
+}
+
+.shadow--4 {
+ box-shadow: 0 4px 5px 0 rgba(0,0,0,.14), 0 1px 10px 0 rgba(0,0,0,.12), 0 2px 4px -1px rgba(0,0,0,.2);
+}
+
+.shadow--2{
+ box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12);
+}
+
+.shadow--3 {
+ box-shadow: 0 3px 4px 0 rgba(0,0,0,.14), 0 3px 3px -2px rgba(0,0,0,.2), 0 1px 8px 0 rgba(0,0,0,.12);
+}
+
+
diff --git a/client/coral-ui/components/Card.js b/client/coral-ui/components/Card.js
new file mode 100644
index 000000000..0bdb490e7
--- /dev/null
+++ b/client/coral-ui/components/Card.js
@@ -0,0 +1,8 @@
+import React from 'react';
+import styles from './Card.css';
+
+export default ({children, className, shadow = 2, ...props}) => (
+
+ {children}
+
+);
diff --git a/client/coral-ui/components/CoralLogo.css b/client/coral-ui/components/CoralLogo.css
new file mode 100644
index 000000000..6091cffcb
--- /dev/null
+++ b/client/coral-ui/components/CoralLogo.css
@@ -0,0 +1,8 @@
+.base {
+ height: 30px;
+ width : 30px;
+}
+
+.mark {
+ stroke: #FFFFFF;
+}
diff --git a/client/coral-ui/components/CoralLogo.js b/client/coral-ui/components/CoralLogo.js
index f85fd6f2d..002b0cabc 100644
--- a/client/coral-ui/components/CoralLogo.js
+++ b/client/coral-ui/components/CoralLogo.js
@@ -1,9 +1,10 @@
import React, {PropTypes} from 'react';
+import styles from './CoralLogo.css';
-const CoralLogo = ({height = '30px', width = '30px', stroke = '#FFFFFF'}) => (
-
+const CoralLogo = ({className = ''}) => (
+
-
+
diff --git a/client/coral-ui/components/FabButton.css b/client/coral-ui/components/FabButton.css
index 688190407..c90d857d9 100644
--- a/client/coral-ui/components/FabButton.css
+++ b/client/coral-ui/components/FabButton.css
@@ -1,9 +1,25 @@
+.base {
+ background: red;
+ i {
+ font-size: 30px;
+ transform: translate(-14px,-12px) !important;
+ }
+}
+
.type--approve {
- background: #00796b;
- color: rgba(255, 255, 255, 0.901961);
+ background: #388E3C;
+ color: rgba(255, 255, 255, 0.901961);
+}
+
+.type--approve:hover {
+ background: #40a244;
}
.type--reject {
- background: #d32f2f ;
- color: rgba(255, 255, 255, 0.901961);
+ background: #D32F2F ;
+ color: rgba(255, 255, 255, 0.901961);
+}
+
+.type--reject:hover {
+ background: #e53333;
}
diff --git a/client/coral-ui/components/FabButton.js b/client/coral-ui/components/FabButton.js
index df48af55d..64ba4e82a 100644
--- a/client/coral-ui/components/FabButton.js
+++ b/client/coral-ui/components/FabButton.js
@@ -3,7 +3,7 @@ import styles from './FabButton.css';
import {FABButton, Icon} from 'react-mdl';
const FabButton = ({cStyle = 'local', icon, className, ...props}) => (
-
+
);
diff --git a/client/coral-ui/components/Icon.js b/client/coral-ui/components/Icon.js
new file mode 100644
index 000000000..65bf52f72
--- /dev/null
+++ b/client/coral-ui/components/Icon.js
@@ -0,0 +1,8 @@
+import React from 'react';
+import {Icon as IconMDL} from 'react-mdl';
+
+const Icon = ({className, name}) => (
+
+);
+
+export default Icon;
diff --git a/client/coral-ui/components/Item.css b/client/coral-ui/components/Item.css
new file mode 100644
index 000000000..3a608d28c
--- /dev/null
+++ b/client/coral-ui/components/Item.css
@@ -0,0 +1,24 @@
+.base {
+ box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12);
+ background-color: white;
+ width: 208px;
+ padding: 10px 14px;
+ font-size: 14px;
+ margin-bottom: 10px;
+ list-style: none;
+
+ &:hover {
+ cursor: pointer;
+ }
+
+ &.active {
+ color: white;
+ background-color: #262626;
+ }
+
+ i {
+ margin-right: 13px;
+ font-size: 18px;
+ vertical-align: middle;
+ }
+}
diff --git a/client/coral-ui/components/Item.js b/client/coral-ui/components/Item.js
new file mode 100644
index 000000000..ecb81ab88
--- /dev/null
+++ b/client/coral-ui/components/Item.js
@@ -0,0 +1,13 @@
+import React from 'react';
+import styles from './Item.css';
+import Icon from './Icon';
+
+export default ({children, itemId, active, onItemClick, className = '', icon}) => (
+ onItemClick(itemId)}
+ >
+ {icon && }
+ {children}
+
+);
diff --git a/client/coral-ui/components/List.css b/client/coral-ui/components/List.css
new file mode 100644
index 000000000..82dc6b010
--- /dev/null
+++ b/client/coral-ui/components/List.css
@@ -0,0 +1,4 @@
+.base {
+ padding: 0;
+ margin: 0;
+}
diff --git a/client/coral-ui/components/List.js b/client/coral-ui/components/List.js
new file mode 100644
index 000000000..500f2307d
--- /dev/null
+++ b/client/coral-ui/components/List.js
@@ -0,0 +1,32 @@
+import React, {Component} from 'react';
+import styles from './List.css';
+
+export default class List extends Component {
+ constructor(props) {
+ super(props);
+ this.handleClickItem = this.handleClickItem.bind(this);
+ }
+
+ handleClickItem(itemId) {
+ if (this.props.onChange) {
+ this.props.onChange(itemId);
+ }
+ }
+
+ render() {
+ const {children, activeItem, className = ''} = this.props;
+ return (
+
+ {React.Children.toArray(children)
+ .filter(child => !child.props.restricted)
+ .map((child, i) =>
+ React.cloneElement(child, {
+ i,
+ active: child.props.itemId === activeItem,
+ onItemClick: this.handleClickItem,
+ })
+ )}
+
+ );
+ }
+}
diff --git a/client/coral-ui/components/Pager.css b/client/coral-ui/components/Pager.css
index 82e96f727..42559d11d 100644
--- a/client/coral-ui/components/Pager.css
+++ b/client/coral-ui/components/Pager.css
@@ -1,10 +1,19 @@
-.li {
+.pager {
+ text-align: center;
+
+ li {
display: inline-block;
margin-right: 5px;
- padding: 0;
- min-width: 30px;
+ color: white;
+ height: 30px;
+ text-align: center;
+ vertical-align: middle;
+ line-height: 30px;
+ width: 30px;
+ }
}
.current {
- background: #e3edf3;
-}
\ No newline at end of file
+ background: #696969;
+ box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12);
+}
diff --git a/client/coral-ui/components/Pager.js b/client/coral-ui/components/Pager.js
index fca20b0db..ffd1b9d0a 100644
--- a/client/coral-ui/components/Pager.js
+++ b/client/coral-ui/components/Pager.js
@@ -2,19 +2,18 @@ import React, {PropTypes} from 'react';
import styles from './Pager.css';
const Rows = (curr, total, onClickHandler) => Array.from(Array(total)).map((e, i) =>
- onClickHandler(i + 1)}>
{i + 1}
);
const Pager = ({totalPages, page, onNewPageHandler}) => (
-
+
{
(totalPages > page && totalPages > 1) ?
onNewPageHandler(page - 1)}>
Prev
@@ -25,7 +24,6 @@ const Pager = ({totalPages, page, onNewPageHandler}) => (
{
(page < totalPages && totalPages > 1) ?
onNewPageHandler(page + 1)}>
Next
diff --git a/client/coral-ui/components/TabBar.js b/client/coral-ui/components/TabBar.js
index 2a673f78c..f22250194 100644
--- a/client/coral-ui/components/TabBar.js
+++ b/client/coral-ui/components/TabBar.js
@@ -1,7 +1,7 @@
import React from 'react';
import styles from './TabBar.css';
-export class TabBar extends React.Component {
+class TabBar extends React.Component {
constructor(props) {
super(props);
this.handleClickTab = this.handleClickTab.bind(this);
diff --git a/client/coral-ui/index.js b/client/coral-ui/index.js
index 71b0d1d7e..23d5d876a 100644
--- a/client/coral-ui/index.js
+++ b/client/coral-ui/index.js
@@ -9,3 +9,8 @@ export {default as Spinner} from './components/Spinner';
export {default as Tooltip} from './components/Tooltip';
export {default as PopupMenu} from './components/PopupMenu';
export {default as Checkbox} from './components/Checkbox';
+export {default as Icon} from './components/Icon';
+export {default as List} from './components/List';
+export {default as Item} from './components/Item';
+export {default as Card} from './components/Card';
+export {default as Pager} from './components/Pager';
diff --git a/docs/swagger.yaml b/docs/swagger.yaml
index eeb9d6383..6358f8f06 100644
--- a/docs/swagger.yaml
+++ b/docs/swagger.yaml
@@ -924,7 +924,7 @@ definitions:
type: array
items:
type: string
- description: Roles occupied by the user (e.g. 'admin', 'moderator', etc.)
+ description: Roles occupied by the user (e.g. 'ADMIN', 'MODERATOR', etc.)
status:
type: string
description: The current status of the user in the system.
diff --git a/errors.js b/errors.js
index b5b217b61..1b26e7170 100644
--- a/errors.js
+++ b/errors.js
@@ -54,6 +54,11 @@ const ErrEmailTaken = new APIError('Email address already in use', {
status: 400
});
+const ErrDisplayTaken = new APIError('Display name already in use', {
+ translation_key: 'DISPLAYNAME_IN_USE',
+ status: 400
+});
+
const ErrSpecialChars = new APIError('No special characters are allowed in a display name', {
translation_key: 'NO_SPECIAL_CHARACTERS',
status: 400
@@ -116,6 +121,7 @@ module.exports = {
ErrSpecialChars,
ErrMissingDisplay,
ErrContainsProfanity,
+ ErrDisplayTaken,
ErrAssetCommentingClosed,
ErrNotFound,
ErrInvalidAssetURL,
diff --git a/graph/context.js b/graph/context.js
new file mode 100644
index 000000000..ce8f5eb83
--- /dev/null
+++ b/graph/context.js
@@ -0,0 +1,23 @@
+const loaders = require('./loaders');
+const mutators = require('./mutators');
+
+/**
+ * Stores the request context.
+ */
+class Context {
+ constructor({user = null}) {
+
+ // Load the current logged in user to `user`, otherwise this'll be null.
+ if (user) {
+ this.user = user;
+ }
+
+ // Create the loaders.
+ this.loaders = loaders(this);
+
+ // Create the mutators.
+ this.mutators = mutators(this);
+ }
+}
+
+module.exports = Context;
diff --git a/graph/index.js b/graph/index.js
new file mode 100644
index 000000000..7fddce2eb
--- /dev/null
+++ b/graph/index.js
@@ -0,0 +1,14 @@
+const schema = require('./schema');
+const Context = require('./context');
+
+module.exports = {
+ createGraphOptions: (req) => ({
+
+ // Schema is created already, so just include it.
+ schema,
+
+ // Load in the new context here, this'll create the loaders + mutators for
+ // the lifespan of this request.
+ context: new Context(req)
+ })
+};
diff --git a/graph/loaders/actions.js b/graph/loaders/actions.js
new file mode 100644
index 000000000..cc7d43a12
--- /dev/null
+++ b/graph/loaders/actions.js
@@ -0,0 +1,28 @@
+const DataLoader = require('dataloader');
+
+const util = require('./util');
+
+const ActionsService = require('../../services/actions');
+
+/**
+ * Looks up actions based on the requested id's all bounded by the user.
+ * @param {Object} context the context of the request
+ * @param {Array} ids array of id's to get
+ * @return {Promise} resolves to the promises of the requested actions
+ */
+const genActionSummariessByItemID = ({user = {}}, item_ids) => {
+ return ActionsService
+ .getActionSummaries(item_ids, user.id)
+ .then(util.arrayJoinBy(item_ids, 'item_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) => ({
+ Actions: {
+ getByItemID: new DataLoader((ids) => genActionSummariessByItemID(context, ids)),
+ }
+});
diff --git a/graph/loaders/assets.js b/graph/loaders/assets.js
new file mode 100644
index 000000000..a07a18a3f
--- /dev/null
+++ b/graph/loaders/assets.js
@@ -0,0 +1,64 @@
+const DataLoader = require('dataloader');
+const url = require('url');
+
+const errors = require('../../errors');
+const scraper = require('../../services/scraper');
+const util = require('./util');
+
+const AssetModel = require('../../models/asset');
+const AssetsService = require('../../services/assets');
+
+/**
+ * Retrieves assets by an array of ids.
+ * @param {Object} context the context of the request
+ * @param {Array} ids array of ids to lookup
+ */
+const genAssetsByID = (context, ids) => AssetModel.find({
+ id: {
+ $in: ids
+ }
+}).then(util.singleJoinBy(ids, 'id'));
+
+/**
+ * This endpoint find or creates an asset at the given url when it is loaded.
+ * @param {Object} context the context of the request
+ * @param {String} asset_url the url passed in from the query
+ * @returns {Promise} resolves to the asset
+ */
+const findOrCreateAssetByURL = (context, asset_url) => {
+
+ // Verify that the asset_url is parsable.
+ let parsed_asset_url = url.parse(asset_url);
+ if (!parsed_asset_url.protocol) {
+ return Promise.reject(errors.ErrInvalidAssetURL);
+ }
+
+ return AssetsService.findOrCreateByUrl(asset_url)
+ .then((asset) => {
+
+ // If the asset wasn't scraped before, scrape it! Otherwise just return
+ // the asset.
+ if (!asset.scraped) {
+ return scraper.create(asset).then(() => asset);
+ }
+
+ return asset;
+ });
+};
+
+/**
+ * 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) => ({
+ Assets: {
+
+ // TODO: decide whether we want to move these to mutators or not, as in fact
+ // this operation create a new asset if one isn't found.
+ getByURL: (url) => findOrCreateAssetByURL(context, url),
+
+ getByID: new DataLoader((ids) => genAssetsByID(context, ids)),
+ getAll: new util.SingletonResolver(() => AssetModel.find({}))
+ }
+});
diff --git a/graph/loaders/comments.js b/graph/loaders/comments.js
new file mode 100644
index 000000000..35463dfd0
--- /dev/null
+++ b/graph/loaders/comments.js
@@ -0,0 +1,84 @@
+const DataLoader = require('dataloader');
+
+const util = require('./util');
+
+const ActionModel = require('../../models/action');
+const CommentModel = require('../../models/comment');
+const CommentsService = require('../../services/comments');
+
+/**
+ * Retrieves comments by an array of asset id's.
+ * @param {Array} ids array of ids to lookup
+ */
+const genCommentsByAssetID = (context, ids) => CommentModel.find({
+ asset_id: {
+ $in: ids
+ },
+ parent_id: null,
+ status: {
+ $in: [null, 'ACCEPTED']
+ }
+}).then(util.arrayJoinBy(ids, 'asset_id'));
+
+/**
+ * Retrieves comments by an array of parent ids.
+ * @param {Array} ids array of ids to lookup
+ */
+const genCommentsByParentID = (context, ids) => CommentModel.find({
+ parent_id: {
+ $in: ids
+ },
+ status: {
+ $in: [null, 'ACCEPTED']
+ }
+}).then(util.arrayJoinBy(ids, 'parent_id'));
+
+const getCommentsByStatusAndAssetID = (context, {status = null, asset_id = null}) => {
+
+ // TODO: remove when we move the enum over to the uppercase.
+ if (status) {
+ status = status.toLowerCase();
+ }
+
+ return CommentsService.moderationQueue(status, asset_id);
+};
+
+const getCommentsByActionTypeAndAssetID = (context, {action_type, asset_id = null}) => {
+ return ActionModel.find({
+ action_type,
+ item_type: 'COMMENTS'
+ }).then((actions) => {
+ let comments = CommentModel.find({
+ id: {
+ $in: actions.map((action) => action.item_id)
+ }
+ });
+
+ if (asset_id) {
+ comments = comments.where({asset_id});
+ }
+
+ return comments;
+ });
+};
+
+const genCommentsByAuthorID = (context, authorIDs) => CommentModel.find({
+ author_id: {
+ $in: authorIDs
+ }
+}).then(util.arrayJoinBy(authorIDs, 'author_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) => ({
+ Comments: {
+ getByParentID: new DataLoader((ids) => genCommentsByParentID(context, ids)),
+ getByAssetID: new DataLoader((ids) => genCommentsByAssetID(context, ids)),
+ getByStatusAndAssetID: (query) => getCommentsByStatusAndAssetID(context, query),
+ getByActionTypeAndAssetID: (query) => getCommentsByActionTypeAndAssetID(context, query),
+ getByAuthorID: new DataLoader((authorIDs) => genCommentsByAuthorID(context, authorIDs))
+ }
+});
diff --git a/graph/loaders/index.js b/graph/loaders/index.js
new file mode 100644
index 000000000..536e40fa9
--- /dev/null
+++ b/graph/loaders/index.js
@@ -0,0 +1,28 @@
+const _ = require('lodash');
+
+const Actions = require('./actions');
+const Assets = require('./assets');
+const Comments = require('./comments');
+const Settings = require('./settings');
+const Users = require('./users');
+
+/**
+ * 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) => {
+
+ // We need to return an object to be accessed.
+ return _.merge(...[
+ Actions,
+ Assets,
+ Comments,
+ Settings,
+ Users
+ ].map((loaders) => {
+
+ // Each loader is a function which takes the context.
+ return loaders(context);
+ }));
+};
diff --git a/graph/loaders/settings.js b/graph/loaders/settings.js
new file mode 100644
index 000000000..77ece165f
--- /dev/null
+++ b/graph/loaders/settings.js
@@ -0,0 +1,12 @@
+const SettingsService = require('../../services/settings');
+
+const util = require('./util');
+
+/**
+ * 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 = () => ({
+ Settings: new util.SingletonResolver(() => SettingsService.retrieve())
+});
diff --git a/graph/loaders/users.js b/graph/loaders/users.js
new file mode 100644
index 000000000..90c661f71
--- /dev/null
+++ b/graph/loaders/users.js
@@ -0,0 +1,20 @@
+const DataLoader = require('dataloader');
+
+const util = require('./util');
+
+const UsersService = require('../../services/users');
+
+const genUserByIDs = (context, ids) => UsersService
+ .findByIdArray(ids)
+ .then(util.singleJoinBy(ids, '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) => ({
+ Users: {
+ getByID: new DataLoader((ids) => genUserByIDs(context, ids))
+ }
+});
diff --git a/graph/loaders/util.js b/graph/loaders/util.js
new file mode 100644
index 000000000..e947f2fa6
--- /dev/null
+++ b/graph/loaders/util.js
@@ -0,0 +1,68 @@
+const _ = require('lodash');
+
+/**
+ * SingletonResolver is a cached loader for a single result.
+ */
+class SingletonResolver {
+ constructor(resolver) {
+ this._cache = null;
+ this._resolver = resolver;
+ }
+
+ load() {
+ if (this._cache) {
+ return this._cache;
+ }
+
+ let promise = this._resolver(arguments).then((result) => {
+ return result;
+ });
+
+ // Set the promise on the cache.
+ this._cache = promise;
+
+ return promise;
+ }
+}
+
+/**
+ * This joins a set of results with a specific keys and sets an empty array in
+ * place if it was not found.
+ * @param {Array} ids ids to locate
+ * @param {String} key key to group by
+ * @return {Array} array of results
+ */
+const arrayJoinBy = (ids, key) => (items) => {
+ const itemsByKey = _.groupBy(items, key);
+ return ids.map((id) => {
+ if (id in itemsByKey) {
+ return itemsByKey[id];
+ }
+
+ return [];
+ });
+};
+
+/**
+ * This joins a set of results with a specific keys and sets null in place if it
+ * was not found.
+ * @param {Array} ids ids to locate
+ * @param {String} key key to group by
+ * @return {Array} array of results
+ */
+const singleJoinBy = (ids, key) => (items) => {
+ const itemsByKey = _.groupBy(items, key);
+ return ids.map((id) => {
+ if (id in itemsByKey) {
+ return itemsByKey[id][0];
+ }
+
+ return null;
+ });
+};
+
+module.exports = {
+ singleJoinBy,
+ arrayJoinBy,
+ SingletonResolver
+};
diff --git a/graph/mutators/action.js b/graph/mutators/action.js
new file mode 100644
index 000000000..cf80c496d
--- /dev/null
+++ b/graph/mutators/action.js
@@ -0,0 +1,55 @@
+const ActionModel = require('../../models/action');
+const ActionsService = require('../../services/actions');
+
+/**
+ * Creates an action on a item.
+ * @param {Object} user the user performing the request
+ * @param {String} item_id id of the item to add the action to
+ * @param {String} item_type type of the item
+ * @param {String} action_type type of the action
+ * @return {Promise} resolves to the action created
+ */
+const createAction = ({user = {}}, {item_id, item_type, action_type, metadata = {}}) => {
+ return ActionsService.insertUserAction({
+ item_id,
+ item_type,
+ user_id: user.id,
+ action_type,
+ metadata
+ });
+};
+
+/**
+ * Deletes an action based on the user id if the user owns that action.
+ * @param {Object} user the user performing the request
+ * @param {String} id the id of the action to delete
+ * @return {Promise} resolves when the action is deleted
+ */
+const deleteAction = ({user}, {id}) => {
+ return ActionModel.remove({
+ id,
+ user_id: user.id
+ });
+};
+
+module.exports = (context) => {
+
+ // TODO: refactor to something that'll return an error in the event an attempt
+ // is made to mutate state while not logged in. There's got to be a better way
+ // to do this.
+ if (context.user) {
+ return {
+ Action: {
+ create: (action) => createAction(context, action),
+ delete: (action) => deleteAction(context, action)
+ }
+ };
+ }
+
+ return {
+ Action: {
+ create: () => {},
+ delete: () => {}
+ }
+ };
+};
diff --git a/graph/mutators/comment.js b/graph/mutators/comment.js
new file mode 100644
index 000000000..6bd7cea26
--- /dev/null
+++ b/graph/mutators/comment.js
@@ -0,0 +1,160 @@
+const errors = require('../../errors');
+
+const AssetsService = require('../../services/assets');
+const CommentsService = require('../../services/comments');
+
+const Wordlist = require('../../services/wordlist');
+
+/**
+ * Creates a new comment.
+ * @param {Object} user the user performing the request
+ * @param {String} body body of the comment
+ * @param {String} asset_id asset for the comment
+ * @param {String} parent_id optional parent of the comment
+ * @param {String} [status=null] the status of the new comment
+ * @return {Promise} resolves to the created comment
+ */
+const createComment = ({user}, {body, asset_id, parent_id = null}, status = null) => {
+ return CommentsService.publicCreate({
+ body,
+ asset_id,
+ parent_id,
+ status,
+ author_id: user.id
+ });
+};
+
+/**
+ * Filters the comment object and outputs wordlist results.
+ * @param {Object} context graphql context
+ * @param {String} body body of a comment
+ * @return {Object} resolves to the wordlist results
+ */
+const filterNewComment = (context, {body}) => {
+
+ // Create a new instance of the Wordlist.
+ const wl = new Wordlist();
+
+ // Load the wordlist and filter the comment content.
+ return wl.load().then(() => wl.scan('body', body));
+};
+
+/**
+ * This resolves a given comment's status to take into account moderator actions
+ * are applied.
+ * @param {Object} context graphql context
+ * @param {String} body body of the comment
+ * @param {String} asset_id asset for the comment
+ * @param {Object} [wordlist={}] the results of the wordlist scan
+ * @return {Promise} resolves to the comment's status
+ */
+const resolveNewCommentStatus = (context, {asset_id, body}, wordlist = {}) => {
+
+ // Decide the status based on whether or not the current asset/settings
+ // has pre-mod enabled or not. If the comment was rejected based on the
+ // wordlist, then reject it, otherwise if the moderation setting is
+ // premod, set it to `premod`.
+ let status;
+
+ if (wordlist.banned) {
+ status = Promise.resolve('REJECTED');
+ } else {
+ status = AssetsService
+ .rectifySettings(AssetsService.findById(asset_id).then((asset) => {
+ if (!asset) {
+ return Promise.reject(errors.ErrNotFound);
+ }
+
+ // Check to see if the asset has closed commenting...
+ if (asset.isClosed) {
+
+ // They have, ensure that we send back an error.
+ return Promise.reject(new errors.ErrAssetCommentingClosed(asset.closedMessage));
+ }
+
+ return asset;
+ }))
+
+ // Return `premod` if pre-moderation is enabled and an empty "new" status
+ // in the event that it is not in pre-moderation mode.
+ .then(({moderation, charCountEnable, charCount}) => {
+
+ // Reject if the comment is too long
+ if (charCountEnable && body.length > charCount) {
+ return 'REJECTED';
+ }
+ return moderation === 'PRE' ? 'PREMOD' : null;
+ });
+ }
+
+ return status;
+};
+
+/**
+ * createPublicComment is designed to create a comment from a public source. It
+ * validates the comment, and performs some automated moderator actions based on
+ * the settings.
+ * @param {Object} context the graphql context
+ * @param {Object} commentInput the new comment to be created
+ * @return {Promise} resolves to a new comment
+ */
+const createPublicComment = (context, commentInput) => {
+
+ // First we filter the comment contents to ensure that we note any validation
+ // issues.
+ return filterNewComment(context, commentInput)
+
+ // We then take the wordlist and the comment into consideration when
+ // considering what status to assign the new comment, and resolve the new
+ // status to set the comment to.
+ .then((wordlist) => resolveNewCommentStatus(context, commentInput, wordlist)
+
+ // Then we actually create the comment with the new status.
+ .then((status) => createComment(context, commentInput, status))
+ .then((comment) => {
+
+ // If the comment was flagged as being suspect, we need to add a
+ // flag to it to indicate that it needs to be looked at.
+ // Otherwise just return the new comment.
+
+ // TODO: Check why the wordlist is undefined
+ if (wordlist != null) {
+
+ // 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
+ // defined in a checkable schema.
+ return context.mutators.Action.createAction(null, {
+ item_id: comment.id,
+ item_type: 'COMMENTS',
+ action_type: 'FLAG',
+ metadata: {
+ field: 'body',
+ details: 'Matched suspect word filters.'
+ }
+ }).then(() => comment);
+ }
+
+ // Finally, we return the comment.
+ return comment;
+ }));
+};
+
+module.exports = (context) => {
+
+ // TODO: refactor to something that'll return an error in the event an attempt
+ // is made to mutate state while not logged in. There's got to be a better way
+ // to do this.
+ if (context.user) {
+ return {
+ Comment: {
+ create: (comment) => createPublicComment(context, comment)
+ }
+ };
+ }
+
+ return {
+ Comment: {
+ create: () => {}
+ }
+ };
+};
diff --git a/graph/mutators/index.js b/graph/mutators/index.js
new file mode 100644
index 000000000..b799cf83d
--- /dev/null
+++ b/graph/mutators/index.js
@@ -0,0 +1,19 @@
+const _ = require('lodash');
+
+const Comment = require('./comment');
+const Action = require('./action');
+const User = require('./user');
+
+module.exports = (context) => {
+
+ // We need to return an object to be accessed.
+ return _.merge(...[
+ Comment,
+ Action,
+ User,
+ ].map((mutators) => {
+
+ // Each set of mutators is a function which takes the context.
+ return mutators(context);
+ }));
+};
diff --git a/graph/mutators/user.js b/graph/mutators/user.js
new file mode 100644
index 000000000..22837caae
--- /dev/null
+++ b/graph/mutators/user.js
@@ -0,0 +1,31 @@
+const UsersService = require('../../services/users');
+
+/**
+ * Updates a users settings.
+ * @param {Object} user the user performing the request
+ * @param {String} bio the new user bio
+ * @return {Promise}
+ */
+const updateUserSettings = ({user}, {bio}) => {
+ return UsersService.updateSettings(user.id, {bio});
+};
+
+module.exports = (context) => {
+
+ // TODO: refactor to something that'll return an error in the event an attempt
+ // is made to mutate state while not logged in. There's got to be a better way
+ // to do this.
+ if (context.user) {
+ return {
+ User: {
+ updateSettings: (settings) => updateUserSettings(context, settings)
+ }
+ };
+ }
+
+ return {
+ User: {
+ updateSettings: () => {}
+ }
+ };
+};
diff --git a/graph/resolvers/action.js b/graph/resolvers/action.js
new file mode 100644
index 000000000..e6886653e
--- /dev/null
+++ b/graph/resolvers/action.js
@@ -0,0 +1,12 @@
+const Action = {
+
+ // This will load the user for the specific action. We'll limit this to the
+ // admin users only.
+ user({user_id}, _, {loaders, user}) {
+ if (user.hasRole('ADMIN')) {
+ return loaders.Users.getByID.load(user_id);
+ }
+ }
+};
+
+module.exports = Action;
diff --git a/graph/resolvers/action_summary.js b/graph/resolvers/action_summary.js
new file mode 100644
index 000000000..48eff50c7
--- /dev/null
+++ b/graph/resolvers/action_summary.js
@@ -0,0 +1,3 @@
+const ActionSummary = {};
+
+module.exports = ActionSummary;
diff --git a/graph/resolvers/asset.js b/graph/resolvers/asset.js
new file mode 100644
index 000000000..dcf6e1a75
--- /dev/null
+++ b/graph/resolvers/asset.js
@@ -0,0 +1,18 @@
+const Asset = {
+ comments({id}, _, {loaders}) {
+ return loaders.Comments.getByAssetID.load(id);
+ },
+ settings({settings = null}, _, {loaders}) {
+ return loaders.Settings.load()
+ .then((globalSettings) => {
+ if (settings) {
+ settings = Object.assign({}, globalSettings.toObject(), settings);
+ } else {
+ settings = globalSettings.toObject();
+ }
+ return settings;
+ });
+ }
+};
+
+module.exports = Asset;
diff --git a/graph/resolvers/comment.js b/graph/resolvers/comment.js
new file mode 100644
index 000000000..ce2283cda
--- /dev/null
+++ b/graph/resolvers/comment.js
@@ -0,0 +1,16 @@
+const Comment = {
+ user({author_id}, _, {loaders}) {
+ return loaders.Users.getByID.load(author_id);
+ },
+ replies({id}, _, {loaders}) {
+ return loaders.Comments.getByParentID.load(id);
+ },
+ actions({id}, _, {loaders}) {
+ return loaders.Actions.getByItemID.load(id);
+ },
+ asset({asset_id}, _, {loaders}) {
+ return loaders.Assets.getByID.load(asset_id);
+ }
+};
+
+module.exports = Comment;
diff --git a/graph/resolvers/index.js b/graph/resolvers/index.js
new file mode 100644
index 000000000..064b1e664
--- /dev/null
+++ b/graph/resolvers/index.js
@@ -0,0 +1,19 @@
+const Action = require('./action');
+const ActionSummary = require('./action_summary');
+const Asset = require('./asset');
+const Comment = require('./comment');
+const RootMutation = require('./root_mutation');
+const RootQuery = require('./root_query');
+const Settings = require('./settings');
+const User = require('./user');
+
+module.exports = {
+ Action,
+ ActionSummary,
+ Asset,
+ Comment,
+ RootMutation,
+ RootQuery,
+ Settings,
+ User
+};
diff --git a/graph/resolvers/root_mutation.js b/graph/resolvers/root_mutation.js
new file mode 100644
index 000000000..bf108671e
--- /dev/null
+++ b/graph/resolvers/root_mutation.js
@@ -0,0 +1,16 @@
+const RootMutation = {
+ createComment(_, {asset_id, parent_id, body}, {mutators}) {
+ return mutators.Comment.create({asset_id, parent_id, body});
+ },
+ createAction(_, {action}, {mutators}) {
+ return mutators.Action.create(action);
+ },
+ deleteAction(_, {id}, {mutators}) {
+ return mutators.Action.delete({id});
+ },
+ updateUserSettings(_, {settings}, {mutators}) {
+ return mutators.User.updateSettings(settings);
+ }
+};
+
+module.exports = RootMutation;
diff --git a/graph/resolvers/root_query.js b/graph/resolvers/root_query.js
new file mode 100644
index 000000000..5676b4f48
--- /dev/null
+++ b/graph/resolvers/root_query.js
@@ -0,0 +1,50 @@
+const RootQuery = {
+ assets(_, args, {loaders, user}) {
+ if (user == null || !user.hasRoles('ADMIN')) {
+ return null;
+ }
+
+ return loaders.Assets.getAll.load();
+ },
+ asset(_, query, {loaders}) {
+ if (query.id) {
+
+ // TODO: we may not always have a comment stream here, therefore, when we
+ // load it, we may also need to create with the url. This may also have to
+ // move the logic over to the mutators function as an upsert operation
+ // possibly.
+ return loaders.Assets.getByID.load(query.id);
+ }
+
+ return loaders.Assets.getByURL(query.url);
+ },
+ settings(_, args, {loaders}) {
+ return loaders.Settings.load();
+ },
+
+ // This endpoint is used for loading moderation queues, so hide it in the
+ // event that we aren't an admin.
+ comments(_, {query}, {loaders, user}) {
+ if (user == null || !user.hasRoles('ADMIN')) {
+ return null;
+ }
+
+ if (query.action_type) {
+ return loaders.Comments.getByActionTypeAndAssetID(query);
+ } else {
+ return loaders.Comments.getByStatusAndAssetID(query);
+ }
+ },
+
+ // This returns the current user, ensure that if we aren't logged in, we
+ // return null.
+ me(_, args, {user}) {
+ if (user == null) {
+ return null;
+ }
+
+ return user;
+ }
+};
+
+module.exports = RootQuery;
diff --git a/graph/resolvers/settings.js b/graph/resolvers/settings.js
new file mode 100644
index 000000000..72608a9c4
--- /dev/null
+++ b/graph/resolvers/settings.js
@@ -0,0 +1,3 @@
+const Settings = {};
+
+module.exports = Settings;
diff --git a/graph/resolvers/user.js b/graph/resolvers/user.js
new file mode 100644
index 000000000..65882feee
--- /dev/null
+++ b/graph/resolvers/user.js
@@ -0,0 +1,17 @@
+const User = {
+ actions({id}, _, {loaders}) {
+ return loaders.Actions.getByID.load(id);
+ },
+ comments({id}, _, {loaders, user}) {
+
+ // If the user is not an admin, only return comment list for the owner of
+ // the comments.
+ if (!user.hasRoles('ADMIN') || user.id !== id) {
+ return null;
+ }
+
+ return loaders.Comments.getByAuthorID.load(id);
+ }
+};
+
+module.exports = User;
diff --git a/graph/schema.js b/graph/schema.js
new file mode 100644
index 000000000..b4b42b809
--- /dev/null
+++ b/graph/schema.js
@@ -0,0 +1,8 @@
+const tools = require('graphql-tools');
+
+const resolvers = require('./resolvers');
+const typeDefs = require('./typeDefs');
+
+const schema = tools.makeExecutableSchema({typeDefs, resolvers});
+
+module.exports = schema;
diff --git a/graph/typeDefs.js b/graph/typeDefs.js
new file mode 100644
index 000000000..0665a6132
--- /dev/null
+++ b/graph/typeDefs.js
@@ -0,0 +1,185 @@
+// TODO: Adjust `RootQuery.asset(id: ID, url: String)` to instead be
+// `RootQuery.asset(id: ID, url: String!)` because we'll always need the url, if
+// this change is done now everything will likely break on the front end.
+
+const typeDefs = [`
+interface ActionableItem {
+ id: ID!
+}
+
+type UserSettings {
+ # bio of the user.
+ bio: String
+}
+
+input CommentsInput {
+ # current status of a comment.
+ status: COMMENT_STATUS
+
+ # asset that a comment is on.
+ asset_id: ID
+
+ # action type to find comments that have an action with.
+ action_type: ACTION_TYPE
+}
+
+# Any person who can author comments, create actions, and view comments on a
+# stream.
+type User {
+ id: ID!
+
+ # display name of a user.
+ displayName: String!
+
+ # actions against a specific user.
+ actions: [ActionSummary]
+
+ # settings for a user.
+ settings: UserSettings
+
+ # returns all comments based on a query.
+ comments(query: CommentsInput): [Comment]
+}
+
+type Comment {
+ id: ID!
+
+ # the actual comment data.
+ body: String!
+
+ # the user who authored the comment.
+ user: User
+
+ # the replies that were made to the comment.
+ replies(limit: Int = 3): [Comment]
+
+ # the actions made against a comment.
+ actions: [ActionSummary]
+
+ # the asset that a comment was made on.
+ asset: Asset
+
+ # the current status of a comment.
+ status: COMMENT_STATUS
+
+ # the time when the comment was created
+ created_at: String!
+}
+
+enum ITEM_TYPE {
+ ASSETS
+ COMMENTS
+ USERS
+}
+
+enum ACTION_TYPE {
+ LIKE
+ FLAG
+}
+
+type Action {
+ id: ID!
+ action_type: ACTION_TYPE!
+
+ item_id: ID!
+ item_type: ITEM_TYPE!
+ item: ActionableItem
+
+ user: User!
+ updated_at: String
+ created_at: String
+}
+
+type ActionSummary {
+ action_type: ACTION_TYPE!
+ item_type: ITEM_TYPE!
+ count: Int
+ current_user: Action
+}
+
+enum MODERATION_MODE {
+ PRE
+ POST
+}
+
+type Settings {
+ moderation: MODERATION_MODE!
+ infoBoxEnable: Boolean
+ infoBoxContent: String
+ closeTimeout: Int
+ closedMessage: String
+ charCountEnable: Boolean
+ charCount: Int
+ requireEmailConfirmation: Boolean
+}
+
+type Asset {
+ id: ID!
+ title: String
+ url: String
+ comments: [Comment]
+ settings: Settings!
+ closedAt: String
+ created_at: String
+}
+
+enum COMMENT_STATUS {
+ ACCEPTED
+ REJECTED
+ PREMOD
+}
+
+type RootQuery {
+ # retrieves site wide settings and defaults.
+ settings: Settings
+
+ # retrieves all assets.
+ assets: [Asset]
+
+ # retrieves a specific asset.
+ asset(id: ID, url: String): Asset
+
+ # retrieves comments based on the input query.
+ comments(query: CommentsInput): [Comment]
+
+ # retrieves the current logged in user.
+ me: User
+}
+
+input CreateActionInput {
+ # the type of action.
+ action_type: ACTION_TYPE!
+
+ # the type of the item.
+ item_type: ITEM_TYPE!
+
+ # the id of the item that is related to the action.
+ item_id: ID!
+}
+
+input UpdateUserSettingsInput {
+ # user bio
+ bio: String!
+}
+
+type RootMutation {
+ # creates a comment on the asset.
+ createComment(asset_id: ID!, parent_id: ID, body: String!): Comment
+
+ # creates an action based on an input.
+ createAction(action: CreateActionInput!): Action
+
+ # delete an action based on the action id.
+ deleteAction(id: ID!): Boolean
+
+ # updates a user's settings, it will return if the query was successful.
+ updateUserSettings(settings: UpdateUserSettingsInput!): Boolean
+}
+
+schema {
+ query: RootQuery
+ mutation: RootMutation
+}
+`];
+
+module.exports = typeDefs;
diff --git a/init.js b/init.js
index c29684b23..eb17963a6 100644
--- a/init.js
+++ b/init.js
@@ -1,7 +1,13 @@
-const Setting = require('./models/setting');
+const SettingsService = require('./services/settings');
module.exports = () => Promise.all([
// Upsert the settings object.
- Setting.init({id: '1', moderation: 'pre', wordlist: {banned: [], suspect: []}})
+ SettingsService.init({
+ moderation: 'PRE',
+ wordlist: {
+ banned: [],
+ suspect: []
+ }
+ })
]);
diff --git a/middleware/authorization.js b/middleware/authorization.js
index efe4b1034..d93913109 100644
--- a/middleware/authorization.js
+++ b/middleware/authorization.js
@@ -17,7 +17,11 @@ const ErrNotAuthorized = require('../errors').ErrNotAuthorized;
* @return {Boolean} true if the user has all the roles required, false
* otherwise
*/
-authorization.has = (user, ...roles) => roles.every((role) => user.roles.indexOf(role) >= 0);
+authorization.has = (user, ...roles) => roles.every((role) => {
+
+ // TODO: remove toUpperCase once we've migrated over the roles.
+ return user.roles.indexOf(role.toUpperCase()) >= 0;
+});
/**
* needed is a connect middleware layer that ensures that all requests coming
diff --git a/middleware/payload-filter.js b/middleware/payload-filter.js
deleted file mode 100644
index b4cf90021..000000000
--- a/middleware/payload-filter.js
+++ /dev/null
@@ -1,74 +0,0 @@
-// The maximum depth to recurse into nested objects checking for mongoose
-// objects.
-const maxRecursion = 3;
-
-/**
- * Middleware to wrap the `res.json` function to ensure that we can filter the
- * payload response first based on user and role.
- */
-module.exports = (req, res, next) => {
-
- /**
- * Updates the original document based on filtering out for roles.
- * @param {Mixed} o original object to be modified
- * @param {Integer} l current level of depth in the first object
- * @return {Mixed} (possibly) modified object
- */
- const wrap = (o, d = 0) => {
- if (d > maxRecursion) {
- return o;
- }
-
- // If this is an array, we need to walk over all the object's elements.
- if (Array.isArray(o)) {
-
- // Map each of the array elements.
- return o.map((ob) => wrap(ob, d + 1));
-
- } else if (o && o.constructor && o.constructor.name === 'model') {
-
- // The object here is definitly a mongoose model.
-
- // Check to see if it has a `filterForUser` method.
- if (typeof o.filterForUser === 'function') {
-
- // The object here actually has the `filterForUser` function, so filter
- // the object!
- o = o.filterForUser(req.user);
- }
-
- } else if (typeof o === 'object') {
-
- // Iterate over the props, find only properties owned by the object.
- for (let prop in o) {
-
- // If and only if the object owns the property do we actually pull the
- // property out.
- if (typeof o.hasOwnProperty === 'function' && o.hasOwnProperty(prop)) {
-
- // Wrap the property with one more layer down.
- o[prop] = wrap(o[prop], d + 1);
- }
- }
-
- }
-
- return o;
- };
-
- // Save a reference to the original json function.
- const json = res.json;
-
- // Override the original json function.
- res.json = (payload) => {
-
- // Restore the old pointer.
- res.json = json;
-
- // Send it down the pipe after we've filtered it.
- res.json(wrap(payload));
- };
-
- // Now that we've overridden the `res.json`, let's hand it down.
- next();
-};
diff --git a/models/SETTINGS_ROADMAP.md b/models/SETTINGS_ROADMAP.md
deleted file mode 100644
index b49a0f8a9..000000000
--- a/models/SETTINGS_ROADMAP.md
+++ /dev/null
@@ -1,30 +0,0 @@
-These are some settings we're planning on implementing in the future.
-I'm keeping them in this file for reference
-
-```javascript
-anonymous_users: {type: Boolean, default: false},
-block_mute_enabled: {type: Boolean, default: false},
-comment_count: {type: Boolean, default: false},
-comment_editing_enabled: {type: Boolean, default: false},
-comments_hidden: {type: Boolean, default: false},
-community_guidelines: {type: Boolean, default: false},
-detailed_flags: {type: Boolean, default: false},
-emojis_enabled: {type: Boolean, default: false},
-following: {type: Boolean, default: false},
-likes_enabled: {type: Boolean, default: false},
-mentions: {type: Boolean, default: false},
-nested_replies: {type: Boolean, default: false},
-notification_timeout: {type: Number, default: 4500},
-permalinks: {type: Boolean, default: false},
-post_button_text: {type: String, default: 'Post'},
-pseudonyms: {type: Boolean, default: false},
-public_profile: {type: Boolean, default: false},
-reactions_enabled: {type: Boolean, default: false},
-reply_button_text: {type: String, default: 'Reply'},
-rich_content: {type: Boolean, default: false},
-show_staff_picks: {type: Boolean, default: false},
-up_down_voting: {type: Boolean, default: false},
-user_badges: {type: Boolean, default: false},
-user_mods_enabled: {type: Boolean, default: false},
-user_stats_enabled: {type: Boolean, default: false}
-```
diff --git a/models/action.js b/models/action.js
index 97be21b26..ed871f56b 100644
--- a/models/action.js
+++ b/models/action.js
@@ -1,16 +1,32 @@
const mongoose = require('../services/mongoose');
const uuid = require('uuid');
-const _ = require('lodash');
const Schema = mongoose.Schema;
+const ACTION_TYPES = [
+ 'LIKE',
+ 'FLAG'
+];
+
+const ITEM_TYPES = [
+ 'ASSETS',
+ 'COMMENTS',
+ 'USERS'
+];
+
const ActionSchema = new Schema({
id: {
type: String,
default: uuid.v4,
unique: true
},
- action_type: String,
- item_type: String,
+ action_type: {
+ type: String,
+ enum: ACTION_TYPES
+ },
+ item_type: {
+ type: String,
+ enum: ITEM_TYPES
+ },
item_id: String,
user_id: String,
metadata: Schema.Types.Mixed
@@ -21,187 +37,6 @@ const ActionSchema = new Schema({
}
});
-/**
- * Finds an action by the id.
- * @param {String} id identifier of the action (uuid)
-*/
-ActionSchema.statics.findById = function(id) {
- return Action.findOne({id});
-};
-
-/**
- * Add an action.
- * @param {String} item_id identifier of the comment (uuid)
- * @param {String} user_id user id of the action (uuid)
- * @param {String} action the new action to the comment
- * @return {Promise}
- */
-ActionSchema.statics.insertUserAction = (action) => {
-
- // Actions are made unique by using a query that can be reproducable, i.e.,
- // not containing user inputable values.
- let query = {
- action_type: action.action_type,
- item_type: action.item_type,
- item_id: action.item_id,
- user_id: action.user_id
- };
-
- // Create/Update the action.
- return Action.findOneAndUpdate(query, action, {
-
- // Ensure that if it's new, we return the new object created.
- new: true,
-
- // Perform an upsert in the event that this doesn't exist.
- upsert: true,
-
- // Set the default values if not provided based on the mongoose models.
- setDefaultsOnInsert: true
- });
-};
-
-/**
- * Finds actions in an array of ids.
- * @param {String} ids array of user identifiers (uuid)
-*/
-ActionSchema.statics.findByItemIdArray = function(item_ids) {
- return Action.find({
- 'item_id': {$in: item_ids}
- });
-};
-
-/**
- * Fetches the action summaries for the given asset, and comments around the
- * given user id.
- * @param {[type]} asset_id [description]
- * @param {[type]} comments [description]
- * @param {String} [current_user_id=''] [description]
- * @return {[type]} [description]
- */
-ActionSchema.statics.getActionSummariesFromComments = (asset_id = '', comments, current_user_id = '') => {
-
- // Get the user id's from the author id's as a unique array that gets
- // sorted.
- let userIDs = _.uniq(comments.map((comment) => comment.author_id)).sort();
-
- // Fetch the actions for pretty much everything at this point.
- return Action.getActionSummaries(_.uniq([
-
- // Actions can be on assets...
- asset_id,
-
- // Comments...
- ...comments.map((comment) => comment.id),
-
- // Or Authors...
- ...userIDs
- ].filter((e) => e)), current_user_id);
-};
-
-/**
- * Returns summaries of actions for an array of ids
- * @param {String} ids array of user identifiers (uuid)
-*/
-ActionSchema.statics.getActionSummaries = function(item_ids, current_user_id = '') {
- return Action.aggregate([
- {
-
- // only grab items that match the specified item id's
- $match: {
- item_id: {
- $in: item_ids
- }
- }
- },
- {
- $group: {
-
- // group unique documents by these properties, we are leveraging the
- // fact that each uuid is completly unique.
- _id: {
- item_id: '$item_id',
- action_type: '$action_type'
- },
-
- // and sum up all actions matching the above grouping criteria
- count: {
- $sum: 1
- },
-
- // we are leveraging the fact that each uuid is completly unique and
- // just grabbing the last instance of the item type here.
- item_type: {
- $last: '$item_type'
- },
- metadata: {
- $push: '$metadata'
- },
- created_at: {
- $min: '$created_at'
- },
- updated_at: {
- $max : '$updated_at'
- },
- current_user: {
- $max: {
- $cond: {
- if: {
- $eq: ['$user_id', current_user_id],
- },
- then: '$$CURRENT',
- else: null
- }
- }
- }
- }
- },
- {
- $project: {
-
- // suppress the _id field
- _id: false,
-
- // map the fields from the _id grouping down a level
- item_id: '$_id.item_id',
- action_type: '$_id.action_type',
-
- // map the field directly
- count: '$count',
- item_type: '$item_type',
-
- // set the current user to false here
- current_user: '$current_user'
- }
- }
- ])
- .exec();
-};
-
-/*
- * Finds all actions for a specific type.
- * @param {String} action_type type of action
- * @param {String} item_type type of item the action is on
-*/
-ActionSchema.statics.findByType = function(action_type, item_type) {
- return Action.find({
- 'action_type': action_type,
- 'item_type': item_type
- });
-};
-
-/**
- * Finds all comments ids for a specific action.
- * @param {String} action_type type of action
- * @param {String} item_type type of item the action is on
-*/
-ActionSchema.statics.findCommentsIdByActionType = function(action_type, item_type) {
- return Action.find({
- 'action_type': action_type,
- 'item_type': item_type
- }, 'item_id');
-};
-
const Action = mongoose.model('Action', ActionSchema);
module.exports = Action;
diff --git a/models/asset.js b/models/asset.js
index ac87e006f..f0046aef0 100644
--- a/models/asset.js
+++ b/models/asset.js
@@ -1,8 +1,5 @@
const mongoose = require('../services/mongoose');
const Schema = mongoose.Schema;
-
-const Setting = require('./setting');
-
const uuid = require('uuid');
const AssetSchema = new Schema({
@@ -68,18 +65,6 @@ AssetSchema.index({
background: true
});
-/**
- * Finds an asset by its id.
- * @param {String} id identifier of the asset (uuid).
- */
-AssetSchema.statics.findById = (id) => Asset.findOne({id});
-
-/**
- * Finds a asset by its url.
- * @param {String} url identifier of the asset (uuid).
- */
-AssetSchema.statics.findByUrl = (url) => Asset.findOne({url});
-
/**
* Returns true if the asset is closed, false else.
*/
@@ -87,85 +72,6 @@ AssetSchema.virtual('isClosed').get(function() {
return this.closedAt && this.closedAt.getTime() <= new Date().getTime();
});
-/**
- * Retrieves the settings given an asset query and rectifies it against the
- * global settings.
- * @param {Promise} assetQuery an asset query that returns a single asset.
- * @return {Promise}
- */
-AssetSchema.statics.rectifySettings = (assetQuery) => Promise.all([
- Setting.retrieve(),
- assetQuery
-]).then(([settings, asset]) => {
-
- // If the asset exists and has settings then return the merged object.
- if (asset && asset.settings) {
- settings.merge(asset.settings);
- }
-
- return settings;
-});
-
-/**
- * Finds a asset by its url.
- *
- * NOTE: This function has scalability concerns regarding mongoose's decision
- * always write {updated_at: new Date()} on every call to findOneAndUpdate
- * even though the update document exactly matches the query document... In
- * the future this function should never update, only findOneAndCreate but this
- * is not possible with the mongoose driver.
- *
- * @param {String} url identifier of the asset (uuid).
- * @return {Promise}
- */
-AssetSchema.statics.findOrCreateByUrl = (url) => Asset.findOneAndUpdate({url}, {url}, {
-
- // Ensure that if it's new, we return the new object created.
- new: true,
-
- // Perform an upsert in the event that this doesn't exist.
- upsert: true,
-
- // Set the default values if not provided based on the mongoose models.
- setDefaultsOnInsert: true
-});
-
-/**
- * Updates the settings for the asset.
- * @param {[type]} id [description]
- * @param {[type]} settings [description]
- * @return {[type]} [description]
- */
-AssetSchema.statics.overrideSettings = (id, settings) => Asset.findOneAndUpdate({id}, {
- $set: {
- settings
- }
-}, {
- new: true
-});
-
-/**
- * Finds assets matching keywords on the model. If `value` is an empty string,
- * then it will not even perform a text search query.
- * @param {String} value string to search by.
- * @return {Promise}
- */
-AssetSchema.statics.search = (value) => value.length === 0 ? Asset.find({}) : Asset.find({
- $text: {
- $search: value
- }
-});
-
-/**
- * Finds multiple assets with matching ids
- * @param {Array} ids an array of Strings of asset_id
- * @return {Promise} resolves to list of Assets
- */
-AssetSchema.statics.findMultipleById = function (ids) {
- const query = ids.map(id => ({id}));
- return Asset.find(query);
-};
-
const Asset = mongoose.model('Asset', AssetSchema);
module.exports = Asset;
diff --git a/models/comment.js b/models/comment.js
index d99cbed06..b656f9238 100644
--- a/models/comment.js
+++ b/models/comment.js
@@ -1,8 +1,13 @@
const mongoose = require('../services/mongoose');
const Schema = mongoose.Schema;
-const _ = require('lodash');
const uuid = require('uuid');
-const Action = require('./action');
+
+const STATUSES = [
+ 'ACCEPTED',
+ 'REJECTED',
+ 'PREMOD',
+ null
+];
/**
* The Mongo schema for a Comment Status.
@@ -11,11 +16,7 @@ const Action = require('./action');
const StatusSchema = new Schema({
type: {
type: String,
- enum: [
- 'accepted',
- 'rejected',
- 'premod',
- ],
+ enum: STATUSES,
},
// The User ID of the user that assigned the status.
@@ -56,244 +57,6 @@ const CommentSchema = new Schema({
}
});
-/**
- * toJSON overrides to remove fields from the json
- * output.
- */
-CommentSchema.options.toJSON = {};
-CommentSchema.options.toJSON.virtuals = true;
-CommentSchema.options.toJSON.hide = '_id';
-CommentSchema.options.toJSON.transform = (doc, ret, options) => {
- if (options.hide) {
- options.hide.split(' ').forEach((prop) => {
- delete ret[prop];
- });
- }
-
- return ret;
-};
-
-/**
- * Filters the object for the given user only allowing those with the allowed
- * roles/permissions to access particular parameters.
- */
-CommentSchema.method('filterForUser', function(user = false) {
- if (!user || !user.roles.includes('admin')) {
- return _.pick(this.toJSON(), ['id', 'body', 'asset_id', 'author_id', 'parent_id', 'status']);
- }
-
- return this.toJSON();
-});
-
-/**
- * Creates a new Comment that came from a public source.
- * @param {Mixed} comment either a single comment or an array of comments.
- * @return {Promise}
- */
-CommentSchema.statics.publicCreate = (comment) => {
-
- // Check to see if this is an array of comments, if so map it out.
- if (Array.isArray(comment)) {
- return Promise.all(comment.map(Comment.publicCreate));
- }
-
- const {
- body,
- asset_id,
- parent_id,
- status = null,
- author_id
- } = comment;
-
- comment = new Comment({
- body,
- asset_id,
- parent_id,
- status_history: status ? [{
- type: status,
- created_at: new Date()
- }] : [],
- status,
- author_id
- });
-
- return comment.save();
-};
-
-/**
- * Finds a comment by the id.
- * @param {String} id identifier of comment (uuid)
- * @return {Promise}
- */
-CommentSchema.statics.findById = (id) => Comment.findOne({id});
-
-/**
- * Finds ALL the comments by the asset_id.
- * @param {String} asset_id identifier of the asset which owns this comment (uuid)
- * @return {Promise}
- */
-CommentSchema.statics.findByAssetId = (asset_id) => Comment.find({
- asset_id
-});
-
-/**
- * Finds the accepted comments by the asset_id get the comments that are
- * accepted.
- * @param {String} asset_id identifier of the asset which owns the comments (uuid)
- * @return {Promise}
- */
-CommentSchema.statics.findAcceptedByAssetId = (asset_id) => Comment.find({
- asset_id,
- status: 'accepted'
-});
-
-/**
- * Finds the new and accepted comments by the asset_id.
- * @param {String} asset_id identifier of the asset which owns the comments (uuid)
- * @return {Promise}
- */
-CommentSchema.statics.findAcceptedAndNewByAssetId = (asset_id) => Comment.find({
- asset_id,
- $or: [
- {status: 'accepted'},
- {status: null}
- ]
-});
-
-/**
- * Find comments by an action that was performed on them.
- * @param {String} action_type the type of action that was performed on the comment
- * @return {Promise}
- */
-CommentSchema.statics.findByActionType = (action_type) => Action
- .findCommentsIdByActionType(action_type, 'comment')
- .then((actions) => Comment.find({
- id: {
- $in: actions.map((a) => a.item_id)
- }
- }));
-
-/**
- * Find comment id's where the action type matches the argument.
- * @param {String} action_type the type of action that was performed on the comment
- * @return {Promise}
- */
-CommentSchema.statics.findIdsByActionType = (action_type) => Action
- .findCommentsIdByActionType(action_type, 'comments')
- .then((actions) => actions.map(a => a.item_id));
-
-/**
- * Find comments by current status
- * @param {String} status status of the comment to search for
- * @return {Promise} resovles to comment array
- */
-CommentSchema.statics.findByStatus = (status = null) => {
- return Comment.find({status});
-};
-
-/**
- * Find comments that need to be moderated (aka moderation queue).
- * @param {String} asset_id
- * @return {Promise}
- */
-CommentSchema.statics.moderationQueue = (status, asset_id = null) => {
-
- /**
- * 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;
- };
-
- // Pre-moderation: New comments are shown in the moderator queues immediately.
- let comments = assetIDWrap(Comment.findByStatus(status));
-
- return comments;
-};
-
-/**
- * Pushes a new status in for the user.
- * @param {String} id identifier of the comment (uuid)
- * @param {String} status the new status of the comment
- * @param {String} assigned_by the user id for the user who performed the
- * moderation action
- * @return {Promise}
- */
-CommentSchema.statics.pushStatus = (id, status, assigned_by = null) => Comment.update({id}, {
- $push: {
- status_history: {
- type: status,
- created_at: new Date(),
- assigned_by
- }
- },
- $set: {status}
-});
-
-/**
- * Add an action to the comment.
- * @param {String} item_id identifier of the comment (uuid)
- * @param {String} user_id user id of the action (uuid)
- * @param {String} action the new action to the comment
- * @return {Promise}
- */
-CommentSchema.statics.addAction = (item_id, user_id, action_type, metadata) => Action.insertUserAction({
- item_id,
- item_type: 'comments',
- user_id,
- action_type,
- metadata
-});
-
-/**
- * Change the status of a comment.
- * @param {String} id identifier of the comment (uuid)
- * @param {String} status the new status of the comment
- * @return {Promise}
- */
-CommentSchema.statics.removeById = (id) => Comment.remove({id});
-
-/**
- * Remove an action from the comment.
- * @param {String} id identifier of the comment (uuid)
- * @param {String} action_type the type of the action to be removed
- * @param {String} user_id the id of the user performing the action
- * @return {Promise}
- */
-CommentSchema.statics.removeAction = (item_id, user_id, action_type) => Action.remove({
- action_type,
- item_type: 'comment',
- item_id,
- user_id
-});
-
-/**
- * Returns all the comments in the collection.
- * @return {Promise}
- */
-CommentSchema.statics.all = () => Comment.find();
-
-/**
- * Returns all the comments by user
- * probably to be paginated at some point in the future
- * @return {Promise} array resolves to an array of comments by that user
- */
-CommentSchema.statics.findByUserId = function (author_id, admin = false) {
-
- // do not return un-published comments for non-admins
- let query = {author_id};
-
- if (!admin) {
- query.$nor = [{status: 'premod'}, {status: 'rejected'}];
- }
-
- return Comment.find(query);
-};
-
// Comment model.
const Comment = mongoose.model('Comment', CommentSchema);
diff --git a/models/setting.js b/models/setting.js
index ed41acfa6..3d478b2d9 100644
--- a/models/setting.js
+++ b/models/setting.js
@@ -1,6 +1,5 @@
const mongoose = require('../services/mongoose');
const Schema = mongoose.Schema;
-const _ = require('lodash');
const WordlistSchema = new Schema({
banned: [String],
@@ -21,10 +20,10 @@ const SettingSchema = new Schema({
moderation: {
type: String,
enum: [
- 'pre',
- 'post'
+ 'PRE',
+ 'POST'
],
- default: 'pre'
+ default: 'PRE'
},
infoBoxEnable: {
type: Boolean,
@@ -64,12 +63,6 @@ const SettingSchema = new Schema({
}
});
-/**
- * toJSON provides settings overrides to this object's serialization methods.
- */
-SettingSchema.options.toJSON = {};
-SettingSchema.options.toJSON.virtuals = true;
-
/**
* Merges two settings objects.
*/
@@ -88,72 +81,9 @@ SettingSchema.method('merge', function(src) {
});
});
-/**
- * Filters the object for the given user only allowing those with the allowed
- * roles/permissions to access particular parameters.
- */
-SettingSchema.method('filterForUser', function(user = false) {
- if (!user || !user.roles.includes('admin')) {
- return _.pick(this.toJSON(), [
- 'moderation',
- 'infoBoxEnable',
- 'infoBoxContent',
- 'closeTimeout',
- 'closedMessage',
- 'charCountEnable',
- 'charCount',
- 'requireEmailConfirmation'
- ]);
- }
-
- return this.toJSON();
-});
-
/**
* The Mongo Mongoose object.
*/
const Setting = mongoose.model('Setting', SettingSchema);
-/**
- * The Setting Service object exposing the Setting model.
- * @type {Object}
- */
-const SettingService = module.exports = {};
-
-/**
- * The selector used to uniquely identify the settings document.
- */
-const selector = {id: '1'};
-
-/**
- * Gets the entire settings record and sends it back
- * @return {Promise} settings the whole settings record
- */
-SettingService.retrieve = () => Setting.findOne(selector);
-
-/**
- * This will update the settings object with whatever you pass in
- * @param {object} setting a hash of whatever settings you want to update
- * @return {Promise} settings Promise that resolves to the entire (updated) settings object.
- */
-SettingService.update = (settings) => Setting.findOneAndUpdate(selector, {
- $set: settings
-}, {
- upsert: true,
- new: true,
- setDefaultsOnInsert: true
-});
-
-/**
- * This is run once when the app starts to ensure settings are populated.
- * @return {Promise} null initialize the global settings object
- */
-SettingService.init = (defaults) => {
-
- // Inject the defaults on top of the passed in defaults to ensure that the new
- // settings conform to the required selector.
- defaults = Object.assign({}, defaults, selector);
-
- // Actually update the settings collection.
- return SettingService.update(defaults);
-};
+module.exports = Setting;
diff --git a/models/user.js b/models/user.js
index 3a7ffd3ad..52111a4bd 100644
--- a/models/user.js
+++ b/models/user.js
@@ -1,42 +1,20 @@
const mongoose = require('../services/mongoose');
const uuid = require('uuid');
-const _ = require('lodash');
-const bcrypt = require('bcrypt');
-const jwt = require('jsonwebtoken');
-const Action = require('./action');
-const Comment = require('./comment');
-const Wordlist = require('../services/wordlist');
-const errors = require('../errors');
-
-const EMAIL_CONFIRM_JWT_SUBJECT = 'email_confirm';
-const PASSWORD_RESET_JWT_SUBJECT = 'password_reset';
-
-// SALT_ROUNDS is the number of rounds that the bcrypt algorithm will run
-// through during the salting process.
-const SALT_ROUNDS = 10;
// USER_ROLES is the array of roles that is permissible as a user role.
const USER_ROLES = [
- 'admin',
- 'moderator'
+ 'ADMIN',
+ 'MODERATOR'
];
-// USER_STATUSES is the list of statuses that are permitted for the user status.
+// USER_STATUS is the list of statuses that are permitted for the user status.
const USER_STATUS = [
- 'active',
- 'pending',
- 'suspended',
- 'banned'
+ 'ACTIVE',
+ 'BANNED',
+ 'PENDING',
+ 'SUSPENDED',
];
-// In the event that the TALK_SESSION_SECRET is missing but we are testing, then
-// set the process.env.TALK_SESSION_SECRET.
-if (process.env.NODE_ENV === 'test' && !process.env.TALK_SESSION_SECRET) {
- process.env.TALK_SESSION_SECRET = 'keyboard cat';
-} else if (!process.env.TALK_SESSION_SECRET) {
- throw new Error('TALK_SESSION_SECRET must be defined to encode JSON Web Tokens and other auth functionality');
-}
-
// ProfileSchema is the mongoose schema defined as the representation of a
// User's profile stored in MongoDB.
const ProfileSchema = new mongoose.Schema({
@@ -83,7 +61,11 @@ const UserSchema = new mongoose.Schema({
// This is sourced from the social provider or set manually during user setup
// and simply provides a name to display for the given user.
- displayName: String,
+ displayName: {
+ type: String,
+ unique: true,
+ required: true
+ },
// This is true when the user account is disabled, no action should be
// acknowledged when they are disabled. Logins are also prevented.
@@ -101,11 +83,18 @@ const UserSchema = new mongoose.Schema({
// Roles provides an array of roles (as strings) that is associated with a
// user.
- roles: [String],
+ roles: [{
+ type: String,
+ enum: USER_ROLES
+ }],
// Status provides a string that says in which state the account is.
// When the account is banned, the user login is disabled.
- status: {type: String, enum: USER_STATUS, default: 'active'},
+ status: {
+ type: String,
+ enum: USER_STATUS,
+ default: 'ACTIVE'
+ },
// User's settings
settings: {
@@ -133,625 +122,19 @@ UserSchema.index({
});
/**
- * toJSON overrides to remove the password field from the json
- * output.
+ * Returns true if the user has all the roles specified.
*/
-UserSchema.options.toJSON = {};
-UserSchema.options.toJSON.hide = '_id password';
-UserSchema.options.toJSON.virtuals = true;
-UserSchema.options.toJSON.transform = (doc, ret, options) => {
- if (options.hide) {
- options.hide.split(' ').forEach((prop) => {
- delete ret[prop];
- });
- }
+UserSchema.method('hasRoles', function(...roles) {
+ return roles.every((role) => {
- return ret;
-};
-
-/**
- * Filters the object for the given user only allowing those with the allowed
- * roles/permissions to access particular parameters.
- */
-UserSchema.method('filterForUser', function(user = false) {
- if (!user || !user.roles.includes('admin')) {
- let allowed = ['id', 'displayName', 'settings', 'created_at', 'updated_at'];
-
- if (user && user.id === this.id) {
- allowed.push('roles');
- }
-
- return _.pick(this.toJSON(), allowed);
- }
-
- return this.toJSON();
+ // TODO: remove toUpperCase() once we've migrated usage.
+ return this.roles.indexOf(role.toUpperCase()) >= 0;
+ });
});
// Create the User model.
const UserModel = mongoose.model('User', UserSchema);
-// UserService is the interface for the application to interact with the
-// UserModel through.
-const UserService = module.exports = {};
-
-/**
- * Finds a user given their email address that we have for them in the system
- * and ensures that the retuned user matches the password passed in as well.
- * @param {string} email - email to look up the user by
- * @param {string} password - password to match against the found user
- * @param {Function} done [description]
- */
-UserService.findLocalUser = (email, password) => {
- if (!email || typeof email !== 'string') {
- return Promise.reject('email is required for findLocalUser');
- }
-
- return UserModel.findOne({
- profiles: {
- $elemMatch: {
- id: email.toLowerCase(),
- provider: 'local'
- }
- }
- })
- .then((user) => {
- if (!user) {
- return false;
- }
-
- return new Promise((resolve, reject) => {
- bcrypt.compare(password, user.password, (err, res) => {
- if (err) {
- return reject(err);
- }
-
- if (!res) {
- return resolve(false);
- }
-
- return resolve(user);
- });
- });
- });
-};
-
-/**
- * Merges two users together by taking all the profiles on a given user and
- * pushing them into the source user followed by deleting the destination user's
- * user account. This will not merge the roles associated with the source user.
- * @param {String} dstUserID id of the user to which is the target of the merge
- * @param {String} srcUserID id of the user to which is the source of the merge
- * @return {Promise} resolves when the users are merged
- */
-UserService.mergeUsers = (dstUserID, srcUserID) => {
- let srcUser, dstUser;
-
- return Promise
- .all([
- UserModel.findOne({id: dstUserID}).exec(),
- UserModel.findOne({id: srcUserID}).exec()
- ])
- .then((users) => {
- dstUser = users[0];
- srcUser = users[1];
-
- srcUser.profiles.forEach((profile) => {
- dstUser.profiles.push(profile);
- });
-
- return srcUser.remove();
- })
- .then(() => dstUser.save());
-};
-
-/**
- * Finds a user given a social profile and if the user does not exist, creates
- * them.
- * @param {Object} profile - User social/external profile
- * @param {Function} done [description]
- */
-UserService.findOrCreateExternalUser = (profile) => {
- return UserModel
- .findOne({
- profiles: {
- $elemMatch: {
- id: profile.id,
- provider: profile.provider
- }
- }
- })
- .then((user) => {
- if (user) {
- return user;
- }
-
- // The user was not found, lets create them!
- user = new UserModel({
- displayName: profile.displayName,
- roles: [],
- profiles: [
- {
- id: profile.id,
- provider: profile.provider
- }
- ]
- });
-
- return user.save();
- });
-};
-
-UserService.changePassword = (id, password) => {
- return new Promise((resolve, reject) => {
- bcrypt.hash(password, SALT_ROUNDS, (err, hashedPassword) => {
- if (err) {
- return reject(err);
- }
-
- resolve(hashedPassword);
- });
- })
- .then((hashedPassword) => {
- return UserModel.update({id}, {
- $inc: {__v: 1},
- $set: {
- password: hashedPassword
- }
- });
- });
-};
-
-/**
- * Creates local users.
- * @param {Array} users Users to create
- * @return {Promise} Resolves with the users that were created
- */
-UserService.createLocalUsers = (users) => {
- return Promise.all(users.map((user) => {
- return UserService
- .createLocalUser(user.email, user.password, user.displayName);
- }));
-};
-
-/**
- * Check the requested displayname for naughty words (currently in English) and special chars
- * @param {String} displayName word to be checked for profanity
- * @return {Promise} rejected if the machine's sensibilites are offended
- */
-const isValidDisplayName = (displayName) => {
- const onlyLettersNumbersUnderscore = /^[a-z0-9_]+$/;
-
- if (!displayName) {
- return Promise.reject(errors.ErrMissingDisplay);
- }
-
- if (!onlyLettersNumbersUnderscore.test(displayName)) {
-
- return Promise.reject(errors.ErrSpecialChars);
- }
-
- // check for profanity
- return Wordlist.displayNameCheck(displayName);
-};
-
-/**
- * Creates the local user with a given email, password, and name.
- * @param {String} email email of the new user
- * @param {String} password plaintext password of the new user
- * @param {String} displayName name of the display user
- * @param {Function} done callback
- */
-UserService.createLocalUser = (email, password, displayName) => {
-
- if (!email) {
- return Promise.reject(errors.ErrMissingEmail);
- }
-
- email = email.toLowerCase().trim();
- displayName = displayName.toLowerCase().trim();
-
- if (!password) {
- return Promise.reject(errors.ErrMissingPassword);
- }
-
- if (password.length < 8) {
- return Promise.reject(errors.ErrPasswordTooShort);
- }
-
- return isValidDisplayName(displayName)
- .then(() => { // displayName is valid
- return new Promise((resolve, reject) => {
- bcrypt.hash(password, SALT_ROUNDS, (err, hashedPassword) => {
- if (err) {
- return reject(err);
- }
-
- let user = new UserModel({
- displayName: displayName,
- password: hashedPassword,
- roles: [],
- profiles: [
- {
- id: email,
- provider: 'local'
- }
- ]
- });
-
- user.save((err) => {
- if (err) {
- if (err.code === 11000) {
- return reject(errors.ErrEmailTaken);
- }
- return reject(err);
- }
- return resolve(user);
- });
- });
- });
- });
-};
-
-/**
- * Disables a given user account.
- * @param {String} id id of a user
- * @param {Function} done callback after the operation is complete
- */
-UserService.disableUser = (id) => {
- return UserModel.update({
- id: id
- }, {
- $set: {
- disabled: true
- }
- });
-};
-
-/**
- * Enables a given user account.
- * @param {String} id id of a user
- * @param {Function} done callback after the operation is complete
- */
-UserService.enableUser = (id) => {
- return UserModel.update({
- id: id
- }, {
- $set: {
- disabled: false
- }
- });
-};
-
-/**
- * Adds a role to a user.
- * @param {String} id id of a user
- * @param {String} role role to add
- * @param {Function} done callback after the operation is complete
- */
-UserService.addRoleToUser = (id, role) => {
-
- // Check to see if the user role is in the allowable set of roles.
- if (USER_ROLES.indexOf(role) === -1) {
-
- // User role is not supported! Error out here.
- return Promise.reject(new Error(`role ${role} is not supported`));
- }
-
- return UserModel.update({
- id: id
- }, {
- $addToSet: {
- roles: role
- }
- });
-};
-
-/**
- * Removes a role from a user.
- * @param {String} id id of a user
- * @param {String} role role to remove
- * @param {Function} done callback after the operation is complete
- */
-UserService.removeRoleFromUser = (id, role) => {
- return UserModel.update({
- id: id
- }, {
- $pull: {
- roles: role
- }
- });
-};
-
-/**
- * Set status of a user.
- * @param {String} id id of a user
- * @param {String} status status to set
- * @param {String} comment_id id of the comment that the user was ban for.
- * @param {Function} done callback after the operation is complete
- */
-UserService.setStatus = (id, status, comment_id) => {
-
- // Check to see if the user status is in the allowable set of roles.
- if (USER_STATUS.indexOf(status) === -1) {
-
- // User status is not supported! Error out here.
- return Promise.reject(new Error(`status ${status} is not supported`));
- }
-
- // If ban then reject the comment and update status
- if (status === 'banned' || status === 'suspended') {
- return UserModel.update({
- id: id
- }, {
- $set: {
- status: status
- }
- })
- .then(() => {
- return comment_id ? Comment.pushStatus(comment_id, 'rejected', id) :
- Promise.resolve();
- });
- }
-
- if (status === 'active' || status === 'pending') {
- return UserModel.update({
- id: id
- }, {
- $set: {
- status: status
- }
- });
- }
-};
-
-/**
- * Finds a user with the id.
- * @param {String} id user id (uuid)
-*/
-UserService.findById = (id) => {
- return UserModel.findOne({id});
-};
-
-/**
- * Finds users in an array of ids.
- * @param {Array} ids array of user identifiers (uuid)
-*/
-UserService.findByIdArray = (ids) => {
- return UserModel.find({
- 'id': {$in: ids}
- });
-};
-
-/**
- * Finds public user information by an array of ids.
- * @param {Array} ids array of user identifiers (uuid)
-*/
-UserService.findPublicByIdArray = (ids) => {
- return UserModel.find({
- 'id': {$in: ids}
- }, 'id displayName');
-};
-
-/**
- * Creates a JWT from a user email. Only works for local accounts.
- * @param {String} email of the local user
- */
-UserService.createPasswordResetToken = function (email) {
- if (!email || typeof email !== 'string') {
- return Promise.reject('email is required when creating a JWT for resetting passord');
- }
-
- email = email.toLowerCase();
-
- return UserModel.findOne({profiles: {$elemMatch: {id: email}}})
- .then((user) => {
- if (!user) {
-
- // Since we don't want to reveal that the email does/doesn't exist
- // just go ahead and resolve the Promise with null and check in the
- // endpoint.
- return;
- }
-
- const payload = {
- jti: uuid.v4(),
- email,
- userId: user.id,
- version: user.__v
- };
-
- return jwt.sign(payload, process.env.TALK_SESSION_SECRET, {
- algorithm: 'HS256',
- expiresIn: '1d',
- subject: PASSWORD_RESET_JWT_SUBJECT
- });
- });
-};
-
-/**
- * Verifies that the token was indeed signed by the session secret.
- * @param {String} token JWT token from the client
- * @return {Promise}
- */
-UserService.verifyToken = (token, options = {}) => {
- return new Promise((resolve, reject) => {
-
- // Set the allowed algorithms.
- options.algorithms = ['HS256'];
-
- jwt.verify(token, process.env.TALK_SESSION_SECRET, options, (err, decoded) => {
- if (err) {
- return reject(err);
- }
-
- resolve(decoded);
- });
- });
-};
-
-/**
- * Verifies a jwt and returns the associated user.
- * @param {String} token the JSON Web Token to verify
- */
-UserService.verifyPasswordResetToken = (token) => {
- return UserService
- .verifyToken(token, {
- subject: PASSWORD_RESET_JWT_SUBJECT
- })
- .then((decoded) => UserService.findById(decoded.userId));
-};
-
-/**
- * Finds a user using a value which gets compared using a prefix match against
- * the user's email address and/or their display name.
- * @param {String} value value to search by
- * @return {Promise}
- */
-UserService.search = (value) => {
- return UserModel.find({
- $or: [
-
- // Search by a prefix match on the displayName.
- {
- 'displayName': {
- $regex: new RegExp(`^${value}`),
- $options: 'i'
- }
- },
-
- // Search by a prefix match on the email address.
- {
- 'profiles': {
- $elemMatch: {
- id: {
- $regex: new RegExp(`^${value}`),
- $options: 'i'
- },
- provider: 'local'
- }
- }
- }
- ]
- });
-};
-
-/**
- * Returns a count of the current users.
- * @return {Promise}
- */
-UserService.count = () => UserModel.count();
-
-/**
- * Returns all the users.
- * @return {Promise}
- */
-UserService.all = () => UserModel.find();
-
-/**
- * Updates the user's settings.
- * @return {Promise}
- */
-UserService.updateSettings = (id, settings) => UserModel.update({
- id
-}, {
- $set: {
- settings
- }
-});
-
-/**
- * Add an action to the user.
- * @param {String} item_id identifier of the user (uuid)
- * @param {String} user_id user id of the action (uuid)
- * @param {String} action the new action to the user
- * @return {Promise}
- */
-UserService.addAction = (item_id, user_id, action_type, metadata) => Action.insertUserAction({
- item_id,
- item_type: 'users',
- user_id,
- action_type,
- metadata
-});
-
-/**
- * Returns all users with pending moderation actions.
- * @return {Promise}
- */
-UserService.moderationQueue = () => {
- return UserModel.find({status: 'pending'});
-};
-
-/*
- * This creates a token based around confirming the local profile.
- * @param {String} userID The user id for the user that we are creating the
- * token for.
- * @param {String} email The email that we are needing to get confirmed.
- * @return {Promise}
- */
-UserService.createEmailConfirmToken = (userID, email) => {
- if (!email || typeof email !== 'string') {
- return Promise.reject('email is required when creating a JWT for resetting passord');
- }
-
- email = email.toLowerCase();
-
- return UserService
- .findById(userID)
- .then((user) => {
- if (!user) {
- return Promise.reject(new Error('user not found'));
- }
-
- // Get the profile representing the local account.
- let profile = user.profiles.find((profile) => profile.id === email && profile.provider === 'local');
-
- // Ensure that the user email hasn't already been verified.
- if (profile && profile.metadata && profile.metadata.confirmed_at) {
- return Promise.reject(new Error('email address already confirmed'));
- }
-
- const payload = {
- email,
- userID
- };
-
- return jwt.sign(payload, process.env.TALK_SESSION_SECRET, {
- jwtid: uuid.v4(),
- algorithm: 'HS256',
- expiresIn: '1d',
- subject: EMAIL_CONFIRM_JWT_SUBJECT
- });
- });
-};
-
-/**
- * This verifies that a given token was for the email confirmation and updates
- * that user's profile with a 'confirmed_at' parameter with the current date.
- * @param {String} token the token containing the email confirmation details
- * signed with our secret.
- * @return {Promise}
- */
-UserService.verifyEmailConfirmation = (token) => {
- return UserService
- .verifyToken(token, {
- subject: EMAIL_CONFIRM_JWT_SUBJECT
- })
- .then(({userID, email}) => {
-
- return UserModel
- .update({
- id: userID,
- profiles: {
- $elemMatch: {
- id: email,
- provider: 'local'
- }
- }
- }, {
- $set: {
- 'profiles.$.metadata.confirmed_at': new Date()
- }
- });
- });
-};
+module.exports = UserModel;
+module.exports.USER_ROLES = USER_ROLES;
+module.exports.USER_STATUS = USER_STATUS;
diff --git a/package.json b/package.json
index e6e211d07..59b31eb69 100644
--- a/package.json
+++ b/package.json
@@ -5,13 +5,13 @@
"main": "app.js",
"scripts": {
"start": "./bin/cli serve --jobs",
- "dev-start": "nodemon --exec \"./bin/cli -c .env serve --jobs\"",
+ "dev-start": "nodemon --config .nodemon.json --exec \"./bin/cli -c .env serve --jobs\"",
"build": "NODE_ENV=production webpack --config webpack.config.js --bail",
"build-watch": "NODE_ENV=development webpack --config webpack.config.dev.js --watch",
"lint": "eslint bin/* .",
"lint-fix": "eslint bin/* . --fix",
- "test": "TEST_MODE=unit NODE_ENV=test mocha --compilers js:babel-core/register tests/helpers/*.js --require ignore-styles --recursive tests",
- "test-watch": "TEST_MODE=unit NODE_ENV=test mocha --compilers js:babel-core/register --recursive -w tests",
+ "test": "TEST_MODE=unit NODE_ENV=test mocha -R ${NPM_PACKAGE_CONFIG_MOCHA_REPORTER:-spec}",
+ "test-cover": "TEST_MODE=unit NODE_ENV=test istanbul cover _mocha --report text --check-coverage -- -R spec",
"pree2e": "NODE_ENV=test scripts/pree2e.sh",
"e2e": "NODE_ENV=test nightwatch",
"embed-start": "NODE_ENV=development npm run build && ./bin/cli serve --jobs",
@@ -48,18 +48,24 @@
},
"homepage": "https://github.com/coralproject/talk#readme",
"dependencies": {
+ "apollo-client": "^0.7.3",
"bcrypt": "^0.8.7",
"body-parser": "^1.15.2",
"cli-table": "^0.3.1",
"commander": "^2.9.0",
"connect-redis": "^3.1.0",
"csurf": "^1.9.0",
+ "dataloader": "^1.2.0",
"debug": "^2.2.0",
"dotenv": "^4.0.0",
"ejs": "^2.5.2",
"env-rewrite": "^1.0.2",
"express": "^4.14.0",
"express-session": "^1.14.2",
+ "graphql": "^0.8.2",
+ "graphql-server-express": "^0.5.0",
+ "graphql-tag": "^1.2.3",
+ "graphql-tools": "^0.9.0",
"helmet": "^3.1.0",
"jsonwebtoken": "^7.1.9",
"kue": "^0.11.5",
@@ -74,6 +80,7 @@
"passport-facebook": "^2.1.1",
"passport-local": "^1.0.0",
"prompt": "^1.0.0",
+ "react-apollo": "^0.8.1",
"redis": "^2.6.3",
"uuid": "^2.0.3"
},
@@ -115,6 +122,7 @@
"ignore-styles": "^5.0.1",
"immutable": "^3.8.1",
"imports-loader": "^0.6.5",
+ "istanbul": "^1.1.0-alpha.1",
"jsdom": "^9.8.3",
"json-loader": "^0.5.4",
"keymaster": "^1.6.2",
diff --git a/routes/admin/index.js b/routes/admin/index.js
index 6241dcedb..2a37e8f0f 100644
--- a/routes/admin/index.js
+++ b/routes/admin/index.js
@@ -3,13 +3,17 @@ const router = express.Router();
// Get /password-reset expects a signed token (JWT) in the hash.
// Links to this endpoint are generated by /views/password-reset-email.ejs.
-router.get('/password-reset', (req, res, next) => {
+router.get('/password-reset', (req, res) => {
// TODO: store the redirect uri in the token or something fancy.
// admins and regular users should probably be redirected to different places.
res.render('password-reset', {redirectUri: process.env.TALK_ROOT_URL});
});
+router.get('/login', (req, res, next) => {
+ res.render('admin/login');
+});
+
router.get('*', (req, res) => {
res.render('admin', {basePath: '/client/coral-admin'});
});
diff --git a/routes/api/account/index.js b/routes/api/account/index.js
index 3f699a51d..57647a89d 100644
--- a/routes/api/account/index.js
+++ b/routes/api/account/index.js
@@ -1,6 +1,6 @@
const express = require('express');
const router = express.Router();
-const User = require('../../../models/user');
+const UsersService = require('../../../services/users');
const mailer = require('../../../services/mailer');
const authorization = require('../../../middleware/authorization');
const errors = require('../../../errors');
@@ -26,7 +26,7 @@ router.post('/email/confirm', (req, res, next) => {
return next(errors.ErrMissingToken);
}
- User
+ UsersService
.verifyEmailConfirmation(token)
.then(() => {
res.status(204).end();
@@ -47,7 +47,7 @@ router.post('/password/reset', (req, res, next) => {
return next('you must submit an email when requesting a password.');
}
- User
+ UsersService
.createPasswordResetToken(email)
.then((token) => {
@@ -100,9 +100,10 @@ router.put('/password/reset', (req, res, next) => {
return next(errors.ErrPasswordTooShort);
}
- User.verifyPasswordResetToken(token)
+ UsersService
+ .verifyPasswordResetToken(token)
.then(user => {
- return User.changePassword(user.id, password);
+ return UsersService.changePassword(user.id, password);
})
.then(() => {
res.status(204).end();
@@ -120,7 +121,7 @@ router.put('/settings', authorization.needed(), (req, res, next) => {
bio
} = req.body;
- User
+ UsersService
.updateSettings(req.user.id, {bio})
.then(() => {
res.status(204).end();
diff --git a/routes/api/actions/index.js b/routes/api/actions/index.js
index 9724f5a73..20316d93b 100644
--- a/routes/api/actions/index.js
+++ b/routes/api/actions/index.js
@@ -1,10 +1,10 @@
const express = require('express');
-const Action = require('../../../models/action');
+const ActionsService = require('../../../services/actions');
const router = express.Router();
router.delete('/:action_id', (req, res, next) => {
- Action
+ ActionsService
.findOneAndRemove({
id: req.params.action_id,
user_id: req.user.id
diff --git a/routes/api/assets/index.js b/routes/api/assets/index.js
index f5a4afe44..0b6762de8 100644
--- a/routes/api/assets/index.js
+++ b/routes/api/assets/index.js
@@ -1,8 +1,10 @@
const express = require('express');
const router = express.Router();
-const Asset = require('../../../models/asset');
const scraper = require('../../../services/scraper');
+const AssetsService = require('../../../services/assets');
+
+const AssetModel = require('../../../models/asset');
// List assets.
router.get('/', (req, res, next) => {
@@ -46,13 +48,13 @@ router.get('/', (req, res, next) => {
Promise.all([
// Find the actuall assets.
- FilterOpenAssets(Asset.search(search), filter)
+ FilterOpenAssets(AssetsService.search(search), filter)
.sort({[field]: (sort === 'asc') ? 1 : -1})
.skip(parseInt(skip))
.limit(parseInt(limit)),
// Get the count of actual assets.
- FilterOpenAssets(Asset.search(search), filter)
+ FilterOpenAssets(AssetsService.search(search), filter)
.count()
])
.then(([result, count]) => {
@@ -73,7 +75,7 @@ router.get('/', (req, res, next) => {
router.get('/:asset_id', (req, res, next) => {
// Send back the asset.
- Asset
+ AssetsService
.findById(req.params.asset_id)
.then((asset) => {
if (!asset) {
@@ -91,7 +93,7 @@ router.get('/:asset_id', (req, res, next) => {
router.post('/:asset_id/scrape', (req, res, next) => {
// Create a new asset scrape job.
- Asset
+ AssetsService
.findById(req.params.asset_id)
.then((asset) => {
if (!asset) {
@@ -113,7 +115,7 @@ router.post('/:asset_id/scrape', (req, res, next) => {
router.put('/:asset_id/settings', (req, res, next) => {
// Override the settings for the asset.
- Asset
+ AssetsService
.overrideSettings(req.params.asset_id, req.body)
.then(() => res.status(204).end())
.catch((err) => next(err));
@@ -128,7 +130,7 @@ router.put('/:asset_id/status', (req, res, next) => {
closedMessage
} = req.body;
- Asset
+ AssetModel
.update({id}, {
$set: {
closedAt,
diff --git a/routes/api/comments/index.js b/routes/api/comments/index.js
index 1d5d73ea0..19346274d 100644
--- a/routes/api/comments/index.js
+++ b/routes/api/comments/index.js
@@ -1,10 +1,12 @@
const express = require('express');
const errors = require('../../../errors');
-const Comment = require('../../../models/comment');
-const Asset = require('../../../models/asset');
-const User = require('../../../models/user');
-const Action = require('../../../models/action');
-const wordlist = require('../../../services/wordlist');
+
+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');
@@ -20,13 +22,13 @@ router.get('/', (req, res, next) => {
} = 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) {
+ 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')) {
+ if (req.user.id !== user_id && !authorization.has(req.user, 'ADMIN')) {
next(errors.ErrNotAuthorized);
return;
}
@@ -48,27 +50,27 @@ router.get('/', (req, res, next) => {
// 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 = Comment.findByUserId(user_id, authorization.has(req.user, 'admin'));
+ query = CommentsService.findByUserId(user_id, authorization.has(req.user, 'ADMIN'));
} else if (status) {
- query = assetIDWrap(Comment.findByStatus(status === 'new' ? null : status));
+ query = assetIDWrap(CommentsService.findByStatus(status === 'NEW' ? null : status));
} else if (action_type) {
- query = Comment
+ query = CommentsService
.findIdsByActionType(action_type)
- .then((ids) => assetIDWrap(Comment.find({
+ .then((ids) => assetIDWrap(CommentModel.find({
id: {
$in: ids
}
})));
} else {
- query = assetIDWrap(Comment.all());
+ query = assetIDWrap(CommentsService.all());
}
query.then((comments) => {
return Promise.all([
comments,
- Asset.findMultipleById(comments.map(comment => comment.asset_id)),
- User.findByIdArray(_.uniq(comments.map((comment) => comment.author_id))),
- Action.getActionSummariesFromComments(asset_id, comments, req.user ? req.user.id : false)
+ 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]) =>
@@ -83,79 +85,8 @@ router.get('/', (req, res, next) => {
});
});
-router.post('/', wordlist.filter('body'), (req, res, next) => {
-
- const {
- body,
- asset_id,
- parent_id
- } = req.body;
-
- // Decide the status based on whether or not the current asset/settings
- // has pre-mod enabled or not. If the comment was rejected based on the
- // wordlist, then reject it, otherwise if the moderation setting is
- // premod, set it to `premod`.
- let status;
-
- if (req.wordlist.banned) {
- status = Promise.resolve('rejected');
- } else {
- status = Asset
- .rectifySettings(Asset.findById(asset_id).then((asset) => {
- if (!asset) {
- return Promise.reject(errors.ErrNotFound);
- }
-
- // Check to see if the asset has closed commenting...
- if (asset.isClosed) {
-
- // They have, ensure that we send back an error.
- return Promise.reject(new errors.ErrAssetCommentingClosed(asset.closedMessage));
- }
-
- return asset;
- }))
-
- // Return `premod` if pre-moderation is enabled and an empty "new" status
- // in the event that it is not in pre-moderation mode.
- .then(({moderation, charCountEnable, charCount}) => {
-
- // Reject if the comment is too long
- if (charCountEnable && body.length > charCount) {
- return 'rejected';
- }
- return moderation === 'pre' ? 'premod' : null;
- });
- }
-
- status.then((status) => Comment.publicCreate({
- body,
- asset_id,
- parent_id,
- status,
- author_id: req.user.id
- }))
- .then((comment) => {
- if (req.wordlist.suspect) {
- return Comment
- .addAction(comment.id, null, 'flag', {field: 'body', details: 'Matched suspect word filters.'})
- .then(() => comment);
- }
-
- return comment;
- })
- .then((comment) => {
-
- // The comment was created! Send back the created comment.
- res.status(201).json(comment);
- })
- .catch((err) => {
- next(err);
- });
-});
-
-router.get('/:comment_id', authorization.needed('admin'), (req, res, next) => {
- Comment
+router.get('/:comment_id', authorization.needed('ADMIN'), (req, res, next) => {
+ CommentsService
.findById(req.params.comment_id)
.then(comment => {
if (!comment) {
@@ -170,8 +101,8 @@ router.get('/:comment_id', authorization.needed('admin'), (req, res, next) => {
});
});
-router.delete('/:comment_id', authorization.needed('admin'), (req, res, next) => {
- Comment
+router.delete('/:comment_id', authorization.needed('ADMIN'), (req, res, next) => {
+ CommentsService
.removeById(req.params.comment_id)
.then(() => {
res.status(204).end();
@@ -181,12 +112,12 @@ router.delete('/:comment_id', authorization.needed('admin'), (req, res, next) =>
});
});
-router.put('/:comment_id/status', authorization.needed('admin'), (req, res, next) => {
+router.put('/:comment_id/status', authorization.needed('ADMIN'), (req, res, next) => {
const {
status
} = req.body;
- Comment
+ CommentsService
.pushStatus(req.params.comment_id, status, req.user.id)
.then(() => {
res.status(204).end();
@@ -203,7 +134,7 @@ router.post('/:comment_id/actions', (req, res, next) => {
metadata
} = req.body;
- Comment
+ CommentsService
.addAction(req.params.comment_id, req.user.id, action_type, metadata)
.then((action) => {
res.status(201).json(action);
diff --git a/routes/api/index.js b/routes/api/index.js
index 120fd47ec..c40c428b4 100644
--- a/routes/api/index.js
+++ b/routes/api/index.js
@@ -1,25 +1,20 @@
const express = require('express');
const authorization = require('../../middleware/authorization');
-const payloadFilter = require('../../middleware/payload-filter');
const router = express.Router();
-// Filter all content going down the pipe based on user roles.
-router.use(payloadFilter);
-
-router.use('/assets', authorization.needed('admin'), require('./assets'));
-router.use('/settings', authorization.needed('admin'), require('./settings'));
-router.use('/queue', authorization.needed('admin'), require('./queue'));
+router.use('/assets', authorization.needed('ADMIN'), require('./assets'));
+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('/stream', require('./stream'));
router.use('/users', require('./users'));
router.use('/account', require('./account'));
// Bind the kue handler to the /kue path.
-router.use('/kue', authorization.needed('admin'), require('../../services/kue').kue.app);
+router.use('/kue', authorization.needed('ADMIN'), require('../../services/kue').kue.app);
module.exports = router;
diff --git a/routes/api/queue/index.js b/routes/api/queue/index.js
index 32fa6f3bf..a0756b4c3 100644
--- a/routes/api/queue/index.js
+++ b/routes/api/queue/index.js
@@ -1,7 +1,8 @@
const express = require('express');
-const Comment = require('../../../models/comment');
-const User = require('../../../models/user');
-const Action = require('../../../models/action');
+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');
@@ -10,8 +11,8 @@ const router = express.Router();
function gatherActionsAndUsers (comments) {
return Promise.all([
comments,
- User.findByIdArray(_.uniq(comments.map((comment) => comment.author_id))),
- Action.getActionSummaries(_.uniq([
+ UsersService.findByIdArray(_.uniq(comments.map((comment) => comment.author_id))),
+ ActionsService.getActionSummaries(_.uniq([
...comments.map((comment) => comment.id),
...comments.map((comment) => comment.author_id)
]))
@@ -26,11 +27,11 @@ function gatherActionsAndUsers (comments) {
// 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/pending', authorization.needed('admin'), (req, res, next) => {
+router.get('/comments/pending', authorization.needed('ADMIN'), (req, res, next) => {
const {asset_id} = req.query;
- Comment.moderationQueue('premod', asset_id)
+ CommentsService.moderationQueue('PREMOD', asset_id)
.then(gatherActionsAndUsers)
.then(([comments, users, actions]) => {
res.json({comments, users, actions});
@@ -40,10 +41,10 @@ router.get('/comments/pending', authorization.needed('admin'), (req, res, next)
});
});
-router.get('/comments/rejected', authorization.needed('admin'), (req, res, next) => {
+router.get('/comments/rejected', authorization.needed('ADMIN'), (req, res, next) => {
const {asset_id} = req.query;
- Comment.moderationQueue('rejected', asset_id)
+ CommentsService.moderationQueue('REJECTED', asset_id)
.then(gatherActionsAndUsers)
.then(([comments, users, actions]) => {
res.json({comments, users, actions});
@@ -53,7 +54,7 @@ router.get('/comments/rejected', authorization.needed('admin'), (req, res, next)
});
});
-router.get('/comments/flagged', authorization.needed('admin'), (req, res, next) => {
+router.get('/comments/flagged', authorization.needed('ADMIN'), (req, res, next) => {
const {asset_id} = req.query;
const assetIDWrap = (query) => {
@@ -64,8 +65,8 @@ router.get('/comments/flagged', authorization.needed('admin'), (req, res, next)
return query;
};
- Comment.findIdsByActionType('flag')
- .then(ids => assetIDWrap(Comment.find({
+ CommentsService.findIdsByActionType('FLAG')
+ .then(ids => assetIDWrap(CommentModel.find({
id: {$in: ids}
})))
.then(gatherActionsAndUsers)
@@ -79,12 +80,11 @@ router.get('/comments/flagged', authorization.needed('admin'), (req, res, next)
// Returns back all the users that are in the moderation queue.
router.get('/users/pending', (req, res, next) => {
-
- User.moderationQueue()
+ UsersService.moderationQueue()
.then((users) => {
return Promise.all([
users,
- Action.getActionSummaries(users.map((user) => user.id))
+ ActionsService.getActionSummaries(users.map((user) => user.id))
]);
})
.then(([users, actions]) => {
diff --git a/routes/api/settings/index.js b/routes/api/settings/index.js
index 76d16c7ec..f1a5fe9f7 100644
--- a/routes/api/settings/index.js
+++ b/routes/api/settings/index.js
@@ -1,24 +1,26 @@
const express = require('express');
-const Setting = require('../../../models/setting');
+const SettingsService = require('../../../services/settings');
const router = express.Router();
router.get('/', (req, res, next) => {
- Setting.retrieve().then((settings) => {
- res.json(settings);
- })
- .catch((err) => {
- next(err);
- });
+ SettingsService
+ .retrieve().then((settings) => {
+ res.json(settings);
+ })
+ .catch((err) => {
+ next(err);
+ });
});
router.put('/', (req, res, next) => {
- Setting.update(req.body).then(() => {
- res.status(204).end();
- })
- .catch((err) => {
- next(err);
- });
+ SettingsService
+ .update(req.body).then(() => {
+ res.status(204).end();
+ })
+ .catch((err) => {
+ next(err);
+ });
});
module.exports = router;
diff --git a/routes/api/stream/index.js b/routes/api/stream/index.js
deleted file mode 100644
index 540e36e52..000000000
--- a/routes/api/stream/index.js
+++ /dev/null
@@ -1,121 +0,0 @@
-const express = require('express');
-const _ = require('lodash');
-const scraper = require('../../../services/scraper');
-const errors = require('../../../errors');
-const url = require('url');
-
-const Comment = require('../../../models/comment');
-const User = require('../../../models/user');
-const Action = require('../../../models/action');
-const Asset = require('../../../models/asset');
-const Setting = require('../../../models/setting');
-
-const router = express.Router();
-
-router.get('/', (req, res, next) => {
-
- let asset_url = decodeURIComponent(req.query.asset_url);
-
- // Verify that the asset_url is parsable.
- let parsed_asset_url = url.parse(asset_url);
- if (!parsed_asset_url.protocol) {
- return next(errors.ErrInvalidAssetURL);
- }
-
- // Get the asset_id for this url (or create it if it doesn't exist)
- Promise.all([
-
- // Find or create the asset by url.
- Asset.findOrCreateByUrl(asset_url)
-
- // Add the found asset to the scraper if it's not already scraped.
- .then((asset) => {
- if (!asset.scraped) {
- return scraper.create(asset).then(() => asset);
- }
-
- return asset;
- }),
-
- // Get the moderation setting from the settings.
- Setting.retrieve()
- ])
- .then(([asset, settings]) => {
-
- // Merge the asset specific settings with the returned settings object in
- // the event that the asset that was returned also had settings.
- if (asset && asset.settings) {
- settings.merge(asset.settings);
- }
-
- // Fetch the appropriate comments stream.
- let comments;
-
- if (settings.moderation === 'pre') {
- comments = Comment.findAcceptedByAssetId(asset.id);
- } else {
- comments = Comment.findAcceptedAndNewByAssetId(asset.id);
- }
-
- return Promise.all([
-
- // This is the promised component... Fetch the comments based on the
- // moderation settings.
- comments,
-
- // Send back the reference to the asset.
- asset,
-
- // Send back the settings to the stream.
- settings
- ]);
- })
-
- // Get all the users and actions for those comments.
- .then(([comments, asset, settings]) => {
-
- // Get the user id's from the author id's as a unique array that gets
- // sorted.
- let userIDs = _.uniq(comments.map((comment) => comment.author_id)).sort();
-
- // Fetch the users for which there is a comment available for them.
- let users = userIDs.length > 0 ? User.findByIdArray(userIDs) : [];
-
- // Fetch the actions for pretty much everything at this point.
- let actions = Action.getActionSummariesFromComments(asset.id, comments, req.user ? req.user.id : false);
-
- return Promise.all([
-
- // Pass back the asset that we loaded...
- asset,
-
- // It's comments...
- comments,
-
- // The users who wrote those comments
- users,
-
- // And all actions about the asset, comments, and users.
- actions,
-
- // Pass back the settings that we loaded.
- settings
- ]);
- })
- .then(([asset, comments, users, actions, settings]) => {
-
- // Send back the payload containing all this data.
- res.json({
- assets: [asset],
- comments,
- users,
- actions,
- settings
- });
- })
- .catch(error => {
- next(error);
- });
-});
-
-module.exports = router;
diff --git a/routes/api/users/index.js b/routes/api/users/index.js
index 5dc71264d..da47007ee 100644
--- a/routes/api/users/index.js
+++ b/routes/api/users/index.js
@@ -1,11 +1,12 @@
const express = require('express');
const router = express.Router();
-const User = require('../../../models/user');
-const Setting = require('../../../models/setting');
+const UsersService = require('../../../services/users');
+const SettingsService = require('../../../services/settings');
+const CommentsService = require('../../../services/comments');
const mailer = require('../../../services/mailer');
const authorization = require('../../../middleware/authorization');
-router.get('/', authorization.needed('admin'), (req, res, next) => {
+router.get('/', authorization.needed('ADMIN'), (req, res, next) => {
const {
value = '',
field = 'created_at',
@@ -15,12 +16,12 @@ router.get('/', authorization.needed('admin'), (req, res, next) => {
} = req.query;
Promise.all([
- User
+ UsersService
.search(value)
.sort({[field]: (asc === 'true') ? 1 : -1})
.skip((page - 1) * limit)
.limit(limit),
- User.count()
+ UsersService.count()
])
.then(([result, count]) => {
res.json({
@@ -34,8 +35,8 @@ router.get('/', authorization.needed('admin'), (req, res, next) => {
.catch(next);
});
-router.post('/:user_id/role', authorization.needed('admin'), (req, res, next) => {
- User
+router.post('/:user_id/role', authorization.needed('ADMIN'), (req, res, next) => {
+ UsersService
.addRoleToUser(req.params.user_id, req.body.role)
.then(() => {
res.status(204).end();
@@ -43,17 +44,21 @@ router.post('/:user_id/role', authorization.needed('admin'), (req, res, next) =>
.catch(next);
});
-router.post('/:user_id/status', (req, res, next) => {
- User
- .setStatus(req.params.user_id, req.body.status, req.body.comment_id)
+router.post('/:user_id/status', authorization.needed('ADMIN'), (req, res, next) => {
+ UsersService
+ .setStatus(req.params.user_id, req.body.status)
.then((status) => {
res.status(201).json(status);
+
+ if (status === 'BANNED' && req.body.comment_id) {
+ return CommentsService.pushStatus(req.body.comment_id, 'rejected', req.params.user_id);
+ }
})
.catch(next);
});
router.post('/:user_id/email', authorization.needed('admin'), (req, res, next) => {
- User.findById(req.params.user_id)
+ UsersService.findById(req.params.user_id)
.then(user => {
let localProfile = user.profiles.find((profile) => profile.provider === 'local');
@@ -92,7 +97,7 @@ router.post('/:user_id/email', authorization.needed('admin'), (req, res, next) =
* @param {String} userID the id for the user to send the email to
* @param {String} email the email for the user to send the email to
*/
-const SendEmailConfirmation = (app, userID, email) => User
+const SendEmailConfirmation = (app, userID, email) => UsersService
.createEmailConfirmToken(userID, email)
.then((token) => {
return mailer.sendSimple({
@@ -115,14 +120,14 @@ router.post('/', (req, res, next) => {
displayName
} = req.body;
- User
+ UsersService
.createLocalUser(email, password, displayName)
.then((user) => {
// Get the settings from the database to find out if we need to send an
// email confirmation. The Front end will know about the
// requireEmailConfirmation as it's included in the settings get endpoint.
- return Setting.retrieve().then(({requireEmailConfirmation = false}) => {
+ return SettingsService.retrieve().then(({requireEmailConfirmation = false}) => {
if (requireEmailConfirmation) {
@@ -150,13 +155,13 @@ router.post('/:user_id/actions', authorization.needed(), (req, res, next) => {
metadata
} = req.body;
- User
+ UsersService
.addAction(req.params.user_id, req.user.id, action_type, metadata)
.then((action) => {
// Set the user status to "pending" for review by moderators
- if (action_type.slice(0, 4) === 'flag') {
- return User.setStatus(req.user.id, 'pending')
+ if (action_type.slice(0, 4) === 'FLAG') {
+ return UsersService.setStatus(req.user.id, 'PENDING')
.then(() => action);
} else {
return action;
@@ -166,16 +171,17 @@ router.post('/:user_id/actions', authorization.needed(), (req, res, next) => {
res.status(201).json(action);
})
.catch((err) => {
+ console.log('Error', err);
next(err);
});
});
-router.post('/:user_id/email/confirm', authorization.needed('admin'), (req, res, next) => {
+router.post('/:user_id/email/confirm', authorization.needed('ADMIN'), (req, res, next) => {
const {
user_id
} = req.params;
- User
+ UsersService
.findById(user_id)
.then((user) => {
if (!user) {
diff --git a/services/actions.js b/services/actions.js
new file mode 100644
index 000000000..c336dd974
--- /dev/null
+++ b/services/actions.js
@@ -0,0 +1,177 @@
+const ActionModel = require('../models/action');
+const _ = require('lodash');
+
+module.exports = class ActionsService {
+
+ /**
+ * Finds an action by the id.
+ * @param {String} id identifier of the action (uuid)
+ */
+ static findById(id) {
+ return ActionModel.findOne({id});
+ }
+
+ /**
+ * Add an action.
+ * @param {String} item_id identifier of the comment (uuid)
+ * @param {String} user_id user id of the action (uuid)
+ * @param {String} action the new action to the comment
+ * @return {Promise}
+ */
+ static insertUserAction(action) {
+
+ // Actions are made unique by using a query that can be reproducable, i.e.,
+ // not containing user inputable values.
+ let query = {
+ action_type: action.action_type,
+ item_type: action.item_type,
+ item_id: action.item_id,
+ user_id: action.user_id
+ };
+
+ // Create/Update the action.
+ return ActionModel.findOneAndUpdate(query, action, {
+
+ // Ensure that if it's new, we return the new object created.
+ new: true,
+
+ // Perform an upsert in the event that this doesn't exist.
+ upsert: true,
+
+ // Set the default values if not provided based on the mongoose models.
+ setDefaultsOnInsert: true
+ });
+ }
+
+ /**
+ * Finds actions in an array of ids.
+ * @param {String} ids array of user identifiers (uuid)
+ */
+ static findByItemIdArray(item_ids) {
+ return ActionModel.find({
+ 'item_id': {$in: item_ids}
+ });
+ }
+
+ /**
+ * Fetches the action summaries for the given asset, and comments around the
+ * given user id.
+ * @param {[type]} asset_id [description]
+ * @param {[type]} comments [description]
+ * @param {String} [current_user_id=''] [description]
+ * @return {[type]} [description]
+ */
+ static getActionSummariesFromComments(asset_id = '', comments, current_user_id = '') {
+
+ // Get the user id's from the author id's as a unique array that gets
+ // sorted.
+ let userIDs = _.uniq(comments.map((comment) => comment.author_id)).sort();
+
+ // Fetch the actions for pretty much everything at this point.
+ return ActionsService.getActionSummaries(_.uniq([
+
+ // Actions can be on assets...
+ asset_id,
+
+ // Comments...
+ ...comments.map((comment) => comment.id),
+
+ // Or Authors...
+ ...userIDs
+ ].filter((e) => e)), current_user_id);
+ }
+
+ /**
+ * Returns summaries of actions for an array of ids
+ * @param {String} ids array of user identifiers (uuid)
+ */
+ static getActionSummaries(item_ids, current_user_id = '') {
+ return ActionModel.aggregate([
+ {
+
+ // only grab items that match the specified item id's
+ $match: {
+ item_id: {
+ $in: item_ids
+ }
+ }
+ },
+ {
+ $group: {
+
+ // group unique documents by these properties, we are leveraging the
+ // fact that each uuid is completly unique.
+ _id: {
+ item_id: '$item_id',
+ action_type: '$action_type'
+ },
+
+ // and sum up all actions matching the above grouping criteria
+ count: {
+ $sum: 1
+ },
+
+ // we are leveraging the fact that each uuid is completly unique and
+ // just grabbing the last instance of the item type here.
+ item_type: {
+ $last: '$item_type'
+ },
+
+ current_user: {
+ $max: {
+ $cond: {
+ if: {
+ $eq: ['$user_id', current_user_id],
+ },
+ then: '$$CURRENT',
+ else: null
+ }
+ }
+ }
+ }
+ },
+ {
+ $project: {
+
+ // suppress the _id field
+ _id: false,
+
+ // map the fields from the _id grouping down a level
+ item_id: '$_id.item_id',
+ action_type: '$_id.action_type',
+
+ // map the field directly
+ count: '$count',
+ item_type: '$item_type',
+
+ // set the current user to false here
+ current_user: '$current_user'
+ }
+ }
+ ]);
+ }
+
+ /*
+ * Finds all comments for a specific action.
+ * @param {String} action_type type of action
+ * @param {String} item_type type of item the action is on
+ */
+ static findByType(action_type, item_type) {
+ return ActionModel.find({
+ 'action_type': action_type,
+ 'item_type': item_type
+ });
+ }
+
+ /**
+ * Finds all comments ids for a specific action.
+ * @param {String} action_type type of action
+ * @param {String} item_type type of item the action is on
+ */
+ static findCommentsIdByActionType(action_type, item_type) {
+ return ActionModel.find({
+ 'action_type': action_type,
+ 'item_type': item_type
+ }, 'item_id');
+ }
+};
diff --git a/services/assets.js b/services/assets.js
new file mode 100644
index 000000000..569c7ee2d
--- /dev/null
+++ b/services/assets.js
@@ -0,0 +1,116 @@
+const AssetModel = require('../models/asset');
+const SettingsService = require('./settings');
+
+module.exports = class AssetsService {
+
+ /**
+ * Finds an asset by its id.
+ * @param {String} id identifier of the asset (uuid).
+ */
+ static findById(id) {
+ return AssetModel.findOne({id});
+ }
+
+ /**
+ * Finds a asset by its url.
+ * @param {String} url identifier of the asset (uuid).
+ */
+ static findByUrl(url) {
+ return AssetModel.findOne({url});
+ }
+
+ /**
+ * Retrieves the settings given an asset query and rectifies it against the
+ * global settings.
+ * @param {Promise} assetQuery an asset query that returns a single asset.
+ * @return {Promise}
+ */
+ static rectifySettings(assetQuery) {
+ return Promise.all([
+ SettingsService.retrieve(),
+ assetQuery
+ ]).then(([settings, asset]) => {
+
+ // If the asset exists and has settings then return the merged object.
+ if (asset && asset.settings) {
+ settings.merge(asset.settings);
+ }
+
+ return settings;
+ });
+ }
+
+ /**
+ * Finds a asset by its url.
+ *
+ * NOTE: This function has scalability concerns regarding mongoose's decision
+ * always write {updated_at: new Date()} on every call to findOneAndUpdate
+ * even though the update document exactly matches the query document... In
+ * the future this function should never update, only findOneAndCreate but this
+ * is not possible with the mongoose driver.
+ *
+ * @param {String} url identifier of the asset (uuid).
+ * @return {Promise}
+ */
+ static findOrCreateByUrl(url) {
+ return AssetModel.findOneAndUpdate({url}, {url}, {
+
+ // Ensure that if it's new, we return the new object created.
+ new: true,
+
+ // Perform an upsert in the event that this doesn't exist.
+ upsert: true,
+
+ // Set the default values if not provided based on the mongoose models.
+ setDefaultsOnInsert: true
+ });
+ }
+
+ /**
+ * Updates the settings for the asset.
+ * @param {[type]} id [description]
+ * @param {[type]} settings [description]
+ * @return {[type]} [description]
+ */
+ static overrideSettings(id, settings) {
+ return AssetModel.findOneAndUpdate({id}, {
+ $set: {
+ settings
+ }
+ }, {
+ new: true
+ });
+ }
+
+ /**
+ * Finds assets matching keywords on the model. If `value` is an empty string,
+ * then it will not even perform a text search query.
+ * @param {String} value string to search by.
+ * @return {Promise}
+ */
+ static search(value) {
+ if (value.length === 0) {
+ return AssetsService.all();
+ } else {
+ return AssetModel.find({
+ $text: {
+ $search: value
+ }
+ });
+ }
+ }
+
+ /**
+ * Finds multiple assets with matching ids
+ * @param {Array} ids an array of Strings of asset_id
+ * @return {Promise} resolves to list of Assets
+ */
+ static findMultipleById(ids) {
+ const query = ids.map(id => ({id}));
+ return AssetModel.find(query);
+ }
+
+ static all() {
+ return AssetModel.find({});
+ }
+};
diff --git a/services/comments.js b/services/comments.js
new file mode 100644
index 000000000..5d39a129e
--- /dev/null
+++ b/services/comments.js
@@ -0,0 +1,220 @@
+const CommentModel = require('../models/comment');
+
+const ActionModel = require('../models/action');
+const ActionsService = require('./actions');
+
+module.exports = class CommentsService {
+
+ /**
+ * Creates a new Comment that came from a public source.
+ * @param {Mixed} comment either a single comment or an array of comments.
+ * @return {Promise}
+ */
+ static publicCreate(comment) {
+
+ // Check to see if this is an array of comments, if so map it out.
+ if (Array.isArray(comment)) {
+ return Promise.all(comment.map(CommentsService.publicCreate));
+ }
+
+ const {
+ body,
+ asset_id,
+ parent_id,
+ status = null,
+ author_id
+ } = comment;
+
+ comment = new CommentModel({
+ body,
+ asset_id,
+ parent_id,
+ status_history: status ? [{
+ type: status,
+ created_at: new Date()
+ }] : [],
+ status,
+ author_id
+ });
+
+ return comment.save();
+ }
+
+ /**
+ * Finds a comment by the id.
+ * @param {String} id identifier of comment (uuid)
+ * @return {Promise}
+ */
+ static findById(id) {
+ return CommentModel.findOne({id});
+ }
+
+ /**
+ * Finds ALL the comments by the asset_id.
+ * @param {String} asset_id identifier of the asset which owns this comment (uuid)
+ * @return {Promise}
+ */
+ static findByAssetId(asset_id) {
+ return CommentModel.find({
+ asset_id
+ });
+ }
+
+ /**
+ * findByAssetIdWithStatuses finds all the comments where the asset id matches
+ * what's provided and the status is one of the ones listed in the statuses
+ * array.
+ * @param {String} asset_id the asset id to search by
+ * @param {Array} [statuses=[]] the array of statuses to search by
+ * @return {Promise} resolves to an array of comments
+ */
+ static findByAssetIdWithStatuses(asset_id, statuses = []) {
+ return CommentModel.find({
+ asset_id,
+ status: {
+ $in: statuses
+ }
+ });
+ }
+
+ /**
+ * Find comments by an action that was performed on them.
+ * @param {String} action_type the type of action that was performed on the comment
+ * @return {Promise}
+ */
+ static findByActionType(action_type) {
+ return ActionsService
+ .findCommentsIdByActionType(action_type, 'COMMENTS')
+ .then((actions) => CommentModel.find({
+ id: {
+ $in: actions.map((a) => a.item_id)
+ }
+ }));
+ }
+
+ /**
+ * Find comment id's where the action type matches the argument.
+ * @param {String} action_type the type of action that was performed on the comment
+ * @return {Promise}
+ */
+ static findIdsByActionType(action_type) {
+ return ActionsService
+ .findCommentsIdByActionType(action_type, 'COMMENTS')
+ .then((actions) => actions.map(a => a.item_id));
+ }
+
+ /**
+ * Find comments by current status
+ * @param {String} status status of the comment to search for
+ * @return {Promise} resovles to comment array
+ */
+ static findByStatus(status = null) {
+ return CommentModel.find({status});
+ }
+
+ /**
+ * Find comments that need to be moderated (aka moderation queue).
+ * @param {String} asset_id
+ * @return {Promise}
+ */
+ static moderationQueue(status = null, asset_id = null) {
+
+ // Fetch the comments with statuses.
+ let comments = CommentModel.find({status});
+
+ if (asset_id) {
+ comments = comments.where({asset_id});
+ }
+
+ return comments;
+ }
+
+ /**
+ * Pushes a new status in for the user.
+ * @param {String} id identifier of the comment (uuid)
+ * @param {String} status the new status of the comment
+ * @param {String} assigned_by the user id for the user who performed the
+ * moderation action
+ * @return {Promise}
+ */
+ static pushStatus(id, status, assigned_by = null) {
+ return CommentModel.update({id}, {
+ $push: {
+ status_history: {
+ type: status,
+ created_at: new Date(),
+ assigned_by
+ }
+ },
+ $set: {status}
+ });
+ }
+
+ /**
+ * Add an action to the comment.
+ * @param {String} item_id identifier of the comment (uuid)
+ * @param {String} user_id user id of the action (uuid)
+ * @param {String} action the new action to the comment
+ * @return {Promise}
+ */
+ static addAction(item_id, user_id, action_type, metadata) {
+ return ActionsService.insertUserAction({
+ item_id,
+ item_type: 'COMMENTS',
+ user_id,
+ action_type,
+ metadata
+ });
+ }
+
+ /**
+ * Change the status of a comment.
+ * @param {String} id identifier of the comment (uuid)
+ * @param {String} status the new status of the comment
+ * @return {Promise}
+ */
+ static removeById(id) {
+ return CommentModel.remove({id});
+ }
+
+ /**
+ * Remove an action from the comment.
+ * @param {String} id identifier of the comment (uuid)
+ * @param {String} action_type the type of the action to be removed
+ * @param {String} user_id the id of the user performing the action
+ * @return {Promise}
+ */
+ static removeAction(item_id, user_id, action_type) {
+ return ActionModel.remove({
+ action_type,
+ item_type: 'COMMENTS',
+ item_id,
+ user_id
+ });
+ }
+
+ /**
+ * Returns all the comments in the collection.
+ * @return {Promise}
+ */
+ static all() {
+ return CommentModel.find({});
+ }
+
+ /**
+ * Returns all the comments by user
+ * probably to be paginated at some point in the future
+ * @return {Promise} array resolves to an array of comments by that user
+ */
+ static findByUserId(author_id, admin = false) {
+
+ // do not return un-published comments for non-admins
+ let query = {author_id};
+
+ if (!admin) {
+ query.$nor = [{status: 'PREMOD'}, {status: 'REJECTED'}];
+ }
+
+ return CommentModel.find(query);
+ }
+};
diff --git a/services/mongoose.js b/services/mongoose.js
index 1b9097760..c7092326f 100644
--- a/services/mongoose.js
+++ b/services/mongoose.js
@@ -57,3 +57,12 @@ mongoose.connect(url, (err) => {
});
module.exports = mongoose;
+
+// Here we include all the models that mongoose is used for, this ensures that
+// when we import mongoose that we also start up all the indexing opreations
+// here.
+require('../models/action');
+require('../models/asset');
+require('../models/comment');
+require('../models/setting');
+require('../models/user');
diff --git a/services/passport.js b/services/passport.js
index 130de234d..f24d12165 100644
--- a/services/passport.js
+++ b/services/passport.js
@@ -1,6 +1,6 @@
const passport = require('passport');
-const User = require('../models/user');
-const Setting = require('../models/setting');
+const UsersService = require('./users');
+const SettingsService = require('./settings');
const LocalStrategy = require('passport-local').Strategy;
const FacebookStrategy = require('passport-facebook').Strategy;
@@ -13,7 +13,7 @@ passport.serializeUser((user, done) => {
});
passport.deserializeUser((id, done) => {
- User
+ UsersService
.findById(id)
.then((user) => {
done(null, user);
@@ -43,7 +43,7 @@ function ValidateUserLogin(loginProfile, user, done) {
}
// The user is a local user, check if we need email confirmation.
- return Setting.retrieve().then(({requireEmailConfirmation = false}) => {
+ return SettingsService.retrieve().then(({requireEmailConfirmation = false}) => {
// If we have the requirement of checking that emails for users are
// verified, then we need to check the email address to ensure that it has
@@ -77,7 +77,7 @@ passport.use(new LocalStrategy({
usernameField: 'email',
passwordField: 'password'
}, (email, password, done) => {
- User
+ UsersService
.findLocalUser(email, password)
.then((user) => {
if (!user) {
@@ -103,7 +103,7 @@ if (process.env.TALK_FACEBOOK_APP_ID && process.env.TALK_FACEBOOK_APP_SECRET &&
callbackURL: `${process.env.TALK_ROOT_URL}/api/v1/auth/facebook/callback`,
profileFields: ['id', 'displayName', 'picture.type(large)']
}, (accessToken, refreshToken, profile, done) => {
- User
+ UsersService
.findOrCreateExternalUser(profile)
.then((user) => {
return ValidateUserLogin(profile, user, done);
diff --git a/services/scraper.js b/services/scraper.js
index 6a3260757..aa56bf61b 100644
--- a/services/scraper.js
+++ b/services/scraper.js
@@ -1,6 +1,7 @@
const kue = require('./kue');
const debug = require('debug')('talk:services:scraper');
-const Asset = require('../models/asset');
+const AssetModel = require('../models/asset');
+const AssetsService = require('./assets');
const metascraper = require('metascraper');
@@ -49,7 +50,7 @@ const scraper = {
* Updates an Asset based on scraped asset metadata.
*/
update(id, meta) {
- return Asset.update({id}, {
+ return AssetModel.update({id}, {
$set: {
title: meta.title || '',
description: meta.description || '',
@@ -74,7 +75,7 @@ const scraper = {
debug(`Starting on Job[${job.id}] for Asset[${job.data.asset_id}]`);
- Asset
+ AssetsService
// Find the asset, or complain that it doesn't exist.
.findById(job.data.asset_id)
diff --git a/services/settings.js b/services/settings.js
new file mode 100644
index 000000000..d82fb91a5
--- /dev/null
+++ b/services/settings.js
@@ -0,0 +1,49 @@
+const SettingModel = require('../models/setting');
+
+/**
+ * The selector used to uniquely identify the settings document.
+ */
+const selector = {id: '1'};
+
+/**
+ * The Setting Service object exposing the Setting model.
+ */
+module.exports = class SettingsService {
+
+ /**
+ * Gets the entire settings record and sends it back
+ * @return {Promise} settings the whole settings record
+ */
+ static retrieve() {
+ return SettingModel.findOne(selector);
+ }
+
+ /**
+ * This will update the settings object with whatever you pass in
+ * @param {object} setting a hash of whatever settings you want to update
+ * @return {Promise} settings Promise that resolves to the entire (updated) settings object.
+ */
+ static update(settings) {
+ return SettingModel.findOneAndUpdate(selector, {
+ $set: settings
+ }, {
+ upsert: true,
+ new: true,
+ setDefaultsOnInsert: true
+ });
+ }
+
+ /**
+ * This is run once when the app starts to ensure settings are populated.
+ * @return {Promise} null initialize the global settings object
+ */
+ static init(defaults) {
+
+ // Inject the defaults on top of the passed in defaults to ensure that the new
+ // settings conform to the required selector.
+ defaults = Object.assign({}, defaults, selector);
+
+ // Actually update the settings collection.
+ return SettingsService.update(defaults);
+ }
+};
diff --git a/services/users.js b/services/users.js
new file mode 100644
index 000000000..7a888ede1
--- /dev/null
+++ b/services/users.js
@@ -0,0 +1,609 @@
+const bcrypt = require('bcrypt');
+const jwt = require('jsonwebtoken');
+const Wordlist = require('./wordlist');
+const errors = require('../errors');
+const uuid = require('uuid');
+
+const UserModel = require('../models/user');
+const USER_STATUS = require('../models/user').USER_STATUS;
+const USER_ROLES = require('../models/user').USER_ROLES;
+
+const ActionsService = require('./actions');
+
+// In the event that the TALK_SESSION_SECRET is missing but we are testing, then
+// set the process.env.TALK_SESSION_SECRET.
+if (process.env.NODE_ENV === 'test' && !process.env.TALK_SESSION_SECRET) {
+ process.env.TALK_SESSION_SECRET = 'keyboard cat';
+} else if (!process.env.TALK_SESSION_SECRET) {
+ throw new Error('TALK_SESSION_SECRET must be defined to encode JSON Web Tokens and other auth functionality');
+}
+
+const EMAIL_CONFIRM_JWT_SUBJECT = 'email_confirm';
+const PASSWORD_RESET_JWT_SUBJECT = 'password_reset';
+
+// SALT_ROUNDS is the number of rounds that the bcrypt algorithm will run
+// through during the salting process.
+const SALT_ROUNDS = 10;
+
+// UsersService is the interface for the application to interact with the
+// UserModel through.
+module.exports = class UsersService {
+
+ /**
+ * Finds a user given their email address that we have for them in the system
+ * and ensures that the retuned user matches the password passed in as well.
+ * @param {string} email - email to look up the user by
+ * @param {string} password - password to match against the found user
+ * @param {Function} done [description]
+ */
+ static findLocalUser(email, password) {
+ if (!email || typeof email !== 'string') {
+ return Promise.reject('email is required for findLocalUser');
+ }
+
+ return UserModel.findOne({
+ profiles: {
+ $elemMatch: {
+ id: email.toLowerCase(),
+ provider: 'local'
+ }
+ }
+ })
+ .then((user) => {
+ if (!user) {
+ return false;
+ }
+
+ return new Promise((resolve, reject) => {
+ bcrypt.compare(password, user.password, (err, res) => {
+ if (err) {
+ return reject(err);
+ }
+
+ if (!res) {
+ return resolve(false);
+ }
+
+ return resolve(user);
+ });
+ });
+ });
+ }
+
+ /**
+ * Merges two users together by taking all the profiles on a given user and
+ * pushing them into the source user followed by deleting the destination user's
+ * user account. This will not merge the roles associated with the source user.
+ * @param {String} dstUserID id of the user to which is the target of the merge
+ * @param {String} srcUserID id of the user to which is the source of the merge
+ * @return {Promise} resolves when the users are merged
+ */
+ static mergeUsers(dstUserID, srcUserID) {
+ let srcUser, dstUser;
+
+ return Promise
+ .all([
+ UserModel.findOne({id: dstUserID}).exec(),
+ UserModel.findOne({id: srcUserID}).exec()
+ ])
+ .then((users) => {
+ dstUser = users[0];
+ srcUser = users[1];
+
+ srcUser.profiles.forEach((profile) => {
+ dstUser.profiles.push(profile);
+ });
+
+ return srcUser.remove();
+ })
+ .then(() => dstUser.save());
+ }
+
+ /**
+ * Finds a user given a social profile and if the user does not exist, creates
+ * them.
+ * @param {Object} profile - User social/external profile
+ * @param {Function} done [description]
+ */
+ static findOrCreateExternalUser(profile) {
+ return UserModel
+ .findOne({
+ profiles: {
+ $elemMatch: {
+ id: profile.id,
+ provider: profile.provider
+ }
+ }
+ })
+ .then((user) => {
+ if (user) {
+ return user;
+ }
+
+ // The user was not found, lets create them!
+ user = new UserModel({
+ displayName: profile.displayName,
+ roles: [],
+ profiles: [
+ {
+ id: profile.id,
+ provider: profile.provider
+ }
+ ]
+ });
+
+ return user.save();
+ });
+ }
+
+ static changePassword(id, password) {
+ return new Promise((resolve, reject) => {
+ bcrypt.hash(password, SALT_ROUNDS, (err, hashedPassword) => {
+ if (err) {
+ return reject(err);
+ }
+
+ resolve(hashedPassword);
+ });
+ })
+ .then((hashedPassword) => {
+ return UserModel.update({id}, {
+ $inc: {__v: 1},
+ $set: {
+ password: hashedPassword
+ }
+ });
+ });
+ }
+
+ /**
+ * Creates local users.
+ * @param {Array} users Users to create
+ * @return {Promise} Resolves with the users that were created
+ */
+ static createLocalUsers(users) {
+ return Promise.all(users.map((user) => {
+ return UsersService
+ .createLocalUser(user.email, user.password, user.displayName);
+ }));
+ }
+
+ /**
+ * Check the requested displayname for naughty words (currently in English) and special chars
+ * @param {String} displayName word to be checked for profanity
+ * @return {Promise} rejected if the machine's sensibilites are offended
+ */
+ static isValidDisplayName(displayName) {
+ const onlyLettersNumbersUnderscore = /^[a-z0-9_]+$/;
+
+ if (!displayName) {
+ return Promise.reject(errors.ErrMissingDisplay);
+ }
+
+ if (!onlyLettersNumbersUnderscore.test(displayName)) {
+
+ return Promise.reject(errors.ErrSpecialChars);
+ }
+
+ // check for profanity
+ return Wordlist.displayNameCheck(displayName);
+ }
+
+ /**
+ * Creates the local user with a given email, password, and name.
+ * @param {String} email email of the new user
+ * @param {String} password plaintext password of the new user
+ * @param {String} displayName name of the display user
+ * @param {Function} done callback
+ */
+ static createLocalUser(email, password, displayName) {
+
+ if (!email) {
+ return Promise.reject(errors.ErrMissingEmail);
+ }
+
+ email = email.toLowerCase().trim();
+ displayName = displayName.toLowerCase().trim();
+
+ if (!password) {
+ return Promise.reject(errors.ErrMissingPassword);
+ }
+
+ if (password.length < 8) {
+ return Promise.reject(errors.ErrPasswordTooShort);
+ }
+
+ return UsersService.isValidDisplayName(displayName)
+ .then(() => { // displayName is valid
+ return new Promise((resolve, reject) => {
+ bcrypt.hash(password, SALT_ROUNDS, (err, hashedPassword) => {
+ if (err) {
+ return reject(err);
+ }
+
+ let user = new UserModel({
+ displayName: displayName,
+ password: hashedPassword,
+ roles: [],
+ profiles: [
+ {
+ id: email,
+ provider: 'local'
+ }
+ ]
+ });
+
+ user.save((err) => {
+ if (err) {
+ if (err.code === 11000) {
+ if (err.message.match('displayName')) {
+ return reject(errors.ErrDisplayTaken);
+ }
+ return reject(errors.ErrEmailTaken);
+ }
+ return reject(err);
+ }
+ return resolve(user);
+ });
+ });
+ });
+ });
+ }
+
+ /**
+ * Disables a given user account.
+ * @param {String} id id of a user
+ * @param {Function} done callback after the operation is complete
+ */
+ static disableUser(id) {
+ return UserModel.update({
+ id: id
+ }, {
+ $set: {
+ disabled: true
+ }
+ });
+ }
+
+ /**
+ * Enables a given user account.
+ * @param {String} id id of a user
+ * @param {Function} done callback after the operation is complete
+ */
+ static enableUser(id) {
+ return UserModel.update({
+ id: id
+ }, {
+ $set: {
+ disabled: false
+ }
+ });
+ }
+
+ /**
+ * Adds a role to a user.
+ * @param {String} id id of a user
+ * @param {String} role role to add
+ * @param {Function} done callback after the operation is complete
+ */
+ static addRoleToUser(id, role) {
+
+ // Check to see if the user role is in the allowable set of roles.
+ if (USER_ROLES.indexOf(role) === -1) {
+
+ // User role is not supported! Error out here.
+ return Promise.reject(new Error(`role ${role} is not supported`));
+ }
+
+ return UserModel.update({
+ id: id
+ }, {
+ $addToSet: {
+ roles: role
+ }
+ });
+ }
+
+ /**
+ * Removes a role from a user.
+ * @param {String} id id of a user
+ * @param {String} role role to remove
+ * @param {Function} done callback after the operation is complete
+ */
+ static removeRoleFromUser(id, role) {
+
+ // Check to see if the user role is in the allowable set of roles.
+ if (USER_ROLES.indexOf(role) === -1) {
+
+ // User role is not supported! Error out here.
+ return Promise.reject(new Error(`role ${role} is not supported`));
+ }
+
+ return UserModel.update({
+ id: id
+ }, {
+ $pull: {
+ roles: role
+ }
+ });
+ }
+
+ /**
+ * Set status of a user.
+ * @param {String} id id of a user
+ * @param {String} status status to set
+ * @param {Function} done callback after the operation is complete
+ */
+ static setStatus(id, status) {
+
+ // Check to see if the user status is in the allowable set of roles.
+ if (USER_STATUS.indexOf(status) === -1) {
+
+ // User status is not supported! Error out here.
+ return Promise.reject(new Error(`status ${status} is not supported`));
+ }
+
+ return UserModel.update({id}, {$set: {status}});
+ }
+
+ /**
+ * Finds a user with the id.
+ * @param {String} id user id (uuid)
+ */
+ static findById(id) {
+ return UserModel.findOne({id});
+ }
+
+ /**
+ * Finds users in an array of ids.
+ * @param {Array} ids array of user identifiers (uuid)
+ */
+ static findByIdArray(ids) {
+ return UserModel.find({
+ id: {$in: ids}
+ });
+ }
+
+ /**
+ * Finds public user information by an array of ids.
+ * @param {Array} ids array of user identifiers (uuid)
+ */
+ static findPublicByIdArray(ids) {
+ return UserModel.find({
+ id: {$in: ids}
+ }, 'id displayName');
+ }
+
+ /**
+ * Creates a JWT from a user email. Only works for local accounts.
+ * @param {String} email of the local user
+ */
+ static createPasswordResetToken(email) {
+ if (!email || typeof email !== 'string') {
+ return Promise.reject('email is required when creating a JWT for resetting passord');
+ }
+
+ email = email.toLowerCase();
+
+ return UserModel.findOne({profiles: {$elemMatch: {id: email}}})
+ .then((user) => {
+ if (!user) {
+
+ // Since we don't want to reveal that the email does/doesn't exist
+ // just go ahead and resolve the Promise with null and check in the
+ // endpoint.
+ return;
+ }
+
+ const payload = {
+ jti: uuid.v4(),
+ email,
+ userId: user.id,
+ version: user.__v
+ };
+
+ return jwt.sign(payload, process.env.TALK_SESSION_SECRET, {
+ algorithm: 'HS256',
+ expiresIn: '1d',
+ subject: PASSWORD_RESET_JWT_SUBJECT
+ });
+ });
+ }
+
+ /**
+ * Verifies that the token was indeed signed by the session secret.
+ * @param {String} token JWT token from the client
+ * @return {Promise}
+ */
+ static verifyToken(token, options = {}) {
+ return new Promise((resolve, reject) => {
+
+ // Set the allowed algorithms.
+ options.algorithms = ['HS256'];
+
+ jwt.verify(token, process.env.TALK_SESSION_SECRET, options, (err, decoded) => {
+ if (err) {
+ return reject(err);
+ }
+
+ resolve(decoded);
+ });
+ });
+ }
+
+ /**
+ * Verifies a jwt and returns the associated user.
+ * @param {String} token the JSON Web Token to verify
+ */
+ static verifyPasswordResetToken(token) {
+ return UsersService
+ .verifyToken(token, {
+ subject: PASSWORD_RESET_JWT_SUBJECT
+ })
+ .then((decoded) => UsersService.findById(decoded.userId));
+ }
+
+ /**
+ * Finds a user using a value which gets compared using a prefix match against
+ * the user's email address and/or their display name.
+ * @param {String} value value to search by
+ * @return {Promise}
+ */
+ static search(value) {
+ return UserModel.find({
+ $or: [
+
+ // Search by a prefix match on the displayName.
+ {
+ 'displayName': {
+ $regex: new RegExp(`^${value}`),
+ $options: 'i'
+ }
+ },
+
+ // Search by a prefix match on the email address.
+ {
+ 'profiles': {
+ $elemMatch: {
+ id: {
+ $regex: new RegExp(`^${value}`),
+ $options: 'i'
+ },
+ provider: 'local'
+ }
+ }
+ }
+ ]
+ });
+ }
+
+ /**
+ * Returns a count of the current users.
+ * @return {Promise}
+ */
+ static count() {
+ return UserModel.count();
+ }
+
+ /**
+ * Returns all the users.
+ * @return {Promise}
+ */
+ static all() {
+ return UserModel.find();
+ }
+
+ /**
+ * Updates the user's settings.
+ * @return {Promise}
+ */
+ static updateSettings(id, settings) {
+ return UserModel.update({
+ id
+ }, {
+ $set: {
+ settings
+ }
+ });
+ }
+
+ /**
+ * Add an action to the user.
+ * @param {String} item_id identifier of the user (uuid)
+ * @param {String} user_id user id of the action (uuid)
+ * @param {String} action the new action to the user
+ * @return {Promise}
+ */
+ static addAction(item_id, user_id, action_type, metadata) {
+ return ActionsService.insertUserAction({
+ item_id,
+ item_type: 'users',
+ user_id,
+ action_type,
+ metadata
+ });
+ }
+
+ /**
+ * This creates a token based around confirming the local profile.
+ * @param {String} userID The user id for the user that we are creating the
+ * token for.
+ * @param {String} email The email that we are needing to get confirmed.
+ * @return {Promise}
+ */
+ static createEmailConfirmToken(userID, email) {
+ if (!email || typeof email !== 'string') {
+ return Promise.reject('email is required when creating a JWT for resetting passord');
+ }
+
+ email = email.toLowerCase();
+
+ return UsersService
+ .findById(userID)
+ .then((user) => {
+ if (!user) {
+ return Promise.reject(new Error('user not found'));
+ }
+
+ // Get the profile representing the local account.
+ let profile = user.profiles.find((profile) => profile.id === email && profile.provider === 'local');
+
+ // Ensure that the user email hasn't already been verified.
+ if (profile && profile.metadata && profile.metadata.confirmed_at) {
+ return Promise.reject(new Error('email address already confirmed'));
+ }
+
+ const payload = {
+ email,
+ userID
+ };
+
+ return jwt.sign(payload, process.env.TALK_SESSION_SECRET, {
+ jwtid: uuid.v4(),
+ algorithm: 'HS256',
+ expiresIn: '1d',
+ subject: EMAIL_CONFIRM_JWT_SUBJECT
+ });
+ });
+ }
+
+ /**
+ * This verifies that a given token was for the email confirmation and updates
+ * that user's profile with a 'confirmed_at' parameter with the current date.
+ * @param {String} token the token containing the email confirmation details
+ * signed with our secret.
+ * @return {Promise}
+ */
+ static verifyEmailConfirmation(token) {
+ return UsersService
+ .verifyToken(token, {
+ subject: EMAIL_CONFIRM_JWT_SUBJECT
+ })
+ .then(({userID, email}) => {
+
+ return UserModel
+ .update({
+ id: userID,
+ profiles: {
+ $elemMatch: {
+ id: email,
+ provider: 'local'
+ }
+ }
+ }, {
+ $set: {
+ 'profiles.$.metadata.confirmed_at': new Date()
+ }
+ });
+ });
+ }
+
+ /**
+ * Returns all users with pending 'ADMIN'ation actions.
+ * @return {Promise}
+ */
+ static moderationQueue() {
+ return UserModel.find({status: 'PENDING'});
+ }
+
+};
diff --git a/services/wordlist.js b/services/wordlist.js
index e912abc43..a06801e87 100644
--- a/services/wordlist.js
+++ b/services/wordlist.js
@@ -2,7 +2,7 @@ const debug = require('debug')('talk:services:wordlist');
const _ = require('lodash');
const natural = require('natural');
const tokenizer = new natural.WordTokenizer();
-const Setting = require('../models/setting');
+const SettingsService = require('./settings');
const Errors = require('../errors');
/**
@@ -22,7 +22,7 @@ class Wordlist {
* Loads wordlists in from the database
*/
load() {
- return Setting
+ return SettingsService
.retrieve()
.then((settings) => {
@@ -118,6 +118,42 @@ class Wordlist {
});
}
+ /**
+ * Scans a specific field for wordlist violations.
+ */
+ scan(fieldName, phrase) {
+ let errors = {};
+
+ // If the field doesn't exist in the body, then it can't be profane!
+ if (!phrase) {
+
+ // Return that there wasn't a profane word here.
+ return errors;
+ }
+
+ // Check if the field contains a banned word.
+ if (this.match(this.lists.banned, phrase)) {
+ debug(`the field "${fieldName}" contained a phrase "${phrase}" which contained a banned word/phrase`);
+
+ errors.banned = Errors.ErrContainsProfanity;
+
+ // Stop looping through the fields now, we discovered the worst possible
+ // situation (a banned word).
+ return errors;
+ }
+
+ // Check if the field contains a banned word.
+ if (this.match(this.lists.suspect, phrase)) {
+ debug(`the field "${fieldName}" contained a phrase "${phrase}" which contained a suspected word/phrase`);
+
+ errors.suspect = Errors.ErrContainsProfanity;
+
+ // Continue looping through the fields now, we discovered a possible bad
+ // word (suspect).
+ return errors;
+ }
+ }
+
/**
* Perform the filtering based on the loaded wordlists.
*/
@@ -129,9 +165,9 @@ class Wordlist {
// Loop over all the fields from the body that we want to check.
for (let i = 0; i < fields.length; i++) {
- let field = fields[i];
+ let fieldName = fields[i];
- let phrase = _.get(body, field, false);
+ let phrase = _.get(body, fieldName, false);
// If the field doesn't exist in the body, then it can't be profane!
if (!phrase) {
@@ -140,11 +176,10 @@ class Wordlist {
continue;
}
- // Check if the field contains a banned word.
- if (this.match(this.lists.banned, phrase)) {
- debug(`the field "${field}" contained a phrase "${phrase}" which contained a banned word/phrase`);
+ errors = Object.assign(errors, this.scan(fieldName, phrase));
- errors.banned = Errors.ErrContainsProfanity;
+ // Check if the field contains a banned word.
+ if (errors.banned) {
// Stop looping through the fields now, we discovered the worst possible
// situation (a banned word).
@@ -152,10 +187,7 @@ class Wordlist {
}
// Check if the field contains a banned word.
- if (this.match(this.lists.suspect, phrase)) {
- debug(`the field "${field}" contained a phrase "${phrase}" which contained a suspected word/phrase`);
-
- errors.suspect = Errors.ErrContainsProfanity;
+ if (errors.suspect) {
// Continue looping through the fields now, we discovered a possible bad
// word (suspect).
diff --git a/tests/.eslintrc.json b/test/.eslintrc.json
similarity index 100%
rename from tests/.eslintrc.json
rename to test/.eslintrc.json
diff --git a/tests/client/.eslintrc.json b/test/client/.eslintrc.json
similarity index 100%
rename from tests/client/.eslintrc.json
rename to test/client/.eslintrc.json
diff --git a/tests/client/coral-admin/actions/assets.js b/test/client/coral-admin/actions/assets.js
similarity index 100%
rename from tests/client/coral-admin/actions/assets.js
rename to test/client/coral-admin/actions/assets.js
diff --git a/tests/client/coral-admin/reducers/assets.js b/test/client/coral-admin/reducers/assets.js
similarity index 100%
rename from tests/client/coral-admin/reducers/assets.js
rename to test/client/coral-admin/reducers/assets.js
diff --git a/tests/client/coral-framework/store/itemActions.js b/test/client/coral-framework/store/itemActions.js
similarity index 100%
rename from tests/client/coral-framework/store/itemActions.js
rename to test/client/coral-framework/store/itemActions.js
diff --git a/tests/client/coral-framework/store/itemReducer.js b/test/client/coral-framework/store/itemReducer.js
similarity index 100%
rename from tests/client/coral-framework/store/itemReducer.js
rename to test/client/coral-framework/store/itemReducer.js
diff --git a/tests/client/coral-framework/store/notificationReducer.spec.js b/test/client/coral-framework/store/notificationReducer.spec.js
similarity index 100%
rename from tests/client/coral-framework/store/notificationReducer.spec.js
rename to test/client/coral-framework/store/notificationReducer.spec.js
diff --git a/tests/client/coral-plugin-history/Comment.spec.js b/test/client/coral-plugin-history/Comment.spec.js
similarity index 85%
rename from tests/client/coral-plugin-history/Comment.spec.js
rename to test/client/coral-plugin-history/Comment.spec.js
index 78ad104bf..6d69d54cd 100644
--- a/tests/client/coral-plugin-history/Comment.spec.js
+++ b/test/client/coral-plugin-history/Comment.spec.js
@@ -9,20 +9,19 @@ describe('coral-plugin-history/Comment', () => {
const asset = {url: 'https://google.com'};
beforeEach(() => {
- render = shallow( );
+ render = shallow({}}/>);
});
it('should render the provided comment body', () => {
- const wrapper = mount( );
+ const wrapper = mount({}}/>);
expect(wrapper.find('.myCommentBody')).to.have.length(1);
expect(wrapper.find('.myCommentBody').text()).to.equal('this is a comment');
});
it('should render the asset url as a link', () => {
- const wrapper = mount( );
+ const wrapper = mount({}}/>);
expect(wrapper.find('.myCommentAnchor')).to.have.length(1);
expect(wrapper.find('.myCommentAnchor').text()).to.equal('https://google.com');
- expect(wrapper.find('.myCommentAnchor').props().href).to.equal('https://google.com#123');
});
it('should render the comment with styles', () => {
diff --git a/tests/client/coral-plugin-history/CommentHistory.spec.js b/test/client/coral-plugin-history/CommentHistory.spec.js
similarity index 98%
rename from tests/client/coral-plugin-history/CommentHistory.spec.js
rename to test/client/coral-plugin-history/CommentHistory.spec.js
index 3b6f5ee47..9d81f79cd 100644
--- a/tests/client/coral-plugin-history/CommentHistory.spec.js
+++ b/test/client/coral-plugin-history/CommentHistory.spec.js
@@ -9,11 +9,11 @@ describe('coral-plugin-history/CommentHistory', () => {
const assets = [{'settings': null, 'created_at':'2016-12-06T21:36:09.302Z', 'url':'localhost:3000/', 'scraped':null, 'status':'open', 'updated_at':'2016-12-08T02:11:15.943Z', '_id':'58472f499e775a38f23d5da0', 'type':'article', 'closedMessage':null, 'id':'7302e637-f884-47c0-9723-02cc10a18617', 'closedAt':null}, {'settings':null, 'created_at':'2016-12-07T02:25:31.983Z', 'url':'http://localhost:3000/', 'scraped':null, 'status':'open', 'updated_at':'2016-12-13T22:58:36.061Z', '_id':'5847731b9e775a38f23d5da1', 'type':'article', 'closedMessage':null, 'id':'96fddf96-7c83-4008-80ad-50091997d006', 'closedAt':null}, {'settings':null, 'created_at':'2016-12-12T19:04:05.770Z', 'url':'http://localhost:3000/embed/stream', 'scraped':null, 'updated_at':'2016-12-14T20:13:21.934Z', '_id':'584ef4a59e775a38f23d5e86', 'type':'article', 'closedMessage':null, 'id':'cef81015-1b53-4d70-b9af-6eca680f22fc', 'closedAt':null}];
beforeEach(() => {
- render = shallow( );
+ render = shallow({}}/>);
});
it('should render Comments as children when given comments and assets', () => {
- const wrapper = mount( );
+ const wrapper = mount({}}/>);
expect(wrapper.find('.commentHistory__list').children()).to.have.length(7);
});
@@ -21,4 +21,3 @@ describe('coral-plugin-history/CommentHistory', () => {
expect(render.props().style).to.be.defined;
});
});
-
diff --git a/tests/e2e/globals.js b/test/e2e/globals.js
similarity index 100%
rename from tests/e2e/globals.js
rename to test/e2e/globals.js
diff --git a/tests/e2e/mocks.js b/test/e2e/mocks.js
similarity index 100%
rename from tests/e2e/mocks.js
rename to test/e2e/mocks.js
diff --git a/tests/e2e/pages/adminPage.js b/test/e2e/pages/adminPage.js
similarity index 100%
rename from tests/e2e/pages/adminPage.js
rename to test/e2e/pages/adminPage.js
diff --git a/tests/e2e/pages/embedStreamPage.js b/test/e2e/pages/embedStreamPage.js
similarity index 100%
rename from tests/e2e/pages/embedStreamPage.js
rename to test/e2e/pages/embedStreamPage.js
diff --git a/tests/e2e/tests/00_AppTest.js b/test/e2e/tests/00_AppTest.js
similarity index 100%
rename from tests/e2e/tests/00_AppTest.js
rename to test/e2e/tests/00_AppTest.js
diff --git a/tests/e2e/tests/01_EmbedStreamTest.js b/test/e2e/tests/01_EmbedStreamTest.js
similarity index 100%
rename from tests/e2e/tests/01_EmbedStreamTest.js
rename to test/e2e/tests/01_EmbedStreamTest.js
diff --git a/tests/e2e/tests/Admin/LoginTest.js b/test/e2e/tests/Admin/LoginTest.js
similarity index 93%
rename from tests/e2e/tests/Admin/LoginTest.js
rename to test/e2e/tests/Admin/LoginTest.js
index e75d29903..9babdf29b 100644
--- a/tests/e2e/tests/Admin/LoginTest.js
+++ b/test/e2e/tests/Admin/LoginTest.js
@@ -1,5 +1,5 @@
module.exports = {
- '@tags': ['login', 'admin'],
+ '@tags': ['login', 'ADMIN'],
before(client) {
const embedStreamPage = client.page.embedStreamPage();
const {launchUrl} = client;
diff --git a/tests/e2e/tests/Commenter/FlagCommentTest.js b/test/e2e/tests/Commenter/FlagCommentTest.js
similarity index 100%
rename from tests/e2e/tests/Commenter/FlagCommentTest.js
rename to test/e2e/tests/Commenter/FlagCommentTest.js
diff --git a/tests/e2e/tests/Commenter/FlagUsernameTest.js b/test/e2e/tests/Commenter/FlagUsernameTest.js
similarity index 100%
rename from tests/e2e/tests/Commenter/FlagUsernameTest.js
rename to test/e2e/tests/Commenter/FlagUsernameTest.js
diff --git a/tests/e2e/tests/Commenter/LikeCommentTest.js b/test/e2e/tests/Commenter/LikeCommentTest.js
similarity index 100%
rename from tests/e2e/tests/Commenter/LikeCommentTest.js
rename to test/e2e/tests/Commenter/LikeCommentTest.js
diff --git a/tests/e2e/tests/Commenter/LoginTest.js b/test/e2e/tests/Commenter/LoginTest.js
similarity index 100%
rename from tests/e2e/tests/Commenter/LoginTest.js
rename to test/e2e/tests/Commenter/LoginTest.js
diff --git a/tests/e2e/tests/Commenter/PermalinkTest.js b/test/e2e/tests/Commenter/PermalinkTest.js
similarity index 100%
rename from tests/e2e/tests/Commenter/PermalinkTest.js
rename to test/e2e/tests/Commenter/PermalinkTest.js
diff --git a/tests/e2e/tests/Commenter/PostComment.js b/test/e2e/tests/Commenter/PostComment.js
similarity index 100%
rename from tests/e2e/tests/Commenter/PostComment.js
rename to test/e2e/tests/Commenter/PostComment.js
diff --git a/tests/e2e/tests/EmbedStreamTests.js b/test/e2e/tests/EmbedStreamTests.js
similarity index 96%
rename from tests/e2e/tests/EmbedStreamTests.js
rename to test/e2e/tests/EmbedStreamTests.js
index d572c2695..70b4c6ad0 100644
--- a/tests/e2e/tests/EmbedStreamTests.js
+++ b/test/e2e/tests/EmbedStreamTests.js
@@ -20,7 +20,7 @@ module.exports = {
},
'User registers and posts a comment with premod off': client => {
client.perform((client, done) => {
- mocks.settings({moderation: 'post'})
+ mocks.settings({moderation: 'POST'})
.then(() => {
// Load Page
@@ -61,7 +61,7 @@ module.exports = {
},
'User posts a comment with premod on': client => {
client.perform((client, done) => {
- mocks.settings({moderation: 'pre'})
+ mocks.settings({moderation: 'PRE'})
.then(() => {
// Load Page
@@ -86,7 +86,7 @@ module.exports = {
},
'User replies to a comment with premod off': client => {
client.perform((client, done) => {
- mocks.settings({moderation: 'post'})
+ mocks.settings({moderation: 'POST'})
.then(() => {
// Load Page
@@ -119,7 +119,7 @@ module.exports = {
},
'User replies to a comment with premod on': client => {
client.perform((client, done) => {
- mocks.settings({moderation: 'pre'})
+ mocks.settings({moderation: 'PRE'})
// Add a mock user
.then(() => {
diff --git a/tests/e2e/tests/Moderator/LoginTest.js b/test/e2e/tests/Moderator/LoginTest.js
similarity index 92%
rename from tests/e2e/tests/Moderator/LoginTest.js
rename to test/e2e/tests/Moderator/LoginTest.js
index 3f1754a50..d04baabac 100644
--- a/tests/e2e/tests/Moderator/LoginTest.js
+++ b/test/e2e/tests/Moderator/LoginTest.js
@@ -1,5 +1,5 @@
module.exports = {
- '@tags': ['login', 'moderator'],
+ '@tags': ['login', 'MODERATOR'],
before: client => {
const embedStreamPage = client.page.embedStreamPage();
const {launchUrl} = client;
diff --git a/tests/e2e/tests/Visitor/FlagCommentTest.js b/test/e2e/tests/Visitor/FlagCommentTest.js
similarity index 100%
rename from tests/e2e/tests/Visitor/FlagCommentTest.js
rename to test/e2e/tests/Visitor/FlagCommentTest.js
diff --git a/tests/e2e/tests/Visitor/LikeCommentTest.js b/test/e2e/tests/Visitor/LikeCommentTest.js
similarity index 100%
rename from tests/e2e/tests/Visitor/LikeCommentTest.js
rename to test/e2e/tests/Visitor/LikeCommentTest.js
diff --git a/tests/e2e/tests/Visitor/SignUpTest.js b/test/e2e/tests/Visitor/SignUpTest.js
similarity index 100%
rename from tests/e2e/tests/Visitor/SignUpTest.js
rename to test/e2e/tests/Visitor/SignUpTest.js
diff --git a/tests/helpers/browser.js b/test/helpers/browser.js
similarity index 100%
rename from tests/helpers/browser.js
rename to test/helpers/browser.js
diff --git a/tests/helpers/index.test.html b/test/helpers/index.test.html
similarity index 100%
rename from tests/helpers/index.test.html
rename to test/helpers/index.test.html
diff --git a/tests/helpers/mongoose.js b/test/helpers/mongoose.js
similarity index 100%
rename from tests/helpers/mongoose.js
rename to test/helpers/mongoose.js
diff --git a/tests/kue.js b/test/kue.js
similarity index 100%
rename from tests/kue.js
rename to test/kue.js
diff --git a/test/mocha.opts b/test/mocha.opts
new file mode 100644
index 000000000..a159ae7aa
--- /dev/null
+++ b/test/mocha.opts
@@ -0,0 +1,7 @@
+test/helpers/*.js
+test
+--compilers js:babel-core/register
+--require ignore-styles
+--recursive
+--colors
+--sort
diff --git a/tests/mongoose.js b/test/mongoose.js
similarity index 100%
rename from tests/mongoose.js
rename to test/mongoose.js
diff --git a/tests/passport.js b/test/passport.js
similarity index 100%
rename from tests/passport.js
rename to test/passport.js
diff --git a/tests/routes/api/assets/index.js b/test/routes/api/assets/index.js
similarity index 85%
rename from tests/routes/api/assets/index.js
rename to test/routes/api/assets/index.js
index 3354f9671..56c20fc78 100644
--- a/tests/routes/api/assets/index.js
+++ b/test/routes/api/assets/index.js
@@ -8,12 +8,13 @@ const expect = chai.expect;
chai.should();
chai.use(require('chai-http'));
-const Asset = require('../../../../models/asset');
+const AssetModel = require('../../../../models/asset');
+const AssetsService = require('../../../../services/assets');
describe('/api/v1/assets', () => {
beforeEach(() => {
- return Asset.create([
+ return AssetModel.create([
{
url: 'https://coralproject.net/news/asset1',
title: 'Asset 1',
@@ -34,7 +35,7 @@ describe('/api/v1/assets', () => {
it('should return all assets without a search query', () => {
return chai.request(app)
.get('/api/v1/assets')
- .set(passport.inject({roles: ['admin']}))
+ .set(passport.inject({roles: ['ADMIN']}))
.then((res) => {
const body = res.body;
@@ -50,7 +51,7 @@ describe('/api/v1/assets', () => {
it('should return assets that we search for', () => {
return chai.request(app)
.get('/api/v1/assets?search=term2')
- .set(passport.inject({roles: ['admin']}))
+ .set(passport.inject({roles: ['ADMIN']}))
.then((res) => {
const body = res.body;
@@ -71,7 +72,7 @@ describe('/api/v1/assets', () => {
it('should not return assets that we do not search for', () => {
return chai.request(app)
.get('/api/v1/assets?search=term3')
- .set(passport.inject({roles: ['admin']}))
+ .set(passport.inject({roles: ['ADMIN']}))
.then((res) => {
const body = res.body;
@@ -85,7 +86,7 @@ describe('/api/v1/assets', () => {
it('should return only closed assets', () => {
return chai.request(app)
.get('/api/v1/assets?filter=closed')
- .set(passport.inject({roles: ['admin']}))
+ .set(passport.inject({roles: ['ADMIN']}))
.then((res) => {
const body = res.body;
@@ -101,7 +102,7 @@ describe('/api/v1/assets', () => {
it('should return only opened assets', () => {
return chai.request(app)
.get('/api/v1/assets?filter=open')
- .set(passport.inject({roles: ['admin']}))
+ .set(passport.inject({roles: ['ADMIN']}))
.then((res) => {
const body = res.body;
@@ -121,21 +122,21 @@ describe('/api/v1/assets', () => {
const today = Date.now();
- return Asset.findOrCreateByUrl('http://test.com')
+ return AssetsService.findOrCreateByUrl('http://test.com')
.then((asset) => {
expect(asset).to.have.property('isClosed', null);
expect(asset).to.have.property('closedAt', null);
return chai.request(app)
.put(`/api/v1/assets/${asset.id}/status`)
- .set(passport.inject({roles: ['admin']}))
+ .set(passport.inject({roles: ['ADMIN']}))
.send({closedAt: today});
})
.then((res) => {
expect(res).to.have.status(204);
- return Asset.findByUrl('http://test.com');
+ return AssetsService.findByUrl('http://test.com');
})
.then((asset) => {
expect(asset).to.have.property('isClosed', true);
diff --git a/tests/routes/api/auth/index.js b/test/routes/api/auth/index.js
similarity index 84%
rename from tests/routes/api/auth/index.js
rename to test/routes/api/auth/index.js
index 6b39e0c8e..4882d859e 100644
--- a/tests/routes/api/auth/index.js
+++ b/test/routes/api/auth/index.js
@@ -4,7 +4,7 @@ const expect = chai.expect;
chai.use(require('chai-http'));
-const User = require('../../../../models/user');
+const UsersService = require('../../../../services/users');
describe('/api/v1/auth', () => {
describe('#get', () => {
@@ -19,15 +19,15 @@ describe('/api/v1/auth', () => {
});
});
-const Setting = require('../../../../models/setting');
+const SettingsService = require('../../../../services/settings');
describe('/api/v1/auth/local', () => {
let mockUser;
beforeEach(() => {
const settings = {requireEmailConfirmation: false, wordlist: {banned: ['bad'], suspect: ['naughty']}};
- return Setting.init(settings).then(() => {
- return User.createLocalUser('maria@gmail.com', 'password!', 'Maria')
+ return SettingsService.init(settings).then(() => {
+ return UsersService.createLocalUser('maria@gmail.com', 'password!', 'Maria')
.then((user) => {
mockUser = user;
});
@@ -66,7 +66,7 @@ describe('/api/v1/auth/local', () => {
describe('email confirmation enabled', () => {
- beforeEach(() => Setting.init({requireEmailConfirmation: true}));
+ beforeEach(() => SettingsService.init({requireEmailConfirmation: true}));
describe('#post', () => {
it('should not allow a login from a user that is not confirmed', () => {
@@ -76,9 +76,9 @@ describe('/api/v1/auth/local', () => {
.catch((err) => {
err.response.should.have.status(401);
- return User.createEmailConfirmToken(mockUser.id, mockUser.profiles[0].id);
+ return UsersService.createEmailConfirmToken(mockUser.id, mockUser.profiles[0].id);
})
- .then(User.verifyEmailConfirmation)
+ .then(UsersService.verifyEmailConfirmation)
.then(() => {
return chai.request(app)
.post('/api/v1/auth/local')
diff --git a/test/routes/api/comments/index.js b/test/routes/api/comments/index.js
new file mode 100644
index 000000000..c305f9f6b
--- /dev/null
+++ b/test/routes/api/comments/index.js
@@ -0,0 +1,345 @@
+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 = [{
+ displayName: 'Ana',
+ email: 'ana@gmail.com',
+ password: '123456789'
+ }, {
+ displayName: '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 owner’s 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 = [{
+ displayName: 'Ana',
+ email: 'ana@gmail.com',
+ password: '123456789'
+ }, {
+ displayName: '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);
+ });
+ });
+ });
+});
+
+describe('/api/v1/comments/:comment_id/actions', () => {
+
+ const comments = [{
+ id: 'abc',
+ body: 'comment 10',
+ asset_id: 'asset',
+ author_id: '123',
+ status_history: []
+ }, {
+ id: 'def',
+ body: 'comment 20',
+ asset_id: 'asset',
+ author_id: '456',
+ status: 'REJECTED',
+ status_history: [{
+ type: 'REJECTED'
+ }]
+ }, {
+ id: 'hij',
+ body: 'comment 30',
+ asset_id: '456',
+ status: 'ACCEPTED',
+ status_history: [{
+ type: 'ACCEPTED'
+ }]
+ }];
+
+ const users = [{
+ displayName: 'Ana',
+ email: 'ana@gmail.com',
+ password: '123456789'
+ }, {
+ displayName: 'Maria',
+ email: 'maria@gmail.com',
+ password: '123456789'
+ }];
+
+ const actions = [{
+ action_type: 'FLAG',
+ item_type: 'COMMENTS',
+ item_id: 'abc'
+ }, {
+ action_type: 'LIKE',
+ item_type: 'COMMENTS',
+ item_id: 'hij'
+ }];
+
+ beforeEach(() => {
+ return SettingsService.init(settings).then(() => {
+ return Promise.all([
+ CommentModel.create(comments),
+ UsersService.createLocalUsers(users),
+ ActionModel.create(actions)
+ ]);
+ });
+ });
+
+ describe('#post', () => {
+ it('it should create an action', () => {
+ return chai.request(app)
+ .post('/api/v1/comments/abc/actions')
+ .set(passport.inject({id: '456', roles: ['ADMIN']}))
+ .send({'action_type': 'flag', 'metadata': {'reason': 'Comment is too awesome.'}})
+ .then((res) => {
+ expect(res).to.have.status(201);
+ expect(res).to.have.body;
+
+ expect(res.body).to.have.property('action_type', 'flag');
+ expect(res.body).to.have.property('metadata');
+ expect(res.body.metadata).to.deep.equal({'reason': 'Comment is too awesome.'});
+ expect(res.body).to.have.property('item_id', 'abc');
+ });
+ });
+ });
+});
diff --git a/tests/routes/api/queue/index.js b/test/routes/api/queue/index.js
similarity index 75%
rename from tests/routes/api/queue/index.js
rename to test/routes/api/queue/index.js
index 6439fad84..6b760dee4 100644
--- a/tests/routes/api/queue/index.js
+++ b/test/routes/api/queue/index.js
@@ -10,10 +10,10 @@ chai.use(require('chai-http'));
const Comment = require('../../../../models/comment');
const Action = require('../../../../models/action');
-const User = require('../../../../models/user');
+const UsersService = require('../../../../services/users');
-const Setting = require('../../../../models/setting');
-const settings = {id: '1', moderation: 'pre', wordlist: {banned: ['banned'], suspect: ['suspect']}};
+const SettingsService = require('../../../../services/settings');
+const settings = {id: '1', moderation: 'PRE', wordlist: {banned: ['banned'], suspect: ['suspect']}};
describe('/api/v1/queue', () => {
const comments = [{
@@ -21,26 +21,26 @@ describe('/api/v1/queue', () => {
body: 'comment 10',
asset_id: 'asset',
author_id: '123',
- status: 'rejected',
+ status: 'REJECTED',
status_history: [{
- type: 'rejected'
+ type: 'REJECTED'
}]
}, {
id: 'def',
body: 'comment 20',
asset_id: 'asset',
author_id: '456',
- status: 'premod',
+ status: 'PREMOD',
status_history: [{
- type: 'premod'
+ type: 'PREMOD'
}]
}, {
id: 'hij',
body: 'comment 30',
asset_id: '456',
- status: 'accepted',
+ status: 'ACCEPTED',
status_history: [{
- type: 'accepted'
+ type: 'ACCEPTED'
}]
}];
@@ -55,22 +55,22 @@ describe('/api/v1/queue', () => {
}];
const actions = [{
- action_type: 'flag',
+ action_type: 'FLAG',
item_id: 'abc',
- item_type: 'comment'
+ item_type: 'COMMENTS'
}, {
- action_type: 'like',
+ action_type: 'LIKE',
item_id: 'hij',
- item_type: 'comment'
+ item_type: 'COMMENTS'
}, {
- action_type: 'flag',
+ action_type: 'FLAG',
item_id: '123',
- item_type: 'user'
+ item_type: 'USERS'
}];
beforeEach(() => {
- return Setting.init(settings).then(() => {
- return User.createLocalUsers(users)
+ 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;
@@ -79,7 +79,7 @@ describe('/api/v1/queue', () => {
return Promise.all([
Comment.create(comments),
u,
- ...u.map((u) => User.setStatus(u.id, 'pending'))
+ ...u.map((user) => UsersService.setStatus(user.id, 'PENDING'))
]);
})
.then(([c, u]) => {
@@ -89,7 +89,7 @@ describe('/api/v1/queue', () => {
return Promise.all([
Action.create(actions),
- Setting.init(settings)
+ SettingsService.init(settings)
]);
});
});
@@ -98,7 +98,7 @@ describe('/api/v1/queue', () => {
it('should return all the pending comments, users and actions', () => {
return chai.request(app)
.get('/api/v1/queue/comments/pending')
- .set(passport.inject({roles: ['admin']}))
+ .set(passport.inject({roles: ['ADMIN']}))
.then((res) => {
expect(res).to.have.status(200);
expect(res.body.comments).to.have.length(1);
@@ -111,7 +111,7 @@ describe('/api/v1/queue', () => {
it('should return all pending users and actions', function(done){
chai.request(app)
.get('/api/v1/queue/users/pending')
- .set(passport.inject({roles: ['admin']}))
+ .set(passport.inject({roles: ['ADMIN']}))
.end(function(err, res){
expect(err).to.be.null;
expect(res).to.have.status(200);
diff --git a/tests/routes/api/settings/index.js b/test/routes/api/settings/index.js
similarity index 64%
rename from tests/routes/api/settings/index.js
rename to test/routes/api/settings/index.js
index 318af81f4..dfe846edf 100644
--- a/tests/routes/api/settings/index.js
+++ b/test/routes/api/settings/index.js
@@ -7,12 +7,12 @@ const expect = chai.expect;
chai.should();
chai.use(require('chai-http'));
-const Setting = require('../../../../models/setting');
-const defaults = {id: '1', moderation: 'pre'};
+const SettingsService = require('../../../../services/settings');
+const defaults = {id: '1', moderation: 'PRE'};
describe('/api/v1/settings', () => {
- beforeEach(() => Setting.init(defaults));
+ beforeEach(() => SettingsService.init(defaults));
describe('#get', () => {
@@ -20,12 +20,12 @@ describe('/api/v1/settings', () => {
return chai.request(app)
.get('/api/v1/settings')
.set(passport.inject({
- roles: ['admin']
+ roles: ['ADMIN']
}))
.then((res) => {
expect(res).to.have.status(200);
expect(res).to.be.json;
- expect(res.body).to.have.property('moderation', 'pre');
+ expect(res.body).to.have.property('moderation', 'PRE');
});
});
});
@@ -35,15 +35,15 @@ describe('/api/v1/settings', () => {
it('should update the settings', () => {
return chai.request(app)
.put('/api/v1/settings')
- .set(passport.inject({roles: ['admin']}))
- .send({moderation: 'post'})
+ .set(passport.inject({roles: ['ADMIN']}))
+ .send({moderation: 'POST'})
.then((res) => {
expect(res).to.have.status(204);
- return Setting.retrieve();
+ return SettingsService.retrieve();
})
.then((settings) => {
- expect(settings).to.have.property('moderation', 'post');
+ expect(settings).to.have.property('moderation', 'POST');
});
});
});
diff --git a/tests/routes/api/user/index.js b/test/routes/api/user/index.js
similarity index 69%
rename from tests/routes/api/user/index.js
rename to test/routes/api/user/index.js
index 3aa6cbe3c..d1771726d 100644
--- a/tests/routes/api/user/index.js
+++ b/test/routes/api/user/index.js
@@ -5,21 +5,21 @@ const mailer = require('../../../../services/mailer');
const chai = require('chai');
const expect = chai.expect;
-const Setting = require('../../../../models/setting');
-const settings = {id: '1', moderation: 'pre', wordlist: {banned: ['bad words'], suspect: ['suspect words']}};
+const SettingsService = require('../../../../services/settings');
+const settings = {id: '1', moderation: 'PRE', wordlist: {banned: ['bad words'], suspect: ['suspect words']}};
// Setup chai.
chai.should();
chai.use(require('chai-http'));
-const User = require('../../../../models/user');
+const UsersService = require('../../../../services/users');
describe('/api/v1/users/:user_id/email/confirm', () => {
let mockUser;
- beforeEach(() => Setting.init(settings).then(() => {
- return User.createLocalUser('ana@gmail.com', '123321123', 'Ana');
+ beforeEach(() => SettingsService.init(settings).then(() => {
+ return UsersService.createLocalUser('ana@gmail.com', '123321123', 'Ana');
})
.then((user) => {
mockUser = user;
@@ -31,7 +31,7 @@ describe('/api/v1/users/:user_id/email/confirm', () => {
return chai.request(app)
.post(`/api/v1/users/${mockUser.id}/email/confirm`)
- .set(passport.inject({roles: ['admin']}))
+ .set(passport.inject({roles: ['ADMIN']}))
.then((res) => {
expect(res).to.have.status(204);
expect(mailer.task.tasks).to.have.length(1);
@@ -41,7 +41,7 @@ describe('/api/v1/users/:user_id/email/confirm', () => {
it('should send a 404 on not matching a user', () => {
return chai.request(app)
.post(`/api/v1/users/${mockUser.id}/email/confirm`)
- .set(passport.inject({roles: ['admin']}))
+ .set(passport.inject({roles: ['ADMIN']}))
.then((res) => {
expect(res).to.have.status(204);
expect(mailer.task.tasks).to.have.length(1);
@@ -63,8 +63,8 @@ describe('/api/v1/users/:user_id/actions', () => {
}];
beforeEach(() => {
- return Setting.init(settings).then(() => {
- return User.createLocalUsers(users);
+ return SettingsService.init(settings).then(() => {
+ return UsersService.createLocalUsers(users);
});
});
@@ -72,14 +72,12 @@ describe('/api/v1/users/:user_id/actions', () => {
it('it should update actions', () => {
return chai.request(app)
.post('/api/v1/users/abc/actions')
- .set(passport.inject({id: '456', roles: ['admin']}))
- .send({'action_type': 'flag', metadata: {reason: 'Bio is too awesome.'}})
+ .set(passport.inject({id: '456', roles: ['ADMIN']}))
+ .send({'action_type': 'FLAG', metadata: {reason: 'Bio is too awesome.'}})
.then((res) => {
expect(res).to.have.status(201);
expect(res).to.have.body;
- expect(res.body).to.have.property('action_type', 'flag');
- expect(res.body).to.have.property('metadata')
- .and.to.deep.equal({'reason': 'Bio is too awesome.'});
+ expect(res.body).to.have.property('action_type', 'FLAG');
expect(res.body).to.have.property('item_id', 'abc');
});
});
diff --git a/tests/models/action.js b/test/services/actions.js
similarity index 58%
rename from tests/models/action.js
rename to test/services/actions.js
index a1ebdc027..7fdec0a12 100644
--- a/tests/models/action.js
+++ b/test/services/actions.js
@@ -1,30 +1,32 @@
-const Action = require('../../models/action');
+const ActionModel = require('../../models/action');
+const ActionsService = require('../../services/actions');
+
const expect = require('chai').expect;
-describe('models.Action', () => {
+describe('services.ActionsService', () => {
let mockActions = [];
- beforeEach(() => Action.create([
+ beforeEach(() => ActionModel.create([
{
- action_type: 'flag',
+ action_type: 'FLAG',
item_id: '123',
- item_type: 'comment',
+ item_type: 'COMMENTS',
user_id: 'flagginguserid'
},
{
- action_type: 'flag',
+ action_type: 'FLAG',
item_id: '456',
- item_type: 'comment'
+ item_type: 'COMMENTS'
},
{
- action_type: 'flag',
+ action_type: 'FLAG',
item_id: '123',
- item_type: 'comment'
+ item_type: 'COMMENTS'
},
{
- action_type: 'like',
+ action_type: 'LIKE',
item_id: '123',
- item_type: 'comment'
+ item_type: 'COMMENTS'
}
]).then((actions) => {
mockActions = actions;
@@ -32,16 +34,16 @@ describe('models.Action', () => {
describe('#findById()', () => {
it('should find an action by id', () => {
- return Action.findById(mockActions[0].id).then((result) => {
+ return ActionsService.findById(mockActions[0].id).then((result) => {
expect(result).to.not.be.null;
- expect(result).to.have.property('action_type', 'flag');
+ expect(result).to.have.property('action_type', 'FLAG');
});
});
});
describe('#findByItemIdArray()', () => {
it('should find an array of actions from an array of item_ids', () => {
- return Action.findByItemIdArray(['123', '456']).then((result) => {
+ return ActionsService.findByItemIdArray(['123', '456']).then((result) => {
expect(result).to.have.length(4);
});
});
@@ -49,46 +51,48 @@ describe('models.Action', () => {
describe('#getActionSummaries()', () => {
it('should return properly formatted summaries from an array of item_ids', () => {
- return Action.getActionSummaries(['123', '789']).then((summaries) => {
- expect(summaries).to.have.length(2);
+ return ActionsService
+ .getActionSummaries(['123', '789'])
+ .then((summaries) => {
+ expect(summaries).to.have.length(2);
- expect(summaries).to.deep.include({
- action_type: 'like',
- count: 1,
- item_id: '123',
- item_type: 'comment',
- current_user: null
- });
+ expect(summaries).to.deep.include({
+ action_type: 'LIKE',
+ count: 1,
+ item_id: '123',
+ item_type: 'COMMENTS',
+ current_user: null
+ });
- expect(summaries).to.deep.include({
- action_type: 'flag',
- count: 2,
- item_id: '123',
- item_type: 'comment',
- current_user: null
+ expect(summaries).to.deep.include({
+ action_type: 'FLAG',
+ count: 2,
+ item_id: '123',
+ item_type: 'COMMENTS',
+ current_user: null
+ });
});
- });
});
it('should include a current user when one is passed', () => {
- return Action
+ return ActionsService
.getActionSummaries(['123'], 'flagginguserid')
.then((summaries) => {
expect(summaries).to.have.length(2);
- let summary = summaries.find((s) => s.item_id === '123' && s.action_type === 'flag');
+ let summary = summaries.find((s) => s.item_id === '123' && s.action_type === 'FLAG');
expect(summary).to.not.be.undefined;
expect(summary.current_user).to.not.be.null;
expect(summary.current_user).to.have.property('item_id', '123');
- expect(summary.current_user).to.have.property('item_type', 'comment');
+ expect(summary.current_user).to.have.property('item_type', 'COMMENTS');
expect(summary.current_user).to.have.property('user_id', 'flagginguserid');
- expect(summary.current_user).to.have.property('action_type', 'flag');
+ expect(summary.current_user).to.have.property('action_type', 'FLAG');
});
});
it('should not include a current user when one is passed for a user that doesn\'t have an action', () => {
- return Action
+ return ActionsService
.getActionSummaries(['123'], 'flagginguserid2')
.then((summaries) => {
expect(summaries).to.have.length(2);
diff --git a/tests/models/asset.js b/test/services/assets.js
similarity index 68%
rename from tests/models/asset.js
rename to test/services/assets.js
index 7c5869caf..ff8d9e175 100644
--- a/tests/models/asset.js
+++ b/test/services/assets.js
@@ -1,4 +1,5 @@
-const Asset = require('../../models/asset');
+const AssetModel = require('../../models/asset');
+const AssetsService = require('../../services/assets');
const chai = require('chai');
const expect = chai.expect;
@@ -6,16 +7,16 @@ const expect = chai.expect;
// Use the chai should.
chai.should();
-describe('models.Asset', () => {
+describe('services.AssetsService', () => {
beforeEach(() => {
const defaults = {url:'http://test.com'};
- return Asset.update({id: '1'}, {$setOnInsert: defaults}, {upsert: true});
+ return AssetModel.update({id: '1'}, {$setOnInsert: defaults}, {upsert: true});
});
describe('#findById', ()=> {
it('should find an asset by the id', () => {
- return Asset.findById(1)
+ return AssetsService.findById(1)
.then((asset) => {
expect(asset).to.have.property('url')
.and.to.equal('http://test.com');
@@ -24,17 +25,19 @@ describe('models.Asset', () => {
});
describe('#findByUrl', ()=> {
- beforeEach(() => Asset.findOrCreateByUrl('http://test.com'));
+ beforeEach(() => AssetsService.findOrCreateByUrl('http://test.com'));
it('should find an asset by a url', () => {
- return Asset.findByUrl('http://test.com')
+ return AssetsService
+ .findByUrl('http://test.com')
.then((asset) => {
expect(asset).to.have.property('url', 'http://test.com');
});
});
it('should return null when a url does not exist', () => {
- return Asset.findByUrl('http://new.test.com')
+ return AssetsService
+ .findByUrl('http://new.test.com')
.then((asset) => {
expect(asset).to.be.null;
});
@@ -43,7 +46,8 @@ describe('models.Asset', () => {
describe('#findOrCreateByUrl', ()=> {
it('should find an asset by a url', () => {
- return Asset.findOrCreateByUrl('http://test.com')
+ return AssetsService
+ .findOrCreateByUrl('http://test.com')
.then((asset) => {
expect(asset).to.have.property('url')
.and.to.equal('http://test.com');
@@ -51,7 +55,8 @@ describe('models.Asset', () => {
});
it('should return a new asset when the url does not exist', () => {
- return Asset.findOrCreateByUrl('http://new.test.com')
+ return AssetsService
+ .findOrCreateByUrl('http://new.test.com')
.then((asset) => {
expect(asset).to.have.property('id')
.and.to.not.equal(1);
@@ -61,28 +66,29 @@ describe('models.Asset', () => {
describe('#overrideSettings', () => {
it('should update the settings', () => {
- return Asset
+ return AssetsService
.findOrCreateByUrl('https://override.test.com/asset')
.then((asset) => {
expect(asset).to.have.property('settings');
expect(asset.settings).to.be.null;
- return Asset.overrideSettings(asset.id, {moderation: 'pre'});
+ return AssetsService.overrideSettings(asset.id, {moderation: 'PRE'});
})
.then(() => {
- return Asset.findOrCreateByUrl('https://override.test.com/asset');
+ return AssetsService.findOrCreateByUrl('https://override.test.com/asset');
})
.then((asset) => {
expect(asset).to.have.property('settings');
expect(asset.settings).is.an('object');
- expect(asset.settings).to.have.property('moderation', 'pre');
+ expect(asset.settings).to.have.property('moderation', 'PRE');
});
});
});
describe('#findOrCreateByUrl', ()=> {
it('should find an asset by a url', () => {
- return Asset.findOrCreateByUrl('http://test.com')
+ return AssetsService
+ .findOrCreateByUrl('http://test.com')
.then((asset) => {
expect(asset).to.have.property('url')
.and.to.equal('http://test.com');
@@ -90,7 +96,8 @@ describe('models.Asset', () => {
});
it('should return a new asset when the url does not exist', () => {
- return Asset.findOrCreateByUrl('http://new.test.com')
+ return AssetsService
+ .findOrCreateByUrl('http://new.test.com')
.then((asset) => {
expect(asset).to.have.property('id')
.and.to.not.equal(1);
diff --git a/test/services/comments.js b/test/services/comments.js
new file mode 100644
index 000000000..fc2105cb1
--- /dev/null
+++ b/test/services/comments.js
@@ -0,0 +1,259 @@
+const CommentModel = require('../../models/comment');
+const ActionModel = require('../../models/action');
+
+const ActionsService = require('../../services/actions');
+const UsersService = require('../../services/users');
+const SettingsService = require('../../services/settings');
+const CommentsService = require('../../services/comments');
+
+const settings = {id: '1', moderation: 'PRE', wordlist: {banned: ['bad words'], suspect: ['suspect words']}};
+
+const expect = require('chai').expect;
+
+describe('services.CommentsService', () => {
+ const comments = [{
+ body: 'comment 10',
+ asset_id: '123',
+ status_history: [],
+ parent_id: '',
+ author_id: '123',
+ id: '1'
+ }, {
+ body: 'comment 20',
+ asset_id: '123',
+ status_history: [{
+ type: 'ACCEPTED'
+ }],
+ status: 'ACCEPTED',
+ parent_id: '',
+ author_id: '123',
+ id: '2'
+ }, {
+ body: 'comment 30',
+ asset_id: '456',
+ status_history: [],
+ parent_id: '',
+ author_id: '456',
+ id: '3'
+ }, {
+ body: 'comment 40',
+ asset_id: '123',
+ status_history: [{
+ type: 'REJECTED'
+ }],
+ status: 'REJECTED',
+ parent_id: '',
+ author_id: '456',
+ id: '4'
+ }, {
+ body: 'comment 50',
+ asset_id: '1234',
+ status_history: [{
+ type: 'PREMOD'
+ }],
+ status: 'PREMOD',
+ parent_id: '',
+ author_id: '456',
+ id: '5'
+ }, {
+ body: 'comment 60',
+ asset_id: '1234',
+ status_history: [{
+ type: 'PREMOD'
+ }],
+ status: 'PREMOD',
+ parent_id: '',
+ author_id: '456',
+ id: '6'
+ }];
+
+ const users = [{
+ email: 'stampi@gmail.com',
+ displayName: 'Stampi',
+ password: '1Coral!!'
+ }, {
+ email: 'sockmonster@gmail.com',
+ displayName: 'Sockmonster',
+ password: '2Coral!!'
+ }];
+
+ const actions = [{
+ action_type: 'FLAG',
+ item_id: '3',
+ item_type: 'COMMENTS',
+ user_id: '123'
+ }, {
+ action_type: 'LIKE',
+ item_id: '1',
+ item_type: 'COMMENTS',
+ user_id: '456'
+ }];
+
+ beforeEach(() => {
+ return SettingsService.init(settings).then(() => {
+ return Promise.all([
+ CommentModel.create(comments),
+ UsersService.createLocalUsers(users),
+ ActionModel.create(actions)
+ ]);
+ });
+ });
+
+ describe('#publicCreate()', () => {
+
+ it('creates a new comment', () => {
+ return CommentsService
+ .publicCreate({
+ body: 'This is a comment!',
+ status: 'ACCEPTED'
+ }).then((c) => {
+ expect(c).to.not.be.null;
+ expect(c.id).to.not.be.null;
+ expect(c.id).to.be.uuid;
+ expect(c.status).to.be.equal('ACCEPTED');
+ });
+ });
+
+ it('creates many new comments', () => {
+ return CommentsService
+ .publicCreate([{
+ body: 'This is a comment!',
+ status: 'ACCEPTED'
+ }, {
+ body: 'This is another comment!'
+ }, {
+ body: 'This is a rejected comment!',
+ status: 'REJECTED'
+ }]).then(([c1, c2, c3]) => {
+ expect(c1).to.not.be.null;
+ expect(c1.id).to.be.uuid;
+ expect(c1.status).to.be.equal('ACCEPTED');
+
+ expect(c2).to.not.be.null;
+ expect(c2.id).to.be.uuid;
+ expect(c2.status).to.be.null;
+
+ expect(c3).to.not.be.null;
+ expect(c3.id).to.be.uuid;
+ expect(c3.status).to.be.equal('REJECTED');
+ });
+ });
+
+ });
+
+ describe('#findById()', () => {
+
+ it('should find a comment by id', () => {
+ return CommentsService
+ .findById('1')
+ .then((result) => {
+ expect(result).to.not.be.null;
+ expect(result).to.have.property('body', 'comment 10');
+ });
+ });
+
+ });
+
+ describe('#findByAssetId()', () => {
+
+ it('should find an array of all comments by asset id', () => {
+ return CommentsService
+ .findByAssetId('123')
+ .then((result) => {
+ expect(result).to.have.length(3);
+ result.sort((a, b) => {
+ if (a.body < b.body) {return -1;}
+ else {return 1;}
+ });
+ expect(result[0]).to.have.property('body', 'comment 10');
+ expect(result[1]).to.have.property('body', 'comment 20');
+ expect(result[2]).to.have.property('body', 'comment 40');
+ });
+ });
+ });
+
+ describe('#moderationQueue()', () => {
+
+ it('should find an array of new comments to moderate when pre-moderation', () => {
+ return CommentsService
+ .moderationQueue('PREMOD')
+ .then((result) => {
+ expect(result).to.not.be.null;
+ expect(result).to.have.lengthOf(2);
+ });
+ });
+
+ });
+
+ describe('#removeAction', () => {
+
+ it('should remove an action', () => {
+ return CommentsService
+ .removeAction('3', '123', 'flag')
+ .then(() => {
+ return ActionsService.findByItemIdArray(['123']);
+ })
+ .then((actions) => {
+ expect(actions.length).to.equal(0);
+ });
+ });
+ });
+
+ describe('#findByUserId', () => {
+ it('should return all comments if admin', () => {
+ return CommentsService
+ .findByUserId('456', true)
+ .then(comments => {
+ expect(comments).to.have.length(4);
+ });
+ });
+
+ it('should not return premod and rejected comments if not admin', () => {
+ return CommentsService
+ .findByUserId('456')
+ .then(comments => {
+ expect(comments).to.have.length(1);
+ });
+ });
+
+ });
+
+ describe('#changeStatus', () => {
+
+ it('should change the status of a comment from no status', () => {
+ let comment_id = comments[0].id;
+
+ return CommentsService.findById(comment_id)
+ .then((c) => {
+ expect(c.status).to.be.null;
+
+ return CommentsService.pushStatus(comment_id, 'REJECTED', '123');
+ })
+ .then(() => CommentsService.findById(comment_id))
+ .then((c) => {
+ expect(c).to.have.property('status');
+ expect(c.status).to.equal('REJECTED');
+ expect(c.status_history).to.have.length(1);
+ expect(c.status_history[0]).to.have.property('type', 'REJECTED');
+ expect(c.status_history[0]).to.have.property('assigned_by', '123');
+ });
+ });
+
+ it('should change the status of a comment from accepted', () => {
+ return CommentsService.pushStatus(comments[1].id, 'REJECTED', '123')
+ .then(() => CommentsService.findById(comments[1].id))
+ .then((c) => {
+ expect(c).to.have.property('status_history');
+ expect(c).to.have.property('status');
+ expect(c.status).to.equal('REJECTED');
+ expect(c.status_history).to.have.length(2);
+ expect(c.status_history[0]).to.have.property('type', 'ACCEPTED');
+ expect(c.status_history[0]).to.have.property('assigned_by', null);
+
+ expect(c.status_history[1]).to.have.property('type', 'REJECTED');
+ expect(c.status_history[1]).to.have.property('assigned_by', '123');
+ });
+ });
+
+ });
+});
diff --git a/tests/services/scraper.js b/test/services/scraper.js
similarity index 92%
rename from tests/services/scraper.js
rename to test/services/scraper.js
index f3b5d006c..ab2474134 100644
--- a/tests/services/scraper.js
+++ b/test/services/scraper.js
@@ -1,4 +1,4 @@
-describe('scraper: services', () => {
+describe('services.scraper', () => {
describe('#create', () => {
it('should create a new kue job');
});
diff --git a/tests/models/setting.js b/test/services/settings.js
similarity index 64%
rename from tests/models/setting.js
rename to test/services/settings.js
index a7c97abfb..3620ae633 100644
--- a/tests/models/setting.js
+++ b/test/services/settings.js
@@ -1,19 +1,19 @@
-const Setting = require('../../models/setting');
+const SettingsService = require('../../services/settings');
const expect = require('chai').expect;
-describe('models.Setting', () => {
+describe('services.SettingsService', () => {
- beforeEach(() => Setting.init({moderation: 'pre', wordlist: ['donut']}));
+ beforeEach(() => SettingsService.init({moderation: 'PRE', wordlist: ['donut']}));
describe('#retrieve()', () => {
it('should have a moderation field defined', () => {
- return Setting.retrieve().then(settings => {
- expect(settings).to.have.property('moderation').and.to.equal('pre');
+ return SettingsService.retrieve().then(settings => {
+ expect(settings).to.have.property('moderation').and.to.equal('PRE');
});
});
it('should have two infoBox fields defined', () => {
- return Setting.retrieve().then(settings => {
+ return SettingsService.retrieve().then(settings => {
expect(settings).to.have.property('infoBoxEnable').and.to.equal(false);
expect(settings).to.have.property('infoBoxContent').and.to.equal('');
});
@@ -22,10 +22,10 @@ describe('models.Setting', () => {
describe('#update()', () => {
it('should update the settings with a passed object', () => {
- const mockSettings = {moderation: 'post', infoBoxEnable: true, infoBoxContent: 'yeah'};
- return Setting.update(mockSettings).then(updatedSettings => {
+ const mockSettings = {moderation: 'POST', infoBoxEnable: true, infoBoxContent: 'yeah'};
+ return SettingsService.update(mockSettings).then(updatedSettings => {
expect(updatedSettings).to.be.an('object');
- expect(updatedSettings).to.have.property('moderation').and.to.equal('post');
+ expect(updatedSettings).to.have.property('moderation').and.to.equal('POST');
expect(updatedSettings).to.have.property('infoBoxEnable', true);
expect(updatedSettings).to.have.property('infoBoxContent', 'yeah');
});
@@ -34,7 +34,7 @@ describe('models.Setting', () => {
describe('#get', () => {
it('should return the moderation settings', () => {
- return Setting.retrieve().then(({moderation}) => {
+ return SettingsService.retrieve().then(({moderation}) => {
expect(moderation).not.to.be.null;
});
});
@@ -42,14 +42,14 @@ describe('models.Setting', () => {
describe('#merge', () => {
it('should merge a settings object and its overrides', () => {
- return Setting
+ return SettingsService
.retrieve()
.then((settings) => {
- let ovrSett = {moderation: 'post'};
+ let ovrSett = {moderation: 'POST'};
settings.merge(ovrSett);
- expect(settings).to.have.property('moderation', 'post');
+ expect(settings).to.have.property('moderation', 'POST');
});
});
});
diff --git a/tests/models/user.js b/test/services/users.js
similarity index 59%
rename from tests/models/user.js
rename to test/services/users.js
index 69be314c4..e2e5655e4 100644
--- a/tests/models/user.js
+++ b/test/services/users.js
@@ -1,16 +1,16 @@
-const User = require('../../models/user');
-const Comment = require('../../models/comment');
-const Setting = require('../../models/setting');
+const UsersService = require('../../services/users');
+const SettingsService = require('../../services/settings');
const expect = require('chai').expect;
-describe('models.User', () => {
+describe('services.UsersService', () => {
+
let mockUsers;
beforeEach(() => {
- const settings = {id: '1', moderation: 'pre', wordlist: {banned: ['bad words'], suspect: ['suspect words']}};
+ const settings = {id: '1', moderation: 'PRE', wordlist: {banned: ['bad words'], suspect: ['suspect words']}};
- return Setting.init(settings).then(() => {
- return User.createLocalUsers([{
+ return SettingsService.init(settings).then(() => {
+ return UsersService.createLocalUsers([{
email: 'stampi@gmail.com',
displayName: 'Stampi',
password: '1Coral!-'
@@ -30,11 +30,10 @@ describe('models.User', () => {
describe('#findById()', () => {
it('should find a user by id', () => {
- return User
+ return UsersService
.findById(mockUsers[0].id)
.then((user) => {
- expect(user).to.have.property('displayName')
- .and.to.equal('stampi');
+ expect(user).to.have.property('displayName', 'stampi');
});
});
});
@@ -42,7 +41,7 @@ describe('models.User', () => {
describe('#findByIdArray()', () => {
it('should find an array of users from an array of ids', () => {
const ids = mockUsers.map((user) => user.id);
- return User.findByIdArray(ids).then((result) => {
+ return UsersService.findByIdArray(ids).then((result) => {
expect(result).to.have.length(3);
});
});
@@ -51,15 +50,14 @@ describe('models.User', () => {
describe('#findPublicByIdArray()', () => {
it('should find an array of users from an array of ids', () => {
const ids = mockUsers.map((user) => user.id);
- return User.findPublicByIdArray(ids).then((result) => {
+ return UsersService.findPublicByIdArray(ids).then((result) => {
expect(result).to.have.length(3);
const sorted = result.sort((a, b) => {
if(a.displayName < b.displayName) {return -1;}
if(a.displayName > b.displayName) {return 1;}
return 0;
});
- expect(sorted[0]).to.have.property('displayName')
- .and.to.equal('marvel');
+ expect(sorted[0]).to.have.property('displayName', 'marvel');
});
});
});
@@ -67,7 +65,7 @@ describe('models.User', () => {
describe('#findLocalUser', () => {
it('should find a user when we give the right credentials', () => {
- return User
+ return UsersService
.findLocalUser(mockUsers[0].profiles[0].id, '1Coral!-')
.then((user) => {
expect(user).to.have.property('displayName')
@@ -76,7 +74,7 @@ describe('models.User', () => {
});
it('should not find the user when we give the wrong credentials', () => {
- return User
+ return UsersService
.findLocalUser(mockUsers[0].profiles[0].id, '1Coral!-')
.then((user) => {
expect(user).to.equal(false);
@@ -85,10 +83,26 @@ describe('models.User', () => {
});
+ describe('#createLocalUser', () => {
+ it('should not create a user with duplicate display name', () => {
+ return UsersService.createLocalUsers([{
+ email: 'otrostampi@gmail.com',
+ displayName: 'StampiTheSecond',
+ password: '1Coralito!'
+ }])
+ .then((user) => {
+ expect(user).to.be.null;
+ })
+ .catch((error) => {
+ expect(error).to.not.be.null;
+ });
+ });
+ });
+
describe('#createEmailConfirmToken', () => {
it('should create a token for a valid user', () => {
- return User
+ return UsersService
.createEmailConfirmToken(mockUsers[0].id, mockUsers[0].profiles[0].id)
.then((token) => {
expect(token).to.not.be.null;
@@ -96,15 +110,15 @@ describe('models.User', () => {
});
it('should not create a token for a user already verified', () => {
- return User
+ return UsersService
.createEmailConfirmToken(mockUsers[0].id, mockUsers[0].profiles[0].id)
.then((token) => {
expect(token).to.not.be.null;
- return User.verifyEmailConfirmation(token);
+ return UsersService.verifyEmailConfirmation(token);
})
.then(() => {
- return User.createEmailConfirmToken(mockUsers[0].id, mockUsers[0].profiles[0].id);
+ return UsersService.createEmailConfirmToken(mockUsers[0].id, mockUsers[0].profiles[0].id);
})
.catch((err) => {
expect(err).to.have.property('message', 'email address already confirmed');
@@ -116,17 +130,17 @@ describe('models.User', () => {
describe('#verifyEmailConfirmation', () => {
it('should correctly validate a valid token', () => {
- return User
+ return UsersService
.createEmailConfirmToken(mockUsers[0].id, mockUsers[0].profiles[0].id)
.then((token) => {
expect(token).to.not.be.null;
- return User.verifyEmailConfirmation(token);
+ return UsersService.verifyEmailConfirmation(token);
});
});
it('should correctly reject an invalid token', () => {
- return User
+ return UsersService
.verifyEmailConfirmation('cats')
.catch((err) => {
expect(err).to.not.be.null;
@@ -134,15 +148,15 @@ describe('models.User', () => {
});
it('should update the user model when verification is complete', () => {
- return User
+ return UsersService
.createEmailConfirmToken(mockUsers[0].id, mockUsers[0].profiles[0].id)
.then((token) => {
expect(token).to.not.be.null;
- return User.verifyEmailConfirmation(token);
+ return UsersService.verifyEmailConfirmation(token);
})
.then(() => {
- return User.findById(mockUsers[0].id);
+ return UsersService.findById(mockUsers[0].id);
})
.then((user) => {
expect(user.profiles[0]).to.have.property('metadata');
@@ -155,85 +169,42 @@ describe('models.User', () => {
describe('#setStatus', () => {
it('should set the status to active', () => {
- return User
- .setStatus(mockUsers[0].id, 'active')
- .then(() => {
- User.findById(mockUsers[0].id)
- .then((user) => {
- expect(user).to.have.property('status')
- .and.to.equal('active');
- });
+ return UsersService
+ .setStatus(mockUsers[0].id, 'ACTIVE')
+ .then(() => UsersService.findById(mockUsers[0].id))
+ .then((user) => {
+ expect(user).to.have.property('status', 'ACTIVE');
});
});
});
describe('#ban', () => {
-
- let mockComment;
-
- beforeEach(() => {
- return Comment.create([
- {
- body: 'testing the comment for that user if it is rejected.',
- id: mockUsers[0].id
- }
- ])
- .then(([comment]) => {
- mockComment = comment;
- });
- });
-
it('should set the status to banned', () => {
- return User
- .setStatus(mockUsers[0].id, 'banned', mockComment.id)
- .then(() => {
- return User.findById(mockUsers[0].id);
- })
+ return UsersService
+ .setStatus(mockUsers[0].id, 'BANNED')
+ .then(() => UsersService.findById(mockUsers[0].id))
.then((user) => {
- expect(user).to.have.property('status')
- .and.to.equal('banned');
- });
- });
-
- it('should set the comment to rejected', () => {
- return User
- .setStatus(mockUsers[0].id, 'banned', mockComment.id)
- .then(() => {
- return Comment.findById(mockComment.id);
- })
- .then((comment) => {
- expect(comment).to.have.property('status')
- .and.to.equal('rejected');
+ expect(user).to.have.property('status', 'BANNED');
});
});
it('should still disable and ban the user if there is no comment', () => {
- return User
- .setStatus(mockUsers[0].id, 'banned', '')
- .then(() => User.findById(mockUsers[0].id))
+ return UsersService
+ .setStatus(mockUsers[0].id, 'BANNED')
+ .then(() => UsersService.findById(mockUsers[0].id))
.then((user) => {
- expect(user).to.have.property('status', 'banned');
+ expect(user).to.have.property('status', 'BANNED');
});
});
});
describe('#unban', () => {
- let mockComment;
- beforeEach(() => {
- return Promise.all([
- Comment.create([{body: 'testing the comment for that user if it is rejected.', id: mockUsers[0].id}])
- ])
- .then((comment) => {
- mockComment = comment;
- });
- });
-
it('should set the status to active', () => {
- return User
- .setStatus(mockUsers[0].id, 'active', mockComment.id)
- .then(() => User.findById(mockUsers[0].id))
+ return UsersService
+ .setStatus(mockUsers[0].id, 'ACTIVE')
+ .then(() => UsersService.findById(mockUsers[0].id))
.then((user) => {
- expect(user).to.have.property('status', 'active');
+ expect(user).to.have.property('status', 'ACTIVE');
});
});
});
diff --git a/tests/services/wordlist.js b/test/services/wordlist.js
similarity index 91%
rename from tests/services/wordlist.js
rename to test/services/wordlist.js
index 5bb1d32ab..6bd05e640 100644
--- a/tests/services/wordlist.js
+++ b/test/services/wordlist.js
@@ -1,9 +1,9 @@
const expect = require('chai').expect;
const Errors = require('../../errors');
const Wordlist = require('../../services/wordlist');
-const Setting = require('../../models/setting');
+const SettingsService = require('../../services/settings');
-describe('wordlist: services', () => {
+describe('services.Wordlist', () => {
const wordlists = {
banned: [
@@ -17,9 +17,9 @@ describe('wordlist: services', () => {
};
let wordlist = new Wordlist();
- const settings = {id: '1', moderation: 'pre', wordlist: {banned: ['bad words'], suspect: ['suspect words']}};
+ const settings = {id: '1', moderation: 'PRE', wordlist: {banned: ['bad words'], suspect: ['suspect words']}};
- beforeEach(() => Setting.init(settings));
+ beforeEach(() => SettingsService.init(settings));
describe('#init', () => {
diff --git a/tests/models/comment.js b/tests/models/comment.js
deleted file mode 100644
index 419fb4d79..000000000
--- a/tests/models/comment.js
+++ /dev/null
@@ -1,267 +0,0 @@
-const Comment = require('../../models/comment');
-const User = require('../../models/user');
-const Action = require('../../models/action');
-const Setting = require('../../models/setting');
-
-const settings = {id: '1', moderation: 'pre', wordlist: {banned: ['bad words'], suspect: ['suspect words']}};
-
-const expect = require('chai').expect;
-
-describe('models.Comment', () => {
- const comments = [{
- body: 'comment 10',
- asset_id: '123',
- status_history: [],
- parent_id: '',
- author_id: '123',
- id: '1'
- }, {
- body: 'comment 20',
- asset_id: '123',
- status_history: [{
- type: 'accepted'
- }],
- status: 'accepted',
- parent_id: '',
- author_id: '123',
- id: '2'
- }, {
- body: 'comment 30',
- asset_id: '456',
- status_history: [],
- parent_id: '',
- author_id: '456',
- id: '3'
- }, {
- body: 'comment 40',
- asset_id: '123',
- status_history: [{
- type: 'rejected'
- }],
- status: 'rejected',
- parent_id: '',
- author_id: '456',
- id: '4'
- }, {
- body: 'comment 50',
- asset_id: '1234',
- status_history: [{
- type: 'premod'
- }],
- status: 'premod',
- parent_id: '',
- author_id: '456',
- id: '5'
- }, {
- body: 'comment 60',
- asset_id: '1234',
- status_history: [{
- type: 'premod'
- }],
- status: 'premod',
- parent_id: '',
- author_id: '456',
- id: '6'
- }];
-
- const users = [{
- email: 'stampi@gmail.com',
- displayName: 'Stampi',
- password: '1Coral!!'
- }, {
- email: 'sockmonster@gmail.com',
- displayName: 'Sockmonster',
- password: '2Coral!!'
- }];
-
- const actions = [{
- action_type: 'flag',
- item_id: '3',
- item_type: 'comments',
- user_id: '123'
- }, {
- action_type: 'like',
- item_id: '1',
- item_type: 'comments',
- user_id: '456'
- }];
-
- beforeEach(() => {
- return Setting.init(settings).then(() => {
- return Promise.all([
- Comment.create(comments),
- User.createLocalUsers(users),
- Action.create(actions)
- ]);
- });
- });
-
- describe('#publicCreate()', () => {
-
- it('creates a new comment', () => {
- return Comment.publicCreate({
- body: 'This is a comment!',
- status: 'accepted'
- }).then((c) => {
- expect(c).to.not.be.null;
- expect(c.id).to.not.be.null;
- expect(c.id).to.be.uuid;
- expect(c.status).to.be.equal('accepted');
- });
- });
-
- it('creates many new comments', () => {
- return Comment.publicCreate([{
- body: 'This is a comment!',
- status: 'accepted'
- }, {
- body: 'This is another comment!'
- }, {
- body: 'This is a rejected comment!',
- status: 'rejected'
- }]).then(([c1, c2, c3]) => {
- expect(c1).to.not.be.null;
- expect(c1.id).to.be.uuid;
- expect(c1.status).to.be.equal('accepted');
-
- expect(c2).to.not.be.null;
- expect(c2.id).to.be.uuid;
- expect(c2.status).to.be.null;
-
- expect(c3).to.not.be.null;
- expect(c3.id).to.be.uuid;
- expect(c3.status).to.be.equal('rejected');
- });
- });
-
- });
-
- describe('#findById()', () => {
-
- it('should find a comment by id', () => {
- return Comment.findById('1').then((result) => {
- expect(result).to.not.be.null;
- expect(result).to.have.property('body', 'comment 10');
- });
- });
-
- });
-
- describe('#findByAssetId()', () => {
-
- it('should find an array of all comments by asset id', () => {
- return Comment.findByAssetId('123').then((result) => {
- expect(result).to.have.length(3);
- result.sort((a, b) => {
- if (a.body < b.body) {return -1;}
- else {return 1;}
- });
- expect(result[0]).to.have.property('body', 'comment 10');
- expect(result[1]).to.have.property('body', 'comment 20');
- expect(result[2]).to.have.property('body', 'comment 40');
- });
- });
-
- it('should find an array of accepted comments by asset id', () => {
- return Comment.findAcceptedByAssetId('123').then((result) => {
- expect(result).to.have.length(1);
- result.sort((a, b) => {
- if (a.body < b.body) {return -1;}
- else {return 1;}
- });
- expect(result[0]).to.have.property('body', 'comment 20');
- });
- });
-
- it('should find an array of new and accepted comments by asset id', () => {
- return Comment.findAcceptedAndNewByAssetId('123').then((result) => {
- expect(result).to.have.length(2);
- result.sort((a, b) => {
- if (a.body < b.body) {return -1;}
- else {return 1;}
- });
- expect(result[0]).to.have.property('body', 'comment 10');
- });
- });
- });
-
- describe('#moderationQueue()', () => {
-
- it('should find an array of new comments to moderate when pre-moderation', () => {
- return Comment.moderationQueue('premod').then((result) => {
- expect(result).to.not.be.null;
- expect(result).to.have.lengthOf(2);
- });
- });
-
- });
-
- describe('#removeAction', () => {
-
- it('should remove an action', () => {
- return Comment.removeAction('3', '123', 'flag')
- .then(() => {
- return Action.findByItemIdArray(['123']);
- })
- .then((actions) => {
- expect(actions.length).to.equal(0);
- });
- });
- });
-
- describe('#findByUserId', () => {
- it('should return all comments if admin', () => {
- return Comment.findByUserId('456', true)
- .then(comments => {
- expect(comments).to.have.length(4);
- });
- });
-
- it('should not return premod and rejected comments if not admin', () => {
- return Comment.findByUserId('456')
- .then(comments => {
- expect(comments).to.have.length(1);
- });
- });
-
- });
-
- describe('#changeStatus', () => {
-
- it('should change the status of a comment from no status', () => {
- let comment_id = comments[0].id;
-
- return Comment.findById(comment_id)
- .then((c) => {
- expect(c.status).to.be.null;
-
- return Comment.pushStatus(comment_id, 'rejected', '123');
- })
- .then(() => Comment.findById(comment_id))
- .then((c) => {
- expect(c).to.have.property('status');
- expect(c.status).to.equal('rejected');
- expect(c.status_history).to.have.length(1);
- expect(c.status_history[0]).to.have.property('type', 'rejected');
- expect(c.status_history[0]).to.have.property('assigned_by', '123');
- });
- });
-
- it('should change the status of a comment from accepted', () => {
- return Comment.pushStatus(comments[1].id, 'rejected', '123')
- .then(() => Comment.findById(comments[1].id))
- .then((c) => {
- expect(c).to.have.property('status_history');
- expect(c).to.have.property('status');
- expect(c.status).to.equal('rejected');
- expect(c.status_history).to.have.length(2);
- expect(c.status_history[0]).to.have.property('type', 'accepted');
- expect(c.status_history[0]).to.have.property('assigned_by', null);
-
- expect(c.status_history[1]).to.have.property('type', 'rejected');
- expect(c.status_history[1]).to.have.property('assigned_by', '123');
- });
- });
-
- });
-});
diff --git a/tests/routes/api/comments/index.js b/tests/routes/api/comments/index.js
deleted file mode 100644
index ed1f3d22f..000000000
--- a/tests/routes/api/comments/index.js
+++ /dev/null
@@ -1,485 +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 Asset = require('../../../../models/asset');
-const Action = require('../../../../models/action');
-const User = require('../../../../models/user');
-
-const Setting = require('../../../../models/setting');
-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(() => Setting.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 = [{
- displayName: 'Ana',
- email: 'ana@gmail.com',
- password: '123456789'
- }, {
- displayName: '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([
- Comment.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 Action.create(actions);
- }),
- User.createLocalUsers(users)
- ]);
- });
-
- it('should return only the owner’s 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('#post', () => {
-
- let asset_id;
- let postmod_asset_id;
-
- beforeEach(() => Promise.all([
- Asset.findOrCreateByUrl('https://coralproject.net/section/article-is-the-best').then((asset) => {
-
- // Update the asset id.
- asset_id = asset.id;
- }),
- Asset.findOrCreateByUrl('https://coralproject.net/section/postmod-article-is-the-best').then((asset) => {
-
- // Update the asset id.
- postmod_asset_id = asset.id;
-
- return Asset.overrideSettings(postmod_asset_id, {moderation: 'post'});
- }),
- ]));
-
- it('should create a comment', () => {
- return chai.request(app)
- .post('/api/v1/comments')
- .set(passport.inject({roles: []}))
- .send({'body': 'Something body.', 'author_id': '123', 'asset_id': asset_id, 'parent_id': ''})
- .then((res) => {
- expect(res).to.have.status(201);
- expect(res.body).to.have.property('id');
- });
- });
-
- it('should create a comment with a rejected status if it contains a bad word', () => {
- return chai.request(app).post('/api/v1/comments')
- .set(passport.inject({roles: []}))
- .send({'body': 'bad words are the baddest', 'author_id': '123', 'asset_id': asset_id, 'parent_id': ''})
- .then((res) => {
- expect(res).to.have.status(201);
- expect(res.body).to.have.property('id');
- expect(res.body).to.have.property('status', 'rejected');
- });
- });
-
- it('should create a comment with no status and a flag if it contains a suspected word', () => {
- return chai.request(app).post('/api/v1/comments')
- .set(passport.inject({roles: []}))
- .send({'body': 'suspect words are the most suspicious', 'author_id': '123', 'asset_id': postmod_asset_id, 'parent_id': ''})
- .then((res) => {
- expect(res).to.have.status(201);
- expect(res.body).to.have.property('id');
- expect(res.body).to.have.property('status', null);
- return Promise.all([
- res.body,
- Action.findByType('flag', 'comments')
- ]);
- })
- .then(([comment, actions]) => {
- expect(actions).to.have.length(1);
-
- let action = actions[0];
-
- expect(action).to.have.property('item_id', comment.id);
- expect(action).to.have.property('metadata');
- expect(action.metadata).to.have.property('field', 'body');
- expect(action.metadata).to.have.property('details', 'Matched suspect word filters.');
- });
- });
-
- it('should create a comment with a premod status if it\'s asset is has pre-moderation enabled', () => {
- return Asset
- .findOrCreateByUrl('https://coralproject.net/article1')
- .then((asset) => {
- return Asset
- .overrideSettings(asset.id, {moderation: 'pre'})
- .then(() => asset);
- })
- .then((asset) => {
- return chai.request(app).post('/api/v1/comments')
- .set(passport.inject({roles: []}))
- .send({'body': 'Something body.', 'author_id': '123', 'asset_id': asset.id, 'parent_id': ''});
- })
- .then((res) => {
- expect(res).to.have.status(201);
- expect(res.body).to.have.property('id');
- expect(res.body).to.have.property('asset_id');
- expect(res.body).to.have.property('status', 'premod');
- });
- });
-
- it('should create a rejected comment if the body is above the character count', () => {
- return Asset
- .findOrCreateByUrl('https://coralproject.net/article1')
- .then((asset) => {
- return Asset
- .overrideSettings(asset.id, {charCountEnable: true, charCount: 10})
- .then(() => asset);
- })
- .then((asset) => {
- return chai.request(app).post('/api/v1/comments')
- .set(passport.inject({roles: []}))
- .send({'body': 'This is way way way way way too long.', 'author_id': '123', 'asset_id': asset.id, 'parent_id': ''});
- })
- .then((res) => {
- expect(res).to.have.status(201);
- expect(res.body).to.have.property('id');
- expect(res.body).to.have.property('asset_id');
- expect(res.body).to.have.property('status', 'rejected');
- });
- });
-
- it('shouldn\'t create a comment when the asset has expired commenting', () => {
- return Asset.create({
- closedAt: new Date().setDate(0),
- closedMessage: 'tests said expired!'
- })
- .then((asset) => {
- return chai.request(app).post('/api/v1/comments')
- .set(passport.inject({roles: []}))
- .send({'body': 'Something body.', 'author_id': '123', 'asset_id': asset.id, 'parent_id': ''});
- })
- .then((res) => {
- expect(res).to.have.status(500);
- })
- .catch((err) => {
- expect(err.response.body).to.not.be.null;
- expect(err.response.body).to.have.property('message');
- expect(err.response.body.error.metadata.closedMessage).to.be.equal('tests said expired!');
- });
- });
-
- it('should create a comment when the asset has not expired yet', () => {
- return Asset.create({
- closedAt: new Date().setDate(32),
- closedMessage: 'tests said expired!'
- })
- .then((asset) => {
- return chai.request(app).post('/api/v1/comments')
- .set(passport.inject({roles: []}))
- .send({'body': 'Something body.', 'author_id': '123', 'asset_id': asset.id, 'parent_id': ''});
- })
- .then((res) => {
- expect(res).to.have.status(201);
- });
- });
- });
-});
-
-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 = [{
- displayName: 'Ana',
- email: 'ana@gmail.com',
- password: '123456789'
- }, {
- displayName: 'Maria',
- email: 'maria@gmail.com',
- password: '123456789'
- }];
-
- const actions = [{
- action_type: 'flag',
- item_id: 'abc',
- item_type: 'comment'
- }, {
- action_type: 'like',
- item_id: 'hij',
- item_type: 'comment'
- }];
-
- beforeEach(() => {
- return Setting.init(settings).then(() => {
- return Promise.all([
- Comment.create(comments),
- User.createLocalUsers(users),
- Action.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 Comment.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);
- });
- });
- });
-});
-
-describe('/api/v1/comments/:comment_id/actions', () => {
-
- const comments = [{
- id: 'abc',
- body: 'comment 10',
- asset_id: 'asset',
- author_id: '123',
- status_history: []
- }, {
- id: 'def',
- body: 'comment 20',
- asset_id: 'asset',
- author_id: '456',
- status_history: [{
- type: 'rejected'
- }]
- }, {
- id: 'hij',
- body: 'comment 30',
- asset_id: '456',
- status_history: [{
- type: 'accepted'
- }]
- }];
-
- const users = [{
- displayName: 'Ana',
- email: 'ana@gmail.com',
- password: '123456789'
- }, {
- displayName: 'Maria',
- email: 'maria@gmail.com',
- password: '123456789'
- }];
-
- const actions = [{
- action_type: 'flag',
- item_id: 'abc'
- }, {
- action_type: 'like',
- item_id: 'hij'
- }];
-
- beforeEach(() => {
- return Setting.init(settings).then(() => {
- return Promise.all([
- Comment.create(comments),
- User.createLocalUsers(users),
- Action.create(actions)
- ]);
- });
- });
-
- describe('#post', () => {
- it('it should create an action', () => {
- return chai.request(app)
- .post('/api/v1/comments/abc/actions')
- .set(passport.inject({id: '456', roles: ['admin']}))
- .send({'action_type': 'flag', 'metadata': {'reason': 'Comment is too awesome.'}})
- .then((res) => {
- expect(res).to.have.status(201);
- expect(res).to.have.body;
-
- expect(res.body).to.have.property('action_type', 'flag');
- expect(res.body).to.have.property('metadata');
- expect(res.body.metadata).to.deep.equal({'reason': 'Comment is too awesome.'});
- expect(res.body).to.have.property('item_id', 'abc');
- });
- });
- });
-});
diff --git a/tests/routes/api/stream/index.js b/tests/routes/api/stream/index.js
deleted file mode 100644
index d9b59d6fe..000000000
--- a/tests/routes/api/stream/index.js
+++ /dev/null
@@ -1,152 +0,0 @@
-const app = require('../../../../app');
-const chai = require('chai');
-const expect = chai.expect;
-
-// Setup chai.
-chai.should();
-chai.use(require('chai-http'));
-
-const Action = require('../../../../models/action');
-const User = require('../../../../models/user');
-const Comment = require('../../../../models/comment');
-const Asset = require('../../../../models/asset');
-
-const Setting = require('../../../../models/setting');
-
-describe('/api/v1/stream', () => {
- describe('#get', () => {
- const settings = {
- id: '1',
- moderation: 'post',
- wordlist: {
- banned: ['banned'],
- suspect: ['suspect']
- }
- };
-
- const comments = [{
- id: 'abc',
- body: 'comment 10',
- author_id: '',
- parent_id: '',
- status: 'accepted',
- status_history: [{
- type: 'accepted'
- }]
- }, {
- id: 'def',
- body: 'comment 20',
- author_id: '',
- parent_id: '',
- status: null,
- status_history: []
- }, {
- id: 'uio',
- body: 'comment 30',
- asset_id: 'asset',
- author_id: '456',
- parent_id: '',
- status: 'accepted',
- status_history: [{
- type: 'accepted'
- }]
- }, {
- id: 'hij',
- body: 'comment 40',
- asset_id: '456',
- status: 'rejected',
- status_history: [{
- type: 'rejected'
- }]
- }];
-
- const users = [{
- displayName: 'Ana',
- email: 'ana@gmail.com',
- password: '123456789'
- }, {
- displayName: 'Maria',
- email: 'maria@gmail.com',
- password: '123456789'
- }];
-
- const actions = [{
- action_type: 'flag',
- item_id: 'abc'
- }, {
- action_type: 'like',
- item_id: 'hij'
- }];
-
- beforeEach(() => {
- return Setting.init(settings).then(() => {
- return Promise.all([
- User.createLocalUsers(users),
- Asset.findOrCreateByUrl('http://test.com'),
- Asset
- .findOrCreateByUrl('http://coralproject.net/asset2')
- .then((asset) => {
- return Asset
- .overrideSettings(asset.id, {moderation: 'pre'})
- .then(() => asset);
- })
- ])
- .then(([users, asset1, asset2]) => {
-
- comments[0].author_id = users[0].id;
- comments[1].author_id = users[1].id;
- comments[2].author_id = users[0].id;
- comments[3].author_id = users[1].id;
-
- comments[0].asset_id = asset1.id;
- comments[1].asset_id = asset1.id;
- comments[2].asset_id = asset2.id;
- comments[3].asset_id = asset2.id;
-
- return Promise.all([
- Comment.create(comments),
- Action.create(actions)
- ]);
- });
- });
- });
-
- it('should return a stream with comments, users and actions for an existing asset', () => {
- return chai.request(app)
- .get('/api/v1/stream')
- .query({'asset_url': 'http://test.com'})
- .then(res => {
- expect(res).to.have.status(200);
- expect(res.body.assets.length).to.equal(1);
- expect(res.body.comments.length).to.equal(2);
- expect(res.body.users.length).to.equal(2);
- expect(res.body.actions.length).to.equal(1);
- expect(res.body.settings).to.have.property('moderation', 'post');
- });
- });
-
- it('should reject requests without a scheme in the asset_url', () => {
- return chai.request(app)
- .get('/api/v1/stream')
- .query({asset_url: 'test.com'})
- .catch((err) => {
- expect(err).to.have.status(400);
- expect(err.response.body.message).to.contain('asset_url is invalid');
- });
- });
-
- it('should merge the settings when the asset contains settings to override it with', () => {
- return chai.request(app)
- .get('/api/v1/stream')
- .query({'asset_url': 'http://coralproject.net/asset2'})
- .then((res) => {
- expect(res).to.have.status(200);
- expect(res.body.assets.length).to.equal(1);
- expect(res.body.comments.length).to.equal(1);
- expect(res.body.users.length).to.equal(1);
- expect(res.body.settings).to.have.property('moderation', 'pre');
- expect(res.body.settings).to.not.have.property('wordlist');
- });
- });
- });
-});
diff --git a/util.js b/util.js
index 6907d90cd..b081078e7 100644
--- a/util.js
+++ b/util.js
@@ -37,6 +37,8 @@ util.onshutdown = (jobs) => {
};
// Attach to the SIGTERM + SIGINT handles to ensure a clean shutdown in the
-// event that we have an external event.
-process.on('SIGTERM', () => util.shutdown());
-process.on('SIGINT', () => util.shutdown());
+// event that we have an external event. SIGUSR2 is called when the app is asked
+// to be 'killed', same procedure here.
+process.on('SIGTERM', () => util.shutdown());
+process.on('SIGINT', () => util.shutdown());
+process.once('SIGUSR2', () => util.shutdown());
diff --git a/views/admin.ejs b/views/admin.ejs
index efcf1dfc9..32d38734a 100644
--- a/views/admin.ejs
+++ b/views/admin.ejs
@@ -5,6 +5,7 @@
Talk - Coral Admin
+
+
+
+
+
+
+
+ |