diff --git a/codegen.json b/codegen.json index f8e66b6e79c..50e40fa3bbe 100644 --- a/codegen.json +++ b/codegen.json @@ -48,6 +48,9 @@ "mappers": { "SetSlackNotificationPayload": "./types/SetSlackNotificationPayload#SetSlackNotificationPayloadSource", "SetDefaultSlackChannelSuccess": "./types/SetDefaultSlackChannelSuccess#SetDefaultSlackChannelSuccessSource", + "AddCommentSuccess": "./types/AddCommentSuccess#AddCommentSuccessSource", + "DeleteCommentSuccess": "./types/DeleteCommentSuccess#DeleteCommentSuccessSource", + "UpdateCommentContentSuccess": "./types/UpdateCommentContentSuccess#UpdateCommentContentSuccessSource", "AddSlackAuthPayload": "./types/AddSlackAuthPayload#AddSlackAuthPayloadSource", "RemoveAgendaItemPayload": "./types/RemoveAgendaItemPayload#RemoveAgendaItemPayloadSource", "AddAgendaItemPayload": "./types/AddAgendaItemPayload#AddAgendaItemPayloadSource", diff --git a/packages/client/components/DiscussionThreadInput.tsx b/packages/client/components/DiscussionThreadInput.tsx index 5d2e1f0373a..c9ed94f2e90 100644 --- a/packages/client/components/DiscussionThreadInput.tsx +++ b/packages/client/components/DiscussionThreadInput.tsx @@ -196,7 +196,7 @@ const DiscussionThreadInput = forwardRef((props: Props, ref: any) => { isAnonymous: isAnonymousComment, discussionId, threadParentId, - threadSortOrder: getMaxSortOrder() + SORT_STEP + dndNoise() + threadSortOrder: getMaxSortOrder() + SORT_STEP } AddCommentMutation(atmosphere, {comment}, {onError, onCompleted}) // move focus to end is very important! otherwise ghost chars appear @@ -263,7 +263,7 @@ const DiscussionThreadInput = forwardRef((props: Props, ref: any) => { discussionId, meetingId, threadParentId, - threadSortOrder: getMaxSortOrder() + SORT_STEP + dndNoise(), + threadSortOrder: getMaxSortOrder() + SORT_STEP, userId: viewerId, teamId } as const diff --git a/packages/embedder/workflows/helpers/publishSimilarRetroTopics.ts b/packages/embedder/workflows/helpers/publishSimilarRetroTopics.ts index 0f881ee4621..8490667c5eb 100644 --- a/packages/embedder/workflows/helpers/publishSimilarRetroTopics.ts +++ b/packages/embedder/workflows/helpers/publishSimilarRetroTopics.ts @@ -8,6 +8,7 @@ import { buildCommentContentBlock, createAIComment } from '../../../server/graphql/mutations/helpers/addAIGeneratedContentToThreads' +import getKysely from '../../../server/postgres/getKysely' import getPhase from '../../../server/utils/getPhase' import publish from '../../../server/utils/publish' @@ -54,6 +55,7 @@ export const publishSimilarRetroTopics = async ( dataLoader: DataLoaderInstance ) => { const r = await getRethink() + const pg = getKysely() const links = await Promise.all( similarEmbeddings.map((se) => makeSimilarDiscussionLink(se, dataLoader)) ) @@ -68,5 +70,16 @@ export const publishSimilarRetroTopics = async ( 2 ) await r.table('Comment').insert(relatedDiscussionsComment).run() + await pg + .insertInto('Comment') + .values({ + id: relatedDiscussionsComment.id, + content: relatedDiscussionsComment.content, + plaintextContent: relatedDiscussionsComment.plaintextContent, + createdBy: relatedDiscussionsComment.createdBy, + threadSortOrder: relatedDiscussionsComment.threadSortOrder, + discussionId: relatedDiscussionsComment.discussionId + }) + .execute() publishComment(meetingId, relatedDiscussionsComment.id) } diff --git a/packages/server/dataloader/customLoaderMakers.ts b/packages/server/dataloader/customLoaderMakers.ts index 8a5b883dce3..00ef82e4031 100644 --- a/packages/server/dataloader/customLoaderMakers.ts +++ b/packages/server/dataloader/customLoaderMakers.ts @@ -83,31 +83,21 @@ export const commentCountByDiscussionId = ( dependsOn('comments') return new DataLoader( async (discussionIds) => { - const r = await getRethink() - const groups = (await ( - r - .table('Comment') - .getAll(r.args(discussionIds as string[]), {index: 'discussionId'}) - .filter((row: RDatum) => - row('isActive').eq(true).and(row('createdBy').ne(PARABOL_AI_USER_ID)) - ) - .group('discussionId') as any + const commentsByDiscussionId = await Promise.all( + discussionIds.map((discussionId) => parent.get('commentsByDiscussionId').load(discussionId)) ) - .count() - .ungroup() - .run()) as {group: string; reduction: number}[] - const lookup: Record = {} - groups.forEach(({group, reduction}) => { - lookup[group] = reduction + return commentsByDiscussionId.map((commentArr) => { + const activeHumanComments = commentArr.filter( + (comment) => comment.isActive && comment.createdBy !== PARABOL_AI_USER_ID + ) + return activeHumanComments.length }) - return discussionIds.map((discussionId) => lookup[discussionId] || 0) }, { ...parent.dataLoaderOptions } ) } - export const latestTaskEstimates = (parent: RootDataLoader) => { return new DataLoader( async (taskIds) => { diff --git a/packages/server/dataloader/foreignKeyLoaderMakers.ts b/packages/server/dataloader/foreignKeyLoaderMakers.ts index 2ff2918b551..d65ab6b9385 100644 --- a/packages/server/dataloader/foreignKeyLoaderMakers.ts +++ b/packages/server/dataloader/foreignKeyLoaderMakers.ts @@ -2,6 +2,7 @@ import getKysely from '../postgres/getKysely' import {getTeamPromptResponsesByMeetingIds} from '../postgres/queries/getTeamPromptResponsesByMeetingIds' import { selectAgendaItems, + selectComments, selectOrganizations, selectRetroReflections, selectSlackAuths, @@ -205,3 +206,12 @@ export const slackNotificationsByTeamId = foreignKeyLoaderMaker( return selectSlackNotifications().where('teamId', 'in', teamIds).execute() } ) + +export const _pgcommentsByDiscussionId = foreignKeyLoaderMaker( + '_pgcomments', + 'discussionId', + async (discussionIds) => { + // include deleted comments so we can replace them with tombstones + return selectComments().where('discussionId', 'in', discussionIds).execute() + } +) diff --git a/packages/server/dataloader/primaryKeyLoaderMakers.ts b/packages/server/dataloader/primaryKeyLoaderMakers.ts index d514172dde0..c57d6b9adab 100644 --- a/packages/server/dataloader/primaryKeyLoaderMakers.ts +++ b/packages/server/dataloader/primaryKeyLoaderMakers.ts @@ -7,6 +7,7 @@ import getTemplateRefsByIds from '../postgres/queries/getTemplateRefsByIds' import {getUsersByIds} from '../postgres/queries/getUsersByIds' import { selectAgendaItems, + selectComments, selectMeetingSettings, selectOrganizations, selectRetroReflections, @@ -105,3 +106,7 @@ export const slackAuths = primaryKeyLoaderMaker((ids: readonly string[]) => { export const slackNotifications = primaryKeyLoaderMaker((ids: readonly string[]) => { return selectSlackNotifications().where('id', 'in', ids).execute() }) + +export const _pgcomments = primaryKeyLoaderMaker((ids: readonly string[]) => { + return selectComments().where('id', 'in', ids).execute() +}) diff --git a/packages/server/graphql/mutations/addComment.ts b/packages/server/graphql/mutations/addComment.ts deleted file mode 100644 index 1a117aeb780..00000000000 --- a/packages/server/graphql/mutations/addComment.ts +++ /dev/null @@ -1,183 +0,0 @@ -import {GraphQLNonNull} from 'graphql' -import {SubscriptionChannel} from 'parabol-client/types/constEnums' -import normalizeRawDraftJS from 'parabol-client/validation/normalizeRawDraftJS' -import MeetingMemberId from '../../../client/shared/gqlIds/MeetingMemberId' -import TeamMemberId from '../../../client/shared/gqlIds/TeamMemberId' -import getTypeFromEntityMap from '../../../client/utils/draftjs/getTypeFromEntityMap' -import getRethink from '../../database/rethinkDriver' -import Comment from '../../database/types/Comment' -import GenericMeetingPhase, { - NewMeetingPhaseTypeEnum -} from '../../database/types/GenericMeetingPhase' -import GenericMeetingStage from '../../database/types/GenericMeetingStage' -import NotificationDiscussionMentioned from '../../database/types/NotificationDiscussionMentioned' -import NotificationResponseReplied from '../../database/types/NotificationResponseReplied' -import {IGetDiscussionsByIdsQueryResult} from '../../postgres/queries/generated/getDiscussionsByIdsQuery' -import {analytics} from '../../utils/analytics/analytics' -import {getUserId} from '../../utils/authorization' -import publish from '../../utils/publish' -import {GQLContext} from '../graphql' -import publishNotification from '../public/mutations/helpers/publishNotification' -import AddCommentInput from '../types/AddCommentInput' -import AddCommentPayload from '../types/AddCommentPayload' -import {IntegrationNotifier} from './helpers/notifications/IntegrationNotifier' - -type AddCommentMutationVariables = { - comment: { - discussionId: string - content: string - threadSortOrder: number - } -} - -const getMentionNotifications = ( - content: string, - viewerId: string, - discussion: IGetDiscussionsByIdsQueryResult, - commentId: string, - meetingId: string -) => { - let parsedContent: any - try { - parsedContent = JSON.parse(content) - } catch { - // If we can't parse the content, assume no new notifications. - return [] - } - - const {entityMap} = parsedContent - return getTypeFromEntityMap('MENTION', entityMap) - .filter((mentionedUserId) => { - if (mentionedUserId === viewerId) { - return false - } - - if (discussion.discussionTopicType === 'teamPromptResponse') { - const {userId: responseUserId} = TeamMemberId.split(discussion.discussionTopicId) - if (responseUserId === mentionedUserId) { - // The mentioned user will already receive a 'RESPONSE_REPLIED' notification for this - // comment - return false - } - } - - // :TODO: (jmtaber129): Consider limiting these to when the mentionee is *not* on the - // relevant page. - return true - }) - .map( - (mentioneeUserId) => - new NotificationDiscussionMentioned({ - userId: mentioneeUserId, - meetingId: meetingId, - authorId: viewerId, - commentId, - discussionId: discussion.id - }) - ) -} - -const addComment = { - type: new GraphQLNonNull(AddCommentPayload), - description: `Add a comment to a discussion`, - args: { - comment: { - type: new GraphQLNonNull(AddCommentInput), - description: 'A partial new comment' - } - }, - resolve: async ( - _source: unknown, - {comment}: AddCommentMutationVariables, - {authToken, dataLoader, socketId: mutatorId}: GQLContext - ) => { - const r = await getRethink() - const viewerId = getUserId(authToken) - const operationId = dataLoader.share() - const subOptions = {mutatorId, operationId} - - //AUTH - const {discussionId} = comment - const discussion = await dataLoader.get('discussions').load(discussionId) - if (!discussion) { - return {error: {message: 'Invalid discussion thread'}} - } - const {meetingId} = discussion - if (!meetingId) { - return {error: {message: 'Discussion does not take place in a meeting'}} - } - const meetingMemberId = MeetingMemberId.join(meetingId, viewerId) - const [meeting, viewerMeetingMember, viewer] = await Promise.all([ - dataLoader.get('newMeetings').load(meetingId), - dataLoader.get('meetingMembers').load(meetingMemberId), - dataLoader.get('users').loadNonNull(viewerId) - ]) - - if (!viewerMeetingMember) { - return {error: {message: 'Not a member of the meeting'}} - } - - // VALIDATION - const content = normalizeRawDraftJS(comment.content) - - const dbComment = new Comment({...comment, content, createdBy: viewerId}) - const {id: commentId, isAnonymous, threadParentId} = dbComment - await r.table('Comment').insert(dbComment).run() - - if (discussion.discussionTopicType === 'teamPromptResponse') { - const {userId: responseUserId} = TeamMemberId.split(discussion.discussionTopicId) - - if (responseUserId !== viewerId) { - const notification = new NotificationResponseReplied({ - userId: responseUserId, - meetingId: meetingId, - authorId: viewerId, - commentId - }) - - await r.table('Notification').insert(notification).run() - - IntegrationNotifier.sendNotificationToUser?.( - dataLoader, - notification.id, - notification.userId - ) - publishNotification(notification, subOptions) - } - } - - const notificationsToAdd = getMentionNotifications( - content, - viewerId, - discussion, - commentId, - meetingId - ) - - if (notificationsToAdd.length) { - await r.table('Notification').insert(notificationsToAdd).run() - notificationsToAdd.forEach((notification) => { - publishNotification(notification, subOptions) - }) - } - - const data = {commentId, meetingId} - const {phases} = meeting! - const threadablePhases = [ - 'discuss', - 'agendaitems', - 'ESTIMATE', - 'RESPONSES' - ] as NewMeetingPhaseTypeEnum[] - const containsThreadablePhase = phases.find(({phaseType}: GenericMeetingPhase) => - threadablePhases.includes(phaseType) - )! - const {stages} = containsThreadablePhase - const isAsync = stages.some((stage: GenericMeetingStage) => stage.isAsync) - analytics.commentAdded(viewer, meeting, isAnonymous, isAsync, !!threadParentId) - publish(SubscriptionChannel.MEETING, meetingId, 'AddCommentSuccess', data, subOptions) - return data - } -} - -export default addComment diff --git a/packages/server/graphql/mutations/deleteComment.ts b/packages/server/graphql/mutations/deleteComment.ts deleted file mode 100644 index a3910576a4f..00000000000 --- a/packages/server/graphql/mutations/deleteComment.ts +++ /dev/null @@ -1,71 +0,0 @@ -import {GraphQLID, GraphQLNonNull} from 'graphql' -import {SubscriptionChannel} from 'parabol-client/types/constEnums' -import toTeamMemberId from 'parabol-client/utils/relay/toTeamMemberId' -import {PARABOL_AI_USER_ID} from '../../../client/utils/constants' -import getRethink from '../../database/rethinkDriver' -import {getUserId} from '../../utils/authorization' -import publish from '../../utils/publish' -import {GQLContext} from '../graphql' -import DeleteCommentPayload from '../types/DeleteCommentPayload' - -type DeleteCommentMutationVariables = { - commentId: string - meetingId: string -} - -const deleteComment = { - type: new GraphQLNonNull(DeleteCommentPayload), - description: `Delete a comment from a discussion`, - args: { - commentId: { - type: new GraphQLNonNull(GraphQLID) - }, - meetingId: { - type: new GraphQLNonNull(GraphQLID) - } - }, - resolve: async ( - _source: unknown, - {commentId, meetingId}: DeleteCommentMutationVariables, - {authToken, dataLoader, socketId: mutatorId}: GQLContext - ) => { - const r = await getRethink() - const viewerId = getUserId(authToken) - const operationId = dataLoader.share() - const subOptions = {mutatorId, operationId} - const now = new Date() - - //AUTH - const meetingMemberId = toTeamMemberId(meetingId, viewerId) - const [comment, viewerMeetingMember] = await Promise.all([ - r.table('Comment').get(commentId).run(), - dataLoader.get('meetingMembers').load(meetingMemberId), - dataLoader.get('newMeetings').load(meetingId) - ]) - if (!comment || !comment.isActive) { - return {error: {message: 'Comment does not exist'}} - } - if (!viewerMeetingMember) { - return {error: {message: `Not a member of the meeting`}} - } - const {createdBy, discussionId} = comment - const discussion = await dataLoader.get('discussions').loadNonNull(discussionId) - if (discussion.meetingId !== meetingId) { - return {error: {message: `Comment is not from this meeting`}} - } - if (createdBy !== viewerId && createdBy !== PARABOL_AI_USER_ID) { - return {error: {message: 'Can only delete your own comment or Parabol AI comments'}} - } - - await r.table('Comment').get(commentId).update({isActive: false, updatedAt: now}).run() - - const data = {commentId} - - if (meetingId) { - publish(SubscriptionChannel.MEETING, meetingId, 'DeleteCommentSuccess', data, subOptions) - } - return data - } -} - -export default deleteComment diff --git a/packages/server/graphql/mutations/endCheckIn.ts b/packages/server/graphql/mutations/endCheckIn.ts index a3e0c2573da..4ad865c40b0 100644 --- a/packages/server/graphql/mutations/endCheckIn.ts +++ b/packages/server/graphql/mutations/endCheckIn.ts @@ -23,6 +23,7 @@ import getPhase from '../../utils/getPhase' import publish from '../../utils/publish' import standardError from '../../utils/standardError' import {DataLoaderWorker, GQLContext} from '../graphql' +import isValid from '../isValid' import EndCheckInPayload from '../types/EndCheckInPayload' import sendNewMeetingSummary from './helpers/endMeeting/sendNewMeetingSummary' import gatherInsights from './helpers/gatherInsights' @@ -133,6 +134,10 @@ const summarizeCheckInMeeting = async (meeting: MeetingAction, dataLoader: DataL const pinnedAgendaItems = await getPinnedAgendaItems(teamId, dataLoader) const isKill = !!(meetingPhase && ![AGENDA_ITEMS, LAST_CALL].includes(meetingPhase.phaseType)) if (!isKill) await clearAgendaItems(teamId, dataLoader) + const commentCounts = ( + await dataLoader.get('commentCountByDiscussionId').loadMany(discussionIds) + ).filter(isValid) + const commentCount = commentCounts.reduce((cumSum, count) => cumSum + count, 0) await Promise.all([ isKill ? undefined : archiveTasksForDB(doneTasks, meetingId), isKill ? undefined : clonePinnedAgendaItems(pinnedAgendaItems, dataLoader), @@ -143,11 +148,7 @@ const summarizeCheckInMeeting = async (meeting: MeetingAction, dataLoader: DataL .update( { agendaItemCount: activeAgendaItems.length, - commentCount: r - .table('Comment') - .getAll(r.args(discussionIds), {index: 'discussionId'}) - .count() - .default(0) as unknown as number, + commentCount, taskCount: tasks.length }, {nonAtomic: true} diff --git a/packages/server/graphql/mutations/endSprintPoker.ts b/packages/server/graphql/mutations/endSprintPoker.ts index 267aa3eb4cf..00db314a2ab 100644 --- a/packages/server/graphql/mutations/endSprintPoker.ts +++ b/packages/server/graphql/mutations/endSprintPoker.ts @@ -16,6 +16,7 @@ import getRedis from '../../utils/getRedis' import publish from '../../utils/publish' import standardError from '../../utils/standardError' import {GQLContext} from '../graphql' +import isValid from '../isValid' import EndSprintPokerPayload from '../types/EndSprintPokerPayload' import sendNewMeetingSummary from './helpers/endMeeting/sendNewMeetingSummary' import gatherInsights from './helpers/gatherInsights' @@ -77,7 +78,10 @@ export default { ).size const discussionIds = estimateStages.map((stage) => stage.discussionId) const insights = await gatherInsights(meeting, dataLoader) - + const commentCounts = ( + await dataLoader.get('commentCountByDiscussionId').loadMany(discussionIds) + ).filter(isValid) + const commentCount = commentCounts.reduce((cumSum, count) => cumSum + count, 0) const completedMeeting = (await r .table('NewMeeting') .get(meetingId) @@ -85,11 +89,7 @@ export default { { endedAt: now, phases, - commentCount: r - .table('Comment') - .getAll(r.args(discussionIds), {index: 'discussionId'}) - .count() - .default(0) as unknown as number, + commentCount, storyCount, ...insights }, diff --git a/packages/server/graphql/mutations/helpers/addAIGeneratedContentToThreads.ts b/packages/server/graphql/mutations/helpers/addAIGeneratedContentToThreads.ts index fa513232f98..5eae10abdb7 100644 --- a/packages/server/graphql/mutations/helpers/addAIGeneratedContentToThreads.ts +++ b/packages/server/graphql/mutations/helpers/addAIGeneratedContentToThreads.ts @@ -2,6 +2,7 @@ import {PARABOL_AI_USER_ID} from '../../../../client/utils/constants' import getRethink from '../../../database/rethinkDriver' import Comment from '../../../database/types/Comment' import DiscussStage from '../../../database/types/DiscussStage' +import getKysely from '../../../postgres/getKysely' import {convertHtmlToTaskContent} from '../../../utils/draftjs/convertHtmlToTaskContent' import {DataLoaderWorker} from '../../graphql' @@ -45,7 +46,15 @@ const addAIGeneratedContentToThreads = async ( ) comments.push(topicSummaryComment) } - + const pgComments = comments.map((comment) => ({ + id: comment.id, + content: comment.content, + plaintextContent: comment.plaintextContent, + createdBy: comment.createdBy, + threadSortOrder: comment.threadSortOrder, + discussionId: comment.discussionId + })) + await getKysely().insertInto('Comment').values(pgComments).execute() return r.table('Comment').insert(comments).run() }) await Promise.all(commentPromises) diff --git a/packages/server/graphql/mutations/helpers/safeEndRetrospective.ts b/packages/server/graphql/mutations/helpers/safeEndRetrospective.ts index 3f3f0b4be4f..1af261de253 100644 --- a/packages/server/graphql/mutations/helpers/safeEndRetrospective.ts +++ b/packages/server/graphql/mutations/helpers/safeEndRetrospective.ts @@ -1,10 +1,9 @@ import {SubscriptionChannel} from 'parabol-client/types/constEnums' -import {DISCUSS, PARABOL_AI_USER_ID} from 'parabol-client/utils/constants' +import {DISCUSS} from 'parabol-client/utils/constants' import getMeetingPhase from 'parabol-client/utils/getMeetingPhase' import findStageById from 'parabol-client/utils/meetings/findStageById' import {checkTeamsLimit} from '../../../billing/helpers/teamLimitsCheck' import getRethink from '../../../database/rethinkDriver' -import {RDatum} from '../../../database/stricterR' import MeetingRetrospective from '../../../database/types/MeetingRetrospective' import TimelineEventRetroComplete from '../../../database/types/TimelineEventRetroComplete' import getKysely from '../../../postgres/getKysely' @@ -17,6 +16,7 @@ import getPhase from '../../../utils/getPhase' import publish from '../../../utils/publish' import standardError from '../../../utils/standardError' import {InternalContext} from '../../graphql' +import isValid from '../../isValid' import sendNewMeetingSummary from './endMeeting/sendNewMeetingSummary' import gatherInsights from './gatherInsights' import generateWholeMeetingSentimentScore from './generateWholeMeetingSentimentScore' @@ -51,20 +51,16 @@ const summarizeRetroMeeting = async (meeting: MeetingRetrospective, context: Int generateWholeMeetingSummary(discussionIds, meetingId, teamId, facilitatorUserId, dataLoader), getTranscription(recallBotId) ]) - + const commentCounts = ( + await dataLoader.get('commentCountByDiscussionId').loadMany(discussionIds) + ).filter(isValid) + const commentCount = commentCounts.reduce((cumSum, count) => cumSum + count, 0) await r .table('NewMeeting') .get(meetingId) .update( { - commentCount: r - .table('Comment') - .getAll(r.args(discussionIds), {index: 'discussionId'}) - .filter((row: RDatum) => - row('isActive').eq(true).and(row('createdBy').ne(PARABOL_AI_USER_ID)) - ) - .count() - .default(0) as unknown as number, + commentCount, taskCount: r .table('Task') .getAll(r.args(discussionIds), {index: 'discussionId'}) diff --git a/packages/server/graphql/mutations/resetRetroMeetingToGroupStage.ts b/packages/server/graphql/mutations/resetRetroMeetingToGroupStage.ts index 5e1238fedb9..0e775bc2dc2 100644 --- a/packages/server/graphql/mutations/resetRetroMeetingToGroupStage.ts +++ b/packages/server/graphql/mutations/resetRetroMeetingToGroupStage.ts @@ -109,6 +109,7 @@ const resetRetroMeetingToGroupStage = { .getAll(r.args(discussionIdsToDelete), {index: 'discussionId'}) .delete() .run(), + pg.deleteFrom('Comment').where('discussionId', 'in', discussionIdsToDelete).execute(), r.table('Task').getAll(r.args(discussionIdsToDelete), {index: 'discussionId'}).delete().run(), pg .updateTable('RetroReflectionGroup') diff --git a/packages/server/graphql/mutations/updateCommentContent.ts b/packages/server/graphql/mutations/updateCommentContent.ts deleted file mode 100644 index 362301e9019..00000000000 --- a/packages/server/graphql/mutations/updateCommentContent.ts +++ /dev/null @@ -1,88 +0,0 @@ -import {GraphQLID, GraphQLNonNull, GraphQLString} from 'graphql' -import {SubscriptionChannel} from 'parabol-client/types/constEnums' -import extractTextFromDraftString from 'parabol-client/utils/draftjs/extractTextFromDraftString' -import toTeamMemberId from 'parabol-client/utils/relay/toTeamMemberId' -import normalizeRawDraftJS from 'parabol-client/validation/normalizeRawDraftJS' -import {PARABOL_AI_USER_ID} from '../../../client/utils/constants' -import getRethink from '../../database/rethinkDriver' -import {getUserId} from '../../utils/authorization' -import publish from '../../utils/publish' -import standardError from '../../utils/standardError' -import {GQLContext} from '../graphql' -import UpdateCommentContentPayload from '../types/UpdateCommentContentPayload' - -export default { - type: UpdateCommentContentPayload, - description: 'Update the content of a comment', - args: { - commentId: { - type: new GraphQLNonNull(GraphQLID) - }, - content: { - type: new GraphQLNonNull(GraphQLString), - description: 'A stringified draft-js document containing thoughts' - }, - meetingId: { - type: new GraphQLNonNull(GraphQLID) - } - }, - async resolve( - _source: unknown, - {commentId, content, meetingId}: {commentId: string; content: string; meetingId: string}, - {authToken, dataLoader, socketId: mutatorId}: GQLContext - ) { - const r = await getRethink() - const operationId = dataLoader.share() - const now = new Date() - const subOptions = {operationId, mutatorId} - - // AUTH - const viewerId = getUserId(authToken) - const meetingMemberId = toTeamMemberId(meetingId, viewerId) - const [comment, viewerMeetingMember] = await Promise.all([ - r.table('Comment').get(commentId).run(), - dataLoader.get('meetingMembers').load(meetingMemberId), - dataLoader.get('newMeetings').load(meetingId) - ]) - if (!comment || !comment.isActive) { - return standardError(new Error('comment not found'), {userId: viewerId}) - } - if (!viewerMeetingMember) { - return {error: {message: `Not a member of the meeting`}} - } - const {createdBy, discussionId} = comment - const discussion = await dataLoader.get('discussions').loadNonNull(discussionId) - if (discussion.meetingId !== meetingId) { - return {error: {message: `Comment is not from this meeting`}} - } - if (createdBy !== viewerId && createdBy !== PARABOL_AI_USER_ID) { - return {error: {message: 'Can only update your own comment or Parabol AI comments'}} - } - - // VALIDATION - const normalizedContent = normalizeRawDraftJS(content) - - // RESOLUTION - const plaintextContent = extractTextFromDraftString(normalizedContent) - await r - .table('Comment') - .get(commentId) - .update({content: normalizedContent, plaintextContent, updatedAt: now}) - .run() - - // :TODO: (jmtaber129): diff new and old comment content for mentions and handle notifications - // appropriately. - - const data = {commentId} - if (meetingId) { - publish( - SubscriptionChannel.MEETING, - meetingId, - 'UpdateCommentContentSuccess', - data, - subOptions - ) - } - return data - } -} diff --git a/packages/server/graphql/public/mutations/addComment.ts b/packages/server/graphql/public/mutations/addComment.ts new file mode 100644 index 00000000000..2e0392c062e --- /dev/null +++ b/packages/server/graphql/public/mutations/addComment.ts @@ -0,0 +1,170 @@ +import {SubscriptionChannel} from 'parabol-client/types/constEnums' +import normalizeRawDraftJS from 'parabol-client/validation/normalizeRawDraftJS' +import MeetingMemberId from '../../../../client/shared/gqlIds/MeetingMemberId' +import TeamMemberId from '../../../../client/shared/gqlIds/TeamMemberId' +import getTypeFromEntityMap from '../../../../client/utils/draftjs/getTypeFromEntityMap' +import getRethink from '../../../database/rethinkDriver' +import Comment from '../../../database/types/Comment' +import GenericMeetingPhase, { + NewMeetingPhaseTypeEnum +} from '../../../database/types/GenericMeetingPhase' +import GenericMeetingStage from '../../../database/types/GenericMeetingStage' +import NotificationDiscussionMentioned from '../../../database/types/NotificationDiscussionMentioned' +import NotificationResponseReplied from '../../../database/types/NotificationResponseReplied' +import getKysely from '../../../postgres/getKysely' +import {IGetDiscussionsByIdsQueryResult} from '../../../postgres/queries/generated/getDiscussionsByIdsQuery' +import {analytics} from '../../../utils/analytics/analytics' +import {getUserId} from '../../../utils/authorization' +import publish from '../../../utils/publish' +import {IntegrationNotifier} from '../../mutations/helpers/notifications/IntegrationNotifier' +import {MutationResolvers} from '../resolverTypes' +import publishNotification from './helpers/publishNotification' + +const getMentionNotifications = ( + content: string, + viewerId: string, + discussion: IGetDiscussionsByIdsQueryResult, + commentId: string, + meetingId: string +) => { + let parsedContent: any + try { + parsedContent = JSON.parse(content) + } catch { + // If we can't parse the content, assume no new notifications. + return [] + } + + const {entityMap} = parsedContent + return getTypeFromEntityMap('MENTION', entityMap) + .filter((mentionedUserId) => { + if (mentionedUserId === viewerId) { + return false + } + + if (discussion.discussionTopicType === 'teamPromptResponse') { + const {userId: responseUserId} = TeamMemberId.split(discussion.discussionTopicId) + if (responseUserId === mentionedUserId) { + // The mentioned user will already receive a 'RESPONSE_REPLIED' notification for this + // comment + return false + } + } + + // :TODO: (jmtaber129): Consider limiting these to when the mentionee is *not* on the + // relevant page. + return true + }) + .map( + (mentioneeUserId) => + new NotificationDiscussionMentioned({ + userId: mentioneeUserId, + meetingId: meetingId, + authorId: viewerId, + commentId, + discussionId: discussion.id + }) + ) +} + +const addComment: MutationResolvers['addComment'] = async ( + _source, + {comment}, + {authToken, dataLoader, socketId: mutatorId} +) => { + const r = await getRethink() + const viewerId = getUserId(authToken) + const operationId = dataLoader.share() + const subOptions = {mutatorId, operationId} + + //AUTH + const {discussionId} = comment + const discussion = await dataLoader.get('discussions').load(discussionId) + if (!discussion) { + return {error: {message: 'Invalid discussion thread'}} + } + const {meetingId} = discussion + if (!meetingId) { + return {error: {message: 'Discussion does not take place in a meeting'}} + } + const meetingMemberId = MeetingMemberId.join(meetingId, viewerId) + const [meeting, viewerMeetingMember, viewer] = await Promise.all([ + dataLoader.get('newMeetings').load(meetingId), + dataLoader.get('meetingMembers').load(meetingMemberId), + dataLoader.get('users').loadNonNull(viewerId) + ]) + + if (!viewerMeetingMember) { + return {error: {message: 'Not a member of the meeting'}} + } + + // VALIDATION + const content = normalizeRawDraftJS(comment.content) + + const dbComment = new Comment({...comment, content, createdBy: viewerId}) + const {id: commentId, isAnonymous, threadParentId} = dbComment + await r.table('Comment').insert(dbComment).run() + await getKysely() + .insertInto('Comment') + .values({ + id: dbComment.id, + content: dbComment.content, + plaintextContent: dbComment.plaintextContent, + createdBy: dbComment.createdBy, + threadSortOrder: dbComment.threadSortOrder, + discussionId: dbComment.discussionId + }) + .execute() + + if (discussion.discussionTopicType === 'teamPromptResponse') { + const {userId: responseUserId} = TeamMemberId.split(discussion.discussionTopicId) + + if (responseUserId !== viewerId) { + const notification = new NotificationResponseReplied({ + userId: responseUserId, + meetingId: meetingId, + authorId: viewerId, + commentId + }) + + await r.table('Notification').insert(notification).run() + + IntegrationNotifier.sendNotificationToUser?.(dataLoader, notification.id, notification.userId) + publishNotification(notification, subOptions) + } + } + + const notificationsToAdd = getMentionNotifications( + content, + viewerId, + discussion, + commentId, + meetingId + ) + + if (notificationsToAdd.length) { + await r.table('Notification').insert(notificationsToAdd).run() + notificationsToAdd.forEach((notification) => { + publishNotification(notification, subOptions) + }) + } + + const data = {commentId, meetingId} + const {phases} = meeting! + const threadablePhases = [ + 'discuss', + 'agendaitems', + 'ESTIMATE', + 'RESPONSES' + ] as NewMeetingPhaseTypeEnum[] + const containsThreadablePhase = phases.find(({phaseType}: GenericMeetingPhase) => + threadablePhases.includes(phaseType) + )! + const {stages} = containsThreadablePhase + const isAsync = stages.some((stage: GenericMeetingStage) => stage.isAsync) + analytics.commentAdded(viewer, meeting, isAnonymous, isAsync, !!threadParentId) + publish(SubscriptionChannel.MEETING, meetingId, 'AddCommentSuccess', data, subOptions) + return data +} + +export default addComment diff --git a/packages/server/graphql/public/mutations/addReactjiToReactable.ts b/packages/server/graphql/public/mutations/addReactjiToReactable.ts index b60543c50ad..93225e8837e 100644 --- a/packages/server/graphql/public/mutations/addReactjiToReactable.ts +++ b/packages/server/graphql/public/mutations/addReactjiToReactable.ts @@ -93,7 +93,6 @@ const addReactjiToReactable: MutationResolvers['addReactjiToReactable'] = async tableName === 'TeamPromptResponse' ? TeamPromptResponseId.split(reactableId) : reactableId const updatePG = async (pgTable: ValueOf) => { - if (pgTable === 'Comment') return if (isRemove) { await pg .updateTable(pgTable) diff --git a/packages/server/graphql/public/mutations/deleteComment.ts b/packages/server/graphql/public/mutations/deleteComment.ts new file mode 100644 index 00000000000..957cbda541a --- /dev/null +++ b/packages/server/graphql/public/mutations/deleteComment.ts @@ -0,0 +1,58 @@ +import {SubscriptionChannel} from 'parabol-client/types/constEnums' +import toTeamMemberId from 'parabol-client/utils/relay/toTeamMemberId' +import {PARABOL_AI_USER_ID} from '../../../../client/utils/constants' +import getRethink from '../../../database/rethinkDriver' +import getKysely from '../../../postgres/getKysely' +import {getUserId} from '../../../utils/authorization' +import publish from '../../../utils/publish' +import {MutationResolvers} from '../resolverTypes' + +const deleteComment: MutationResolvers['deleteComment'] = async ( + _source, + {commentId, meetingId}, + {authToken, dataLoader, socketId: mutatorId} +) => { + const r = await getRethink() + const viewerId = getUserId(authToken) + const operationId = dataLoader.share() + const subOptions = {mutatorId, operationId} + const now = new Date() + + //AUTH + const meetingMemberId = toTeamMemberId(meetingId, viewerId) + const [comment, viewerMeetingMember] = await Promise.all([ + dataLoader.get('comments').load(commentId), + dataLoader.get('meetingMembers').load(meetingMemberId), + dataLoader.get('newMeetings').load(meetingId) + ]) + if (!comment || !comment.isActive) { + return {error: {message: 'Comment does not exist'}} + } + if (!viewerMeetingMember) { + return {error: {message: `Not a member of the meeting`}} + } + const {createdBy, discussionId} = comment + const discussion = await dataLoader.get('discussions').loadNonNull(discussionId) + if (discussion.meetingId !== meetingId) { + return {error: {message: `Comment is not from this meeting`}} + } + if (createdBy !== viewerId && createdBy !== PARABOL_AI_USER_ID) { + return {error: {message: 'Can only delete your own comment or Parabol AI comments'}} + } + + await r.table('Comment').get(commentId).update({isActive: false, updatedAt: now}).run() + await getKysely() + .updateTable('Comment') + .set({updatedAt: now}) + .where('id', '=', commentId) + .execute() + dataLoader.clearAll('comments') + const data = {commentId} + + if (meetingId) { + publish(SubscriptionChannel.MEETING, meetingId, 'DeleteCommentSuccess', data, subOptions) + } + return data +} + +export default deleteComment diff --git a/packages/server/graphql/public/mutations/updateCommentContent.ts b/packages/server/graphql/public/mutations/updateCommentContent.ts new file mode 100644 index 00000000000..3f11febcc72 --- /dev/null +++ b/packages/server/graphql/public/mutations/updateCommentContent.ts @@ -0,0 +1,71 @@ +import {SubscriptionChannel} from 'parabol-client/types/constEnums' +import extractTextFromDraftString from 'parabol-client/utils/draftjs/extractTextFromDraftString' +import toTeamMemberId from 'parabol-client/utils/relay/toTeamMemberId' +import normalizeRawDraftJS from 'parabol-client/validation/normalizeRawDraftJS' +import {PARABOL_AI_USER_ID} from '../../../../client/utils/constants' +import getRethink from '../../../database/rethinkDriver' +import getKysely from '../../../postgres/getKysely' +import {getUserId} from '../../../utils/authorization' +import publish from '../../../utils/publish' +import standardError from '../../../utils/standardError' +import {MutationResolvers} from '../resolverTypes' + +const updateCommentContent: MutationResolvers['updateCommentContent'] = async ( + _source, + {commentId, content, meetingId}, + {authToken, dataLoader, socketId: mutatorId} +) => { + const r = await getRethink() + const operationId = dataLoader.share() + const now = new Date() + const subOptions = {operationId, mutatorId} + + // AUTH + const viewerId = getUserId(authToken) + const meetingMemberId = toTeamMemberId(meetingId, viewerId) + const [comment, viewerMeetingMember] = await Promise.all([ + dataLoader.get('comments').load(commentId), + dataLoader.get('meetingMembers').load(meetingMemberId) + ]) + if (!comment || !comment.isActive) { + return standardError(new Error('comment not found'), {userId: viewerId}) + } + if (!viewerMeetingMember) { + return {error: {message: `Not a member of the meeting`}} + } + const {createdBy, discussionId} = comment + const discussion = await dataLoader.get('discussions').loadNonNull(discussionId) + if (discussion.meetingId !== meetingId) { + return {error: {message: `Comment is not from this meeting`}} + } + if (createdBy !== viewerId && createdBy !== PARABOL_AI_USER_ID) { + return {error: {message: 'Can only update your own comment or Parabol AI comments'}} + } + + // VALIDATION + const normalizedContent = normalizeRawDraftJS(content) + + // RESOLUTION + const plaintextContent = extractTextFromDraftString(normalizedContent) + await r + .table('Comment') + .get(commentId) + .update({content: normalizedContent, plaintextContent, updatedAt: now}) + .run() + await getKysely() + .updateTable('Comment') + .set({content: normalizedContent, plaintextContent}) + .where('id', '=', commentId) + .execute() + dataLoader.clearAll('comments') + // :TODO: (jmtaber129): diff new and old comment content for mentions and handle notifications + // appropriately. + + const data = {commentId} + if (meetingId) { + publish(SubscriptionChannel.MEETING, meetingId, 'UpdateCommentContentSuccess', data, subOptions) + } + return data +} + +export default updateCommentContent diff --git a/packages/server/graphql/public/typeDefs/AddCommentInput.graphql b/packages/server/graphql/public/typeDefs/AddCommentInput.graphql index d9c2ef2b1ed..9fb75071f5e 100644 --- a/packages/server/graphql/public/typeDefs/AddCommentInput.graphql +++ b/packages/server/graphql/public/typeDefs/AddCommentInput.graphql @@ -13,6 +13,6 @@ input AddCommentInput { foreign key for the discussion this was created in """ discussionId: ID! - threadSortOrder: Float! + threadSortOrder: Int! threadParentId: ID } diff --git a/packages/server/graphql/public/typeDefs/Comment.graphql b/packages/server/graphql/public/typeDefs/Comment.graphql index eadc0fe6fd3..782cb1d5885 100644 --- a/packages/server/graphql/public/typeDefs/Comment.graphql +++ b/packages/server/graphql/public/typeDefs/Comment.graphql @@ -45,7 +45,7 @@ type Comment implements Reactable & Threadable { """ the order of this threadable, relative to threadParentId """ - threadSortOrder: Float + threadSortOrder: Int """ The timestamp the item was updated diff --git a/packages/server/graphql/public/typeDefs/CreatePollInput.graphql b/packages/server/graphql/public/typeDefs/CreatePollInput.graphql index d73109d5924..e12e37221f0 100644 --- a/packages/server/graphql/public/typeDefs/CreatePollInput.graphql +++ b/packages/server/graphql/public/typeDefs/CreatePollInput.graphql @@ -7,7 +7,7 @@ input CreatePollInput { """ The order of this threadable """ - threadSortOrder: Float! + threadSortOrder: Int! """ Poll question diff --git a/packages/server/graphql/public/typeDefs/CreateTaskInput.graphql b/packages/server/graphql/public/typeDefs/CreateTaskInput.graphql index 374821407a8..9a14e61ab8a 100644 --- a/packages/server/graphql/public/typeDefs/CreateTaskInput.graphql +++ b/packages/server/graphql/public/typeDefs/CreateTaskInput.graphql @@ -11,7 +11,7 @@ input CreateTaskInput { foreign key for the thread this was created in """ discussionId: ID - threadSortOrder: Float + threadSortOrder: Int threadParentId: ID sortOrder: Float status: TaskStatusEnum! diff --git a/packages/server/graphql/public/typeDefs/Poll.graphql b/packages/server/graphql/public/typeDefs/Poll.graphql index 62d1a3325d7..0da37ebfb78 100644 --- a/packages/server/graphql/public/typeDefs/Poll.graphql +++ b/packages/server/graphql/public/typeDefs/Poll.graphql @@ -40,7 +40,7 @@ type Poll implements Threadable { """ the order of this threadable, relative to threadParentId """ - threadSortOrder: Float + threadSortOrder: Int """ The timestamp the item was updated diff --git a/packages/server/graphql/public/typeDefs/Task.graphql b/packages/server/graphql/public/typeDefs/Task.graphql index 4ea269155c0..ea3d30bd31f 100644 --- a/packages/server/graphql/public/typeDefs/Task.graphql +++ b/packages/server/graphql/public/typeDefs/Task.graphql @@ -45,7 +45,7 @@ type Task implements Threadable { """ the order of this threadable, relative to threadParentId """ - threadSortOrder: Float + threadSortOrder: Int """ The timestamp the item was updated diff --git a/packages/server/graphql/public/typeDefs/Threadable.graphql b/packages/server/graphql/public/typeDefs/Threadable.graphql index 4f713b5c208..96644234f9b 100644 --- a/packages/server/graphql/public/typeDefs/Threadable.graphql +++ b/packages/server/graphql/public/typeDefs/Threadable.graphql @@ -40,7 +40,7 @@ interface Threadable { """ the order of this threadable, relative to threadParentId """ - threadSortOrder: Float + threadSortOrder: Int """ The timestamp the item was updated diff --git a/packages/server/graphql/public/types/AddCommentSuccess.ts b/packages/server/graphql/public/types/AddCommentSuccess.ts new file mode 100644 index 00000000000..5f946d07872 --- /dev/null +++ b/packages/server/graphql/public/types/AddCommentSuccess.ts @@ -0,0 +1,14 @@ +import {AddCommentSuccessResolvers} from '../resolverTypes' + +export type AddCommentSuccessSource = { + commentId: string + meetingId: string +} + +const AddCommentSuccess: AddCommentSuccessResolvers = { + comment: async ({commentId}, _args, {dataLoader}) => { + return dataLoader.get('comments').load(commentId) + } +} + +export default AddCommentSuccess diff --git a/packages/server/graphql/public/types/DeleteCommentSuccess.ts b/packages/server/graphql/public/types/DeleteCommentSuccess.ts new file mode 100644 index 00000000000..b9f2c86f76d --- /dev/null +++ b/packages/server/graphql/public/types/DeleteCommentSuccess.ts @@ -0,0 +1,13 @@ +import {DeleteCommentSuccessResolvers} from '../resolverTypes' + +export type DeleteCommentSuccessSource = { + commentId: string +} + +const DeleteCommentSuccess: DeleteCommentSuccessResolvers = { + comment: async ({commentId}, _args, {dataLoader}) => { + return dataLoader.get('comments').load(commentId) + } +} + +export default DeleteCommentSuccess diff --git a/packages/server/graphql/public/types/TeamPromptMeeting.ts b/packages/server/graphql/public/types/TeamPromptMeeting.ts index 25f29461423..1bf595b202d 100644 --- a/packages/server/graphql/public/types/TeamPromptMeeting.ts +++ b/packages/server/graphql/public/types/TeamPromptMeeting.ts @@ -5,6 +5,7 @@ import {getTeamPromptResponsesByMeetingId} from '../../../postgres/queries/getTe import {getUserId} from '../../../utils/authorization' import filterTasksByMeeting from '../../../utils/filterTasksByMeeting' import getPhase from '../../../utils/getPhase' +import isValid from '../../isValid' import {TeamPromptMeetingResolvers} from '../resolverTypes' const TeamPromptMeeting: TeamPromptMeetingResolvers = { @@ -98,14 +99,11 @@ const TeamPromptMeeting: TeamPromptMeetingResolvers = { const discussPhase = getPhase(phases, 'RESPONSES') const {stages} = discussPhase const discussionIds = stages.map((stage) => stage.discussionId) - const r = await getRethink() - return r - .table('Comment') - .getAll(r.args(discussionIds), {index: 'discussionId'}) - .filter({isActive: true}) - .count() - .default(0) - .run() + const commentCounts = ( + await dataLoader.get('commentCountByDiscussionId').loadMany(discussionIds) + ).filter(isValid) + const commentCount = commentCounts.reduce((cumSum, count) => cumSum + count, 0) + return commentCount } } diff --git a/packages/server/graphql/public/types/Threadable.ts b/packages/server/graphql/public/types/Threadable.ts index 80fa584c253..0964be02ce2 100644 --- a/packages/server/graphql/public/types/Threadable.ts +++ b/packages/server/graphql/public/types/Threadable.ts @@ -15,7 +15,11 @@ const Threadable: ThreadableResolvers = { createdByUser: ({createdBy}, _args, {dataLoader}) => { return createdBy ? dataLoader.get('users').loadNonNull(createdBy) : null }, - replies: ({replies}) => replies || [] + replies: ({replies}) => replies || [], + // Can remove after Comment is in PG + threadSortOrder: ({threadSortOrder}) => { + return isNaN(threadSortOrder) ? 0 : Math.trunc(threadSortOrder) + } } export default Threadable diff --git a/packages/server/graphql/public/types/UpdateCommentContentSuccess.ts b/packages/server/graphql/public/types/UpdateCommentContentSuccess.ts new file mode 100644 index 00000000000..62c7382325b --- /dev/null +++ b/packages/server/graphql/public/types/UpdateCommentContentSuccess.ts @@ -0,0 +1,13 @@ +import {UpdateCommentContentSuccessResolvers} from '../resolverTypes' + +export type UpdateCommentContentSuccessSource = { + commentId: string +} + +const UpdateCommentContentSuccess: UpdateCommentContentSuccessResolvers = { + comment: async ({commentId}, _args, {dataLoader}) => { + return dataLoader.get('comments').load(commentId) + } +} + +export default UpdateCommentContentSuccess diff --git a/packages/server/graphql/rootMutation.ts b/packages/server/graphql/rootMutation.ts index 71fde7a3e06..ab88c2e03ba 100644 --- a/packages/server/graphql/rootMutation.ts +++ b/packages/server/graphql/rootMutation.ts @@ -1,7 +1,6 @@ import {GraphQLObjectType} from 'graphql' import {GQLContext} from './graphql' import addAtlassianAuth from './mutations/addAtlassianAuth' -import addComment from './mutations/addComment' import addGitHubAuth from './mutations/addGitHubAuth' import addIntegrationProvider from './mutations/addIntegrationProvider' import addOrg from './mutations/addOrg' @@ -20,7 +19,6 @@ import createPoll from './mutations/createPoll' import createReflection from './mutations/createReflection' import createTask from './mutations/createTask' import createTaskIntegration from './mutations/createTaskIntegration' -import deleteComment from './mutations/deleteComment' import deleteTask from './mutations/deleteTask' import deleteUser from './mutations/deleteUser' import denyPushInvitation from './mutations/denyPushInvitation' @@ -92,7 +90,6 @@ import startDraggingReflection from './mutations/startDraggingReflection' import startSprintPoker from './mutations/startSprintPoker' import toggleTeamDrawer from './mutations/toggleTeamDrawer' import updateAzureDevOpsDimensionField from './mutations/updateAzureDevOpsDimensionField' -import updateCommentContent from './mutations/updateCommentContent' import updateDragLocation from './mutations/updateDragLocation' import updateGitHubDimensionField from './mutations/updateGitHubDimensionField' import updateNewCheckInQuestion from './mutations/updateNewCheckInQuestion' @@ -114,7 +111,6 @@ export default new GraphQLObjectType({ fields: () => ({ addAtlassianAuth, - addComment, addPokerTemplateDimension, addPokerTemplateScale, addPokerTemplateScaleValue, @@ -132,7 +128,6 @@ export default new GraphQLObjectType({ createOAuth1AuthorizeUrl, createReflection, createTask, - deleteComment, deleteTask, deleteUser, denyPushInvitation, @@ -190,7 +185,6 @@ export default new GraphQLObjectType({ startDraggingReflection, startSprintPoker, setTaskHighlight, - updateCommentContent, oldUpdateCreditCard, updatePokerTemplateDimensionScale, updatePokerTemplateScaleValue, diff --git a/packages/server/graphql/rootTypes.ts b/packages/server/graphql/rootTypes.ts index d2c9f7cfe3e..6ef6af5d374 100644 --- a/packages/server/graphql/rootTypes.ts +++ b/packages/server/graphql/rootTypes.ts @@ -1,4 +1,3 @@ -import Comment from './types/Comment' import IntegrationProviderOAuth1 from './types/IntegrationProviderOAuth1' import IntegrationProviderOAuth2 from './types/IntegrationProviderOAuth2' import IntegrationProviderWebhook from './types/IntegrationProviderWebhook' @@ -22,7 +21,6 @@ const rootTypes = [ TimelineEventCompletedRetroMeeting, TimelineEventCompletedActionMeeting, TimelineEventPokerComplete, - Comment, JiraDimensionField, RenamePokerTemplatePayload, UserTiersCount diff --git a/packages/server/graphql/types/AddCommentInput.ts b/packages/server/graphql/types/AddCommentInput.ts deleted file mode 100644 index ff86e2917bc..00000000000 --- a/packages/server/graphql/types/AddCommentInput.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { - GraphQLBoolean, - GraphQLFloat, - GraphQLID, - GraphQLInputObjectType, - GraphQLNonNull, - GraphQLString -} from 'graphql' - -const AddCommentInput = new GraphQLInputObjectType({ - name: 'AddCommentInput', - fields: () => ({ - content: { - type: new GraphQLNonNull(GraphQLString), - description: 'A stringified draft-js document containing thoughts' - }, - isAnonymous: { - type: GraphQLBoolean, - description: 'true if the comment should be anonymous' - }, - discussionId: { - type: new GraphQLNonNull(GraphQLID), - description: 'foreign key for the discussion this was created in' - }, - threadSortOrder: { - type: new GraphQLNonNull(GraphQLFloat) - }, - threadParentId: { - type: GraphQLID - } - }) -}) - -export default AddCommentInput diff --git a/packages/server/graphql/types/AddCommentPayload.ts b/packages/server/graphql/types/AddCommentPayload.ts deleted file mode 100644 index c0535e1b9fe..00000000000 --- a/packages/server/graphql/types/AddCommentPayload.ts +++ /dev/null @@ -1,25 +0,0 @@ -import {GraphQLID, GraphQLNonNull, GraphQLObjectType} from 'graphql' -import {GQLContext} from '../graphql' -import Comment from './Comment' -import makeMutationPayload from './makeMutationPayload' - -export const AddCommentSuccess = new GraphQLObjectType({ - name: 'AddCommentSuccess', - fields: () => ({ - comment: { - type: new GraphQLNonNull(Comment), - description: 'the comment just created', - resolve: async ({commentId}, _args: unknown, {dataLoader}) => { - return dataLoader.get('comments').load(commentId) - } - }, - meetingId: { - type: new GraphQLNonNull(GraphQLID), - description: 'The id of the meeting where the comment was added' - } - }) -}) - -const AddCommentPayload = makeMutationPayload('AddCommentPayload', AddCommentSuccess) - -export default AddCommentPayload diff --git a/packages/server/graphql/types/Comment.ts b/packages/server/graphql/types/Comment.ts deleted file mode 100644 index 93b25e00d4f..00000000000 --- a/packages/server/graphql/types/Comment.ts +++ /dev/null @@ -1,29 +0,0 @@ -import {GraphQLObjectType} from 'graphql' -import connectionDefinitions from '../connectionDefinitions' -import GraphQLISO8601Type from './GraphQLISO8601Type' -import PageInfoDateCursor from './PageInfoDateCursor' - -const Comment = new GraphQLObjectType({ - name: 'Comment', - fields: {} -}) - -const {connectionType, edgeType} = connectionDefinitions({ - name: Comment.name, - nodeType: Comment, - edgeFields: () => ({ - cursor: { - type: GraphQLISO8601Type - } - }), - connectionFields: () => ({ - pageInfo: { - type: PageInfoDateCursor, - description: 'Page info with cursors coerced to ISO8601 dates' - } - }) -}) - -export const TaskConnection = connectionType -export const TaskEdge = edgeType -export default Comment diff --git a/packages/server/graphql/types/CreatePollInput.ts b/packages/server/graphql/types/CreatePollInput.ts index ddeb17bf122..70c0bff6deb 100644 --- a/packages/server/graphql/types/CreatePollInput.ts +++ b/packages/server/graphql/types/CreatePollInput.ts @@ -1,7 +1,7 @@ import { - GraphQLFloat, GraphQLID, GraphQLInputObjectType, + GraphQLInt, GraphQLList, GraphQLNonNull, GraphQLString @@ -16,7 +16,7 @@ const CreatePollInput = new GraphQLInputObjectType({ description: 'Foreign key for the discussion this was created in' }, threadSortOrder: { - type: new GraphQLNonNull(GraphQLFloat), + type: new GraphQLNonNull(GraphQLInt), description: 'The order of this threadable' }, title: { diff --git a/packages/server/graphql/types/CreateTaskInput.ts b/packages/server/graphql/types/CreateTaskInput.ts index dc6a7d1999a..6bad42ddbc7 100644 --- a/packages/server/graphql/types/CreateTaskInput.ts +++ b/packages/server/graphql/types/CreateTaskInput.ts @@ -2,6 +2,7 @@ import { GraphQLFloat, GraphQLID, GraphQLInputObjectType, + GraphQLInt, GraphQLNonNull, GraphQLString } from 'graphql' @@ -41,7 +42,7 @@ const CreateTaskInput = new GraphQLInputObjectType({ description: 'foreign key for the thread this was created in' }, threadSortOrder: { - type: GraphQLFloat + type: GraphQLInt }, threadParentId: { type: GraphQLID diff --git a/packages/server/graphql/types/DeleteCommentPayload.ts b/packages/server/graphql/types/DeleteCommentPayload.ts deleted file mode 100644 index 361812d4cb4..00000000000 --- a/packages/server/graphql/types/DeleteCommentPayload.ts +++ /dev/null @@ -1,24 +0,0 @@ -import {GraphQLID, GraphQLNonNull, GraphQLObjectType} from 'graphql' -import {GQLContext} from '../graphql' -import Comment from './Comment' -import makeMutationPayload from './makeMutationPayload' - -export const DeleteCommentSuccess = new GraphQLObjectType({ - name: 'DeleteCommentSuccess', - fields: () => ({ - commentId: { - type: new GraphQLNonNull(GraphQLID) - }, - comment: { - type: new GraphQLNonNull(Comment), - description: 'the comment just deleted', - resolve: async ({commentId}, _args: unknown, {dataLoader}) => { - return dataLoader.get('comments').load(commentId) - } - } - }) -}) - -const DeleteCommentPayload = makeMutationPayload('DeleteCommentPayload', DeleteCommentSuccess) - -export default DeleteCommentPayload diff --git a/packages/server/graphql/types/Poll.ts b/packages/server/graphql/types/Poll.ts index 98de0976f31..0922b4fac7c 100644 --- a/packages/server/graphql/types/Poll.ts +++ b/packages/server/graphql/types/Poll.ts @@ -3,16 +3,13 @@ import PollId from '../../../client/shared/gqlIds/PollId' import {GQLContext} from './../graphql' import PollOption from './PollOption' import Team from './Team' -import Threadable, {threadableFields} from './Threadable' import User from './User' const Poll: GraphQLObjectType = new GraphQLObjectType({ name: 'Poll', description: 'A poll created during the meeting', - interfaces: () => [Threadable], isTypeOf: ({title}) => !!title, fields: () => ({ - ...(threadableFields() as any), createdByUser: { type: new GraphQLNonNull(User), description: 'The user that created the item', diff --git a/packages/server/graphql/types/Task.ts b/packages/server/graphql/types/Task.ts index fcf13730521..d2a8b1b7ad8 100644 --- a/packages/server/graphql/types/Task.ts +++ b/packages/server/graphql/types/Task.ts @@ -32,15 +32,12 @@ import TaskIntegration from './TaskIntegration' import TaskServiceEnum from './TaskServiceEnum' import TaskStatusEnum from './TaskStatusEnum' import Team from './Team' -import Threadable, {threadableFields} from './Threadable' const Task: GraphQLObjectType = new GraphQLObjectType({ name: 'Task', description: 'A long-term task shared across the team, assigned to a single user ', - interfaces: () => [Threadable], isTypeOf: ({status}) => !!status, fields: () => ({ - ...(threadableFields() as any), agendaItem: { type: AgendaItem, description: 'The agenda item that the task was created in, if any', diff --git a/packages/server/graphql/types/Threadable.ts b/packages/server/graphql/types/Threadable.ts deleted file mode 100644 index 3341b404ba9..00000000000 --- a/packages/server/graphql/types/Threadable.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { - GraphQLFloat, - GraphQLID, - GraphQLInterfaceType, - GraphQLList, - GraphQLNonNull, - GraphQLString -} from 'graphql' -import connectionDefinitions from '../connectionDefinitions' -import {GQLContext} from '../graphql' -import {ThreadableSource as ThreadableDB} from '../public/types/Threadable' -import GraphQLISO8601Type from './GraphQLISO8601Type' -import PageInfo from './PageInfo' - -export const threadableFields = () => ({ - id: { - type: new GraphQLNonNull(GraphQLID), - description: 'shortid' - }, - createdAt: { - type: new GraphQLNonNull(GraphQLISO8601Type), - description: 'The timestamp the item was created' - }, - createdBy: { - type: GraphQLID, - description: 'The userId that created the item' - }, - createdByUser: { - type: require('./User').default, - description: 'The user that created the item', - resolve: ({createdBy}: {createdBy: string}, _args: unknown, {dataLoader}: GQLContext) => { - return dataLoader.get('users').load(createdBy) - } - }, - replies: { - type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(Threadable))), - description: 'the replies to this threadable item', - resolve: ({replies}: {replies: ThreadableDB[]}) => replies || [] - }, - discussionId: { - type: GraphQLID, - description: - 'The FK of the discussion this task was created in. Null if task was not created in a discussion', - // can remove the threadId after 2021-07-01 - resolve: ({discussionId, threadId}: {discussionId: string; threadId: string}) => - discussionId || threadId - }, - threadParentId: { - type: GraphQLID, - description: 'the parent, if this threadable is a reply, else null' - }, - threadSortOrder: { - type: GraphQLFloat, - description: 'the order of this threadable, relative to threadParentId' - }, - updatedAt: { - type: new GraphQLNonNull(GraphQLISO8601Type), - description: 'The timestamp the item was updated' - } -}) - -const Threadable: GraphQLInterfaceType = new GraphQLInterfaceType({ - name: 'Threadable', - fields: {} -}) - -const {connectionType, edgeType} = connectionDefinitions({ - name: Threadable.name, - nodeType: Threadable, - edgeFields: () => ({ - cursor: { - type: GraphQLString - } - }), - connectionFields: () => ({ - error: { - type: GraphQLString, - description: 'Any errors that prevented the query from returning the full results' - }, - pageInfo: { - type: PageInfo, - description: 'Page info with strings (sortOrder) as cursors' - } - }) -}) - -export const ThreadableConnection = connectionType -export const ThreadableEdge = edgeType -export default Threadable diff --git a/packages/server/graphql/types/UpdateCommentContentPayload.ts b/packages/server/graphql/types/UpdateCommentContentPayload.ts deleted file mode 100644 index 9d028791b90..00000000000 --- a/packages/server/graphql/types/UpdateCommentContentPayload.ts +++ /dev/null @@ -1,24 +0,0 @@ -import {GraphQLNonNull, GraphQLObjectType} from 'graphql' -import {GQLContext} from '../graphql' -import Comment from './Comment' -import makeMutationPayload from './makeMutationPayload' - -export const UpdateCommentContentSuccess = new GraphQLObjectType({ - name: 'UpdateCommentContentSuccess', - fields: () => ({ - comment: { - type: new GraphQLNonNull(Comment), - description: 'the comment with updated content', - resolve: async ({commentId}, _args: unknown, {dataLoader}) => { - return dataLoader.get('comments').load(commentId) - } - } - }) -}) - -const UpdateCommentContentPayload = makeMutationPayload( - 'UpdateCommentContentPayload', - UpdateCommentContentSuccess -) - -export default UpdateCommentContentPayload diff --git a/packages/server/postgres/migrations/1724780116674_Comment-phase1.ts b/packages/server/postgres/migrations/1724780116674_Comment-phase1.ts new file mode 100644 index 00000000000..5090679072f --- /dev/null +++ b/packages/server/postgres/migrations/1724780116674_Comment-phase1.ts @@ -0,0 +1,48 @@ +import {Client} from 'pg' +import getPgConfig from '../getPgConfig' + +export async function up() { + const client = new Client(getPgConfig()) + await client.connect() + await client.query(` + DO $$ + BEGIN + CREATE TABLE IF NOT EXISTS "Comment" ( + "id" VARCHAR(100) PRIMARY KEY, + "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + "isActive" BOOLEAN NOT NULL DEFAULT TRUE, + "isAnonymous" BOOLEAN NOT NULL DEFAULT FALSE, + "threadParentId" VARCHAR(100), + "reactjis" "Reactji"[] NOT NULL DEFAULT array[]::"Reactji"[], + "content" JSONB NOT NULL, + "createdBy" VARCHAR(100), + "plaintextContent" VARCHAR(2000) NOT NULL, + "discussionId" VARCHAR(100) NOT NULL, + "threadSortOrder" INTEGER NOT NULL, + CONSTRAINT "fk_createdBy" + FOREIGN KEY("createdBy") + REFERENCES "User"("id") + ON DELETE SET NULL, + CONSTRAINT "fk_discussionId" + FOREIGN KEY("discussionId") + REFERENCES "Discussion"("id") + ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS "idx_Comment_threadParentId" ON "Comment"("threadParentId"); + CREATE INDEX IF NOT EXISTS "idx_Comment_createdBy" ON "Comment"("createdBy"); + CREATE INDEX IF NOT EXISTS "idx_Comment_discussionId" ON "Comment"("discussionId"); + END $$; +`) + // TODO add constraint threadParentId in phase 2 + await client.end() +} + +export async function down() { + const client = new Client(getPgConfig()) + await client.connect() + await client.query(` + DROP TABLE "Comment"; + ` /* Do undo magic */) + await client.end() +} diff --git a/packages/server/postgres/select.ts b/packages/server/postgres/select.ts index 481164d8916..9a610b1de19 100644 --- a/packages/server/postgres/select.ts +++ b/packages/server/postgres/select.ts @@ -210,3 +210,21 @@ export const selectSlackAuths = () => getKysely().selectFrom('SlackAuth').select export const selectSlackNotifications = () => getKysely().selectFrom('SlackNotification').selectAll() + +export const selectComments = () => + getKysely() + .selectFrom('Comment') + .select([ + 'id', + 'createdAt', + 'isActive', + 'isAnonymous', + 'threadParentId', + 'updatedAt', + 'content', + 'createdBy', + 'plaintextContent', + 'discussionId', + 'threadSortOrder' + ]) + .select(({fn}) => [fn('to_json', ['reactjis']).as('reactjis')])