mirror of
https://github.com/wassname/talk.git
synced 2026-09-12 13:01:11 +08:00
Adjusted placement of server side graph code.
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
const loaders = require('./loaders');
|
||||
const mutators = require('./mutators');
|
||||
const schema = require('./schema');
|
||||
|
||||
module.exports = {
|
||||
createGraphOptions: (req) => {
|
||||
|
||||
let context = {};
|
||||
|
||||
// Load the current logged in user to `user`, otherwise this'll be null.
|
||||
context.user = req.user;
|
||||
|
||||
// Create the loaders.
|
||||
context.loaders = loaders(context);
|
||||
|
||||
// Create the mutators.
|
||||
context.mutators = mutators(context);
|
||||
|
||||
return {
|
||||
schema,
|
||||
context
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,171 @@
|
||||
const DataLoader = require('dataloader');
|
||||
const _ = require('lodash');
|
||||
const url = require('url');
|
||||
const errors = require('../errors');
|
||||
const scraper = require('../services/scraper');
|
||||
|
||||
const Comment = require('../models/comment');
|
||||
const User = require('../models/user');
|
||||
const Action = require('../models/action');
|
||||
const Asset = require('../models/asset');
|
||||
const Settings = require('../models/setting');
|
||||
|
||||
/**
|
||||
* 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;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves assets by an array of ids.
|
||||
* @param {Array} ids array of ids to lookup
|
||||
*/
|
||||
const genAssetsByID = (ids) => Asset.find({
|
||||
id: {
|
||||
$in: ids
|
||||
}
|
||||
}).then(singleJoinBy(ids, 'id'));
|
||||
|
||||
/**
|
||||
* Retrieves actions by an array of ids.
|
||||
* @param {Array} ids array of ids to lookup
|
||||
*/
|
||||
const genActionsByID = (ids, user = {}) => Action.getActionSummaries(ids, user.id).then(arrayJoinBy(ids, 'item_id'));
|
||||
|
||||
/**
|
||||
* Retrieves comments by an array of asset id's.
|
||||
* @param {Array} ids array of ids to lookup
|
||||
*/
|
||||
const genCommentsByAssetID = (ids) => Comment.find({
|
||||
asset_id: {
|
||||
$in: ids
|
||||
},
|
||||
parent_id: null,
|
||||
status: {
|
||||
$in: [null, 'accepted']
|
||||
}
|
||||
}).then(arrayJoinBy(ids, 'asset_id'));
|
||||
|
||||
/**
|
||||
* Retrieves comments by an array of parent ids.
|
||||
* @param {Array} ids array of ids to lookup
|
||||
*/
|
||||
const genCommentsByParentID = (ids) => Comment.find({
|
||||
parent_id: {
|
||||
$in: ids
|
||||
},
|
||||
status: {
|
||||
$in: [null, 'accepted']
|
||||
}
|
||||
}).then(arrayJoinBy(ids, 'parent_id'));
|
||||
|
||||
/**
|
||||
* This endpoint find or creates an asset at the given url when it is loaded.
|
||||
* @param {String} asset_url the url passed in from the query
|
||||
* @returns {Promise} resolves to the asset
|
||||
*/
|
||||
const findOrCreateAssetByURL = (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 Asset.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
|
||||
*/
|
||||
const createLoaders = (context) => ({
|
||||
Comments: {
|
||||
getByParentID: new DataLoader((ids) => genCommentsByParentID(ids)),
|
||||
getByAssetID: new DataLoader((ids) => genCommentsByAssetID(ids)),
|
||||
},
|
||||
Actions: {
|
||||
getByID: new DataLoader((ids) => genActionsByID(ids, context.user)),
|
||||
},
|
||||
Users: {
|
||||
getByID: new DataLoader((ids) => User.findByIdArray(ids))
|
||||
},
|
||||
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(url),
|
||||
|
||||
getByID: new DataLoader((ids) => genAssetsByID(ids)),
|
||||
getAll: new SingletonResolver(() => Asset.find({}))
|
||||
},
|
||||
Settings: new SingletonResolver(() => Settings.retrieve())
|
||||
});
|
||||
|
||||
module.exports = createLoaders;
|
||||
@@ -0,0 +1,215 @@
|
||||
const errors = require('../errors');
|
||||
|
||||
const Action = require('../models/action');
|
||||
const Asset = require('../models/asset');
|
||||
const Comment = require('../models/comment');
|
||||
const User = require('../models/user');
|
||||
|
||||
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 Comment.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 = 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;
|
||||
});
|
||||
}
|
||||
|
||||
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.
|
||||
if (wordlist.suspect) {
|
||||
|
||||
// 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 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;
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 Action.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 {[type]} id [description]
|
||||
* @return {[type]} [description]
|
||||
*/
|
||||
const deleteAction = ({user}, {id}) => {
|
||||
return Action.remove({
|
||||
id,
|
||||
user_id: user.id
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 User.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 {
|
||||
Comment: {
|
||||
create: (comment) => createPublicComment(context, comment)
|
||||
},
|
||||
Action: {
|
||||
create: (action) => createAction(context, action),
|
||||
delete: (action) => deleteAction(context, action)
|
||||
},
|
||||
User: {
|
||||
updateSettings: (settings) => updateUserSettings(context, settings)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
Comment: {
|
||||
create: () => {}
|
||||
},
|
||||
Action: {
|
||||
create: () => {},
|
||||
delete: () => {}
|
||||
},
|
||||
User: {
|
||||
updateSettings: () => {}
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
const Action = {
|
||||
action_type({action_type}) {
|
||||
|
||||
// TODO: remove once we cast the data model to have uppercase action
|
||||
// types.
|
||||
return action_type.toUpperCase();
|
||||
},
|
||||
item_type({item_type}) {
|
||||
|
||||
// TODO: remove once we cast the data model to have uppercase item
|
||||
// types.
|
||||
return item_type.toUpperCase();
|
||||
},
|
||||
user({user_id}, _, {loaders}) {
|
||||
return loaders.Users.getByID.load(user_id);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = Action;
|
||||
@@ -0,0 +1,16 @@
|
||||
const ActionSummary = {
|
||||
action_type({action_type}) {
|
||||
|
||||
// TODO: remove once we cast the data model to have uppercase action
|
||||
// types.
|
||||
return action_type.toUpperCase();
|
||||
},
|
||||
item_type({item_type}) {
|
||||
|
||||
// TODO: remove once we cast the data model to have uppercase item
|
||||
// types.
|
||||
return item_type.toUpperCase();
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = ActionSummary;
|
||||
@@ -0,0 +1,20 @@
|
||||
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({}, settings, globalSettings);
|
||||
} else {
|
||||
settings = globalSettings;
|
||||
}
|
||||
|
||||
return settings;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = Asset;
|
||||
@@ -0,0 +1,13 @@
|
||||
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.getByID.load(id);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = Comment;
|
||||
@@ -0,0 +1,17 @@
|
||||
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 User = require('./user');
|
||||
|
||||
module.exports = {
|
||||
Action,
|
||||
ActionSummary,
|
||||
Asset,
|
||||
Comment,
|
||||
RootMutation,
|
||||
RootQuery,
|
||||
User
|
||||
};
|
||||
@@ -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;
|
||||
@@ -0,0 +1,25 @@
|
||||
const RootQuery = {
|
||||
assets(_, args, {loaders}) {
|
||||
return loaders.Assets.getAll.load();
|
||||
},
|
||||
asset(_, {id = null, url}, {loaders}) {
|
||||
if (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(id);
|
||||
} else {
|
||||
return loaders.Assets.getByURL(url);
|
||||
}
|
||||
},
|
||||
settings(_, args, {loaders}) {
|
||||
return loaders.Settings.load();
|
||||
},
|
||||
me(_, args, {user}) {
|
||||
return user;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = RootQuery;
|
||||
@@ -0,0 +1,7 @@
|
||||
const User = {
|
||||
actions({id}, _, {loaders}) {
|
||||
return loaders.Actions.getByID.load(id);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = User;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,123 @@
|
||||
// TODO: Adjust `RootQuery.asset(id: ID, url: URL)` to instead be
|
||||
// `RootQuery.asset(id: ID, url: URL!)` because we'll always need the url, if
|
||||
// this change is done now everything will likely break on the front end.
|
||||
|
||||
const typeDefs = [`
|
||||
type UserSettings {
|
||||
bio: String
|
||||
}
|
||||
|
||||
type User {
|
||||
id: ID!
|
||||
displayName: String!
|
||||
actions: [ActionSummary]
|
||||
settings: UserSettings
|
||||
}
|
||||
|
||||
type Comment {
|
||||
id: ID!
|
||||
body: String!
|
||||
user: User
|
||||
replies(limit: Int = 3): [Comment]
|
||||
actions: [ActionSummary]
|
||||
}
|
||||
|
||||
enum ITEM_TYPE {
|
||||
ASSETS
|
||||
COMMENTS
|
||||
USERS
|
||||
}
|
||||
|
||||
enum ACTION_TYPE {
|
||||
LIKE
|
||||
FLAG
|
||||
}
|
||||
|
||||
interface ActionInterface {
|
||||
action_type: ACTION_TYPE!
|
||||
item_type: ITEM_TYPE!
|
||||
}
|
||||
|
||||
type Action implements ActionInterface {
|
||||
id: ID!
|
||||
item_id: ID!
|
||||
action_type: ACTION_TYPE!
|
||||
item_type: ITEM_TYPE!
|
||||
user: User!
|
||||
updated_at: String
|
||||
created_at: String
|
||||
}
|
||||
|
||||
type ActionSummary implements ActionInterface {
|
||||
action_type: ACTION_TYPE!
|
||||
item_type: ITEM_TYPE!
|
||||
count: Int
|
||||
current_user: Action
|
||||
}
|
||||
|
||||
type Settings {
|
||||
moderation: String
|
||||
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!
|
||||
currentUser: User
|
||||
}
|
||||
|
||||
scalar URL
|
||||
|
||||
type RootQuery {
|
||||
settings: Settings
|
||||
assets: [Asset]
|
||||
asset(id: ID, url: URL): Asset
|
||||
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;
|
||||
Reference in New Issue
Block a user