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
This commit is contained in:
Moriz Wahl 2025-04-04 19:16:50 +02:00 committed by GitHub
parent 1e6a74b8ce
commit 0835057cc7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 400 additions and 13 deletions

View File

@ -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: [] },

View File

@ -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'))
})

View File

@ -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,
})
})
})
})
})