diff --git a/.travis.yml b/.travis.yml index eb0c6f89a..c266b5f0a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -18,6 +18,7 @@ install: - docker-compose -f docker-compose.yml -f docker-compose.travis.yml up --build -d # avoid "Database constraints have changed after this transaction started" - wait-on http://localhost:7474 + - docker-compose exec neo4j db_setup script: - export CYPRESS_RETRIES=1 diff --git a/backend/package.json b/backend/package.json index c3b03e454..1e4c2b8a1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -49,24 +49,24 @@ "apollo-client": "~2.6.4", "apollo-link-context": "~1.0.18", "apollo-link-http": "~1.5.15", - "apollo-server": "~2.8.2", - "apollo-server-express": "^2.8.1", + "apollo-server": "~2.9.0", + "apollo-server-express": "^2.9.0", "babel-plugin-transform-runtime": "^6.23.0", "bcryptjs": "~2.4.3", "cheerio": "~1.0.0-rc.3", "cors": "~2.8.5", "cross-env": "~5.2.0", - "date-fns": "2.0.0", + "date-fns": "2.0.1", "debug": "~4.1.1", "dotenv": "~8.1.0", "express": "^4.17.1", "faker": "Marak/faker.js#master", - "graphql": "^14.5.0", + "graphql": "^14.5.3", "graphql-custom-directives": "~0.2.14", "graphql-iso-date": "~3.6.1", "graphql-middleware": "~3.0.5", "graphql-middleware-sentry": "^3.2.0", - "graphql-shield": "~6.0.5", + "graphql-shield": "~6.0.6", "graphql-tag": "~2.10.1", "helmet": "~3.20.0", "jsonwebtoken": "~8.5.1", @@ -110,15 +110,15 @@ "@babel/plugin-proposal-throw-expressions": "^7.2.0", "@babel/preset-env": "~7.5.5", "@babel/register": "~7.5.5", - "apollo-server-testing": "~2.8.2", + "apollo-server-testing": "~2.9.0", "babel-core": "~7.0.0-0", - "babel-eslint": "~10.0.2", + "babel-eslint": "~10.0.3", "babel-jest": "~24.9.0", "chai": "~4.2.0", "cucumber": "~5.1.0", "eslint": "~6.2.1", "eslint-config-prettier": "~6.1.0", - "eslint-config-standard": "~14.0.0", + "eslint-config-standard": "~14.0.1", "eslint-plugin-import": "~2.18.2", "eslint-plugin-jest": "~22.15.2", "eslint-plugin-node": "~9.1.0", diff --git a/backend/src/middleware/handleHtmlContent/handleContentData.js b/backend/src/middleware/handleHtmlContent/handleContentData.js deleted file mode 100644 index 403b2044e..000000000 --- a/backend/src/middleware/handleHtmlContent/handleContentData.js +++ /dev/null @@ -1,96 +0,0 @@ -import extractMentionedUsers from './notifications/extractMentionedUsers' -import extractHashtags from './hashtags/extractHashtags' - -const notifyMentions = async (label, id, idsOfMentionedUsers, context) => { - if (!idsOfMentionedUsers.length) return - - const session = context.driver.session() - const createdAt = new Date().toISOString() - let cypher - if (label === 'Post') { - cypher = ` - MATCH (post: Post { id: $id })<-[:WROTE]-(author: User) - MATCH (user: User) - WHERE user.id in $idsOfMentionedUsers - AND NOT (user)<-[:BLOCKED]-(author) - CREATE (notification: Notification {id: apoc.create.uuid(), read: false, createdAt: $createdAt }) - MERGE (post)-[:NOTIFIED]->(notification)-[:NOTIFIED]->(user) - ` - } else { - cypher = ` - MATCH (postAuthor: User)-[:WROTE]->(post: Post)<-[:COMMENTS]-(comment: Comment { id: $id })<-[:WROTE]-(author: User) - MATCH (user: User) - WHERE user.id in $idsOfMentionedUsers - AND NOT (user)<-[:BLOCKED]-(author) - AND NOT (user)<-[:BLOCKED]-(postAuthor) - CREATE (notification: Notification {id: apoc.create.uuid(), read: false, createdAt: $createdAt }) - MERGE (comment)-[:NOTIFIED]->(notification)-[:NOTIFIED]->(user) - ` - } - await session.run(cypher, { - idsOfMentionedUsers, - label, - createdAt, - id, - }) - session.close() -} - -const updateHashtagsOfPost = async (postId, hashtags, context) => { - if (!hashtags.length) return - - const session = context.driver.session() - // We need two Cypher statements, because the 'MATCH' in the 'cypherDeletePreviousRelations' statement - // functions as an 'if'. In case there is no previous relation, the rest of the commands are omitted - // and no new Hashtags and relations will be created. - const cypherDeletePreviousRelations = ` - MATCH (p: Post { id: $postId })-[previousRelations: TAGGED]->(t: Tag) - DELETE previousRelations - RETURN p, t - ` - const cypherCreateNewTagsAndRelations = ` - MATCH (p: Post { id: $postId}) - UNWIND $hashtags AS tagName - MERGE (t: Tag { id: tagName, disabled: false, deleted: false }) - MERGE (p)-[:TAGGED]->(t) - RETURN p, t - ` - await session.run(cypherDeletePreviousRelations, { - postId, - }) - await session.run(cypherCreateNewTagsAndRelations, { - postId, - hashtags, - }) - session.close() -} - -const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => { - const idsOfMentionedUsers = extractMentionedUsers(args.content) - const hashtags = extractHashtags(args.content) - - const post = await resolve(root, args, context, resolveInfo) - - await notifyMentions('Post', post.id, idsOfMentionedUsers, context) - await updateHashtagsOfPost(post.id, hashtags, context) - - return post -} - -const handleContentDataOfComment = async (resolve, root, args, context, resolveInfo) => { - const idsOfMentionedUsers = extractMentionedUsers(args.content) - const comment = await resolve(root, args, context, resolveInfo) - - await notifyMentions('Comment', comment.id, idsOfMentionedUsers, context) - - return comment -} - -export default { - Mutation: { - CreatePost: handleContentDataOfPost, - UpdatePost: handleContentDataOfPost, - CreateComment: handleContentDataOfComment, - UpdateComment: handleContentDataOfComment, - }, -} diff --git a/backend/src/middleware/handleHtmlContent/handleContentData.spec.js b/backend/src/middleware/handleHtmlContent/handleContentData.spec.js deleted file mode 100644 index 2925f92cf..000000000 --- a/backend/src/middleware/handleHtmlContent/handleContentData.spec.js +++ /dev/null @@ -1,392 +0,0 @@ -import { gql } from '../../jest/helpers' -import Factory from '../../seed/factories' -import { createTestClient } from 'apollo-server-testing' -import { neode, getDriver } from '../../bootstrap/neo4j' -import createServer from '../../server' - -let server -let query -let mutate -let user -let authenticatedUser -const factory = Factory() -const driver = getDriver() -const instance = neode() -const categoryIds = ['cat9'] -const createPostMutation = gql` - mutation($id: ID, $title: String!, $content: String!, $categoryIds: [ID]!) { - CreatePost(id: $id, title: $title, content: $content, categoryIds: $categoryIds) { - id - title - content - } - } -` -const updatePostMutation = gql` - mutation($id: ID!, $title: String!, $content: String!, $categoryIds: [ID]!) { - UpdatePost(id: $id, content: $content, title: $title, categoryIds: $categoryIds) { - title - content - } - } -` - -beforeAll(() => { - const createServerResult = createServer({ - context: () => { - return { - user: authenticatedUser, - neode: instance, - driver, - } - }, - }) - server = createServerResult.server - const createTestClientResult = createTestClient(server) - query = createTestClientResult.query - mutate = createTestClientResult.mutate -}) - -beforeEach(async () => { - user = await instance.create('User', { - id: 'you', - name: 'Al Capone', - slug: 'al-capone', - email: 'test@example.org', - password: '1234', - }) - await instance.create('Category', { - id: 'cat9', - name: 'Democracy & Politics', - icon: 'university', - }) -}) - -afterEach(async () => { - await factory.cleanDatabase() -}) - -describe('notifications', () => { - const notificationQuery = gql` - query($read: Boolean) { - currentUser { - notifications(read: $read, orderBy: createdAt_desc) { - read - post { - content - } - comment { - content - } - } - } - } - ` - - describe('authenticated', () => { - beforeEach(async () => { - authenticatedUser = user - }) - - describe('given another user', () => { - let postAuthor - beforeEach(async () => { - postAuthor = await instance.create('User', { - email: 'post-author@example.org', - password: '1234', - id: 'postAuthor', - }) - }) - - describe('who mentions me in a post', () => { - const title = 'Mentioning Al Capone' - const content = - 'Hey @al-capone how do you do?' - - const createPostAction = async () => { - authenticatedUser = await postAuthor.toJson() - await mutate({ - mutation: createPostMutation, - variables: { id: 'p47', title, content, categoryIds }, - }) - authenticatedUser = await user.toJson() - } - - it('sends you a notification', async () => { - await createPostAction() - const expectedContent = - 'Hey @al-capone how do you do?' - const expected = expect.objectContaining({ - data: { - currentUser: { - notifications: [ - { - read: false, - post: { - content: expectedContent, - }, - comment: null, - }, - ], - }, - }, - }) - const { query } = createTestClient(server) - await expect( - query({ - query: notificationQuery, - variables: { - read: false, - }, - }), - ).resolves.toEqual(expected) - }) - - describe('who mentions me many times', () => { - const updatePostAction = async () => { - const updatedContent = ` - One more mention to - - @al-capone - - and again: - - @al-capone - - and again - - @al-capone - - ` - authenticatedUser = await postAuthor.toJson() - await mutate({ - mutation: updatePostMutation, - variables: { - id: 'p47', - title, - content: updatedContent, - categoryIds, - }, - }) - authenticatedUser = await user.toJson() - } - - it('creates exactly one more notification', async () => { - 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, - post: { - content: expectedContent, - }, - comment: null, - }, - { - read: false, - post: { - content: expectedContent, - }, - comment: null, - }, - ], - }, - }, - }) - await expect( - query({ - query: notificationQuery, - variables: { - read: false, - }, - }), - ).resolves.toEqual(expected) - }) - }) - - describe('but the author of the post blocked me', () => { - beforeEach(async () => { - await postAuthor.relateTo(user, 'blocked') - }) - - it('sends no notification', async () => { - await createPostAction() - const expected = expect.objectContaining({ - data: { - currentUser: { - notifications: [], - }, - }, - }) - const { query } = createTestClient(server) - await expect( - query({ - query: notificationQuery, - variables: { - read: false, - }, - }), - ).resolves.toEqual(expected) - }) - }) - - describe('but the author of the post blocked me and a mentioner mentions me in a comment', () => { - const createCommentOnPostAction = async () => { - await createPostAction() - const createCommentMutation = gql` - mutation($id: ID, $postId: ID!, $commentContent: String!) { - CreateComment(id: $id, postId: $postId, content: $commentContent) { - id - content - } - } - ` - authenticatedUser = await commentMentioner.toJson() - await mutate({ - mutation: createCommentMutation, - variables: { - id: 'c47', - postId: 'p47', - commentContent: - 'One mention of me with .', - }, - }) - authenticatedUser = await user.toJson() - } - let commentMentioner - - beforeEach(async () => { - await postAuthor.relateTo(user, 'blocked') - commentMentioner = await instance.create('User', { - id: 'mentioner', - name: 'Mr Mentioner', - slug: 'mr-mentioner', - email: 'mentioner@example.org', - password: '1234', - }) - }) - - it('sends no notification', async () => { - await createCommentOnPostAction() - const expected = expect.objectContaining({ - data: { - currentUser: { - notifications: [], - }, - }, - }) - const { query } = createTestClient(server) - await expect( - query({ - query: notificationQuery, - variables: { - read: false, - }, - }), - ).resolves.toEqual(expected) - }) - }) - }) - }) - }) -}) - -describe('Hashtags', () => { - const id = 'p135' - const title = 'Two Hashtags' - const content = - '

