Merge branch 'master' into revert-6103-newsfeed-bigger-gap-between-posts

This commit is contained in:
Wolfgang Huß 2023-03-20 13:41:12 +01:00 committed by GitHub
commit f3f7e8c508
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 474 additions and 48 deletions

View File

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

View File

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

View File

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

View File

@ -397,7 +397,8 @@ describe('given some notifications', () => {
it('returns all as read', async () => {
const response = await mutate({ mutation: markAllAsReadMutation(), variables })
expect(response.data.markAllAsRead).toEqual([
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' },
@ -408,7 +409,8 @@ describe('given some notifications', () => {
from: { __typename: 'Post', content: 'You have been mentioned in a post' },
read: true,
},
])
]),
)
expect(response.errors).toBeUndefined()
})
})

View File

@ -1,5 +0,0 @@
enum ReasonNotification {
mentioned_in_post
mentioned_in_comment
commented_on_post
}

View File

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

View File

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

View File

@ -1,19 +1,24 @@
<template>
<article :class="{ '--read': notification.read, notification: true }">
<client-only>
<user-teaser :user="from.author" :date-time="from.createdAt" />
<user-teaser
:user="isGroup ? notification.relatedUser : from.author"
:date-time="from.createdAt"
/>
</client-only>
<p class="description">{{ $t(`notifications.reason.${notification.reason}`) }}</p>
<nuxt-link
class="link"
:to="{ name: 'post-id-slug', params, ...hashParam }"
:to="{ name: isGroup ? 'group-id-slug' : 'post-id-slug', params, hashParam }"
@click.native="$emit('read')"
>
<base-card wideContent>
<h2 class="title">{{ from.title || from.post.title }}</h2>
<h2 class="title">{{ from.title || from.groupName || from.post.title }}</h2>
<p>
<strong v-if="isComment" class="comment">{{ $t(`notifications.comment`) }}:</strong>
{{ from.contentExcerpt | removeHtml }}
<strong v-if="isGroup" class="comment">{{ $t(`notifications.group`) }}:</strong>
{{ from.descriptionExcerpt | removeHtml }}
</p>
</base-card>
</nuxt-link>
@ -41,11 +46,14 @@ export default {
isComment() {
return this.from.__typename === 'Comment'
},
isGroup() {
return this.from.__typename === 'Group'
},
params() {
const post = this.isComment ? this.from.post : this.from
const target = this.isComment ? this.from.post : this.from
return {
id: post.id,
slug: post.slug,
id: target.id,
slug: target.slug,
}
},
hashParam() {

View File

@ -39,7 +39,11 @@
<ds-space margin-bottom="base">
<client-only>
<user-teaser
:user="notification.from.author"
:user="
isGroup(notification.from)
? notification.relatedUser
: notification.from.author
"
:date-time="notification.from.createdAt"
:class="{ 'notification-status': notification.read }"
/>
@ -61,14 +65,18 @@
class="notification-mention-post"
:class="{ 'notification-status': notification.read }"
:to="{
name: 'post-id-slug',
name: isGroup(notification.from) ? 'group-id-slug' : 'post-id-slug',
params: params(notification.from),
hash: hashParam(notification.from),
}"
@click.native="markNotificationAsRead(notification.from.id)"
>
<b>
{{ notification.from.title || notification.from.post.title | truncate(50) }}
{{
notification.from.title ||
notification.from.groupName ||
notification.from.post.title | truncate(50)
}}
</b>
</nuxt-link>
</base-card>
@ -76,7 +84,10 @@
<ds-flex-item>
<base-card :wide-content="true">
<b :class="{ 'notification-status': notification.read }">
{{ notification.from.contentExcerpt | removeHtml }}
{{
notification.from.contentExcerpt ||
notification.from.descriptionExcerpt | removeHtml
}}
</b>
</base-card>
</ds-flex-item>
@ -132,11 +143,16 @@ export default {
isComment(notificationSource) {
return notificationSource.__typename === 'Comment'
},
isGroup(notificationSource) {
return notificationSource.__typename === 'Group'
},
params(notificationSource) {
const post = this.isComment(notificationSource) ? notificationSource.post : notificationSource
const target = this.isComment(notificationSource)
? notificationSource.post
: notificationSource
return {
id: post.id,
slug: post.slug,
id: target.id,
slug: target.slug,
}
},
hashParam(notificationSource) {

View File

@ -6,6 +6,7 @@ import {
userFragment,
postFragment,
commentFragment,
groupFragment,
} from './Fragments'
export const profileUserQuery = (i18n) => {
@ -113,6 +114,7 @@ export const notificationQuery = (_i18n) => {
${userFragment}
${commentFragment}
${postFragment}
${groupFragment}
query ($read: Boolean, $orderBy: NotificationOrdering, $first: Int, $offset: Int) {
notifications(read: $read, orderBy: $orderBy, first: $first, offset: $offset) {
@ -121,6 +123,9 @@ export const notificationQuery = (_i18n) => {
reason
createdAt
updatedAt
to {
...user
}
from {
__typename
... on Post {
@ -141,6 +146,12 @@ export const notificationQuery = (_i18n) => {
}
}
}
... on Group {
...group
}
}
relatedUser {
...user
}
}
}
@ -152,6 +163,7 @@ export const markAsReadMutation = (_i18n) => {
${userFragment}
${commentFragment}
${postFragment}
${groupFragment}
mutation ($id: ID!) {
markAsRead(id: $id) {
@ -177,6 +189,9 @@ export const markAsReadMutation = (_i18n) => {
}
}
}
... on Group {
...group
}
}
}
}
@ -188,6 +203,7 @@ export const markAllAsReadMutation = (_i18n) => {
${userFragment}
${commentFragment}
${postFragment}
${groupFragment}
mutation {
markAllAsRead {
@ -213,6 +229,9 @@ export const markAllAsReadMutation = (_i18n) => {
}
}
}
... on Group {
...group
}
}
}
}

View File

@ -640,20 +640,25 @@
},
"notifications": {
"comment": "Kommentar",
"content": "Inhalt",
"content": "Inhalt oder Beschreibung",
"empty": "Bedaure, Du hast momentan keinerlei Benachrichtigungen.",
"filterLabel": {
"all": "Alle",
"read": "Gelesen",
"unread": "Ungelesen"
},
"group": "Beschreibung",
"markAllAsRead": "Markiere alle als gelesen",
"pageLink": "Alle Benachrichtigungen",
"post": "Beitrag",
"post": "Beitrag oder Gruppe",
"reason": {
"changed_group_member_role": "Hat Deine Rolle in der Gruppe geändert …",
"commented_on_post": "Hat Deinen Beitrag kommentiert …",
"mentioned_in_comment": "Hat Dich in einem Kommentar erwähnt …",
"mentioned_in_post": "Hat Dich in einem Beitrag erwähnt …"
"mentioned_in_post": "Hat Dich in einem Beitrag erwähnt …",
"removed_user_from_group": "Hat Dich aus der Gruppe entfernt …",
"user_joined_group": "Ist Deiner Gruppe beigetreten …",
"user_left_group": "Hat deine Gruppe verlassen …"
},
"title": "Benachrichtigungen",
"user": "Nutzer"

View File

@ -640,20 +640,25 @@
},
"notifications": {
"comment": "Comment",
"content": "Content",
"content": "Content or Description",
"empty": "Sorry, you don't have any notifications at the moment.",
"filterLabel": {
"all": "All",
"read": "Read",
"unread": "Unread"
},
"group": "Description",
"markAllAsRead": "Mark all as read",
"pageLink": "All notifications",
"post": "Post",
"post": "Post or Group",
"reason": {
"changed_group_member_role": "Changed your role in group …",
"commented_on_post": "Commented on your post …",
"mentioned_in_comment": "Mentioned you in a comment …",
"mentioned_in_post": "Mentioned you in a post …"
"mentioned_in_post": "Mentioned you in a post …",
"removed_user_from_group": "Removed you from group …",
"user_joined_group": "Joined your group …",
"user_left_group": "Left your group …"
},
"title": "Notifications",
"user": "User"

View File

@ -565,7 +565,7 @@ export default {
// },
prepareJoinLeave() {
// "membersCountStartValue" is updated to avoid counting from 0 when join/leave
this.membersCountStartValue = this.GroupMembers.length
this.membersCountStartValue = (this.GroupMembers && this.GroupMembers.length) || 0
},
updateJoinLeave({ myRoleInGroup }) {
this.Group[0].myRole = myRoleInGroup