diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index 733258372..2be4b51dd 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -1,3 +1,5 @@ + + -## :bug: Bug Report +## 🐛 Bugreport diff --git a/.github/ISSUE_TEMPLATE/devops_ticket.md b/.github/ISSUE_TEMPLATE/devops_ticket.md index 115664911..981d0fdd6 100644 --- a/.github/ISSUE_TEMPLATE/devops_ticket.md +++ b/.github/ISSUE_TEMPLATE/devops_ticket.md @@ -1,9 +1,10 @@ --- -name: 💥 DevOps Ticket -about: Help us manage our deployed app. +name: 💥 DevOps ticket +about: Help us manage our deployed Software. labels: devops title: 💥 [DevOps] --- + -## 💥 DevOps Ticket +## 💥 DevOps ticket diff --git a/.github/ISSUE_TEMPLATE/epic.md b/.github/ISSUE_TEMPLATE/epic.md index cf72cd673..af511e6e2 100644 --- a/.github/ISSUE_TEMPLATE/epic.md +++ b/.github/ISSUE_TEMPLATE/epic.md @@ -1,12 +1,13 @@ --- name: 🌟 Epic -about: Define a big development step. +about: Define a big development Step labels: epic title: 🌟 [EPIC] --- + + - + ## 🌟 EPIC diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index beae80901..f313740a5 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,9 +1,10 @@ --- -name: 🚀 Feature Request -about: Suggest an idea for this project. +name: 🚀 Feature request +about: Suggest an idea for this project labels: feature title: 🚀 [Feature] --- + -## :rocket: Feature Request +## 🚀 Feature diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md index 40e6e381b..b57adcd06 100644 --- a/.github/ISSUE_TEMPLATE/question.md +++ b/.github/ISSUE_TEMPLATE/question.md @@ -1,12 +1,13 @@ --- name: 💬 Question -about: If you need help understanding ocelot.social. +about: If you need help understanding our Software. labels: question title: 💬 [Question] --- - - + + + + ## 💬 Question diff --git a/.github/ISSUE_TEMPLATE/refactor_tickets.md b/.github/ISSUE_TEMPLATE/refactor_ticket.md similarity index 64% rename from .github/ISSUE_TEMPLATE/refactor_tickets.md rename to .github/ISSUE_TEMPLATE/refactor_ticket.md index d1841e35e..b75f7b5fc 100644 --- a/.github/ISSUE_TEMPLATE/refactor_tickets.md +++ b/.github/ISSUE_TEMPLATE/refactor_ticket.md @@ -1,10 +1,10 @@ --- -name: 🔧 Refactor +name: 🔧 Refactor ticket about: Help us improve our code by refactoring it. labels: refactor title: 🔧 [Refactor] --- + -## 🔧 Refactor +## 🔧 Refactor ticket - diff --git a/.github/ISSUE_TEMPLATE/release.md b/.github/ISSUE_TEMPLATE/release.md new file mode 100644 index 000000000..c9f4b787d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/release.md @@ -0,0 +1,13 @@ +--- +name: 🎂 Release +about: Define a Release +labels: release +title: 🎂 [RELEASE] +--- + + + + + +## 🎂 RELEASE + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 6cfe4c30c..d40a2770a 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,3 +1,5 @@ + + ## 🍰 Pullrequest diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1c7927665..2a2e73dc6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,7 +4,9 @@ on: push: branches: - master - # - 4451-new-deployment-with-base-and-code # for testing while developing + # - 5059-epic-groups # for testing while developing + # template branches in repo + # - template--separate-branch-auto-deployment--5059-epic-groups jobs: ############################################################################## @@ -56,9 +58,9 @@ jobs: # NEO4J ################################################################## ########################################################################## - name: Neo4J | Build `community` image - run: docker build --target community -t "ocelotsocialnetwork/neo4j:latest" -t "ocelotsocialnetwork/neo4j:community" -t "ocelotsocialnetwork/neo4j:${VERSION}" -t "ocelotsocialnetwork/neo4j:${BUILD_VERSION}" --build-arg BBUILD_DATE=$BUILD_DATE --build-arg BBUILD_VERSION=$BUILD_VERSION --build-arg BBUILD_COMMIT=$BUILD_COMMIT neo4j/ + run: docker build --target community -t "ocelotsocialnetwork/neo4j-community:latest" -t "ocelotsocialnetwork/neo4j-community:${VERSION}" -t "ocelotsocialnetwork/neo4j-community:${BUILD_VERSION}" --build-arg BBUILD_DATE=$BUILD_DATE --build-arg BBUILD_VERSION=$BUILD_VERSION --build-arg BBUILD_COMMIT=$BUILD_COMMIT neo4j/ - name: Neo4J | Save docker image - run: docker save "ocelotsocialnetwork/neo4j" > /tmp/neo4j.tar + run: docker save "ocelotsocialnetwork/neo4j-community" > /tmp/neo4j.tar - name: Upload Artifact uses: actions/upload-artifact@v2 with: @@ -238,7 +240,7 @@ jobs: - name: login to dockerhub run: echo "${DOCKERHUB_TOKEN}" | docker login -u "${DOCKERHUB_USERNAME}" --password-stdin - name: Push neo4j - run: docker push --all-tags ocelotsocialnetwork/neo4j + run: docker push --all-tags ocelotsocialnetwork/neo4j-community - name: Push backend run: docker push --all-tags ocelotsocialnetwork/backend - name: Push webapp @@ -246,6 +248,81 @@ jobs: - name: Push maintenance run: docker push --all-tags ocelotsocialnetwork/maintenance + ############################################################################## + # JOB: KUBERNETES DEPLOY ACTUAL/LATEST VERSION ###################################### + ############################################################################## + kubernetes_deploy: + # see example https://github.com/do-community/example-doctl-action + # see example https://github.com/do-community/example-doctl-action/blob/main/.github/workflows/workflow.yaml + name: Kubernetes deploy of latest version to stage.ocelot.social cluster at DigitalOcean + runs-on: ubuntu-latest + needs: [upload_to_dockerhub] + steps: + ########################################################################## + # CHECKOUT CODE ########################################################## + ########################################################################## + - name: Checkout code + uses: actions/checkout@v2 + ########################################################################## + # SET ENVS ############################################################### + ########################################################################## + - name: ENV - VERSION + run: echo "VERSION=$(node -p -e "require('./package.json').version")" >> $GITHUB_ENV + - name: ENV - BUILD_VERSION + run: echo "BUILD_VERSION=${VERSION}-${GITHUB_RUN_NUMBER}" >> $GITHUB_ENV + ########################################################################## + # Install DigitalOceans doctl and set kubeconfig ######################### + ########################################################################## + - name: Install doctl + uses: digitalocean/action-doctl@v2 + with: + token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} + - name: Save DigitalOcean kubeconfig with short-lived credentials + run: doctl kubernetes cluster kubeconfig save --expiry-seconds 600 cluster-stage-ocelot-social + ########################################################################## + # Deploy new Docker images to DigitalOcean Kubernetes cluster ############ + ########################################################################## + # - name: Deploy 'latest' to DigitalOcean Kubernetes + # run: | + # kubectl -n default set image deployment/ocelot-webapp container-ocelot-webapp=ocelotsocialnetwork/webapp:latest + # kubectl -n default rollout restart deployment/ocelot-webapp + # kubectl -n default set image deployment/ocelot-backend container-ocelot-backend=ocelotsocialnetwork/backend:latest + # kubectl -n default rollout restart deployment/ocelot-backend + # kubectl -n default set image deployment/ocelot-maintenance container-ocelot-maintenance=ocelotsocialnetwork/maintenance:latest + # kubectl -n default rollout restart deployment/ocelot-maintenance + # kubectl -n default set image deployment/ocelot-neo4j container-ocelot-neo4j=ocelotsocialnetwork/neo4j-community:latest + # kubectl -n default rollout restart deployment/ocelot-neo4j + - name: Deploy actual version '$BUILD_VERSION' to DigitalOcean Kubernetes + run: | + kubectl -n default set image deployment/ocelot-webapp container-ocelot-webapp=ocelotsocialnetwork/webapp:$BUILD_VERSION + kubectl -n default rollout restart deployment/ocelot-webapp + kubectl -n default set image deployment/ocelot-backend container-ocelot-backend=ocelotsocialnetwork/backend:$BUILD_VERSION + kubectl -n default rollout restart deployment/ocelot-backend + kubectl -n default set image deployment/ocelot-maintenance container-ocelot-maintenance=ocelotsocialnetwork/maintenance:$BUILD_VERSION + kubectl -n default rollout restart deployment/ocelot-maintenance + kubectl -n default set image deployment/ocelot-neo4j container-ocelot-neo4j=ocelotsocialnetwork/neo4j-community:$BUILD_VERSION + kubectl -n default rollout restart deployment/ocelot-neo4j + # 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 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 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: | + kubectl -n default exec -it $(kubectl -n default get pods | grep ocelot-backend | awk '{ print $1 }') -- /bin/sh -c "node --experimental-repl-await dist/db/clean.js && node --experimental-repl-await dist/db/seed.js" + ############################################################################## # JOB: GITHUB TAG LATEST VERSION ############################################# ############################################################################## @@ -313,4 +390,4 @@ jobs: release_name: ${{ env.VERSION }} body_path: ./CHANGELOG.md draft: false - prerelease: false \ No newline at end of file + prerelease: false diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5dd385ad8..4b310a20b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -43,8 +43,8 @@ jobs: ########################################################################## - name: Neo4J | Build `community` image run: | - docker build --target community -t "ocelotsocialnetwork/neo4j:community" neo4j/ - docker save "ocelotsocialnetwork/neo4j:community" > /tmp/neo4j.tar + docker build --target community -t "ocelotsocialnetwork/neo4j-community:test" neo4j/ + docker save "ocelotsocialnetwork/neo4j-community:test" > /tmp/neo4j.tar - name: Upload Artifact uses: actions/upload-artifact@v2 with: @@ -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: 63 token: ${{ github.token }} ############################################################################## diff --git a/CHANGELOG.md b/CHANGELOG.md index acc917e13..e029abcf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,130 @@ 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). +#### [2.2.0](https://github.com/Ocelot-Social-Community/Ocelot-Social/compare/1.1.0...2.2.0) + +- refactor: 🍰 Refactor Design Of Category Filter In Filter Menu [`#5597`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5597) +- feat: 🍰 Footer And Header Links Configurable To Have External Link Target [`#5590`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5590) +- feat: 🍰 Header Menu To Component And Other Refinements [`#5546`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5546) +- fix: 🍰 Comments On Posts In Groups In Webapp [`#5607`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5607) +- docs: 🍰 Add Live Demo To Main Readme [`#5600`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5600) +- feat: Restrict Comments on Posts in Groups [`#5599`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5599) +- fix: 🍰 Add Space In Register Box [`#5591`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5591) +- fix: 🍰 Update Group Avatar After Upload [`#5583`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5583) +- feat: 🍰 Make Donation Progress Bar Color Configurable [`#5593`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5593) +- fix: 🍰 Bug In Edit Slug Of Group [`#5596`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5596) +- fix: 🍰 Fix Group Teaser [`#5584`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5584) +- feat: 🍰 List All Groups [`#5582`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5582) +- feat: 🍰 Header Logo Routing Update [`#5579`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5579) +- chore: 🍰 Release v2.1.0 [`#5574`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5574) +- feat: 🍰 EPIC Groups [`#5132`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5132) +- chore: 🍰 Remove Group Branchs `5059-epic-groups` Separate Auto-Deployment [`#5552`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5552) +- fix: [WIP] 🍰 Long Words Are Being Wrapped Now [`#5559`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5559) +- 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) +- Bump eslint-plugin-prettier from 3.1.2 to 3.4.1 in /backend [`#5211`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5211) +- Bump slug from 4.0.2 to 6.0.0 in /backend [`#5193`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5193) +- chore: 🍰 Fix typo in PULL_REQUEST_TEMPLATE.md file [`#5208`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5208) +- Bump slug from 5.1.0 to 6.0.0 [`#5191`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5191) +- Bump vue-sweetalert-icons from 4.3.0 to 4.3.1 in /webapp [`#5174`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5174) +- 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) +- improved code and tests as suggested by @tirokk, thanks for the great review! [`631f34a`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/631f34a2e5224d68279337a92e7535794b670d70) +- implement and test post visibilty when leaving or changing the role in a group [`76bfe48`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/76bfe484768cf9b20b2dced865d5d3e3eb999235) +- comment out LanguagesFilter, EmotionsFilter, fix tests, fix lint [`52dcd77`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/52dcd772fa81e02a0d95e89a9fc8232e70a09d28) + +#### [1.1.0](https://github.com/Ocelot-Social-Community/Ocelot-Social/compare/1.0.9...1.1.0) + +> 4 August 2022 + +- chore: 🍰 Release v1.1.0 - Implement Categories Again [`#5145`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5145) +- feat: Make Categories Optional [`#5102`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5102) +- Update issue templates [`#5101`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5101) +- chore: 🍰 Betters Automatic Deployment To `stage.ocelot.social` On Push To `master` Branch [`#5097`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5097) +- add optional categories to teaser and post [`bc95500`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/bc955003f7c33aabe592bee782aca973b4f00cba) +- env vatiable for CATEGORIES_ACTIVE and switch for categories in contribution form [`e31f250`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/e31f250ea5e1949f4f08e72fe82622d41ecd85f1) +- fix some tests [`5393c2a`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/5393c2aeaaf070a637390c430d5f03057030ff52) + +#### [1.0.9](https://github.com/Ocelot-Social-Community/Ocelot-Social/compare/1.0.8...1.0.9) + +> 20 July 2022 + +- chore: 🍰 Release v1.0.9 [`#5095`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5095) +- chore: 🍰 Automatic Deployment To `stage.ocelot.social` On Push To `master` Branch [`#5080`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5080) +- change footer version-link [`#5091`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5091) +- docs: 🍰 Add Neo4j Docu For Important Commands [`#5090`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5090) +- 5079 legends for search field [`#5088`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5088) +- chore: 🍰 Change `image` Entries In Docker Compose Files And Fix Apple M1 Problem [`#5073`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5073) +- chore: 🍰 Rename Neo4j Docker Image In General To `neo4j-community:*` [`#5078`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5078) +- chore: 🍰 Fix `ocelotsocialnetwork/webapp:latest` And `ocelotsocialnetwork/backend:latest` On Start In Cluster [`#5076`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5076) +- Add documentation for Apple M1 Docker Compose override files [`2f3f37c`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/2f3f37c158cfc9b300540d3c8f016548b15a5277) +- Add documentation for Docker build analyzes [`fbbcc5b`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/fbbcc5bb854d53b5fa658b83d56d381a3cbc2b1a) +- Implement DigitalOcean Kubernetes deployment on publishing [`485e698`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/485e6986b88a14db5ab75ed12bab5cdc73592ca6) + +#### [1.0.8](https://github.com/Ocelot-Social-Community/Ocelot-Social/compare/1.0.7...1.0.8) + +> 1 July 2022 + +- chore: 🍰 Release v1.0.8 [`#5058`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5058) +- chore: 🍰 Log E-Mail If Not Sending It [`#5038`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5038) +- test: 🍰 Test And Refactor E-Mail Templates [`#4787`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/4787) +- fix: 🍰 Replace Hashtag In d.tube Url [`#4980`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/4980) +- Fix location backend test [`#5034`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5034) +- fix: 🍰 Add Expiration Date To Cookies Etc. [`#4882`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/4882) +- feat: 🍰 Refactor Social Media List With Slots [`#4773`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/4773) +- bug: 🍰 Replace Deleted Faker Package – Change To Import Again [`#4975`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/4975) +- bug: 🍰 Replace Deleted Faker Package [`#4973`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/4973) +- fix: 🍰 Change Tip Tap Editor Legend Hover To Click [`#4911`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/4911) +- fix: 🍰 Fix Embed iframe Width And Height CSS [`#4897`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/4897) +- Implement MySomethingList for social media, use list item slot [`d3cc49d`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/d3cc49d37ba260f9a285c078c57e673a32a76732) +- Split social media page and list component [`b740033`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/b7400339aba22d5fb5506dc3b25f082d7f09edfc) +- Remove input addSocialMedia [`58464fd`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/58464fd53ef6aab52af1c2477c2615648ad889e3) + #### [1.0.7](https://github.com/Ocelot-Social-Community/Ocelot-Social/compare/1.0.6...1.0.7) +> 2 December 2021 + +- fix: 🍰 Fix Migration 'add donations node‘ And Release V1.0.7 [`#4821`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/4821) - Bump rosie from 2.0.1 to 2.1.0 [`#4520`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/4520) - fix: 🍰 Renew JWT In Decode Test [`#4798`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/4798) - docs: 🍰 Refine Main README.md With Test Tech Stack And Video Link [`#4772`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/4772) diff --git a/DOCKER_MORE_CLOSELY.md b/DOCKER_MORE_CLOSELY.md new file mode 100644 index 000000000..03e6417ec --- /dev/null +++ b/DOCKER_MORE_CLOSELY.md @@ -0,0 +1,86 @@ +# Docker More Closely + +## Apple M1 Platform + +***Attention:** For using Docker commands in Apple M1 environments!* + +### Enviroment Variable For Apple M1 Platform + +To set the Docker platform environment variable in your terminal tab, run: + +```bash +# set env variable for your shell +$ export DOCKER_DEFAULT_PLATFORM=linux/amd64 +``` + +### Docker Compose Override File For Apple M1 Platform + +For Docker compose `up` or `build` commands, you can use our Apple M1 override file that specifies the M1 platform: + +```bash +# in main folder + +# for development +$ docker compose -f docker-compose.yml -f docker-compose.override.yml -f docker-compose.apple-m1.override.yml up +# only once: init admin user and create indexes and contraints in Neo4j database +$ docker compose exec backend yarn prod:migrate init +# clean db +$ docker compose exec backend yarn db:reset +# seed db +$ docker compose exec backend yarn db:seed + +# for production +$ docker compose -f docker-compose.yml -f docker-compose.apple-m1.override.yml up +# only once: init admin user and create indexes and contraints in Neo4j database +$ docker compose exec backend /bin/sh -c "yarn prod:migrate init" +``` + +## Analysing Docker Builds + +To analyze a Docker build, there is a wonderful tool called [dive](https://github.com/wagoodman/dive). Please sponsor if you're using it! + +The `dive build` command is exactly the right one to fulfill what we are looking for. +We can use it just like the `docker build` command and get an analysis afterwards. + +So, in our main folder, we use it in the following way: + +```bash +# in main folder +$ dive build --target -t "ocelotsocialnetwork/:local-" --build-arg BBUILD_DATE="" --build-arg BBUILD_VERSION="" --build-arg BBUILD_COMMIT="" / +``` + +The build arguments are optional. + +For the specific applications, we use them as follows. + +### Backend + +#### Production For Backend + +```bash +# in main folder +$ dive build --target production -t "ocelotsocialnetwork/backend:local-production" backend/ +``` + +#### Development For Backend + +```bash +# in main folder +$ dive build --target development -t "ocelotsocialnetwork/backend:local-development" backend/ +``` + +### Webapp + +#### Production For Webapp + +```bash +# in main folder +$ dive build --target production -t "ocelotsocialnetwork/webapp:local-production" webapp/ +``` + +#### Development For Webapp + +```bash +# in main folder +$ dive build --target development -t "ocelotsocialnetwork/webapp:local-development" webapp/ +``` diff --git a/README.md b/README.md index 45006581c..f0688f820 100644 --- a/README.md +++ b/README.md @@ -65,11 +65,18 @@ Change into the new folder. $ cd Ocelot-Social ``` +## Live Demo And Developer Logins + +**Try out our deployed [development environment](https://stage.ocelot.social).** + +Visit our staging networks: + +* central staging network: [stage.ocelot.social](https://stage.ocelot.social) + + ### Login - - -Logins in the browser after the following installations: +Logins for the live demos and developers (local developers after the following installations) in the browser: | email | password | role | | :--- | :--- | :--- | diff --git a/SUMMARY.md b/SUMMARY.md index 9c74b1974..f848633d5 100644 --- a/SUMMARY.md +++ b/SUMMARY.md @@ -15,6 +15,7 @@ * [End-to-end tests](cypress/README.md) * [Frontend tests](webapp/testing.md) * [Backend tests](backend/testing.md) +* [Docker More Closely](DOCKER_MORE_CLOSELY.md) * [Deployment](https://github.com/Ocelot-Social-Community/Ocelot-Social-Deploy-Rebranding/blob/master/deployment/README.md) * [Contributing](CONTRIBUTING.md) * [Feature Specification](cypress/features.md) diff --git a/backend/.env.template b/backend/.env.template index 5858a5d1e..dd46846a9 100644 --- a/backend/.env.template +++ b/backend/.env.template @@ -28,3 +28,5 @@ AWS_BUCKET= EMAIL_DEFAULT_SENDER="devops@ocelot.social" EMAIL_SUPPORT="devops@ocelot.social" + +CATEGORIES_ACTIVE=false diff --git a/backend/Dockerfile b/backend/Dockerfile index 0ebdfb1eb..6c2f72bd3 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -91,6 +91,7 @@ FROM base as production # Copy "binary"-files from build image COPY --from=build ${DOCKER_WORKDIR}/dist ./dist +COPY --from=build ${DOCKER_WORKDIR}/node_modules ./node_modules # Copy static files # TODO - externalize the uploads so we can copy the whole folder COPY --from=build ${DOCKER_WORKDIR}/public/img/ ./public/img/ diff --git a/backend/README.md b/backend/README.md index 6d837856c..0af58af48 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 ``` @@ -60,6 +64,7 @@ your `.env` configuration file. Your backend is up and running at [http://localhost:4000/](http://localhost:4000/) This will start the GraphQL service \(by default on localhost:4000\) where you can issue GraphQL requests or access GraphQL Playground in the browser. +More details about our GraphQL playground and how to use it with ocelot.social can be found [here](./src/graphql/GraphQL-Playground.md). ![GraphQL Playground](../.gitbook/assets/graphql-playground.png) @@ -72,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 ``` @@ -79,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 ``` @@ -98,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 @@ -117,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 ``` @@ -140,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/ ``` @@ -147,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 ``` @@ -156,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/ ``` @@ -163,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 ``` @@ -180,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 ``` @@ -190,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 e0d53b5b9..fd285a3b4 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ocelot-social-backend", - "version": "1.0.7", + "version": "2.2.0", "description": "GraphQL Backend for ocelot.social", "repository": "https://github.com/Ocelot-Social-Community/Ocelot-Social", "author": "ocelot.social Community", @@ -15,7 +15,7 @@ "dev": "nodemon --exec babel-node src/ -e js,gql", "dev:debug": "nodemon --exec babel-node --inspect=0.0.0.0:9229 src/ -e js,gql", "lint": "eslint src --config .eslintrc.js", - "test": "cross-env NODE_ENV=test jest --forceExit --detectOpenHandles --runInBand --coverage", + "test": "cross-env NODE_ENV=test NODE_OPTIONS=--max-old-space-size=8192 jest --forceExit --detectOpenHandles --runInBand --coverage --logHeapUsage", "db:clean": "babel-node src/db/clean.js", "db:reset": "yarn run db:clean", "db:seed": "babel-node src/db/seed.js", @@ -103,14 +103,14 @@ "mustache": "^4.2.0", "neo4j-driver": "^4.0.2", "neo4j-graphql-js": "^2.11.5", - "neode": "^0.4.7", + "neode": "^0.4.8", "node-fetch": "~2.6.1", "nodemailer": "^6.4.4", "nodemailer-html-to-text": "^3.2.0", "npm-run-all": "~4.1.5", "request": "~2.88.2", "sanitize-html": "~1.22.0", - "slug": "~4.0.2", + "slug": "~6.0.0", "subscriptions-transport-ws": "^0.9.19", "trunc-html": "~1.1.2", "uuid": "~8.3.2", @@ -129,7 +129,7 @@ "eslint-plugin-import": "~2.20.2", "eslint-plugin-jest": "~23.8.2", "eslint-plugin-node": "~11.1.0", - "eslint-plugin-prettier": "~3.1.2", + "eslint-plugin-prettier": "~3.4.1", "eslint-plugin-promise": "~4.3.1", "eslint-plugin-standard": "~4.0.1", "jest": "~25.3.0", diff --git a/backend/src/config/index.js b/backend/src/config/index.js index d6d8cc166..7df780cfc 100644 --- a/backend/src/config/index.js +++ b/backend/src/config/index.js @@ -22,6 +22,8 @@ const environment = { DEBUG: env.NODE_ENV !== 'production' && env.DEBUG, TEST: env.NODE_ENV === 'test', PRODUCTION: env.NODE_ENV === 'production', + // used for staging enviroments if 'PRODUCTION=true' and 'PRODUCTION_DB_CLEAN_ALLOW=true' + PRODUCTION_DB_CLEAN_ALLOW: env.PRODUCTION_DB_CLEAN_ALLOW === 'true' || false, // default = false DISABLED_MIDDLEWARES: (env.NODE_ENV !== 'production' && env.DISABLED_MIDDLEWARES) || false, } @@ -84,6 +86,7 @@ const options = { ORGANIZATION_URL: emails.ORGANIZATION_LINK, PUBLIC_REGISTRATION: env.PUBLIC_REGISTRATION === 'true' || false, INVITE_REGISTRATION: env.INVITE_REGISTRATION !== 'false', // default = true + CATEGORIES_ACTIVE: process.env.CATEGORIES_ACTIVE === 'true' || false, } // Check if all required configs are present diff --git a/backend/src/constants/categories.js b/backend/src/constants/categories.js new file mode 100644 index 000000000..0d61a041a --- /dev/null +++ b/backend/src/constants/categories.js @@ -0,0 +1,102 @@ +// 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', + name: 'networking', + description: 'Kooperation, Aktionsbündnisse, Solidarität, Hilfe', + }, + { + icon: 'home', + name: 'home', + description: 'Bauen, Lebensgemeinschaften, Tiny Houses, Gemüsegarten', + }, + { + icon: 'energy', + name: 'energy', + description: 'Öl, Gas, Kohle, Wind, Wasserkraft, Biogas, Atomenergie, ...', + }, + { + icon: 'psyche', + name: 'psyche', + description: 'Seele, Gefühle, Glück', + }, + { + icon: 'movement', + name: 'body-and-excercise', + description: 'Sport, Yoga, Massage, Tanzen, Entspannung', + }, + { + icon: 'balance-scale', + name: 'law', + description: 'Menschenrechte, Gesetze, Verordnungen', + }, + { + icon: 'finance', + name: 'finance', + description: 'Geld, Finanzsystem, Alternativwährungen, ...', + }, + { + icon: 'child', + name: 'children', + description: 'Familie, Pädagogik, Schule, Prägung', + }, + { + icon: 'mobility', + name: 'mobility', + description: 'Reise, Verkehr, Elektromobilität', + }, + { + icon: 'shopping-cart', + name: 'economy', + description: 'Handel, Konsum, Marketing, Lebensmittel, Lieferketten, ...', + }, + { + icon: 'peace', + name: 'peace', + description: 'Krieg, Militär, soziale Verteidigung, Waffen, Cyberattacken', + }, + { + icon: 'politics', + name: 'politics', + description: 'Demokratie, Mitbestimmung, Wahlen, Korruption, Parteien', + }, + { + icon: 'nature', + name: 'nature', + description: 'Tiere, Pflanzen, Landwirtschaft, Ökologie, Artenvielfalt', + }, + { + icon: 'science', + name: 'science', + description: 'Bildung, Hochschule, Publikationen, ...', + }, + { + icon: 'health', + name: 'health', + description: 'Medizin, Ernährung, WHO, Impfungen, Schadstoffe, ...', + }, + { + icon: 'media', + name: 'it-and-media', + description: + 'Nachrichten, Manipulation, Datenschutz, Überwachung, Datenkraken, AI, Software, Apps', + }, + { + icon: 'spirituality', + name: 'spirituality', + description: 'Religion, Werte, Ethik', + }, + { + icon: 'culture', + name: 'culture', + description: 'Kunst, Theater, Musik, Fotografie, Film', + }, + { + icon: 'miscellaneous', + name: 'miscellaneous', + description: '', + }, +] diff --git a/backend/src/constants/groups.js b/backend/src/constants/groups.js new file mode 100644 index 000000000..e9c941a89 --- /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/clean.js b/backend/src/db/clean.js index db8e8aad6..eac26036c 100644 --- a/backend/src/db/clean.js +++ b/backend/src/db/clean.js @@ -1,8 +1,8 @@ -import { cleanDatabase } from '../db/factories' import CONFIG from '../config' +import { cleanDatabase } from '../db/factories' -if (CONFIG.PRODUCTION) { - throw new Error(`You cannot clean the database in production environment!`) +if (CONFIG.PRODUCTION && !CONFIG.PRODUCTION_DB_CLEAN_ALLOW) { + throw new Error(`You cannot clean the database in a non-staging and real production environment!`) } ;(async function () { diff --git a/backend/src/db/graphql/authentications.js b/backend/src/db/graphql/authentications.js new file mode 100644 index 000000000..91605ec9f --- /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 000000000..b408c5e95 --- /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 000000000..e388b2cd9 --- /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 000000000..2669d6f24 --- /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 000000000..3cb8a05f8 --- /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 377caf0b0..57a317b47 100644 --- a/backend/src/db/migrate/store.js +++ b/backend/src/db/migrate/store.js @@ -1,6 +1,8 @@ import { getDriver, getNeode } from '../../db/neo4j' import { hashSync } from 'bcryptjs' import { v4 as uuid } from 'uuid' +import { categories } from '../../constants/categories' +import CONFIG from '../../config' const defaultAdmin = { email: 'admin@example.org', @@ -10,6 +12,29 @@ const defaultAdmin = { slug: 'admin', } +const createCategories = async (session) => { + const createCategoriesTxResultPromise = session.writeTransaction(async (txc) => { + categories.forEach(({ icon, name }, index) => { + const id = `cat${index + 1}` + txc.run( + `MERGE (c:Category { + icon: "${icon}", + slug: "${name}", + name: "${name}", + id: "${id}", + createdAt: toString(datetime()) + })`, + ) + }) + }) + try { + await createCategoriesTxResultPromise + console.log('Successfully created categories!') // eslint-disable-line no-console + } catch (error) { + console.log(`Error creating categories: ${error}`) // eslint-disable-line no-console + } +} + const createDefaultAdminUser = async (session) => { const readTxResultPromise = session.readTransaction(async (txc) => { const result = await txc.run('MATCH (user:User) RETURN count(user) AS userCount') @@ -45,7 +70,7 @@ const createDefaultAdminUser = async (session) => { }) try { await createAdminTxResultPromise - console.log('Successfully created default admin user') // eslint-disable-line no-console + console.log('Successfully created default admin user!') // eslint-disable-line no-console } catch (error) { console.log(error) // eslint-disable-line no-console } @@ -58,12 +83,13 @@ class Store { const { driver } = neode const session = driver.session() 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 000000000..b87e5632a --- /dev/null +++ b/backend/src/db/migrations/20220803060819-create_fulltext_indices_and_unique_keys_for_groups.js @@ -0,0 +1,66 @@ +import { getDriver } from '../../db/neo4j' + +export const description = ` + We introduced a new node label 'Group' and we need two primary keys 'id' and 'slug' for it. + Additional we like to have fulltext indices the keys 'name', 'slug', 'about', and 'description'. +` + +export async function up(next) { + const driver = getDriver() + const session = driver.session() + const transaction = session.beginTransaction() + + try { + // Implement your migration here. + await transaction.run(` + CREATE CONSTRAINT ON ( group:Group ) ASSERT group.id IS UNIQUE + `) + await transaction.run(` + CREATE CONSTRAINT ON ( group:Group ) ASSERT group.slug IS UNIQUE + `) + await transaction.run(` + CALL db.index.fulltext.createNodeIndex("group_fulltext_search",["Group"],["name", "slug", "about", "description"]) + `) + await transaction.commit() + next() + } catch (error) { + // eslint-disable-next-line no-console + console.log(error) + await transaction.rollback() + // eslint-disable-next-line no-console + console.log('rolled back') + throw new Error(error) + } finally { + session.close() + } +} + +export async function down(next) { + const driver = getDriver() + const session = driver.session() + const transaction = session.beginTransaction() + + try { + // Implement your migration here. + await transaction.run(` + DROP CONSTRAINT ON ( group:Group ) ASSERT group.id IS UNIQUE + `) + await transaction.run(` + DROP CONSTRAINT ON ( group:Group ) ASSERT group.slug IS UNIQUE + `) + await transaction.run(` + CALL db.index.fulltext.drop("group_fulltext_search") + `) + await transaction.commit() + next() + } catch (error) { + // eslint-disable-next-line no-console + console.log(error) + await transaction.rollback() + // eslint-disable-next-line no-console + console.log('rolled back') + throw new Error(error) + } finally { + session.close() + } +} diff --git a/backend/src/db/seed.js b/backend/src/db/seed.js index 7bfd4f159..dd8bb59cb 100644 --- a/backend/src/db/seed.js +++ b/backend/src/db/seed.js @@ -1,10 +1,22 @@ import sample from 'lodash/sample' import { createTestClient } from 'apollo-server-testing' +import CONFIG from '../config' 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) { + throw new Error(`You cannot seed the database in a non-staging and real production environment!`) +} const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] @@ -262,104 +274,16 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] dagobert.relateTo(louie, 'blocked'), ]) - await Promise.all([ - Factory.build('category', { - id: 'cat1', - name: 'Just For Fun', - slug: 'just-for-fun', - icon: 'smile', + await Promise.all( + categories.map(({ icon, name }, index) => { + Factory.build('category', { + id: `cat${index + 1}`, + slug: name, + name, + icon, + }) }), - Factory.build('category', { - id: 'cat2', - name: 'Happiness & Values', - slug: 'happiness-values', - icon: 'heart-o', - }), - Factory.build('category', { - id: 'cat3', - name: 'Health & Wellbeing', - slug: 'health-wellbeing', - icon: 'medkit', - }), - Factory.build('category', { - id: 'cat4', - name: 'Environment & Nature', - slug: 'environment-nature', - icon: 'tree', - }), - Factory.build('category', { - id: 'cat5', - name: 'Animal Protection', - slug: 'animal-protection', - icon: 'paw', - }), - Factory.build('category', { - id: 'cat6', - name: 'Human Rights & Justice', - slug: 'human-rights-justice', - icon: 'balance-scale', - }), - Factory.build('category', { - id: 'cat7', - name: 'Education & Sciences', - slug: 'education-sciences', - icon: 'graduation-cap', - }), - Factory.build('category', { - id: 'cat8', - name: 'Cooperation & Development', - slug: 'cooperation-development', - icon: 'users', - }), - Factory.build('category', { - id: 'cat9', - name: 'Democracy & Politics', - slug: 'democracy-politics', - icon: 'university', - }), - Factory.build('category', { - id: 'cat10', - name: 'Economy & Finances', - slug: 'economy-finances', - icon: 'money', - }), - Factory.build('category', { - id: 'cat11', - name: 'Energy & Technology', - slug: 'energy-technology', - icon: 'flash', - }), - Factory.build('category', { - id: 'cat12', - name: 'IT, Internet & Data Privacy', - slug: 'it-internet-data-privacy', - icon: 'mouse-pointer', - }), - Factory.build('category', { - id: 'cat13', - name: 'Art, Culture & Sport', - slug: 'art-culture-sport', - icon: 'paint-brush', - }), - Factory.build('category', { - id: 'cat14', - name: 'Freedom of Speech', - slug: 'freedom-of-speech', - icon: 'bullhorn', - }), - Factory.build('category', { - id: 'cat15', - name: 'Consumption & Sustainability', - slug: 'consumption-sustainability', - icon: 'shopping-cart', - }), - Factory.build('category', { - id: 'cat16', - name: 'Global Peace & Nonviolence', - slug: 'global-peace-nonviolence', - icon: 'angellist', - }), - ]) + ) const [environment, nature, democracy, freedom] = await Promise.all([ Factory.build('tag', { @@ -376,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:

  • Use the exercises (consciously) for your personal development.

