const { SharedCounterDataLoader, singleJoinBy } = require('./util'); const DataLoader = require('dataloader'); const { SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS, SEARCH_OTHERS_COMMENTS, } = require('../../perms/constants'); const { CACHE_EXPIRY_COMMENT_COUNT, ALLOW_NO_LIMIT_QUERIES, } = require('../../config'); const ms = require('ms'); const sc = require('snake-case'); const CommentModel = require('../../models/comment'); /** * Returns the comment count for all comments that are public based on their * asset ids. * @param {Object} context graph context * @param {Array} asset_ids the ids of assets for which there are * comments that we want to get */ const getCountsByAssetID = (context, asset_ids) => { return CommentModel.aggregate([ { $match: { asset_id: { $in: asset_ids, }, status: { $in: ['NONE', 'ACCEPTED'], }, }, }, { $group: { _id: '$asset_id', count: { $sum: 1, }, }, }, ]) .then(singleJoinBy(asset_ids, '_id')) .then(results => results.map(result => (result ? result.count : 0))); }; /** * Returns the comment count for all comments that are public based on their * asset ids. * @param {Object} context graph context * @param {Array} asset_ids the ids of assets for which there are * comments that we want to get */ const getParentCountsByAssetID = (context, asset_ids) => { return CommentModel.aggregate([ { $match: { asset_id: { $in: asset_ids, }, status: { $in: ['NONE', 'ACCEPTED'], }, parent_id: null, }, }, { $group: { _id: '$asset_id', count: { $sum: 1, }, }, }, ]) .then(singleJoinBy(asset_ids, '_id')) .then(results => results.map(result => (result ? result.count : 0))); }; /** * Retrieves the count of comments based on the passed in query. * @param {Object} ctx graph context * @param {Object} query query to execute against the comments collection * to compute the counts * @return {Promise} resolves to the counts of the comments from the * query */ const getCommentCountByQuery = (ctx, options) => { const { statuses, asset_id, parent_id, author_id, tags, action_type, excludeDeleted, } = options; // If user queries for statuses other than NONE and/or ACCEPTED statuses, it needs // special privileges. if ( (!statuses || statuses.some(status => !['NONE', 'ACCEPTED'].includes(status))) && (ctx.user == null || !ctx.user.can(SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS)) ) { return null; } const query = CommentModel.find(); if (asset_id != null) { query.merge({ asset_id }); } if (parent_id !== undefined) { query.merge({ parent_id }); } if (author_id) { query.merge({ author_id }); } if (excludeDeleted) { // The null query matches documents that either contain the `deleted_at` // field whose value is null or that do not contain the `deleted_at` field. query.merge({ deleted_at: null }); } if (ctx.user != null && ctx.user.can(SEARCH_OTHERS_COMMENTS) && action_type) { query.merge({ [`action_counts.${sc(action_type.toLowerCase())}`]: { $gt: 0, }, }); } if (statuses && statuses.length > 0) { query.merge({ status: { $in: statuses } }); } if (tags && tags.length > 0) { query.merge({ 'tags.tag.name': { $in: tags, }, }); } return CommentModel.find(query).count(); }; /** * getStartCursor will retrieve the start cursor based on the sortBy field. * * @param {Object} ctx the graph context * @param {Object} nodes the result set of retrieved comments * @param {Object} params the params from the client describing the query */ const getStartCursor = (ctx, nodes, { cursor, sortBy }) => { if (sortBy === 'CREATED_AT') { return nodes.length ? nodes[0].created_at : null; } else if (sortBy === 'REPLIES') { // The cursor is the start! This is using numeric pagination. return cursor != null ? cursor : 0; } const SORT_KEY = sortBy.toLowerCase(); if ( !ctx.plugins || !ctx.plugins.Sort.Comments || !ctx.plugins.Sort.Comments[SORT_KEY] || !ctx.plugins.Sort.Comments[SORT_KEY].startCursor ) { throw new Error( `unable to sort by ${sortBy}, no plugin was provided to handle this type` ); } return ctx.plugins.Sort.Comments[SORT_KEY].startCursor(ctx, nodes, { cursor, }); }; /** * getEndCursor will fetch the end cursor based on the desired sortBy parameter. * * @param {Object} ctx the graph context * @param {Object} nodes the result set of retrieved comments * @param {Object} params the params from the client describing the query */ const getEndCursor = (ctx, nodes, { cursor, sortBy }) => { if (sortBy === 'CREATED_AT') { return nodes.length ? nodes[nodes.length - 1].created_at : null; } else if (sortBy === 'REPLIES') { return nodes.length ? (cursor != null ? cursor : 0) + nodes.length : null; } const SORT_KEY = sortBy.toLowerCase(); if ( !ctx.plugins || !ctx.plugins.Sort.Comments || !ctx.plugins.Sort.Comments[SORT_KEY] || !ctx.plugins.Sort.Comments[SORT_KEY].endCursor ) { throw new Error( `unable to sort by ${sortBy}, no plugin was provided to handle this type` ); } return ctx.plugins.Sort.Comments[SORT_KEY].endCursor(ctx, nodes, { cursor }); }; /** * applySort will add the actual `.sort` and `.skip/.where` clauses to the query * to apply the desired sort. * * @param {Object} ctx the graph context * @param {Object} query the current mongoose query object * @param {Object} params the params from the client describing the query */ const applySort = (ctx, query, { cursor, sortOrder, sortBy }) => { if (sortBy === 'CREATED_AT') { if (cursor) { if (sortOrder === 'DESC') { query = query.where({ created_at: { $lt: cursor, }, }); } else { query = query.where({ created_at: { $gt: cursor, }, }); } } return query.sort({ created_at: sortOrder === 'DESC' ? -1 : 1 }); } else if (sortBy === 'REPLIES') { if (cursor) { query = query.skip(cursor); } return query.sort({ reply_count: sortOrder === 'DESC' ? -1 : 1, created_at: sortOrder === 'DESC' ? -1 : 1, }); } const SORT_KEY = sortBy.toLowerCase(); if ( !ctx.plugins || !ctx.plugins.Sort.Comments || !ctx.plugins.Sort.Comments[SORT_KEY] || !ctx.plugins.Sort.Comments[SORT_KEY].sort ) { throw new Error( `unable to sort by ${sortBy}, no plugin was provided to handle this type` ); } return ctx.plugins.Sort.Comments[SORT_KEY].sort(ctx, query, { cursor, sortOrder, }); }; /** * executeWithSort will actually retrieve the comments based on the pre-assembled * query and will compose on top the sort operators necessary to get the desired * result. * * @param {Object} ctx the graph context * @param {Object} query the current mongoose query object * @param {Object} params the params from the client describing the query */ const executeWithSort = async ( ctx, query, { cursor, sortOrder, sortBy, limit } ) => { // Apply the sort to the query. query = applySort(ctx, query, { cursor, sortOrder, sortBy }); // Apply the limit (if it exists, as it's applied universally). if (limit >= 0) { query = query.limit(limit + 1); } // Fetch the nodes based on the source query. const nodes = await query.exec(); // The hasNextPage is always handled the same (ask for one more than we need, // if there is one more, than there is more). let hasNextPage = false; if (limit >= 0 && nodes.length > limit) { // There was one more than we expected! Set hasNextPage = true and remove // the last item from the array that we requested. hasNextPage = true; nodes.splice(limit, 1); } // Use the generator functions below to extract the cursor details based on // the current sortBy parameter. return { startCursor: getStartCursor(ctx, nodes, { cursor, sortBy, }), endCursor: getEndCursor(ctx, nodes, { cursor, sortBy }), hasNextPage, nodes, }; }; /** * Retrieves comments based on the passed in query that is filtered by the * current used passed in via the context. * * @param {Object} context graph context * @param {Object} query query terms to apply to the comments query */ const getCommentsByQuery = async ( ctx, { ids, statuses, asset_id, parent_id, author_id, limit, cursor, sortOrder, sortBy, excludeIgnored, excludeDeleted, tags, action_type, } ) => { const query = CommentModel.find(); // Enforce that the limit must be gte 0 if this option is not true. if (!ALLOW_NO_LIMIT_QUERIES && limit < 0) { throw new Error('cannot query for limit < 0'); } // If user queries for statuses other than NONE and/or ACCEPTED statuses, it needs // special privileges. if ( (!statuses || statuses.some(status => !['NONE', 'ACCEPTED'].includes(status))) && (ctx.user == null || !ctx.user.can(SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS)) ) { return null; } if (statuses) { query.merge({ status: { $in: statuses } }); } if (excludeDeleted) { // The null query matches documents that either contain the `deleted_at` // field whose value is null or that do not contain the `deleted_at` field. query.merge({ deleted_at: null }); } if (ctx.user != null && ctx.user.can(SEARCH_OTHERS_COMMENTS) && action_type) { query.merge({ [`action_counts.${sc(action_type.toLowerCase())}`]: { $gt: 0, }, }); } if (ids) { query.merge({ id: { $in: ids, }, }); } if (tags) { query.merge({ 'tags.tag.name': { $in: tags, }, }); } // Only let an admin request any user or the current user request themself. if ( ctx.user && (ctx.user.can(SEARCH_OTHERS_COMMENTS) || ctx.user.id === author_id) && author_id != null ) { query.merge({ author_id }); } if (asset_id) { query.merge({ asset_id }); } // We perform the undefined check because, null, is a valid state for the // search to be with, which indicates that it is at depth 0. if (parent_id !== undefined) { query.merge({ parent_id }); } if ( excludeIgnored && ctx.user && ctx.user.ignoresUsers && ctx.user.ignoresUsers.length > 0 ) { query.merge({ author_id: { $nin: ctx.user.ignoresUsers }, }); } return executeWithSort(ctx, query, { cursor, sortOrder, sortBy, limit }); }; /** * getComments returns the comments by the id's. Only admins can see non-public * comments. * * @param {Object} context graph context * @param {Array} ids the comment id's to fetch * @return {Promise} resolves to the comments */ const getComments = ({ user }, ids) => { let comments; if (user && user.can(SEARCH_OTHERS_COMMENTS)) { comments = CommentModel.find({ id: { $in: ids, }, }); } else { comments = CommentModel.find({ id: { $in: ids, }, status: { $in: ['NONE', 'ACCEPTED'], }, }); } return comments.then(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 => ({ Comments: { get: new DataLoader(ids => getComments(context, ids)), getByQuery: query => getCommentsByQuery(context, query), getCountByQuery: query => getCommentCountByQuery(context, query), countByAssetID: new SharedCounterDataLoader( 'Comments.totalCommentCount', ms(CACHE_EXPIRY_COMMENT_COUNT), ids => getCountsByAssetID(context, ids) ), parentCountByAssetID: new SharedCounterDataLoader( 'Comments.countByAssetID', ms(CACHE_EXPIRY_COMMENT_COUNT), ids => getParentCountsByAssetID(context, ids) ), }, });