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 @@ + + +hand-pointer-o + + 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(), } }) },