diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ac333604ec..2a2e73dc69 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,7 +4,9 @@ on: push: branches: - master - # - 5093-fix-automatic-deployment # for testing while developing + # - 5059-epic-groups # for testing while developing + # template branches in repo + # - template--separate-branch-auto-deployment--5059-epic-groups jobs: ############################################################################## @@ -303,16 +305,19 @@ jobs: # because this step 'kubectl -n default rollout status deployment/* --timeout=600s' does not work as expected # and we need the pods to be up again for cleaning and seeding the Neo4j database and the backend. # !!! this is not a perfect solution !!! - # deployments are regularely up again after 3 minutes and 10 seconds + # deployments are regularly up again after 3 minutes and 10 seconds - name: Sleep for 4 minutes, means 240 seconds run: sleep 240s shell: bash - - name: Verify deployment and wait for the pods of each deplyment to get ready for cleaning and seeding of the database + - name: Verify deployment and wait for the pods of each deployment to get ready for cleaning and seeding of the database run: | kubectl -n default rollout status deployment/ocelot-backend --timeout=600s kubectl -n default rollout status deployment/ocelot-neo4j --timeout=600s kubectl -n default rollout status deployment/ocelot-maintenance --timeout=600s kubectl -n default rollout status deployment/ocelot-webapp --timeout=600s + - name: Run migrations for Neo4j database via backend for staging + run: | + kubectl -n default exec -it $(kubectl -n default get pods | grep ocelot-backend | awk '{ print $1 }') -- /bin/sh -c "yarn prod:migrate up" - name: Reset and seed Neo4j database via backend for staging # db cleaning and seeding is only possible in production if env 'PRODUCTION_DB_CLEAN_ALLOW=true' is set in deployment run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9564aa2f7e..cc4d2deb93 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -202,6 +202,8 @@ jobs: run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps neo4j backend - name: backend | Initialize Database run: docker-compose exec -T backend yarn db:migrate init + - name: backend | Migrate Database Up + run: docker-compose exec -T backend yarn db:migrate up - name: backend | Unit test run: docker-compose exec -T backend yarn test ########################################################################## @@ -267,7 +269,7 @@ jobs: report_name: Coverage Webapp type: lcov result_path: ./coverage/lcov.info - min_coverage: 65 + min_coverage: 64 token: ${{ github.token }} ############################################################################## diff --git a/CHANGELOG.md b/CHANGELOG.md index 940cc77a8e..81762c003f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,18 +4,43 @@ 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.1.1](https://github.com/Ocelot-Social-Community/Ocelot-Social/compare/1.1.0...1.1.1) +#### [2.0.0](https://github.com/Ocelot-Social-Community/Ocelot-Social/compare/1.1.0...2.0.0) +- feat: 🍰 Search For Groups [`#5543`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5543) +- feat: 🍰 Mobile Footer Menu To Header Menu [`#5524`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5524) +- feat: 🍰 Implement `LOGO_HEADER_CLICK` As Configuration [`#5525`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5525) +- refactor: 🍰 Category Filter In Filter Menu [`#5527`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5527) +- feat: 🍰 Group Invitation [`#5512`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5512) +- feat: 🍰 Implement Post In Group In Webapp [`#5468`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5468) +- feat: :cake: Update Issue Templates [`#5508`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5508) +- refactor: 🍰 Disable Submit Button On group Update Changed Error [`#5489`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5489) +- feat: 🍰 Seed Posts In Groups [`#5503`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5503) +- feat: 🍰 Make Configurable Header Menu Translatable [`#5491`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5491) +- feat: 🍰 Post In Groups [`#5380`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5380) +- feat: 🍰 Refine Group Creation And Group Edit [`#5418`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5418) +- feat: 🍰 Implement Content Menu On Group Profile [`#5419`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5419) +- feat: 🍰 Group Members Management [`#5345`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5345) +- feat: 🍰 Have My Groups In The User Menu And Configure Groups Button In Header [`#5411`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5411) +- chore: 🍰 Implement Automatic Deployment For Groups Branch '5059-epic-groups' [`#5408`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5408) +- Chore: 🍰 Release v1.1.1 – Refactor Rebranding [`#5392`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5392) - chore: 🍰 Refactor Rebranding [`#5390`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5390) +- feat: 🍰 Group Profile Description Etc [`#5368`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5368) - feat: 🍰 Tooltips For Topics [`#5350`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5350) +- feat: 🍰 Group Profile Members List Etc [`#5335`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5335) +- feat: 🍰 Implement Group Profile – Visibility [`#5332`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5332) - feat: 🍰 Save Categories In Frontend [`#5284`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5284) - feat: 🍰 Add New Yunite Icons [`#5319`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5319) - chore: 🍰 Update Neode From v^0.4.7 To v^0.4.8 In Backend [`#5334`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5334) +- feat: 🍰 My Groups Page [`#5148`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5148) +- feat: 🍰 Hidden Groups Shall Not Be Visible For None Or Pending Members In Backend [`#5317`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5317) +- feat: 🍰 Implement Group Profile [`#5197`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5197) - fix: Category Filter Menu Client Only [`#5301`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5301) - feat: Save Category Settings [`#5261`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5261) +- feat: 🍰 Implement `UpdateGroup` Resolver [`#5224`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5224) - feat: Topics Menu [`#5248`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5248) - docs: 🍰 Document GraqhQL Playground [`#5253`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5253) - feat: Categories Filter Menu [`#5198`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5198) +- feat: 🍰 Implement `JoinGroup`, `GroupMember`, `SwitchGroupMemberRole` Resolvers [`#5199`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5199) - fix: 🍰 Fix Test Description From `enter-nonce.vue` To `change-password` [`#5217`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5217) - Bump cookie-universal-nuxt from 2.1.5 to 2.2.2 in /webapp [`#5218`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5218) - Bump prettier from 2.2.1 to 2.7.1 in /webapp [`#5170`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5170) @@ -27,10 +52,12 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - feat: 🍰 Change Error Message With `Authorised` To `Authorized` All Over The Place To Have American English [`#5206`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5206) - Bump cross-env from 7.0.2 to 7.0.3 in /webapp [`#5168`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5168) - chore: 🍰 Add `--logHeapUsage` To Jest Test Call [`#5182`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5182) +- chore: 🍰 Add Groups To Seeding [`#5185`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5185) +- feat: 🍰 Implement Group GQL Model And CRUD Resolvers – First Step [`#5139`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5139) - refactor: 🍰 Rename `UserGroup` To `UserRole` [`#5143`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5143) -- add new yunite icons [`bb0d632`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/bb0d6329e7e36ea03671318ea8dd128a6d5a5a7a) -- cleanup refactor rebranding [`5f5c0fa`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/5f5c0faa1f28cd4df7681eba335ae5998b2d9cca) -- change color and scss in branding [`52070b8`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/52070b8c570970bf48df561134bf67cb4111b640) +- Refine design and a bit functionality, first step [`13ec4ee`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/13ec4ee776728983e25d87078b804bdaaca66e38) +- add tets for posts in groups [`a9cd661`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/a9cd661e63181dc5ea207b2e1e656f720e0b10d0) +- Refine design and functionality of group list and create, edit group [`7b11122`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/7b11122bea4868624dd1c1641219e71070412e20) #### [1.1.0](https://github.com/Ocelot-Social-Community/Ocelot-Social/compare/1.0.9...1.1.0) diff --git a/backend/.env.template b/backend/.env.template index 239046dd32..dd46846a99 100644 --- a/backend/.env.template +++ b/backend/.env.template @@ -29,4 +29,4 @@ AWS_BUCKET= EMAIL_DEFAULT_SENDER="devops@ocelot.social" EMAIL_SUPPORT="devops@ocelot.social" -CATEGORIES_ACTIVE=false \ No newline at end of file +CATEGORIES_ACTIVE=false diff --git a/backend/README.md b/backend/README.md index 083606b09b..0af58af488 100644 --- a/backend/README.md +++ b/backend/README.md @@ -7,8 +7,9 @@ Run the following command to install everything through docker. The installation takes a bit longer on the first pass or on rebuild ... ```bash +# in main folder $ docker-compose up - +# or # rebuild the containers for a cleanup $ docker-compose up --build ``` @@ -28,6 +29,7 @@ between different local node versions. Install node dependencies with [yarn](https://yarnpkg.com/en/): ```bash +# in main folder $ cd backend $ yarn install ``` @@ -45,12 +47,14 @@ a [local Neo4J](http://localhost:7474) instance is up and running. Start the backend for development with: ```bash +# in backend/ $ yarn run dev ``` or start the backend in production environment with: ```bash +# in backend/ $ yarn run start ``` @@ -73,6 +77,7 @@ backend is running: {% tab title="Docker" %} ```bash +# in main folder while docker-compose is running $ docker-compose exec backend yarn run db:migrate init ``` @@ -80,7 +85,7 @@ $ docker-compose exec backend yarn run db:migrate init {% tab title="Without Docker" %} ```bash -# in folder backend/ +# in folder backend/ while database is running # make sure your database is running on http://localhost:7474/browser/ yarn run db:migrate init ``` @@ -99,12 +104,14 @@ need to seed your database: In another terminal run: ```bash +# in main folder while docker-compose is running $ docker-compose exec backend yarn run db:seed ``` To reset the database run: ```bash +# in main folder while docker-compose is running $ docker-compose exec backend yarn run db:reset # you could also wipe out your neo4j database and delete all volumes with: $ docker-compose down -v @@ -118,12 +125,14 @@ $ docker-compose exec backend yarn run db:migrate init Run: ```bash +# in backend/ while database is running $ yarn run db:seed ``` To reset the database run: ```bash +# in backend/ while database is running $ yarn run db:reset ``` @@ -141,6 +150,7 @@ you have to migrate your data e.g. because your data modeling has changed. Generate a data migration file: ```bash +# in main folder while docker-compose is running $ docker-compose exec backend yarn run db:migrate:create your_data_migration # Edit the file in ./src/db/migrations/ ``` @@ -148,6 +158,7 @@ $ docker-compose exec backend yarn run db:migrate:create your_data_migration To run the migration: ```bash +# in main folder while docker-compose is running $ docker-compose exec backend yarn run db:migrate up ``` @@ -157,6 +168,7 @@ $ docker-compose exec backend yarn run db:migrate up Generate a data migration file: ```bash +# in backend/ $ yarn run db:migrate:create your_data_migration # Edit the file in ./src/db/migrations/ ``` @@ -164,6 +176,7 @@ $ yarn run db:migrate:create your_data_migration To run the migration: ```bash +# in backend/ while database is running $ yarn run db:migrate up ``` @@ -181,6 +194,7 @@ database after each test, running the tests will wipe out all your data! Run the unit tests: ```bash +# in main folder while docker-compose is running $ docker-compose exec backend yarn run test ``` @@ -191,6 +205,7 @@ $ docker-compose exec backend yarn run test Run the unit tests: ```bash +# in backend/ while database is running $ yarn run test ``` diff --git a/backend/package.json b/backend/package.json index bcc91a25a4..fdf029c7bf 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ocelot-social-backend", - "version": "1.1.1", + "version": "2.0.0", "description": "GraphQL Backend for ocelot.social", "repository": "https://github.com/Ocelot-Social-Community/Ocelot-Social", "author": "ocelot.social Community", diff --git a/backend/src/constants/categories.js b/backend/src/constants/categories.js index 3676fed0b4..0d61a041aa 100644 --- a/backend/src/constants/categories.js +++ b/backend/src/constants/categories.js @@ -1,3 +1,7 @@ +// this file is duplicated in `backend/src/constants/metadata.js` and `webapp/constants/metadata.js` +export const CATEGORIES_MIN = 1 +export const CATEGORIES_MAX = 3 + export const categories = [ { icon: 'networking', diff --git a/backend/src/constants/groups.js b/backend/src/constants/groups.js new file mode 100644 index 0000000000..e9c941a897 --- /dev/null +++ b/backend/src/constants/groups.js @@ -0,0 +1,3 @@ +// this file is duplicated in `backend/src/constants/group.js` and `webapp/constants/group.js` +export const DESCRIPTION_WITHOUT_HTML_LENGTH_MIN = 100 // with removed HTML tags +export const DESCRIPTION_EXCERPT_HTML_LENGTH = 250 // with removed HTML tags diff --git a/backend/src/db/graphql/authentications.js b/backend/src/db/graphql/authentications.js new file mode 100644 index 0000000000..91605ec9fe --- /dev/null +++ b/backend/src/db/graphql/authentications.js @@ -0,0 +1,30 @@ +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 + ) { + id + slug + } + } +` + +// ------ queries + +// fill queries in here diff --git a/backend/src/db/graphql/comments.js b/backend/src/db/graphql/comments.js new file mode 100644 index 0000000000..b408c5e95f --- /dev/null +++ b/backend/src/db/graphql/comments.js @@ -0,0 +1,15 @@ +import gql from 'graphql-tag' + +// ------ mutations + +export const createCommentMutation = gql` + mutation ($id: ID, $postId: ID!, $content: String!) { + CreateComment(id: $id, postId: $postId, content: $content) { + id + } + } +` + +// ------ queries + +// fill queries in here diff --git a/backend/src/db/graphql/groups.js b/backend/src/db/graphql/groups.js new file mode 100644 index 0000000000..e388b2cd9e --- /dev/null +++ b/backend/src/db/graphql/groups.js @@ -0,0 +1,203 @@ +import gql from 'graphql-tag' + +// ------ mutations + +export const createGroupMutation = () => { + return gql` + mutation ( + $id: ID + $name: String! + $slug: String + $about: String + $description: String! + $groupType: GroupType! + $actionRadius: GroupActionRadius! + $categoryIds: [ID] + $locationName: String # empty string '' sets it to null + ) { + CreateGroup( + id: $id + name: $name + slug: $slug + about: $about + description: $description + groupType: $groupType + actionRadius: $actionRadius + categoryIds: $categoryIds + locationName: $locationName + ) { + id + name + slug + createdAt + updatedAt + disabled + deleted + about + description + descriptionExcerpt + groupType + actionRadius + categories { + id + slug + name + icon + } + locationName + location { + name + nameDE + nameEN + } + myRole + } + } + ` +} + +export const updateGroupMutation = () => { + return gql` + mutation ( + $id: ID! + $name: String + $slug: String + $about: String + $description: String + $actionRadius: GroupActionRadius + $categoryIds: [ID] + $avatar: ImageInput + $locationName: String # empty string '' sets it to null + ) { + UpdateGroup( + id: $id + name: $name + slug: $slug + about: $about + description: $description + actionRadius: $actionRadius + categoryIds: $categoryIds + avatar: $avatar + locationName: $locationName + ) { + id + name + slug + createdAt + updatedAt + disabled + deleted + about + description + descriptionExcerpt + groupType + actionRadius + categories { + id + slug + name + icon + } + # avatar # test this as result + locationName + location { + name + nameDE + nameEN + } + myRole + } + } + ` +} + +export const joinGroupMutation = () => { + return gql` + mutation ($groupId: ID!, $userId: ID!) { + JoinGroup(groupId: $groupId, userId: $userId) { + id + name + slug + myRoleInGroup + } + } + ` +} + +export const leaveGroupMutation = () => { + return gql` + mutation ($groupId: ID!, $userId: ID!) { + LeaveGroup(groupId: $groupId, userId: $userId) { + id + name + slug + myRoleInGroup + } + } + ` +} + +export const changeGroupMemberRoleMutation = () => { + return gql` + mutation ($groupId: ID!, $userId: ID!, $roleInGroup: GroupMemberRole!) { + ChangeGroupMemberRole(groupId: $groupId, userId: $userId, roleInGroup: $roleInGroup) { + id + name + slug + myRoleInGroup + } + } + ` +} + +// ------ queries + +export const groupQuery = () => { + return gql` + query ($isMember: Boolean, $id: ID, $slug: String) { + Group(isMember: $isMember, id: $id, slug: $slug) { + id + name + slug + createdAt + updatedAt + disabled + deleted + about + description + descriptionExcerpt + groupType + actionRadius + categories { + id + slug + name + icon + } + avatar { + url + } + locationName + location { + name + nameDE + nameEN + } + myRole + } + } + ` +} + +export const groupMembersQuery = () => { + return gql` + query ($id: ID!) { + GroupMembers(id: $id) { + id + name + slug + myRoleInGroup + } + } + ` +} diff --git a/backend/src/db/graphql/posts.js b/backend/src/db/graphql/posts.js new file mode 100644 index 0000000000..2669d6f242 --- /dev/null +++ b/backend/src/db/graphql/posts.js @@ -0,0 +1,88 @@ +import gql from 'graphql-tag' + +// ------ mutations + +export const createPostMutation = () => { + return gql` + mutation ( + $id: ID + $title: String! + $slug: String + $content: String! + $categoryIds: [ID] + $groupId: ID + ) { + CreatePost( + id: $id + title: $title + slug: $slug + content: $content + categoryIds: $categoryIds + groupId: $groupId + ) { + id + slug + title + content + } + } + ` +} + +// ------ queries + +export const postQuery = () => { + return gql` + query Post($id: ID!) { + Post(id: $id) { + id + title + content + } + } + ` +} + +export const filterPosts = () => { + return gql` + query Post($filter: _PostFilter, $first: Int, $offset: Int, $orderBy: [_PostOrdering]) { + Post(filter: $filter, first: $first, offset: $offset, orderBy: $orderBy) { + id + title + content + } + } + ` +} + +export const profilePagePosts = () => { + return gql` + query profilePagePosts( + $filter: _PostFilter + $first: Int + $offset: Int + $orderBy: [_PostOrdering] + ) { + profilePagePosts(filter: $filter, first: $first, offset: $offset, orderBy: $orderBy) { + id + title + content + } + } + ` +} + +export const searchPosts = () => { + return gql` + query ($query: String!, $firstPosts: Int, $postsOffset: Int) { + searchPosts(query: $query, firstPosts: $firstPosts, postsOffset: $postsOffset) { + postCount + posts { + id + title + content + } + } + } + ` +} diff --git a/backend/src/db/graphql/userManagement.js b/backend/src/db/graphql/userManagement.js new file mode 100644 index 0000000000..3cb8a05f84 --- /dev/null +++ b/backend/src/db/graphql/userManagement.js @@ -0,0 +1,13 @@ +import gql from 'graphql-tag' + +// ------ mutations + +export const loginMutation = gql` + mutation ($email: String!, $password: String!) { + login(email: $email, password: $password) + } +` + +// ------ queries + +// fill queries in here diff --git a/backend/src/db/migrate/store.js b/backend/src/db/migrate/store.js index d6fd25bd89..57a317b475 100644 --- a/backend/src/db/migrate/store.js +++ b/backend/src/db/migrate/store.js @@ -85,11 +85,11 @@ class Store { await createDefaultAdminUser(session) if (CONFIG.CATEGORIES_ACTIVE) await createCategories(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("post_fulltext_search",["Post"],["title", "content"])', 'CALL db.index.fulltext.createNodeIndex("user_fulltext_search",["User"],["name", "slug"])', + '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 0000000000..b87e5632a3 --- /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() + } +} diff --git a/backend/src/db/seed.js b/backend/src/db/seed.js index 482517e69f..dd8bb59cb7 100644 --- a/backend/src/db/seed.js +++ b/backend/src/db/seed.js @@ -5,7 +5,13 @@ import createServer from '../server' import faker from '@faker-js/faker' import Factory from '../db/factories' import { getNeode, getDriver } from '../db/neo4j' -import { gql } from '../helpers/jest' +import { + createGroupMutation, + joinGroupMutation, + changeGroupMemberRoleMutation, +} from './graphql/groups' +import { createPostMutation } from './graphql/posts' +import { createCommentMutation } from './graphql/comments' import { categories } from '../constants/categories' if (CONFIG.PRODUCTION && !CONFIG.PRODUCTION_DB_CLEAN_ALLOW) { @@ -294,6 +300,302 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] }), ]) + // Create Groups + + authenticatedUser = await peterLustig.toJson() + await Promise.all([ + mutate({ + mutation: createGroupMutation(), + variables: { + id: 'g0', + name: 'Investigative Journalism', + about: 'Investigative journalists share ideas and insights and can collaborate.', + description: `

English:

This group is hidden.

What is our group for?

This group was created to allow investigative journalists to share and collaborate.

How does it work?

Here you can internally share posts and comments about them.


Deutsch:

Diese Gruppe ist verborgen.

Wofür ist unsere Gruppe?

Diese Gruppe wurde geschaffen, um investigativen Journalisten den Austausch und die Zusammenarbeit zu ermöglichen.

Wie funktioniert das?

Hier könnt ihr euch intern über Beiträge und Kommentare zu ihnen austauschen.

`, + groupType: 'hidden', + actionRadius: 'global', + categoryIds: ['cat6', 'cat12', 'cat16'], + locationName: 'Hamburg, Germany', + }, + }), + ]) + await Promise.all([ + mutate({ + mutation: joinGroupMutation(), + variables: { + groupId: 'g0', + userId: 'u2', + }, + }), + mutate({ + mutation: joinGroupMutation(), + variables: { + groupId: 'g0', + userId: 'u4', + }, + }), + mutate({ + mutation: joinGroupMutation(), + variables: { + groupId: 'g0', + userId: 'u6', + }, + }), + ]) + await Promise.all([ + mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'g0', + userId: 'u2', + roleInGroup: 'usual', + }, + }), + mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'g0', + userId: 'u4', + roleInGroup: 'admin', + }, + }), + ]) + + // post into group + await Promise.all([ + mutate({ + mutation: createPostMutation(), + variables: { + id: 'p0-g0', + groupId: 'g0', + title: `What happend in Shanghai?`, + content: 'A sack of rise dropped in Shanghai. Should we further investigate?', + categoryIds: ['cat6'], + }, + }), + ]) + authenticatedUser = await bobDerBaumeister.toJson() + await Promise.all([ + mutate({ + mutation: createPostMutation(), + variables: { + id: 'p1-g0', + groupId: 'g0', + title: `The man on the moon`, + content: 'We have to further investigate about the stories of a man living on the moon.', + categoryIds: ['cat12', 'cat16'], + }, + }), + ]) + + authenticatedUser = await jennyRostock.toJson() + await Promise.all([ + mutate({ + mutation: createGroupMutation(), + variables: { + id: 'g1', + name: 'School For Citizens', + about: 'Our children shall receive education for life.', + description: `

English

Our goal

Only those who enjoy learning and do not lose their curiosity can obtain a good education for life and continue to learn with joy throughout their lives.

Curiosity

For this we need a school that takes up the curiosity of the children, the people, and satisfies it through a lot of experience.


Deutsch

Unser Ziel

Nur wer Spaß am Lernen hat und seine Neugier nicht verliert, kann gute Bildung für's Leben erlangen und sein ganzes Leben mit Freude weiter lernen.

Neugier

Dazu benötigen wir eine Schule, die die Neugier der Kinder, der Menschen, aufnimmt und durch viel Erfahrung befriedigt.

`, + groupType: 'closed', + actionRadius: 'national', + categoryIds: ['cat8', 'cat14'], + locationName: 'France', + }, + }), + ]) + await Promise.all([ + mutate({ + mutation: joinGroupMutation(), + variables: { + groupId: 'g1', + userId: 'u1', + }, + }), + mutate({ + mutation: joinGroupMutation(), + variables: { + groupId: 'g1', + userId: 'u2', + }, + }), + mutate({ + mutation: joinGroupMutation(), + variables: { + groupId: 'g1', + userId: 'u5', + }, + }), + mutate({ + mutation: joinGroupMutation(), + variables: { + groupId: 'g1', + userId: 'u6', + }, + }), + mutate({ + mutation: joinGroupMutation(), + variables: { + groupId: 'g1', + userId: 'u7', + }, + }), + ]) + await Promise.all([ + mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'g1', + userId: 'u1', + roleInGroup: 'usual', + }, + }), + mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'g1', + userId: 'u5', + roleInGroup: 'admin', + }, + }), + mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'g1', + userId: 'u6', + roleInGroup: 'owner', + }, + }), + ]) + // post into group + await Promise.all([ + mutate({ + mutation: createPostMutation(), + variables: { + id: 'p0-g1', + groupId: 'g1', + title: `Can we use ocelot for education?`, + content: 'I like the concept of this school. Can we use our software in this?', + categoryIds: ['cat8'], + }, + }), + ]) + authenticatedUser = await peterLustig.toJson() + await Promise.all([ + mutate({ + mutation: createPostMutation(), + variables: { + id: 'p1-g1', + groupId: 'g1', + title: `Can we push this idea out of France?`, + content: 'This idea is too inportant to have the scope only on France.', + categoryIds: ['cat14'], + }, + }), + ]) + + authenticatedUser = await bobDerBaumeister.toJson() + await Promise.all([ + mutate({ + mutation: createGroupMutation(), + variables: { + id: 'g2', + name: 'Yoga Practice', + about: 'We do yoga around the clock.', + description: `

What Is yoga?

Yoga is not just about practicing asanas. It's about how we do it.

And practicing asanas doesn't have to be yoga, it can be more athletic than yogic.

What makes practicing asanas yogic?

The important thing is:

