feat(backend): notify posts in groups (#8346)

Co-authored-by: Wolfgang Huß <wolle.huss@pjannto.com>
This commit is contained in:
Moriz Wahl 2025-04-11 22:50:59 +02:00 committed by GitHub
parent 7b4b0774e4
commit aedf8d93c7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 560 additions and 8 deletions

View File

@ -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({

View File

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

View File

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

View File

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

View File

@ -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 (

View File

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

View File

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

View File

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

View File

@ -387,6 +387,10 @@ export default {
name: 'followingUsers',
value: parent.emailNotificationsFollowingUsers ?? true,
},
{
name: 'postInGroup',
value: parent.emailNotificationsPostInGroup ?? true,
},
],
},
{

View File

@ -2,6 +2,7 @@ enum EmailNotificationSettingsName {
commentOnObservedPost
mention
followingUsers
postInGroup
chatMessage
groupMemberJoined
groupMemberLeft

View File

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

View File

@ -27,6 +27,7 @@ enum NotificationReason {
changed_group_member_role
removed_user_from_group
followed_user_posted
post_in_group
}
type Query {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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