Hey Dude, #Democracy should work equal for everybody!? That seems to be the only way to have equal #Liberty for everyone.

' - const postWithHastagsQuery = gql` - query($id: ID) { - Post(id: $id) { - tags { - id - } - } - } - ` - const postWithHastagsVariables = { - id, - } - - describe('authenticated', () => { - beforeEach(async () => { - authenticatedUser = await user.toJson() - }) - - describe('create a Post with Hashtags', () => { - beforeEach(async () => { - await mutate({ - mutation: createPostMutation, - variables: { - id, - title, - content, - categoryIds, - }, - }) - }) - - it('both Hashtags are created with the "id" set to their "name"', async () => { - const expected = [{ id: 'Democracy' }, { id: 'Liberty' }] - await expect( - query({ - query: postWithHastagsQuery, - variables: postWithHastagsVariables, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - Post: [ - { - tags: expect.arrayContaining(expected), - }, - ], - }, - }), - ) - }) - - describe('afterwards update the Post by removing a Hashtag, leaving a Hashtag and add a Hashtag', () => { - // The already existing Hashtag has no class at this point. - const content = - '

Hey Dude, #Elections should work equal for everybody!? That seems to be the only way to have equal #Liberty for everyone.

' - - it('only one previous Hashtag and the new Hashtag exists', async () => { - await mutate({ - mutation: updatePostMutation, - variables: { - id, - title, - content, - categoryIds, - }, - }) - - const expected = [{ id: 'Elections' }, { id: 'Liberty' }] - await expect( - query({ - query: postWithHastagsQuery, - variables: postWithHastagsVariables, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - Post: [ - { - tags: expect.arrayContaining(expected), - }, - ], - }, - }), - ) - }) - }) - }) - }) -}) diff --git a/backend/src/middleware/handleHtmlContent/hashtags/extractHashtags.js b/backend/src/middleware/hashtags/extractHashtags.js similarity index 100% rename from backend/src/middleware/handleHtmlContent/hashtags/extractHashtags.js rename to backend/src/middleware/hashtags/extractHashtags.js diff --git a/backend/src/middleware/handleHtmlContent/hashtags/extractHashtags.spec.js b/backend/src/middleware/hashtags/extractHashtags.spec.js similarity index 100% rename from backend/src/middleware/handleHtmlContent/hashtags/extractHashtags.spec.js rename to backend/src/middleware/hashtags/extractHashtags.spec.js diff --git a/backend/src/middleware/hashtags/hashtagsMiddleware.js b/backend/src/middleware/hashtags/hashtagsMiddleware.js new file mode 100644 index 000000000..c9156398d --- /dev/null +++ b/backend/src/middleware/hashtags/hashtagsMiddleware.js @@ -0,0 +1,49 @@ +import extractHashtags from '../hashtags/extractHashtags' + +const updateHashtagsOfPost = async (postId, hashtags, context) => { + if (!hashtags.length) return + + const session = context.driver.session() + // We need two Cypher statements, because the 'MATCH' in the 'cypherDeletePreviousRelations' statement + // functions as an 'if'. In case there is no previous relation, the rest of the commands are omitted + // and no new Hashtags and relations will be created. + const cypherDeletePreviousRelations = ` + MATCH (p: Post { id: $postId })-[previousRelations: TAGGED]->(t: Tag) + DELETE previousRelations + RETURN p, t + ` + const cypherCreateNewTagsAndRelations = ` + MATCH (p: Post { id: $postId}) + UNWIND $hashtags AS tagName + MERGE (t: Tag { id: tagName, disabled: false, deleted: false }) + MERGE (p)-[:TAGGED]->(t) + RETURN p, t + ` + await session.run(cypherDeletePreviousRelations, { + postId, + }) + await session.run(cypherCreateNewTagsAndRelations, { + postId, + hashtags, + }) + session.close() +} + +const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => { + const hashtags = extractHashtags(args.content) + + const post = await resolve(root, args, context, resolveInfo) + + if (post) { + await updateHashtagsOfPost(post.id, hashtags, context) + } + + return post +} + +export default { + Mutation: { + CreatePost: handleContentDataOfPost, + UpdatePost: handleContentDataOfPost, + }, +} diff --git a/backend/src/middleware/hashtags/hashtagsMiddleware.spec.js b/backend/src/middleware/hashtags/hashtagsMiddleware.spec.js new file mode 100644 index 000000000..3f101f778 --- /dev/null +++ b/backend/src/middleware/hashtags/hashtagsMiddleware.spec.js @@ -0,0 +1,176 @@ +import { gql } from '../../jest/helpers' +import Factory from '../../seed/factories' +import { createTestClient } from 'apollo-server-testing' +import { neode, getDriver } from '../../bootstrap/neo4j' +import createServer from '../../server' + +let server +let query +let mutate +let hashtagingUser +let authenticatedUser +const factory = Factory() +const driver = getDriver() +const instance = neode() +const categoryIds = ['cat9'] +const createPostMutation = gql` + mutation($id: ID, $title: String!, $postContent: String!, $categoryIds: [ID]!) { + CreatePost(id: $id, title: $title, content: $postContent, categoryIds: $categoryIds) { + id + title + content + } + } +` +const updatePostMutation = gql` + mutation($id: ID!, $title: String!, $postContent: String!, $categoryIds: [ID]!) { + UpdatePost(id: $id, content: $postContent, title: $title, categoryIds: $categoryIds) { + title + content + } + } +` + +beforeAll(() => { + const createServerResult = createServer({ + context: () => { + return { + user: authenticatedUser, + neode: instance, + driver, + } + }, + }) + server = createServerResult.server + const createTestClientResult = createTestClient(server) + query = createTestClientResult.query + mutate = createTestClientResult.mutate +}) + +beforeEach(async () => { + hashtagingUser = await instance.create('User', { + id: 'you', + name: 'Al Capone', + slug: 'al-capone', + email: 'test@example.org', + password: '1234', + }) + await instance.create('Category', { + id: 'cat9', + name: 'Democracy & Politics', + icon: 'university', + }) +}) + +afterEach(async () => { + await factory.cleanDatabase() +}) + +describe('hashtags', () => { + const id = 'p135' + const title = 'Two Hashtags' + const postContent = + '

Hey Dude, #Democracy should work equal for everybody!? That seems to be the only way to have equal #Liberty for everyone.

' + const postWithHastagsQuery = gql` + query($id: ID) { + Post(id: $id) { + tags { + id + } + } + } + ` + const postWithHastagsVariables = { + id, + } + + describe('authenticated', () => { + beforeEach(async () => { + authenticatedUser = await hashtagingUser.toJson() + }) + + describe('create a Post with Hashtags', () => { + beforeEach(async () => { + await mutate({ + mutation: createPostMutation, + variables: { + id, + title, + postContent, + categoryIds, + }, + }) + }) + + it('both hashtags are created with the "id" set to their "name"', async () => { + const expected = [ + { + id: 'Democracy', + }, + { + id: 'Liberty', + }, + ] + await expect( + query({ + query: postWithHastagsQuery, + variables: postWithHastagsVariables, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + Post: [ + { + tags: expect.arrayContaining(expected), + }, + ], + }, + }), + ) + }) + + describe('afterwards update the Post by removing a Hashtag, leaving a Hashtag and add a Hashtag', () => { + // The already existing Hashtag has no class at this point. + const postContent = + '

Hey Dude, #Elections should work equal for everybody!? That seems to be the only way to have equal #Liberty for everyone.

