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
This commit is contained in:
Chuck Driesler
2024-08-21 13:03:02 +01:00
committed by GitHub
parent 7cc7539ddc
commit 79d4e2d402
8 changed files with 563 additions and 430 deletions
@@ -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
@@ -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<string>
text: Nullable<string | SmartTextEditorValueSchema>
screenshot: Nullable<string>
data: Nullable<LegacyData | DataStruct>
archived: boolean
-19
View File
@@ -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
}
+22
View File
@@ -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
@@ -466,15 +466,15 @@ export type PaginatedProjectCommentsParams = {
cursor?: MaybeNullOrUndefined<string>
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
}>
>
}
@@ -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
}
}
@@ -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<CommentRecord>('comments')
const CommentLinks = () => knex<CommentLinkRecord>('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<CommentRecord> = {
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<CommentLinkRecord, 'commentId'> = {
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
}
@@ -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<CommentThreadActivityMessage> &
Pick<CommentThreadActivityMessage, 'type'>
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