From bf9dd205ca6a790c12550182f68ec43e99bf8076 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Tue, 2 Aug 2022 08:26:14 +0200 Subject: [PATCH 01/29] Implement GQL for groups --- backend/src/db/migrate/store.js | 3 +- backend/src/models/Groups.js | 133 ++++++++++ backend/src/models/index.js | 1 + .../schema/types/enum/GroupActionRadius.gql | 6 + backend/src/schema/types/enum/GroupType.gql | 5 + backend/src/schema/types/type/Group.gql | 249 ++++++++++++++++++ backend/src/schema/types/type/User.gql | 52 ++-- 7 files changed, 422 insertions(+), 27 deletions(-) create mode 100644 backend/src/models/Groups.js create mode 100644 backend/src/schema/types/enum/GroupActionRadius.gql create mode 100644 backend/src/schema/types/enum/GroupType.gql create mode 100644 backend/src/schema/types/type/Group.gql diff --git a/backend/src/db/migrate/store.js b/backend/src/db/migrate/store.js index 377caf0b0..7a8be0b94 100644 --- a/backend/src/db/migrate/store.js +++ b/backend/src/db/migrate/store.js @@ -62,8 +62,9 @@ class Store { await txc.run('CALL apoc.schema.assert({},{},true)') // drop all indices return Promise.all( [ - 'CALL db.index.fulltext.createNodeIndex("post_fulltext_search",["Post"],["title", "content"])', 'CALL db.index.fulltext.createNodeIndex("user_fulltext_search",["User"],["name", "slug"])', + 'CALL db.index.fulltext.createNodeIndex("user_fulltext_search",["Group"],["name", "slug", "description"])', // Wolle: check for 'name', 'slug', 'description' + 'CALL db.index.fulltext.createNodeIndex("post_fulltext_search",["Post"],["title", "content"])', 'CALL db.index.fulltext.createNodeIndex("tag_fulltext_search",["Tag"],["id"])', ].map((statement) => txc.run(statement)), ) diff --git a/backend/src/models/Groups.js b/backend/src/models/Groups.js new file mode 100644 index 000000000..aa6f5767e --- /dev/null +++ b/backend/src/models/Groups.js @@ -0,0 +1,133 @@ +import { v4 as uuid } from 'uuid' + +export default { + id: { type: 'string', primary: true, default: uuid }, // TODO: should be type: 'uuid' but simplified for our tests + name: { type: 'string', disallow: [null], min: 3 }, + slug: { type: 'string', unique: 'true', regex: /^[a-z0-9_-]+$/, lowercase: true }, + avatar: { + type: 'relationship', + relationship: 'AVATAR_IMAGE', + target: 'Image', + direction: 'out', + }, + deleted: { type: 'boolean', default: false }, + disabled: { type: 'boolean', default: false }, + wasSeeded: 'boolean', // Wolle: used or needed? + locationName: { type: 'string', allow: [null] }, + about: { type: 'string', allow: [null, ''] }, // Wolle: null? + description: { type: 'string', allow: [null, ''] }, // Wolle: null? HTML with Tiptap, similar to post content + // Wolle: followedBy: { + // type: 'relationship', + // relationship: 'FOLLOWS', + // target: 'User', + // direction: 'in', + // properties: { + // createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, + // }, + // }, + // Wolle: correct this way? + members: { type: 'relationship', + relationship: 'MEMBERS', + target: 'User', + direction: 'out' + }, + // Wolle: needed? lastActiveAt: { type: 'string', isoDate: true }, + createdAt: { + type: 'string', + isoDate: true, + default: () => new Date().toISOString() + }, + updatedAt: { + type: 'string', + isoDate: true, + required: true, + default: () => new Date().toISOString(), + }, + // Wolle: emoted: { + // type: 'relationships', + // relationship: 'EMOTED', + // target: 'Post', + // direction: 'out', + // properties: { + // emotion: { + // type: 'string', + // valid: ['happy', 'cry', 'surprised', 'angry', 'funny'], + // invalid: [null], + // }, + // }, + // eager: true, + // cascade: true, + // }, + // Wolle: blocked: { + // type: 'relationship', + // relationship: 'BLOCKED', + // target: 'User', + // direction: 'out', + // properties: { + // createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, + // }, + // }, + // Wolle: muted: { + // type: 'relationship', + // relationship: 'MUTED', + // target: 'User', + // direction: 'out', + // properties: { + // createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, + // }, + // }, + // Wolle: notifications: { + // type: 'relationship', + // relationship: 'NOTIFIED', + // target: 'User', + // direction: 'in', + // }, + // Wolle inviteCodes: { + // type: 'relationship', + // relationship: 'GENERATED', + // target: 'InviteCode', + // direction: 'out', + // }, + // Wolle: redeemedInviteCode: { + // type: 'relationship', + // relationship: 'REDEEMED', + // target: 'InviteCode', + // direction: 'out', + // }, + // Wolle: shouted: { + // type: 'relationship', + // relationship: 'SHOUTED', + // target: 'Post', + // direction: 'out', + // properties: { + // createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, + // }, + // }, + isIn: { + type: 'relationship', + relationship: 'IS_IN', + target: 'Location', + direction: 'out', + }, + // Wolle: pinned: { + // type: 'relationship', + // relationship: 'PINNED', + // target: 'Post', + // direction: 'out', + // properties: { + // createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, + // }, + // }, + // Wolle: showShoutsPublicly: { + // type: 'boolean', + // default: false, + // }, + // Wolle: sendNotificationEmails: { + // type: 'boolean', + // default: true, + // }, + // Wolle: locale: { + // type: 'string', + // allow: [null], + // }, +} diff --git a/backend/src/models/index.js b/backend/src/models/index.js index 8d6a021ab..d476e5f9b 100644 --- a/backend/src/models/index.js +++ b/backend/src/models/index.js @@ -4,6 +4,7 @@ export default { Image: require('./Image.js').default, Badge: require('./Badge.js').default, User: require('./User.js').default, + Group: require('./Group.js').default, EmailAddress: require('./EmailAddress.js').default, UnverifiedEmailAddress: require('./UnverifiedEmailAddress.js').default, SocialMedia: require('./SocialMedia.js').default, diff --git a/backend/src/schema/types/enum/GroupActionRadius.gql b/backend/src/schema/types/enum/GroupActionRadius.gql new file mode 100644 index 000000000..afc421133 --- /dev/null +++ b/backend/src/schema/types/enum/GroupActionRadius.gql @@ -0,0 +1,6 @@ +enum GroupActionRadius { + regional + national + continental + international +} diff --git a/backend/src/schema/types/enum/GroupType.gql b/backend/src/schema/types/enum/GroupType.gql new file mode 100644 index 000000000..2cf298474 --- /dev/null +++ b/backend/src/schema/types/enum/GroupType.gql @@ -0,0 +1,5 @@ +enum GroupType { + public + closed + hidden +} diff --git a/backend/src/schema/types/type/Group.gql b/backend/src/schema/types/type/Group.gql new file mode 100644 index 000000000..655a251d6 --- /dev/null +++ b/backend/src/schema/types/type/Group.gql @@ -0,0 +1,249 @@ +enum _GroupOrdering { + id_asc + id_desc + name_asc + name_desc + slug_asc + slug_desc + locationName_asc + locationName_desc + about_asc + about_desc + createdAt_asc + createdAt_desc + updatedAt_asc + updatedAt_desc + # Wolle: needed? locale_asc + # locale_desc +} + +type Group { + id: ID! + name: String # title + slug: String! + + createdAt: String + updatedAt: String + deleted: Boolean + disabled: Boolean + + avatar: Image @relation(name: "AVATAR_IMAGE", direction: "OUT") + + location: Location @cypher(statement: "MATCH (this)-[:IS_IN]->(l:Location) RETURN l") + locationName: String + about: String # goal + description: String + groupType: GroupType + actionRadius: GroupActionRadius + + categories: [Category] @relation(name: "CATEGORIZED", direction: "OUT") + + # Wolle: needed? + socialMedia: [SocialMedia]! @relation(name: "OWNED_BY", direction: "IN") + + # Wolle: showShoutsPublicly: Boolean + # Wolle: sendNotificationEmails: Boolean + # Wolle: needed? locale: String + members: [User]! @relation(name: "MEMBERS", direction: "OUT") + membersCount: Int! + @cypher(statement: "MATCH (this)-[:MEMBERS]->(r:User) RETURN COUNT(DISTINCT r)") + + # Wolle: followedBy: [User]! @relation(name: "FOLLOWS", direction: "IN") + # Wolle: followedByCount: Int! + # @cypher(statement: "MATCH (this)<-[:FOLLOWS]-(r:User) RETURN COUNT(DISTINCT r)") + + # Wolle: inviteCodes: [InviteCode] @relation(name: "GENERATED", direction: "OUT") + # Wolle: redeemedInviteCode: InviteCode @relation(name: "REDEEMED", direction: "OUT") + + # Is the currently logged in user following that user? + # Wolle: followedByCurrentUser: Boolean! + # @cypher( + # statement: """ + # MATCH (this)<-[:FOLLOWS]-(u:User { id: $cypherParams.currentUserId}) + # RETURN COUNT(u) >= 1 + # """ + # ) + + # Wolle: isBlocked: Boolean! + # @cypher( + # statement: """ + # MATCH (this)<-[:BLOCKED]-(user:User {id: $cypherParams.currentUserId}) + # RETURN COUNT(user) >= 1 + # """ + # ) + # Wolle: blocked: Boolean! + # @cypher( + # statement: """ + # MATCH (this)-[:BLOCKED]-(user:User {id: $cypherParams.currentUserId}) + # RETURN COUNT(user) >= 1 + # """ + # ) + + # Wolle: isMuted: Boolean! + # @cypher( + # statement: """ + # MATCH (this)<-[:MUTED]-(user:User { id: $cypherParams.currentUserId}) + # RETURN COUNT(user) >= 1 + # """ + # ) + + # 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" + # ) + # Wolle: needed? + # 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) + # """ + # ) + + # Wolle: 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))" + # ) + + # Wolle: 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)" + # ) + + # Wolle: badges: [Badge]! @relation(name: "REWARDED", direction: "IN") + # badgesCount: Int! @cypher(statement: "MATCH (this)<-[:REWARDED]-(r:Badge) RETURN COUNT(r)") + + # Wolle: emotions: [EMOTED] +} + + +input _GroupFilter { + AND: [_GroupFilter!] + OR: [_GroupFilter!] + name_contains: String + about_contains: String + description_contains: String + slug_contains: String + id: ID + id_not: ID + id_in: [ID!] + id_not_in: [ID!] + # Wolle: + # friends: _GroupFilter + # friends_not: _GroupFilter + # friends_in: [_GroupFilter!] + # friends_not_in: [_GroupFilter!] + # friends_some: _GroupFilter + # friends_none: _GroupFilter + # friends_single: _GroupFilter + # friends_every: _GroupFilter + # following: _GroupFilter + # following_not: _GroupFilter + # following_in: [_GroupFilter!] + # following_not_in: [_GroupFilter!] + # following_some: _GroupFilter + # following_none: _GroupFilter + # following_single: _GroupFilter + # following_every: _GroupFilter + # followedBy: _GroupFilter + # followedBy_not: _GroupFilter + # followedBy_in: [_GroupFilter!] + # followedBy_not_in: [_GroupFilter!] + # followedBy_some: _GroupFilter + # followedBy_none: _GroupFilter + # followedBy_single: _GroupFilter + # followedBy_every: _GroupFilter + # role_in: [UserGroup!] +} + +type Query { + Group( + id: ID + email: String # admins need to search for a user sometimes + name: String + slug: String + locationName: String + about: String + description: String + createdAt: String + updatedAt: String + first: Int + offset: Int + orderBy: [_GroupOrdering] + filter: _GroupFilter + ): [Group] + + availableGroupTypes: [GroupType]! + + # Wolle: + # availableRoles: [UserGroup]! + # mutedUsers: [User] + # blockedUsers: [User] + # isLoggedIn: Boolean! + # currentUser: User + # findUsers(query: String!,limit: Int = 10, filter: _GroupFilter): [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 + # """ + # ) +} + +# Wolle: enum Deletable { +# Post +# Comment +# } + +type Mutation { + CreateGroup ( + id: ID! + name: String + email: String + slug: String + avatar: ImageInput + locationName: String + about: String + description: String + # Wolle: add group settings + # Wolle: + # showShoutsPublicly: Boolean + # sendNotificationEmails: Boolean + # locale: String + ): Group + + UpdateUser ( + id: ID! + name: String + email: String + slug: String + avatar: ImageInput + locationName: String + about: String + description: String + # Wolle: + # showShoutsPublicly: Boolean + # sendNotificationEmails: Boolean + # locale: String + ): Group + + DeleteGroup(id: ID!): Group + + # Wolle: + # muteUser(id: ID!): User + # unmuteUser(id: ID!): User + # blockUser(id: ID!): User + # unblockUser(id: ID!): User + + # Wolle: switchUserRole(role: UserGroup!, id: ID!): User +} diff --git a/backend/src/schema/types/type/User.gql b/backend/src/schema/types/type/User.gql index 772dedf6b..aca08df0e 100644 --- a/backend/src/schema/types/type/User.gql +++ b/backend/src/schema/types/type/User.gql @@ -156,19 +156,19 @@ input _UserFilter { type Query { User( - id: ID - email: String # admins need to search for a user sometimes - name: String - slug: String - role: UserGroup - locationName: String - about: String - createdAt: String - updatedAt: String - first: Int - offset: Int - orderBy: [_UserOrdering] - filter: _UserFilter + id: ID + email: String # admins need to search for a user sometimes + name: String + slug: String + role: UserGroup + locationName: String + about: String + createdAt: String + updatedAt: String + first: Int + offset: Int + orderBy: [_UserOrdering] + filter: _UserFilter ): [User] availableRoles: [UserGroup]! @@ -197,19 +197,19 @@ enum Deletable { type Mutation { UpdateUser ( - id: ID! - name: String - email: String - slug: String - avatar: ImageInput - locationName: String - about: String - termsAndConditionsAgreedVersion: String - termsAndConditionsAgreedAt: String - allowEmbedIframes: Boolean - showShoutsPublicly: Boolean - sendNotificationEmails: Boolean - locale: String + id: ID! + name: String + email: String + slug: String + avatar: ImageInput + locationName: String + about: String + termsAndConditionsAgreedVersion: String + termsAndConditionsAgreedAt: String + allowEmbedIframes: Boolean + showShoutsPublicly: Boolean + sendNotificationEmails: Boolean + locale: String ): User DeleteUser(id: ID!, resource: [Deletable]): User From 52bffa426b798378a2eef7ba00c773b92361c182 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Tue, 2 Aug 2022 09:10:02 +0200 Subject: [PATCH 02/29] Fix linting --- backend/src/models/Groups.js | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/backend/src/models/Groups.js b/backend/src/models/Groups.js index aa6f5767e..b26f7779c 100644 --- a/backend/src/models/Groups.js +++ b/backend/src/models/Groups.js @@ -26,16 +26,12 @@ export default { // }, // }, // Wolle: correct this way? - members: { type: 'relationship', - relationship: 'MEMBERS', - target: 'User', - direction: 'out' - }, + members: { type: 'relationship', relationship: 'MEMBERS', target: 'User', direction: 'out' }, // Wolle: needed? lastActiveAt: { type: 'string', isoDate: true }, createdAt: { type: 'string', isoDate: true, - default: () => new Date().toISOString() + default: () => new Date().toISOString(), }, updatedAt: { type: 'string', From 9632d0f8524a9868a984a54409cc2e68bd6a7d0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Tue, 2 Aug 2022 09:18:30 +0200 Subject: [PATCH 03/29] Fix file name from 'Groups.js' to singular 'Group.js' --- backend/src/models/{Groups.js => Group.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename backend/src/models/{Groups.js => Group.js} (100%) diff --git a/backend/src/models/Groups.js b/backend/src/models/Group.js similarity index 100% rename from backend/src/models/Groups.js rename to backend/src/models/Group.js From f565e5fb6a042286c4f5eeed484011b0f24ca58f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Tue, 2 Aug 2022 16:20:11 +0200 Subject: [PATCH 04/29] Implement 'CreateGroup' with tests --- backend/.env.template | 2 + backend/src/config/index.js | 1 + backend/src/db/graphql/mutations.ts | 29 + backend/src/db/migrate/store.js | 2 +- backend/src/middleware/excerptMiddleware.js | 5 + .../src/middleware/permissionsMiddleware.js | 1 + backend/src/middleware/sluggifyMiddleware.js | 4 + backend/src/middleware/slugify/uniqueSlug.js | 1 + backend/src/models/Group.js | 11 +- backend/src/schema/resolvers/groups.js | 224 +++++++ backend/src/schema/resolvers/groups.spec.js | 610 ++++++++++++++++++ backend/src/schema/types/type/Group.gql | 19 +- webapp/.env.template | 1 + webapp/config/index.js | 1 + 14 files changed, 899 insertions(+), 12 deletions(-) create mode 100644 backend/src/db/graphql/mutations.ts create mode 100644 backend/src/schema/resolvers/groups.js create mode 100644 backend/src/schema/resolvers/groups.spec.js diff --git a/backend/.env.template b/backend/.env.template index 5858a5d1e..dd46846a9 100644 --- a/backend/.env.template +++ b/backend/.env.template @@ -28,3 +28,5 @@ AWS_BUCKET= EMAIL_DEFAULT_SENDER="devops@ocelot.social" EMAIL_SUPPORT="devops@ocelot.social" + +CATEGORIES_ACTIVE=false diff --git a/backend/src/config/index.js b/backend/src/config/index.js index 6ad8c578b..7df780cfc 100644 --- a/backend/src/config/index.js +++ b/backend/src/config/index.js @@ -86,6 +86,7 @@ const options = { ORGANIZATION_URL: emails.ORGANIZATION_LINK, PUBLIC_REGISTRATION: env.PUBLIC_REGISTRATION === 'true' || false, INVITE_REGISTRATION: env.INVITE_REGISTRATION !== 'false', // default = true + CATEGORIES_ACTIVE: process.env.CATEGORIES_ACTIVE === 'true' || false, } // Check if all required configs are present diff --git a/backend/src/db/graphql/mutations.ts b/backend/src/db/graphql/mutations.ts new file mode 100644 index 000000000..a29cfa112 --- /dev/null +++ b/backend/src/db/graphql/mutations.ts @@ -0,0 +1,29 @@ +import gql from 'graphql-tag' + +export const createGroupMutation = gql` + mutation ( + $id: ID, + $name: String!, + $slug: String, + $about: String, + $categoryIds: [ID] + ) { + CreateGroup( + id: $id + name: $name + slug: $slug + about: $about + categoryIds: $categoryIds + ) { + id + name + slug + about + disabled + deleted + owner { + name + } + } + } +` diff --git a/backend/src/db/migrate/store.js b/backend/src/db/migrate/store.js index 7a8be0b94..78960be6b 100644 --- a/backend/src/db/migrate/store.js +++ b/backend/src/db/migrate/store.js @@ -59,7 +59,7 @@ class Store { const session = driver.session() await createDefaultAdminUser(session) const writeTxResultPromise = session.writeTransaction(async (txc) => { - await txc.run('CALL apoc.schema.assert({},{},true)') // drop all indices + await txc.run('CALL apoc.schema.assert({},{},true)') // drop all indices and contraints return Promise.all( [ 'CALL db.index.fulltext.createNodeIndex("user_fulltext_search",["User"],["name", "slug"])', diff --git a/backend/src/middleware/excerptMiddleware.js b/backend/src/middleware/excerptMiddleware.js index 40a6a6ae4..cfaf7f1b0 100644 --- a/backend/src/middleware/excerptMiddleware.js +++ b/backend/src/middleware/excerptMiddleware.js @@ -2,6 +2,11 @@ import trunc from 'trunc-html' export default { Mutation: { + CreateGroup: async (resolve, root, args, context, info) => { + args.descriptionExcerpt = trunc(args.description, 120).html + const result = await resolve(root, args, context, info) + return result + }, CreatePost: async (resolve, root, args, context, info) => { args.contentExcerpt = trunc(args.content, 120).html const result = await resolve(root, args, context, info) diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index b10389f50..7e23cfe0f 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -140,6 +140,7 @@ export default shield( Signup: or(publicRegistration, inviteRegistration, isAdmin), SignupVerification: allow, UpdateUser: onlyYourself, + CreateGroup: isAuthenticated, CreatePost: isAuthenticated, UpdatePost: isAuthor, DeletePost: isAuthor, diff --git a/backend/src/middleware/sluggifyMiddleware.js b/backend/src/middleware/sluggifyMiddleware.js index 165235be9..25c7c21a4 100644 --- a/backend/src/middleware/sluggifyMiddleware.js +++ b/backend/src/middleware/sluggifyMiddleware.js @@ -26,6 +26,10 @@ export default { args.slug = args.slug || (await uniqueSlug(args.name, isUniqueFor(context, 'User'))) return resolve(root, args, context, info) }, + CreateGroup: async (resolve, root, args, context, info) => { + args.slug = args.slug || (await uniqueSlug(args.title, isUniqueFor(context, 'Group'))) + return resolve(root, args, context, info) + }, CreatePost: async (resolve, root, args, context, info) => { args.slug = args.slug || (await uniqueSlug(args.title, isUniqueFor(context, 'Post'))) return resolve(root, args, context, info) diff --git a/backend/src/middleware/slugify/uniqueSlug.js b/backend/src/middleware/slugify/uniqueSlug.js index 7cfb89c19..41d58ece3 100644 --- a/backend/src/middleware/slugify/uniqueSlug.js +++ b/backend/src/middleware/slugify/uniqueSlug.js @@ -1,4 +1,5 @@ import slugify from 'slug' + export default async function uniqueSlug(string, isUnique) { const slug = slugify(string || 'anonymous', { lower: true, diff --git a/backend/src/models/Group.js b/backend/src/models/Group.js index b26f7779c..651c2983e 100644 --- a/backend/src/models/Group.js +++ b/backend/src/models/Group.js @@ -15,7 +15,8 @@ export default { wasSeeded: 'boolean', // Wolle: used or needed? locationName: { type: 'string', allow: [null] }, about: { type: 'string', allow: [null, ''] }, // Wolle: null? - description: { type: 'string', allow: [null, ''] }, // Wolle: null? HTML with Tiptap, similar to post content + description: { type: 'string', allow: [null, ''] }, // Wolle: null? HTML with Tiptap, similar to post content, wie bei Posts "content: { type: 'string', disallow: [null], min: 3 },"? + descriptionExcerpt: { type: 'string', allow: [null] }, // Wolle: followedBy: { // type: 'relationship', // relationship: 'FOLLOWS', @@ -25,8 +26,14 @@ export default { // createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, // }, // }, + owner: { + type: 'relationship', + relationship: 'OWNS', + target: 'User', + direction: 'in', + }, // Wolle: correct this way? - members: { type: 'relationship', relationship: 'MEMBERS', target: 'User', direction: 'out' }, + // members: { type: 'relationship', relationship: 'MEMBERS', target: 'User', direction: 'out' }, // Wolle: needed? lastActiveAt: { type: 'string', isoDate: true }, createdAt: { type: 'string', diff --git a/backend/src/schema/resolvers/groups.js b/backend/src/schema/resolvers/groups.js new file mode 100644 index 000000000..0ed5d1356 --- /dev/null +++ b/backend/src/schema/resolvers/groups.js @@ -0,0 +1,224 @@ +import { v4 as uuid } from 'uuid' +// Wolle: import { neo4jgraphql } from 'neo4j-graphql-js' +// Wolle: import { isEmpty } from 'lodash' +import { UserInputError } from 'apollo-server' +import CONFIG from '../../config' +// Wolle: import { mergeImage, deleteImage } from './images/images' +import Resolver from './helpers/Resolver' +// Wolle: import { filterForMutedUsers } from './helpers/filterForMutedUsers' + +// Wolle: const maintainPinnedPosts = (params) => { +// const pinnedPostFilter = { pinned: true } +// if (isEmpty(params.filter)) { +// params.filter = { OR: [pinnedPostFilter, {}] } +// } else { +// params.filter = { OR: [pinnedPostFilter, { ...params.filter }] } +// } +// return params +// } + +export default { + // Wolle: Query: { + // Post: async (object, params, context, resolveInfo) => { + // 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 filterForMutedUsers(params, context) + // return neo4jgraphql(object, params, context, resolveInfo) + // }, + // PostsEmotionsCountByEmotion: async (object, params, context, resolveInfo) => { + // const { postId, data } = params + // const session = context.driver.session() + // const readTxResultPromise = session.readTransaction(async (transaction) => { + // const emotionsCountTransactionResponse = await transaction.run( + // ` + // MATCH (post:Post {id: $postId})<-[emoted:EMOTED {emotion: $data.emotion}]-() + // RETURN COUNT(DISTINCT emoted) as emotionsCount + // `, + // { postId, data }, + // ) + // return emotionsCountTransactionResponse.records.map( + // (record) => record.get('emotionsCount').low, + // ) + // }) + // try { + // const [emotionsCount] = await readTxResultPromise + // return emotionsCount + // } finally { + // session.close() + // } + // }, + // PostsEmotionsByCurrentUser: async (object, params, context, resolveInfo) => { + // const { postId } = params + // const session = context.driver.session() + // const readTxResultPromise = session.readTransaction(async (transaction) => { + // const emotionsTransactionResponse = await transaction.run( + // ` + // MATCH (user:User {id: $userId})-[emoted:EMOTED]->(post:Post {id: $postId}) + // RETURN collect(emoted.emotion) as emotion + // `, + // { userId: context.user.id, postId }, + // ) + // return emotionsTransactionResponse.records.map((record) => record.get('emotion')) + // }) + // try { + // const [emotions] = await readTxResultPromise + // return emotions + // } finally { + // session.close() + // } + // }, + // }, + Mutation: { + CreateGroup: async (_parent, params, context, _resolveInfo) => { + const { categoryIds } = params + delete params.categoryIds + params.id = params.id || uuid() + const session = context.driver.session() + const writeTxResultPromise = session.writeTransaction(async (transaction) => { + const categoriesCypher = + CONFIG.CATEGORIES_ACTIVE && categoryIds + ? `WITH group + UNWIND $categoryIds AS categoryId + MATCH (category:Category {id: categoryId}) + MERGE (group)-[:CATEGORIZED]->(category)` + : '' + const ownercreateGroupTransactionResponse = await transaction.run( + ` + CREATE (group:Group) + SET group += $params + SET group.createdAt = toString(datetime()) + SET group.updatedAt = toString(datetime()) + WITH group + MATCH (owner:User {id: $userId}) + MERGE (group)<-[:OWNS]-(owner) + MERGE (group)<-[:ADMINISTERS]-(owner) + ${categoriesCypher} + RETURN group {.*} + `, + { userId: context.user.id, categoryIds, params }, + ) + const [group] = ownercreateGroupTransactionResponse.records.map((record) => + record.get('group'), + ) + return group + }) + try { + const group = await writeTxResultPromise + return group + } catch (e) { + if (e.code === 'Neo.ClientError.Schema.ConstraintValidationFailed') + throw new UserInputError('Group with this slug already exists!') + throw new Error(e) + } finally { + session.close() + } + }, + // UpdatePost: async (_parent, params, context, _resolveInfo) => { + // const { categoryIds } = params + // const { image: imageInput } = params + // delete params.categoryIds + // delete params.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) { + // const cypherDeletePreviousRelations = ` + // MATCH (post:Post { id: $params.id })-[previousRelations:CATEGORIZED]->(category:Category) + // DELETE previousRelations + // RETURN post, category + // ` + + // await session.writeTransaction((transaction) => { + // return transaction.run(cypherDeletePreviousRelations, { params }) + // }) + + // updatePostCypher += ` + // UNWIND $categoryIds AS categoryId + // MATCH (category:Category {id: categoryId}) + // MERGE (post)-[:CATEGORIZED]->(category) + // WITH post + // ` + // } + + // updatePostCypher += `RETURN post {.*}` + // const updatePostVariables = { categoryIds, params } + // try { + // const writeTxResultPromise = session.writeTransaction(async (transaction) => { + // const updatePostTransactionResponse = await transaction.run( + // updatePostCypher, + // updatePostVariables, + // ) + // const [post] = updatePostTransactionResponse.records.map((record) => record.get('post')) + // await mergeImage(post, 'HERO_IMAGE', imageInput, { transaction }) + // return post + // }) + // const post = await writeTxResultPromise + // return post + // } finally { + // session.close() + // } + // }, + + // DeletePost: async (object, args, context, resolveInfo) => { + // const session = context.driver.session() + // const writeTxResultPromise = session.writeTransaction(async (transaction) => { + // const deletePostTransactionResponse = await transaction.run( + // ` + // MATCH (post:Post {id: $postId}) + // OPTIONAL MATCH (post)<-[:COMMENTS]-(comment:Comment) + // SET post.deleted = TRUE + // SET post.content = 'UNAVAILABLE' + // SET post.contentExcerpt = 'UNAVAILABLE' + // SET post.title = 'UNAVAILABLE' + // SET comment.deleted = TRUE + // RETURN post {.*} + // `, + // { postId: args.id }, + // ) + // const [post] = deletePostTransactionResponse.records.map((record) => record.get('post')) + // await deleteImage(post, 'HERO_IMAGE', { transaction }) + // return post + // }) + // try { + // const post = await writeTxResultPromise + // return post + // } finally { + // session.close() + // } + }, + Group: { + ...Resolver('Group', { + // Wolle: undefinedToNull: ['activityId', 'objectId', 'language', 'pinnedAt', 'pinned'], + hasMany: { + // Wolle: tags: '-[:TAGGED]->(related:Tag)', + categories: '-[:CATEGORIZED]->(related:Category)', + }, + hasOne: { + owner: '<-[:OWNS]-(related:User)', + // Wolle: image: '-[:HERO_IMAGE]->(related:Image)', + }, + // Wolle: count: { + // contributionsCount: + // '-[:WROTE]->(related:Post) WHERE NOT related.disabled = true AND NOT related.deleted = true', + // }, + // Wolle: boolean: { + // shoutedByCurrentUser: + // 'MATCH(this)<-[:SHOUTED]-(related:User {id: $cypherParams.currentUserId}) RETURN COUNT(related) >= 1', + // viewedTeaserByCurrentUser: + // 'MATCH (this)<-[:VIEWED_TEASER]-(u:User {id: $cypherParams.currentUserId}) RETURN COUNT(u) >= 1', + // }, + }), + }, +} diff --git a/backend/src/schema/resolvers/groups.spec.js b/backend/src/schema/resolvers/groups.spec.js new file mode 100644 index 000000000..aba718159 --- /dev/null +++ b/backend/src/schema/resolvers/groups.spec.js @@ -0,0 +1,610 @@ +import { createTestClient } from 'apollo-server-testing' +import Factory, { cleanDatabase } from '../../db/factories' +import { createGroupMutation } from '../../db/graphql/mutations' +import { getNeode, getDriver } from '../../db/neo4j' +import createServer from '../../server' + +const driver = getDriver() +const neode = getNeode() + +// Wolle: let query +let mutate +let authenticatedUser +let user + +const categoryIds = ['cat9', 'cat4', 'cat15'] +let variables = {} + +beforeAll(async () => { + await cleanDatabase() + + const { server } = createServer({ + context: () => { + return { + driver, + neode, + user: authenticatedUser, + } + }, + }) + // Wolle: query = createTestClient(server).query + mutate = createTestClient(server).mutate +}) + +afterAll(async () => { + await cleanDatabase() +}) + +beforeEach(async () => { + variables = {} + user = await Factory.build( + 'user', + { + id: 'current-user', + name: 'TestUser', + }, + { + email: 'test@example.org', + password: '1234', + }, + ) + await Promise.all([ + neode.create('Category', { + id: 'cat9', + name: 'Democracy & Politics', + icon: 'university', + }), + neode.create('Category', { + id: 'cat4', + name: 'Environment & Nature', + icon: 'tree', + }), + neode.create('Category', { + id: 'cat15', + name: 'Consumption & Sustainability', + icon: 'shopping-cart', + }), + neode.create('Category', { + id: 'cat27', + name: 'Animal Protection', + icon: 'paw', + }), + ]) + authenticatedUser = null +}) + +// TODO: avoid database clean after each test in the future if possible for performance and flakyness reasons by filling the database step by step, see issue https://github.com/Ocelot-Social-Community/Ocelot-Social/issues/4543 +afterEach(async () => { + await cleanDatabase() +}) + +// describe('Group', () => { +// describe('can be filtered', () => { +// let followedUser, happyPost, cryPost +// beforeEach(async () => { +// ;[followedUser] = await Promise.all([ +// Factory.build( +// 'user', +// { +// id: 'followed-by-me', +// name: 'Followed User', +// }, +// { +// email: 'followed@example.org', +// password: '1234', +// }, +// ), +// ]) +// ;[happyPost, cryPost] = await Promise.all([ +// Factory.build('post', { id: 'happy-post' }, { categoryIds: ['cat4'] }), +// Factory.build('post', { id: 'cry-post' }, { categoryIds: ['cat15'] }), +// Factory.build( +// 'post', +// { +// id: 'post-by-followed-user', +// }, +// { +// categoryIds: ['cat9'], +// author: followedUser, +// }, +// ), +// ]) +// }) + +// describe('no filter', () => { +// it('returns all posts', async () => { +// const postQueryNoFilters = gql` +// query Post($filter: _PostFilter) { +// Post(filter: $filter) { +// id +// } +// } +// ` +// const expected = [{ id: 'happy-post' }, { id: 'cry-post' }, { id: 'post-by-followed-user' }] +// variables = { filter: {} } +// await expect(query({ query: postQueryNoFilters, variables })).resolves.toMatchObject({ +// data: { +// Post: expect.arrayContaining(expected), +// }, +// }) +// }) +// }) + +// /* it('by categories', async () => { +// const postQueryFilteredByCategories = gql` +// query Post($filter: _PostFilter) { +// Post(filter: $filter) { +// id +// categories { +// id +// } +// } +// } +// ` +// const expected = { +// data: { +// Post: [ +// { +// id: 'post-by-followed-user', +// categories: [{ id: 'cat9' }], +// }, +// ], +// }, +// } +// variables = { ...variables, filter: { categories_some: { id_in: ['cat9'] } } } +// await expect( +// query({ query: postQueryFilteredByCategories, variables }), +// ).resolves.toMatchObject(expected) +// }) */ + +// describe('by emotions', () => { +// const postQueryFilteredByEmotions = gql` +// query Post($filter: _PostFilter) { +// Post(filter: $filter) { +// id +// emotions { +// emotion +// } +// } +// } +// ` + +// it('filters by single emotion', async () => { +// const expected = { +// data: { +// Post: [ +// { +// id: 'happy-post', +// emotions: [{ emotion: 'happy' }], +// }, +// ], +// }, +// } +// await user.relateTo(happyPost, 'emoted', { emotion: 'happy' }) +// variables = { ...variables, filter: { emotions_some: { emotion_in: ['happy'] } } } +// await expect( +// query({ query: postQueryFilteredByEmotions, variables }), +// ).resolves.toMatchObject(expected) +// }) + +// it('filters by multiple emotions', async () => { +// const expected = [ +// { +// id: 'happy-post', +// emotions: [{ emotion: 'happy' }], +// }, +// { +// id: 'cry-post', +// emotions: [{ emotion: 'cry' }], +// }, +// ] +// await user.relateTo(happyPost, 'emoted', { emotion: 'happy' }) +// await user.relateTo(cryPost, 'emoted', { emotion: 'cry' }) +// variables = { ...variables, filter: { emotions_some: { emotion_in: ['happy', 'cry'] } } } +// await expect( +// query({ query: postQueryFilteredByEmotions, variables }), +// ).resolves.toMatchObject({ +// data: { +// Post: expect.arrayContaining(expected), +// }, +// errors: undefined, +// }) +// }) +// }) + +// it('by followed-by', async () => { +// const postQueryFilteredByUsersFollowed = gql` +// query Post($filter: _PostFilter) { +// Post(filter: $filter) { +// id +// author { +// id +// name +// } +// } +// } +// ` + +// await user.relateTo(followedUser, 'following') +// variables = { filter: { author: { followedBy_some: { id: 'current-user' } } } } +// await expect( +// query({ query: postQueryFilteredByUsersFollowed, variables }), +// ).resolves.toMatchObject({ +// data: { +// Post: [ +// { +// id: 'post-by-followed-user', +// author: { id: 'followed-by-me', name: 'Followed User' }, +// }, +// ], +// }, +// errors: undefined, +// }) +// }) +// }) +// }) + +describe('CreateGroup', () => { + beforeEach(() => { + variables = { + ...variables, + id: 'g589', + name: 'The Best Group', + slug: 'the-best-group', + about: 'We will change the world!', + categoryIds, + } + }) + + describe('unauthenticated', () => { + it('throws authorization error', async () => { + const { errors } = await mutate({ mutation: createGroupMutation, variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') + }) + }) + + describe('authenticated', () => { + beforeEach(async () => { + authenticatedUser = await user.toJson() + }) + + it('creates a group', async () => { + const expected = { + data: { + CreateGroup: { + // Wolle: id: 'g589', + name: 'The Best Group', + slug: 'the-best-group', + about: 'We will change the world!', + }, + }, + errors: undefined, + } + await expect(mutate({ mutation: createGroupMutation, variables })).resolves.toMatchObject( + expected, + ) + }) + + it('assigns the authenticated user as owner', async () => { + const expected = { + data: { + CreateGroup: { + name: 'The Best Group', + owner: { + name: 'TestUser', + }, + }, + }, + errors: undefined, + } + await expect(mutate({ mutation: createGroupMutation, variables })).resolves.toMatchObject( + expected, + ) + }) + + it('`disabled` and `deleted` default to `false`', async () => { + const expected = { data: { CreateGroup: { disabled: false, deleted: false } } } + await expect(mutate({ mutation: createGroupMutation, variables })).resolves.toMatchObject( + expected, + ) + }) + }) +}) + +// describe('UpdatePost', () => { +// let author, newlyCreatedPost +// const updatePostMutation = gql` +// mutation ($id: ID!, $title: String!, $content: String!, $image: ImageInput) { +// UpdatePost(id: $id, title: $title, content: $content, image: $image) { +// id +// title +// content +// author { +// name +// slug +// } +// createdAt +// updatedAt +// } +// } +// ` +// beforeEach(async () => { +// author = await Factory.build('user', { slug: 'the-author' }) +// newlyCreatedPost = await Factory.build( +// 'post', +// { +// id: 'p9876', +// title: 'Old title', +// content: 'Old content', +// }, +// { +// author, +// categoryIds, +// }, +// ) + +// variables = { +// id: 'p9876', +// title: 'New title', +// content: 'New content', +// } +// }) + +// describe('unauthenticated', () => { +// it('throws authorization error', async () => { +// authenticatedUser = null +// expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject({ +// errors: [{ message: 'Not Authorised!' }], +// data: { UpdatePost: null }, +// }) +// }) +// }) + +// describe('authenticated but not the author', () => { +// beforeEach(async () => { +// authenticatedUser = await user.toJson() +// }) + +// it('throws authorization error', async () => { +// const { errors } = await mutate({ mutation: updatePostMutation, variables }) +// expect(errors[0]).toHaveProperty('message', 'Not Authorised!') +// }) +// }) + +// describe('authenticated as author', () => { +// beforeEach(async () => { +// authenticatedUser = await author.toJson() +// }) + +// it('updates a post', async () => { +// const expected = { +// data: { UpdatePost: { id: 'p9876', content: 'New content' } }, +// errors: undefined, +// } +// await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject( +// expected, +// ) +// }) + +// it('updates a post, but maintains non-updated attributes', async () => { +// const expected = { +// data: { +// UpdatePost: { id: 'p9876', content: 'New content', createdAt: expect.any(String) }, +// }, +// errors: undefined, +// } +// await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject( +// expected, +// ) +// }) + +// it('updates the updatedAt attribute', async () => { +// newlyCreatedPost = await newlyCreatedPost.toJson() +// const { +// data: { UpdatePost }, +// } = await mutate({ mutation: updatePostMutation, variables }) +// expect(newlyCreatedPost.updatedAt).toBeTruthy() +// expect(Date.parse(newlyCreatedPost.updatedAt)).toEqual(expect.any(Number)) +// expect(UpdatePost.updatedAt).toBeTruthy() +// expect(Date.parse(UpdatePost.updatedAt)).toEqual(expect.any(Number)) +// expect(newlyCreatedPost.updatedAt).not.toEqual(UpdatePost.updatedAt) +// }) + +// /* describe('no new category ids provided for update', () => { +// it('resolves and keeps current categories', async () => { +// const expected = { +// data: { +// UpdatePost: { +// id: 'p9876', +// categories: expect.arrayContaining([{ id: 'cat9' }, { id: 'cat4' }, { id: 'cat15' }]), +// }, +// }, +// errors: undefined, +// } +// await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject( +// expected, +// ) +// }) +// }) */ + +// /* describe('given category ids', () => { +// beforeEach(() => { +// variables = { ...variables, categoryIds: ['cat27'] } +// }) + +// it('updates categories of a post', async () => { +// const expected = { +// data: { +// UpdatePost: { +// id: 'p9876', +// categories: expect.arrayContaining([{ id: 'cat27' }]), +// }, +// }, +// errors: undefined, +// } +// await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject( +// expected, +// ) +// }) +// }) */ + +// describe('params.image', () => { +// describe('is object', () => { +// beforeEach(() => { +// variables = { ...variables, image: { sensitive: true } } +// }) +// it('updates the image', async () => { +// await expect(neode.first('Image', { sensitive: true })).resolves.toBeFalsy() +// await mutate({ mutation: updatePostMutation, variables }) +// await expect(neode.first('Image', { sensitive: true })).resolves.toBeTruthy() +// }) +// }) + +// describe('is null', () => { +// beforeEach(() => { +// variables = { ...variables, image: null } +// }) +// it('deletes the image', async () => { +// await expect(neode.all('Image')).resolves.toHaveLength(6) +// await mutate({ mutation: updatePostMutation, variables }) +// await expect(neode.all('Image')).resolves.toHaveLength(5) +// }) +// }) + +// describe('is undefined', () => { +// beforeEach(() => { +// delete variables.image +// }) +// it('keeps the image unchanged', async () => { +// await expect(neode.first('Image', { sensitive: true })).resolves.toBeFalsy() +// await mutate({ mutation: updatePostMutation, variables }) +// await expect(neode.first('Image', { sensitive: true })).resolves.toBeFalsy() +// }) +// }) +// }) +// }) +// }) + +// describe('DeletePost', () => { +// let author +// const deletePostMutation = gql` +// mutation ($id: ID!) { +// DeletePost(id: $id) { +// id +// deleted +// content +// contentExcerpt +// image { +// url +// } +// comments { +// deleted +// content +// contentExcerpt +// } +// } +// } +// ` + +// beforeEach(async () => { +// author = await Factory.build('user') +// await Factory.build( +// 'post', +// { +// id: 'p4711', +// title: 'I will be deleted', +// content: 'To be deleted', +// }, +// { +// image: Factory.build('image', { +// url: 'path/to/some/image', +// }), +// author, +// categoryIds, +// }, +// ) +// variables = { ...variables, id: 'p4711' } +// }) + +// describe('unauthenticated', () => { +// it('throws authorization error', async () => { +// const { errors } = await mutate({ mutation: deletePostMutation, variables }) +// expect(errors[0]).toHaveProperty('message', 'Not Authorised!') +// }) +// }) + +// describe('authenticated but not the author', () => { +// beforeEach(async () => { +// authenticatedUser = await user.toJson() +// }) + +// it('throws authorization error', async () => { +// const { errors } = await mutate({ mutation: deletePostMutation, variables }) +// expect(errors[0]).toHaveProperty('message', 'Not Authorised!') +// }) +// }) + +// describe('authenticated as author', () => { +// beforeEach(async () => { +// authenticatedUser = await author.toJson() +// }) + +// it('marks the post as deleted and blacks out attributes', async () => { +// const expected = { +// data: { +// DeletePost: { +// id: 'p4711', +// deleted: true, +// content: 'UNAVAILABLE', +// contentExcerpt: 'UNAVAILABLE', +// image: null, +// comments: [], +// }, +// }, +// } +// await expect(mutate({ mutation: deletePostMutation, variables })).resolves.toMatchObject( +// expected, +// ) +// }) + +// describe('if there are comments on the post', () => { +// beforeEach(async () => { +// await Factory.build( +// 'comment', +// { +// content: 'to be deleted comment content', +// contentExcerpt: 'to be deleted comment content', +// }, +// { +// postId: 'p4711', +// }, +// ) +// }) + +// it('marks the comments as deleted', async () => { +// const expected = { +// data: { +// DeletePost: { +// id: 'p4711', +// deleted: true, +// content: 'UNAVAILABLE', +// contentExcerpt: 'UNAVAILABLE', +// image: null, +// comments: [ +// { +// deleted: true, +// // Should we black out the comment content in the database, too? +// content: 'UNAVAILABLE', +// contentExcerpt: 'UNAVAILABLE', +// }, +// ], +// }, +// }, +// } +// await expect(mutate({ mutation: deletePostMutation, variables })).resolves.toMatchObject( +// expected, +// ) +// }) +// }) +// }) +// }) diff --git a/backend/src/schema/types/type/Group.gql b/backend/src/schema/types/type/Group.gql index 655a251d6..2dbe3c8c6 100644 --- a/backend/src/schema/types/type/Group.gql +++ b/backend/src/schema/types/type/Group.gql @@ -21,7 +21,7 @@ type Group { id: ID! name: String # title slug: String! - + createdAt: String updatedAt: String deleted: Boolean @@ -41,12 +41,14 @@ type Group { # Wolle: needed? socialMedia: [SocialMedia]! @relation(name: "OWNED_BY", direction: "IN") + owner: User @relation(name: "OWNS", direction: "IN") + # Wolle: showShoutsPublicly: Boolean # Wolle: sendNotificationEmails: Boolean # Wolle: needed? locale: String - members: [User]! @relation(name: "MEMBERS", direction: "OUT") - membersCount: Int! - @cypher(statement: "MATCH (this)-[:MEMBERS]->(r:User) RETURN COUNT(DISTINCT r)") + # members: [User]! @relation(name: "MEMBERS", direction: "OUT") + # membersCount: Int! + # @cypher(statement: "MATCH (this)-[:MEMBERS]->(r:User) RETURN COUNT(DISTINCT r)") # Wolle: followedBy: [User]! @relation(name: "FOLLOWS", direction: "IN") # Wolle: followedByCount: Int! @@ -207,14 +209,14 @@ type Query { type Mutation { CreateGroup ( - id: ID! - name: String - email: String + id: ID + name: String! slug: String avatar: ImageInput locationName: String about: String description: String + categoryIds: [ID] # Wolle: add group settings # Wolle: # showShoutsPublicly: Boolean @@ -222,10 +224,9 @@ type Mutation { # locale: String ): Group - UpdateUser ( + UpdateGroup ( id: ID! name: String - email: String slug: String avatar: ImageInput locationName: String diff --git a/webapp/.env.template b/webapp/.env.template index 7373255a9..9776fcea2 100644 --- a/webapp/.env.template +++ b/webapp/.env.template @@ -4,3 +4,4 @@ PUBLIC_REGISTRATION=false INVITE_REGISTRATION=true WEBSOCKETS_URI=ws://localhost:3000/api/graphql GRAPHQL_URI=http://localhost:4000/ +CATEGORIES_ACTIVE=false diff --git a/webapp/config/index.js b/webapp/config/index.js index 00df85bac..db030e929 100644 --- a/webapp/config/index.js +++ b/webapp/config/index.js @@ -33,6 +33,7 @@ const options = { // Cookies COOKIE_EXPIRE_TIME: process.env.COOKIE_EXPIRE_TIME || 730, // Two years by default COOKIE_HTTPS_ONLY: process.env.COOKIE_HTTPS_ONLY || process.env.NODE_ENV === 'production', // ensure true in production if not set explicitly + CATEGORIES_ACTIVE: process.env.CATEGORIES_ACTIVE === 'true' || false, } const CONFIG = { From 4f1646509b40a9ddc21b30dadd7056b10ffaa91e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Tue, 2 Aug 2022 16:25:46 +0200 Subject: [PATCH 05/29] Fix Group name slugification --- backend/src/middleware/sluggifyMiddleware.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/middleware/sluggifyMiddleware.js b/backend/src/middleware/sluggifyMiddleware.js index 25c7c21a4..2a965c87f 100644 --- a/backend/src/middleware/sluggifyMiddleware.js +++ b/backend/src/middleware/sluggifyMiddleware.js @@ -27,7 +27,7 @@ export default { return resolve(root, args, context, info) }, CreateGroup: async (resolve, root, args, context, info) => { - args.slug = args.slug || (await uniqueSlug(args.title, isUniqueFor(context, 'Group'))) + args.slug = args.slug || (await uniqueSlug(args.name, isUniqueFor(context, 'Group'))) return resolve(root, args, context, info) }, CreatePost: async (resolve, root, args, context, info) => { From da2c7dc6845cd100d8b9d3e0a6b0ef4a7426cbbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Wed, 3 Aug 2022 07:29:51 +0200 Subject: [PATCH 06/29] Add migration with fulltext indeces and unique keys for groups --- backend/src/db/migrate/store.js | 1 - ...text_indices_and_unique_keys_for_groups.js | 66 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 backend/src/db/migrations/20220803060819-create_fulltext_indices_and_unique_keys_for_groups.js diff --git a/backend/src/db/migrate/store.js b/backend/src/db/migrate/store.js index 78960be6b..938ebef02 100644 --- a/backend/src/db/migrate/store.js +++ b/backend/src/db/migrate/store.js @@ -63,7 +63,6 @@ class Store { return Promise.all( [ 'CALL db.index.fulltext.createNodeIndex("user_fulltext_search",["User"],["name", "slug"])', - 'CALL db.index.fulltext.createNodeIndex("user_fulltext_search",["Group"],["name", "slug", "description"])', // Wolle: check for 'name', 'slug', 'description' 'CALL db.index.fulltext.createNodeIndex("post_fulltext_search",["Post"],["title", "content"])', 'CALL db.index.fulltext.createNodeIndex("tag_fulltext_search",["Tag"],["id"])', ].map((statement) => txc.run(statement)), diff --git a/backend/src/db/migrations/20220803060819-create_fulltext_indices_and_unique_keys_for_groups.js b/backend/src/db/migrations/20220803060819-create_fulltext_indices_and_unique_keys_for_groups.js new file mode 100644 index 000000000..b87e5632a --- /dev/null +++ b/backend/src/db/migrations/20220803060819-create_fulltext_indices_and_unique_keys_for_groups.js @@ -0,0 +1,66 @@ +import { getDriver } from '../../db/neo4j' + +export const description = ` + We introduced a new node label 'Group' and we need two primary keys 'id' and 'slug' for it. + Additional we like to have fulltext indices the keys 'name', 'slug', 'about', and 'description'. +` + +export async function up(next) { + const driver = getDriver() + const session = driver.session() + const transaction = session.beginTransaction() + + try { + // Implement your migration here. + await transaction.run(` + CREATE CONSTRAINT ON ( group:Group ) ASSERT group.id IS UNIQUE + `) + await transaction.run(` + CREATE CONSTRAINT ON ( group:Group ) ASSERT group.slug IS UNIQUE + `) + await transaction.run(` + CALL db.index.fulltext.createNodeIndex("group_fulltext_search",["Group"],["name", "slug", "about", "description"]) + `) + await transaction.commit() + next() + } catch (error) { + // eslint-disable-next-line no-console + console.log(error) + await transaction.rollback() + // eslint-disable-next-line no-console + console.log('rolled back') + throw new Error(error) + } finally { + session.close() + } +} + +export async function down(next) { + const driver = getDriver() + const session = driver.session() + const transaction = session.beginTransaction() + + try { + // Implement your migration here. + await transaction.run(` + DROP CONSTRAINT ON ( group:Group ) ASSERT group.id IS UNIQUE + `) + await transaction.run(` + DROP CONSTRAINT ON ( group:Group ) ASSERT group.slug IS UNIQUE + `) + await transaction.run(` + CALL db.index.fulltext.drop("group_fulltext_search") + `) + await transaction.commit() + next() + } catch (error) { + // eslint-disable-next-line no-console + console.log(error) + await transaction.rollback() + // eslint-disable-next-line no-console + console.log('rolled back') + throw new Error(error) + } finally { + session.close() + } +} From fc20143a6566df4e81dd437c833f3e606abe31dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Wed, 3 Aug 2022 07:51:29 +0200 Subject: [PATCH 07/29] Test groups creation in slugifyMiddleware --- backend/src/db/graphql/mutations.ts | 30 +++ .../src/middleware/slugifyMiddleware.spec.js | 246 ++++++++++++++---- backend/src/models/User.spec.js | 2 +- backend/src/schema/resolvers/groups.spec.js | 4 +- 4 files changed, 222 insertions(+), 60 deletions(-) diff --git a/backend/src/db/graphql/mutations.ts b/backend/src/db/graphql/mutations.ts index a29cfa112..5fc554ee2 100644 --- a/backend/src/db/graphql/mutations.ts +++ b/backend/src/db/graphql/mutations.ts @@ -27,3 +27,33 @@ export const createGroupMutation = gql` } } ` + +export const createPostMutation = gql` + mutation ($title: String!, $content: String!, $categoryIds: [ID]!, $slug: String) { + CreatePost(title: $title, content: $content, categoryIds: $categoryIds, slug: $slug) { + slug + } + } +` + +export const signupVerificationMutation = gql` + mutation ( + $password: String! + $email: String! + $name: String! + $slug: String + $nonce: String! + $termsAndConditionsAgreedVersion: String! + ) { + SignupVerification( + email: $email + password: $password + name: $name + slug: $slug + nonce: $nonce + termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion + ) { + slug + } + } +` diff --git a/backend/src/middleware/slugifyMiddleware.spec.js b/backend/src/middleware/slugifyMiddleware.spec.js index 7c6f18ab1..fbf03c675 100644 --- a/backend/src/middleware/slugifyMiddleware.spec.js +++ b/backend/src/middleware/slugifyMiddleware.spec.js @@ -1,8 +1,9 @@ -import Factory, { cleanDatabase } from '../db/factories' -import { gql } from '../helpers/jest' +import gql from 'graphql-tag' import { getNeode, getDriver } from '../db/neo4j' import createServer from '../server' import { createTestClient } from 'apollo-server-testing' +import Factory, { cleanDatabase } from '../db/factories' +import { createPostMutation, createGroupMutation, signupVerificationMutation } from '../db/graphql/mutations' let mutate let authenticatedUser @@ -57,15 +58,128 @@ afterEach(async () => { }) describe('slugifyMiddleware', () => { + describe('CreateGroup', () => { + const categoryIds = ['cat9'] + + beforeEach(() => { + variables = { + ...variables, + name: 'The Best Group', + about: 'Some about', + categoryIds, + } + }) + + describe('if slug not exists', () => { + it('generates a slug based on name', async () => { + await expect( + mutate({ + mutation: createGroupMutation, + variables, + }), + ).resolves.toMatchObject({ + data: { + CreateGroup: { + slug: 'the-best-group', + }, + }, + }) + }) + + it('generates a slug based on given slug', async () => { + await expect( + mutate({ + mutation: createGroupMutation, + variables: { + ...variables, + slug: 'the-group', + }, + }), + ).resolves.toMatchObject({ + data: { + CreateGroup: { + slug: 'the-group', + }, + }, + }) + }) + }) + + describe('if slug exists', () => { + beforeEach(async () => { + await mutate({ + mutation: createGroupMutation, + variables: { + ...variables, + name: 'Pre-Existing Group', + slug: 'pre-existing-group', + about: 'As an about', + }, + }) + }) + + it('chooses another slug', async () => { + variables = { + ...variables, + name: 'Pre-Existing Group', + about: 'As an about', + } + await expect( + mutate({ + mutation: createGroupMutation, + variables, + }), + ).resolves.toMatchObject({ + data: { + CreateGroup: { + slug: 'pre-existing-group-1', + }, + }, + }) + }) + + describe('but if the client specifies a slug', () => { + it('rejects CreateGroup', async (done) => { + variables = { + ...variables, + name: 'Pre-Existing Group', + about: 'As an about', + slug: 'pre-existing-group', + } + try { + await expect( + mutate({ mutation: createGroupMutation, variables }), + ).resolves.toMatchObject({ + errors: [ + { + message: 'Group with this slug already exists!', + }, + ], + }) + done() + } catch (error) { + throw new Error(` + ${error} + + Probably your database has no unique constraints! + + To see all constraints go to http://localhost:7474/browser/ and + paste the following: + \`\`\` + CALL db.constraints(); + \`\`\` + + Learn how to setup the database here: + https://github.com/Ocelot-Social-Community/Ocelot-Social/blob/master/backend/README.md#database-indices-and-constraints + `) + } + }) + }) + }) + }) + describe('CreatePost', () => { const categoryIds = ['cat9'] - const createPostMutation = gql` - mutation ($title: String!, $content: String!, $categoryIds: [ID]!, $slug: String) { - CreatePost(title: $title, content: $content, categoryIds: $categoryIds, slug: $slug) { - slug - } - } - ` beforeEach(() => { variables = { @@ -76,18 +190,38 @@ describe('slugifyMiddleware', () => { } }) - it('generates a slug based on title', async () => { - await expect( - mutate({ - mutation: createPostMutation, - variables, - }), - ).resolves.toMatchObject({ - data: { - CreatePost: { - slug: 'i-am-a-brand-new-post', + describe('if slug not exists', () => { + it('generates a slug based on title', async () => { + await expect( + mutate({ + mutation: createPostMutation, + variables, + }), + ).resolves.toMatchObject({ + data: { + CreatePost: { + slug: 'i-am-a-brand-new-post', + }, }, - }, + }) + }) + + it('generates a slug based on given slug', async () => { + await expect( + mutate({ + mutation: createPostMutation, + variables: { + ...variables, + slug: 'the-post', + }, + }), + ).resolves.toMatchObject({ + data: { + CreatePost: { + slug: 'the-post', + }, + }, + }) }) }) @@ -160,7 +294,7 @@ describe('slugifyMiddleware', () => { \`\`\` Learn how to setup the database here: - https://docs.human-connection.org/human-connection/backend#database-indices-and-constraints + https://github.com/Ocelot-Social-Community/Ocelot-Social/blob/master/backend/README.md#database-indices-and-constraints `) } }) @@ -169,28 +303,6 @@ describe('slugifyMiddleware', () => { }) describe('SignupVerification', () => { - const mutation = gql` - mutation ( - $password: String! - $email: String! - $name: String! - $slug: String - $nonce: String! - $termsAndConditionsAgreedVersion: String! - ) { - SignupVerification( - email: $email - password: $password - name: $name - slug: $slug - nonce: $nonce - termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion - ) { - slug - } - } - ` - beforeEach(() => { variables = { ...variables, @@ -211,21 +323,41 @@ describe('slugifyMiddleware', () => { }) }) - it('generates a slug based on name', async () => { - await expect( - mutate({ - mutation, - variables, - }), - ).resolves.toMatchObject({ - data: { - SignupVerification: { - slug: 'i-am-a-user', + describe('if slug not exists', () => { + it('generates a slug based on name', async () => { + await expect( + mutate({ + mutation: signupVerificationMutation, + variables, + }), + ).resolves.toMatchObject({ + data: { + SignupVerification: { + slug: 'i-am-a-user', + }, }, - }, + }) + }) + + it('generates a slug based on given slug', async () => { + await expect( + mutate({ + mutation: signupVerificationMutation, + variables: { + ...variables, + slug: 'the-user', + }, + }), + ).resolves.toMatchObject({ + data: { + SignupVerification: { + slug: 'the-user', + }, + }, + }) }) }) - + describe('if slug exists', () => { beforeEach(async () => { await Factory.build('user', { @@ -237,7 +369,7 @@ describe('slugifyMiddleware', () => { it('chooses another slug', async () => { await expect( mutate({ - mutation, + mutation: signupVerificationMutation, variables, }), ).resolves.toMatchObject({ @@ -260,7 +392,7 @@ describe('slugifyMiddleware', () => { it('rejects SignupVerification (on FAIL Neo4j constraints may not defined in database)', async () => { await expect( mutate({ - mutation, + mutation: signupVerificationMutation, variables, }), ).resolves.toMatchObject({ diff --git a/backend/src/models/User.spec.js b/backend/src/models/User.spec.js index 102acde6a..c64d1fd37 100644 --- a/backend/src/models/User.spec.js +++ b/backend/src/models/User.spec.js @@ -55,7 +55,7 @@ describe('slug', () => { \`\`\` Learn how to setup the database here: - https://docs.human-connection.org/human-connection/backend#database-indices-and-constraints + https://github.com/Ocelot-Social-Community/Ocelot-Social/blob/master/backend/README.md#database-indices-and-constraints `) } }) diff --git a/backend/src/schema/resolvers/groups.spec.js b/backend/src/schema/resolvers/groups.spec.js index aba718159..8d8c7c3d8 100644 --- a/backend/src/schema/resolvers/groups.spec.js +++ b/backend/src/schema/resolvers/groups.spec.js @@ -250,7 +250,7 @@ describe('CreateGroup', () => { ...variables, id: 'g589', name: 'The Best Group', - slug: 'the-best-group', + slug: 'the-group', about: 'We will change the world!', categoryIds, } @@ -274,7 +274,7 @@ describe('CreateGroup', () => { CreateGroup: { // Wolle: id: 'g589', name: 'The Best Group', - slug: 'the-best-group', + slug: 'the-group', about: 'We will change the world!', }, }, From ea0223b6f2a9f19c74307d28612e0cd16f192628 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Wed, 3 Aug 2022 07:59:00 +0200 Subject: [PATCH 08/29] Rename 'UserGroup' to 'UserRole' --- backend/src/schema/types/type/Group.gql | 32 ++++++++++++------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/backend/src/schema/types/type/Group.gql b/backend/src/schema/types/type/Group.gql index 2dbe3c8c6..310df9dbc 100644 --- a/backend/src/schema/types/type/Group.gql +++ b/backend/src/schema/types/type/Group.gql @@ -160,7 +160,7 @@ input _GroupFilter { # followedBy_none: _GroupFilter # followedBy_single: _GroupFilter # followedBy_every: _GroupFilter - # role_in: [UserGroup!] + # role_in: [UserRole!] } type Query { @@ -183,7 +183,7 @@ type Query { availableGroupTypes: [GroupType]! # Wolle: - # availableRoles: [UserGroup]! + # availableRoles: [UserRole]! # mutedUsers: [User] # blockedUsers: [User] # isLoggedIn: Boolean! @@ -208,7 +208,7 @@ type Query { # } type Mutation { - CreateGroup ( + CreateGroup( id: ID name: String! slug: String @@ -217,14 +217,14 @@ type Mutation { about: String description: String categoryIds: [ID] - # Wolle: add group settings - # Wolle: - # showShoutsPublicly: Boolean - # sendNotificationEmails: Boolean - # locale: String - ): Group + ): # Wolle: add group settings + # Wolle: + # showShoutsPublicly: Boolean + # sendNotificationEmails: Boolean + # locale: String + Group - UpdateGroup ( + UpdateGroup( id: ID! name: String slug: String @@ -232,11 +232,11 @@ type Mutation { locationName: String about: String description: String - # Wolle: - # showShoutsPublicly: Boolean - # sendNotificationEmails: Boolean - # locale: String - ): Group + ): # Wolle: + # showShoutsPublicly: Boolean + # sendNotificationEmails: Boolean + # locale: String + Group DeleteGroup(id: ID!): Group @@ -246,5 +246,5 @@ type Mutation { # blockUser(id: ID!): User # unblockUser(id: ID!): User - # Wolle: switchUserRole(role: UserGroup!, id: ID!): User + # Wolle: switchUserRole(role: UserRole!, id: ID!): User } From 2c402ba25bd20e71e7997d712bd85423b6bc359f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Wed, 3 Aug 2022 08:22:53 +0200 Subject: [PATCH 09/29] Fix linting --- backend/src/middleware/slugifyMiddleware.spec.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/src/middleware/slugifyMiddleware.spec.js b/backend/src/middleware/slugifyMiddleware.spec.js index fbf03c675..44701970d 100644 --- a/backend/src/middleware/slugifyMiddleware.spec.js +++ b/backend/src/middleware/slugifyMiddleware.spec.js @@ -1,9 +1,12 @@ -import gql from 'graphql-tag' import { getNeode, getDriver } from '../db/neo4j' import createServer from '../server' import { createTestClient } from 'apollo-server-testing' import Factory, { cleanDatabase } from '../db/factories' -import { createPostMutation, createGroupMutation, signupVerificationMutation } from '../db/graphql/mutations' +import { + createPostMutation, + createGroupMutation, + signupVerificationMutation, +} from '../db/graphql/mutations' let mutate let authenticatedUser @@ -357,7 +360,7 @@ describe('slugifyMiddleware', () => { }) }) }) - + describe('if slug exists', () => { beforeEach(async () => { await Factory.build('user', { From 45558f06dd4ef948ff785aa0a065677eac5f7829 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Wed, 3 Aug 2022 11:49:46 +0200 Subject: [PATCH 10/29] Add comment about faking the 'gql' tag in the backend --- backend/src/helpers/jest.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/src/helpers/jest.js b/backend/src/helpers/jest.js index 201d68c14..14317642b 100644 --- a/backend/src/helpers/jest.js +++ b/backend/src/helpers/jest.js @@ -1,3 +1,6 @@ +// TODO: can be replaced with, which is no a fake: +// import gql from 'graphql-tag' + //* This is a fake ES2015 template string, just to benefit of syntax // highlighting of `gql` template strings in certain editors. export function gql(strings) { From 520598c89770044534a1c64f67be593a76b3b861 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Wed, 3 Aug 2022 11:50:56 +0200 Subject: [PATCH 11/29] Implement 'description', 'groupType', and 'actionRadius' in 'CreateGroup' --- backend/src/db/graphql/mutations.ts | 9 ++++ .../src/middleware/slugifyMiddleware.spec.js | 8 +++ backend/src/models/Group.js | 54 +++++++++++-------- backend/src/schema/resolvers/groups.spec.js | 4 +- backend/src/schema/types/type/Group.gql | 23 ++++---- 5 files changed, 64 insertions(+), 34 deletions(-) diff --git a/backend/src/db/graphql/mutations.ts b/backend/src/db/graphql/mutations.ts index 5fc554ee2..c49856f2a 100644 --- a/backend/src/db/graphql/mutations.ts +++ b/backend/src/db/graphql/mutations.ts @@ -6,6 +6,9 @@ export const createGroupMutation = gql` $name: String!, $slug: String, $about: String, + $description: String!, + $groupType: GroupType!, + $actionRadius: GroupActionRadius!, $categoryIds: [ID] ) { CreateGroup( @@ -13,12 +16,18 @@ export const createGroupMutation = gql` name: $name slug: $slug about: $about + description: $description + groupType: $groupType + actionRadius: $actionRadius categoryIds: $categoryIds ) { id name slug about + description + groupType + actionRadius disabled deleted owner { diff --git a/backend/src/middleware/slugifyMiddleware.spec.js b/backend/src/middleware/slugifyMiddleware.spec.js index 44701970d..af6ff25b0 100644 --- a/backend/src/middleware/slugifyMiddleware.spec.js +++ b/backend/src/middleware/slugifyMiddleware.spec.js @@ -69,6 +69,9 @@ describe('slugifyMiddleware', () => { ...variables, name: 'The Best Group', about: 'Some about', + description: 'Some description', + groupType: 'closed', + actionRadius: 'national', categoryIds, } }) @@ -83,7 +86,12 @@ describe('slugifyMiddleware', () => { ).resolves.toMatchObject({ data: { CreateGroup: { + name: 'The Best Group', slug: 'the-best-group', + about: 'Some about', + description: 'Some description', + groupType: 'closed', + actionRadius: 'national', }, }, }) diff --git a/backend/src/models/Group.js b/backend/src/models/Group.js index 651c2983e..53b02fbec 100644 --- a/backend/src/models/Group.js +++ b/backend/src/models/Group.js @@ -4,19 +4,44 @@ export default { id: { type: 'string', primary: true, default: uuid }, // TODO: should be type: 'uuid' but simplified for our tests name: { type: 'string', disallow: [null], min: 3 }, slug: { type: 'string', unique: 'true', regex: /^[a-z0-9_-]+$/, lowercase: true }, + + createdAt: { + type: 'string', + isoDate: true, + required: true, + default: () => new Date().toISOString(), + }, + updatedAt: { + type: 'string', + isoDate: true, + required: true, + default: () => new Date().toISOString(), + }, + deleted: { type: 'boolean', default: false }, + disabled: { type: 'boolean', default: false }, + avatar: { type: 'relationship', relationship: 'AVATAR_IMAGE', target: 'Image', direction: 'out', }, - deleted: { type: 'boolean', default: false }, - disabled: { type: 'boolean', default: false }, - wasSeeded: 'boolean', // Wolle: used or needed? - locationName: { type: 'string', allow: [null] }, - about: { type: 'string', allow: [null, ''] }, // Wolle: null? - description: { type: 'string', allow: [null, ''] }, // Wolle: null? HTML with Tiptap, similar to post content, wie bei Posts "content: { type: 'string', disallow: [null], min: 3 },"? + + about: { type: 'string', allow: [null, ''] }, + description: { type: 'string', disallow: [null], min: 100 }, descriptionExcerpt: { type: 'string', allow: [null] }, + groupType: { type: 'string', default: 'public' }, + actionRadius: { type: 'string', default: 'regional' }, + + locationName: { type: 'string', allow: [null] }, + + wasSeeded: 'boolean', // Wolle: used or needed? + owner: { + type: 'relationship', + relationship: 'OWNS', + target: 'User', + direction: 'in', + }, // Wolle: followedBy: { // type: 'relationship', // relationship: 'FOLLOWS', @@ -26,26 +51,9 @@ export default { // createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, // }, // }, - owner: { - type: 'relationship', - relationship: 'OWNS', - target: 'User', - direction: 'in', - }, // Wolle: correct this way? // members: { type: 'relationship', relationship: 'MEMBERS', target: 'User', direction: 'out' }, // Wolle: needed? lastActiveAt: { type: 'string', isoDate: true }, - createdAt: { - type: 'string', - isoDate: true, - default: () => new Date().toISOString(), - }, - updatedAt: { - type: 'string', - isoDate: true, - required: true, - default: () => new Date().toISOString(), - }, // Wolle: emoted: { // type: 'relationships', // relationship: 'EMOTED', diff --git a/backend/src/schema/resolvers/groups.spec.js b/backend/src/schema/resolvers/groups.spec.js index 8d8c7c3d8..4932c2c5e 100644 --- a/backend/src/schema/resolvers/groups.spec.js +++ b/backend/src/schema/resolvers/groups.spec.js @@ -252,6 +252,9 @@ describe('CreateGroup', () => { name: 'The Best Group', slug: 'the-group', about: 'We will change the world!', + description: 'Some description', + groupType: 'public', + actionRadius: 'regional', categoryIds, } }) @@ -272,7 +275,6 @@ describe('CreateGroup', () => { const expected = { data: { CreateGroup: { - // Wolle: id: 'g589', name: 'The Best Group', slug: 'the-group', about: 'We will change the world!', diff --git a/backend/src/schema/types/type/Group.gql b/backend/src/schema/types/type/Group.gql index 310df9dbc..ee71f3e1f 100644 --- a/backend/src/schema/types/type/Group.gql +++ b/backend/src/schema/types/type/Group.gql @@ -19,27 +19,28 @@ enum _GroupOrdering { type Group { id: ID! - name: String # title + name: String! # title slug: String! - createdAt: String - updatedAt: String + createdAt: String! + updatedAt: String! deleted: Boolean disabled: Boolean avatar: Image @relation(name: "AVATAR_IMAGE", direction: "OUT") + about: String # goal + description: String! + groupType: GroupType! + actionRadius: GroupActionRadius! + location: Location @cypher(statement: "MATCH (this)-[:IS_IN]->(l:Location) RETURN l") locationName: String - about: String # goal - description: String - groupType: GroupType - actionRadius: GroupActionRadius categories: [Category] @relation(name: "CATEGORIZED", direction: "OUT") # Wolle: needed? - socialMedia: [SocialMedia]! @relation(name: "OWNED_BY", direction: "IN") + # socialMedia: [SocialMedia]! @relation(name: "OWNED_BY", direction: "IN") owner: User @relation(name: "OWNS", direction: "IN") @@ -213,10 +214,12 @@ type Mutation { name: String! slug: String avatar: ImageInput - locationName: String about: String - description: String + description: String! + groupType: GroupType! + actionRadius: GroupActionRadius! categoryIds: [ID] + locationName: String ): # Wolle: add group settings # Wolle: # showShoutsPublicly: Boolean From cc44ee8ebcf6157172d92d2267d1e4607e7df73b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Wed, 3 Aug 2022 13:12:50 +0200 Subject: [PATCH 12/29] Refactor relations ':OWNS' and ':ADMINISTERS' to ':MEMBER_OF' with properties --- backend/src/db/graphql/mutations.ts | 7 +- backend/src/models/Group.js | 14 +- backend/src/schema/resolvers/groups.js | 128 ++++++++---------- backend/src/schema/resolvers/groups.spec.js | 7 +- .../src/schema/types/enum/GroupMemberRole.gql | 6 + backend/src/schema/types/type/Group.gql | 15 +- backend/src/schema/types/type/MEMBER_OF.gql | 5 + 7 files changed, 94 insertions(+), 88 deletions(-) create mode 100644 backend/src/schema/types/enum/GroupMemberRole.gql create mode 100644 backend/src/schema/types/type/MEMBER_OF.gql diff --git a/backend/src/db/graphql/mutations.ts b/backend/src/db/graphql/mutations.ts index c49856f2a..4f07e0f1e 100644 --- a/backend/src/db/graphql/mutations.ts +++ b/backend/src/db/graphql/mutations.ts @@ -30,9 +30,10 @@ export const createGroupMutation = gql` actionRadius disabled deleted - owner { - name - } + myRole + # Wolle: owner { + # name + # } } } ` diff --git a/backend/src/models/Group.js b/backend/src/models/Group.js index 53b02fbec..0cec02bf8 100644 --- a/backend/src/models/Group.js +++ b/backend/src/models/Group.js @@ -33,15 +33,17 @@ export default { groupType: { type: 'string', default: 'public' }, actionRadius: { type: 'string', default: 'regional' }, + myRole: { type: 'string', default: 'pending' }, + locationName: { type: 'string', allow: [null] }, wasSeeded: 'boolean', // Wolle: used or needed? - owner: { - type: 'relationship', - relationship: 'OWNS', - target: 'User', - direction: 'in', - }, + // Wolle: owner: { + // type: 'relationship', + // relationship: 'OWNS', + // target: 'User', + // direction: 'in', + // }, // Wolle: followedBy: { // type: 'relationship', // relationship: 'FOLLOWS', diff --git a/backend/src/schema/resolvers/groups.js b/backend/src/schema/resolvers/groups.js index 0ed5d1356..b202c6037 100644 --- a/backend/src/schema/resolvers/groups.js +++ b/backend/src/schema/resolvers/groups.js @@ -18,63 +18,47 @@ import Resolver from './helpers/Resolver' // } export default { - // Wolle: Query: { - // Post: async (object, params, context, resolveInfo) => { - // 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 filterForMutedUsers(params, context) - // return neo4jgraphql(object, params, context, resolveInfo) - // }, - // PostsEmotionsCountByEmotion: async (object, params, context, resolveInfo) => { - // const { postId, data } = params - // const session = context.driver.session() - // const readTxResultPromise = session.readTransaction(async (transaction) => { - // const emotionsCountTransactionResponse = await transaction.run( - // ` - // MATCH (post:Post {id: $postId})<-[emoted:EMOTED {emotion: $data.emotion}]-() - // RETURN COUNT(DISTINCT emoted) as emotionsCount - // `, - // { postId, data }, - // ) - // return emotionsCountTransactionResponse.records.map( - // (record) => record.get('emotionsCount').low, - // ) - // }) - // try { - // const [emotionsCount] = await readTxResultPromise - // return emotionsCount - // } finally { - // session.close() - // } - // }, - // PostsEmotionsByCurrentUser: async (object, params, context, resolveInfo) => { - // const { postId } = params - // const session = context.driver.session() - // const readTxResultPromise = session.readTransaction(async (transaction) => { - // const emotionsTransactionResponse = await transaction.run( - // ` - // MATCH (user:User {id: $userId})-[emoted:EMOTED]->(post:Post {id: $postId}) - // RETURN collect(emoted.emotion) as emotion - // `, - // { userId: context.user.id, postId }, - // ) - // return emotionsTransactionResponse.records.map((record) => record.get('emotion')) - // }) - // try { - // const [emotions] = await readTxResultPromise - // return emotions - // } finally { - // session.close() - // } - // }, - // }, + Query: { + // Wolle: Post: async (object, params, context, resolveInfo) => { + // params = await filterForMutedUsers(params, context) + // // params = await maintainPinnedPosts(params) + // return neo4jgraphql(object, params, context, resolveInfo) + // }, + // Group: async (object, params, context, resolveInfo) => { + // // const { email } = params + // const session = context.driver.session() + // const readTxResultPromise = session.readTransaction(async (txc) => { + // const result = await txc.run( + // ` + // MATCH (user:User {id: $userId})-[:MEMBER_OF]->(group:Group) + // RETURN properties(group) AS inviteCodes + // `, + // { + // userId: context.user.id, + // }, + // ) + // return result.records.map((record) => record.get('inviteCodes')) + // }) + // if (email) { + // try { + // session = context.driver.session() + // const readTxResult = await session.readTransaction((txc) => { + // const result = txc.run( + // ` + // MATCH (user:User)-[:PRIMARY_EMAIL]->(e:EmailAddress {email: $args.email}) + // RETURN user`, + // { args }, + // ) + // return result + // }) + // return readTxResult.records.map((r) => r.get('user').properties) + // } finally { + // session.close() + // } + // } + // return neo4jgraphql(object, args, context, resolveInfo) + // }, + }, Mutation: { CreateGroup: async (_parent, params, context, _resolveInfo) => { const { categoryIds } = params @@ -84,12 +68,14 @@ export default { const writeTxResultPromise = session.writeTransaction(async (transaction) => { const categoriesCypher = CONFIG.CATEGORIES_ACTIVE && categoryIds - ? `WITH group - UNWIND $categoryIds AS categoryId - MATCH (category:Category {id: categoryId}) - MERGE (group)-[:CATEGORIZED]->(category)` + ? ` + WITH group, membership + UNWIND $categoryIds AS categoryId + MATCH (category:Category {id: categoryId}) + MERGE (group)-[:CATEGORIZED]->(category) + ` : '' - const ownercreateGroupTransactionResponse = await transaction.run( + const ownerCreateGroupTransactionResponse = await transaction.run( ` CREATE (group:Group) SET group += $params @@ -97,14 +83,16 @@ export default { SET group.updatedAt = toString(datetime()) WITH group MATCH (owner:User {id: $userId}) - MERGE (group)<-[:OWNS]-(owner) - MERGE (group)<-[:ADMINISTERS]-(owner) + MERGE (owner)-[membership:MEMBER_OF]->(group) + SET membership.createdAt = toString(datetime()) + SET membership.updatedAt = toString(datetime()) + SET membership.role = 'owner' ${categoriesCypher} - RETURN group {.*} + RETURN group {.*, myRole: membership.role} `, { userId: context.user.id, categoryIds, params }, ) - const [group] = ownercreateGroupTransactionResponse.records.map((record) => + const [group] = ownerCreateGroupTransactionResponse.records.map((record) => record.get('group'), ) return group @@ -205,10 +193,10 @@ export default { // Wolle: tags: '-[:TAGGED]->(related:Tag)', categories: '-[:CATEGORIZED]->(related:Category)', }, - hasOne: { - owner: '<-[:OWNS]-(related:User)', - // Wolle: image: '-[:HERO_IMAGE]->(related:Image)', - }, + // hasOne: { + // owner: '<-[:OWNS]-(related:User)', + // // Wolle: image: '-[:HERO_IMAGE]->(related:Image)', + // }, // Wolle: count: { // contributionsCount: // '-[:WROTE]->(related:Post) WHERE NOT related.disabled = true AND NOT related.deleted = true', diff --git a/backend/src/schema/resolvers/groups.spec.js b/backend/src/schema/resolvers/groups.spec.js index 4932c2c5e..17fc4b1da 100644 --- a/backend/src/schema/resolvers/groups.spec.js +++ b/backend/src/schema/resolvers/groups.spec.js @@ -292,9 +292,10 @@ describe('CreateGroup', () => { data: { CreateGroup: { name: 'The Best Group', - owner: { - name: 'TestUser', - }, + myRole: 'owner', + // Wolle: owner: { + // name: 'TestUser', + // }, }, }, errors: undefined, diff --git a/backend/src/schema/types/enum/GroupMemberRole.gql b/backend/src/schema/types/enum/GroupMemberRole.gql new file mode 100644 index 000000000..dacdd4b52 --- /dev/null +++ b/backend/src/schema/types/enum/GroupMemberRole.gql @@ -0,0 +1,6 @@ +enum GroupMemberRole { + pending + usual + admin + owner +} diff --git a/backend/src/schema/types/type/Group.gql b/backend/src/schema/types/type/Group.gql index ee71f3e1f..72ac9b57a 100644 --- a/backend/src/schema/types/type/Group.gql +++ b/backend/src/schema/types/type/Group.gql @@ -39,10 +39,12 @@ type Group { categories: [Category] @relation(name: "CATEGORIZED", direction: "OUT") + myRole: GroupMemberRole # if 'null' then the current user is no member + # Wolle: needed? # socialMedia: [SocialMedia]! @relation(name: "OWNED_BY", direction: "IN") - owner: User @relation(name: "OWNS", direction: "IN") + # Wolle: owner: User @relation(name: "OWNS", direction: "IN") # Wolle: showShoutsPublicly: Boolean # Wolle: sendNotificationEmails: Boolean @@ -129,9 +131,12 @@ input _GroupFilter { AND: [_GroupFilter!] OR: [_GroupFilter!] name_contains: String + slug_contains: String about_contains: String description_contains: String - slug_contains: String + groupType_in: [GroupType!] + actionRadius_in: [GroupActionRadius!] + myRole_in: [GroupMemberRole!] id: ID id_not: ID id_in: [ID!] @@ -161,20 +166,18 @@ input _GroupFilter { # followedBy_none: _GroupFilter # followedBy_single: _GroupFilter # followedBy_every: _GroupFilter - # role_in: [UserRole!] } type Query { Group( id: ID - email: String # admins need to search for a user sometimes name: String slug: String + createdAt: String + updatedAt: String locationName: String about: String description: String - createdAt: String - updatedAt: String first: Int offset: Int orderBy: [_GroupOrdering] diff --git a/backend/src/schema/types/type/MEMBER_OF.gql b/backend/src/schema/types/type/MEMBER_OF.gql new file mode 100644 index 000000000..edda989f6 --- /dev/null +++ b/backend/src/schema/types/type/MEMBER_OF.gql @@ -0,0 +1,5 @@ +type MEMBER_OF { + createdAt: String! + updatedAt: String! + role: GroupMemberRole! +} From 94411648fd23feb74564f978c8e6988829de196f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Wed, 3 Aug 2022 13:29:49 +0200 Subject: [PATCH 13/29] Implement 'Group' query, first step --- .../src/middleware/permissionsMiddleware.js | 1 + backend/src/schema/resolvers/groups.js | 64 ++++++++----------- 2 files changed, 28 insertions(+), 37 deletions(-) diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index 7e23cfe0f..99dcfc0cd 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -114,6 +114,7 @@ export default shield( reports: isModerator, statistics: allow, currentUser: allow, + Group: isAuthenticated, Post: allow, profilePagePosts: allow, Comment: allow, diff --git a/backend/src/schema/resolvers/groups.js b/backend/src/schema/resolvers/groups.js index b202c6037..9a55c9f4d 100644 --- a/backend/src/schema/resolvers/groups.js +++ b/backend/src/schema/resolvers/groups.js @@ -24,40 +24,30 @@ export default { // // params = await maintainPinnedPosts(params) // return neo4jgraphql(object, params, context, resolveInfo) // }, - // Group: async (object, params, context, resolveInfo) => { - // // const { email } = params - // const session = context.driver.session() - // const readTxResultPromise = session.readTransaction(async (txc) => { - // const result = await txc.run( - // ` - // MATCH (user:User {id: $userId})-[:MEMBER_OF]->(group:Group) - // RETURN properties(group) AS inviteCodes - // `, - // { - // userId: context.user.id, - // }, - // ) - // return result.records.map((record) => record.get('inviteCodes')) - // }) - // if (email) { - // try { - // session = context.driver.session() - // const readTxResult = await session.readTransaction((txc) => { - // const result = txc.run( - // ` - // MATCH (user:User)-[:PRIMARY_EMAIL]->(e:EmailAddress {email: $args.email}) - // RETURN user`, - // { args }, - // ) - // return result - // }) - // return readTxResult.records.map((r) => r.get('user').properties) - // } finally { - // session.close() - // } - // } - // return neo4jgraphql(object, args, context, resolveInfo) - // }, + Group: async (_object, _params, context, _resolveInfo) => { + const session = context.driver.session() + const readTxResultPromise = session.readTransaction(async (txc) => { + const result = await txc.run( + ` + MATCH (user:User {id: $userId})-[membership:MEMBER_OF]->(group:Group) + RETURN group {.*, myRole: membership.role} + `, + { + userId: context.user.id, + }, + ) + const group = result.records.map((record) => record.get('group')) + return group + }) + try { + const group = await readTxResultPromise + return group + } catch (error) { + throw new Error(error) + } finally { + session.close() + } + }, }, Mutation: { CreateGroup: async (_parent, params, context, _resolveInfo) => { @@ -100,10 +90,10 @@ export default { try { const group = await writeTxResultPromise return group - } catch (e) { - if (e.code === 'Neo.ClientError.Schema.ConstraintValidationFailed') + } catch (error) { + if (error.code === 'Neo.ClientError.Schema.ConstraintValidationFailed') throw new UserInputError('Group with this slug already exists!') - throw new Error(e) + throw new Error(error) } finally { session.close() } From 867b78dfa3983f92978ff7ec5284830a6fc34d5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Wed, 3 Aug 2022 15:16:23 +0200 Subject: [PATCH 14/29] Implement 'Group' query, second step --- backend/src/db/graphql/authentications.ts | 29 ++ backend/src/db/graphql/groups.ts | 95 +++++ backend/src/db/graphql/mutations.ts | 69 ---- backend/src/db/graphql/posts.ts | 15 + .../src/middleware/slugifyMiddleware.spec.js | 8 +- backend/src/schema/resolvers/groups.spec.js | 325 +++++++++--------- backend/src/schema/types/type/Group.gql | 2 +- 7 files changed, 302 insertions(+), 241 deletions(-) create mode 100644 backend/src/db/graphql/authentications.ts create mode 100644 backend/src/db/graphql/groups.ts delete mode 100644 backend/src/db/graphql/mutations.ts create mode 100644 backend/src/db/graphql/posts.ts diff --git a/backend/src/db/graphql/authentications.ts b/backend/src/db/graphql/authentications.ts new file mode 100644 index 000000000..f05970650 --- /dev/null +++ b/backend/src/db/graphql/authentications.ts @@ -0,0 +1,29 @@ +import gql from 'graphql-tag' + +// ------ mutations + +export const signupVerificationMutation = gql` + mutation ( + $password: String! + $email: String! + $name: String! + $slug: String + $nonce: String! + $termsAndConditionsAgreedVersion: String! + ) { + SignupVerification( + email: $email + password: $password + name: $name + slug: $slug + nonce: $nonce + termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion + ) { + slug + } + } +` + +// ------ queries + +// fill queries in here diff --git a/backend/src/db/graphql/groups.ts b/backend/src/db/graphql/groups.ts new file mode 100644 index 000000000..80b599658 --- /dev/null +++ b/backend/src/db/graphql/groups.ts @@ -0,0 +1,95 @@ +import gql from 'graphql-tag' + +// ------ mutations + +export const createGroupMutation = gql` + mutation ( + $id: ID, + $name: String!, + $slug: String, + $about: String, + $description: String!, + $groupType: GroupType!, + $actionRadius: GroupActionRadius!, + $categoryIds: [ID] + ) { + CreateGroup( + id: $id + name: $name + slug: $slug + about: $about + description: $description + groupType: $groupType + actionRadius: $actionRadius + categoryIds: $categoryIds + ) { + id + name + slug + createdAt + updatedAt + disabled + deleted + about + description + groupType + actionRadius + myRole + # Wolle: owner { + # name + # } + } + } +` + +// ------ queries + +export const groupQuery = gql` + query ( + $id: ID, + $name: String, + $slug: String, + $createdAt: String + $updatedAt: String + $about: String, + $description: String, + # $groupType: GroupType!, + # $actionRadius: GroupActionRadius!, + $categoryIds: [ID] + $locationName: String + $first: Int + $offset: Int + $orderBy: [_GroupOrdering] + $filter: _GroupFilter + ) { + Group( + id: $id + name: $name + slug: $slug + createdAt: $createdAt + updatedAt: $updatedAt + about: $about + description: $description + # groupType: $groupType + # actionRadius: $actionRadius + categoryIds: $categoryIds + locationName: $locationName + ) { + id + name + slug + createdAt + updatedAt + disabled + deleted + about + description + groupType + actionRadius + myRole + # Wolle: owner { + # name + # } + } + } +` diff --git a/backend/src/db/graphql/mutations.ts b/backend/src/db/graphql/mutations.ts deleted file mode 100644 index 4f07e0f1e..000000000 --- a/backend/src/db/graphql/mutations.ts +++ /dev/null @@ -1,69 +0,0 @@ -import gql from 'graphql-tag' - -export const createGroupMutation = gql` - mutation ( - $id: ID, - $name: String!, - $slug: String, - $about: String, - $description: String!, - $groupType: GroupType!, - $actionRadius: GroupActionRadius!, - $categoryIds: [ID] - ) { - CreateGroup( - id: $id - name: $name - slug: $slug - about: $about - description: $description - groupType: $groupType - actionRadius: $actionRadius - categoryIds: $categoryIds - ) { - id - name - slug - about - description - groupType - actionRadius - disabled - deleted - myRole - # Wolle: owner { - # name - # } - } - } -` - -export const createPostMutation = gql` - mutation ($title: String!, $content: String!, $categoryIds: [ID]!, $slug: String) { - CreatePost(title: $title, content: $content, categoryIds: $categoryIds, slug: $slug) { - slug - } - } -` - -export const signupVerificationMutation = gql` - mutation ( - $password: String! - $email: String! - $name: String! - $slug: String - $nonce: String! - $termsAndConditionsAgreedVersion: String! - ) { - SignupVerification( - email: $email - password: $password - name: $name - slug: $slug - nonce: $nonce - termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion - ) { - slug - } - } -` diff --git a/backend/src/db/graphql/posts.ts b/backend/src/db/graphql/posts.ts new file mode 100644 index 000000000..3277af820 --- /dev/null +++ b/backend/src/db/graphql/posts.ts @@ -0,0 +1,15 @@ +import gql from 'graphql-tag' + +// ------ mutations + +export const createPostMutation = gql` + mutation ($title: String!, $content: String!, $categoryIds: [ID]!, $slug: String) { + CreatePost(title: $title, content: $content, categoryIds: $categoryIds, slug: $slug) { + slug + } + } +` + +// ------ queries + +// fill queries in here diff --git a/backend/src/middleware/slugifyMiddleware.spec.js b/backend/src/middleware/slugifyMiddleware.spec.js index af6ff25b0..3c18e70b0 100644 --- a/backend/src/middleware/slugifyMiddleware.spec.js +++ b/backend/src/middleware/slugifyMiddleware.spec.js @@ -2,11 +2,9 @@ import { getNeode, getDriver } from '../db/neo4j' import createServer from '../server' import { createTestClient } from 'apollo-server-testing' import Factory, { cleanDatabase } from '../db/factories' -import { - createPostMutation, - createGroupMutation, - signupVerificationMutation, -} from '../db/graphql/mutations' +import { createGroupMutation } from '../db/graphql/groups' +import { createPostMutation } from '../db/graphql/posts' +import { signupVerificationMutation } from '../db/graphql/authentications' let mutate let authenticatedUser diff --git a/backend/src/schema/resolvers/groups.spec.js b/backend/src/schema/resolvers/groups.spec.js index 17fc4b1da..8860f87f2 100644 --- a/backend/src/schema/resolvers/groups.spec.js +++ b/backend/src/schema/resolvers/groups.spec.js @@ -1,6 +1,6 @@ import { createTestClient } from 'apollo-server-testing' import Factory, { cleanDatabase } from '../../db/factories' -import { createGroupMutation } from '../../db/graphql/mutations' +import { createGroupMutation } from '../../db/graphql/groups' import { getNeode, getDriver } from '../../db/neo4j' import createServer from '../../server' @@ -78,171 +78,164 @@ afterEach(async () => { await cleanDatabase() }) -// describe('Group', () => { -// describe('can be filtered', () => { -// let followedUser, happyPost, cryPost -// beforeEach(async () => { -// ;[followedUser] = await Promise.all([ -// Factory.build( -// 'user', -// { -// id: 'followed-by-me', -// name: 'Followed User', -// }, -// { -// email: 'followed@example.org', -// password: '1234', -// }, -// ), -// ]) -// ;[happyPost, cryPost] = await Promise.all([ -// Factory.build('post', { id: 'happy-post' }, { categoryIds: ['cat4'] }), -// Factory.build('post', { id: 'cry-post' }, { categoryIds: ['cat15'] }), -// Factory.build( -// 'post', -// { -// id: 'post-by-followed-user', -// }, -// { -// categoryIds: ['cat9'], -// author: followedUser, -// }, -// ), -// ]) -// }) - -// describe('no filter', () => { -// it('returns all posts', async () => { -// const postQueryNoFilters = gql` -// query Post($filter: _PostFilter) { -// Post(filter: $filter) { -// id -// } -// } -// ` -// const expected = [{ id: 'happy-post' }, { id: 'cry-post' }, { id: 'post-by-followed-user' }] -// variables = { filter: {} } -// await expect(query({ query: postQueryNoFilters, variables })).resolves.toMatchObject({ -// data: { -// Post: expect.arrayContaining(expected), -// }, -// }) -// }) -// }) - -// /* it('by categories', async () => { -// const postQueryFilteredByCategories = gql` -// query Post($filter: _PostFilter) { -// Post(filter: $filter) { -// id -// categories { -// id -// } -// } -// } -// ` -// const expected = { -// data: { -// Post: [ -// { -// id: 'post-by-followed-user', -// categories: [{ id: 'cat9' }], -// }, -// ], -// }, -// } -// variables = { ...variables, filter: { categories_some: { id_in: ['cat9'] } } } -// await expect( -// query({ query: postQueryFilteredByCategories, variables }), -// ).resolves.toMatchObject(expected) -// }) */ - -// describe('by emotions', () => { -// const postQueryFilteredByEmotions = gql` -// query Post($filter: _PostFilter) { -// Post(filter: $filter) { -// id -// emotions { -// emotion -// } -// } -// } -// ` - -// it('filters by single emotion', async () => { -// const expected = { -// data: { -// Post: [ -// { -// id: 'happy-post', -// emotions: [{ emotion: 'happy' }], -// }, -// ], -// }, -// } -// await user.relateTo(happyPost, 'emoted', { emotion: 'happy' }) -// variables = { ...variables, filter: { emotions_some: { emotion_in: ['happy'] } } } -// await expect( -// query({ query: postQueryFilteredByEmotions, variables }), -// ).resolves.toMatchObject(expected) -// }) - -// it('filters by multiple emotions', async () => { -// const expected = [ -// { -// id: 'happy-post', -// emotions: [{ emotion: 'happy' }], -// }, -// { -// id: 'cry-post', -// emotions: [{ emotion: 'cry' }], -// }, -// ] -// await user.relateTo(happyPost, 'emoted', { emotion: 'happy' }) -// await user.relateTo(cryPost, 'emoted', { emotion: 'cry' }) -// variables = { ...variables, filter: { emotions_some: { emotion_in: ['happy', 'cry'] } } } -// await expect( -// query({ query: postQueryFilteredByEmotions, variables }), -// ).resolves.toMatchObject({ -// data: { -// Post: expect.arrayContaining(expected), -// }, -// errors: undefined, -// }) -// }) -// }) - -// it('by followed-by', async () => { -// const postQueryFilteredByUsersFollowed = gql` -// query Post($filter: _PostFilter) { -// Post(filter: $filter) { -// id -// author { -// id -// name -// } -// } -// } -// ` - -// await user.relateTo(followedUser, 'following') -// variables = { filter: { author: { followedBy_some: { id: 'current-user' } } } } -// await expect( -// query({ query: postQueryFilteredByUsersFollowed, variables }), -// ).resolves.toMatchObject({ -// data: { -// Post: [ -// { -// id: 'post-by-followed-user', -// author: { id: 'followed-by-me', name: 'Followed User' }, -// }, -// ], -// }, -// errors: undefined, -// }) -// }) -// }) -// }) +describe('Group', () => { + // describe('can be filtered', () => { + // let followedUser, happyPost, cryPost + // beforeEach(async () => { + // ;[followedUser] = await Promise.all([ + // Factory.build( + // 'user', + // { + // id: 'followed-by-me', + // name: 'Followed User', + // }, + // { + // email: 'followed@example.org', + // password: '1234', + // }, + // ), + // ]) + // ;[happyPost, cryPost] = await Promise.all([ + // Factory.build('post', { id: 'happy-post' }, { categoryIds: ['cat4'] }), + // Factory.build('post', { id: 'cry-post' }, { categoryIds: ['cat15'] }), + // Factory.build( + // 'post', + // { + // id: 'post-by-followed-user', + // }, + // { + // categoryIds: ['cat9'], + // author: followedUser, + // }, + // ), + // ]) + // }) + // describe('no filter', () => { + // it('returns all posts', async () => { + // const postQueryNoFilters = gql` + // query Post($filter: _PostFilter) { + // Post(filter: $filter) { + // id + // } + // } + // ` + // const expected = [{ id: 'happy-post' }, { id: 'cry-post' }, { id: 'post-by-followed-user' }] + // variables = { filter: {} } + // await expect(query({ query: postQueryNoFilters, variables })).resolves.toMatchObject({ + // data: { + // Post: expect.arrayContaining(expected), + // }, + // }) + // }) + // }) + // /* it('by categories', async () => { + // const postQueryFilteredByCategories = gql` + // query Post($filter: _PostFilter) { + // Post(filter: $filter) { + // id + // categories { + // id + // } + // } + // } + // ` + // const expected = { + // data: { + // Post: [ + // { + // id: 'post-by-followed-user', + // categories: [{ id: 'cat9' }], + // }, + // ], + // }, + // } + // variables = { ...variables, filter: { categories_some: { id_in: ['cat9'] } } } + // await expect( + // query({ query: postQueryFilteredByCategories, variables }), + // ).resolves.toMatchObject(expected) + // }) */ + // describe('by emotions', () => { + // const postQueryFilteredByEmotions = gql` + // query Post($filter: _PostFilter) { + // Post(filter: $filter) { + // id + // emotions { + // emotion + // } + // } + // } + // ` + // it('filters by single emotion', async () => { + // const expected = { + // data: { + // Post: [ + // { + // id: 'happy-post', + // emotions: [{ emotion: 'happy' }], + // }, + // ], + // }, + // } + // await user.relateTo(happyPost, 'emoted', { emotion: 'happy' }) + // variables = { ...variables, filter: { emotions_some: { emotion_in: ['happy'] } } } + // await expect( + // query({ query: postQueryFilteredByEmotions, variables }), + // ).resolves.toMatchObject(expected) + // }) + // it('filters by multiple emotions', async () => { + // const expected = [ + // { + // id: 'happy-post', + // emotions: [{ emotion: 'happy' }], + // }, + // { + // id: 'cry-post', + // emotions: [{ emotion: 'cry' }], + // }, + // ] + // await user.relateTo(happyPost, 'emoted', { emotion: 'happy' }) + // await user.relateTo(cryPost, 'emoted', { emotion: 'cry' }) + // variables = { ...variables, filter: { emotions_some: { emotion_in: ['happy', 'cry'] } } } + // await expect( + // query({ query: postQueryFilteredByEmotions, variables }), + // ).resolves.toMatchObject({ + // data: { + // Post: expect.arrayContaining(expected), + // }, + // errors: undefined, + // }) + // }) + // }) + // it('by followed-by', async () => { + // const postQueryFilteredByUsersFollowed = gql` + // query Post($filter: _PostFilter) { + // Post(filter: $filter) { + // id + // author { + // id + // name + // } + // } + // } + // ` + // await user.relateTo(followedUser, 'following') + // variables = { filter: { author: { followedBy_some: { id: 'current-user' } } } } + // await expect( + // query({ query: postQueryFilteredByUsersFollowed, variables }), + // ).resolves.toMatchObject({ + // data: { + // Post: [ + // { + // id: 'post-by-followed-user', + // author: { id: 'followed-by-me', name: 'Followed User' }, + // }, + // ], + // }, + // errors: undefined, + // }) + // }) + // }) +}) describe('CreateGroup', () => { beforeEach(() => { diff --git a/backend/src/schema/types/type/Group.gql b/backend/src/schema/types/type/Group.gql index 72ac9b57a..cd15689ec 100644 --- a/backend/src/schema/types/type/Group.gql +++ b/backend/src/schema/types/type/Group.gql @@ -175,9 +175,9 @@ type Query { slug: String createdAt: String updatedAt: String - locationName: String about: String description: String + locationName: String first: Int offset: Int orderBy: [_GroupOrdering] From fcca0f378d92726f5dc63e0fd0e6672edd210d72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Thu, 4 Aug 2022 09:24:04 +0200 Subject: [PATCH 15/29] Implement 'Group' query, next step --- ...{authentications.ts => authentications.js} | 0 .../src/db/graphql/{groups.ts => groups.js} | 10 +- backend/src/db/graphql/{posts.ts => posts.js} | 0 backend/src/schema/resolvers/groups.js | 34 +- backend/src/schema/resolvers/groups.spec.js | 432 +++++++++++------- backend/src/schema/types/type/Group.gql | 1 + 6 files changed, 307 insertions(+), 170 deletions(-) rename backend/src/db/graphql/{authentications.ts => authentications.js} (100%) rename backend/src/db/graphql/{groups.ts => groups.js} (89%) rename backend/src/db/graphql/{posts.ts => posts.js} (100%) diff --git a/backend/src/db/graphql/authentications.ts b/backend/src/db/graphql/authentications.js similarity index 100% rename from backend/src/db/graphql/authentications.ts rename to backend/src/db/graphql/authentications.js diff --git a/backend/src/db/graphql/groups.ts b/backend/src/db/graphql/groups.js similarity index 89% rename from backend/src/db/graphql/groups.ts rename to backend/src/db/graphql/groups.js index 80b599658..e8da8e90b 100644 --- a/backend/src/db/graphql/groups.ts +++ b/backend/src/db/graphql/groups.js @@ -46,6 +46,7 @@ export const createGroupMutation = gql` export const groupQuery = gql` query ( + $isMember: Boolean $id: ID, $name: String, $slug: String, @@ -55,7 +56,7 @@ export const groupQuery = gql` $description: String, # $groupType: GroupType!, # $actionRadius: GroupActionRadius!, - $categoryIds: [ID] + # $categoryIds: [ID] $locationName: String $first: Int $offset: Int @@ -63,6 +64,7 @@ export const groupQuery = gql` $filter: _GroupFilter ) { Group( + isMember: $isMember id: $id name: $name slug: $slug @@ -72,8 +74,12 @@ export const groupQuery = gql` description: $description # groupType: $groupType # actionRadius: $actionRadius - categoryIds: $categoryIds + # categoryIds: $categoryIds locationName: $locationName + first: $first + offset: $offset + orderBy: $orderBy + filter: $filter ) { id name diff --git a/backend/src/db/graphql/posts.ts b/backend/src/db/graphql/posts.js similarity index 100% rename from backend/src/db/graphql/posts.ts rename to backend/src/db/graphql/posts.js diff --git a/backend/src/schema/resolvers/groups.js b/backend/src/schema/resolvers/groups.js index 9a55c9f4d..be07fecc6 100644 --- a/backend/src/schema/resolvers/groups.js +++ b/backend/src/schema/resolvers/groups.js @@ -24,18 +24,34 @@ export default { // // params = await maintainPinnedPosts(params) // return neo4jgraphql(object, params, context, resolveInfo) // }, - Group: async (_object, _params, context, _resolveInfo) => { + Group: async (_object, params, context, _resolveInfo) => { + const { isMember } = params const session = context.driver.session() const readTxResultPromise = session.readTransaction(async (txc) => { - const result = await txc.run( - ` - MATCH (user:User {id: $userId})-[membership:MEMBER_OF]->(group:Group) + let groupCypher + if (isMember === true) { + groupCypher = ` + MATCH (:User {id: $userId})-[membership:MEMBER_OF]->(group:Group) RETURN group {.*, myRole: membership.role} - `, - { - userId: context.user.id, - }, - ) + ` + } else { + if (isMember === false) { + groupCypher = ` + MATCH (group:Group) + WHERE NOT (:User {id: $userId})-[:MEMBER_OF]->(group) + RETURN group {.*, myRole: NULL} + ` + } else { + groupCypher = ` + MATCH (group:Group) + OPTIONAL MATCH (:User {id: $userId})-[membership:MEMBER_OF]->(group) + RETURN group {.*, myRole: membership.role} + ` + } + } + const result = await txc.run(groupCypher, { + userId: context.user.id, + }) const group = result.records.map((record) => record.get('group')) return group }) diff --git a/backend/src/schema/resolvers/groups.spec.js b/backend/src/schema/resolvers/groups.spec.js index 8860f87f2..58e5f37be 100644 --- a/backend/src/schema/resolvers/groups.spec.js +++ b/backend/src/schema/resolvers/groups.spec.js @@ -1,13 +1,13 @@ import { createTestClient } from 'apollo-server-testing' import Factory, { cleanDatabase } from '../../db/factories' -import { createGroupMutation } from '../../db/graphql/groups' +import { createGroupMutation, groupQuery } from '../../db/graphql/groups' import { getNeode, getDriver } from '../../db/neo4j' import createServer from '../../server' const driver = getDriver() const neode = getNeode() -// Wolle: let query +let query let mutate let authenticatedUser let user @@ -27,7 +27,7 @@ beforeAll(async () => { } }, }) - // Wolle: query = createTestClient(server).query + query = createTestClient(server).query mutate = createTestClient(server).mutate }) @@ -79,162 +79,276 @@ afterEach(async () => { }) describe('Group', () => { - // describe('can be filtered', () => { - // let followedUser, happyPost, cryPost - // beforeEach(async () => { - // ;[followedUser] = await Promise.all([ - // Factory.build( - // 'user', - // { - // id: 'followed-by-me', - // name: 'Followed User', - // }, - // { - // email: 'followed@example.org', - // password: '1234', - // }, - // ), - // ]) - // ;[happyPost, cryPost] = await Promise.all([ - // Factory.build('post', { id: 'happy-post' }, { categoryIds: ['cat4'] }), - // Factory.build('post', { id: 'cry-post' }, { categoryIds: ['cat15'] }), - // Factory.build( - // 'post', - // { - // id: 'post-by-followed-user', - // }, - // { - // categoryIds: ['cat9'], - // author: followedUser, - // }, - // ), - // ]) - // }) - // describe('no filter', () => { - // it('returns all posts', async () => { - // const postQueryNoFilters = gql` - // query Post($filter: _PostFilter) { - // Post(filter: $filter) { - // id - // } - // } - // ` - // const expected = [{ id: 'happy-post' }, { id: 'cry-post' }, { id: 'post-by-followed-user' }] - // variables = { filter: {} } - // await expect(query({ query: postQueryNoFilters, variables })).resolves.toMatchObject({ - // data: { - // Post: expect.arrayContaining(expected), - // }, - // }) - // }) - // }) - // /* it('by categories', async () => { - // const postQueryFilteredByCategories = gql` - // query Post($filter: _PostFilter) { - // Post(filter: $filter) { - // id - // categories { - // id - // } - // } - // } - // ` - // const expected = { - // data: { - // Post: [ - // { - // id: 'post-by-followed-user', - // categories: [{ id: 'cat9' }], - // }, - // ], - // }, - // } - // variables = { ...variables, filter: { categories_some: { id_in: ['cat9'] } } } - // await expect( - // query({ query: postQueryFilteredByCategories, variables }), - // ).resolves.toMatchObject(expected) - // }) */ - // describe('by emotions', () => { - // const postQueryFilteredByEmotions = gql` - // query Post($filter: _PostFilter) { - // Post(filter: $filter) { - // id - // emotions { - // emotion - // } - // } - // } - // ` - // it('filters by single emotion', async () => { - // const expected = { - // data: { - // Post: [ - // { - // id: 'happy-post', - // emotions: [{ emotion: 'happy' }], - // }, - // ], - // }, - // } - // await user.relateTo(happyPost, 'emoted', { emotion: 'happy' }) - // variables = { ...variables, filter: { emotions_some: { emotion_in: ['happy'] } } } - // await expect( - // query({ query: postQueryFilteredByEmotions, variables }), - // ).resolves.toMatchObject(expected) - // }) - // it('filters by multiple emotions', async () => { - // const expected = [ - // { - // id: 'happy-post', - // emotions: [{ emotion: 'happy' }], - // }, - // { - // id: 'cry-post', - // emotions: [{ emotion: 'cry' }], - // }, - // ] - // await user.relateTo(happyPost, 'emoted', { emotion: 'happy' }) - // await user.relateTo(cryPost, 'emoted', { emotion: 'cry' }) - // variables = { ...variables, filter: { emotions_some: { emotion_in: ['happy', 'cry'] } } } - // await expect( - // query({ query: postQueryFilteredByEmotions, variables }), - // ).resolves.toMatchObject({ - // data: { - // Post: expect.arrayContaining(expected), - // }, - // errors: undefined, - // }) - // }) - // }) - // it('by followed-by', async () => { - // const postQueryFilteredByUsersFollowed = gql` - // query Post($filter: _PostFilter) { - // Post(filter: $filter) { - // id - // author { - // id - // name - // } - // } - // } - // ` - // await user.relateTo(followedUser, 'following') - // variables = { filter: { author: { followedBy_some: { id: 'current-user' } } } } - // await expect( - // query({ query: postQueryFilteredByUsersFollowed, variables }), - // ).resolves.toMatchObject({ - // data: { - // Post: [ - // { - // id: 'post-by-followed-user', - // author: { id: 'followed-by-me', name: 'Followed User' }, - // }, - // ], - // }, - // errors: undefined, - // }) - // }) - // }) + describe('unauthenticated', () => { + it('throws authorization error', async () => { + const { errors } = await query({ query: groupQuery, variables: {} }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') + }) + }) + + describe('authenticated', () => { + beforeEach(async () => { + authenticatedUser = await user.toJson() + }) + + let otherUser + + beforeEach(async () => { + otherUser = await Factory.build( + 'user', + { + id: 'other-user', + name: 'Other TestUser', + }, + { + email: 'test2@example.org', + password: '1234', + }, + ) + authenticatedUser = await otherUser.toJson() + await mutate({ + mutation: createGroupMutation, + variables: { + id: 'others-group', + name: 'Uninteresting Group', + about: 'We will change nothing!', + description: 'We love it like it is!?', + groupType: 'closed', + actionRadius: 'international', + categoryIds, + }, + }) + authenticatedUser = await user.toJson() + await mutate({ + mutation: createGroupMutation, + variables: { + id: 'my-group', + name: 'The Best Group', + about: 'We will change the world!', + description: 'Some description', + groupType: 'public', + actionRadius: 'regional', + categoryIds, + }, + }) + }) + + describe('query can fetch', () => { + it('groups where user is member (or owner in this case)', async () => { + const expected = { + data: { + Group: [ + { + id: 'my-group', + slug: 'the-best-group', + myRole: 'owner', + }, + ], + }, + errors: undefined, + } + await expect( + query({ query: groupQuery, variables: { isMember: true } }), + ).resolves.toMatchObject(expected) + }) + + it('groups where user is not(!) member', async () => { + const expected = { + data: { + Group: expect.arrayContaining([ + expect.objectContaining({ + id: 'others-group', + slug: 'uninteresting-group', + myRole: null, + }), + ]), + }, + errors: undefined, + } + await expect( + query({ query: groupQuery, variables: { isMember: false } }), + ).resolves.toMatchObject(expected) + }) + + it('all groups', async () => { + const expected = { + data: { + Group: expect.arrayContaining([ + expect.objectContaining({ + id: 'my-group', + slug: 'the-best-group', + myRole: 'owner', + }), + expect.objectContaining({ + id: 'others-group', + slug: 'uninteresting-group', + myRole: null, + }), + ]), + }, + errors: undefined, + } + await expect(query({ query: groupQuery, variables: {} })).resolves.toMatchObject(expected) + }) + }) + + // Wolle: describe('can be filtered', () => { + // let followedUser, happyPost, cryPost + // beforeEach(async () => { + // ;[followedUser] = await Promise.all([ + // Factory.build( + // 'user', + // { + // id: 'followed-by-me', + // name: 'Followed User', + // }, + // { + // email: 'followed@example.org', + // password: '1234', + // }, + // ), + // ]) + // ;[happyPost, cryPost] = await Promise.all([ + // Factory.build('post', { id: 'happy-post' }, { categoryIds: ['cat4'] }), + // Factory.build('post', { id: 'cry-post' }, { categoryIds: ['cat15'] }), + // Factory.build( + // 'post', + // { + // id: 'post-by-followed-user', + // }, + // { + // categoryIds: ['cat9'], + // author: followedUser, + // }, + // ), + // ]) + // }) + // describe('no filter', () => { + // it('returns all posts', async () => { + // const postQueryNoFilters = gql` + // query Post($filter: _PostFilter) { + // Post(filter: $filter) { + // id + // } + // } + // ` + // const expected = [{ id: 'happy-post' }, { id: 'cry-post' }, { id: 'post-by-followed-user' }] + // variables = { filter: {} } + // await expect(query({ query: postQueryNoFilters, variables })).resolves.toMatchObject({ + // data: { + // Post: expect.arrayContaining(expected), + // }, + // }) + // }) + // }) + // /* it('by categories', async () => { + // const postQueryFilteredByCategories = gql` + // query Post($filter: _PostFilter) { + // Post(filter: $filter) { + // id + // categories { + // id + // } + // } + // } + // ` + // const expected = { + // data: { + // Post: [ + // { + // id: 'post-by-followed-user', + // categories: [{ id: 'cat9' }], + // }, + // ], + // }, + // } + // variables = { ...variables, filter: { categories_some: { id_in: ['cat9'] } } } + // await expect( + // query({ query: postQueryFilteredByCategories, variables }), + // ).resolves.toMatchObject(expected) + // }) */ + // describe('by emotions', () => { + // const postQueryFilteredByEmotions = gql` + // query Post($filter: _PostFilter) { + // Post(filter: $filter) { + // id + // emotions { + // emotion + // } + // } + // } + // ` + // it('filters by single emotion', async () => { + // const expected = { + // data: { + // Post: [ + // { + // id: 'happy-post', + // emotions: [{ emotion: 'happy' }], + // }, + // ], + // }, + // } + // await user.relateTo(happyPost, 'emoted', { emotion: 'happy' }) + // variables = { ...variables, filter: { emotions_some: { emotion_in: ['happy'] } } } + // await expect( + // query({ query: postQueryFilteredByEmotions, variables }), + // ).resolves.toMatchObject(expected) + // }) + // it('filters by multiple emotions', async () => { + // const expected = [ + // { + // id: 'happy-post', + // emotions: [{ emotion: 'happy' }], + // }, + // { + // id: 'cry-post', + // emotions: [{ emotion: 'cry' }], + // }, + // ] + // await user.relateTo(happyPost, 'emoted', { emotion: 'happy' }) + // await user.relateTo(cryPost, 'emoted', { emotion: 'cry' }) + // variables = { ...variables, filter: { emotions_some: { emotion_in: ['happy', 'cry'] } } } + // await expect( + // query({ query: postQueryFilteredByEmotions, variables }), + // ).resolves.toMatchObject({ + // data: { + // Post: expect.arrayContaining(expected), + // }, + // errors: undefined, + // }) + // }) + // }) + // it('by followed-by', async () => { + // const postQueryFilteredByUsersFollowed = gql` + // query Post($filter: _PostFilter) { + // Post(filter: $filter) { + // id + // author { + // id + // name + // } + // } + // } + // ` + // await user.relateTo(followedUser, 'following') + // variables = { filter: { author: { followedBy_some: { id: 'current-user' } } } } + // await expect( + // query({ query: postQueryFilteredByUsersFollowed, variables }), + // ).resolves.toMatchObject({ + // data: { + // Post: [ + // { + // id: 'post-by-followed-user', + // author: { id: 'followed-by-me', name: 'Followed User' }, + // }, + // ], + // }, + // errors: undefined, + // }) + // }) + // }) + }) }) describe('CreateGroup', () => { diff --git a/backend/src/schema/types/type/Group.gql b/backend/src/schema/types/type/Group.gql index cd15689ec..b8e00f0ee 100644 --- a/backend/src/schema/types/type/Group.gql +++ b/backend/src/schema/types/type/Group.gql @@ -170,6 +170,7 @@ input _GroupFilter { type Query { Group( + isMember: Boolean # if 'undefined' or 'null' then all groups id: ID name: String slug: String From b12056594fbbe13056d80dc8a22cc5925ab17755 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Tue, 9 Aug 2022 07:15:30 +0200 Subject: [PATCH 16/29] Add issue to TODO comment --- backend/src/helpers/jest.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/src/helpers/jest.js b/backend/src/helpers/jest.js index 14317642b..ecfc1a042 100644 --- a/backend/src/helpers/jest.js +++ b/backend/src/helpers/jest.js @@ -1,5 +1,6 @@ -// TODO: can be replaced with, which is no a fake: +// TODO: can be replaced with: (which is no a fake) // import gql from 'graphql-tag' +// See issue: https://github.com/Ocelot-Social-Community/Ocelot-Social/issues/5152 //* This is a fake ES2015 template string, just to benefit of syntax // highlighting of `gql` template strings in certain editors. From 7847d6912c2142e5bdc5f061df0e981c4accb8a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Tue, 9 Aug 2022 08:19:51 +0200 Subject: [PATCH 17/29] Return 'categories' in the 'Group' GQL query --- backend/src/db/graphql/groups.js | 30 +- backend/src/schema/resolvers/groups.spec.js | 372 ++++++++++---------- backend/src/schema/types/type/Group.gql | 14 +- 3 files changed, 219 insertions(+), 197 deletions(-) diff --git a/backend/src/db/graphql/groups.js b/backend/src/db/graphql/groups.js index e8da8e90b..c41f06e4d 100644 --- a/backend/src/db/graphql/groups.js +++ b/backend/src/db/graphql/groups.js @@ -4,13 +4,13 @@ import gql from 'graphql-tag' export const createGroupMutation = gql` mutation ( - $id: ID, - $name: String!, - $slug: String, - $about: String, - $description: String!, - $groupType: GroupType!, - $actionRadius: GroupActionRadius!, + $id: ID + $name: String! + $slug: String + $about: String + $description: String! + $groupType: GroupType! + $actionRadius: GroupActionRadius! $categoryIds: [ID] ) { CreateGroup( @@ -47,13 +47,13 @@ export const createGroupMutation = gql` export const groupQuery = gql` query ( $isMember: Boolean - $id: ID, - $name: String, - $slug: String, + $id: ID + $name: String + $slug: String $createdAt: String $updatedAt: String - $about: String, - $description: String, + $about: String + $description: String # $groupType: GroupType!, # $actionRadius: GroupActionRadius!, # $categoryIds: [ID] @@ -93,6 +93,12 @@ export const groupQuery = gql` groupType actionRadius myRole + categories { + id + slug + name + icon + } # Wolle: owner { # name # } diff --git a/backend/src/schema/resolvers/groups.spec.js b/backend/src/schema/resolvers/groups.spec.js index 58e5f37be..dd5a48568 100644 --- a/backend/src/schema/resolvers/groups.spec.js +++ b/backend/src/schema/resolvers/groups.spec.js @@ -52,21 +52,25 @@ beforeEach(async () => { neode.create('Category', { id: 'cat9', name: 'Democracy & Politics', + slug: 'democracy-politics', icon: 'university', }), neode.create('Category', { id: 'cat4', name: 'Environment & Nature', + slug: 'environment-nature', icon: 'tree', }), neode.create('Category', { id: 'cat15', name: 'Consumption & Sustainability', + slug: 'consumption-sustainability', icon: 'shopping-cart', }), neode.create('Category', { id: 'cat27', name: 'Animal Protection', + slug: 'animal-protection', icon: 'paw', }), ]) @@ -133,44 +137,8 @@ describe('Group', () => { }) }) - describe('query can fetch', () => { - it('groups where user is member (or owner in this case)', async () => { - const expected = { - data: { - Group: [ - { - id: 'my-group', - slug: 'the-best-group', - myRole: 'owner', - }, - ], - }, - errors: undefined, - } - await expect( - query({ query: groupQuery, variables: { isMember: true } }), - ).resolves.toMatchObject(expected) - }) - - it('groups where user is not(!) member', async () => { - const expected = { - data: { - Group: expect.arrayContaining([ - expect.objectContaining({ - id: 'others-group', - slug: 'uninteresting-group', - myRole: null, - }), - ]), - }, - errors: undefined, - } - await expect( - query({ query: groupQuery, variables: { isMember: false } }), - ).resolves.toMatchObject(expected) - }) - - it('all groups', async () => { + describe('can find', () => { + it('all', async () => { const expected = { data: { Group: expect.arrayContaining([ @@ -190,164 +158,200 @@ describe('Group', () => { } await expect(query({ query: groupQuery, variables: {} })).resolves.toMatchObject(expected) }) + + it('where user is member (or owner in this case)', async () => { + const expected = { + data: { + Group: [ + { + id: 'my-group', + slug: 'the-best-group', + myRole: 'owner', + }, + ], + }, + errors: undefined, + } + await expect( + query({ query: groupQuery, variables: { isMember: true } }), + ).resolves.toMatchObject(expected) + }) + + it('where user is not(!) member', async () => { + const expected = { + data: { + Group: expect.arrayContaining([ + expect.objectContaining({ + id: 'others-group', + slug: 'uninteresting-group', + myRole: null, + }), + ]), + }, + errors: undefined, + } + await expect( + query({ query: groupQuery, variables: { isMember: false } }), + ).resolves.toMatchObject(expected) + }) }) - // Wolle: describe('can be filtered', () => { - // let followedUser, happyPost, cryPost - // beforeEach(async () => { - // ;[followedUser] = await Promise.all([ - // Factory.build( - // 'user', - // { - // id: 'followed-by-me', - // name: 'Followed User', - // }, - // { - // email: 'followed@example.org', - // password: '1234', - // }, - // ), - // ]) - // ;[happyPost, cryPost] = await Promise.all([ - // Factory.build('post', { id: 'happy-post' }, { categoryIds: ['cat4'] }), - // Factory.build('post', { id: 'cry-post' }, { categoryIds: ['cat15'] }), - // Factory.build( - // 'post', - // { - // id: 'post-by-followed-user', - // }, - // { - // categoryIds: ['cat9'], - // author: followedUser, - // }, - // ), - // ]) - // }) - // describe('no filter', () => { - // it('returns all posts', async () => { - // const postQueryNoFilters = gql` - // query Post($filter: _PostFilter) { - // Post(filter: $filter) { - // id - // } - // } - // ` - // const expected = [{ id: 'happy-post' }, { id: 'cry-post' }, { id: 'post-by-followed-user' }] - // variables = { filter: {} } - // await expect(query({ query: postQueryNoFilters, variables })).resolves.toMatchObject({ - // data: { - // Post: expect.arrayContaining(expected), - // }, - // }) - // }) - // }) - // /* it('by categories', async () => { - // const postQueryFilteredByCategories = gql` - // query Post($filter: _PostFilter) { - // Post(filter: $filter) { - // id - // categories { - // id - // } - // } + // describe('can be filtered', () => { + // Wolle: it('by categories', async () => { + // const postQueryFilteredByCategories = gql` + // query Post($filter: _PostFilter) { + // Post(filter: $filter) { + // id + // categories { + // id // } - // ` - // const expected = { - // data: { - // Post: [ - // { - // id: 'post-by-followed-user', - // categories: [{ id: 'cat9' }], - // }, - // ], - // }, // } - // variables = { ...variables, filter: { categories_some: { id_in: ['cat9'] } } } - // await expect( - // query({ query: postQueryFilteredByCategories, variables }), - // ).resolves.toMatchObject(expected) - // }) */ - // describe('by emotions', () => { - // const postQueryFilteredByEmotions = gql` - // query Post($filter: _PostFilter) { - // Post(filter: $filter) { - // id - // emotions { - // emotion - // } - // } + // } + // ` + // const expected = { + // data: { + // Post: [ + // { + // id: 'post-by-followed-user', + // categories: [{ id: 'cat9' }], + // }, + // ], + // }, + // } + // variables = { ...variables, filter: { categories_some: { id_in: ['cat9'] } } } + // await expect( + // query({ query: postQueryFilteredByCategories, variables }), + // ).resolves.toMatchObject(expected) + // }) + // Wolle: let followedUser, happyPost, cryPost + // beforeEach(async () => { + // ;[followedUser] = await Promise.all([ + // Factory.build( + // 'user', + // { + // id: 'followed-by-me', + // name: 'Followed User', + // }, + // { + // email: 'followed@example.org', + // password: '1234', + // }, + // ), + // ]) + // ;[happyPost, cryPost] = await Promise.all([ + // Factory.build('post', { id: 'happy-post' }, { categoryIds: ['cat4'] }), + // Factory.build('post', { id: 'cry-post' }, { categoryIds: ['cat15'] }), + // Factory.build( + // 'post', + // { + // id: 'post-by-followed-user', + // }, + // { + // categoryIds: ['cat9'], + // author: followedUser, + // }, + // ), + // ]) + // }) + // describe('no filter', () => { + // it('returns all posts', async () => { + // const postQueryNoFilters = gql` + // query Post($filter: _PostFilter) { + // Post(filter: $filter) { + // id // } - // ` - // it('filters by single emotion', async () => { - // const expected = { - // data: { - // Post: [ - // { - // id: 'happy-post', - // emotions: [{ emotion: 'happy' }], - // }, - // ], - // }, + // } + // ` + // const expected = [{ id: 'happy-post' }, { id: 'cry-post' }, { id: 'post-by-followed-user' }] + // variables = { filter: {} } + // await expect(query({ query: postQueryNoFilters, variables })).resolves.toMatchObject({ + // data: { + // Post: expect.arrayContaining(expected), + // }, + // }) + // }) + // }) + // describe('by emotions', () => { + // const postQueryFilteredByEmotions = gql` + // query Post($filter: _PostFilter) { + // Post(filter: $filter) { + // id + // emotions { + // emotion // } - // await user.relateTo(happyPost, 'emoted', { emotion: 'happy' }) - // variables = { ...variables, filter: { emotions_some: { emotion_in: ['happy'] } } } - // await expect( - // query({ query: postQueryFilteredByEmotions, variables }), - // ).resolves.toMatchObject(expected) - // }) - // it('filters by multiple emotions', async () => { - // const expected = [ + // } + // } + // ` + // it('filters by single emotion', async () => { + // const expected = { + // data: { + // Post: [ // { // id: 'happy-post', // emotions: [{ emotion: 'happy' }], // }, - // { - // id: 'cry-post', - // emotions: [{ emotion: 'cry' }], - // }, - // ] - // await user.relateTo(happyPost, 'emoted', { emotion: 'happy' }) - // await user.relateTo(cryPost, 'emoted', { emotion: 'cry' }) - // variables = { ...variables, filter: { emotions_some: { emotion_in: ['happy', 'cry'] } } } - // await expect( - // query({ query: postQueryFilteredByEmotions, variables }), - // ).resolves.toMatchObject({ - // data: { - // Post: expect.arrayContaining(expected), - // }, - // errors: undefined, - // }) - // }) - // }) - // it('by followed-by', async () => { - // const postQueryFilteredByUsersFollowed = gql` - // query Post($filter: _PostFilter) { - // Post(filter: $filter) { - // id - // author { - // id - // name - // } - // } - // } - // ` - // await user.relateTo(followedUser, 'following') - // variables = { filter: { author: { followedBy_some: { id: 'current-user' } } } } - // await expect( - // query({ query: postQueryFilteredByUsersFollowed, variables }), - // ).resolves.toMatchObject({ - // data: { - // Post: [ - // { - // id: 'post-by-followed-user', - // author: { id: 'followed-by-me', name: 'Followed User' }, - // }, - // ], - // }, - // errors: undefined, - // }) + // ], + // }, + // } + // await user.relateTo(happyPost, 'emoted', { emotion: 'happy' }) + // variables = { ...variables, filter: { emotions_some: { emotion_in: ['happy'] } } } + // await expect( + // query({ query: postQueryFilteredByEmotions, variables }), + // ).resolves.toMatchObject(expected) + // }) + // it('filters by multiple emotions', async () => { + // const expected = [ + // { + // id: 'happy-post', + // emotions: [{ emotion: 'happy' }], + // }, + // { + // id: 'cry-post', + // emotions: [{ emotion: 'cry' }], + // }, + // ] + // await user.relateTo(happyPost, 'emoted', { emotion: 'happy' }) + // await user.relateTo(cryPost, 'emoted', { emotion: 'cry' }) + // variables = { ...variables, filter: { emotions_some: { emotion_in: ['happy', 'cry'] } } } + // await expect( + // query({ query: postQueryFilteredByEmotions, variables }), + // ).resolves.toMatchObject({ + // data: { + // Post: expect.arrayContaining(expected), + // }, + // errors: undefined, // }) // }) + // }) + // it('by followed-by', async () => { + // const postQueryFilteredByUsersFollowed = gql` + // query Post($filter: _PostFilter) { + // Post(filter: $filter) { + // id + // author { + // id + // name + // } + // } + // } + // ` + // await user.relateTo(followedUser, 'following') + // variables = { filter: { author: { followedBy_some: { id: 'current-user' } } } } + // await expect( + // query({ query: postQueryFilteredByUsersFollowed, variables }), + // ).resolves.toMatchObject({ + // data: { + // Post: [ + // { + // id: 'post-by-followed-user', + // author: { id: 'followed-by-me', name: 'Followed User' }, + // }, + // ], + // }, + // errors: undefined, + // }) + // }) + // }) }) }) diff --git a/backend/src/schema/types/type/Group.gql b/backend/src/schema/types/type/Group.gql index b8e00f0ee..2dc20aebf 100644 --- a/backend/src/schema/types/type/Group.gql +++ b/backend/src/schema/types/type/Group.gql @@ -141,6 +141,14 @@ input _GroupFilter { id_not: ID id_in: [ID!] id_not_in: [ID!] + # categories: _CategoryFilter + # categories_not: _CategoryFilter + # categories_in: [_CategoryFilter!] + # categories_not_in: [_CategoryFilter!] + # categories_some: _CategoryFilter + # categories_none: _CategoryFilter + # categories_single: _CategoryFilter + # categories_every: _CategoryFilter # Wolle: # friends: _GroupFilter # friends_not: _GroupFilter @@ -185,7 +193,11 @@ type Query { filter: _GroupFilter ): [Group] - availableGroupTypes: [GroupType]! + AvailableGroupTypes: [GroupType]! + + AvailableGroupActionRadii: [GroupActionRadius]! + + AvailableGroupMemberRoles: [GroupMemberRole]! # Wolle: # availableRoles: [UserRole]! From 61344fc96bb920e3fe04da97a3ee67dbcc643b84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Tue, 9 Aug 2022 08:50:25 +0200 Subject: [PATCH 18/29] Implement errors for to less or to many categories and test it --- backend/src/constants/categories.js | 5 +++++ backend/src/schema/resolvers/groups.js | 7 ++++++ backend/src/schema/resolvers/groups.spec.js | 24 ++++++++++++++++++++- 3 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 backend/src/constants/categories.js diff --git a/backend/src/constants/categories.js b/backend/src/constants/categories.js new file mode 100644 index 000000000..37cac8151 --- /dev/null +++ b/backend/src/constants/categories.js @@ -0,0 +1,5 @@ +// this file is duplicated in `backend/src/config/metadata.js` and `webapp/constants/metadata.js` +export default { + CATEGORIES_MIN: 1, + CATEGORIES_MAX: 3, +} diff --git a/backend/src/schema/resolvers/groups.js b/backend/src/schema/resolvers/groups.js index be07fecc6..a958e990e 100644 --- a/backend/src/schema/resolvers/groups.js +++ b/backend/src/schema/resolvers/groups.js @@ -3,6 +3,7 @@ import { v4 as uuid } from 'uuid' // Wolle: import { isEmpty } from 'lodash' import { UserInputError } from 'apollo-server' import CONFIG from '../../config' +import categories from '../../constants/categories' // Wolle: import { mergeImage, deleteImage } from './images/images' import Resolver from './helpers/Resolver' // Wolle: import { filterForMutedUsers } from './helpers/filterForMutedUsers' @@ -69,6 +70,12 @@ export default { CreateGroup: async (_parent, params, context, _resolveInfo) => { const { categoryIds } = params delete params.categoryIds + if (!categoryIds || categoryIds.length < categories.CATEGORIES_MIN) { + throw new UserInputError('To Less Categories!') + } + if (categoryIds && categoryIds.length > categories.CATEGORIES_MAX) { + throw new UserInputError('To Many Categories!') + } params.id = params.id || uuid() const session = context.driver.session() const writeTxResultPromise = session.writeTransaction(async (transaction) => { diff --git a/backend/src/schema/resolvers/groups.spec.js b/backend/src/schema/resolvers/groups.spec.js index dd5a48568..8f20c4fa7 100644 --- a/backend/src/schema/resolvers/groups.spec.js +++ b/backend/src/schema/resolvers/groups.spec.js @@ -416,12 +416,34 @@ describe('CreateGroup', () => { ) }) - it('`disabled` and `deleted` default to `false`', async () => { + it('"disabled" and "deleted" default to "false"', async () => { const expected = { data: { CreateGroup: { disabled: false, deleted: false } } } await expect(mutate({ mutation: createGroupMutation, variables })).resolves.toMatchObject( expected, ) }) + + describe('categories', () => { + describe('not even one', () => { + it('throws error: "To Less Categories!"', async () => { + const { errors } = await mutate({ + mutation: createGroupMutation, + variables: { ...variables, categoryIds: null }, + }) + expect(errors[0]).toHaveProperty('message', 'To Less Categories!') + }) + }) + + describe('four', () => { + it('throws error: "To Many Categories!"', async () => { + const { errors } = await mutate({ + mutation: createGroupMutation, + variables: { ...variables, categoryIds: ['cat9', 'cat4', 'cat15', 'cat27'] }, + }) + expect(errors[0]).toHaveProperty('message', 'To Many Categories!') + }) + }) + }) }) }) From 117bd5e4e6e49eda5483ff1bc34dd833dc9b1b2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Tue, 9 Aug 2022 16:34:50 +0200 Subject: [PATCH 19/29] Implement error for to short 'description' and test it --- backend/src/constants/categories.js | 8 +++--- backend/src/constants/groups.js | 2 ++ backend/src/middleware/helpers/cleanHtml.js | 7 +++++ backend/src/middleware/languages/languages.js | 9 +------ backend/src/schema/resolvers/groups.js | 15 ++++++++--- backend/src/schema/resolvers/groups.spec.js | 27 ++++++++++++++++--- webapp/constants/categories.js | 3 +++ webapp/constants/groups.js | 2 ++ 8 files changed, 54 insertions(+), 19 deletions(-) create mode 100644 backend/src/constants/groups.js create mode 100644 webapp/constants/categories.js create mode 100644 webapp/constants/groups.js diff --git a/backend/src/constants/categories.js b/backend/src/constants/categories.js index 37cac8151..64ceb9021 100644 --- a/backend/src/constants/categories.js +++ b/backend/src/constants/categories.js @@ -1,5 +1,3 @@ -// this file is duplicated in `backend/src/config/metadata.js` and `webapp/constants/metadata.js` -export default { - CATEGORIES_MIN: 1, - CATEGORIES_MAX: 3, -} +// this file is duplicated in `backend/src/constants/metadata.js` and `webapp/constants/metadata.js` +export const CATEGORIES_MIN = 1 +export const CATEGORIES_MAX = 3 diff --git a/backend/src/constants/groups.js b/backend/src/constants/groups.js new file mode 100644 index 000000000..b4a6063f1 --- /dev/null +++ b/backend/src/constants/groups.js @@ -0,0 +1,2 @@ +// this file is duplicated in `backend/src/constants/group.js` and `webapp/constants/group.js` +export const DESCRIPTION_WITHOUT_HTML_LENGTH_MIN = 100 // with removed HTML tags diff --git a/backend/src/middleware/helpers/cleanHtml.js b/backend/src/middleware/helpers/cleanHtml.js index 72976b43c..ac71f6bdc 100644 --- a/backend/src/middleware/helpers/cleanHtml.js +++ b/backend/src/middleware/helpers/cleanHtml.js @@ -1,6 +1,13 @@ import sanitizeHtml from 'sanitize-html' import linkifyHtml from 'linkifyjs/html' +export const removeHtmlTags = (input) => { + return sanitizeHtml(input, { + allowedTags: [], + allowedAttributes: {}, + }) +} + const standardSanitizeHtmlOptions = { allowedTags: [ 'img', diff --git a/backend/src/middleware/languages/languages.js b/backend/src/middleware/languages/languages.js index 3cf760f31..087252975 100644 --- a/backend/src/middleware/languages/languages.js +++ b/backend/src/middleware/languages/languages.js @@ -1,12 +1,5 @@ import LanguageDetect from 'languagedetect' -import sanitizeHtml from 'sanitize-html' - -const removeHtmlTags = (input) => { - return sanitizeHtml(input, { - allowedTags: [], - allowedAttributes: {}, - }) -} +import { removeHtmlTags } from '../helpers/cleanHtml.js' const setPostLanguage = (text) => { const lngDetector = new LanguageDetect() diff --git a/backend/src/schema/resolvers/groups.js b/backend/src/schema/resolvers/groups.js index a958e990e..0e07b7542 100644 --- a/backend/src/schema/resolvers/groups.js +++ b/backend/src/schema/resolvers/groups.js @@ -3,7 +3,9 @@ import { v4 as uuid } from 'uuid' // Wolle: import { isEmpty } from 'lodash' import { UserInputError } from 'apollo-server' import CONFIG from '../../config' -import categories from '../../constants/categories' +import { CATEGORIES_MIN, CATEGORIES_MAX } from '../../constants/categories' +import { DESCRIPTION_WITHOUT_HTML_LENGTH_MIN } from '../../constants/groups' +import { removeHtmlTags } from '../../middleware/helpers/cleanHtml.js' // Wolle: import { mergeImage, deleteImage } from './images/images' import Resolver from './helpers/Resolver' // Wolle: import { filterForMutedUsers } from './helpers/filterForMutedUsers' @@ -70,12 +72,19 @@ export default { CreateGroup: async (_parent, params, context, _resolveInfo) => { const { categoryIds } = params delete params.categoryIds - if (!categoryIds || categoryIds.length < categories.CATEGORIES_MIN) { + if (!categoryIds || categoryIds.length < CATEGORIES_MIN) { throw new UserInputError('To Less Categories!') } - if (categoryIds && categoryIds.length > categories.CATEGORIES_MAX) { + if (categoryIds && categoryIds.length > CATEGORIES_MAX) { throw new UserInputError('To Many Categories!') } + if ( + params.description === undefined || + params.description === null || + removeHtmlTags(params.description).length < DESCRIPTION_WITHOUT_HTML_LENGTH_MIN + ) { + throw new UserInputError('To Short Description!') + } params.id = params.id || uuid() const session = context.driver.session() const writeTxResultPromise = session.writeTransaction(async (transaction) => { diff --git a/backend/src/schema/resolvers/groups.spec.js b/backend/src/schema/resolvers/groups.spec.js index 8f20c4fa7..ad9b6d68e 100644 --- a/backend/src/schema/resolvers/groups.spec.js +++ b/backend/src/schema/resolvers/groups.spec.js @@ -13,6 +13,8 @@ let authenticatedUser let user const categoryIds = ['cat9', 'cat4', 'cat15'] +const descriptionAddition100 = + ' 123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789' let variables = {} beforeAll(async () => { @@ -116,7 +118,7 @@ describe('Group', () => { id: 'others-group', name: 'Uninteresting Group', about: 'We will change nothing!', - description: 'We love it like it is!?', + description: 'We love it like it is!?' + descriptionAddition100, groupType: 'closed', actionRadius: 'international', categoryIds, @@ -129,7 +131,7 @@ describe('Group', () => { id: 'my-group', name: 'The Best Group', about: 'We will change the world!', - description: 'Some description', + description: 'Some description' + descriptionAddition100, groupType: 'public', actionRadius: 'regional', categoryIds, @@ -363,7 +365,7 @@ describe('CreateGroup', () => { name: 'The Best Group', slug: 'the-group', about: 'We will change the world!', - description: 'Some description', + description: 'Some description' + descriptionAddition100, groupType: 'public', actionRadius: 'regional', categoryIds, @@ -423,6 +425,25 @@ describe('CreateGroup', () => { ) }) + describe('description', () => { + describe('length without HTML', () => { + describe('less then 100 chars', () => { + it('throws error: "To Less Categories!"', async () => { + const { errors } = await mutate({ + mutation: createGroupMutation, + variables: { + ...variables, + description: + '0123456789' + + '0123456789', + }, + }) + expect(errors[0]).toHaveProperty('message', 'To Short Description!') + }) + }) + }) + }) + describe('categories', () => { describe('not even one', () => { it('throws error: "To Less Categories!"', async () => { diff --git a/webapp/constants/categories.js b/webapp/constants/categories.js new file mode 100644 index 000000000..64ceb9021 --- /dev/null +++ b/webapp/constants/categories.js @@ -0,0 +1,3 @@ +// this file is duplicated in `backend/src/constants/metadata.js` and `webapp/constants/metadata.js` +export const CATEGORIES_MIN = 1 +export const CATEGORIES_MAX = 3 diff --git a/webapp/constants/groups.js b/webapp/constants/groups.js new file mode 100644 index 000000000..b4a6063f1 --- /dev/null +++ b/webapp/constants/groups.js @@ -0,0 +1,2 @@ +// this file is duplicated in `backend/src/constants/group.js` and `webapp/constants/group.js` +export const DESCRIPTION_WITHOUT_HTML_LENGTH_MIN = 100 // with removed HTML tags From 0149af12d4c09ca8ec3f99c62a746609bacf6e44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Tue, 9 Aug 2022 16:48:46 +0200 Subject: [PATCH 20/29] Cleanup --- backend/src/db/graphql/groups.js | 12 - backend/src/models/Group.js | 100 +---- backend/src/schema/resolvers/groups.js | 112 ----- backend/src/schema/resolvers/groups.spec.js | 458 -------------------- backend/src/schema/types/type/Group.gql | 164 +------ 5 files changed, 3 insertions(+), 843 deletions(-) diff --git a/backend/src/db/graphql/groups.js b/backend/src/db/graphql/groups.js index c41f06e4d..2a611f324 100644 --- a/backend/src/db/graphql/groups.js +++ b/backend/src/db/graphql/groups.js @@ -35,9 +35,6 @@ export const createGroupMutation = gql` groupType actionRadius myRole - # Wolle: owner { - # name - # } } } ` @@ -54,9 +51,6 @@ export const groupQuery = gql` $updatedAt: String $about: String $description: String - # $groupType: GroupType!, - # $actionRadius: GroupActionRadius!, - # $categoryIds: [ID] $locationName: String $first: Int $offset: Int @@ -72,9 +66,6 @@ export const groupQuery = gql` updatedAt: $updatedAt about: $about description: $description - # groupType: $groupType - # actionRadius: $actionRadius - # categoryIds: $categoryIds locationName: $locationName first: $first offset: $offset @@ -99,9 +90,6 @@ export const groupQuery = gql` name icon } - # Wolle: owner { - # name - # } } } ` diff --git a/backend/src/models/Group.js b/backend/src/models/Group.js index 0cec02bf8..25149e9c3 100644 --- a/backend/src/models/Group.js +++ b/backend/src/models/Group.js @@ -38,109 +38,11 @@ export default { locationName: { type: 'string', allow: [null] }, wasSeeded: 'boolean', // Wolle: used or needed? - // Wolle: owner: { - // type: 'relationship', - // relationship: 'OWNS', - // target: 'User', - // direction: 'in', - // }, - // Wolle: followedBy: { - // type: 'relationship', - // relationship: 'FOLLOWS', - // target: 'User', - // direction: 'in', - // properties: { - // createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, - // }, - // }, - // Wolle: correct this way? - // members: { type: 'relationship', relationship: 'MEMBERS', target: 'User', direction: 'out' }, - // Wolle: needed? lastActiveAt: { type: 'string', isoDate: true }, - // Wolle: emoted: { - // type: 'relationships', - // relationship: 'EMOTED', - // target: 'Post', - // direction: 'out', - // properties: { - // emotion: { - // type: 'string', - // valid: ['happy', 'cry', 'surprised', 'angry', 'funny'], - // invalid: [null], - // }, - // }, - // eager: true, - // cascade: true, - // }, - // Wolle: blocked: { - // type: 'relationship', - // relationship: 'BLOCKED', - // target: 'User', - // direction: 'out', - // properties: { - // createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, - // }, - // }, - // Wolle: muted: { - // type: 'relationship', - // relationship: 'MUTED', - // target: 'User', - // direction: 'out', - // properties: { - // createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, - // }, - // }, - // Wolle: notifications: { - // type: 'relationship', - // relationship: 'NOTIFIED', - // target: 'User', - // direction: 'in', - // }, - // Wolle inviteCodes: { - // type: 'relationship', - // relationship: 'GENERATED', - // target: 'InviteCode', - // direction: 'out', - // }, - // Wolle: redeemedInviteCode: { - // type: 'relationship', - // relationship: 'REDEEMED', - // target: 'InviteCode', - // direction: 'out', - // }, - // Wolle: shouted: { - // type: 'relationship', - // relationship: 'SHOUTED', - // target: 'Post', - // direction: 'out', - // properties: { - // createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, - // }, - // }, + isIn: { type: 'relationship', relationship: 'IS_IN', target: 'Location', direction: 'out', }, - // Wolle: pinned: { - // type: 'relationship', - // relationship: 'PINNED', - // target: 'Post', - // direction: 'out', - // properties: { - // createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, - // }, - // }, - // Wolle: showShoutsPublicly: { - // type: 'boolean', - // default: false, - // }, - // Wolle: sendNotificationEmails: { - // type: 'boolean', - // default: true, - // }, - // Wolle: locale: { - // type: 'string', - // allow: [null], - // }, } diff --git a/backend/src/schema/resolvers/groups.js b/backend/src/schema/resolvers/groups.js index 0e07b7542..75f9e35df 100644 --- a/backend/src/schema/resolvers/groups.js +++ b/backend/src/schema/resolvers/groups.js @@ -1,32 +1,13 @@ import { v4 as uuid } from 'uuid' -// Wolle: import { neo4jgraphql } from 'neo4j-graphql-js' -// Wolle: import { isEmpty } from 'lodash' import { UserInputError } from 'apollo-server' import CONFIG from '../../config' import { CATEGORIES_MIN, CATEGORIES_MAX } from '../../constants/categories' import { DESCRIPTION_WITHOUT_HTML_LENGTH_MIN } from '../../constants/groups' import { removeHtmlTags } from '../../middleware/helpers/cleanHtml.js' -// Wolle: import { mergeImage, deleteImage } from './images/images' import Resolver from './helpers/Resolver' -// Wolle: import { filterForMutedUsers } from './helpers/filterForMutedUsers' - -// Wolle: const maintainPinnedPosts = (params) => { -// const pinnedPostFilter = { pinned: true } -// if (isEmpty(params.filter)) { -// params.filter = { OR: [pinnedPostFilter, {}] } -// } else { -// params.filter = { OR: [pinnedPostFilter, { ...params.filter }] } -// } -// return params -// } export default { Query: { - // Wolle: Post: async (object, params, context, resolveInfo) => { - // params = await filterForMutedUsers(params, context) - // // params = await maintainPinnedPosts(params) - // return neo4jgraphql(object, params, context, resolveInfo) - // }, Group: async (_object, params, context, _resolveInfo) => { const { isMember } = params const session = context.driver.session() @@ -130,105 +111,12 @@ export default { session.close() } }, - // UpdatePost: async (_parent, params, context, _resolveInfo) => { - // const { categoryIds } = params - // const { image: imageInput } = params - // delete params.categoryIds - // delete params.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) { - // const cypherDeletePreviousRelations = ` - // MATCH (post:Post { id: $params.id })-[previousRelations:CATEGORIZED]->(category:Category) - // DELETE previousRelations - // RETURN post, category - // ` - - // await session.writeTransaction((transaction) => { - // return transaction.run(cypherDeletePreviousRelations, { params }) - // }) - - // updatePostCypher += ` - // UNWIND $categoryIds AS categoryId - // MATCH (category:Category {id: categoryId}) - // MERGE (post)-[:CATEGORIZED]->(category) - // WITH post - // ` - // } - - // updatePostCypher += `RETURN post {.*}` - // const updatePostVariables = { categoryIds, params } - // try { - // const writeTxResultPromise = session.writeTransaction(async (transaction) => { - // const updatePostTransactionResponse = await transaction.run( - // updatePostCypher, - // updatePostVariables, - // ) - // const [post] = updatePostTransactionResponse.records.map((record) => record.get('post')) - // await mergeImage(post, 'HERO_IMAGE', imageInput, { transaction }) - // return post - // }) - // const post = await writeTxResultPromise - // return post - // } finally { - // session.close() - // } - // }, - - // DeletePost: async (object, args, context, resolveInfo) => { - // const session = context.driver.session() - // const writeTxResultPromise = session.writeTransaction(async (transaction) => { - // const deletePostTransactionResponse = await transaction.run( - // ` - // MATCH (post:Post {id: $postId}) - // OPTIONAL MATCH (post)<-[:COMMENTS]-(comment:Comment) - // SET post.deleted = TRUE - // SET post.content = 'UNAVAILABLE' - // SET post.contentExcerpt = 'UNAVAILABLE' - // SET post.title = 'UNAVAILABLE' - // SET comment.deleted = TRUE - // RETURN post {.*} - // `, - // { postId: args.id }, - // ) - // const [post] = deletePostTransactionResponse.records.map((record) => record.get('post')) - // await deleteImage(post, 'HERO_IMAGE', { transaction }) - // return post - // }) - // try { - // const post = await writeTxResultPromise - // return post - // } finally { - // session.close() - // } }, Group: { ...Resolver('Group', { - // Wolle: undefinedToNull: ['activityId', 'objectId', 'language', 'pinnedAt', 'pinned'], hasMany: { - // Wolle: tags: '-[:TAGGED]->(related:Tag)', categories: '-[:CATEGORIZED]->(related:Category)', }, - // hasOne: { - // owner: '<-[:OWNS]-(related:User)', - // // Wolle: image: '-[:HERO_IMAGE]->(related:Image)', - // }, - // Wolle: count: { - // contributionsCount: - // '-[:WROTE]->(related:Post) WHERE NOT related.disabled = true AND NOT related.deleted = true', - // }, - // Wolle: boolean: { - // shoutedByCurrentUser: - // 'MATCH(this)<-[:SHOUTED]-(related:User {id: $cypherParams.currentUserId}) RETURN COUNT(related) >= 1', - // viewedTeaserByCurrentUser: - // 'MATCH (this)<-[:VIEWED_TEASER]-(u:User {id: $cypherParams.currentUserId}) RETURN COUNT(u) >= 1', - // }, }), }, } diff --git a/backend/src/schema/resolvers/groups.spec.js b/backend/src/schema/resolvers/groups.spec.js index ad9b6d68e..bae530c61 100644 --- a/backend/src/schema/resolvers/groups.spec.js +++ b/backend/src/schema/resolvers/groups.spec.js @@ -197,163 +197,6 @@ describe('Group', () => { ).resolves.toMatchObject(expected) }) }) - - // describe('can be filtered', () => { - // Wolle: it('by categories', async () => { - // const postQueryFilteredByCategories = gql` - // query Post($filter: _PostFilter) { - // Post(filter: $filter) { - // id - // categories { - // id - // } - // } - // } - // ` - // const expected = { - // data: { - // Post: [ - // { - // id: 'post-by-followed-user', - // categories: [{ id: 'cat9' }], - // }, - // ], - // }, - // } - // variables = { ...variables, filter: { categories_some: { id_in: ['cat9'] } } } - // await expect( - // query({ query: postQueryFilteredByCategories, variables }), - // ).resolves.toMatchObject(expected) - // }) - // Wolle: let followedUser, happyPost, cryPost - // beforeEach(async () => { - // ;[followedUser] = await Promise.all([ - // Factory.build( - // 'user', - // { - // id: 'followed-by-me', - // name: 'Followed User', - // }, - // { - // email: 'followed@example.org', - // password: '1234', - // }, - // ), - // ]) - // ;[happyPost, cryPost] = await Promise.all([ - // Factory.build('post', { id: 'happy-post' }, { categoryIds: ['cat4'] }), - // Factory.build('post', { id: 'cry-post' }, { categoryIds: ['cat15'] }), - // Factory.build( - // 'post', - // { - // id: 'post-by-followed-user', - // }, - // { - // categoryIds: ['cat9'], - // author: followedUser, - // }, - // ), - // ]) - // }) - // describe('no filter', () => { - // it('returns all posts', async () => { - // const postQueryNoFilters = gql` - // query Post($filter: _PostFilter) { - // Post(filter: $filter) { - // id - // } - // } - // ` - // const expected = [{ id: 'happy-post' }, { id: 'cry-post' }, { id: 'post-by-followed-user' }] - // variables = { filter: {} } - // await expect(query({ query: postQueryNoFilters, variables })).resolves.toMatchObject({ - // data: { - // Post: expect.arrayContaining(expected), - // }, - // }) - // }) - // }) - // describe('by emotions', () => { - // const postQueryFilteredByEmotions = gql` - // query Post($filter: _PostFilter) { - // Post(filter: $filter) { - // id - // emotions { - // emotion - // } - // } - // } - // ` - // it('filters by single emotion', async () => { - // const expected = { - // data: { - // Post: [ - // { - // id: 'happy-post', - // emotions: [{ emotion: 'happy' }], - // }, - // ], - // }, - // } - // await user.relateTo(happyPost, 'emoted', { emotion: 'happy' }) - // variables = { ...variables, filter: { emotions_some: { emotion_in: ['happy'] } } } - // await expect( - // query({ query: postQueryFilteredByEmotions, variables }), - // ).resolves.toMatchObject(expected) - // }) - // it('filters by multiple emotions', async () => { - // const expected = [ - // { - // id: 'happy-post', - // emotions: [{ emotion: 'happy' }], - // }, - // { - // id: 'cry-post', - // emotions: [{ emotion: 'cry' }], - // }, - // ] - // await user.relateTo(happyPost, 'emoted', { emotion: 'happy' }) - // await user.relateTo(cryPost, 'emoted', { emotion: 'cry' }) - // variables = { ...variables, filter: { emotions_some: { emotion_in: ['happy', 'cry'] } } } - // await expect( - // query({ query: postQueryFilteredByEmotions, variables }), - // ).resolves.toMatchObject({ - // data: { - // Post: expect.arrayContaining(expected), - // }, - // errors: undefined, - // }) - // }) - // }) - // it('by followed-by', async () => { - // const postQueryFilteredByUsersFollowed = gql` - // query Post($filter: _PostFilter) { - // Post(filter: $filter) { - // id - // author { - // id - // name - // } - // } - // } - // ` - // await user.relateTo(followedUser, 'following') - // variables = { filter: { author: { followedBy_some: { id: 'current-user' } } } } - // await expect( - // query({ query: postQueryFilteredByUsersFollowed, variables }), - // ).resolves.toMatchObject({ - // data: { - // Post: [ - // { - // id: 'post-by-followed-user', - // author: { id: 'followed-by-me', name: 'Followed User' }, - // }, - // ], - // }, - // errors: undefined, - // }) - // }) - // }) }) }) @@ -406,9 +249,6 @@ describe('CreateGroup', () => { CreateGroup: { name: 'The Best Group', myRole: 'owner', - // Wolle: owner: { - // name: 'TestUser', - // }, }, }, errors: undefined, @@ -467,301 +307,3 @@ describe('CreateGroup', () => { }) }) }) - -// describe('UpdatePost', () => { -// let author, newlyCreatedPost -// const updatePostMutation = gql` -// mutation ($id: ID!, $title: String!, $content: String!, $image: ImageInput) { -// UpdatePost(id: $id, title: $title, content: $content, image: $image) { -// id -// title -// content -// author { -// name -// slug -// } -// createdAt -// updatedAt -// } -// } -// ` -// beforeEach(async () => { -// author = await Factory.build('user', { slug: 'the-author' }) -// newlyCreatedPost = await Factory.build( -// 'post', -// { -// id: 'p9876', -// title: 'Old title', -// content: 'Old content', -// }, -// { -// author, -// categoryIds, -// }, -// ) - -// variables = { -// id: 'p9876', -// title: 'New title', -// content: 'New content', -// } -// }) - -// describe('unauthenticated', () => { -// it('throws authorization error', async () => { -// authenticatedUser = null -// expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject({ -// errors: [{ message: 'Not Authorised!' }], -// data: { UpdatePost: null }, -// }) -// }) -// }) - -// describe('authenticated but not the author', () => { -// beforeEach(async () => { -// authenticatedUser = await user.toJson() -// }) - -// it('throws authorization error', async () => { -// const { errors } = await mutate({ mutation: updatePostMutation, variables }) -// expect(errors[0]).toHaveProperty('message', 'Not Authorised!') -// }) -// }) - -// describe('authenticated as author', () => { -// beforeEach(async () => { -// authenticatedUser = await author.toJson() -// }) - -// it('updates a post', async () => { -// const expected = { -// data: { UpdatePost: { id: 'p9876', content: 'New content' } }, -// errors: undefined, -// } -// await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject( -// expected, -// ) -// }) - -// it('updates a post, but maintains non-updated attributes', async () => { -// const expected = { -// data: { -// UpdatePost: { id: 'p9876', content: 'New content', createdAt: expect.any(String) }, -// }, -// errors: undefined, -// } -// await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject( -// expected, -// ) -// }) - -// it('updates the updatedAt attribute', async () => { -// newlyCreatedPost = await newlyCreatedPost.toJson() -// const { -// data: { UpdatePost }, -// } = await mutate({ mutation: updatePostMutation, variables }) -// expect(newlyCreatedPost.updatedAt).toBeTruthy() -// expect(Date.parse(newlyCreatedPost.updatedAt)).toEqual(expect.any(Number)) -// expect(UpdatePost.updatedAt).toBeTruthy() -// expect(Date.parse(UpdatePost.updatedAt)).toEqual(expect.any(Number)) -// expect(newlyCreatedPost.updatedAt).not.toEqual(UpdatePost.updatedAt) -// }) - -// /* describe('no new category ids provided for update', () => { -// it('resolves and keeps current categories', async () => { -// const expected = { -// data: { -// UpdatePost: { -// id: 'p9876', -// categories: expect.arrayContaining([{ id: 'cat9' }, { id: 'cat4' }, { id: 'cat15' }]), -// }, -// }, -// errors: undefined, -// } -// await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject( -// expected, -// ) -// }) -// }) */ - -// /* describe('given category ids', () => { -// beforeEach(() => { -// variables = { ...variables, categoryIds: ['cat27'] } -// }) - -// it('updates categories of a post', async () => { -// const expected = { -// data: { -// UpdatePost: { -// id: 'p9876', -// categories: expect.arrayContaining([{ id: 'cat27' }]), -// }, -// }, -// errors: undefined, -// } -// await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject( -// expected, -// ) -// }) -// }) */ - -// describe('params.image', () => { -// describe('is object', () => { -// beforeEach(() => { -// variables = { ...variables, image: { sensitive: true } } -// }) -// it('updates the image', async () => { -// await expect(neode.first('Image', { sensitive: true })).resolves.toBeFalsy() -// await mutate({ mutation: updatePostMutation, variables }) -// await expect(neode.first('Image', { sensitive: true })).resolves.toBeTruthy() -// }) -// }) - -// describe('is null', () => { -// beforeEach(() => { -// variables = { ...variables, image: null } -// }) -// it('deletes the image', async () => { -// await expect(neode.all('Image')).resolves.toHaveLength(6) -// await mutate({ mutation: updatePostMutation, variables }) -// await expect(neode.all('Image')).resolves.toHaveLength(5) -// }) -// }) - -// describe('is undefined', () => { -// beforeEach(() => { -// delete variables.image -// }) -// it('keeps the image unchanged', async () => { -// await expect(neode.first('Image', { sensitive: true })).resolves.toBeFalsy() -// await mutate({ mutation: updatePostMutation, variables }) -// await expect(neode.first('Image', { sensitive: true })).resolves.toBeFalsy() -// }) -// }) -// }) -// }) -// }) - -// describe('DeletePost', () => { -// let author -// const deletePostMutation = gql` -// mutation ($id: ID!) { -// DeletePost(id: $id) { -// id -// deleted -// content -// contentExcerpt -// image { -// url -// } -// comments { -// deleted -// content -// contentExcerpt -// } -// } -// } -// ` - -// beforeEach(async () => { -// author = await Factory.build('user') -// await Factory.build( -// 'post', -// { -// id: 'p4711', -// title: 'I will be deleted', -// content: 'To be deleted', -// }, -// { -// image: Factory.build('image', { -// url: 'path/to/some/image', -// }), -// author, -// categoryIds, -// }, -// ) -// variables = { ...variables, id: 'p4711' } -// }) - -// describe('unauthenticated', () => { -// it('throws authorization error', async () => { -// const { errors } = await mutate({ mutation: deletePostMutation, variables }) -// expect(errors[0]).toHaveProperty('message', 'Not Authorised!') -// }) -// }) - -// describe('authenticated but not the author', () => { -// beforeEach(async () => { -// authenticatedUser = await user.toJson() -// }) - -// it('throws authorization error', async () => { -// const { errors } = await mutate({ mutation: deletePostMutation, variables }) -// expect(errors[0]).toHaveProperty('message', 'Not Authorised!') -// }) -// }) - -// describe('authenticated as author', () => { -// beforeEach(async () => { -// authenticatedUser = await author.toJson() -// }) - -// it('marks the post as deleted and blacks out attributes', async () => { -// const expected = { -// data: { -// DeletePost: { -// id: 'p4711', -// deleted: true, -// content: 'UNAVAILABLE', -// contentExcerpt: 'UNAVAILABLE', -// image: null, -// comments: [], -// }, -// }, -// } -// await expect(mutate({ mutation: deletePostMutation, variables })).resolves.toMatchObject( -// expected, -// ) -// }) - -// describe('if there are comments on the post', () => { -// beforeEach(async () => { -// await Factory.build( -// 'comment', -// { -// content: 'to be deleted comment content', -// contentExcerpt: 'to be deleted comment content', -// }, -// { -// postId: 'p4711', -// }, -// ) -// }) - -// it('marks the comments as deleted', async () => { -// const expected = { -// data: { -// DeletePost: { -// id: 'p4711', -// deleted: true, -// content: 'UNAVAILABLE', -// contentExcerpt: 'UNAVAILABLE', -// image: null, -// comments: [ -// { -// deleted: true, -// // Should we black out the comment content in the database, too? -// content: 'UNAVAILABLE', -// contentExcerpt: 'UNAVAILABLE', -// }, -// ], -// }, -// }, -// } -// await expect(mutate({ mutation: deletePostMutation, variables })).resolves.toMatchObject( -// expected, -// ) -// }) -// }) -// }) -// }) diff --git a/backend/src/schema/types/type/Group.gql b/backend/src/schema/types/type/Group.gql index 2dc20aebf..3165b4a44 100644 --- a/backend/src/schema/types/type/Group.gql +++ b/backend/src/schema/types/type/Group.gql @@ -13,8 +13,6 @@ enum _GroupOrdering { createdAt_desc updatedAt_asc updatedAt_desc - # Wolle: needed? locale_asc - # locale_desc } type Group { @@ -40,90 +38,6 @@ type Group { categories: [Category] @relation(name: "CATEGORIZED", direction: "OUT") myRole: GroupMemberRole # if 'null' then the current user is no member - - # Wolle: needed? - # socialMedia: [SocialMedia]! @relation(name: "OWNED_BY", direction: "IN") - - # Wolle: owner: User @relation(name: "OWNS", direction: "IN") - - # Wolle: showShoutsPublicly: Boolean - # Wolle: sendNotificationEmails: Boolean - # Wolle: needed? locale: String - # members: [User]! @relation(name: "MEMBERS", direction: "OUT") - # membersCount: Int! - # @cypher(statement: "MATCH (this)-[:MEMBERS]->(r:User) RETURN COUNT(DISTINCT r)") - - # Wolle: followedBy: [User]! @relation(name: "FOLLOWS", direction: "IN") - # Wolle: followedByCount: Int! - # @cypher(statement: "MATCH (this)<-[:FOLLOWS]-(r:User) RETURN COUNT(DISTINCT r)") - - # Wolle: inviteCodes: [InviteCode] @relation(name: "GENERATED", direction: "OUT") - # Wolle: redeemedInviteCode: InviteCode @relation(name: "REDEEMED", direction: "OUT") - - # Is the currently logged in user following that user? - # Wolle: followedByCurrentUser: Boolean! - # @cypher( - # statement: """ - # MATCH (this)<-[:FOLLOWS]-(u:User { id: $cypherParams.currentUserId}) - # RETURN COUNT(u) >= 1 - # """ - # ) - - # Wolle: isBlocked: Boolean! - # @cypher( - # statement: """ - # MATCH (this)<-[:BLOCKED]-(user:User {id: $cypherParams.currentUserId}) - # RETURN COUNT(user) >= 1 - # """ - # ) - # Wolle: blocked: Boolean! - # @cypher( - # statement: """ - # MATCH (this)-[:BLOCKED]-(user:User {id: $cypherParams.currentUserId}) - # RETURN COUNT(user) >= 1 - # """ - # ) - - # Wolle: isMuted: Boolean! - # @cypher( - # statement: """ - # MATCH (this)<-[:MUTED]-(user:User { id: $cypherParams.currentUserId}) - # RETURN COUNT(user) >= 1 - # """ - # ) - - # 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" - # ) - # Wolle: needed? - # 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) - # """ - # ) - - # Wolle: 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))" - # ) - - # Wolle: 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)" - # ) - - # Wolle: badges: [Badge]! @relation(name: "REWARDED", direction: "IN") - # badgesCount: Int! @cypher(statement: "MATCH (this)<-[:REWARDED]-(r:Badge) RETURN COUNT(r)") - - # Wolle: emotions: [EMOTED] } @@ -141,39 +55,6 @@ input _GroupFilter { id_not: ID id_in: [ID!] id_not_in: [ID!] - # categories: _CategoryFilter - # categories_not: _CategoryFilter - # categories_in: [_CategoryFilter!] - # categories_not_in: [_CategoryFilter!] - # categories_some: _CategoryFilter - # categories_none: _CategoryFilter - # categories_single: _CategoryFilter - # categories_every: _CategoryFilter - # Wolle: - # friends: _GroupFilter - # friends_not: _GroupFilter - # friends_in: [_GroupFilter!] - # friends_not_in: [_GroupFilter!] - # friends_some: _GroupFilter - # friends_none: _GroupFilter - # friends_single: _GroupFilter - # friends_every: _GroupFilter - # following: _GroupFilter - # following_not: _GroupFilter - # following_in: [_GroupFilter!] - # following_not_in: [_GroupFilter!] - # following_some: _GroupFilter - # following_none: _GroupFilter - # following_single: _GroupFilter - # following_every: _GroupFilter - # followedBy: _GroupFilter - # followedBy_not: _GroupFilter - # followedBy_in: [_GroupFilter!] - # followedBy_not_in: [_GroupFilter!] - # followedBy_some: _GroupFilter - # followedBy_none: _GroupFilter - # followedBy_single: _GroupFilter - # followedBy_every: _GroupFilter } type Query { @@ -198,32 +79,8 @@ type Query { AvailableGroupActionRadii: [GroupActionRadius]! AvailableGroupMemberRoles: [GroupMemberRole]! - - # Wolle: - # availableRoles: [UserRole]! - # mutedUsers: [User] - # blockedUsers: [User] - # isLoggedIn: Boolean! - # currentUser: User - # findUsers(query: String!,limit: Int = 10, filter: _GroupFilter): [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 - # """ - # ) } -# Wolle: enum Deletable { -# Post -# Comment -# } - type Mutation { CreateGroup( id: ID @@ -236,12 +93,7 @@ type Mutation { actionRadius: GroupActionRadius! categoryIds: [ID] locationName: String - ): # Wolle: add group settings - # Wolle: - # showShoutsPublicly: Boolean - # sendNotificationEmails: Boolean - # locale: String - Group + ): Group UpdateGroup( id: ID! @@ -251,19 +103,7 @@ type Mutation { locationName: String about: String description: String - ): # Wolle: - # showShoutsPublicly: Boolean - # sendNotificationEmails: Boolean - # locale: String - Group + ): Group DeleteGroup(id: ID!): Group - - # Wolle: - # muteUser(id: ID!): User - # unmuteUser(id: ID!): User - # blockUser(id: ID!): User - # unblockUser(id: ID!): User - - # Wolle: switchUserRole(role: UserRole!, id: ID!): User } From 3d1b403656969929b4f2163f4d3d095cc961c5ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Tue, 9 Aug 2022 16:55:43 +0200 Subject: [PATCH 21/29] Add relation 'CREATED' between owner and group --- backend/src/schema/resolvers/groups.js | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/schema/resolvers/groups.js b/backend/src/schema/resolvers/groups.js index 75f9e35df..e6a8c3a18 100644 --- a/backend/src/schema/resolvers/groups.js +++ b/backend/src/schema/resolvers/groups.js @@ -86,6 +86,7 @@ export default { SET group.updatedAt = toString(datetime()) WITH group MATCH (owner:User {id: $userId}) + MERGE (owner)-[:CREATED]->(group) MERGE (owner)-[membership:MEMBER_OF]->(group) SET membership.createdAt = toString(datetime()) SET membership.updatedAt = toString(datetime()) From 9cae32e6e13ce27a01b53e2641c5a937361eb401 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Wed, 10 Aug 2022 08:41:01 +0200 Subject: [PATCH 22/29] Upgrade neode to v0.4.8 --- backend/package.json | 2 +- backend/yarn.lock | 83 +++++++++++++++++++++++++++----------------- 2 files changed, 52 insertions(+), 33 deletions(-) diff --git a/backend/package.json b/backend/package.json index 62188a650..9651cbb95 100644 --- a/backend/package.json +++ b/backend/package.json @@ -103,7 +103,7 @@ "mustache": "^4.2.0", "neo4j-driver": "^4.0.2", "neo4j-graphql-js": "^2.11.5", - "neode": "^0.4.7", + "neode": "^0.4.8", "node-fetch": "~2.6.1", "nodemailer": "^6.4.4", "nodemailer-html-to-text": "^3.2.0", diff --git a/backend/yarn.lock b/backend/yarn.lock index 24bd00b3a..8c69a0814 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -997,9 +997,9 @@ tslib "1.11.1" "@hapi/address@2.x.x": - version "2.1.2" - resolved "https://registry.yarnpkg.com/@hapi/address/-/address-2.1.2.tgz#1c794cd6dbf2354d1eb1ef10e0303f573e1c7222" - integrity sha512-O4QDrx+JoGKZc6aN64L04vqa7e41tIiLU+OvKdcYaEMP97UttL0f9GIi9/0A4WAMx0uBd6SidDIhktZhgOcN8Q== + version "2.1.4" + resolved "https://registry.yarnpkg.com/@hapi/address/-/address-2.1.4.tgz#5d67ed43f3fd41a69d4b9ff7b56e7c0d1d0a81e5" + integrity sha512-QD1PhQk+s31P1ixsX0H0Suoupp3VMXzIVMSwobR3F3MSUO2YCV0B7xqLcUw/Bh8yuvd3LhpyqLQWTNcRmp6IdQ== "@hapi/address@^4.0.1": version "4.0.1" @@ -1018,10 +1018,10 @@ resolved "https://registry.yarnpkg.com/@hapi/formula/-/formula-2.0.0.tgz#edade0619ed58c8e4f164f233cda70211e787128" integrity sha512-V87P8fv7PI0LH7LiVi8Lkf3x+KCO7pQozXRssAHNXXL9L1K+uyu4XypLXwxqVDKgyQai6qj3/KteNlrqDx4W5A== -"@hapi/hoek@8.x.x": - version "8.2.4" - resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-8.2.4.tgz#684a14f4ca35d46f44abc87dfc696e5e4fe8a020" - integrity sha512-Ze5SDNt325yZvNO7s5C4fXDscjJ6dcqLFXJQ/M7dZRQCewuDj2iDUuBi6jLQt+APbW9RjjVEvLr35FXuOEqjow== +"@hapi/hoek@8.x.x", "@hapi/hoek@^8.3.0": + version "8.5.1" + resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-8.5.1.tgz#fde96064ca446dec8c55a8c2f130957b070c6e06" + integrity sha512-yN7kbciD87WzLGc5539Tn0sApjyiGHAJgKvG9W8C7O+6c7qmoQMfVs0W4bX17eqz6C78QJqqFrtgdK5EWf6Qow== "@hapi/hoek@^9.0.0": version "9.0.0" @@ -1055,11 +1055,11 @@ integrity sha512-vzXR5MY7n4XeIvLpfl3HtE3coZYO4raKXW766R6DZw/6aLqR26iuZ109K7a0NtF2Db0jxqh7xz2AxkUwpUFybw== "@hapi/topo@3.x.x": - version "3.1.3" - resolved "https://registry.yarnpkg.com/@hapi/topo/-/topo-3.1.3.tgz#c7a02e0d936596d29f184e6d7fdc07e8b5efce11" - integrity sha512-JmS9/vQK6dcUYn7wc2YZTqzIKubAQcJKu2KCKAru6es482U5RT5fP1EXCPtlXpiK7PR0On/kpQKI4fRKkzpZBQ== + version "3.1.6" + resolved "https://registry.yarnpkg.com/@hapi/topo/-/topo-3.1.6.tgz#68d935fa3eae7fdd5ab0d7f953f3205d8b2bfc29" + integrity sha512-tAag0jEcjwH+P2quUfipd7liWCNX2F8NvYjQp2wtInsZxnMlypdw0FtAOLxtvvkO+GSRRbmNi8m/5y42PQJYCQ== dependencies: - "@hapi/hoek" "8.x.x" + "@hapi/hoek" "^8.3.0" "@hapi/topo@^5.0.0": version "5.0.0" @@ -2681,6 +2681,11 @@ base64-js@^1.0.2: resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1" integrity sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g== +base64-js@^1.3.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== + base@^0.11.1: version "0.11.2" resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f" @@ -2850,6 +2855,14 @@ buffer@4.9.1: ieee754 "^1.1.4" isarray "^1.0.0" +buffer@^6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" + integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.2.1" + busboy@^0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/busboy/-/busboy-0.3.1.tgz#170899274c5bf38aae27d5c62b71268cd585fd1b" @@ -3929,7 +3942,7 @@ dot-prop@^4.1.0: dotenv@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-4.0.0.tgz#864ef1379aced55ce6f95debecdce179f7a0cd1d" - integrity sha1-hk7xN5rO1Vzm+V3r7NzhefegzR0= + integrity sha512-XcaMACOr3JMVcEv0Y/iUM2XaOsATRZ3U1In41/1jjK6vJZ2PZbQ1bzCG8uvaByfaBpl9gqc9QWJovpUGBXLLYQ== dotenv@^6.1.0: version "6.2.0" @@ -5516,6 +5529,11 @@ ieee754@1.1.13, ieee754@^1.1.4: resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84" integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg== +ieee754@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" + integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== + ienoopen@1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/ienoopen/-/ienoopen-1.1.0.tgz#411e5d530c982287dbdc3bb31e7a9c9e32630974" @@ -7528,18 +7546,19 @@ negotiator@0.6.2: resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb" integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw== -neo4j-driver-bolt-connection@^4.3.4: - version "4.3.4" - resolved "https://registry.yarnpkg.com/neo4j-driver-bolt-connection/-/neo4j-driver-bolt-connection-4.3.4.tgz#de642bb6a62ffc6ae2e280dccf21395b4d1705a2" - integrity sha512-yxbvwGav+N7EYjcEAINqL6D3CZV+ee2qLInpAhx+iNurwbl3zqtBGiVP79SZ+7tU++y3Q1fW5ofikH06yc+LqQ== +neo4j-driver-bolt-connection@^4.4.7: + version "4.4.7" + resolved "https://registry.yarnpkg.com/neo4j-driver-bolt-connection/-/neo4j-driver-bolt-connection-4.4.7.tgz#0582d54de1f213e60c374209193d1f645ba523ea" + integrity sha512-6Q4hCtvWE6gzN64N09UqZqf/3rDl7FUWZZXiVQL0ZRbaMkJpZNC2NmrDIgGXYE05XEEbRBexf2tVv5OTYZYrow== dependencies: - neo4j-driver-core "^4.3.4" - text-encoding-utf-8 "^1.0.2" + buffer "^6.0.3" + neo4j-driver-core "^4.4.7" + string_decoder "^1.3.0" -neo4j-driver-core@^4.3.4: - version "4.3.4" - resolved "https://registry.yarnpkg.com/neo4j-driver-core/-/neo4j-driver-core-4.3.4.tgz#b445a4fbf94dce8441075099bd6ac3133c1cf5ee" - integrity sha512-3tn3j6IRUNlpXeehZ9Xv7dLTZPB4a7APaoJ+xhQyMmYQO3ujDM4RFHc0pZcG+GokmaltT5pUCIPTDYx6ODdhcA== +neo4j-driver-core@^4.4.7: + version "4.4.7" + resolved "https://registry.yarnpkg.com/neo4j-driver-core/-/neo4j-driver-core-4.4.7.tgz#d2475e107b3fea2b9d1c36b0c273da5c5a291c37" + integrity sha512-NhvVuQYgG7eO/vXxRaoJfkWUNkjvIpmCIS9UWU9Bbhb4V+wCOyX/MVOXqD0Yizhs4eyIkD7x90OXb79q+vi+oA== neo4j-driver@^4.0.1, neo4j-driver@^4.0.2: version "4.0.2" @@ -7552,13 +7571,13 @@ neo4j-driver@^4.0.1, neo4j-driver@^4.0.2: uri-js "^4.2.2" neo4j-driver@^4.2.2: - version "4.3.4" - resolved "https://registry.yarnpkg.com/neo4j-driver/-/neo4j-driver-4.3.4.tgz#a54f0562f868ee94dff7509df74e3eb2c1f95a85" - integrity sha512-AGrsFFqnoZv4KhJdmKt4mOBV5mnxmV3+/t8KJTOM68jQuEWoy+RlmAaRRaCSU4eY586OFN/R8lg9MrJpZdSFjw== + version "4.4.7" + resolved "https://registry.yarnpkg.com/neo4j-driver/-/neo4j-driver-4.4.7.tgz#51b3fb48241e66eb3be94e90032cc494c44e59f3" + integrity sha512-N7GddPhp12gVJe4eB84u5ik5SmrtRv8nH3rK47Qy7IUKnJkVEos/F1QjOJN6zt1jLnDXwDcGzCKK8XklYpzogw== dependencies: "@babel/runtime" "^7.5.5" - neo4j-driver-bolt-connection "^4.3.4" - neo4j-driver-core "^4.3.4" + neo4j-driver-bolt-connection "^4.4.7" + neo4j-driver-core "^4.4.7" rxjs "^6.6.3" neo4j-graphql-js@^2.11.5: @@ -7574,10 +7593,10 @@ neo4j-graphql-js@^2.11.5: lodash "^4.17.15" neo4j-driver "^4.0.1" -neode@^0.4.7: - version "0.4.7" - resolved "https://registry.yarnpkg.com/neode/-/neode-0.4.7.tgz#033007b57a2ee167e9ee5537493086db08d005eb" - integrity sha512-YXlc187JRpeKCBcUIkY6nimXXG+Tvlopfe71/FPno2THrwmYt5mm0RPHZ+mXF2O1Xg6zvjKvOpCpDz2vHBfroQ== +neode@^0.4.8: + version "0.4.8" + resolved "https://registry.yarnpkg.com/neode/-/neode-0.4.8.tgz#0889b4fc7f1bf0b470b01fa5b8870373b5d47ad6" + integrity sha512-pb91NfCOg4Fj5o+98H+S2XYC+ByQfbdhwcc1UVuzuUQ0Ezzj+jWz8NmKWU8ZfCH6l4plk71yDAPd2eTwpt+Xvg== dependencies: "@hapi/joi" "^15.1.1" dotenv "^4.0.0" @@ -9603,7 +9622,7 @@ string.prototype.trimstart@^1.0.1: define-properties "^1.1.3" es-abstract "^1.17.5" -string_decoder@^1.1.1: +string_decoder@^1.1.1, string_decoder@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== From 7682aa7e45a289df8018eba72442ff197f5234ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Wed, 10 Aug 2022 11:34:51 +0200 Subject: [PATCH 23/29] Fix description length for slugify tests --- backend/src/middleware/slugifyMiddleware.spec.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/src/middleware/slugifyMiddleware.spec.js b/backend/src/middleware/slugifyMiddleware.spec.js index 3c18e70b0..59fa72ba7 100644 --- a/backend/src/middleware/slugifyMiddleware.spec.js +++ b/backend/src/middleware/slugifyMiddleware.spec.js @@ -12,6 +12,8 @@ let variables const driver = getDriver() const neode = getNeode() +const descriptionAddition100 = + ' 123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789' beforeAll(async () => { await cleanDatabase() @@ -67,7 +69,7 @@ describe('slugifyMiddleware', () => { ...variables, name: 'The Best Group', about: 'Some about', - description: 'Some description', + description: 'Some description' + descriptionAddition100, groupType: 'closed', actionRadius: 'national', categoryIds, @@ -87,7 +89,7 @@ describe('slugifyMiddleware', () => { name: 'The Best Group', slug: 'the-best-group', about: 'Some about', - description: 'Some description', + description: 'Some description' + descriptionAddition100, groupType: 'closed', actionRadius: 'national', }, From 82401b1488dd6aee9282b6b9f810f480f25edf2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Wed, 10 Aug 2022 12:52:20 +0200 Subject: [PATCH 24/29] Update backend/src/schema/resolvers/groups.js Co-authored-by: Moriz Wahl --- backend/src/schema/resolvers/groups.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/src/schema/resolvers/groups.js b/backend/src/schema/resolvers/groups.js index e6a8c3a18..f6d482421 100644 --- a/backend/src/schema/resolvers/groups.js +++ b/backend/src/schema/resolvers/groups.js @@ -36,8 +36,7 @@ export default { const result = await txc.run(groupCypher, { userId: context.user.id, }) - const group = result.records.map((record) => record.get('group')) - return group + return result.records.map((record) => record.get('group')) }) try { const group = await readTxResultPromise From f150ea3d7ce24286127d2ddf8fa08ca977c5ded7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Wed, 10 Aug 2022 12:52:31 +0200 Subject: [PATCH 25/29] Update backend/src/schema/resolvers/groups.js Co-authored-by: Moriz Wahl --- backend/src/schema/resolvers/groups.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/src/schema/resolvers/groups.js b/backend/src/schema/resolvers/groups.js index f6d482421..dadbcd2a1 100644 --- a/backend/src/schema/resolvers/groups.js +++ b/backend/src/schema/resolvers/groups.js @@ -39,8 +39,7 @@ export default { return result.records.map((record) => record.get('group')) }) try { - const group = await readTxResultPromise - return group + return await readTxResultPromise } catch (error) { throw new Error(error) } finally { From 5e741ead8d3f96d5d4eacf88a7e7e3f70a361fff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Wed, 10 Aug 2022 13:15:43 +0200 Subject: [PATCH 26/29] Overtake Moriz suggestions --- backend/src/models/Group.js | 2 - backend/src/schema/resolvers/groups.js | 6 +- backend/src/schema/resolvers/groups.spec.js | 126 +++++++++--------- .../schema/types/enum/GroupActionRadius.gql | 3 +- 4 files changed, 71 insertions(+), 66 deletions(-) diff --git a/backend/src/models/Group.js b/backend/src/models/Group.js index 25149e9c3..a75ad518f 100644 --- a/backend/src/models/Group.js +++ b/backend/src/models/Group.js @@ -37,8 +37,6 @@ export default { locationName: { type: 'string', allow: [null] }, - wasSeeded: 'boolean', // Wolle: used or needed? - isIn: { type: 'relationship', relationship: 'IS_IN', diff --git a/backend/src/schema/resolvers/groups.js b/backend/src/schema/resolvers/groups.js index dadbcd2a1..d1af98513 100644 --- a/backend/src/schema/resolvers/groups.js +++ b/backend/src/schema/resolvers/groups.js @@ -52,17 +52,17 @@ export default { const { categoryIds } = params delete params.categoryIds if (!categoryIds || categoryIds.length < CATEGORIES_MIN) { - throw new UserInputError('To Less Categories!') + throw new UserInputError('Too view categories!') } if (categoryIds && categoryIds.length > CATEGORIES_MAX) { - throw new UserInputError('To Many Categories!') + throw new UserInputError('Too many categories!') } if ( params.description === undefined || params.description === null || removeHtmlTags(params.description).length < DESCRIPTION_WITHOUT_HTML_LENGTH_MIN ) { - throw new UserInputError('To Short Description!') + throw new UserInputError('Description too short!') } params.id = params.id || uuid() const session = context.driver.session() diff --git a/backend/src/schema/resolvers/groups.spec.js b/backend/src/schema/resolvers/groups.spec.js index bae530c61..5354f5ebe 100644 --- a/backend/src/schema/resolvers/groups.spec.js +++ b/backend/src/schema/resolvers/groups.spec.js @@ -120,7 +120,7 @@ describe('Group', () => { about: 'We will change nothing!', description: 'We love it like it is!?' + descriptionAddition100, groupType: 'closed', - actionRadius: 'international', + actionRadius: 'global', categoryIds, }, }) @@ -139,62 +139,68 @@ describe('Group', () => { }) }) - describe('can find', () => { - it('all', async () => { - const expected = { - data: { - Group: expect.arrayContaining([ - expect.objectContaining({ - id: 'my-group', - slug: 'the-best-group', - myRole: 'owner', - }), - expect.objectContaining({ - id: 'others-group', - slug: 'uninteresting-group', - myRole: null, - }), - ]), - }, - errors: undefined, - } - await expect(query({ query: groupQuery, variables: {} })).resolves.toMatchObject(expected) + describe('query groups', () => { + describe('without any filters', () => { + it('finds all groups', async () => { + const expected = { + data: { + Group: expect.arrayContaining([ + expect.objectContaining({ + id: 'my-group', + slug: 'the-best-group', + myRole: 'owner', + }), + expect.objectContaining({ + id: 'others-group', + slug: 'uninteresting-group', + myRole: null, + }), + ]), + }, + errors: undefined, + } + await expect(query({ query: groupQuery, variables: {} })).resolves.toMatchObject(expected) + }) }) - it('where user is member (or owner in this case)', async () => { - const expected = { - data: { - Group: [ - { - id: 'my-group', - slug: 'the-best-group', - myRole: 'owner', - }, - ], - }, - errors: undefined, - } - await expect( - query({ query: groupQuery, variables: { isMember: true } }), - ).resolves.toMatchObject(expected) + describe('isMember = true', () => { + it('finds only groups where user is member', async () => { + const expected = { + data: { + Group: [ + { + id: 'my-group', + slug: 'the-best-group', + myRole: 'owner', + }, + ], + }, + errors: undefined, + } + await expect( + query({ query: groupQuery, variables: { isMember: true } }), + ).resolves.toMatchObject(expected) + }) }) - it('where user is not(!) member', async () => { - const expected = { - data: { - Group: expect.arrayContaining([ - expect.objectContaining({ - id: 'others-group', - slug: 'uninteresting-group', - myRole: null, - }), - ]), - }, - errors: undefined, - } - await expect( - query({ query: groupQuery, variables: { isMember: false } }), - ).resolves.toMatchObject(expected) + describe('isMember = false', () => { + it('finds only groups where user is not(!) member', async () => { + const expected = { + data: { + Group: expect.arrayContaining([ + expect.objectContaining({ + id: 'others-group', + slug: 'uninteresting-group', + myRole: null, + }), + ]), + }, + errors: undefined, + } + await expect( + query({ query: groupQuery, variables: { isMember: false } }), + ).resolves.toMatchObject(expected) + }) }) }) }) @@ -258,7 +264,7 @@ describe('CreateGroup', () => { ) }) - it('"disabled" and "deleted" default to "false"', async () => { + it('has "disabled" and "deleted" default to "false"', async () => { const expected = { data: { CreateGroup: { disabled: false, deleted: false } } } await expect(mutate({ mutation: createGroupMutation, variables })).resolves.toMatchObject( expected, @@ -268,7 +274,7 @@ describe('CreateGroup', () => { describe('description', () => { describe('length without HTML', () => { describe('less then 100 chars', () => { - it('throws error: "To Less Categories!"', async () => { + it('throws error: "Too view categories!"', async () => { const { errors } = await mutate({ mutation: createGroupMutation, variables: { @@ -278,7 +284,7 @@ describe('CreateGroup', () => { '0123456789', }, }) - expect(errors[0]).toHaveProperty('message', 'To Short Description!') + expect(errors[0]).toHaveProperty('message', 'Description too short!') }) }) }) @@ -286,22 +292,22 @@ describe('CreateGroup', () => { describe('categories', () => { describe('not even one', () => { - it('throws error: "To Less Categories!"', async () => { + it('throws error: "Too view categories!"', async () => { const { errors } = await mutate({ mutation: createGroupMutation, variables: { ...variables, categoryIds: null }, }) - expect(errors[0]).toHaveProperty('message', 'To Less Categories!') + expect(errors[0]).toHaveProperty('message', 'Too view categories!') }) }) describe('four', () => { - it('throws error: "To Many Categories!"', async () => { + it('throws error: "Too many categories!"', async () => { const { errors } = await mutate({ mutation: createGroupMutation, variables: { ...variables, categoryIds: ['cat9', 'cat4', 'cat15', 'cat27'] }, }) - expect(errors[0]).toHaveProperty('message', 'To Many Categories!') + expect(errors[0]).toHaveProperty('message', 'Too many categories!') }) }) }) diff --git a/backend/src/schema/types/enum/GroupActionRadius.gql b/backend/src/schema/types/enum/GroupActionRadius.gql index afc421133..221ed7f87 100644 --- a/backend/src/schema/types/enum/GroupActionRadius.gql +++ b/backend/src/schema/types/enum/GroupActionRadius.gql @@ -2,5 +2,6 @@ enum GroupActionRadius { regional national continental - international + global + interplanetary } From b0d28f8649bba912a11263f206d6d368b579b648 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Wed, 10 Aug 2022 13:19:42 +0200 Subject: [PATCH 27/29] Rename 'descriptionAddition100' to 'descriptionAdditional100' --- backend/src/middleware/slugifyMiddleware.spec.js | 6 +++--- backend/src/schema/resolvers/groups.spec.js | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/src/middleware/slugifyMiddleware.spec.js b/backend/src/middleware/slugifyMiddleware.spec.js index 59fa72ba7..9605aada9 100644 --- a/backend/src/middleware/slugifyMiddleware.spec.js +++ b/backend/src/middleware/slugifyMiddleware.spec.js @@ -12,7 +12,7 @@ let variables const driver = getDriver() const neode = getNeode() -const descriptionAddition100 = +const descriptionAdditional100 = ' 123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789' beforeAll(async () => { @@ -69,7 +69,7 @@ describe('slugifyMiddleware', () => { ...variables, name: 'The Best Group', about: 'Some about', - description: 'Some description' + descriptionAddition100, + description: 'Some description' + descriptionAdditional100, groupType: 'closed', actionRadius: 'national', categoryIds, @@ -89,7 +89,7 @@ describe('slugifyMiddleware', () => { name: 'The Best Group', slug: 'the-best-group', about: 'Some about', - description: 'Some description' + descriptionAddition100, + description: 'Some description' + descriptionAdditional100, groupType: 'closed', actionRadius: 'national', }, diff --git a/backend/src/schema/resolvers/groups.spec.js b/backend/src/schema/resolvers/groups.spec.js index 5354f5ebe..b3327d44a 100644 --- a/backend/src/schema/resolvers/groups.spec.js +++ b/backend/src/schema/resolvers/groups.spec.js @@ -13,7 +13,7 @@ let authenticatedUser let user const categoryIds = ['cat9', 'cat4', 'cat15'] -const descriptionAddition100 = +const descriptionAdditional100 = ' 123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789' let variables = {} @@ -118,7 +118,7 @@ describe('Group', () => { id: 'others-group', name: 'Uninteresting Group', about: 'We will change nothing!', - description: 'We love it like it is!?' + descriptionAddition100, + description: 'We love it like it is!?' + descriptionAdditional100, groupType: 'closed', actionRadius: 'global', categoryIds, @@ -131,7 +131,7 @@ describe('Group', () => { id: 'my-group', name: 'The Best Group', about: 'We will change the world!', - description: 'Some description' + descriptionAddition100, + description: 'Some description' + descriptionAdditional100, groupType: 'public', actionRadius: 'regional', categoryIds, @@ -214,7 +214,7 @@ describe('CreateGroup', () => { name: 'The Best Group', slug: 'the-group', about: 'We will change the world!', - description: 'Some description' + descriptionAddition100, + description: 'Some description' + descriptionAdditional100, groupType: 'public', actionRadius: 'regional', categoryIds, From dd876c52fab328eabccb1379923f2dbf6ba9891d Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Mon, 15 Aug 2022 10:52:31 +0200 Subject: [PATCH 28/29] increas max-old-space-size for jest, handle some asyncs, test validation for caregories only if categories are active --- backend/package.json | 2 +- backend/src/middleware/excerptMiddleware.js | 15 ++++-------- .../src/middleware/slugifyMiddleware.spec.js | 24 +++++++++---------- backend/src/schema/resolvers/groups.js | 6 ++--- 4 files changed, 21 insertions(+), 26 deletions(-) diff --git a/backend/package.json b/backend/package.json index 9651cbb95..9aa7f539f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -15,7 +15,7 @@ "dev": "nodemon --exec babel-node src/ -e js,gql", "dev:debug": "nodemon --exec babel-node --inspect=0.0.0.0:9229 src/ -e js,gql", "lint": "eslint src --config .eslintrc.js", - "test": "cross-env NODE_ENV=test jest --forceExit --detectOpenHandles --runInBand --coverage", + "test": "cross-env NODE_ENV=test NODE_OPTIONS=--max-old-space-size=8192 jest --forceExit --detectOpenHandles --runInBand --coverage", "db:clean": "babel-node src/db/clean.js", "db:reset": "yarn run db:clean", "db:seed": "babel-node src/db/seed.js", diff --git a/backend/src/middleware/excerptMiddleware.js b/backend/src/middleware/excerptMiddleware.js index cfaf7f1b0..ca061609a 100644 --- a/backend/src/middleware/excerptMiddleware.js +++ b/backend/src/middleware/excerptMiddleware.js @@ -4,28 +4,23 @@ export default { Mutation: { CreateGroup: async (resolve, root, args, context, info) => { args.descriptionExcerpt = trunc(args.description, 120).html - const result = await resolve(root, args, context, info) - return result + return resolve(root, args, context, info) }, CreatePost: async (resolve, root, args, context, info) => { args.contentExcerpt = trunc(args.content, 120).html - const result = await resolve(root, args, context, info) - return result + return resolve(root, args, context, info) }, UpdatePost: async (resolve, root, args, context, info) => { args.contentExcerpt = trunc(args.content, 120).html - const result = await resolve(root, args, context, info) - return result + return resolve(root, args, context, info) }, CreateComment: async (resolve, root, args, context, info) => { args.contentExcerpt = trunc(args.content, 180).html - const result = await resolve(root, args, context, info) - return result + return resolve(root, args, context, info) }, UpdateComment: async (resolve, root, args, context, info) => { args.contentExcerpt = trunc(args.content, 180).html - const result = await resolve(root, args, context, info) - return result + return resolve(root, args, context, info) }, }, } diff --git a/backend/src/middleware/slugifyMiddleware.spec.js b/backend/src/middleware/slugifyMiddleware.spec.js index 9605aada9..3fea526ee 100644 --- a/backend/src/middleware/slugifyMiddleware.spec.js +++ b/backend/src/middleware/slugifyMiddleware.spec.js @@ -6,7 +6,6 @@ import { createGroupMutation } from '../db/graphql/groups' import { createPostMutation } from '../db/graphql/posts' import { signupVerificationMutation } from '../db/graphql/authentications' -let mutate let authenticatedUser let variables @@ -15,19 +14,20 @@ const neode = getNeode() const descriptionAdditional100 = ' 123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789' +const { server } = createServer({ + context: () => { + return { + driver, + neode, + user: authenticatedUser, + } + }, +}) + +const { mutate } = createTestClient(server) + beforeAll(async () => { await cleanDatabase() - - const { server } = createServer({ - context: () => { - return { - driver, - neode, - user: authenticatedUser, - } - }, - }) - mutate = createTestClient(server).mutate }) afterAll(async () => { diff --git a/backend/src/schema/resolvers/groups.js b/backend/src/schema/resolvers/groups.js index d1af98513..5737f5505 100644 --- a/backend/src/schema/resolvers/groups.js +++ b/backend/src/schema/resolvers/groups.js @@ -51,10 +51,10 @@ export default { CreateGroup: async (_parent, params, context, _resolveInfo) => { const { categoryIds } = params delete params.categoryIds - if (!categoryIds || categoryIds.length < CATEGORIES_MIN) { + if (CONFIG.CATEGORIES_ACTIVE && (!categoryIds || categoryIds.length < CATEGORIES_MIN)) { throw new UserInputError('Too view categories!') } - if (categoryIds && categoryIds.length > CATEGORIES_MAX) { + if (CONFIG.CATEGORIES_ACTIVE && categoryIds && categoryIds.length > CATEGORIES_MAX) { throw new UserInputError('Too many categories!') } if ( @@ -94,7 +94,7 @@ export default { `, { userId: context.user.id, categoryIds, params }, ) - const [group] = ownerCreateGroupTransactionResponse.records.map((record) => + const [group] = await ownerCreateGroupTransactionResponse.records.map((record) => record.get('group'), ) return group From beacad4a17b33ececa908b6fe655626f1487f949 Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Mon, 15 Aug 2022 11:07:45 +0200 Subject: [PATCH 29/29] set CONFIG in specs --- backend/src/schema/resolvers/groups.spec.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/src/schema/resolvers/groups.spec.js b/backend/src/schema/resolvers/groups.spec.js index b3327d44a..707558a06 100644 --- a/backend/src/schema/resolvers/groups.spec.js +++ b/backend/src/schema/resolvers/groups.spec.js @@ -3,6 +3,7 @@ import Factory, { cleanDatabase } from '../../db/factories' import { createGroupMutation, groupQuery } from '../../db/graphql/groups' import { getNeode, getDriver } from '../../db/neo4j' import createServer from '../../server' +import CONFIG from '../../config' const driver = getDriver() const neode = getNeode() @@ -291,6 +292,10 @@ describe('CreateGroup', () => { }) describe('categories', () => { + beforeEach(() => { + CONFIG.CATEGORIES_ACTIVE = true + }) + describe('not even one', () => { it('throws error: "Too view categories!"', async () => { const { errors } = await mutate({