' + + it('only one previous Hashtag and the new Hashtag exists', async () => { + await mutate({ + mutation: updatePostMutation, + variables: { + id, + title, + postContent, + categoryIds, + }, + }) + + const expected = [ + { + id: 'Elections', + }, + { + id: 'Liberty', + }, + ] + await expect( + query({ + query: postWithHastagsQuery, + variables: postWithHastagsVariables, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + Post: [ + { + tags: expect.arrayContaining(expected), + }, + ], + }, + }), + ) + }) + }) + }) + }) +}) diff --git a/backend/src/middleware/index.js b/backend/src/middleware/index.js index 3407c6874..7774ccc15 100644 --- a/backend/src/middleware/index.js +++ b/backend/src/middleware/index.js @@ -12,26 +12,30 @@ import user from './userMiddleware' import includedFields from './includedFieldsMiddleware' import orderBy from './orderByMiddleware' import validation from './validation/validationMiddleware' -import handleContentData from './handleHtmlContent/handleContentData' +import notifications from './notifications/notificationsMiddleware' +import hashtags from './hashtags/hashtagsMiddleware' import email from './email/emailMiddleware' import sentry from './sentryMiddleware' export default schema => { const middlewares = { - permissions: permissions, - sentry: sentry, - activityPub: activityPub, - dateTime: dateTime, - validation: validation, - sluggify: sluggify, - excerpt: excerpt, - handleContentData: handleContentData, - xss: xss, - softDelete: softDelete, - user: user, - includedFields: includedFields, - orderBy: orderBy, - email: email({ isEnabled: CONFIG.SMTP_HOST && CONFIG.SMTP_PORT }), + permissions, + sentry, + activityPub, + dateTime, + validation, + sluggify, + excerpt, + notifications, + hashtags, + xss, + softDelete, + user, + includedFields, + orderBy, + email: email({ + isEnabled: CONFIG.SMTP_HOST && CONFIG.SMTP_PORT, + }), } let order = [ @@ -43,7 +47,8 @@ export default schema => { 'sluggify', 'excerpt', 'email', - 'handleContentData', + 'notifications', + 'hashtags', 'xss', 'softDelete', 'user', diff --git a/backend/src/middleware/handleHtmlContent/notifications/extractMentionedUsers.js b/backend/src/middleware/notifications/mentions/extractMentionedUsers.js similarity index 100% rename from backend/src/middleware/handleHtmlContent/notifications/extractMentionedUsers.js rename to backend/src/middleware/notifications/mentions/extractMentionedUsers.js diff --git a/backend/src/middleware/handleHtmlContent/notifications/extractMentionedUsers.spec.js b/backend/src/middleware/notifications/mentions/extractMentionedUsers.spec.js similarity index 100% rename from backend/src/middleware/handleHtmlContent/notifications/extractMentionedUsers.spec.js rename to backend/src/middleware/notifications/mentions/extractMentionedUsers.spec.js diff --git a/backend/src/middleware/notifications/notificationsMiddleware.js b/backend/src/middleware/notifications/notificationsMiddleware.js new file mode 100644 index 000000000..c9dfe406c --- /dev/null +++ b/backend/src/middleware/notifications/notificationsMiddleware.js @@ -0,0 +1,122 @@ +import extractMentionedUsers from './mentions/extractMentionedUsers' + +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'] + 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)) + ) { + throw new Error('Notification does not fit the reason!') + } + + const session = context.driver.session() + const createdAt = new Date().toISOString() + let cypher + switch (reason) { + case 'mentioned_in_post': { + cypher = ` + MATCH (post: Post { id: $id })<-[:WROTE]-(author: User) + 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) + ` + break + } + case 'mentioned_in_comment': { + 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 (user)<-[:BLOCKED]-(postAuthor) + CREATE (notification: Notification {id: apoc.create.uuid(), read: false, reason: $reason, createdAt: $createdAt }) + MERGE (comment)-[:NOTIFIED]->(notification)-[:NOTIFIED]->(user) + ` + break + } + case 'comment_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) + ` + break + } + } + await session.run(cypher, { + label, + id, + idsOfUsers, + reason, + createdAt, + }) + session.close() +} + +const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => { + const idsOfUsers = extractMentionedUsers(args.content) + + const post = await resolve(root, args, context, resolveInfo) + + if (post) { + await notifyUsers('Post', post.id, idsOfUsers, 'mentioned_in_post', context) + } + + return post +} + +const handleContentDataOfComment = async (resolve, root, args, context, resolveInfo) => { + const idsOfUsers = extractMentionedUsers(args.content) + const comment = await resolve(root, args, context, resolveInfo) + + if (comment) { + await notifyUsers('Comment', comment.id, idsOfUsers, 'mentioned_in_comment', context) + } + + return comment +} + +const handleCreateComment = async (resolve, root, args, context, resolveInfo) => { + const comment = await handleContentDataOfComment(resolve, root, args, context, resolveInfo) + + if (comment) { + const session = context.driver.session() + const cypherFindUser = ` + MATCH (user: User)-[:WROTE]->(:Post)<-[:COMMENTS]-(:Comment { id: $commentId }) + RETURN user { .id } + ` + const result = await session.run(cypherFindUser, { + commentId: comment.id, + }) + session.close() + const [postAuthor] = await result.records.map(record => { + return record.get('user') + }) + if (context.user.id !== postAuthor.id) { + await notifyUsers('Comment', comment.id, [postAuthor.id], 'comment_on_post', context) + } + } + + return comment +} + +export default { + Mutation: { + CreatePost: handleContentDataOfPost, + UpdatePost: handleContentDataOfPost, + CreateComment: handleCreateComment, + UpdateComment: handleContentDataOfComment, + }, +} diff --git a/backend/src/middleware/notifications/notificationsMiddleware.spec.js b/backend/src/middleware/notifications/notificationsMiddleware.spec.js new file mode 100644 index 000000000..624cedddc --- /dev/null +++ b/backend/src/middleware/notifications/notificationsMiddleware.spec.js @@ -0,0 +1,463 @@ +import { gql } from '../../jest/helpers' +import Factory from '../../seed/factories' +import { createTestClient } from 'apollo-server-testing' +import { neode, getDriver } from '../../bootstrap/neo4j' +import createServer from '../../server' + +let server +let query +let mutate +let notifiedUser +let authenticatedUser +const factory = Factory() +const driver = getDriver() +const instance = neode() +const categoryIds = ['cat9'] +const createPostMutation = gql` + mutation($id: ID, $title: String!, $postContent: String!, $categoryIds: [ID]!) { + CreatePost(id: $id, title: $title, content: $postContent, categoryIds: $categoryIds) { + id + title + content + } + } +` +const updatePostMutation = gql` + mutation($id: ID!, $title: String!, $postContent: String!, $categoryIds: [ID]!) { + UpdatePost(id: $id, content: $postContent, title: $title, categoryIds: $categoryIds) { + title + content + } + } +` +const createCommentMutation = gql` + mutation($id: ID, $postId: ID!, $commentContent: String!) { + CreateComment(id: $id, postId: $postId, content: $commentContent) { + id + content + } + } +` + +beforeAll(() => { + const createServerResult = createServer({ + context: () => { + return { + user: authenticatedUser, + neode: instance, + driver, + } + }, + }) + server = createServerResult.server + const createTestClientResult = createTestClient(server) + query = createTestClientResult.query + mutate = createTestClientResult.mutate +}) + +beforeEach(async () => { + notifiedUser = await instance.create('User', { + id: 'you', + name: 'Al Capone', + slug: 'al-capone', + email: 'test@example.org', + password: '1234', + }) + await instance.create('Category', { + id: 'cat9', + name: 'Democracy & Politics', + icon: 'university', + }) +}) + +afterEach(async () => { + await factory.cleanDatabase() +}) + +describe('notifications', () => { + const notificationQuery = gql` + query($read: Boolean) { + currentUser { + notifications(read: $read, orderBy: createdAt_desc) { + read + reason + post { + content + } + comment { + content + } + } + } + } + ` + + describe('authenticated', () => { + beforeEach(async () => { + authenticatedUser = await notifiedUser.toJson() + }) + + describe('given another user', () => { + let title + let postContent + let postAuthor + const createPostAction = async () => { + authenticatedUser = await postAuthor.toJson() + await mutate({ + mutation: createPostMutation, + variables: { + id: 'p47', + title, + postContent, + categoryIds, + }, + }) + authenticatedUser = await notifiedUser.toJson() + } + + let commentContent + let commentAuthor + const createCommentOnPostAction = async () => { + await createPostAction() + authenticatedUser = await commentAuthor.toJson() + await mutate({ + mutation: createCommentMutation, + variables: { + id: 'c47', + postId: 'p47', + commentContent, + }, + }) + authenticatedUser = await notifiedUser.toJson() + } + + describe('comments on my post', () => { + beforeEach(async () => { + title = 'My post' + postContent = 'My post content.' + postAuthor = notifiedUser + }) + + describe('commenter is not me', () => { + beforeEach(async () => { + commentContent = 'Commenters comment.' + commentAuthor = await instance.create('User', { + id: 'commentAuthor', + name: 'Mrs Comment', + slug: 'mrs-comment', + email: 'commentauthor@example.org', + password: '1234', + }) + }) + + it('sends me a notification', async () => { + await createCommentOnPostAction() + const expected = expect.objectContaining({ + data: { + currentUser: { + notifications: [ + { + read: false, + reason: 'comment_on_post', + post: null, + comment: { + content: commentContent, + }, + }, + ], + }, + }, + }) + const { query } = createTestClient(server) + await expect( + query({ + query: notificationQuery, + variables: { + read: false, + }, + }), + ).resolves.toEqual(expected) + }) + + it('sends me no notification if I have blocked the comment author', async () => { + await notifiedUser.relateTo(commentAuthor, 'blocked') + await createCommentOnPostAction() + const expected = expect.objectContaining({ + data: { + currentUser: { + notifications: [], + }, + }, + }) + const { query } = createTestClient(server) + await expect( + query({ + query: notificationQuery, + variables: { + read: false, + }, + }), + ).resolves.toEqual(expected) + }) + }) + + describe('commenter is me', () => { + beforeEach(async () => { + commentContent = 'My comment.' + commentAuthor = notifiedUser + }) + + it('sends me no notification', async () => { + await notifiedUser.relateTo(commentAuthor, 'blocked') + await createCommentOnPostAction() + const expected = expect.objectContaining({ + data: { + currentUser: { + notifications: [], + }, + }, + }) + const { query } = createTestClient(server) + await expect( + query({ + query: notificationQuery, + variables: { + read: false, + }, + }), + ).resolves.toEqual(expected) + }) + }) + }) + + beforeEach(async () => { + postAuthor = await instance.create('User', { + id: 'postAuthor', + name: 'Mrs Post', + slug: 'mrs-post', + email: 'post-author@example.org', + password: '1234', + }) + }) + + describe('mentions me in a post', () => { + beforeEach(async () => { + title = 'Mentioning Al Capone' + postContent = + 'Hey @al-capone how do you do?' + }) + + it('sends me a notification', async () => { + await createPostAction() + const expectedContent = + '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, + }, + ], + }, + }, + }) + const { query } = createTestClient(server) + await expect( + query({ + query: notificationQuery, + variables: { + read: false, + }, + }), + ).resolves.toEqual(expected) + }) + + describe('many times', () => { + const updatePostAction = async () => { + const updatedContent = ` + One more mention to + + @al-capone + + and again: + + @al-capone + + and again + + @al-capone + + ` + authenticatedUser = await postAuthor.toJson() + await mutate({ + mutation: updatePostMutation, + variables: { + id: 'p47', + title, + postContent: updatedContent, + categoryIds, + }, + }) + authenticatedUser = await notifiedUser.toJson() + } + + it('creates exactly one more notification', async () => { + 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, + }, + { + read: false, + reason: 'mentioned_in_post', + post: { + content: expectedContent, + }, + comment: null, + }, + ], + }, + }, + }) + await expect( + query({ + query: notificationQuery, + variables: { + read: false, + }, + }), + ).resolves.toEqual(expected) + }) + }) + + describe('but the author of the post blocked me', () => { + beforeEach(async () => { + await postAuthor.relateTo(notifiedUser, 'blocked') + }) + + it('sends no notification', async () => { + await createPostAction() + const expected = expect.objectContaining({ + data: { + currentUser: { + notifications: [], + }, + }, + }) + const { query } = createTestClient(server) + await expect( + query({ + query: notificationQuery, + variables: { + read: false, + }, + }), + ).resolves.toEqual(expected) + }) + }) + }) + + describe('mentions me in a comment', () => { + beforeEach(async () => { + title = 'Post where I get mentioned in a comment' + postContent = 'Content of post where I get mentioned in a comment.' + }) + + describe('I am not blocked at all', () => { + beforeEach(async () => { + commentContent = + 'One mention about me with @al-capone.' + commentAuthor = await instance.create('User', { + id: 'commentAuthor', + name: 'Mrs Comment', + slug: 'mrs-comment', + email: 'comment-author@example.org', + password: '1234', + }) + }) + + it('sends a notification', async () => { + await createCommentOnPostAction() + const expected = expect.objectContaining({ + data: { + currentUser: { + notifications: [ + { + read: false, + reason: 'mentioned_in_comment', + post: null, + comment: { + content: commentContent, + }, + }, + ], + }, + }, + }) + const { query } = createTestClient(server) + await expect( + query({ + query: notificationQuery, + variables: { + read: false, + }, + }), + ).resolves.toEqual(expected) + }) + }) + + describe('but the author of the post blocked me', () => { + beforeEach(async () => { + await postAuthor.relateTo(notifiedUser, 'blocked') + commentContent = + 'One mention about me with @al-capone.' + commentAuthor = await instance.create('User', { + id: 'commentAuthor', + name: 'Mrs Comment', + slug: 'mrs-comment', + email: 'comment-author@example.org', + password: '1234', + }) + }) + + it('sends no notification', async () => { + await createCommentOnPostAction() + const expected = expect.objectContaining({ + data: { + currentUser: { + notifications: [], + }, + }, + }) + const { query } = createTestClient(server) + await expect( + query({ + query: notificationQuery, + variables: { + read: false, + }, + }), + ).resolves.toEqual(expected) + }) + }) + }) + }) + }) +}) diff --git a/backend/src/middleware/sentryMiddleware.js b/backend/src/middleware/sentryMiddleware.js index b1130ad37..da8ef32d0 100644 --- a/backend/src/middleware/sentryMiddleware.js +++ b/backend/src/middleware/sentryMiddleware.js @@ -14,13 +14,16 @@ if (sentryConfigs.SENTRY_DSN_BACKEND) { }, withScope: (scope, error, context) => { scope.setUser({ - id: context.user.id, + id: context.user && context.user.id, }) scope.setExtra('body', context.req.body) scope.setExtra('origin', context.req.headers.origin) scope.setExtra('user-agent', context.req.headers['user-agent']) }, }) +} else { + // eslint-disable-next-line no-console + if (process.env.NODE_ENV !== 'test') console.log('Warning: Sentry middleware inactive.') } export default sentryMiddleware diff --git a/backend/src/middleware/validation/validationMiddleware.js b/backend/src/middleware/validation/validationMiddleware.js index 6094911b1..134c85c0c 100644 --- a/backend/src/middleware/validation/validationMiddleware.js +++ b/backend/src/middleware/validation/validationMiddleware.js @@ -2,6 +2,8 @@ import { UserInputError } from 'apollo-server' const COMMENT_MIN_LENGTH = 1 const NO_POST_ERR_MESSAGE = 'Comment cannot be created without a post!' +const NO_CATEGORIES_ERR_MESSAGE = + 'You cannot save a post without at least one category or more than three' const validateCommentCreation = async (resolve, root, args, context, info) => { const content = args.content.replace(/<(?:.|\n)*?>/gm, '').trim() @@ -19,6 +21,7 @@ const validateCommentCreation = async (resolve, root, args, context, info) => { postId, }, ) + session.close() const [post] = postQueryRes.records.map(record => { return record.get('post') }) @@ -43,9 +46,33 @@ const validateUpdateComment = async (resolve, root, args, context, info) => { const validatePost = async (resolve, root, args, context, info) => { const { categoryIds } = args if (!Array.isArray(categoryIds) || !categoryIds.length || categoryIds.length > 3) { - throw new UserInputError( - 'You cannot save a post without at least one category or more than three', - ) + throw new UserInputError(NO_CATEGORIES_ERR_MESSAGE) + } + return resolve(root, args, context, info) +} + +const validateUpdatePost = async (resolve, root, args, context, info) => { + const { id, categoryIds } = args + const session = context.driver.session() + const categoryQueryRes = await session.run( + ` + MATCH (post:Post {id: $id})-[:CATEGORIZED]->(category:Category) + RETURN category`, + { id }, + ) + session.close() + const [category] = categoryQueryRes.records.map(record => { + return record.get('category') + }) + + if (category) { + if (categoryIds && categoryIds.length > 3) { + throw new UserInputError(NO_CATEGORIES_ERR_MESSAGE) + } + } else { + if (!Array.isArray(categoryIds) || !categoryIds.length || categoryIds.length > 3) { + throw new UserInputError(NO_CATEGORIES_ERR_MESSAGE) + } } return resolve(root, args, context, info) } @@ -55,6 +82,6 @@ export default { CreateComment: validateCommentCreation, UpdateComment: validateUpdateComment, CreatePost: validatePost, - UpdatePost: validatePost, + UpdatePost: validateUpdatePost, }, } diff --git a/backend/src/models/Notification.js b/backend/src/models/Notification.js index b8690b8c1..b54a99574 100644 --- a/backend/src/models/Notification.js +++ b/backend/src/models/Notification.js @@ -1,9 +1,26 @@ import uuid from 'uuid/v4' module.exports = { - id: { type: 'uuid', primary: true, default: uuid }, - createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, - read: { type: 'boolean', default: false }, + 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', diff --git a/backend/src/schema/resolvers/notifications.spec.js b/backend/src/schema/resolvers/notifications.spec.js index 6b173514f..3ca7727e4 100644 --- a/backend/src/schema/resolvers/notifications.spec.js +++ b/backend/src/schema/resolvers/notifications.spec.js @@ -69,23 +69,29 @@ describe('currentUser notifications', () => { 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) @@ -287,9 +293,11 @@ describe('UpdateNotification', () => { 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) diff --git a/backend/src/schema/resolvers/posts.js b/backend/src/schema/resolvers/posts.js index 5bb0c4f81..46d7c414f 100644 --- a/backend/src/schema/resolvers/posts.js +++ b/backend/src/schema/resolvers/posts.js @@ -75,22 +75,28 @@ export default { delete params.categoryIds params = await fileUpload(params, { file: 'imageUpload', url: 'image' }) const session = context.driver.session() - const cypherDeletePreviousRelations = ` - MATCH (post:Post { id: $params.id })-[previousRelations:CATEGORIZED]->(category:Category) - DELETE previousRelations - RETURN post, category + + let updatePostCypher = `MATCH (post:Post {id: $params.id}) + SET post = $params ` - await session.run(cypherDeletePreviousRelations, { params }) + if (categoryIds && categoryIds.length) { + const cypherDeletePreviousRelations = ` + MATCH (post:Post { id: $params.id })-[previousRelations:CATEGORIZED]->(category:Category) + DELETE previousRelations + RETURN post, category + ` - const updatePostCypher = `MATCH (post:Post {id: $params.id}) - SET post = $params - WITH post + await session.run(cypherDeletePreviousRelations, { params }) + + updatePostCypher += `WITH post UNWIND $categoryIds AS categoryId MATCH (category:Category {id: categoryId}) MERGE (post)-[:CATEGORIZED]->(category) - RETURN post` + ` + } + updatePostCypher += `RETURN post` const updatePostVariables = { categoryIds, params } const transactionRes = await session.run(updatePostCypher, updatePostVariables) diff --git a/backend/src/schema/resolvers/posts.spec.js b/backend/src/schema/resolvers/posts.spec.js index 44618ecdc..62507af0e 100644 --- a/backend/src/schema/resolvers/posts.spec.js +++ b/backend/src/schema/resolvers/posts.spec.js @@ -13,6 +13,7 @@ let client let userParams let authorParams +const postId = 'p3589' const postTitle = 'I am a title' const postContent = 'Some content' const oldTitle = 'Old title' @@ -96,7 +97,7 @@ beforeEach(async () => { }), ]) createPostVariables = { - id: 'p3589', + id: postId, title: postTitle, content: postContent, categoryIds, @@ -218,7 +219,7 @@ describe('CreatePost', () => { Post: [ { title: postTitle, - id: 'p3589', + id: postId, categories: expect.arrayContaining(categoryIdsArray), }, ], @@ -246,17 +247,16 @@ describe('UpdatePost', () => { await asAuthor.create('User', authorParams) await asAuthor.authenticateAs(authorParams) await asAuthor.create('Post', { - id: 'p1', + id: postId, title: oldTitle, content: oldContent, categoryIds, }) updatePostVariables = { - id: 'p1', + id: postId, title: newTitle, content: newContent, - categoryIds: null, } }) @@ -291,55 +291,96 @@ describe('UpdatePost', () => { }) it('updates a post', async () => { - updatePostVariables.categoryIds = ['cat9'] - const expected = { UpdatePost: { id: 'p1', content: newContent } } + updatePostVariables.categoryIds = ['cat27'] + const expected = { UpdatePost: { id: postId, content: newContent } } await expect(client.request(updatePostMutation, updatePostVariables)).resolves.toEqual( expected, ) }) describe('categories', () => { - beforeEach(async () => { - await client.request(createPostMutation, createPostVariables) - updatePostVariables = { - id: 'p3589', - title: newTitle, - content: newContent, - categoryIds: ['cat27'], - } + it('allows a user to update other attributes without passing in categoryIds explicitly', async () => { + const expected = { UpdatePost: { id: postId, content: newContent } } + await expect(client.request(updatePostMutation, updatePostVariables)).resolves.toEqual( + expected, + ) }) it('allows a user to update the categories of a post', async () => { + updatePostVariables.categoryIds = ['cat27'] await client.request(updatePostMutation, updatePostVariables) const expected = [{ id: 'cat27' }] const postQueryWithCategoriesVariables = { - id: 'p3589', + id: postId, } await expect( client.request(postQueryWithCategories, postQueryWithCategoriesVariables), ).resolves.toEqual({ Post: [{ categories: expect.arrayContaining(expected) }] }) }) - it('throws an error if categoryIds is not an array', async () => { - updatePostVariables.categoryIds = null - await expect(client.request(updatePostMutation, updatePostVariables)).rejects.toThrow( - postSaveError, - ) - }) - - it('requires at least one category for successful update', async () => { - updatePostVariables.categoryIds = [] - await expect(client.request(updatePostMutation, updatePostVariables)).rejects.toThrow( - postSaveError, - ) - }) - it('allows a maximum of three category for a successful update', async () => { updatePostVariables.categoryIds = ['cat9', 'cat27', 'cat15', 'cat4'] await expect(client.request(updatePostMutation, updatePostVariables)).rejects.toThrow( postSaveError, ) }) + + describe('post created without categories somehow', () => { + let ownerNode, owner, postMutationAction + beforeEach(async () => { + const postSomehowCreated = await instance.create('Post', { + id: 'how-was-this-created', + title: postTitle, + content: postContent, + }) + ownerNode = await instance.create('User', { + id: 'author-of-post-without-category', + name: 'Hacker', + slug: 'hacker', + email: 'hacker@example.org', + password: '1234', + }) + owner = await ownerNode.toJson() + await postSomehowCreated.relateTo(ownerNode, 'author') + postMutationAction = async (user, mutation, variables) => { + const { server } = createServer({ + context: () => { + return { + user, + neode: instance, + driver, + } + }, + }) + const { mutate } = createTestClient(server) + + return mutate({ + mutation, + variables, + }) + } + updatePostVariables.id = 'how-was-this-created' + }) + + it('throws an error if categoryIds is not an array', async () => { + const mustAddCategoryToPost = await postMutationAction( + owner, + updatePostMutation, + updatePostVariables, + ) + expect(mustAddCategoryToPost.errors[0]).toHaveProperty('message', postSaveError) + }) + + it('requires at least one category for successful update', async () => { + updatePostVariables.categoryIds = [] + const mustAddCategoryToPost = await postMutationAction( + owner, + updatePostMutation, + updatePostVariables, + ) + expect(mustAddCategoryToPost.errors[0]).toHaveProperty('message', postSaveError) + }) + }) }) }) }) @@ -355,7 +396,7 @@ describe('DeletePost', () => { ` const variables = { - id: 'p1', + id: postId, } beforeEach(async () => { @@ -363,7 +404,7 @@ describe('DeletePost', () => { await asAuthor.create('User', authorParams) await asAuthor.authenticateAs(authorParams) await asAuthor.create('Post', { - id: 'p1', + id: postId, content: 'To be deleted', categoryIds, }) @@ -396,7 +437,7 @@ describe('DeletePost', () => { }) it('deletes a post', async () => { - const expected = { DeletePost: { id: 'p1', content: 'To be deleted' } } + const expected = { DeletePost: { id: postId, content: 'To be deleted' } } await expect(client.request(mutation, variables)).resolves.toEqual(expected) }) }) diff --git a/backend/src/schema/types/enum/ReasonNotification.gql b/backend/src/schema/types/enum/ReasonNotification.gql new file mode 100644 index 000000000..a66c446be --- /dev/null +++ b/backend/src/schema/types/enum/ReasonNotification.gql @@ -0,0 +1,5 @@ +enum ReasonNotification { + mentioned_in_post + mentioned_in_comment + comment_on_post +} \ No newline at end of file diff --git a/backend/src/schema/types/type/Notification.gql b/backend/src/schema/types/type/Notification.gql index 0f94c2301..a3543445f 100644 --- a/backend/src/schema/types/type/Notification.gql +++ b/backend/src/schema/types/type/Notification.gql @@ -1,8 +1,9 @@ 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") - createdAt: String } diff --git a/backend/src/seed/factories/index.js b/backend/src/seed/factories/index.js index df3886a6c..56518bd06 100644 --- a/backend/src/seed/factories/index.js +++ b/backend/src/seed/factories/index.js @@ -62,15 +62,26 @@ export default function Factory(options = {}) { lastResponse: null, neodeInstance, async authenticateAs({ email, password }) { - const headers = await authenticatedHeaders({ email, password }, seedServerHost) + const headers = await authenticatedHeaders( + { + email, + password, + }, + seedServerHost, + ) this.lastResponse = headers - this.graphQLClient = new GraphQLClient(seedServerHost, { headers }) + this.graphQLClient = new GraphQLClient(seedServerHost, { + headers, + }) return this }, async create(node, args = {}) { const { factory, mutation, variables } = this.factories[node](args) if (factory) { - this.lastResponse = await factory({ args, neodeInstance }) + this.lastResponse = await factory({ + args, + neodeInstance, + }) return this.lastResponse } else { this.lastResponse = await this.graphQLClient.request(mutation, variables) @@ -121,11 +132,15 @@ export default function Factory(options = {}) { }, async invite({ email }) { const mutation = ` mutation($email: String!) { invite( email: $email) } ` - this.lastResponse = await this.graphQLClient.request(mutation, { email }) + this.lastResponse = await this.graphQLClient.request(mutation, { + email, + }) return this }, async cleanDatabase() { - this.lastResponse = await cleanDatabase({ driver: this.neo4jDriver }) + this.lastResponse = await cleanDatabase({ + driver: this.neo4jDriver, + }) return this }, async emote({ to, data }) { diff --git a/backend/yarn.lock b/backend/yarn.lock index 7acb1fc0b..9d1abf176 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -1675,10 +1675,10 @@ apollo-server-caching@0.5.0: dependencies: lru-cache "^5.0.0" -apollo-server-core@2.8.2: - version "2.8.2" - resolved "https://registry.yarnpkg.com/apollo-server-core/-/apollo-server-core-2.8.2.tgz#d7e5a94c43457dd5c5a171c79b1c554b418581d4" - integrity sha512-ePMy1Ci5PflvM9XUWdnF2C+B6kZF2mhmsoV+SUN7O2jWFb5cW2XvWd4Pppov6reusqkz4VlABgZDfjr+Ck09+g== +apollo-server-core@2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/apollo-server-core/-/apollo-server-core-2.9.0.tgz#5db251093ee121a5f4d90a24d51aa4c21e421243" + integrity sha512-IvKIgqOqEEB8nszlpHWzlhAu4376So2PgNhFP6UrlfNTllt/WDti5YMOHnVimPWIDHmLPKFan0+wfzpsoRCRdg== dependencies: "@apollographql/apollo-tools" "^0.4.0" "@apollographql/graphql-playground-html" "1.6.24" @@ -1694,7 +1694,7 @@ apollo-server-core@2.8.2: apollo-server-types "0.2.1" apollo-tracing "0.8.1" fast-json-stable-stringify "^2.0.0" - graphql-extensions "0.9.2" + graphql-extensions "0.10.0" graphql-tag "^2.9.2" graphql-tools "^4.0.0" graphql-upload "^8.0.2" @@ -1715,10 +1715,10 @@ apollo-server-errors@2.3.1: resolved "https://registry.yarnpkg.com/apollo-server-errors/-/apollo-server-errors-2.3.1.tgz#033cf331463ebb99a563f8354180b41ac6714eb6" integrity sha512-errZvnh0vUQChecT7M4A/h94dnBSRL213dNxpM5ueMypaLYgnp4hiCTWIEaooo9E4yMGd1qA6WaNbLDG2+bjcg== -apollo-server-express@2.8.2, apollo-server-express@^2.8.1: - version "2.8.2" - resolved "https://registry.yarnpkg.com/apollo-server-express/-/apollo-server-express-2.8.2.tgz#cd1c6994cf5adceea057a088aff52f289bb36377" - integrity sha512-eA7IupNbx3PjIW4E0uMjQU9WvxcHznzgdFWRxJ4RqDiIwrrwROb7dgmPm3TJaatU/etjGq482pdfJIlMDNYPeA== +apollo-server-express@2.9.0, apollo-server-express@^2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/apollo-server-express/-/apollo-server-express-2.9.0.tgz#9d2a2d9823422ef26bca15931669d3153dc8a08b" + integrity sha512-+057V6Ui1BX69jUlV6YDQ7Xw9CCBfowN/GauvyF09KnsjYUJ+cB1xf4mkj/HAjaz4ReXQaALJNr2qPYPXS4R6w== dependencies: "@apollographql/graphql-playground-html" "1.6.24" "@types/accepts" "^1.3.5" @@ -1726,12 +1726,13 @@ apollo-server-express@2.8.2, apollo-server-express@^2.8.1: "@types/cors" "^2.8.4" "@types/express" "4.17.1" accepts "^1.3.5" - apollo-server-core "2.8.2" + apollo-server-core "2.9.0" apollo-server-types "0.2.1" body-parser "^1.18.3" cors "^2.8.4" graphql-subscriptions "^1.0.0" graphql-tools "^4.0.0" + parseurl "^1.3.2" subscriptions-transport-ws "^0.9.16" type-is "^1.6.16" @@ -1742,12 +1743,12 @@ apollo-server-plugin-base@0.6.1: dependencies: apollo-server-types "0.2.1" -apollo-server-testing@~2.8.2: - version "2.8.2" - resolved "https://registry.yarnpkg.com/apollo-server-testing/-/apollo-server-testing-2.8.2.tgz#8faa8f1778fa4893f5bf705f7cea84a69477aa3f" - integrity sha512-ccp1DpmjdmLT98ww4NtSiDPbeIPlVZJ5Iy408ToyhAGwNXRHk5f8Czf+JAgSayvgt4cxCm1fzxnVe1OjO8oIvA== +apollo-server-testing@~2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/apollo-server-testing/-/apollo-server-testing-2.9.0.tgz#fb5276e0761992ed493d84e998eaa4f696914519" + integrity sha512-N6c+wx5MaDZ0mWPzA11nKkkJjX+E3ubATY3G5ejprUsN8BiHENyEQ0EZh+dO0yL9+q/mUHix3p7Utax9odxBcw== dependencies: - apollo-server-core "2.8.2" + apollo-server-core "2.9.0" apollo-server-types@0.2.1: version "0.2.1" @@ -1758,13 +1759,13 @@ apollo-server-types@0.2.1: apollo-server-caching "0.5.0" apollo-server-env "2.4.1" -apollo-server@~2.8.2: - version "2.8.2" - resolved "https://registry.yarnpkg.com/apollo-server/-/apollo-server-2.8.2.tgz#8dd9edef656f7466402be8d7715e788d73d2c50e" - integrity sha512-7mZVsM+p8mf0cA3pTiMuEw8uYilQjZErKx092XNYRzAjoDdGddIC3GvUuhxMkmqvD2YhrWRNRL3QlxHZKnYXQw== +apollo-server@~2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/apollo-server/-/apollo-server-2.9.0.tgz#32685712215d420ff5f3298b3b34e972e21ec1c6" + integrity sha512-KouRjMWn8pnR4KvVsFXT1GZYzH53J0+v9KwnLUKrLNo2G4KiZu5KhP+tEkF7uTlpHzdPMQAIbwjdXKzOH/r6ew== dependencies: - apollo-server-core "2.8.2" - apollo-server-express "2.8.2" + apollo-server-core "2.9.0" + apollo-server-express "2.9.0" express "^4.0.0" graphql-subscriptions "^1.0.0" graphql-tools "^4.0.0" @@ -1976,17 +1977,17 @@ babel-core@~7.0.0-0: resolved "https://registry.yarnpkg.com/babel-core/-/babel-core-7.0.0-bridge.0.tgz#95a492ddd90f9b4e9a4a1da14eb335b87b634ece" integrity sha512-poPX9mZH/5CSanm50Q+1toVci6pv5KSRv/5TWCwtzQS5XEwn40BcCrgIeMFWP9CKKIniKXNxoIOnOq4VVlGXhg== -babel-eslint@~10.0.2: - version "10.0.2" - resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-10.0.2.tgz#182d5ac204579ff0881684b040560fdcc1558456" - integrity sha512-UdsurWPtgiPgpJ06ryUnuaSXC2s0WoSZnQmEpbAH65XZSdwowgN5MvyP7e88nW07FYXv72erVtpBkxyDVKhH1Q== +babel-eslint@~10.0.3: + version "10.0.3" + resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-10.0.3.tgz#81a2c669be0f205e19462fed2482d33e4687a88a" + integrity sha512-z3U7eMY6r/3f3/JB9mTsLjyxrv0Yb1zb8PCWCLpguxfCzBIZUwy23R1t/XKewP+8mEN2Ck8Dtr4q20z6ce6SoA== dependencies: "@babel/code-frame" "^7.0.0" "@babel/parser" "^7.0.0" "@babel/traverse" "^7.0.0" "@babel/types" "^7.0.0" - eslint-scope "3.7.1" eslint-visitor-keys "^1.0.0" + resolve "^1.12.0" babel-jest@^24.9.0, babel-jest@~24.9.0: version "24.9.0" @@ -2833,10 +2834,10 @@ data-urls@^1.0.0: whatwg-mimetype "^2.2.0" whatwg-url "^7.0.0" -date-fns@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.0.0.tgz#52f05c6ae1fe0e395670082c72b690ab781682d0" - integrity sha512-nGZDA64Ktq5uTWV4LEH3qX+foV4AguT5qxwRlJDzJtf57d4xLNwtwrfb7SzKCoikoae8Bvxf0zdaEG/xWssp/w== +date-fns@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.0.1.tgz#c5f30e31d3294918e6b6a82753a4e719120e203d" + integrity sha512-C14oTzTZy8DH1Eq8N78owrCWvf3+cnJw88BTK/N3DYWVxDJuJzPaNdplzYxDYuuXXGvqBcO4Vy5SOrwAooXSWw== debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8, debug@^2.6.9: version "2.6.9" @@ -3284,10 +3285,10 @@ eslint-config-prettier@~6.1.0: dependencies: get-stdin "^6.0.0" -eslint-config-standard@~14.0.0: - version "14.0.0" - resolved "https://registry.yarnpkg.com/eslint-config-standard/-/eslint-config-standard-14.0.0.tgz#1de7bf5af37542dc6eef879ab7eb5e5e0f830747" - integrity sha512-bV6e2LFvJEetrLjVAy4KWPOUsIhPWr040c649MigTPR6yUtaGuOt6CEAyNeez2lRiC+2+vjGWa02byjs25EB3A== +eslint-config-standard@~14.0.1: + version "14.0.1" + resolved "https://registry.yarnpkg.com/eslint-config-standard/-/eslint-config-standard-14.0.1.tgz#375c3636fb4bd453cb95321d873de12e4eef790b" + integrity sha512-1RWsAKTDTZgA8bIM6PSC9aTGDAUlKqNkYNJlTZ5xYD/HYkIM6GlcefFvgcJ8xi0SWG5203rttKYX28zW+rKNOg== eslint-import-resolver-node@^0.3.2: version "0.3.2" @@ -3366,14 +3367,6 @@ eslint-plugin-standard@~4.0.1: resolved "https://registry.yarnpkg.com/eslint-plugin-standard/-/eslint-plugin-standard-4.0.1.tgz#ff0519f7ffaff114f76d1bd7c3996eef0f6e20b4" integrity sha512-v/KBnfyaOMPmZc/dmc6ozOdWqekGp7bBGq4jLAecEfPGmfKiWS4sA8sC0LqiV9w5qmXAtXVn4M3p1jSyhY85SQ== -eslint-scope@3.7.1: - version "3.7.1" - resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-3.7.1.tgz#3d63c3edfda02e06e01a452ad88caacc7cdcb6e8" - integrity sha1-PWPD7f2gLgbgGkUq2IyqzHzctug= - dependencies: - esrecurse "^4.1.0" - estraverse "^4.1.1" - eslint-scope@^4.0.0: version "4.0.3" resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-4.0.3.tgz#ca03833310f6889a3264781aa82e63eb9cfe7848" @@ -4079,6 +4072,15 @@ graphql-custom-directives@~0.2.14: moment "^2.22.2" numeral "^2.0.6" +graphql-extensions@0.10.0: + version "0.10.0" + resolved "https://registry.yarnpkg.com/graphql-extensions/-/graphql-extensions-0.10.0.tgz#ceafc42e16554930b0dc90f64d5727ee2a9e9cf9" + integrity sha512-qz9Ev0NgsRxdTYqYSCpYwBWS9r1imm+vCBt3PmHzqZlE7SEpUPGddn9oKcLRB/P8uXT6dsr60hDmDHukIxiVOw== + dependencies: + "@apollographql/apollo-tools" "^0.4.0" + apollo-server-env "2.4.1" + apollo-server-types "0.2.1" + graphql-extensions@0.8.1: version "0.8.1" resolved "https://registry.yarnpkg.com/graphql-extensions/-/graphql-extensions-0.8.1.tgz#f5f1fed5fe49620c4e70c5d08bdbd0039e91c402" @@ -4097,15 +4099,6 @@ graphql-extensions@0.9.1: apollo-server-env "2.4.1" apollo-server-types "0.2.1" -graphql-extensions@0.9.2: - version "0.9.2" - resolved "https://registry.yarnpkg.com/graphql-extensions/-/graphql-extensions-0.9.2.tgz#4bdd81d5d9102e20b7ad3d790b16624fb97c7ab7" - integrity sha512-7yP6Mr6cDBadrM5dl4CIlp1wTMyPPpL64FtcsOABmaOdf9sOb/X7E3wJSi80UsB8sw0CY2V/HCeU3CIXParQjw== - dependencies: - "@apollographql/apollo-tools" "^0.4.0" - apollo-server-env "2.4.1" - apollo-server-types "0.2.1" - graphql-import@0.7.1: version "0.7.1" resolved "https://registry.yarnpkg.com/graphql-import/-/graphql-import-0.7.1.tgz#4add8d91a5f752d764b0a4a7a461fcd93136f223" @@ -4138,10 +4131,10 @@ graphql-request@~1.8.2: dependencies: cross-fetch "2.2.2" -graphql-shield@~6.0.5: - version "6.0.5" - resolved "https://registry.yarnpkg.com/graphql-shield/-/graphql-shield-6.0.5.tgz#e55c7eb1984c684863c897746044bb216a285b41" - integrity sha512-+uRVptAv6RvaM5GVqZjEsanlZ2OTmUgDu+x/UW/qD6+Zb+I6nTGZ7nII8LTFHuUdXrCICfxesyMODhQYXcEZWQ== +graphql-shield@~6.0.6: + version "6.0.6" + resolved "https://registry.yarnpkg.com/graphql-shield/-/graphql-shield-6.0.6.tgz#ef8c53f1dd972c2d1828ffd45ce9b1f877576534" + integrity sha512-rwhno5ZvEBbedQ8mEOi/Lk71J5CrpQCOcyuDIO+qb1hqm7cvWLtLVyZFrhVp7vN/vULV9oX30j0clC/1d05LpQ== dependencies: "@types/yup" "0.26.23" lightercollective "^0.3.0" @@ -4200,10 +4193,10 @@ graphql-upload@^8.0.2: http-errors "^1.7.2" object-path "^0.11.4" -graphql@^14.2.1, graphql@^14.5.0: - version "14.5.0" - resolved "https://registry.yarnpkg.com/graphql/-/graphql-14.5.0.tgz#4801e6460942c9c591944617f6dd224a9e531520" - integrity sha512-wnGcTD181L2xPnIwHHjx/moV4ulxA2Kms9zcUY+B/SIrK+2N+iOC6WNgnR2zVTmg1Z8P+CZq5KXibTnatg3WUw== +graphql@^14.2.1, graphql@^14.5.3: + version "14.5.3" + resolved "https://registry.yarnpkg.com/graphql/-/graphql-14.5.3.tgz#e025851cc413e153220f4edbbb25d49f55104fa0" + integrity sha512-W8A8nt9BsMg0ZK2qA3DJIVU6muWhxZRYLTmc+5XGwzWzVdUdPVlAAg5hTBjiTISEnzsKL/onasu6vl3kgGTbYg== dependencies: iterall "^1.2.2" @@ -6773,7 +6766,7 @@ parse5@^3.0.1: dependencies: "@types/node" "*" -parseurl@~1.3.3: +parseurl@^1.3.2, parseurl@~1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== @@ -7404,7 +7397,7 @@ resolve@1.1.7: resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b" integrity sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs= -resolve@^1.10.0, resolve@^1.10.1, resolve@^1.11.0, resolve@^1.3.2, resolve@^1.3.3, resolve@^1.5.0: +resolve@^1.10.0, resolve@^1.10.1, resolve@^1.11.0, resolve@^1.12.0, resolve@^1.3.2, resolve@^1.3.3, resolve@^1.5.0: version "1.12.0" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.12.0.tgz#3fc644a35c84a48554609ff26ec52b66fa577df6" integrity sha512-B/dOmuoAik5bKcD6s6nXDCjzUKnaDvdkRyAk6rsmsKLipWj4797iothd7jmmUhWTfinVMU+wc56rYKsit2Qy4w== diff --git a/deployment/human-connection/deployment-neo4j.yaml b/deployment/human-connection/deployment-neo4j.yaml index 2fcba9061..297f4b551 100644 --- a/deployment/human-connection/deployment-neo4j.yaml +++ b/deployment/human-connection/deployment-neo4j.yaml @@ -25,13 +25,20 @@ - name: nitro-neo4j image: humanconnection/neo4j:latest imagePullPolicy: Always + resources: + requests: + memory: "1G" + limits: + memory: "2G" env: - name: NEO4J_apoc_import_file_enabled value: "true" - name: NEO4J_dbms_memory_pagecache_size - value: 1G + value: "490M" - name: NEO4J_dbms_memory_heap_max__size - value: 1G + value: "500M" + - name: NEO4J_dbms_memory_heap_initial__size + value: "500M" - name: NEO4J_dbms_security_procedures_unrestricted value: "algo.*,apoc.*" envFrom: diff --git a/neo4j/Dockerfile b/neo4j/Dockerfile index 56175e423..1cfa04507 100644 --- a/neo4j/Dockerfile +++ b/neo4j/Dockerfile @@ -5,7 +5,6 @@ ARG BUILD_COMMIT ENV BUILD_COMMIT=$BUILD_COMMIT COPY db_setup.sh /usr/local/bin/db_setup -COPY entrypoint.sh /docker-entrypoint-wrapper.sh -RUN apt-get update && apt-get -y install procps wget + +RUN apt-get update && apt-get -y install wget htop RUN wget https://github.com/neo4j-contrib/neo4j-apoc-procedures/releases/download/3.5.0.4/apoc-3.5.0.4-all.jar -P plugins/ -ENTRYPOINT ["/docker-entrypoint-wrapper.sh"] diff --git a/neo4j/README.md b/neo4j/README.md index 78c4bc62e..fe8825734 100644 --- a/neo4j/README.md +++ b/neo4j/README.md @@ -18,6 +18,16 @@ docker-compose up You can access Neo4J through [http://localhost:7474/](http://localhost:7474/) for an interactive cypher shell and a visualization of the graph. +### Database Indices and Constraints + +Database indices and constraints need to be created when the database is +running. So start the container with the command above and run: + +```bash +docker-compose exec neo4j db_setup +``` + + ## Installation without Docker Install the community edition of [Neo4j](https://neo4j.com/) along with the plugin @@ -35,6 +45,20 @@ Then make sure to allow Apoc procedures by adding the following line to your Neo ``` dbms.security.procedures.unrestricted=apoc.* ``` +### Database Indices and Constraints + +If you have `cypher-shell` available with your local installation of neo4j you +can run: + +```bash +# in folder neo4j/ +$ cp .env.template .env +$ ./db_setup.sh +``` + +Otherwise, if you don't have `cypher-shell` available, copy the cypher +statements [from the `db_setup.sh` script](https://github.com/Human-Connection/Human-Connection/blob/master/neo4j/db_setup.sh) and paste the scripts into your +[database browser frontend](http://localhost:7474). ### Alternatives @@ -50,21 +74,3 @@ in `backend/.env`. Start Neo4J and confirm the database is running at [http://localhost:7474](http://localhost:7474). -## Database Indices and Constraints - -If you are not running our dedicated Neo4J [docker image](https://hub.docker.com/r/humanconnection/neo4j), -which is the case if you setup Neo4J locally without docker, then you have to -setup unique indices and database constraints manually. - -If you have `cypher-shell` available with your local installation of neo4j you -can run: - -```bash -# in folder neo4j/ -$ cp .env.template .env -$ ./db_setup.sh -``` - -Otherwise, if you don't have `cypher-shell` available, copy the cypher -statements [from the `db_setup.sh` script](https://github.com/Human-Connection/Human-Connection/blob/master/neo4j/db_setup.sh) and paste the scripts into your -[database browser frontend](http://localhost:7474). diff --git a/neo4j/entrypoint.sh b/neo4j/entrypoint.sh deleted file mode 100755 index f9c1afbe1..000000000 --- a/neo4j/entrypoint.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/bash - -# credits: https://github.com/javamonkey79 -# https://github.com/neo4j/docker-neo4j/issues/166 - -# turn on bash's job control -set -m - -# Start the primary process and put it in the background -/docker-entrypoint.sh neo4j & - -# Start the helper process -db_setup - -# the my_helper_process might need to know how to wait on the -# primary process to start before it does its work and returns - - -# now we bring the primary process back into the foreground -# and leave it there -fg %1 diff --git a/package.json b/package.json index 799a8e7ce..7569217fb 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "codecov": "^3.5.0", "cross-env": "^5.2.0", "cypress": "^3.4.1", - "cypress-cucumber-preprocessor": "^1.14.1", + "cypress-cucumber-preprocessor": "^1.15.0", "cypress-file-upload": "^3.3.3", "cypress-plugin-retries": "^1.2.2", "dotenv": "^8.1.0", @@ -34,4 +34,4 @@ "npm-run-all": "^4.1.5", "slug": "^1.1.0" } -} \ No newline at end of file +} diff --git a/webapp/components/Comment.spec.js b/webapp/components/Comment.spec.js index 4fdc48bbd..b9be448e4 100644 --- a/webapp/components/Comment.spec.js +++ b/webapp/components/Comment.spec.js @@ -8,7 +8,7 @@ const localVue = createLocalVue() localVue.use(Vuex) localVue.use(Styleguide) -config.stubs['no-ssr'] = '' +config.stubs['client-only'] = '' describe('Comment.vue', () => { let propsData diff --git a/webapp/components/Comment.vue b/webapp/components/Comment.vue index 6d3b05eff..16d717430 100644 --- a/webapp/components/Comment.vue +++ b/webapp/components/Comment.vue @@ -15,7 +15,7 @@ - + - +
diff --git a/webapp/components/CommentList/CommentList.spec.js b/webapp/components/CommentList/CommentList.spec.js index e1090475a..5551227a1 100644 --- a/webapp/components/CommentList/CommentList.spec.js +++ b/webapp/components/CommentList/CommentList.spec.js @@ -14,7 +14,7 @@ localVue.filter('truncate', string => string) config.stubs['v-popover'] = '' config.stubs['nuxt-link'] = '' -config.stubs['no-ssr'] = '' +config.stubs['client-only'] = '' describe('CommentList.vue', () => { let mocks diff --git a/webapp/components/ContributionForm/ContributionForm.spec.js b/webapp/components/ContributionForm/ContributionForm.spec.js index e1f293c77..90ed226ee 100644 --- a/webapp/components/ContributionForm/ContributionForm.spec.js +++ b/webapp/components/ContributionForm/ContributionForm.spec.js @@ -16,7 +16,7 @@ localVue.use(Vuex) localVue.use(Styleguide) localVue.use(Filters) -config.stubs['no-ssr'] = '' +config.stubs['client-only'] = '' config.stubs['nuxt-link'] = '' config.stubs['v-popover'] = '' diff --git a/webapp/components/ContributionForm/ContributionForm.vue b/webapp/components/ContributionForm/ContributionForm.vue index e3daf753d..b54cd4f37 100644 --- a/webapp/components/ContributionForm/ContributionForm.vue +++ b/webapp/components/ContributionForm/ContributionForm.vue @@ -14,7 +14,7 @@ {{ form.title.length }}/{{ formSchema.title.max }} - + {{ form.contentLength }}/{{ contentMax }} - + - + - + diff --git a/webapp/components/EditCommentForm/EditCommentForm.vue b/webapp/components/EditCommentForm/EditCommentForm.vue index e647131b8..0a5cd7d98 100644 --- a/webapp/components/EditCommentForm/EditCommentForm.vue +++ b/webapp/components/EditCommentForm/EditCommentForm.vue @@ -2,7 +2,7 @@ diff --git a/webapp/components/Registration/Signup.vue b/webapp/components/Registration/Signup.vue index 175db1fd6..dcf4f0e88 100644 --- a/webapp/components/Registration/Signup.vue +++ b/webapp/components/Registration/Signup.vue @@ -9,17 +9,21 @@ :schema="formSchema" @submit="handleSubmit" > -

{{ $t('registration.signup.title') }}

+

{{ invitation ? $t('profile.invites.title') : $t('registration.signup.title') }}

- {{ $t('registration.signup.form.description') }} + {{ + invitation + ? $t('profile.invites.description') + : $t('registration.signup.form.description') + }}
@@ -73,11 +75,13 @@ export const SignupByInvitationMutation = gql` } ` export default { + name: 'Signup', components: { SweetalertIcon, }, props: { token: { type: String, default: null }, + invitation: { type: Boolean, default: false }, }, data() { return { diff --git a/webapp/components/User/index.vue b/webapp/components/User/index.vue index 0fefe4eb3..684220f38 100644 --- a/webapp/components/User/index.vue +++ b/webapp/components/User/index.vue @@ -25,9 +25,9 @@
- + - +
diff --git a/webapp/components/notifications/Notification/Notification.spec.js b/webapp/components/notifications/Notification/Notification.spec.js index 8fbc524fb..279500f7f 100644 --- a/webapp/components/notifications/Notification/Notification.spec.js +++ b/webapp/components/notifications/Notification/Notification.spec.js @@ -8,16 +8,17 @@ const localVue = createLocalVue() localVue.use(Styleguide) localVue.use(Filters) -config.stubs['no-ssr'] = '' +config.stubs['client-only'] = '' describe('Notification', () => { let stubs let mocks let propsData + let wrapper beforeEach(() => { propsData = {} mocks = { - $t: jest.fn(), + $t: key => key, } stubs = { NuxtLink: RouterLinkStub, @@ -33,37 +34,159 @@ describe('Notification', () => { }) } - describe('given a notification', () => { + describe('given a notification about a comment on a post', () => { beforeEach(() => { propsData.notification = { - post: { - title: "It's a title", - id: 'post-1', - slug: 'its-a-title', - contentExcerpt: '@jenny-rostock is the best', + reason: 'comment_on_post', + post: null, + comment: { + id: 'comment-1', + contentExcerpt: + '@dagobert-duck is the best on this comment.', + post: { + title: "It's a post title", + id: 'post-1', + slug: 'its-a-title', + contentExcerpt: 'Post content.', + }, }, } }) + it('renders reason', () => { + wrapper = Wrapper() + expect(wrapper.find('.reason-text-for-test').text()).toEqual( + 'notifications.menu.comment_on_post', + ) + }) it('renders title', () => { - expect(Wrapper().text()).toContain("It's a title") + wrapper = Wrapper() + expect(wrapper.text()).toContain("It's a post title") + }) + it('renders the "Comment:"', () => { + wrapper = Wrapper() + expect(wrapper.text()).toContain('Comment:') }) - it('renders the contentExcerpt', () => { - expect(Wrapper().text()).toContain('@jenny-rostock is the best') + wrapper = Wrapper() + expect(wrapper.text()).toContain('@dagobert-duck is the best on this comment.') }) - it('has no class "read"', () => { - expect(Wrapper().classes()).not.toContain('read') + wrapper = Wrapper() + expect(wrapper.classes()).not.toContain('read') }) describe('that is read', () => { beforeEach(() => { propsData.notification.read = true + wrapper = Wrapper() }) it('has class "read"', () => { - expect(Wrapper().classes()).toContain('read') + expect(wrapper.classes()).toContain('read') + }) + }) + }) + + describe('given a notification about a mention in a post', () => { + beforeEach(() => { + propsData.notification = { + reason: 'mentioned_in_post', + 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, + } + }) + + it('renders reason', () => { + wrapper = Wrapper() + expect(wrapper.find('.reason-text-for-test').text()).toEqual( + 'notifications.menu.mentioned_in_post', + ) + }) + it('renders title', () => { + wrapper = Wrapper() + expect(wrapper.text()).toContain("It's a post title") + }) + it('renders the contentExcerpt', () => { + wrapper = Wrapper() + expect(wrapper.text()).toContain('@jenny-rostock is the best on this post.') + }) + it('has no class "read"', () => { + wrapper = Wrapper() + expect(wrapper.classes()).not.toContain('read') + }) + + describe('that is read', () => { + beforeEach(() => { + propsData.notification.read = true + wrapper = Wrapper() + }) + + it('has class "read"', () => { + expect(wrapper.classes()).toContain('read') + }) + }) + }) + + describe('given a notification about a mention in a comment', () => { + beforeEach(() => { + propsData.notification = { + reason: 'mentioned_in_comment', + post: null, + comment: { + id: 'comment-1', + contentExcerpt: + '@dagobert-duck is the best on this comment.', + post: { + title: "It's a post title", + id: 'post-1', + slug: 'its-a-title', + contentExcerpt: 'Post content.', + }, + }, + } + }) + + it('renders reason', () => { + wrapper = Wrapper() + expect(wrapper.find('.reason-text-for-test').text()).toEqual( + 'notifications.menu.mentioned_in_comment', + ) + }) + it('renders title', () => { + wrapper = Wrapper() + expect(wrapper.text()).toContain("It's a post title") + }) + + it('renders the "Comment:"', () => { + wrapper = Wrapper() + expect(wrapper.text()).toContain('Comment:') + }) + + it('renders the contentExcerpt', () => { + wrapper = Wrapper() + expect(wrapper.text()).toContain('@dagobert-duck is the best on this comment.') + }) + + it('has no class "read"', () => { + wrapper = Wrapper() + expect(wrapper.classes()).not.toContain('read') + }) + + describe('that is read', () => { + beforeEach(() => { + propsData.notification.read = true + wrapper = Wrapper() + }) + + it('has class "read"', () => { + expect(wrapper.classes()).toContain('read') }) }) }) diff --git a/webapp/components/notifications/Notification/Notification.vue b/webapp/components/notifications/Notification/Notification.vue index 6aa4a5eeb..193b5f67b 100644 --- a/webapp/components/notifications/Notification/Notification.vue +++ b/webapp/components/notifications/Notification/Notification.vue @@ -1,6 +1,6 @@ diff --git a/webapp/locales/de.json b/webapp/locales/de.json index 298a45f5e..255212e7d 100644 --- a/webapp/locales/de.json +++ b/webapp/locales/de.json @@ -14,7 +14,7 @@ "all": "Alle" }, "general": { - "header": "Filtern nach..." + "header": "Filtern nach …" }, "followers": { "label": "Benutzern, denen ich folge" @@ -96,7 +96,7 @@ } }, "editor": { - "placeholder": "Schreib etwas Inspirierendes...", + "placeholder": "Schreib etwas Inspirierendes …", "mention": { "noUsersFound": "Keine Benutzer gefunden" }, @@ -122,13 +122,19 @@ "followingNobody": "folgt niemandem.", "followedBy": "wird gefolgt von:", "followedByNobody": "wird von niemandem gefolgt.", - "and": "und", - "more": "weitere" + "andMore": "und {number} weitere …" + }, + "invites": { + "title": "Lade jemanden zu Human Connection ein!", + "description": "Für die Einladung trage seine E-Mail-Adresse hier ein.", + "emailPlaceholder": "E-Mail-Adresse für die Einladung" } }, "notifications": { "menu": { - "mentioned": "hat dich in einem {resource} erwähnt" + "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 …" } }, "search": { @@ -271,8 +277,8 @@ }, "invites": { "name": "Benutzer einladen", - "title": "Benutzer als Admin anmelden", - "description": "Dieses Anmeldeformular ist zu sehen sobald die Anmeldung öffentlich zugänglich ist." + "title": "Leute einladen", + "description": "Einladungen sind ein wunderbarer Weg, deine Freund in deinem Netzwerk zu haben …" } }, "post": { @@ -300,7 +306,7 @@ }, "comment": { "content": { - "unavailable-placeholder": "...dieser Kommentar ist nicht mehr verfügbar" + "unavailable-placeholder": "… dieser Kommentar ist nicht mehr verfügbar" }, "menu": { "edit": "Kommentar bearbeiten", diff --git a/webapp/locales/en.json b/webapp/locales/en.json index 6fe26ebcd..a24161288 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -14,7 +14,7 @@ "all": "All" }, "general": { - "header": "Filter by..." + "header": "Filter by …" }, "followers": { "label": "Users I follow" @@ -96,7 +96,7 @@ } }, "editor": { - "placeholder": "Leave your inspirational thoughts...", + "placeholder": "Leave your inspirational thoughts …", "mention": { "noUsersFound": "No users found" }, @@ -122,13 +122,19 @@ "followingNobody": "follows nobody.", "followedBy": "is followed by:", "followedByNobody": "is not followed by anyone.", - "and": "and", - "more": "more" + "andMore": "and {number} more …" + }, + "invites": { + "title": "Invite somebody to Human Connection!", + "description": "Enter thier email address for invitation.", + "emailPlaceholder": "Email to invite" } }, "notifications": { "menu": { - "mentioned": "mentioned you in a {resource}" + "mentioned_in_post": "Mentioned you in a post …", + "mentioned_in_comment": "Mentioned you in a comment …", + "comment_on_post": "Commented on your post …" } }, "search": { @@ -271,8 +277,8 @@ }, "invites": { "name": "Invite users", - "title": "Signup users as admin", - "description": "This registration form will be visible as soon as the registration is open to the public." + "title": "Invite people", + "description": "Invitations are a wonderful way to have your friends in your network …" } }, "post": { @@ -300,7 +306,7 @@ }, "comment": { "content": { - "unavailable-placeholder": "...this comment is not available anymore" + "unavailable-placeholder": "… this comment is not available anymore" }, "menu": { "edit": "Edit Comment", diff --git a/webapp/package.json b/webapp/package.json index be7960fdb..6357e06e5 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -51,7 +51,7 @@ }, "dependencies": { "@human-connection/styleguide": "0.5.19", - "@nuxtjs/apollo": "^4.0.0-rc11", + "@nuxtjs/apollo": "^4.0.0-rc12", "@nuxtjs/axios": "~5.6.0", "@nuxtjs/dotenv": "~1.4.0", "@nuxtjs/sentry": "^3.0.0", @@ -61,9 +61,9 @@ "apollo-client": "~2.6.4", "cookie-universal-nuxt": "~2.0.17", "cross-env": "~5.2.0", - "date-fns": "2.0.0", + "date-fns": "2.0.1", "express": "~4.17.1", - "graphql": "~14.5.0", + "graphql": "~14.5.3", "isemail": "^3.2.0", "jsonwebtoken": "~8.5.1", "linkify-it": "~2.2.0", @@ -95,7 +95,7 @@ "@vue/server-test-utils": "~1.0.0-beta.29", "@vue/test-utils": "~1.0.0-beta.29", "babel-core": "~7.0.0-bridge.0", - "babel-eslint": "~10.0.2", + "babel-eslint": "~10.0.3", "babel-jest": "~24.9.0", "babel-loader": "~8.0.6", "babel-preset-vue": "~2.0.2", @@ -104,7 +104,7 @@ "eslint": "~5.16.0", "eslint-config-prettier": "~6.1.0", "eslint-config-standard": "~12.0.0", - "eslint-loader": "~2.2.1", + "eslint-loader": "~3.0.0", "eslint-plugin-import": "~2.18.2", "eslint-plugin-jest": "~22.15.2", "eslint-plugin-node": "~9.1.0", diff --git a/webapp/pages/admin/index.vue b/webapp/pages/admin/index.vue index ca8ce4df7..f764238e3 100644 --- a/webapp/pages/admin/index.vue +++ b/webapp/pages/admin/index.vue @@ -1,23 +1,23 @@ diff --git a/webapp/pages/admin/invite.vue b/webapp/pages/admin/invite.vue index 36e679112..3f9b4d831 100644 --- a/webapp/pages/admin/invite.vue +++ b/webapp/pages/admin/invite.vue @@ -1,14 +1,11 @@ diff --git a/webapp/pages/index.spec.js b/webapp/pages/index.spec.js index 6dacd6069..3a97e3709 100644 --- a/webapp/pages/index.spec.js +++ b/webapp/pages/index.spec.js @@ -15,7 +15,7 @@ localVue.use(Filters) localVue.use(VTooltip) localVue.use(InfiniteScroll) -config.stubs['no-ssr'] = '' +config.stubs['client-only'] = '' config.stubs['router-link'] = '' config.stubs['nuxt-link'] = '' diff --git a/webapp/pages/index.vue b/webapp/pages/index.vue index 910fa7a2f..afc566940 100644 --- a/webapp/pages/index.vue +++ b/webapp/pages/index.vue @@ -36,7 +36,7 @@ - + - +
diff --git a/webapp/pages/login.vue b/webapp/pages/login.vue index 9a1666361..4228f8319 100644 --- a/webapp/pages/login.vue +++ b/webapp/pages/login.vue @@ -10,9 +10,9 @@ - + + { let wrapper diff --git a/webapp/pages/post/_id/_slug/index.vue b/webapp/pages/post/_id/_slug/index.vue index 88d8a0ce2..1b9d13148 100644 --- a/webapp/pages/post/_id/_slug/index.vue +++ b/webapp/pages/post/_id/_slug/index.vue @@ -8,7 +8,7 @@ - + - + {{ post.title }} diff --git a/webapp/pages/profile/_id/_slug.spec.js b/webapp/pages/profile/_id/_slug.spec.js index 59dfddc8b..16b4776b4 100644 --- a/webapp/pages/profile/_id/_slug.spec.js +++ b/webapp/pages/profile/_id/_slug.spec.js @@ -13,7 +13,7 @@ localVue.use(Filters) localVue.use(InfiniteScroll) localVue.filter('date', d => d) -config.stubs['no-ssr'] = '' +config.stubs['client-only'] = '' config.stubs['v-popover'] = '' config.stubs['nuxt-link'] = '' diff --git a/webapp/pages/profile/_id/_slug.vue b/webapp/pages/profile/_id/_slug.vue index bc712e6e0..d2fe497c7 100644 --- a/webapp/pages/profile/_id/_slug.vue +++ b/webapp/pages/profile/_id/_slug.vue @@ -15,7 +15,7 @@ - + - + {{ userName }} @@ -41,18 +41,18 @@ - + - + - + - + @@ -89,14 +89,17 @@ @@ -116,14 +119,17 @@ @@ -160,33 +166,33 @@
  • - + - +
  • - + - +
  • - + - +
  • @@ -195,16 +201,22 @@
    - - + + + +