diff --git a/backend/src/db/factories.js b/backend/src/db/factories.js
index 8a6ca380e..1f636d981 100644
--- a/backend/src/db/factories.js
+++ b/backend/src/db/factories.js
@@ -130,6 +130,7 @@ Factory.define('post')
deleted: false,
imageBlurred: false,
imageAspectRatio: 1.333,
+ clickedCount: 0,
})
.attr('pinned', ['pinned'], (pinned) => {
// Convert false to null
diff --git a/backend/src/db/migrations/1614023644903-add-clickedCount-to-posts.js b/backend/src/db/migrations/1614023644903-add-clickedCount-to-posts.js
new file mode 100644
index 000000000..ff95a25df
--- /dev/null
+++ b/backend/src/db/migrations/1614023644903-add-clickedCount-to-posts.js
@@ -0,0 +1,53 @@
+import { getDriver } from '../../db/neo4j'
+
+export const description = `
+This migration adds the clickedCount property to all posts, setting it to 0.
+`
+
+module.exports.up = async function (next) {
+ const driver = getDriver()
+ const session = driver.session()
+ const transaction = session.beginTransaction()
+ try {
+ // Implement your migration here.
+ await transaction.run(`
+ MATCH (p:Post)
+ SET p.clickedCount = 0
+ `)
+ await transaction.commit()
+ next()
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.log(error)
+ await transaction.rollback()
+ // eslint-disable-next-line no-console
+ console.log('rolled back')
+ throw new Error(error)
+ } finally {
+ session.close()
+ }
+}
+
+module.exports.down = async function (next) {
+ const driver = getDriver()
+ const session = driver.session()
+ const transaction = session.beginTransaction()
+ try {
+ // Implement your migration here.
+ await transaction.run(`
+ MATCH (p:Post)
+ REMOVE p.clickedCount
+ `)
+ await transaction.commit()
+ next()
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.log(error)
+ await transaction.rollback()
+ // eslint-disable-next-line no-console
+ console.log('rolled back')
+ throw new Error(error)
+ } finally {
+ session.close()
+ }
+}
diff --git a/backend/src/middleware/index.js b/backend/src/middleware/index.js
index 0ad8cb1ae..592e25a60 100644
--- a/backend/src/middleware/index.js
+++ b/backend/src/middleware/index.js
@@ -15,6 +15,7 @@ import hashtags from './hashtags/hashtagsMiddleware'
import email from './email/emailMiddleware'
import sentry from './sentryMiddleware'
import languages from './languages/languages'
+import userInteractions from './userInteractions'
export default (schema) => {
const middlewares = {
@@ -32,6 +33,7 @@ export default (schema) => {
includedFields,
orderBy,
languages,
+ userInteractions,
}
let order = [
@@ -40,6 +42,7 @@ export default (schema) => {
'xss',
// 'activityPub', disabled temporarily
'validation',
+ 'userInteractions',
'sluggify',
'languages',
'excerpt',
diff --git a/backend/src/middleware/slugifyMiddleware.spec.js b/backend/src/middleware/slugifyMiddleware.spec.js
index 48c4fb651..97503851a 100644
--- a/backend/src/middleware/slugifyMiddleware.spec.js
+++ b/backend/src/middleware/slugifyMiddleware.spec.js
@@ -11,7 +11,8 @@ let variables
const driver = getDriver()
const neode = getNeode()
-beforeAll(() => {
+beforeAll(async () => {
+ await cleanDatabase()
const { server } = createServer({
context: () => {
return {
diff --git a/backend/src/middleware/userInteractions.js b/backend/src/middleware/userInteractions.js
new file mode 100644
index 000000000..553aefe78
--- /dev/null
+++ b/backend/src/middleware/userInteractions.js
@@ -0,0 +1,44 @@
+const createRelatedCypher = (relation) => `
+MATCH (user:User { id: $currentUser})
+MATCH (post:Post { id: $postId})
+OPTIONAL MATCH (post)<-[r:${relation}]-(u:User)
+WHERE NOT u.disabled AND NOT u.deleted
+WITH user, post, count(DISTINCT u) AS count
+MERGE (user)-[relation:${relation} { }]->(post)
+ON CREATE
+SET relation.count = 1,
+relation.createdAt = toString(datetime()),
+post.clickedCount = count + 1
+ON MATCH
+SET relation.count = relation.count + 1,
+relation.updatedAt = toString(datetime()),
+post.clickedCount = count
+RETURN user, post, relation
+`
+
+const setPostCounter = async (postId, relation, context) => {
+ const {
+ user: { id: currentUser },
+ } = context
+ const session = context.driver.session()
+ try {
+ await session.writeTransaction((txc) => {
+ return txc.run(createRelatedCypher(relation), { currentUser, postId })
+ })
+ } finally {
+ session.close()
+ }
+}
+
+const userClickedPost = async (resolve, root, args, context, info) => {
+ if (args.id) {
+ await setPostCounter(args.id, 'CLICKED', context)
+ }
+ return resolve(root, args, context, info)
+}
+
+export default {
+ Query: {
+ Post: userClickedPost,
+ },
+}
diff --git a/backend/src/middleware/userInteractions.spec.js b/backend/src/middleware/userInteractions.spec.js
new file mode 100644
index 000000000..77c9fbd1d
--- /dev/null
+++ b/backend/src/middleware/userInteractions.spec.js
@@ -0,0 +1,98 @@
+import Factory, { cleanDatabase } from '../db/factories'
+import { gql } from '../helpers/jest'
+import { getNeode, getDriver } from '../db/neo4j'
+import createServer from '../server'
+import { createTestClient } from 'apollo-server-testing'
+
+let query, aUser, bUser, post, authenticatedUser, variables
+
+const driver = getDriver()
+const neode = getNeode()
+
+const postQuery = gql`
+ query($id: ID) {
+ Post(id: $id) {
+ clickedCount
+ }
+ }
+`
+
+beforeAll(async () => {
+ await cleanDatabase()
+ aUser = await Factory.build('user', {
+ id: 'a-user',
+ })
+ bUser = await Factory.build('user', {
+ id: 'b-user',
+ })
+ post = await Factory.build('post')
+ authenticatedUser = await aUser.toJson()
+ const { server } = createServer({
+ context: () => {
+ return {
+ driver,
+ neode,
+ user: authenticatedUser,
+ }
+ },
+ })
+ query = createTestClient(server).query
+})
+
+afterAll(async () => {
+ await cleanDatabase()
+})
+
+describe('middleware/userInteractions', () => {
+ describe('given one post', () => {
+ it('does not change clickedCount when queried without ID', async () => {
+ await expect(query({ query: postQuery, variables })).resolves.toMatchObject({
+ data: {
+ Post: expect.arrayContaining([
+ {
+ clickedCount: 0,
+ },
+ ]),
+ },
+ })
+ })
+
+ it('changes clickedCount when queried with ID', async () => {
+ variables = { id: post.get('id') }
+ await expect(query({ query: postQuery, variables })).resolves.toMatchObject({
+ data: {
+ Post: expect.arrayContaining([
+ {
+ clickedCount: 1,
+ },
+ ]),
+ },
+ })
+ })
+
+ it('does not change clickedCount when same user queries the post again', async () => {
+ await expect(query({ query: postQuery, variables })).resolves.toMatchObject({
+ data: {
+ Post: expect.arrayContaining([
+ {
+ clickedCount: 1,
+ },
+ ]),
+ },
+ })
+ })
+
+ it('changes clickedCount when another user queries the post', async () => {
+ authenticatedUser = await bUser.toJson()
+ await expect(query({ query: postQuery, variables })).resolves.toMatchObject({
+ data: {
+ Post: expect.arrayContaining([
+ {
+ clickedCount: 2,
+ },
+ ]),
+ },
+ })
+ })
+ })
+})
diff --git a/backend/src/models/Post.js b/backend/src/models/Post.js
index 43f63ebd3..42bf844e1 100644
--- a/backend/src/models/Post.js
+++ b/backend/src/models/Post.js
@@ -22,6 +22,7 @@ export default {
contentExcerpt: { type: 'string', allow: [null] },
deleted: { type: 'boolean', default: false },
disabled: { type: 'boolean', default: false },
+ clickedCount: { type: 'int', default: 0 },
notified: {
type: 'relationship',
relationship: 'NOTIFIED',
diff --git a/backend/src/schema/resolvers/posts.js b/backend/src/schema/resolvers/posts.js
index 14e645730..47e150cac 100644
--- a/backend/src/schema/resolvers/posts.js
+++ b/backend/src/schema/resolvers/posts.js
@@ -88,6 +88,7 @@ export default {
SET post += $params
SET post.createdAt = toString(datetime())
SET post.updatedAt = toString(datetime())
+ SET post.clickedCount = 0
WITH post
MATCH (author:User {id: $userId})
MERGE (post)<-[:WROTE]-(author)
diff --git a/backend/src/schema/resolvers/searches.js b/backend/src/schema/resolvers/searches.js
index 7b157fc65..934883b12 100644
--- a/backend/src/schema/resolvers/searches.js
+++ b/backend/src/schema/resolvers/searches.js
@@ -38,7 +38,8 @@ const searchPostsSetup = {
__typename: labels(resource)[0],
author: properties(author),
commentsCount: toString(size(comments)),
- shoutedCount: toString(size(shouter))
+ shoutedCount: toString(size(shouter)),
+ clickedCount: toString(resource.clickedCount)
}`,
limit: 'LIMIT $limit',
}
diff --git a/backend/src/schema/types/type/Post.gql b/backend/src/schema/types/type/Post.gql
index 37f9dd176..99e1e9e59 100644
--- a/backend/src/schema/types/type/Post.gql
+++ b/backend/src/schema/types/type/Post.gql
@@ -156,6 +156,8 @@ type Post {
statement: "MATCH (this)<-[:SHOUTED]-(u:User {id: $cypherParams.currentUserId}) RETURN COUNT(u) >= 1"
)
+ clickedCount: Int!
+
emotions: [EMOTED]
emotionsCount: Int!
@cypher(statement: "MATCH (this)<-[emoted:EMOTED]-(:User) RETURN COUNT(DISTINCT emoted)")
diff --git a/webapp/assets/_new/icons/svgs/hand-pointer.svg b/webapp/assets/_new/icons/svgs/hand-pointer.svg
new file mode 100644
index 000000000..bdd2c8fe0
--- /dev/null
+++ b/webapp/assets/_new/icons/svgs/hand-pointer.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/webapp/components/PostTeaser/PostTeaser.spec.js b/webapp/components/PostTeaser/PostTeaser.spec.js
index 1e90cd1cf..5b71347d0 100644
--- a/webapp/components/PostTeaser/PostTeaser.spec.js
+++ b/webapp/components/PostTeaser/PostTeaser.spec.js
@@ -25,6 +25,7 @@ describe('PostTeaser', () => {
disabled: false,
shoutedCount: 0,
commentsCount: 0,
+ clickedCount: 0,
name: 'It is a post',
author: {
id: 'u1',
diff --git a/webapp/components/PostTeaser/PostTeaser.story.js b/webapp/components/PostTeaser/PostTeaser.story.js
index 5fecae4db..41c34cad4 100644
--- a/webapp/components/PostTeaser/PostTeaser.story.js
+++ b/webapp/components/PostTeaser/PostTeaser.story.js
@@ -28,6 +28,7 @@ export const post = {
shoutedCount: 5,
commentedCount: 39,
followedByCount: 2,
+ clickedCount: 42,
followedByCurrentUser: true,
location: null,
badges: [
diff --git a/webapp/components/PostTeaser/PostTeaser.vue b/webapp/components/PostTeaser/PostTeaser.vue
index 6b768c365..554215651 100644
--- a/webapp/components/PostTeaser/PostTeaser.vue
+++ b/webapp/components/PostTeaser/PostTeaser.vue
@@ -34,6 +34,11 @@
:count="post.commentsCount"
:title="$t('contribution.amount-comments', { amount: post.commentsCount })"
/>
+
{
title: 'Post Title',
commentsCount: 3,
shoutedCount: 6,
+ clickedCount: 5,
createdAt: '23.08.2019',
author: {
name: 'Post Author',
diff --git a/webapp/components/generic/SearchPost/SearchPost.vue b/webapp/components/generic/SearchPost/SearchPost.vue
index 079322097..2ca16e0d2 100644
--- a/webapp/components/generic/SearchPost/SearchPost.vue
+++ b/webapp/components/generic/SearchPost/SearchPost.vue
@@ -5,6 +5,7 @@
+
{{ option.author.name | truncate(32) }} - {{ option.createdAt | dateTime('dd.MM.yyyy') }}
diff --git a/webapp/components/generic/SearchableInput/SearchableInput.story.js b/webapp/components/generic/SearchableInput/SearchableInput.story.js
index 0b4a2234c..5042d0089 100644
--- a/webapp/components/generic/SearchableInput/SearchableInput.story.js
+++ b/webapp/components/generic/SearchableInput/SearchableInput.story.js
@@ -14,6 +14,7 @@ export const searchResults = [
value: 'User Post by Jenny',
shoutedCount: 0,
commentsCount: 4,
+ clickedCount: 8,
createdAt: '2019-11-13T03:03:16.155Z',
author: {
id: 'u3',
@@ -29,6 +30,7 @@ export const searchResults = [
value: 'Eum quos est molestiae enim magni consequuntur sed commodi eos.',
shoutedCount: 0,
commentsCount: 0,
+ clickedCount: 9,
createdAt: '2019-11-13T03:00:45.478Z',
author: {
id: 'u6',
@@ -44,6 +46,7 @@ export const searchResults = [
value: 'This is post #7',
shoutedCount: 1,
commentsCount: 1,
+ clickedCount: 1,
createdAt: '2019-11-13T03:00:23.098Z',
author: {
id: 'u6',
@@ -59,6 +62,7 @@ export const searchResults = [
value: 'This is post #12',
shoutedCount: 0,
commentsCount: 12,
+ clickedCount: 14,
createdAt: '2019-11-13T03:00:23.098Z',
author: {
id: 'u6',
diff --git a/webapp/graphql/Fragments.js b/webapp/graphql/Fragments.js
index e4c31bd4b..5702dafb8 100644
--- a/webapp/graphql/Fragments.js
+++ b/webapp/graphql/Fragments.js
@@ -67,6 +67,7 @@ export const postCountsFragment = gql`
shoutedCount
shoutedByCurrentUser
emotionsCount
+ clickedCount
}
`
diff --git a/webapp/graphql/Search.js b/webapp/graphql/Search.js
index 51c23b1c6..b5c20a945 100644
--- a/webapp/graphql/Search.js
+++ b/webapp/graphql/Search.js
@@ -12,6 +12,7 @@ export const searchQuery = gql`
...post
commentsCount
shoutedCount
+ clickedCount
author {
...user
}
@@ -40,6 +41,7 @@ export const searchPosts = gql`
...tagsCategoriesAndPinned
commentsCount
shoutedCount
+ clickedCount
author {
...user
}
diff --git a/webapp/locales/de.json b/webapp/locales/de.json
index 9ef86a7d4..248ffe27d 100644
--- a/webapp/locales/de.json
+++ b/webapp/locales/de.json
@@ -174,6 +174,7 @@
}
},
"contribution": {
+ "amount-clicks": "{amount} clicks",
"amount-comments": "{amount} comments",
"amount-shouts": "{amount} recommendations",
"categories": {
diff --git a/webapp/locales/en.json b/webapp/locales/en.json
index 3c2af7556..c8ec49f69 100644
--- a/webapp/locales/en.json
+++ b/webapp/locales/en.json
@@ -174,6 +174,7 @@
}
},
"contribution": {
+ "amount-clicks": "{amount} clicks",
"amount-comments": "{amount} comments",
"amount-shouts": "{amount} recommendations",
"categories": {
diff --git a/webapp/storybook/helpers.js b/webapp/storybook/helpers.js
index 391c93946..aa10f34fe 100644
--- a/webapp/storybook/helpers.js
+++ b/webapp/storybook/helpers.js
@@ -81,6 +81,7 @@ const helpers = {
slug: faker.lorem.slug(title),
shoutedCount: faker.random.number(),
commentsCount: faker.random.number(),
+ clickedCount: faker.random.number(),
}
})
},