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 @@
-
+
+
@@ -61,6 +62,8 @@
:resource="post"
:modalsData="menuModalsData"
:is-owner="isAuthor"
+ @pinPost="pinPost"
+ @unpinPost="unpinPost"
/>
@@ -114,6 +117,9 @@ export default {
this.deletePostCallback,
)
},
+ isPinned() {
+ return this.post && this.post.pinnedBy
+ },
},
methods: {
async deletePostCallback() {
@@ -127,6 +133,12 @@ export default {
this.$toast.error(err.message)
}
},
+ pinPost(post) {
+ this.$emit('pinPost', post)
+ },
+ unpinPost(post) {
+ this.$emit('unpinPost', post)
+ },
},
}
@@ -167,4 +179,8 @@ export default {
text-indent: -999999px;
}
}
+
+.post--pinned {
+ border: 1px solid $color-warning;
+}
diff --git a/webapp/components/Ribbon/index.vue b/webapp/components/Ribbon/index.vue
index c92935352..c8c09c194 100644
--- a/webapp/components/Ribbon/index.vue
+++ b/webapp/components/Ribbon/index.vue
@@ -46,4 +46,12 @@ export default {
border-color: $background-color-secondary transparent transparent $background-color-secondary;
}
}
+
+.ribbon--pinned {
+ background-color: $color-warning-active;
+
+ &::before {
+ border-color: $color-warning transparent transparent $color-warning;
+ }
+}
diff --git a/webapp/components/notifications/NotificationMenu/NotificationMenu.vue b/webapp/components/notifications/NotificationMenu/NotificationMenu.vue
index 9c3ed0bd7..26d8256bd 100644
--- a/webapp/components/notifications/NotificationMenu/NotificationMenu.vue
+++ b/webapp/components/notifications/NotificationMenu/NotificationMenu.vue
@@ -93,7 +93,7 @@ export default {
return data.notifications
},
error(error) {
- this.$toast.error(error)
+ this.$toast.error(error.message)
},
},
},
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..01227ea87 100644
--- a/webapp/graphql/PostMutations.js
+++ b/webapp/graphql/PostMutations.js
@@ -50,6 +50,11 @@ export default () => {
content
contentExcerpt
language
+ pinnedBy {
+ id
+ name
+ role
+ }
}
}
`,
@@ -86,5 +91,39 @@ export default () => {
}
}
`,
+ pinPost: gql`
+ mutation($id: ID!) {
+ pinPost(id: $id) {
+ id
+ title
+ slug
+ content
+ contentExcerpt
+ language
+ pinnedBy {
+ id
+ name
+ role
+ }
+ }
+ }
+ `,
+ unpinPost: gql`
+ mutation($id: ID!) {
+ unpinPost(id: $id) {
+ id
+ title
+ slug
+ content
+ contentExcerpt
+ language
+ pinnedBy {
+ id
+ name
+ role
+ }
+ }
+ }
+ `,
}
}
diff --git a/webapp/graphql/PostQuery.js b/webapp/graphql/PostQuery.js
index bca276f64..3de1178b0 100644
--- a/webapp/graphql/PostQuery.js
+++ b/webapp/graphql/PostQuery.js
@@ -35,6 +35,26 @@ export const filterPosts = i18n => {
`
}
+export const profilePagePosts = i18n => {
+ const lang = i18n.locale().toUpperCase()
+ return gql`
+ ${postFragment(lang)}
+ ${postCountsFragment}
+
+ query profilePagePosts(
+ $filter: _PostFilter
+ $first: Int
+ $offset: Int
+ $orderBy: [_PostOrdering]
+ ) {
+ profilePagePosts(filter: $filter, first: $first, offset: $offset, orderBy: $orderBy) {
+ ...post
+ ...postCounts
+ }
+ }
+ `
+}
+
export const PostsEmotionsByCurrentUser = () => {
return gql`
query PostsEmotionsByCurrentUser($postId: ID!) {
diff --git a/webapp/locales/de.json b/webapp/locales/de.json
index 9f10d929d..923bc487d 100644
--- a/webapp/locales/de.json
+++ b/webapp/locales/de.json
@@ -354,6 +354,7 @@
},
"post": {
"name": "Beitrag",
+ "pinned": "Meldung",
"moreInfo": {
"name": "Mehr Info",
"title": "Mehr Informationen",
diff --git a/webapp/locales/en.json b/webapp/locales/en.json
index 9860aa457..abfad576d 100644
--- a/webapp/locales/en.json
+++ b/webapp/locales/en.json
@@ -355,6 +355,7 @@
},
"post": {
"name": "Post",
+ "pinned": "Announcement",
"moreInfo": {
"name": "More info",
"title": "More information",
@@ -368,7 +369,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..eef45989a 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"
/>
@@ -58,12 +60,13 @@