Files
speckle-server/packages/server/modules/comments/graph/resolvers/comments.js
T
Kristaps Fabians Geikins 0b2ca9a515 feat: add missing FE1 fields to the FE2 GQL schema (#2471)
* WIP version create

* commitCreate migrated

* minor cleanup

* commitReceived migrated

* added Project.object

* Project.comment introduced

* moving away old API usages in FE1

* ProjectMutations.batchDelete

* project pending access requests

* WIP project access req tests

* project access req tests done

* ModelByName test

* version mutation tests

* project.object tests

* batch delete tests

* minor improvements to redirect logging
2024-07-10 11:33:53 +02:00

663 lines
20 KiB
JavaScript

const { pubsub } = require('@/modules/shared/utils/subscriptions')
const { ForbiddenError: ApolloForbiddenError } = require('apollo-server-express')
const { ForbiddenError } = require('@/modules/shared/errors')
const { getStream } = require('@/modules/core/services/streams')
const { Roles } = require('@/modules/core/helpers/mainConstants')
const {
getComments,
getResourceCommentCount,
createComment,
createCommentReply,
viewComment,
archiveComment,
editComment,
streamResourceCheck
} = require('@/modules/comments/services/index')
const { getComment } = require('@/modules/comments/repositories/comments')
const {
ensureCommentSchema
} = require('@/modules/comments/services/commentTextService')
const { withFilter } = require('graphql-subscriptions')
const { has } = require('lodash')
const {
documentToBasicString
} = require('@/modules/core/services/richTextEditorService')
const {
getPaginatedCommitComments,
getPaginatedBranchComments,
getPaginatedProjectComments
} = require('@/modules/comments/services/retrieval')
const {
publish,
ViewerSubscriptions,
CommentSubscriptions,
filteredSubscribe,
ProjectSubscriptions
} = require('@/modules/shared/utils/subscriptions')
const {
addCommentCreatedActivity,
addCommentArchivedActivity,
addReplyAddedActivity
} = require('@/modules/activitystream/services/commentActivity')
const {
getViewerResourceItemsUngrouped,
getViewerResourcesForComment,
doViewerResourcesFit
} = require('@/modules/core/services/commit/viewerResources')
const {
authorizeProjectCommentsAccess,
authorizeCommentAccess,
markViewed,
createCommentThreadAndNotify,
createCommentReplyAndNotify,
editCommentAndNotify,
archiveCommentAndNotify
} = require('@/modules/comments/services/management')
const {
isLegacyData,
isDataStruct,
formatSerializedViewerState,
convertStateToLegacyData,
convertLegacyDataToState
} = require('@/modules/comments/services/data')
const getStreamComment = async ({ streamId, commentId }, ctx) => {
await authorizeProjectCommentsAccess({
projectId: streamId,
authCtx: ctx
})
const comment = await getComment({ id: commentId, userId: ctx.userId })
if (comment.streamId !== streamId)
throw new ApolloForbiddenError('You do not have access to this comment.')
return comment
}
/** @type {import('@/modules/core/graph/generated/graphql').Resolvers} */
module.exports = {
Query: {
async comment(_parent, args, context) {
return await getStreamComment(
{ streamId: args.streamId, commentId: args.id },
context
)
},
async comments(parent, args, context) {
await authorizeProjectCommentsAccess({
projectId: args.streamId,
authCtx: context
})
return { ...(await getComments({ ...args, userId: context.userId })) }
}
},
Comment: {
async replies(parent, args, ctx) {
// If limit=0, short-cut full execution and use data loader
if (args.limit === 0) {
return {
totalCount: await ctx.loaders.comments.getReplyCount.load(parent.id),
items: [],
cursor: null
}
}
const resources = [{ resourceId: parent.id, resourceType: 'comment' }]
return await getComments({
resources,
replies: true,
limit: args.limit,
cursor: args.cursor
})
},
/**
* Format comment.text for output, since it can have multiple formats
*/
text(parent) {
const commentText = parent?.text || ''
return ensureCommentSchema(commentText)
},
rawText(parent) {
const { doc } = ensureCommentSchema(parent.text || '')
return documentToBasicString(doc)
},
async hasParent(parent) {
return !!parent.parentComment
},
async parent(parent, _args, ctx) {
return ctx.loaders.comments.getReplyParent.load(parent.id)
},
/**
* Resolve resources, if they weren't already preloaded
*/
async resources(parent, _args, ctx) {
if (has(parent, 'resources')) return parent.resources
return await ctx.loaders.comments.getResources.load(parent.id)
},
async viewedAt(parent, _args, ctx) {
if (has(parent, 'viewedAt')) return parent.viewedAt
return await ctx.loaders.comments.getViewedAt.load(parent.id)
},
async author(parent, _args, ctx) {
return ctx.loaders.users.getUser.load(parent.authorId)
},
async replyAuthors(parent, args, ctx) {
const authorIds = await ctx.loaders.comments.getReplyAuthorIds.load(parent.id)
return {
totalCount: authorIds.length,
authorIds: authorIds.slice(0, args.limit || 25)
}
},
async viewerResources(parent) {
return await getViewerResourcesForComment(parent.streamId, parent.id)
},
/**
* Until recently 'data' was just a JSONObject so theoretically it was possible to return all kinds of object
* structures. So we need to guard against this and ensure we always return the correct thing.
*/
async data(parent) {
const parentData = parent.data
if (!parentData) return null
if (isLegacyData(parentData)) {
return {
location: parentData.location || {},
camPos: parentData.camPos || [],
sectionBox: parentData.sectionBox || null,
selection: parentData.selection || null,
filters: parentData.filters || {}
}
}
if (isDataStruct(parentData)) {
const formattedState = formatSerializedViewerState(parentData.state)
return convertStateToLegacyData(formattedState)
}
return null
},
/**
* SerializedViewerState
*/
async viewerState(parent) {
const parentData = parent.data
if (!parentData) return null
if (isDataStruct(parentData)) {
const formattedState = formatSerializedViewerState(parentData.state)
return formattedState
}
if (isLegacyData(parentData)) {
return convertLegacyDataToState(parentData, parent)
}
return null
}
},
CommentReplyAuthorCollection: {
async items(parent, _args, ctx) {
return await ctx.loaders.users.getUser.loadMany(parent.authorIds)
}
},
Project: {
async commentThreads(parent, args, context) {
await authorizeProjectCommentsAccess({
projectId: parent.id,
authCtx: context
})
return await getPaginatedProjectComments({
...args,
projectId: parent.id,
filter: {
...(args.filter || {}),
allModelVersions: !args.filter?.loadedVersionsOnly,
threadsOnly: true
}
})
},
async comment(parent, args, context) {
return await getStreamComment(
{ streamId: parent.id, commentId: args.id },
context
)
}
},
Version: {
async commentThreads(parent, args, context) {
const stream = await context.loaders.commits.getCommitStream.load(parent.id)
await authorizeProjectCommentsAccess({
projectId: stream.id,
authCtx: context
})
return await getPaginatedCommitComments({
...args,
commitId: parent.id,
filter: {
...(args.filter || {}),
threadsOnly: true
}
})
}
},
Model: {
async commentThreads(parent, args, context) {
await authorizeProjectCommentsAccess({
projectId: parent.streamId,
authCtx: context
})
return await getPaginatedBranchComments({
...args,
branchId: parent.id,
filter: {
...(args.filter || {}),
threadsOnly: true
}
})
}
},
ViewerUserActivityMessage: {
async user(parent, args, context) {
const { userId } = parent
return context.loaders.users.getUser.load(userId)
}
},
Stream: {
async commentCount(parent, _args, context) {
if (context.role === Roles.Server.ArchivedUser)
throw new ApolloForbiddenError('You are not authorized.')
return await context.loaders.streams.getCommentThreadCount.load(parent.id)
}
},
Commit: {
async commentCount(parent, args, context) {
if (context.role === Roles.Server.ArchivedUser)
throw new ApolloForbiddenError('You are not authorized.')
return await getResourceCommentCount({ resourceId: parent.id })
}
},
Object: {
async commentCount(parent, args, context) {
if (context.role === Roles.Server.ArchivedUser)
throw new ApolloForbiddenError('You are not authorized.')
return await getResourceCommentCount({ resourceId: parent.id })
}
},
CommentMutations: {
async markViewed(_parent, args, ctx) {
await authorizeCommentAccess({
authCtx: ctx,
commentId: args.commentId
})
await markViewed(args.commentId, ctx.userId)
return true
},
async create(_parent, args, ctx) {
await authorizeProjectCommentsAccess({
projectId: args.input.projectId,
authCtx: ctx,
requireProjectRole: true
})
return await createCommentThreadAndNotify(args.input, ctx.userId)
},
async reply(_parent, args, ctx) {
await authorizeCommentAccess({
commentId: args.input.threadId,
authCtx: ctx,
requireProjectRole: true
})
return await createCommentReplyAndNotify(args.input, ctx.userId)
},
async edit(_parent, args, ctx) {
await authorizeCommentAccess({
authCtx: ctx,
commentId: args.input.commentId,
requireProjectRole: true
})
return await editCommentAndNotify(args.input, ctx.userId)
},
async archive(_parent, args, ctx) {
await authorizeCommentAccess({
authCtx: ctx,
commentId: args.commentId,
requireProjectRole: true
})
await archiveCommentAndNotify(args.commentId, ctx.userId, args.archived)
return true
}
},
Mutation: {
commentMutations: () => ({}),
async broadcastViewerUserActivity(_parent, args, context) {
await authorizeProjectCommentsAccess({
projectId: args.projectId,
authCtx: context
})
await publish(ViewerSubscriptions.UserActivityBroadcasted, {
projectId: args.projectId,
resourceItems: await getViewerResourceItemsUngrouped(args),
viewerUserActivityBroadcasted: args.message,
userId: context.userId
})
return true
},
async userViewerActivityBroadcast(parent, args, context) {
await authorizeProjectCommentsAccess({
projectId: args.streamId,
authCtx: context
})
// const stream = await getStream({
// streamId: args.streamId,
// userId: context.userId
// })
// if (!stream) {
// throw new ApolloError('Stream not found')
// }
// if (!stream.isPublic && !context.auth) {
// return false
// }
await pubsub.publish(CommentSubscriptions.ViewerActivity, {
userViewerActivity: args.data,
streamId: args.streamId,
resourceId: args.resourceId,
authorId: context.userId
})
return true
},
async userCommentThreadActivityBroadcast(parent, args, context) {
if (!context.userId) return false
const stream = await getStream({
streamId: args.streamId,
userId: context.userId
})
if (!stream.allowPublicComments && !stream.role)
throw new ApolloForbiddenError('You are not authorized.')
await pubsub.publish(CommentSubscriptions.CommentThreadActivity, {
commentThreadActivity: { type: 'reply-typing-status', data: args.data },
streamId: args.streamId,
commentId: args.commentId
})
return true
},
async commentCreate(parent, args, context) {
if (!context.userId)
throw new ApolloForbiddenError('Only registered users can comment.')
const stream = await getStream({
streamId: args.input.streamId,
userId: context.userId
})
if (!stream.allowPublicComments && !stream.role)
throw new ApolloForbiddenError('You are not authorized.')
const comment = await createComment({
userId: context.userId,
input: args.input
})
await addCommentCreatedActivity({
streamId: args.input.streamId,
userId: context.userId,
input: args.input,
comment
})
return comment.id
},
async commentEdit(parent, args, context) {
// NOTE: This is NOT in use anywhere
const stream = await authorizeProjectCommentsAccess({
projectId: args.input.streamId,
authCtx: context,
requireProjectRole: true
})
const matchUser = !stream.role
try {
await editComment({ userId: context.userId, input: args.input, matchUser })
return true
} catch (err) {
if (err instanceof ForbiddenError) throw new ApolloForbiddenError(err.message)
throw err
}
},
// used for flagging a comment as viewed
async commentView(parent, args, context) {
await authorizeProjectCommentsAccess({
projectId: args.streamId,
authCtx: context
})
await viewComment({ userId: context.userId, commentId: args.commentId })
return true
},
async commentArchive(parent, args, context) {
await authorizeProjectCommentsAccess({
projectId: args.streamId,
authCtx: context,
requireProjectRole: true
})
let updatedComment
try {
updatedComment = await archiveComment({ ...args, userId: context.userId }) // NOTE: permissions check inside service
} catch (err) {
if (err instanceof ForbiddenError) throw new ApolloForbiddenError(err.message)
throw err
}
await addCommentArchivedActivity({
streamId: args.streamId,
commentId: args.commentId,
userId: context.userId,
input: args,
comment: updatedComment
})
return true
},
async commentReply(parent, args, context) {
if (!context.userId)
throw new ApolloForbiddenError('Only registered users can comment.')
const stream = await getStream({
streamId: args.input.streamId,
userId: context.userId
})
if (!stream.allowPublicComments && !stream.role)
throw new ApolloForbiddenError('You are not authorized.')
const reply = await createCommentReply({
authorId: context.userId,
parentCommentId: args.input.parentComment,
streamId: args.input.streamId,
text: args.input.text,
data: args.input.data,
blobIds: args.input.blobIds
})
await addReplyAddedActivity({
streamId: args.input.streamId,
input: args.input,
reply,
userId: context.userId
})
return reply.id
}
},
Subscription: {
userViewerActivity: {
subscribe: withFilter(
() => pubsub.asyncIterator([CommentSubscriptions.ViewerActivity]),
async (payload, variables, context) => {
const stream = await getStream({
streamId: payload.streamId,
userId: context.userId
})
if (!stream.allowPublicComments && !stream.role)
throw new ApolloForbiddenError('You are not authorized.')
// dont report users activity to himself
if (context.userId && context.userId === payload.authorId) {
return false
}
return (
payload.streamId === variables.streamId &&
payload.resourceId === variables.resourceId
)
}
)
},
commentActivity: {
subscribe: withFilter(
() => pubsub.asyncIterator([CommentSubscriptions.CommentActivity]),
async (payload, variables, context) => {
const stream = await getStream({
streamId: payload.streamId,
userId: context.userId
})
if (!stream.allowPublicComments && !stream.role)
throw new ApolloForbiddenError('You are not authorized.')
// if we're listening for a stream's root comments events
if (!variables.resourceIds) {
return payload.streamId === variables.streamId
}
// otherwise perform a deeper check
try {
// prevents comment exfiltration by listening in to a auth'ed stream, but different commit ("stream hopping" for subscriptions)
await streamResourceCheck({
streamId: variables.streamId,
resources: variables.resourceIds.map((resId) => {
return {
resourceId: resId,
resourceType: resId.length === 10 ? 'commit' : 'object'
}
})
})
for (const res of variables.resourceIds) {
if (
payload.resourceIds.includes(res) &&
payload.streamId === variables.streamId
) {
return true
}
}
} catch {
return false
}
}
)
},
commentThreadActivity: {
subscribe: withFilter(
() => pubsub.asyncIterator([CommentSubscriptions.CommentThreadActivity]),
async (payload, variables, context) => {
const stream = await getStream({
streamId: payload.streamId,
userId: context.userId
})
if (!stream.allowPublicComments && !stream.role)
throw new ApolloForbiddenError('You are not authorized.')
return (
payload.streamId === variables.streamId &&
payload.commentId === variables.commentId
)
}
)
},
// new subscriptions:
viewerUserActivityBroadcasted: {
subscribe: filteredSubscribe(
ViewerSubscriptions.UserActivityBroadcasted,
async (payload, variables, context) => {
const target = variables.target
const sessionId = variables.sessionId
if (!target.resourceIdString.trim().length) return false
if (payload.projectId !== target.projectId) return false
const [stream, requestedResourceItems] = await Promise.all([
getStream({
streamId: payload.projectId,
userId: context.userId
}),
getViewerResourceItemsUngrouped(target)
])
if (!stream.isPublic && !stream.role)
throw new ApolloForbiddenError('You are not authorized.')
// dont report users activity to himself
if (
sessionId &&
sessionId === payload.viewerUserActivityBroadcasted.sessionId
) {
return false
}
// Check if resources fit
if (doViewerResourcesFit(requestedResourceItems, payload.resourceItems)) {
return true
}
return false
}
)
},
projectCommentsUpdated: {
subscribe: filteredSubscribe(
ProjectSubscriptions.ProjectCommentsUpdated,
async (payload, variables, context) => {
const target = variables.target
if (payload.projectId !== target.projectId) return false
const [stream, requestedResourceItems] = await Promise.all([
getStream({
streamId: payload.projectId,
userId: context.userId
}),
getViewerResourceItemsUngrouped(target)
])
if (!(stream.isDiscoverable || stream.isPublic) && !stream.role)
throw new ApolloForbiddenError('You are not authorized.')
if (!target.resourceIdString) {
return true
}
// Check if resources fit
if (doViewerResourcesFit(requestedResourceItems, payload.resourceItems)) {
return true
}
return false
}
)
}
}
}