`, + 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', @@ -553,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`, @@ -572,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', @@ -581,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(), @@ -591,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', @@ -610,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/graphql/GraphQL-Playground.md b/backend/src/graphql/GraphQL-Playground.md new file mode 100644 index 000000000..af248f112 --- /dev/null +++ b/backend/src/graphql/GraphQL-Playground.md @@ -0,0 +1,108 @@ +# GraphQL Playground + +To use GraphQL Playground, we need to know some basics: + +## How To Login? + +First, we need to have a user from ocelot.social to log in as. +The user can be created by seeding the Neo4j database from the backend or by multiple GraphQL mutations. + +### Seed The Neo4j Database + +In your browser you can reach the GraphQL Playground under `http://localhost:4000/`, if the database and the backend are running, see [backend](../../README.md). +There you will also find instructions on how to seed the database. + +### Use GraphQL Mutations To Create A User + +TODO: Describe how to create a user using GraphQL mutations! + +### Login Via GraphQL + +You can register a user by sending the query: + +```gql +mutation { + login(email: "user@example.org", password: "1234") +} +``` + +Or use `"moderator@example.org"` or `"admin@example.org"` for the roll you need. + +If all goes well, you will receive a QGL response like: + +```json +{ + "data": { + "login": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InUzIiwibmFtZSI6Ikplbm55IFJvc3RvY2siLCJzbHVnIjoiamVubnktcm9zdG9jayIsImlhdCI6MTY2MjAyMzMwNSwiZXhwIjoxNzI1MTM4NTA1LCJhdWQiOiJodHRwOi8vbG9jYWxob3N0OjMwMDAiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjQwMDAiLCJzdWIiOiJ1MyJ9.atBS-SOeS784HPeFl_5s8sRWehEAU1BkgcOZFD8d4bU" + } +} +``` + +You can use this response to set an HTTP header when you click `HTTP HEADERS` in the footer. +Just set it with the login token you received in response: + +```json +{ + "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InUzIiwibmFtZSI6Ikplbm55IFJvc3RvY2siLCJzbHVnIjoiamVubnktcm9zdG9jayIsImlhdCI6MTY2MjAyMzMwNSwiZXhwIjoxNzI1MTM4NTA1LCJhdWQiOiJodHRwOi8vbG9jYWxob3N0OjMwMDAiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjQwMDAiLCJzdWIiOiJ1MyJ9.atBS-SOeS784HPeFl_5s8sRWehEAU1BkgcOZFD8d4bU" +} +``` + +This token is used for all other queries and mutations you send to the backend. + +## Query And Mutate + +When you are logged in and open a new playground tab by clicking "+", you can create a new group by sending the following mutation: + +```gql +mutation { + CreateGroup( + # id: "" + name: "My Group" + # slug: "" + about: "We will save the world" + 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: interplanetary + categoryIds: ["cat12"] + ) { + id + name + slug + createdAt + updatedAt + disabled + deleted + about + description + groupType + actionRadius + myRole + } +} +``` + +You will receive the answer: + +```json +{ + "data": { + "CreateGroup": { + "id": "2e3bbadb-804b-4ebc-a673-2d7c7f05e827", + "name": "My Group", + "slug": "my-group", + "createdAt": "2022-09-01T09:44:47.969Z", + "updatedAt": "2022-09-01T09:44:47.969Z", + "disabled": false, + "deleted": false, + "about": "We will save the world", + "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": "interplanetary", + "myRole": "owner" + } + } +} +``` + +If you look into the Neo4j database with your browser and search the groups, you will now also find your new group. +For more details about our Neo4j database read [here](../../../neo4j/README.md). diff --git a/backend/src/helpers/jest.js b/backend/src/helpers/jest.js index 201d68c14..e3f6a3c84 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 40a6a6ae4..28b30fb4f 100644 --- a/backend/src/middleware/excerptMiddleware.js +++ b/backend/src/middleware/excerptMiddleware.js @@ -1,26 +1,32 @@ 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) => { + if (args.description) + 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 72976b43c..ac71f6bdc 100644 --- a/backend/src/middleware/helpers/cleanHtml.js +++ b/backend/src/middleware/helpers/cleanHtml.js @@ -1,6 +1,13 @@ import sanitizeHtml from 'sanitize-html' import linkifyHtml from 'linkifyjs/html' +export const removeHtmlTags = (input) => { + return sanitizeHtml(input, { + allowedTags: [], + allowedAttributes: {}, + }) +} + const standardSanitizeHtmlOptions = { allowedTags: [ 'img', diff --git a/backend/src/middleware/languages/languages.js b/backend/src/middleware/languages/languages.js index 3cf760f31..087252975 100644 --- a/backend/src/middleware/languages/languages.js +++ b/backend/src/middleware/languages/languages.js @@ -1,12 +1,5 @@ import LanguageDetect from 'languagedetect' -import sanitizeHtml from 'sanitize-html' - -const removeHtmlTags = (input) => { - return sanitizeHtml(input, { - allowedTags: [], - allowedAttributes: {}, - }) -} +import { removeHtmlTags } from '../helpers/cleanHtml.js' const setPostLanguage = (text) => { const lngDetector = new LanguageDetect() diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index b10389f50..906285d12 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,237 @@ 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 canCommentPost = rule({ + cache: 'no_cache', +})(async (_parent, args, { user, driver }) => { + if (!(user && user.id)) return false + const { postId } = args + const userId = user.id + const session = driver.session() + const readTxPromise = session.readTransaction(async (transaction) => { + const transactionResponse = await transaction.run( + ` + MATCH (post:Post { id: $postId }) + OPTIONAL MATCH (post)-[:IN]->(group:Group) + OPTIONAL MATCH (user:User { id: $userId })-[membership:MEMBER_OF]->(group) + RETURN group AS group, membership AS membership + `, + { postId, userId }, + ) + return { + group: transactionResponse.records.map((record) => record.get('group'))[0], + membership: transactionResponse.records.map((record) => record.get('membership'))[0], + } + }) + try { + const { group, membership } = await readTxPromise + return ( + !group || (membership && ['usual', 'admin', 'owner'].includes(membership.properties.role)) + ) + } catch (error) { + throw new Error(error) + } finally { + session.close() + } +}) + const isAuthor = rule({ cache: 'no_cache', })(async (_parent, args, { user, driver }) => { @@ -78,7 +309,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 +333,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 +344,9 @@ export default shield( reports: isModerator, statistics: allow, currentUser: allow, + Group: isAuthenticated, + GroupMembers: isAllowedSeeingGroupMembers, + GroupCount: isAuthenticated, Post: allow, profilePagePosts: allow, Comment: allow, @@ -140,7 +373,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, @@ -157,7 +395,7 @@ export default shield( unshout: isAuthenticated, changePassword: isAuthenticated, review: isModerator, - CreateComment: isAuthenticated, + CreateComment: and(isAuthenticated, canCommentPost), UpdateComment: isAuthor, DeleteComment: isAuthor, DeleteUser: or(isDeletingOwnAccount, isAdmin), @@ -178,6 +416,7 @@ export default shield( GenerateInviteCode: isAuthenticated, switchUserRole: isAdmin, markTeaserAsViewed: allow, + saveCategorySettings: isAuthenticated, }, User: { email: or(isMyOwn, isAdmin), @@ -188,5 +427,6 @@ export default shield( debug, allowExternalErrors, fallbackRule: allow, + fallbackError: Error('Not Authorized!'), }, ) diff --git a/backend/src/middleware/permissionsMiddleware.spec.js b/backend/src/middleware/permissionsMiddleware.spec.js index e29e0a4af..9d71314bb 100644 --- a/backend/src/middleware/permissionsMiddleware.spec.js +++ b/backend/src/middleware/permissionsMiddleware.spec.js @@ -102,7 +102,7 @@ describe('authorization', () => { await expect( query({ query: userQuery, variables: { name: 'Owner' } }), ).resolves.toMatchObject({ - errors: [{ message: 'Not Authorised!' }], + errors: [{ message: 'Not Authorized!' }], data: { User: [null] }, }) }) @@ -132,7 +132,7 @@ describe('authorization', () => { await expect( query({ query: userQuery, variables: { name: 'Owner' } }), ).resolves.toMatchObject({ - errors: [{ message: 'Not Authorised!' }], + errors: [{ message: 'Not Authorized!' }], data: { User: [null] }, }) }) @@ -147,7 +147,7 @@ describe('authorization', () => { await expect( query({ query: userQuery, variables: { name: 'Owner' } }), ).resolves.toMatchObject({ - errors: [{ message: 'Not Authorised!' }], + errors: [{ message: 'Not Authorized!' }], data: { User: [null] }, }) }) @@ -198,7 +198,7 @@ describe('authorization', () => { it('denies permission', async () => { await expect(mutate({ mutation: signupMutation, variables })).resolves.toMatchObject({ - errors: [{ message: 'Not Authorised!' }], + errors: [{ message: 'Not Authorized!' }], data: { Signup: null }, }) }) @@ -288,7 +288,7 @@ describe('authorization', () => { it('denies permission', async () => { await expect(mutate({ mutation: signupMutation, variables })).resolves.toMatchObject({ - errors: [{ message: 'Not Authorised!' }], + errors: [{ message: 'Not Authorized!' }], data: { Signup: null }, }) }) diff --git a/backend/src/middleware/sluggifyMiddleware.js b/backend/src/middleware/sluggifyMiddleware.js index 165235be9..bbe47c9aa 100644 --- a/backend/src/middleware/sluggifyMiddleware.js +++ b/backend/src/middleware/sluggifyMiddleware.js @@ -26,11 +26,16 @@ 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) + }, 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? what happens if "args.title" is not defined? may it works accidentally, because "args.title" or "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 7cfb89c19..41d58ece3 100644 --- a/backend/src/middleware/slugify/uniqueSlug.js +++ b/backend/src/middleware/slugify/uniqueSlug.js @@ -1,4 +1,5 @@ import slugify from 'slug' + export default async function uniqueSlug(string, isUnique) { const slug = slugify(string || 'anonymous', { lower: true, diff --git a/backend/src/middleware/slugifyMiddleware.spec.js b/backend/src/middleware/slugifyMiddleware.spec.js index 7c6f18ab1..1fdfb7364 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,242 @@ 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 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 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 +308,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 +361,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 +377,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 +416,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 +424,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 +447,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 +495,7 @@ describe('slugifyMiddleware', () => { it('chooses another slug', async () => { await expect( mutate({ - mutation, + mutation: signupVerificationMutation, variables, }), ).resolves.toMatchObject({ @@ -246,6 +504,7 @@ describe('slugifyMiddleware', () => { slug: 'i-am-a-user-1', }, }, + errors: undefined, }) }) @@ -260,7 +519,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 553aefe78..62e8e47f7 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/Category.js b/backend/src/models/Category.js index ea617adc8..9a3f47fd0 100644 --- a/backend/src/models/Category.js +++ b/backend/src/models/Category.js @@ -9,8 +9,7 @@ export default { updatedAt: { type: 'string', isoDate: true, - required: true, - default: () => new Date().toISOString(), + required: false, }, post: { type: 'relationship', diff --git a/backend/src/models/Group.js b/backend/src/models/Group.js new file mode 100644 index 000000000..a75ad518f --- /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 102acde6a..c64d1fd37 100644 --- a/backend/src/models/User.spec.js +++ b/backend/src/models/User.spec.js @@ -55,7 +55,7 @@ describe('slug', () => { \`\`\` Learn how to setup the database here: - https://docs.human-connection.org/human-connection/backend#database-indices-and-constraints + https://github.com/Ocelot-Social-Community/Ocelot-Social/blob/master/backend/README.md#database-indices-and-constraints `) } }) diff --git a/backend/src/models/index.js b/backend/src/models/index.js index 8d6a021ab..d476e5f9b 100644 --- a/backend/src/models/index.js +++ b/backend/src/models/index.js @@ -4,6 +4,7 @@ export default { Image: require('./Image.js').default, Badge: require('./Badge.js').default, User: require('./User.js').default, + Group: require('./Group.js').default, EmailAddress: require('./EmailAddress.js').default, UnverifiedEmailAddress: require('./UnverifiedEmailAddress.js').default, SocialMedia: require('./SocialMedia.js').default, diff --git a/backend/src/schema/index.js b/backend/src/schema/index.js index 612487147..06e150c86 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/comments.spec.js b/backend/src/schema/resolvers/comments.spec.js index cbd2b98fc..33b33ed3e 100644 --- a/backend/src/schema/resolvers/comments.spec.js +++ b/backend/src/schema/resolvers/comments.spec.js @@ -88,10 +88,10 @@ describe('CreateComment', () => { variables = { ...variables, postId: 'p1', - content: "I'm not authorised to comment", + content: "I'm not authorized to comment", } const { errors } = await mutate({ mutation: createCommentMutation, variables }) - expect(errors[0]).toHaveProperty('message', 'Not Authorised!') + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') }) }) @@ -107,14 +107,14 @@ describe('CreateComment', () => { variables = { ...variables, postId: 'p1', - content: "I'm authorised to comment", + content: "I'm authorized to comment", } }) it('creates a comment', async () => { await expect(mutate({ mutation: createCommentMutation, variables })).resolves.toMatchObject( { - data: { CreateComment: { content: "I'm authorised to comment" } }, + data: { CreateComment: { content: "I'm authorized to comment" } }, errors: undefined, }, ) @@ -150,7 +150,7 @@ describe('UpdateComment', () => { describe('unauthenticated', () => { it('throws authorization error', async () => { const { errors } = await mutate({ mutation: updateCommentMutation, variables }) - expect(errors[0]).toHaveProperty('message', 'Not Authorised!') + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') }) }) @@ -162,7 +162,7 @@ describe('UpdateComment', () => { it('throws authorization error', async () => { const { errors } = await mutate({ mutation: updateCommentMutation, variables }) - expect(errors[0]).toHaveProperty('message', 'Not Authorised!') + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') }) }) @@ -217,7 +217,7 @@ describe('UpdateComment', () => { it('returns null', async () => { const { data, errors } = await mutate({ mutation: updateCommentMutation, variables }) expect(data).toMatchObject({ UpdateComment: null }) - expect(errors[0]).toHaveProperty('message', 'Not Authorised!') + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') }) }) }) @@ -242,7 +242,7 @@ describe('DeleteComment', () => { describe('unauthenticated', () => { it('throws authorization error', async () => { const result = await mutate({ mutation: deleteCommentMutation, variables }) - expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!') + expect(result.errors[0]).toHaveProperty('message', 'Not Authorized!') }) }) @@ -254,7 +254,7 @@ describe('DeleteComment', () => { it('throws authorization error', async () => { const { errors } = await mutate({ mutation: deleteCommentMutation, variables }) - expect(errors[0]).toHaveProperty('message', 'Not Authorised!') + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') }) }) diff --git a/backend/src/schema/resolvers/donations.spec.js b/backend/src/schema/resolvers/donations.spec.js index a9210f6a5..11df0c67b 100644 --- a/backend/src/schema/resolvers/donations.spec.js +++ b/backend/src/schema/resolvers/donations.spec.js @@ -72,7 +72,7 @@ describe('donations', () => { it('throws authorization error', async () => { authenticatedUser = undefined await expect(query({ query: donationsQuery, variables })).resolves.toMatchObject({ - errors: [{ message: 'Not Authorised!' }], + errors: [{ message: 'Not Authorized!' }], }) }) }) @@ -106,7 +106,7 @@ describe('donations', () => { await expect( mutate({ mutation: updateDonationsMutation, variables }), ).resolves.toMatchObject({ - errors: [{ message: 'Not Authorised!' }], + errors: [{ message: 'Not Authorized!' }], }) }) }) @@ -126,7 +126,7 @@ describe('donations', () => { mutate({ mutation: updateDonationsMutation, variables }), ).resolves.toMatchObject({ data: { UpdateDonations: null }, - errors: [{ message: 'Not Authorised!' }], + errors: [{ message: 'Not Authorized!' }], }) }) }) @@ -145,7 +145,7 @@ describe('donations', () => { mutate({ mutation: updateDonationsMutation, variables }), ).resolves.toMatchObject({ data: { UpdateDonations: null }, - errors: [{ message: 'Not Authorised!' }], + errors: [{ message: 'Not Authorized!' }], }) }) }) diff --git a/backend/src/schema/resolvers/emails.spec.js b/backend/src/schema/resolvers/emails.spec.js index 39b70ac0b..b527ca3b0 100644 --- a/backend/src/schema/resolvers/emails.spec.js +++ b/backend/src/schema/resolvers/emails.spec.js @@ -63,7 +63,7 @@ describe('AddEmailAddress', () => { it('throws AuthorizationError', async () => { await expect(mutate({ mutation, variables })).resolves.toMatchObject({ data: { AddEmailAddress: null }, - errors: [{ message: 'Not Authorised!' }], + errors: [{ message: 'Not Authorized!' }], }) }) }) @@ -169,7 +169,7 @@ describe('VerifyEmailAddress', () => { it('throws AuthorizationError', async () => { await expect(mutate({ mutation, variables })).resolves.toMatchObject({ data: { VerifyEmailAddress: null }, - errors: [{ message: 'Not Authorised!' }], + errors: [{ message: 'Not Authorized!' }], }) }) }) diff --git a/backend/src/schema/resolvers/follow.spec.js b/backend/src/schema/resolvers/follow.spec.js index 9cc8403e5..e0b2a277a 100644 --- a/backend/src/schema/resolvers/follow.spec.js +++ b/backend/src/schema/resolvers/follow.spec.js @@ -117,7 +117,7 @@ describe('follow', () => { variables, }), ).resolves.toMatchObject({ - errors: [{ message: 'Not Authorised!' }], + errors: [{ message: 'Not Authorized!' }], data: { followUser: null }, }) }) @@ -191,7 +191,7 @@ describe('follow', () => { authenticatedUser = null await expect(mutate({ mutation: mutationUnfollowUser, variables })).resolves.toMatchObject({ data: { unfollowUser: null }, - errors: [{ message: 'Not Authorised!' }], + errors: [{ message: 'Not Authorized!' }], }) }) }) diff --git a/backend/src/schema/resolvers/groups.js b/backend/src/schema/resolvers/groups.js new file mode 100644 index 000000000..c6248c0c6 --- /dev/null +++ b/backend/src/schema/resolvers/groups.js @@ -0,0 +1,385 @@ +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, first, offset } = params + let pagination = '' + if (first !== undefined && offset !== undefined) pagination = `SKIP ${offset} LIMIT ${first}` + 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} + ${pagination} + ` + } 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} + ${pagination} + ` + } 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} + ${pagination} + ` + } + } + 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() + } + }, + GroupCount: async (_object, params, context, _resolveInfo) => { + const { isMember } = params + const { + user: { id: userId }, + } = context + const session = context.driver.session() + const readTxResultPromise = session.readTransaction(async (txc) => { + let cypher + if (isMember) { + cypher = `MATCH (user:User)-[membership:MEMBER_OF]->(group:Group) + WHERE user.id = $userId + AND membership.role IN ['usual', 'admin', 'owner'] + RETURN toString(count(group)) AS count` + } else { + cypher = `MATCH (group:Group) + OPTIONAL MATCH (user:User)-[membership:MEMBER_OF]->(group) + WHERE user.id = $userId + WITH group, membership + WHERE group.groupType IN ['public', 'closed'] + OR membership.role IN ['usual', 'admin', 'owner'] + RETURN toString(count(group)) AS count` + } + const transactionResponse = await txc.run(cypher, { userId }) + return transactionResponse.records.map((record) => record.get('count')) + }) + try { + return parseInt(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 000000000..3be8c0f90 --- /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-best-group', // 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 f2861e7a0..6e8211521 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 000000000..73dfaad91 --- /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/moderation.spec.js b/backend/src/schema/resolvers/moderation.spec.js index 662be41bd..3c2926c14 100644 --- a/backend/src/schema/resolvers/moderation.spec.js +++ b/backend/src/schema/resolvers/moderation.spec.js @@ -120,7 +120,7 @@ describe('moderate resources', () => { await expect( mutate({ mutation: reviewMutation, variables: disableVariables }), ).resolves.toMatchObject({ - errors: [{ message: 'Not Authorised!' }], + errors: [{ message: 'Not Authorized!' }], }) }) }) @@ -134,7 +134,7 @@ describe('moderate resources', () => { await expect( mutate({ mutation: reviewMutation, variables: disableVariables }), ).resolves.toMatchObject({ - errors: [{ message: 'Not Authorised!' }], + errors: [{ message: 'Not Authorized!' }], }) }) }) @@ -218,7 +218,7 @@ describe('moderate resources', () => { await expect( mutate({ mutation: reviewMutation, variables: disableVariables }), ).resolves.toMatchObject({ - errors: [{ message: 'Not Authorised!' }], + errors: [{ message: 'Not Authorized!' }], }) }) }) @@ -232,7 +232,7 @@ describe('moderate resources', () => { await expect( mutate({ mutation: reviewMutation, variables: disableVariables }), ).resolves.toMatchObject({ - errors: [{ message: 'Not Authorised!' }], + errors: [{ message: 'Not Authorized!' }], }) }) }) @@ -488,7 +488,7 @@ describe('moderate resources', () => { await expect( mutate({ mutation: reviewMutation, variables: enableVariables }), ).resolves.toMatchObject({ - errors: [{ message: 'Not Authorised!' }], + errors: [{ message: 'Not Authorized!' }], }) }) }) @@ -507,7 +507,7 @@ describe('moderate resources', () => { await expect( mutate({ mutation: reviewMutation, variables: enableVariables }), ).resolves.toMatchObject({ - errors: [{ message: 'Not Authorised!' }], + errors: [{ message: 'Not Authorized!' }], }) }) }) diff --git a/backend/src/schema/resolvers/notifications.spec.js b/backend/src/schema/resolvers/notifications.spec.js index 2ea468914..924154488 100644 --- a/backend/src/schema/resolvers/notifications.spec.js +++ b/backend/src/schema/resolvers/notifications.spec.js @@ -165,7 +165,7 @@ describe('given some notifications', () => { describe('unauthenticated', () => { it('throws authorization error', async () => { const { errors } = await query({ query: notificationQuery }) - expect(errors[0]).toHaveProperty('message', 'Not Authorised!') + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') }) }) @@ -313,7 +313,7 @@ describe('given some notifications', () => { mutation: markAsReadMutation, variables: { ...variables, id: 'p1' }, }) - expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!') + expect(result.errors[0]).toHaveProperty('message', 'Not Authorized!') }) }) diff --git a/backend/src/schema/resolvers/posts.js b/backend/src/schema/resolvers/posts.js index d199b6f09..78515e641 100644 --- a/backend/src/schema/resolvers/posts.js +++ b/backend/src/schema/resolvers/posts.js @@ -5,6 +5,8 @@ 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) => { const pinnedPostFilter = { pinned: true } @@ -19,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) }, @@ -76,12 +76,44 @@ export default { }, Mutation: { CreatePost: async (_parent, params, context, _resolveInfo) => { + 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 + UNWIND $categoryIds AS categoryId + MATCH (category:Category {id: categoryId}) + MERGE (post)-[:CATEGORIZED]->(category)` + : '' const createPostTransactionResponse = await transaction.run( ` CREATE (post:Post) @@ -93,9 +125,11 @@ export default { WITH post MATCH (author:User {id: $userId}) MERGE (post)<-[:WROTE]-(author) + ${categoriesCypher} + ${groupCypher} RETURN post {.*} `, - { userId: context.user.id, params }, + { userId: context.user.id, categoryIds, groupId, params }, ) const [post] = createPostTransactionResponse.records.map((record) => record.get('post')) if (imageInput) { @@ -121,13 +155,13 @@ 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 (categoryIds && categoryIds.length) { + if (CONFIG.CATEGORIES_ACTIVE && categoryIds && categoryIds.length) { const cypherDeletePreviousRelations = ` MATCH (post:Post { id: $params.id })-[previousRelations:CATEGORIZED]->(category:Category) DELETE previousRelations @@ -348,7 +382,7 @@ export default { undefinedToNull: ['activityId', 'objectId', 'language', 'pinnedAt', 'pinned'], hasMany: { tags: '-[:TAGGED]->(related:Tag)', - // categories: '-[:CATEGORIZED]->(related:Category)', + categories: '-[:CATEGORIZED]->(related:Category)', comments: '<-[:COMMENTS]-(related:Comment)', shoutedBy: '<-[:SHOUTED]-(related:User)', emotions: '<-[related:EMOTED]', @@ -357,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 2c8b7e90b..6fc9b5722 100644 --- a/backend/src/schema/resolvers/posts.spec.js +++ b/backend/src/schema/resolvers/posts.spec.js @@ -281,7 +281,7 @@ describe('CreatePost', () => { describe('unauthenticated', () => { it('throws authorization error', async () => { const { errors } = await mutate({ mutation: createPostMutation, variables }) - expect(errors[0]).toHaveProperty('message', 'Not Authorised!') + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') }) }) @@ -368,8 +368,8 @@ describe('UpdatePost', () => { describe('unauthenticated', () => { it('throws authorization error', async () => { authenticatedUser = null - expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject({ - errors: [{ message: 'Not Authorised!' }], + await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject({ + errors: [{ message: 'Not Authorized!' }], data: { UpdatePost: null }, }) }) @@ -382,7 +382,7 @@ describe('UpdatePost', () => { it('throws authorization error', async () => { const { errors } = await mutate({ mutation: updatePostMutation, variables }) - expect(errors[0]).toHaveProperty('message', 'Not Authorised!') + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') }) }) @@ -547,7 +547,7 @@ describe('pin posts', () => { it('throws authorization error', async () => { authenticatedUser = null await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject({ - errors: [{ message: 'Not Authorised!' }], + errors: [{ message: 'Not Authorized!' }], data: { pinPost: null }, }) }) @@ -556,7 +556,7 @@ describe('pin posts', () => { describe('ordinary users', () => { it('throws authorization error', async () => { await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject({ - errors: [{ message: 'Not Authorised!' }], + errors: [{ message: 'Not Authorized!' }], data: { pinPost: null }, }) }) @@ -571,7 +571,7 @@ describe('pin posts', () => { it('throws authorization error', async () => { await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject({ - errors: [{ message: 'Not Authorised!' }], + errors: [{ message: 'Not Authorized!' }], data: { pinPost: null }, }) }) @@ -854,7 +854,7 @@ describe('unpin posts', () => { it('throws authorization error', async () => { authenticatedUser = null await expect(mutate({ mutation: unpinPostMutation, variables })).resolves.toMatchObject({ - errors: [{ message: 'Not Authorised!' }], + errors: [{ message: 'Not Authorized!' }], data: { unpinPost: null }, }) }) @@ -863,7 +863,7 @@ describe('unpin posts', () => { describe('users cannot unpin posts', () => { it('throws authorization error', async () => { await expect(mutate({ mutation: unpinPostMutation, variables })).resolves.toMatchObject({ - errors: [{ message: 'Not Authorised!' }], + errors: [{ message: 'Not Authorized!' }], data: { unpinPost: null }, }) }) @@ -878,7 +878,7 @@ describe('unpin posts', () => { it('throws authorization error', async () => { await expect(mutate({ mutation: unpinPostMutation, variables })).resolves.toMatchObject({ - errors: [{ message: 'Not Authorised!' }], + errors: [{ message: 'Not Authorized!' }], data: { unpinPost: null }, }) }) @@ -975,7 +975,7 @@ describe('DeletePost', () => { describe('unauthenticated', () => { it('throws authorization error', async () => { const { errors } = await mutate({ mutation: deletePostMutation, variables }) - expect(errors[0]).toHaveProperty('message', 'Not Authorised!') + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') }) }) @@ -986,7 +986,7 @@ describe('DeletePost', () => { it('throws authorization error', async () => { const { errors } = await mutate({ mutation: deletePostMutation, variables }) - expect(errors[0]).toHaveProperty('message', 'Not Authorised!') + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') }) }) @@ -1128,7 +1128,7 @@ describe('emotions', () => { variables, }) - expect(addPostEmotions.errors[0]).toHaveProperty('message', 'Not Authorised!') + expect(addPostEmotions.errors[0]).toHaveProperty('message', 'Not Authorized!') }) }) @@ -1249,7 +1249,7 @@ describe('emotions', () => { mutation: removePostEmotionsMutation, variables: removePostEmotionsVariables, }) - expect(removePostEmotions.errors[0]).toHaveProperty('message', 'Not Authorised!') + expect(removePostEmotions.errors[0]).toHaveProperty('message', 'Not Authorized!') }) }) diff --git a/backend/src/schema/resolvers/postsInGroups.spec.js b/backend/src/schema/resolvers/postsInGroups.spec.js new file mode 100644 index 000000000..2c1e88b62 --- /dev/null +++ b/backend/src/schema/resolvers/postsInGroups.spec.js @@ -0,0 +1,1681 @@ +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' +import { createCommentMutation } from '../../db/graphql/comments' +// 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('commenting 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: createCommentMutation, + variables: { + postId: 'post-to-public-group', + content: + 'I am commenting a post in a public group without being a member of the group', + }, + }), + ).resolves.toMatchObject({ + errors: expect.arrayContaining([expect.objectContaining({ message: 'Not Authorized!' })]), + }) + }) + + it('throws an error for closed groups', async () => { + await expect( + mutate({ + mutation: createCommentMutation, + variables: { + postId: 'post-to-closed-group', + content: + 'I am commenting a post in a closed group without being a member of the group', + }, + }), + ).resolves.toMatchObject({ + errors: expect.arrayContaining([expect.objectContaining({ message: 'Not Authorized!' })]), + }) + }) + + it('throws an error for hidden groups', async () => { + await expect( + mutate({ + mutation: createCommentMutation, + variables: { + postId: 'post-to-hidden-group', + content: + 'I am commenting a post in a hidden group without being a member of the 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: createCommentMutation, + variables: { + postId: 'post-to-public-group', + content: 'I am commenting a post in a public group as a pending member of the group', + }, + }), + ).resolves.toMatchObject({ + errors: expect.arrayContaining([expect.objectContaining({ message: 'Not Authorized!' })]), + }) + }) + + it('throws an error for closed groups', async () => { + await expect( + mutate({ + mutation: createCommentMutation, + variables: { + postId: 'post-to-closed-group', + content: 'I am commenting a post in a closed group as a pending member of the group', + }, + }), + ).resolves.toMatchObject({ + errors: expect.arrayContaining([expect.objectContaining({ message: 'Not Authorized!' })]), + }) + }) + + it('throws an error for hidden groups', async () => { + await expect( + mutate({ + mutation: createCommentMutation, + variables: { + postId: 'post-to-hidden-group', + content: 'I am commenting a post in a hidden group as a pending member of the group', + }, + }), + ).resolves.toMatchObject({ + errors: expect.arrayContaining([expect.objectContaining({ message: 'Not Authorized!' })]), + }) + }) + }) + + describe('as a member of group', () => { + beforeAll(async () => { + authenticatedUser = await allGroupsUser.toJson() + }) + + it('comments a post in a public group', async () => { + await expect( + mutate({ + mutation: createCommentMutation, + variables: { + postId: 'post-to-public-group', + content: 'I am commenting a post in a public group as a member of the group', + }, + }), + ).resolves.toMatchObject({ + data: { + CreateComment: { + id: expect.any(String), + }, + }, + errors: undefined, + }) + }) + + it('comments a post in a closed group', async () => { + await expect( + mutate({ + mutation: createCommentMutation, + variables: { + postId: 'post-to-closed-group', + content: 'I am commenting a post in a closed group as a member of the group', + }, + }), + ).resolves.toMatchObject({ + data: { + CreateComment: { + id: expect.any(String), + }, + }, + errors: undefined, + }) + }) + + it('comments a post in a hidden group', async () => { + await expect( + mutate({ + mutation: createCommentMutation, + variables: { + postId: 'post-to-hidden-group', + content: 'I am commenting a post in a hidden group as a member of the group', + }, + }), + ).resolves.toMatchObject({ + data: { + CreateComment: { + id: expect.any(String), + }, + }, + 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 ea420bc2a..52c92b033 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/registration.spec.js b/backend/src/schema/resolvers/registration.spec.js index 573af1d35..ebe36456b 100644 --- a/backend/src/schema/resolvers/registration.spec.js +++ b/backend/src/schema/resolvers/registration.spec.js @@ -61,7 +61,7 @@ describe('Signup', () => { CONFIG.INVITE_REGISTRATION = false CONFIG.PUBLIC_REGISTRATION = false await expect(mutate({ mutation, variables })).resolves.toMatchObject({ - errors: [{ message: 'Not Authorised!' }], + errors: [{ message: 'Not Authorized!' }], }) }) diff --git a/backend/src/schema/resolvers/reports.spec.js b/backend/src/schema/resolvers/reports.spec.js index 09d0869fc..7cf4d9a34 100644 --- a/backend/src/schema/resolvers/reports.spec.js +++ b/backend/src/schema/resolvers/reports.spec.js @@ -130,7 +130,7 @@ describe('file a report on a resource', () => { authenticatedUser = null await expect(mutate({ mutation: fileReportMutation, variables })).resolves.toMatchObject({ data: { fileReport: null }, - errors: [{ message: 'Not Authorised!' }], + errors: [{ message: 'Not Authorized!' }], }) }) }) @@ -729,7 +729,7 @@ describe('file a report on a resource', () => { authenticatedUser = null expect(query({ query: reportsQuery })).resolves.toMatchObject({ data: { reports: null }, - errors: [{ message: 'Not Authorised!' }], + errors: [{ message: 'Not Authorized!' }], }) }) }) @@ -739,7 +739,7 @@ describe('file a report on a resource', () => { authenticatedUser = await currentUser.toJson() expect(query({ query: reportsQuery })).resolves.toMatchObject({ data: { reports: null }, - errors: [{ message: 'Not Authorised!' }], + errors: [{ message: 'Not Authorized!' }], }) }) diff --git a/backend/src/schema/resolvers/rewards.spec.js b/backend/src/schema/resolvers/rewards.spec.js index 718378699..f44cf51f6 100644 --- a/backend/src/schema/resolvers/rewards.spec.js +++ b/backend/src/schema/resolvers/rewards.spec.js @@ -97,7 +97,7 @@ describe('rewards', () => { authenticatedUser = null await expect(mutate({ mutation: rewardMutation, variables })).resolves.toMatchObject({ data: { reward: null }, - errors: [{ message: 'Not Authorised!' }], + errors: [{ message: 'Not Authorized!' }], }) }) }) @@ -255,7 +255,7 @@ describe('rewards', () => { it('throws authorization error', async () => { await expect(mutate({ mutation: rewardMutation, variables })).resolves.toMatchObject({ data: { reward: null }, - errors: [{ message: 'Not Authorised!' }], + errors: [{ message: 'Not Authorized!' }], }) }) }) @@ -308,7 +308,7 @@ describe('rewards', () => { authenticatedUser = null await expect(mutate({ mutation: unrewardMutation, variables })).resolves.toMatchObject({ data: { unreward: null }, - errors: [{ message: 'Not Authorised!' }], + errors: [{ message: 'Not Authorized!' }], }) }) }) @@ -341,7 +341,7 @@ describe('rewards', () => { it('throws authorization error', async () => { await expect(mutate({ mutation: unrewardMutation, variables })).resolves.toMatchObject({ data: { unreward: null }, - errors: [{ message: 'Not Authorised!' }], + errors: [{ message: 'Not Authorized!' }], }) }) }) diff --git a/backend/src/schema/resolvers/searches.js b/backend/src/schema/resolvers/searches.js index 60fd4318f..3fdf22da3 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/shout.spec.js b/backend/src/schema/resolvers/shout.spec.js index a4e9890d5..087c452da 100644 --- a/backend/src/schema/resolvers/shout.spec.js +++ b/backend/src/schema/resolvers/shout.spec.js @@ -90,7 +90,7 @@ describe('shout and unshout posts', () => { variables = { id: 'post-to-shout-id' } authenticatedUser = undefined await expect(mutate({ mutation: mutationShoutPost, variables })).resolves.toMatchObject({ - errors: [{ message: 'Not Authorised!' }], + errors: [{ message: 'Not Authorized!' }], }) }) }) @@ -165,7 +165,7 @@ describe('shout and unshout posts', () => { authenticatedUser = undefined variables = { id: 'post-to-shout-id' } await expect(mutate({ mutation: mutationUnshoutPost, variables })).resolves.toMatchObject({ - errors: [{ message: 'Not Authorised!' }], + errors: [{ message: 'Not Authorized!' }], }) }) }) diff --git a/backend/src/schema/resolvers/socialMedia.spec.js b/backend/src/schema/resolvers/socialMedia.spec.js index bb7886c3f..fc9ee8f70 100644 --- a/backend/src/schema/resolvers/socialMedia.spec.js +++ b/backend/src/schema/resolvers/socialMedia.spec.js @@ -94,7 +94,7 @@ describe('SocialMedia', () => { const user = null const result = await socialMediaAction(user, mutation, variables) - expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!') + expect(result.errors[0]).toHaveProperty('message', 'Not Authorized!') }) }) @@ -186,7 +186,7 @@ describe('SocialMedia', () => { const user = null const result = await socialMediaAction(user, mutation, variables) - expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!') + expect(result.errors[0]).toHaveProperty('message', 'Not Authorized!') }) }) @@ -195,7 +195,7 @@ describe('SocialMedia', () => { const user = someUser const result = await socialMediaAction(user, mutation, variables) - expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!') + expect(result.errors[0]).toHaveProperty('message', 'Not Authorized!') }) }) @@ -222,7 +222,7 @@ describe('SocialMedia', () => { variables.id = 'some-id' const result = await socialMediaAction(user, mutation, variables) - expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!') + expect(result.errors[0]).toHaveProperty('message', 'Not Authorized!') }) }) }) @@ -249,7 +249,7 @@ describe('SocialMedia', () => { const user = null const result = await socialMediaAction(user, mutation, variables) - expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!') + expect(result.errors[0]).toHaveProperty('message', 'Not Authorized!') }) }) @@ -258,7 +258,7 @@ describe('SocialMedia', () => { const user = someUser const result = await socialMediaAction(user, mutation, variables) - expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!') + expect(result.errors[0]).toHaveProperty('message', 'Not Authorized!') }) }) diff --git a/backend/src/schema/resolvers/user_management.js b/backend/src/schema/resolvers/user_management.js index beb2cddb3..d88eafdae 100644 --- a/backend/src/schema/resolvers/user_management.js +++ b/backend/src/schema/resolvers/user_management.js @@ -20,16 +20,22 @@ export default { const result = await transaction.run( ` MATCH (user:User {id: $id}) - WITH user, [(user)<-[:OWNED_BY]-(medium:SocialMedia) | properties(medium) ] as media - RETURN user {.*, socialMedia: media } as user + OPTIONAL MATCH (category:Category) WHERE NOT ((user)-[:NOT_INTERESTED_IN]->(category)) + OPTIONAL MATCH (cats:Category) + WITH user, [(user)<-[:OWNED_BY]-(medium:SocialMedia) | properties(medium) ] AS media, category, toString(COUNT(cats)) AS categoryCount + RETURN user {.*, socialMedia: media, activeCategories: collect(category.id) } AS user, categoryCount `, { id: user.id }, ) - log(result) - return result.records.map((record) => record.get('user')) + const [categoryCount] = result.records.map((record) => record.get('categoryCount')) + const [currentUser] = result.records.map((record) => record.get('user')) + // frontend expects empty array when all categories are selected + if (currentUser.activeCategories.length === parseInt(categoryCount)) + currentUser.activeCategories = [] + return currentUser }) try { - const [currentUser] = await currentUserTransactionPromise + const currentUser = await currentUserTransactionPromise return currentUser } finally { session.close() diff --git a/backend/src/schema/resolvers/user_management.spec.js b/backend/src/schema/resolvers/user_management.spec.js index 2dcb14855..50e6e84bf 100644 --- a/backend/src/schema/resolvers/user_management.spec.js +++ b/backend/src/schema/resolvers/user_management.spec.js @@ -2,10 +2,12 @@ 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' import { getNeode } from '../../db/neo4j' +import { categories } from '../../constants/categories' const neode = getNeode() let query, mutate, variables, req, user @@ -118,6 +120,7 @@ describe('currentUser', () => { } email role + activeCategories } } ` @@ -172,17 +175,57 @@ describe('currentUser', () => { } await respondsWith(expected) }) + + describe('with categories in DB', () => { + beforeEach(async () => { + await Promise.all( + categories.map(async ({ icon, name }, index) => { + await Factory.build('category', { + id: `cat${index + 1}`, + slug: name, + name, + icon, + }) + }), + ) + }) + + it('returns empty array for all categories', async () => { + await respondsWith({ + data: { + currentUser: expect.objectContaining({ activeCategories: [] }), + }, + }) + }) + + describe('with categories saved for current user', () => { + const saveCategorySettings = gql` + mutation ($activeCategories: [String]) { + saveCategorySettings(activeCategories: $activeCategories) + } + ` + beforeEach(async () => { + await mutate({ + mutation: saveCategorySettings, + variables: { activeCategories: ['cat1', 'cat3', 'cat5', 'cat7'] }, + }) + }) + + it('returns only the saved active categories', async () => { + const result = await query({ query: currentUserQuery, variables }) + expect(result.data.currentUser.activeCategories).toHaveLength(4) + expect(result.data.currentUser.activeCategories).toContain('cat1') + expect(result.data.currentUser.activeCategories).toContain('cat3') + expect(result.data.currentUser.activeCategories).toContain('cat5') + expect(result.data.currentUser.activeCategories).toContain('cat7') + }) + }) + }) }) }) }) 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) } @@ -310,8 +353,8 @@ describe('change password', () => { }) describe('unauthenticated', () => { - it('throws "Not Authorised!"', async () => { - await respondsWith({ errors: [{ message: 'Not Authorised!' }] }) + it('throws "Not Authorized!"', async () => { + await respondsWith({ errors: [{ message: 'Not Authorized!' }] }) }) }) diff --git a/backend/src/schema/resolvers/users.js b/backend/src/schema/resolvers/users.js index 5dc78c5e1..1ce3b986f 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) @@ -269,6 +271,49 @@ export default { session.close() } }, + saveCategorySettings: async (object, args, context, resolveInfo) => { + const { activeCategories } = args + const { + user: { id }, + } = context + + const session = context.driver.session() + await session.writeTransaction((transaction) => { + return transaction.run( + ` + MATCH (user:User { id: $id })-[previousCategories:NOT_INTERESTED_IN]->(category:Category) + DELETE previousCategories + RETURN user, category + `, + { id }, + ) + }) + + // frontend gives [] when all categories are selected (default) + if (activeCategories.length === 0) return true + + const writeTxResultPromise = session.writeTransaction(async (transaction) => { + const saveCategorySettingsResponse = await transaction.run( + ` + MATCH (category:Category) WHERE NOT category.id IN $activeCategories + MATCH (user:User { id: $id }) + MERGE (user)-[r:NOT_INTERESTED_IN]->(category) + RETURN user, r, category + `, + { id, activeCategories }, + ) + const [user] = await saveCategorySettingsResponse.records.map((record) => + record.get('user'), + ) + return user + }) + try { + await writeTxResultPromise + return true + } finally { + session.close() + } + }, }, User: { email: async (parent, params, context, resolveInfo) => { diff --git a/backend/src/schema/resolvers/users.spec.js b/backend/src/schema/resolvers/users.spec.js index 102d2f6fe..87226ec4d 100644 --- a/backend/src/schema/resolvers/users.spec.js +++ b/backend/src/schema/resolvers/users.spec.js @@ -3,6 +3,7 @@ import { gql } from '../../helpers/jest' import { getNeode, getDriver } from '../../db/neo4j' import createServer from '../../server' import { createTestClient } from 'apollo-server-testing' +import { categories } from '../../constants/categories' const categoryIds = ['cat9'] let user @@ -45,7 +46,7 @@ const deleteUserMutation = gql` } ` const switchUserRoleMutation = gql` - mutation ($role: UserGroup!, $id: ID!) { + mutation ($role: UserRole!, $id: ID!) { switchUserRole(role: $role, id: $id) { name role @@ -56,6 +57,12 @@ const switchUserRoleMutation = gql` } ` +const saveCategorySettings = gql` + mutation ($activeCategories: [String]) { + saveCategorySettings(activeCategories: $activeCategories) + } +` + beforeAll(async () => { await cleanDatabase() @@ -101,7 +108,7 @@ describe('User', () => { it('is forbidden', async () => { await expect(query({ query: userQuery, variables })).resolves.toMatchObject({ - errors: [{ message: 'Not Authorised!' }], + errors: [{ message: 'Not Authorized!' }], }) }) @@ -154,7 +161,7 @@ describe('UpdateUser', () => { $id: ID! $name: String $termsAndConditionsAgreedVersion: String - $locationName: String + $locationName: String # empty string '' sets it to null ) { UpdateUser( id: $id @@ -167,6 +174,11 @@ describe('UpdateUser', () => { termsAndConditionsAgreedVersion termsAndConditionsAgreedAt locationName + location { + name + nameDE + nameEN + } } } ` @@ -207,7 +219,7 @@ describe('UpdateUser', () => { it('is not allowed to change other user accounts', async () => { const { errors } = await mutate({ mutation: updateUserMutation, variables }) - expect(errors[0]).toHaveProperty('message', 'Not Authorised!') + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') }) }) @@ -282,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, + }) + }) }) }) }) @@ -500,7 +540,7 @@ describe('switch user role', () => { expect.objectContaining({ errors: [ expect.objectContaining({ - message: 'Not Authorised!', + message: 'Not Authorized!', }), ], }), @@ -544,3 +584,140 @@ describe('switch user role', () => { }) }) }) + +describe('save category settings', () => { + beforeEach(async () => { + await Promise.all( + categories.map(({ icon, name }, index) => { + Factory.build('category', { + id: `cat${index + 1}`, + slug: name, + name, + icon, + }) + }), + ) + }) + + beforeEach(async () => { + user = await Factory.build('user', { + id: 'user', + role: 'user', + }) + variables = { + activeCategories: ['cat1', 'cat3', 'cat5'], + } + }) + + describe('not authenticated', () => { + beforeEach(async () => { + authenticatedUser = undefined + }) + + it('throws an error', async () => { + await expect(mutate({ mutation: saveCategorySettings, variables })).resolves.toEqual( + expect.objectContaining({ + errors: [ + expect.objectContaining({ + message: 'Not Authorized!', + }), + ], + }), + ) + }) + }) + + describe('authenticated', () => { + beforeEach(async () => { + authenticatedUser = await user.toJson() + }) + + const userQuery = gql` + query ($id: ID) { + User(id: $id) { + activeCategories + } + } + ` + + describe('no categories saved', () => { + it('returns true for active categories mutation', async () => { + await expect(mutate({ mutation: saveCategorySettings, variables })).resolves.toEqual( + expect.objectContaining({ + data: { saveCategorySettings: true }, + }), + ) + }) + + describe('query for user', () => { + beforeEach(async () => { + await mutate({ mutation: saveCategorySettings, variables }) + }) + + it('returns the active categories when user is queried', async () => { + await expect( + query({ query: userQuery, variables: { id: authenticatedUser.id } }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + User: [ + { + activeCategories: expect.arrayContaining(['cat1', 'cat3', 'cat5']), + }, + ], + }, + }), + ) + }) + }) + }) + + describe('categories already saved', () => { + beforeEach(async () => { + variables = { + activeCategories: ['cat1', 'cat3', 'cat5'], + } + await mutate({ mutation: saveCategorySettings, variables }) + variables = { + activeCategories: ['cat10', 'cat11', 'cat12', 'cat8', 'cat9'], + } + }) + + it('returns true', async () => { + await expect(mutate({ mutation: saveCategorySettings, variables })).resolves.toEqual( + expect.objectContaining({ + data: { saveCategorySettings: true }, + }), + ) + }) + + describe('query for user', () => { + beforeEach(async () => { + await mutate({ mutation: saveCategorySettings, variables }) + }) + + it('returns the new active categories when user is queried', async () => { + await expect( + query({ query: userQuery, variables: { id: authenticatedUser.id } }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + User: [ + { + activeCategories: expect.arrayContaining([ + 'cat10', + 'cat11', + 'cat12', + 'cat8', + 'cat9', + ]), + }, + ], + }, + }), + ) + }) + }) + }) + }) +}) diff --git a/backend/src/schema/resolvers/users/location.js b/backend/src/schema/resolvers/users/location.js index affd3267e..9d8a11f89 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/resolvers/users/location.spec.js b/backend/src/schema/resolvers/users/location.spec.js index 92f08893e..c0525ca1c 100644 --- a/backend/src/schema/resolvers/users/location.spec.js +++ b/backend/src/schema/resolvers/users/location.spec.js @@ -121,7 +121,7 @@ describe('Location Service', () => { const result = await query({ query: queryLocations, variables }) expect(result.data.queryLocations).toEqual( expect.arrayContaining([ - { id: 'place.14094307404564380', place_name: 'Berlin, Germany' }, + { id: expect.stringMatching(/^place\.[0-9]+$/), place_name: 'Berlin, Germany' }, { id: expect.stringMatching(/^place\.[0-9]+$/), place_name: 'Berlin, Maryland, United States', diff --git a/backend/src/schema/resolvers/users/mutedUsers.spec.js b/backend/src/schema/resolvers/users/mutedUsers.spec.js index 36b526268..d8efacfae 100644 --- a/backend/src/schema/resolvers/users/mutedUsers.spec.js +++ b/backend/src/schema/resolvers/users/mutedUsers.spec.js @@ -58,7 +58,7 @@ describe('mutedUsers', () => { it('throws permission error', async () => { const { query } = createTestClient(server) const result = await query({ query: mutedUserQuery }) - expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!') + expect(result.errors[0]).toHaveProperty('message', 'Not Authorized!') }) describe('authenticated and given a muted user', () => { @@ -116,7 +116,7 @@ describe('muteUser', () => { it('throws permission error', async () => { const result = await muteAction({ id: 'u2' }) - expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!') + expect(result.errors[0]).toHaveProperty('message', 'Not Authorized!') }) describe('authenticated', () => { @@ -333,7 +333,7 @@ describe('unmuteUser', () => { it('throws permission error', async () => { const result = await unmuteAction({ id: 'u2' }) - expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!') + expect(result.errors[0]).toHaveProperty('message', 'Not Authorized!') }) describe('authenticated', () => { diff --git a/backend/src/schema/types/enum/GroupActionRadius.gql b/backend/src/schema/types/enum/GroupActionRadius.gql new file mode 100644 index 000000000..221ed7f87 --- /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 000000000..dacdd4b52 --- /dev/null +++ b/backend/src/schema/types/enum/GroupMemberRole.gql @@ -0,0 +1,6 @@ +enum GroupMemberRole { + pending + usual + admin + owner +} diff --git a/backend/src/schema/types/enum/GroupType.gql b/backend/src/schema/types/enum/GroupType.gql new file mode 100644 index 000000000..2cf298474 --- /dev/null +++ b/backend/src/schema/types/enum/GroupType.gql @@ -0,0 +1,5 @@ +enum GroupType { + public + closed + hidden +} diff --git a/backend/src/schema/types/enum/UserGroup.gql b/backend/src/schema/types/enum/UserRole.gql similarity index 63% rename from backend/src/schema/types/enum/UserGroup.gql rename to backend/src/schema/types/enum/UserRole.gql index 587e90c71..846afde13 100644 --- a/backend/src/schema/types/enum/UserGroup.gql +++ b/backend/src/schema/types/enum/UserRole.gql @@ -1,4 +1,4 @@ -enum UserGroup { +enum UserRole { admin moderator user diff --git a/backend/src/schema/types/type/Group.gql b/backend/src/schema/types/type/Group.gql new file mode 100644 index 000000000..ce90fad1d --- /dev/null +++ b/backend/src/schema/types/type/Group.gql @@ -0,0 +1,135 @@ +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 + offset: Int + # 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] + + GroupCount(isMember: Boolean): Int + + # 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 000000000..edda989f6 --- /dev/null +++ b/backend/src/schema/types/type/MEMBER_OF.gql @@ -0,0 +1,5 @@ +type MEMBER_OF { + createdAt: String! + updatedAt: String! + role: GroupMemberRole! +} diff --git a/backend/src/schema/types/type/Post.gql b/backend/src/schema/types/type/Post.gql index 2f9221dd1..9eac00b0b 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 9537b5a84..5cb68e22d 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 772dedf6b..fe1ff43f0 100644 --- a/backend/src/schema/types/type/User.gql +++ b/backend/src/schema/types/type/User.gql @@ -28,13 +28,13 @@ type User { avatar: Image @relation(name: "AVATAR_IMAGE", direction: "OUT") deleted: Boolean disabled: Boolean - role: UserGroup! + role: UserRole! publicKey: String invitedBy: User @relation(name: "INVITED", direction: "IN") invited: [User] @relation(name: "INVITED", direction: "OUT") - location: Location @cypher(statement: "MATCH (this)-[:IS_IN]->(l:Location) RETURN l") locationName: String + location: Location @cypher(statement: "MATCH (this)-[:IS_IN]->(l:Location) RETURN l") about: String socialMedia: [SocialMedia]! @relation(name: "OWNED_BY", direction: "IN") @@ -114,6 +114,16 @@ type User { badgesCount: Int! @cypher(statement: "MATCH (this)<-[:REWARDED]-(r:Badge) RETURN COUNT(r)") emotions: [EMOTED] + + activeCategories: [String] @cypher( + statement: """ + MATCH (category:Category) + WHERE NOT ((this)-[:NOT_INTERESTED_IN]->(category)) + RETURN collect(category.id) + """ + ) + + myRoleInGroup: GroupMemberRole } @@ -151,43 +161,31 @@ input _UserFilter { followedBy_none: _UserFilter followedBy_single: _UserFilter followedBy_every: _UserFilter - role_in: [UserGroup!] + role_in: [UserRole!] } type Query { User( - id: ID - email: String # admins need to search for a user sometimes - name: String - slug: String - role: UserGroup - locationName: String - about: String - createdAt: String - updatedAt: String - first: Int - offset: Int - orderBy: [_UserOrdering] - filter: _UserFilter + id: ID + email: String # admins need to search for a user sometimes + name: String + slug: String + role: UserRole + locationName: String + about: String + createdAt: String + updatedAt: String + first: Int + offset: Int + orderBy: [_UserOrdering] + filter: _UserFilter ): [User] - availableRoles: [UserGroup]! + availableRoles: [UserRole]! mutedUsers: [User] 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 { @@ -197,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 @@ -219,5 +217,7 @@ type Mutation { blockUser(id: ID!): User unblockUser(id: ID!): User - switchUserRole(role: UserGroup!, id: ID!): User + switchUserRole(role: UserRole!, id: ID!): User + + saveCategorySettings(activeCategories: [String]): Boolean } diff --git a/backend/yarn.lock b/backend/yarn.lock index 24bd00b3a..8283660d5 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" @@ -4253,10 +4266,10 @@ eslint-plugin-node@~11.1.0: resolve "^1.10.1" semver "^6.1.0" -eslint-plugin-prettier@~3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-3.1.2.tgz#432e5a667666ab84ce72f945c72f77d996a5c9ba" - integrity sha512-GlolCC9y3XZfv3RQfwGew7NnuFDKsfI4lbvRK+PIIo23SFH+LemGs4cKwzAaRa+Mdb+lQO/STaIayno8T5sJJA== +eslint-plugin-prettier@~3.4.1: + version "3.4.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-3.4.1.tgz#e9ddb200efb6f3d05ffe83b1665a716af4a387e5" + integrity sha512-htg25EUYUeIhKHXjOinK4BgCcDwtLHjqaxCDsMy5nbnUMkKFvIhMVCp+5GFUXQ4Nr8lBsPqtGAqBenbpFqAA2g== dependencies: prettier-linter-helpers "^1.0.0" @@ -5516,6 +5529,11 @@ ieee754@1.1.13, ieee754@^1.1.4: resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84" integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg== +ieee754@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" + integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== + ienoopen@1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/ienoopen/-/ienoopen-1.1.0.tgz#411e5d530c982287dbdc3bb31e7a9c9e32630974" @@ -7528,18 +7546,19 @@ negotiator@0.6.2: resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb" integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw== -neo4j-driver-bolt-connection@^4.3.4: - version "4.3.4" - resolved "https://registry.yarnpkg.com/neo4j-driver-bolt-connection/-/neo4j-driver-bolt-connection-4.3.4.tgz#de642bb6a62ffc6ae2e280dccf21395b4d1705a2" - integrity sha512-yxbvwGav+N7EYjcEAINqL6D3CZV+ee2qLInpAhx+iNurwbl3zqtBGiVP79SZ+7tU++y3Q1fW5ofikH06yc+LqQ== +neo4j-driver-bolt-connection@^4.4.7: + version "4.4.7" + resolved "https://registry.yarnpkg.com/neo4j-driver-bolt-connection/-/neo4j-driver-bolt-connection-4.4.7.tgz#0582d54de1f213e60c374209193d1f645ba523ea" + integrity sha512-6Q4hCtvWE6gzN64N09UqZqf/3rDl7FUWZZXiVQL0ZRbaMkJpZNC2NmrDIgGXYE05XEEbRBexf2tVv5OTYZYrow== dependencies: - neo4j-driver-core "^4.3.4" - text-encoding-utf-8 "^1.0.2" + buffer "^6.0.3" + neo4j-driver-core "^4.4.7" + string_decoder "^1.3.0" -neo4j-driver-core@^4.3.4: - version "4.3.4" - resolved "https://registry.yarnpkg.com/neo4j-driver-core/-/neo4j-driver-core-4.3.4.tgz#b445a4fbf94dce8441075099bd6ac3133c1cf5ee" - integrity sha512-3tn3j6IRUNlpXeehZ9Xv7dLTZPB4a7APaoJ+xhQyMmYQO3ujDM4RFHc0pZcG+GokmaltT5pUCIPTDYx6ODdhcA== +neo4j-driver-core@^4.4.7: + version "4.4.7" + resolved "https://registry.yarnpkg.com/neo4j-driver-core/-/neo4j-driver-core-4.4.7.tgz#d2475e107b3fea2b9d1c36b0c273da5c5a291c37" + integrity sha512-NhvVuQYgG7eO/vXxRaoJfkWUNkjvIpmCIS9UWU9Bbhb4V+wCOyX/MVOXqD0Yizhs4eyIkD7x90OXb79q+vi+oA== neo4j-driver@^4.0.1, neo4j-driver@^4.0.2: version "4.0.2" @@ -7552,13 +7571,13 @@ neo4j-driver@^4.0.1, neo4j-driver@^4.0.2: uri-js "^4.2.2" neo4j-driver@^4.2.2: - version "4.3.4" - resolved "https://registry.yarnpkg.com/neo4j-driver/-/neo4j-driver-4.3.4.tgz#a54f0562f868ee94dff7509df74e3eb2c1f95a85" - integrity sha512-AGrsFFqnoZv4KhJdmKt4mOBV5mnxmV3+/t8KJTOM68jQuEWoy+RlmAaRRaCSU4eY586OFN/R8lg9MrJpZdSFjw== + version "4.4.7" + resolved "https://registry.yarnpkg.com/neo4j-driver/-/neo4j-driver-4.4.7.tgz#51b3fb48241e66eb3be94e90032cc494c44e59f3" + integrity sha512-N7GddPhp12gVJe4eB84u5ik5SmrtRv8nH3rK47Qy7IUKnJkVEos/F1QjOJN6zt1jLnDXwDcGzCKK8XklYpzogw== dependencies: "@babel/runtime" "^7.5.5" - neo4j-driver-bolt-connection "^4.3.4" - neo4j-driver-core "^4.3.4" + neo4j-driver-bolt-connection "^4.4.7" + neo4j-driver-core "^4.4.7" rxjs "^6.6.3" neo4j-graphql-js@^2.11.5: @@ -7574,10 +7593,10 @@ neo4j-graphql-js@^2.11.5: lodash "^4.17.15" neo4j-driver "^4.0.1" -neode@^0.4.7: - version "0.4.7" - resolved "https://registry.yarnpkg.com/neode/-/neode-0.4.7.tgz#033007b57a2ee167e9ee5537493086db08d005eb" - integrity sha512-YXlc187JRpeKCBcUIkY6nimXXG+Tvlopfe71/FPno2THrwmYt5mm0RPHZ+mXF2O1Xg6zvjKvOpCpDz2vHBfroQ== +neode@^0.4.8: + version "0.4.8" + resolved "https://registry.yarnpkg.com/neode/-/neode-0.4.8.tgz#0889b4fc7f1bf0b470b01fa5b8870373b5d47ad6" + integrity sha512-pb91NfCOg4Fj5o+98H+S2XYC+ByQfbdhwcc1UVuzuUQ0Ezzj+jWz8NmKWU8ZfCH6l4plk71yDAPd2eTwpt+Xvg== dependencies: "@hapi/joi" "^15.1.1" dotenv "^4.0.0" @@ -9262,10 +9281,10 @@ slug@^0.9.2: dependencies: unicode ">= 0.3.1" -slug@~4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/slug/-/slug-4.0.2.tgz#35a62b4e71582778ac08bb30a1bf439fd0a43ea7" - integrity sha512-c5XbWkwxHU13gAdSvBHQgnGy2sxv/REMz0ugcM0SOSBCO/N4wfU0TDBC3pgdOwVGjZwGnLBTRljXzdVYE+KYNw== +slug@~6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/slug/-/slug-6.0.0.tgz#39637b32e5a873bc692812a630842880499ed6c9" + integrity sha512-0MpNLyCSUSf0G1nAZmp9gY1cvesPP35a1Live25vZ23gWQ5SAopF0N+0hk9KI4ytNuTebJrHGNrgTnxboofcSg== smart-buffer@^4.1.0: version "4.1.0" @@ -9603,7 +9622,7 @@ string.prototype.trimstart@^1.0.1: define-properties "^1.1.3" es-abstract "^1.17.5" -string_decoder@^1.1.1: +string_decoder@^1.1.1, string_decoder@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== 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 3a6600ff6..fad21e1a6 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 356593f9c..8d439b112 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 d20a181f2..8b501f3f5 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 f92789ef8..366fd8292 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/docker-compose.apple-m1.override.yml b/docker-compose.apple-m1.override.yml new file mode 100644 index 000000000..cbb041c51 --- /dev/null +++ b/docker-compose.apple-m1.override.yml @@ -0,0 +1,34 @@ +version: "3.4" + +services: + + ######################################################## + # WEBAPP ############################################### + ######################################################## + webapp: + platform: linux/amd64 + + ######################################################## + # BACKEND ############################################## + ######################################################## + backend: + platform: linux/amd64 + + ######################################################## + # MAINTENANCE ########################################## + ######################################################## + maintenance: + platform: linux/amd64 + + ######################################################## + # NEO4J ################################################ + ######################################################## + neo4j: + platform: linux/amd64 + + ######################################################## + # MAILSERVER TO FAKE SMTP ############################## + ######################################################## + # commented out, because otherwise override of production would error. and it seems unnecessary + # mailserver: + # platform: linux/amd64 diff --git a/docker-compose.maintenance.yml b/docker-compose.maintenance.yml index 56da9be52..831bb3f4a 100644 --- a/docker-compose.maintenance.yml +++ b/docker-compose.maintenance.yml @@ -1,3 +1,5 @@ +# Todo: !!! This file seems related to our old maintenance worker for MongoDB and has to be refactored in case of using it !!! + version: "3.4" services: diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 15cf00e7a..d8a3edc06 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -1,11 +1,13 @@ version: "3.4" services: + ######################################################## # WEBAPP ############################################### ######################################################## webapp: - image: ocelotsocialnetwork/webapp:development + # name the image so that it cannot be found in a DockerHub repository, otherwise it will not be built locally from the 'dockerfile' but pulled from there + image: ocelotsocialnetwork/webapp:local-development build: target: development environment: @@ -18,11 +20,13 @@ services: - webapp_node_modules:/app/node_modules # bind the local folder to the docker to allow live reload - ./webapp:/app + ######################################################## # BACKEND ############################################## ######################################################## backend: - image: ocelotsocialnetwork/backend:development + # name the image so that it cannot be found in a DockerHub repository, otherwise it will not be built locally from the 'dockerfile' but pulled from there + image: ocelotsocialnetwork/backend:local-development build: target: development environment: @@ -34,22 +38,27 @@ services: - backend_node_modules:/app/node_modules # bind the local folder to the docker to allow live reload - ./backend:/app + + ######################################################## + # MAINTENANCE ########################################## + ######################################################## + maintenance: + # name the image so that it cannot be found in a DockerHub repository, otherwise it will not be built locally from the 'dockerfile' but pulled from there + image: ocelotsocialnetwork/maintenance:local-development + ######################################################## # NEO4J ################################################ ######################################################## neo4j: - image: ocelotsocialnetwork/neo4j:development + # name the image so that it cannot be found in a DockerHub repository, otherwise it will not be built locally from the 'dockerfile' but pulled from there + image: ocelotsocialnetwork/neo4j-community:local-development ports: # Also expose the neo4j query browser - 7474:7474 networks: # So we can access the neo4j query browser from our host machine - external-net - ######################################################## - # MAINTENANCE ########################################## - ######################################################## - maintenance: - image: ocelotsocialnetwork/maintenance:development + ######################################################## # MAILSERVER TO FAKE SMTP ############################## ######################################################## diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 973cf87cf..fa858f28c 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -1,10 +1,12 @@ version: "3.4" services: + ######################################################## # WEBAPP ############################################### ######################################################## webapp: + # name the image so that it cannot be found in a DockerHub repository, otherwise it will not be built locally from the 'dockerfile' but pulled from there image: ocelotsocialnetwork/webapp:test build: target: test @@ -12,10 +14,12 @@ services: - NODE_ENV="test" volumes: - ./coverage:/app/coverage + ######################################################## # BACKEND ############################################## ######################################################## backend: + # name the image so that it cannot be found in a DockerHub repository, otherwise it will not be built locally from the 'dockerfile' but pulled from there image: ocelotsocialnetwork/backend:test build: target: test @@ -23,11 +27,20 @@ services: - NODE_ENV="test" volumes: - ./coverage:/app/coverage + + ######################################################## + # MAINTENANCE ########################################## + ######################################################## + maintenance: + # name the image so that it cannot be found in a DockerHub repository, otherwise it will not be built locally from the 'dockerfile' but pulled from there + image: ocelotsocialnetwork/maintenance:test + ######################################################## # NEO4J ################################################ ######################################################## neo4j: - image: ocelotsocialnetwork/neo4j:community + # name the image so that it cannot be found in a DockerHub repository, otherwise it will not be built locally from the 'dockerfile' but pulled from there + image: ocelotsocialnetwork/neo4j-community:test #environment: # - NEO4J_dbms_connector_bolt_enabled=true # - NEO4J_dbms_connector_bolt_tls__level=OPTIONAL @@ -39,11 +52,7 @@ services: networks: # So we can access the neo4j query browser from our host machine - external-net - ######################################################## - # MAINTENANCE ########################################## - ######################################################## - maintenance: - image: ocelotsocialnetwork/maintenance:test + ######################################################## # MAILSERVER TO FAKE SMTP ############################## ######################################################## diff --git a/docker-compose.yml b/docker-compose.yml index d20bb6aec..154a8fd4c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,11 +6,13 @@ version: "3.4" services: + ######################################################## # WEBAPP ############################################### ######################################################## webapp: - image: ocelotsocialnetwork/webapp:latest + # name the image so that it cannot be found in a DockerHub repository, otherwise it will not be built locally from the 'dockerfile' but pulled from there + image: ocelotsocialnetwork/webapp:local-production build: context: ./webapp target: production @@ -35,11 +37,13 @@ services: - GRAPHQL_URI=http://backend:4000 env_file: - ./webapp/.env + ######################################################## # BACKEND ############################################## ######################################################## backend: - image: ocelotsocialnetwork/backend:latest + # name the image so that it cannot be found in a DockerHub repository, otherwise it will not be built locally from the 'dockerfile' but pulled from there + image: ocelotsocialnetwork/backend:local-production build: context: ./backend target: production @@ -67,11 +71,28 @@ services: - CLIENT_URI=http://webapp:3000 env_file: - ./backend/.env + + ######################################################## + # MAINTENANCE ########################################## + ######################################################## + maintenance: + # name the image so that it cannot be found in a DockerHub repository, otherwise it will not be built locally from the 'dockerfile' but pulled from there + image: ocelotsocialnetwork/maintenance:local-production + build: + # TODO: Separate from webapp, this must be independent + context: ./webapp + dockerfile: Dockerfile.maintenance + networks: + - external-net + ports: + - 3001:80 + ######################################################## # NEO4J ################################################ ######################################################## neo4j: - image: ocelotsocialnetwork/neo4j:latest + # name the image so that it cannot be found in a DockerHub repository, otherwise it will not be built locally from the 'dockerfile' but pulled from there + image: ocelotsocialnetwork/neo4j-community:local-production build: context: ./neo4j # community edition 👆🏼, because we have no enterprise licence 👇🏼 at the moment @@ -90,19 +111,6 @@ services: # TODO: clarify if that is the only thing needed to unlock the Enterprise version # - NEO4J_ACCEPT_LICENSE_AGREEMENT=yes # TODO: Remove the playground from production - ######################################################## - # MAINTENANCE ########################################## - ######################################################## - maintenance: - image: ocelotsocialnetwork/maintenance:latest - build: - # TODO: Separate from webapp, this must be independent - context: ./webapp - dockerfile: Dockerfile.maintenance - networks: - - external-net - ports: - - 5000:80 networks: external-net: diff --git a/neo4j/README.md b/neo4j/README.md index a4242b512..f2b26d551 100644 --- a/neo4j/README.md +++ b/neo4j/README.md @@ -7,7 +7,7 @@ database available. The community edition of Neo4J is Free and Open Source and we try our best to keep our application compatible with the community edition only. -## Installation with Docker +## Installation With Docker Run: @@ -19,7 +19,7 @@ You can access Neo4J through [http://localhost:7474/](http://localhost:7474/) for an interactive cypher shell and a visualization of the graph. -## Installation without Docker +## Installation Without Docker Install the community edition of [Neo4j](https://neo4j.com/) along with the plugin [Apoc](https://github.com/neo4j-contrib/neo4j-apoc-procedures) on your system. @@ -51,3 +51,81 @@ in `backend/.env`. Start Neo4J and confirm the database is running at [http://localhost:7474](http://localhost:7474). +## Commands + +Here we describe some rarely used Cypher commands for Neo4j that are needed from time to time: + +### Index And Contraint Commands + +If indexes or constraints are missing or not set correctly, the browser search will not work or the database seed for development will not work. + +The indexes and constraints of our database are set in `backend/src/db/migrate/store.js`. +This is where the magic happens. + +It's called by our `prod:migrate init` command. +This command initializes the Admin user and creates all necessary indexes and constraints in the Neo4j database. + +***Calls in development*** + +Locally without Docker: + +```bash +# in backend folder +$ yarn prod:migrate init +``` + +Locally with Docker: + +```bash +# in main folder +$ docker compose exec backend yarn prod:migrate init +``` + +***Calls in production*** + +Locally with Docker: + +```bash +# in main folder +$ docker compose exec backend /bin/sh -c "yarn prod:migrate init" +``` + +On a server with Kubernetes cluster: + +```bash +# tested for one backend replica +# !!! be aware of the kubectl context !!! +$ kubectl -n default exec -it $(kubectl -n default get pods | grep ocelot-backend | awk '{ print $1 }') -- /bin/sh -c "yarn prod:migrate init" +``` + +***Cypher commands to show indexes and contraints*** + +```bash +# in browser command line or cypher shell + +# show all indexes and contraints +$ :schema + +# show all indexes +$ CALL db.indexes(); + +# show all contraints +$ CALL db.constraints(); +``` + +***Cypher commands to create and drop indexes and contraints*** + +```bash +# in browser command line or cypher shell + +# create indexes +$ 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("tag_fulltext_search",["Tag"],["id"]); + +# drop an index +$ DROP CONSTRAINT ON ( image:Image ) ASSERT image.url IS UNIQUE + +# drop all indexes and contraints +$ CALL apoc.schema.assert({},{},true) YIELD label, key RETURN * ; +``` diff --git a/package.json b/package.json index 9b0e27d95..f7b97c4a1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ocelot-social", - "version": "1.0.7", + "version": "2.2.0", "description": "Free and open source software program code available to run social networks.", "author": "ocelot.social Community", "license": "MIT", @@ -25,6 +25,7 @@ "@babel/core": "^7.9.0", "@babel/preset-env": "^7.12.7", "@babel/register": "^7.12.10", + "@faker-js/faker": "5.1.0", "auto-changelog": "^2.3.0", "bcryptjs": "^2.4.3", "codecov": "^3.8.2", @@ -36,16 +37,15 @@ "date-fns": "^2.25.0", "dotenv": "^8.2.0", "expect": "^25.3.0", - "@faker-js/faker": "5.1.0", "graphql-request": "^2.0.0", "import": "^0.0.6", "jsonwebtoken": "^8.5.1", "mock-socket": "^9.0.3", "neo4j-driver": "^4.3.4", - "neode": "^0.4.7", + "neode": "^0.4.8", "npm-run-all": "^4.1.5", "rosie": "^2.1.0", - "slug": "^5.1.0" + "slug": "^6.0.0" }, "resolutions": { "set-value": "^2.0.1" diff --git a/webapp/.env.template b/webapp/.env.template index 7373255a9..9776fcea2 100644 --- a/webapp/.env.template +++ b/webapp/.env.template @@ -4,3 +4,4 @@ PUBLIC_REGISTRATION=false INVITE_REGISTRATION=true WEBSOCKETS_URI=ws://localhost:3000/api/graphql GRAPHQL_URI=http://localhost:4000/ +CATEGORIES_ACTIVE=false diff --git a/webapp/Dockerfile b/webapp/Dockerfile index 8d830a9d5..a086a4b2a 100644 --- a/webapp/Dockerfile +++ b/webapp/Dockerfile @@ -94,6 +94,13 @@ FROM base as production COPY --from=build ${DOCKER_WORKDIR}/.nuxt ./.nuxt COPY --from=build ${DOCKER_WORKDIR}/node_modules ./node_modules COPY --from=build ${DOCKER_WORKDIR}/nuxt.config.js ./nuxt.config.js +# Copy static files +# TODO - this seems not be needed anymore for the new rebranding +# TODO - this should be one Folder containign all stuff needed to be copied +COPY --from=build ${DOCKER_WORKDIR}/config/ ./config/ +COPY --from=build ${DOCKER_WORKDIR}/constants ./constants +COPY --from=build ${DOCKER_WORKDIR}/static ./static +COPY --from=build ${DOCKER_WORKDIR}/locales ./locales # Copy package.json for script definitions (lock file should not be needed) COPY --from=build ${DOCKER_WORKDIR}/package.json ./package.json diff --git a/webapp/assets/_new/icons/svgs/child.svg b/webapp/assets/_new/icons/svgs/child.svg new file mode 100644 index 000000000..fcb5651f0 --- /dev/null +++ b/webapp/assets/_new/icons/svgs/child.svg @@ -0,0 +1,5 @@ + + +child + + diff --git a/webapp/assets/_new/icons/svgs/culture.svg b/webapp/assets/_new/icons/svgs/culture.svg new file mode 100644 index 000000000..d63e38cb4 --- /dev/null +++ b/webapp/assets/_new/icons/svgs/culture.svg @@ -0,0 +1,20 @@ + + + + + + + + diff --git a/webapp/assets/_new/icons/svgs/desktop.svg b/webapp/assets/_new/icons/svgs/desktop.svg new file mode 100644 index 000000000..ba1ef8431 --- /dev/null +++ b/webapp/assets/_new/icons/svgs/desktop.svg @@ -0,0 +1,5 @@ + + +desktop + + diff --git a/webapp/assets/_new/icons/svgs/ellipsis-h.svg b/webapp/assets/_new/icons/svgs/ellipsis-h.svg new file mode 100644 index 000000000..eb7deeab0 --- /dev/null +++ b/webapp/assets/_new/icons/svgs/ellipsis-h.svg @@ -0,0 +1,5 @@ + + +ellipsis-h + + diff --git a/webapp/assets/_new/icons/svgs/energy.svg b/webapp/assets/_new/icons/svgs/energy.svg new file mode 100644 index 000000000..5035a5586 --- /dev/null +++ b/webapp/assets/_new/icons/svgs/energy.svg @@ -0,0 +1,14 @@ + + + diff --git a/webapp/assets/_new/icons/svgs/finance.svg b/webapp/assets/_new/icons/svgs/finance.svg new file mode 100644 index 000000000..74081bc6a --- /dev/null +++ b/webapp/assets/_new/icons/svgs/finance.svg @@ -0,0 +1,13 @@ + + + + diff --git a/webapp/assets/_new/icons/svgs/health.svg b/webapp/assets/_new/icons/svgs/health.svg new file mode 100644 index 000000000..acf50d7c1 --- /dev/null +++ b/webapp/assets/_new/icons/svgs/health.svg @@ -0,0 +1,7 @@ + + + + diff --git a/webapp/assets/_new/icons/svgs/home.svg b/webapp/assets/_new/icons/svgs/home.svg new file mode 100644 index 000000000..b1a13b06f --- /dev/null +++ b/webapp/assets/_new/icons/svgs/home.svg @@ -0,0 +1,5 @@ + + +home + + diff --git a/webapp/assets/_new/icons/svgs/lightbulb.svg b/webapp/assets/_new/icons/svgs/lightbulb.svg new file mode 100644 index 000000000..1c19c81b1 --- /dev/null +++ b/webapp/assets/_new/icons/svgs/lightbulb.svg @@ -0,0 +1,5 @@ + + +lightbulb-o + + diff --git a/webapp/assets/_new/icons/svgs/media.svg b/webapp/assets/_new/icons/svgs/media.svg new file mode 100644 index 000000000..d63c98610 --- /dev/null +++ b/webapp/assets/_new/icons/svgs/media.svg @@ -0,0 +1,7 @@ + + + + diff --git a/webapp/assets/_new/icons/svgs/miscellaneous.svg b/webapp/assets/_new/icons/svgs/miscellaneous.svg new file mode 100644 index 000000000..07f8dbe3f --- /dev/null +++ b/webapp/assets/_new/icons/svgs/miscellaneous.svg @@ -0,0 +1,13 @@ + + + + diff --git a/webapp/assets/_new/icons/svgs/mobility.svg b/webapp/assets/_new/icons/svgs/mobility.svg new file mode 100644 index 000000000..9e36ec21e --- /dev/null +++ b/webapp/assets/_new/icons/svgs/mobility.svg @@ -0,0 +1,11 @@ + + + diff --git a/webapp/assets/_new/icons/svgs/movement.svg b/webapp/assets/_new/icons/svgs/movement.svg new file mode 100644 index 000000000..81052875d --- /dev/null +++ b/webapp/assets/_new/icons/svgs/movement.svg @@ -0,0 +1,19 @@ + + + + diff --git a/webapp/assets/_new/icons/svgs/music.svg b/webapp/assets/_new/icons/svgs/music.svg new file mode 100644 index 000000000..b84b87800 --- /dev/null +++ b/webapp/assets/_new/icons/svgs/music.svg @@ -0,0 +1,5 @@ + + +music + + diff --git a/webapp/assets/_new/icons/svgs/nature.svg b/webapp/assets/_new/icons/svgs/nature.svg new file mode 100644 index 000000000..d40250af4 --- /dev/null +++ b/webapp/assets/_new/icons/svgs/nature.svg @@ -0,0 +1,18 @@ + + + + diff --git a/webapp/assets/_new/icons/svgs/networking.svg b/webapp/assets/_new/icons/svgs/networking.svg new file mode 100644 index 000000000..b8d35da69 --- /dev/null +++ b/webapp/assets/_new/icons/svgs/networking.svg @@ -0,0 +1,22 @@ + + + + + diff --git a/webapp/assets/_new/icons/svgs/peace.svg b/webapp/assets/_new/icons/svgs/peace.svg new file mode 100644 index 000000000..408601cae --- /dev/null +++ b/webapp/assets/_new/icons/svgs/peace.svg @@ -0,0 +1,8 @@ + + + diff --git a/webapp/assets/_new/icons/svgs/politics.svg b/webapp/assets/_new/icons/svgs/politics.svg new file mode 100644 index 000000000..35322097d --- /dev/null +++ b/webapp/assets/_new/icons/svgs/politics.svg @@ -0,0 +1,12 @@ + + + + + diff --git a/webapp/assets/_new/icons/svgs/psyche.svg b/webapp/assets/_new/icons/svgs/psyche.svg new file mode 100644 index 000000000..8c285d5ca --- /dev/null +++ b/webapp/assets/_new/icons/svgs/psyche.svg @@ -0,0 +1,8 @@ + + + diff --git a/webapp/assets/_new/icons/svgs/save.svg b/webapp/assets/_new/icons/svgs/save.svg new file mode 100644 index 000000000..31c1d8459 --- /dev/null +++ b/webapp/assets/_new/icons/svgs/save.svg @@ -0,0 +1,5 @@ + + +save + + diff --git a/webapp/assets/_new/icons/svgs/science.svg b/webapp/assets/_new/icons/svgs/science.svg new file mode 100644 index 000000000..9d3211223 --- /dev/null +++ b/webapp/assets/_new/icons/svgs/science.svg @@ -0,0 +1,25 @@ + + + + diff --git a/webapp/assets/_new/icons/svgs/spirituality.svg b/webapp/assets/_new/icons/svgs/spirituality.svg new file mode 100644 index 000000000..0c2757071 --- /dev/null +++ b/webapp/assets/_new/icons/svgs/spirituality.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/webapp/assets/_new/icons/svgs/suitcase.svg b/webapp/assets/_new/icons/svgs/suitcase.svg new file mode 100644 index 000000000..ceca5cbad --- /dev/null +++ b/webapp/assets/_new/icons/svgs/suitcase.svg @@ -0,0 +1,5 @@ + + +suitcase + + diff --git a/webapp/assets/_new/styles/tokens.scss b/webapp/assets/_new/styles/tokens.scss index 53abd01f8..180f9c820 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; @@ -356,3 +357,22 @@ $media-query-x-large: (min-width: 1200px); /** * @tokens Background Images */ + +/** + * @tokens Header Color + */ + +$color-header-background: $color-neutral-100; + +/** + * @tokens Footer Color + */ + +$color-footer-background: $color-neutral-100; +$color-footer-link: $color-primary; + +/** + * @tokens Locale Menu Color + */ + +$color-locale-menu: $text-color-soft; \ No newline at end of file diff --git a/webapp/assets/styles/imports/_branding.scss b/webapp/assets/styles/imports/_branding.scss new file mode 100644 index 000000000..75058595d --- /dev/null +++ b/webapp/assets/styles/imports/_branding.scss @@ -0,0 +1,5 @@ +/* + * + * Here, all SCSS variables and classes can be adapted to your custom design. + * +*/ \ No newline at end of file diff --git a/webapp/assets/styles/main.scss b/webapp/assets/styles/main.scss index d6821e013..144701f83 100644 --- a/webapp/assets/styles/main.scss +++ b/webapp/assets/styles/main.scss @@ -179,4 +179,4 @@ hr { .dropdown-arrow { font-size: $font-size-xx-small; -} +} \ No newline at end of file diff --git a/webapp/components/AvatarMenu/AvatarMenu.spec.js b/webapp/components/AvatarMenu/AvatarMenu.spec.js index 85f5c32a8..5495c9ae6 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 "Groups"', () => { + const profileLink = wrapper + .findAll('.ds-menu-item span') + .at(wrapper.vm.routes.findIndex((route) => route.path === '/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 d47eb2d68..a5b56ba43 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 b7d71de2d..7fc9bcf1c 100644 --- a/webapp/components/CategoriesSelect/CategoriesSelect.vue +++ b/webapp/components/CategoriesSelect/CategoriesSelect.vue @@ -9,6 +9,10 @@ :disabled="isDisabled(category.id)" :icon="category.icon" size="small" + v-tooltip="{ + content: $t(`contribution.category.description.${category.slug}`), + placement: 'bottom-start', + }" > {{ $t(`contribution.category.name.${category.slug}`) }} @@ -17,6 +21,7 @@ + + diff --git a/webapp/components/ContributionForm/ContributionForm.spec.js b/webapp/components/ContributionForm/ContributionForm.spec.js index cce187a63..1ef1777fe 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 @@ -58,6 +58,9 @@ describe('ContributionForm.vue', () => { back: jest.fn(), push: jest.fn(), }, + $env: { + CATEGORIES_ACTIVE: false, + }, } propsData = {} }) @@ -132,8 +135,10 @@ describe('ContributionForm.vue', () => { variables: { title: postTitle, content: postContent, + categoryIds: [], id: null, image: null, + groupId: null, }, } postTitleInput = wrapper.find('.ds-input') @@ -198,7 +203,7 @@ describe('ContributionForm.vue', () => { beforeEach(async () => { jest.useFakeTimers() mocks.$apollo.mutate = jest.fn().mockRejectedValueOnce({ - message: 'Not Authorised!', + message: 'Not Authorized!', }) wrapper = Wrapper() postTitleInput = wrapper.find('.ds-input') @@ -209,7 +214,7 @@ describe('ContributionForm.vue', () => { it('shows an error toaster when apollo mutation rejects', async () => { await wrapper.find('form').trigger('submit') await mocks.$apollo.mutate - await expect(mocks.$toast.error).toHaveBeenCalledWith('Not Authorised!') + await expect(mocks.$toast.error).toHaveBeenCalledWith('Not Authorized!') }) }) }) @@ -254,7 +259,9 @@ describe('ContributionForm.vue', () => { variables: { title: propsData.contribution.title, 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 a06679149..0428b2e23 100644 --- a/webapp/components/ContributionForm/ContributionForm.vue +++ b/webapp/components/ContributionForm/ContributionForm.vue @@ -51,6 +51,19 @@ {{ contentLength }} + + + {{ formData.categoryIds.length }} / 3 + +
{{ $t('actions.cancel') }} @@ -69,7 +82,8 @@ import gql from 'graphql-tag' import { mapGetters } from 'vuex' import HcEditor from '~/components/Editor/Editor' import PostMutations from '~/graphql/PostMutations.js' -import ImageUploader from '~/components/ImageUploader/ImageUploader' +import CategoriesSelect from '~/components/CategoriesSelect/CategoriesSelect' +import ImageUploader from '~/components/Uploader/ImageUploader' import links from '~/constants/links.js' import PageParamsLink from '~/components/_new/features/PageParamsLink/PageParamsLink.vue' @@ -78,15 +92,20 @@ export default { HcEditor, ImageUploader, PageParamsLink, + CategoriesSelect, }, props: { contribution: { type: Object, default: () => ({}), }, + groupId: { + type: String, + default: () => null, + }, }, data() { - const { title, content, image } = this.contribution + const { title, content, image, categories } = this.contribution const { sensitive: imageBlurred = false, aspectRatio: imageAspectRatio = null, @@ -94,6 +113,7 @@ export default { } = image || {} return { + categoriesActive: this.$env.CATEGORIES_ACTIVE, links, formData: { title: title || '', @@ -102,11 +122,22 @@ export default { imageAspectRatio, imageType, imageBlurred, + categoryIds: categories ? categories.map((category) => category.id) : [], }, formSchema: { title: { required: true, min: 3, max: 100 }, content: { required: true }, imageBlurred: { required: false }, + categoryIds: { + type: 'array', + required: this.categoriesActive, + validator: (_, value = []) => { + if (this.categoriesActive && (value.length === 0 || value.length > 3)) { + return [new Error(this.$t('common.validations.categories'))] + } + return [] + }, + }, }, loading: false, users: [], @@ -125,7 +156,7 @@ export default { methods: { submit() { let image = null - const { title, content } = this.formData + const { title, content, categoryIds } = this.formData if (this.formData.image) { image = { sensitive: this.formData.imageBlurred, @@ -143,8 +174,10 @@ export default { variables: { title, content, + categoryIds, id: this.contribution.id || null, image, + groupId: this.groupId, }, }) .then(({ data }) => { diff --git a/webapp/components/DeleteData/DeleteData.spec.js b/webapp/components/DeleteData/DeleteData.spec.js index 70f98424a..c540c9832 100644 --- a/webapp/components/DeleteData/DeleteData.spec.js +++ b/webapp/components/DeleteData/DeleteData.spec.js @@ -29,7 +29,7 @@ describe('DeleteData.vue', () => { }, }, }) - .mockRejectedValue({ message: 'Not authorised!' }), + .mockRejectedValue({ message: 'Not Authorized!' }), }, $toast: { error: jest.fn(), @@ -180,7 +180,7 @@ describe('DeleteData.vue', () => { // second submission causes mutation to reject await deleteAccountBtn.trigger('click') await mocks.$apollo.mutate - expect(mocks.$toast.error).toHaveBeenCalledWith('Not authorised!') + expect(mocks.$toast.error).toHaveBeenCalledWith('Not Authorized!') }) }) }) diff --git a/webapp/components/DeleteData/DeleteData.vue b/webapp/components/DeleteData/DeleteData.vue index 21b842fe6..a0cdcf29b 100644 --- a/webapp/components/DeleteData/DeleteData.vue +++ b/webapp/components/DeleteData/DeleteData.vue @@ -103,7 +103,7 @@ export default { this.$apollo .mutate({ mutation: gql` - mutation($id: ID!, $resource: [Deletable]) { + mutation ($id: ID!, $resource: [Deletable]) { DeleteUser(id: $id, resource: $resource) { id } diff --git a/webapp/components/Dropdown.vue b/webapp/components/Dropdown.vue index c01b12bd9..7e4d21223 100644 --- a/webapp/components/Dropdown.vue +++ b/webapp/components/Dropdown.vue @@ -8,7 +8,7 @@ :offset="offset" > -
+
', + html: '', } const plugins = [ diff --git a/webapp/components/Editor/nodes/Embed.spec.js b/webapp/components/Editor/nodes/Embed.spec.js index e87ee1570..372b189f9 100644 --- a/webapp/components/Editor/nodes/Embed.spec.js +++ b/webapp/components/Editor/nodes/Embed.spec.js @@ -33,8 +33,7 @@ describe('Embed.vue', () => { video: null, lang: 'de', sources: ['resource', 'oembed'], - html: - '', + html: '', }), } }) diff --git a/webapp/components/Embed/EmbedComponent.spec.js b/webapp/components/Embed/EmbedComponent.spec.js index fd7452c01..cd3526368 100644 --- a/webapp/components/Embed/EmbedComponent.spec.js +++ b/webapp/components/Embed/EmbedComponent.spec.js @@ -114,8 +114,7 @@ describe('EmbedComponent.vue', () => { video: null, lang: 'de', sources: ['resource', 'oembed'], - html: - '', + html: '', } wrapper = Wrapper() }) diff --git a/webapp/components/Emotions/Emotions.vue b/webapp/components/Emotions/Emotions.vue index e75c8848d..b4dc14a61 100644 --- a/webapp/components/Emotions/Emotions.vue +++ b/webapp/components/Emotions/Emotions.vue @@ -83,7 +83,7 @@ export default { this.$apollo .query({ query: gql` - query($postId: ID!, $data: _EMOTEDInput!) { + query ($postId: ID!, $data: _EMOTEDInput!) { PostsEmotionsCountByEmotion(postId: $postId, data: $data) } `, diff --git a/webapp/components/FilterMenu/CategoriesFilter.spec.js b/webapp/components/FilterMenu/CategoriesFilter.spec.js index ef0f2ad90..677217585 100644 --- a/webapp/components/FilterMenu/CategoriesFilter.spec.js +++ b/webapp/components/FilterMenu/CategoriesFilter.spec.js @@ -15,8 +15,19 @@ describe('CategoriesFilter.vue', () => { 'posts/filteredCategoryIds': jest.fn(() => []), } + const apolloMutationMock = jest.fn().mockResolvedValue({ + data: { saveCategorySettings: true }, + }) + const mocks = { $t: jest.fn((string) => string), + $apollo: { + mutate: apolloMutationMock, + }, + $toast: { + success: jest.fn(), + error: jest.fn(), + }, } const Wrapper = () => { @@ -48,20 +59,22 @@ describe('CategoriesFilter.vue', () => { describe('mount', () => { it('starts with all categories button active', () => { - const allCategoriesButton = wrapper.find('.categories-filter .sidebar .base-button') + const allCategoriesButton = wrapper.find('.categories-filter .item-all-topics .base-button') expect(allCategoriesButton.attributes().class).toContain('--filled') }) it('sets category button attribute `filled` when corresponding category is filtered', async () => { getters['posts/filteredCategoryIds'] = jest.fn(() => ['cat9']) wrapper = await Wrapper() - democracyAndPoliticsButton = wrapper.findAll('.categories-filter .item .base-button').at(2) + democracyAndPoliticsButton = wrapper.find('.categories-filter .item-save-topics .base-button') expect(democracyAndPoliticsButton.attributes().class).toContain('--filled') }) describe('click on an "catetories-buttons" button', () => { it('calls TOGGLE_CATEGORY when clicked', () => { - environmentAndNatureButton = wrapper.findAll('.categories-filter .item .base-button').at(0) + environmentAndNatureButton = wrapper + .findAll('.categories-filter .item-category .base-button') + .at(0) environmentAndNatureButton.trigger('click') expect(mutations['posts/TOGGLE_CATEGORY']).toHaveBeenCalledWith({}, 'cat4') }) @@ -71,10 +84,19 @@ describe('CategoriesFilter.vue', () => { it('when all button is clicked', async () => { getters['posts/filteredCategoryIds'] = jest.fn(() => ['cat9']) wrapper = await Wrapper() - const allCategoriesButton = wrapper.find('.categories-filter .sidebar .base-button') + const allCategoriesButton = wrapper.find('.categories-filter .item-all-topics .base-button') allCategoriesButton.trigger('click') expect(mutations['posts/RESET_CATEGORIES']).toHaveBeenCalledTimes(1) }) }) + + describe('save categories', () => { + it('calls the API', async () => { + wrapper = await Wrapper() + const saveButton = wrapper.find('.categories-filter .item-save-topics .base-button') + saveButton.trigger('click') + expect(apolloMutationMock).toBeCalled() + }) + }) }) }) diff --git a/webapp/components/FilterMenu/CategoriesFilter.vue b/webapp/components/FilterMenu/CategoriesFilter.vue index 47e4bcc10..0d27507f9 100644 --- a/webapp/components/FilterMenu/CategoriesFilter.vue +++ b/webapp/components/FilterMenu/CategoriesFilter.vue @@ -1,20 +1,32 @@