diff --git a/backend/src/middleware/dateTimeMiddleware.js b/backend/src/middleware/dateTimeMiddleware.js index c8af53a7a..ff1fcc996 100644 --- a/backend/src/middleware/dateTimeMiddleware.js +++ b/backend/src/middleware/dateTimeMiddleware.js @@ -12,11 +12,9 @@ export default { CreatePost: setCreatedAt, CreateComment: setCreatedAt, CreateOrganization: setCreatedAt, - CreateNotification: setCreatedAt, UpdateUser: setUpdatedAt, UpdatePost: setUpdatedAt, UpdateComment: setUpdatedAt, UpdateOrganization: setUpdatedAt, - UpdateNotification: setUpdatedAt, }, } diff --git a/backend/src/middleware/notifications/notificationsMiddleware.js b/backend/src/middleware/notifications/notificationsMiddleware.js index c9dfe406c..64386800d 100644 --- a/backend/src/middleware/notifications/notificationsMiddleware.js +++ b/backend/src/middleware/notifications/notificationsMiddleware.js @@ -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) } } diff --git a/backend/src/middleware/notifications/notificationsMiddleware.spec.js b/backend/src/middleware/notifications/notificationsMiddleware.spec.js index 624cedddc..b737768f2 100644 --- a/backend/src/middleware/notifications/notificationsMiddleware.spec.js +++ b/backend/src/middleware/notifications/notificationsMiddleware.spec.js @@ -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 @al-capone 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 = + '
One more mention to

@al-capone

and again:

@al-capone

and again

@al-capone

' await createPostAction() await updatePostAction() - const expectedContent = - '
One more mention to

@al-capone

and again:

@al-capone

and again

@al-capone

