diff --git a/backend/Dockerfile b/backend/Dockerfile index f0251bddc..2e8667461 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,4 +1,4 @@ -FROM node:12.4-alpine as base +FROM node:12.5-alpine as base LABEL Description="Backend of the Social Network Human-Connection.org" Vendor="Human Connection gGmbH" Version="0.0.1" Maintainer="Human Connection gGmbH (developer@human-connection.org)" EXPOSE 4000 diff --git a/backend/package.json b/backend/package.json index 565973a67..599e8eac6 100644 --- a/backend/package.json +++ b/backend/package.json @@ -61,7 +61,7 @@ "graphql-custom-directives": "~0.2.14", "graphql-iso-date": "~3.6.1", "graphql-middleware": "~3.0.2", - "graphql-shield": "~5.7.1", + "graphql-shield": "~6.0.2", "graphql-tag": "~2.10.1", "graphql-yoga": "~1.18.0", "helmet": "~3.18.0", diff --git a/backend/src/middleware/nodes/locations.js b/backend/src/middleware/nodes/locations.js index 62d1e3a65..d7abb90ff 100644 --- a/backend/src/middleware/nodes/locations.js +++ b/backend/src/middleware/nodes/locations.js @@ -87,6 +87,9 @@ const createOrUpdateLocations = async (userId, locationName, driver) => { } const session = driver.session() + if (data.place_type.length > 1) { + data.id = 'region.' + data.id.split('.')[1] + } await createLocation(session, data) let parent = data diff --git a/backend/src/middleware/notifications/spec.js b/backend/src/middleware/notifications/spec.js index 985654b0f..d214a5571 100644 --- a/backend/src/middleware/notifications/spec.js +++ b/backend/src/middleware/notifications/spec.js @@ -88,21 +88,26 @@ describe('currentUser { notifications }', () => { describe('who mentions me again', () => { beforeEach(async () => { const updatedContent = `${post.content} One more mention to @al-capone` + const updatedTitle = 'this post has been updated' // The response `post.content` contains a link but the XSSmiddleware // should have the `mention` CSS class removed. I discovered this // during development and thought: A feature not a bug! This way we // can encode a re-mentioning of users when you edit your post or // comment. - const createPostMutation = ` - mutation($id: ID!, $content: String!) { - UpdatePost(id: $id, content: $content) { + const updatePostMutation = ` + mutation($id: ID!, $title: String!, $content: String!) { + UpdatePost(id: $id, title: $title, content: $content) { title content } } ` authorClient = new GraphQLClient(host, { headers: authorHeaders }) - await authorClient.request(createPostMutation, { id: post.id, content: updatedContent }) + await authorClient.request(updatePostMutation, { + id: post.id, + content: updatedContent, + title: updatedTitle, + }) }) it('creates exactly one more notification', async () => { diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index dbcde849c..af4a46d81 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -4,7 +4,9 @@ import { rule, shield, deny, allow, or } from 'graphql-shield' * TODO: implement * See: https://github.com/Human-Connection/Nitro-Backend/pull/40#pullrequestreview-180898363 */ -const isAuthenticated = rule()(async (parent, args, ctx, info) => { +const isAuthenticated = rule({ + cache: 'contextual', +})(async (_parent, _args, ctx, _info) => { return ctx.user !== null }) @@ -105,7 +107,7 @@ const permissions = shield( Query: { '*': deny, findPosts: allow, - Category: isAdmin, + Category: allow, Tag: isAdmin, Report: isModerator, Notification: isAdmin, diff --git a/backend/src/middleware/sluggifyMiddleware.js b/backend/src/middleware/sluggifyMiddleware.js index 2b1f25d5c..226bef8e5 100644 --- a/backend/src/middleware/sluggifyMiddleware.js +++ b/backend/src/middleware/sluggifyMiddleware.js @@ -17,6 +17,10 @@ export default { args.slug = args.slug || (await uniqueSlug(args.title, isUniqueFor(context, 'Post'))) return resolve(root, args, context, info) }, + UpdatePost: async (resolve, root, args, context, info) => { + args.slug = args.slug || (await uniqueSlug(args.title, isUniqueFor(context, 'Post'))) + return resolve(root, args, context, info) + }, CreateUser: async (resolve, root, args, context, info) => { args.slug = args.slug || (await uniqueSlug(args.name, isUniqueFor(context, 'User'))) return resolve(root, args, context, info) diff --git a/backend/src/schema/resolvers/comments.js b/backend/src/schema/resolvers/comments.js index d2e296596..7aef63c59 100644 --- a/backend/src/schema/resolvers/comments.js +++ b/backend/src/schema/resolvers/comments.js @@ -18,9 +18,6 @@ export default { if (!params.content || content.length < COMMENT_MIN_LENGTH) { throw new UserInputError(`Comment must be at least ${COMMENT_MIN_LENGTH} character long!`) } - if (!postId.trim()) { - throw new UserInputError(NO_POST_ERR_MESSAGE) - } const session = context.driver.session() const postQueryRes = await session.run( diff --git a/backend/src/schema/resolvers/comments.spec.js b/backend/src/schema/resolvers/comments.spec.js index 55b946bb9..07462ed49 100644 --- a/backend/src/schema/resolvers/comments.spec.js +++ b/backend/src/schema/resolvers/comments.spec.js @@ -23,7 +23,7 @@ afterEach(async () => { describe('CreateComment', () => { const createCommentMutation = gql` - mutation($postId: ID, $content: String!) { + mutation($postId: ID!, $content: String!) { CreateComment(postId: $postId, content: $content) { id content @@ -37,13 +37,6 @@ describe('CreateComment', () => { } } ` - const commentQueryForPostId = gql` - query($content: String) { - Comment(content: $content) { - postId - } - } - ` describe('unauthenticated', () => { it('throws authorization error', async () => { createCommentVariables = { @@ -191,23 +184,6 @@ describe('CreateComment', () => { client.request(createCommentMutation, createCommentVariablesWithNonExistentPost), ).rejects.toThrow('Comment cannot be created without a post!') }) - - it('does not create the comment with the postId as an attribute', async () => { - const commentQueryVariablesByContent = { - content: "I'm authorised to comment", - } - - await client.request(createCommentMutation, createCommentVariables) - const { Comment } = await client.request( - commentQueryForPostId, - commentQueryVariablesByContent, - ) - expect(Comment).toEqual([ - { - postId: null, - }, - ]) - }) }) }) diff --git a/backend/src/schema/resolvers/fileUpload/index.js b/backend/src/schema/resolvers/fileUpload/index.js index c37d87e39..fa78238c3 100644 --- a/backend/src/schema/resolvers/fileUpload/index.js +++ b/backend/src/schema/resolvers/fileUpload/index.js @@ -12,7 +12,6 @@ const storeUpload = ({ createReadStream, fileLocation }) => export default async function fileUpload(params, { file, url }, uploadCallback = storeUpload) { const upload = params[file] - if (upload) { const { createReadStream, filename } = await upload const { name } = path.parse(filename) diff --git a/backend/src/schema/resolvers/posts.js b/backend/src/schema/resolvers/posts.js index ea962a662..0c8dfb7f0 100644 --- a/backend/src/schema/resolvers/posts.js +++ b/backend/src/schema/resolvers/posts.js @@ -1,30 +1,74 @@ -import { neo4jgraphql } from 'neo4j-graphql-js' +import uuid from 'uuid/v4' import fileUpload from './fileUpload' export default { Mutation: { UpdatePost: async (object, params, context, resolveInfo) => { + const { categoryIds } = params + delete params.categoryIds params = await fileUpload(params, { file: 'imageUpload', url: 'image' }) - return neo4jgraphql(object, params, context, resolveInfo, false) + const session = context.driver.session() + const cypherDeletePreviousRelations = ` + MATCH (post:Post { id: $params.id })-[previousRelations:CATEGORIZED]->(category:Category) + DELETE previousRelations + RETURN post, category + ` + + await session.run(cypherDeletePreviousRelations, { params }) + + let updatePostCypher = `MATCH (post:Post {id: $params.id}) + SET post = $params + ` + if (categoryIds && categoryIds.length) { + updatePostCypher += `WITH post + UNWIND $categoryIds AS categoryId + MATCH (category:Category {id: categoryId}) + MERGE (post)-[:CATEGORIZED]->(category) + ` + } + updatePostCypher += `RETURN post` + const updatePostVariables = { categoryIds, params } + + const transactionRes = await session.run(updatePostCypher, updatePostVariables) + const [post] = transactionRes.records.map(record => { + return record.get('post') + }) + + session.close() + + return post.properties }, CreatePost: async (object, params, context, resolveInfo) => { + const { categoryIds } = params + delete params.categoryIds params = await fileUpload(params, { file: 'imageUpload', url: 'image' }) - const result = await neo4jgraphql(object, params, context, resolveInfo, false) + params.id = params.id || uuid() + let createPostCypher = `CREATE (post:Post {params}) + WITH post + MATCH (author:User {id: $userId}) + MERGE (post)<-[:WROTE]-(author) + ` + if (categoryIds) { + createPostCypher += `WITH post + UNWIND $categoryIds AS categoryId + MATCH (category:Category {id: categoryId}) + MERGE (post)-[:CATEGORIZED]->(category) + ` + } + createPostCypher += `RETURN post` + const createPostVariables = { userId: context.user.id, categoryIds, params } const session = context.driver.session() - await session.run( - 'MATCH (author:User {id: $userId}), (post:Post {id: $postId}) ' + - 'MERGE (post)<-[:WROTE]-(author) ' + - 'RETURN author', - { - userId: context.user.id, - postId: result.id, - }, - ) + const transactionRes = await session.run(createPostCypher, createPostVariables) + + const [post] = transactionRes.records.map(record => { + return record.get('post') + }) + session.close() - return result + return post.properties }, }, } diff --git a/backend/src/schema/resolvers/posts.spec.js b/backend/src/schema/resolvers/posts.spec.js index 3bff53ddb..763945527 100644 --- a/backend/src/schema/resolvers/posts.spec.js +++ b/backend/src/schema/resolvers/posts.spec.js @@ -4,7 +4,34 @@ import { host, login } from '../../jest/helpers' const factory = Factory() let client - +const postTitle = 'I am a title' +const postContent = 'Some content' +const oldTitle = 'Old title' +const oldContent = 'Old content' +const newTitle = 'New title' +const newContent = 'New content' +const createPostVariables = { title: postTitle, content: postContent } +const createPostWithCategoriesMutation = ` + mutation($title: String!, $content: String!, $categoryIds: [ID]) { + CreatePost(title: $title, content: $content, categoryIds: $categoryIds) { + id + } + } +` +const creatPostWithCategoriesVariables = { + title: postTitle, + content: postContent, + categoryIds: ['cat9', 'cat4', 'cat15'], +} +const postQueryWithCategories = ` + query($id: ID) { + Post(id: $id) { + categories { + id + } + } + } +` beforeEach(async () => { await factory.create('User', { email: 'test@example.org', @@ -18,8 +45,8 @@ afterEach(async () => { describe('CreatePost', () => { const mutation = ` - mutation { - CreatePost(title: "I am a title", content: "Some content") { + mutation($title: String!, $content: String!) { + CreatePost(title: $title, content: $content) { title content slug @@ -32,7 +59,7 @@ describe('CreatePost', () => { describe('unauthenticated', () => { it('throws authorization error', async () => { client = new GraphQLClient(host) - await expect(client.request(mutation)).rejects.toThrow('Not Authorised') + await expect(client.request(mutation, createPostVariables)).rejects.toThrow('Not Authorised') }) }) @@ -46,15 +73,15 @@ describe('CreatePost', () => { it('creates a post', async () => { const expected = { CreatePost: { - title: 'I am a title', - content: 'Some content', + title: postTitle, + content: postContent, }, } - await expect(client.request(mutation)).resolves.toMatchObject(expected) + await expect(client.request(mutation, createPostVariables)).resolves.toMatchObject(expected) }) it('assigns the authenticated user as author', async () => { - await client.request(mutation) + await client.request(mutation, createPostVariables) const { User } = await client.request( `{ User(email:"test@example.org") { @@ -65,49 +92,75 @@ describe('CreatePost', () => { }`, { headers }, ) - expect(User).toEqual([{ contributions: [{ title: 'I am a title' }] }]) + expect(User).toEqual([{ contributions: [{ title: postTitle }] }]) }) describe('disabled and deleted', () => { it('initially false', async () => { const expected = { CreatePost: { disabled: false, deleted: false } } - await expect(client.request(mutation)).resolves.toMatchObject(expected) + await expect(client.request(mutation, createPostVariables)).resolves.toMatchObject(expected) }) }) describe('language', () => { it('allows a user to set the language of the post', async () => { const createPostWithLanguageMutation = ` - mutation { - CreatePost(title: "I am a title", content: "Some content", language: "en") { + mutation($title: String!, $content: String!, $language: String) { + CreatePost(title: $title, content: $content, language: $language) { language } } ` + const createPostWithLanguageVariables = { + title: postTitle, + content: postContent, + language: 'en', + } const expected = { CreatePost: { language: 'en' } } - await expect(client.request(createPostWithLanguageMutation)).resolves.toEqual( - expect.objectContaining(expected), + await expect( + client.request(createPostWithLanguageMutation, createPostWithLanguageVariables), + ).resolves.toEqual(expect.objectContaining(expected)) + }) + }) + + describe('categories', () => { + it('allows a user to set the categories of the post', async () => { + await Promise.all([ + factory.create('Category', { + id: 'cat9', + name: 'Democracy & Politics', + icon: 'university', + }), + factory.create('Category', { + id: 'cat4', + name: 'Environment & Nature', + icon: 'tree', + }), + factory.create('Category', { + id: 'cat15', + name: 'Consumption & Sustainability', + icon: 'shopping-cart', + }), + ]) + const expected = [{ id: 'cat9' }, { id: 'cat4' }, { id: 'cat15' }] + const postWithCategories = await client.request( + createPostWithCategoriesMutation, + creatPostWithCategoriesVariables, ) + const postQueryWithCategoriesVariables = { + id: postWithCategories.CreatePost.id, + } + await expect( + client.request(postQueryWithCategories, postQueryWithCategoriesVariables), + ).resolves.toEqual({ Post: [{ categories: expect.arrayContaining(expected) }] }) }) }) }) }) describe('UpdatePost', () => { - const mutation = ` - mutation($id: ID!, $content: String) { - UpdatePost(id: $id, content: $content) { - id - content - } - } - ` - - let variables = { - id: 'p1', - content: 'New content', - } - + let updatePostMutation + let updatePostVariables beforeEach(async () => { const asAuthor = Factory() await asAuthor.create('User', { @@ -120,14 +173,32 @@ describe('UpdatePost', () => { }) await asAuthor.create('Post', { id: 'p1', - content: 'Old content', + title: oldTitle, + content: oldContent, }) + updatePostMutation = ` + mutation($id: ID!, $title: String!, $content: String!, $categoryIds: [ID]) { + UpdatePost(id: $id, title: $title, content: $content, categoryIds: $categoryIds) { + id + content + } + } + ` + + updatePostVariables = { + id: 'p1', + title: newTitle, + content: newContent, + categoryIds: null, + } }) describe('unauthenticated', () => { it('throws authorization error', async () => { client = new GraphQLClient(host) - await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised') + await expect(client.request(updatePostMutation, updatePostVariables)).rejects.toThrow( + 'Not Authorised', + ) }) }) @@ -139,7 +210,9 @@ describe('UpdatePost', () => { }) it('throws authorization error', async () => { - await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised') + await expect(client.request(updatePostMutation, updatePostVariables)).rejects.toThrow( + 'Not Authorised', + ) }) }) @@ -151,8 +224,59 @@ describe('UpdatePost', () => { }) it('updates a post', async () => { - const expected = { UpdatePost: { id: 'p1', content: 'New content' } } - await expect(client.request(mutation, variables)).resolves.toEqual(expected) + const expected = { UpdatePost: { id: 'p1', content: newContent } } + await expect(client.request(updatePostMutation, updatePostVariables)).resolves.toEqual( + expected, + ) + }) + + describe('categories', () => { + let postWithCategories + beforeEach(async () => { + await Promise.all([ + factory.create('Category', { + id: 'cat9', + name: 'Democracy & Politics', + icon: 'university', + }), + factory.create('Category', { + id: 'cat4', + name: 'Environment & Nature', + icon: 'tree', + }), + factory.create('Category', { + id: 'cat15', + name: 'Consumption & Sustainability', + icon: 'shopping-cart', + }), + factory.create('Category', { + id: 'cat27', + name: 'Animal Protection', + icon: 'paw', + }), + ]) + postWithCategories = await client.request( + createPostWithCategoriesMutation, + creatPostWithCategoriesVariables, + ) + updatePostVariables = { + id: postWithCategories.CreatePost.id, + title: newTitle, + content: newContent, + categoryIds: ['cat27'], + } + }) + + it('allows a user to update the categories of a post', async () => { + await client.request(updatePostMutation, updatePostVariables) + const expected = [{ id: 'cat27' }] + const postQueryWithCategoriesVariables = { + id: postWithCategories.CreatePost.id, + } + await expect( + client.request(postQueryWithCategories, postQueryWithCategoriesVariables), + ).resolves.toEqual({ Post: [{ categories: expect.arrayContaining(expected) }] }) + }) }) }) }) diff --git a/backend/src/schema/resolvers/reports.js b/backend/src/schema/resolvers/reports.js index 2c0fbfc75..67c896939 100644 --- a/backend/src/schema/resolvers/reports.js +++ b/backend/src/schema/resolvers/reports.js @@ -11,12 +11,31 @@ export default { description: description, } + const reportQueryRes = await session.run( + ` + match (u:User {id:$submitterId}) -[:REPORTED]->(report)-[:REPORTED]-> (resource {id: $resourceId}) + return labels(resource)[0] as label + `, + { + resourceId: id, + submitterId: user.id, + }, + ) + const [rep] = reportQueryRes.records.map(record => { + return { + label: record.get('label'), + } + }) + + if (rep) { + throw new Error(rep.label) + } const res = await session.run( ` MATCH (submitter:User {id: $userId}) MATCH (resource {id: $resourceId}) WHERE resource:User OR resource:Comment OR resource:Post - CREATE (report:Report $reportData) + MERGE (report:Report {id: {reportData}.id }) MERGE (resource)<-[:REPORTED]-(report) MERGE (report)<-[:REPORTED]-(submitter) RETURN report, submitter, resource, labels(resource)[0] as type @@ -27,6 +46,7 @@ export default { reportData, }, ) + session.close() const [dbResponse] = res.records.map(r => { @@ -59,6 +79,7 @@ export default { response.user = resource.properties break } + return response }, }, diff --git a/backend/src/schema/resolvers/reports.spec.js b/backend/src/schema/resolvers/reports.spec.js index 6b996b016..2a798f5ee 100644 --- a/backend/src/schema/resolvers/reports.spec.js +++ b/backend/src/schema/resolvers/reports.spec.js @@ -13,7 +13,9 @@ describe('report', () => { beforeEach(async () => { returnedObject = '{ description }' - variables = { id: 'whatever' } + variables = { + id: 'whatever', + } headers = {} await factory.create('User', { id: 'u1', @@ -42,7 +44,9 @@ describe('report', () => { ) ${returnedObject} } ` - client = new GraphQLClient(host, { headers }) + client = new GraphQLClient(host, { + headers, + }) return client.request(mutation, variables) } @@ -53,7 +57,10 @@ describe('report', () => { describe('authenticated', () => { beforeEach(async () => { - headers = await login({ email: 'test@example.org', password: '1234' }) + headers = await login({ + email: 'test@example.org', + password: '1234', + }) }) describe('invalid resource id', () => { @@ -66,19 +73,25 @@ describe('report', () => { describe('valid resource id', () => { beforeEach(async () => { - variables = { id: 'u2' } + variables = { + id: 'u2', + } }) - - it('creates a report', async () => { - await expect(action()).resolves.toEqual({ - report: { description: 'Violates code of conduct' }, - }) - }) - + /* + it('creates a report', async () => { + await expect(action()).resolves.toEqual({ + type: null, + }) + }) + */ it('returns the submitter', async () => { returnedObject = '{ submitter { email } }' await expect(action()).resolves.toEqual({ - report: { submitter: { email: 'test@example.org' } }, + report: { + submitter: { + email: 'test@example.org', + }, + }, }) }) @@ -86,50 +99,72 @@ describe('report', () => { it('returns type "User"', async () => { returnedObject = '{ type }' await expect(action()).resolves.toEqual({ - report: { type: 'User' }, + report: { + type: 'User', + }, }) }) it('returns resource in user attribute', async () => { returnedObject = '{ user { name } }' await expect(action()).resolves.toEqual({ - report: { user: { name: 'abusive-user' } }, + report: { + user: { + name: 'abusive-user', + }, + }, }) }) }) describe('reported resource is a post', () => { beforeEach(async () => { - await factory.authenticateAs({ email: 'test@example.org', password: '1234' }) + await factory.authenticateAs({ + email: 'test@example.org', + password: '1234', + }) await factory.create('Post', { id: 'p23', title: 'Matt and Robert having a pair-programming', }) - variables = { id: 'p23' } + variables = { + id: 'p23', + } }) it('returns type "Post"', async () => { returnedObject = '{ type }' await expect(action()).resolves.toEqual({ - report: { type: 'Post' }, + report: { + type: 'Post', + }, }) }) it('returns resource in post attribute', async () => { returnedObject = '{ post { title } }' await expect(action()).resolves.toEqual({ - report: { post: { title: 'Matt and Robert having a pair-programming' } }, + report: { + post: { + title: 'Matt and Robert having a pair-programming', + }, + }, }) }) it('returns null in user attribute', async () => { returnedObject = '{ user { name } }' await expect(action()).resolves.toEqual({ - report: { user: null }, + report: { + user: null, + }, }) }) }) + /* An der Stelle würde ich den p23 noch mal prüfen, diesmal muss aber eine error meldung kommen. + At this point I would check the p23 again, but this time there must be an error message. */ + describe('reported resource is a comment', () => { beforeEach(async () => { createPostVariables = { @@ -147,34 +182,54 @@ describe('report', () => { id: 'c34', content: 'Robert getting tired.', }) - variables = { id: 'c34' } + variables = { + id: 'c34', + } }) it('returns type "Comment"', async () => { returnedObject = '{ type }' await expect(action()).resolves.toEqual({ - report: { type: 'Comment' }, + report: { + type: 'Comment', + }, }) }) it('returns resource in comment attribute', async () => { returnedObject = '{ comment { content } }' await expect(action()).resolves.toEqual({ - report: { comment: { content: 'Robert getting tired.' } }, + report: { + comment: { + content: 'Robert getting tired.', + }, + }, }) }) }) + /* An der Stelle würde ich den c34 noch mal prüfen, diesmal muss aber eine error meldung kommen. + At this point I would check the c34 again, but this time there must be an error message. */ + describe('reported resource is a tag', () => { beforeEach(async () => { - await factory.create('Tag', { id: 't23' }) - variables = { id: 't23' } + await factory.create('Tag', { + id: 't23', + }) + variables = { + id: 't23', + } }) it('returns null', async () => { - await expect(action()).resolves.toEqual({ report: null }) + await expect(action()).resolves.toEqual({ + report: null, + }) }) }) + + /* An der Stelle würde ich den t23 noch mal prüfen, diesmal muss aber eine error meldung kommen. + At this point I would check the t23 again, but this time there must be an error message. */ }) }) }) diff --git a/backend/src/schema/resolvers/users.spec.js b/backend/src/schema/resolvers/users.spec.js index 352d38eaa..9df5473bf 100644 --- a/backend/src/schema/resolvers/users.spec.js +++ b/backend/src/schema/resolvers/users.spec.js @@ -143,7 +143,7 @@ describe('users', () => { let deleteUserVariables let asAuthor const deleteUserMutation = gql` - mutation($id: ID!, $resource: [String]) { + mutation($id: ID!, $resource: [Deletable]) { DeleteUser(id: $id, resource: $resource) { id contributions { diff --git a/backend/src/schema/types/scalar/Upload.gql b/backend/src/schema/types/scalar/Upload.gql index fca9ea1fc..cf3965846 100644 --- a/backend/src/schema/types/scalar/Upload.gql +++ b/backend/src/schema/types/scalar/Upload.gql @@ -1 +1 @@ -scalar Upload \ No newline at end of file +scalar Upload diff --git a/backend/src/schema/types/schema.gql b/backend/src/schema/types/schema.gql index 1ef83bac3..8b0f422c8 100644 --- a/backend/src/schema/types/schema.gql +++ b/backend/src/schema/types/schema.gql @@ -40,7 +40,7 @@ type Mutation { follow(id: ID!, type: FollowTypeEnum): Boolean! # Unfollow the given Type and ID unfollow(id: ID!, type: FollowTypeEnum): Boolean! - DeleteUser(id: ID!, resource: [String]): User + DeleteUser(id: ID!, resource: [Deletable]): User } type Statistics { @@ -92,6 +92,11 @@ type Report { user: User @relation(name: "REPORTED", direction: "OUT") } +enum Deletable { + Post + Comment +} + enum ShoutTypeEnum { Post Organization diff --git a/backend/src/schema/types/type/Comment.gql b/backend/src/schema/types/type/Comment.gql index 077366e8a..441fba179 100644 --- a/backend/src/schema/types/type/Comment.gql +++ b/backend/src/schema/types/type/Comment.gql @@ -1,7 +1,6 @@ type Comment { id: ID! activityId: String - postId: ID author: User @relation(name: "WROTE", direction: "IN") content: String! contentExcerpt: String @@ -11,4 +10,24 @@ type Comment { deleted: Boolean disabled: Boolean disabledBy: User @relation(name: "DISABLED", direction: "IN") -} \ No newline at end of file +} + +type Mutation { + CreateComment( + id: ID + postId: ID! + content: String! + contentExcerpt: String + deleted: Boolean + disabled: Boolean + createdAt: String + ): Comment + UpdateComment( + id: ID! + content: String + contentExcerpt: String + deleted: Boolean + disabled: Boolean + ): Comment + DeleteComment(id: ID!): Comment +} diff --git a/backend/src/schema/types/type/Post.gql b/backend/src/schema/types/type/Post.gql index 271d92750..deb1d8f85 100644 --- a/backend/src/schema/types/type/Post.gql +++ b/backend/src/schema/types/type/Post.gql @@ -49,3 +49,42 @@ type Post { """ ) } + +type Mutation { + CreatePost( + id: ID + activityId: String + objectId: String + title: String! + slug: String + content: String! + image: String + imageUpload: Upload + visibility: Visibility + deleted: Boolean + disabled: Boolean + createdAt: String + updatedAt: String + language: String + categoryIds: [ID] + contentExcerpt: String + ): Post + UpdatePost( + id: ID! + activityId: String + objectId: String + title: String! + slug: String + content: String! + contentExcerpt: String + image: String + imageUpload: Upload + visibility: Visibility + deleted: Boolean + disabled: Boolean + createdAt: String + updatedAt: String + language: String + categoryIds: [ID] + ): Post +} diff --git a/backend/src/seed/factories/comments.js b/backend/src/seed/factories/comments.js index b1079e392..20933e947 100644 --- a/backend/src/seed/factories/comments.js +++ b/backend/src/seed/factories/comments.js @@ -10,7 +10,7 @@ export default function(params) { return { mutation: ` - mutation($id: ID!, $postId: ID, $content: String!) { + mutation($id: ID!, $postId: ID!, $content: String!) { CreateComment(id: $id, postId: $postId, content: $content) { id } diff --git a/backend/yarn.lock b/backend/yarn.lock index 14cec1a81..53075537f 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -1110,10 +1110,10 @@ resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-12.0.9.tgz#693e76a52f61a2f1e7fb48c0eef167b95ea4ffd0" integrity sha512-sCZy4SxP9rN2w30Hlmg5dtdRwgYQfYRiLo9usw8X9cxlf+H4FqM1xX7+sNH7NNKVdbXMJWqva7iyy+fxh/V7fA== -"@types/yup@0.26.17": - version "0.26.17" - resolved "https://registry.yarnpkg.com/@types/yup/-/yup-0.26.17.tgz#5cb7cfc211d8e985b21d88289542591c92cad9dc" - integrity sha512-MN7VHlPsZQ2MTBxLE2Gl+Qfg2WyKsoz+vIr8xN0OSZ4AvJDrrKBlxc8b59UXCCIG9tPn9XhxTXh3j/htHbzC2Q== +"@types/yup@0.26.20": + version "0.26.20" + resolved "https://registry.yarnpkg.com/@types/yup/-/yup-0.26.20.tgz#3b85a05f5dd76e2e8475abb6a8aeae7777627143" + integrity sha512-LpCsA6NG7vIU7Umv1k4w3YGIBH5ZLZRPEKo8vJLHVbBUqRy2WaJ002kbsRqcwODpkICAOMuyGOqLQJa5isZ8+g== "@types/zen-observable@^0.5.3": version "0.5.4" @@ -3788,12 +3788,12 @@ graphql-request@~1.8.2: dependencies: cross-fetch "2.2.2" -graphql-shield@~5.7.1: - version "5.7.1" - resolved "https://registry.yarnpkg.com/graphql-shield/-/graphql-shield-5.7.1.tgz#04095fb8148a463997f7c509d4aeb2a6abf79f98" - integrity sha512-UZ0K1uAqRAoGA1U2DsUu4vIZX2Vents4Xim99GFEUBTgvSDkejiE+k/Dywqfu76lJFEE8qu3vG5fhJN3SmnKbA== +graphql-shield@~6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/graphql-shield/-/graphql-shield-6.0.2.tgz#3ebad8faacbada91b8e576029732e91b5a041c7f" + integrity sha512-3qV2qjeNZla1Fyg6Q2NR5J9AsMaNePLbUboOwhRXB7IcMnTnrxSiVn2R//8VnjnmBjF9rcvgAIAvETZ8AKGfsg== dependencies: - "@types/yup" "0.26.17" + "@types/yup" "0.26.20" lightercollective "^0.3.0" object-hash "^1.3.1" yup "^0.27.0" diff --git a/package.json b/package.json index 856a241de..1446f0009 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "cross-env": "^5.2.0", "cypress": "^3.3.2", "cypress-cucumber-preprocessor": "^1.12.0", - "cypress-file-upload": "^3.1.4", + "cypress-file-upload": "^3.2.0", "cypress-plugin-retries": "^1.2.2", "dotenv": "^8.0.0", "faker": "Marak/faker.js#master", diff --git a/webapp/Dockerfile b/webapp/Dockerfile index feba44c36..9b7f1329c 100644 --- a/webapp/Dockerfile +++ b/webapp/Dockerfile @@ -1,4 +1,4 @@ -FROM node:12.4-alpine as base +FROM node:12.5-alpine as base LABEL Description="Web Frontend of the Social Network Human-Connection.org" Vendor="Human-Connection gGmbH" Version="0.0.1" Maintainer="Human-Connection gGmbH (developer@human-connection.org)" EXPOSE 3000 diff --git a/webapp/components/CategoriesSelect/CategoriesSelect.spec.js b/webapp/components/CategoriesSelect/CategoriesSelect.spec.js new file mode 100644 index 000000000..199dacb74 --- /dev/null +++ b/webapp/components/CategoriesSelect/CategoriesSelect.spec.js @@ -0,0 +1,108 @@ +import { mount, createLocalVue } from '@vue/test-utils' +import CategoriesSelect from './CategoriesSelect' +import Styleguide from '@human-connection/styleguide' + +const localVue = createLocalVue() +localVue.use(Styleguide) + +describe('CategoriesSelect.vue', () => { + let wrapper + let mocks + let democracyAndPolitics + let environmentAndNature + let consumptionAndSustainablity + + const categories = [ + { + id: 'cat9', + name: 'Democracy & Politics', + icon: 'university', + }, + { + id: 'cat4', + name: 'Environment & Nature', + icon: 'tree', + }, + { + id: 'cat15', + name: 'Consumption & Sustainability', + icon: 'shopping-cart', + }, + { + name: 'Cooperation & Development', + icon: 'users', + id: 'cat8', + }, + ] + beforeEach(() => { + mocks = { + $t: jest.fn(), + } + }) + + describe('shallowMount', () => { + const Wrapper = () => { + return mount(CategoriesSelect, { mocks, localVue }) + } + + beforeEach(() => { + wrapper = Wrapper() + }) + + describe('toggleCategory', () => { + beforeEach(() => { + wrapper.vm.categories = categories + democracyAndPolitics = wrapper.findAll('button').at(0) + democracyAndPolitics.trigger('click') + }) + + it('adds categories to selectedCategoryIds when clicked', () => { + expect(wrapper.vm.selectedCategoryIds).toEqual([categories[0].id]) + }) + + it('emits an updateCategories event when the selectedCategoryIds changes', () => { + expect(wrapper.emitted().updateCategories[0][0]).toEqual([categories[0].id]) + }) + + it('removes categories when clicked a second time', () => { + democracyAndPolitics.trigger('click') + expect(wrapper.vm.selectedCategoryIds).toEqual([]) + }) + + it('changes the selectedCount when selectedCategoryIds is updated', () => { + expect(wrapper.vm.selectedCount).toEqual(1) + democracyAndPolitics.trigger('click') + expect(wrapper.vm.selectedCount).toEqual(0) + }) + + it('sets a category to active when it has been selected', () => { + expect(wrapper.vm.isActive(categories[0].id)).toEqual(true) + }) + + describe('maximum', () => { + beforeEach(() => { + environmentAndNature = wrapper.findAll('button').at(1) + consumptionAndSustainablity = wrapper.findAll('button').at(2) + environmentAndNature.trigger('click') + consumptionAndSustainablity.trigger('click') + }) + + it('allows three categories to be selected', () => { + expect(wrapper.vm.selectedCategoryIds).toEqual([ + categories[0].id, + categories[1].id, + categories[2].id, + ]) + }) + + it('sets reachedMaximum to true after three', () => { + expect(wrapper.vm.reachedMaximum).toEqual(true) + }) + + it('sets other categories to disabled after three', () => { + expect(wrapper.vm.isDisabled(categories[3].id)).toEqual(true) + }) + }) + }) + }) +}) diff --git a/webapp/components/CategoriesSelect/CategoriesSelect.vue b/webapp/components/CategoriesSelect/CategoriesSelect.vue new file mode 100644 index 000000000..163f31419 --- /dev/null +++ b/webapp/components/CategoriesSelect/CategoriesSelect.vue @@ -0,0 +1,102 @@ + + + diff --git a/webapp/components/Comment.vue b/webapp/components/Comment.vue index d1311df07..d8f63526f 100644 --- a/webapp/components/Comment.vue +++ b/webapp/components/Comment.vue @@ -1,51 +1,50 @@ diff --git a/webapp/components/ContentMenu.vue b/webapp/components/ContentMenu.vue index 935526867..4a1c2ed19 100644 --- a/webapp/components/ContentMenu.vue +++ b/webapp/components/ContentMenu.vue @@ -46,9 +46,9 @@ export default { modalsData: { type: Object, required: false, - // default: () => { - // return {} - // }, + default: () => { + return {} + }, }, }, computed: { diff --git a/webapp/components/ContributionForm/ContributionForm.spec.js b/webapp/components/ContributionForm/ContributionForm.spec.js index 6856a64b2..0813d16f0 100644 --- a/webapp/components/ContributionForm/ContributionForm.spec.js +++ b/webapp/components/ContributionForm/ContributionForm.spec.js @@ -1,13 +1,17 @@ import { config, mount, createLocalVue } from '@vue/test-utils' -import ContributionForm from './index.vue' +import ContributionForm from './ContributionForm.vue' import Styleguide from '@human-connection/styleguide' import Vuex from 'vuex' import PostMutations from '~/graphql/PostMutations.js' +import CategoriesSelect from '~/components/CategoriesSelect/CategoriesSelect' +import Filters from '~/plugins/vue-filters' +import TeaserImage from '~/components/TeaserImage/TeaserImage' const localVue = createLocalVue() localVue.use(Vuex) localVue.use(Styleguide) +localVue.use(Filters) config.stubs['no-ssr'] = '' @@ -21,7 +25,11 @@ describe('ContributionForm.vue', () => { let propsData const postTitle = 'this is a title for a post' const postContent = 'this is a post' - + const imageUpload = { + file: { filename: 'avataar.svg', previewElement: '' }, + url: 'someUrlToImage', + } + const image = '/uploads/1562010976466-avataaars' beforeEach(() => { mocks = { $t: jest.fn(), @@ -100,7 +108,15 @@ describe('ContributionForm.vue', () => { beforeEach(async () => { expectedParams = { mutation: PostMutations().CreatePost, - variables: { title: postTitle, content: postContent, language: 'en', id: null }, + variables: { + title: postTitle, + content: postContent, + language: 'en', + id: null, + categoryIds: null, + imageUpload: null, + image: null, + }, } postTitleInput = wrapper.find('.ds-input') postTitleInput.setValue(postTitle) @@ -124,6 +140,21 @@ describe('ContributionForm.vue', () => { expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams)) }) + it('supports adding categories', async () => { + const categoryIds = ['cat12', 'cat15', 'cat37'] + expectedParams.variables.categoryIds = categoryIds + wrapper.find(CategoriesSelect).vm.$emit('updateCategories', categoryIds) + await wrapper.find('form').trigger('submit') + expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams)) + }) + + it('supports adding a teaser image', async () => { + expectedParams.variables.imageUpload = imageUpload + wrapper.find(TeaserImage).vm.$emit('addTeaserImage', imageUpload) + await wrapper.find('form').trigger('submit') + expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams)) + }) + it("pushes the user to the post's page", async () => { expect(mocks.$router.push).toHaveBeenCalledTimes(1) }) @@ -143,6 +174,7 @@ describe('ContributionForm.vue', () => { describe('handles errors', () => { beforeEach(async () => { + jest.useFakeTimers() wrapper = Wrapper() postTitleInput = wrapper.find('.ds-input') postTitleInput.setValue(postTitle) @@ -150,6 +182,7 @@ describe('ContributionForm.vue', () => { // second submission causes mutation to reject await wrapper.find('form').trigger('submit') }) + it('shows an error toaster when apollo mutation rejects', async () => { await wrapper.find('form').trigger('submit') await mocks.$apollo.mutate @@ -167,6 +200,8 @@ describe('ContributionForm.vue', () => { title: 'dies ist ein Post', content: 'auf Deutsch geschrieben', language: 'de', + image, + categories: [{ id: 'cat12', name: 'Democracy & Politics' }], }, } wrapper = Wrapper() @@ -188,10 +223,6 @@ describe('ContributionForm.vue', () => { expect(wrapper.vm.form.content).toEqual(propsData.contribution.content) }) - it('sets language equal to contribution language', () => { - expect(wrapper.vm.form.language).toEqual({ value: propsData.contribution.language }) - }) - it('calls the UpdatePost apollo mutation', async () => { expectedParams = { mutation: PostMutations().UpdatePost, @@ -200,6 +231,9 @@ describe('ContributionForm.vue', () => { content: postContent, language: propsData.contribution.language, id: propsData.contribution.id, + categoryIds: ['cat12'], + image, + imageUpload: null, }, } postTitleInput = wrapper.find('.ds-input') @@ -208,6 +242,17 @@ describe('ContributionForm.vue', () => { await wrapper.find('form').trigger('submit') expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams)) }) + + it('supports updating categories', async () => { + const categoryIds = ['cat3', 'cat51', 'cat37'] + postTitleInput = wrapper.find('.ds-input') + postTitleInput.setValue(postTitle) + wrapper.vm.updateEditorContent(postContent) + expectedParams.variables.categoryIds = categoryIds + wrapper.find(CategoriesSelect).vm.$emit('updateCategories', categoryIds) + await wrapper.find('form').trigger('submit') + expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams)) + }) }) }) }) diff --git a/webapp/components/ContributionForm/index.vue b/webapp/components/ContributionForm/ContributionForm.vue similarity index 72% rename from webapp/components/ContributionForm/index.vue rename to webapp/components/ContributionForm/ContributionForm.vue index c925a6dca..c6bb2cdc4 100644 --- a/webapp/components/ContributionForm/index.vue +++ b/webapp/components/ContributionForm/ContributionForm.vue @@ -2,11 +2,23 @@ @@ -50,10 +63,14 @@ import HcEditor from '~/components/Editor' import orderBy from 'lodash/orderBy' import locales from '~/locales' import PostMutations from '~/graphql/PostMutations.js' +import HcCategoriesSelect from '~/components/CategoriesSelect/CategoriesSelect' +import HcTeaserImage from '~/components/TeaserImage/TeaserImage' export default { components: { HcEditor, + HcCategoriesSelect, + HcTeaserImage, }, props: { contribution: { type: Object, default: () => {} }, @@ -63,8 +80,11 @@ export default { form: { title: '', content: '', + teaserImage: null, + image: null, language: null, languageOptions: [], + categoryIds: null, }, formSchema: { title: { required: true, min: 3, max: 64 }, @@ -88,7 +108,8 @@ export default { this.slug = contribution.slug this.form.content = contribution.content this.form.title = contribution.title - this.form.language = { value: contribution.language } + this.form.image = contribution.image + this.form.categoryIds = this.categoryIds(contribution.categories) }, }, }, @@ -106,22 +127,33 @@ export default { }, methods: { submit() { + const { title, content, image, teaserImage, categoryIds } = this.form + let language + if (this.form.language) { + language = this.form.language.value + } else if (this.contribution && this.contribution.language) { + language = this.contribution.language + } else { + language = this.$i18n.locale() + } this.loading = true this.$apollo .mutate({ mutation: this.id ? PostMutations().UpdatePost : PostMutations().CreatePost, variables: { id: this.id, - title: this.form.title, - content: this.form.content, - language: this.form.language ? this.form.language.value : this.$i18n.locale(), + title, + content, + categoryIds, + language, + image, + imageUpload: teaserImage, }, }) .then(res => { this.loading = false this.$toast.success(this.$t('contribution.success')) this.disabled = true - const result = res.data[this.id ? 'UpdatePost' : 'CreatePost'] this.$router.push({ @@ -144,6 +176,19 @@ export default { this.form.languageOptions.push({ label: locale.name, value: locale.code }) }) }, + updateCategories(ids) { + this.form.categoryIds = ids + }, + addTeaserImage(file) { + this.form.teaserImage = file + }, + categoryIds(categories) { + let categoryIds = [] + categories.map(categoryId => { + categoryIds.push(categoryId.id) + }) + return categoryIds + }, }, apollo: { User: { @@ -176,8 +221,4 @@ export default { padding-right: 0; } } - -.contribution-form-footer { - border-top: $border-size-base solid $border-color-softest; -} diff --git a/webapp/components/DeleteData/DeleteData.vue b/webapp/components/DeleteData/DeleteData.vue index 14b6bc9c3..293e65221 100644 --- a/webapp/components/DeleteData/DeleteData.vue +++ b/webapp/components/DeleteData/DeleteData.vue @@ -111,7 +111,7 @@ export default { this.$apollo .mutate({ mutation: gql` - mutation($id: ID!, $resource: [String]) { + mutation($id: ID!, $resource: [Deletable]) { DeleteUser(id: $id, resource: $resource) { id } diff --git a/webapp/components/Modal/ReportModal.vue b/webapp/components/Modal/ReportModal.vue index 54721839e..16b6a469b 100644 --- a/webapp/components/Modal/ReportModal.vue +++ b/webapp/components/Modal/ReportModal.vue @@ -89,8 +89,19 @@ export default { }, 500) }, 1500) } catch (err) { + this.$emit('close') this.success = false - this.$toast.error(err.message) + switch (err.message) { + case 'GraphQL error: User': + this.$toast.error(this.$t('report.user.error')) + break + case 'GraphQL error: Post': + this.$toast.error(this.$t('report.contribution.error')) + break + case 'GraphQL error: Comment': + this.$toast.error(this.$t('report.comment.error')) + break + } } finally { this.loading = false } diff --git a/webapp/components/SearchInput.vue b/webapp/components/SearchInput.vue index be31239bd..098b30610 100644 --- a/webapp/components/SearchInput.vue +++ b/webapp/components/SearchInput.vue @@ -10,9 +10,7 @@ >
- -   - +   - - {{ option.label | truncate(70) }} - + {{ option.label | truncate(70) }} diff --git a/webapp/components/TeaserImage/TeaserImage.spec.js b/webapp/components/TeaserImage/TeaserImage.spec.js new file mode 100644 index 000000000..07b17e16b --- /dev/null +++ b/webapp/components/TeaserImage/TeaserImage.spec.js @@ -0,0 +1,61 @@ +import { mount, createLocalVue } from '@vue/test-utils' +import TeaserImage from './TeaserImage.vue' +import Styleguide from '@human-connection/styleguide' + +const localVue = createLocalVue() + +localVue.use(Styleguide) + +describe('TeaserImage.vue', () => { + let wrapper + let mocks + + beforeEach(() => { + mocks = { + $toast: { + error: jest.fn(), + }, + } + }) + describe('mount', () => { + const Wrapper = () => { + return mount(TeaserImage, { mocks, localVue }) + } + beforeEach(() => { + wrapper = Wrapper() + }) + + describe('File upload', () => { + const imageUpload = [ + { file: { filename: 'avataar.svg', previewElement: '' }, url: 'someUrlToImage' }, + ] + + it('supports adding a teaser image', () => { + wrapper.vm.addTeaserImage(imageUpload) + expect(wrapper.emitted().addTeaserImage[0]).toEqual(imageUpload) + }) + }) + + describe('handles errors', () => { + beforeEach(() => jest.useFakeTimers()) + const message = 'File upload failed' + const fileError = { status: 'error' } + + it('defaults to error false', () => { + expect(wrapper.vm.error).toEqual(false) + }) + + it('shows an error toaster when verror is called', () => { + wrapper.vm.verror(fileError, message) + expect(mocks.$toast.error).toHaveBeenCalledWith(fileError.status, message) + }) + + it('changes error status from false to true to false', () => { + wrapper.vm.verror(fileError, message) + expect(wrapper.vm.error).toEqual(true) + jest.runAllTimers() + expect(wrapper.vm.error).toEqual(false) + }) + }) + }) +}) diff --git a/webapp/components/TeaserImage/TeaserImage.vue b/webapp/components/TeaserImage/TeaserImage.vue new file mode 100644 index 000000000..cb657fe9a --- /dev/null +++ b/webapp/components/TeaserImage/TeaserImage.vue @@ -0,0 +1,197 @@ + + + + diff --git a/webapp/components/Upload/index.vue b/webapp/components/Upload/index.vue index f7f730632..3f84f8a7c 100644 --- a/webapp/components/Upload/index.vue +++ b/webapp/components/Upload/index.vue @@ -9,7 +9,7 @@ @vdropzone-error="verror" >
- +
@@ -22,12 +22,10 @@ +