feat(backend): do not notify blocked or muted users (#8403)

* no more notifications of blocked or muted users in groups

* do not receive notifications on mentions or observed posts from muted users
This commit is contained in:
Moriz Wahl 2025-04-17 20:22:20 +02:00 committed by GitHub
parent de4325cb50
commit 89b0fa7a51
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 240 additions and 44 deletions

View File

@ -118,7 +118,7 @@ afterAll(async () => {
})
describe('notify group members of new posts in group', () => {
beforeAll(async () => {
beforeEach(async () => {
postAuthor = await Factory.build(
'user',
{
@ -193,8 +193,12 @@ describe('notify group members of new posts in group', () => {
})
})
afterEach(async () => {
await cleanDatabase()
})
describe('group owner posts in group', () => {
beforeAll(async () => {
beforeEach(async () => {
jest.clearAllMocks()
authenticatedUser = await groupMember.toJson()
await markAllAsRead()
@ -275,29 +279,15 @@ describe('notify group members of new posts in group', () => {
})
describe('group member mutes group', () => {
it('sets the muted status correctly', async () => {
beforeEach(async () => {
authenticatedUser = await groupMember.toJson()
await expect(
mutate({
mutation: muteGroupMutation,
variables: {
groupId: 'g-1',
},
}),
).resolves.toMatchObject({
data: {
muteGroup: {
isMutedByMe: true,
},
await mutate({
mutation: muteGroupMutation,
variables: {
groupId: 'g-1',
},
errors: undefined,
})
})
it('sends NO notification when another post is posted', async () => {
jest.clearAllMocks()
authenticatedUser = await groupMember.toJson()
await markAllAsRead()
authenticatedUser = await postAuthor.toJson()
await mutate({
mutation: createPostMutation,
@ -308,7 +298,9 @@ describe('notify group members of new posts in group', () => {
groupId: 'g-1',
},
})
authenticatedUser = await groupMember.toJson()
})
it('sends NO notification when another post is posted', async () => {
await expect(
query({
query: notificationQuery,
@ -329,30 +321,18 @@ describe('notify group members of new posts in group', () => {
})
describe('group member unmutes group again but disables email', () => {
beforeAll(async () => {
beforeEach(async () => {
authenticatedUser = await groupMember.toJson()
await mutate({
mutation: unmuteGroupMutation,
variables: {
groupId: 'g-1',
},
})
jest.clearAllMocks()
await groupMember.update({ emailNotificationsPostInGroup: false })
})
it('sets the muted status correctly', async () => {
authenticatedUser = await groupMember.toJson()
await expect(
mutate({
mutation: unmuteGroupMutation,
variables: {
groupId: 'g-1',
},
}),
).resolves.toMatchObject({
data: {
unmuteGroup: {
isMutedByMe: false,
},
},
errors: undefined,
})
})
it('sends notification when another post is posted', async () => {
authenticatedUser = await groupMember.toJson()
await markAllAsRead()
@ -396,5 +376,85 @@ describe('notify group members of new posts in group', () => {
})
})
})
describe('group member blocks author', () => {
beforeEach(async () => {
await groupMember.relateTo(postAuthor, 'blocked')
authenticatedUser = await groupMember.toJson()
await markAllAsRead()
jest.clearAllMocks()
authenticatedUser = await postAuthor.toJson()
await mutate({
mutation: createPostMutation,
variables: {
id: 'post-1',
title: 'This is another post in the group',
content: 'This is the content of another post in the group',
groupId: 'g-1',
},
})
})
it('sends no notification to the user', async () => {
authenticatedUser = await groupMember.toJson()
await expect(
query({
query: notificationQuery,
variables: {
read: false,
},
}),
).resolves.toMatchObject({
data: {
notifications: [],
},
errors: undefined,
})
})
it('sends NO email', () => {
expect(sendMailMock).not.toHaveBeenCalled()
})
})
describe('group member mutes author', () => {
beforeEach(async () => {
await groupMember.relateTo(postAuthor, 'muted')
authenticatedUser = await groupMember.toJson()
await markAllAsRead()
jest.clearAllMocks()
authenticatedUser = await postAuthor.toJson()
await mutate({
mutation: createPostMutation,
variables: {
id: 'post-1',
title: 'This is another post in the group',
content: 'This is the content of another post in the group',
groupId: 'g-1',
},
})
})
it('sends no notification to the user', async () => {
authenticatedUser = await groupMember.toJson()
await expect(
query({
query: notificationQuery,
variables: {
read: false,
},
}),
).resolves.toMatchObject({
data: {
notifications: [],
},
errors: undefined,
})
})
it('sends NO email', () => {
expect(sendMailMock).not.toHaveBeenCalled()
})
})
})
})

View File

@ -294,6 +294,25 @@ describe('notifications', () => {
).resolves.toEqual(expected)
})
})
describe('if I have muted the comment author', () => {
it('sends me no notification', async () => {
await notifiedUser.relateTo(commentAuthor, 'muted')
await createCommentOnPostAction()
const expected = expect.objectContaining({
data: { notifications: [] },
})
await expect(
query({
query: notificationQuery,
variables: {
read: false,
},
}),
).resolves.toEqual(expected)
})
})
})
describe('commenter is me', () => {
@ -581,6 +600,48 @@ describe('notifications', () => {
expect(pubsubSpy).not.toHaveBeenCalled()
})
})
describe('but the author of the post muted me', () => {
beforeEach(async () => {
await postAuthor.relateTo(notifiedUser, 'muted')
})
it('sends me a notification', async () => {
await createPostAction()
const expected = expect.objectContaining({
data: {
notifications: [
{
createdAt: expect.any(String),
from: {
__typename: 'Post',
content:
'Hey <a class="mention" data-mention-id="you" href="/profile/you/al-capone" target="_blank">@al-capone</a> how do you do?',
id: 'p47',
},
read: false,
reason: 'mentioned_in_post',
relatedUser: null,
},
],
},
})
await expect(
query({
query: notificationQuery,
variables: {
read: false,
},
}),
).resolves.toEqual(expected)
})
it('publishes `NOTIFICATION_ADDED`', async () => {
await createPostAction()
expect(pubsubSpy).toHaveBeenCalled()
})
})
})
describe('mentions me in a comment', () => {
@ -736,6 +797,72 @@ describe('notifications', () => {
expect(pubsubSpy).toHaveBeenCalledTimes(1)
})
})
describe('but the author of the post muted me', () => {
beforeEach(async () => {
await postAuthor.relateTo(notifiedUser, 'muted')
commentContent =
'One mention about me with <a data-mention-id="you" class="mention" href="/profile/you" target="_blank">@al-capone</a>.'
commentAuthor = await neode.create(
'User',
{
id: 'commentAuthor',
name: 'Mrs Comment',
slug: 'mrs-comment',
},
{
email: 'comment-author@example.org',
password: '1234',
},
)
})
it('sends me a notification', async () => {
await createCommentOnPostAction()
await expect(
query({
query: notificationQuery,
variables: {
read: false,
},
}),
).resolves.toMatchObject({
data: {
notifications: [
{
createdAt: expect.any(String),
from: {
__typename: 'Comment',
content:
'One mention about me with <a data-mention-id="you" class="mention" href="/profile/you" target="_blank">@al-capone</a>.',
id: 'c47',
},
read: false,
reason: 'mentioned_in_comment',
relatedUser: null,
},
],
},
errors: undefined,
})
})
it('publishes `NOTIFICATION_ADDED` to authenticated user and me', async () => {
await createCommentOnPostAction()
expect(pubsubSpy).toHaveBeenCalledWith(
'NOTIFICATION_ADDED',
expect.objectContaining({
notificationAdded: expect.objectContaining({
reason: 'commented_on_post',
to: expect.objectContaining({
id: 'postAuthor', // that's expected, it's not me but the post author
}),
}),
}),
)
expect(pubsubSpy).toHaveBeenCalledTimes(2)
})
})
})
})
})

View File

@ -245,6 +245,8 @@ const notifyGroupMembersOfNewPost = async (postId, groupId, context) => {
MATCH (post)-[:IN]->(group:Group { id: $groupId })<-[membership:MEMBER_OF]-(user:User)
WHERE NOT membership.role = 'pending'
AND NOT (user)-[:MUTED]->(group)
AND NOT (user)-[:MUTED]->(author)
AND NOT (user)-[:BLOCKED]-(author)
AND NOT user.id = $userId
WITH post, author, user
MERGE (post)-[notification:NOTIFIED {reason: $reason}]->(user)
@ -360,7 +362,10 @@ const notifyUsersOfMention = async (label, id, idsOfUsers, reason, context) => {
case 'mentioned_in_post': {
mentionedCypher = `
MATCH (post: Post { id: $id })<-[:WROTE]-(author: User)
MATCH (user: User) WHERE user.id in $idsOfUsers AND NOT (user)-[:BLOCKED]-(author)
MATCH (user: User)
WHERE user.id in $idsOfUsers
AND NOT (user)-[:BLOCKED]-(author)
AND NOT (user)-[:MUTED]->(author)
OPTIONAL MATCH (post)-[:IN]->(group:Group)
OPTIONAL MATCH (group)<-[membership:MEMBER_OF]-(user)
WITH post, author, user, group WHERE group IS NULL OR group.groupType = 'public' OR membership.role IN ['usual', 'admin', 'owner']
@ -376,6 +381,8 @@ const notifyUsersOfMention = async (label, id, idsOfUsers, reason, context) => {
WHERE user.id in $idsOfUsers
AND NOT (user)-[:BLOCKED]-(commenter)
AND NOT (user)-[:BLOCKED]-(postAuthor)
AND NOT (user)-[:MUTED]->(commenter)
AND NOT (user)-[:MUTED]->(postAuthor)
OPTIONAL MATCH (post)-[:IN]->(group:Group)
OPTIONAL MATCH (group)<-[membership:MEMBER_OF]-(user)
WITH comment, user, group WHERE group IS NULL OR group.groupType = 'public' OR membership.role IN ['usual', 'admin', 'owner']
@ -422,7 +429,9 @@ const notifyUsersOfComment = async (label, commentId, reason, context) => {
const notificationTransactionResponse = await transaction.run(
`
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
WHERE NOT (observingUser)-[:BLOCKED]-(commenter)
AND NOT (observingUser)-[:MUTED]->(commenter)
AND NOT observingUser.id = $userId
WITH observingUser, post, comment, commenter
MATCH (postAuthor:User)-[:WROTE]->(post)
MERGE (comment)-[notification:NOTIFIED {reason: $reason}]->(observingUser)