diff --git a/backend/neo4j/Dockerfile b/backend/neo4j/Dockerfile index f6e71811b..79f1e5200 100644 --- a/backend/neo4j/Dockerfile +++ b/backend/neo4j/Dockerfile @@ -1,3 +1,3 @@ -FROM neo4j:3.5.0 +FROM neo4j:3.5.3 RUN wget https://github.com/neo4j-contrib/neo4j-apoc-procedures/releases/download/3.5.0.1/apoc-3.5.0.1-all.jar -P plugins/ COPY migrate.sh /usr/local/bin/migrate diff --git a/backend/package.json b/backend/package.json index d03f35114..2e0d7301a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -16,8 +16,7 @@ "test:cucumber:cmd": "wait-on tcp:4001 tcp:4123 && cucumber-js --require-module @babel/register --exit test/", "test:jest:cmd:debug": "wait-on tcp:4001 tcp:4123 && node --inspect-brk ./node_modules/.bin/jest -i --forceExit --detectOpenHandles --runInBand", "test:jest": "run-p --race test:before:* 'test:jest:cmd {@}' --", - "test:cucumber": " cross-env CLIENT_URI=http://localhost:4123 run-p --race test:before:server test:cucumber:before:seeder 'test:cucumber:cmd {@}' --", - "test:cucumber:before:seeder": "cross-env GRAPHQL_URI=http://localhost:4001 GRAPHQL_PORT=4001 DISABLED_MIDDLEWARES=permissions yarn run dev", + "test:cucumber": " cross-env CLIENT_URI=http://localhost:4123 run-p --race test:before:* 'test:cucumber:cmd {@}' --", "test:jest:debug": "run-p --race test:before:* 'test:jest:cmd:debug {@}' --", "db:script:seed": "wait-on tcp:4001 && babel-node src/seed/seed-db.js", "db:reset": "babel-node src/seed/reset-db.js", @@ -51,7 +50,7 @@ "graphql-custom-directives": "~0.2.14", "graphql-iso-date": "~3.6.1", "graphql-middleware": "~3.0.2", - "graphql-shield": "~5.3.1", + "graphql-shield": "~5.3.2", "graphql-tag": "~2.10.1", "graphql-yoga": "~1.17.4", "helmet": "~3.16.0", diff --git a/backend/src/graphql-schema.js b/backend/src/graphql-schema.js index 4a592e908..1e13c95f4 100644 --- a/backend/src/graphql-schema.js +++ b/backend/src/graphql-schema.js @@ -8,6 +8,7 @@ import posts from './resolvers/posts.js' import moderation from './resolvers/moderation.js' import rewards from './resolvers/rewards.js' import socialMedia from './resolvers/socialMedia.js' +import notifications from './resolvers/notifications' export const typeDefs = fs .readFileSync( @@ -18,7 +19,8 @@ export const typeDefs = fs export const resolvers = { Query: { ...statistics.Query, - ...userManagement.Query + ...userManagement.Query, + ...notifications.Query }, Mutation: { ...userManagement.Mutation, @@ -26,6 +28,7 @@ export const resolvers = { ...posts.Mutation, ...moderation.Mutation, ...rewards.Mutation, - ...socialMedia.Mutation + ...socialMedia.Mutation, + ...notifications.Mutation } } diff --git a/backend/src/middleware/index.js b/backend/src/middleware/index.js index 3073264ba..e6759e8ff 100644 --- a/backend/src/middleware/index.js +++ b/backend/src/middleware/index.js @@ -11,20 +11,22 @@ import userMiddleware from './userMiddleware' import includedFieldsMiddleware from './includedFieldsMiddleware' import orderByMiddleware from './orderByMiddleware' import validUrlMiddleware from './validUrlMiddleware' +import notificationsMiddleware from './notificationsMiddleware' export default schema => { let middleware = [ passwordMiddleware, dateTimeMiddleware, + validUrlMiddleware, sluggifyMiddleware, excerptMiddleware, xssMiddleware, fixImageUrlsMiddleware, + notificationsMiddleware, softDeleteMiddleware, userMiddleware, includedFieldsMiddleware, - orderByMiddleware, - validUrlMiddleware + orderByMiddleware ] // add permisions middleware at the first position (unless we're seeding) diff --git a/backend/src/middleware/notifications/mentions.js b/backend/src/middleware/notifications/mentions.js new file mode 100644 index 000000000..137c23f1c --- /dev/null +++ b/backend/src/middleware/notifications/mentions.js @@ -0,0 +1,10 @@ +const MENTION_REGEX = /\s@([\w_-]+)/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..f12df7f07 --- /dev/null +++ b/backend/src/middleware/notifications/mentions.spec.js @@ -0,0 +1,30 @@ +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('@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', () => { + const content = 'Hello somebody@example.org' + expect(extractSlugs(content)).toEqual([]) + }) +}) diff --git a/backend/src/middleware/notificationsMiddleware.js b/backend/src/middleware/notificationsMiddleware.js new file mode 100644 index 000000000..30205278b --- /dev/null +++ b/backend/src/middleware/notificationsMiddleware.js @@ -0,0 +1,27 @@ +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 = extractSlugs(content) + const createdAt = (new Date()).toISOString() + 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/notificationsMiddleware.spec.js b/backend/src/middleware/notificationsMiddleware.spec.js new file mode 100644 index 000000000..e6fc78c52 --- /dev/null +++ b/backend/src/middleware/notificationsMiddleware.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) { + read + post { + content + } + } + } + }` + + 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, { headers: 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) + }) + }) + }) + }) +}) diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index 58aae6cb6..0d335b363 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/resolvers/user_management.js b/backend/src/resolvers/user_management.js index c20baaf51..26dfb81db 100644 --- a/backend/src/resolvers/user_management.js +++ b/backend/src/resolvers/user_management.js @@ -32,7 +32,7 @@ export default { const session = driver.session() const result = await session.run( 'MATCH (user:User {email: $userEmail}) ' + - 'RETURN user {.id, .slug, .name, .avatar, .email, .password, .role, .disabled} as user LIMIT 1', + 'RETURN user {.id, .slug, .name, .avatar, .email, .password, .role, .disabled} as user LIMIT 1', { userEmail: email } @@ -102,4 +102,4 @@ export default { } } } -} \ No newline at end of file +} diff --git a/backend/src/server.js b/backend/src/server.js index 0dff63635..5861646f4 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -29,10 +29,10 @@ let schema = makeAugmentedSchema({ resolvers, config: { query: { - exclude: ['Statistics', 'LoggedInUser'] + exclude: ['Notfication', 'Statistics', 'LoggedInUser'] }, mutation: { - exclude: ['Statistics', 'LoggedInUser'] + exclude: ['Notfication', 'Statistics', 'LoggedInUser'] }, debug: debug } diff --git a/backend/yarn.lock b/backend/yarn.lock index a491398fc..a58f1f865 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -1104,6 +1104,11 @@ resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-12.0.9.tgz#693e76a52f61a2f1e7fb48c0eef167b95ea4ffd0" integrity sha512-sCZy4SxP9rN2w30Hlmg5dtdRwgYQfYRiLo9usw8X9cxlf+H4FqM1xX7+sNH7NNKVdbXMJWqva7iyy+fxh/V7fA== +"@types/yup@0.26.9": + version "0.26.9" + resolved "https://registry.yarnpkg.com/@types/yup/-/yup-0.26.9.tgz#8a619ac4d2b8dcacb0d81345746018303b479919" + integrity sha512-C7HdLLs1ZNPbYeNsSX++fMosxWAwzVeUs9wc76XlKJrKvLEyNwXMDUjag75EVAPxlZ36YiRJ6iTy4zc5Dbtndw== + "@types/zen-observable@^0.5.3": version "0.5.4" resolved "https://registry.yarnpkg.com/@types/zen-observable/-/zen-observable-0.5.4.tgz#b863a4191e525206819e008097ebf0fb2e3a1cdc" @@ -3738,11 +3743,12 @@ graphql-request@~1.8.2: dependencies: cross-fetch "2.2.2" -graphql-shield@~5.3.1: - version "5.3.1" - resolved "https://registry.yarnpkg.com/graphql-shield/-/graphql-shield-5.3.1.tgz#34cff4d1bfdcc3caa6fc348afb11503dde1893cd" - integrity sha512-vVJ7rjkR7miWi/Zspr7/ibmtdL2gEHagCtpsJY534DyRE70r+PurCp2kR/e1fZhb4JdmTYCS+sokyYfH974/+w== +graphql-shield@~5.3.2: + version "5.3.2" + resolved "https://registry.yarnpkg.com/graphql-shield/-/graphql-shield-5.3.2.tgz#2d47907ed9882a0636cb8ade6087123309d215ef" + integrity sha512-fib7rSr5aS/WHL3+Aa5LXhcCuPGEIDXmzfGtFjUXkUiZ6E5u+bDSL+9KRXo/p14A28GkJF+1Vu1hlg9H/QFG1w== dependencies: + "@types/yup" "0.26.9" lightercollective "^0.2.0" object-hash "^1.3.1" yup "^0.27.0" diff --git a/webapp/components/ContentMenu.vue b/webapp/components/ContentMenu.vue index abab7897d..5de2820da 100644 --- a/webapp/components/ContentMenu.vue +++ b/webapp/components/ContentMenu.vue @@ -111,9 +111,8 @@ export default { if (this.isOwner && this.resourceType === 'user') { routes.push({ - name: this.$t(`settings.data.name`), - // eslint-disable-next-line vue/no-side-effects-in-computed-properties - callback: () => this.$router.push('/settings'), + name: this.$t(`settings.name`), + path: '/settings', icon: 'edit' }) } diff --git a/webapp/package.json b/webapp/package.json index 935220cc8..76d7c3e76 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -75,7 +75,7 @@ "eslint-plugin-vue": "~5.2.2", "jest": "~24.7.1", "node-sass": "~4.11.0", - "nodemon": "~1.18.10", + "nodemon": "~1.18.11", "prettier": "~1.14.3", "sass-loader": "~7.1.0", "vue-jest": "~3.0.4", diff --git a/webapp/pages/admin.vue b/webapp/pages/admin.vue index 7149109b1..6cacedef5 100644 --- a/webapp/pages/admin.vue +++ b/webapp/pages/admin.vue @@ -32,22 +32,26 @@ export default { name: this.$t('admin.dashboard.name'), path: `/admin` }, - { + // TODO implement + /* { name: this.$t('admin.users.name'), path: `/admin/users` - }, - { + }, */ + // TODO implement + /* { name: this.$t('admin.organizations.name'), path: `/admin/organizations` - }, - { + }, */ + // TODO implement + /* { name: this.$t('admin.pages.name'), path: `/admin/pages` - }, - { + }, */ + // TODO implement + /* { name: this.$t('admin.notifications.name'), path: `/admin/notifications` - }, + }, */ { name: this.$t('admin.categories.name'), path: `/admin/categories` @@ -55,11 +59,12 @@ export default { { name: this.$t('admin.tags.name'), path: `/admin/tags` - }, - { + } + // TODO implement + /* { name: this.$t('admin.settings.name'), path: `/admin/settings` - } + } */ ] } } diff --git a/webapp/pages/index.vue b/webapp/pages/index.vue index ee824eb59..f5d7e03ba 100644 --- a/webapp/pages/index.vue +++ b/webapp/pages/index.vue @@ -149,8 +149,8 @@ export default { .post-add-button { z-index: 100; position: fixed; - top: 100vh; - left: 100vw; + top: 98vh; + left: 98vw; transform: translate(-120%, -120%); box-shadow: $box-shadow-x-large; } diff --git a/webapp/pages/post/_id.vue b/webapp/pages/post/_id.vue index d9e8beb01..d8fc5d4f9 100644 --- a/webapp/pages/post/_id.vue +++ b/webapp/pages/post/_id.vue @@ -58,25 +58,28 @@ export default { { name: this.$t('common.comment', null, 2), path: `/post/${id}/${slug}#comments` - }, - { + } + // TODO implement + /* { name: this.$t('common.letsTalk'), path: `/post/${id}/${slug}#lets-talk` - }, - { + }, */ + // TODO implement + /* { name: this.$t('common.versus'), path: `/post/${id}/${slug}#versus` - } + } */ ] }, { name: this.$t('common.moreInfo'), path: `/post/${id}/${slug}/more-info` - }, - { + } + // TODO implement + /* { name: this.$t('common.takeAction'), path: `/post/${id}/${slug}/take-action` - } + } */ ] } } diff --git a/webapp/pages/post/_id/_slug/index.vue b/webapp/pages/post/_id/_slug/index.vue index 49340ae9b..a2937b4e5 100644 --- a/webapp/pages/post/_id/_slug/index.vue +++ b/webapp/pages/post/_id/_slug/index.vue @@ -62,16 +62,18 @@ > {{ category.name }} - --> + --> @@ -87,9 +89,7 @@ color="primary" size="small" round - > - {{ post.commentsCount }} -   Comments + >{{ post.commentsCount }}  Comments diff --git a/webapp/pages/profile/_id/_slug.vue b/webapp/pages/profile/_id/_slug.vue index a3b10529e..38bf847a2 100644 --- a/webapp/pages/profile/_id/_slug.vue +++ b/webapp/pages/profile/_id/_slug.vue @@ -42,7 +42,8 @@ color="soft" size="small" > - {{ user.location.name }} + + {{ user.location.name }} - + @@ -82,9 +81,7 @@ - + - - - + + + - + --> + + + @@ -273,9 +266,7 @@ :key="post.id" :width="{ base: '100%', md: '100%', xl: '50%' }" > - +