feat(backend): notify users when a followed user posted (#8313)

This commit is contained in:
Moriz Wahl 2025-04-11 17:56:11 +02:00 committed by GitHub
parent bda0de0249
commit 3734e2ef56
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 510 additions and 4 deletions

View File

@ -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,
})
})
})
})

View File

@ -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 })

View File

@ -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') ||

View File

@ -185,6 +185,10 @@ export default {
type: 'boolean',
default: true,
},
emailNotificationsFollowingUsers: {
type: 'boolean',
default: true,
},
locale: {
type: 'string',

View File

@ -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,
},
],
},
{

View File

@ -383,6 +383,10 @@ export default {
name: 'mention',
value: parent.emailNotificationsMention ?? true,
},
{
name: 'followingUsers',
value: parent.emailNotificationsFollowingUsers ?? true,
},
],
},
{

View File

@ -1,9 +1,10 @@
enum EmailNotificationSettingsName {
commentOnObservedPost
mention
followingUsers
chatMessage
groupMemberJoined
groupMemberLeft
groupMemberRemoved
groupMemberRoleChanged
}
}

View File

@ -26,6 +26,7 @@ enum NotificationReason {
user_left_group
changed_group_member_role
removed_user_from_group
followed_user_posted
}
type Query {

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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ę",

View File

@ -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",

View File

@ -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": "Участник вышел из группы, которой владею",