diff --git a/planning/v1/ROLES.md b/planning/v1/ROLES.md index 80d9a6e2..474c6fb0 100644 --- a/planning/v1/ROLES.md +++ b/planning/v1/ROLES.md @@ -46,7 +46,6 @@ _TODO: Consider combining reaction permissions in comment roles_ | comment.superadmin | true | true | true | true | true | true | true | --- - ## Issue Section | Role Name | issue.read.unpublished | issue.read.admin | issue.write.new | issue.write.all | issue.write.delete | diff --git a/server/config/apolloServer.js b/server/config/apolloServer.js index e3c1d541..1e7a324c 100644 --- a/server/config/apolloServer.js +++ b/server/config/apolloServer.js @@ -8,6 +8,7 @@ const MediaDataSources = require('../schema/media/media.datasources'); const IssueDataSources = require('../schema/issue/issue.datasources'); const CategoryMapDataSources = require('../schema/categoryMap/categoryMap.datasources'); const ArticleDataSources = require('../schema/article/article.datasources'); +const CommentDataSources = require('../schema/comment/comment.datasources'); const CompanyDataSources = require('../schema/company/company.datasources'); const LiveDataSources = require('../schema/live/live.datasources'); @@ -59,6 +60,7 @@ const apolloServer = (httpServer) => Article: ArticleDataSources(), Company: CompanyDataSources(), Live: LiveDataSources(), + Comment: CommentDataSources(), }, }), debug: !process.env.NODE_ENV || process.env.NODE_ENV === 'development', diff --git a/server/schema/article/article.resolver.js b/server/schema/article/article.resolver.js index 221a4688..7d98f3df 100644 --- a/server/schema/article/article.resolver.js +++ b/server/schema/article/article.resolver.js @@ -348,8 +348,7 @@ module.exports = { createArticle: async ( _parent, { articleType, title, authors, photographers, designers, tech, categoryNumbers }, - { mid, session, authToken, decodedToken, API: { Article, CategoryMap } }, - { fieldNodes } + { mid, session, authToken, decodedToken, API: { Article, CategoryMap } } ) => { try { if (!UserPermission.exists(session, authToken, decodedToken, 'article.write.new')) { diff --git a/server/schema/comment/comment.datasources.js b/server/schema/comment/comment.datasources.js new file mode 100644 index 00000000..1d3bc9d0 --- /dev/null +++ b/server/schema/comment/comment.datasources.js @@ -0,0 +1,101 @@ +const DataLoader = require('dataloader'); +const { APIError } = require('../../utils/exception'); +const UserSession = require('../../utils/userAuth/session'); +const userModel = require('../user/user.model'); +const CommentModel = require('./comment.model'); + +const findByID = () => + new DataLoader( + async (data) => { + try { + const _comments = await CommentModel.find({ _id: { $in: data.map(({ id }) => id) } }); + + const _returnComments = data.map(({ id, permission, mid }) => { + const _comment = _comments.find((comment) => comment._id.toString() === id.toString()); + return _comment && _comment.approved ? _comment : permission || mid === _comment.createdBy ? _comment : null; + }); + return _returnComments; + } catch (error) { + throw APIError(null, error); + } + }, + { + batchScheduleFn: (cb) => setTimeout(cb, 100), + } + ); + +const findAll = (offset, limit, permission, mid) => { + // Get approved comments if the user does not have permission to read unapproved comments and the user is not the author + // Get all comments if the user has permission to read unapproved comments or the user is the author + const query = permission ? {} : { $or: [{ approved: true }, { approved: false, createdBy: mid }] }; + return CommentModel.find(query).sort({ createdAt: 'desc' }).skip(offset).limit(limit); +}; + +const countNumberOfComments = (parentID, parentModel) => + CommentModel.countDocuments({ + 'parent.reference': parentID, + 'parent.model': parentModel, + approved: true, + }); + +const create = async (authorID, content, parentID, parentType, session, authToken, mid, approved) => { + try { + const _author = await userModel.findById(authorID); + if (!_author) { + throw APIError('NOT FOUND', null, 'Invalid Author ID'); + } + + const [_comment] = await CommentModel.create([ + { + content, + author: { + name: _author.fullName, + reference: authorID, + }, + parent: { + reference: parentID, + model: parentType, + }, + approved: approved || false, + createdBy: UserSession.valid(session, authToken) ? mid : null, + }, + ]); + + return _comment; + } catch (error) { + throw APIError(null, error); + } +}; + +const updateContent = async (id, content, session, authToken, mid) => { + try { + const _comment = await CommentModel.findByIdAndUpdate( + id, + { + content, + updatedBy: UserSession.valid(session, authToken) ? mid : null, + }, + { new: true } + ); + + return _comment; + } catch (error) { + throw APIError(null, error); + } +}; + +const approve = (id) => CommentModel.findByIdAndUpdate(id, { approved: true }, { new: true }); + +const remove = (id) => CommentModel.findByIdAndDelete(id); + +const CommentDataSources = () => ({ + findAll, + findByID: findByID(), + countNumberOfComments, + create, + approve, + updateContent, + remove, +}); + +module.exports = CommentDataSources; diff --git a/server/schema/comment/comment.enum.types.js b/server/schema/comment/comment.enum.types.js new file mode 100644 index 00000000..7468be8b --- /dev/null +++ b/server/schema/comment/comment.enum.types.js @@ -0,0 +1,34 @@ +const { + // GraphQLObjectType, + // GraphQLScalarType, + // GraphQLUnionType, + // GraphQLInputObjectType, + GraphQLEnumType, + // GraphQLInterfaceType, + // GraphQLSchema, + // GraphQLNonNull, + // GraphQLError, + // GraphQLList, + // GraphQLString, + // GraphQLID, + // GraphQLBoolean, + // GraphQLInt, + // GraphQLFloat, + // GraphQLDate, + // GraphQLTime, + // GraphQLDateTime, + // GraphQLJSON, + // GraphQLJSONObject, +} = require('../scalars'); + +const CommentParentModelEmum = new GraphQLEnumType({ + name: 'CommentParentModelEnum', + values: { + ARTICLE: { value: 'Article' }, + COMMENT: { value: 'Comment' }, + }, +}); + +module.exports = { + CommentParentModelEmum, +}; diff --git a/server/schema/comment/comment.model.js b/server/schema/comment/comment.model.js index f01c4443..0deb7a36 100644 --- a/server/schema/comment/comment.model.js +++ b/server/schema/comment/comment.model.js @@ -9,7 +9,7 @@ * @since 0.1.0 */ -const { Schema, model } = require('mongoose'); +const { Schema, model, Model: _Model } = require('mongoose'); /** * @description The schema definition for Comment Model @@ -19,13 +19,10 @@ const { Schema, model } = require('mongoose'); */ const CommentSchema = new Schema( { - // TODO: update content with final structure - content: [ - { - type: Object, - required: true, - }, - ], + content: { + type: String, + required: true, + }, author: { name: { type: String, @@ -38,6 +35,11 @@ const CommentSchema = new Schema( trim: true, }, }, + approved: { + type: Boolean, + required: false, + default: false, + }, parent: { model: { type: String, @@ -81,6 +83,6 @@ const CommentSchema = new Schema( * @description Generated Comment Model * @constant CommentModel * - * @type {model} + * @type {_Model} */ module.exports = model('Comment', CommentSchema); diff --git a/server/schema/comment/comment.mutation.js b/server/schema/comment/comment.mutation.js index e69de29b..f0cf6ca2 100644 --- a/server/schema/comment/comment.mutation.js +++ b/server/schema/comment/comment.mutation.js @@ -0,0 +1,68 @@ +const { + GraphQLObjectType, + // GraphQLScalarType, + // GraphQLUnionType, + // GraphQLInputObjectType, + // GraphQLEnumType, + // GraphQLInterfaceType, + // GraphQLSchema, + GraphQLNonNull, + // GraphQLError, + // GraphQLList, + GraphQLString, + GraphQLID, + // GraphQLBoolean, + // GraphQLInt, + // GraphQLFloat, + // GraphQLDate, + // GraphQLTime, + // GraphQLDateTime, + // GraphQLJSON, + // GraphQLJSONObject, +} = require('../scalars'); +const { CommentParentModelEmum } = require('./comment.enum.types'); +const { createComment, deleteComment, updateCommentContent, approveComment } = require('./comment.resolver'); + +const CommentType = require('./comment.type'); + +module.exports = new GraphQLObjectType({ + name: 'CommentMutation', + fields: { + createComment: { + description: 'Create a comment', + type: CommentType, + args: { + content: { type: new GraphQLNonNull(GraphQLString) }, + authorID: { type: new GraphQLNonNull(GraphQLID) }, + parentID: { type: new GraphQLNonNull(GraphQLID) }, + parentType: { type: new GraphQLNonNull(CommentParentModelEmum) }, + }, + resolve: createComment, + }, + approveComment: { + description: 'Approve a comment', + type: CommentType, + args: { + id: { type: new GraphQLNonNull(GraphQLID) }, + }, + resolve: approveComment, + }, + updateCommentContent: { + description: 'Update Comment by Id', + type: CommentType, + args: { + id: { type: new GraphQLNonNull(GraphQLID) }, + content: { type: new GraphQLNonNull(GraphQLString) }, + }, + resolve: updateCommentContent, + }, + deleteComment: { + description: 'Delete comment by Id', + type: CommentType, + args: { + id: { type: new GraphQLNonNull(GraphQLID) }, + }, + resolve: deleteComment, + }, + }, +}); diff --git a/server/schema/comment/comment.query.js b/server/schema/comment/comment.query.js index e69de29b..cadb713c 100644 --- a/server/schema/comment/comment.query.js +++ b/server/schema/comment/comment.query.js @@ -0,0 +1,77 @@ +const { + GraphQLObjectType, + // GraphQLScalarType, + // GraphQLUnionType, + // GraphQLInputObjectType, + // GraphQLEnumType, + // GraphQLInterfaceType, + // GraphQLSchema, + GraphQLNonNull, + // GraphQLError, + GraphQLList, + // GraphQLString, + GraphQLID, + // GraphQLBoolean, + GraphQLInt, + // GraphQLFloat, + // GraphQLDate, + // GraphQLTime, + // GraphQLDateTime, + // GraphQLJSON, + // GraphQLJSONObject, +} = require('../scalars'); +const { CommentParentModelEmum } = require('./comment.enum.types'); +const { getListOfComments, getCommentById, countOfComments } = require('./comment.resolver'); + +const CommentType = require('./comment.type'); + +module.exports = new GraphQLObjectType({ + name: 'CommentQuery', + fields: { + getListOfComments: { + description: 'Retrieves comments for given list of ids (default all) in descending order of creation time', + type: new GraphQLList(new GraphQLNonNull(CommentType)), + args: { + ids: { + description: 'List of Ids of comments to be retrieved', + type: new GraphQLList(new GraphQLNonNull(GraphQLID)), + }, + limit: { + description: 'No. of Comments to be retrieved', + type: GraphQLInt, + }, + offset: { + description: 'No. of Comments to be skipped | pagination', + type: GraphQLInt, + }, + }, + resolve: getListOfComments, + }, + getCommentById: { + description: 'Retrieves single comment based on id', + type: CommentType, + args: { + id: { + description: 'The id of comment to be retrieved', + type: new GraphQLNonNull(GraphQLID), + }, + }, + resolve: getCommentById, + }, + countOfCommentsByParent: { + description: 'The number of comments on a article/comment', + type: GraphQLInt, + args: { + id: { + description: 'Id of article or comment', + type: new GraphQLNonNull(GraphQLID), + }, + parentType: { + description: 'Type of parent', + type: new GraphQLNonNull(CommentParentModelEmum), + }, + }, + resolve: countOfComments, + }, + }, +}); diff --git a/server/schema/comment/comment.resolver.js b/server/schema/comment/comment.resolver.js index e69de29b..f871a8fe 100644 --- a/server/schema/comment/comment.resolver.js +++ b/server/schema/comment/comment.resolver.js @@ -0,0 +1,142 @@ +const { APIError } = require('../../utils/exception'); +const UserPermission = require('../../utils/userAuth/permission'); + +const DEF_LIMIT = 10, + DEF_OFFSET = 0; + +const canMutateComment = async (session, authToken, decodedToken, id, mid, Comment, needsAdmin = false) => { + const _comment = await Comment.findByID.load(id); + + if (!_comment) { + throw APIError('NOT FOUND', null, { reason: 'Requested comments were not found' }); + } + + if ( + // If the user is not the author of the comment or does not have permission to delete his/her own comment + _comment.createdBy !== mid || + (!UserPermission.exists(session, authToken, decodedToken, 'comment.write.self') && + // Furthermore, if the user is not an admin or does not have permission to delete other's comment + needsAdmin && + !UserPermission.exists(session, authToken, decodedToken, 'comment.write.delete')) + ) { + throw APIError('FORBIDDEN', null, 'User does not have required permission to update the comment'); + } +}; + +const canReadUnApprovedComments = (session, authToken, decodedToken) => { + try { + if (!UserPermission.exists(session, authToken, decodedToken, 'comment.read.unapproved')) { + return false; + } + + return true; + } catch (error) { + return false; + } +}; + +module.exports = { + getListOfComments: async ( + _parent, + { ids = null, limit = DEF_LIMIT, offset = DEF_OFFSET }, + { session, authToken, decodedToken, mid, API: { Comment } } + ) => { + try { + const permission = canReadUnApprovedComments(session, authToken, decodedToken); + + const _comments = ids + ? // Gets approved and unapproved comments of the user if the user does not have permission to read unapproved comments + // Gets all comments if the user has permission to read unapproved comments + // Self comments are always returned regardless of permission to be handled by the client + await Promise.all( + ids.slice(offset, offset + limit).map((id) => Comment.findByID.load({ id, permission, mid })) + ) + : await Comment.findAll(offset, limit, permission, mid); + + return _comments.filter((comment) => comment); + } catch (error) { + throw APIError(null, error); + } + }, + getCommentById: async (_parent, { id }, { session, authToken, decodedToken, mid, API: { Comment } }) => { + try { + // Gets only approved comments if the user does not have permission to read unapproved comments + const permission = canReadUnApprovedComments(session, authToken, decodedToken); + + const _comment = await Comment.findByID.load({ id, permission, mid }); + if (!_comment) { + throw APIError('NOT FOUND', null, { reason: 'Invalid id for comment' }); + } + + return _comment; + } catch (error) { + throw APIError(null, error); + } + }, + countOfComments: async (_parent, { id, parentType }, { API: { Comment } }) => { + try { + const _count = await Comment.countNumberOfComments(id, parentType); + return _count; + } catch (error) { + throw APIError(null, error); + } + }, + createComment: async ( + _parent, + { authorID, content, parentID, parentType }, + { session, authToken, decodedToken, mid, API: { Comment } } + ) => { + try { + if (!UserPermission.exists(session, authToken, decodedToken, 'comment.write.new')) { + throw APIError('FORBIDDEN', null, 'User does not have required permission to create comment'); + } + + // User can write pre-approved comments if they have permission to write approved comments + const approved = UserPermission.exists(session, authToken, decodedToken, 'comment.write.approve'); + + const _comment = await Comment.create(authorID, content, parentID, parentType, session, authToken, mid, approved); + + return _comment; + } catch (error) { + throw APIError(null, error); + } + }, + updateCommentContent: async ( + _parent, + { id, content }, + { session, authToken, decodedToken, mid, API: { Comment } } + ) => { + try { + await canMutateComment(session, authToken, decodedToken, id, mid, Comment, false); + const _comment = await Comment.updateContent(id, content, session, authToken, mid); + + return _comment; + } catch (error) { + throw APIError(null, error); + } + }, + approveComment: async (_parent, { id }, { session, authToken, decodedToken, API: { Comment } }) => { + try { + if (!UserPermission.exists(session, authToken, decodedToken, 'comment.approve.all')) { + throw APIError('FORBIDDEN', null, 'User does not have required permission to approve comment'); + } + + const _comment = await Comment.approve(id); + + return _comment; + } catch (error) { + throw APIError(null, error); + } + }, + deleteComment: async (_parent, { id }, { session, authToken, decodedToken, mid, API: { Comment } }) => { + try { + await canMutateComment(session, authToken, decodedToken, id, mid, Comment, true); + + const _comment = await Comment.remove(id); + + return _comment; + } catch (error) { + throw APIError(null, error); + } + }, +}; diff --git a/server/schema/comment/comment.schema.js b/server/schema/comment/comment.schema.js new file mode 100644 index 00000000..d19b55e0 --- /dev/null +++ b/server/schema/comment/comment.schema.js @@ -0,0 +1,8 @@ +const { GraphQLSchema } = require('graphql'); +const CommentQuery = require('./comment.query'); +const CommentMutation = require('./comment.mutation'); + +module.exports = new GraphQLSchema({ + query: CommentQuery, + mutation: CommentMutation, +}); diff --git a/server/schema/comment/comment.type.js b/server/schema/comment/comment.type.js index e69de29b..a82260c2 100644 --- a/server/schema/comment/comment.type.js +++ b/server/schema/comment/comment.type.js @@ -0,0 +1,93 @@ +const { + GraphQLObjectType, + // GraphQLScalarType, + GraphQLUnionType, + // GraphQLInputObjectType, + // GraphQLEnumType, + // GraphQLInterfaceType, + // GraphQLSchema, + GraphQLNonNull, + // GraphQLError, + // GraphQLList, + GraphQLString, + GraphQLID, + GraphQLBoolean, + GraphQLInt, + // GraphQLFloat, + // GraphQLDate, + // GraphQLTime, + GraphQLDateTime, + // GraphQLJSON, + // GraphQLJSONObject, +} = require('../scalars'); + +const ArticleType = require('../article/article.type'); +const { getArticleByID } = require('../article/article.resolver'); +const { getCommentById } = require('./comment.resolver'); +const { getUser } = require('../user/user.resolver'); +const UserType = require('../user/user.type'); +const { CommentParentModelEmum } = require('./comment.enum.types'); + +const ParentType = new GraphQLObjectType({ + name: 'Parent', + fields: () => ({ + model: { type: CommentParentModelEmum }, + reference: { type: GraphQLID }, + + parent: { + // eslint-disable-next-line no-use-before-define + type: ParentUniontype, + resolve: (parent, _args, context, info) => + parent.reference + ? parent.model === 'Article' + ? getArticleByID(parent, { id: parent.reference }, context, info) + : getCommentById(parent, { id: parent.reference }, context, info) + : null, + }, + }), +}); + +const AuthorType = new GraphQLObjectType({ + name: 'Author', + fields: () => ({ + name: { type: GraphQLString }, + reference: { type: GraphQLID }, + user: { + type: UserType, + resolve: (parent, _args, context, info) => + parent.reference ? getUser(parent, { id: parent.reference }, context, info) : null, + }, + }), +}); + +const CommentType = new GraphQLObjectType({ + name: 'Comment', + fields: () => ({ + id: { type: new GraphQLNonNull(GraphQLID) }, + + content: { type: new GraphQLNonNull(GraphQLString) }, + + author: { + type: AuthorType, + }, + + approved: { type: GraphQLBoolean }, + + parent: { type: ParentType }, + + createdAt: { type: GraphQLDateTime }, + createdBy: { type: GraphQLID }, + updatedAt: { type: GraphQLDateTime }, + updatedBy: { type: GraphQLID }, + schemaVersion: { type: GraphQLInt }, + }), +}); + +const ParentUniontype = new GraphQLUnionType({ + name: 'ParentUnion', + description: 'Union of article and comment for parent of comment', + types: [ArticleType, CommentType], + resolveType: (value) => (value.categories ? 'Article' : 'Comment'), +}); + +module.exports = CommentType; diff --git a/server/schema/index.js b/server/schema/index.js index fa6afda3..75a6ed8b 100644 --- a/server/schema/index.js +++ b/server/schema/index.js @@ -25,6 +25,8 @@ const CompanySchema = require('./company/company.schema'); const CompanyType = require('./company/company.type'); const LiveSchema = require('./live/live.schema'); const MediaSchema = require('./media/media.schema'); +const CommentSchema = require('./comment/comment.schema'); +const CommentType = require('./comment/comment.type'); module.exports = stitchSchemas({ subschemas: [ @@ -36,7 +38,8 @@ module.exports = stitchSchemas({ CompanySchema, LiveSchema, MediaSchema, + CommentSchema, ], - types: [MediaType, ContentType, UserDetailType, CategoryMapType, CompanyType], + types: [MediaType, ContentType, UserDetailType, CategoryMapType, CompanyType, CommentType], mergeTypes: true, }); diff --git a/server/utils/userAuth/index.js b/server/utils/userAuth/index.js index a3f4788a..a81679e2 100644 --- a/server/utils/userAuth/index.js +++ b/server/utils/userAuth/index.js @@ -40,7 +40,7 @@ const UserAuth = { return { uid: '', exp: 4102444800, // Jan 1, 2100 at midnight - mid: '', + mid: process.env.MID || '', roles: SUPERADMIN_ROLES, email_verified: true, }; @@ -50,7 +50,7 @@ const UserAuth = { return { uid: '', exp: 4102444800, // Jan 1, 2100 at midnight - mid: '', + mid: process.env.MID || '', roles: SUPERADMIN_ROLES, email_verified: true, }; diff --git a/server/utils/userAuth/role.js b/server/utils/userAuth/role.js index 29a49f42..00d35b5c 100644 --- a/server/utils/userAuth/role.js +++ b/server/utils/userAuth/role.js @@ -16,7 +16,8 @@ const UserRole = { cache: async (_RoleModel = RoleModel) => { try { const _roles = await _RoleModel.find({}, 'name permissions section', { lean: true }); - fs.writeFileSync('./roles.json', JSON.stringify(_roles)); + // Write the roles to a file for caching with proper formatting + fs.writeFileSync('./roles.json', JSON.stringify(_roles, null, 2)); rolesCacheFile = fs.realpathSync('./roles.json'); return rolesCacheFile; } catch (error) {