Merge pull request #1426 from Human-Connection/1414-refactor_notifications

1414 Bugfix: Delete notifications if connected post, comment or user is deleted
This commit is contained in:
mattwr18 2019-08-30 19:31:46 +02:00 committed by GitHub
commit 63520a43cd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 723 additions and 793 deletions

View File

@ -12,11 +12,9 @@ export default {
CreatePost: setCreatedAt,
CreateComment: setCreatedAt,
CreateOrganization: setCreatedAt,
CreateNotification: setCreatedAt,
UpdateUser: setUpdatedAt,
UpdatePost: setUpdatedAt,
UpdateComment: setUpdatedAt,
UpdateOrganization: setUpdatedAt,
UpdateNotification: setUpdatedAt,
},
}

View File

@ -4,13 +4,13 @@ const notifyUsers = async (label, id, idsOfUsers, reason, context) => {
if (!idsOfUsers.length) return
// Checked here, because it does not go through GraphQL checks at all in this file.
const reasonsAllowed = ['mentioned_in_post', 'mentioned_in_comment', 'comment_on_post']
const reasonsAllowed = ['mentioned_in_post', 'mentioned_in_comment', 'commented_on_post']
if (!reasonsAllowed.includes(reason)) {
throw new Error('Notification reason is not allowed!')
}
if (
(label === 'Post' && reason !== 'mentioned_in_post') ||
(label === 'Comment' && !['mentioned_in_comment', 'comment_on_post'].includes(reason))
(label === 'Comment' && !['mentioned_in_comment', 'commented_on_post'].includes(reason))
) {
throw new Error('Notification does not fit the reason!')
}
@ -25,8 +25,9 @@ const notifyUsers = async (label, id, idsOfUsers, reason, context) => {
MATCH (user: User)
WHERE user.id in $idsOfUsers
AND NOT (user)<-[:BLOCKED]-(author)
CREATE (notification: Notification {id: apoc.create.uuid(), read: false, reason: $reason, createdAt: $createdAt })
MERGE (post)-[:NOTIFIED]->(notification)-[:NOTIFIED]->(user)
MERGE (post)-[notification:NOTIFIED {reason: $reason}]->(user)
SET notification.read = FALSE
SET notification.createdAt = $createdAt
`
break
}
@ -37,20 +38,22 @@ const notifyUsers = async (label, id, idsOfUsers, reason, context) => {
WHERE user.id in $idsOfUsers
AND NOT (user)<-[:BLOCKED]-(author)
AND NOT (user)<-[:BLOCKED]-(postAuthor)
CREATE (notification: Notification {id: apoc.create.uuid(), read: false, reason: $reason, createdAt: $createdAt })
MERGE (comment)-[:NOTIFIED]->(notification)-[:NOTIFIED]->(user)
MERGE (comment)-[notification:NOTIFIED {reason: $reason}]->(user)
SET notification.read = FALSE
SET notification.createdAt = $createdAt
`
break
}
case 'comment_on_post': {
case 'commented_on_post': {
cypher = `
MATCH (postAuthor: User)-[:WROTE]->(post: Post)<-[:COMMENTS]-(comment: Comment { id: $id })<-[:WROTE]-(author: User)
MATCH (user: User)
WHERE user.id in $idsOfUsers
AND NOT (user)<-[:BLOCKED]-(author)
AND NOT (author)<-[:BLOCKED]-(user)
CREATE (notification: Notification {id: apoc.create.uuid(), read: false, reason: $reason, createdAt: $createdAt })
MERGE (comment)-[:NOTIFIED]->(notification)-[:NOTIFIED]->(user)
MERGE (comment)-[notification:NOTIFIED {reason: $reason}]->(user)
SET notification.read = FALSE
SET notification.createdAt = $createdAt
`
break
}
@ -105,7 +108,7 @@ const handleCreateComment = async (resolve, root, args, context, resolveInfo) =>
return record.get('user')
})
if (context.user.id !== postAuthor.id) {
await notifyUsers('Comment', comment.id, [postAuthor.id], 'comment_on_post', context)
await notifyUsers('Comment', comment.id, [postAuthor.id], 'commented_on_post', context)
}
}

View File

