diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index 31efb9316..a0116a439 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -134,6 +134,7 @@ const permissions = shield( PostsEmotionsByCurrentUser: isAuthenticated, blockedUsers: isAuthenticated, notifications: isAuthenticated, + profilePagePosts: or(onlyEnabledContent, isModerator), }, Mutation: { '*': deny, @@ -174,6 +175,8 @@ const permissions = shield( markAsRead: isAuthenticated, AddEmailAddress: isAuthenticated, VerifyEmailAddress: isAuthenticated, + pinPost: isAdmin, + unpinPost: isAdmin, }, User: { email: or(isMyOwn, isAdmin), diff --git a/backend/src/models/User.js b/backend/src/models/User.js index ec096d10e..b24148f00 100644 --- a/backend/src/models/User.js +++ b/backend/src/models/User.js @@ -114,6 +114,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/helpers/Resolver.js b/backend/src/schema/resolvers/helpers/Resolver.js index 9a6f77513..03c0d4176 100644 --- a/backend/src/schema/resolvers/helpers/Resolver.js +++ b/backend/src/schema/resolvers/helpers/Resolver.js @@ -86,6 +86,7 @@ export default function Resolver(type, options = {}) { } return resolvers } + const result = { ...undefinedToNullResolver(undefinedToNull), ...booleanResolver(boolean), diff --git a/backend/src/schema/resolvers/posts.js b/backend/src/schema/resolvers/posts.js index e65fa9b76..069fb3058 100644 --- a/backend/src/schema/resolvers/posts.js +++ b/backend/src/schema/resolvers/posts.js @@ -2,10 +2,9 @@ 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' +import { mergeWith, isArray, isEmpty } from 'lodash' import { UserInputError } from 'apollo-server' import Resolver from './helpers/Resolver' - const filterForBlockedUsers = async (params, context) => { if (!context.user) return params const [blockedUsers, blockedByUsers] = await Promise.all([ @@ -29,16 +28,31 @@ const filterForBlockedUsers = async (params, context) => { return params } +const maintainPinnedPosts = params => { + const pinnedPostFilter = { pinnedBy_in: { role_in: ['admin'] } } + if (isEmpty(params.filter)) { + params.filter = { OR: [pinnedPostFilter, {}] } + } else { + params.filter = { OR: [pinnedPostFilter, { ...params.filter }] } + } + return params +} + export default { Query: { Post: async (object, params, context, resolveInfo) => { params = await filterForBlockedUsers(params, context) + params = await maintainPinnedPosts(params) 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) }, + profilePagePosts: 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 @@ -115,10 +129,10 @@ export default { 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) { @@ -131,10 +145,10 @@ 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 ` } @@ -211,10 +225,75 @@ export default { }) return emoted }, + pinPost: async (_parent, params, context, _resolveInfo) => { + let pinnedPostWithNestedAttributes + const { driver, user } = context + const session = driver.session() + const { id: userId } = user + let writeTxResultPromise = session.writeTransaction(async transaction => { + const deletePreviousRelationsResponse = await transaction.run( + ` + MATCH (:User)-[previousRelations:PINNED]->(post:Post) + DELETE previousRelations + RETURN post + `, + ) + return deletePreviousRelationsResponse.records.map(record => record.get('post').properties) + }) + await writeTxResultPromise + + writeTxResultPromise = session.writeTransaction(async transaction => { + const pinPostTransactionResponse = await transaction.run( + ` + MATCH (user:User {id: $userId}) WHERE user.role = 'admin' + MATCH (post:Post {id: $params.id}) + MERGE (user)-[pinned:PINNED {createdAt: toString(datetime())}]->(post) + RETURN post, pinned.createdAt as pinnedAt + `, + { userId, params }, + ) + return pinPostTransactionResponse.records.map(record => ({ + pinnedPost: record.get('post').properties, + pinnedAt: record.get('pinnedAt'), + })) + }) + try { + const [transactionResult] = await writeTxResultPromise + const { pinnedPost, pinnedAt } = transactionResult + pinnedPostWithNestedAttributes = { + ...pinnedPost, + pinnedAt, + } + } finally { + session.close() + } + return pinnedPostWithNestedAttributes + }, + unpinPost: async (_parent, params, context, _resolveInfo) => { + let unpinnedPost + const session = context.driver.session() + const writeTxResultPromise = session.writeTransaction(async transaction => { + const unpinPostTransactionResponse = await transaction.run( + ` + MATCH (:User)-[previousRelations:PINNED]->(post:Post {id: $params.id}) + DELETE previousRelations + RETURN post + `, + { params }, + ) + return unpinPostTransactionResponse.records.map(record => record.get('post').properties) + }) + try { + ;[unpinnedPost] = await writeTxResultPromise + } finally { + session.close() + } + return unpinnedPost + }, }, Post: { ...Resolver('Post', { - undefinedToNull: ['activityId', 'objectId', 'image', 'language'], + undefinedToNull: ['activityId', 'objectId', 'image', 'language', 'pinnedAt'], hasMany: { tags: '-[:TAGGED]->(related:Tag)', categories: '-[:CATEGORIZED]->(related:Category)', @@ -225,6 +304,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 0e7272e8e..da4a49dba 100644 --- a/backend/src/schema/resolvers/posts.spec.js +++ b/backend/src/schema/resolvers/posts.spec.js @@ -39,7 +39,8 @@ const createPostMutation = gql` } ` -beforeAll(() => { +beforeAll(async () => { + await factory.cleanDatabase() const { server } = createServer({ context: () => { return { @@ -269,7 +270,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, ) @@ -285,6 +289,7 @@ describe('CreatePost', () => { }, }, }, + errors: undefined, } await expect(mutate({ mutation: createPostMutation, variables })).resolves.toMatchObject( expected, @@ -366,7 +371,12 @@ describe('UpdatePost', () => { mutation($id: ID!, $title: String!, $content: String!, $categoryIds: [ID]) { UpdatePost(id: $id, title: $title, content: $content, categoryIds: $categoryIds) { id + title content + author { + name + slug + } categories { id } @@ -386,7 +396,6 @@ describe('UpdatePost', () => { }) variables = { - ...variables, id: 'p9876', title: 'New title', content: 'New content', @@ -395,8 +404,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 }, + }) }) }) @@ -550,6 +562,371 @@ describe('UpdatePost', () => { }) }) }) + + describe('pin posts', () => { + const pinPostMutation = gql` + mutation($id: ID!) { + pinPost(id: $id) { + id + title + content + author { + name + slug + } + pinnedBy { + id + name + role + } + createdAt + updatedAt + pinnedAt + } + } + ` + beforeEach(async () => { + variables = { ...variables } + }) + + describe('unauthenticated', () => { + it('throws authorization error', async () => { + authenticatedUser = null + await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject({ + errors: [{ message: 'Not Authorised!' }], + data: { pinPost: null }, + }) + }) + }) + + describe('users cannot pin posts', () => { + it('throws authorization error', async () => { + await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject({ + errors: [{ message: 'Not Authorised!' }], + data: { pinPost: null }, + }) + }) + }) + + describe('moderators cannot pin 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: pinPostMutation, variables })).resolves.toMatchObject({ + errors: [{ message: 'Not Authorised!' }], + data: { pinPost: null }, + }) + }) + }) + + describe('admin can pin posts', () => { + let admin + beforeEach(async () => { + admin = await user.update({ + role: 'admin', + name: 'Admin', + updatedAt: new Date().toISOString(), + }) + authenticatedUser = await admin.toJson() + }) + + 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: { + pinPost: { + id: 'created-and-pinned-by-same-admin', + author: { + name: 'Admin', + }, + pinnedBy: { + id: 'current-user', + name: 'Admin', + role: 'admin', + }, + }, + }, + errors: undefined, + } + + await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject( + expected, + ) + }) + + it('sets createdAt date for PINNED', async () => { + variables = { ...variables, id: 'created-and-pinned-by-same-admin' } + const expected = { + data: { + pinPost: { + id: 'created-and-pinned-by-same-admin', + pinnedAt: expect.any(String), + }, + }, + errors: undefined, + } + await expect(mutate({ mutation: pinPostMutation, 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: { + pinPost: { + id: 'created-by-one-admin-pinned-by-different-one', + author: { + name: 'otherAdmin', + }, + pinnedBy: { + id: 'current-user', + name: 'Admin', + role: 'admin', + }, + }, + }, + errors: undefined, + } + + await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject( + expected, + ) + }) + }) + + describe('post created by another user', () => { + it('responds with the updated Post', async () => { + const expected = { + data: { + pinPost: { + id: 'p9876', + author: { + slug: 'the-author', + }, + pinnedBy: { + id: 'current-user', + name: 'Admin', + role: 'admin', + }, + }, + }, + errors: undefined, + } + + await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject( + expected, + ) + }) + }) + + describe('removes other pinned post', () => { + let pinnedPost + beforeEach(async () => { + await factory.create('Post', { + id: 'only-pinned-post', + author: admin, + }) + await mutate({ mutation: pinPostMutation, variables }) + variables = { ...variables, id: 'only-pinned-post' } + await mutate({ mutation: pinPostMutation, variables }) + pinnedPost = await neode.cypher( + `MATCH (:User)-[pinned:PINNED]->(post:Post) RETURN post, pinned`, + ) + }) + + it('leaves only one pinned post at a time', async () => { + expect(pinnedPost.records).toHaveLength(1) + }) + }) + + 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') + }) + + 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, + ) + }) + }) + }) + }) + + describe('unpin posts', () => { + const unpinPostMutation = gql` + mutation($id: ID!) { + unpinPost(id: $id) { + id + title + content + author { + name + slug + } + pinnedBy { + id + name + role + } + createdAt + updatedAt + } + } + ` + beforeEach(async () => { + variables = { ...variables } + }) + + describe('unauthenticated', () => { + it('throws authorization error', async () => { + authenticatedUser = null + await expect(mutate({ mutation: unpinPostMutation, variables })).resolves.toMatchObject({ + errors: [{ message: 'Not Authorised!' }], + data: { unpinPost: null }, + }) + }) + }) + + describe('users cannot unpin posts', () => { + it('throws authorization error', async () => { + await expect(mutate({ mutation: unpinPostMutation, variables })).resolves.toMatchObject({ + errors: [{ message: 'Not Authorised!' }], + data: { unpinPost: null }, + }) + }) + }) + + describe('moderators cannot unpin 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: unpinPostMutation, variables })).resolves.toMatchObject({ + errors: [{ message: 'Not Authorised!' }], + data: { unpinPost: null }, + }) + }) + }) + + describe('admin can unpin posts', () => { + let admin, pinnedPost + beforeEach(async () => { + pinnedPost = await factory.create('Post', { id: 'post-to-be-unpinned' }) + admin = await user.update({ + role: 'admin', + name: 'Admin', + updatedAt: new Date().toISOString(), + }) + authenticatedUser = await admin.toJson() + await admin.relateTo(pinnedPost, 'pinned', { createdAt: new Date().toISOString() }) + }) + + it('responds with the unpinned Post', async () => { + authenticatedUser = await admin.toJson() + variables = { ...variables, id: 'post-to-be-unpinned' } + const expected = { + data: { + unpinPost: { + id: 'post-to-be-unpinned', + pinnedBy: null, + }, + }, + errors: undefined, + } + + await expect(mutate({ mutation: unpinPostMutation, 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 5b11757d3..f917b2c3e 100644 --- a/backend/src/schema/types/type/Post.gql +++ b/backend/src/schema/types/type/Post.gql @@ -16,6 +16,10 @@ 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 pinned.createdAt" + ) + pinnedBy: User @relation(name:"PINNED", direction: "IN") relatedContributions: [Post]! @cypher( statement: """ @@ -40,7 +44,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( @@ -84,9 +88,12 @@ type Mutation { DeletePost(id: ID!): Post AddPostEmotions(to: _PostInput!, data: _EMOTEDInput!): EMOTED RemovePostEmotions(to: _PostInput!, data: _EMOTEDInput!): EMOTED + pinPost(id: ID!): Post + unpinPost(id: ID!): Post } type Query { PostsEmotionsCountByEmotion(postId: ID!, data: _EMOTEDInput!): Int! PostsEmotionsByCurrentUser(postId: ID!): [String] + profilePagePosts(filter: _PostFilter, first: Int, offset: Int, orderBy: [_PostOrdering]): [Post] } diff --git a/backend/src/schema/types/type/User.gql b/backend/src/schema/types/type/User.gql index 1f46dc6cd..cce0df058 100644 --- a/backend/src/schema/types/type/User.gql +++ b/backend/src/schema/types/type/User.gql @@ -1,182 +1,181 @@ type User { - id: ID! - actorId: String - name: String - email: String! @cypher(statement: "MATCH (this)-[: PRIMARY_EMAIL]->(e: EmailAddress) RETURN e.email") - slug: String! - avatar: String - coverImg: String - deleted: Boolean - disabled: Boolean - disabledBy: User @relation(name: "DISABLED", direction: "IN") - role: UserGroup! - publicKey: String - invitedBy: User @relation(name: "INVITED", direction: "IN") - invited: [User] @relation(name: "INVITED", direction: "OUT") + id: ID! + actorId: String + name: String + email: String! @cypher(statement: "MATCH (this)-[: PRIMARY_EMAIL]->(e: EmailAddress) RETURN e.email") + slug: String! + avatar: String + coverImg: String + deleted: Boolean + disabled: Boolean + disabledBy: User @relation(name: "DISABLED", direction: "IN") + role: UserGroup! + publicKey: String + invitedBy: User @relation(name: "INVITED", direction: "IN") + invited: [User] @relation(name: "INVITED", direction: "OUT") - location: Location @cypher(statement: "MATCH (this)-[: IS_IN]->(l: Location) RETURN l") - locationName: String - about: String - socialMedia: [SocialMedia]! @relation(name: "OWNED_BY", direction: "IN") + location: Location @cypher(statement: "MATCH (this)-[: IS_IN]->(l: Location) RETURN l") + locationName: String + about: String + socialMedia: [SocialMedia]! @relation(name: "OWNED_BY", direction: "IN") - # createdAt: DateTime - # updatedAt: DateTime - createdAt: String - updatedAt: String + # createdAt: DateTime + # updatedAt: DateTime + createdAt: String + updatedAt: String - termsAndConditionsAgreedVersion: String - termsAndConditionsAgreedAt: String + termsAndConditionsAgreedVersion: String + termsAndConditionsAgreedAt: String - allowEmbedIframes: Boolean + allowEmbedIframes: Boolean + locale: String + friends: [User]! @relation(name: "FRIENDS", direction: "BOTH") + friendsCount: Int! @cypher(statement: "MATCH (this)<-[: FRIENDS]->(r: User) RETURN COUNT(DISTINCT r)") - locale: String + following: [User]! @relation(name: "FOLLOWS", direction: "OUT") + followingCount: Int! @cypher(statement: "MATCH (this)-[: FOLLOWS]->(r: User) RETURN COUNT(DISTINCT r)") - friends: [User]! @relation(name: "FRIENDS", direction: "BOTH") - friendsCount: Int! @cypher(statement: "MATCH (this)<-[: FRIENDS]->(r: User) RETURN COUNT(DISTINCT r)") + followedBy: [User]! @relation(name: "FOLLOWS", direction: "IN") + followedByCount: Int! @cypher(statement: "MATCH (this)<-[: FOLLOWS]-(r: User) RETURN COUNT(DISTINCT r)") - following: [User]! @relation(name: "FOLLOWS", direction: "OUT") - followingCount: Int! @cypher(statement: "MATCH (this)-[: FOLLOWS]->(r: User) RETURN COUNT(DISTINCT r)") + # Is the currently logged in user following that user? + followedByCurrentUser: Boolean! @cypher( + statement: """ + MATCH (this)<-[: FOLLOWS]-(u: User { id: $cypherParams.currentUserId}) + RETURN COUNT(u) >= 1 + """ + ) + isBlocked: Boolean! @cypher( + statement: """ + MATCH (this)<-[: BLOCKED]-(u: User { id: $cypherParams.currentUserId}) + RETURN COUNT(u) >= 1 + """ + ) - followedBy: [User]! @relation(name: "FOLLOWS", direction: "IN") - followedByCount: Int! @cypher(statement: "MATCH (this)<-[: FOLLOWS]-(r: User) RETURN COUNT(DISTINCT r)") + # contributions: [WrittenPost]! + # contributions2(first: Int = 10, offset: Int = 0): [WrittenPost2]! + # @cypher( + # statement: "MATCH (this)-[w:WROTE]->(p:Post) RETURN p as Post, w.timestamp as timestamp" + # ) + contributions: [Post]! @relation(name: "WROTE", direction: "OUT") + contributionsCount: Int! @cypher( + statement: """ + MATCH (this)-[: WROTE]->(r: Post) + WHERE NOT r.deleted = true AND NOT r.disabled = true + RETURN COUNT(r) + """ + ) - # Is the currently logged in user following that user? - followedByCurrentUser: Boolean! @cypher( - statement: """ - MATCH (this)<-[: FOLLOWS]-(u: User { id: $cypherParams.currentUserId}) - RETURN COUNT(u) >= 1 - """ - ) - isBlocked: Boolean! @cypher( - statement: """ - MATCH (this)<-[: BLOCKED]-(u: User { id: $cypherParams.currentUserId}) - RETURN COUNT(u) >= 1 - """ - ) + comments: [Comment]! @relation(name: "WROTE", direction: "OUT") + commentedCount: Int! @cypher(statement: "MATCH (this)-[: WROTE]->(: Comment)-[: COMMENTS]->(p: Post) WHERE NOT p.deleted = true AND NOT p.disabled = true RETURN COUNT(DISTINCT(p))") - # contributions: [WrittenPost]! - # contributions2(first: Int = 10, offset: Int = 0): [WrittenPost2]! - # @cypher( - # statement: "MATCH (this)-[w:WROTE]->(p:Post) RETURN p as Post, w.timestamp as timestamp" - # ) - contributions: [Post]! @relation(name: "WROTE", direction: "OUT") - contributionsCount: Int! @cypher( - statement: """ - MATCH (this)-[: WROTE]->(r: Post) - WHERE NOT r.deleted = true AND NOT r.disabled = true - RETURN COUNT(r) - """ - ) + shouted: [Post]! @relation(name: "SHOUTED", direction: "OUT") + shoutedCount: Int! @cypher(statement: "MATCH (this)-[: SHOUTED]->(r: Post) WHERE NOT r.deleted = true AND NOT r.disabled = true RETURN COUNT(DISTINCT r)") - comments: [Comment]! @relation(name: "WROTE", direction: "OUT") - commentedCount: Int! @cypher(statement: "MATCH (this)-[: WROTE]->(: Comment)-[: COMMENTS]->(p: Post) WHERE NOT p.deleted = true AND NOT p.disabled = true RETURN COUNT(DISTINCT(p))") + categories: [Category]! @relation(name: "CATEGORIZED", direction: "OUT") - shouted: [Post]! @relation(name: "SHOUTED", direction: "OUT") - shoutedCount: Int! @cypher(statement: "MATCH (this)-[: SHOUTED]->(r: Post) WHERE NOT r.deleted = true AND NOT r.disabled = true RETURN COUNT(DISTINCT r)") + badges: [Badge]! @relation(name: "REWARDED", direction: "IN") + badgesCount: Int! @cypher(statement: "MATCH (this)<-[: REWARDED]-(r: Badge) RETURN COUNT(r)") - categories: [Category]! @relation(name: "CATEGORIZED", direction: "OUT") - - badges: [Badge]! @relation(name: "REWARDED", direction: "IN") - badgesCount: Int! @cypher(statement: "MATCH (this)<-[: REWARDED]-(r: Badge) RETURN COUNT(r)") - - emotions: [EMOTED] + emotions: [EMOTED] } input _UserFilter { - AND: [_UserFilter!] - OR: [_UserFilter!] - name_contains: String - about_contains: String - slug_contains: String - id: ID - id_not: ID - id_in: [ID!] - id_not_in: [ID!] - id_contains: ID - id_not_contains: ID - id_starts_with: ID - id_not_starts_with: ID - id_ends_with: ID - id_not_ends_with: ID - friends: _UserFilter - friends_not: _UserFilter - friends_in: [_UserFilter!] - friends_not_in: [_UserFilter!] - friends_some: _UserFilter - friends_none: _UserFilter - friends_single: _UserFilter - friends_every: _UserFilter - following: _UserFilter - following_not: _UserFilter - following_in: [_UserFilter!] - following_not_in: [_UserFilter!] - following_some: _UserFilter - following_none: _UserFilter - following_single: _UserFilter - following_every: _UserFilter - followedBy: _UserFilter - followedBy_not: _UserFilter - followedBy_in: [_UserFilter!] - followedBy_not_in: [_UserFilter!] - followedBy_some: _UserFilter - followedBy_none: _UserFilter - followedBy_single: _UserFilter - followedBy_every: _UserFilter + AND: [_UserFilter!] + OR: [_UserFilter!] + name_contains: String + about_contains: String + slug_contains: String + id: ID + id_not: ID + id_in: [ID!] + id_not_in: [ID!] + id_contains: ID + id_not_contains: ID + id_starts_with: ID + id_not_starts_with: ID + id_ends_with: ID + id_not_ends_with: ID + friends: _UserFilter + friends_not: _UserFilter + friends_in: [_UserFilter!] + friends_not_in: [_UserFilter!] + friends_some: _UserFilter + friends_none: _UserFilter + friends_single: _UserFilter + friends_every: _UserFilter + following: _UserFilter + following_not: _UserFilter + following_in: [_UserFilter!] + following_not_in: [_UserFilter!] + following_some: _UserFilter + following_none: _UserFilter + following_single: _UserFilter + following_every: _UserFilter + followedBy: _UserFilter + followedBy_not: _UserFilter + followedBy_in: [_UserFilter!] + followedBy_not_in: [_UserFilter!] + followedBy_some: _UserFilter + followedBy_none: _UserFilter + followedBy_single: _UserFilter + followedBy_every: _UserFilter + role_in: [UserGroup!] } type Query { - User( - id: ID - email: String - actorId: String - name: String - slug: String - avatar: String - coverImg: String - role: UserGroup - locationName: String - about: String - createdAt: String - updatedAt: String - friendsCount: Int - followingCount: Int - followedByCount: Int - followedByCurrentUser: Boolean - contributionsCount: Int - commentedCount: Int - shoutedCount: Int - badgesCount: Int - first: Int - offset: Int - orderBy: [_UserOrdering] - filter: _UserFilter - ): [User] + User( + id: ID + email: String + actorId: String + name: String + slug: String + avatar: String + coverImg: String + role: UserGroup + locationName: String + about: String + createdAt: String + updatedAt: String + friendsCount: Int + followingCount: Int + followedByCount: Int + followedByCurrentUser: Boolean + contributionsCount: Int + commentedCount: Int + shoutedCount: Int + badgesCount: Int + first: Int + offset: Int + orderBy: [_UserOrdering] + filter: _UserFilter + ): [User] - blockedUsers: [User] - currentUser: User + blockedUsers: [User] + currentUser: User } type Mutation { - UpdateUser ( - id: ID! - name: String - email: String - slug: String - avatar: String - coverImg: String - avatarUpload: Upload - locationName: String - about: String - termsAndConditionsAgreedVersion: String - termsAndConditionsAgreedAt: String - allowEmbedIframes: Boolean + UpdateUser ( + id: ID! + name: String + email: String + slug: String + avatar: String + coverImg: String + avatarUpload: Upload + locationName: String + about: String + termsAndConditionsAgreedVersion: String + termsAndConditionsAgreedAt: String + allowEmbedIframes: Boolean locale: String - ): User + ): User - DeleteUser(id: ID!, resource: [Deletable]): User + DeleteUser(id: ID!, resource: [Deletable]): User - block(id: ID!): User - unblock(id: ID!): User + block(id: ID!): User + unblock(id: ID!): User } diff --git a/webapp/components/ContentMenu.vue b/webapp/components/ContentMenu.vue index 3b82486fe..521a8ed6e 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) { diff --git a/webapp/components/PostCard/index.spec.js b/webapp/components/PostCard/PostCard.spec.js similarity index 98% rename from webapp/components/PostCard/index.spec.js rename to webapp/components/PostCard/PostCard.spec.js index 26d0515d6..ab902f05a 100644 --- a/webapp/components/PostCard/index.spec.js +++ b/webapp/components/PostCard/PostCard.spec.js @@ -2,7 +2,7 @@ import { config, shallowMount, mount, createLocalVue, RouterLinkStub } from '@vu import Styleguide from '@human-connection/styleguide' import Vuex from 'vuex' import Filters from '~/plugins/vue-filters' -import PostCard from '.' +import PostCard from './PostCard.vue' const localVue = createLocalVue() diff --git a/webapp/components/PostCard/PostCard.story.js b/webapp/components/PostCard/PostCard.story.js index 1f9f70110..1e470ce11 100644 --- a/webapp/components/PostCard/PostCard.story.js +++ b/webapp/components/PostCard/PostCard.story.js @@ -1,6 +1,6 @@ import { storiesOf } from '@storybook/vue' import { withA11y } from '@storybook/addon-a11y' -import HcPostCard from '~/components/PostCard' +import HcPostCard from './PostCard.vue' import helpers from '~/storybook/helpers' helpers.init() @@ -76,3 +76,23 @@ storiesOf('Post Card', module) /> `, })) + .add('pinned by admin', () => ({ + components: { HcPostCard }, + store: helpers.store, + data: () => ({ + post: { + ...post, + pinnedBy: { + id: '4711', + name: 'Ad Min', + role: 'admin', + }, + }, + }), + template: ` + + `, + })) diff --git a/webapp/components/PostCard/index.vue b/webapp/components/PostCard/PostCard.vue similarity index 90% rename from webapp/components/PostCard/index.vue rename to webapp/components/PostCard/PostCard.vue index 85b19f105..f368fadbb 100644 --- a/webapp/components/PostCard/index.vue +++ b/webapp/components/PostCard/PostCard.vue @@ -1,7 +1,7 @@ @@ -58,12 +60,13 @@