fix(backend): block/mute chat (#8364)

* no notification when blocked

* no notifications for muted users

* move tests from emssage to notification middleware

* fix test

* also dont update unreadRoomCount when user is muted

---------

Co-authored-by: mahula <lenzmath@posteo.de>
This commit is contained in:
Ulf Gebhardt 2025-04-15 10:38:48 +02:00 committed by GitHub
parent b31a439c8b
commit 2d8fe8a941
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 154 additions and 60 deletions

View File

@ -31,8 +31,10 @@ jest.mock('../helpers/isUserOnline', () => ({
isUserOnline: () => isUserOnlineMock(),
}))
const pubsubSpy = jest.spyOn(pubsub, 'publish')
let server, query, mutate, notifiedUser, authenticatedUser
let publishSpy
const driver = getDriver()
const neode = getNeode()
const categoryIds = ['cat9']
@ -65,7 +67,6 @@ const createCommentMutation = gql`
beforeAll(async () => {
await cleanDatabase()
publishSpy = jest.spyOn(pubsub, 'publish')
const createServerResult = createServer({
context: () => {
return {
@ -87,7 +88,6 @@ afterAll(async () => {
})
beforeEach(async () => {
publishSpy.mockClear()
notifiedUser = await Factory.build(
'user',
{
@ -417,7 +417,7 @@ describe('notifications', () => {
it('publishes `NOTIFICATION_ADDED` to me', async () => {
await createPostAction()
expect(publishSpy).toHaveBeenCalledWith(
expect(pubsubSpy).toHaveBeenCalledWith(
'NOTIFICATION_ADDED',
expect.objectContaining({
notificationAdded: expect.objectContaining({
@ -428,7 +428,7 @@ describe('notifications', () => {
}),
}),
)
expect(publishSpy).toHaveBeenCalledTimes(1)
expect(pubsubSpy).toHaveBeenCalledTimes(1)
})
describe('updates the post and mentions me again', () => {
@ -578,7 +578,7 @@ describe('notifications', () => {
it('does not publish `NOTIFICATION_ADDED`', async () => {
await createPostAction()
expect(publishSpy).not.toHaveBeenCalled()
expect(pubsubSpy).not.toHaveBeenCalled()
})
})
})
@ -722,7 +722,7 @@ describe('notifications', () => {
it('does not publish `NOTIFICATION_ADDED` to authenticated user', async () => {
await createCommentOnPostAction()
expect(publishSpy).toHaveBeenCalledWith(
expect(pubsubSpy).toHaveBeenCalledWith(
'NOTIFICATION_ADDED',
expect.objectContaining({
notificationAdded: expect.objectContaining({
@ -733,14 +733,14 @@ describe('notifications', () => {
}),
}),
)
expect(publishSpy).toHaveBeenCalledTimes(1)
expect(pubsubSpy).toHaveBeenCalledTimes(1)
})
})
})
})
})
describe('chat email notifications', () => {
describe('chat notifications', () => {
let chatSender
let chatReceiver
let roomId
@ -779,7 +779,7 @@ describe('notifications', () => {
})
describe('if the chatReceiver is online', () => {
it('sends no email', async () => {
it('publishes subscriptions but sends no email', async () => {
isUserOnlineMock = jest.fn().mockReturnValue(true)
await mutate({
@ -790,13 +790,32 @@ describe('notifications', () => {
},
})
expect(pubsubSpy).toHaveBeenCalledWith('ROOM_COUNT_UPDATED', {
roomCountUpdated: '1',
userId: 'chatReceiver',
})
expect(pubsubSpy).toHaveBeenCalledWith('CHAT_MESSAGE_ADDED', {
chatMessageAdded: expect.objectContaining({
id: expect.any(String),
content: 'Some nice message to chatReceiver',
senderId: 'chatSender',
username: 'chatSender',
avatar: null,
date: expect.any(String),
saved: true,
distributed: false,
seen: false,
}),
userId: 'chatReceiver',
})
expect(sendMailMock).not.toHaveBeenCalled()
expect(chatMessageTemplateMock).not.toHaveBeenCalled()
})
})
describe('if the chatReceiver is offline', () => {
it('sends an email', async () => {
it('publishes subscriptions and sends an email', async () => {
isUserOnlineMock = jest.fn().mockReturnValue(false)
await mutate({
@ -807,13 +826,32 @@ describe('notifications', () => {
},
})
expect(pubsubSpy).toHaveBeenCalledWith('ROOM_COUNT_UPDATED', {
roomCountUpdated: '1',
userId: 'chatReceiver',
})
expect(pubsubSpy).toHaveBeenCalledWith('CHAT_MESSAGE_ADDED', {
chatMessageAdded: expect.objectContaining({
id: expect.any(String),
content: 'Some nice message to chatReceiver',
senderId: 'chatSender',
username: 'chatSender',
avatar: null,
date: expect.any(String),
saved: true,
distributed: false,
seen: false,
}),
userId: 'chatReceiver',
})
expect(sendMailMock).toHaveBeenCalledTimes(1)
expect(chatMessageTemplateMock).toHaveBeenCalledTimes(1)
})
})
describe('if the chatReceiver has blocked chatSender', () => {
it('sends no email', async () => {
it('publishes no subscriptions and sends no email', async () => {
isUserOnlineMock = jest.fn().mockReturnValue(false)
await chatReceiver.relateTo(chatSender, 'blocked')
@ -825,13 +863,37 @@ describe('notifications', () => {
},
})
expect(pubsubSpy).not.toHaveBeenCalled()
expect(pubsubSpy).not.toHaveBeenCalled()
expect(sendMailMock).not.toHaveBeenCalled()
expect(chatMessageTemplateMock).not.toHaveBeenCalled()
})
})
describe('if the chatReceiver has muted chatSender', () => {
it('publishes no subscriptions and sends no email', async () => {
isUserOnlineMock = jest.fn().mockReturnValue(false)
await chatReceiver.relateTo(chatSender, 'muted')
await mutate({
mutation: createMessageMutation(),
variables: {
roomId,
content: 'Some nice message to chatReceiver',
},
})
expect(pubsubSpy).not.toHaveBeenCalled()
expect(pubsubSpy).not.toHaveBeenCalled()
expect(sendMailMock).not.toHaveBeenCalled()
expect(chatMessageTemplateMock).not.toHaveBeenCalled()
})
})
describe('if the chatReceiver has disabled `emailNotificationsChatMessage`', () => {
it('sends no email', async () => {
it('publishes subscriptions but sends no email', async () => {
isUserOnlineMock = jest.fn().mockReturnValue(false)
await chatReceiver.update({ emailNotificationsChatMessage: false })
@ -843,6 +905,25 @@ describe('notifications', () => {
},
})
expect(pubsubSpy).toHaveBeenCalledWith('ROOM_COUNT_UPDATED', {
roomCountUpdated: '1',
userId: 'chatReceiver',
})
expect(pubsubSpy).toHaveBeenCalledWith('CHAT_MESSAGE_ADDED', {
chatMessageAdded: expect.objectContaining({
id: expect.any(String),
content: 'Some nice message to chatReceiver',
senderId: 'chatSender',
username: 'chatSender',
avatar: null,
date: expect.any(String),
saved: true,
distributed: false,
seen: false,
}),
userId: 'chatReceiver',
})
expect(sendMailMock).not.toHaveBeenCalled()
expect(chatMessageTemplateMock).not.toHaveBeenCalled()
})

View File

@ -7,7 +7,9 @@ import {
import { isUserOnline } from '@middleware/helpers/isUserOnline'
import { validateNotifyUsers } from '@middleware/validation/validationMiddleware'
// eslint-disable-next-line import/no-cycle
import { pubsub, NOTIFICATION_ADDED } from '@src/server'
import { getUnreadRoomsCount } from '@schema/resolvers/rooms'
// eslint-disable-next-line import/no-cycle
import { pubsub, NOTIFICATION_ADDED, ROOM_COUNT_UPDATED, CHAT_MESSAGE_ADDED } from '@src/server'
import extractMentionedUsers from './mentions/extractMentionedUsers'
@ -436,7 +438,7 @@ const notifyUsersOfComment = async (label, commentId, reason, context) => {
const handleCreateMessage = async (resolve, root, args, context, resolveInfo) => {
// Execute resolver
const result = await resolve(root, args, context, resolveInfo)
const message = await resolve(root, args, context, resolveInfo)
// Query Parameters
const { roomId } = args
@ -452,7 +454,7 @@ const handleCreateMessage = async (resolve, root, args, context, resolveInfo) =>
MATCH (room)<-[:CHATS_IN]-(recipientUser:User)-[:PRIMARY_EMAIL]->(emailAddress:EmailAddress)
WHERE NOT recipientUser.id = $currentUserId
AND NOT (recipientUser)-[:BLOCKED]-(senderUser)
AND NOT recipientUser.emailNotificationsChatMessage = false
AND NOT (recipientUser)-[:MUTED]->(senderUser)
RETURN senderUser {.*}, recipientUser {.*}, emailAddress {.email}
`
const txResponse = await transaction.run(messageRecipientCypher, {
@ -471,13 +473,27 @@ const handleCreateMessage = async (resolve, root, args, context, resolveInfo) =>
// Execute Query
const { senderUser, recipientUser, email } = await messageRecipient
// Send EMail if we found a user(not blocked) and he is not considered online
if (recipientUser && !isUserOnline(recipientUser)) {
void sendMail(chatMessageTemplate({ email, variables: { senderUser, recipientUser } }))
if (recipientUser) {
// send subscriptions
const roomCountUpdated = await getUnreadRoomsCount(recipientUser.id, session)
void pubsub.publish(ROOM_COUNT_UPDATED, {
roomCountUpdated,
userId: recipientUser.id,
})
void pubsub.publish(CHAT_MESSAGE_ADDED, {
chatMessageAdded: message,
userId: recipientUser.id,
})
// Send EMail if we found a user(not blocked) and he is not considered online
if (recipientUser.emailNotificationsChatMessage !== false && !isUserOnline(recipientUser)) {
void sendMail(chatMessageTemplate({ email, variables: { senderUser, recipientUser } }))
}
}
// Return resolver result to client
return result
return message
} catch (error) {
throw new Error(error)
} finally {

View File

@ -9,13 +9,13 @@ import createServer, { pubsub } from '@src/server'
const driver = getDriver()
const neode = getNeode()
const pubsubSpy = jest.spyOn(pubsub, 'publish')
let query
let mutate
let authenticatedUser
let chattingUser, otherChattingUser, notChattingUser
const pubsubSpy = jest.spyOn(pubsub, 'publish')
beforeAll(async () => {
await cleanDatabase()
@ -118,7 +118,7 @@ describe('Message', () => {
})
describe('user chats in room', () => {
it('returns the message and publishes subscriptions', async () => {
it('returns the message', async () => {
await expect(
mutate({
mutation: createMessageMutation(),
@ -143,24 +143,6 @@ describe('Message', () => {
},
},
})
expect(pubsubSpy).toBeCalledWith('ROOM_COUNT_UPDATED', {
roomCountUpdated: '1',
userId: 'other-chatting-user',
})
expect(pubsubSpy).toBeCalledWith('CHAT_MESSAGE_ADDED', {
chatMessageAdded: expect.objectContaining({
id: expect.any(String),
content: 'Some nice message to other chatting user',
senderId: 'chatting-user',
username: 'Chatting User',
avatar: expect.any(String),
date: expect.any(String),
saved: true,
distributed: false,
seen: false,
}),
userId: 'other-chatting-user',
})
})
describe('room is updated as well', () => {

View File

@ -1,10 +1,9 @@
import { withFilter } from 'graphql-subscriptions'
import { neo4jgraphql } from 'neo4j-graphql-js'
import { pubsub, ROOM_COUNT_UPDATED, CHAT_MESSAGE_ADDED } from '@src/server'
import { pubsub, CHAT_MESSAGE_ADDED } from '@src/server'
import Resolver from './helpers/Resolver'
import { getUnreadRoomsCount } from './rooms'
const setMessagesAsDistributed = async (undistributedMessagesIds, session) => {
return session.writeTransaction(async (transaction) => {
@ -111,22 +110,7 @@ export default {
return message
})
try {
const message = await writeTxResultPromise
if (message) {
const roomCountUpdated = await getUnreadRoomsCount(message.recipientId, session)
// send subscriptions
void pubsub.publish(ROOM_COUNT_UPDATED, {
roomCountUpdated,
userId: message.recipientId,
})
void pubsub.publish(CHAT_MESSAGE_ADDED, {
chatMessageAdded: message,
userId: message.recipientId,
})
}
return message
return await writeTxResultPromise
} catch (error) {
throw new Error(error)
} finally {

View File

@ -387,6 +387,34 @@ describe('Room', () => {
},
})
})
it('when chattingUser is blocked has 0 unread rooms', async () => {
authenticatedUser = await otherChattingUser.toJson()
await otherChattingUser.relateTo(chattingUser, 'blocked')
await expect(
query({
query: unreadRoomsQuery(),
}),
).resolves.toMatchObject({
data: {
UnreadRooms: 0,
},
})
})
it('when chattingUser is muted has 0 unread rooms', async () => {
authenticatedUser = await otherChattingUser.toJson()
await otherChattingUser.relateTo(chattingUser, 'muted')
await expect(
query({
query: unreadRoomsQuery(),
}),
).resolves.toMatchObject({
data: {
UnreadRooms: 0,
},
})
})
})
describe('as not chatting user', () => {

View File

@ -1,6 +1,7 @@
import { withFilter } from 'graphql-subscriptions'
import { neo4jgraphql } from 'neo4j-graphql-js'
// eslint-disable-next-line import/no-cycle
import { pubsub, ROOM_COUNT_UPDATED } from '@src/server'
import Resolver from './helpers/Resolver'
@ -8,8 +9,10 @@ import Resolver from './helpers/Resolver'
export const getUnreadRoomsCount = async (userId, session) => {
return session.readTransaction(async (transaction) => {
const unreadRoomsCypher = `
MATCH (:User { id: $userId })-[:CHATS_IN]->(room:Room)<-[:INSIDE]-(message:Message)<-[:CREATED]-(sender:User)
MATCH (user:User { id: $userId })-[:CHATS_IN]->(room:Room)<-[:INSIDE]-(message:Message)<-[:CREATED]-(sender:User)
WHERE NOT sender.id = $userId AND NOT message.seen
AND NOT (user)-[:BLOCKED]->(sender)
AND NOT (user)-[:MUTED]->(sender)
RETURN toString(COUNT(DISTINCT room)) AS count
`
const unreadRoomsTxResponse = await transaction.run(unreadRoomsCypher, { userId })