diff --git a/backend/package.json b/backend/package.json
index 89fb87edf..1e4c2b8a1 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -61,7 +61,7 @@
"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",
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 57bdabfc9..7774ccc15 100644
--- a/backend/src/middleware/index.js
+++ b/backend/src/middleware/index.js
@@ -12,7 +12,8 @@ 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'
@@ -25,13 +26,16 @@ export default schema => {
validation,
sluggify,
excerpt,
- handleContentData,
+ notifications,
+ hashtags,
xss,
softDelete,
user,
includedFields,
orderBy,
- email: email({ isEnabled: CONFIG.SMTP_HOST && CONFIG.SMTP_PORT }),
+ 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/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/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 2f6f02d8e..9d1abf176 100644
--- a/backend/yarn.lock
+++ b/backend/yarn.lock
@@ -4193,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"
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/package.json b/package.json
index 30f122f08..7569217fb 100644
--- a/package.json
+++ b/package.json
@@ -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/PostCard/index.spec.js b/webapp/components/PostCard/index.spec.js
index 390396383..36b8bccda 100644
--- a/webapp/components/PostCard/index.spec.js
+++ b/webapp/components/PostCard/index.spec.js
@@ -10,7 +10,7 @@ localVue.use(Vuex)
localVue.use(Styleguide)
localVue.use(Filters)
-config.stubs['no-ssr'] = ''
+config.stubs['client-only'] = ''
config.stubs['v-popover'] = ''
describe('PostCard', () => {
diff --git a/webapp/components/PostCard/index.vue b/webapp/components/PostCard/index.vue
index c0cc1a9a6..7c79fe9eb 100644
--- a/webapp/components/PostCard/index.vue
+++ b/webapp/components/PostCard/index.vue
@@ -13,9 +13,9 @@
-
+
-
+
@@ -42,7 +42,7 @@
:icon="category.icon"
/>
-
+
@@ -63,7 +63,7 @@
:is-owner="isAuthor"
/>
-
+
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 @@
-
+
-
- {{ $t('notifications.menu.mentioned', { resource: resourceType }) }}
+
+ {{ $t(`notifications.menu.${notification.reason}`) }}
-
+
string)
-config.stubs['no-ssr'] = ''
+config.stubs['client-only'] = ''
config.stubs['v-popover'] = ''
describe('NotificationList.vue', () => {
diff --git a/webapp/graphql/User.js b/webapp/graphql/User.js
index fb861fb98..80fc6ac84 100644
--- a/webapp/graphql/User.js
+++ b/webapp/graphql/User.js
@@ -78,12 +78,13 @@ export default i18n => {
export const currentUserNotificationsQuery = () => {
return gql`
- {
+ query {
currentUser {
id
notifications(read: false, orderBy: createdAt_desc) {
id
read
+ reason
createdAt
post {
id
diff --git a/webapp/layouts/default.vue b/webapp/layouts/default.vue
index 1094acf21..00b0ec1aa 100644
--- a/webapp/layouts/default.vue
+++ b/webapp/layouts/default.vue
@@ -37,14 +37,14 @@
:width="{ base: '15%', sm: '15%', md: '10%', lg: '10%' }"
:class="{ 'hide-mobile-menu': !toggleMobileMenu }"
>
-
+
-
+
-
+
-
+
-
+
-
-
+
+
-
+
@@ -140,9 +140,9 @@
{{ $t('site.changelog') }}
-
+
-
+
diff --git a/webapp/locales/de.json b/webapp/locales/de.json
index 99689ec44..bb70ed36e 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"
},
@@ -132,7 +132,9 @@
},
"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": {
@@ -304,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 9c9d1cd73..94451fa88 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"
},
@@ -132,7 +132,9 @@
},
"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": {
@@ -304,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 9ed991e62..6357e06e5 100644
--- a/webapp/package.json
+++ b/webapp/package.json
@@ -61,7 +61,7 @@
"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.3",
"isemail": "^3.2.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 @@
-
+
-
+
-
+
-
+
-
+
@@ -29,9 +29,9 @@
size="x-large"
uppercase
>
-
+
-
+
@@ -43,9 +43,9 @@
size="x-large"
uppercase
>
-
+
-
+
@@ -57,9 +57,9 @@
size="x-large"
uppercase
>
-
+
-
+
@@ -71,42 +71,42 @@
size="x-large"
uppercase
>
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
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 b9db7aa37..afc566940 100644
--- a/webapp/pages/index.vue
+++ b/webapp/pages/index.vue
@@ -36,7 +36,7 @@
-
+
-
+
-
+
-
+
'
+config.stubs['client-only'] = ''
describe('PostSlug', () => {
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 b21668548..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,9 +89,9 @@
-
+
-
+
@@ -119,9 +119,9 @@
-
+
-
+
@@ -166,33 +166,33 @@
-
+
-
+
-
+
-
+
-
+
-
+
diff --git a/webapp/storybook/config.js b/webapp/storybook/config.js
index 9908171eb..3d3b1709e 100644
--- a/webapp/storybook/config.js
+++ b/webapp/storybook/config.js
@@ -13,7 +13,7 @@ Vue.component('nuxt-link', {
},
template: 'NuxtLink',
})
-Vue.component('no-ssr', {
+Vue.component('client-only', {
render() {
return this.$slots.default
},
diff --git a/webapp/yarn.lock b/webapp/yarn.lock
index 2fc5f3362..78d019e27 100644
--- a/webapp/yarn.lock
+++ b/webapp/yarn.lock
@@ -5711,10 +5711,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==
date-fns@^1.27.2:
version "1.30.1"