diff --git a/backend/package.json b/backend/package.json index 19904c22d..853ef25b6 100644 --- a/backend/package.json +++ b/backend/package.json @@ -89,7 +89,7 @@ "metascraper-youtube": "^5.6.3", "minimatch": "^3.0.4", "neo4j-driver": "~1.7.5", - "neo4j-graphql-js": "^2.6.3", + "neo4j-graphql-js": "^2.7.0", "neode": "^0.3.1", "node-fetch": "~2.6.0", "nodemailer": "^6.3.0", diff --git a/backend/src/middleware/handleHtmlContent/handleContentData.js b/backend/src/middleware/handleHtmlContent/handleContentData.js index 3faa31689..ab0456f9a 100644 --- a/backend/src/middleware/handleHtmlContent/handleContentData.js +++ b/backend/src/middleware/handleHtmlContent/handleContentData.js @@ -7,11 +7,14 @@ const notifyMentions = async (label, id, idsOfMentionedUsers, context) => { const session = context.driver.session() const createdAt = new Date().toISOString() const cypher = ` - MATCH (u: User) WHERE u.id in $idsOfMentionedUsers - MATCH (source) WHERE source.id = $id AND $label IN LABELS(source) - CREATE (n: Notification { id: apoc.create.uuid(), read: false, createdAt: $createdAt }) - MERGE (n)-[:NOTIFIED]->(u) - MERGE (source)-[:NOTIFIED]->(n) + MATCH (source) + WHERE source.id = $id AND $label IN LABELS(source) + MATCH (source)<-[:WROTE]-(author: User) + MATCH (u: User) + WHERE u.id in $idsOfMentionedUsers + AND NOT (u)<-[:BLOCKED]-(author) + CREATE (n: Notification {id: apoc.create.uuid(), read: false, createdAt: $createdAt }) + MERGE (source)-[:NOTIFIED]->(n)-[:NOTIFIED]->(u) ` await session.run(cypher, { idsOfMentionedUsers, diff --git a/backend/src/middleware/handleHtmlContent/handleContentData.spec.js b/backend/src/middleware/handleHtmlContent/handleContentData.spec.js index 6fe5d3891..40d8a2481 100644 --- a/backend/src/middleware/handleHtmlContent/handleContentData.spec.js +++ b/backend/src/middleware/handleHtmlContent/handleContentData.spec.js @@ -1,12 +1,36 @@ -import { GraphQLClient } from 'graphql-request' -import { host, login, gql } from '../../jest/helpers' +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' const factory = Factory() -let client +const driver = getDriver() +const instance = neode() +let server +let query +let mutate +let user +let authenticatedUser + +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 () => { - await factory.create('User', { + user = await instance.create('User', { id: 'you', name: 'Al Capone', slug: 'al-capone', @@ -19,8 +43,8 @@ afterEach(async () => { await factory.cleanDatabase() }) -describe('currentUser { notifications }', () => { - const query = gql` +describe('notifications', () => { + const notificationQuery = gql` query($read: Boolean) { currentUser { notifications(read: $read, orderBy: createdAt_desc) { @@ -34,82 +58,60 @@ describe('currentUser { notifications }', () => { ` describe('authenticated', () => { - let headers beforeEach(async () => { - headers = await login({ - email: 'test@example.org', - password: '1234', - }) - client = new GraphQLClient(host, { - headers, - }) + authenticatedUser = user }) describe('given another user', () => { - let authorClient - let authorParams - let authorHeaders - + let author beforeEach(async () => { - authorParams = { + author = await instance.create('User', { email: 'author@example.org', password: '1234', id: 'author', - } - await factory.create('User', authorParams) - authorHeaders = await login(authorParams) + }) }) describe('who mentions me in a post', () => { - let post const title = 'Mentioning Al Capone' const content = 'Hey @al-capone how do you do?' - beforeEach(async () => { + const createPostAction = async () => { const createPostMutation = gql` - mutation($title: String!, $content: String!) { - CreatePost(title: $title, content: $content) { + mutation($id: ID, $title: String!, $content: String!) { + CreatePost(id: $id, title: $title, content: $content) { id title content } } ` - authorClient = new GraphQLClient(host, { - headers: authorHeaders, + authenticatedUser = await author.toJson() + await mutate({ + mutation: createPostMutation, + variables: { id: 'p47', title, content }, }) - const { CreatePost } = await authorClient.request(createPostMutation, { - title, - content, - }) - post = CreatePost - }) + authenticatedUser = await user.toJson() + } it('sends you a notification', async () => { + await createPostAction() const expectedContent = 'Hey @al-capone how do you do?' - const expected = { - currentUser: { - notifications: [ - { - read: false, - post: { - content: expectedContent, - }, - }, - ], + const expected = expect.objectContaining({ + data: { + currentUser: { notifications: [{ read: false, post: { content: expectedContent } }] }, }, - } + }) + const { query } = createTestClient(server) await expect( - client.request(query, { - read: false, - }), + query({ query: notificationQuery, variables: { read: false } }), ).resolves.toEqual(expected) }) describe('who mentions me many times', () => { - beforeEach(async () => { + const updatePostAction = async () => { const updatedContent = ` One more mention to @@ -132,41 +134,52 @@ describe('currentUser { notifications }', () => { } } ` - authorClient = new GraphQLClient(host, { - headers: authorHeaders, + authenticatedUser = await author.toJson() + await mutate({ + mutation: updatePostMutation, + variables: { + id: 'p47', + title, + content: updatedContent, + }, }) - await authorClient.request(updatePostMutation, { - id: post.id, - title: post.title, - content: updatedContent, - }) - }) + 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 = { - currentUser: { - notifications: [ - { - read: false, - post: { - content: expectedContent, - }, - }, - { - read: false, - post: { - content: expectedContent, - }, - }, - ], + const expected = expect.objectContaining({ + data: { + currentUser: { + notifications: [ + { read: false, post: { content: expectedContent } }, + { read: false, post: { content: expectedContent } }, + ], + }, }, - } + }) await expect( - client.request(query, { - read: false, - }), + query({ query: notificationQuery, variables: { read: false } }), + ).resolves.toEqual(expected) + }) + }) + + describe('but the author of the post blocked me', () => { + beforeEach(async () => { + await author.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) }) }) @@ -204,46 +217,40 @@ describe('Hashtags', () => { ` describe('authenticated', () => { - let headers beforeEach(async () => { - headers = await login({ - email: 'test@example.org', - password: '1234', - }) - client = new GraphQLClient(host, { - headers, - }) + authenticatedUser = await user.toJson() }) describe('create a Post with Hashtags', () => { beforeEach(async () => { - await client.request(createPostMutation, { - postId, - postTitle, - postContent, + await mutate({ + mutation: createPostMutation, + variables: { + postId, + postTitle, + postContent, + }, }) }) - it('both Hashtags are created with the "id" set to thier "name"', async () => { + it('both Hashtags are created with the "id" set to their "name"', async () => { const expected = [ - { - id: 'Democracy', - name: 'Democracy', - }, - { - id: 'Liberty', - name: 'Liberty', - }, + { id: 'Democracy', name: 'Democracy' }, + { id: 'Liberty', name: 'Liberty' }, ] await expect( - client.request(postWithHastagsQuery, postWithHastagsVariables), - ).resolves.toEqual({ - Post: [ - { - tags: expect.arrayContaining(expected), + 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', () => { @@ -261,31 +268,28 @@ describe('Hashtags', () => { ` it('only one previous Hashtag and the new Hashtag exists', async () => { - await client.request(updatePostMutation, { - postId, - postTitle, - updatedPostContent, + await mutate({ + mutation: updatePostMutation, + variables: { + postId, + postTitle, + updatedPostContent, + }, }) const expected = [ - { - id: 'Elections', - name: 'Elections', - }, - { - id: 'Liberty', - name: 'Liberty', - }, + { id: 'Elections', name: 'Elections' }, + { id: 'Liberty', name: 'Liberty' }, ] await expect( - client.request(postWithHastagsQuery, postWithHastagsVariables), - ).resolves.toEqual({ - Post: [ - { - tags: expect.arrayContaining(expected), + query({ query: postWithHastagsQuery, variables: postWithHastagsVariables }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + Post: [{ tags: expect.arrayContaining(expected) }], }, - ], - }) + }), + ) }) }) }) diff --git a/backend/src/middleware/handleHtmlContent/notifications/extractMentionedUsers.js b/backend/src/middleware/handleHtmlContent/notifications/extractMentionedUsers.js index d08309f0b..e245e84b5 100644 --- a/backend/src/middleware/handleHtmlContent/notifications/extractMentionedUsers.js +++ b/backend/src/middleware/handleHtmlContent/notifications/extractMentionedUsers.js @@ -3,11 +3,13 @@ import cheerio from 'cheerio' export default function(content) { if (!content) return [] const $ = cheerio.load(content) - let userIds = $('a.mention[data-mention-id]') + const userIds = $('a.mention[data-mention-id]') .map((_, el) => { return $(el).attr('data-mention-id') }) .get() - userIds = userIds.map(id => id.trim()).filter(id => !!id) return userIds + .map(id => id.trim()) + .filter(id => !!id) + .filter((id, index, allIds) => allIds.indexOf(id) === index) } diff --git a/backend/src/middleware/handleHtmlContent/notifications/extractMentionedUsers.spec.js b/backend/src/middleware/handleHtmlContent/notifications/extractMentionedUsers.spec.js index a631b64a3..9865eab0d 100644 --- a/backend/src/middleware/handleHtmlContent/notifications/extractMentionedUsers.spec.js +++ b/backend/src/middleware/handleHtmlContent/notifications/extractMentionedUsers.spec.js @@ -6,6 +6,8 @@ const contentEmptyMentions = '

Something inspirational about @bob-der-baumeister and @jenny-rostock.

' const contentWithPlainLinks = '

Something inspirational about @bob-der-baumeister and @jenny-rostock.

' +const contentWithDuplicateIds = + 'One more mention to @al-capone and again: @al-capone and again @al-capone ' describe('extractMentionedUsers', () => { describe('content undefined', () => { @@ -18,6 +20,10 @@ describe('extractMentionedUsers', () => { expect(extractMentionedUsers(contentWithPlainLinks)).toEqual([]) }) + it('removes duplicates', () => { + expect(extractMentionedUsers(contentWithDuplicateIds)).toEqual(['you']) + }) + describe('given a link with .mention class and `data-mention-id` attribute ', () => { it('extracts ids', () => { expect(extractMentionedUsers(contentWithMentions)).toEqual(['u3']) diff --git a/backend/src/middleware/orderByMiddleware.spec.js b/backend/src/middleware/orderByMiddleware.spec.js index 450220cd6..7a42166cf 100644 --- a/backend/src/middleware/orderByMiddleware.spec.js +++ b/backend/src/middleware/orderByMiddleware.spec.js @@ -1,22 +1,29 @@ -import { GraphQLClient } from 'graphql-request' import Factory from '../seed/factories' -import { host } from '../jest/helpers' +import { gql } from '../jest/helpers' +import { neode as getNeode, getDriver } from '../bootstrap/neo4j' +import { createTestClient } from 'apollo-server-testing' +import createServer from '../server' -let client -let headers -let query const factory = Factory() +const neode = getNeode() +const driver = getDriver() + +const { server } = createServer({ + context: () => { + return { + user: null, + neode, + driver, + } + }, +}) +const { query } = createTestClient(server) beforeEach(async () => { - const userParams = { name: 'Author', email: 'author@example.org', password: '1234' } - await factory.create('User', userParams) - await factory.authenticateAs(userParams) - await factory.create('Post', { title: 'first' }) - await factory.create('Post', { title: 'second' }) - await factory.create('Post', { title: 'third' }) - await factory.create('Post', { title: 'last' }) - headers = {} - client = new GraphQLClient(host, { headers }) + await neode.create('Post', { title: 'first' }) + await neode.create('Post', { title: 'second' }) + await neode.create('Post', { title: 'third' }) + await neode.create('Post', { title: 'last' }) }) afterEach(async () => { @@ -25,10 +32,6 @@ afterEach(async () => { describe('Query', () => { describe('Post', () => { - beforeEach(() => { - query = '{ Post { title } }' - }) - describe('orderBy', () => { it('createdAt descending is default', async () => { const posts = [ @@ -37,15 +40,21 @@ describe('Query', () => { { title: 'second' }, { title: 'first' }, ] - const expected = { Post: posts } - await expect(client.request(query)).resolves.toEqual(expected) + const expected = expect.objectContaining({ data: { Post: posts } }) + await expect( + query({ + query: gql` + { + Post { + title + } + } + `, + }), + ).resolves.toEqual(expected) }) describe('(orderBy: createdAt_asc)', () => { - beforeEach(() => { - query = '{ Post(orderBy: createdAt_asc) { title } }' - }) - it('orders by createdAt ascending', async () => { const posts = [ { title: 'first' }, @@ -53,8 +62,18 @@ describe('Query', () => { { title: 'third' }, { title: 'last' }, ] - const expected = { Post: posts } - await expect(client.request(query)).resolves.toEqual(expected) + const expected = expect.objectContaining({ data: { Post: posts } }) + await expect( + query({ + query: gql` + { + Post(orderBy: createdAt_asc) { + title + } + } + `, + }), + ).resolves.toEqual(expected) }) }) }) diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index 78f833c23..0f6f9e1c5 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -10,7 +10,7 @@ const instance = neode() const isAuthenticated = rule({ cache: 'contextual', })(async (_parent, _args, ctx, _info) => { - return ctx.user != null + return !!(ctx && ctx.user && ctx.user.id) }) const isModerator = rule()(async (parent, args, { user }, info) => { @@ -159,6 +159,7 @@ const permissions = shield( Badge: allow, PostsEmotionsCountByEmotion: allow, PostsEmotionsByCurrentUser: allow, + blockedUsers: isAuthenticated, }, Mutation: { '*': deny, @@ -195,6 +196,8 @@ const permissions = shield( resetPassword: allow, AddPostEmotions: isAuthenticated, RemovePostEmotions: isAuthenticated, + block: isAuthenticated, + unblock: isAuthenticated, }, User: { email: isMyOwn, diff --git a/backend/src/models/Notification.js b/backend/src/models/Notification.js new file mode 100644 index 000000000..b8690b8c1 --- /dev/null +++ b/backend/src/models/Notification.js @@ -0,0 +1,19 @@ +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 }, + user: { + type: 'relationship', + relationship: 'NOTIFIED', + target: 'User', + direction: 'out', + }, + post: { + type: 'relationship', + relationship: 'NOTIFIED', + target: 'Post', + direction: 'in', + }, +} diff --git a/backend/src/models/User.js b/backend/src/models/User.js index 2c1575423..fa578f8ad 100644 --- a/backend/src/models/User.js +++ b/backend/src/models/User.js @@ -71,4 +71,16 @@ module.exports = { eager: true, cascade: true, }, + blocked: { + type: 'relationship', + relationship: 'BLOCKED', + target: 'User', + direction: 'out', + }, + notifications: { + type: 'relationship', + relationship: 'NOTIFIED', + target: 'Notification', + direction: 'in', + }, } diff --git a/backend/src/models/index.js b/backend/src/models/index.js index b8dc451d7..6f6b300f8 100644 --- a/backend/src/models/index.js +++ b/backend/src/models/index.js @@ -7,4 +7,5 @@ export default { EmailAddress: require('./EmailAddress.js'), SocialMedia: require('./SocialMedia.js'), Post: require('./Post.js'), + Notification: require('./Notification.js'), } diff --git a/backend/src/schema/resolvers/helpers/Resolver.js b/backend/src/schema/resolvers/helpers/Resolver.js index 655cf08a0..fd41205a3 100644 --- a/backend/src/schema/resolvers/helpers/Resolver.js +++ b/backend/src/schema/resolvers/helpers/Resolver.js @@ -15,10 +15,12 @@ export default function Resolver(type, options = {}) { const { idAttribute = 'id', undefinedToNull = [], + boolean = {}, count = {}, hasOne = {}, hasMany = {}, } = options + const _hasResolver = (resolvers, { key, connection }, { returnType }) => { return async (parent, params, context, resolveInfo) => { if (typeof parent[key] !== 'undefined') return parent[key] @@ -31,6 +33,26 @@ export default function Resolver(type, options = {}) { } } + const booleanResolver = obj => { + const resolvers = {} + for (const [key, condition] of Object.entries(obj)) { + resolvers[key] = async (parent, params, { cypherParams }, resolveInfo) => { + if (typeof parent[key] !== 'undefined') return parent[key] + const result = await instance.cypher( + ` + ${condition.replace('this', 'this {id: $parent.id}')} as ${key}`, + { + parent, + cypherParams, + }, + ) + const [record] = result.records + return record.get(key) + } + } + return resolvers + } + const countResolver = obj => { const resolvers = {} for (const [key, connection] of Object.entries(obj)) { @@ -67,6 +89,7 @@ export default function Resolver(type, options = {}) { } const result = { ...undefinedToNullResolver(undefinedToNull), + ...booleanResolver(boolean), ...countResolver(count), ...hasOneResolver(hasOne), ...hasManyResolver(hasMany), diff --git a/backend/src/schema/resolvers/posts.js b/backend/src/schema/resolvers/posts.js index 336f816f5..ea1f680bd 100644 --- a/backend/src/schema/resolvers/posts.js +++ b/backend/src/schema/resolvers/posts.js @@ -1,7 +1,74 @@ import uuid from 'uuid/v4' +import { neo4jgraphql } from 'neo4j-graphql-js' import fileUpload from './fileUpload' +import { getBlockedUsers, getBlockedByUsers } from './users.js' +import { mergeWith, isArray } from 'lodash' + +const filterForBlockedUsers = async (params, context) => { + if (!context.user) return params + const [blockedUsers, blockedByUsers] = await Promise.all([ + getBlockedUsers(context), + getBlockedByUsers(context), + ]) + const badIds = [...blockedByUsers.map(b => b.id), ...blockedUsers.map(b => b.id)] + params.filter = mergeWith( + params.filter, + { + author_not: { id_in: badIds }, + }, + (objValue, srcValue) => { + if (isArray(objValue)) { + return objValue.concat(srcValue) + } + }, + ) + return params +} export default { + Query: { + Post: async (object, params, context, resolveInfo) => { + params = await filterForBlockedUsers(params, context) + return neo4jgraphql(object, params, context, resolveInfo, false) + }, + findPosts: async (object, params, context, resolveInfo) => { + params = await filterForBlockedUsers(params, context) + return neo4jgraphql(object, params, context, resolveInfo, false) + }, + PostsEmotionsCountByEmotion: async (object, params, context, resolveInfo) => { + const session = context.driver.session() + const { postId, data } = params + const transactionRes = await session.run( + `MATCH (post:Post {id: $postId})<-[emoted:EMOTED {emotion: $data.emotion}]-() + RETURN COUNT(DISTINCT emoted) as emotionsCount + `, + { postId, data }, + ) + session.close() + + const [emotionsCount] = transactionRes.records.map(record => { + return record.get('emotionsCount').low + }) + + return emotionsCount + }, + PostsEmotionsByCurrentUser: async (object, params, context, resolveInfo) => { + const session = context.driver.session() + const { postId } = params + const transactionRes = await session.run( + `MATCH (user:User {id: $userId})-[emoted:EMOTED]->(post:Post {id: $postId}) + RETURN collect(emoted.emotion) as emotion`, + { userId: context.user.id, postId }, + ) + + session.close() + + const [emotions] = transactionRes.records.map(record => { + return record.get('emotion') + }) + return emotions + }, + }, Mutation: { UpdatePost: async (object, params, context, resolveInfo) => { const { categoryIds } = params @@ -112,39 +179,4 @@ export default { return emoted }, }, - Query: { - PostsEmotionsCountByEmotion: async (object, params, context, resolveInfo) => { - const session = context.driver.session() - const { postId, data } = params - const transactionRes = await session.run( - `MATCH (post:Post {id: $postId})<-[emoted:EMOTED {emotion: $data.emotion}]-() - RETURN COUNT(DISTINCT emoted) as emotionsCount - `, - { postId, data }, - ) - session.close() - - const [emotionsCount] = transactionRes.records.map(record => { - return record.get('emotionsCount').low - }) - - return emotionsCount - }, - PostsEmotionsByCurrentUser: async (object, params, context, resolveInfo) => { - const session = context.driver.session() - const { postId } = params - const transactionRes = await session.run( - `MATCH (user:User {id: $userId})-[emoted:EMOTED]->(post:Post {id: $postId}) - RETURN collect(emoted.emotion) as emotion`, - { userId: context.user.id, postId }, - ) - - session.close() - - const [emotions] = transactionRes.records.map(record => { - return record.get('emotion') - }) - return emotions - }, - }, } diff --git a/backend/src/schema/resolvers/posts.spec.js b/backend/src/schema/resolvers/posts.spec.js index b48be16db..15376c8a4 100644 --- a/backend/src/schema/resolvers/posts.spec.js +++ b/backend/src/schema/resolvers/posts.spec.js @@ -460,6 +460,7 @@ describe('emotions', () => { context: () => { return { user, + neode: instance, driver, } }, @@ -476,6 +477,7 @@ describe('emotions', () => { context: () => { return { user, + neode: instance, driver, } }, diff --git a/backend/src/schema/resolvers/users.js b/backend/src/schema/resolvers/users.js index 77e4ae2aa..74561c5fe 100644 --- a/backend/src/schema/resolvers/users.js +++ b/backend/src/schema/resolvers/users.js @@ -6,8 +6,45 @@ import Resolver from './helpers/Resolver' const instance = neode() +export const getBlockedUsers = async context => { + const { neode } = context + const userModel = neode.model('User') + let blockedUsers = neode + .query() + .match('user', userModel) + .where('user.id', context.user.id) + .relationship(userModel.relationships().get('blocked')) + .to('blocked', userModel) + .return('blocked') + blockedUsers = await blockedUsers.execute() + blockedUsers = blockedUsers.records.map(r => r.get('blocked').properties) + return blockedUsers +} + +export const getBlockedByUsers = async context => { + const { neode } = context + const userModel = neode.model('User') + let blockedByUsers = neode + .query() + .match('user', userModel) + .relationship(userModel.relationships().get('blocked')) + .to('blocked', userModel) + .where('blocked.id', context.user.id) + .return('user') + blockedByUsers = await blockedByUsers.execute() + blockedByUsers = blockedByUsers.records.map(r => r.get('user').properties) + return blockedByUsers +} + export default { Query: { + blockedUsers: async (object, args, context, resolveInfo) => { + try { + return getBlockedUsers(context) + } catch (e) { + throw new UserInputError(e.message) + } + }, User: async (object, args, context, resolveInfo) => { const { email } = args if (email) { @@ -20,6 +57,36 @@ export default { }, }, Mutation: { + block: async (object, args, context, resolveInfo) => { + const { user: currentUser } = context + if (currentUser.id === args.id) return null + await instance.cypher( + ` + MATCH(u:User {id: $currentUser.id})-[r:FOLLOWS]->(b:User {id: $args.id}) + DELETE r + `, + { currentUser, args }, + ) + const [user, blockedUser] = await Promise.all([ + instance.find('User', currentUser.id), + instance.find('User', args.id), + ]) + await user.relateTo(blockedUser, 'blocked') + return blockedUser.toJson() + }, + unblock: async (object, args, context, resolveInfo) => { + const { user: currentUser } = context + if (currentUser.id === args.id) return null + await instance.cypher( + ` + MATCH(u:User {id: $currentUser.id})-[r:BLOCKED]->(b:User {id: $args.id}) + DELETE r + `, + { currentUser, args }, + ) + const blockedUser = await instance.find('User', args.id) + return blockedUser.toJson() + }, UpdateUser: async (object, args, context, resolveInfo) => { args = await fileUpload(args, { file: 'avatarUpload', url: 'avatar' }) try { @@ -73,6 +140,12 @@ export default { 'locationName', 'about', ], + boolean: { + followedByCurrentUser: + 'MATCH (this)<-[:FOLLOWS]-(u:User {id: $cypherParams.currentUserId}) RETURN COUNT(u) >= 1', + isBlocked: + 'MATCH (this)<-[:BLOCKED]-(u:User {id: $cypherParams.currentUserId}) RETURN COUNT(u) >= 1', + }, count: { contributionsCount: '-[:WROTE]->(related:Post)', friendsCount: '<-[:FRIENDS]->(related:User)', @@ -91,7 +164,6 @@ export default { followedBy: '<-[:FOLLOWS]-(related:User)', following: '-[:FOLLOWS]->(related:User)', friends: '-[:FRIENDS]-(related:User)', - blacklisted: '-[:BLACKLISTED]->(related:User)', socialMedia: '-[:OWNED_BY]->(related:SocialMedia', contributions: '-[:WROTE]->(related:Post)', comments: '-[:WROTE]->(related:Comment)', diff --git a/backend/src/schema/resolvers/users/blockedUsers.spec.js b/backend/src/schema/resolvers/users/blockedUsers.spec.js new file mode 100644 index 000000000..887c34494 --- /dev/null +++ b/backend/src/schema/resolvers/users/blockedUsers.spec.js @@ -0,0 +1,393 @@ +import { createTestClient } from 'apollo-server-testing' +import createServer from '../../../server' +import Factory from '../../../seed/factories' +import { gql } from '../../../jest/helpers' +import { neode, getDriver } from '../../../bootstrap/neo4j' + +const driver = getDriver() +const factory = Factory() +const instance = neode() + +let currentUser +let blockedUser +let authenticatedUser +let server + +beforeEach(() => { + authenticatedUser = undefined + ;({ server } = createServer({ + context: () => { + return { + user: authenticatedUser, + driver, + neode: instance, + cypherParams: { + currentUserId: authenticatedUser ? authenticatedUser.id : null, + }, + } + }, + })) +}) + +afterEach(async () => { + await factory.cleanDatabase() +}) + +describe('blockedUsers', () => { + let blockedUserQuery + beforeEach(() => { + blockedUserQuery = gql` + query { + blockedUsers { + id + name + isBlocked + } + } + ` + }) + + it('throws permission error', async () => { + const { query } = createTestClient(server) + const result = await query({ query: blockedUserQuery }) + expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!') + }) + + describe('authenticated and given a blocked user', () => { + beforeEach(async () => { + currentUser = await instance.create('User', { + name: 'Current User', + id: 'u1', + }) + blockedUser = await instance.create('User', { + name: 'Blocked User', + id: 'u2', + }) + await currentUser.relateTo(blockedUser, 'blocked') + authenticatedUser = await currentUser.toJson() + }) + + it('returns a list of blocked users', async () => { + const { query } = createTestClient(server) + await expect(query({ query: blockedUserQuery })).resolves.toEqual( + expect.objectContaining({ + data: { + blockedUsers: [ + { + name: 'Blocked User', + id: 'u2', + isBlocked: true, + }, + ], + }, + }), + ) + }) + }) +}) + +describe('block', () => { + let blockAction + + beforeEach(() => { + currentUser = undefined + blockAction = variables => { + const { mutate } = createTestClient(server) + const blockMutation = gql` + mutation($id: ID!) { + block(id: $id) { + id + name + isBlocked + } + } + ` + return mutate({ mutation: blockMutation, variables }) + } + }) + + it('throws permission error', async () => { + const result = await blockAction({ id: 'u2' }) + expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!') + }) + + describe('authenticated', () => { + beforeEach(async () => { + currentUser = await instance.create('User', { + name: 'Current User', + id: 'u1', + }) + authenticatedUser = await currentUser.toJson() + }) + + describe('block yourself', () => { + it('returns null', async () => { + await expect(blockAction({ id: 'u1' })).resolves.toEqual( + expect.objectContaining({ data: { block: null } }), + ) + }) + }) + + describe('block not existing user', () => { + it('returns null', async () => { + await expect(blockAction({ id: 'u2' })).resolves.toEqual( + expect.objectContaining({ data: { block: null } }), + ) + }) + }) + + describe('given a to-be-blocked user', () => { + beforeEach(async () => { + blockedUser = await instance.create('User', { + name: 'Blocked User', + id: 'u2', + }) + }) + + it('blocks a user', async () => { + await expect(blockAction({ id: 'u2' })).resolves.toEqual( + expect.objectContaining({ + data: { block: { id: 'u2', name: 'Blocked User', isBlocked: true } }, + }), + ) + }) + + it('unfollows the user', async () => { + await currentUser.relateTo(blockedUser, 'following') + const queryUser = gql` + query { + User(id: "u2") { + id + isBlocked + followedByCurrentUser + } + } + ` + const { query } = createTestClient(server) + await expect(query({ query: queryUser })).resolves.toEqual( + expect.objectContaining({ + data: { User: [{ id: 'u2', isBlocked: false, followedByCurrentUser: true }] }, + }), + ) + await blockAction({ id: 'u2' }) + await expect(query({ query: queryUser })).resolves.toEqual( + expect.objectContaining({ + data: { User: [{ id: 'u2', isBlocked: true, followedByCurrentUser: false }] }, + }), + ) + }) + + describe('given both the current user and the to-be-blocked user write a post', () => { + let postQuery + + beforeEach(async () => { + const post1 = await instance.create('Post', { + id: 'p12', + title: 'A post written by the current user', + }) + const post2 = await instance.create('Post', { + id: 'p23', + title: 'A post written by the blocked user', + }) + await Promise.all([ + post1.relateTo(currentUser, 'author'), + post2.relateTo(blockedUser, 'author'), + ]) + postQuery = gql` + query { + Post(orderBy: createdAt_asc) { + id + title + author { + id + name + } + } + } + ` + }) + + const bothPostsAreInTheNewsfeed = async () => { + const { query } = createTestClient(server) + await expect(query({ query: postQuery })).resolves.toEqual( + expect.objectContaining({ + data: { + Post: [ + { + id: 'p12', + title: 'A post written by the current user', + author: { + name: 'Current User', + id: 'u1', + }, + }, + { + id: 'p23', + title: 'A post written by the blocked user', + author: { + name: 'Blocked User', + id: 'u2', + }, + }, + ], + }, + }), + ) + } + + describe('from the perspective of the current user', () => { + it('both posts are in the newsfeed', bothPostsAreInTheNewsfeed) + + describe('but if the current user blocks the other user', () => { + beforeEach(async () => { + await currentUser.relateTo(blockedUser, 'blocked') + }) + + it("the blocked user's post won't show up in the newsfeed of the current user", async () => { + const { query } = createTestClient(server) + await expect(query({ query: postQuery })).resolves.toEqual( + expect.objectContaining({ + data: { + Post: [ + { + id: 'p12', + title: 'A post written by the current user', + author: { name: 'Current User', id: 'u1' }, + }, + ], + }, + }), + ) + }) + }) + }) + + describe('from the perspective of the blocked user', () => { + beforeEach(async () => { + authenticatedUser = await blockedUser.toJson() + }) + + it('both posts are in the newsfeed', bothPostsAreInTheNewsfeed) + describe('but if the current user blocks the other user', () => { + beforeEach(async () => { + await currentUser.relateTo(blockedUser, 'blocked') + }) + + it("the current user's post won't show up in the newsfeed of the blocked user", async () => { + const { query } = createTestClient(server) + await expect(query({ query: postQuery })).resolves.toEqual( + expect.objectContaining({ + data: { + Post: [ + { + id: 'p23', + title: 'A post written by the blocked user', + author: { name: 'Blocked User', id: 'u2' }, + }, + ], + }, + }), + ) + }) + }) + }) + }) + }) + }) +}) + +describe('unblock', () => { + let unblockAction + + beforeEach(() => { + currentUser = undefined + unblockAction = variables => { + const { mutate } = createTestClient(server) + const unblockMutation = gql` + mutation($id: ID!) { + unblock(id: $id) { + id + name + isBlocked + } + } + ` + return mutate({ mutation: unblockMutation, variables }) + } + }) + + it('throws permission error', async () => { + const result = await unblockAction({ id: 'u2' }) + expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!') + }) + + describe('authenticated', () => { + beforeEach(async () => { + currentUser = await instance.create('User', { + name: 'Current User', + id: 'u1', + }) + authenticatedUser = await currentUser.toJson() + }) + + describe('unblock yourself', () => { + it('returns null', async () => { + await expect(unblockAction({ id: 'u1' })).resolves.toEqual( + expect.objectContaining({ data: { unblock: null } }), + ) + }) + }) + + describe('unblock not-existing user', () => { + it('returns null', async () => { + await expect(unblockAction({ id: 'lksjdflksfdj' })).resolves.toEqual( + expect.objectContaining({ data: { unblock: null } }), + ) + }) + }) + + describe('given another user', () => { + beforeEach(async () => { + blockedUser = await instance.create('User', { + name: 'Blocked User', + id: 'u2', + }) + }) + + describe('unblocking a not yet blocked user', () => { + it('does not hurt', async () => { + await expect(unblockAction({ id: 'u2' })).resolves.toEqual( + expect.objectContaining({ + data: { unblock: { id: 'u2', name: 'Blocked User', isBlocked: false } }, + }), + ) + }) + }) + + describe('given a blocked user', () => { + beforeEach(async () => { + await currentUser.relateTo(blockedUser, 'blocked') + }) + + it('unblocks a user', async () => { + await expect(unblockAction({ id: 'u2' })).resolves.toEqual( + expect.objectContaining({ + data: { unblock: { id: 'u2', name: 'Blocked User', isBlocked: false } }, + }), + ) + }) + + describe('unblocking twice', () => { + it('has no effect', async () => { + await unblockAction({ id: 'u2' }) + await expect(unblockAction({ id: 'u2' })).resolves.toEqual( + expect.objectContaining({ + data: { unblock: { id: 'u2', name: 'Blocked User', isBlocked: false } }, + }), + ) + }) + }) + }) + }) + }) +}) diff --git a/backend/src/schema/types/schema.gql b/backend/src/schema/types/schema.gql index d2651215b..560669a7d 100644 --- a/backend/src/schema/types/schema.gql +++ b/backend/src/schema/types/schema.gql @@ -4,15 +4,16 @@ type Query { currentUser: User # Get the latest Network Statistics statistics: Statistics! - findPosts(filter: String!, limit: Int = 10): [Post]! + findPosts(query: String!, limit: Int = 10): [Post]! @cypher( statement: """ - CALL db.index.fulltext.queryNodes('full_text_search', $filter) + CALL db.index.fulltext.queryNodes('full_text_search', $query) YIELD node as post, score MATCH (post)<-[:WROTE]-(user:User) WHERE score >= 0.2 AND NOT user.deleted = true AND NOT user.disabled = true AND NOT post.deleted = true AND NOT post.disabled = true + AND NOT user.id in COALESCE($filter.author_not.id_in, []) RETURN post LIMIT $limit """ @@ -50,15 +51,6 @@ type Statistics { countShouts: Int! } -type Notification { - id: ID! - read: Boolean - user: User @relation(name: "NOTIFIED", direction: "OUT") - post: Post @relation(name: "NOTIFIED", direction: "IN") - comment: Comment @relation(name: "NOTIFIED", direction: "IN") - createdAt: String -} - type Location { id: ID! name: String! diff --git a/backend/src/schema/types/type/Category.gql b/backend/src/schema/types/type/Category.gql index 5920ebbdb..9ee628d76 100644 --- a/backend/src/schema/types/type/Category.gql +++ b/backend/src/schema/types/type/Category.gql @@ -10,4 +10,4 @@ type Category { posts: [Post]! @relation(name: "CATEGORIZED", direction: "IN") postCount: Int! @cypher(statement: "MATCH (this)<-[:CATEGORIZED]-(r:Post) RETURN COUNT(r)") -} \ No newline at end of file +} diff --git a/backend/src/schema/types/type/Notification.gql b/backend/src/schema/types/type/Notification.gql new file mode 100644 index 000000000..e4bc16fec --- /dev/null +++ b/backend/src/schema/types/type/Notification.gql @@ -0,0 +1,7 @@ +type Notification { + id: ID! + read: Boolean + user: User @relation(name: "NOTIFIED", direction: "OUT") + post: Post @relation(name: "NOTIFIED", direction: "IN") + createdAt: String +} diff --git a/backend/src/schema/types/type/User.gql b/backend/src/schema/types/type/User.gql index 2534463d1..e3e8f8450 100644 --- a/backend/src/schema/types/type/User.gql +++ b/backend/src/schema/types/type/User.gql @@ -42,6 +42,12 @@ type User { RETURN COUNT(u) >= 1 """ ) + isBlocked: Boolean! @cypher( + statement: """ + MATCH (this)<-[:BLOCKED]-(u:User {id: $cypherParams.currentUserId}) + RETURN COUNT(u) >= 1 + """ + ) #contributions: [WrittenPost]! #contributions2(first: Int = 10, offset: Int = 0): [WrittenPost2]! @@ -67,8 +73,6 @@ type User { organizationsCreated: [Organization] @relation(name: "CREATED_ORGA", direction: "OUT") organizationsOwned: [Organization] @relation(name: "OWNING_ORGA", direction: "OUT") - blacklisted: [User]! @relation(name: "BLACKLISTED", direction: "OUT") - categories: [Category]! @relation(name: "CATEGORIZED", direction: "OUT") badges: [Badge]! @relation(name: "REWARDED", direction: "IN") @@ -148,6 +152,8 @@ type Query { orderBy: [_UserOrdering] filter: _UserFilter ): [User] + + blockedUsers: [User] } type Mutation { @@ -164,4 +170,8 @@ type Mutation { ): User DeleteUser(id: ID!, resource: [Deletable]): User + + + block(id: ID!): User + unblock(id: ID!): User } diff --git a/backend/src/seed/seed-db.js b/backend/src/seed/seed-db.js index 07e4f0625..86f755a24 100644 --- a/backend/src/seed/seed-db.js +++ b/backend/src/seed/seed-db.js @@ -127,73 +127,35 @@ import Factory from './factories' bobDerBaumeister.relateTo(turtle, 'rewarded'), jennyRostock.relateTo(bear, 'rewarded'), dagobert.relateTo(rabbit, 'rewarded'), - ]) - await Promise.all([ - f.relate('User', 'Friends', { - from: 'u1', - to: 'u2', - }), - f.relate('User', 'Friends', { - from: 'u1', - to: 'u3', - }), - f.relate('User', 'Friends', { - from: 'u2', - to: 'u3', - }), - f.relate('User', 'Blacklisted', { - from: 'u7', - to: 'u4', - }), - f.relate('User', 'Blacklisted', { - from: 'u7', - to: 'u5', - }), - f.relate('User', 'Blacklisted', { - from: 'u7', - to: 'u6', - }), - ]) + peterLustig.relateTo(bobDerBaumeister, 'friends'), + peterLustig.relateTo(jennyRostock, 'friends'), + bobDerBaumeister.relateTo(jennyRostock, 'friends'), - await Promise.all([ - asAdmin.follow({ - id: 'u3', - type: 'User', - }), - asModerator.follow({ - id: 'u4', - type: 'User', - }), - asUser.follow({ - id: 'u4', - type: 'User', - }), - asTick.follow({ - id: 'u6', - type: 'User', - }), - asTrick.follow({ - id: 'u4', - type: 'User', - }), - asTrack.follow({ - id: 'u3', - type: 'User', - }), + peterLustig.relateTo(jennyRostock, 'following'), + peterLustig.relateTo(tick, 'following'), + bobDerBaumeister.relateTo(tick, 'following'), + jennyRostock.relateTo(tick, 'following'), + tick.relateTo(track, 'following'), + trick.relateTo(tick, 'following'), + track.relateTo(jennyRostock, 'following'), + + dagobert.relateTo(tick, 'blocked'), + dagobert.relateTo(trick, 'blocked'), + dagobert.relateTo(track, 'blocked'), ]) await Promise.all([ f.create('Category', { id: 'cat1', name: 'Just For Fun', - slug: 'justforfun', + slug: 'just-for-fun', icon: 'smile', }), f.create('Category', { id: 'cat2', - name: 'Happyness & Values', - slug: 'happyness-values', + name: 'Happiness & Values', + slug: 'happiness-values', icon: 'heart-o', }), f.create('Category', { @@ -211,13 +173,13 @@ import Factory from './factories' f.create('Category', { id: 'cat5', name: 'Animal Protection', - slug: 'animalprotection', + slug: 'animal-protection', icon: 'paw', }), f.create('Category', { id: 'cat6', - name: 'Humanrights Justice', - slug: 'humanrights-justice', + name: 'Human Rights & Justice', + slug: 'human-rights-justice', icon: 'balance-scale', }), f.create('Category', { @@ -253,19 +215,19 @@ import Factory from './factories' f.create('Category', { id: 'cat12', name: 'IT, Internet & Data Privacy', - slug: 'it-internet-dataprivacy', + slug: 'it-internet-data-privacy', icon: 'mouse-pointer', }), f.create('Category', { id: 'cat13', - name: 'Art, Curlure & Sport', + name: 'Art, Culture & Sport', slug: 'art-culture-sport', icon: 'paint-brush', }), f.create('Category', { id: 'cat14', name: 'Freedom of Speech', - slug: 'freedomofspeech', + slug: 'freedom-of-speech', icon: 'bullhorn', }), f.create('Category', { @@ -277,7 +239,7 @@ import Factory from './factories' f.create('Category', { id: 'cat16', name: 'Global Peace & Nonviolence', - slug: 'globalpeace-nonviolence', + slug: 'global-peace-nonviolence', icon: 'angellist', }), ]) diff --git a/backend/src/server.js b/backend/src/server.js index 5f13daeea..752cd96fb 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -3,7 +3,7 @@ import helmet from 'helmet' import { ApolloServer } from 'apollo-server-express' import CONFIG, { requiredConfigs } from './config' import middleware from './middleware' -import { getDriver } from './bootstrap/neo4j' +import { neode as getNeode, getDriver } from './bootstrap/neo4j' import decode from './jwt/decode' import schema from './schema' @@ -16,6 +16,7 @@ Object.entries(requiredConfigs).map(entry => { }) const driver = getDriver() +const neode = getNeode() const createServer = options => { const defaults = { @@ -23,6 +24,7 @@ const createServer = options => { const user = await decode(driver, req.headers.authorization) return { driver, + neode, user, req, cypherParams: { diff --git a/backend/yarn.lock b/backend/yarn.lock index de67894e7..2a4ba2d1a 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -707,10 +707,18 @@ pirates "^4.0.0" source-map-support "^0.5.9" -"@babel/runtime@^7.0.0", "@babel/runtime@^7.4.4": - version "7.4.5" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.4.5.tgz#582bb531f5f9dc67d2fcb682979894f75e253f12" - integrity sha512-TuI4qpWZP6lGOGIuGWtp9sPluqYICmbk8T/1vpSysqJxRPkudh/ofFWyqdcMsDf2s7KvDL4/YHgKyvcS3g9CJQ== +"@babel/runtime-corejs2@^7.5.5": + version "7.5.5" + resolved "https://registry.yarnpkg.com/@babel/runtime-corejs2/-/runtime-corejs2-7.5.5.tgz#c3214c08ef20341af4187f1c9fbdc357fbec96b2" + integrity sha512-FYATQVR00NSNi7mUfpPDp7E8RYMXDuO8gaix7u/w3GekfUinKgX1AcTxs7SoiEmoEW9mbpjrwqWSW6zCmw5h8A== + dependencies: + core-js "^2.6.5" + regenerator-runtime "^0.13.2" + +"@babel/runtime@^7.0.0", "@babel/runtime@^7.4.4", "@babel/runtime@^7.5.5": + version "7.5.5" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.5.5.tgz#74fba56d35efbeca444091c7850ccd494fd2f132" + integrity sha512-28QvEGyQyNkB0/m2B4FU7IEZGK2NUrcMtT6BZEFALTguLk+AUT6ofsHtPk5QyjAdUkpMJ+/Em+quwz4HOt30AQ== dependencies: regenerator-runtime "^0.13.2" @@ -5638,7 +5646,7 @@ lodash.unescape@4.0.1: resolved "https://registry.yarnpkg.com/lodash.unescape/-/lodash.unescape-4.0.1.tgz#bf2249886ce514cda112fae9218cdc065211fc9c" integrity sha1-vyJJiGzlFM2hEvrpIYzcBlIR/Jw= -lodash@4.17.15, lodash@^4.13.1, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.4, lodash@^4.17.5, lodash@~4.17.11, lodash@~4.17.14, lodash@~4.17.15: +lodash@4.17.15, lodash@^4.13.1, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.4, lodash@^4.17.5, lodash@~4.17.11, lodash@~4.17.14, lodash@~4.17.15: version "4.17.15" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== @@ -6133,14 +6141,16 @@ neo4j-driver@^1.6.3, neo4j-driver@^1.7.3, neo4j-driver@~1.7.5: text-encoding-utf-8 "^1.0.2" uri-js "^4.2.2" -neo4j-graphql-js@^2.6.3: - version "2.6.3" - resolved "https://registry.yarnpkg.com/neo4j-graphql-js/-/neo4j-graphql-js-2.6.3.tgz#8f28c2479adda08c90abcc32a784587ef49b8b95" - integrity sha512-WZdEqQ8EL9GOIB1ZccbLk1BZz5Dqdbk9i8BDXqxhp1SOI07P9y2cZ244f2Uz4zyES9AVXGmv+861N5xLhrSL2A== +neo4j-graphql-js@^2.7.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/neo4j-graphql-js/-/neo4j-graphql-js-2.7.0.tgz#4f3f94436c494b1488d914022e00155fb7a58112" + integrity sha512-518T6KTDFs9qXa3i+uou7pUi0pBW+Yra2CR7cVcMeUpqJqh8CjxpGz8CJDX7K34MmlSZm3MlA4A1v4tAU1JePw== dependencies: + "@babel/runtime" "^7.5.5" + "@babel/runtime-corejs2" "^7.5.5" graphql "^14.2.1" graphql-auth-directives "^2.1.0" - lodash "^4.17.11" + lodash "^4.17.15" neo4j-driver "^1.7.3" neode@^0.3.1: diff --git a/cypress/integration/common/search.js b/cypress/integration/common/search.js index 5a3819a9d..35b2d1346 100644 --- a/cypress/integration/common/search.js +++ b/cypress/integration/common/search.js @@ -11,6 +11,13 @@ Then("I should have one post in the select dropdown", () => { }); }); +Then("the search has no results", () => { + cy.get(".input .ds-select-dropdown").should($li => { + expect($li).to.have.length(1); + }); + cy.get(".ds-select-dropdown").should("contain", 'Nothing found'); +}); + Then("I should see the following posts in the select dropdown:", table => { table.hashes().forEach(({ title }) => { cy.get(".ds-select-dropdown").should("contain", title); diff --git a/cypress/integration/common/steps.js b/cypress/integration/common/steps.js index 387273eff..e1eab98c1 100644 --- a/cypress/integration/common/steps.js +++ b/cypress/integration/common/steps.js @@ -362,3 +362,96 @@ Then("the notification gets marked as read", () => { Then("there are no notifications in the top menu", () => { cy.get(".notifications-menu").should("contain", "0"); }); + +Given("there is an annoying user called {string}", (name) => { + const annoyingParams = { + email: 'spammy-spammer@example.org', + password: '1234', + } + cy.factory().create('User', { + ...annoyingParams, + id: 'annoying-user', + name + }) +}) + +Given("I am on the profile page of the annoying user", (name) => { + cy.openPage('/profile/annoying-user/spammy-spammer'); +}) + +When("I visit the profile page of the annoying user", (name) => { + cy.openPage('/profile/annoying-user'); +}) + +When("I ", (name) => { + cy.openPage('/profile/annoying-user'); +}) + +When("I click on {string} from the content menu in the user info box", (button) => { + cy.get('.user-content-menu .content-menu-trigger') + .click() + cy.get('.popover .ds-menu-item-link') + .contains(button) + .click() +}) + +When ("I navigate to my {string} settings page", (settingsPage) => { + cy.get(".avatar-menu").click(); + cy.get(".avatar-menu-popover") + .find('a[href]').contains("Settings").click() + cy.contains('.ds-menu-item-link', settingsPage).click() +}) + +Given("I follow the user {string}", (name) => { + cy.neode() + .first('User', { name }).then((followed) => { + cy.neode() + .first('User', {name: narratorParams.name}) + .relateTo(followed, 'following') + }) +}) + +Given("\"Spammy Spammer\" wrote a post {string}", (title) => { + cy.factory() + .authenticateAs({ + email: 'spammy-spammer@example.org', + password: '1234', + }) + .create("Post", { title }) +}) + +Then("the list of posts of this user is empty", () => { + cy.get('.ds-card-content').not('.post-link') + cy.get('.main-container').find('.ds-space.hc-empty') +}) + +Then("nobody is following the user profile anymore", () => { + cy.get('.ds-card-content').not('.post-link') + cy.get('.main-container').contains('.ds-card-content', 'is not followed by anyone') +}) + +Given("I wrote a post {string}", (title) => { + cy.factory() + .authenticateAs(loginCredentials) + .create("Post", { title }) +}) + +When("I block the user {string}", (name) => { + cy.neode() + .first('User', { name }).then((blocked) => { + cy.neode() + .first('User', {name: narratorParams.name}) + .relateTo(blocked, 'blocked') + }) +}) + +When("I log in with:", (table) => { + const [firstRow] = table.hashes() + const { Email, Password } = firstRow + cy.login({email: Email, password: Password}) +}) + +Then("I see only one post with the title {string}", (title) => { + cy.get('.main-container').find('.post-link').should('have.length', 1) + cy.get('.main-container').contains('.post-link', title) +}) diff --git a/cypress/integration/user_profile/blocked-users/Blocking.feature b/cypress/integration/user_profile/blocked-users/Blocking.feature new file mode 100644 index 000000000..9ab4fde6e --- /dev/null +++ b/cypress/integration/user_profile/blocked-users/Blocking.feature @@ -0,0 +1,36 @@ +Feature: Block a User + As a user + I'd like to have a button to block another user + To prevent him from seeing and interacting with my contributions and also to avoid seeing his/her posts + + Background: + Given I have a user account + And there is an annoying user called "Spammy Spammer" + And I am logged in + + Scenario: Block a user + Given I am on the profile page of the annoying user + When I click on "Block user" from the content menu in the user info box + And I navigate to my "Blocked users" settings page + Then I can see the following table: + | Avatar | Name | + | | Spammy Spammer | + + Scenario: Block a previously followed user + Given I follow the user "Spammy Spammer" + And "Spammy Spammer" wrote a post "Spam Spam Spam" + When I visit the profile page of the annoying user + And I click on "Block user" from the content menu in the user info box + Then the list of posts of this user is empty + And nobody is following the user profile anymore + + Scenario: Posts of blocked users are filtered from search results + Given "Spammy Spammer" wrote a post "Spam Spam Spam" + When I search for "Spam" + Then I should see the following posts in the select dropdown: + | title | + | Spam Spam Spam | + When I block the user "Spammy Spammer" + And I refresh the page + And I search for "Spam" + Then the search has no results diff --git a/cypress/integration/user_profile/blocked-users/Content.feature b/cypress/integration/user_profile/blocked-users/Content.feature new file mode 100644 index 000000000..edc0d63b9 --- /dev/null +++ b/cypress/integration/user_profile/blocked-users/Content.feature @@ -0,0 +1,22 @@ +Feature: Block a User + As a user + I'd like to have a button to block another user + To prevent him from seeing and interacting with my contributions and also to avoid seeing his/her posts + + Background: + Given I have a user account + And there is an annoying user called "Spammy Spammer" + + Scenario Outline: Blocked users cannot see each others posts + Given "Spammy Spammer" wrote a post "Spam Spam Spam" + And I wrote a post "I hate spammers" + And I block the user "Spammy Spammer" + When I log in with: + | Email | Password | + | | | + Then I see only one post with the title "" + Examples: + | email | password | expected_title | + | peterpan@example.org | 1234 | I hate spammers | + | spammy-spammer@example.org | 1234 | Spam Spam Spam | + diff --git a/cypress/support/factories.js b/cypress/support/factories.js index 825f65b83..20a4d4c77 100644 --- a/cypress/support/factories.js +++ b/cypress/support/factories.js @@ -1,5 +1,5 @@ import Factory from '../../backend/src/seed/factories' -import { getDriver } from '../../backend/src/bootstrap/neo4j' +import { getDriver, neode as getNeode } from '../../backend/src/bootstrap/neo4j' import setupNeode from '../../backend/src/bootstrap/neode' import neode from 'neode' @@ -16,6 +16,24 @@ beforeEach(async () => { await factory.cleanDatabase({ seedServerHost, neo4jDriver }) }) +Cypress.Commands.add('neode', () => { + return setupNeode(neo4jConfigs) +}) +Cypress.Commands.add( + 'first', + { prevSubject: true }, + async (neode, model, properties) => { + return neode.first(model, properties) + } +) +Cypress.Commands.add( + 'relateTo', + { prevSubject: true }, + async (node, otherNode, relationship) => { + return node.relateTo(otherNode, relationship) + } +) + Cypress.Commands.add('factory', () => { return Factory({ seedServerHost, neo4jDriver, neodeInstance: setupNeode(neo4jConfigs) }) }) diff --git a/deployment/legacy-migration/maintenance-worker/migration/neo4j/categories/categories.cql b/deployment/legacy-migration/maintenance-worker/migration/neo4j/categories/categories.cql index 0862fe0d9..5d4958876 100644 --- a/deployment/legacy-migration/maintenance-worker/migration/neo4j/categories/categories.cql +++ b/deployment/legacy-migration/maintenance-worker/migration/neo4j/categories/categories.cql @@ -43,11 +43,13 @@ c.updatedAt = category.updatedAt.`$date` MATCH (c:Category) WHERE (c.icon = "categories-justforfun") SET c.icon = 'smile' +SET c.slug = 'just-for-fun' ; MATCH (c:Category) WHERE (c.icon = "categories-luck") SET c.icon = 'heart-o' +SET c.slug = 'happiness-values' ; MATCH (c:Category) @@ -63,11 +65,14 @@ SET c.icon = 'tree' MATCH (c:Category) WHERE (c.icon = "categories-animal-justice") SET c.icon = 'paw' +SET c.slug = 'animal-protection' ; MATCH (c:Category) WHERE (c.icon = "categories-human-rights") SET c.icon = 'balance-scale' +SET c.slug = 'human-rights-justice' + ; MATCH (c:Category) @@ -98,6 +103,7 @@ SET c.icon = 'flash' MATCH (c:Category) WHERE (c.icon = "categories-internet") SET c.icon = 'mouse-pointer' +SET c.slug = 'it-internet-data-privacy' ; MATCH (c:Category) @@ -108,6 +114,7 @@ SET c.icon = 'paint-brush' MATCH (c:Category) WHERE (c.icon = "categories-freedom-of-speech") SET c.icon = 'bullhorn' +SET c.slug = 'freedom-of-speech' ; MATCH (c:Category) @@ -118,4 +125,5 @@ SET c.icon = 'shopping-cart' MATCH (c:Category) WHERE (c.icon = "categories-peace") SET c.icon = 'angellist' +SET c.slug = 'global-peace-nonviolence' ; diff --git a/deployment/legacy-migration/maintenance-worker/migration/neo4j/users/users.cql b/deployment/legacy-migration/maintenance-worker/migration/neo4j/users/users.cql index 7574fd3b2..a1045bdeb 100644 --- a/deployment/legacy-migration/maintenance-worker/migration/neo4j/users/users.cql +++ b/deployment/legacy-migration/maintenance-worker/migration/neo4j/users/users.cql @@ -117,7 +117,7 @@ MERGE (e:EmailAddress { verifiedAt: toString(datetime()) }) MERGE (e)-[:BELONGS_TO]->(u) -MERGE (u)<-[:PRIMARY_EMAIL]-(e) +MERGE (u)-[:PRIMARY_EMAIL]->(e) WITH u, user, user.badgeIds AS badgeIds UNWIND badgeIds AS badgeId MATCH (b:Badge {id: badgeId}) diff --git a/webapp/components/CategoriesSelect/CategoriesSelect.vue b/webapp/components/CategoriesSelect/CategoriesSelect.vue index 94276a958..e1928b240 100644 --- a/webapp/components/CategoriesSelect/CategoriesSelect.vue +++ b/webapp/components/CategoriesSelect/CategoriesSelect.vue @@ -10,7 +10,7 @@ :disabled="isDisabled(category.id)" > - {{ category.name }} + {{ $t(`contribution.category.name.${category.slug}`) }} diff --git a/webapp/components/ContentMenu.vue b/webapp/components/ContentMenu.vue index 0ac885597..3b82486fe 100644 --- a/webapp/components/ContentMenu.vue +++ b/webapp/components/ContentMenu.vue @@ -122,13 +122,34 @@ export default { } } - if (this.isOwner && this.resourceType === 'user') { - routes.push({ - name: this.$t(`settings.name`), - path: '/settings', - icon: 'edit', - }) + if (this.resourceType === 'user') { + if (this.isOwner) { + routes.push({ + name: this.$t(`settings.name`), + path: '/settings', + icon: 'edit', + }) + } else { + if (this.resource.isBlocked) { + routes.push({ + name: this.$t(`settings.blocked-users.unblock`), + callback: () => { + this.$emit('unblock', this.resource) + }, + icon: 'user-plus', + }) + } else { + routes.push({ + name: this.$t(`settings.blocked-users.block`), + callback: () => { + this.$emit('block', this.resource) + }, + icon: 'user-times', + }) + } + } } + return routes }, isModerator() { diff --git a/webapp/components/FilterPosts/FilterPostsMenuItems.vue b/webapp/components/FilterPosts/FilterPostsMenuItems.vue index 4db44aa41..c761070b7 100644 --- a/webapp/components/FilterPosts/FilterPostsMenuItems.vue +++ b/webapp/components/FilterPosts/FilterPostsMenuItems.vue @@ -5,7 +5,7 @@ {{ $t('filter-posts.categories.header') }} - + - + diff --git a/webapp/components/Registration/CreateUserAccount.spec.js b/webapp/components/Registration/CreateUserAccount.spec.js index e3f7503a3..a8d63193f 100644 --- a/webapp/components/Registration/CreateUserAccount.spec.js +++ b/webapp/components/Registration/CreateUserAccount.spec.js @@ -56,6 +56,7 @@ describe('CreateUserAccount', () => { wrapper.find('input#name').setValue('John Doe') wrapper.find('input#password').setValue('hellopassword') wrapper.find('input#passwordConfirmation').setValue('hellopassword') + wrapper.find('input#checkbox').setChecked() await wrapper.find('form').trigger('submit') await wrapper.html() } diff --git a/webapp/components/Registration/CreateUserAccount.vue b/webapp/components/Registration/CreateUserAccount.vue index 86267594b..e5b1ee38f 100644 --- a/webapp/components/Registration/CreateUserAccount.vue +++ b/webapp/components/Registration/CreateUserAccount.vue @@ -1,71 +1,82 @@ + + diff --git a/webapp/store/search.js b/webapp/store/search.js index 4aeb5030d..c5ed9523a 100644 --- a/webapp/store/search.js +++ b/webapp/store/search.js @@ -46,8 +46,8 @@ export const actions = { await this.app.apolloProvider.defaultClient .query({ query: gql(` - query findPosts($filter: String!) { - findPosts(filter: $filter, limit: 10) { + query findPosts($query: String!) { + findPosts(query: $query, limit: 10) { id slug label: title @@ -64,7 +64,7 @@ export const actions = { } `), variables: { - filter: value.replace(/\s/g, '~ ') + '~', + query: value.replace(/\s/g, '~ ') + '~', }, }) .then(res => { diff --git a/webapp/yarn.lock b/webapp/yarn.lock index d83591e71..b27a0951f 100644 --- a/webapp/yarn.lock +++ b/webapp/yarn.lock @@ -14184,10 +14184,10 @@ tiptap-commands@^1.10.12: prosemirror-utils "^0.9.6" tiptap-utils "^1.6.1" -tiptap-extensions@~1.26.1: - version "1.26.1" - resolved "https://registry.yarnpkg.com/tiptap-extensions/-/tiptap-extensions-1.26.1.tgz#f0fa492f268f73e4a4313de5de8d22799988ae39" - integrity sha512-Qzr63TOhZLLKw4A/F/EnxwWtdDELA0w6ogk/cKI+xmDUM14RN6VsVoz3QVZyuyzC/ceiQ6dV/iP67PJJ6kvKTw== +tiptap-extensions@~1.26.2: + version "1.26.2" + resolved "https://registry.yarnpkg.com/tiptap-extensions/-/tiptap-extensions-1.26.2.tgz#bf920a8c3586788f4c90690e4e6ee7e26bbc0390" + integrity sha512-UrpDkfZZXJkFd2VRUQAXhYb3M5c0c6JM4wT9yCXfFWLuzz7ifFNPuWnWK7FZOZ6QQpS27ZnYrpQQyQ63xeDSQg== dependencies: lowlight "^1.12.1" prosemirror-collab "^1.1.2" @@ -14198,7 +14198,7 @@ tiptap-extensions@~1.26.1: prosemirror-transform "^1.1.3" prosemirror-utils "^0.9.6" prosemirror-view "^1.9.13" - tiptap "^1.24.1" + tiptap "^1.24.2" tiptap-commands "^1.10.12" tiptap-utils@^1.6.1: @@ -14211,10 +14211,10 @@ tiptap-utils@^1.6.1: prosemirror-tables "^0.9.1" prosemirror-utils "^0.9.6" -tiptap@^1.24.1, tiptap@~1.24.0: - version "1.24.1" - resolved "https://registry.yarnpkg.com/tiptap/-/tiptap-1.24.1.tgz#ecbc4c42be37c26dd73bf3969af1fe20befad8b7" - integrity sha512-/4ABBDoEoDbFCHQQ65clEAjTxqR3DX1PQDsjp7LnYjPiVMGLL11NKnyT1kAn8QNaNGzV+uSSgkROBArcsiF5eA== +tiptap@^1.24.2, tiptap@~1.24.2: + version "1.24.2" + resolved "https://registry.yarnpkg.com/tiptap/-/tiptap-1.24.2.tgz#bdaef96b4c3b6b5091da66ad8ebcd1f4515d1197" + integrity sha512-KRfGmMdHdLmuBg1+nmlFtaqV27rjYrILAgIBcs+paopw2JDP7Hy71HdCJO80Jmr2O5q8dqE4AiMovb9aNrMZtg== dependencies: prosemirror-commands "^1.0.8" prosemirror-dropcursor "^1.1.1" @@ -14889,7 +14889,8 @@ vue-hot-reload-api@^2.3.0: vue-izitoast@roschaefer/vue-izitoast#patch-1: version "1.1.2" - resolved "https://codeload.github.com/roschaefer/vue-izitoast/tar.gz/c246fd78b1964c71b1889683379902d8d6284280" + uid ba6b03eb24c7c04c299e64a9703e101bf158ae50 + resolved "https://codeload.github.com/roschaefer/vue-izitoast/tar.gz/ba6b03eb24c7c04c299e64a9703e101bf158ae50" dependencies: izitoast "^1.3.0"