diff --git a/backend/src/middleware/notifications/followed-users.spec.ts b/backend/src/middleware/notifications/followed-users.spec.ts index b82ef2571..4d4b0e872 100644 --- a/backend/src/middleware/notifications/followed-users.spec.ts +++ b/backend/src/middleware/notifications/followed-users.spec.ts @@ -1,7 +1,7 @@ import { createTestClient } from 'apollo-server-testing' import gql from 'graphql-tag' -import { cleanDatabase } from '@db/factories' +import Factory, { cleanDatabase } from '@db/factories' import { getNeode, getDriver } from '@db/neo4j' import { createGroupMutation } from '@graphql/groups' import CONFIG from '@src/config' @@ -9,6 +9,11 @@ import createServer from '@src/server' CONFIG.CATEGORIES_ACTIVE = false +const sendMailMock = jest.fn() +jest.mock('../helpers/email/sendMail', () => ({ + sendMail: () => sendMailMock(), +})) + let server, query, mutate, authenticatedUser let postAuthor, firstFollower, secondFollower @@ -89,8 +94,8 @@ afterAll(async () => { describe('following users notifications', () => { beforeAll(async () => { - postAuthor = await neode.create( - 'User', + postAuthor = await Factory.build( + 'user', { id: 'post-author', name: 'Post Author', @@ -101,8 +106,8 @@ describe('following users notifications', () => { password: '1234', }, ) - firstFollower = await neode.create( - 'User', + firstFollower = await Factory.build( + 'user', { id: 'first-follower', name: 'First Follower', @@ -113,8 +118,8 @@ describe('following users notifications', () => { password: '1234', }, ) - secondFollower = await neode.create( - 'User', + secondFollower = await Factory.build( + 'user', { id: 'second-follower', name: 'Second Follower', @@ -136,6 +141,7 @@ describe('following users notifications', () => { mutation: followUserMutation, variables: { id: 'post-author' }, }) + jest.clearAllMocks() }) describe('the followed user writes a post', () => { @@ -209,6 +215,10 @@ describe('following users notifications', () => { errors: undefined, }) }) + + it('sends only one email, as second follower has emails disabled', () => { + expect(sendMailMock).toHaveBeenCalledTimes(1) + }) }) describe('followed user posts in public group', () => { @@ -248,7 +258,7 @@ describe('following users notifications', () => { }) }) - it('sends notification to the first follower although he is no member of the group', async () => { + it('sends a notification to the first follower', async () => { authenticatedUser = await firstFollower.toJson() await expect( query({ diff --git a/backend/src/middleware/notifications/notificationsMiddleware.ts b/backend/src/middleware/notifications/notificationsMiddleware.ts index a733bc201..ebbcd7886 100644 --- a/backend/src/middleware/notifications/notificationsMiddleware.ts +++ b/backend/src/middleware/notifications/notificationsMiddleware.ts @@ -125,6 +125,11 @@ const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo [notifyFollowingUsers(post.id, groupId, context)], 'emailNotificationsFollowingUsers', ) + await publishNotifications( + context, + [notifyGroupMembersOfNewPost(post.id, groupId, context)], + 'emailNotificationsPostInGroup', + ) } return post } @@ -216,6 +221,49 @@ const notifyFollowingUsers = async (postId, groupId, context) => { } } +const notifyGroupMembersOfNewPost = async (postId, groupId, context) => { + if (!groupId) return [] + const reason = 'post_in_group' + const cypher = ` + MATCH (post:Post { id: $postId })<-[:WROTE]-(author:User { id: $userId }) + 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.id = $userId + WITH post, author, user + MERGE (post)-[notification:NOTIFIED {reason: $reason}]->(user) + SET notification.read = FALSE + SET notification.createdAt = COALESCE(notification.createdAt, toString(datetime())) + SET notification.updatedAt = toString(datetime()) + WITH notification, author, user, + post {.*, author: properties(author) } AS finalResource + RETURN notification { + .*, + from: finalResource, + to: properties(user), + relatedUser: properties(author) + } + ` + const session = context.driver.session() + const writeTxResultPromise = session.writeTransaction(async (transaction) => { + const notificationTransactionResponse = await transaction.run(cypher, { + postId, + reason, + groupId, + userId: context.user.id, + }) + return notificationTransactionResponse.records.map((record) => record.get('notification')) + }) + try { + const notifications = await writeTxResultPromise + return notifications + } catch (error) { + throw new Error(error) + } finally { + session.close() + } +} + const notifyOwnersOfGroup = async (groupId, userId, reason, context) => { const cypher = ` MATCH (user:User { id: $userId }) diff --git a/backend/src/middleware/notifications/posts-in-groups.spec.ts b/backend/src/middleware/notifications/posts-in-groups.spec.ts new file mode 100644 index 000000000..86e700d1c --- /dev/null +++ b/backend/src/middleware/notifications/posts-in-groups.spec.ts @@ -0,0 +1,395 @@ +import { createTestClient } from 'apollo-server-testing' +import gql from 'graphql-tag' + +import Factory, { cleanDatabase } from '@db/factories' +import { getNeode, getDriver } from '@db/neo4j' +import { + createGroupMutation, + joinGroupMutation, + changeGroupMemberRoleMutation, +} from '@graphql/groups' +import CONFIG from '@src/config' +import createServer from '@src/server' + +CONFIG.CATEGORIES_ACTIVE = false + +const sendMailMock = jest.fn() +jest.mock('../helpers/email/sendMail', () => ({ + sendMail: () => sendMailMock(), +})) + +let server, query, mutate, authenticatedUser + +let postAuthor, groupMember, pendingMember + +const driver = getDriver() +const neode = getNeode() + +const createPostMutation = gql` + mutation ($id: ID, $title: String!, $content: String!, $groupId: ID) { + CreatePost(id: $id, title: $title, content: $content, groupId: $groupId) { + id + title + 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 muteGroupMutation = gql` + mutation ($id: ID!) { + muteGroup(id: $id) { + id + isMutedByMe + } + } +` + +const unmuteGroupMutation = gql` + mutation ($id: ID!) { + unmuteGroup(id: $id) { + id + isMutedByMe + } + } +` + +const markAllAsRead = async () => + mutate({ + mutation: gql` + mutation { + markAllAsRead { + id + } + } + `, + }) + +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('notify group members of new posts in group', () => { + beforeAll(async () => { + postAuthor = await Factory.build( + 'user', + { + id: 'post-author', + name: 'Post Author', + slug: 'post-author', + }, + { + email: 'test@example.org', + password: '1234', + }, + ) + groupMember = await Factory.build( + 'user', + { + id: 'group-member', + name: 'Group Member', + slug: 'group-member', + }, + { + email: 'test2@example.org', + password: '1234', + }, + ) + pendingMember = await Factory.build( + 'user', + { + id: 'pending-member', + name: 'Pending Member', + slug: 'pending-member', + }, + { + email: 'test3@example.org', + password: '1234', + }, + ) + authenticatedUser = await postAuthor.toJson() + await mutate({ + mutation: createGroupMutation(), + variables: { + id: 'g-1', + name: 'A closed group', + description: 'A closed group to test the notifications to group members', + groupType: 'closed', + actionRadius: 'national', + }, + }) + authenticatedUser = await groupMember.toJson() + await mutate({ + mutation: joinGroupMutation(), + variables: { + groupId: 'g-1', + userId: 'group-member', + }, + }) + authenticatedUser = await pendingMember.toJson() + await mutate({ + mutation: joinGroupMutation(), + variables: { + groupId: 'g-1', + userId: 'pending-member', + }, + }) + authenticatedUser = await postAuthor.toJson() + await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'g-1', + userId: 'group-member', + roleInGroup: 'usual', + }, + }) + }) + + describe('group owner posts in group', () => { + beforeAll(async () => { + jest.clearAllMocks() + authenticatedUser = await groupMember.toJson() + await markAllAsRead() + authenticatedUser = await postAuthor.toJson() + await markAllAsRead() + await mutate({ + mutation: createPostMutation, + variables: { + id: 'post', + title: 'This is the new post in the group', + content: 'This is the content of the new post in the group', + groupId: 'g-1', + }, + }) + }) + + it('sends NO notification to the author of the post', async () => { + await expect( + query({ + query: notificationQuery, + variables: { + read: false, + }, + }), + ).resolves.toMatchObject({ + data: { + notifications: [], + }, + errors: undefined, + }) + }) + + it('sends NO notification to the pending group member', async () => { + authenticatedUser = await pendingMember.toJson() + await expect( + query({ + query: notificationQuery, + variables: { + read: false, + }, + }), + ).resolves.toMatchObject({ + data: { + notifications: [], + }, + errors: undefined, + }) + }) + + it('sends notification to the group member', async () => { + authenticatedUser = await groupMember.toJson() + await expect( + query({ + query: notificationQuery, + variables: { + read: false, + }, + }), + ).resolves.toMatchObject({ + data: { + notifications: [ + { + from: { + __typename: 'Post', + id: 'post', + }, + read: false, + reason: 'post_in_group', + }, + ], + }, + errors: undefined, + }) + }) + + it('sends one email', () => { + expect(sendMailMock).toHaveBeenCalledTimes(1) + }) + + describe('group member mutes group', () => { + it('sets the muted status correctly', async () => { + authenticatedUser = await groupMember.toJson() + await expect( + mutate({ + mutation: muteGroupMutation, + variables: { + id: 'g-1', + }, + }), + ).resolves.toMatchObject({ + data: { + muteGroup: { + isMutedByMe: true, + }, + }, + errors: undefined, + }) + }) + + it('sends NO notification when another post is posted', async () => { + authenticatedUser = await groupMember.toJson() + await markAllAsRead() + 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', + }, + }) + authenticatedUser = await groupMember.toJson() + await expect( + query({ + query: notificationQuery, + variables: { + read: false, + }, + }), + ).resolves.toMatchObject({ + data: { + notifications: [], + }, + errors: undefined, + }) + }) + + describe('group member unmutes group again but disables email', () => { + beforeAll(async () => { + jest.clearAllMocks() + await groupMember.update({ emailNotificationsPostInGroup: false }) + }) + + it('sets the muted status correctly', async () => { + authenticatedUser = await groupMember.toJson() + await expect( + mutate({ + mutation: unmuteGroupMutation, + variables: { + id: '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() + authenticatedUser = await postAuthor.toJson() + await mutate({ + mutation: createPostMutation, + variables: { + id: 'post-2', + title: 'This is yet another post in the group', + content: 'This is the content of yet another post in the group', + groupId: 'g-1', + }, + }) + authenticatedUser = await groupMember.toJson() + await expect( + query({ + query: notificationQuery, + variables: { + read: false, + }, + }), + ).resolves.toMatchObject({ + data: { + notifications: [ + { + from: { + __typename: 'Post', + id: 'post-2', + }, + read: false, + reason: 'post_in_group', + }, + ], + }, + errors: undefined, + }) + }) + + it('sends NO email', () => { + expect(sendMailMock).not.toHaveBeenCalled() + }) + }) + }) + }) +}) diff --git a/backend/src/middleware/permissionsMiddleware.ts b/backend/src/middleware/permissionsMiddleware.ts index fcda6d218..b8c728f4c 100644 --- a/backend/src/middleware/permissionsMiddleware.ts +++ b/backend/src/middleware/permissionsMiddleware.ts @@ -468,6 +468,8 @@ export default shield( CreateMessage: isAuthenticated, MarkMessagesAsSeen: isAuthenticated, toggleObservePost: isAuthenticated, + muteGroup: and(isAuthenticated, isMemberOfGroup), + unmuteGroup: and(isAuthenticated, isMemberOfGroup), }, User: { email: or(isMyOwn, isAdmin), diff --git a/backend/src/middleware/validation/validationMiddleware.ts b/backend/src/middleware/validation/validationMiddleware.ts index 0920df6e0..072f2d7b9 100644 --- a/backend/src/middleware/validation/validationMiddleware.ts +++ b/backend/src/middleware/validation/validationMiddleware.ts @@ -106,6 +106,7 @@ export const validateNotifyUsers = async (label, reason) => { 'mentioned_in_comment', 'commented_on_post', 'followed_user_posted', + 'post_in_group', ] if (!reasonsAllowed.includes(reason)) throw new Error('Notification reason is not allowed!') if ( diff --git a/backend/src/models/User.ts b/backend/src/models/User.ts index e0cc770b4..754f879a4 100644 --- a/backend/src/models/User.ts +++ b/backend/src/models/User.ts @@ -189,6 +189,10 @@ export default { type: 'boolean', default: true, }, + emailNotificationsPostInGroup: { + type: 'boolean', + default: true, + }, locale: { type: 'string', diff --git a/backend/src/schema/resolvers/groups.ts b/backend/src/schema/resolvers/groups.ts index 8b383e702..6a54ce17f 100644 --- a/backend/src/schema/resolvers/groups.ts +++ b/backend/src/schema/resolvers/groups.ts @@ -368,6 +368,64 @@ export default { session.close() } }, + muteGroup: async (_parent, params, context, _resolveInfo) => { + const { id: groupId } = params + const userId = context.user.id + const session = context.driver.session() + const writeTxResultPromise = session.writeTransaction(async (transaction) => { + const transactionResponse = await transaction.run( + ` + MATCH (group:Group { id: $groupId }) + MATCH (user:User { id: $userId }) + MERGE (user)-[m:MUTED]->(group) + SET m.createdAt = toString(datetime()) + RETURN group { .* } + `, + { + groupId, + userId, + }, + ) + const [group] = await transactionResponse.records.map((record) => record.get('group')) + return group + }) + try { + return await writeTxResultPromise + } catch (error) { + throw new Error(error) + } finally { + session.close() + } + }, + unmuteGroup: async (_parent, params, context, _resolveInfo) => { + const { id: groupId } = params + const userId = context.user.id + const session = context.driver.session() + const writeTxResultPromise = session.writeTransaction(async (transaction) => { + const transactionResponse = await transaction.run( + ` + MATCH (group:Group { id: $groupId }) + MATCH (user:User { id: $userId }) + OPTIONAL MATCH (user)-[m:MUTED]->(group) + DELETE m + RETURN group { .* } + `, + { + groupId, + userId, + }, + ) + const [group] = await transactionResponse.records.map((record) => record.get('group')) + return group + }) + try { + return await writeTxResultPromise + } catch (error) { + throw new Error(error) + } finally { + session.close() + } + }, }, Group: { ...Resolver('Group', { @@ -380,6 +438,10 @@ export default { avatar: '-[:AVATAR_IMAGE]->(related:Image)', location: '-[:IS_IN]->(related:Location)', }, + boolean: { + isMutedByMe: + 'MATCH (this)<-[:MUTED]-(related:User {id: $cypherParams.currentUserId}) RETURN COUNT(related) >= 1', + }, }), }, } diff --git a/backend/src/schema/resolvers/users.spec.ts b/backend/src/schema/resolvers/users.spec.ts index 77d606fbb..0b14575db 100644 --- a/backend/src/schema/resolvers/users.spec.ts +++ b/backend/src/schema/resolvers/users.spec.ts @@ -679,6 +679,10 @@ describe('emailNotificationSettings', () => { name: 'followingUsers', value: true, }, + { + name: 'postInGroup', + value: true, + }, ], }, { @@ -773,6 +777,10 @@ describe('emailNotificationSettings', () => { name: 'followingUsers', value: true, }, + { + name: 'postInGroup', + value: true, + }, ], }, { diff --git a/backend/src/schema/resolvers/users.ts b/backend/src/schema/resolvers/users.ts index 134fb34cb..e4e701006 100644 --- a/backend/src/schema/resolvers/users.ts +++ b/backend/src/schema/resolvers/users.ts @@ -387,6 +387,10 @@ export default { name: 'followingUsers', value: parent.emailNotificationsFollowingUsers ?? true, }, + { + name: 'postInGroup', + value: parent.emailNotificationsPostInGroup ?? true, + }, ], }, { diff --git a/backend/src/schema/types/enum/EmailNotificationSettingsName.gql b/backend/src/schema/types/enum/EmailNotificationSettingsName.gql index bcc6e617c..59989faca 100644 --- a/backend/src/schema/types/enum/EmailNotificationSettingsName.gql +++ b/backend/src/schema/types/enum/EmailNotificationSettingsName.gql @@ -2,6 +2,7 @@ enum EmailNotificationSettingsName { commentOnObservedPost mention followingUsers + postInGroup chatMessage groupMemberJoined groupMemberLeft diff --git a/backend/src/schema/types/type/Group.gql b/backend/src/schema/types/type/Group.gql index acf585f71..2e14c8c0a 100644 --- a/backend/src/schema/types/type/Group.gql +++ b/backend/src/schema/types/type/Group.gql @@ -41,6 +41,10 @@ type Group { myRole: GroupMemberRole # if 'null' then the current user is no member posts: [Post] @relation(name: "IN", direction: "IN") + + isMutedByMe: Boolean! + @cypher( + statement: "MATCH (this)<-[m:MUTED]-(u:User {id: $cypherParams.currentUserId}) RETURN COUNT(m) >= 1") } @@ -137,4 +141,7 @@ type Mutation { groupId: ID! userId: ID! ): User + + muteGroup(id: ID!): Group + unmuteGroup(id: ID!): Group } diff --git a/backend/src/schema/types/type/NOTIFIED.gql b/backend/src/schema/types/type/NOTIFIED.gql index 2726f503a..d32b4e042 100644 --- a/backend/src/schema/types/type/NOTIFIED.gql +++ b/backend/src/schema/types/type/NOTIFIED.gql @@ -27,6 +27,7 @@ enum NotificationReason { changed_group_member_role removed_user_from_group followed_user_posted + post_in_group } type Query { diff --git a/webapp/locales/de.json b/webapp/locales/de.json index 0a64283a5..dd2f36115 100644 --- a/webapp/locales/de.json +++ b/webapp/locales/de.json @@ -741,6 +741,7 @@ "followed_user_posted": "Hat einen neuen Betrag geschrieben …", "mentioned_in_comment": "Hat Dich in einem Kommentar erwähnt …", "mentioned_in_post": "Hat Dich in einem Beitrag erwähnt …", + "post_in_group": "Hat einen Beitrag in der Gruppe geschrieben …", "removed_user_from_group": "Hat Dich aus der Gruppe entfernt …", "user_joined_group": "Ist Deiner Gruppe beigetreten …", "user_left_group": "Hat deine Gruppe verlassen …" diff --git a/webapp/locales/en.json b/webapp/locales/en.json index f73e77804..7e5570b89 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -741,6 +741,7 @@ "followed_user_posted": "Wrote a new post …", "mentioned_in_comment": "Mentioned you in a comment …", "mentioned_in_post": "Mentioned you in a post …", + "post_in_group": "Posted in a group …", "removed_user_from_group": "Removed you from group …", "user_joined_group": "Joined your group …", "user_left_group": "Left your group …" diff --git a/webapp/locales/es.json b/webapp/locales/es.json index 8efa724e9..a3e5cfcc2 100644 --- a/webapp/locales/es.json +++ b/webapp/locales/es.json @@ -741,6 +741,7 @@ "followed_user_posted": null, "mentioned_in_comment": "Le mencionó en un comentario …", "mentioned_in_post": "Le mencionó en una contribución …", + "post_in_group": null, "removed_user_from_group": null, "user_joined_group": null, "user_left_group": null diff --git a/webapp/locales/fr.json b/webapp/locales/fr.json index e00df6543..0153c20a1 100644 --- a/webapp/locales/fr.json +++ b/webapp/locales/fr.json @@ -741,6 +741,7 @@ "followed_user_posted": null, "mentioned_in_comment": "Vous a mentionné dans un commentaire…", "mentioned_in_post": "Vous a mentionné dans un post…", + "post_in_group": null, "removed_user_from_group": null, "user_joined_group": null, "user_left_group": null diff --git a/webapp/locales/it.json b/webapp/locales/it.json index 52597f9fb..586f44839 100644 --- a/webapp/locales/it.json +++ b/webapp/locales/it.json @@ -741,6 +741,7 @@ "followed_user_posted": null, "mentioned_in_comment": null, "mentioned_in_post": null, + "post_in_group": null, "removed_user_from_group": null, "user_joined_group": null, "user_left_group": null diff --git a/webapp/locales/nl.json b/webapp/locales/nl.json index bbf99f186..2e183bdcc 100644 --- a/webapp/locales/nl.json +++ b/webapp/locales/nl.json @@ -741,6 +741,7 @@ "followed_user_posted": null, "mentioned_in_comment": null, "mentioned_in_post": null, + "post_in_group": null, "removed_user_from_group": null, "user_joined_group": null, "user_left_group": null diff --git a/webapp/locales/pl.json b/webapp/locales/pl.json index 78223f943..0624842fe 100644 --- a/webapp/locales/pl.json +++ b/webapp/locales/pl.json @@ -741,6 +741,7 @@ "followed_user_posted": null, "mentioned_in_comment": null, "mentioned_in_post": null, + "post_in_group": null, "removed_user_from_group": null, "user_joined_group": null, "user_left_group": null diff --git a/webapp/locales/pt.json b/webapp/locales/pt.json index 4848546a0..0ab934e23 100644 --- a/webapp/locales/pt.json +++ b/webapp/locales/pt.json @@ -741,6 +741,7 @@ "followed_user_posted": null, "mentioned_in_comment": "Mentionou você em um comentário …", "mentioned_in_post": "Mencinou você em um post …", + "post_in_group": null, "removed_user_from_group": null, "user_joined_group": null, "user_left_group": null diff --git a/webapp/locales/ru.json b/webapp/locales/ru.json index ac33dff47..cf0d8ed62 100644 --- a/webapp/locales/ru.json +++ b/webapp/locales/ru.json @@ -741,6 +741,7 @@ "followed_user_posted": null, "mentioned_in_comment": "Упоминание в комментарии....", "mentioned_in_post": "Упоминание в посте....", + "post_in_group": null, "removed_user_from_group": null, "user_joined_group": null, "user_left_group": null