From 2ac3a9f9e30f59fe9d040640b9bef36f91b4d1b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Mon, 25 Jul 2022 18:07:14 +0200 Subject: [PATCH 01/17] Update issue templates --- .github/ISSUE_TEMPLATE/bug_report.md | 6 ++++-- .github/ISSUE_TEMPLATE/devops_ticket.md | 6 ++++-- .github/ISSUE_TEMPLATE/epic.md | 7 +++++-- .github/ISSUE_TEMPLATE/feature_request.md | 6 ++++-- .github/ISSUE_TEMPLATE/question.md | 7 +++++-- .github/ISSUE_TEMPLATE/refactor_tickets.md | 7 ++++--- 6 files changed, 26 insertions(+), 13 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 595c9d584..1fe27f6c6 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,8 +1,10 @@ --- -name: 🐛 Bug Report +name: "\U0001F41B Bug Report" about: Create a report to help us to improve. +title: "\U0001F41B [Bug] XXX" labels: bug -title: 🐛 [Bug] +assignees: '' + --- ## :bug: Bug Report diff --git a/.github/ISSUE_TEMPLATE/devops_ticket.md b/.github/ISSUE_TEMPLATE/devops_ticket.md index 115664911..17533cd54 100644 --- a/.github/ISSUE_TEMPLATE/devops_ticket.md +++ b/.github/ISSUE_TEMPLATE/devops_ticket.md @@ -1,8 +1,10 @@ --- -name: 💥 DevOps Ticket +name: "\U0001F4A5 DevOps Ticket" about: Help us manage our deployed app. +title: "\U0001F4A5 [DevOps] XXX" labels: devops -title: 💥 [DevOps] +assignees: '' + --- ## 💥 DevOps Ticket diff --git a/.github/ISSUE_TEMPLATE/epic.md b/.github/ISSUE_TEMPLATE/epic.md index cf72cd673..57eca6dfe 100644 --- a/.github/ISSUE_TEMPLATE/epic.md +++ b/.github/ISSUE_TEMPLATE/epic.md @@ -1,9 +1,12 @@ --- -name: 🌟 Epic +name: "\U0001F31F Epic" about: Define a big development step. +title: "\U0001F31F [EPIC] XXX" labels: epic -title: 🌟 [EPIC] +assignees: '' + --- + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index beae80901..22cd5045e 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,8 +1,10 @@ --- -name: 🚀 Feature Request +name: "\U0001F680 Feature Request" about: Suggest an idea for this project. +title: "\U0001F680 [Feature] XXX" labels: feature -title: 🚀 [Feature] +assignees: '' + --- ## :rocket: Feature Request diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md index 40e6e381b..f2328dcc7 100644 --- a/.github/ISSUE_TEMPLATE/question.md +++ b/.github/ISSUE_TEMPLATE/question.md @@ -1,9 +1,12 @@ --- -name: 💬 Question +name: "\U0001F4AC Question" about: If you need help understanding ocelot.social. +title: "\U0001F4AC [Question] XXX" labels: question -title: 💬 [Question] +assignees: '' + --- + diff --git a/.github/ISSUE_TEMPLATE/refactor_tickets.md b/.github/ISSUE_TEMPLATE/refactor_tickets.md index d1841e35e..867c809ae 100644 --- a/.github/ISSUE_TEMPLATE/refactor_tickets.md +++ b/.github/ISSUE_TEMPLATE/refactor_tickets.md @@ -1,10 +1,11 @@ --- -name: 🔧 Refactor +name: "\U0001F527 Refactor" about: Help us improve our code by refactoring it. +title: "\U0001F527 [Refactor] XXX" labels: refactor -title: 🔧 [Refactor] +assignees: '' + --- ## 🔧 Refactor - 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 02/17] 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 03/17] 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 04/17] 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 05/17] 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 06/17] 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 07/17] 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 08/17] 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 09/17] 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 10/17] 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 11/17] 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 12/17] 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 13/17] 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 14/17] 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 15/17] 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 e758e1337d94e9925c52ab0a06ff28ad0f562c89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Wed, 3 Aug 2022 15:24:42 +0200 Subject: [PATCH 16/17] Release v1.1.0 - implement categories again --- CHANGELOG.md | 6 +++++- backend/package.json | 2 +- package.json | 2 +- webapp/maintenance/source/package.json | 2 +- webapp/package.json | 2 +- 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f8aa04ee..74f2c1dcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,12 @@ All notable changes to this project will be documented in this file. Dates are d Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). -#### [1.0.9](https://github.com/Ocelot-Social-Community/Ocelot-Social/compare/1.0.8...1.0.9) +#### [1.1.0](https://github.com/Ocelot-Social-Community/Ocelot-Social/compare/1.0.8...1.1.0) +- feat: Make Categories Optional [`#5102`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5102) +- Update issue templates [`#5101`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5101) +- chore: 🍰 Betters Automatic Deployment To `stage.ocelot.social` On Push To `master` Branch [`#5097`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5097) +- chore: 🍰 Release v1.0.9 [`#5095`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5095) - chore: 🍰 Automatic Deployment To `stage.ocelot.social` On Push To `master` Branch [`#5080`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5080) - change footer version-link [`#5091`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5091) - docs: 🍰 Add Neo4j Docu For Important Commands [`#5090`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5090) diff --git a/backend/package.json b/backend/package.json index 028b9295e..62188a650 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ocelot-social-backend", - "version": "1.0.9", + "version": "1.1.0", "description": "GraphQL Backend for ocelot.social", "repository": "https://github.com/Ocelot-Social-Community/Ocelot-Social", "author": "ocelot.social Community", diff --git a/package.json b/package.json index e23a5f5c7..7756ac8e6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ocelot-social", - "version": "1.0.9", + "version": "1.1.0", "description": "Free and open source software program code available to run social networks.", "author": "ocelot.social Community", "license": "MIT", diff --git a/webapp/maintenance/source/package.json b/webapp/maintenance/source/package.json index 3ee1a5b5c..79ced0031 100644 --- a/webapp/maintenance/source/package.json +++ b/webapp/maintenance/source/package.json @@ -1,6 +1,6 @@ { "name": "@ocelot-social/maintenance", - "version": "1.0.9", + "version": "1.1.0", "description": "Maintenance page for ocelot.social", "repository": "https://github.com/Ocelot-Social-Community/Ocelot-Social", "author": "ocelot.social Community", diff --git a/webapp/package.json b/webapp/package.json index 77455f49f..234149521 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -1,6 +1,6 @@ { "name": "ocelot-social-webapp", - "version": "1.0.9", + "version": "1.1.0", "description": "ocelot.social Frontend", "repository": "https://github.com/Ocelot-Social-Community/Ocelot-Social", "author": "ocelot.social Community", 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 17/17] 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