diff --git a/backend/src/middleware/notifications/followed-users.spec.ts b/backend/src/middleware/notifications/followed-users.spec.ts new file mode 100644 index 000000000..b82ef2571 --- /dev/null +++ b/backend/src/middleware/notifications/followed-users.spec.ts @@ -0,0 +1,420 @@ +import { createTestClient } from 'apollo-server-testing' +import gql from 'graphql-tag' + +import { cleanDatabase } from '@db/factories' +import { getNeode, getDriver } from '@db/neo4j' +import { createGroupMutation } from '@graphql/groups' +import CONFIG from '@src/config' +import createServer from '@src/server' + +CONFIG.CATEGORIES_ACTIVE = false + +let server, query, mutate, authenticatedUser + +let postAuthor, firstFollower, secondFollower + +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 followUserMutation = gql` + mutation ($id: ID!) { + followUser(id: $id) { + 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('following users notifications', () => { + beforeAll(async () => { + postAuthor = await neode.create( + 'User', + { + id: 'post-author', + name: 'Post Author', + slug: 'post-author', + }, + { + email: 'test@example.org', + password: '1234', + }, + ) + firstFollower = await neode.create( + 'User', + { + id: 'first-follower', + name: 'First Follower', + slug: 'first-follower', + }, + { + email: 'test2@example.org', + password: '1234', + }, + ) + secondFollower = await neode.create( + 'User', + { + id: 'second-follower', + name: 'Second Follower', + slug: 'second-follower', + }, + { + email: 'test3@example.org', + password: '1234', + }, + ) + await secondFollower.update({ emailNotificationsFollowingUsers: false }) + authenticatedUser = await firstFollower.toJson() + await mutate({ + mutation: followUserMutation, + variables: { id: 'post-author' }, + }) + authenticatedUser = await secondFollower.toJson() + await mutate({ + mutation: followUserMutation, + variables: { id: 'post-author' }, + }) + }) + + describe('the followed user writes a post', () => { + beforeAll(async () => { + authenticatedUser = await postAuthor.toJson() + await mutate({ + mutation: createPostMutation, + variables: { + id: 'post', + title: 'This is the post', + content: 'This is the content of the post', + }, + }) + }) + + it('sends NO notification to the post author', async () => { + await expect( + query({ + query: notificationQuery, + }), + ).resolves.toMatchObject({ + data: { + notifications: [], + }, + errors: undefined, + }) + }) + + it('sends notification to the first follower', async () => { + authenticatedUser = await firstFollower.toJson() + await expect( + query({ + query: notificationQuery, + }), + ).resolves.toMatchObject({ + data: { + notifications: [ + { + from: { + __typename: 'Post', + id: 'post', + }, + read: false, + reason: 'followed_user_posted', + }, + ], + }, + errors: undefined, + }) + }) + + it('sends notification to the second follower', async () => { + authenticatedUser = await secondFollower.toJson() + await expect( + query({ + query: notificationQuery, + }), + ).resolves.toMatchObject({ + data: { + notifications: [ + { + from: { + __typename: 'Post', + id: 'post', + }, + read: false, + reason: 'followed_user_posted', + }, + ], + }, + errors: undefined, + }) + }) + }) + + describe('followed user posts in public group', () => { + beforeAll(async () => { + authenticatedUser = await postAuthor.toJson() + await mutate({ + mutation: createGroupMutation(), + variables: { + id: 'g-1', + name: 'A group', + description: 'A group to test the follow user notification', + groupType: 'public', + actionRadius: 'national', + }, + }) + await mutate({ + mutation: createPostMutation, + variables: { + id: 'group-post', + title: 'This is the post in the group', + content: 'This is the content of the post in the group', + groupId: 'g-1', + }, + }) + }) + + it('sends NO notification to the post author', async () => { + await expect( + query({ + query: notificationQuery, + }), + ).resolves.toMatchObject({ + data: { + notifications: [], + }, + errors: undefined, + }) + }) + + it('sends notification to the first follower although he is no member of the group', async () => { + authenticatedUser = await firstFollower.toJson() + await expect( + query({ + query: notificationQuery, + }), + ).resolves.toMatchObject({ + data: { + notifications: [ + { + from: { + __typename: 'Post', + id: 'group-post', + }, + read: false, + reason: 'followed_user_posted', + }, + { + from: { + __typename: 'Post', + id: 'post', + }, + read: false, + reason: 'followed_user_posted', + }, + ], + }, + errors: undefined, + }) + }) + }) + + describe('followed user posts in closed group', () => { + beforeAll(async () => { + authenticatedUser = await postAuthor.toJson() + await mutate({ + mutation: createGroupMutation(), + variables: { + id: 'g-2', + name: 'A closed group', + description: 'A group to test the follow user notification', + groupType: 'closed', + actionRadius: 'national', + }, + }) + await mutate({ + mutation: createPostMutation, + variables: { + id: 'closed-group-post', + title: 'This is the post in the closed group', + content: 'This is the content of the post in the closed group', + groupId: 'g-2', + }, + }) + }) + + it('sends NO notification to the post author', async () => { + await expect( + query({ + query: notificationQuery, + }), + ).resolves.toMatchObject({ + data: { + notifications: [], + }, + errors: undefined, + }) + }) + + it('sends NO notification to the first follower', async () => { + authenticatedUser = await firstFollower.toJson() + await expect( + query({ + query: notificationQuery, + }), + ).resolves.toMatchObject({ + data: { + notifications: [ + { + from: { + __typename: 'Post', + id: 'group-post', + }, + read: false, + reason: 'followed_user_posted', + }, + { + from: { + __typename: 'Post', + id: 'post', + }, + read: false, + reason: 'followed_user_posted', + }, + ], + }, + errors: undefined, + }) + }) + }) + + describe('followed user posts in hidden group', () => { + beforeAll(async () => { + authenticatedUser = await postAuthor.toJson() + await mutate({ + mutation: createGroupMutation(), + variables: { + id: 'g-3', + name: 'A hidden group', + description: 'A hidden group to test the follow user notification', + groupType: 'hidden', + actionRadius: 'national', + }, + }) + await mutate({ + mutation: createPostMutation, + variables: { + id: 'hidden-group-post', + title: 'This is the post in the hidden group', + content: 'This is the content of the post in the hidden group', + groupId: 'g-3', + }, + }) + }) + + it('sends NO notification to the post author', async () => { + await expect( + query({ + query: notificationQuery, + }), + ).resolves.toMatchObject({ + data: { + notifications: [], + }, + errors: undefined, + }) + }) + + it('sends NO notification to the first follower', async () => { + authenticatedUser = await firstFollower.toJson() + await expect( + query({ + query: notificationQuery, + }), + ).resolves.toMatchObject({ + data: { + notifications: [ + { + from: { + __typename: 'Post', + id: 'group-post', + }, + read: false, + reason: 'followed_user_posted', + }, + { + from: { + __typename: 'Post', + id: 'post', + }, + read: false, + reason: 'followed_user_posted', + }, + ], + }, + errors: undefined, + }) + }) + }) +}) diff --git a/backend/src/middleware/notifications/notificationsMiddleware.ts b/backend/src/middleware/notifications/notificationsMiddleware.ts index d24ddc8ef..a733bc201 100644 --- a/backend/src/middleware/notifications/notificationsMiddleware.ts +++ b/backend/src/middleware/notifications/notificationsMiddleware.ts @@ -111,6 +111,7 @@ const handleRemoveUserFromGroup = async (resolve, root, args, context, resolveIn } const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => { + const { groupId } = args const idsOfUsers = extractMentionedUsers(args.content) const post = await resolve(root, args, context, resolveInfo) if (post) { @@ -119,6 +120,11 @@ const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo [notifyUsersOfMention('Post', post.id, idsOfUsers, 'mentioned_in_post', context)], 'emailNotificationsMention', ) + await publishNotifications( + context, + [notifyFollowingUsers(post.id, groupId, context)], + 'emailNotificationsFollowingUsers', + ) } return post } @@ -171,6 +177,45 @@ const postAuthorOfComment = async (commentId, { context }) => { } } +const notifyFollowingUsers = async (postId, groupId, context) => { + const reason = 'followed_user_posted' + const cypher = ` + MATCH (post:Post { id: $postId })<-[:WROTE]-(author:User { id: $userId })<-[:FOLLOWS]-(user:User) + OPTIONAL MATCH (post)-[:IN]->(group:Group { id: $groupId }) + WITH post, author, user, group WHERE group IS NULL OR group.groupType = 'public' + 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: groupId || null, + 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/validation/validationMiddleware.ts b/backend/src/middleware/validation/validationMiddleware.ts index ff26f5ef1..0920df6e0 100644 --- a/backend/src/middleware/validation/validationMiddleware.ts +++ b/backend/src/middleware/validation/validationMiddleware.ts @@ -101,7 +101,12 @@ const validateReview = async (resolve, root, args, context, info) => { } export const validateNotifyUsers = async (label, reason) => { - const reasonsAllowed = ['mentioned_in_post', 'mentioned_in_comment', 'commented_on_post'] + const reasonsAllowed = [ + 'mentioned_in_post', + 'mentioned_in_comment', + 'commented_on_post', + 'followed_user_posted', + ] if (!reasonsAllowed.includes(reason)) throw new Error('Notification reason is not allowed!') if ( (label === 'Post' && reason !== 'mentioned_in_post') || diff --git a/backend/src/models/User.ts b/backend/src/models/User.ts index e9fbfb6ce..e0cc770b4 100644 --- a/backend/src/models/User.ts +++ b/backend/src/models/User.ts @@ -185,6 +185,10 @@ export default { type: 'boolean', default: true, }, + emailNotificationsFollowingUsers: { + type: 'boolean', + default: true, + }, locale: { type: 'string', diff --git a/backend/src/schema/resolvers/users.spec.ts b/backend/src/schema/resolvers/users.spec.ts index df5a7f785..77d606fbb 100644 --- a/backend/src/schema/resolvers/users.spec.ts +++ b/backend/src/schema/resolvers/users.spec.ts @@ -675,6 +675,10 @@ describe('emailNotificationSettings', () => { name: 'mention', value: true, }, + { + name: 'followingUsers', + value: true, + }, ], }, { @@ -765,6 +769,10 @@ describe('emailNotificationSettings', () => { name: 'mention', value: false, }, + { + name: 'followingUsers', + value: true, + }, ], }, { diff --git a/backend/src/schema/resolvers/users.ts b/backend/src/schema/resolvers/users.ts index cca8e1278..134fb34cb 100644 --- a/backend/src/schema/resolvers/users.ts +++ b/backend/src/schema/resolvers/users.ts @@ -383,6 +383,10 @@ export default { name: 'mention', value: parent.emailNotificationsMention ?? true, }, + { + name: 'followingUsers', + value: parent.emailNotificationsFollowingUsers ?? true, + }, ], }, { diff --git a/backend/src/schema/types/enum/EmailNotificationSettingsName.gql b/backend/src/schema/types/enum/EmailNotificationSettingsName.gql index fa1d5846e..bcc6e617c 100644 --- a/backend/src/schema/types/enum/EmailNotificationSettingsName.gql +++ b/backend/src/schema/types/enum/EmailNotificationSettingsName.gql @@ -1,9 +1,10 @@ enum EmailNotificationSettingsName { commentOnObservedPost mention + followingUsers chatMessage groupMemberJoined groupMemberLeft groupMemberRemoved groupMemberRoleChanged -} \ No newline at end of file +} diff --git a/backend/src/schema/types/type/NOTIFIED.gql b/backend/src/schema/types/type/NOTIFIED.gql index 1f825decc..2726f503a 100644 --- a/backend/src/schema/types/type/NOTIFIED.gql +++ b/backend/src/schema/types/type/NOTIFIED.gql @@ -26,6 +26,7 @@ enum NotificationReason { user_left_group changed_group_member_role removed_user_from_group + followed_user_posted } type Query { diff --git a/webapp/locales/de.json b/webapp/locales/de.json index 42f6ab74f..0a64283a5 100644 --- a/webapp/locales/de.json +++ b/webapp/locales/de.json @@ -737,7 +737,8 @@ "post": "Beitrag oder Gruppe", "reason": { "changed_group_member_role": "Hat Deine Rolle in der Gruppe geändert …", - "commented_on_post": "Hat Deinen Beitrag kommentiert …", + "commented_on_post": "Hat einen Beitrag den du beobachtest kommentiert …", + "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 …", "removed_user_from_group": "Hat Dich aus der Gruppe entfernt …", @@ -1043,6 +1044,7 @@ "chatMessage": "Nachricht erhalten während Abwesenheit", "checkAll": "Alle auswählen", "commentOnObservedPost": "Kommentare zu beobachteten Beiträgen", + "followingUsers": "Ein Nutzer dem ich folge veröffentlichte einen neuen Beitrag", "group": "Gruppen", "groupMemberJoined": "Ein Mitglied ist deiner Gruppe beigetreten", "groupMemberLeft": "Ein Mitglied hat deine Gruppe verlassen", diff --git a/webapp/locales/en.json b/webapp/locales/en.json index 714c3f3c0..f73e77804 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -737,7 +737,8 @@ "post": "Post or Group", "reason": { "changed_group_member_role": "Changed your role in group …", - "commented_on_post": "Commented on your post …", + "commented_on_post": "Commented on a post you observe …", + "followed_user_posted": "Wrote a new post …", "mentioned_in_comment": "Mentioned you in a comment …", "mentioned_in_post": "Mentioned you in a post …", "removed_user_from_group": "Removed you from group …", @@ -1043,6 +1044,7 @@ "chatMessage": "Message received while absent", "checkAll": "Check all", "commentOnObservedPost": "Comments on observed posts", + "followingUsers": "User I follow published a new post", "group": "Groups", "groupMemberJoined": "Member joined a group I own", "groupMemberLeft": "Member left a group I own", diff --git a/webapp/locales/es.json b/webapp/locales/es.json index f0a1a866b..8efa724e9 100644 --- a/webapp/locales/es.json +++ b/webapp/locales/es.json @@ -738,6 +738,7 @@ "reason": { "changed_group_member_role": null, "commented_on_post": "Comentó su contribución ...", + "followed_user_posted": null, "mentioned_in_comment": "Le mencionó en un comentario …", "mentioned_in_post": "Le mencionó en una contribución …", "removed_user_from_group": null, @@ -1043,6 +1044,7 @@ "chatMessage": "Mensaje recibido mientras estaba ausente", "checkAll": "Seleccionar todo", "commentOnObservedPost": "Comentario en una contribución que estoy observando", + "followingUsers": null, "group": "Grupos", "groupMemberJoined": "Un nuevo miembro se unió a un grupo mio", "groupMemberLeft": "Un miembro dejó un grupo mio", diff --git a/webapp/locales/fr.json b/webapp/locales/fr.json index a31e197a1..e00df6543 100644 --- a/webapp/locales/fr.json +++ b/webapp/locales/fr.json @@ -738,6 +738,7 @@ "reason": { "changed_group_member_role": null, "commented_on_post": "Commenté sur votre post…", + "followed_user_posted": null, "mentioned_in_comment": "Vous a mentionné dans un commentaire…", "mentioned_in_post": "Vous a mentionné dans un post…", "removed_user_from_group": null, @@ -1043,6 +1044,7 @@ "chatMessage": "Message reçu pendant l'absence", "checkAll": "Tout cocher", "commentOnObservedPost": "Commentez une contribution que je suis", + "followingUsers": null, "group": "Groups", "groupMemberJoined": "Un nouveau membre a rejoint un de mes groupes", "groupMemberLeft": "Un membre a quitté un de mes groupes", diff --git a/webapp/locales/it.json b/webapp/locales/it.json index 8f14dfda2..52597f9fb 100644 --- a/webapp/locales/it.json +++ b/webapp/locales/it.json @@ -738,6 +738,7 @@ "reason": { "changed_group_member_role": null, "commented_on_post": null, + "followed_user_posted": null, "mentioned_in_comment": null, "mentioned_in_post": null, "removed_user_from_group": null, @@ -1043,6 +1044,7 @@ "chatMessage": "Messaggio ricevuto durante l'assenza", "checkAll": "Seleziona tutto", "commentOnObservedPost": "Commenta un contributo che sto guardando", + "followingUsers": null, "group": "Gruppi", "groupMemberJoined": "Un nuovo membro si è unito a un mio gruppo", "groupMemberLeft": "Un membro ha lasciato un mio gruppo", diff --git a/webapp/locales/nl.json b/webapp/locales/nl.json index e332d38dc..bbf99f186 100644 --- a/webapp/locales/nl.json +++ b/webapp/locales/nl.json @@ -738,6 +738,7 @@ "reason": { "changed_group_member_role": null, "commented_on_post": null, + "followed_user_posted": null, "mentioned_in_comment": null, "mentioned_in_post": null, "removed_user_from_group": null, @@ -1043,6 +1044,7 @@ "chatMessage": "Bericht ontvangen tijdens afwezigheid", "checkAll": "Vink alles aan", "commentOnObservedPost": "Geef commentaar op een bijdrage die ik volg", + "followingUsers": null, "group": "Groepen", "groupMemberJoined": "Een nieuw lid is lid geworden van een groep van mij", "groupMemberLeft": "Een lid heeft een groep van mij verlaten", diff --git a/webapp/locales/pl.json b/webapp/locales/pl.json index 5c636dfab..78223f943 100644 --- a/webapp/locales/pl.json +++ b/webapp/locales/pl.json @@ -738,6 +738,7 @@ "reason": { "changed_group_member_role": null, "commented_on_post": null, + "followed_user_posted": null, "mentioned_in_comment": null, "mentioned_in_post": null, "removed_user_from_group": null, @@ -1043,6 +1044,7 @@ "chatMessage": "Wiadomość otrzymana podczas nieobecności", "checkAll": "Wybierz wszystko", "commentOnObservedPost": "Skomentuj wpis, który obserwuję", + "followingUsers": null, "group": "Grupy", "groupMemberJoined": "Nowy członek dołączył do mojej grupy", "groupMemberLeft": "Członek opuścił moją grupę", diff --git a/webapp/locales/pt.json b/webapp/locales/pt.json index c00acbf0a..4848546a0 100644 --- a/webapp/locales/pt.json +++ b/webapp/locales/pt.json @@ -738,6 +738,7 @@ "reason": { "changed_group_member_role": null, "commented_on_post": "Comentou no seu post …", + "followed_user_posted": null, "mentioned_in_comment": "Mentionou você em um comentário …", "mentioned_in_post": "Mencinou você em um post …", "removed_user_from_group": null, @@ -1043,6 +1044,7 @@ "chatMessage": "Mensagem recebida durante a ausência", "checkAll": "Marcar tudo", "commentOnObservedPost": "Comentários sobre as mensagens observadas", + "followingUsers": null, "group": "Grupos", "groupMemberJoined": "Member joined a group I own", "groupMemberLeft": "Membro saiu de um grupo de que sou proprietário", diff --git a/webapp/locales/ru.json b/webapp/locales/ru.json index 5775264fa..ac33dff47 100644 --- a/webapp/locales/ru.json +++ b/webapp/locales/ru.json @@ -738,6 +738,7 @@ "reason": { "changed_group_member_role": null, "commented_on_post": "Комментарий к посту...", + "followed_user_posted": null, "mentioned_in_comment": "Упоминание в комментарии....", "mentioned_in_post": "Упоминание в посте....", "removed_user_from_group": null, @@ -1043,6 +1044,7 @@ "chatMessage": "Сообщение, полученное во время отсутствия", "checkAll": "Отметить все", "commentOnObservedPost": "Комментарии по поводу замеченных сообщений", + "followingUsers": null, "group": "Группы", "groupMemberJoined": "Участник присоединился к группе, которой я владею", "groupMemberLeft": "Участник вышел из группы, которой владею",