Files
speckle-server/packages/server/modules/comments/services/index.ts
T
2024-10-09 10:39:00 +03:00

270 lines
7.7 KiB
TypeScript

import crs from 'crypto-random-string'
import { ForbiddenError } from '@/modules/shared/errors'
import { buildCommentTextFromInput } from '@/modules/comments/services/commentTextService'
import { CommentsEvents, CommentsEventsEmit } from '@/modules/comments/events/emitter'
import { isNonNullable, 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'
import {
CheckStreamResourceAccess,
CheckStreamResourcesAccess,
DeleteComment,
GetComment,
InsertCommentLinks,
InsertComments,
MarkCommentUpdated,
MarkCommentViewed,
UpdateComment,
ValidateInputAttachments
} from '@/modules/comments/domain/operations'
import { ResourceType } from '@/modules/comments/domain/types'
import { GetStream } from '@/modules/core/domain/streams/operations'
export const streamResourceCheckFactory =
(deps: {
checkStreamResourceAccess: CheckStreamResourceAccess
}): CheckStreamResourcesAccess =>
async ({
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) => deps.checkStreamResourceAccess(res, streamId))
)
}
/**
* @deprecated Use 'createCommentThreadAndNotify()' instead
*/
export const createCommentFactory =
(deps: {
checkStreamResourcesAccess: CheckStreamResourcesAccess
validateInputAttachments: ValidateInputAttachments
insertComments: InsertComments
insertCommentLinks: InsertCommentLinks
deleteComment: DeleteComment
markCommentViewed: MarkCommentViewed
commentsEventsEmit: CommentsEventsEmit
}) =>
async ({ 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 = {
streamId: input.streamId,
text: input.text as SmartTextEditorValueSchema,
data: input.data,
screenshot: input.screenshot ?? null
}
await deps.validateInputAttachments(input.streamId, input.blobIds)
comment.text = buildCommentTextFromInput({
doc: input.text,
blobIds: input.blobIds
})
const id = crs({ length: 10 })
const [newComment] = await deps.insertComments([
{
...comment,
id,
authorId: userId
}
])
try {
await deps.checkStreamResourcesAccess({
streamId: input.streamId,
resources: input.resources.filter(isNonNullable)
})
for (const res of input.resources) {
if (!res) continue
await deps.insertCommentLinks([
{
commentId: id,
resourceId: res.resourceId,
resourceType: res.resourceType
}
])
}
} catch (e) {
await deps.deleteComment({ commentId: id }) // roll back
throw e // pass on to resolver
}
await deps.markCommentViewed(id, userId) // so we don't self mark a comment as unread the moment it's created
await deps.commentsEventsEmit(CommentsEvents.Created, {
comment: newComment
})
return newComment
}
/**
* @deprecated Use 'createCommentReplyAndNotify()' instead
*/
export const createCommentReplyFactory =
(deps: {
validateInputAttachments: ValidateInputAttachments
insertComments: InsertComments
insertCommentLinks: InsertCommentLinks
checkStreamResourcesAccess: CheckStreamResourcesAccess
deleteComment: DeleteComment
markCommentUpdated: MarkCommentUpdated
commentsEventsEmit: CommentsEventsEmit
}) =>
async ({
authorId,
parentCommentId,
streamId,
text,
data,
blobIds
}: {
authorId: string
parentCommentId: string
streamId: string
text: SmartTextEditorValue
data: CommentRecord['data']
blobIds: string[]
}) => {
await deps.validateInputAttachments(streamId, blobIds)
const comment = {
id: crs({ length: 10 }),
authorId,
text: buildCommentTextFromInput({ doc: text, blobIds }),
data,
streamId,
parentComment: parentCommentId
}
const [newComment] = await deps.insertComments([comment])
try {
const commentLink: CommentLinkRecord = {
resourceId: parentCommentId,
resourceType: 'comment',
commentId: newComment.id
}
await deps.checkStreamResourcesAccess({
streamId,
resources: [
{
resourceType: commentLink.resourceType as ResourceType,
resourceId: commentLink.resourceId
}
]
})
await deps.insertCommentLinks([commentLink])
} catch (e) {
await deps.deleteComment({ commentId: comment.id }) // roll back
throw e // pass on to resolver
}
await deps.markCommentUpdated(parentCommentId)
await deps.commentsEventsEmit(CommentsEvents.Created, {
comment: newComment
})
return newComment
}
/**
* @deprecated Use 'editCommentAndNotify()'
*/
export const editCommentFactory =
(deps: {
getComment: GetComment
validateInputAttachments: ValidateInputAttachments
updateComment: UpdateComment
commentsEventsEmit: CommentsEventsEmit
}) =>
async ({
userId,
input,
matchUser = false
}: {
userId: string
input: CommentEditInput
matchUser: boolean
}) => {
const editedComment = await deps.getComment({ id: input.id })
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 deps.validateInputAttachments(input.streamId, input.blobIds)
const newText = buildCommentTextFromInput({
doc: input.text,
blobIds: input.blobIds
})
const updatedComment = await deps.updateComment(input.id, { text: newText })
await deps.commentsEventsEmit(CommentsEvents.Updated, {
previousComment: editedComment,
newComment: updatedComment!
})
return updatedComment
}
/**
* @deprecated Use 'archiveCommentAndNotify()'
*/
export const archiveCommentFactory =
(deps: {
getComment: GetComment
getStream: GetStream
updateComment: UpdateComment
}) =>
async ({
commentId,
userId,
streamId,
archived = true
}: {
commentId: string
userId: string
streamId: string
archived: boolean
}) => {
const comment = await deps.getComment({ id: commentId })
if (!comment)
throw new Error(
`No comment ${commentId} exists, cannot change its archival status`
)
const streamWithRole = await deps.getStream({ streamId, userId })
if (comment.authorId !== userId) {
if (!streamWithRole || streamWithRole.role !== Roles.Stream.Owner)
throw new ForbiddenError("You don't have permission to archive the comment")
}
const updatedComment = await deps.updateComment(commentId, { archived })
return updatedComment!
}