From 774581f2e062b6331bae879ffd5c27494bb781fb Mon Sep 17 00:00:00 2001 From: mattwr18 Date: Tue, 8 Oct 2019 15:07:08 +0200 Subject: [PATCH 01/22] Allow admins to create a post as pinned - at the moment, it is created as a property of Post, but will be refactored to create a relationship[:PINNED] from the admin to the Post - Co-authored-by: kachulio1 --- .../src/middleware/permissionsMiddleware.js | 7 +- backend/src/schema/resolvers/posts.spec.js | 86 ++++++++++++++++++- backend/src/schema/types/type/Post.gql | 2 + 3 files changed, 90 insertions(+), 5 deletions(-) diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index 31efb9316..9eda4da3b 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -111,6 +111,11 @@ const noEmailFilter = rule({ return !('email' in args) }) +const pinnedPost = rule({ + cache: 'no_cache', +})(async (_, args) => { + return 'pinned' in args +}) const publicRegistration = rule()(() => !!CONFIG.PUBLIC_REGISTRATION) // Permissions @@ -143,7 +148,7 @@ const permissions = shield( SignupVerification: allow, CreateInvitationCode: and(isAuthenticated, or(not(invitationLimitReached), isAdmin)), UpdateUser: onlyYourself, - CreatePost: isAuthenticated, + CreatePost: or(and(isAuthenticated, not(pinnedPost)), isAdmin), UpdatePost: isAuthor, DeletePost: isAuthor, report: isAuthenticated, diff --git a/backend/src/schema/resolvers/posts.spec.js b/backend/src/schema/resolvers/posts.spec.js index 0e7272e8e..eaf2b6f99 100644 --- a/backend/src/schema/resolvers/posts.spec.js +++ b/backend/src/schema/resolvers/posts.spec.js @@ -17,13 +17,21 @@ const categoryIds = ['cat9', 'cat4', 'cat15'] let variables const createPostMutation = gql` - mutation($id: ID, $title: String!, $content: String!, $language: String, $categoryIds: [ID]) { + mutation( + $id: ID + $title: String! + $content: String! + $language: String + $categoryIds: [ID] + $pinned: Boolean + ) { CreatePost( id: $id title: $title content: $content language: $language categoryIds: $categoryIds + pinned: $pinned ) { id title @@ -31,6 +39,7 @@ const createPostMutation = gql` slug disabled deleted + pinned language author { name @@ -357,6 +366,73 @@ describe('CreatePost', () => { }) }) }) + + describe('pinned posts', () => { + beforeEach(async () => { + variables = { ...variables, categoryIds: ['cat9', 'cat27', 'cat15'], pinned: true } + }) + describe('users cannot create pinned posts', () => { + it('throws authorization error', async () => { + await expect(mutate({ mutation: createPostMutation, variables })).resolves.toMatchObject({ + errors: [{ message: 'Not Authorised!' }], + data: { CreatePost: null }, + }) + }) + }) + + describe('moderator cannot create pinned posts', () => { + let moderator + beforeEach(async () => { + moderator = await user.update({ role: 'moderator', updatedAt: new Date().toISOString() }) + authenticatedUser = await moderator.toJson() + }) + + it('throws authorization error', async () => { + await expect(mutate({ mutation: createPostMutation, variables })).resolves.toMatchObject({ + errors: [{ message: 'Not Authorised!' }], + data: { CreatePost: null }, + }) + }) + }) + + describe('admin can create pinned posts', () => { + let admin + beforeEach(async () => { + admin = await user.update({ + role: 'admin', + name: 'Admin', + updatedAt: new Date().toISOString(), + }) + authenticatedUser = await admin.toJson() + variables = { + ...variables, + title: 'pinned post', + content: + 'this is super important for the community and we promise not to have too many', + } + }) + + it('throws authorization error', async () => { + const expected = { + data: { + CreatePost: { + title: 'pinned post', + content: + 'this is super important for the community and we promise not to have too many', + author: { + name: 'Admin', + }, + pinned: true, + }, + }, + } + + await expect(mutate({ mutation: createPostMutation, variables })).resolves.toMatchObject( + expected, + ) + }) + }) + }) }) }) @@ -386,7 +462,6 @@ describe('UpdatePost', () => { }) variables = { - ...variables, id: 'p9876', title: 'New title', content: 'New content', @@ -395,8 +470,11 @@ describe('UpdatePost', () => { describe('unauthenticated', () => { it('throws authorization error', async () => { - const { errors } = await mutate({ mutation: updatePostMutation, variables }) - expect(errors[0]).toHaveProperty('message', 'Not Authorised!') + authenticatedUser = null + expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject({ + errors: [{ message: 'Not Authorised!' }], + data: { UpdatePost: null }, + }) }) }) diff --git a/backend/src/schema/types/type/Post.gql b/backend/src/schema/types/type/Post.gql index 5b11757d3..69305a66d 100644 --- a/backend/src/schema/types/type/Post.gql +++ b/backend/src/schema/types/type/Post.gql @@ -16,6 +16,7 @@ type Post { createdAt: String updatedAt: String language: String + pinned: Boolean relatedContributions: [Post]! @cypher( statement: """ @@ -68,6 +69,7 @@ type Mutation { language: String categoryIds: [ID] contentExcerpt: String + pinned: Boolean ): Post UpdatePost( id: ID! From ab06e8a91febbb521645bf842b61c9e4bd8ba39e Mon Sep 17 00:00:00 2001 From: aonomike Date: Wed, 9 Oct 2019 13:04:35 +0300 Subject: [PATCH 02/22] Add relationship for pinned posts and user - The CreateUser mutation now returns the user who pinned a post and so we can see the user who pinned the post --- backend/src/schema/resolvers/posts.js | 22 +++++++++++++++++----- backend/src/schema/resolvers/posts.spec.js | 13 +++++++++++-- backend/src/schema/types/type/Post.gql | 4 ++-- 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/backend/src/schema/resolvers/posts.js b/backend/src/schema/resolvers/posts.js index e65fa9b76..4b3c11413 100644 --- a/backend/src/schema/resolvers/posts.js +++ b/backend/src/schema/resolvers/posts.js @@ -75,20 +75,29 @@ export default { }, Mutation: { CreatePost: async (_parent, params, context, _resolveInfo) => { - const { categoryIds } = params + const { categoryIds, pinned } = params + delete params.pinned delete params.categoryIds params = await fileUpload(params, { file: 'imageUpload', url: 'image' }) params.id = params.id || uuid() let post - const createPostCypher = `CREATE (post:Post {params}) + let createPostCypher = `CREATE (post:Post {params}) SET post.createdAt = toString(datetime()) SET post.updatedAt = toString(datetime()) WITH post MATCH (author:User {id: $userId}) MERGE (post)<-[:WROTE]-(author) - WITH post - UNWIND $categoryIds AS categoryId + WITH post, author` + + if (pinned) { + createPostCypher += ` + MERGE (post)<-[:PINNED]-(author) + WITH post + ` + } + + createPostCypher += `UNWIND $categoryIds AS categoryId MATCH (category:Category {id: categoryId}) MERGE (post)-[:CATEGORIZED]->(category) RETURN post` @@ -98,7 +107,9 @@ export default { const session = context.driver.session() try { const transactionRes = await session.run(createPostCypher, createPostVariables) - const posts = transactionRes.records.map(record => record.get('post').properties) + const posts = transactionRes.records.map(record => { + return record.get('post').properties + }) post = posts[0] } catch (e) { if (e.code === 'Neo.ClientError.Schema.ConstraintValidationFailed') @@ -225,6 +236,7 @@ export default { hasOne: { author: '<-[:WROTE]-(related:User)', disabledBy: '<-[:DISABLED]-(related:User)', + pinnedBy: '<-[:PINNED]-(related:User)', }, count: { commentsCount: diff --git a/backend/src/schema/resolvers/posts.spec.js b/backend/src/schema/resolvers/posts.spec.js index eaf2b6f99..919454b64 100644 --- a/backend/src/schema/resolvers/posts.spec.js +++ b/backend/src/schema/resolvers/posts.spec.js @@ -39,7 +39,11 @@ const createPostMutation = gql` slug disabled deleted - pinned + pinnedBy { + id + name + role + } language author { name @@ -422,9 +426,14 @@ describe('CreatePost', () => { author: { name: 'Admin', }, - pinned: true, + pinnedBy: { + id: 'current-user', + name: 'Admin', + role: 'admin', + }, }, }, + errors: undefined, } await expect(mutate({ mutation: createPostMutation, variables })).resolves.toMatchObject( diff --git a/backend/src/schema/types/type/Post.gql b/backend/src/schema/types/type/Post.gql index 69305a66d..85e73975f 100644 --- a/backend/src/schema/types/type/Post.gql +++ b/backend/src/schema/types/type/Post.gql @@ -16,7 +16,7 @@ type Post { createdAt: String updatedAt: String language: String - pinned: Boolean + pinnedBy: User @relation(name:"PINNED", direction: "IN") relatedContributions: [Post]! @cypher( statement: """ @@ -41,7 +41,7 @@ type Post { @cypher( statement: "MATCH (this)<-[:SHOUTED]-(r:User) WHERE NOT r.deleted = true AND NOT r.disabled = true RETURN COUNT(DISTINCT r)" ) - + # Has the currently logged in user shouted that post? shoutedByCurrentUser: Boolean! @cypher( From 56d88d6e84c93410e0ba6d5e5e5f16579c022991 Mon Sep 17 00:00:00 2001 From: aonomike Date: Wed, 9 Oct 2019 13:25:41 +0300 Subject: [PATCH 03/22] Resolve failing test --- backend/src/schema/resolvers/posts.js | 3 ++- backend/src/schema/resolvers/posts.spec.js | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/backend/src/schema/resolvers/posts.js b/backend/src/schema/resolvers/posts.js index 4b3c11413..79d3105af 100644 --- a/backend/src/schema/resolvers/posts.js +++ b/backend/src/schema/resolvers/posts.js @@ -97,7 +97,8 @@ export default { ` } - createPostCypher += `UNWIND $categoryIds AS categoryId + createPostCypher += ` + UNWIND $categoryIds AS categoryId MATCH (category:Category {id: categoryId}) MERGE (post)-[:CATEGORIZED]->(category) RETURN post` diff --git a/backend/src/schema/resolvers/posts.spec.js b/backend/src/schema/resolvers/posts.spec.js index 919454b64..f70df708f 100644 --- a/backend/src/schema/resolvers/posts.spec.js +++ b/backend/src/schema/resolvers/posts.spec.js @@ -282,7 +282,10 @@ describe('CreatePost', () => { }) it('creates a post', async () => { - const expected = { data: { CreatePost: { title: 'I am a title', content: 'Some content' } } } + const expected = { + data: { CreatePost: { title: 'I am a title', content: 'Some content' } }, + errors: undefined, + } await expect(mutate({ mutation: createPostMutation, variables })).resolves.toMatchObject( expected, ) @@ -298,6 +301,7 @@ describe('CreatePost', () => { }, }, }, + errors: undefined, } await expect(mutate({ mutation: createPostMutation, variables })).resolves.toMatchObject( expected, From 64f9d02c1a6ab62c3e3ac7d0bb0afd68bc5c43e1 Mon Sep 17 00:00:00 2001 From: mattwr18 Date: Mon, 14 Oct 2019 18:11:00 +0200 Subject: [PATCH 04/22] Start refactoring backend to pin posts on update - we want to give the admins the ability to create posts, then pin them later, or pin other admins posts, etc... --- backend/src/schema/resolvers/posts.spec.js | 152 ++++++++++----------- backend/src/schema/types/type/Post.gql | 5 +- 2 files changed, 79 insertions(+), 78 deletions(-) diff --git a/backend/src/schema/resolvers/posts.spec.js b/backend/src/schema/resolvers/posts.spec.js index f70df708f..fd970c1b1 100644 --- a/backend/src/schema/resolvers/posts.spec.js +++ b/backend/src/schema/resolvers/posts.spec.js @@ -23,7 +23,6 @@ const createPostMutation = gql` $content: String! $language: String $categoryIds: [ID] - $pinned: Boolean ) { CreatePost( id: $id @@ -31,7 +30,6 @@ const createPostMutation = gql` content: $content language: $language categoryIds: $categoryIds - pinned: $pinned ) { id title @@ -52,7 +50,8 @@ const createPostMutation = gql` } ` -beforeAll(() => { +beforeAll(async () => { + await factory.cleanDatabase() const { server } = createServer({ context: () => { return { @@ -374,86 +373,14 @@ describe('CreatePost', () => { }) }) }) - - describe('pinned posts', () => { - beforeEach(async () => { - variables = { ...variables, categoryIds: ['cat9', 'cat27', 'cat15'], pinned: true } - }) - describe('users cannot create pinned posts', () => { - it('throws authorization error', async () => { - await expect(mutate({ mutation: createPostMutation, variables })).resolves.toMatchObject({ - errors: [{ message: 'Not Authorised!' }], - data: { CreatePost: null }, - }) - }) - }) - - describe('moderator cannot create pinned posts', () => { - let moderator - beforeEach(async () => { - moderator = await user.update({ role: 'moderator', updatedAt: new Date().toISOString() }) - authenticatedUser = await moderator.toJson() - }) - - it('throws authorization error', async () => { - await expect(mutate({ mutation: createPostMutation, variables })).resolves.toMatchObject({ - errors: [{ message: 'Not Authorised!' }], - data: { CreatePost: null }, - }) - }) - }) - - describe('admin can create pinned posts', () => { - let admin - beforeEach(async () => { - admin = await user.update({ - role: 'admin', - name: 'Admin', - updatedAt: new Date().toISOString(), - }) - authenticatedUser = await admin.toJson() - variables = { - ...variables, - title: 'pinned post', - content: - 'this is super important for the community and we promise not to have too many', - } - }) - - it('throws authorization error', async () => { - const expected = { - data: { - CreatePost: { - title: 'pinned post', - content: - 'this is super important for the community and we promise not to have too many', - author: { - name: 'Admin', - }, - pinnedBy: { - id: 'current-user', - name: 'Admin', - role: 'admin', - }, - }, - }, - errors: undefined, - } - - await expect(mutate({ mutation: createPostMutation, variables })).resolves.toMatchObject( - expected, - ) - }) - }) - }) }) }) describe('UpdatePost', () => { let author, newlyCreatedPost const updatePostMutation = gql` - mutation($id: ID!, $title: String!, $content: String!, $categoryIds: [ID]) { - UpdatePost(id: $id, title: $title, content: $content, categoryIds: $categoryIds) { + mutation($id: ID!, $title: String!, $content: String!, $categoryIds: [ID], $pinned: Boolean) { + UpdatePost(id: $id, title: $title, content: $content, categoryIds: $categoryIds, pinned: $pinned) { id content categories { @@ -641,6 +568,77 @@ describe('UpdatePost', () => { }) }) }) + + describe('pinned posts', () => { + beforeEach(async () => { + variables = { ...variables, categoryIds: ['cat9', 'cat27', 'cat15'], pinned: true } + }) + describe('users cannot pin posts on update', () => { + it.only('throws authorization error', async () => { + await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject({ + errors: [{ message: 'Not Authorised!' }], + data: { UpdatePost: null }, + }) + }) + }) + + describe('moderator cannot create pinned posts', () => { + let moderator + beforeEach(async () => { + moderator = await user.update({ role: 'moderator', updatedAt: new Date().toISOString() }) + authenticatedUser = await moderator.toJson() + }) + + it('throws authorization error', async () => { + await expect(mutate({ mutation: createPostMutation, variables })).resolves.toMatchObject({ + errors: [{ message: 'Not Authorised!' }], + data: { CreatePost: null }, + }) + }) + }) + + describe('admin can create pinned posts', () => { + let admin + beforeEach(async () => { + admin = await user.update({ + role: 'admin', + name: 'Admin', + updatedAt: new Date().toISOString(), + }) + authenticatedUser = await admin.toJson() + variables = { + ...variables, + title: 'pinned post', + content: 'this is super important for the community and we promise not to have too many', + } + }) + + it('throws authorization error', async () => { + const expected = { + data: { + CreatePost: { + title: 'pinned post', + content: + 'this is super important for the community and we promise not to have too many', + author: { + name: 'Admin', + }, + pinnedBy: { + id: 'current-user', + name: 'Admin', + role: 'admin', + }, + }, + }, + errors: undefined, + } + + await expect(mutate({ mutation: createPostMutation, variables })).resolves.toMatchObject( + expected, + ) + }) + }) + }) }) describe('DeletePost', () => { diff --git a/backend/src/schema/types/type/Post.gql b/backend/src/schema/types/type/Post.gql index 85e73975f..278513e2c 100644 --- a/backend/src/schema/types/type/Post.gql +++ b/backend/src/schema/types/type/Post.gql @@ -16,6 +16,9 @@ type Post { createdAt: String updatedAt: String language: String + pinnedAt: String @cypher( + statement: "MATCH (this)<-[pinned:PINNED]-(:User) WHERE NOT this.deleted = true AND NOT this.disabled = true RETURN this.createdAt" + ) pinnedBy: User @relation(name:"PINNED", direction: "IN") relatedContributions: [Post]! @cypher( @@ -69,7 +72,6 @@ type Mutation { language: String categoryIds: [ID] contentExcerpt: String - pinned: Boolean ): Post UpdatePost( id: ID! @@ -82,6 +84,7 @@ type Mutation { visibility: Visibility language: String categoryIds: [ID] + pinned: Boolean ): Post DeletePost(id: ID!): Post AddPostEmotions(to: _PostInput!, data: _EMOTEDInput!): EMOTED From 0007533b8c8df92534e6bd75c6c2ce326fccfd78 Mon Sep 17 00:00:00 2001 From: mattwr18 Date: Wed, 16 Oct 2019 01:20:17 +0200 Subject: [PATCH 05/22] Add tests that admin can pin anyone's post/limit 1 pinned post --- .../src/middleware/permissionsMiddleware.js | 4 +- backend/src/schema/resolvers/posts.js | 42 ++-- backend/src/schema/resolvers/posts.spec.js | 180 ++++++++++++++---- 3 files changed, 169 insertions(+), 57 deletions(-) diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index 9eda4da3b..690835d91 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -148,8 +148,8 @@ const permissions = shield( SignupVerification: allow, CreateInvitationCode: and(isAuthenticated, or(not(invitationLimitReached), isAdmin)), UpdateUser: onlyYourself, - CreatePost: or(and(isAuthenticated, not(pinnedPost)), isAdmin), - UpdatePost: isAuthor, + CreatePost: isAuthenticated, + UpdatePost: or(and(isAuthor, not(pinnedPost)), isAdmin), DeletePost: isAuthor, report: isAuthenticated, CreateSocialMedia: isAuthenticated, diff --git a/backend/src/schema/resolvers/posts.js b/backend/src/schema/resolvers/posts.js index 79d3105af..4e7f21c40 100644 --- a/backend/src/schema/resolvers/posts.js +++ b/backend/src/schema/resolvers/posts.js @@ -75,29 +75,19 @@ export default { }, Mutation: { CreatePost: async (_parent, params, context, _resolveInfo) => { - const { categoryIds, pinned } = params - delete params.pinned + const { categoryIds } = params delete params.categoryIds params = await fileUpload(params, { file: 'imageUpload', url: 'image' }) params.id = params.id || uuid() let post - let createPostCypher = `CREATE (post:Post {params}) + const createPostCypher = `CREATE (post:Post {params}) SET post.createdAt = toString(datetime()) SET post.updatedAt = toString(datetime()) WITH post MATCH (author:User {id: $userId}) MERGE (post)<-[:WROTE]-(author) - WITH post, author` - - if (pinned) { - createPostCypher += ` - MERGE (post)<-[:PINNED]-(author) - WITH post - ` - } - - createPostCypher += ` + WITH post, author UNWIND $categoryIds AS categoryId MATCH (category:Category {id: categoryId}) MERGE (post)-[:CATEGORIZED]->(category) @@ -123,14 +113,16 @@ export default { return post }, UpdatePost: async (_parent, params, context, _resolveInfo) => { - const { categoryIds } = params + const { categoryIds, pinned } = params + const { id: userId } = context.user + delete params.pinned delete params.categoryIds params = await fileUpload(params, { file: 'imageUpload', url: 'image' }) const session = context.driver.session() - let updatePostCypher = `MATCH (post:Post {id: $params.id}) SET post += $params SET post.updatedAt = toString(datetime()) + WITH post ` if (categoryIds && categoryIds.length) { @@ -143,15 +135,31 @@ export default { await session.run(cypherDeletePreviousRelations, { params }) updatePostCypher += ` - WITH post UNWIND $categoryIds AS categoryId MATCH (category:Category {id: categoryId}) MERGE (post)-[:CATEGORIZED]->(category) + WITH post ` } + if (pinned) { + const cypherDeletePreviousRelations = ` + MATCH ()-[previousRelations:PINNED]->(post:Post) + DELETE previousRelations + RETURN post + ` + + await session.run(cypherDeletePreviousRelations) + + updatePostCypher += ` + MATCH (user:User {id: $userId}) WHERE user.role = 'admin' + MERGE (user)-[:PINNED]->(post) + WITH post + ` + } + updatePostCypher += `RETURN post` - const updatePostVariables = { categoryIds, params } + const updatePostVariables = { categoryIds, params, userId } const transactionRes = await session.run(updatePostCypher, updatePostVariables) const [post] = transactionRes.records.map(record => { diff --git a/backend/src/schema/resolvers/posts.spec.js b/backend/src/schema/resolvers/posts.spec.js index fd970c1b1..f41d442a4 100644 --- a/backend/src/schema/resolvers/posts.spec.js +++ b/backend/src/schema/resolvers/posts.spec.js @@ -17,13 +17,7 @@ const categoryIds = ['cat9', 'cat4', 'cat15'] let variables const createPostMutation = gql` - mutation( - $id: ID - $title: String! - $content: String! - $language: String - $categoryIds: [ID] - ) { + mutation($id: ID, $title: String!, $content: String!, $language: String, $categoryIds: [ID]) { CreatePost( id: $id title: $title @@ -380,9 +374,25 @@ describe('UpdatePost', () => { let author, newlyCreatedPost const updatePostMutation = gql` mutation($id: ID!, $title: String!, $content: String!, $categoryIds: [ID], $pinned: Boolean) { - UpdatePost(id: $id, title: $title, content: $content, categoryIds: $categoryIds, pinned: $pinned) { + UpdatePost( + id: $id + title: $title + content: $content + categoryIds: $categoryIds + pinned: $pinned + ) { id + title content + author { + name + slug + } + pinnedBy { + id + name + role + } categories { id } @@ -568,13 +578,13 @@ describe('UpdatePost', () => { }) }) }) - + describe('pinned posts', () => { beforeEach(async () => { - variables = { ...variables, categoryIds: ['cat9', 'cat27', 'cat15'], pinned: true } + variables = { ...variables, pinned: true } }) - describe('users cannot pin posts on update', () => { - it.only('throws authorization error', async () => { + describe('users cannot pin posts', () => { + it('throws authorization error', async () => { await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject({ errors: [{ message: 'Not Authorised!' }], data: { UpdatePost: null }, @@ -582,7 +592,7 @@ describe('UpdatePost', () => { }) }) - describe('moderator cannot create pinned posts', () => { + describe('moderator cannot pin posts', () => { let moderator beforeEach(async () => { moderator = await user.update({ role: 'moderator', updatedAt: new Date().toISOString() }) @@ -590,14 +600,14 @@ describe('UpdatePost', () => { }) it('throws authorization error', async () => { - await expect(mutate({ mutation: createPostMutation, variables })).resolves.toMatchObject({ + await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject({ errors: [{ message: 'Not Authorised!' }], - data: { CreatePost: null }, + data: { UpdatePost: null }, }) }) }) - describe('admin can create pinned posts', () => { + describe('admin can pin posts', () => { let admin beforeEach(async () => { admin = await user.update({ @@ -605,37 +615,131 @@ describe('UpdatePost', () => { name: 'Admin', updatedAt: new Date().toISOString(), }) - authenticatedUser = await admin.toJson() variables = { ...variables, title: 'pinned post', - content: 'this is super important for the community and we promise not to have too many', + content: 'this is super important for the community', } + authenticatedUser = await admin.toJson() }) - it('throws authorization error', async () => { - const expected = { - data: { - CreatePost: { - title: 'pinned post', - content: - 'this is super important for the community and we promise not to have too many', - author: { - name: 'Admin', - }, - pinnedBy: { - id: 'current-user', - name: 'Admin', - role: 'admin', + describe('post created by them', () => { + beforeEach(async () => { + await factory.create('Post', { + id: 'created-and-pinned-by-same-admin', + author: admin, + }) + }) + + it('responds with the updated Post', async () => { + variables = { ...variables, id: 'created-and-pinned-by-same-admin' } + const expected = { + data: { + UpdatePost: { + title: 'pinned post', + content: 'this is super important for the community', + author: { + name: 'Admin', + }, + pinnedBy: { + id: 'current-user', + name: 'Admin', + role: 'admin', + }, }, }, - }, - errors: undefined, - } + errors: undefined, + } - await expect(mutate({ mutation: createPostMutation, variables })).resolves.toMatchObject( - expected, - ) + await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject( + expected, + ) + }) + }) + + describe('post created by another admin', () => { + let otherAdmin + beforeEach(async () => { + otherAdmin = await factory.create('User', { + role: 'admin', + name: 'otherAdmin', + }) + authenticatedUser = await otherAdmin.toJson() + await factory.create('Post', { + id: 'created-by-one-admin-pinned-by-different-one', + author: otherAdmin, + }) + }) + + it('responds with the updated Post', async () => { + authenticatedUser = await admin.toJson() + variables = { ...variables, id: 'created-by-one-admin-pinned-by-different-one' } + const expected = { + data: { + UpdatePost: { + title: 'pinned post', + content: 'this is super important for the community', + author: { + name: 'otherAdmin', + }, + pinnedBy: { + id: 'current-user', + name: 'Admin', + role: 'admin', + }, + }, + }, + errors: undefined, + } + + await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject( + expected, + ) + }) + }) + + describe('post created by another user', () => { + it('responds with the updated Post', async () => { + const expected = { + data: { + UpdatePost: { + title: 'pinned post', + content: 'this is super important for the community', + author: { + slug: 'the-author', + }, + pinnedBy: { + id: 'current-user', + name: 'Admin', + role: 'admin', + }, + }, + }, + errors: undefined, + } + + await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject( + expected, + ) + }) + }) + + describe('removes other pinned post', () => { + beforeEach(async () => { + await factory.create('Post', { + id: 'only-pinned-post', + author: admin, + }) + }) + + it('leaves only one pinned post at a time', async () => { + expect.assertions(1) + await mutate({ mutation: updatePostMutation, variables }) + variables = { ...variables, id: 'only-pinned-post' } + await mutate({ mutation: updatePostMutation, variables }) + const pinnedPosts = await neode.cypher(`MATCH ()-[:PINNED]->(post:Post) RETURN post`) + expect(pinnedPosts.records).toHaveLength(1) + }) }) }) }) From f1243c6df05d992a9f983a652cbce6e20d5939d6 Mon Sep 17 00:00:00 2001 From: mattwr18 Date: Wed, 16 Oct 2019 01:31:45 +0200 Subject: [PATCH 06/22] Add createdAt attribute to PINNED and test --- backend/src/schema/resolvers/posts.js | 2 +- backend/src/schema/resolvers/posts.spec.js | 21 +++++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/backend/src/schema/resolvers/posts.js b/backend/src/schema/resolvers/posts.js index 4e7f21c40..4a11d98c4 100644 --- a/backend/src/schema/resolvers/posts.js +++ b/backend/src/schema/resolvers/posts.js @@ -153,7 +153,7 @@ export default { updatePostCypher += ` MATCH (user:User {id: $userId}) WHERE user.role = 'admin' - MERGE (user)-[:PINNED]->(post) + MERGE (user)-[:PINNED {createdAt: toString(datetime())}]->(post) WITH post ` } diff --git a/backend/src/schema/resolvers/posts.spec.js b/backend/src/schema/resolvers/posts.spec.js index f41d442a4..d23bf23f0 100644 --- a/backend/src/schema/resolvers/posts.spec.js +++ b/backend/src/schema/resolvers/posts.spec.js @@ -725,20 +725,29 @@ describe('UpdatePost', () => { }) describe('removes other pinned post', () => { + let pinnedPost beforeEach(async () => { await factory.create('Post', { id: 'only-pinned-post', author: admin, }) - }) - - it('leaves only one pinned post at a time', async () => { - expect.assertions(1) await mutate({ mutation: updatePostMutation, variables }) variables = { ...variables, id: 'only-pinned-post' } await mutate({ mutation: updatePostMutation, variables }) - const pinnedPosts = await neode.cypher(`MATCH ()-[:PINNED]->(post:Post) RETURN post`) - expect(pinnedPosts.records).toHaveLength(1) + pinnedPost = await neode.cypher( + `MATCH ()-[relationship:PINNED]->(post:Post) RETURN post, relationship`, + ) + }) + + it('leaves only one pinned post at a time', async () => { + expect(pinnedPost.records).toHaveLength(1) + }) + + it('leaves only one pinned post at a time', async () => { + const [pinnedPostCreatedAt] = pinnedPost.records.map( + record => record.get('relationship').properties.createdAt, + ) + expect(pinnedPostCreatedAt).toEqual(expect.any(String)) }) }) }) From dd55d117397990fb33618034bac324452b9ce030 Mon Sep 17 00:00:00 2001 From: mattwr18 Date: Wed, 16 Oct 2019 15:37:34 +0200 Subject: [PATCH 07/22] Add backend test for PostOrdering --- backend/src/models/User.js | 9 +++ backend/src/schema/resolvers/posts.spec.js | 74 ++++++++++++++++++++-- 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/backend/src/models/User.js b/backend/src/models/User.js index 86900c3ae..30b313774 100644 --- a/backend/src/models/User.js +++ b/backend/src/models/User.js @@ -105,6 +105,15 @@ module.exports = { target: 'Location', direction: 'out', }, + pinned: { + type: 'relationship', + relationship: 'PINNED', + target: 'Post', + direction: 'out', + properties: { + createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, + }, + }, allowEmbedIframes: { type: 'boolean', default: false, diff --git a/backend/src/schema/resolvers/posts.spec.js b/backend/src/schema/resolvers/posts.spec.js index d23bf23f0..9a2fc1227 100644 --- a/backend/src/schema/resolvers/posts.spec.js +++ b/backend/src/schema/resolvers/posts.spec.js @@ -743,13 +743,79 @@ describe('UpdatePost', () => { expect(pinnedPost.records).toHaveLength(1) }) - it('leaves only one pinned post at a time', async () => { - const [pinnedPostCreatedAt] = pinnedPost.records.map( - record => record.get('relationship').properties.createdAt, - ) + it('sets createdAt date for PINNED', () => { + const [pinnedPostCreatedAt] = pinnedPost.records.map(record => { + return record.get('relationship').properties.createdAt + }) expect(pinnedPostCreatedAt).toEqual(expect.any(String)) }) }) + + describe('PostOrdering', () => { + let pinnedPost, postCreatedAfterPinnedPost, newDate, timeInPast, admin + beforeEach(async () => { + ;[pinnedPost, postCreatedAfterPinnedPost] = await Promise.all([ + neode.create('Post', { + id: 'im-a-pinned-post', + }), + neode.create('Post', { + id: 'i-was-created-after-pinned-post', + }), + ]) + newDate = new Date() + timeInPast = newDate.getDate() - 3 + newDate.setDate(timeInPast) + await pinnedPost.update({ + createdAt: newDate.toISOString(), + updatedAt: new Date().toISOString(), + }) + timeInPast = newDate.getDate() + 1 + newDate.setDate(timeInPast) + await postCreatedAfterPinnedPost.update({ + createdAt: newDate.toISOString(), + updatedAt: new Date().toISOString(), + }) + admin = await user.update({ + role: 'admin', + name: 'Admin', + updatedAt: new Date().toISOString(), + }) + await admin.relateTo(pinnedPost, 'pinned', { createdAt: newDate.toISOString() }) + }) + + it('pinned post appear first even when created before other posts', async () => { + const postOrderingQuery = gql` + query($orderBy: [_PostOrdering]) { + Post(orderBy: $orderBy) { + id + pinnedAt + } + } + ` + const expected = { + data: { + Post: [ + { + id: 'im-a-pinned-post', + pinnedAt: expect.any(String), + }, + { + id: 'p9876', + pinnedAt: null, + }, + { + id: 'i-was-created-after-pinned-post', + pinnedAt: null, + }, + ], + }, + } + variables = { orderBy: ['pinnedAt_asc', 'createdAt_desc'] } + await expect(query({ query: postOrderingQuery, variables })).resolves.toMatchObject( + expected, + ) + }) + }) }) }) }) From 36f6be9e36c2d6c4b79121f7eddc5ae9ce86f900 Mon Sep 17 00:00:00 2001 From: mattwr18 Date: Wed, 16 Oct 2019 23:02:58 +0200 Subject: [PATCH 08/22] Support unpinning Post --- backend/src/schema/resolvers/posts.js | 13 ++++++++++++- backend/src/schema/types/type/Post.gql | 1 + backend/src/schema/types/type/User.gql | 1 + 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/backend/src/schema/resolvers/posts.js b/backend/src/schema/resolvers/posts.js index 4a11d98c4..63069e393 100644 --- a/backend/src/schema/resolvers/posts.js +++ b/backend/src/schema/resolvers/posts.js @@ -113,9 +113,10 @@ export default { return post }, UpdatePost: async (_parent, params, context, _resolveInfo) => { - const { categoryIds, pinned } = params + const { categoryIds, pinned, unpinned } = params const { id: userId } = context.user delete params.pinned + delete params.unpinned delete params.categoryIds params = await fileUpload(params, { file: 'imageUpload', url: 'image' }) const session = context.driver.session() @@ -142,6 +143,16 @@ export default { ` } + if (unpinned) { + const cypherRemovePinnedStatus = ` + MATCH ()-[previousRelations:PINNED]->(post:Post {id: $params.id}) + DELETE previousRelations + RETURN post + ` + + await session.run(cypherRemovePinnedStatus, { params }) + } + if (pinned) { const cypherDeletePreviousRelations = ` MATCH ()-[previousRelations:PINNED]->(post:Post) diff --git a/backend/src/schema/types/type/Post.gql b/backend/src/schema/types/type/Post.gql index 278513e2c..2d85d9eb9 100644 --- a/backend/src/schema/types/type/Post.gql +++ b/backend/src/schema/types/type/Post.gql @@ -85,6 +85,7 @@ type Mutation { language: String categoryIds: [ID] pinned: Boolean + unpinned: Boolean ): Post DeletePost(id: ID!): Post AddPostEmotions(to: _PostInput!, data: _EMOTEDInput!): EMOTED diff --git a/backend/src/schema/types/type/User.gql b/backend/src/schema/types/type/User.gql index d9084dd90..5a72832a7 100644 --- a/backend/src/schema/types/type/User.gql +++ b/backend/src/schema/types/type/User.gql @@ -121,6 +121,7 @@ input _UserFilter { followedBy_none: _UserFilter followedBy_single: _UserFilter followedBy_every: _UserFilter + role_in: [UserGroup!] } type Query { From f871df02ae1bb3da9d50cef6a4c89b3db51a40d6 Mon Sep 17 00:00:00 2001 From: mattwr18 Date: Wed, 16 Oct 2019 23:03:45 +0200 Subject: [PATCH 09/22] Start setting up frontend pinning/unpinning - Add pin/unpin post to content menu - Update apollo cache to reactively unpin - Update apollo cache in root path to re-order Posts - Order with pinned post first - Start setting up filters, so that the pinned post is always the first post visible --- webapp/components/ContentMenu.vue | 60 +++++++++++++++++++-------- webapp/components/PostCard/index.vue | 8 ++++ webapp/graphql/Fragments.js | 6 +++ webapp/graphql/PostMutations.js | 9 ++++ webapp/locales/en.json | 6 ++- webapp/pages/index.vue | 36 +++++++++++++++- webapp/pages/post/_id/_slug/index.vue | 34 +++++++++++++++ webapp/pages/profile/_id/_slug.vue | 2 +- webapp/store/postsFilter.js | 39 ++++++++++++----- 9 files changed, 170 insertions(+), 30 deletions(-) diff --git a/webapp/components/ContentMenu.vue b/webapp/components/ContentMenu.vue index 3b82486fe..88dab2b07 100644 --- a/webapp/components/ContentMenu.vue +++ b/webapp/components/ContentMenu.vue @@ -55,24 +55,46 @@ export default { routes() { let routes = [] - if (this.isOwner && this.resourceType === 'contribution') { - routes.push({ - name: this.$t(`post.menu.edit`), - path: this.$router.resolve({ - name: 'post-edit-id', - params: { - id: this.resource.id, + if (this.resourceType === 'contribution') { + if (this.isOwner) { + routes.push({ + name: this.$t(`post.menu.edit`), + path: this.$router.resolve({ + name: 'post-edit-id', + params: { + id: this.resource.id, + }, + }).href, + icon: 'edit', + }) + routes.push({ + name: this.$t(`post.menu.delete`), + callback: () => { + this.openModal('delete') }, - }).href, - icon: 'edit', - }) - routes.push({ - name: this.$t(`post.menu.delete`), - callback: () => { - this.openModal('delete') - }, - icon: 'trash', - }) + icon: 'trash', + }) + } + + if (this.isAdmin) { + if (!this.resource.pinnedBy) { + routes.push({ + name: this.$t(`post.menu.pin`), + callback: () => { + this.$emit('pinPost', this.resource) + }, + icon: 'link', + }) + } else { + routes.push({ + name: this.$t(`post.menu.unpin`), + callback: () => { + this.$emit('unpinPost', this.resource) + }, + icon: 'unlink', + }) + } + } } if (this.isOwner && this.resourceType === 'comment') { @@ -155,6 +177,9 @@ export default { isModerator() { return this.$store.getters['auth/isModerator'] }, + isAdmin() { + return this.$store.getters['auth/isAdmin'] + }, }, methods: { openItem(route, toggleMenu) { @@ -175,6 +200,7 @@ export default { }, }) }, + pinPost() {}, }, } diff --git a/webapp/components/PostCard/index.vue b/webapp/components/PostCard/index.vue index 85b19f105..84c5e8073 100644 --- a/webapp/components/PostCard/index.vue +++ b/webapp/components/PostCard/index.vue @@ -61,6 +61,8 @@ :resource="post" :modalsData="menuModalsData" :is-owner="isAuthor" + @pinPost="pinPost" + @unpinPost="unpinPost" /> @@ -127,6 +129,12 @@ export default { this.$toast.error(err.message) } }, + pinPost(post) { + this.$emit('pinPost', post) + }, + unpinPost(post) { + this.$emit('unpinPost', post) + }, }, } diff --git a/webapp/graphql/Fragments.js b/webapp/graphql/Fragments.js index e0c6e699e..37ec15435 100644 --- a/webapp/graphql/Fragments.js +++ b/webapp/graphql/Fragments.js @@ -57,6 +57,12 @@ export const postFragment = lang => gql` name icon } + pinnedBy { + id + name + role + } + pinnedAt } ` export const commentFragment = lang => gql` diff --git a/webapp/graphql/PostMutations.js b/webapp/graphql/PostMutations.js index fc672c40d..f3528e567 100644 --- a/webapp/graphql/PostMutations.js +++ b/webapp/graphql/PostMutations.js @@ -34,6 +34,8 @@ export default () => { $imageUpload: Upload $categoryIds: [ID] $image: String + $pinned: Boolean + $unpinned: Boolean ) { UpdatePost( id: $id @@ -43,6 +45,8 @@ export default () => { imageUpload: $imageUpload categoryIds: $categoryIds image: $image + pinned: $pinned + unpinned: $unpinned ) { id title @@ -50,6 +54,11 @@ export default () => { content contentExcerpt language + pinnedBy { + id + name + role + } } } `, diff --git a/webapp/locales/en.json b/webapp/locales/en.json index 9860aa457..189f67bc9 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -368,7 +368,11 @@ }, "menu": { "edit": "Edit Post", - "delete": "Delete Post" + "delete": "Delete Post", + "pin": "Pin post", + "pinnedSuccessfully": "Post pinned successfully!", + "unpin": "Unpin post", + "unpinnedSuccessfully": "Post unpinned successfully!" }, "comment": { "submit": "Comment", diff --git a/webapp/pages/index.vue b/webapp/pages/index.vue index 91acb288c..a6f822249 100644 --- a/webapp/pages/index.vue +++ b/webapp/pages/index.vue @@ -21,6 +21,8 @@ :post="post" :width="{ base: '100%', xs: '100%', md: '50%', xl: '33%' }" @removePostFromList="deletePost" + @pinPost="pinPost" + @unpinPost="unpinPost" /> @@ -64,6 +66,7 @@ import MasonryGrid from '~/components/MasonryGrid/MasonryGrid.vue' import MasonryGridItem from '~/components/MasonryGrid/MasonryGridItem.vue' import { mapGetters } from 'vuex' import { filterPosts } from '~/graphql/PostQuery.js' +import PostMutations from '~/graphql/PostMutations' export default { components: { @@ -166,6 +169,37 @@ export default { return post.id !== deletedPost.id }) }, + resetPostList() { + this.offset = 0 + this.posts = [] + this.hasMore = true + }, + pinPost(post) { + this.$apollo + .mutate({ + mutation: PostMutations().UpdatePost, + variables: { id: post.id, title: post.title, content: post.content, pinned: true }, + }) + .then(() => { + this.$toast.success(this.$t('post.menu.pinnedSuccessfully')) + this.resetPostList() + this.$apollo.queries.Post.refetch() + }) + .catch(error => this.$toast.error(error.message)) + }, + unpinPost(post) { + this.$apollo + .mutate({ + mutation: PostMutations().UpdatePost, + variables: { id: post.id, title: post.title, content: post.content, unpinned: true }, + }) + .then(() => { + this.$toast.success(this.$t('post.menu.unpinnedSuccessfully')) + this.resetPostList() + this.$apollo.queries.Post.refetch() + }) + .catch(error => this.$toast.error(error.message)) + }, }, apollo: { Post: { @@ -176,7 +210,7 @@ export default { return { filter: this.finalFilters, first: this.pageSize, - orderBy: this.sorting, + orderBy: ['pinnedAt_asc', this.sorting], offset: 0, } }, diff --git a/webapp/pages/post/_id/_slug/index.vue b/webapp/pages/post/_id/_slug/index.vue index 0cb26b62e..e2de1d15e 100644 --- a/webapp/pages/post/_id/_slug/index.vue +++ b/webapp/pages/post/_id/_slug/index.vue @@ -18,6 +18,8 @@ :resource="post" :modalsData="menuModalsData" :is-owner="isAuthor(post.author ? post.author.id : null)" + @pinPost="pinPost" + @unpinPost="unpinPost" /> @@ -77,6 +79,7 @@ From e37736500a6fe697bce9684972ae23351060989f Mon Sep 17 00:00:00 2001 From: mattwr18 Date: Fri, 18 Oct 2019 19:05:00 +0200 Subject: [PATCH 21/22] Move comment to above relevant code --- webapp/pages/post/_id/_slug/index.spec.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/webapp/pages/post/_id/_slug/index.spec.js b/webapp/pages/post/_id/_slug/index.spec.js index 369b0d29a..b5827db8d 100644 --- a/webapp/pages/post/_id/_slug/index.spec.js +++ b/webapp/pages/post/_id/_slug/index.spec.js @@ -31,10 +31,10 @@ describe('PostSlug', () => { $filters: { truncate: a => a, }, - // If you are mocking the router, then don't use VueRouter with localVue: https://vue-test-utils.vuejs.org/guides/using-with-vue-router.html $route: { hash: '', }, + // If you are mocking the router, then don't use VueRouter with localVue: https://vue-test-utils.vuejs.org/guides/using-with-vue-router.html $router: { history: { push: jest.fn(), @@ -47,9 +47,6 @@ describe('PostSlug', () => { $apollo: { mutate: jest.fn().mockResolvedValue(), }, - $route: { - hash: '', - }, } }) From c2c69a2f7087caa34b40a4911e59e3aef58913a4 Mon Sep 17 00:00:00 2001 From: Alina Beck Date: Mon, 21 Oct 2019 14:34:26 +0300 Subject: [PATCH 22/22] update styling and wording for pinned post ribbons --- webapp/components/PostCard/PostCard.vue | 8 ++++---- webapp/components/Ribbon/index.vue | 8 ++++++++ webapp/locales/de.json | 2 +- webapp/locales/en.json | 2 +- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/webapp/components/PostCard/PostCard.vue b/webapp/components/PostCard/PostCard.vue index e1c63c121..f368fadbb 100644 --- a/webapp/components/PostCard/PostCard.vue +++ b/webapp/components/PostCard/PostCard.vue @@ -1,7 +1,7 @@