From 4dead6e6f74faef26102a5f8d52632c950ba28dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Fri, 5 Apr 2019 14:35:41 +0200 Subject: [PATCH 1/7] Sketch test to create a notificaion for a mention --- .../src/middleware/notificationMiddleware.js | 0 .../middleware/notificationMiddleware.spec.js | 85 +++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 backend/src/middleware/notificationMiddleware.js create mode 100644 backend/src/middleware/notificationMiddleware.spec.js diff --git a/backend/src/middleware/notificationMiddleware.js b/backend/src/middleware/notificationMiddleware.js new file mode 100644 index 000000000..e69de29bb diff --git a/backend/src/middleware/notificationMiddleware.spec.js b/backend/src/middleware/notificationMiddleware.spec.js new file mode 100644 index 000000000..ccb38fcbf --- /dev/null +++ b/backend/src/middleware/notificationMiddleware.spec.js @@ -0,0 +1,85 @@ +import Factory from '../seed/factories' +import { GraphQLClient } from 'graphql-request' +import { host, login } from '../jest/helpers' + +const factory = Factory() +let client + +beforeEach(async () => { + await factory.create('User', { + id: 'you', + name: 'Al Capone', + slug: 'al-capone', + email: 'test@example.org', + password: '1234' + }) +}) + +afterEach(async () => { + await factory.cleanDatabase() +}) + +describe('currentUser { notifications }', () => { + const query = `query($read: Boolean) { + currentUser { + notifications(read: $read, orderBy: createdAt_desc) { + id + post { + id + } + } + } + }` + + describe('authenticated', () => { + let headers + beforeEach(async () => { + headers = await login({ email: 'test@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + }) + + describe('given another user', () => { + let authorClient + let authorParams + let authorHeaders + + beforeEach(async () => { + authorParams = { + email: 'author@example.org', + password: '1234', + id: 'author' + } + await factory.create('User', authorParams) + authorHeaders = await login(authorParams) + }) + + describe('who mentions me in a post', () => { + beforeEach(async () => { + const content = 'Hey @al-capone how do you do?' + const title = 'Mentioning Al Capone' + const createPostMutation = ` + mutation($title: String!, $content: String!) { + CreatePost(title: $title, content: $content) { + title + content + } + } + ` + authorClient = new GraphQLClient(host, authorHeaders) + await authorClient.request(createPostMutation, { title, content }) + }) + + it('sends you a notification', async () => { + const expected = { + currentUser: { + notifications: [ + { read: false, post: { content: 'Hey @al-capone how do you do?' } } + ] + } + } + await expect(client.request(query, { read: false })).resolves.toEqual(expected) + }) + }) + }) + }) +}) From bab748e5062044b30de3a90e259068acb98ad2d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Sat, 6 Apr 2019 00:33:10 +0200 Subject: [PATCH 2/7] Create notifications on CreatePost --- backend/src/middleware/index.js | 2 ++ .../src/middleware/notificationMiddleware.js | 0 .../src/middleware/notificationsMiddleware.js | 31 +++++++++++++++++++ ...pec.js => notificationsMiddleware.spec.js} | 4 ++- 4 files changed, 36 insertions(+), 1 deletion(-) delete mode 100644 backend/src/middleware/notificationMiddleware.js create mode 100644 backend/src/middleware/notificationsMiddleware.js rename backend/src/middleware/{notificationMiddleware.spec.js => notificationsMiddleware.spec.js} (94%) diff --git a/backend/src/middleware/index.js b/backend/src/middleware/index.js index 8f86a88e6..8d893a78b 100644 --- a/backend/src/middleware/index.js +++ b/backend/src/middleware/index.js @@ -10,6 +10,7 @@ import permissionsMiddleware from './permissionsMiddleware' import userMiddleware from './userMiddleware' import includedFieldsMiddleware from './includedFieldsMiddleware' import orderByMiddleware from './orderByMiddleware' +import notificationsMiddleware from './notificationsMiddleware' export default schema => { let middleware = [ @@ -19,6 +20,7 @@ export default schema => { excerptMiddleware, xssMiddleware, fixImageUrlsMiddleware, + notificationsMiddleware, softDeleteMiddleware, userMiddleware, includedFieldsMiddleware, diff --git a/backend/src/middleware/notificationMiddleware.js b/backend/src/middleware/notificationMiddleware.js deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/src/middleware/notificationsMiddleware.js b/backend/src/middleware/notificationsMiddleware.js new file mode 100644 index 000000000..1150ab0d9 --- /dev/null +++ b/backend/src/middleware/notificationsMiddleware.js @@ -0,0 +1,31 @@ +const MENTION_REGEX = /@(\S+)/g + +const notify = async (resolve, root, args, context, resolveInfo) => { + const post = await resolve(root, args, context, resolveInfo) + + const session = context.driver.session() + const { content, id: postId } = post + const slugs = [] + const createdAt = (new Date()).toISOString() + let match + while ((match = MENTION_REGEX.exec(content)) != null) { + slugs.push(match[1]) + } + const cypher = ` + match(u:User) where u.slug in $slugs + match(p:Post) where p.id = $postId + create(n:Notification{id: apoc.create.uuid(), read: false, createdAt: $createdAt}) + merge (n)-[:NOTIFIED]->(u) + merge (p)-[:NOTIFIED]->(n) + ` + await session.run(cypher, { slugs, createdAt, postId }) + session.close() + + return post +} + +export default { + Mutation: { + CreatePost: notify + } +} diff --git a/backend/src/middleware/notificationMiddleware.spec.js b/backend/src/middleware/notificationsMiddleware.spec.js similarity index 94% rename from backend/src/middleware/notificationMiddleware.spec.js rename to backend/src/middleware/notificationsMiddleware.spec.js index ccb38fcbf..9fed4a59a 100644 --- a/backend/src/middleware/notificationMiddleware.spec.js +++ b/backend/src/middleware/notificationsMiddleware.spec.js @@ -24,8 +24,10 @@ describe('currentUser { notifications }', () => { currentUser { notifications(read: $read, orderBy: createdAt_desc) { id + read post { id + content } } } @@ -65,7 +67,7 @@ describe('currentUser { notifications }', () => { } } ` - authorClient = new GraphQLClient(host, authorHeaders) + authorClient = new GraphQLClient(host, { headers: authorHeaders }) await authorClient.request(createPostMutation, { title, content }) }) From 771779348a8c83a5f3d7a338b23e8976b072c8a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Mon, 8 Apr 2019 10:19:57 +0200 Subject: [PATCH 3/7] Fix test --- backend/src/middleware/notificationsMiddleware.spec.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/backend/src/middleware/notificationsMiddleware.spec.js b/backend/src/middleware/notificationsMiddleware.spec.js index 9fed4a59a..e6fc78c52 100644 --- a/backend/src/middleware/notificationsMiddleware.spec.js +++ b/backend/src/middleware/notificationsMiddleware.spec.js @@ -23,10 +23,8 @@ describe('currentUser { notifications }', () => { const query = `query($read: Boolean) { currentUser { notifications(read: $read, orderBy: createdAt_desc) { - id read post { - id content } } From 58019c8975d9fdd27418953b8d9abbef05ce4f23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Mon, 8 Apr 2019 12:01:09 +0200 Subject: [PATCH 4/7] Avoid to send out notifications for email adresses --- backend/src/middleware/notifications/mentions.js | 10 ++++++++++ .../src/middleware/notifications/mentions.spec.js | 15 +++++++++++++++ backend/src/middleware/notificationsMiddleware.js | 8 ++------ 3 files changed, 27 insertions(+), 6 deletions(-) create mode 100644 backend/src/middleware/notifications/mentions.js create mode 100644 backend/src/middleware/notifications/mentions.spec.js diff --git a/backend/src/middleware/notifications/mentions.js b/backend/src/middleware/notifications/mentions.js new file mode 100644 index 000000000..fb4a049f2 --- /dev/null +++ b/backend/src/middleware/notifications/mentions.js @@ -0,0 +1,10 @@ +const MENTION_REGEX = /\s@(\S+)/g + +export function extractSlugs(content) { + let slugs = [] + let match + while ((match = MENTION_REGEX.exec(content)) != null) { + slugs.push(match[1]) + } + return slugs +} diff --git a/backend/src/middleware/notifications/mentions.spec.js b/backend/src/middleware/notifications/mentions.spec.js new file mode 100644 index 000000000..8fe9221b3 --- /dev/null +++ b/backend/src/middleware/notifications/mentions.spec.js @@ -0,0 +1,15 @@ +import { extractSlugs } from './mentions' + +describe('extract', () => { + describe('finds mentions in the form of', () => { + it('@user', () => { + const content = 'Hello @user' + expect(extractSlugs(content)).toEqual(['user']) + }) + }) + + it('ignores email addresses', () => { + const content = 'Hello somebody@example.org' + expect(extractSlugs(content)).toEqual([]) + }) +}) diff --git a/backend/src/middleware/notificationsMiddleware.js b/backend/src/middleware/notificationsMiddleware.js index 1150ab0d9..30205278b 100644 --- a/backend/src/middleware/notificationsMiddleware.js +++ b/backend/src/middleware/notificationsMiddleware.js @@ -1,16 +1,12 @@ -const MENTION_REGEX = /@(\S+)/g +import { extractSlugs } from './notifications/mentions' const notify = async (resolve, root, args, context, resolveInfo) => { const post = await resolve(root, args, context, resolveInfo) const session = context.driver.session() const { content, id: postId } = post - const slugs = [] + const slugs = extractSlugs(content) const createdAt = (new Date()).toISOString() - let match - while ((match = MENTION_REGEX.exec(content)) != null) { - slugs.push(match[1]) - } const cypher = ` match(u:User) where u.slug in $slugs match(p:Post) where p.id = $postId From 0476c151639f44db80f8d5d6055d490cc7d1d0f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Mon, 8 Apr 2019 12:08:49 +0200 Subject: [PATCH 5/7] Remove dots from matched @mention regex --- backend/src/middleware/notifications/mentions.js | 2 +- .../src/middleware/notifications/mentions.spec.js | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/backend/src/middleware/notifications/mentions.js b/backend/src/middleware/notifications/mentions.js index fb4a049f2..7071c9313 100644 --- a/backend/src/middleware/notifications/mentions.js +++ b/backend/src/middleware/notifications/mentions.js @@ -1,4 +1,4 @@ -const MENTION_REGEX = /\s@(\S+)/g +const MENTION_REGEX = /\s@([\w_-]+)/g export function extractSlugs(content) { let slugs = [] diff --git a/backend/src/middleware/notifications/mentions.spec.js b/backend/src/middleware/notifications/mentions.spec.js index 8fe9221b3..0c70aae1c 100644 --- a/backend/src/middleware/notifications/mentions.spec.js +++ b/backend/src/middleware/notifications/mentions.spec.js @@ -6,6 +6,21 @@ describe('extract', () => { const content = 'Hello @user' expect(extractSlugs(content)).toEqual(['user']) }) + + it('@user-with-dash', () => { + const content = 'Hello @user-with-dash' + expect(extractSlugs(content)).toEqual(['user-with-dash']) + }) + + it('@user.', () => { + const content = 'Hello @user.' + expect(extractSlugs(content)).toEqual(['user']) + }) + + it('@user-With-Capital-LETTERS', () => { + const content = 'Hello @user-With-Capital-LETTERS' + expect(extractSlugs(content)).toEqual(['user-With-Capital-LETTERS']) + }) }) it('ignores email addresses', () => { From 26caff5a9b487e3befc84967d08adf1a5782e719 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Tue, 9 Apr 2019 20:51:17 +0200 Subject: [PATCH 6/7] Fix lint --- backend/src/middleware/notifications/mentions.js | 2 +- backend/src/middleware/notifications/mentions.spec.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/src/middleware/notifications/mentions.js b/backend/src/middleware/notifications/mentions.js index 7071c9313..137c23f1c 100644 --- a/backend/src/middleware/notifications/mentions.js +++ b/backend/src/middleware/notifications/mentions.js @@ -1,6 +1,6 @@ const MENTION_REGEX = /\s@([\w_-]+)/g -export function extractSlugs(content) { +export function extractSlugs (content) { let slugs = [] let match while ((match = MENTION_REGEX.exec(content)) != null) { diff --git a/backend/src/middleware/notifications/mentions.spec.js b/backend/src/middleware/notifications/mentions.spec.js index 0c70aae1c..f12df7f07 100644 --- a/backend/src/middleware/notifications/mentions.spec.js +++ b/backend/src/middleware/notifications/mentions.spec.js @@ -24,7 +24,7 @@ describe('extract', () => { }) it('ignores email addresses', () => { - const content = 'Hello somebody@example.org' - expect(extractSlugs(content)).toEqual([]) + const content = 'Hello somebody@example.org' + expect(extractSlugs(content)).toEqual([]) }) }) From b63200ac8ee7815c7745befd11cf28e45944d0d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Wed, 10 Apr 2019 01:52:14 +0200 Subject: [PATCH 7/7] Authorize and whitelist Notifications --- backend/src/graphql-schema.js | 7 +- .../src/middleware/permissionsMiddleware.js | 16 +++++ backend/src/resolvers/notifications.js | 14 ++++ backend/src/resolvers/notifications.spec.js | 71 +++++++++++++++++-- backend/src/server.js | 4 +- 5 files changed, 103 insertions(+), 9 deletions(-) create mode 100644 backend/src/resolvers/notifications.js diff --git a/backend/src/graphql-schema.js b/backend/src/graphql-schema.js index 57b2ffb6c..c17b967d2 100644 --- a/backend/src/graphql-schema.js +++ b/backend/src/graphql-schema.js @@ -7,6 +7,7 @@ import reports from './resolvers/reports.js' import posts from './resolvers/posts.js' import moderation from './resolvers/moderation.js' import rewards from './resolvers/rewards.js' +import notifications from './resolvers/notifications' export const typeDefs = fs .readFileSync( @@ -17,13 +18,15 @@ export const typeDefs = fs export const resolvers = { Query: { ...statistics.Query, - ...userManagement.Query + ...userManagement.Query, + ...notifications.Query }, Mutation: { ...userManagement.Mutation, ...reports.Mutation, ...posts.Mutation, ...moderation.Mutation, - ...rewards.Mutation + ...rewards.Mutation, + ...notifications.Mutation } } diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index 495bc9145..4ff334806 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -20,6 +20,21 @@ const isMyOwn = rule({ cache: 'no_cache' })(async (parent, args, context, info) return context.user.id === parent.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) +}) + const onlyEnabledContent = rule({ cache: 'strict' })(async (parent, args, ctx, info) => { const { disabled, deleted } = args return !(disabled || deleted) @@ -50,6 +65,7 @@ const permissions = shield({ Post: or(onlyEnabledContent, isModerator) }, Mutation: { + UpdateNotification: belongsToMe, CreatePost: isAuthenticated, UpdatePost: isAuthor, DeletePost: isAuthor, diff --git a/backend/src/resolvers/notifications.js b/backend/src/resolvers/notifications.js new file mode 100644 index 000000000..bc3da0acf --- /dev/null +++ b/backend/src/resolvers/notifications.js @@ -0,0 +1,14 @@ +import { neo4jgraphql } from 'neo4j-graphql-js' + +export default { + Query: { + Notification: (object, params, context, resolveInfo) => { + return neo4jgraphql(object, params, context, resolveInfo, false) + } + }, + Mutation: { + UpdateNotification: (object, params, context, resolveInfo) => { + return neo4jgraphql(object, params, context, resolveInfo, false) + } + } +} diff --git a/backend/src/resolvers/notifications.spec.js b/backend/src/resolvers/notifications.spec.js index 50ded7bc4..799bc1594 100644 --- a/backend/src/resolvers/notifications.spec.js +++ b/backend/src/resolvers/notifications.spec.js @@ -5,13 +5,14 @@ import { host, login } from '../jest/helpers' const factory = Factory() let client +let userParams = { + id: 'you', + email: 'test@example.org', + password: '1234' +} beforeEach(async () => { - await factory.create('User', { - id: 'you', - email: 'test@example.org', - password: '1234' - }) + await factory.create('User', userParams) }) afterEach(async () => { @@ -118,3 +119,63 @@ describe('currentUser { notifications }', () => { }) }) }) + +describe('UpdateNotification', () => { + const mutation = `mutation($id: ID!, $read: Boolean){ + UpdateNotification(id: $id, read: $read) { + id read + } + }` + const variables = { id: 'to-be-updated', read: true } + + describe('given a notifications', () => { + let headers + + beforeEach(async () => { + const mentionedParams = { + id: 'mentioned-1', + email: 'mentioned@example.org', + password: '1234', + slug: 'mentioned' + } + await factory.create('User', mentionedParams) + await factory.create('Notification', { id: 'to-be-updated' }) + await factory.authenticateAs(userParams) + await factory.create('Post', { id: 'p1' }) + await Promise.all([ + factory.relate('Notification', 'User', { from: 'to-be-updated', to: 'mentioned-1' }), + factory.relate('Notification', 'Post', { from: 'p1', to: 'to-be-updated' }) + ]) + }) + + describe('unauthenticated', () => { + it('throws authorization error', async () => { + client = new GraphQLClient(host) + await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised') + }) + }) + + describe('authenticated', () => { + beforeEach(async () => { + headers = await login({ email: 'test@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + }) + + it('throws authorization error', async () => { + await expect(client.request(mutation, variables)).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 notification', async () => { + const expected = { UpdateNotification: { id: 'to-be-updated', read: true } } + await expect(client.request(mutation, variables)).resolves.toEqual(expected) + }) + }) + }) + }) +}) diff --git a/backend/src/server.js b/backend/src/server.js index efa9a17c0..fe0d4ee1d 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -28,10 +28,10 @@ let schema = makeAugmentedSchema({ resolvers, config: { query: { - exclude: ['Statistics', 'LoggedInUser'] + exclude: ['Notfication', 'Statistics', 'LoggedInUser'] }, mutation: { - exclude: ['Statistics', 'LoggedInUser'] + exclude: ['Notfication', 'Statistics', 'LoggedInUser'] }, debug: debug }