' 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( diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index 83c29d19d..2a52e54af 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -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, diff --git a/backend/src/models/Notification.js b/backend/src/models/Notification.js deleted file mode 100644 index b54a99574..000000000 --- a/backend/src/models/Notification.js +++ /dev/null @@ -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', - }, -} diff --git a/backend/src/models/index.js b/backend/src/models/index.js index 295082de4..5a4510ac6 100644 --- a/backend/src/models/index.js +++ b/backend/src/models/index.js @@ -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'), } diff --git a/backend/src/schema/index.js b/backend/src/schema/index.js index b8f120057..e8fa63d97 100644 --- a/backend/src/schema/index.js +++ b/backend/src/schema/index.js @@ -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 }, diff --git a/backend/src/schema/resolvers/comments.js b/backend/src/schema/resolvers/comments.js index 89a2040f4..c8fb8db3f 100644 --- a/backend/src/schema/resolvers/comments.js +++ b/backend/src/schema/resolvers/comments.js @@ -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)', + }, + }), + }, } diff --git a/backend/src/schema/resolvers/notifications.js b/backend/src/schema/resolvers/notifications.js index ddc1985cf..65c92b4be 100644 --- a/backend/src/schema/resolvers/notifications.js +++ b/backend/src/schema/resolvers/notifications.js @@ -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 }, }, } diff --git a/backend/src/schema/resolvers/notifications.spec.js b/backend/src/schema/resolvers/notifications.spec.js index 3ca7727e4..b321d449f 100644 --- a/backend/src/schema/resolvers/notifications.spec.js +++ b/backend/src/schema/resolvers/notifications.spec.js @@ -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', + }, + }) + }) + }) + }) + }) + }) }) diff --git a/backend/src/schema/resolvers/posts.js b/backend/src/schema/resolvers/posts.js index a265c28f0..f9e4f0718 100644 --- a/backend/src/schema/resolvers/posts.js +++ b/backend/src/schema/resolvers/posts.js @@ -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 + }, + }, } diff --git a/backend/src/schema/types/enum/ReasonNotification.gql b/backend/src/schema/types/enum/ReasonNotification.gql index a66c446be..e870e01dc 100644 --- a/backend/src/schema/types/enum/ReasonNotification.gql +++ b/backend/src/schema/types/enum/ReasonNotification.gql @@ -1,5 +1,5 @@ enum ReasonNotification { mentioned_in_post mentioned_in_comment - comment_on_post -} \ No newline at end of file + commented_on_post +} diff --git a/backend/src/schema/types/type/NOTIFIED.gql b/backend/src/schema/types/type/NOTIFIED.gql new file mode 100644 index 000000000..b90e30598 --- /dev/null +++ b/backend/src/schema/types/type/NOTIFIED.gql @@ -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 +} diff --git a/backend/src/schema/types/type/Notification.gql b/backend/src/schema/types/type/Notification.gql deleted file mode 100644 index a3543445f..000000000 --- a/backend/src/schema/types/type/Notification.gql +++ /dev/null @@ -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") -} diff --git a/backend/src/schema/types/type/User.gql b/backend/src/schema/types/type/User.gql index 49592e8ba..9bbef50e4 100644 --- a/backend/src/schema/types/type/User.gql +++ b/backend/src/schema/types/type/User.gql @@ -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)") diff --git a/backend/src/seed/factories/index.js b/backend/src/seed/factories/index.js index 56518bd06..4cc143e68 100644 --- a/backend/src/seed/factories/index.js +++ b/backend/src/seed/factories/index.js @@ -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 = {}) => { diff --git a/backend/src/seed/factories/notifications.js b/backend/src/seed/factories/notifications.js deleted file mode 100644 index d14d4294a..000000000 --- a/backend/src/seed/factories/notifications.js +++ /dev/null @@ -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 }, - } -} diff --git a/backend/src/seed/seed-db.js b/backend/src/seed/seed-db.js index bbacd2149..5a5c7716b 100644 --- a/backend/src/seed/seed-db.js +++ b/backend/src/seed/seed-db.js @@ -270,7 +270,7 @@ import Factory from './factories' const hashtag1 = 'See #NaturphilosophieYoga can really help you!' const hashtagAndMention1 = - 'The new physics of #QuantenFlussTheorie can explain #QuantumGravity! @peter-lustig got that already. ;-)' + 'The new physics of #QuantenFlussTheorie can explain #QuantumGravity! @peter-lustig got that already. ;-)' await Promise.all([ asAdmin.create('Post', { diff --git a/cypress/integration/common/steps.js b/cypress/integration/common/steps.js index e3db43e14..386035ea2 100644 --- a/cypress/integration/common/steps.js +++ b/cypress/integration/common/steps.js @@ -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"); }); diff --git a/webapp/components/notifications/Notification/Notification.spec.js b/webapp/components/notifications/Notification/Notification.spec.js index 279500f7f..9e5586fc2 100644 --- a/webapp/components/notifications/Notification/Notification.spec.js +++ b/webapp/components/notifications/Notification/Notification.spec.js @@ -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: '@dagobert-duck 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: '@jenny-rostock 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: '@dagobert-duck is the best on this comment.', diff --git a/webapp/components/notifications/Notification/Notification.vue b/webapp/components/notifications/Notification/Notification.vue index 193b5f67b..d55cdf1b2 100644 --- a/webapp/components/notifications/Notification/Notification.vue +++ b/webapp/components/notifications/Notification/Notification.vue @@ -1,14 +1,8 @@ @@ -24,8 +24,8 @@ export default { }, }, methods: { - markAsRead(notificationId) { - this.$emit('markAsRead', notificationId) + markAsRead(notificationSourceId) { + this.$emit('markAsRead', notificationSourceId) }, }, } diff --git a/webapp/components/notifications/NotificationMenu/NotificationMenu.vue b/webapp/components/notifications/NotificationMenu/NotificationMenu.vue index c534f2986..9b0842301 100644 --- a/webapp/components/notifications/NotificationMenu/NotificationMenu.vue +++ b/webapp/components/notifications/NotificationMenu/NotificationMenu.vue @@ -18,7 +18,7 @@