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/ContributionForm/ContributionForm.vue b/webapp/components/ContributionForm/ContributionForm.vue
index 0428b2e23..8863fbc8a 100644
--- a/webapp/components/ContributionForm/ContributionForm.vue
+++ b/webapp/components/ContributionForm/ContributionForm.vue
@@ -41,7 +41,7 @@
{{ formData.title.length }}/{{ formSchema.title.max }}