diff --git a/backend/src/middleware/notifications/notificationsMiddleware.js b/backend/src/middleware/notifications/notificationsMiddleware.js index a871605f7..1c97e9591 100644 --- a/backend/src/middleware/notifications/notificationsMiddleware.js +++ b/backend/src/middleware/notifications/notificationsMiddleware.js @@ -51,6 +51,50 @@ const publishNotifications = async (context, promises) => { }) } +const handleJoinGroup = async (resolve, root, args, context, resolveInfo) => { + const { groupId, userId } = args + const user = await resolve(root, args, context, resolveInfo) + if (user) { + await publishNotifications(context, [ + notifyOwnersOfGroup(groupId, userId, 'user_joined_group', context), + ]) + } + return user +} + +const handleLeaveGroup = async (resolve, root, args, context, resolveInfo) => { + const { groupId, userId } = args + const user = await resolve(root, args, context, resolveInfo) + if (user) { + await publishNotifications(context, [ + notifyOwnersOfGroup(groupId, userId, 'user_left_group', context), + ]) + } + return user +} + +const handleChangeGroupMemberRole = async (resolve, root, args, context, resolveInfo) => { + const { groupId, userId } = args + const user = await resolve(root, args, context, resolveInfo) + if (user) { + await publishNotifications(context, [ + notifyMemberOfGroup(groupId, userId, 'changed_group_member_role', context), + ]) + } + return user +} + +const handleRemoveUserFromGroup = async (resolve, root, args, context, resolveInfo) => { + const { groupId, userId } = args + const user = await resolve(root, args, context, resolveInfo) + if (user) { + await publishNotifications(context, [ + notifyMemberOfGroup(groupId, userId, 'removed_user_from_group', context), + ]) + } + return user +} + const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => { const idsOfUsers = extractMentionedUsers(args.content) const post = await resolve(root, args, context, resolveInfo) @@ -94,6 +138,72 @@ const postAuthorOfComment = async (commentId, { context }) => { } } +const notifyOwnersOfGroup = async (groupId, userId, reason, context) => { + const cypher = ` + MATCH (group:Group { id: $groupId })<-[membership:MEMBER_OF]-(owner:User) + WHERE membership.role = 'owner' + WITH owner, group + MERGE (group)-[notification:NOTIFIED {reason: $reason}]->(owner) + WITH group, owner, notification + SET notification.read = FALSE + SET notification.createdAt = COALESCE(notification.createdAt, toString(datetime())) + SET notification.updatedAt = toString(datetime()) + SET notification.relatedUserId = $userId + RETURN notification {.*, from: group, to: properties(owner)} + ` + const session = context.driver.session() + const writeTxResultPromise = session.writeTransaction(async (transaction) => { + const notificationTransactionResponse = await transaction.run(cypher, { + groupId, + reason, + userId, + }) + 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 notifyMemberOfGroup = async (groupId, userId, reason, context) => { + const { user: owner } = context + const cypher = ` + MATCH (user:User { id: $userId }) + MATCH (group:Group { id: $groupId }) + WITH user, group + MERGE (group)-[notification:NOTIFIED {reason: $reason}]->(user) + WITH group, user, notification + SET notification.read = FALSE + SET notification.createdAt = COALESCE(notification.createdAt, toString(datetime())) + SET notification.updatedAt = toString(datetime()) + SET notification.relatedUserId = $ownerId + RETURN notification {.*, from: group, to: properties(user)} + ` + const session = context.driver.session() + const writeTxResultPromise = session.writeTransaction(async (transaction) => { + const notificationTransactionResponse = await transaction.run(cypher, { + groupId, + reason, + userId, + ownerId: owner.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 notifyUsersOfMention = async (label, id, idsOfUsers, reason, context) => { if (!(idsOfUsers && idsOfUsers.length)) return [] await validateNotifyUsers(label, reason) @@ -188,5 +298,9 @@ export default { UpdatePost: handleContentDataOfPost, CreateComment: handleContentDataOfComment, UpdateComment: handleContentDataOfComment, + JoinGroup: handleJoinGroup, + LeaveGroup: handleLeaveGroup, + ChangeGroupMemberRole: handleChangeGroupMemberRole, + RemoveUserFromGroup: handleRemoveUserFromGroup, }, } diff --git a/backend/src/middleware/notifications/notificationsMiddleware.spec.js b/backend/src/middleware/notifications/notificationsMiddleware.spec.js index a8a5d396b..a9046b09f 100644 --- a/backend/src/middleware/notifications/notificationsMiddleware.spec.js +++ b/backend/src/middleware/notifications/notificationsMiddleware.spec.js @@ -3,6 +3,13 @@ import { cleanDatabase } from '../../db/factories' import { createTestClient } from 'apollo-server-testing' import { getNeode, getDriver } from '../../db/neo4j' import createServer, { pubsub } from '../../server' +import { + createGroupMutation, + joinGroupMutation, + leaveGroupMutation, + changeGroupMemberRoleMutation, + removeUserFromGroupMutation, +} from '../../graphql/groups' let server, query, mutate, notifiedUser, authenticatedUser let publishSpy @@ -92,6 +99,9 @@ describe('notifications', () => { read reason createdAt + relatedUser { + id + } from { __typename ... on Post { @@ -102,6 +112,9 @@ describe('notifications', () => { id content } + ... on Group { + id + } } } } @@ -185,6 +198,7 @@ describe('notifications', () => { id: 'c47', content: commentContent, }, + relatedUser: null, }, ], }, @@ -357,6 +371,7 @@ describe('notifications', () => { id: 'p47', content: expectedUpdatedContent, }, + relatedUser: null, }, ], }, @@ -513,6 +528,7 @@ describe('notifications', () => { id: 'c47', content: commentContent, }, + relatedUser: null, }, ], }, @@ -547,6 +563,7 @@ describe('notifications', () => { id: 'c47', content: commentContent, }, + relatedUser: null, }, ], }, @@ -616,4 +633,232 @@ describe('notifications', () => { }) }) }) + + describe('group notifications', () => { + let groupOwner + + beforeEach(async () => { + groupOwner = await neode.create( + 'User', + { + id: 'group-owner', + name: 'Group Owner', + slug: 'group-owner', + }, + { + email: 'owner@example.org', + password: '1234', + }, + ) + authenticatedUser = await groupOwner.toJson() + await mutate({ + mutation: createGroupMutation(), + variables: { + id: 'closed-group', + name: 'The Closed Group', + about: 'Will test the closed group!', + description: 'Some description' + Array(50).join('_'), + groupType: 'public', + actionRadius: 'regional', + categoryIds, + }, + }) + }) + + describe('user joins group', () => { + beforeEach(async () => { + authenticatedUser = await notifiedUser.toJson() + await mutate({ + mutation: joinGroupMutation(), + variables: { + groupId: 'closed-group', + userId: authenticatedUser.id, + }, + }) + authenticatedUser = await groupOwner.toJson() + }) + + it('has the notification in database', async () => { + await expect( + query({ + query: notificationQuery, + }), + ).resolves.toMatchObject({ + data: { + notifications: [ + { + read: false, + reason: 'user_joined_group', + createdAt: expect.any(String), + from: { + __typename: 'Group', + id: 'closed-group', + }, + relatedUser: { + id: 'you', + }, + }, + ], + }, + errors: undefined, + }) + }) + }) + + describe('user leaves group', () => { + beforeEach(async () => { + authenticatedUser = await notifiedUser.toJson() + await mutate({ + mutation: joinGroupMutation(), + variables: { + groupId: 'closed-group', + userId: authenticatedUser.id, + }, + }) + await mutate({ + mutation: leaveGroupMutation(), + variables: { + groupId: 'closed-group', + userId: authenticatedUser.id, + }, + }) + authenticatedUser = await groupOwner.toJson() + }) + + it('has two the notification in database', async () => { + await expect( + query({ + query: notificationQuery, + }), + ).resolves.toMatchObject({ + data: { + notifications: [ + { + read: false, + reason: 'user_left_group', + createdAt: expect.any(String), + from: { + __typename: 'Group', + id: 'closed-group', + }, + relatedUser: { + id: 'you', + }, + }, + { + read: false, + reason: 'user_joined_group', + createdAt: expect.any(String), + from: { + __typename: 'Group', + id: 'closed-group', + }, + relatedUser: { + id: 'you', + }, + }, + ], + }, + errors: undefined, + }) + }) + }) + + describe('user role in group changes', () => { + beforeEach(async () => { + authenticatedUser = await notifiedUser.toJson() + await mutate({ + mutation: joinGroupMutation(), + variables: { + groupId: 'closed-group', + userId: authenticatedUser.id, + }, + }) + authenticatedUser = await groupOwner.toJson() + await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'closed-group', + userId: 'you', + roleInGroup: 'admin', + }, + }) + authenticatedUser = await notifiedUser.toJson() + }) + + it('has notification in database', async () => { + await expect( + query({ + query: notificationQuery, + }), + ).resolves.toMatchObject({ + data: { + notifications: [ + { + read: false, + reason: 'changed_group_member_role', + createdAt: expect.any(String), + from: { + __typename: 'Group', + id: 'closed-group', + }, + relatedUser: { + id: 'group-owner', + }, + }, + ], + }, + errors: undefined, + }) + }) + }) + + describe('user is removed from group', () => { + beforeEach(async () => { + authenticatedUser = await notifiedUser.toJson() + await mutate({ + mutation: joinGroupMutation(), + variables: { + groupId: 'closed-group', + userId: authenticatedUser.id, + }, + }) + authenticatedUser = await groupOwner.toJson() + await mutate({ + mutation: removeUserFromGroupMutation(), + variables: { + groupId: 'closed-group', + userId: 'you', + }, + }) + authenticatedUser = await notifiedUser.toJson() + }) + + it('has notification in database', async () => { + await expect( + query({ + query: notificationQuery, + }), + ).resolves.toMatchObject({ + data: { + notifications: [ + { + read: false, + reason: 'removed_user_from_group', + createdAt: expect.any(String), + from: { + __typename: 'Group', + id: 'closed-group', + }, + relatedUser: { + id: 'group-owner', + }, + }, + ], + }, + errors: undefined, + }) + }) + }) + }) }) diff --git a/backend/src/schema/resolvers/notifications.js b/backend/src/schema/resolvers/notifications.js index a2b850336..117b9b530 100644 --- a/backend/src/schema/resolvers/notifications.js +++ b/backend/src/schema/resolvers/notifications.js @@ -47,12 +47,22 @@ export default { ` MATCH (resource {deleted: false, disabled: false})-[notification:NOTIFIED]->(user:User {id:$id}) ${whereClause} - WITH user, notification, resource, + OPTIONAL MATCH (relatedUser:User { id: notification.relatedUserId }) + OPTIONAL MATCH (resource)<-[membership:MEMBER_OF]-(relatedUser) + WITH user, notification, resource, membership, relatedUser, [(resource)<-[:WROTE]-(author:User) | author {.*}] AS authors, - [(resource)-[:COMMENTS]->(post:Post)<-[:WROTE]-(author:User) | post{.*, author: properties(author)} ] AS posts - WITH resource, user, notification, authors, posts, - resource {.*, __typename: labels(resource)[0], author: authors[0], post: posts[0]} AS finalResource - RETURN notification {.*, from: finalResource, to: properties(user)} + [(resource)-[:COMMENTS]->(post:Post)<-[:WROTE]-(author:User) | post {.*, author: properties(author)} ] AS posts + WITH resource, user, notification, authors, posts, relatedUser, membership, + resource {.*, + __typename: labels(resource)[0], + author: authors[0], + post: posts[0], + myRole: membership.role } AS finalResource + RETURN notification {.*, + from: finalResource, + to: properties(user), + relatedUser: properties(relatedUser) + } ${orderByClause} ${offset} ${limit} `, @@ -81,8 +91,9 @@ export default { WITH user, notification, resource, [(resource)<-[:WROTE]-(author:User) | author {.*}] AS authors, [(resource)-[:COMMENTS]->(post:Post)<-[:WROTE]-(author:User) | post{.*, author: properties(author)} ] AS posts - WITH resource, user, notification, authors, posts, - resource {.*, __typename: labels(resource)[0], author: authors[0], post: posts[0]} AS finalResource + OPTIONAL MATCH (resource)<-[membership:MEMBER_OF]-(user) + WITH resource, user, notification, authors, posts, membership, + resource {.*, __typename: labels(resource)[0], author: authors[0], post: posts[0], myRole: membership.role } AS finalResource RETURN notification {.*, from: finalResource, to: properties(user)} `, { resourceId: args.id, id: currentUser.id }, @@ -110,8 +121,9 @@ export default { WITH user, notification, resource, [(resource)<-[:WROTE]-(author:User) | author {.*}] AS authors, [(resource)-[:COMMENTS]->(post:Post)<-[:WROTE]-(author:User) | post{.*, author: properties(author)} ] AS posts - WITH resource, user, notification, authors, posts, - resource {.*, __typename: labels(resource)[0], author: authors[0], post: posts[0]} AS finalResource + OPTIONAL MATCH (resource)<-[membership:MEMBER_OF]-(user) + WITH resource, user, notification, authors, posts, membership, + resource {.*, __typename: labels(resource)[0], author: authors[0], post: posts[0], myRole: membership.role} AS finalResource RETURN notification {.*, from: finalResource, to: properties(user)} `, { id: currentUser.id }, diff --git a/backend/src/schema/resolvers/notifications.spec.js b/backend/src/schema/resolvers/notifications.spec.js index 47134aea6..9deaea457 100644 --- a/backend/src/schema/resolvers/notifications.spec.js +++ b/backend/src/schema/resolvers/notifications.spec.js @@ -397,18 +397,20 @@ describe('given some notifications', () => { it('returns all as read', async () => { const response = await mutate({ mutation: markAllAsReadMutation(), variables }) - expect(response.data.markAllAsRead).toEqual([ - { - createdAt: '2019-08-30T19:33:48.651Z', - from: { __typename: 'Comment', content: 'You have been mentioned in a comment' }, - read: true, - }, - { - createdAt: '2019-08-31T17:33:48.651Z', - from: { __typename: 'Post', content: 'You have been mentioned in a post' }, - read: true, - }, - ]) + expect(response.data.markAllAsRead).toEqual( + expect.arrayContaining([ + { + createdAt: '2019-08-30T19:33:48.651Z', + from: { __typename: 'Comment', content: 'You have been mentioned in a comment' }, + read: true, + }, + { + createdAt: '2019-08-31T17:33:48.651Z', + from: { __typename: 'Post', content: 'You have been mentioned in a post' }, + read: true, + }, + ]), + ) expect(response.errors).toBeUndefined() }) }) diff --git a/backend/src/schema/types/enum/ReasonNotification.gql b/backend/src/schema/types/enum/ReasonNotification.gql deleted file mode 100644 index e870e01dc..000000000 --- a/backend/src/schema/types/enum/ReasonNotification.gql +++ /dev/null @@ -1,5 +0,0 @@ -enum ReasonNotification { - mentioned_in_post - mentioned_in_comment - commented_on_post -} diff --git a/backend/src/schema/types/type/NOTIFIED.gql b/backend/src/schema/types/type/NOTIFIED.gql index 864cdea4d..62a1f3696 100644 --- a/backend/src/schema/types/type/NOTIFIED.gql +++ b/backend/src/schema/types/type/NOTIFIED.gql @@ -6,9 +6,10 @@ type NOTIFIED { updatedAt: String! read: Boolean reason: NotificationReason + relatedUser: User } -union NotificationSource = Post | Comment +union NotificationSource = Post | Comment | Group enum NotificationOrdering { createdAt_asc @@ -21,6 +22,10 @@ enum NotificationReason { mentioned_in_post mentioned_in_comment commented_on_post + user_joined_group + user_left_group + changed_group_member_role + removed_user_from_group } type Query { diff --git a/webapp/components/Button/JoinLeaveButton.vue b/webapp/components/Button/JoinLeaveButton.vue index 152039eb0..f4cc2c009 100644 --- a/webapp/components/Button/JoinLeaveButton.vue +++ b/webapp/components/Button/JoinLeaveButton.vue @@ -63,7 +63,7 @@ export default { content: this.$t('group.joinLeaveButton.tooltip'), placement: 'right', show: this.isMember && !this.isNonePendingMember && this.hovered, - trigger: this.isMember && !this.isNonePendingMember ? 'hover' : 'manual', + trigger: 'manual', } }, }, diff --git a/webapp/components/Notification/Notification.vue b/webapp/components/Notification/Notification.vue index acb83b028..a9cd75f4e 100644 --- a/webapp/components/Notification/Notification.vue +++ b/webapp/components/Notification/Notification.vue @@ -1,19 +1,24 @@