From 79d4e2d402778a7942f85d28c0549be76256a122 Mon Sep 17 00:00:00 2001 From: Chuck Driesler Date: Wed, 21 Aug 2024 13:03:02 +0100 Subject: [PATCH] chore(comments): update comments module to typescript (#2513) * chore(comments): files to .ts * chore(comment): services to .ts * chore(comments): repo to .ts * chore(comments): resolvers to .ts * chore(comments): init to .ts * fix(comments): FIXME for non-null assertions * chore(comments): drop some comments --- .../resolvers/{comments.js => comments.ts} | 178 ++++---- .../server/modules/comments/helpers/types.ts | 3 +- packages/server/modules/comments/index.js | 19 - packages/server/modules/comments/index.ts | 22 + .../modules/comments/repositories/comments.ts | 10 +- .../server/modules/comments/services/index.js | 324 -------------- .../server/modules/comments/services/index.ts | 395 ++++++++++++++++++ .../modules/shared/utils/subscriptions.ts | 42 +- 8 files changed, 563 insertions(+), 430 deletions(-) rename packages/server/modules/comments/graph/resolvers/{comments.js => comments.ts} (79%) delete mode 100644 packages/server/modules/comments/index.js create mode 100644 packages/server/modules/comments/index.ts delete mode 100644 packages/server/modules/comments/services/index.js create mode 100644 packages/server/modules/comments/services/index.ts diff --git a/packages/server/modules/comments/graph/resolvers/comments.js b/packages/server/modules/comments/graph/resolvers/comments.ts similarity index 79% rename from packages/server/modules/comments/graph/resolvers/comments.js rename to packages/server/modules/comments/graph/resolvers/comments.ts index fd60bbc71..fe69bd6d4 100644 --- a/packages/server/modules/comments/graph/resolvers/comments.js +++ b/packages/server/modules/comments/graph/resolvers/comments.ts @@ -1,10 +1,9 @@ -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 { +import { pubsub } from '@/modules/shared/utils/subscriptions' +import { ForbiddenError as ApolloForbiddenError } from 'apollo-server-express' +import { ForbiddenError } from '@/modules/shared/errors' +import { getStream } from '@/modules/core/services/streams' +import { Roles } from '@/modules/core/helpers/mainConstants' +import { getComments, getResourceCommentCount, createComment, @@ -13,39 +12,37 @@ const { 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 { +} from '@/modules/comments/services/index' +import { getComment } from '@/modules/comments/repositories/comments' +import { ensureCommentSchema } from '@/modules/comments/services/commentTextService' +import { has } from 'lodash' +import { + documentToBasicString, + SmartTextEditorValueSchema +} from '@/modules/core/services/richTextEditorService' +import { getPaginatedCommitComments, getPaginatedBranchComments, getPaginatedProjectComments -} = require('@/modules/comments/services/retrieval') -const { +} from '@/modules/comments/services/retrieval' +import { publish, ViewerSubscriptions, CommentSubscriptions, filteredSubscribe, ProjectSubscriptions -} = require('@/modules/shared/utils/subscriptions') -const { +} from '@/modules/shared/utils/subscriptions' +import { addCommentCreatedActivity, addCommentArchivedActivity, addReplyAddedActivity -} = require('@/modules/activitystream/services/commentActivity') -const { +} from '@/modules/activitystream/services/commentActivity' +import { getViewerResourceItemsUngrouped, getViewerResourcesForComment, doViewerResourcesFit -} = require('@/modules/core/services/commit/viewerResources') -const { +} from '@/modules/core/services/commit/viewerResources' +import { authorizeProjectCommentsAccess, authorizeCommentAccess, markViewed, @@ -53,30 +50,40 @@ const { createCommentReplyAndNotify, editCommentAndNotify, archiveCommentAndNotify -} = require('@/modules/comments/services/management') -const { +} from '@/modules/comments/services/management' +import { isLegacyData, isDataStruct, formatSerializedViewerState, convertStateToLegacyData, convertLegacyDataToState -} = require('@/modules/comments/services/data') +} from '@/modules/comments/services/data' +import { + Resolvers, + ResourceIdentifier, + ResourceType +} from '@/modules/core/graph/generated/graphql' +import { GraphQLContext } from '@/modules/shared/helpers/typeHelper' +import { CommentRecord } from '@/modules/comments/helpers/types' -const getStreamComment = async ({ streamId, commentId }, ctx) => { +const getStreamComment = async ( + { streamId, commentId }: { streamId: string; commentId: string }, + ctx: GraphQLContext +) => { await authorizeProjectCommentsAccess({ projectId: streamId, authCtx: ctx }) const comment = await getComment({ id: commentId, userId: ctx.userId }) - if (comment.streamId !== streamId) + 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 = { +// FIXME: Non-null assertions considered unsafe but are parity with previous .js logic +export = { Query: { async comment(_parent, args, context) { return await getStreamComment( @@ -85,12 +92,18 @@ module.exports = { ) }, - async comments(parent, args, context) { + async comments(_parent, args, context) { await authorizeProjectCommentsAccess({ projectId: args.streamId, authCtx: context }) - return { ...(await getComments({ ...args, userId: context.userId })) } + return { + ...(await getComments({ + ...args, + resources: args.resources?.filter((res): res is ResourceIdentifier => !!res), + userId: context.userId! + })) + } } }, Comment: { @@ -104,7 +117,7 @@ module.exports = { } } - const resources = [{ resourceId: parent.id, resourceType: 'comment' }] + const resources = [{ resourceId: parent.id, resourceType: ResourceType.Comment }] return await getComments({ resources, replies: true, @@ -117,11 +130,13 @@ module.exports = { */ text(parent) { const commentText = parent?.text || '' - return ensureCommentSchema(commentText) + return ensureCommentSchema(commentText as SmartTextEditorValueSchema) }, rawText(parent) { - const { doc } = ensureCommentSchema(parent.text || '') + const { doc } = ensureCommentSchema( + (parent.text as SmartTextEditorValueSchema) || '' + ) return documentToBasicString(doc) }, async hasParent(parent) { @@ -134,11 +149,13 @@ module.exports = { * Resolve resources, if they weren't already preloaded */ async resources(parent, _args, ctx) { - if (has(parent, 'resources')) return parent.resources + if (has(parent, 'resources')) + return (parent as CommentRecord & { resources: ResourceIdentifier[] }).resources return await ctx.loaders.comments.getResources.load(parent.id) }, async viewedAt(parent, _args, ctx) { - if (has(parent, 'viewedAt')) return parent.viewedAt + if (has(parent, 'viewedAt')) + return (parent as CommentRecord & { viewedAt: Date }).viewedAt return await ctx.loaders.comments.getViewedAt.load(parent.id) }, async author(parent, _args, ctx) { @@ -230,14 +247,14 @@ module.exports = { async commentThreads(parent, args, context) { const stream = await context.loaders.commits.getCommitStream.load(parent.id) await authorizeProjectCommentsAccess({ - projectId: stream.id, + projectId: stream!.id, authCtx: context }) return await getPaginatedCommitComments({ ...args, commitId: parent.id, filter: { - ...(args.filter || {}), + includeArchived: false, threadsOnly: true } }) @@ -253,7 +270,7 @@ module.exports = { ...args, branchId: parent.id, filter: { - ...(args.filter || {}), + includeArchived: false, threadsOnly: true } }) @@ -262,14 +279,13 @@ module.exports = { ViewerUserActivityMessage: { async user(parent, args, context) { const { userId } = parent - return context.loaders.users.getUser.load(userId) + 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) } }, @@ -293,7 +309,7 @@ module.exports = { authCtx: ctx, commentId: args.commentId }) - await markViewed(args.commentId, ctx.userId) + await markViewed(args.commentId, ctx.userId!) return true }, async create(_parent, args, ctx) { @@ -302,7 +318,7 @@ module.exports = { authCtx: ctx, requireProjectRole: true }) - return await createCommentThreadAndNotify(args.input, ctx.userId) + return await createCommentThreadAndNotify(args.input, ctx.userId!) }, async reply(_parent, args, ctx) { await authorizeCommentAccess({ @@ -310,7 +326,7 @@ module.exports = { authCtx: ctx, requireProjectRole: true }) - return await createCommentReplyAndNotify(args.input, ctx.userId) + return await createCommentReplyAndNotify(args.input, ctx.userId!) }, async edit(_parent, args, ctx) { await authorizeCommentAccess({ @@ -318,7 +334,7 @@ module.exports = { commentId: args.input.commentId, requireProjectRole: true }) - return await editCommentAndNotify(args.input, ctx.userId) + return await editCommentAndNotify(args.input, ctx.userId!) }, async archive(_parent, args, ctx) { await authorizeCommentAccess({ @@ -326,7 +342,7 @@ module.exports = { commentId: args.commentId, requireProjectRole: true }) - await archiveCommentAndNotify(args.commentId, ctx.userId, args.archived) + await archiveCommentAndNotify(args.commentId, ctx.userId!, args.archived) return true } }, @@ -342,7 +358,7 @@ module.exports = { projectId: args.projectId, resourceItems: await getViewerResourceItemsUngrouped(args), viewerUserActivityBroadcasted: args.message, - userId: context.userId + userId: context.userId! }) return true }, @@ -379,7 +395,7 @@ module.exports = { userId: context.userId }) - if (!stream.allowPublicComments && !stream.role) + if (!stream?.allowPublicComments && !stream?.role) throw new ApolloForbiddenError('You are not authorized.') await pubsub.publish(CommentSubscriptions.CommentThreadActivity, { @@ -399,7 +415,7 @@ module.exports = { userId: context.userId }) - if (!stream.allowPublicComments && !stream.role) + if (!stream?.allowPublicComments && !stream?.role) throw new ApolloForbiddenError('You are not authorized.') const comment = await createComment({ @@ -426,7 +442,7 @@ module.exports = { }) const matchUser = !stream.role try { - await editComment({ userId: context.userId, input: args.input, matchUser }) + await editComment({ userId: context.userId!, input: args.input, matchUser }) return true } catch (err) { if (err instanceof ForbiddenError) throw new ApolloForbiddenError(err.message) @@ -440,7 +456,7 @@ module.exports = { projectId: args.streamId, authCtx: context }) - await viewComment({ userId: context.userId, commentId: args.commentId }) + await viewComment({ userId: context.userId!, commentId: args.commentId }) return true }, @@ -453,7 +469,7 @@ module.exports = { let updatedComment try { - updatedComment = await archiveComment({ ...args, userId: context.userId }) // NOTE: permissions check inside service + 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 @@ -462,7 +478,7 @@ module.exports = { await addCommentArchivedActivity({ streamId: args.streamId, commentId: args.commentId, - userId: context.userId, + userId: context.userId!, input: args, comment: updatedComment }) @@ -479,15 +495,15 @@ module.exports = { userId: context.userId }) - if (!stream.allowPublicComments && !stream.role) + 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, + text: args.input.text as SmartTextEditorValueSchema, + data: args.input.data ?? null, blobIds: args.input.blobIds }) @@ -503,15 +519,15 @@ module.exports = { }, Subscription: { userViewerActivity: { - subscribe: withFilter( - () => pubsub.asyncIterator([CommentSubscriptions.ViewerActivity]), + subscribe: filteredSubscribe( + CommentSubscriptions.ViewerActivity, async (payload, variables, context) => { const stream = await getStream({ streamId: payload.streamId, userId: context.userId }) - if (!stream.allowPublicComments && !stream.role) + if (!stream?.allowPublicComments && !stream?.role) throw new ApolloForbiddenError('You are not authorized.') // dont report users activity to himself @@ -527,15 +543,15 @@ module.exports = { ) }, commentActivity: { - subscribe: withFilter( - () => pubsub.asyncIterator([CommentSubscriptions.CommentActivity]), + subscribe: filteredSubscribe( + CommentSubscriptions.CommentActivity, async (payload, variables, context) => { const stream = await getStream({ streamId: payload.streamId, userId: context.userId }) - if (!stream.allowPublicComments && !stream.role) + if (!stream?.allowPublicComments && !stream?.role) throw new ApolloForbiddenError('You are not authorized.') // if we're listening for a stream's root comments events @@ -548,14 +564,18 @@ module.exports = { // 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' - } - }) + resources: variables.resourceIds + .filter((resId): resId is string => !!resId) + .map((resId) => { + return { + resourceId: resId, + resourceType: + resId.length === 10 ? ResourceType.Commit : ResourceType.Object + } + }) }) for (const res of variables.resourceIds) { + if (!res) continue if ( payload.resourceIds.includes(res) && payload.streamId === variables.streamId @@ -566,19 +586,21 @@ module.exports = { } catch { return false } + + return false } ) }, commentThreadActivity: { - subscribe: withFilter( - () => pubsub.asyncIterator([CommentSubscriptions.CommentThreadActivity]), + subscribe: filteredSubscribe( + CommentSubscriptions.CommentThreadActivity, async (payload, variables, context) => { const stream = await getStream({ streamId: payload.streamId, userId: context.userId }) - if (!stream.allowPublicComments && !stream.role) + if (!stream?.allowPublicComments && !stream?.role) throw new ApolloForbiddenError('You are not authorized.') return ( @@ -607,7 +629,7 @@ module.exports = { getViewerResourceItemsUngrouped(target) ]) - if (!stream.isPublic && !stream.role) + if (!stream?.isPublic && !stream?.role) throw new ApolloForbiddenError('You are not authorized.') // dont report users activity to himself @@ -642,7 +664,7 @@ module.exports = { getViewerResourceItemsUngrouped(target) ]) - if (!(stream.isDiscoverable || stream.isPublic) && !stream.role) + if (!(stream?.isDiscoverable || stream?.isPublic) && !stream?.role) throw new ApolloForbiddenError('You are not authorized.') if (!target.resourceIdString) { @@ -659,4 +681,4 @@ module.exports = { ) } } -} +} as Resolvers diff --git a/packages/server/modules/comments/helpers/types.ts b/packages/server/modules/comments/helpers/types.ts index 2b4675cdb..8677fb78c 100644 --- a/packages/server/modules/comments/helpers/types.ts +++ b/packages/server/modules/comments/helpers/types.ts @@ -1,4 +1,5 @@ import { DataStruct, LegacyData } from '@/modules/comments/services/data' +import { SmartTextEditorValueSchema } from '@/modules/core/services/richTextEditorService' import { Nullable } from '@/modules/shared/helpers/typeHelper' export type CommentLinkResourceType = 'stream' | 'commit' | 'object' | 'comment' @@ -9,7 +10,7 @@ export interface CommentRecord { authorId: string createdAt: Date updatedAt: Date - text: Nullable + text: Nullable screenshot: Nullable data: Nullable archived: boolean diff --git a/packages/server/modules/comments/index.js b/packages/server/modules/comments/index.js deleted file mode 100644 index ff9d0e81d..000000000 --- a/packages/server/modules/comments/index.js +++ /dev/null @@ -1,19 +0,0 @@ -const { moduleLogger } = require('@/logging/logging') -const { - notifyUsersOnCommentEvents -} = require('@/modules/comments/services/notifications') - -let unsubFromEvents - -exports.init = async (_, isInitial) => { - moduleLogger.info('🗣 Init comments module') - - if (isInitial) { - unsubFromEvents = await notifyUsersOnCommentEvents() - } -} -exports.finalize = async () => {} -exports.shutdown = async () => { - unsubFromEvents?.() - unsubFromEvents = undefined -} diff --git a/packages/server/modules/comments/index.ts b/packages/server/modules/comments/index.ts new file mode 100644 index 000000000..cbed94686 --- /dev/null +++ b/packages/server/modules/comments/index.ts @@ -0,0 +1,22 @@ +import { moduleLogger } from '@/logging/logging' +import { notifyUsersOnCommentEvents } from '@/modules/comments/services/notifications' +import { Optional, SpeckleModule } from '@/modules/shared/helpers/typeHelper' + +let unsubFromEvents: Optional<() => void> = undefined + +const commentsModule: SpeckleModule = { + async init(_, isInitial) { + moduleLogger.info('🗣 Init comments module') + + if (isInitial) { + unsubFromEvents = await notifyUsersOnCommentEvents() + } + }, + async finalize() {}, + async shutdown() { + unsubFromEvents?.() + unsubFromEvents = undefined + } +} + +export = commentsModule diff --git a/packages/server/modules/comments/repositories/comments.ts b/packages/server/modules/comments/repositories/comments.ts index 700471a7c..8eba1df09 100644 --- a/packages/server/modules/comments/repositories/comments.ts +++ b/packages/server/modules/comments/repositories/comments.ts @@ -466,15 +466,15 @@ export type PaginatedProjectCommentsParams = { cursor?: MaybeNullOrUndefined filter?: MaybeNullOrUndefined< Partial<{ - threadsOnly: boolean - includeArchived: boolean - archivedOnly: boolean - resourceIdString: string + threadsOnly: boolean | null + includeArchived: boolean | null + archivedOnly: boolean | null + resourceIdString: string | null /** * If true, will ignore the version parts of `model@version` identifiers and look for comments of * all versions of any selected comments */ - allModelVersions: boolean + allModelVersions: boolean | null }> > } diff --git a/packages/server/modules/comments/services/index.js b/packages/server/modules/comments/services/index.js deleted file mode 100644 index 93231162b..000000000 --- a/packages/server/modules/comments/services/index.js +++ /dev/null @@ -1,324 +0,0 @@ -'use strict' -const crs = require('crypto-random-string') -const knex = require('@/db/knex') -const { ForbiddenError } = require('@/modules/shared/errors') -const { - buildCommentTextFromInput, - validateInputAttachments -} = require('@/modules/comments/services/commentTextService') -const { CommentsEmitter, CommentsEvents } = require('@/modules/comments/events/emitter') -const { - getComment, - getStreamCommentCount, - markCommentViewed -} = require('@/modules/comments/repositories/comments') -const { clamp } = require('lodash') -const { Roles } = require('@speckle/shared') - -const Comments = () => knex('comments') -const CommentLinks = () => knex('comment_links') - -const resourceCheck = async (res, streamId) => { - // The switch of doom: if something throws, we're out - switch (res.resourceType) { - case 'stream': - // Stream validity is already checked, so we can just go ahead. - break - case 'commit': { - const linkage = await knex('stream_commits') - .select() - .where({ commitId: res.resourceId, streamId }) - .first() - if (!linkage) throw new Error('Commit not found') - if (linkage.streamId !== streamId) - throw new Error( - 'Stop hacking - that commit id is not part of the specified stream.' - ) - break - } - case 'object': { - const obj = await knex('objects') - .select() - .where({ id: res.resourceId, streamId }) - .first() - if (!obj) throw new Error('Object not found') - break - } - case 'comment': { - const comment = await Comments().where({ id: res.resourceId }).first() - if (!comment) throw new Error('Comment not found') - if (comment.streamId !== streamId) - throw new Error( - 'Stop hacking - that comment is not part of the specified stream.' - ) - break - } - default: - throw Error( - `resource type ${res.resourceType} is not supported as a comment target` - ) - } -} - -module.exports = { - async streamResourceCheck({ streamId, resources }) { - // this itches - a for loop with queries... but okay let's hit the road now - await Promise.all(resources.map((res) => resourceCheck(res, streamId))) - }, - - /** - * @deprecated Use 'createCommentThreadAndNotify()' instead - */ - async createComment({ userId, input }) { - if (input.resources.length < 1) - throw Error('Must specify at least one resource as the comment target') - - const commentResource = input.resources.find((r) => r.resourceType === 'comment') - if (commentResource) throw new Error('Please use the comment reply mutation.') - - // Stream checks - const streamResources = input.resources.filter((r) => r.resourceType === 'stream') - if (streamResources.length > 1) - throw Error('Commenting on multiple streams is not supported') - - const [stream] = streamResources - if (stream && stream.resourceId !== input.streamId) - throw Error("Input streamId doesn't match the stream resource.resourceId") - - const comment = { - streamId: input.streamId, - text: input.text, - data: input.data, - screenshot: input.screenshot - } - - comment.id = crs({ length: 10 }) - comment.authorId = userId - - await validateInputAttachments(input.streamId, input.blobIds) - comment.text = buildCommentTextFromInput({ - doc: input.text, - blobIds: input.blobIds - }) - - const [newComment] = await Comments().insert(comment, '*') - try { - await module.exports.streamResourceCheck({ - streamId: input.streamId, - resources: input.resources - }) - for (const res of input.resources) { - await CommentLinks().insert({ - commentId: comment.id, - resourceId: res.resourceId, - resourceType: res.resourceType - }) - } - } catch (e) { - await Comments().where({ id: comment.id }).delete() // roll back - throw e // pass on to resolver - } - await module.exports.viewComment({ userId, commentId: comment.id }) // so we don't self mark a comment as unread the moment it's created - - await CommentsEmitter.emit(CommentsEvents.Created, { - comment: newComment - }) - - return newComment - }, - - /** - * @deprecated Use 'createCommentReplyAndNotify()' instead - */ - async createCommentReply({ - authorId, - parentCommentId, - streamId, - text, - data, - blobIds - }) { - await validateInputAttachments(streamId, blobIds) - const comment = { - id: crs({ length: 10 }), - authorId, - text: buildCommentTextFromInput({ doc: text, blobIds }), - data, - streamId, - parentComment: parentCommentId - } - - const [newComment] = await Comments().insert(comment, '*') - try { - const commentLink = { resourceId: parentCommentId, resourceType: 'comment' } - await module.exports.streamResourceCheck({ - streamId, - resources: [commentLink] - }) - await CommentLinks().insert({ commentId: comment.id, ...commentLink }) - } catch (e) { - await Comments().where({ id: comment.id }).delete() // roll back - throw e // pass on to resolver - } - await Comments().where({ id: parentCommentId }).update({ updatedAt: knex.fn.now() }) - - await CommentsEmitter.emit(CommentsEvents.Created, { - comment: newComment - }) - - return newComment - }, - - /** - * @deprecated Use 'editCommentAndNotify()' - */ - async editComment({ userId, input, matchUser = false }) { - const editedComment = await Comments().where({ id: input.id }).first() - if (!editedComment) throw new Error("The comment doesn't exist") - - if (matchUser && editedComment.authorId !== userId) - throw new ForbiddenError("You cannot edit someone else's comments") - - await validateInputAttachments(input.streamId, input.blobIds) - const newText = buildCommentTextFromInput({ - doc: input.text, - blobIds: input.blobIds - }) - const [updatedComment] = await Comments() - .where({ id: input.id }) - .update({ text: newText }, '*') - - await CommentsEmitter.emit(CommentsEvents.Updated, { - previousComment: editedComment, - newComment: updatedComment - }) - - return updatedComment - }, - - /** - * @deprecated Use 'markCommentViewed()' - */ - async viewComment({ userId, commentId }) { - await markCommentViewed(commentId, userId) - }, - /** - * @deprecated Use repository method - */ - getComment, - /** - * @deprecated Use 'archiveCommentAndNotify()' - */ - async archiveComment({ commentId, userId, streamId, archived = true }) { - const comment = await Comments().where({ id: commentId }).first() - if (!comment) - throw new Error( - `No comment ${commentId} exists, cannot change its archival status` - ) - - const aclEntry = await knex('stream_acl') - .select() - .where({ resourceId: streamId, userId }) - .first() - - if (comment.authorId !== userId) { - if (!aclEntry || aclEntry.role !== Roles.Stream.Owner) - throw new ForbiddenError("You don't have permission to archive the comment") - } - - const [updatedComment] = await Comments() - .where({ id: commentId }) - .update({ archived }, '*') - return updatedComment - }, - - /** - * @deprecated Use `getPaginatedProjectComments()` instead - */ - async getComments({ - resources, - limit, - cursor, - userId = null, - replies = false, - streamId, - archived = false - }) { - const query = knex.with('comms', (cte) => { - cte.select().distinctOn('id').from('comments') - cte.join('comment_links', 'comments.id', '=', 'commentId') - - if (userId) { - // link viewed At - cte.leftOuterJoin('comment_views', (b) => { - b.on('comment_views.commentId', '=', 'comments.id') - b.andOn('comment_views.userId', '=', knex.raw('?', userId)) - }) - } - - if (resources && resources.length !== 0) { - cte.where((q) => { - // link resources - for (const res of resources) { - q.orWhere('comment_links.resourceId', '=', res.resourceId) - } - }) - } else { - cte.where({ streamId }) - } - if (!replies) { - cte.whereNull('parentComment') - } - cte.where('archived', '=', archived) - }) - - query.select().from('comms') - - // total count coming from our cte - query.joinRaw('right join (select count(*) from comms) c(total_count) on true') - - // get comment's all linked resources - query.joinRaw(` - join( - select cl."commentId" as id, JSON_AGG(json_build_object('resourceId', cl."resourceId", 'resourceType', cl."resourceType")) as resources - from comment_links cl - join comms on comms.id = cl."commentId" - group by cl."commentId" - ) res using(id)`) - - if (cursor) { - query.where('createdAt', '<', cursor) - } - - limit = clamp(limit ?? 10, 0, 100) - query.orderBy('createdAt', 'desc') - query.limit(limit || 1) // need at least 1 row to get totalCount - - const rows = await query - const totalCount = rows && rows.length > 0 ? parseInt(rows[0].total_count) : 0 - const nextCursor = rows && rows.length > 0 ? rows[rows.length - 1].createdAt : null - - return { - items: !limit ? [] : rows, - cursor: nextCursor ? nextCursor.toISOString() : null, - totalCount - } - }, - - async getResourceCommentCount({ resourceId }) { - const [res] = await CommentLinks() - .count('commentId') - .where({ resourceId }) - .join('comments', 'comments.id', '=', 'commentId') - .where('comments.archived', '=', false) - - if (res && res.count) { - return parseInt(res.count) - } - return 0 - }, - - async getStreamCommentCount({ streamId }) { - return (await getStreamCommentCount(streamId, { threadsOnly: true })) || 0 - } -} diff --git a/packages/server/modules/comments/services/index.ts b/packages/server/modules/comments/services/index.ts new file mode 100644 index 000000000..835d7e8cf --- /dev/null +++ b/packages/server/modules/comments/services/index.ts @@ -0,0 +1,395 @@ +import crs from 'crypto-random-string' +import knex from '@/db/knex' +import { ForbiddenError } from '@/modules/shared/errors' +import { + buildCommentTextFromInput, + validateInputAttachments +} from '@/modules/comments/services/commentTextService' +import { CommentsEmitter, CommentsEvents } from '@/modules/comments/events/emitter' +import { + getComment as repoGetComment, + getStreamCommentCount as repoGetStreamCommentCount, + markCommentViewed +} from '@/modules/comments/repositories/comments' +import { clamp } from 'lodash' +import { Roles } from '@speckle/shared' +import { + ResourceIdentifier, + CommentCreateInput, + CommentEditInput, + SmartTextEditorValue +} from '@/modules/core/graph/generated/graphql' +import { CommentLinkRecord, CommentRecord } from '@/modules/comments/helpers/types' +import { SmartTextEditorValueSchema } from '@/modules/core/services/richTextEditorService' + +const Comments = () => knex('comments') +const CommentLinks = () => knex('comment_links') + +const resourceCheck = async (res: ResourceIdentifier, streamId: string) => { + // The switch of doom: if something throws, we're out + switch (res.resourceType) { + case 'stream': + // Stream validity is already checked, so we can just go ahead. + break + case 'commit': { + const linkage = await knex('stream_commits') + .select() + .where({ commitId: res.resourceId, streamId }) + .first() + if (!linkage) throw new Error('Commit not found') + if (linkage.streamId !== streamId) + throw new Error( + 'Stop hacking - that commit id is not part of the specified stream.' + ) + break + } + case 'object': { + const obj = await knex('objects') + .select() + .where({ id: res.resourceId, streamId }) + .first() + if (!obj) throw new Error('Object not found') + break + } + case 'comment': { + const comment = await Comments().where({ id: res.resourceId }).first() + if (!comment) throw new Error('Comment not found') + if (comment.streamId !== streamId) + throw new Error( + 'Stop hacking - that comment is not part of the specified stream.' + ) + break + } + default: + throw Error( + `resource type ${res.resourceType} is not supported as a comment target` + ) + } +} + +export async function streamResourceCheck({ + streamId, + resources +}: { + streamId: string + resources: ResourceIdentifier[] +}) { + // this itches - a for loop with queries... but okay let's hit the road now + await Promise.all(resources.map((res) => resourceCheck(res, streamId))) +} + +/** + * @deprecated Use 'createCommentThreadAndNotify()' instead + */ +export async function createComment({ + userId, + input +}: { + userId: string + input: CommentCreateInput +}) { + if (input.resources.length < 1) + throw Error('Must specify at least one resource as the comment target') + + const commentResource = input.resources.find((r) => r?.resourceType === 'comment') + if (commentResource) throw new Error('Please use the comment reply mutation.') + + // Stream checks + const streamResources = input.resources.filter((r) => r?.resourceType === 'stream') + if (streamResources.length > 1) + throw Error('Commenting on multiple streams is not supported') + + const [stream] = streamResources + if (stream && stream.resourceId !== input.streamId) + throw Error("Input streamId doesn't match the stream resource.resourceId") + + const comment: Partial = { + streamId: input.streamId, + text: input.text as SmartTextEditorValueSchema, + data: input.data, + screenshot: input.screenshot ?? null + } + + comment.id = crs({ length: 10 }) + comment.authorId = userId + + await validateInputAttachments(input.streamId, input.blobIds) + comment.text = buildCommentTextFromInput({ + doc: input.text, + blobIds: input.blobIds + }) as unknown as string + + const [newComment] = await Comments().insert(comment, '*') + try { + await module.exports.streamResourceCheck({ + streamId: input.streamId, + resources: input.resources + }) + for (const res of input.resources) { + if (!res) continue + await CommentLinks().insert({ + commentId: comment.id, + resourceId: res.resourceId, + resourceType: res.resourceType + }) + } + } catch (e) { + await Comments().where({ id: comment.id }).delete() // roll back + throw e // pass on to resolver + } + await module.exports.viewComment({ userId, commentId: comment.id }) // so we don't self mark a comment as unread the moment it's created + + await CommentsEmitter.emit(CommentsEvents.Created, { + comment: newComment + }) + + return newComment +} + +/** + * @deprecated Use 'createCommentReplyAndNotify()' instead + */ +export async function createCommentReply({ + authorId, + parentCommentId, + streamId, + text, + data, + blobIds +}: { + authorId: string + parentCommentId: string + streamId: string + text: SmartTextEditorValue + data: CommentRecord['data'] + blobIds: string[] +}) { + await validateInputAttachments(streamId, blobIds) + const comment = { + id: crs({ length: 10 }), + authorId, + text: buildCommentTextFromInput({ doc: text, blobIds }), + data, + streamId, + parentComment: parentCommentId + } + + const [newComment] = await Comments().insert(comment, '*') + try { + const commentLink: Omit = { + resourceId: parentCommentId, + resourceType: 'comment' + } + await module.exports.streamResourceCheck({ + streamId, + resources: [commentLink] + }) + await CommentLinks().insert({ commentId: comment.id, ...commentLink }) + } catch (e) { + await Comments().where({ id: comment.id }).delete() // roll back + throw e // pass on to resolver + } + await Comments().where({ id: parentCommentId }).update({ updatedAt: knex.fn.now() }) + + await CommentsEmitter.emit(CommentsEvents.Created, { + comment: newComment + }) + + return newComment +} + +/** + * @deprecated Use 'editCommentAndNotify()' + */ +export async function editComment({ + userId, + input, + matchUser = false +}: { + userId: string + input: CommentEditInput + matchUser: boolean +}) { + const editedComment = await Comments().where({ id: input.id }).first() + if (!editedComment) throw new Error("The comment doesn't exist") + + if (matchUser && editedComment.authorId !== userId) + throw new ForbiddenError("You cannot edit someone else's comments") + + await validateInputAttachments(input.streamId, input.blobIds) + const newText = buildCommentTextFromInput({ + doc: input.text, + blobIds: input.blobIds + }) + const [updatedComment] = await Comments() + .where({ id: input.id }) + .update({ text: newText }, '*') + + await CommentsEmitter.emit(CommentsEvents.Updated, { + previousComment: editedComment, + newComment: updatedComment + }) + + return updatedComment +} + +/** + * @deprecated Use 'markCommentViewed()' + */ +export async function viewComment({ + userId, + commentId +}: { + userId: string + commentId: string +}) { + await markCommentViewed(commentId, userId) +} +/** + * @deprecated Use repository method + */ +export const getComment = repoGetComment +/** + * @deprecated Use 'archiveCommentAndNotify()' + */ +export async function archiveComment({ + commentId, + userId, + streamId, + archived = true +}: { + commentId: string + userId: string + streamId: string + archived: boolean +}) { + const comment = await Comments().where({ id: commentId }).first() + if (!comment) + throw new Error(`No comment ${commentId} exists, cannot change its archival status`) + + const aclEntry = await knex('stream_acl') + .select() + .where({ resourceId: streamId, userId }) + .first() + + if (comment.authorId !== userId) { + if (!aclEntry || aclEntry.role !== Roles.Stream.Owner) + throw new ForbiddenError("You don't have permission to archive the comment") + } + + const [updatedComment] = await Comments() + .where({ id: commentId }) + .update({ archived }, '*') + return updatedComment +} + +/** + * One of `streamId` or `resources` expected. If both are provided, then + * `resources` takes precedence. + */ +type GetCommentsParams = { + limit?: number | null + cursor?: string | null + userId?: string | null + replies?: boolean | null + archived?: boolean | null +} & ( + | { + resources: ResourceIdentifier[] + streamId?: null + } + | { + resources?: ResourceIdentifier[] | null + streamId: string + } +) + +/** + * @deprecated Use `getPaginatedProjectComments()` instead + */ +export async function getComments({ + resources, + limit, + cursor, + userId = null, + replies = false, + streamId, + archived = false +}: GetCommentsParams) { + const query = knex.with('comms', (cte) => { + cte.select().distinctOn('id').from('comments') + cte.join('comment_links', 'comments.id', '=', 'commentId') + + if (userId) { + // link viewed At + cte.leftOuterJoin('comment_views', (b) => { + b.on('comment_views.commentId', '=', 'comments.id') + b.andOn('comment_views.userId', '=', knex.raw('?', userId)) + }) + } + + if (resources && resources.length !== 0) { + cte.where((q) => { + // link resources + for (const res of resources) { + q.orWhere('comment_links.resourceId', '=', res.resourceId) + } + }) + } else { + cte.where({ streamId }) + } + if (!replies) { + cte.whereNull('parentComment') + } + cte.where('archived', '=', archived) + }) + + query.select().from('comms') + + // total count coming from our cte + query.joinRaw('right join (select count(*) from comms) c(total_count) on true') + + // get comment's all linked resources + query.joinRaw(` + join( + select cl."commentId" as id, JSON_AGG(json_build_object('resourceId', cl."resourceId", 'resourceType', cl."resourceType")) as resources + from comment_links cl + join comms on comms.id = cl."commentId" + group by cl."commentId" + ) res using(id)`) + + if (cursor) { + query.where('createdAt', '<', cursor) + } + + limit = clamp(limit ?? 10, 0, 100) + query.orderBy('createdAt', 'desc') + query.limit(limit || 1) // need at least 1 row to get totalCount + + const rows = await query + const totalCount = rows && rows.length > 0 ? parseInt(rows[0].total_count) : 0 + const nextCursor = rows && rows.length > 0 ? rows[rows.length - 1].createdAt : null + + return { + items: !limit ? [] : rows, + cursor: nextCursor ? nextCursor.toISOString() : null, + totalCount + } +} + +export async function getResourceCommentCount({ resourceId }: { resourceId: string }) { + const [res] = await CommentLinks() + .count('commentId') + .where({ resourceId }) + .join('comments', 'comments.id', '=', 'commentId') + .where('comments.archived', '=', false) + + if (res && res.count) { + return parseInt(String(res.count)) + } + return 0 +} + +export async function getStreamCommentCount({ streamId }: { streamId: string }) { + return (await repoGetStreamCommentCount(streamId, { threadsOnly: true })) || 0 +} diff --git a/packages/server/modules/shared/utils/subscriptions.ts b/packages/server/modules/shared/utils/subscriptions.ts index 33cbff28b..86329816a 100644 --- a/packages/server/modules/shared/utils/subscriptions.ts +++ b/packages/server/modules/shared/utils/subscriptions.ts @@ -31,6 +31,11 @@ import { GendoAiRender, SubscriptionProjectVersionGendoAiRenderUpdatedArgs, SubscriptionProjectVersionGendoAiRenderCreatedArgs, + CommentThreadActivityMessage, + SubscriptionCommentThreadActivityArgs, + MutationUserViewerActivityBroadcastArgs, + SubscriptionUserViewerActivityArgs, + SubscriptionCommentActivityArgs, StreamUpdateInput, ProjectUpdateInput, SubscriptionStreamUpdatedArgs, @@ -48,6 +53,7 @@ import { ProjectTriggeredAutomationsStatusUpdatedMessageGraphQLReturn, ProjectAutomationsUpdatedMessageGraphQLReturn } from '@/modules/automate/helpers/graphTypes' +import { CommentRecord } from '@/modules/comments/helpers/types' /** * GraphQL Subscription PubSub instance @@ -241,6 +247,35 @@ type SubscriptionTypeMap = { } variables: SubscriptionProjectAutomationsUpdatedArgs } + [CommentSubscriptions.CommentThreadActivity]: { + payload: { + commentThreadActivity: Partial & + Pick + streamId: string + commentId: string + } + variables: SubscriptionCommentThreadActivityArgs + } + [CommentSubscriptions.ViewerActivity]: { + payload: { + userViewerActivity: MutationUserViewerActivityBroadcastArgs + streamId: string + resourceId: string + authorId: string + } + variables: SubscriptionUserViewerActivityArgs + } + [CommentSubscriptions.CommentActivity]: { + payload: { + commentActivity: { + type: 'comment-added' + comment: CommentRecord + } + streamId: string + resourceIds: string[] + } + variables: SubscriptionCommentActivityArgs + } /** * OLD ONES */ @@ -269,11 +304,12 @@ type SubscriptionTypeMap = { } & { [k in SubscriptionEvent]: { payload: unknown; variables: unknown } } type SubscriptionEvent = - | UserSubscriptions - | ProjectSubscriptions - | ViewerSubscriptions + | CommentSubscriptions | FileImportSubscriptions + | ProjectSubscriptions | StreamSubscriptions + | UserSubscriptions + | ViewerSubscriptions /** * Publish a GQL subscription event