`, + groupType: 'public', + actionRadius: 'interplanetary', + categoryIds: ['cat4', 'cat5', 'cat17'], + }, + }), + ]) + await Promise.all([ + mutate({ + mutation: joinGroupMutation(), + variables: { + groupId: 'g2', + userId: 'u3', + }, + }), + mutate({ + mutation: joinGroupMutation(), + variables: { + groupId: 'g2', + userId: 'u4', + }, + }), + mutate({ + mutation: joinGroupMutation(), + variables: { + groupId: 'g2', + userId: 'u5', + }, + }), + mutate({ + mutation: joinGroupMutation(), + variables: { + groupId: 'g2', + userId: 'u6', + }, + }), + mutate({ + mutation: joinGroupMutation(), + variables: { + groupId: 'g2', + userId: 'u7', + }, + }), + ]) + await Promise.all([ + mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'g2', + userId: 'u3', + roleInGroup: 'usual', + }, + }), + mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'g2', + userId: 'u4', + roleInGroup: 'pending', + }, + }), + mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'g2', + userId: 'u5', + roleInGroup: 'admin', + }, + }), + mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'g2', + userId: 'u6', + roleInGroup: 'usual', + }, + }), + ]) + + authenticatedUser = await louie.toJson() + await Promise.all([ + mutate({ + mutation: createPostMutation(), + variables: { + id: 'p0-g2', + groupId: 'g2', + title: `I am a Noob`, + content: 'I am new to Yoga and did not join this group so far.', + categoryIds: ['cat4'], + }, + }), + ]) + + // Create Posts + const [p0, p1, p3, p4, p5, p6, p9, p10, p11, p13, p14, p15] = await Promise.all([ Factory.build( 'post', @@ -471,17 +773,10 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] 'See #NaturphilosophieYoga, it can really help you!' const hashtagAndMention1 = 'The new physics of #QuantenFlussTheorie can explain #QuantumGravity! @peter-lustig got that already. ;-)' - const createPostMutation = gql` - mutation ($id: ID, $title: String!, $content: String!, $categoryIds: [ID]) { - CreatePost(id: $id, title: $title, content: $content, categoryIds: $categoryIds) { - id - } - } - ` await Promise.all([ mutate({ - mutation: createPostMutation, + mutation: createPostMutation(), variables: { id: 'p2', title: `Nature Philosophy Yoga`, @@ -490,7 +785,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] }, }), mutate({ - mutation: createPostMutation, + mutation: createPostMutation(), variables: { id: 'p7', title: 'This is post #7', @@ -499,7 +794,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] }, }), mutate({ - mutation: createPostMutation, + mutation: createPostMutation(), variables: { id: 'p8', image: faker.image.unsplash.nature(), @@ -509,7 +804,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] }, }), mutate({ - mutation: createPostMutation, + mutation: createPostMutation(), variables: { id: 'p12', title: 'This is post #12', @@ -528,13 +823,6 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] 'I heard @jenny-rostock has practiced it for 3 years now.' const mentionInComment2 = 'Did @peter-lustig tell you?' - const createCommentMutation = gql` - mutation ($id: ID, $postId: ID!, $content: String!) { - CreateComment(id: $id, postId: $postId, content: $content) { - id - } - } - ` await Promise.all([ mutate({ mutation: createCommentMutation, diff --git a/backend/src/helpers/jest.js b/backend/src/helpers/jest.js index 201d68c141..e3f6a3c84a 100644 --- a/backend/src/helpers/jest.js +++ b/backend/src/helpers/jest.js @@ -1,5 +1,18 @@ +// TODO: can be replaced with: (which is no a fake) +// import gql from 'graphql-tag' +// See issue: https://github.com/Ocelot-Social-Community/Ocelot-Social/issues/5152 + //* This is a fake ES2015 template string, just to benefit of syntax // highlighting of `gql` template strings in certain editors. export function gql(strings) { return strings.join('') } + +// sometime we have to wait to check a db state by having a look into the db in a certain moment +// or we wait a bit to check if we missed to set an await somewhere +// see: https://www.sitepoint.com/delay-sleep-pause-wait/ +export function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} +// usage – 4 seconds for example +// await sleep(4 * 1000) diff --git a/backend/src/middleware/excerptMiddleware.js b/backend/src/middleware/excerptMiddleware.js index 40a6a6ae40..68eea9a74b 100644 --- a/backend/src/middleware/excerptMiddleware.js +++ b/backend/src/middleware/excerptMiddleware.js @@ -1,26 +1,31 @@ import trunc from 'trunc-html' +import { DESCRIPTION_EXCERPT_HTML_LENGTH } from '../constants/groups' export default { Mutation: { + CreateGroup: async (resolve, root, args, context, info) => { + args.descriptionExcerpt = trunc(args.description, DESCRIPTION_EXCERPT_HTML_LENGTH).html + return resolve(root, args, context, info) + }, + UpdateGroup: async (resolve, root, args, context, info) => { + args.descriptionExcerpt = trunc(args.description, DESCRIPTION_EXCERPT_HTML_LENGTH).html + return resolve(root, args, context, info) + }, CreatePost: async (resolve, root, args, context, info) => { args.contentExcerpt = trunc(args.content, 120).html - const result = await resolve(root, args, context, info) - return result + return resolve(root, args, context, info) }, UpdatePost: async (resolve, root, args, context, info) => { args.contentExcerpt = trunc(args.content, 120).html - const result = await resolve(root, args, context, info) - return result + return resolve(root, args, context, info) }, CreateComment: async (resolve, root, args, context, info) => { args.contentExcerpt = trunc(args.content, 180).html - const result = await resolve(root, args, context, info) - return result + return resolve(root, args, context, info) }, UpdateComment: async (resolve, root, args, context, info) => { args.contentExcerpt = trunc(args.content, 180).html - const result = await resolve(root, args, context, info) - return result + return resolve(root, args, context, info) }, }, } diff --git a/backend/src/middleware/helpers/cleanHtml.js b/backend/src/middleware/helpers/cleanHtml.js index 72976b43cc..ac71f6bdce 100644 --- a/backend/src/middleware/helpers/cleanHtml.js +++ b/backend/src/middleware/helpers/cleanHtml.js @@ -1,6 +1,13 @@ import sanitizeHtml from 'sanitize-html' import linkifyHtml from 'linkifyjs/html' +export const removeHtmlTags = (input) => { + return sanitizeHtml(input, { + allowedTags: [], + allowedAttributes: {}, + }) +} + const standardSanitizeHtmlOptions = { allowedTags: [ 'img', diff --git a/backend/src/middleware/languages/languages.js b/backend/src/middleware/languages/languages.js index 3cf760f310..0872529753 100644 --- a/backend/src/middleware/languages/languages.js +++ b/backend/src/middleware/languages/languages.js @@ -1,12 +1,5 @@ import LanguageDetect from 'languagedetect' -import sanitizeHtml from 'sanitize-html' - -const removeHtmlTags = (input) => { - return sanitizeHtml(input, { - allowedTags: [], - allowedAttributes: {}, - }) -} +import { removeHtmlTags } from '../helpers/cleanHtml.js' const setPostLanguage = (text) => { const lngDetector = new LanguageDetect() diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index 71a44f225b..3d698810e7 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -1,4 +1,4 @@ -import { rule, shield, deny, allow, or } from 'graphql-shield' +import { rule, shield, deny, allow, or, and } from 'graphql-shield' import { getNeode } from '../db/neo4j' import CONFIG from '../config' import { validateInviteCode } from '../schema/resolvers/transactions/inviteCodes' @@ -52,6 +52,203 @@ const isMySocialMedia = rule({ return socialMedia.ownedBy.node.id === user.id }) +const isAllowedToChangeGroupSettings = rule({ + cache: 'no_cache', +})(async (_parent, args, { user, driver }) => { + if (!(user && user.id)) return false + const ownerId = user.id + const { id: groupId } = args + const session = driver.session() + const readTxPromise = session.readTransaction(async (transaction) => { + const transactionResponse = await transaction.run( + ` + MATCH (owner:User {id: $ownerId})-[membership:MEMBER_OF]->(group:Group {id: $groupId}) + RETURN group {.*}, owner {.*, myRoleInGroup: membership.role} + `, + { groupId, ownerId }, + ) + return { + owner: transactionResponse.records.map((record) => record.get('owner'))[0], + group: transactionResponse.records.map((record) => record.get('group'))[0], + } + }) + try { + const { owner, group } = await readTxPromise + return !!group && !!owner && ['owner'].includes(owner.myRoleInGroup) + } catch (error) { + throw new Error(error) + } finally { + session.close() + } +}) + +const isAllowedSeeingGroupMembers = rule({ + cache: 'no_cache', +})(async (_parent, args, { user, driver }) => { + if (!(user && user.id)) return false + const { id: groupId } = args + const session = driver.session() + const readTxPromise = session.readTransaction(async (transaction) => { + const transactionResponse = await transaction.run( + ` + MATCH (group:Group {id: $groupId}) + OPTIONAL MATCH (member:User {id: $userId})-[membership:MEMBER_OF]->(group) + RETURN group {.*}, member {.*, myRoleInGroup: membership.role} + `, + { groupId, userId: user.id }, + ) + return { + member: transactionResponse.records.map((record) => record.get('member'))[0], + group: transactionResponse.records.map((record) => record.get('group'))[0], + } + }) + try { + const { member, group } = await readTxPromise + return ( + !!group && + (group.groupType === 'public' || + (['closed', 'hidden'].includes(group.groupType) && + !!member && + ['usual', 'admin', 'owner'].includes(member.myRoleInGroup))) + ) + } catch (error) { + throw new Error(error) + } finally { + session.close() + } +}) + +const isAllowedToChangeGroupMemberRole = rule({ + cache: 'no_cache', +})(async (_parent, args, { user, driver }) => { + if (!(user && user.id)) return false + const adminId = user.id + const { groupId, userId, roleInGroup } = args + if (adminId === userId) return false + const session = driver.session() + const readTxPromise = session.readTransaction(async (transaction) => { + const transactionResponse = await transaction.run( + ` + MATCH (admin:User {id: $adminId})-[adminMembership:MEMBER_OF]->(group:Group {id: $groupId}) + OPTIONAL MATCH (group)<-[userMembership:MEMBER_OF]-(member:User {id: $userId}) + RETURN group {.*}, admin {.*, myRoleInGroup: adminMembership.role}, member {.*, myRoleInGroup: userMembership.role} + `, + { groupId, adminId, userId }, + ) + return { + admin: transactionResponse.records.map((record) => record.get('admin'))[0], + group: transactionResponse.records.map((record) => record.get('group'))[0], + member: transactionResponse.records.map((record) => record.get('member'))[0], + } + }) + try { + const { admin, group, member } = await readTxPromise + return ( + !!group && + !!admin && + (!member || + (!!member && + (member.myRoleInGroup === roleInGroup || !['owner'].includes(member.myRoleInGroup)))) && + ((['admin'].includes(admin.myRoleInGroup) && + ['pending', 'usual', 'admin'].includes(roleInGroup)) || + (['owner'].includes(admin.myRoleInGroup) && + ['pending', 'usual', 'admin', 'owner'].includes(roleInGroup))) + ) + } catch (error) { + throw new Error(error) + } finally { + session.close() + } +}) + +const isAllowedToJoinGroup = rule({ + cache: 'no_cache', +})(async (_parent, args, { user, driver }) => { + if (!(user && user.id)) return false + const { groupId, userId } = args + const session = driver.session() + const readTxPromise = session.readTransaction(async (transaction) => { + const transactionResponse = await transaction.run( + ` + MATCH (group:Group {id: $groupId}) + OPTIONAL MATCH (group)<-[membership:MEMBER_OF]-(member:User {id: $userId}) + RETURN group {.*}, member {.*, myRoleInGroup: membership.role} + `, + { groupId, userId }, + ) + return { + group: transactionResponse.records.map((record) => record.get('group'))[0], + member: transactionResponse.records.map((record) => record.get('member'))[0], + } + }) + try { + const { group, member } = await readTxPromise + return !!group && (group.groupType !== 'hidden' || (!!member && !!member.myRoleInGroup)) + } catch (error) { + throw new Error(error) + } finally { + session.close() + } +}) + +const isAllowedToLeaveGroup = rule({ + cache: 'no_cache', +})(async (_parent, args, { user, driver }) => { + if (!(user && user.id)) return false + const { groupId, userId } = args + if (user.id !== userId) return false + const session = driver.session() + const readTxPromise = session.readTransaction(async (transaction) => { + const transactionResponse = await transaction.run( + ` + MATCH (member:User {id: $userId})-[membership:MEMBER_OF]->(group:Group {id: $groupId}) + RETURN group {.*}, member {.*, myRoleInGroup: membership.role} + `, + { groupId, userId }, + ) + return { + group: transactionResponse.records.map((record) => record.get('group'))[0], + member: transactionResponse.records.map((record) => record.get('member'))[0], + } + }) + try { + const { group, member } = await readTxPromise + return !!group && !!member && !!member.myRoleInGroup && member.myRoleInGroup !== 'owner' + } catch (error) { + throw new Error(error) + } finally { + session.close() + } +}) + +const isMemberOfGroup = rule({ + cache: 'no_cache', +})(async (_parent, args, { user, driver }) => { + if (!(user && user.id)) return false + const { groupId } = args + if (!groupId) return true + const userId = user.id + const session = driver.session() + const readTxPromise = session.readTransaction(async (transaction) => { + const transactionResponse = await transaction.run( + ` + MATCH (User {id: $userId})-[membership:MEMBER_OF]->(Group {id: $groupId}) + RETURN membership.role AS role + `, + { groupId, userId }, + ) + return transactionResponse.records.map((record) => record.get('role'))[0] + }) + try { + const role = await readTxPromise + return ['usual', 'admin', 'owner'].includes(role) + } catch (error) { + throw new Error(error) + } finally { + session.close() + } +}) + const isAuthor = rule({ cache: 'no_cache', })(async (_parent, args, { user, driver }) => { @@ -78,7 +275,7 @@ const isAuthor = rule({ const isDeletingOwnAccount = rule({ cache: 'no_cache', -})(async (parent, args, context, info) => { +})(async (parent, args, context, _info) => { return context.user.id === args.id }) @@ -102,11 +299,10 @@ export default shield( { Query: { '*': deny, - findPosts: allow, - findUsers: allow, searchResults: allow, searchPosts: allow, searchUsers: allow, + searchGroups: allow, searchHashtags: allow, embed: allow, Category: allow, @@ -114,6 +310,8 @@ export default shield( reports: isModerator, statistics: allow, currentUser: allow, + Group: isAuthenticated, + GroupMembers: isAllowedSeeingGroupMembers, Post: allow, profilePagePosts: allow, Comment: allow, @@ -140,7 +338,12 @@ export default shield( Signup: or(publicRegistration, inviteRegistration, isAdmin), SignupVerification: allow, UpdateUser: onlyYourself, - CreatePost: isAuthenticated, + CreateGroup: isAuthenticated, + UpdateGroup: isAllowedToChangeGroupSettings, + JoinGroup: isAllowedToJoinGroup, + LeaveGroup: isAllowedToLeaveGroup, + ChangeGroupMemberRole: isAllowedToChangeGroupMemberRole, + CreatePost: and(isAuthenticated, isMemberOfGroup), UpdatePost: isAuthor, DeletePost: isAuthor, fileReport: isAuthenticated, diff --git a/backend/src/middleware/sluggifyMiddleware.js b/backend/src/middleware/sluggifyMiddleware.js index 165235be9d..5ef2944be3 100644 --- a/backend/src/middleware/sluggifyMiddleware.js +++ b/backend/src/middleware/sluggifyMiddleware.js @@ -26,11 +26,22 @@ 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.name, isUniqueFor(context, 'Group'))) + return resolve(root, args, context, info) + }, + UpdateGroup: async (resolve, root, args, context, info) => { + if (args.name) { + args.slug = args.slug || (await uniqueSlug(args.name, 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) }, UpdatePost: async (resolve, root, args, context, info) => { + // TODO: is this absolutely correct, see condition in 'UpdateGroup' above? may it works accidentally, because args.slug is always send? 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 7cfb89c193..41d58ece3d 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/middleware/slugifyMiddleware.spec.js b/backend/src/middleware/slugifyMiddleware.spec.js index 7c6f18ab19..0b022fb534 100644 --- a/backend/src/middleware/slugifyMiddleware.spec.js +++ b/backend/src/middleware/slugifyMiddleware.spec.js @@ -1,29 +1,34 @@ -import Factory, { cleanDatabase } from '../db/factories' -import { gql } from '../helpers/jest' import { getNeode, getDriver } from '../db/neo4j' import createServer from '../server' import { createTestClient } from 'apollo-server-testing' +import Factory, { cleanDatabase } from '../db/factories' +import { createGroupMutation, updateGroupMutation } from '../db/graphql/groups' +import { createPostMutation } from '../db/graphql/posts' +import { signupVerificationMutation } from '../db/graphql/authentications' -let mutate let authenticatedUser let variables +const categoryIds = ['cat9'] const driver = getDriver() const neode = getNeode() +const descriptionAdditional100 = + ' 123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789' + +const { server } = createServer({ + context: () => { + return { + driver, + neode, + user: authenticatedUser, + } + }, +}) + +const { mutate } = createTestClient(server) beforeAll(async () => { await cleanDatabase() - - const { server } = createServer({ - context: () => { - return { - driver, - neode, - user: authenticatedUser, - } - }, - }) - mutate = createTestClient(server).mutate }) afterAll(async () => { @@ -46,6 +51,7 @@ beforeEach(async () => { await Factory.build('category', { id: 'cat9', name: 'Democracy & Politics', + slug: 'democracy-politics', icon: 'university', }) authenticatedUser = await admin.toJson() @@ -57,16 +63,296 @@ afterEach(async () => { }) describe('slugifyMiddleware', () => { - 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 - } + describe('CreateGroup', () => { + beforeEach(() => { + variables = { + ...variables, + name: 'The Best Group', + about: 'Some about', + description: 'Some description' + descriptionAdditional100, + groupType: 'closed', + actionRadius: 'national', + categoryIds, } - ` + }) + + describe('if slug not exists', () => { + it('generates a slug based on name', async () => { + await expect( + mutate({ + mutation: createGroupMutation(), + variables, + }), + ).resolves.toMatchObject({ + data: { + CreateGroup: { + name: 'The Best Group', + slug: 'the-best-group', + about: 'Some about', + description: 'Some description' + descriptionAdditional100, + groupType: 'closed', + actionRadius: 'national', + }, + }, + errors: undefined, + }) + }) + 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', + }, + }, + errors: undefined, + }) + }) + }) + + 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 () => { + await expect( + mutate({ + mutation: createGroupMutation(), + variables: { + ...variables, + name: 'Pre-Existing Group', + about: 'As an about', + }, + }), + ).resolves.toMatchObject({ + data: { + CreateGroup: { + slug: 'pre-existing-group-1', + }, + }, + errors: undefined, + }) + }) + + describe('but if the client specifies a slug', () => { + it('rejects CreateGroup', async (done) => { + try { + await expect( + mutate({ + mutation: createGroupMutation(), + variables: { + ...variables, + name: 'Pre-Existing Group', + about: 'As an about', + slug: 'pre-existing-group', + }, + }), + ).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('UpdateGroup', () => { + let createGroupResult + + beforeEach(async () => { + createGroupResult = await mutate({ + mutation: createGroupMutation(), + variables: { + name: 'The Best Group', + slug: 'the-best-group', + about: 'Some about', + description: 'Some description' + descriptionAdditional100, + groupType: 'closed', + actionRadius: 'national', + categoryIds, + }, + }) + }) + + describe('if group exists', () => { + describe('if new slug not(!) exists', () => { + describe('setting slug by group name', () => { + it('has the new slug', async () => { + await expect( + mutate({ + mutation: updateGroupMutation(), + variables: { + id: createGroupResult.data.CreateGroup.id, + name: 'My Best Group', + }, + }), + ).resolves.toMatchObject({ + data: { + UpdateGroup: { + name: 'My Best Group', + slug: 'my-best-group', + about: 'Some about', + description: 'Some description' + descriptionAdditional100, + groupType: 'closed', + actionRadius: 'national', + myRole: 'owner', + }, + }, + errors: undefined, + }) + }) + }) + + describe('setting slug explicitly', () => { + it('has the new slug', async () => { + await expect( + mutate({ + mutation: updateGroupMutation(), + variables: { + id: createGroupResult.data.CreateGroup.id, + slug: 'my-best-group', + }, + }), + ).resolves.toMatchObject({ + data: { + UpdateGroup: { + name: 'The Best Group', + slug: 'my-best-group', + about: 'Some about', + description: 'Some description' + descriptionAdditional100, + groupType: 'closed', + actionRadius: 'national', + myRole: 'owner', + }, + }, + errors: undefined, + }) + }) + }) + }) + + describe('if new slug exists in another group', () => { + beforeEach(async () => { + await mutate({ + mutation: createGroupMutation(), + variables: { + name: 'Pre-Existing Group', + slug: 'pre-existing-group', + about: 'Some about', + description: 'Some description' + descriptionAdditional100, + groupType: 'closed', + actionRadius: 'national', + categoryIds, + }, + }) + }) + + describe('setting slug by group name', () => { + it('has unique slug "*-1"', async () => { + await expect( + mutate({ + mutation: updateGroupMutation(), + variables: { + id: createGroupResult.data.CreateGroup.id, + name: 'Pre-Existing Group', + }, + }), + ).resolves.toMatchObject({ + data: { + UpdateGroup: { + name: 'Pre-Existing Group', + slug: 'pre-existing-group-1', + about: 'Some about', + description: 'Some description' + descriptionAdditional100, + groupType: 'closed', + actionRadius: 'national', + myRole: 'owner', + }, + }, + errors: undefined, + }) + }) + }) + + describe('setting slug explicitly', () => { + it('rejects UpdateGroup', async (done) => { + try { + await expect( + mutate({ + mutation: updateGroupMutation(), + variables: { + id: createGroupResult.data.CreateGroup.id, + slug: 'pre-existing-group', + }, + }), + ).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', () => { beforeEach(() => { variables = { ...variables, @@ -76,18 +362,40 @@ 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', + }, }, - }, + errors: undefined, + }) + }) + + 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', + }, + }, + errors: undefined, + }) }) }) @@ -107,16 +415,15 @@ describe('slugifyMiddleware', () => { }) it('chooses another slug', async () => { - variables = { - ...variables, - title: 'Pre-existing post', - content: 'Some content', - categoryIds, - } await expect( mutate({ - mutation: createPostMutation, - variables, + mutation: createPostMutation(), + variables: { + ...variables, + title: 'Pre-existing post', + content: 'Some content', + categoryIds, + }, }), ).resolves.toMatchObject({ data: { @@ -124,21 +431,24 @@ describe('slugifyMiddleware', () => { slug: 'pre-existing-post-1', }, }, + errors: undefined, }) }) describe('but if the client specifies a slug', () => { it('rejects CreatePost', async (done) => { - variables = { - ...variables, - title: 'Pre-existing post', - content: 'Some content', - slug: 'pre-existing-post', - categoryIds, - } try { await expect( - mutate({ mutation: createPostMutation, variables }), + mutate({ + mutation: createPostMutation(), + variables: { + ...variables, + title: 'Pre-existing post', + content: 'Some content', + slug: 'pre-existing-post', + categoryIds, + }, + }), ).resolves.toMatchObject({ errors: [ { @@ -160,7 +470,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 `) } }) @@ -168,29 +478,9 @@ 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 - } - } - ` + it.todo('UpdatePost') + describe('SignupVerification', () => { beforeEach(() => { variables = { ...variables, @@ -211,18 +501,40 @@ 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', + }, }, - }, + errors: undefined, + }) + }) + + 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', + }, + }, + errors: undefined, + }) }) }) @@ -237,7 +549,7 @@ describe('slugifyMiddleware', () => { it('chooses another slug', async () => { await expect( mutate({ - mutation, + mutation: signupVerificationMutation, variables, }), ).resolves.toMatchObject({ @@ -246,6 +558,7 @@ describe('slugifyMiddleware', () => { slug: 'i-am-a-user-1', }, }, + errors: undefined, }) }) @@ -260,7 +573,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/middleware/userInteractions.js b/backend/src/middleware/userInteractions.js index 553aefe785..62e8e47f71 100644 --- a/backend/src/middleware/userInteractions.js +++ b/backend/src/middleware/userInteractions.js @@ -31,7 +31,7 @@ const setPostCounter = async (postId, relation, context) => { } const userClickedPost = async (resolve, root, args, context, info) => { - if (args.id) { + if (args.id && context.user) { await setPostCounter(args.id, 'CLICKED', context) } return resolve(root, args, context, info) diff --git a/backend/src/models/Group.js b/backend/src/models/Group.js new file mode 100644 index 0000000000..a75ad518f9 --- /dev/null +++ b/backend/src/models/Group.js @@ -0,0 +1,46 @@ +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 }, + + 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', + }, + + 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' }, + + myRole: { type: 'string', default: 'pending' }, + + locationName: { type: 'string', allow: [null] }, + + isIn: { + type: 'relationship', + relationship: 'IS_IN', + target: 'Location', + direction: 'out', + }, +} diff --git a/backend/src/models/User.spec.js b/backend/src/models/User.spec.js index 102acde6ab..c64d1fd374 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/models/index.js b/backend/src/models/index.js index 8d6a021ab0..d476e5f9b1 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/index.js b/backend/src/schema/index.js index 612487147d..06e150c862 100644 --- a/backend/src/schema/index.js +++ b/backend/src/schema/index.js @@ -20,6 +20,7 @@ export default makeAugmentedSchema({ 'FILED', 'REVIEWED', 'Report', + 'Group', ], }, mutation: false, diff --git a/backend/src/schema/resolvers/groups.js b/backend/src/schema/resolvers/groups.js new file mode 100644 index 0000000000..5e22bd7437 --- /dev/null +++ b/backend/src/schema/resolvers/groups.js @@ -0,0 +1,347 @@ +import { v4 as uuid } from 'uuid' +import { UserInputError } from 'apollo-server' +import CONFIG from '../../config' +import { CATEGORIES_MIN, CATEGORIES_MAX } from '../../constants/categories' +import { DESCRIPTION_WITHOUT_HTML_LENGTH_MIN } from '../../constants/groups' +import { removeHtmlTags } from '../../middleware/helpers/cleanHtml.js' +import Resolver, { + removeUndefinedNullValuesFromObject, + convertObjectToCypherMapLiteral, +} from './helpers/Resolver' +import { mergeImage } from './images/images' +import { createOrUpdateLocations } from './users/location' + +export default { + Query: { + Group: async (_object, params, context, _resolveInfo) => { + const { isMember, id, slug } = params + const matchParams = { id, slug } + removeUndefinedNullValuesFromObject(matchParams) + const session = context.driver.session() + const readTxResultPromise = session.readTransaction(async (txc) => { + const groupMatchParamsCypher = convertObjectToCypherMapLiteral(matchParams, true) + let groupCypher + if (isMember === true) { + groupCypher = ` + MATCH (:User {id: $userId})-[membership:MEMBER_OF]->(group:Group${groupMatchParamsCypher}) + WITH group, membership + WHERE (group.groupType IN ['public', 'closed']) OR (group.groupType = 'hidden' AND membership.role IN ['usual', 'admin', 'owner']) + RETURN group {.*, myRole: membership.role} + ` + } else { + if (isMember === false) { + groupCypher = ` + MATCH (group:Group${groupMatchParamsCypher}) + WHERE (NOT (:User {id: $userId})-[:MEMBER_OF]->(group)) + WITH group + WHERE group.groupType IN ['public', 'closed'] + RETURN group {.*, myRole: NULL} + ` + } else { + groupCypher = ` + MATCH (group:Group${groupMatchParamsCypher}) + OPTIONAL MATCH (:User {id: $userId})-[membership:MEMBER_OF]->(group) + WITH group, membership + WHERE (group.groupType IN ['public', 'closed']) OR (group.groupType = 'hidden' AND membership.role IN ['usual', 'admin', 'owner']) + RETURN group {.*, myRole: membership.role} + ` + } + } + const transactionResponse = await txc.run(groupCypher, { + userId: context.user.id, + }) + return transactionResponse.records.map((record) => record.get('group')) + }) + try { + return await readTxResultPromise + } catch (error) { + throw new Error(error) + } finally { + session.close() + } + }, + GroupMembers: async (_object, params, context, _resolveInfo) => { + const { id: groupId } = params + const session = context.driver.session() + const readTxResultPromise = session.readTransaction(async (txc) => { + const groupMemberCypher = ` + MATCH (user:User)-[membership:MEMBER_OF]->(:Group {id: $groupId}) + RETURN user {.*, myRoleInGroup: membership.role} + ` + const transactionResponse = await txc.run(groupMemberCypher, { + groupId, + }) + return transactionResponse.records.map((record) => record.get('user')) + }) + try { + return await readTxResultPromise + } catch (error) { + throw new Error(error) + } finally { + session.close() + } + }, + }, + Mutation: { + CreateGroup: async (_parent, params, context, _resolveInfo) => { + const { categoryIds } = params + delete params.categoryIds + params.locationName = params.locationName === '' ? null : params.locationName + if (CONFIG.CATEGORIES_ACTIVE && (!categoryIds || categoryIds.length < CATEGORIES_MIN)) { + throw new UserInputError('Too view categories!') + } + if (CONFIG.CATEGORIES_ACTIVE && categoryIds && categoryIds.length > CATEGORIES_MAX) { + throw new UserInputError('Too many categories!') + } + if ( + params.description === undefined || + params.description === null || + removeHtmlTags(params.description).length < DESCRIPTION_WITHOUT_HTML_LENGTH_MIN + ) { + throw new UserInputError('Description too short!') + } + params.id = params.id || uuid() + const session = context.driver.session() + const writeTxResultPromise = session.writeTransaction(async (transaction) => { + const categoriesCypher = + CONFIG.CATEGORIES_ACTIVE && categoryIds + ? ` + WITH group, membership + 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 (owner)-[:CREATED]->(group) + MERGE (owner)-[membership:MEMBER_OF]->(group) + SET + membership.createdAt = toString(datetime()), + membership.updatedAt = null, + membership.role = 'owner' + ${categoriesCypher} + RETURN group {.*, myRole: membership.role} + `, + { userId: context.user.id, categoryIds, params }, + ) + const [group] = await ownerCreateGroupTransactionResponse.records.map((record) => + record.get('group'), + ) + return group + }) + try { + const group = await writeTxResultPromise + // TODO: put in a middleware, see "UpdateGroup", "UpdateUser" + await createOrUpdateLocations('Group', params.id, params.locationName, session) + return group + } catch (error) { + if (error.code === 'Neo.ClientError.Schema.ConstraintValidationFailed') + throw new UserInputError('Group with this slug already exists!') + throw new Error(error) + } finally { + session.close() + } + }, + UpdateGroup: async (_parent, params, context, _resolveInfo) => { + const { categoryIds } = params + delete params.categoryIds + const { id: groupId, avatar: avatarInput } = params + delete params.avatar + params.locationName = params.locationName === '' ? null : params.locationName + + if (CONFIG.CATEGORIES_ACTIVE && categoryIds) { + if (categoryIds.length < CATEGORIES_MIN) { + throw new UserInputError('Too view categories!') + } + if (categoryIds.length > CATEGORIES_MAX) { + throw new UserInputError('Too many categories!') + } + } + if ( + params.description && + removeHtmlTags(params.description).length < DESCRIPTION_WITHOUT_HTML_LENGTH_MIN + ) { + throw new UserInputError('Description too short!') + } + const session = context.driver.session() + if (CONFIG.CATEGORIES_ACTIVE && categoryIds && categoryIds.length) { + const cypherDeletePreviousRelations = ` + MATCH (group:Group {id: $groupId})-[previousRelations:CATEGORIZED]->(category:Category) + DELETE previousRelations + RETURN group, category + ` + await session.writeTransaction((transaction) => { + return transaction.run(cypherDeletePreviousRelations, { groupId }) + }) + } + const writeTxResultPromise = session.writeTransaction(async (transaction) => { + let updateGroupCypher = ` + MATCH (group:Group {id: $groupId}) + SET group += $params + SET group.updatedAt = toString(datetime()) + WITH group + ` + if (CONFIG.CATEGORIES_ACTIVE && categoryIds && categoryIds.length) { + updateGroupCypher += ` + UNWIND $categoryIds AS categoryId + MATCH (category:Category {id: categoryId}) + MERGE (group)-[:CATEGORIZED]->(category) + WITH group + ` + } + updateGroupCypher += ` + OPTIONAL MATCH (:User {id: $userId})-[membership:MEMBER_OF]->(group) + RETURN group {.*, myRole: membership.role} + ` + const transactionResponse = await transaction.run(updateGroupCypher, { + groupId, + userId: context.user.id, + categoryIds, + params, + }) + const [group] = await transactionResponse.records.map((record) => record.get('group')) + if (avatarInput) { + await mergeImage(group, 'AVATAR_IMAGE', avatarInput, { transaction }) + } + return group + }) + try { + const group = await writeTxResultPromise + // TODO: put in a middleware, see "CreateGroup", "UpdateUser" + await createOrUpdateLocations('Group', params.id, params.locationName, session) + return group + } catch (error) { + if (error.code === 'Neo.ClientError.Schema.ConstraintValidationFailed') + throw new UserInputError('Group with this slug already exists!') + throw new Error(error) + } finally { + session.close() + } + }, + JoinGroup: async (_parent, params, context, _resolveInfo) => { + const { groupId, userId } = params + const session = context.driver.session() + const writeTxResultPromise = session.writeTransaction(async (transaction) => { + const joinGroupCypher = ` + MATCH (member:User {id: $userId}), (group:Group {id: $groupId}) + MERGE (member)-[membership:MEMBER_OF]->(group) + ON CREATE SET + membership.createdAt = toString(datetime()), + membership.updatedAt = null, + membership.role = + CASE WHEN group.groupType = 'public' + THEN 'usual' + ELSE 'pending' + END + RETURN member {.*, myRoleInGroup: membership.role} + ` + const transactionResponse = await transaction.run(joinGroupCypher, { groupId, userId }) + const [member] = await transactionResponse.records.map((record) => record.get('member')) + return member + }) + try { + return await writeTxResultPromise + } catch (error) { + throw new Error(error) + } finally { + session.close() + } + }, + LeaveGroup: async (_parent, params, context, _resolveInfo) => { + const { groupId, userId } = params + const session = context.driver.session() + const writeTxResultPromise = session.writeTransaction(async (transaction) => { + const leaveGroupCypher = ` + MATCH (member:User {id: $userId})-[membership:MEMBER_OF]->(group:Group {id: $groupId}) + DELETE membership + WITH member, group + OPTIONAL MATCH (p:Post)-[:IN]->(group) + WHERE NOT group.groupType = 'public' + WITH member, group, collect(p) AS posts + FOREACH (post IN posts | + MERGE (member)-[:CANNOT_SEE]->(post)) + RETURN member {.*, myRoleInGroup: NULL} + ` + + const transactionResponse = await transaction.run(leaveGroupCypher, { groupId, userId }) + const [member] = await transactionResponse.records.map((record) => record.get('member')) + return member + }) + try { + return await writeTxResultPromise + } catch (error) { + throw new Error(error) + } finally { + session.close() + } + }, + ChangeGroupMemberRole: async (_parent, params, context, _resolveInfo) => { + const { groupId, userId, roleInGroup } = params + const session = context.driver.session() + const writeTxResultPromise = session.writeTransaction(async (transaction) => { + let postRestrictionCypher = '' + if (['usual', 'admin', 'owner'].includes(roleInGroup)) { + postRestrictionCypher = ` + WITH group, member, membership + FOREACH (restriction IN [(member)-[r:CANNOT_SEE]->(:Post)-[:IN]->(group) | r] | + DELETE restriction)` + } else { + postRestrictionCypher = ` + WITH group, member, membership + FOREACH (post IN [(p:Post)-[:IN]->(group) | p] | + MERGE (member)-[:CANNOT_SEE]->(post))` + } + + const joinGroupCypher = ` + MATCH (member:User {id: $userId}) + MATCH (group:Group {id: $groupId}) + MERGE (member)-[membership:MEMBER_OF]->(group) + ON CREATE SET + membership.createdAt = toString(datetime()), + membership.updatedAt = null, + membership.role = $roleInGroup + ON MATCH SET + membership.updatedAt = toString(datetime()), + membership.role = $roleInGroup + ${postRestrictionCypher} + RETURN member {.*, myRoleInGroup: membership.role} + ` + + const transactionResponse = await transaction.run(joinGroupCypher, { + groupId, + userId, + roleInGroup, + }) + const [member] = await transactionResponse.records.map((record) => record.get('member')) + return member + }) + try { + return await writeTxResultPromise + } catch (error) { + throw new Error(error) + } finally { + session.close() + } + }, + }, + Group: { + ...Resolver('Group', { + undefinedToNull: ['deleted', 'disabled', 'locationName', 'about'], + hasMany: { + categories: '-[:CATEGORIZED]->(related:Category)', + posts: '<-[:IN]-(related:Post)', + }, + hasOne: { + avatar: '-[:AVATAR_IMAGE]->(related:Image)', + location: '-[:IS_IN]->(related:Location)', + }, + }), + }, +} diff --git a/backend/src/schema/resolvers/groups.spec.js b/backend/src/schema/resolvers/groups.spec.js new file mode 100644 index 0000000000..d707440a42 --- /dev/null +++ b/backend/src/schema/resolvers/groups.spec.js @@ -0,0 +1,2984 @@ +import { createTestClient } from 'apollo-server-testing' +import Factory, { cleanDatabase } from '../../db/factories' +import { + createGroupMutation, + updateGroupMutation, + joinGroupMutation, + leaveGroupMutation, + changeGroupMemberRoleMutation, + groupMembersQuery, + groupQuery, +} from '../../db/graphql/groups' +import { getNeode, getDriver } from '../../db/neo4j' +import createServer from '../../server' +import CONFIG from '../../config' + +const driver = getDriver() +const neode = getNeode() + +let authenticatedUser +let user +let noMemberUser +let pendingMemberUser +let usualMemberUser +let adminMemberUser +let ownerMemberUser +let secondOwnerMemberUser + +const categoryIds = ['cat9', 'cat4', 'cat15'] +const descriptionAdditional100 = + ' 123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789' +let variables = {} + +const { server } = createServer({ + context: () => { + return { + driver, + neode, + user: authenticatedUser, + } + }, +}) +const { mutate, query } = createTestClient(server) + +const seedBasicsAndClearAuthentication = 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: 'cat4', + name: 'Environment & Nature', + slug: 'environment-nature', + icon: 'tree', + }), + neode.create('Category', { + id: 'cat9', + name: 'Democracy & Politics', + slug: 'democracy-politics', + icon: 'university', + }), + neode.create('Category', { + id: 'cat15', + name: 'Consumption & Sustainability', + slug: 'consumption-sustainability', + icon: 'shopping-cart', + }), + neode.create('Category', { + id: 'cat27', + name: 'Animal Protection', + slug: 'animal-protection', + icon: 'paw', + }), + ]) + authenticatedUser = null +} + +const seedComplexScenarioAndClearAuthentication = async () => { + await seedBasicsAndClearAuthentication() + // create users + noMemberUser = await Factory.build( + 'user', + { + id: 'none-member-user', + name: 'None Member TestUser', + }, + { + email: 'none-member-user@example.org', + password: '1234', + }, + ) + pendingMemberUser = await Factory.build( + 'user', + { + id: 'pending-member-user', + name: 'Pending Member TestUser', + }, + { + email: 'pending-member-user@example.org', + password: '1234', + }, + ) + usualMemberUser = await Factory.build( + 'user', + { + id: 'usual-member-user', + name: 'Usual Member TestUser', + }, + { + email: 'usual-member-user@example.org', + password: '1234', + }, + ) + adminMemberUser = await Factory.build( + 'user', + { + id: 'admin-member-user', + name: 'Admin Member TestUser', + }, + { + email: 'admin-member-user@example.org', + password: '1234', + }, + ) + ownerMemberUser = await Factory.build( + 'user', + { + id: 'owner-member-user', + name: 'Owner Member TestUser', + }, + { + email: 'owner-member-user@example.org', + password: '1234', + }, + ) + secondOwnerMemberUser = await Factory.build( + 'user', + { + id: 'second-owner-member-user', + name: 'Second Owner Member TestUser', + }, + { + email: 'second-owner-member-user@example.org', + password: '1234', + }, + ) + // create groups + // public-group + authenticatedUser = await usualMemberUser.toJson() + await mutate({ + mutation: createGroupMutation(), + variables: { + id: 'public-group', + name: 'The Best Group', + about: 'We will change the world!', + description: 'Some description' + descriptionAdditional100, + groupType: 'public', + actionRadius: 'regional', + categoryIds, + }, + }) + await mutate({ + mutation: joinGroupMutation(), + variables: { + groupId: 'public-group', + userId: 'owner-of-closed-group', + }, + }) + await mutate({ + mutation: joinGroupMutation(), + variables: { + groupId: 'public-group', + userId: 'owner-of-hidden-group', + }, + }) + // closed-group + authenticatedUser = await ownerMemberUser.toJson() + await mutate({ + mutation: createGroupMutation(), + variables: { + id: 'closed-group', + name: 'Uninteresting Group', + about: 'We will change nothing!', + description: 'We love it like it is!?' + descriptionAdditional100, + groupType: 'closed', + actionRadius: 'national', + categoryIds, + }, + }) + // hidden-group + authenticatedUser = await adminMemberUser.toJson() + await mutate({ + mutation: createGroupMutation(), + variables: { + id: 'hidden-group', + name: 'Investigative Journalism Group', + about: 'We will change all.', + description: 'We research …' + descriptionAdditional100, + groupType: 'hidden', + actionRadius: 'global', + categoryIds, + }, + }) + // 'JoinGroup' mutation does not work in hidden groups so we join them by 'ChangeGroupMemberRole' through the owner + await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'hidden-group', + userId: 'admin-member-user', + roleInGroup: 'usual', + }, + }) + await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'hidden-group', + userId: 'second-owner-member-user', + roleInGroup: 'usual', + }, + }) + await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'hidden-group', + userId: 'admin-member-user', + roleInGroup: 'usual', + }, + }) + await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'hidden-group', + userId: 'second-owner-member-user', + roleInGroup: 'usual', + }, + }) + + authenticatedUser = null +} + +beforeAll(async () => { + await cleanDatabase() +}) + +afterAll(async () => { + await cleanDatabase() +}) + +describe('in mode', () => { + describe('clean db after each single test', () => { + beforeEach(async () => { + await seedBasicsAndClearAuthentication() + }) + + // 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('CreateGroup', () => { + beforeEach(() => { + variables = { + ...variables, + id: 'g589', + name: 'The Best Group', + slug: 'the-group', + about: 'We will change the world!', + description: 'Some description' + descriptionAdditional100, + groupType: 'public', + actionRadius: 'regional', + categoryIds, + locationName: 'Hamburg, Germany', + } + }) + + describe('unauthenticated', () => { + it('throws authorization error', async () => { + const { errors } = await mutate({ mutation: createGroupMutation(), variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('authenticated', () => { + beforeEach(async () => { + authenticatedUser = await user.toJson() + }) + + it('creates a group', async () => { + await expect( + mutate({ mutation: createGroupMutation(), variables }), + ).resolves.toMatchObject({ + data: { + CreateGroup: { + name: 'The Best Group', + slug: 'the-group', + about: 'We will change the world!', + description: 'Some description' + descriptionAdditional100, + descriptionExcerpt: 'Some description' + descriptionAdditional100, + groupType: 'public', + actionRadius: 'regional', + locationName: 'Hamburg, Germany', + location: expect.objectContaining({ + name: 'Hamburg', + nameDE: 'Hamburg', + nameEN: 'Hamburg', + }), + }, + }, + errors: undefined, + }) + }) + + it('assigns the authenticated user as owner', async () => { + await expect( + mutate({ mutation: createGroupMutation(), variables }), + ).resolves.toMatchObject({ + data: { + CreateGroup: { + name: 'The Best Group', + myRole: 'owner', + }, + }, + errors: undefined, + }) + }) + + it('has "disabled" and "deleted" default to "false"', async () => { + await expect( + mutate({ mutation: createGroupMutation(), variables }), + ).resolves.toMatchObject({ + data: { CreateGroup: { disabled: false, deleted: false } }, + }) + }) + + describe('description', () => { + describe('length without HTML', () => { + describe('less then 100 chars', () => { + it('throws error: "Description too short!"', async () => { + const { errors } = await mutate({ + mutation: createGroupMutation(), + variables: { + ...variables, + description: + '0123456789' + + '0123456789', + }, + }) + expect(errors[0]).toHaveProperty('message', 'Description too short!') + }) + }) + }) + }) + + describe('categories', () => { + beforeEach(() => { + CONFIG.CATEGORIES_ACTIVE = true + }) + + describe('with matching amount of categories', () => { + it('has new categories', async () => { + await expect( + mutate({ + mutation: createGroupMutation(), + variables: { + ...variables, + categoryIds: ['cat4', 'cat27'], + }, + }), + ).resolves.toMatchObject({ + data: { + CreateGroup: { + categories: expect.arrayContaining([ + expect.objectContaining({ id: 'cat4' }), + expect.objectContaining({ id: 'cat27' }), + ]), + myRole: 'owner', + }, + }, + errors: undefined, + }) + }) + }) + + describe('not even one', () => { + describe('by "categoryIds: null"', () => { + it('throws error: "Too view categories!"', async () => { + const { errors } = await mutate({ + mutation: createGroupMutation(), + variables: { ...variables, categoryIds: null }, + }) + expect(errors[0]).toHaveProperty('message', 'Too view categories!') + }) + }) + + describe('by "categoryIds: []"', () => { + it('throws error: "Too view categories!"', async () => { + const { errors } = await mutate({ + mutation: createGroupMutation(), + variables: { ...variables, categoryIds: [] }, + }) + expect(errors[0]).toHaveProperty('message', 'Too view categories!') + }) + }) + }) + + describe('four', () => { + it('throws error: "Too many categories!"', async () => { + const { errors } = await mutate({ + mutation: createGroupMutation(), + variables: { ...variables, categoryIds: ['cat9', 'cat4', 'cat15', 'cat27'] }, + }) + expect(errors[0]).toHaveProperty('message', 'Too many categories!') + }) + }) + }) + }) + }) + }) + + describe('building up – clean db after each resolver', () => { + describe('Group', () => { + beforeAll(async () => { + await seedBasicsAndClearAuthentication() + }) + + afterAll(async () => { + await cleanDatabase() + }) + + describe('unauthenticated', () => { + it('throws authorization error', async () => { + const { errors } = await query({ query: groupQuery(), variables: {} }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('authenticated', () => { + let otherUser + let ownerOfHiddenGroupUser + + beforeAll(async () => { + otherUser = await Factory.build( + 'user', + { + id: 'other-user', + name: 'Other TestUser', + }, + { + email: 'other-user@example.org', + password: '1234', + }, + ) + ownerOfHiddenGroupUser = await Factory.build( + 'user', + { + id: 'owner-of-hidden-group', + name: 'Owner Of Hidden Group', + }, + { + email: 'owner-of-hidden-group@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!?' + descriptionAdditional100, + groupType: 'closed', + actionRadius: 'global', + categoryIds, + }, + }) + authenticatedUser = await ownerOfHiddenGroupUser.toJson() + await mutate({ + mutation: createGroupMutation(), + variables: { + id: 'hidden-group', + name: 'Investigative Journalism Group', + about: 'We will change all.', + description: 'We research …' + descriptionAdditional100, + groupType: 'hidden', + actionRadius: 'global', + categoryIds, + }, + }) + await mutate({ + mutation: createGroupMutation(), + variables: { + id: 'second-hidden-group', + name: 'Second Investigative Journalism Group', + about: 'We will change all.', + description: 'We research …' + descriptionAdditional100, + groupType: 'hidden', + actionRadius: 'global', + categoryIds, + }, + }) + await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'second-hidden-group', + userId: 'current-user', + roleInGroup: 'pending', + }, + }) + await mutate({ + mutation: createGroupMutation(), + variables: { + id: 'third-hidden-group', + name: 'Third Investigative Journalism Group', + about: 'We will change all.', + description: 'We research …' + descriptionAdditional100, + groupType: 'hidden', + actionRadius: 'global', + categoryIds, + }, + }) + await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'third-hidden-group', + userId: 'current-user', + roleInGroup: 'usual', + }, + }) + 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' + descriptionAdditional100, + groupType: 'public', + actionRadius: 'regional', + categoryIds, + locationName: 'Hamburg, Germany', + }, + }) + }) + + describe('query groups', () => { + describe('in general finds only listed groups – no hidden groups where user is none or pending member', () => { + describe('without any filters', () => { + it('finds all listed groups – including the set descriptionExcerpts and locations', async () => { + const result = await query({ query: groupQuery(), variables: {} }) + expect(result).toMatchObject({ + data: { + Group: expect.arrayContaining([ + expect.objectContaining({ + id: 'my-group', + slug: 'the-best-group', + descriptionExcerpt: 'Some description' + descriptionAdditional100, + locationName: 'Hamburg, Germany', + location: expect.objectContaining({ + name: 'Hamburg', + nameDE: 'Hamburg', + nameEN: 'Hamburg', + }), + myRole: 'owner', + }), + expect.objectContaining({ + id: 'others-group', + slug: 'uninteresting-group', + descriptionExcerpt: 'We love it like it is!?' + descriptionAdditional100, + locationName: null, + location: null, + myRole: null, + }), + expect.objectContaining({ + id: 'third-hidden-group', + slug: 'third-investigative-journalism-group', + descriptionExcerpt: 'We research …' + descriptionAdditional100, + myRole: 'usual', + locationName: null, + location: null, + }), + ]), + }, + errors: undefined, + }) + expect(result.data.Group.length).toBe(3) + }) + + describe('categories', () => { + beforeEach(() => { + CONFIG.CATEGORIES_ACTIVE = true + }) + + it('has set categories', async () => { + await expect( + query({ query: groupQuery(), variables: {} }), + ).resolves.toMatchObject({ + data: { + Group: expect.arrayContaining([ + expect.objectContaining({ + id: 'my-group', + slug: 'the-best-group', + categories: expect.arrayContaining([ + expect.objectContaining({ id: 'cat4' }), + expect.objectContaining({ id: 'cat9' }), + expect.objectContaining({ id: 'cat15' }), + ]), + myRole: 'owner', + }), + expect.objectContaining({ + id: 'others-group', + slug: 'uninteresting-group', + categories: expect.arrayContaining([ + expect.objectContaining({ id: 'cat4' }), + expect.objectContaining({ id: 'cat9' }), + expect.objectContaining({ id: 'cat15' }), + ]), + myRole: null, + }), + ]), + }, + errors: undefined, + }) + }) + }) + }) + + describe('with given id', () => { + describe("id = 'my-group'", () => { + it('finds only the listed group with this id', async () => { + const result = await query({ query: groupQuery(), variables: { id: 'my-group' } }) + expect(result).toMatchObject({ + data: { + Group: [ + expect.objectContaining({ + id: 'my-group', + slug: 'the-best-group', + myRole: 'owner', + }), + ], + }, + errors: undefined, + }) + expect(result.data.Group.length).toBe(1) + }) + }) + + describe("id = 'third-hidden-group'", () => { + it("finds only the hidden group where I'm 'usual' member", async () => { + const result = await query({ + query: groupQuery(), + variables: { id: 'third-hidden-group' }, + }) + expect(result).toMatchObject({ + data: { + Group: expect.arrayContaining([ + expect.objectContaining({ + id: 'third-hidden-group', + slug: 'third-investigative-journalism-group', + myRole: 'usual', + }), + ]), + }, + errors: undefined, + }) + expect(result.data.Group.length).toBe(1) + }) + }) + + describe("id = 'second-hidden-group'", () => { + it("finds no hidden group where I'm 'pending' member", async () => { + const result = await query({ + query: groupQuery(), + variables: { id: 'second-hidden-group' }, + }) + expect(result.data.Group.length).toBe(0) + }) + }) + + describe("id = 'hidden-group'", () => { + it("finds no hidden group where I'm not(!) a member at all", async () => { + const result = await query({ + query: groupQuery(), + variables: { id: 'hidden-group' }, + }) + expect(result.data.Group.length).toBe(0) + }) + }) + }) + + describe('with given slug', () => { + describe("slug = 'the-best-group'", () => { + it('finds only the listed group with this slug', async () => { + const result = await query({ + query: groupQuery(), + variables: { slug: 'the-best-group' }, + }) + expect(result).toMatchObject({ + data: { + Group: [ + expect.objectContaining({ + id: 'my-group', + slug: 'the-best-group', + myRole: 'owner', + }), + ], + }, + errors: undefined, + }) + expect(result.data.Group.length).toBe(1) + }) + }) + + describe("slug = 'third-investigative-journalism-group'", () => { + it("finds only the hidden group where I'm 'usual' member", async () => { + const result = await query({ + query: groupQuery(), + variables: { slug: 'third-investigative-journalism-group' }, + }) + expect(result).toMatchObject({ + data: { + Group: expect.arrayContaining([ + expect.objectContaining({ + id: 'third-hidden-group', + slug: 'third-investigative-journalism-group', + myRole: 'usual', + }), + ]), + }, + errors: undefined, + }) + expect(result.data.Group.length).toBe(1) + }) + }) + + describe("slug = 'second-investigative-journalism-group'", () => { + it("finds no hidden group where I'm 'pending' member", async () => { + const result = await query({ + query: groupQuery(), + variables: { slug: 'second-investigative-journalism-group' }, + }) + expect(result.data.Group.length).toBe(0) + }) + }) + + describe("slug = 'investigative-journalism-group'", () => { + it("finds no hidden group where I'm not(!) a member at all", async () => { + const result = await query({ + query: groupQuery(), + variables: { slug: 'investigative-journalism-group' }, + }) + expect(result.data.Group.length).toBe(0) + }) + }) + }) + + describe('isMember = true', () => { + it('finds only listed groups where user is member', async () => { + const result = await query({ query: groupQuery(), variables: { isMember: true } }) + expect(result).toMatchObject({ + data: { + Group: expect.arrayContaining([ + expect.objectContaining({ + id: 'my-group', + slug: 'the-best-group', + myRole: 'owner', + }), + expect.objectContaining({ + id: 'third-hidden-group', + slug: 'third-investigative-journalism-group', + myRole: 'usual', + }), + ]), + }, + errors: undefined, + }) + expect(result.data.Group.length).toBe(2) + }) + }) + + describe('isMember = false', () => { + it('finds only listed groups where user is not(!) member', async () => { + const result = await query({ query: groupQuery(), variables: { isMember: false } }) + expect(result).toMatchObject({ + data: { + Group: expect.arrayContaining([ + expect.objectContaining({ + id: 'others-group', + slug: 'uninteresting-group', + myRole: null, + }), + ]), + }, + errors: undefined, + }) + expect(result.data.Group.length).toBe(1) + }) + }) + }) + }) + }) + }) + + describe('JoinGroup', () => { + beforeAll(async () => { + await seedBasicsAndClearAuthentication() + }) + + afterAll(async () => { + await cleanDatabase() + }) + + describe('unauthenticated', () => { + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: joinGroupMutation(), + variables: { + groupId: 'not-existing-group', + userId: 'current-user', + }, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('authenticated', () => { + let ownerOfClosedGroupUser + let ownerOfHiddenGroupUser + + beforeAll(async () => { + // create users + ownerOfClosedGroupUser = await Factory.build( + 'user', + { + id: 'owner-of-closed-group', + name: 'Owner Of Closed Group', + }, + { + email: 'owner-of-closed-group@example.org', + password: '1234', + }, + ) + ownerOfHiddenGroupUser = await Factory.build( + 'user', + { + id: 'owner-of-hidden-group', + name: 'Owner Of Hidden Group', + }, + { + email: 'owner-of-hidden-group@example.org', + password: '1234', + }, + ) + // create groups + // public-group + authenticatedUser = await ownerOfClosedGroupUser.toJson() + await mutate({ + mutation: createGroupMutation(), + variables: { + id: 'closed-group', + name: 'Uninteresting Group', + about: 'We will change nothing!', + description: 'We love it like it is!?' + descriptionAdditional100, + groupType: 'closed', + actionRadius: 'national', + categoryIds, + }, + }) + authenticatedUser = await ownerOfHiddenGroupUser.toJson() + await mutate({ + mutation: createGroupMutation(), + variables: { + id: 'hidden-group', + name: 'Investigative Journalism Group', + about: 'We will change all.', + description: 'We research …' + descriptionAdditional100, + groupType: 'hidden', + actionRadius: 'global', + categoryIds, + }, + }) + authenticatedUser = await user.toJson() + await mutate({ + mutation: createGroupMutation(), + variables: { + id: 'public-group', + name: 'The Best Group', + about: 'We will change the world!', + description: 'Some description' + descriptionAdditional100, + groupType: 'public', + actionRadius: 'regional', + categoryIds, + }, + }) + }) + + describe('public group', () => { + describe('joined by "owner-of-closed-group"', () => { + it('has "usual" as membership role', async () => { + await expect( + mutate({ + mutation: joinGroupMutation(), + variables: { + groupId: 'public-group', + userId: 'owner-of-closed-group', + }, + }), + ).resolves.toMatchObject({ + data: { + JoinGroup: { + id: 'owner-of-closed-group', + myRoleInGroup: 'usual', + }, + }, + errors: undefined, + }) + }) + }) + + describe('joined by its owner', () => { + describe('does not create additional "MEMBER_OF" relation and therefore', () => { + it('has still "owner" as membership role', async () => { + await expect( + mutate({ + mutation: joinGroupMutation(), + variables: { + groupId: 'public-group', + userId: 'current-user', + }, + }), + ).resolves.toMatchObject({ + data: { + JoinGroup: { + id: 'current-user', + myRoleInGroup: 'owner', + }, + }, + errors: undefined, + }) + }) + }) + }) + }) + + describe('closed group', () => { + describe('joined by "current-user"', () => { + it('has "pending" as membership role', async () => { + await expect( + mutate({ + mutation: joinGroupMutation(), + variables: { + groupId: 'closed-group', + userId: 'current-user', + }, + }), + ).resolves.toMatchObject({ + data: { + JoinGroup: { + id: 'current-user', + myRoleInGroup: 'pending', + }, + }, + errors: undefined, + }) + }) + }) + + describe('joined by its owner', () => { + describe('does not create additional "MEMBER_OF" relation and therefore', () => { + it('has still "owner" as membership role', async () => { + await expect( + mutate({ + mutation: joinGroupMutation(), + variables: { + groupId: 'closed-group', + userId: 'owner-of-closed-group', + }, + }), + ).resolves.toMatchObject({ + data: { + JoinGroup: { + id: 'owner-of-closed-group', + myRoleInGroup: 'owner', + }, + }, + errors: undefined, + }) + }) + }) + }) + }) + + describe('hidden group', () => { + describe('joined by "owner-of-closed-group"', () => { + it('throws authorization error', async () => { + const { errors } = await query({ + query: joinGroupMutation(), + variables: { + groupId: 'hidden-group', + userId: 'owner-of-closed-group', + }, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('joined by its owner', () => { + describe('does not create additional "MEMBER_OF" relation and therefore', () => { + it('has still "owner" as membership role', async () => { + await expect( + mutate({ + mutation: joinGroupMutation(), + variables: { + groupId: 'hidden-group', + userId: 'owner-of-hidden-group', + }, + }), + ).resolves.toMatchObject({ + data: { + JoinGroup: { + id: 'owner-of-hidden-group', + myRoleInGroup: 'owner', + }, + }, + errors: undefined, + }) + }) + }) + }) + }) + }) + }) + + describe('GroupMembers', () => { + beforeAll(async () => { + await seedBasicsAndClearAuthentication() + }) + + afterAll(async () => { + await cleanDatabase() + }) + + describe('unauthenticated', () => { + it('throws authorization error', async () => { + variables = { + id: 'not-existing-group', + } + const { errors } = await query({ query: groupMembersQuery(), variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('authenticated', () => { + let otherUser + let pendingUser + let ownerOfClosedGroupUser + let ownerOfHiddenGroupUser + + beforeAll(async () => { + // create users + otherUser = await Factory.build( + 'user', + { + id: 'other-user', + name: 'Other TestUser', + }, + { + email: 'other-user@example.org', + password: '1234', + }, + ) + pendingUser = await Factory.build( + 'user', + { + id: 'pending-user', + name: 'Pending TestUser', + }, + { + email: 'pending@example.org', + password: '1234', + }, + ) + ownerOfClosedGroupUser = await Factory.build( + 'user', + { + id: 'owner-of-closed-group', + name: 'Owner Of Closed Group', + }, + { + email: 'owner-of-closed-group@example.org', + password: '1234', + }, + ) + ownerOfHiddenGroupUser = await Factory.build( + 'user', + { + id: 'owner-of-hidden-group', + name: 'Owner Of Hidden Group', + }, + { + email: 'owner-of-hidden-group@example.org', + password: '1234', + }, + ) + // create groups + // public-group + authenticatedUser = await user.toJson() + await mutate({ + mutation: createGroupMutation(), + variables: { + id: 'public-group', + name: 'The Best Group', + about: 'We will change the world!', + description: 'Some description' + descriptionAdditional100, + groupType: 'public', + actionRadius: 'regional', + categoryIds, + }, + }) + await mutate({ + mutation: joinGroupMutation(), + variables: { + groupId: 'public-group', + userId: 'owner-of-closed-group', + }, + }) + await mutate({ + mutation: joinGroupMutation(), + variables: { + groupId: 'public-group', + userId: 'owner-of-hidden-group', + }, + }) + // closed-group + authenticatedUser = await ownerOfClosedGroupUser.toJson() + await mutate({ + mutation: createGroupMutation(), + variables: { + id: 'closed-group', + name: 'Uninteresting Group', + about: 'We will change nothing!', + description: 'We love it like it is!?' + descriptionAdditional100, + groupType: 'closed', + actionRadius: 'national', + categoryIds, + }, + }) + await mutate({ + mutation: joinGroupMutation(), + variables: { + groupId: 'closed-group', + userId: 'current-user', + }, + }) + await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'closed-group', + userId: 'owner-of-hidden-group', + roleInGroup: 'usual', + }, + }) + // hidden-group + authenticatedUser = await ownerOfHiddenGroupUser.toJson() + await mutate({ + mutation: createGroupMutation(), + variables: { + id: 'hidden-group', + name: 'Investigative Journalism Group', + about: 'We will change all.', + description: 'We research …' + descriptionAdditional100, + groupType: 'hidden', + actionRadius: 'global', + categoryIds, + }, + }) + // 'JoinGroup' mutation does not work in hidden groups so we join them by 'ChangeGroupMemberRole' through the owner + await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'hidden-group', + userId: 'pending-user', + roleInGroup: 'pending', + }, + }) + await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'hidden-group', + userId: 'current-user', + roleInGroup: 'usual', + }, + }) + await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'hidden-group', + userId: 'owner-of-closed-group', + roleInGroup: 'admin', + }, + }) + + authenticatedUser = null + }) + + describe('public group', () => { + beforeEach(async () => { + variables = { + id: 'public-group', + } + }) + + describe('query group members', () => { + describe('by owner "current-user"', () => { + beforeEach(async () => { + authenticatedUser = await user.toJson() + }) + + it('finds all members', async () => { + const result = await query({ + query: groupMembersQuery(), + variables, + }) + expect(result).toMatchObject({ + data: { + GroupMembers: expect.arrayContaining([ + expect.objectContaining({ + id: 'current-user', + myRoleInGroup: 'owner', + }), + expect.objectContaining({ + id: 'owner-of-closed-group', + myRoleInGroup: 'usual', + }), + expect.objectContaining({ + id: 'owner-of-hidden-group', + myRoleInGroup: 'usual', + }), + ]), + }, + errors: undefined, + }) + expect(result.data.GroupMembers.length).toBe(3) + }) + }) + + describe('by usual member "owner-of-closed-group"', () => { + beforeEach(async () => { + authenticatedUser = await ownerOfClosedGroupUser.toJson() + }) + + it('finds all members', async () => { + const result = await query({ + query: groupMembersQuery(), + variables, + }) + expect(result).toMatchObject({ + data: { + GroupMembers: expect.arrayContaining([ + expect.objectContaining({ + id: 'current-user', + myRoleInGroup: 'owner', + }), + expect.objectContaining({ + id: 'owner-of-closed-group', + myRoleInGroup: 'usual', + }), + expect.objectContaining({ + id: 'owner-of-hidden-group', + myRoleInGroup: 'usual', + }), + ]), + }, + errors: undefined, + }) + expect(result.data.GroupMembers.length).toBe(3) + }) + }) + + describe('by none member "other-user"', () => { + beforeEach(async () => { + authenticatedUser = await otherUser.toJson() + }) + + it('finds all members', async () => { + const result = await query({ + query: groupMembersQuery(), + variables, + }) + expect(result).toMatchObject({ + data: { + GroupMembers: expect.arrayContaining([ + expect.objectContaining({ + id: 'current-user', + myRoleInGroup: 'owner', + }), + expect.objectContaining({ + id: 'owner-of-closed-group', + myRoleInGroup: 'usual', + }), + expect.objectContaining({ + id: 'owner-of-hidden-group', + myRoleInGroup: 'usual', + }), + ]), + }, + errors: undefined, + }) + expect(result.data.GroupMembers.length).toBe(3) + }) + }) + }) + }) + + describe('closed group', () => { + beforeEach(async () => { + variables = { + id: 'closed-group', + } + }) + + describe('query group members', () => { + describe('by owner "owner-of-closed-group"', () => { + beforeEach(async () => { + authenticatedUser = await ownerOfClosedGroupUser.toJson() + }) + + it('finds all members', async () => { + const result = await query({ + query: groupMembersQuery(), + variables, + }) + expect(result).toMatchObject({ + data: { + GroupMembers: expect.arrayContaining([ + expect.objectContaining({ + id: 'current-user', + myRoleInGroup: 'pending', + }), + expect.objectContaining({ + id: 'owner-of-closed-group', + myRoleInGroup: 'owner', + }), + expect.objectContaining({ + id: 'owner-of-hidden-group', + myRoleInGroup: 'usual', + }), + ]), + }, + errors: undefined, + }) + expect(result.data.GroupMembers.length).toBe(3) + }) + }) + + describe('by usual member "owner-of-hidden-group"', () => { + beforeEach(async () => { + authenticatedUser = await ownerOfHiddenGroupUser.toJson() + }) + + it('finds all members', async () => { + const result = await query({ + query: groupMembersQuery(), + variables, + }) + expect(result).toMatchObject({ + data: { + GroupMembers: expect.arrayContaining([ + expect.objectContaining({ + id: 'current-user', + myRoleInGroup: 'pending', + }), + expect.objectContaining({ + id: 'owner-of-closed-group', + myRoleInGroup: 'owner', + }), + expect.objectContaining({ + id: 'owner-of-hidden-group', + myRoleInGroup: 'usual', + }), + ]), + }, + errors: undefined, + }) + expect(result.data.GroupMembers.length).toBe(3) + }) + }) + + describe('by pending member "current-user"', () => { + beforeEach(async () => { + authenticatedUser = await user.toJson() + }) + + it('throws authorization error', async () => { + const { errors } = await query({ query: groupMembersQuery(), variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('by none member "other-user"', () => { + beforeEach(async () => { + authenticatedUser = await otherUser.toJson() + }) + + it('throws authorization error', async () => { + const { errors } = await query({ query: groupMembersQuery(), variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + }) + }) + + describe('hidden group', () => { + beforeEach(async () => { + variables = { + id: 'hidden-group', + } + }) + + describe('query group members', () => { + describe('by owner "owner-of-hidden-group"', () => { + beforeEach(async () => { + authenticatedUser = await ownerOfHiddenGroupUser.toJson() + }) + + it('finds all members', async () => { + const result = await query({ + query: groupMembersQuery(), + variables, + }) + expect(result).toMatchObject({ + data: { + GroupMembers: expect.arrayContaining([ + expect.objectContaining({ + id: 'pending-user', + myRoleInGroup: 'pending', + }), + expect.objectContaining({ + id: 'current-user', + myRoleInGroup: 'usual', + }), + expect.objectContaining({ + id: 'owner-of-closed-group', + myRoleInGroup: 'admin', + }), + expect.objectContaining({ + id: 'owner-of-hidden-group', + myRoleInGroup: 'owner', + }), + ]), + }, + errors: undefined, + }) + expect(result.data.GroupMembers.length).toBe(4) + }) + }) + + describe('by usual member "current-user"', () => { + beforeEach(async () => { + authenticatedUser = await user.toJson() + }) + + it('finds all members', async () => { + const result = await query({ + query: groupMembersQuery(), + variables, + }) + expect(result).toMatchObject({ + data: { + GroupMembers: expect.arrayContaining([ + expect.objectContaining({ + id: 'pending-user', + myRoleInGroup: 'pending', + }), + expect.objectContaining({ + id: 'current-user', + myRoleInGroup: 'usual', + }), + expect.objectContaining({ + id: 'owner-of-closed-group', + myRoleInGroup: 'admin', + }), + expect.objectContaining({ + id: 'owner-of-hidden-group', + myRoleInGroup: 'owner', + }), + ]), + }, + errors: undefined, + }) + expect(result.data.GroupMembers.length).toBe(4) + }) + }) + + describe('by admin member "owner-of-closed-group"', () => { + beforeEach(async () => { + authenticatedUser = await ownerOfClosedGroupUser.toJson() + }) + + it('finds all members', async () => { + const result = await query({ + query: groupMembersQuery(), + variables, + }) + expect(result).toMatchObject({ + data: { + GroupMembers: expect.arrayContaining([ + expect.objectContaining({ + id: 'pending-user', + myRoleInGroup: 'pending', + }), + expect.objectContaining({ + id: 'current-user', + myRoleInGroup: 'usual', + }), + expect.objectContaining({ + id: 'owner-of-closed-group', + myRoleInGroup: 'admin', + }), + expect.objectContaining({ + id: 'owner-of-hidden-group', + myRoleInGroup: 'owner', + }), + ]), + }, + errors: undefined, + }) + expect(result.data.GroupMembers.length).toBe(4) + }) + }) + + describe('by pending member "pending-user"', () => { + beforeEach(async () => { + authenticatedUser = await pendingUser.toJson() + }) + + it('throws authorization error', async () => { + const { errors } = await query({ query: groupMembersQuery(), variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('by none member "other-user"', () => { + beforeEach(async () => { + authenticatedUser = await otherUser.toJson() + }) + + it('throws authorization error', async () => { + const { errors } = await query({ query: groupMembersQuery(), variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + }) + }) + }) + }) + + describe('ChangeGroupMemberRole', () => { + beforeAll(async () => { + await seedComplexScenarioAndClearAuthentication() + }) + + afterAll(async () => { + await cleanDatabase() + }) + + describe('unauthenticated', () => { + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'not-existing-group', + userId: 'current-user', + roleInGroup: 'pending', + }, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('authenticated', () => { + describe('in all group types – here "closed-group" for example', () => { + beforeEach(async () => { + variables = { + groupId: 'closed-group', + } + }) + + describe('join the members and give them their prospective roles', () => { + describe('by owner "owner-member-user"', () => { + beforeEach(async () => { + authenticatedUser = await ownerMemberUser.toJson() + }) + + describe('for "usual-member-user"', () => { + beforeEach(async () => { + variables = { + ...variables, + userId: 'usual-member-user', + } + }) + + describe('as usual', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'usual', + } + }) + + it('has role usual', async () => { + await expect( + mutate({ + mutation: changeGroupMemberRoleMutation(), + variables, + }), + ).resolves.toMatchObject({ + data: { + ChangeGroupMemberRole: { + id: 'usual-member-user', + myRoleInGroup: 'usual', + }, + }, + errors: undefined, + }) + }) + + // the GQL mutation needs this fields in the result for testing + it.todo('has "updatedAt" newer as "createdAt"') + }) + }) + + describe('for "admin-member-user"', () => { + beforeEach(async () => { + variables = { + ...variables, + userId: 'admin-member-user', + } + }) + + describe('as admin', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'admin', + } + }) + + it('has role admin', async () => { + await expect( + mutate({ + mutation: changeGroupMemberRoleMutation(), + variables, + }), + ).resolves.toMatchObject({ + data: { + ChangeGroupMemberRole: { + id: 'admin-member-user', + myRoleInGroup: 'admin', + }, + }, + errors: undefined, + }) + }) + }) + }) + + describe('for "second-owner-member-user"', () => { + beforeEach(async () => { + variables = { + ...variables, + userId: 'second-owner-member-user', + } + }) + + describe('as owner', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'owner', + } + }) + + it('has role owner', async () => { + await expect( + mutate({ + mutation: changeGroupMemberRoleMutation(), + variables, + }), + ).resolves.toMatchObject({ + data: { + ChangeGroupMemberRole: { + id: 'second-owner-member-user', + myRoleInGroup: 'owner', + }, + }, + errors: undefined, + }) + }) + }) + }) + }) + }) + + describe('switch role', () => { + describe('of owner "owner-member-user"', () => { + beforeEach(async () => { + variables = { + ...variables, + userId: 'owner-member-user', + } + }) + + describe('by owner themself "owner-member-user"', () => { + beforeEach(async () => { + authenticatedUser = await ownerMemberUser.toJson() + }) + + describe('to admin', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'admin', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + }) + + // shall this be possible in the future? + // or shall only an owner who gave the second owner the owner role downgrade themself for savety? + // otherwise the first owner who downgrades the other one has the victory over the group! + describe('by second owner "second-owner-member-user"', () => { + beforeEach(async () => { + authenticatedUser = await secondOwnerMemberUser.toJson() + }) + + describe('to admin', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'admin', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('to same role owner', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'owner', + } + }) + + it('has role owner still', async () => { + await expect( + mutate({ + mutation: changeGroupMemberRoleMutation(), + variables, + }), + ).resolves.toMatchObject({ + data: { + ChangeGroupMemberRole: { + id: 'owner-member-user', + myRoleInGroup: 'owner', + }, + }, + errors: undefined, + }) + }) + }) + }) + + describe('by admin "admin-member-user"', () => { + beforeEach(async () => { + authenticatedUser = await adminMemberUser.toJson() + }) + + describe('to admin', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'admin', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + }) + + describe('by usual member "usual-member-user"', () => { + beforeEach(async () => { + authenticatedUser = await usualMemberUser.toJson() + }) + + describe('to admin', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'admin', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + }) + + describe('by still pending member "pending-member-user"', () => { + beforeEach(async () => { + authenticatedUser = await pendingMemberUser.toJson() + }) + + describe('to admin', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'admin', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + }) + }) + + describe('of admin "admin-member-user"', () => { + beforeEach(async () => { + variables = { + ...variables, + userId: 'admin-member-user', + } + }) + + describe('by owner "owner-member-user"', () => { + beforeEach(async () => { + authenticatedUser = await ownerMemberUser.toJson() + }) + + describe('to owner', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'owner', + } + }) + + it('has role owner', async () => { + await expect( + mutate({ + mutation: changeGroupMemberRoleMutation(), + variables, + }), + ).resolves.toMatchObject({ + data: { + ChangeGroupMemberRole: { + id: 'admin-member-user', + myRoleInGroup: 'owner', + }, + }, + errors: undefined, + }) + }) + }) + + describe('back to admin', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'admin', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + }) + + describe('by usual member "usual-member-user"', () => { + beforeEach(async () => { + authenticatedUser = await usualMemberUser.toJson() + }) + + describe('upgrade to owner', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'owner', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('degrade to usual', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'usual', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + }) + + describe('by still pending member "pending-member-user"', () => { + beforeEach(async () => { + authenticatedUser = await pendingMemberUser.toJson() + }) + + describe('upgrade to owner', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'owner', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('degrade to usual', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'usual', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + }) + + describe('by none member "current-user"', () => { + beforeEach(async () => { + authenticatedUser = await user.toJson() + }) + + describe('upgrade to owner', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'owner', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('degrade to pending again', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'pending', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + }) + }) + + describe('of usual member "usual-member-user"', () => { + beforeEach(async () => { + variables = { + ...variables, + userId: 'usual-member-user', + } + }) + + describe('by owner "owner-member-user"', () => { + beforeEach(async () => { + authenticatedUser = await ownerMemberUser.toJson() + }) + + describe('to admin', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'admin', + } + }) + + it('has role admin', async () => { + await expect( + mutate({ + mutation: changeGroupMemberRoleMutation(), + variables, + }), + ).resolves.toMatchObject({ + data: { + ChangeGroupMemberRole: { + id: 'usual-member-user', + myRoleInGroup: 'admin', + }, + }, + errors: undefined, + }) + }) + }) + + describe('back to usual', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'usual', + } + }) + + it('has role usual again', async () => { + await expect( + mutate({ + mutation: changeGroupMemberRoleMutation(), + variables, + }), + ).resolves.toMatchObject({ + data: { + ChangeGroupMemberRole: { + id: 'usual-member-user', + myRoleInGroup: 'usual', + }, + }, + errors: undefined, + }) + }) + }) + }) + + describe('by usual member "usual-member-user"', () => { + beforeEach(async () => { + authenticatedUser = await usualMemberUser.toJson() + }) + + describe('upgrade to admin', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'admin', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('degrade to pending', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'pending', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + }) + + describe('by still pending member "pending-member-user"', () => { + beforeEach(async () => { + authenticatedUser = await pendingMemberUser.toJson() + }) + + describe('upgrade to admin', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'admin', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('degrade to pending', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'pending', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + }) + + describe('by none member "current-user"', () => { + beforeEach(async () => { + authenticatedUser = await user.toJson() + }) + + describe('upgrade to admin', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'admin', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('degrade to pending again', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'pending', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + }) + }) + + describe('of still pending member "pending-member-user"', () => { + beforeEach(async () => { + variables = { + ...variables, + userId: 'pending-member-user', + } + }) + + describe('by owner "owner-member-user"', () => { + beforeEach(async () => { + authenticatedUser = await ownerMemberUser.toJson() + }) + + describe('to usual', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'usual', + } + }) + + it('has role usual', async () => { + await expect( + mutate({ + mutation: changeGroupMemberRoleMutation(), + variables, + }), + ).resolves.toMatchObject({ + data: { + ChangeGroupMemberRole: { + id: 'pending-member-user', + myRoleInGroup: 'usual', + }, + }, + errors: undefined, + }) + }) + }) + + describe('back to pending', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'pending', + } + }) + + it('has role usual again', async () => { + await expect( + mutate({ + mutation: changeGroupMemberRoleMutation(), + variables, + }), + ).resolves.toMatchObject({ + data: { + ChangeGroupMemberRole: { + id: 'pending-member-user', + myRoleInGroup: 'pending', + }, + }, + errors: undefined, + }) + }) + }) + }) + + describe('by usual member "usual-member-user"', () => { + beforeEach(async () => { + authenticatedUser = await usualMemberUser.toJson() + }) + + describe('upgrade to usual', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'usual', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + }) + + describe('by still pending member "pending-member-user"', () => { + beforeEach(async () => { + authenticatedUser = await pendingMemberUser.toJson() + }) + + describe('upgrade to usual', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'usual', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + }) + + describe('by none member "current-user"', () => { + beforeEach(async () => { + authenticatedUser = await user.toJson() + }) + + describe('upgrade to usual', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'usual', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + }) + }) + }) + }) + }) + }) + + describe('LeaveGroup', () => { + beforeAll(async () => { + await seedComplexScenarioAndClearAuthentication() + // closed-group + authenticatedUser = await ownerMemberUser.toJson() + await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'closed-group', + userId: 'pending-member-user', + roleInGroup: 'pending', + }, + }) + await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'closed-group', + userId: 'usual-member-user', + roleInGroup: 'usual', + }, + }) + await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'closed-group', + userId: 'admin-member-user', + roleInGroup: 'admin', + }, + }) + await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'closed-group', + userId: 'second-owner-member-user', + roleInGroup: 'owner', + }, + }) + + authenticatedUser = null + }) + + afterAll(async () => { + await cleanDatabase() + }) + + describe('unauthenticated', () => { + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: leaveGroupMutation(), + variables: { + groupId: 'not-existing-group', + userId: 'current-user', + }, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('authenticated', () => { + describe('in all group types', () => { + describe('here "closed-group" for example', () => { + const memberInGroup = async (userId, groupId) => { + const result = await query({ + query: groupMembersQuery(), + variables: { + id: groupId, + }, + }) + return result.data && result.data.GroupMembers + ? !!result.data.GroupMembers.find((member) => member.id === userId) + : null + } + + beforeEach(async () => { + authenticatedUser = null + variables = { + groupId: 'closed-group', + } + }) + + describe('left by "pending-member-user"', () => { + it('has "null" as membership role, was in the group, and left the group', async () => { + authenticatedUser = await ownerMemberUser.toJson() + expect(await memberInGroup('pending-member-user', 'closed-group')).toBe(true) + authenticatedUser = await pendingMemberUser.toJson() + await expect( + mutate({ + mutation: leaveGroupMutation(), + variables: { + ...variables, + userId: 'pending-member-user', + }, + }), + ).resolves.toMatchObject({ + data: { + LeaveGroup: { + id: 'pending-member-user', + myRoleInGroup: null, + }, + }, + errors: undefined, + }) + authenticatedUser = await ownerMemberUser.toJson() + expect(await memberInGroup('pending-member-user', 'closed-group')).toBe(false) + }) + }) + + describe('left by "usual-member-user"', () => { + it('has "null" as membership role, was in the group, and left the group', async () => { + authenticatedUser = await ownerMemberUser.toJson() + expect(await memberInGroup('usual-member-user', 'closed-group')).toBe(true) + authenticatedUser = await usualMemberUser.toJson() + await expect( + mutate({ + mutation: leaveGroupMutation(), + variables: { + ...variables, + userId: 'usual-member-user', + }, + }), + ).resolves.toMatchObject({ + data: { + LeaveGroup: { + id: 'usual-member-user', + myRoleInGroup: null, + }, + }, + errors: undefined, + }) + authenticatedUser = await ownerMemberUser.toJson() + expect(await memberInGroup('usual-member-user', 'closed-group')).toBe(false) + }) + }) + + describe('left by "admin-member-user"', () => { + it('has "null" as membership role, was in the group, and left the group', async () => { + authenticatedUser = await ownerMemberUser.toJson() + expect(await memberInGroup('admin-member-user', 'closed-group')).toBe(true) + authenticatedUser = await adminMemberUser.toJson() + await expect( + mutate({ + mutation: leaveGroupMutation(), + variables: { + ...variables, + userId: 'admin-member-user', + }, + }), + ).resolves.toMatchObject({ + data: { + LeaveGroup: { + id: 'admin-member-user', + myRoleInGroup: null, + }, + }, + errors: undefined, + }) + authenticatedUser = await ownerMemberUser.toJson() + expect(await memberInGroup('admin-member-user', 'closed-group')).toBe(false) + }) + }) + + describe('left by "owner-member-user"', () => { + it('throws authorization error', async () => { + authenticatedUser = await ownerMemberUser.toJson() + const { errors } = await mutate({ + mutation: leaveGroupMutation(), + variables: { + ...variables, + userId: 'owner-member-user', + }, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('left by "second-owner-member-user"', () => { + it('throws authorization error', async () => { + authenticatedUser = await secondOwnerMemberUser.toJson() + const { errors } = await mutate({ + mutation: leaveGroupMutation(), + variables: { + ...variables, + userId: 'second-owner-member-user', + }, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('left by "none-member-user"', () => { + it('throws authorization error', async () => { + authenticatedUser = await noMemberUser.toJson() + const { errors } = await mutate({ + mutation: leaveGroupMutation(), + variables: { + ...variables, + userId: 'none-member-user', + }, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('as "owner-member-user" try to leave member "usual-member-user"', () => { + it('throws authorization error', async () => { + authenticatedUser = await ownerMemberUser.toJson() + const { errors } = await mutate({ + mutation: leaveGroupMutation(), + variables: { + ...variables, + userId: 'usual-member-user', + }, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('as "usual-member-user" try to leave member "admin-member-user"', () => { + it('throws authorization error', async () => { + authenticatedUser = await usualMemberUser.toJson() + const { errors } = await mutate({ + mutation: leaveGroupMutation(), + variables: { + ...variables, + userId: 'admin-member-user', + }, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + }) + }) + }) + }) + + describe('UpdateGroup', () => { + beforeAll(async () => { + await seedBasicsAndClearAuthentication() + }) + + afterAll(async () => { + await cleanDatabase() + }) + + describe('unauthenticated', () => { + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: updateGroupMutation(), + variables: { + id: 'my-group', + slug: 'my-best-group', + }, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('authenticated', () => { + let noMemberUser + + beforeAll(async () => { + noMemberUser = await Factory.build( + 'user', + { + id: 'none-member-user', + name: 'None Member TestUser', + }, + { + email: 'none-member-user@example.org', + password: '1234', + }, + ) + usualMemberUser = await Factory.build( + 'user', + { + id: 'usual-member-user', + name: 'Usual Member TestUser', + }, + { + email: 'usual-member-user@example.org', + password: '1234', + }, + ) + authenticatedUser = await noMemberUser.toJson() + await mutate({ + mutation: createGroupMutation(), + variables: { + id: 'others-group', + name: 'Uninteresting Group', + about: 'We will change nothing!', + description: 'We love it like it is!?' + descriptionAdditional100, + groupType: 'closed', + actionRadius: 'global', + 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' + descriptionAdditional100, + groupType: 'public', + actionRadius: 'regional', + categoryIds, + locationName: 'Berlin, Germany', + }, + }) + await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'my-group', + userId: 'usual-member-user', + roleInGroup: 'usual', + }, + }) + }) + + describe('change group settings', () => { + describe('as owner', () => { + beforeEach(async () => { + authenticatedUser = await user.toJson() + }) + + describe('all standard settings – excluding location', () => { + it('has updated the settings', async () => { + await expect( + mutate({ + mutation: updateGroupMutation(), + variables: { + id: 'my-group', + name: 'The New Group For Our Country', + about: 'We will change the land!', + description: 'Some country relevant description' + descriptionAdditional100, + actionRadius: 'national', + // avatar, // test this as result + }, + }), + ).resolves.toMatchObject({ + data: { + UpdateGroup: { + id: 'my-group', + name: 'The New Group For Our Country', + slug: 'the-new-group-for-our-country', // changing the slug is tested in the slugifyMiddleware + about: 'We will change the land!', + description: 'Some country relevant description' + descriptionAdditional100, + descriptionExcerpt: + 'Some country relevant description' + descriptionAdditional100, + actionRadius: 'national', + // avatar, // test this as result + myRole: 'owner', + }, + }, + errors: undefined, + }) + }) + }) + + describe('location', () => { + describe('"locationName" is undefined – shall not change location', () => { + it('has left locaton unchanged as "Berlin"', async () => { + await expect( + mutate({ + mutation: updateGroupMutation(), + variables: { + id: 'my-group', + }, + }), + ).resolves.toMatchObject({ + data: { + UpdateGroup: { + id: 'my-group', + locationName: 'Berlin, Germany', + location: expect.objectContaining({ + name: 'Berlin', + nameDE: 'Berlin', + nameEN: 'Berlin', + }), + myRole: 'owner', + }, + }, + errors: undefined, + }) + }) + }) + + describe('"locationName" is null – shall change location "Berlin" to unset location', () => { + it('has updated the location to unset location', async () => { + await expect( + mutate({ + mutation: updateGroupMutation(), + variables: { + id: 'my-group', + locationName: null, + }, + }), + ).resolves.toMatchObject({ + data: { + UpdateGroup: { + id: 'my-group', + locationName: null, + location: null, + myRole: 'owner', + }, + }, + errors: undefined, + }) + }) + }) + + describe('change unset location to "Paris"', () => { + it('has updated the location to "Paris"', async () => { + await expect( + mutate({ + mutation: updateGroupMutation(), + variables: { + id: 'my-group', + locationName: 'Paris, France', + }, + }), + ).resolves.toMatchObject({ + data: { + UpdateGroup: { + id: 'my-group', + locationName: 'Paris, France', + location: expect.objectContaining({ + name: 'Paris', + nameDE: 'Paris', + nameEN: 'Paris', + }), + myRole: 'owner', + }, + }, + errors: undefined, + }) + }) + }) + + describe('change location "Paris" to "Hamburg"', () => { + it('has updated the location to "Hamburg"', async () => { + await expect( + mutate({ + mutation: updateGroupMutation(), + variables: { + id: 'my-group', + locationName: 'Hamburg, Germany', + }, + }), + ).resolves.toMatchObject({ + data: { + UpdateGroup: { + id: 'my-group', + locationName: 'Hamburg, Germany', + location: expect.objectContaining({ + name: 'Hamburg', + nameDE: 'Hamburg', + nameEN: 'Hamburg', + }), + myRole: 'owner', + }, + }, + errors: undefined, + }) + }) + }) + + describe('"locationName" is empty string – shall change location "Hamburg" to unset location ', () => { + it('has updated the location to unset', async () => { + await expect( + mutate({ + mutation: updateGroupMutation(), + variables: { + id: 'my-group', + locationName: '', // empty string '' sets it to null + }, + }), + ).resolves.toMatchObject({ + data: { + UpdateGroup: { + id: 'my-group', + locationName: null, + location: null, + myRole: 'owner', + }, + }, + errors: undefined, + }) + }) + }) + }) + + describe('description', () => { + describe('length without HTML', () => { + describe('less then 100 chars', () => { + it('throws error: "Description too short!"', async () => { + const { errors } = await mutate({ + mutation: updateGroupMutation(), + variables: { + id: 'my-group', + description: + '0123456789' + + '0123456789', + }, + }) + expect(errors[0]).toHaveProperty('message', 'Description too short!') + }) + }) + }) + }) + + describe('categories', () => { + beforeEach(async () => { + CONFIG.CATEGORIES_ACTIVE = true + }) + + describe('with matching amount of categories', () => { + it('has new categories', async () => { + await expect( + mutate({ + mutation: updateGroupMutation(), + variables: { + id: 'my-group', + categoryIds: ['cat4', 'cat27'], + }, + }), + ).resolves.toMatchObject({ + data: { + UpdateGroup: { + id: 'my-group', + categories: expect.arrayContaining([ + expect.objectContaining({ id: 'cat4' }), + expect.objectContaining({ id: 'cat27' }), + ]), + myRole: 'owner', + }, + }, + errors: undefined, + }) + }) + }) + + describe('not even one', () => { + describe('by "categoryIds: []"', () => { + it('throws error: "Too view categories!"', async () => { + const { errors } = await mutate({ + mutation: updateGroupMutation(), + variables: { + id: 'my-group', + categoryIds: [], + }, + }) + expect(errors[0]).toHaveProperty('message', 'Too view categories!') + }) + }) + }) + + describe('four', () => { + it('throws error: "Too many categories!"', async () => { + const { errors } = await mutate({ + mutation: updateGroupMutation(), + variables: { + id: 'my-group', + categoryIds: ['cat9', 'cat4', 'cat15', 'cat27'], + }, + }) + expect(errors[0]).toHaveProperty('message', 'Too many categories!') + }) + }) + }) + }) + + describe('as "usual-member-user" member, no(!) owner', () => { + it('throws authorization error', async () => { + authenticatedUser = await usualMemberUser.toJson() + const { errors } = await mutate({ + mutation: updateGroupMutation(), + variables: { + id: 'my-group', + name: 'The New Group For Our Country', + about: 'We will change the land!', + description: 'Some country relevant description' + descriptionAdditional100, + actionRadius: 'national', + categoryIds: ['cat4', 'cat27'], + }, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('as "none-member-user"', () => { + it('throws authorization error', async () => { + authenticatedUser = await noMemberUser.toJson() + const { errors } = await mutate({ + mutation: updateGroupMutation(), + variables: { + id: 'my-group', + name: 'The New Group For Our Country', + about: 'We will change the land!', + description: 'Some country relevant description' + descriptionAdditional100, + actionRadius: 'national', + categoryIds: ['cat4', 'cat27'], + }, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + }) + }) + }) + }) +}) diff --git a/backend/src/schema/resolvers/helpers/Resolver.js b/backend/src/schema/resolvers/helpers/Resolver.js index f2861e7a00..6e8211521c 100644 --- a/backend/src/schema/resolvers/helpers/Resolver.js +++ b/backend/src/schema/resolvers/helpers/Resolver.js @@ -121,3 +121,25 @@ export default function Resolver(type, options = {}) { } return result } + +export const removeUndefinedNullValuesFromObject = (obj) => { + Object.keys(obj).forEach((key) => { + if ([undefined, null].includes(obj[key])) { + delete obj[key] + } + }) +} + +export const convertObjectToCypherMapLiteral = (params, addSpaceInfrontIfMapIsNotEmpty = false) => { + // I have found no other way yet. maybe "apoc.convert.fromJsonMap(key)" can help, but couldn't get it how, see: https://stackoverflow.com/questions/43217823/neo4j-cypher-inline-conversion-of-string-to-a-map + // result looks like: '{id: "g0", slug: "yoga"}' + const paramsEntries = Object.entries(params) + let mapLiteral = '' + paramsEntries.forEach((ele, index) => { + mapLiteral += index === 0 ? '{' : '' + mapLiteral += `${ele[0]}: "${ele[1]}"` + mapLiteral += index < paramsEntries.length - 1 ? ', ' : '}' + }) + mapLiteral = (addSpaceInfrontIfMapIsNotEmpty && mapLiteral.length > 0 ? ' ' : '') + mapLiteral + return mapLiteral +} diff --git a/backend/src/schema/resolvers/helpers/filterInvisiblePosts.js b/backend/src/schema/resolvers/helpers/filterInvisiblePosts.js new file mode 100644 index 0000000000..73dfaad91c --- /dev/null +++ b/backend/src/schema/resolvers/helpers/filterInvisiblePosts.js @@ -0,0 +1,47 @@ +import { mergeWith, isArray } from 'lodash' + +const getInvisiblePosts = async (context) => { + const session = context.driver.session() + const readTxResultPromise = await session.readTransaction(async (transaction) => { + let cypher = '' + const { user } = context + if (user && user.id) { + cypher = ` + MATCH (post:Post)<-[:CANNOT_SEE]-(user:User { id: $userId }) + RETURN collect(post.id) AS invisiblePostIds` + } else { + cypher = ` + MATCH (post:Post)-[:IN]->(group:Group) + WHERE NOT group.groupType = 'public' + RETURN collect(post.id) AS invisiblePostIds` + } + const invisiblePostIdsResponse = await transaction.run(cypher, { + userId: user ? user.id : null, + }) + return invisiblePostIdsResponse.records.map((record) => record.get('invisiblePostIds')) + }) + try { + const [invisiblePostIds] = readTxResultPromise + return invisiblePostIds + } finally { + session.close() + } +} + +export const filterInvisiblePosts = async (params, context) => { + const invisiblePostIds = await getInvisiblePosts(context) + if (!invisiblePostIds.length) return params + + params.filter = mergeWith( + params.filter, + { + id_not_in: invisiblePostIds, + }, + (objValue, srcValue) => { + if (isArray(objValue)) { + return objValue.concat(srcValue) + } + }, + ) + return params +} diff --git a/backend/src/schema/resolvers/posts.js b/backend/src/schema/resolvers/posts.js index d9a04732c3..78515e6418 100644 --- a/backend/src/schema/resolvers/posts.js +++ b/backend/src/schema/resolvers/posts.js @@ -5,6 +5,7 @@ import { UserInputError } from 'apollo-server' import { mergeImage, deleteImage } from './images/images' import Resolver from './helpers/Resolver' import { filterForMutedUsers } from './helpers/filterForMutedUsers' +import { filterInvisiblePosts } from './helpers/filterInvisiblePosts' import CONFIG from '../../config' const maintainPinnedPosts = (params) => { @@ -20,15 +21,13 @@ const maintainPinnedPosts = (params) => { export default { Query: { Post: async (object, params, context, resolveInfo) => { + params = await filterInvisiblePosts(params, context) params = await filterForMutedUsers(params, context) params = await maintainPinnedPosts(params) return neo4jgraphql(object, params, context, resolveInfo) }, - findPosts: async (object, params, context, resolveInfo) => { - params = await filterForMutedUsers(params, context) - return neo4jgraphql(object, params, context, resolveInfo) - }, profilePagePosts: async (object, params, context, resolveInfo) => { + params = await filterInvisiblePosts(params, context) params = await filterForMutedUsers(params, context) return neo4jgraphql(object, params, context, resolveInfo) }, @@ -77,13 +76,37 @@ export default { }, Mutation: { CreatePost: async (_parent, params, context, _resolveInfo) => { - const { categoryIds } = params + const { categoryIds, groupId } = params const { image: imageInput } = params delete params.categoryIds delete params.image + delete params.groupId params.id = params.id || uuid() const session = context.driver.session() const writeTxResultPromise = session.writeTransaction(async (transaction) => { + let groupCypher = '' + if (groupId) { + groupCypher = ` + WITH post MATCH (group:Group { id: $groupId }) + MERGE (post)-[:IN]->(group)` + const groupTypeResponse = await transaction.run( + ` + MATCH (group:Group { id: $groupId }) RETURN group.groupType AS groupType`, + { groupId }, + ) + const [groupType] = groupTypeResponse.records.map((record) => record.get('groupType')) + if (groupType !== 'public') + groupCypher += ` + WITH post, group + MATCH (user:User)-[membership:MEMBER_OF]->(group) + WHERE group.groupType IN ['closed', 'hidden'] + AND membership.role IN ['usual', 'admin', 'owner'] + WITH post, collect(user.id) AS userIds + OPTIONAL MATCH path =(restricted:User) WHERE NOT restricted.id IN userIds + FOREACH (user IN nodes(path) | + MERGE (user)-[:CANNOT_SEE]->(post) + )` + } const categoriesCypher = CONFIG.CATEGORIES_ACTIVE && categoryIds ? `WITH post @@ -103,9 +126,10 @@ export default { MATCH (author:User {id: $userId}) MERGE (post)<-[:WROTE]-(author) ${categoriesCypher} + ${groupCypher} RETURN post {.*} `, - { userId: context.user.id, params, categoryIds }, + { userId: context.user.id, categoryIds, groupId, params }, ) const [post] = createPostTransactionResponse.records.map((record) => record.get('post')) if (imageInput) { @@ -131,11 +155,11 @@ export default { 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 - ` + MATCH (post:Post {id: $params.id}) + SET post += $params + SET post.updatedAt = toString(datetime()) + WITH post + ` if (CONFIG.CATEGORIES_ACTIVE && categoryIds && categoryIds.length) { const cypherDeletePreviousRelations = ` @@ -367,6 +391,7 @@ export default { author: '<-[:WROTE]-(related:User)', pinnedBy: '<-[:PINNED]-(related:User)', image: '-[:HERO_IMAGE]->(related:Image)', + group: '-[:IN]->(related:Group)', }, count: { commentsCount: diff --git a/backend/src/schema/resolvers/posts.spec.js b/backend/src/schema/resolvers/posts.spec.js index 52bd8fcd00..6fc9b57229 100644 --- a/backend/src/schema/resolvers/posts.spec.js +++ b/backend/src/schema/resolvers/posts.spec.js @@ -368,7 +368,7 @@ describe('UpdatePost', () => { describe('unauthenticated', () => { it('throws authorization error', async () => { authenticatedUser = null - expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject({ + await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject({ errors: [{ message: 'Not Authorized!' }], data: { UpdatePost: null }, }) diff --git a/backend/src/schema/resolvers/postsInGroups.spec.js b/backend/src/schema/resolvers/postsInGroups.spec.js new file mode 100644 index 0000000000..d17c928ecc --- /dev/null +++ b/backend/src/schema/resolvers/postsInGroups.spec.js @@ -0,0 +1,1516 @@ +import { createTestClient } from 'apollo-server-testing' +import Factory, { cleanDatabase } from '../../db/factories' +import { getNeode, getDriver } from '../../db/neo4j' +import createServer from '../../server' +import { + createGroupMutation, + changeGroupMemberRoleMutation, + leaveGroupMutation, +} from '../../db/graphql/groups' +import { + createPostMutation, + postQuery, + filterPosts, + profilePagePosts, + searchPosts, +} from '../../db/graphql/posts' +// eslint-disable-next-line no-unused-vars +import { DESCRIPTION_WITHOUT_HTML_LENGTH_MIN } from '../../constants/groups' +import CONFIG from '../../config' +import { signupVerificationMutation } from '../../db/graphql/authentications' + +CONFIG.CATEGORIES_ACTIVE = false + +jest.mock('../../constants/groups', () => { + return { + __esModule: true, + DESCRIPTION_WITHOUT_HTML_LENGTH_MIN: 5, + } +}) + +const driver = getDriver() +const neode = getNeode() + +let query +let mutate +let anyUser +let allGroupsUser +let pendingUser +let publicUser +let closedUser +let hiddenUser +let authenticatedUser +let newUser + +beforeAll(async () => { + await cleanDatabase() + + const { server } = createServer({ + context: () => { + return { + driver, + neode, + user: authenticatedUser, + } + }, + }) + query = createTestClient(server).query + mutate = createTestClient(server).mutate +}) + +afterAll(async () => { + await cleanDatabase() +}) + +describe('Posts in Groups', () => { + beforeAll(async () => { + anyUser = await Factory.build('user', { + id: 'any-user', + name: 'Any User', + about: 'I am just an ordinary user and do not belong to any group.', + }) + + allGroupsUser = await Factory.build('user', { + id: 'all-groups-user', + name: 'All Groups User', + about: 'I am a member of all groups.', + }) + pendingUser = await Factory.build('user', { + id: 'pending-user', + name: 'Pending User', + about: 'I am a pending member of all groups.', + }) + publicUser = await Factory.build('user', { + id: 'public-user', + name: 'Public User', + about: 'I am the owner of the public group.', + }) + + closedUser = await Factory.build('user', { + id: 'closed-user', + name: 'Private User', + about: 'I am the owner of the closed group.', + }) + + hiddenUser = await Factory.build('user', { + id: 'hidden-user', + name: 'Secret User', + about: 'I am the owner of the hidden group.', + }) + + authenticatedUser = await publicUser.toJson() + await mutate({ + mutation: createGroupMutation(), + variables: { + id: 'public-group', + name: 'The Public Group', + about: 'The public group!', + description: 'Anyone can see the posts of this group.', + groupType: 'public', + actionRadius: 'regional', + }, + }) + await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'public-group', + userId: 'pending-user', + roleInGroup: 'pending', + }, + }) + await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'public-group', + userId: 'all-groups-user', + roleInGroup: 'usual', + }, + }) + authenticatedUser = await closedUser.toJson() + await mutate({ + mutation: createGroupMutation(), + variables: { + id: 'closed-group', + name: 'The Closed Group', + about: 'The closed group!', + description: 'Only members of this group can see the posts of this group.', + groupType: 'closed', + actionRadius: 'regional', + }, + }) + await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'closed-group', + userId: 'pending-user', + roleInGroup: 'pending', + }, + }) + await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'closed-group', + userId: 'all-groups-user', + roleInGroup: 'usual', + }, + }) + authenticatedUser = await hiddenUser.toJson() + await mutate({ + mutation: createGroupMutation(), + variables: { + id: 'hidden-group', + name: 'The Hidden Group', + about: 'The hidden group!', + description: 'Only members of this group can see the posts of this group.', + groupType: 'hidden', + actionRadius: 'regional', + }, + }) + await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'hidden-group', + userId: 'pending-user', + roleInGroup: 'pending', + }, + }) + await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'hidden-group', + userId: 'all-groups-user', + roleInGroup: 'usual', + }, + }) + authenticatedUser = await anyUser.toJson() + await mutate({ + mutation: createPostMutation(), + variables: { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + }) + }) + + describe('creating posts in groups', () => { + describe('without membership of group', () => { + beforeAll(async () => { + authenticatedUser = await anyUser.toJson() + }) + + it('throws an error for public groups', async () => { + await expect( + mutate({ + mutation: createPostMutation(), + variables: { + id: 'p2', + title: 'A post to a pubic group', + content: 'I am posting into a public group without being a member of the group', + groupId: 'public-group', + }, + }), + ).resolves.toMatchObject({ + errors: expect.arrayContaining([expect.objectContaining({ message: 'Not Authorized!' })]), + }) + }) + + it('throws an error for closed groups', async () => { + await expect( + mutate({ + mutation: createPostMutation(), + variables: { + id: 'p2', + title: 'A post to a closed group', + content: 'I am posting into a closed group without being a member of the group', + groupId: 'closed-group', + }, + }), + ).resolves.toMatchObject({ + errors: expect.arrayContaining([expect.objectContaining({ message: 'Not Authorized!' })]), + }) + }) + + it('throws an error for hidden groups', async () => { + await expect( + mutate({ + mutation: createPostMutation(), + variables: { + id: 'p2', + title: 'A post to a closed group', + content: 'I am posting into a hidden group without being a member of the group', + groupId: 'hidden-group', + }, + }), + ).resolves.toMatchObject({ + errors: expect.arrayContaining([expect.objectContaining({ message: 'Not Authorized!' })]), + }) + }) + }) + + describe('as a pending member of group', () => { + beforeAll(async () => { + authenticatedUser = await pendingUser.toJson() + }) + + it('throws an error for public groups', async () => { + await expect( + mutate({ + mutation: createPostMutation(), + variables: { + id: 'p2', + title: 'A post to a pubic group', + content: 'I am posting into a public group with a pending membership', + groupId: 'public-group', + }, + }), + ).resolves.toMatchObject({ + errors: expect.arrayContaining([expect.objectContaining({ message: 'Not Authorized!' })]), + }) + }) + + it('throws an error for closed groups', async () => { + await expect( + mutate({ + mutation: createPostMutation(), + variables: { + id: 'p2', + title: 'A post to a closed group', + content: 'I am posting into a closed group with a pending membership', + groupId: 'closed-group', + }, + }), + ).resolves.toMatchObject({ + errors: expect.arrayContaining([expect.objectContaining({ message: 'Not Authorized!' })]), + }) + }) + + it('throws an error for hidden groups', async () => { + await expect( + mutate({ + mutation: createPostMutation(), + variables: { + id: 'p2', + title: 'A post to a closed group', + content: 'I am posting into a hidden group with a pending membership', + groupId: 'hidden-group', + }, + }), + ).resolves.toMatchObject({ + errors: expect.arrayContaining([expect.objectContaining({ message: 'Not Authorized!' })]), + }) + }) + }) + + describe('as a member of group', () => { + beforeAll(async () => { + authenticatedUser = await allGroupsUser.toJson() + }) + + it('creates a post for public groups', async () => { + await expect( + mutate({ + mutation: createPostMutation(), + variables: { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + groupId: 'public-group', + }, + }), + ).resolves.toMatchObject({ + data: { + CreatePost: { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + }, + errors: undefined, + }) + }) + + it('creates a post for closed groups', async () => { + await expect( + mutate({ + mutation: createPostMutation(), + variables: { + id: 'post-to-closed-group', + title: 'A post to a closed group', + content: 'I am posting into a closed group as a member of the group', + groupId: 'closed-group', + }, + }), + ).resolves.toMatchObject({ + data: { + CreatePost: { + id: 'post-to-closed-group', + title: 'A post to a closed group', + content: 'I am posting into a closed group as a member of the group', + }, + }, + errors: undefined, + }) + }) + + it('creates a post for hidden groups', async () => { + await expect( + mutate({ + mutation: createPostMutation(), + variables: { + id: 'post-to-hidden-group', + title: 'A post to a hidden group', + content: 'I am posting into a hidden group as a member of the group', + groupId: 'hidden-group', + }, + }), + ).resolves.toMatchObject({ + data: { + CreatePost: { + id: 'post-to-hidden-group', + title: 'A post to a hidden group', + content: 'I am posting into a hidden group as a member of the group', + }, + }, + errors: undefined, + }) + }) + }) + }) + + describe('visibility of posts', () => { + describe('query post by ID', () => { + describe('without authentication', () => { + beforeAll(async () => { + authenticatedUser = null + }) + + it('shows a post of the public group', async () => { + await expect( + query({ query: postQuery(), variables: { id: 'post-to-public-group' } }), + ).resolves.toMatchObject({ + data: { + Post: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + ]), + }, + errors: undefined, + }) + }) + + it('does not show a post of a closed group', async () => { + await expect( + query({ query: postQuery(), variables: { id: 'post-to-closed-group' } }), + ).resolves.toMatchObject({ + data: { + Post: [], + }, + errors: undefined, + }) + }) + + it('does not show a post of a hidden group', async () => { + await expect( + query({ query: postQuery(), variables: { id: 'post-to-hidden-group' } }), + ).resolves.toMatchObject({ + data: { + Post: [], + }, + errors: undefined, + }) + }) + }) + + describe('as new user', () => { + beforeAll(async () => { + await Factory.build('emailAddress', { + email: 'new-user@example.org', + nonce: '12345', + verifiedAt: null, + }) + const result = await mutate({ + mutation: signupVerificationMutation, + variables: { + name: 'New User', + slug: 'new-user', + nonce: '12345', + password: '1234', + about: 'I am a new user!', + email: 'new-user@example.org', + termsAndConditionsAgreedVersion: '0.0.1', + }, + }) + newUser = result.data.SignupVerification + authenticatedUser = newUser + }) + + it('shows a post of the public group', async () => { + await expect( + query({ query: postQuery(), variables: { id: 'post-to-public-group' } }), + ).resolves.toMatchObject({ + data: { + Post: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + ]), + }, + errors: undefined, + }) + }) + + it('does not show a post of a closed group', async () => { + await expect( + query({ query: postQuery(), variables: { id: 'post-to-closed-group' } }), + ).resolves.toMatchObject({ + data: { + Post: [], + }, + errors: undefined, + }) + }) + + it('does not show a post of a hidden group', async () => { + await expect( + query({ query: postQuery(), variables: { id: 'post-to-hidden-group' } }), + ).resolves.toMatchObject({ + data: { + Post: [], + }, + errors: undefined, + }) + }) + }) + + describe('without membership of group', () => { + beforeAll(async () => { + authenticatedUser = await anyUser.toJson() + }) + + it('shows a post of the public group', async () => { + await expect( + query({ query: postQuery(), variables: { id: 'post-to-public-group' } }), + ).resolves.toMatchObject({ + data: { + Post: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + ]), + }, + errors: undefined, + }) + }) + + it('does not show a post of a closed group', async () => { + await expect( + query({ query: postQuery(), variables: { id: 'post-to-closed-group' } }), + ).resolves.toMatchObject({ + data: { + Post: [], + }, + errors: undefined, + }) + }) + + it('does not show a post of a hidden group', async () => { + await expect( + query({ query: postQuery(), variables: { id: 'post-to-hidden-group' } }), + ).resolves.toMatchObject({ + data: { + Post: [], + }, + errors: undefined, + }) + }) + }) + + describe('with pending membership of group', () => { + beforeAll(async () => { + authenticatedUser = await pendingUser.toJson() + }) + + it('shows a post of the public group', async () => { + await expect( + query({ query: postQuery(), variables: { id: 'post-to-public-group' } }), + ).resolves.toMatchObject({ + data: { + Post: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + ]), + }, + errors: undefined, + }) + }) + + it('does not show a post of a closed group', async () => { + await expect( + query({ query: postQuery(), variables: { id: 'post-to-closed-group' } }), + ).resolves.toMatchObject({ + data: { + Post: [], + }, + errors: undefined, + }) + }) + + it('does not show a post of a hidden group', async () => { + await expect( + query({ query: postQuery(), variables: { id: 'post-to-hidden-group' } }), + ).resolves.toMatchObject({ + data: { + Post: [], + }, + errors: undefined, + }) + }) + }) + + describe('as member of group', () => { + beforeAll(async () => { + authenticatedUser = await allGroupsUser.toJson() + }) + + it('shows post of the public group', async () => { + await expect( + query({ query: postQuery(), variables: { id: 'post-to-public-group' } }), + ).resolves.toMatchObject({ + data: { + Post: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + ]), + }, + errors: undefined, + }) + }) + + it('shows post of a closed group', async () => { + await expect( + query({ query: postQuery(), variables: { id: 'post-to-closed-group' } }), + ).resolves.toMatchObject({ + data: { + Post: expect.arrayContaining([ + { + id: 'post-to-closed-group', + title: 'A post to a closed group', + content: 'I am posting into a closed group as a member of the group', + }, + ]), + }, + errors: undefined, + }) + }) + + it('shows post of a hidden group', async () => { + await expect( + query({ query: postQuery(), variables: { id: 'post-to-hidden-group' } }), + ).resolves.toMatchObject({ + data: { + Post: expect.arrayContaining([ + { + id: 'post-to-hidden-group', + title: 'A post to a hidden group', + content: 'I am posting into a hidden group as a member of the group', + }, + ]), + }, + errors: undefined, + }) + }) + }) + }) + + describe('filter posts', () => { + describe('without authentication', () => { + beforeAll(async () => { + authenticatedUser = null + }) + + it('shows the post of the public group and the post without group', async () => { + const result = await query({ query: filterPosts(), variables: {} }) + expect(result.data.Post).toHaveLength(2) + expect(result).toMatchObject({ + data: { + Post: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + ]), + }, + errors: undefined, + }) + }) + }) + + describe('as new user', () => { + beforeAll(async () => { + authenticatedUser = newUser + }) + + it('shows the post of the public group and the post without group', async () => { + const result = await query({ query: filterPosts(), variables: {} }) + expect(result.data.Post).toHaveLength(2) + expect(result).toMatchObject({ + data: { + Post: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + ]), + }, + errors: undefined, + }) + }) + }) + + describe('without membership of group', () => { + beforeAll(async () => { + authenticatedUser = await anyUser.toJson() + }) + + it('shows the post of the public group and the post without group', async () => { + const result = await query({ query: filterPosts(), variables: {} }) + expect(result.data.Post).toHaveLength(2) + expect(result).toMatchObject({ + data: { + Post: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + ]), + }, + errors: undefined, + }) + }) + }) + + describe('with pending membership of group', () => { + beforeAll(async () => { + authenticatedUser = await pendingUser.toJson() + }) + + it('shows the post of the public group and the post without group', async () => { + const result = await query({ query: filterPosts(), variables: {} }) + expect(result.data.Post).toHaveLength(2) + expect(result).toMatchObject({ + data: { + Post: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + ]), + }, + errors: undefined, + }) + }) + }) + + describe('as member of group', () => { + beforeAll(async () => { + authenticatedUser = await allGroupsUser.toJson() + }) + + it('shows all posts', async () => { + const result = await query({ query: filterPosts(), variables: {} }) + expect(result.data.Post).toHaveLength(4) + expect(result).toMatchObject({ + data: { + Post: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + { + id: 'post-to-closed-group', + title: 'A post to a closed group', + content: 'I am posting into a closed group as a member of the group', + }, + { + id: 'post-to-hidden-group', + title: 'A post to a hidden group', + content: 'I am posting into a hidden group as a member of the group', + }, + ]), + }, + errors: undefined, + }) + }) + }) + }) + + describe('profile page posts', () => { + describe('without authentication', () => { + beforeAll(async () => { + authenticatedUser = null + }) + + it('shows the post of the public group and the post without group', async () => { + const result = await query({ query: profilePagePosts(), variables: {} }) + expect(result.data.profilePagePosts).toHaveLength(2) + expect(result).toMatchObject({ + data: { + profilePagePosts: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + ]), + }, + errors: undefined, + }) + }) + }) + + describe('as new user', () => { + beforeAll(async () => { + authenticatedUser = newUser + }) + + it('shows the post of the public group and the post without group', async () => { + const result = await query({ query: profilePagePosts(), variables: {} }) + expect(result.data.profilePagePosts).toHaveLength(2) + expect(result).toMatchObject({ + data: { + profilePagePosts: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + ]), + }, + errors: undefined, + }) + }) + }) + + describe('without membership of group', () => { + beforeAll(async () => { + authenticatedUser = await anyUser.toJson() + }) + + it('shows the post of the public group and the post without group', async () => { + const result = await query({ query: profilePagePosts(), variables: {} }) + expect(result.data.profilePagePosts).toHaveLength(2) + expect(result).toMatchObject({ + data: { + profilePagePosts: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + ]), + }, + errors: undefined, + }) + }) + }) + + describe('with pending membership of group', () => { + beforeAll(async () => { + authenticatedUser = await pendingUser.toJson() + }) + + it('shows the post of the public group and the post without group', async () => { + const result = await query({ query: profilePagePosts(), variables: {} }) + expect(result.data.profilePagePosts).toHaveLength(2) + expect(result).toMatchObject({ + data: { + profilePagePosts: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + ]), + }, + errors: undefined, + }) + }) + }) + + describe('as member of group', () => { + beforeAll(async () => { + authenticatedUser = await allGroupsUser.toJson() + }) + + it('shows all posts', async () => { + const result = await query({ query: profilePagePosts(), variables: {} }) + expect(result.data.profilePagePosts).toHaveLength(4) + expect(result).toMatchObject({ + data: { + profilePagePosts: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + { + id: 'post-to-closed-group', + title: 'A post to a closed group', + content: 'I am posting into a closed group as a member of the group', + }, + { + id: 'post-to-hidden-group', + title: 'A post to a hidden group', + content: 'I am posting into a hidden group as a member of the group', + }, + ]), + }, + errors: undefined, + }) + }) + }) + }) + + describe('searchPosts', () => { + describe('without authentication', () => { + beforeAll(async () => { + authenticatedUser = null + }) + + it('finds nothing', async () => { + const result = await query({ + query: searchPosts(), + variables: { + query: 'post', + postsOffset: 0, + firstPosts: 25, + }, + }) + expect(result.data.searchPosts.posts).toHaveLength(0) + expect(result).toMatchObject({ + data: { + searchPosts: { + postCount: 0, + posts: [], + }, + }, + }) + }) + }) + + describe('as new user', () => { + beforeAll(async () => { + authenticatedUser = newUser + }) + + it('finds the post of the public group and the post without group', async () => { + const result = await query({ + query: searchPosts(), + variables: { + query: 'post', + postsOffset: 0, + firstPosts: 25, + }, + }) + expect(result.data.searchPosts.posts).toHaveLength(2) + expect(result).toMatchObject({ + data: { + searchPosts: { + postCount: 2, + posts: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + ]), + }, + }, + }) + }) + }) + + describe('without membership of group', () => { + beforeAll(async () => { + authenticatedUser = await anyUser.toJson() + }) + + it('finds the post of the public group and the post without group', async () => { + const result = await query({ + query: searchPosts(), + variables: { + query: 'post', + postsOffset: 0, + firstPosts: 25, + }, + }) + expect(result.data.searchPosts.posts).toHaveLength(2) + expect(result).toMatchObject({ + data: { + searchPosts: { + postCount: 2, + posts: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + ]), + }, + }, + }) + }) + }) + + describe('with pending membership of group', () => { + beforeAll(async () => { + authenticatedUser = await pendingUser.toJson() + }) + + it('finds the post of the public group and the post without group', async () => { + const result = await query({ + query: searchPosts(), + variables: { + query: 'post', + postsOffset: 0, + firstPosts: 25, + }, + }) + expect(result.data.searchPosts.posts).toHaveLength(2) + expect(result).toMatchObject({ + data: { + searchPosts: { + postCount: 2, + posts: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + ]), + }, + }, + }) + }) + }) + + describe('as member of group', () => { + beforeAll(async () => { + authenticatedUser = await allGroupsUser.toJson() + }) + + it('finds all posts', async () => { + const result = await query({ + query: searchPosts(), + variables: { + query: 'post', + postsOffset: 0, + firstPosts: 25, + }, + }) + expect(result.data.searchPosts.posts).toHaveLength(4) + expect(result).toMatchObject({ + data: { + searchPosts: { + postCount: 4, + posts: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + { + id: 'post-to-closed-group', + title: 'A post to a closed group', + content: 'I am posting into a closed group as a member of the group', + }, + { + id: 'post-to-hidden-group', + title: 'A post to a hidden group', + content: 'I am posting into a hidden group as a member of the group', + }, + ]), + }, + }, + }) + }) + }) + }) + }) + + describe('changes of group membership', () => { + describe('pending member becomes usual member', () => { + describe('of closed group', () => { + beforeAll(async () => { + authenticatedUser = await closedUser.toJson() + await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'closed-group', + userId: 'pending-user', + roleInGroup: 'usual', + }, + }) + authenticatedUser = await pendingUser.toJson() + }) + + it('shows the posts of the closed group', async () => { + const result = await query({ query: filterPosts(), variables: {} }) + expect(result.data.Post).toHaveLength(3) + expect(result).toMatchObject({ + data: { + Post: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + { + id: 'post-to-closed-group', + title: 'A post to a closed group', + content: 'I am posting into a closed group as a member of the group', + }, + ]), + }, + errors: undefined, + }) + }) + }) + + describe('of hidden group', () => { + beforeAll(async () => { + authenticatedUser = await hiddenUser.toJson() + await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'hidden-group', + userId: 'pending-user', + roleInGroup: 'usual', + }, + }) + authenticatedUser = await pendingUser.toJson() + }) + + it('shows all the posts', async () => { + const result = await query({ query: filterPosts(), variables: {} }) + expect(result.data.Post).toHaveLength(4) + expect(result).toMatchObject({ + data: { + Post: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + { + id: 'post-to-closed-group', + title: 'A post to a closed group', + content: 'I am posting into a closed group as a member of the group', + }, + { + id: 'post-to-hidden-group', + title: 'A post to a hidden group', + content: 'I am posting into a hidden group as a member of the group', + }, + ]), + }, + errors: undefined, + }) + }) + }) + }) + + describe('usual member becomes pending', () => { + describe('of closed group', () => { + beforeAll(async () => { + authenticatedUser = await closedUser.toJson() + await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'closed-group', + userId: 'pending-user', + roleInGroup: 'pending', + }, + }) + authenticatedUser = await pendingUser.toJson() + }) + + it('does not show the posts of the closed group anymore', async () => { + const result = await query({ query: filterPosts(), variables: {} }) + expect(result.data.Post).toHaveLength(3) + expect(result).toMatchObject({ + data: { + Post: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + { + id: 'post-to-hidden-group', + title: 'A post to a hidden group', + content: 'I am posting into a hidden group as a member of the group', + }, + ]), + }, + errors: undefined, + }) + }) + }) + + describe('of hidden group', () => { + beforeAll(async () => { + authenticatedUser = await hiddenUser.toJson() + await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'hidden-group', + userId: 'pending-user', + roleInGroup: 'pending', + }, + }) + authenticatedUser = await pendingUser.toJson() + }) + + it('shows only the public posts', async () => { + const result = await query({ query: filterPosts(), variables: {} }) + expect(result.data.Post).toHaveLength(2) + expect(result).toMatchObject({ + data: { + Post: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + ]), + }, + errors: undefined, + }) + }) + }) + }) + + describe('usual member leaves', () => { + describe('public group', () => { + beforeAll(async () => { + authenticatedUser = await allGroupsUser.toJson() + await mutate({ + mutation: leaveGroupMutation(), + variables: { + groupId: 'public-group', + userId: 'all-groups-user', + }, + }) + }) + + it('still shows the posts of the public group', async () => { + const result = await query({ query: filterPosts(), variables: {} }) + expect(result.data.Post).toHaveLength(4) + expect(result).toMatchObject({ + data: { + Post: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + { + id: 'post-to-closed-group', + title: 'A post to a closed group', + content: 'I am posting into a closed group as a member of the group', + }, + { + id: 'post-to-hidden-group', + title: 'A post to a hidden group', + content: 'I am posting into a hidden group as a member of the group', + }, + ]), + }, + errors: undefined, + }) + }) + }) + + describe('closed group', () => { + beforeAll(async () => { + authenticatedUser = await allGroupsUser.toJson() + await mutate({ + mutation: leaveGroupMutation(), + variables: { + groupId: 'closed-group', + userId: 'all-groups-user', + }, + }) + }) + + it('does not show the posts of the closed group anymore', async () => { + const result = await query({ query: filterPosts(), variables: {} }) + expect(result.data.Post).toHaveLength(3) + expect(result).toMatchObject({ + data: { + Post: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + { + id: 'post-to-hidden-group', + title: 'A post to a hidden group', + content: 'I am posting into a hidden group as a member of the group', + }, + ]), + }, + errors: undefined, + }) + }) + }) + + describe('hidden group', () => { + beforeAll(async () => { + authenticatedUser = await allGroupsUser.toJson() + await mutate({ + mutation: leaveGroupMutation(), + variables: { + groupId: 'hidden-group', + userId: 'all-groups-user', + }, + }) + }) + + it('does only show the public posts', async () => { + const result = await query({ query: filterPosts(), variables: {} }) + expect(result.data.Post).toHaveLength(2) + expect(result).toMatchObject({ + data: { + Post: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + ]), + }, + errors: undefined, + }) + }) + }) + }) + + describe('any user joins', () => { + describe('closed group', () => { + beforeAll(async () => { + authenticatedUser = await closedUser.toJson() + await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'closed-group', + userId: 'all-groups-user', + roleInGroup: 'usual', + }, + }) + authenticatedUser = await allGroupsUser.toJson() + }) + + it('does not show the posts of the closed group', async () => { + const result = await query({ query: filterPosts(), variables: {} }) + expect(result.data.Post).toHaveLength(3) + expect(result).toMatchObject({ + data: { + Post: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + { + id: 'post-to-closed-group', + title: 'A post to a closed group', + content: 'I am posting into a closed group as a member of the group', + }, + ]), + }, + errors: undefined, + }) + }) + }) + + describe('hidden group', () => { + beforeAll(async () => { + authenticatedUser = await hiddenUser.toJson() + await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'hidden-group', + userId: 'all-groups-user', + roleInGroup: 'usual', + }, + }) + authenticatedUser = await allGroupsUser.toJson() + }) + + it('shows all posts', async () => { + const result = await query({ query: filterPosts(), variables: {} }) + expect(result.data.Post).toHaveLength(4) + expect(result).toMatchObject({ + data: { + Post: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + { + id: 'post-to-closed-group', + title: 'A post to a closed group', + content: 'I am posting into a closed group as a member of the group', + }, + { + id: 'post-to-hidden-group', + title: 'A post to a hidden group', + content: 'I am posting into a hidden group as a member of the group', + }, + ]), + }, + errors: undefined, + }) + }) + }) + }) + }) +}) diff --git a/backend/src/schema/resolvers/registration.js b/backend/src/schema/resolvers/registration.js index ea420bc2ae..52c92b0335 100644 --- a/backend/src/schema/resolvers/registration.js +++ b/backend/src/schema/resolvers/registration.js @@ -72,19 +72,19 @@ const signupCypher = (inviteCode) => { (inviteCode:InviteCode {code: $inviteCode})<-[:GENERATED]-(host:User) ` optionalMerge = ` - MERGE(user)-[:REDEEMED { createdAt: toString(datetime()) }]->(inviteCode) - MERGE(host)-[:INVITED { createdAt: toString(datetime()) }]->(user) - MERGE(user)-[:FOLLOWS { createdAt: toString(datetime()) }]->(host) - MERGE(host)-[:FOLLOWS { createdAt: toString(datetime()) }]->(user) + MERGE (user)-[:REDEEMED { createdAt: toString(datetime()) }]->(inviteCode) + MERGE (host)-[:INVITED { createdAt: toString(datetime()) }]->(user) + MERGE (user)-[:FOLLOWS { createdAt: toString(datetime()) }]->(host) + MERGE (host)-[:FOLLOWS { createdAt: toString(datetime()) }]->(user) ` } const cypher = ` - MATCH(email:EmailAddress {nonce: $nonce, email: $email}) + MATCH (email:EmailAddress {nonce: $nonce, email: $email}) WHERE NOT (email)-[:BELONGS_TO]->() ${optionalMatch} CREATE (user:User) - MERGE(user)-[:PRIMARY_EMAIL]->(email) - MERGE(user)<-[:BELONGS_TO]-(email) + MERGE (user)-[:PRIMARY_EMAIL]->(email) + MERGE (user)<-[:BELONGS_TO]-(email) ${optionalMerge} SET user += $args SET user.id = randomUUID() @@ -95,6 +95,13 @@ const signupCypher = (inviteCode) => { SET user.showShoutsPublicly = false SET user.sendNotificationEmails = true SET email.verifiedAt = toString(datetime()) + WITH user + OPTIONAL MATCH (post:Post)-[:IN]->(group:Group) + WHERE NOT group.groupType = 'public' + WITH user, collect(post) AS invisiblePosts + FOREACH (invisiblePost IN invisiblePosts | + MERGE (user)-[:CANNOT_SEE]->(invisiblePost) + ) RETURN user {.*} ` return cypher diff --git a/backend/src/schema/resolvers/searches.js b/backend/src/schema/resolvers/searches.js index 60fd4318fc..3fdf22da3c 100644 --- a/backend/src/schema/resolvers/searches.js +++ b/backend/src/schema/resolvers/searches.js @@ -23,12 +23,15 @@ const postWhereClause = `WHERE score >= 0.0 AND NOT ( author.deleted = true OR author.disabled = true OR resource.deleted = true OR resource.disabled = true - OR (:User {id: $userId})-[:MUTED]->(author) - )` + ) AND block IS NULL AND restriction IS NULL` const searchPostsSetup = { fulltextIndex: 'post_fulltext_search', - match: 'MATCH (resource:Post)<-[:WROTE]-(author:User)', + match: `MATCH (resource:Post)<-[:WROTE]-(author:User) + MATCH (user:User {id: $userId}) + OPTIONAL MATCH (user)-[block:MUTED]->(author) + OPTIONAL MATCH (user)-[restriction:CANNOT_SEE]->(resource) + WITH user, resource, author, block, restriction`, whereClause: postWhereClause, withClause: `WITH resource, author, [(resource)<-[:COMMENTS]-(comment:Comment) | comment] AS comments, @@ -63,6 +66,21 @@ const searchHashtagsSetup = { limit: 'LIMIT $limit', } +const searchGroupsSetup = { + fulltextIndex: 'group_fulltext_search', + match: `MATCH (resource:Group) + MATCH (user:User {id: $userId}) + OPTIONAL MATCH (user)-[membership:MEMBER_OF]->(resource) + WITH user, resource, membership`, + whereClause: `WHERE score >= 0.0 + AND NOT (resource.deleted = true OR resource.disabled = true) + AND (resource.groupType IN ['public', 'closed'] + OR membership.role IN ['usual', 'admin', 'owner'])`, + withClause: 'WITH resource, membership', + returnClause: 'resource { .*, myRole: membership.role, __typename: labels(resource)[0] }', + limit: 'LIMIT $limit', +} + const countSetup = { returnClause: 'toString(size(collect(resource)))', limit: '', @@ -80,6 +98,10 @@ const countHashtagsSetup = { ...searchHashtagsSetup, ...countSetup, } +const countGroupsSetup = { + ...searchGroupsSetup, + ...countSetup, +} const searchResultPromise = async (session, setup, params) => { return session.readTransaction(async (transaction) => { @@ -110,14 +132,15 @@ const multiSearchMap = [ { symbol: '!', setup: searchPostsSetup, resultName: 'posts' }, { symbol: '@', setup: searchUsersSetup, resultName: 'users' }, { symbol: '#', setup: searchHashtagsSetup, resultName: 'hashtags' }, + { symbol: '&', setup: searchGroupsSetup, resultName: 'groups' }, ] export default { Query: { searchPosts: async (_parent, args, context, _resolveInfo) => { const { query, postsOffset, firstPosts } = args - const { id: userId } = context.user - + let userId = null + if (context.user) userId = context.user.id return { postCount: getSearchResults( context, @@ -175,12 +198,36 @@ export default { }), } }, + searchGroups: async (_parent, args, context, _resolveInfo) => { + const { query, groupsOffset, firstGroups } = args + let userId = null + if (context.user) userId = context.user.id + return { + groupCount: getSearchResults( + context, + countGroupsSetup, + { + query: queryString(query), + skip: 0, + userId, + }, + countResultCallback, + ), + groups: getSearchResults(context, searchGroupsSetup, { + query: queryString(query), + skip: groupsOffset, + limit: firstGroups, + userId, + }), + } + }, searchResults: async (_parent, args, context, _resolveInfo) => { const { query, limit } = args - const { id: userId } = context.user + let userId = null + if (context.user) userId = context.user.id - const searchType = query.replace(/^([!@#]?).*$/, '$1') - const searchString = query.replace(/^([!@#])/, '') + const searchType = query.replace(/^([!@#&]?).*$/, '$1') + const searchString = query.replace(/^([!@#&])/, '') const params = { query: queryString(searchString), @@ -193,6 +240,7 @@ export default { return [ ...(await getSearchResults(context, searchPostsSetup, params)), ...(await getSearchResults(context, searchUsersSetup, params)), + ...(await getSearchResults(context, searchGroupsSetup, params)), ...(await getSearchResults(context, searchHashtagsSetup, params)), ] diff --git a/backend/src/schema/resolvers/user_management.spec.js b/backend/src/schema/resolvers/user_management.spec.js index e7f2f3ed1b..50e6e84bf9 100644 --- a/backend/src/schema/resolvers/user_management.spec.js +++ b/backend/src/schema/resolvers/user_management.spec.js @@ -2,6 +2,7 @@ import jwt from 'jsonwebtoken' import CONFIG from './../../config' import Factory, { cleanDatabase } from '../../db/factories' import { gql } from '../../helpers/jest' +import { loginMutation } from '../../db/graphql/userManagement' import { createTestClient } from 'apollo-server-testing' import createServer, { context } from '../../server' import encode from '../../jwt/encode' @@ -225,12 +226,6 @@ describe('currentUser', () => { }) describe('login', () => { - const loginMutation = gql` - mutation ($email: String!, $password: String!) { - login(email: $email, password: $password) - } - ` - const respondsWith = async (expected) => { await expect(mutate({ mutation: loginMutation, variables })).resolves.toMatchObject(expected) } diff --git a/backend/src/schema/resolvers/users.js b/backend/src/schema/resolvers/users.js index 12f00ffb66..1ce3b986f6 100644 --- a/backend/src/schema/resolvers/users.js +++ b/backend/src/schema/resolvers/users.js @@ -4,7 +4,7 @@ import { UserInputError, ForbiddenError } from 'apollo-server' import { mergeImage, deleteImage } from './images/images' import Resolver from './helpers/Resolver' import log from './helpers/databaseLogger' -import createOrUpdateLocations from './users/location' +import { createOrUpdateLocations } from './users/location' const neode = getNeode() @@ -139,9 +139,10 @@ export default { return blockedUser.toJson() }, UpdateUser: async (_parent, params, context, _resolveInfo) => { - const { termsAndConditionsAgreedVersion } = params const { avatar: avatarInput } = params delete params.avatar + params.locationName = params.locationName === '' ? null : params.locationName + const { termsAndConditionsAgreedVersion } = params if (termsAndConditionsAgreedVersion) { const regEx = new RegExp(/^[0-9]+\.[0-9]+\.[0-9]+$/g) if (!regEx.test(termsAndConditionsAgreedVersion)) { @@ -169,7 +170,8 @@ export default { }) try { const user = await writeTxResultPromise - await createOrUpdateLocations(params.id, params.locationName, session) + // TODO: put in a middleware, see "CreateGroup", "UpdateGroup" + await createOrUpdateLocations('User', params.id, params.locationName, session) return user } catch (error) { throw new UserInputError(error.message) diff --git a/backend/src/schema/resolvers/users.spec.js b/backend/src/schema/resolvers/users.spec.js index d8fce3b296..87226ec4d5 100644 --- a/backend/src/schema/resolvers/users.spec.js +++ b/backend/src/schema/resolvers/users.spec.js @@ -161,7 +161,7 @@ describe('UpdateUser', () => { $id: ID! $name: String $termsAndConditionsAgreedVersion: String - $locationName: String + $locationName: String # empty string '' sets it to null ) { UpdateUser( id: $id @@ -174,6 +174,11 @@ describe('UpdateUser', () => { termsAndConditionsAgreedVersion termsAndConditionsAgreedAt locationName + location { + name + nameDE + nameEN + } } } ` @@ -289,11 +294,39 @@ describe('UpdateUser', () => { expect(errors[0]).toHaveProperty('message', 'Invalid version format!') }) - it('supports updating location', async () => { - variables = { ...variables, locationName: 'Hamburg, New Jersey, United States' } - await expect(mutate({ mutation: updateUserMutation, variables })).resolves.toMatchObject({ - data: { UpdateUser: { locationName: 'Hamburg, New Jersey, United States' } }, - errors: undefined, + describe('supports updating location', () => { + describe('change location to "Hamburg, New Jersey, United States"', () => { + it('has updated location to "Hamburg, New Jersey, United States"', async () => { + variables = { ...variables, locationName: 'Hamburg, New Jersey, United States' } + await expect(mutate({ mutation: updateUserMutation, variables })).resolves.toMatchObject({ + data: { + UpdateUser: { + locationName: 'Hamburg, New Jersey, United States', + location: expect.objectContaining({ + name: 'Hamburg', + nameDE: 'Hamburg', + nameEN: 'Hamburg', + }), + }, + }, + errors: undefined, + }) + }) + }) + + describe('change location to unset location', () => { + it('has updated location to unset location', async () => { + variables = { ...variables, locationName: '' } + await expect(mutate({ mutation: updateUserMutation, variables })).resolves.toMatchObject({ + data: { + UpdateUser: { + locationName: null, + location: null, + }, + }, + errors: undefined, + }) + }) }) }) }) diff --git a/backend/src/schema/resolvers/users/location.js b/backend/src/schema/resolvers/users/location.js index affd3267e4..9d8a11f899 100644 --- a/backend/src/schema/resolvers/users/location.js +++ b/backend/src/schema/resolvers/users/location.js @@ -1,6 +1,5 @@ import request from 'request' import { UserInputError } from 'apollo-server' -import isEmpty from 'lodash/isEmpty' import Debug from 'debug' import asyncForEach from '../../../helpers/asyncForEach' import CONFIG from '../../../config' @@ -62,77 +61,86 @@ const createLocation = async (session, mapboxData) => { }) } -const createOrUpdateLocations = async (userId, locationName, session) => { - if (isEmpty(locationName)) { - return - } - const res = await fetch( - `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent( - locationName, - )}.json?access_token=${CONFIG.MAPBOX_TOKEN}&types=region,place,country&language=${locales.join( - ',', - )}`, - ) +export const createOrUpdateLocations = async (nodeLabel, nodeId, locationName, session) => { + if (locationName === undefined) return - debug(res) + let locationId - if (!res || !res.features || !res.features[0]) { - throw new UserInputError('locationName is invalid') - } + if (locationName !== null) { + const res = await fetch( + `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent( + locationName, + )}.json?access_token=${ + CONFIG.MAPBOX_TOKEN + }&types=region,place,country&language=${locales.join(',')}`, + ) - let data + debug(res) - res.features.forEach((item) => { - if (item.matching_place_name === locationName) { - data = item + if (!res || !res.features || !res.features[0]) { + throw new UserInputError('locationName is invalid') } - }) - if (!data) { - data = res.features[0] - } - if (!data || !data.place_type || !data.place_type.length) { - throw new UserInputError('locationName is invalid') - } + let data - if (data.place_type.length > 1) { - data.id = 'region.' + data.id.split('.')[1] - } - await createLocation(session, data) - - let parent = data - - if (data.context) { - await asyncForEach(data.context, async (ctx) => { - await createLocation(session, ctx) - await session.writeTransaction((transaction) => { - return transaction.run( - ` - MATCH (parent:Location {id: $parentId}), (child:Location {id: $childId}) - MERGE (child)<-[:IS_IN]-(parent) - RETURN child.id, parent.id - `, - { - parentId: parent.id, - childId: ctx.id, - }, - ) - }) - parent = ctx + res.features.forEach((item) => { + if (item.matching_place_name === locationName) { + data = item + } }) + if (!data) { + data = res.features[0] + } + + if (!data || !data.place_type || !data.place_type.length) { + throw new UserInputError('locationName is invalid') + } + + if (data.place_type.length > 1) { + data.id = 'region.' + data.id.split('.')[1] + } + await createLocation(session, data) + + let parent = data + + if (data.context) { + await asyncForEach(data.context, async (ctx) => { + await createLocation(session, ctx) + await session.writeTransaction((transaction) => { + return transaction.run( + ` + MATCH (parent:Location {id: $parentId}), (child:Location {id: $childId}) + MERGE (child)<-[:IS_IN]-(parent) + RETURN child.id, parent.id + `, + { + parentId: parent.id, + childId: ctx.id, + }, + ) + }) + parent = ctx + }) + } + + locationId = data.id + } else { + locationId = 'non-existent-id' } - // delete all current locations from user and add new location + + // delete all current locations from node and add new location await session.writeTransaction((transaction) => { return transaction.run( ` - MATCH (user:User {id: $userId})-[relationship:IS_IN]->(location:Location) - DETACH DELETE relationship - WITH user - MATCH (location:Location {id: $locationId}) - MERGE (user)-[:IS_IN]->(location) - RETURN location.id, user.id - `, - { userId: userId, locationId: data.id }, + MATCH (node:${nodeLabel} {id: $nodeId}) + OPTIONAL MATCH (node)-[relationship:IS_IN]->(:Location) + DELETE relationship + WITH node + MATCH (location:Location {id: $locationId}) + MERGE (node)-[:IS_IN]->(location) + RETURN location.id, node.id + `, + { nodeId, locationId }, ) }) } @@ -147,5 +155,3 @@ export const queryLocations = async ({ place, lang }) => { } return res.features } - -export default createOrUpdateLocations diff --git a/backend/src/schema/types/enum/GroupActionRadius.gql b/backend/src/schema/types/enum/GroupActionRadius.gql new file mode 100644 index 0000000000..221ed7f877 --- /dev/null +++ b/backend/src/schema/types/enum/GroupActionRadius.gql @@ -0,0 +1,7 @@ +enum GroupActionRadius { + regional + national + continental + global + interplanetary +} diff --git a/backend/src/schema/types/enum/GroupMemberRole.gql b/backend/src/schema/types/enum/GroupMemberRole.gql new file mode 100644 index 0000000000..dacdd4b526 --- /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/enum/GroupType.gql b/backend/src/schema/types/enum/GroupType.gql new file mode 100644 index 0000000000..2cf2984748 --- /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 0000000000..c4890fdce9 --- /dev/null +++ b/backend/src/schema/types/type/Group.gql @@ -0,0 +1,133 @@ +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 +} + +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") + + about: String # goal + description: String! + descriptionExcerpt: String! + groupType: GroupType! + actionRadius: GroupActionRadius! + + locationName: String + location: Location @cypher(statement: "MATCH (this)-[:IS_IN]->(l:Location) RETURN l") + + categories: [Category] @relation(name: "CATEGORIZED", direction: "OUT") + + myRole: GroupMemberRole # if 'null' then the current user is no member + + posts: [Post] @relation(name: "IN", direction: "IN") +} + + +input _GroupFilter { + AND: [_GroupFilter!] + OR: [_GroupFilter!] + name_contains: String + slug_contains: String + about_contains: String + description_contains: String + groupType_in: [GroupType!] + actionRadius_in: [GroupActionRadius!] + myRole_in: [GroupMemberRole!] + id: ID + id_not: ID + id_in: [ID!] + id_not_in: [ID!] +} + +type Query { + Group( + isMember: Boolean # if 'undefined' or 'null' then get all groups + id: ID + slug: String + # first: Int # not implemented yet + # offset: Int # not implemented yet + # orderBy: [_GroupOrdering] # not implemented yet + # filter: _GroupFilter # not implemented yet + ): [Group] + + GroupMembers( + id: ID! + # first: Int # not implemented yet + # offset: Int # not implemented yet + # orderBy: [_UserOrdering] # not implemented yet + # filter: _UserFilter # not implemented yet + ): [User] + + # AvailableGroupTypes: [GroupType]! + + # AvailableGroupActionRadii: [GroupActionRadius]! + + # AvailableGroupMemberRoles: [GroupMemberRole]! +} + +type Mutation { + CreateGroup( + id: ID + name: String! + slug: String + about: String + description: String! + groupType: GroupType! + actionRadius: GroupActionRadius! + categoryIds: [ID] + # avatar: ImageInput # a group can not be created with an avatar + locationName: String # empty string '' sets it to null + ): Group + + UpdateGroup( + id: ID! + name: String + slug: String + about: String + description: String + # groupType: GroupType # is not possible at the moment and has to be discussed. may be in the stronger direction: public → closed → hidden + actionRadius: GroupActionRadius + categoryIds: [ID] + avatar: ImageInput # test this as result + locationName: String # empty string '' sets it to null + ): Group + + # DeleteGroup(id: ID!): Group + + JoinGroup( + groupId: ID! + userId: ID! + ): User + + LeaveGroup( + groupId: ID! + userId: ID! + ): User + + ChangeGroupMemberRole( + groupId: ID! + userId: ID! + roleInGroup: GroupMemberRole! + ): User +} 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 0000000000..edda989f61 --- /dev/null +++ b/backend/src/schema/types/type/MEMBER_OF.gql @@ -0,0 +1,5 @@ +type MEMBER_OF { + createdAt: String! + updatedAt: String! + role: GroupMemberRole! +} diff --git a/backend/src/schema/types/type/Post.gql b/backend/src/schema/types/type/Post.gql index 2f9221dd12..9eac00b0ba 100644 --- a/backend/src/schema/types/type/Post.gql +++ b/backend/src/schema/types/type/Post.gql @@ -81,6 +81,7 @@ input _PostFilter { emotions_none: _PostEMOTEDFilter emotions_single: _PostEMOTEDFilter emotions_every: _PostEMOTEDFilter + group: _GroupFilter } enum _PostOrdering { @@ -167,6 +168,8 @@ type Post { emotions: [EMOTED] emotionsCount: Int! @cypher(statement: "MATCH (this)<-[emoted:EMOTED]-(:User) RETURN COUNT(DISTINCT emoted)") + + group: Group @relation(name: "IN", direction: "OUT") } input _PostInput { @@ -184,6 +187,7 @@ type Mutation { language: String categoryIds: [ID] contentExcerpt: String + groupId: ID ): Post UpdatePost( id: ID! @@ -225,18 +229,4 @@ type Query { PostsEmotionsCountByEmotion(postId: ID!, data: _EMOTEDInput!): Int! PostsEmotionsByCurrentUser(postId: ID!): [String] profilePagePosts(filter: _PostFilter, first: Int, offset: Int, orderBy: [_PostOrdering]): [Post] - findPosts(query: String!, limit: Int = 10, filter: _PostFilter): [Post]! - @cypher( - statement: """ - CALL db.index.fulltext.queryNodes('post_fulltext_search', $query) - YIELD node as post, score - MATCH (post)<-[:WROTE]-(user:User) - WHERE score >= 0.2 - AND NOT user.deleted = true AND NOT user.disabled = true - AND NOT post.deleted = true AND NOT post.disabled = true - AND NOT user.id in COALESCE($filter.author_not.id_in, []) - RETURN post - LIMIT $limit - """ - ) } diff --git a/backend/src/schema/types/type/Search.gql b/backend/src/schema/types/type/Search.gql index 9537b5a849..5cb68e22d8 100644 --- a/backend/src/schema/types/type/Search.gql +++ b/backend/src/schema/types/type/Search.gql @@ -1,4 +1,4 @@ -union SearchResult = Post | User | Tag +union SearchResult = Post | User | Tag | Group type postSearchResults { postCount: Int @@ -15,9 +15,15 @@ type hashtagSearchResults { hashtags: [Tag]! } +type groupSearchResults { + groupCount: Int + groups: [Group]! +} + type Query { searchPosts(query: String!, firstPosts: Int, postsOffset: Int): postSearchResults! searchUsers(query: String!, firstUsers: Int, usersOffset: Int): userSearchResults! + searchGroups(query: String!, firstGroups: Int, groupsOffset: Int): groupSearchResults! searchHashtags(query: String!, firstHashtags: Int, hashtagsOffset: Int): hashtagSearchResults! searchResults(query: String!, limit: Int = 5): [SearchResult]! } diff --git a/backend/src/schema/types/type/User.gql b/backend/src/schema/types/type/User.gql index c3fcf932bf..fe1ff43f0c 100644 --- a/backend/src/schema/types/type/User.gql +++ b/backend/src/schema/types/type/User.gql @@ -33,8 +33,8 @@ type User { invitedBy: User @relation(name: "INVITED", direction: "IN") invited: [User] @relation(name: "INVITED", direction: "OUT") - location: Location @cypher(statement: "MATCH (this)-[:IS_IN]->(l:Location) RETURN l") locationName: String + location: Location @cypher(statement: "MATCH (this)-[:IS_IN]->(l:Location) RETURN l") about: String socialMedia: [SocialMedia]! @relation(name: "OWNED_BY", direction: "IN") @@ -122,6 +122,8 @@ type User { RETURN collect(category.id) """ ) + + myRoleInGroup: GroupMemberRole } @@ -164,19 +166,19 @@ input _UserFilter { type Query { User( - id: ID - email: String # admins need to search for a user sometimes - name: String - slug: String - role: UserRole - 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: UserRole + locationName: String + about: String + createdAt: String + updatedAt: String + first: Int + offset: Int + orderBy: [_UserOrdering] + filter: _UserFilter ): [User] availableRoles: [UserRole]! @@ -184,18 +186,6 @@ type Query { blockedUsers: [User] isLoggedIn: Boolean! currentUser: User - findUsers(query: String!,limit: Int = 10, filter: _UserFilter): [User]! - @cypher( - statement: """ - CALL db.index.fulltext.queryNodes('user_fulltext_search', $query) - YIELD node as post, score - MATCH (user) - WHERE score >= 0.2 - AND NOT user.deleted = true AND NOT user.disabled = true - RETURN user - LIMIT $limit - """ - ) } enum Deletable { @@ -205,19 +195,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 # empty string '' sets it to null + about: String + termsAndConditionsAgreedVersion: String + termsAndConditionsAgreedAt: String + allowEmbedIframes: Boolean + showShoutsPublicly: Boolean + sendNotificationEmails: Boolean + locale: String ): User DeleteUser(id: ID!, resource: [Deletable]): User diff --git a/backend/yarn.lock b/backend/yarn.lock index 48c05947e6..8283660d55 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -997,9 +997,9 @@ tslib "1.11.1" "@hapi/address@2.x.x": - version "2.1.2" - resolved "https://registry.yarnpkg.com/@hapi/address/-/address-2.1.2.tgz#1c794cd6dbf2354d1eb1ef10e0303f573e1c7222" - integrity sha512-O4QDrx+JoGKZc6aN64L04vqa7e41tIiLU+OvKdcYaEMP97UttL0f9GIi9/0A4WAMx0uBd6SidDIhktZhgOcN8Q== + version "2.1.4" + resolved "https://registry.yarnpkg.com/@hapi/address/-/address-2.1.4.tgz#5d67ed43f3fd41a69d4b9ff7b56e7c0d1d0a81e5" + integrity sha512-QD1PhQk+s31P1ixsX0H0Suoupp3VMXzIVMSwobR3F3MSUO2YCV0B7xqLcUw/Bh8yuvd3LhpyqLQWTNcRmp6IdQ== "@hapi/address@^4.0.1": version "4.0.1" @@ -1018,10 +1018,10 @@ resolved "https://registry.yarnpkg.com/@hapi/formula/-/formula-2.0.0.tgz#edade0619ed58c8e4f164f233cda70211e787128" integrity sha512-V87P8fv7PI0LH7LiVi8Lkf3x+KCO7pQozXRssAHNXXL9L1K+uyu4XypLXwxqVDKgyQai6qj3/KteNlrqDx4W5A== -"@hapi/hoek@8.x.x": - version "8.2.4" - resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-8.2.4.tgz#684a14f4ca35d46f44abc87dfc696e5e4fe8a020" - integrity sha512-Ze5SDNt325yZvNO7s5C4fXDscjJ6dcqLFXJQ/M7dZRQCewuDj2iDUuBi6jLQt+APbW9RjjVEvLr35FXuOEqjow== +"@hapi/hoek@8.x.x", "@hapi/hoek@^8.3.0": + version "8.5.1" + resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-8.5.1.tgz#fde96064ca446dec8c55a8c2f130957b070c6e06" + integrity sha512-yN7kbciD87WzLGc5539Tn0sApjyiGHAJgKvG9W8C7O+6c7qmoQMfVs0W4bX17eqz6C78QJqqFrtgdK5EWf6Qow== "@hapi/hoek@^9.0.0": version "9.0.0" @@ -1055,11 +1055,11 @@ integrity sha512-vzXR5MY7n4XeIvLpfl3HtE3coZYO4raKXW766R6DZw/6aLqR26iuZ109K7a0NtF2Db0jxqh7xz2AxkUwpUFybw== "@hapi/topo@3.x.x": - version "3.1.3" - resolved "https://registry.yarnpkg.com/@hapi/topo/-/topo-3.1.3.tgz#c7a02e0d936596d29f184e6d7fdc07e8b5efce11" - integrity sha512-JmS9/vQK6dcUYn7wc2YZTqzIKubAQcJKu2KCKAru6es482U5RT5fP1EXCPtlXpiK7PR0On/kpQKI4fRKkzpZBQ== + version "3.1.6" + resolved "https://registry.yarnpkg.com/@hapi/topo/-/topo-3.1.6.tgz#68d935fa3eae7fdd5ab0d7f953f3205d8b2bfc29" + integrity sha512-tAag0jEcjwH+P2quUfipd7liWCNX2F8NvYjQp2wtInsZxnMlypdw0FtAOLxtvvkO+GSRRbmNi8m/5y42PQJYCQ== dependencies: - "@hapi/hoek" "8.x.x" + "@hapi/hoek" "^8.3.0" "@hapi/topo@^5.0.0": version "5.0.0" @@ -2681,6 +2681,11 @@ base64-js@^1.0.2: resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1" integrity sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g== +base64-js@^1.3.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== + base@^0.11.1: version "0.11.2" resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f" @@ -2850,6 +2855,14 @@ buffer@4.9.1: ieee754 "^1.1.4" isarray "^1.0.0" +buffer@^6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" + integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.2.1" + busboy@^0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/busboy/-/busboy-0.3.1.tgz#170899274c5bf38aae27d5c62b71268cd585fd1b" @@ -3929,7 +3942,7 @@ dot-prop@^4.1.0: dotenv@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-4.0.0.tgz#864ef1379aced55ce6f95debecdce179f7a0cd1d" - integrity sha1-hk7xN5rO1Vzm+V3r7NzhefegzR0= + integrity sha512-XcaMACOr3JMVcEv0Y/iUM2XaOsATRZ3U1In41/1jjK6vJZ2PZbQ1bzCG8uvaByfaBpl9gqc9QWJovpUGBXLLYQ== dotenv@^6.1.0: version "6.2.0" @@ -5516,6 +5529,11 @@ ieee754@1.1.13, ieee754@^1.1.4: resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84" integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg== +ieee754@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" + integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== + ienoopen@1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/ienoopen/-/ienoopen-1.1.0.tgz#411e5d530c982287dbdc3bb31e7a9c9e32630974" @@ -7528,18 +7546,19 @@ negotiator@0.6.2: resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb" integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw== -neo4j-driver-bolt-connection@^4.3.4: - version "4.3.4" - resolved "https://registry.yarnpkg.com/neo4j-driver-bolt-connection/-/neo4j-driver-bolt-connection-4.3.4.tgz#de642bb6a62ffc6ae2e280dccf21395b4d1705a2" - integrity sha512-yxbvwGav+N7EYjcEAINqL6D3CZV+ee2qLInpAhx+iNurwbl3zqtBGiVP79SZ+7tU++y3Q1fW5ofikH06yc+LqQ== +neo4j-driver-bolt-connection@^4.4.7: + version "4.4.7" + resolved "https://registry.yarnpkg.com/neo4j-driver-bolt-connection/-/neo4j-driver-bolt-connection-4.4.7.tgz#0582d54de1f213e60c374209193d1f645ba523ea" + integrity sha512-6Q4hCtvWE6gzN64N09UqZqf/3rDl7FUWZZXiVQL0ZRbaMkJpZNC2NmrDIgGXYE05XEEbRBexf2tVv5OTYZYrow== dependencies: - neo4j-driver-core "^4.3.4" - text-encoding-utf-8 "^1.0.2" + buffer "^6.0.3" + neo4j-driver-core "^4.4.7" + string_decoder "^1.3.0" -neo4j-driver-core@^4.3.4: - version "4.3.4" - resolved "https://registry.yarnpkg.com/neo4j-driver-core/-/neo4j-driver-core-4.3.4.tgz#b445a4fbf94dce8441075099bd6ac3133c1cf5ee" - integrity sha512-3tn3j6IRUNlpXeehZ9Xv7dLTZPB4a7APaoJ+xhQyMmYQO3ujDM4RFHc0pZcG+GokmaltT5pUCIPTDYx6ODdhcA== +neo4j-driver-core@^4.4.7: + version "4.4.7" + resolved "https://registry.yarnpkg.com/neo4j-driver-core/-/neo4j-driver-core-4.4.7.tgz#d2475e107b3fea2b9d1c36b0c273da5c5a291c37" + integrity sha512-NhvVuQYgG7eO/vXxRaoJfkWUNkjvIpmCIS9UWU9Bbhb4V+wCOyX/MVOXqD0Yizhs4eyIkD7x90OXb79q+vi+oA== neo4j-driver@^4.0.1, neo4j-driver@^4.0.2: version "4.0.2" @@ -7552,13 +7571,13 @@ neo4j-driver@^4.0.1, neo4j-driver@^4.0.2: uri-js "^4.2.2" neo4j-driver@^4.2.2: - version "4.3.4" - resolved "https://registry.yarnpkg.com/neo4j-driver/-/neo4j-driver-4.3.4.tgz#a54f0562f868ee94dff7509df74e3eb2c1f95a85" - integrity sha512-AGrsFFqnoZv4KhJdmKt4mOBV5mnxmV3+/t8KJTOM68jQuEWoy+RlmAaRRaCSU4eY586OFN/R8lg9MrJpZdSFjw== + version "4.4.7" + resolved "https://registry.yarnpkg.com/neo4j-driver/-/neo4j-driver-4.4.7.tgz#51b3fb48241e66eb3be94e90032cc494c44e59f3" + integrity sha512-N7GddPhp12gVJe4eB84u5ik5SmrtRv8nH3rK47Qy7IUKnJkVEos/F1QjOJN6zt1jLnDXwDcGzCKK8XklYpzogw== dependencies: "@babel/runtime" "^7.5.5" - neo4j-driver-bolt-connection "^4.3.4" - neo4j-driver-core "^4.3.4" + neo4j-driver-bolt-connection "^4.4.7" + neo4j-driver-core "^4.4.7" rxjs "^6.6.3" neo4j-graphql-js@^2.11.5: @@ -9603,7 +9622,7 @@ string.prototype.trimstart@^1.0.1: define-properties "^1.1.3" es-abstract "^1.17.5" -string_decoder@^1.1.1: +string_decoder@^1.1.1, string_decoder@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== diff --git a/cypress/integration/Moderation.ReportContent/I_click_on_the_author.js b/cypress/integration/Moderation.ReportContent/I_click_on_the_author.js index 3a6600ff68..fad21e1a61 100644 --- a/cypress/integration/Moderation.ReportContent/I_click_on_the_author.js +++ b/cypress/integration/Moderation.ReportContent/I_click_on_the_author.js @@ -1,7 +1,7 @@ import { When } from "cypress-cucumber-preprocessor/steps"; When('I click on the author', () => { - cy.get('.user-teaser') + cy.get('[data-test="avatarUserLink"]') .click() .url().should('include', '/profile/') }) \ No newline at end of file diff --git a/cypress/integration/Post.Comment/I_should_see_my_comment.js b/cypress/integration/Post.Comment/I_should_see_my_comment.js index 356593f9cf..8d439b112f 100644 --- a/cypress/integration/Post.Comment/I_should_see_my_comment.js +++ b/cypress/integration/Post.Comment/I_should_see_my_comment.js @@ -5,7 +5,7 @@ Then("I should see my comment", () => { .should("contain", "Ocelot.social rocks") .get(".user-teaser span.slug") .should("contain", "@peter-pan") // specific enough - .get(".user-avatar img") + .get(".profile-avatar img") .should("have.attr", "src") .and("contain", 'https://') // some url .get(".user-teaser > .info > .text") diff --git a/cypress/integration/UserProfile.Avatar/I_cannot_upload_a_picture.js b/cypress/integration/UserProfile.Avatar/I_cannot_upload_a_picture.js index d20a181f2b..8b501f3f5c 100644 --- a/cypress/integration/UserProfile.Avatar/I_cannot_upload_a_picture.js +++ b/cypress/integration/UserProfile.Avatar/I_cannot_upload_a_picture.js @@ -4,5 +4,5 @@ Then("I cannot upload a picture", () => { cy.get(".base-card") .children() .should("not.have.id", "customdropzone") - .should("have.class", "user-avatar"); + .should("have.class", "profile-avatar"); }); \ No newline at end of file diff --git a/cypress/integration/UserProfile.Avatar/I_should_be_able_to_change_my_profile_picture.js b/cypress/integration/UserProfile.Avatar/I_should_be_able_to_change_my_profile_picture.js index f92789ef82..366fd82928 100644 --- a/cypress/integration/UserProfile.Avatar/I_should_be_able_to_change_my_profile_picture.js +++ b/cypress/integration/UserProfile.Avatar/I_should_be_able_to_change_my_profile_picture.js @@ -9,7 +9,7 @@ Then("I should be able to change my profile picture", () => { { subjectType: "drag-n-drop", force: true } ); }); - cy.get(".profile-avatar img") + cy.get(".profile-page-avatar img") .should("have.attr", "src") .and("contains", "onourjourney"); cy.contains(".iziToast-message", "Upload successful") diff --git a/package.json b/package.json index 54560aebd6..c45380bf34 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ocelot-social", - "version": "1.1.1", + "version": "2.0.0", "description": "Free and open source software program code available to run social networks.", "author": "ocelot.social Community", "license": "MIT", diff --git a/webapp/.env.template b/webapp/.env.template index 0a4c3405fe..9776fcea29 100644 --- a/webapp/.env.template +++ b/webapp/.env.template @@ -4,4 +4,4 @@ PUBLIC_REGISTRATION=false INVITE_REGISTRATION=true WEBSOCKETS_URI=ws://localhost:3000/api/graphql GRAPHQL_URI=http://localhost:4000/ -CATEGORIES_ACTIVE=false \ No newline at end of file +CATEGORIES_ACTIVE=false diff --git a/webapp/assets/_new/styles/tokens.scss b/webapp/assets/_new/styles/tokens.scss index 6aa6410df7..180f9c8205 100644 --- a/webapp/assets/_new/styles/tokens.scss +++ b/webapp/assets/_new/styles/tokens.scss @@ -268,6 +268,7 @@ $size-avatar-large: 114px; * @presenter Spacing */ + $size-button-large: 50px; $size-button-base: 36px; $size-button-small: 26px; diff --git a/webapp/components/AvatarMenu/AvatarMenu.spec.js b/webapp/components/AvatarMenu/AvatarMenu.spec.js index 85f5c32a8c..15f536ee7f 100644 --- a/webapp/components/AvatarMenu/AvatarMenu.spec.js +++ b/webapp/components/AvatarMenu/AvatarMenu.spec.js @@ -42,9 +42,9 @@ describe('AvatarMenu.vue', () => { wrapper = Wrapper() }) - it('renders the UserAvatar component', () => { + it('renders the ProfileAvatar component', () => { wrapper.find('.avatar-menu-trigger').trigger('click') - expect(wrapper.find('.user-avatar').exists()).toBe(true) + expect(wrapper.find('.profile-avatar').exists()).toBe(true) }) describe('given a userName', () => { @@ -90,6 +90,13 @@ describe('AvatarMenu.vue', () => { expect(profileLink.exists()).toBe(true) }) + it('displays a link to "My groups"', () => { + const profileLink = wrapper + .findAll('.ds-menu-item span') + .at(wrapper.vm.routes.findIndex((route) => route.path === '/my-groups')) + expect(profileLink.exists()).toBe(true) + }) + it('displays a link to the notifications page', () => { const notificationsLink = wrapper .findAll('.ds-menu-item span') @@ -103,6 +110,11 @@ describe('AvatarMenu.vue', () => { .at(wrapper.vm.routes.findIndex((route) => route.path === '/settings')) expect(settingsLink.exists()).toBe(true) }) + + it('displays a total of 4 links', () => { + const allLinks = wrapper.findAll('.ds-menu-item') + expect(allLinks).toHaveLength(4) + }) }) describe('role moderator', () => { @@ -125,9 +137,9 @@ describe('AvatarMenu.vue', () => { expect(moderationLink.exists()).toBe(true) }) - it('displays a total of 4 links', () => { + it('displays a total of 5 links', () => { const allLinks = wrapper.findAll('.ds-menu-item') - expect(allLinks).toHaveLength(4) + expect(allLinks).toHaveLength(5) }) }) @@ -151,9 +163,9 @@ describe('AvatarMenu.vue', () => { expect(adminLink.exists()).toBe(true) }) - it('displays a total of 5 links', () => { + it('displays a total of 6 links', () => { const allLinks = wrapper.findAll('.ds-menu-item') - expect(allLinks).toHaveLength(5) + expect(allLinks).toHaveLength(6) }) }) }) diff --git a/webapp/components/AvatarMenu/AvatarMenu.vue b/webapp/components/AvatarMenu/AvatarMenu.vue index d47eb2d680..5caec07f26 100644 --- a/webapp/components/AvatarMenu/AvatarMenu.vue +++ b/webapp/components/AvatarMenu/AvatarMenu.vue @@ -11,7 +11,7 @@ " @click.prevent="toggleMenu" > - + @@ -50,12 +50,12 @@ + + diff --git a/webapp/components/CategoriesSelect/CategoriesSelect.vue b/webapp/components/CategoriesSelect/CategoriesSelect.vue index 92779444f5..7fc9bcf1ca 100644 --- a/webapp/components/CategoriesSelect/CategoriesSelect.vue +++ b/webapp/components/CategoriesSelect/CategoriesSelect.vue @@ -12,7 +12,6 @@ v-tooltip="{ content: $t(`contribution.category.description.${category.slug}`), placement: 'bottom-start', - delay: { show: 1500 }, }" > {{ $t(`contribution.category.name.${category.slug}`) }} @@ -22,6 +21,7 @@ + + diff --git a/webapp/components/ContributionForm/ContributionForm.spec.js b/webapp/components/ContributionForm/ContributionForm.spec.js index f5083a8a0f..1ef1777fee 100644 --- a/webapp/components/ContributionForm/ContributionForm.spec.js +++ b/webapp/components/ContributionForm/ContributionForm.spec.js @@ -4,7 +4,7 @@ import ContributionForm from './ContributionForm.vue' import Vuex from 'vuex' import PostMutations from '~/graphql/PostMutations.js' -import ImageUploader from '~/components/ImageUploader/ImageUploader' +import ImageUploader from '~/components/Uploader/ImageUploader' import MutationObserver from 'mutation-observer' global.MutationObserver = MutationObserver @@ -138,6 +138,7 @@ describe('ContributionForm.vue', () => { categoryIds: [], id: null, image: null, + groupId: null, }, } postTitleInput = wrapper.find('.ds-input') @@ -260,6 +261,7 @@ describe('ContributionForm.vue', () => { content: propsData.contribution.content, categoryIds: [], id: propsData.contribution.id, + groupId: null, image: { sensitive: false, }, diff --git a/webapp/components/ContributionForm/ContributionForm.vue b/webapp/components/ContributionForm/ContributionForm.vue index 3d4bb8e7c0..0428b2e23b 100644 --- a/webapp/components/ContributionForm/ContributionForm.vue +++ b/webapp/components/ContributionForm/ContributionForm.vue @@ -83,7 +83,7 @@ import { mapGetters } from 'vuex' import HcEditor from '~/components/Editor/Editor' import PostMutations from '~/graphql/PostMutations.js' import CategoriesSelect from '~/components/CategoriesSelect/CategoriesSelect' -import ImageUploader from '~/components/ImageUploader/ImageUploader' +import ImageUploader from '~/components/Uploader/ImageUploader' import links from '~/constants/links.js' import PageParamsLink from '~/components/_new/features/PageParamsLink/PageParamsLink.vue' @@ -99,6 +99,10 @@ export default { type: Object, default: () => ({}), }, + groupId: { + type: String, + default: () => null, + }, }, data() { const { title, content, image, categories } = this.contribution @@ -173,6 +177,7 @@ export default { categoryIds, id: this.contribution.id || null, image, + groupId: this.groupId, }, }) .then(({ data }) => { diff --git a/webapp/components/FilterMenu/CategoriesFilter.vue b/webapp/components/FilterMenu/CategoriesFilter.vue index 552aa26a05..fcbb682662 100644 --- a/webapp/components/FilterMenu/CategoriesFilter.vue +++ b/webapp/components/FilterMenu/CategoriesFilter.vue @@ -20,7 +20,6 @@ v-tooltip="{ content: $t(`contribution.category.description.${category.slug}`), placement: 'bottom-start', - delay: { show: 1500 }, }" /> diff --git a/webapp/components/Group/GroupButton.vue b/webapp/components/Group/GroupButton.vue new file mode 100644 index 0000000000..2000e3046d --- /dev/null +++ b/webapp/components/Group/GroupButton.vue @@ -0,0 +1,5 @@ + diff --git a/webapp/components/Group/GroupForm.spec.js b/webapp/components/Group/GroupForm.spec.js new file mode 100644 index 0000000000..0300bacd02 --- /dev/null +++ b/webapp/components/Group/GroupForm.spec.js @@ -0,0 +1,39 @@ +import { config, mount } from '@vue/test-utils' +import GroupForm from './GroupForm.vue' + +const localVue = global.localVue + +config.stubs['nuxt-link'] = '' + +const propsData = { + update: false, + group: {}, +} + +describe('GroupForm', () => { + let wrapper + let mocks + + beforeEach(() => { + mocks = { + $t: jest.fn(), + $env: { + CATEGORIES_ACTIVE: true, + }, + } + }) + + describe('mount', () => { + const Wrapper = () => { + return mount(GroupForm, { propsData, mocks, localVue }) + } + + beforeEach(() => { + wrapper = Wrapper() + }) + + it('renders', () => { + expect(wrapper.findAll('.group-form')).toHaveLength(1) + }) + }) +}) diff --git a/webapp/components/Group/GroupForm.vue b/webapp/components/Group/GroupForm.vue new file mode 100644 index 0000000000..e0166bb452 --- /dev/null +++ b/webapp/components/Group/GroupForm.vue @@ -0,0 +1,440 @@ + + + + + diff --git a/webapp/components/Group/GroupLink.vue b/webapp/components/Group/GroupLink.vue new file mode 100644 index 0000000000..4fcc7983d8 --- /dev/null +++ b/webapp/components/Group/GroupLink.vue @@ -0,0 +1,15 @@ + + diff --git a/webapp/components/Group/GroupList.spec.js b/webapp/components/Group/GroupList.spec.js new file mode 100644 index 0000000000..ca6909de79 --- /dev/null +++ b/webapp/components/Group/GroupList.spec.js @@ -0,0 +1,33 @@ +import { mount } from '@vue/test-utils' +import GroupList from './GroupList.vue' + +const localVue = global.localVue + +const propsData = { + groups: [], +} + +describe('GroupList', () => { + let wrapper + let mocks + + beforeEach(() => { + mocks = { + $t: jest.fn(), + } + }) + + describe('mount', () => { + const Wrapper = () => { + return mount(GroupList, { propsData, mocks, localVue }) + } + + beforeEach(() => { + wrapper = Wrapper() + }) + + it('renders', () => { + expect(wrapper.findAll('.group-list')).toHaveLength(1) + }) + }) +}) diff --git a/webapp/components/Group/GroupList.vue b/webapp/components/Group/GroupList.vue new file mode 100644 index 0000000000..7618e5b579 --- /dev/null +++ b/webapp/components/Group/GroupList.vue @@ -0,0 +1,21 @@ + + + diff --git a/webapp/components/Group/GroupMember.spec.js b/webapp/components/Group/GroupMember.spec.js new file mode 100644 index 0000000000..0c0b46e43b --- /dev/null +++ b/webapp/components/Group/GroupMember.spec.js @@ -0,0 +1,34 @@ +import { mount } from '@vue/test-utils' +import GroupMember from './GroupMember.vue' + +const localVue = global.localVue + +const propsData = { + groupId: '', + groupMembers: [], +} + +describe('GroupMember', () => { + let wrapper + let mocks + + beforeEach(() => { + mocks = { + $t: jest.fn(), + } + }) + + describe('mount', () => { + const Wrapper = () => { + return mount(GroupMember, { propsData, mocks, localVue }) + } + + beforeEach(() => { + wrapper = Wrapper() + }) + + it('renders', () => { + expect(wrapper.findAll('.group-member')).toHaveLength(1) + }) + }) +}) diff --git a/webapp/components/Group/GroupMember.vue b/webapp/components/Group/GroupMember.vue new file mode 100644 index 0000000000..0a99aa21cd --- /dev/null +++ b/webapp/components/Group/GroupMember.vue @@ -0,0 +1,234 @@ + + diff --git a/webapp/components/Group/GroupTeaser.vue b/webapp/components/Group/GroupTeaser.vue new file mode 100644 index 0000000000..75a1150d6b --- /dev/null +++ b/webapp/components/Group/GroupTeaser.vue @@ -0,0 +1,178 @@ + + + + + diff --git a/webapp/components/PostTeaser/PostTeaser.vue b/webapp/components/PostTeaser/PostTeaser.vue index 75eefbfb27..2bd63e6ed1 100644 --- a/webapp/components/PostTeaser/PostTeaser.vue +++ b/webapp/components/PostTeaser/PostTeaser.vue @@ -15,13 +15,12 @@ - +

{{ post.title }}

- +
-