From 0835057cc7027b750d7b641814a6db73fb4068d7 Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Fri, 4 Apr 2025 19:16:50 +0200 Subject: [PATCH] refactor(backend): comment on observed post notification (#8311) * all users that observe a post are notified when the post is commented, except of the author of the comment, or users that blocked the commenter * test to illustrate the behavior of notifications for observed posts --- .../notificationsMiddleware.spec.ts | 1 - .../notifications/notificationsMiddleware.ts | 35 +- .../notifications/observing-posts.spec.ts | 377 ++++++++++++++++++ 3 files changed, 400 insertions(+), 13 deletions(-) create mode 100644 backend/src/middleware/notifications/observing-posts.spec.ts diff --git a/backend/src/middleware/notifications/notificationsMiddleware.spec.ts b/backend/src/middleware/notifications/notificationsMiddleware.spec.ts index 6cec5c940..57354d13f 100644 --- a/backend/src/middleware/notifications/notificationsMiddleware.spec.ts +++ b/backend/src/middleware/notifications/notificationsMiddleware.spec.ts @@ -238,7 +238,6 @@ describe('notifications', () => { }) it('sends me no notification', async () => { - await notifiedUser.relateTo(commentAuthor, 'blocked') await createCommentOnPostAction() const expected = expect.objectContaining({ data: { notifications: [] }, diff --git a/backend/src/middleware/notifications/notificationsMiddleware.ts b/backend/src/middleware/notifications/notificationsMiddleware.ts index 706e46c51..aa2cee06e 100644 --- a/backend/src/middleware/notifications/notificationsMiddleware.ts +++ b/backend/src/middleware/notifications/notificationsMiddleware.ts @@ -108,13 +108,19 @@ const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo const handleContentDataOfComment = async (resolve, root, args, context, resolveInfo) => { const { content } = args - let idsOfUsers = extractMentionedUsers(content) + let idsOfMentionedUsers = extractMentionedUsers(content) const comment = await resolve(root, args, context, resolveInfo) const [postAuthor] = await postAuthorOfComment(comment.id, { context }) - idsOfUsers = idsOfUsers.filter((id) => id !== postAuthor.id) + idsOfMentionedUsers = idsOfMentionedUsers.filter((id) => id !== postAuthor.id) await publishNotifications(context, [ - notifyUsersOfMention('Comment', comment.id, idsOfUsers, 'mentioned_in_comment', context), - notifyUsersOfComment('Comment', comment.id, postAuthor.id, 'commented_on_post', context), + notifyUsersOfMention( + 'Comment', + comment.id, + idsOfMentionedUsers, + 'mentioned_in_comment', + context, + ), + notifyUsersOfComment('Comment', comment.id, 'commented_on_post', context), ]) return comment } @@ -269,29 +275,34 @@ const notifyUsersOfMention = async (label, id, idsOfUsers, reason, context) => { } } -const notifyUsersOfComment = async (label, commentId, postAuthorId, reason, context) => { - if (context.user.id === postAuthorId) return [] +const notifyUsersOfComment = async (label, commentId, reason, context) => { await validateNotifyUsers(label, reason) const session = context.driver.session() const writeTxResultPromise = await session.writeTransaction(async (transaction) => { const notificationTransactionResponse = await transaction.run( ` - MATCH (postAuthor:User {id: $postAuthorId})-[:WROTE]->(post:Post)<-[:COMMENTS]-(comment:Comment { id: $commentId })<-[:WROTE]-(commenter:User) - WHERE NOT (postAuthor)-[:BLOCKED]-(commenter) - MERGE (comment)-[notification:NOTIFIED {reason: $reason}]->(postAuthor) + MATCH (observingUser:User)-[:OBSERVES { active: true }]->(post:Post)<-[:COMMENTS]-(comment:Comment { id: $commentId })<-[:WROTE]-(commenter:User) + WHERE NOT (observingUser)-[:BLOCKED]-(commenter) AND NOT observingUser.id = $userId + WITH observingUser, post, comment, commenter + MATCH (postAuthor:User)-[:WROTE]->(post) + MERGE (comment)-[notification:NOTIFIED {reason: $reason}]->(observingUser) SET notification.read = FALSE SET notification.createdAt = COALESCE(notification.createdAt, toString(datetime())) SET notification.updatedAt = toString(datetime()) - WITH notification, postAuthor, post, commenter, + WITH notification, observingUser, post, commenter, postAuthor, comment {.*, __typename: labels(comment)[0], author: properties(commenter), post: post {.*, author: properties(postAuthor) } } AS finalResource RETURN notification { .*, from: finalResource, - to: properties(postAuthor), + to: properties(observingUser), relatedUser: properties(commenter) } `, - { commentId, postAuthorId, reason }, + { + commentId, + reason, + userId: context.user.id, + }, ) return notificationTransactionResponse.records.map((record) => record.get('notification')) }) diff --git a/backend/src/middleware/notifications/observing-posts.spec.ts b/backend/src/middleware/notifications/observing-posts.spec.ts new file mode 100644 index 000000000..13b971ed8 --- /dev/null +++ b/backend/src/middleware/notifications/observing-posts.spec.ts @@ -0,0 +1,377 @@ +import gql from 'graphql-tag' +import { cleanDatabase } from '../../db/factories' +import { getNeode, getDriver } from '../../db/neo4j' +import createServer from '../../server' +import { createTestClient } from 'apollo-server-testing' + +import CONFIG from '../../config' + +CONFIG.CATEGORIES_ACTIVE = false + +let server, query, mutate, authenticatedUser + +let postAuthor, firstCommenter, secondCommenter + +const driver = getDriver() +const neode = getNeode() + +const createPostMutation = gql` + mutation ($id: ID, $title: String!, $content: String!) { + CreatePost(id: $id, title: $title, content: $content) { + id + title + content + } + } +` + +const createCommentMutation = gql` + mutation ($id: ID, $postId: ID!, $content: String!) { + CreateComment(id: $id, postId: $postId, content: $content) { + id + content + } + } +` + +const notificationQuery = gql` + query ($read: Boolean) { + notifications(read: $read, orderBy: updatedAt_desc) { + read + reason + createdAt + relatedUser { + id + } + from { + __typename + ... on Post { + id + content + } + ... on Comment { + id + content + } + ... on Group { + id + } + } + } + } +` + +const toggleObservePostMutation = gql` + mutation ($id: ID!, $value: Boolean!) { + toggleObservePost(id: $id, value: $value) { + isObservedByMe + observingUsersCount + } + } +` + +beforeAll(async () => { + await cleanDatabase() + + const createServerResult = createServer({ + context: () => { + return { + user: authenticatedUser, + neode, + driver, + cypherParams: { + currentUserId: authenticatedUser ? authenticatedUser.id : null, + }, + } + }, + }) + server = createServerResult.server + const createTestClientResult = createTestClient(server) + query = createTestClientResult.query + mutate = createTestClientResult.mutate +}) + +afterAll(async () => { + await cleanDatabase() + driver.close() +}) + +describe('notifications for users that observe a post', () => { + beforeAll(async () => { + postAuthor = await neode.create( + 'User', + { + id: 'post-author', + name: 'Post Author', + slug: 'post-author', + }, + { + email: 'test@example.org', + password: '1234', + }, + ) + firstCommenter = await neode.create( + 'User', + { + id: 'first-commenter', + name: 'First Commenter', + slug: 'first-commenter', + }, + { + email: 'test2@example.org', + password: '1234', + }, + ) + secondCommenter = await neode.create( + 'User', + { + id: 'second-commenter', + name: 'Second Commenter', + slug: 'second-commenter', + }, + { + email: 'test3@example.org', + password: '1234', + }, + ) + authenticatedUser = await postAuthor.toJson() + await mutate({ + mutation: createPostMutation, + variables: { + id: 'post', + title: 'This is the post', + content: 'This is the content of the post', + }, + }) + }) + + describe('first comment on the post', () => { + beforeAll(async () => { + authenticatedUser = await firstCommenter.toJson() + await mutate({ + mutation: createCommentMutation, + variables: { + postId: 'post', + id: 'c-1', + content: 'first comment of first commenter', + }, + }) + }) + + it('sends NO notification to the commenter', async () => { + await expect( + query({ + query: notificationQuery, + }), + ).resolves.toMatchObject({ + data: { + notifications: [], + }, + errors: undefined, + }) + }) + + it('sends notification to the author', async () => { + authenticatedUser = await postAuthor.toJson() + await expect( + query({ + query: notificationQuery, + }), + ).resolves.toMatchObject({ + data: { + notifications: [ + { + from: { + __typename: 'Comment', + id: 'c-1', + }, + read: false, + reason: 'commented_on_post', + }, + ], + }, + errors: undefined, + }) + }) + + describe('second comment on post', () => { + beforeAll(async () => { + authenticatedUser = await secondCommenter.toJson() + await mutate({ + mutation: createCommentMutation, + variables: { + postId: 'post', + id: 'c-2', + content: 'first comment of second commenter', + }, + }) + }) + + it('sends NO notification to the commenter', async () => { + await expect( + query({ + query: notificationQuery, + }), + ).resolves.toMatchObject({ + data: { + notifications: [], + }, + errors: undefined, + }) + }) + + it('sends notification to the author', async () => { + authenticatedUser = await postAuthor.toJson() + await expect( + query({ + query: notificationQuery, + }), + ).resolves.toMatchObject({ + data: { + notifications: [ + { + from: { + __typename: 'Comment', + id: 'c-2', + }, + read: false, + reason: 'commented_on_post', + }, + { + from: { + __typename: 'Comment', + id: 'c-1', + }, + read: false, + reason: 'commented_on_post', + }, + ], + }, + errors: undefined, + }) + }) + + it('sends notification to first commenter', async () => { + authenticatedUser = await firstCommenter.toJson() + await expect( + query({ + query: notificationQuery, + }), + ).resolves.toMatchObject({ + data: { + notifications: [ + { + from: { + __typename: 'Comment', + id: 'c-2', + }, + read: false, + reason: 'commented_on_post', + }, + ], + }, + errors: undefined, + }) + }) + }) + + describe('first commenter unfollows the post and post author comments post', () => { + beforeAll(async () => { + authenticatedUser = await firstCommenter.toJson() + await mutate({ + mutation: toggleObservePostMutation, + variables: { + id: 'post', + value: false, + }, + }) + + authenticatedUser = await postAuthor.toJson() + await mutate({ + mutation: createCommentMutation, + variables: { + postId: 'post', + id: 'c-3', + content: 'first comment of post author', + }, + }) + }) + + it('sends no new notification to the post author', async () => { + await expect( + query({ + query: notificationQuery, + }), + ).resolves.toMatchObject({ + data: { + notifications: [ + { + from: { + __typename: 'Comment', + id: 'c-2', + }, + read: false, + reason: 'commented_on_post', + }, + { + from: { + __typename: 'Comment', + id: 'c-1', + }, + read: false, + reason: 'commented_on_post', + }, + ], + }, + errors: undefined, + }) + }) + + it('sends no new notification to first commenter', async () => { + authenticatedUser = await firstCommenter.toJson() + await expect( + query({ + query: notificationQuery, + }), + ).resolves.toMatchObject({ + data: { + notifications: [ + { + from: { + __typename: 'Comment', + id: 'c-2', + }, + read: false, + reason: 'commented_on_post', + }, + ], + }, + errors: undefined, + }) + }) + + it('sends notification to second commenter', async () => { + authenticatedUser = await secondCommenter.toJson() + await expect( + query({ + query: notificationQuery, + }), + ).resolves.toMatchObject({ + data: { + notifications: [ + { + from: { + __typename: 'Comment', + id: 'c-3', + }, + read: false, + reason: 'commented_on_post', + }, + ], + }, + errors: undefined, + }) + }) + }) + }) +})