@ -77,14 +77,18 @@ afterEach(async () => {
describe('notifications', () => {
const notificationQuery = gql`
query($read: Boolean) {
currentUser {
notifications(read: $read, orderBy: createdAt_desc) {
read
reason
post {
notifications(read: $read, orderBy: createdAt_desc) {
read
reason
createdAt
from {
__typename
... on Post {
id
content
}
comment {
... on Comment {
id
content
}
}
@ -154,18 +158,18 @@ describe('notifications', () => {
await createCommentOnPostAction()
const expected = expect.objectContaining({
data: {
currentUser: {
notifications: [
{
read: false,
reason: 'comment_on_post',
post: null,
comment: {
content: commentContent,
},
notifications: [
{
read: false,
createdAt: expect.any(String),
reason: 'commented_on_post',
from: {
__typename: 'Comment',
id: 'c47',
content: commentContent,
},
],
},
},
],
},
})
const { query } = createTestClient(server)
@ -183,11 +187,7 @@ describe('notifications', () => {
await notifiedUser.relateTo(commentAuthor, 'blocked')
await createCommentOnPostAction()
const expected = expect.objectContaining({
data: {
currentUser: {
notifications: [],
},
},
data: { notifications: [] },
})
const { query } = createTestClient(server)
await expect(
@ -211,11 +211,7 @@ describe('notifications', () => {
await notifiedUser.relateTo(commentAuthor, 'blocked')
await createCommentOnPostAction()
const expected = expect.objectContaining({
data: {
currentUser: {
notifications: [],
},
},
data: { notifications: [] },
})
const { query } = createTestClient(server)
await expect(
@ -253,18 +249,18 @@ describe('notifications', () => {
'Hey <a class="mention" data-mention-id="you" href="/profile/you/al-capone" target="_blank">@al-capone</a> how do you do?'
const expected = expect.objectContaining({
data: {
currentUser: {
notifications: [
{
read: false,
reason: 'mentioned_in_post',
post: {
content: expectedContent,
},
comment: null,
notifications: [
{
read: false,
createdAt: expect.any(String),
reason: 'mentioned_in_post',
from: {
__typename: 'Post',
id: 'p47',
content: expectedContent,
},
],
},
},
],
},
})
const { query } = createTestClient(server)
@ -278,7 +274,7 @@ describe('notifications', () => {
).resolves.toEqual(expected)
})
describe('many times', () => {
describe('updates the post and mentions me again', () => {
const updatePostAction = async () => {
const updatedContent = `
One more mention to
@ -307,33 +303,25 @@ describe('notifications', () => {
authenticatedUser = await notifiedUser.toJson()
}
it('creates exactly one more notification', async () => {
it('creates no duplicate notification for the same resource', async () => {
const expectedUpdatedContent =
'<br>One more mention to<br><a data-mention-id="you" class="mention" href="/profile/you" target="_blank"><br>@al-capone<br></a><br>and again:<br><a data-mention-id="you" class="mention" href="/profile/you" target="_blank"><br>@al-capone<br></a><br>and again<br><a data-mention-id="you" class="mention" href="/profile/you" target="_blank"><br>@al-capone<br></a><br>'
await createPostAction()
await updatePostAction()
const expectedContent =
'<br>One more mention to<br><a data-mention-id="you" class="mention" href="/profile/you" target="_blank"><br>@al-capone<br></a><br>and again:<br><a data-mention-id="you" class="mention" href="/profile/you" target="_blank"><br>@al-capone<br></a><br>and again<br><a data-mention-id="you" class="mention" href="/profile/you" target="_blank"><br>@al-capone<br></a><br>'
const expected = expect.objectContaining({
data: {
currentUser: {
notifications: [
{
read: false,
reason: 'mentioned_in_post',
post: {
content: expectedContent,
},
comment: null,
notifications: [
{
read: false,
createdAt: expect.any(String),
reason: 'mentioned_in_post',
from: {
__typename: 'Post',
id: 'p47',
content: expectedUpdatedContent,
},
{
read: false,
reason: 'mentioned_in_post',
post: {
content: expectedContent,
},
comment: null,
},
],
},
},
],
},
})
await expect(
@ -345,6 +333,68 @@ describe('notifications', () => {
}),
).resolves.toEqual(expected)
})
describe('if the notification was marked as read earlier', () => {
const markAsReadAction = async () => {
const mutation = gql`
mutation($id: ID!) {
markAsRead(id: $id) {
read
}
}
`
await mutate({ mutation, variables: { id: 'p47' } })
}
describe('but the next mention happens after the notification was marked as read', () => {
it('sets the `read` attribute to false again', async () => {
await createPostAction()
await markAsReadAction()
const {
data: {
notifications: [{ read: readBefore }],
},
} = await query({
query: notificationQuery,
})
await updatePostAction()
const {
data: {
notifications: [{ read: readAfter }],
},
} = await query({
query: notificationQuery,
})
expect(readBefore).toEqual(true)
expect(readAfter).toEqual(false)
})
it('updates the `createdAt` attribute', async () => {
await createPostAction()
await markAsReadAction()
const {
data: {
notifications: [{ createdAt: createdAtBefore }],
},
} = await query({
query: notificationQuery,
})
await updatePostAction()
const {
data: {
notifications: [{ createdAt: createdAtAfter }],
},
} = await query({
query: notificationQuery,
})
expect(createdAtBefore).toBeTruthy()
expect(Date.parse(createdAtBefore)).toEqual(expect.any(Number))
expect(createdAtAfter).toBeTruthy()
expect(Date.parse(createdAtAfter)).toEqual(expect.any(Number))
expect(createdAtBefore).not.toEqual(createdAtAfter)
})
})
})
})
describe('but the author of the post blocked me', () => {
@ -355,11 +405,7 @@ describe('notifications', () => {
it('sends no notification', async () => {
await createPostAction()
const expected = expect.objectContaining({
data: {
currentUser: {
notifications: [],
},
},
data: { notifications: [] },
})
const { query } = createTestClient(server)
await expect(
@ -397,18 +443,18 @@ describe('notifications', () => {
await createCommentOnPostAction()
const expected = expect.objectContaining({
data: {
currentUser: {
notifications: [
{
read: false,
reason: 'mentioned_in_comment',
post: null,
comment: {
content: commentContent,
},
notifications: [
{
read: false,
createdAt: expect.any(String),
reason: 'mentioned_in_comment',
from: {
__typename: 'Comment',
id: 'c47',
content: commentContent,
},
],
},
},
],
},
})
const { query } = createTestClient(server)
@ -440,11 +486,7 @@ describe('notifications', () => {
it('sends no notification', async () => {
await createCommentOnPostAction()
const expected = expect.objectContaining({
data: {
currentUser: {
notifications: [],
},
},
data: { notifications: [] },
})
const { query } = createTestClient(server)
await expect(

View File

@ -41,32 +41,6 @@ const isMySocialMedia = rule({
return socialMedia.ownedBy.node.id === user.id
})
const belongsToMe = rule({
cache: 'no_cache',
})(async (_, args, context) => {
const {
driver,
user: { id: userId },
} = context
const { id: notificationId } = args
const session = driver.session()
const result = await session.run(
`
MATCH (u:User {id: $userId})<-[:NOTIFIED]-(n:Notification {id: $notificationId})
RETURN n
`,
{
userId,
notificationId,
},
)
const [notification] = result.records.map(record => {
return record.get('n')
})
session.close()
return Boolean(notification)
})
/* TODO: decide if we want to remove this check: the check
* `onlyEnabledContent` throws authorization errors only if you have
* arguments for `disabled` or `deleted` assuming these are filter
@ -149,7 +123,6 @@ const permissions = shield(
Category: allow,
Tag: allow,
Report: isModerator,
Notification: isAdmin,
statistics: allow,
currentUser: allow,
Post: or(onlyEnabledContent, isModerator),
@ -160,6 +133,7 @@ const permissions = shield(
PostsEmotionsCountByEmotion: allow,
PostsEmotionsByCurrentUser: allow,
blockedUsers: isAuthenticated,
notifications: isAuthenticated,
},
Mutation: {
'*': deny,
@ -168,7 +142,6 @@ const permissions = shield(
Signup: isAdmin,
SignupVerification: allow,
CreateInvitationCode: and(isAuthenticated, or(not(invitationLimitReached), isAdmin)),
UpdateNotification: belongsToMe,
UpdateUser: onlyYourself,
CreatePost: isAuthenticated,
UpdatePost: isAuthor,
@ -198,6 +171,7 @@ const permissions = shield(
RemovePostEmotions: isAuthenticated,
block: isAuthenticated,
unblock: isAuthenticated,
markAsRead: isAuthenticated,
},
User: {
email: isMyOwn,

View File

@ -1,36 +0,0 @@
import uuid from 'uuid/v4'
module.exports = {
id: {
type: 'uuid',
primary: true,
default: uuid,
},
read: {
type: 'boolean',
default: false,
},
reason: {
type: 'string',
valid: ['mentioned_in_post', 'mentioned_in_comment', 'comment_on_post'],
invalid: [null],
default: 'mentioned_in_post',
},
createdAt: {
type: 'string',
isoDate: true,
default: () => new Date().toISOString(),
},
user: {
type: 'relationship',
relationship: 'NOTIFIED',
target: 'User',
direction: 'out',
},
post: {
type: 'relationship',
relationship: 'NOTIFIED',
target: 'Post',
direction: 'in',
},
}

View File

@ -7,6 +7,5 @@ export default {
EmailAddress: require('./EmailAddress.js'),
SocialMedia: require('./SocialMedia.js'),
Post: require('./Post.js'),
Notification: require('./Notification.js'),
Category: require('./Category.js'),
}

View File

@ -20,6 +20,7 @@ export default applyScalars(
'Statistics',
'LoggedInUser',
'SocialMedia',
'NOTIFIED',
],
// add 'User' here as soon as possible
},
@ -32,6 +33,7 @@ export default applyScalars(
'Statistics',
'LoggedInUser',
'SocialMedia',
'NOTIFIED',
],
// add 'User' here as soon as possible
},

View File

@ -1,4 +1,5 @@
import { neo4jgraphql } from 'neo4j-graphql-js'
import Resolver from './helpers/Resolver'
export default {
Mutation: {
@ -52,4 +53,13 @@ export default {
return comment
},
},
Comment: {
...Resolver('Comment', {
hasOne: {
author: '<-[:WROTE]-(related:User)',
post: '-[:COMMENTS]->(related:Post)',
disabledBy: '<-[:DISABLED]-(related:User)',
},
}),
},
}

View File

@ -1,14 +1,80 @@
import { neo4jgraphql } from 'neo4j-graphql-js'
const resourceTypes = ['Post', 'Comment']
const transformReturnType = record => {
return {
...record.get('notification').properties,
from: {
__typename: record.get('resource').labels.find(l => resourceTypes.includes(l)),
...record.get('resource').properties,
},
to: {
...record.get('user').properties,
},
}
}
export default {
Query: {
Notification: (object, params, context, resolveInfo) => {
return neo4jgraphql(object, params, context, resolveInfo, false)
notifications: async (parent, args, context, resolveInfo) => {
const { user: currentUser } = context
const session = context.driver.session()
let notifications
let whereClause
let orderByClause
switch (args.read) {
case true:
whereClause = 'WHERE notification.read = TRUE'
break
case false:
whereClause = 'WHERE notification.read = FALSE'
break
default:
whereClause = ''
}
switch (args.orderBy) {
case 'createdAt_asc':
orderByClause = 'ORDER BY notification.createdAt ASC'
break
case 'createdAt_desc':
orderByClause = 'ORDER BY notification.createdAt DESC'
break
default:
orderByClause = ''
}
try {
const cypher = `
MATCH (resource)-[notification:NOTIFIED]->(user:User {id:$id})
${whereClause}
RETURN resource, notification, user
${orderByClause}
`
const result = await session.run(cypher, { id: currentUser.id })
notifications = await result.records.map(transformReturnType)
} finally {
session.close()
}
return notifications
},
},
Mutation: {
UpdateNotification: (object, params, context, resolveInfo) => {
return neo4jgraphql(object, params, context, resolveInfo, false)
markAsRead: async (parent, args, context, resolveInfo) => {
const { user: currentUser } = context
const session = context.driver.session()
let notification
try {
const cypher = `
MATCH (resource {id: $resourceId})-[notification:NOTIFIED {read: FALSE}]->(user:User {id:$id})
SET notification.read = TRUE
RETURN resource, notification, user
`
const result = await session.run(cypher, { resourceId: args.id, id: currentUser.id })
const notifications = await result.records.map(transformReturnType)
notification = notifications[0]
} finally {
session.close()
}
return notification
},
},
}

View File

@ -1,397 +1,309 @@
import { GraphQLClient } from 'graphql-request'
import Factory from '../../seed/factories'
import { host, login, gql } from '../../jest/helpers'
import { neode } from '../../bootstrap/neo4j'
import { gql } from '../../jest/helpers'
import { neode as getNeode, getDriver } from '../../bootstrap/neo4j'
import { createTestClient } from 'apollo-server-testing'
import createServer from '../.././server'
let client
const factory = Factory()
const instance = neode()
const neode = getNeode()
const driver = getDriver()
const userParams = {
id: 'you',
email: 'test@example.org',
password: '1234',
}
const categoryIds = ['cat9']
let authenticatedUser
let user
let variables
let query
let mutate
beforeAll(() => {
const { server } = createServer({
context: () => {
return {
driver,
user: authenticatedUser,
}
},
})
query = createTestClient(server).query
mutate = createTestClient(server).mutate
})
beforeEach(async () => {
await factory.create('User', userParams)
await instance.create('Category', {
id: 'cat9',
name: 'Democracy & Politics',
icon: 'university',
})
authenticatedUser = null
variables = { orderBy: 'createdAt_asc' }
})
afterEach(async () => {
await factory.cleanDatabase()
})
describe('Notification', () => {
const notificationQuery = gql`
query {
Notification {
id
}
}
`
describe('unauthenticated', () => {
it('throws authorization error', async () => {
client = new GraphQLClient(host)
await expect(client.request(notificationQuery)).rejects.toThrow('Not Authorised')
})
describe('given some notifications', () => {
beforeEach(async () => {
user = await factory.create('User', userParams)
await factory.create('User', { id: 'neighbor' })
await Promise.all(setupNotifications.map(s => neode.cypher(s)))
})
})
const setupNotifications = [
`MATCH(user:User {id: 'neighbor'})
MERGE (:Post {id: 'p1', content: 'Not for you'})
-[:NOTIFIED {createdAt: "2019-08-29T17:33:48.651Z", read: false, reason: "mentioned_in_post"}]
->(user);
`,
`MATCH(user:User {id: 'you'})
MERGE (:Post {id: 'p2', content: 'Already seen post mentioning'})
-[:NOTIFIED {createdAt: "2019-08-30T17:33:48.651Z", read: true, reason: "mentioned_in_post"}]
->(user);
`,
`MATCH(user:User {id: 'you'})
MERGE (:Post {id: 'p3', content: 'You have been mentioned in a post'})
-[:NOTIFIED {createdAt: "2019-08-31T17:33:48.651Z", read: false, reason: "mentioned_in_post"}]
->(user);
`,
`MATCH(user:User {id: 'you'})
MATCH(post:Post {id: 'p3'})
CREATE (comment:Comment {id: 'c1', content: 'You have seen this comment mentioning already'})
MERGE (comment)-[:COMMENTS]->(post)
MERGE (comment)
-[:NOTIFIED {createdAt: "2019-08-30T15:33:48.651Z", read: true, reason: "mentioned_in_comment"}]
->(user);
`,
`MATCH(user:User {id: 'you'})
MATCH(post:Post {id: 'p3'})
CREATE (comment:Comment {id: 'c2', content: 'You have been mentioned in a comment'})
MERGE (comment)-[:COMMENTS]->(post)
MERGE (comment)
-[:NOTIFIED {createdAt: "2019-08-30T19:33:48.651Z", read: false, reason: "mentioned_in_comment"}]
->(user);
`,
`MATCH(user:User {id: 'neighbor'})
MATCH(post:Post {id: 'p3'})
CREATE (comment:Comment {id: 'c3', content: 'Somebody else was mentioned in a comment'})
MERGE (comment)-[:COMMENTS]->(post)
MERGE (comment)
-[:NOTIFIED {createdAt: "2019-09-01T17:33:48.651Z", read: false, reason: "mentioned_in_comment"}]
->(user);
`,
]
describe('currentUser notifications', () => {
const variables = {}
describe('authenticated', () => {
let headers
beforeEach(async () => {
headers = await login({
email: 'test@example.org',
password: '1234',
})
client = new GraphQLClient(host, {
headers,
})
})
describe('given some notifications', () => {
beforeEach(async () => {
const neighborParams = {
email: 'neighbor@example.org',
password: '1234',
id: 'neighbor',
describe('notifications', () => {
const notificationQuery = gql`
query($read: Boolean, $orderBy: NotificationOrdering) {
notifications(read: $read, orderBy: $orderBy) {
from {
__typename
... on Post {
content
}
... on Comment {
content
}
}
read
createdAt
}
await Promise.all([
factory.create('User', neighborParams),
factory.create('Notification', {
id: 'post-mention-not-for-you',
reason: 'mentioned_in_post',
}),
factory.create('Notification', {
id: 'post-mention-already-seen',
read: true,
reason: 'mentioned_in_post',
}),
factory.create('Notification', {
id: 'post-mention-unseen',
reason: 'mentioned_in_post',
}),
factory.create('Notification', {
id: 'comment-mention-not-for-you',
reason: 'mentioned_in_comment',
}),
factory.create('Notification', {
id: 'comment-mention-already-seen',
read: true,
reason: 'mentioned_in_comment',
}),
factory.create('Notification', {
id: 'comment-mention-unseen',
reason: 'mentioned_in_comment',
}),
])
await factory.authenticateAs(neighborParams)
await factory.create('Post', { id: 'p1', categoryIds })
await Promise.all([
factory.relate('Notification', 'User', {
from: 'post-mention-not-for-you',
to: 'neighbor',
}),
factory.relate('Notification', 'Post', {
from: 'p1',
to: 'post-mention-not-for-you',
}),
factory.relate('Notification', 'User', {
from: 'post-mention-unseen',
to: 'you',
}),
factory.relate('Notification', 'Post', {
from: 'p1',
to: 'post-mention-unseen',
}),
factory.relate('Notification', 'User', {
from: 'post-mention-already-seen',
to: 'you',
}),
factory.relate('Notification', 'Post', {
from: 'p1',
to: 'post-mention-already-seen',
}),
])
// Comment and its notifications
await Promise.all([
factory.create('Comment', {
id: 'c1',
postId: 'p1',
}),
])
await Promise.all([
factory.relate('Notification', 'User', {
from: 'comment-mention-not-for-you',
to: 'neighbor',
}),
factory.relate('Notification', 'Comment', {
from: 'c1',
to: 'comment-mention-not-for-you',
}),
factory.relate('Notification', 'User', {
from: 'comment-mention-unseen',
to: 'you',
}),
factory.relate('Notification', 'Comment', {
from: 'c1',
to: 'comment-mention-unseen',
}),
factory.relate('Notification', 'User', {
from: 'comment-mention-already-seen',
to: 'you',
}),
factory.relate('Notification', 'Comment', {
from: 'c1',
to: 'comment-mention-already-seen',
}),
])
})
describe('filter for read: false', () => {
const queryCurrentUserNotificationsFilterRead = gql`
query($read: Boolean) {
currentUser {
notifications(read: $read, orderBy: createdAt_desc) {
id
post {
id
}
comment {
id
}
}
}
}
`
const variables = { read: false }
it('returns only unread notifications of current user', async () => {
const expected = {
currentUser: {
notifications: expect.arrayContaining([
{
id: 'post-mention-unseen',
post: {
id: 'p1',
},
comment: null,
},
{
id: 'comment-mention-unseen',
post: null,
comment: {
id: 'c1',
},
},
]),
},
}
await expect(
client.request(queryCurrentUserNotificationsFilterRead, variables),
).resolves.toEqual(expected)
})
})
describe('no filters', () => {
const queryCurrentUserNotifications = gql`
query {
currentUser {
notifications(orderBy: createdAt_desc) {
id
post {
id
}
comment {
id
}
}
}
}
`
it('returns all notifications of current user', async () => {
const expected = {
currentUser: {
notifications: expect.arrayContaining([
{
id: 'post-mention-unseen',
post: {
id: 'p1',
},
comment: null,
},
{
id: 'post-mention-already-seen',
post: {
id: 'p1',
},
comment: null,
},
{
id: 'comment-mention-unseen',
comment: {
id: 'c1',
},
post: null,
},
{
id: 'comment-mention-already-seen',
comment: {
id: 'c1',
},
post: null,
},
]),
},
}
await expect(client.request(queryCurrentUserNotifications, variables)).resolves.toEqual(
expected,
)
})
})
})
})
})
describe('UpdateNotification', () => {
const mutationUpdateNotification = gql`
mutation($id: ID!, $read: Boolean) {
UpdateNotification(id: $id, read: $read) {
id
read
}
}
`
const variablesPostUpdateNotification = {
id: 'post-mention-to-be-updated',
read: true,
}
const variablesCommentUpdateNotification = {
id: 'comment-mention-to-be-updated',
read: true,
}
describe('given some notifications', () => {
let headers
beforeEach(async () => {
const mentionedParams = {
id: 'mentioned-1',
email: 'mentioned@example.org',
password: '1234',
slug: 'mentioned',
}
await Promise.all([
factory.create('User', mentionedParams),
factory.create('Notification', {
id: 'post-mention-to-be-updated',
reason: 'mentioned_in_post',
}),
factory.create('Notification', {
id: 'comment-mention-to-be-updated',
reason: 'mentioned_in_comment',
}),
])
await factory.authenticateAs(userParams)
await factory.create('Post', { id: 'p1', categoryIds })
await Promise.all([
factory.relate('Notification', 'User', {
from: 'post-mention-to-be-updated',
to: 'mentioned-1',
}),
factory.relate('Notification', 'Post', {
from: 'p1',
to: 'post-mention-to-be-updated',
}),
])
// Comment and its notifications
await Promise.all([
factory.create('Comment', {
id: 'c1',
postId: 'p1',
}),
])
await Promise.all([
factory.relate('Notification', 'User', {
from: 'comment-mention-to-be-updated',
to: 'mentioned-1',
}),
factory.relate('Notification', 'Comment', {
from: 'p1',
to: 'comment-mention-to-be-updated',
}),
])
})
`
describe('unauthenticated', () => {
it('throws authorization error', async () => {
client = new GraphQLClient(host)
await expect(
client.request(mutationUpdateNotification, variablesPostUpdateNotification),
).rejects.toThrow('Not Authorised')
const result = await query({ query: notificationQuery })
expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!')
})
})
describe('authenticated', () => {
beforeEach(async () => {
headers = await login({
email: 'test@example.org',
password: '1234',
})
client = new GraphQLClient(host, {
headers,
authenticatedUser = await user.toJson()
})
describe('no filters', () => {
it('returns all notifications of current user', async () => {
const expected = expect.objectContaining({
data: {
notifications: [
{
from: {
__typename: 'Comment',
content: 'You have seen this comment mentioning already',
},
read: true,
createdAt: '2019-08-30T15:33:48.651Z',
},
{
from: {
__typename: 'Post',
content: 'Already seen post mentioning',
},
read: true,
createdAt: '2019-08-30T17:33:48.651Z',
},
{
from: {
__typename: 'Comment',
content: 'You have been mentioned in a comment',
},
read: false,
createdAt: '2019-08-30T19:33:48.651Z',
},
{
from: {
__typename: 'Post',
content: 'You have been mentioned in a post',
},
read: false,
createdAt: '2019-08-31T17:33:48.651Z',
},
],
},
})
await expect(query({ query: notificationQuery, variables })).resolves.toEqual(expected)
})
})
it('throws authorization error', async () => {
await expect(
client.request(mutationUpdateNotification, variablesPostUpdateNotification),
).rejects.toThrow('Not Authorised')
})
describe('and owner', () => {
beforeEach(async () => {
headers = await login({
email: 'mentioned@example.org',
password: '1234',
})
client = new GraphQLClient(host, {
headers,
})
})
it('updates post notification', async () => {
const expected = {
UpdateNotification: {
id: 'post-mention-to-be-updated',
read: true,
describe('filter for read: false', () => {
it('returns only unread notifications of current user', async () => {
const expected = expect.objectContaining({
data: {
notifications: [
{
from: {
__typename: 'Comment',
content: 'You have been mentioned in a comment',
},
read: false,
createdAt: '2019-08-30T19:33:48.651Z',
},
{
from: {
__typename: 'Post',
content: 'You have been mentioned in a post',
},
read: false,
createdAt: '2019-08-31T17:33:48.651Z',
},
],
},
}
})
await expect(
client.request(mutationUpdateNotification, variablesPostUpdateNotification),
).resolves.toEqual(expected)
})
it('updates comment notification', async () => {
const expected = {
UpdateNotification: {
id: 'comment-mention-to-be-updated',
read: true,
},
}
await expect(
client.request(mutationUpdateNotification, variablesCommentUpdateNotification),
query({ query: notificationQuery, variables: { ...variables, read: false } }),
).resolves.toEqual(expected)
})
})
})
})
describe('markAsRead', () => {
const markAsReadMutation = gql`
mutation($id: ID!) {
markAsRead(id: $id) {
from {
__typename
... on Post {
content
}
... on Comment {
content
}
}
read
createdAt
}
}
`
describe('unauthenticated', () => {
it('throws authorization error', async () => {
const result = await mutate({
mutation: markAsReadMutation,
variables: { ...variables, id: 'p1' },
})
expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!')
})
})
describe('authenticated', () => {
beforeEach(async () => {
authenticatedUser = await user.toJson()
})
describe('not being notified at all', () => {
beforeEach(async () => {
variables = {
...variables,
id: 'p1',
}
})
it('returns null', async () => {
const response = await mutate({ mutation: markAsReadMutation, variables })
expect(response.data.markAsRead).toEqual(null)
expect(response.errors).toBeUndefined()
})
})
describe('being notified', () => {
describe('on a post', () => {
beforeEach(async () => {
variables = {
...variables,
id: 'p3',
}
})
it('updates `read` attribute and returns NOTIFIED relationship', async () => {
const { data } = await mutate({ mutation: markAsReadMutation, variables })
expect(data).toEqual({
markAsRead: {
from: {
__typename: 'Post',
content: 'You have been mentioned in a post',
},
read: true,
createdAt: '2019-08-31T17:33:48.651Z',
},
})
})
describe('but notification was already marked as read', () => {
beforeEach(async () => {
variables = {
...variables,
id: 'p2',
}
})
it('returns null', async () => {
const response = await mutate({ mutation: markAsReadMutation, variables })
expect(response.data.markAsRead).toEqual(null)
expect(response.errors).toBeUndefined()
})
})
})
describe('on a comment', () => {
beforeEach(async () => {
variables = {
...variables,
id: 'c2',
}
})
it('updates `read` attribute and returns NOTIFIED relationship', async () => {
const { data } = await mutate({ mutation: markAsReadMutation, variables })
expect(data).toEqual({
markAsRead: {
from: {
__typename: 'Comment',
content: 'You have been mentioned in a comment',
},
read: true,
createdAt: '2019-08-30T19:33:48.651Z',
},
})
})
})
})
})
})
})

View File

@ -3,6 +3,7 @@ import { neo4jgraphql } from 'neo4j-graphql-js'
import fileUpload from './fileUpload'
import { getBlockedUsers, getBlockedByUsers } from './users.js'
import { mergeWith, isArray } from 'lodash'
import Resolver from './helpers/Resolver'
const filterForBlockedUsers = async (params, context) => {
if (!context.user) return params
@ -181,4 +182,46 @@ export default {
return emoted
},
},
Post: {
...Resolver('Post', {
hasMany: {
tags: '-[:TAGGED]->(related:Tag)',
categories: '-[:CATEGORIZED]->(related:Category)',
comments: '<-[:COMMENTS]-(related:Comment)',
shoutedBy: '<-[:SHOUTED]-(related:User)',
emotions: '<-[related:EMOTED]',
},
hasOne: {
author: '<-[:WROTE]-(related:User)',
disabledBy: '<-[:DISABLED]-(related:User)',
},
count: {
shoutedCount:
'<-[:SHOUTED]-(related:User) WHERE NOT related.deleted = true AND NOT related.disabled = true',
emotionsCount: '<-[related:EMOTED]-(:User)',
},
boolean: {
shoutedByCurrentUser:
'<-[:SHOUTED]-(u:User {id: $cypherParams.currentUserId}) RETURN COUNT(u) >= 1',
},
}),
relatedContributions: async (parent, params, context, resolveInfo) => {
if (typeof parent.relatedContributions !== 'undefined') return parent.relatedContributions
const { id } = parent
const statement = `
MATCH (p:Post {id: $id})-[:TAGGED|CATEGORIZED]->(categoryOrTag)<-[:TAGGED|CATEGORIZED]-(post:Post)
RETURN DISTINCT post
LIMIT 10
`
let relatedContributions
const session = context.driver.session()
try {
const result = await session.run(statement, { id })
relatedContributions = result.records.map(r => r.get('post').properties)
} finally {
session.close()
}
return relatedContributions
},
},
}

View File

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

View File

@ -0,0 +1,28 @@
type NOTIFIED {
from: NotificationSource
to: User
createdAt: String
read: Boolean
reason: NotificationReason
}
union NotificationSource = Post | Comment
enum NotificationOrdering {
createdAt_asc
createdAt_desc
}
enum NotificationReason {
mentioned_in_post
mentioned_in_comment
commented_on_post
}
type Query {
notifications(read: Boolean, orderBy: NotificationOrdering): [NOTIFIED]
}
type Mutation {
markAsRead(id: ID!): NOTIFIED
}

View File

@ -1,9 +0,0 @@
type Notification {
id: ID!
read: Boolean
reason: ReasonNotification
createdAt: String
user: User @relation(name: "NOTIFIED", direction: "OUT")
post: Post @relation(name: "NOTIFIED", direction: "IN")
comment: Comment @relation(name: "NOTIFIED", direction: "IN")
}

View File

@ -24,8 +24,6 @@ type User {
createdAt: String
updatedAt: String
notifications(read: Boolean): [Notification]! @relation(name: "NOTIFIED", direction: "IN")
friends: [User]! @relation(name: "FRIENDS", direction: "BOTH")
friendsCount: Int! @cypher(statement: "MATCH (this)<-[:FRIENDS]->(r:User) RETURN COUNT(DISTINCT r)")

View File

@ -8,7 +8,6 @@ import createComment from './comments.js'
import createCategory from './categories.js'
import createTag from './tags.js'
import createReport from './reports.js'
import createNotification from './notifications.js'
export const seedServerHost = 'http://127.0.0.1:4001'
@ -31,7 +30,6 @@ const factories = {
Category: createCategory,
Tag: createTag,
Report: createReport,
Notification: createNotification,
}
export const cleanDatabase = async (options = {}) => {

View File

@ -1,17 +0,0 @@
import uuid from 'uuid/v4'
export default function(params) {
const { id = uuid(), read = false } = params
return {
mutation: `
mutation($id: ID, $read: Boolean) {
CreateNotification(id: $id, read: $read) {
id
read
}
}
`,
variables: { id, read },
}
}

View File

@ -270,7 +270,7 @@ import Factory from './factories'
const hashtag1 =
'See <a class="hashtag" href="/search/hashtag/NaturphilosophieYoga">#NaturphilosophieYoga</a> can really help you!'
const hashtagAndMention1 =
'The new physics of <a class="hashtag" href="/search/hashtag/QuantenFlussTheorie">#QuantenFlussTheorie</a> can explain <a class="hashtag" href="/search/hashtag/QuantumGravity">#QuantumGravity</a>! <a class="mention" data-mention-id="u3" href="/profile/u1">@peter-lustig</a> got that already. ;-)'
'The new physics of <a class="hashtag" href="/search/hashtag/QuantenFlussTheorie">#QuantenFlussTheorie</a> can explain <a class="hashtag" href="/search/hashtag/QuantumGravity">#QuantumGravity</a>! <a class="mention" data-mention-id="u1" href="/profile/u1">@peter-lustig</a> got that already. ;-)'
await Promise.all([
asAdmin.create('Post', {

View File

@ -357,7 +357,7 @@ When("mention {string} in the text", mention => {
});
Then("the notification gets marked as read", () => {
cy.get(".post.createdAt")
cy.get(".notifications-menu-popover .notification")
.first()
.should("have.class", "read");
});

View File

@ -37,9 +37,9 @@ describe('Notification', () => {
describe('given a notification about a comment on a post', () => {
beforeEach(() => {
propsData.notification = {
reason: 'comment_on_post',
post: null,
comment: {
reason: 'commented_on_post',
from: {
__typename: 'Comment',
id: 'comment-1',
contentExcerpt:
'<a href="/profile/u123" target="_blank">@dagobert-duck</a> is the best on this comment.',
@ -56,7 +56,7 @@ describe('Notification', () => {
it('renders reason', () => {
wrapper = Wrapper()
expect(wrapper.find('.reason-text-for-test').text()).toEqual(
'notifications.menu.comment_on_post',
'notifications.menu.commented_on_post',
)
})
it('renders title', () => {
@ -92,14 +92,14 @@ describe('Notification', () => {
beforeEach(() => {
propsData.notification = {
reason: 'mentioned_in_post',
post: {
from: {
__typename: 'Post',
title: "It's a post title",
id: 'post-1',
slug: 'its-a-title',
contentExcerpt:
'<a href="/profile/u3" target="_blank">@jenny-rostock</a> is the best on this post.',
},
comment: null,
}
})
@ -138,8 +138,8 @@ describe('Notification', () => {
beforeEach(() => {
propsData.notification = {
reason: 'mentioned_in_comment',
post: null,
comment: {
from: {
__typename: 'Comment',
id: 'comment-1',
contentExcerpt:
'<a href="/profile/u123" target="_blank">@dagobert-duck</a> is the best on this comment.',

View File

@ -1,14 +1,8 @@
<template>
<ds-space :class="[{ read: notification.read }, notification]" margin-bottom="x-small">
<ds-space :class="{ read: notification.read, notification: true }" margin-bottom="x-small">
<client-only>
<ds-space margin-bottom="x-small">
<hc-user
v-if="resourceType == 'Post'"
:user="post.author"
:date-time="post.createdAt"
:trunc="35"
/>
<hc-user v-else :user="comment.author" :date-time="comment.createdAt" :trunc="35" />
<hc-user :user="from.author" :date-time="from.createdAt" :trunc="35" />
</ds-space>
<ds-text class="reason-text-for-test" color="soft">
{{ $t(`notifications.menu.${notification.reason}`) }}
@ -22,16 +16,15 @@
>
<ds-space margin-bottom="x-small">
<ds-card
:header="post.title || comment.post.title"
:header="from.title || from.post.title"
hover
space="x-small"
class="notifications-card"
>
<ds-space margin-bottom="x-small" />
<div v-if="resourceType == 'Post'">{{ post.contentExcerpt | removeHtml }}</div>
<div v-else>
<span class="comment-notification-header">Comment:</span>
{{ comment.contentExcerpt | removeHtml }}
<div>
<span v-if="isComment" class="comment-notification-header">Comment:</span>
{{ from.contentExcerpt | removeHtml }}
</div>
</ds-card>
</ds-space>
@ -54,23 +47,21 @@ export default {
},
},
computed: {
resourceType() {
return this.post.id ? 'Post' : 'Comment'
from() {
return this.notification.from
},
post() {
return this.notification.post || {}
},
comment() {
return this.notification.comment || {}
isComment() {
return this.from.__typename === 'Comment'
},
params() {
const post = this.isComment ? this.from.post : this.from
return {
id: this.post.id || this.comment.post.id,
slug: this.post.slug || this.comment.post.slug,
id: post.id,
slug: post.slug,
}
},
hashParam() {
return this.post.id ? {} : { hash: `#commentId-${this.comment.id}` }
return this.isComment ? { hash: `#commentId-${this.from.id}` } : {}
},
},
}

View File

@ -40,9 +40,9 @@ describe('NotificationList.vue', () => {
propsData = {
notifications: [
{
id: 'notification-41',
read: false,
post: {
from: {
__typename: 'Post',
id: 'post-1',
title: 'some post title',
slug: 'some-post-title',
@ -55,9 +55,9 @@ describe('NotificationList.vue', () => {
},
},
{
id: 'notification-42',
read: false,
post: {
from: {
__typename: 'Post',
id: 'post-2',
title: 'another post title',
slug: 'another-post-title',
@ -115,9 +115,9 @@ describe('NotificationList.vue', () => {
.trigger('click')
})
it("emits 'markAsRead' with the notificationId", () => {
it("emits 'markAsRead' with the id of the notification source", () => {
expect(wrapper.emitted('markAsRead')).toBeTruthy()
expect(wrapper.emitted('markAsRead')[0]).toEqual(['notification-42'])
expect(wrapper.emitted('markAsRead')[0]).toEqual(['post-2'])
})
})
})

View File

@ -4,7 +4,7 @@
v-for="notification in notifications"
:key="notification.id"
:notification="notification"
@read="markAsRead(notification.id)"
@read="markAsRead(notification.from.id)"
/>
</div>
</template>
@ -24,8 +24,8 @@ export default {
},
},
methods: {
markAsRead(notificationId) {
this.$emit('markAsRead', notificationId)
markAsRead(notificationSourceId) {
this.$emit('markAsRead', notificationSourceId)
},
},
}

View File

@ -18,7 +18,7 @@
<script>
import Dropdown from '~/components/Dropdown'
import { currentUserNotificationsQuery, updateNotificationMutation } from '~/graphql/User'
import { notificationQuery, markAsReadMutation } from '~/graphql/User'
import NotificationList from '../NotificationList/NotificationList'
export default {
@ -27,36 +27,41 @@ export default {
NotificationList,
Dropdown,
},
data() {
return {
notifications: [],
}
},
props: {
placement: { type: String },
},
computed: {
totalNotifications() {
return (this.notifications || []).length
},
},
methods: {
async markAsRead(notificationId) {
const variables = { id: notificationId, read: true }
async markAsRead(notificationSourceId) {
const variables = { id: notificationSourceId }
try {
await this.$apollo.mutate({
mutation: updateNotificationMutation(),
const {
data: { markAsRead },
} = await this.$apollo.mutate({
mutation: markAsReadMutation(),
variables,
})
if (!(markAsRead && markAsRead.read === true)) return
this.notifications = this.notifications.map(n => {
return n.from.id === markAsRead.from.id ? markAsRead : n
})
} catch (err) {
throw new Error(err)
}
},
},
computed: {
totalNotifications() {
return this.notifications.length
},
},
apollo: {
notifications: {
query: currentUserNotificationsQuery(),
update: data => {
const {
currentUser: { notifications },
} = data
return notifications
},
query: notificationQuery(),
},
},
}

View File

@ -1,5 +1,58 @@
import gql from 'graphql-tag'
const fragments = gql`
fragment post on Post {
id
createdAt
disabled
deleted
title
contentExcerpt
slug
author {
id
slug
name
disabled
deleted
avatar
}
}
fragment comment on Comment {
id
createdAt
disabled
deleted
contentExcerpt
author {
id
slug
name
disabled
deleted
avatar
}
post {
id
createdAt
disabled
deleted
title
contentExcerpt
slug
author {
id
slug
name
disabled
deleted
avatar
}
}
}
`
export default i18n => {
const lang = i18n.locale().toUpperCase()
return gql`
@ -76,77 +129,37 @@ export default i18n => {
`
}
export const currentUserNotificationsQuery = () => {
export const notificationQuery = () => {
return gql`
${fragments}
query {
currentUser {
id
notifications(read: false, orderBy: createdAt_desc) {
id
read
reason
createdAt
post {
id
createdAt
disabled
deleted
title
contentExcerpt
slug
author {
id
slug
name
disabled
deleted
avatar
}
}
comment {
id
createdAt
disabled
deleted
contentExcerpt
author {
id
slug
name
disabled
deleted
avatar
}
post {
id
createdAt
disabled
deleted
title
contentExcerpt
slug
author {
id
slug
name
disabled
deleted
avatar
}
}
}
notifications(read: false, orderBy: createdAt_desc) {
read
reason
createdAt
from {
__typename
...post
...comment
}
}
}
`
}
export const updateNotificationMutation = () => {
export const markAsReadMutation = () => {
return gql`
mutation($id: ID!, $read: Boolean!) {
UpdateNotification(id: $id, read: $read) {
id
${fragments}
mutation($id: ID!) {
markAsRead(id: $id) {
read
reason
createdAt
from {
__typename
...post
...comment
}
}
}
`

View File

@ -134,7 +134,7 @@
"menu": {
"mentioned_in_post": "Hat dich in einem Beitrag erwähnt …",
"mentioned_in_comment": "Hat dich in einem Kommentar erwähnt …",
"comment_on_post": "Hat deinen Beitrag kommentiert …"
"commented_on_post": "Hat deinen Beitrag kommentiert …"
}
},
"search": {

View File

@ -134,7 +134,7 @@
"menu": {
"mentioned_in_post": "Mentioned you in a post …",
"mentioned_in_comment": "Mentioned you in a comment …",
"comment_on_post": "Commented on your post …"
"commented_on_post": "Commented on your post …"
}
},
"search": {

View File

@ -1,3 +1,10 @@
import { InMemoryCache, IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'
import introspectionQueryResultData from './apollo-config/fragmentTypes.json'
const fragmentMatcher = new IntrospectionFragmentMatcher({
introspectionQueryResultData,
})
export default ({ app }) => {
const backendUrl = process.env.GRAPHQL_URI || 'http://localhost:4000'
@ -10,5 +17,6 @@ export default ({ app }) => {
tokenName: 'human-connection-token',
persisting: false,
websocketsOnly: false,
cache: new InMemoryCache({ fragmentMatcher }),
}
}

View File

@ -0,0 +1,18 @@
{
"__schema": {
"types": [
{
"kind": "UNION",
"name": "NotificationSource",
"possibleTypes": [
{
"name": "Post"
},
{
"name": "Comment"
}
]
}
]
}
}

View File

@ -86,23 +86,6 @@ export const actions = {
id
url
}
notifications(read: false, orderBy: createdAt_desc) {
id
read
createdAt
post {
author {
id
slug
name
disabled
deleted
}
title
contentExcerpt
slug
}
}
}
}
`,

View File

@ -1,99 +0,0 @@
import gql from 'graphql-tag'
export const state = () => {
return {
notifications: null,
pending: false,
}
}
export const mutations = {
SET_NOTIFICATIONS(state, notifications) {
state.notifications = notifications
},
SET_PENDING(state, pending) {
state.pending = pending
},
UPDATE_NOTIFICATIONS(state, notification) {
const notifications = state.notifications
const toBeUpdated = notifications.find(n => {
return n.id === notification.id
})
state.notifications = {
...toBeUpdated,
...notification,
}
},
}
export const getters = {
notifications(state) {
return !!state.notifications
},
}
export const actions = {
async init({ getters, commit }) {
if (getters.notifications) return
commit('SET_PENDING', true)
const client = this.app.apolloProvider.defaultClient
let notifications
try {
const {
data: { currentUser },
} = await client.query({
query: gql`
{
currentUser {
id
notifications(orderBy: createdAt_desc) {
id
read
createdAt
post {
author {
id
slug
name
disabled
deleted
}
title
contentExcerpt
slug
}
}
}
}
`,
})
notifications = currentUser.notifications
commit('SET_NOTIFICATIONS', notifications)
} finally {
commit('SET_PENDING', false)
}
return notifications
},
async markAsRead({ commit, rootGetters }, notificationId) {
const client = this.app.apolloProvider.defaultClient
const mutation = gql`
mutation($id: ID!, $read: Boolean!) {
UpdateNotification(id: $id, read: $read) {
id
read
}
}
`
const variables = {
id: notificationId,
read: true,
}
const {
data: { UpdateNotification },
} = await client.mutate({
mutation,
variables,
})
commit('UPDATE_NOTIFICATIONS', UpdateNotification)
},
}