Merge pull request #5380 from Ocelot-Social-Community/post-in-group

feat: 🍰 Post In Groups
This commit is contained in:
Moriz Wahl 2022-10-11 18:41:57 +02:00 committed by GitHub
commit 51ce290195
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 1769 additions and 67 deletions

View File

@ -19,6 +19,7 @@ export const signupVerificationMutation = gql`
nonce: $nonce
termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion
) {
id
slug
}
}

View File

@ -2,15 +2,87 @@ import gql from 'graphql-tag'
// ------ mutations
export const createPostMutation = gql`
mutation ($id: ID, $title: String!, $slug: String, $content: String!, $categoryIds: [ID]!) {
CreatePost(id: $id, title: $title, slug: $slug, content: $content, categoryIds: $categoryIds) {
id
slug
export const createPostMutation = () => {
return gql`
mutation (
$id: ID
$title: String!
$slug: String
$content: String!
$categoryIds: [ID]
$groupId: ID
) {
CreatePost(
id: $id
title: $title
slug: $slug
content: $content
categoryIds: $categoryIds
groupId: $groupId
) {
id
slug
title
content
}
}
}
`
`
}
// ------ queries
// fill queries in here
export const postQuery = () => {
return gql`
query Post($id: ID!) {
Post(id: $id) {
id
title
content
}
}
`
}
export const filterPosts = () => {
return gql`
query Post($filter: _PostFilter, $first: Int, $offset: Int, $orderBy: [_PostOrdering]) {
Post(filter: $filter, first: $first, offset: $offset, orderBy: $orderBy) {
id
title
content
}
}
`
}
export const profilePagePosts = () => {
return gql`
query profilePagePosts(
$filter: _PostFilter
$first: Int
$offset: Int
$orderBy: [_PostOrdering]
) {
profilePagePosts(filter: $filter, first: $first, offset: $offset, orderBy: $orderBy) {
id
title
content
}
}
`
}
export const searchPosts = () => {
return gql`
query ($query: String!, $firstPosts: Int, $postsOffset: Int) {
searchPosts(query: $query, firstPosts: $firstPosts, postsOffset: $postsOffset) {
postCount
posts {
id
title
content
}
}
}
`
}

View File

@ -709,7 +709,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
await Promise.all([
mutate({
mutation: createPostMutation,
mutation: createPostMutation(),
variables: {
id: 'p2',
title: `Nature Philosophy Yoga`,
@ -718,7 +718,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
},
}),
mutate({
mutation: createPostMutation,
mutation: createPostMutation(),
variables: {
id: 'p7',
title: 'This is post #7',
@ -727,7 +727,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
},
}),
mutate({
mutation: createPostMutation,
mutation: createPostMutation(),
variables: {
id: 'p8',
image: faker.image.unsplash.nature(),
@ -737,7 +737,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
},
}),
mutate({
mutation: createPostMutation,
mutation: createPostMutation(),
variables: {
id: 'p12',
title: 'This is post #12',

View File

@ -1,4 +1,4 @@
import { rule, shield, deny, allow, or } from 'graphql-shield'
import { rule, shield, deny, allow, or, and } from 'graphql-shield'
import { getNeode } from '../db/neo4j'
import CONFIG from '../config'
import { validateInviteCode } from '../schema/resolvers/transactions/inviteCodes'
@ -221,6 +221,34 @@ const isAllowedToLeaveGroup = rule({
}
})
const isMemberOfGroup = rule({
cache: 'no_cache',
})(async (_parent, args, { user, driver }) => {
if (!(user && user.id)) return false
const { groupId } = args
if (!groupId) return true
const userId = user.id
const session = driver.session()
const readTxPromise = session.readTransaction(async (transaction) => {
const transactionResponse = await transaction.run(
`
MATCH (User {id: $userId})-[membership:MEMBER_OF]->(Group {id: $groupId})
RETURN membership.role AS role
`,
{ groupId, userId },
)
return transactionResponse.records.map((record) => record.get('role'))[0]
})
try {
const role = await readTxPromise
return ['usual', 'admin', 'owner'].includes(role)
} catch (error) {
throw new Error(error)
} finally {
session.close()
}
})
const isAuthor = rule({
cache: 'no_cache',
})(async (_parent, args, { user, driver }) => {
@ -271,8 +299,6 @@ export default shield(
{
Query: {
'*': deny,
findPosts: allow,
findUsers: allow,
searchResults: allow,
searchPosts: allow,
searchUsers: allow,
@ -316,7 +342,7 @@ export default shield(
JoinGroup: isAllowedToJoinGroup,
LeaveGroup: isAllowedToLeaveGroup,
ChangeGroupMemberRole: isAllowedToChangeGroupMemberRole,
CreatePost: isAuthenticated,
CreatePost: and(isAuthenticated, isMemberOfGroup),
UpdatePost: isAuthor,
DeletePost: isAuthor,
fileReport: isAuthenticated,

View File

@ -366,7 +366,7 @@ describe('slugifyMiddleware', () => {
it('generates a slug based on title', async () => {
await expect(
mutate({
mutation: createPostMutation,
mutation: createPostMutation(),
variables,
}),
).resolves.toMatchObject({
@ -382,7 +382,7 @@ describe('slugifyMiddleware', () => {
it('generates a slug based on given slug', async () => {
await expect(
mutate({
mutation: createPostMutation,
mutation: createPostMutation(),
variables: {
...variables,
slug: 'the-post',
@ -417,7 +417,7 @@ describe('slugifyMiddleware', () => {
it('chooses another slug', async () => {
await expect(
mutate({
mutation: createPostMutation,
mutation: createPostMutation(),
variables: {
...variables,
title: 'Pre-existing post',
@ -440,7 +440,7 @@ describe('slugifyMiddleware', () => {
try {
await expect(
mutate({
mutation: createPostMutation,
mutation: createPostMutation(),
variables: {
...variables,
title: 'Pre-existing post',

View File

@ -31,7 +31,7 @@ const setPostCounter = async (postId, relation, context) => {
}
const userClickedPost = async (resolve, root, args, context, info) => {
if (args.id) {
if (args.id && context.user) {
await setPostCounter(args.id, 'CLICKED', context)
}
return resolve(root, args, context, info)

View File

@ -261,8 +261,15 @@ export default {
const leaveGroupCypher = `
MATCH (member:User {id: $userId})-[membership:MEMBER_OF]->(group:Group {id: $groupId})
DELETE membership
WITH member, group
OPTIONAL MATCH (p:Post)-[:IN]->(group)
WHERE NOT group.groupType = 'public'
WITH member, group, collect(p) AS posts
FOREACH (post IN posts |
MERGE (member)-[:CANNOT_SEE]->(post))
RETURN member {.*, myRoleInGroup: NULL}
`
const transactionResponse = await transaction.run(leaveGroupCypher, { groupId, userId })
const [member] = await transactionResponse.records.map((record) => record.get('member'))
return member
@ -279,8 +286,22 @@ export default {
const { groupId, userId, roleInGroup } = params
const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
let postRestrictionCypher = ''
if (['usual', 'admin', 'owner'].includes(roleInGroup)) {
postRestrictionCypher = `
WITH group, member, membership
FOREACH (restriction IN [(member)-[r:CANNOT_SEE]->(:Post)-[:IN]->(group) | r] |
DELETE restriction)`
} else {
postRestrictionCypher = `
WITH group, member, membership
FOREACH (post IN [(p:Post)-[:IN]->(group) | p] |
MERGE (member)-[:CANNOT_SEE]->(post))`
}
const joinGroupCypher = `
MATCH (member:User {id: $userId}), (group:Group {id: $groupId})
MATCH (member:User {id: $userId})
MATCH (group:Group {id: $groupId})
MERGE (member)-[membership:MEMBER_OF]->(group)
ON CREATE SET
membership.createdAt = toString(datetime()),
@ -289,8 +310,10 @@ export default {
ON MATCH SET
membership.updatedAt = toString(datetime()),
membership.role = $roleInGroup
${postRestrictionCypher}
RETURN member {.*, myRoleInGroup: membership.role}
`
const transactionResponse = await transaction.run(joinGroupCypher, {
groupId,
userId,
@ -313,6 +336,7 @@ export default {
undefinedToNull: ['deleted', 'disabled', 'locationName', 'about'],
hasMany: {
categories: '-[:CATEGORIZED]->(related:Category)',
posts: '<-[:IN]-(related:Post)',
},
hasOne: {
avatar: '-[:AVATAR_IMAGE]->(related:Image)',

View File

@ -0,0 +1,47 @@
import { mergeWith, isArray } from 'lodash'
const getInvisiblePosts = async (context) => {
const session = context.driver.session()
const readTxResultPromise = await session.readTransaction(async (transaction) => {
let cypher = ''
const { user } = context
if (user && user.id) {
cypher = `
MATCH (post:Post)<-[:CANNOT_SEE]-(user:User { id: $userId })
RETURN collect(post.id) AS invisiblePostIds`
} else {
cypher = `
MATCH (post:Post)-[:IN]->(group:Group)
WHERE NOT group.groupType = 'public'
RETURN collect(post.id) AS invisiblePostIds`
}
const invisiblePostIdsResponse = await transaction.run(cypher, {
userId: user ? user.id : null,
})
return invisiblePostIdsResponse.records.map((record) => record.get('invisiblePostIds'))
})
try {
const [invisiblePostIds] = readTxResultPromise
return invisiblePostIds
} finally {
session.close()
}
}
export const filterInvisiblePosts = async (params, context) => {
const invisiblePostIds = await getInvisiblePosts(context)
if (!invisiblePostIds.length) return params
params.filter = mergeWith(
params.filter,
{
id_not_in: invisiblePostIds,
},
(objValue, srcValue) => {
if (isArray(objValue)) {
return objValue.concat(srcValue)
}
},
)
return params
}

View File

@ -5,6 +5,7 @@ import { UserInputError } from 'apollo-server'
import { mergeImage, deleteImage } from './images/images'
import Resolver from './helpers/Resolver'
import { filterForMutedUsers } from './helpers/filterForMutedUsers'
import { filterInvisiblePosts } from './helpers/filterInvisiblePosts'
import CONFIG from '../../config'
const maintainPinnedPosts = (params) => {
@ -20,15 +21,13 @@ const maintainPinnedPosts = (params) => {
export default {
Query: {
Post: async (object, params, context, resolveInfo) => {
params = await filterInvisiblePosts(params, context)
params = await filterForMutedUsers(params, context)
params = await maintainPinnedPosts(params)
return neo4jgraphql(object, params, context, resolveInfo)
},
findPosts: async (object, params, context, resolveInfo) => {
params = await filterForMutedUsers(params, context)
return neo4jgraphql(object, params, context, resolveInfo)
},
profilePagePosts: async (object, params, context, resolveInfo) => {
params = await filterInvisiblePosts(params, context)
params = await filterForMutedUsers(params, context)
return neo4jgraphql(object, params, context, resolveInfo)
},
@ -77,13 +76,37 @@ export default {
},
Mutation: {
CreatePost: async (_parent, params, context, _resolveInfo) => {
const { categoryIds } = params
const { categoryIds, groupId } = params
const { image: imageInput } = params
delete params.categoryIds
delete params.image
delete params.groupId
params.id = params.id || uuid()
const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
let groupCypher = ''
if (groupId) {
groupCypher = `
WITH post MATCH (group:Group { id: $groupId })
MERGE (post)-[:IN]->(group)`
const groupTypeResponse = await transaction.run(
`
MATCH (group:Group { id: $groupId }) RETURN group.groupType AS groupType`,
{ groupId },
)
const [groupType] = groupTypeResponse.records.map((record) => record.get('groupType'))
if (groupType !== 'public')
groupCypher += `
WITH post, group
MATCH (user:User)-[membership:MEMBER_OF]->(group)
WHERE group.groupType IN ['closed', 'hidden']
AND membership.role IN ['usual', 'admin', 'owner']
WITH post, collect(user.id) AS userIds
OPTIONAL MATCH path =(restricted:User) WHERE NOT restricted.id IN userIds
FOREACH (user IN nodes(path) |
MERGE (user)-[:CANNOT_SEE]->(post)
)`
}
const categoriesCypher =
CONFIG.CATEGORIES_ACTIVE && categoryIds
? `WITH post
@ -103,9 +126,10 @@ export default {
MATCH (author:User {id: $userId})
MERGE (post)<-[:WROTE]-(author)
${categoriesCypher}
${groupCypher}
RETURN post {.*}
`,
{ userId: context.user.id, params, categoryIds },
{ userId: context.user.id, categoryIds, groupId, params },
)
const [post] = createPostTransactionResponse.records.map((record) => record.get('post'))
if (imageInput) {
@ -367,6 +391,7 @@ export default {
author: '<-[:WROTE]-(related:User)',
pinnedBy: '<-[:PINNED]-(related:User)',
image: '-[:HERO_IMAGE]->(related:Image)',
group: '-[:IN]->(related:Group)',
},
count: {
commentsCount:

File diff suppressed because it is too large Load Diff

View File

@ -72,19 +72,19 @@ const signupCypher = (inviteCode) => {
(inviteCode:InviteCode {code: $inviteCode})<-[:GENERATED]-(host:User)
`
optionalMerge = `
MERGE(user)-[:REDEEMED { createdAt: toString(datetime()) }]->(inviteCode)
MERGE(host)-[:INVITED { createdAt: toString(datetime()) }]->(user)
MERGE(user)-[:FOLLOWS { createdAt: toString(datetime()) }]->(host)
MERGE(host)-[:FOLLOWS { createdAt: toString(datetime()) }]->(user)
MERGE (user)-[:REDEEMED { createdAt: toString(datetime()) }]->(inviteCode)
MERGE (host)-[:INVITED { createdAt: toString(datetime()) }]->(user)
MERGE (user)-[:FOLLOWS { createdAt: toString(datetime()) }]->(host)
MERGE (host)-[:FOLLOWS { createdAt: toString(datetime()) }]->(user)
`
}
const cypher = `
MATCH(email:EmailAddress {nonce: $nonce, email: $email})
MATCH (email:EmailAddress {nonce: $nonce, email: $email})
WHERE NOT (email)-[:BELONGS_TO]->()
${optionalMatch}
CREATE (user:User)
MERGE(user)-[:PRIMARY_EMAIL]->(email)
MERGE(user)<-[:BELONGS_TO]-(email)
MERGE (user)-[:PRIMARY_EMAIL]->(email)
MERGE (user)<-[:BELONGS_TO]-(email)
${optionalMerge}
SET user += $args
SET user.id = randomUUID()
@ -95,6 +95,13 @@ const signupCypher = (inviteCode) => {
SET user.showShoutsPublicly = false
SET user.sendNotificationEmails = true
SET email.verifiedAt = toString(datetime())
WITH user
OPTIONAL MATCH (post:Post)-[:IN]->(group:Group)
WHERE NOT group.groupType = 'public'
WITH user, collect(post) AS invisiblePosts
FOREACH (invisiblePost IN invisiblePosts |
MERGE (user)-[:CANNOT_SEE]->(invisiblePost)
)
RETURN user {.*}
`
return cypher

View File

@ -23,12 +23,15 @@ const postWhereClause = `WHERE score >= 0.0
AND NOT (
author.deleted = true OR author.disabled = true
OR resource.deleted = true OR resource.disabled = true
OR (:User {id: $userId})-[:MUTED]->(author)
)`
) AND block IS NULL AND restriction IS NULL`
const searchPostsSetup = {
fulltextIndex: 'post_fulltext_search',
match: 'MATCH (resource:Post)<-[:WROTE]-(author:User)',
match: `MATCH (resource:Post)<-[:WROTE]-(author:User)
MATCH (user:User {id: $userId})
OPTIONAL MATCH (user)-[block:MUTED]->(author)
OPTIONAL MATCH (user)-[restriction:CANNOT_SEE]->(resource)
WITH user, resource, author, block, restriction`,
whereClause: postWhereClause,
withClause: `WITH resource, author,
[(resource)<-[:COMMENTS]-(comment:Comment) | comment] AS comments,
@ -116,8 +119,8 @@ export default {
Query: {
searchPosts: async (_parent, args, context, _resolveInfo) => {
const { query, postsOffset, firstPosts } = args
const { id: userId } = context.user
let userId = null
if (context.user) userId = context.user.id
return {
postCount: getSearchResults(
context,
@ -177,7 +180,8 @@ export default {
},
searchResults: async (_parent, args, context, _resolveInfo) => {
const { query, limit } = args
const { id: userId } = context.user
let userId = null
if (context.user) userId = context.user.id
const searchType = query.replace(/^([!@#]?).*$/, '$1')
const searchString = query.replace(/^([!@#])/, '')

View File

@ -39,6 +39,8 @@ type Group {
categories: [Category] @relation(name: "CATEGORIZED", direction: "OUT")
myRole: GroupMemberRole # if 'null' then the current user is no member
posts: [Post] @relation(name: "IN", direction: "IN")
}

View File

@ -81,6 +81,7 @@ input _PostFilter {
emotions_none: _PostEMOTEDFilter
emotions_single: _PostEMOTEDFilter
emotions_every: _PostEMOTEDFilter
group: _GroupFilter
}
enum _PostOrdering {
@ -167,6 +168,8 @@ type Post {
emotions: [EMOTED]
emotionsCount: Int!
@cypher(statement: "MATCH (this)<-[emoted:EMOTED]-(:User) RETURN COUNT(DISTINCT emoted)")
group: Group @relation(name: "IN", direction: "OUT")
}
input _PostInput {
@ -184,6 +187,7 @@ type Mutation {
language: String
categoryIds: [ID]
contentExcerpt: String
groupId: ID
): Post
UpdatePost(
id: ID!
@ -225,18 +229,4 @@ type Query {
PostsEmotionsCountByEmotion(postId: ID!, data: _EMOTEDInput!): Int!
PostsEmotionsByCurrentUser(postId: ID!): [String]
profilePagePosts(filter: _PostFilter, first: Int, offset: Int, orderBy: [_PostOrdering]): [Post]
findPosts(query: String!, limit: Int = 10, filter: _PostFilter): [Post]!
@cypher(
statement: """
CALL db.index.fulltext.queryNodes('post_fulltext_search', $query)
YIELD node as post, score
MATCH (post)<-[:WROTE]-(user:User)
WHERE score >= 0.2
AND NOT user.deleted = true AND NOT user.disabled = true
AND NOT post.deleted = true AND NOT post.disabled = true
AND NOT user.id in COALESCE($filter.author_not.id_in, [])
RETURN post
LIMIT $limit
"""
)
}

View File

@ -186,18 +186,6 @@ type Query {
blockedUsers: [User]
isLoggedIn: Boolean!
currentUser: User
findUsers(query: String!,limit: Int = 10, filter: _UserFilter): [User]!
@cypher(
statement: """
CALL db.index.fulltext.queryNodes('user_fulltext_search', $query)
YIELD node as post, score
MATCH (user)
WHERE score >= 0.2
AND NOT user.deleted = true AND NOT user.disabled = true
RETURN user
LIMIT $limit
"""
)
}
enum Deletable {