diff --git a/.github/file-filters.yml b/.github/file-filters.yml
index d7f9cb6c0..8d2d93fac 100644
--- a/.github/file-filters.yml
+++ b/.github/file-filters.yml
@@ -1,4 +1,5 @@
backend: &backend
+ - '.github/workflows/test-backend.yml'
- 'backend/**/*'
- 'neo4j/**/*'
@@ -6,4 +7,5 @@ docker: &docker
- 'docker-compose.*'
webapp: &webapp
+ - '.github/workflows/test-webapp.yml'
- 'webapp/**/*'
diff --git a/.github/workflows/cleanup-cache-at-pr-closing.yml b/.github/workflows/cleanup-cache-at-pr-closing.yml
new file mode 100644
index 000000000..284702e76
--- /dev/null
+++ b/.github/workflows/cleanup-cache-at-pr-closing.yml
@@ -0,0 +1,42 @@
+###############################################################################
+# A Github repo has max 10 GB of cache.
+# https://github.blog/changelog/2021-11-23-github-actions-cache-size-is-now-increased-to-10gb-per-repository/
+#
+# To avoid "cache thrashing" by their cache eviction policy it is recommended
+# to apply a cache cleanup workflow at PR closing to dele cache leftovers of
+# the current branch:
+# https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#force-deleting-cache-entries
+###############################################################################
+
+name: ocelot.social cache cleanup on pr closing
+
+on:
+ pull_request:
+ types:
+ - closed
+
+jobs:
+ clean-branch-cache:
+ name: Cleanup branch cache
+ runs-on: ubuntu-latest
+ continue-on-error: true
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v3
+
+ - name: Cleanup
+ run: |
+ gh extension install actions/gh-actions-cache
+ REPO=${{ github.repository }}
+ BRANCH="refs/pull/${{ github.event.pull_request.number }}/merge"
+ echo "Fetching list of cache key"
+ cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH | cut -f 1 )
+ set +e
+ echo "Deleting caches..."
+ for cacheKey in $cacheKeysForPR
+ do
+ gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm
+ done
+ echo "Done"
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
\ No newline at end of file
diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test-backend.yml
index 84d87c770..03e517826 100644
--- a/.github/workflows/test-backend.yml
+++ b/.github/workflows/test-backend.yml
@@ -1,7 +1,7 @@
name: ocelot.social backend test CI
-on: [push]
+on: push
jobs:
files-changed:
@@ -13,7 +13,7 @@ jobs:
steps:
- uses: actions/checkout@v3.3.0
- - name: Check for frontend file changes
+ - name: Check for backend file changes
uses: dorny/paths-filter@v2.11.1
id: changes
with:
@@ -34,12 +34,13 @@ jobs:
run: |
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@v3
+
+ - name: Cache docker images
+ id: cache-neo4j
+ uses: actions/cache/save@v3.3.1
with:
- name: docker-neo4j-image
path: /tmp/neo4j.tar
+ key: ${{ github.run_id }}-backend-neo4j-cache
build_test_backend:
name: Docker Build Test - Backend
@@ -54,12 +55,13 @@ jobs:
run: |
docker build --target test -t "ocelotsocialnetwork/backend:test" backend/
docker save "ocelotsocialnetwork/backend:test" > /tmp/backend.tar
-
- - name: Upload Artifact
- uses: actions/upload-artifact@v3
+
+ - name: Cache docker images
+ id: cache-backend
+ uses: actions/cache/save@v3.3.1
with:
- name: docker-backend-test
path: /tmp/backend.tar
+ key: ${{ github.run_id }}-backend-cache
lint_backend:
name: Lint Backend
@@ -84,28 +86,29 @@ jobs:
- name: Checkout code
uses: actions/checkout@v3
- - name: Download Docker Image (Neo4J)
- uses: actions/download-artifact@v3
+ - name: Restore Neo4J cache
+ uses: actions/cache/restore@v3.3.1
with:
- name: docker-neo4j-image
- path: /tmp
+ path: /tmp/neo4j.tar
+ key: ${{ github.run_id }}-backend-neo4j-cache
+ fail-on-cache-miss: true
- - name: Load Docker Image
- run: docker load < /tmp/neo4j.tar
-
- - name: Download Docker Image (Backend)
- uses: actions/download-artifact@v3
+ - name: Restore Backend cache
+ uses: actions/cache/restore@v3.3.1
with:
- name: docker-backend-test
- path: /tmp
+ path: /tmp/backend.tar
+ key: ${{ github.run_id }}-backend-cache
+ fail-on-cache-miss: true
- - name: Load Docker Image
- run: docker load < /tmp/backend.tar
+ - name: Load Docker Images
+ run: |
+ docker load < /tmp/neo4j.tar
+ docker load < /tmp/backend.tar
- - name: backend | copy env files webapp
- run: cp webapp/.env.template webapp/.env
- - name: backend | copy env files backend
- run: cp backend/.env.template backend/.env
+ - name: backend | copy env files
+ run: |
+ cp webapp/.env.template webapp/.env
+ cp backend/.env.template backend/.env
- name: backend | docker-compose
run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps neo4j backend
@@ -118,3 +121,20 @@ jobs:
- name: backend | Unit test incl. coverage check
run: docker-compose exec -T backend yarn test
+
+ cleanup:
+ name: Cleanup
+ if: ${{ needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.docker == 'true' }}
+ needs: [files-changed, unit_test_backend]
+ runs-on: ubuntu-latest
+ continue-on-error: true
+ steps:
+ - name: Delete cache
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ gh extension install actions/gh-actions-cache
+ KEY="${{ github.run_id }}-backend-neo4j-cache"
+ gh actions-cache delete $KEY -R Ocelot-Social-Community/Ocelot-Social --confirm
+ KEY="${{ github.run_id }}-backend-cache"
+ gh actions-cache delete $KEY -R Ocelot-Social-Community/Ocelot-Social --confirm
diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml
index 74ebd1c43..02d65ba9e 100644
--- a/.github/workflows/test-e2e.yml
+++ b/.github/workflows/test-e2e.yml
@@ -1,9 +1,54 @@
name: ocelot.social end-to-end test CI
+
on: push
jobs:
+ docker_preparation:
+ name: Fullstack test preparation
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v3
+
+ - name: Copy env files
+ run: |
+ cp webapp/.env.template webapp/.env
+ cp backend/.env.template backend/.env
+
+ - name: Build docker images
+ run: |
+ mkdir /tmp/images
+ docker build --target community -t "ocelotsocialnetwork/neo4j-community:test" neo4j/
+ docker save "ocelotsocialnetwork/neo4j-community:test" > /tmp/images/neo4j.tar
+ docker build --target test -t "ocelotsocialnetwork/backend:test" backend/
+ docker save "ocelotsocialnetwork/backend:test" > /tmp/images/backend.tar
+ docker build --target test -t "ocelotsocialnetwork/webapp:test" webapp/
+ docker save "ocelotsocialnetwork/webapp:test" > /tmp/images/webapp.tar
+
+ - name: Install cypress requirements
+ run: |
+ wget --no-verbose -O /opt/cucumber-json-formatter "https://github.com/cucumber/json-formatter/releases/download/v19.0.0/cucumber-json-formatter-linux-386"
+ cd backend
+ yarn install
+ yarn build
+ cd ..
+ yarn install
+
+ - name: Cache docker images
+ id: cache
+ uses: actions/cache/save@v3.3.1
+ with:
+ path: |
+ /opt/cucumber-json-formatter
+ /home/runner/.cache/Cypress
+ /home/runner/work/Ocelot-Social/Ocelot-Social
+ /tmp/images/
+ key: ${{ github.run_id }}-e2e-preparation-cache
+
fullstack_tests:
name: Fullstack tests
+ if: success()
+ needs: docker_preparation
runs-on: ubuntu-latest
env:
jobs: 8
@@ -12,28 +57,27 @@ jobs:
# run copies of the current job in parallel
job: [1, 2, 3, 4, 5, 6, 7, 8]
steps:
- - name: Checkout code
- uses: actions/checkout@v3
+ - name: Restore cache
+ uses: actions/cache/restore@v3.3.1
+ id: cache
+ with:
+ path: |
+ /opt/cucumber-json-formatter
+ /home/runner/.cache/Cypress
+ /home/runner/work/Ocelot-Social/Ocelot-Social
+ /tmp/images/
+ key: ${{ github.run_id }}-e2e-preparation-cache
+ fail-on-cache-miss: true
- - name: webapp | copy env file
- run: cp webapp/.env.template webapp/.env
-
- - name: backend | copy env file
- run: cp backend/.env.template backend/.env
-
- - name: boot up test system | docker-compose
- run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps webapp neo4j backend
-
- - name: Full stack tests | prepare
+ - name: Boot up test system | docker-compose
run: |
- wget --no-verbose -O /opt/cucumber-json-formatter "https://github.com/cucumber/json-formatter/releases/download/v19.0.0/cucumber-json-formatter-linux-386"
chmod +x /opt/cucumber-json-formatter
sudo ln -fs /opt/cucumber-json-formatter /usr/bin/cucumber-json-formatter
- cd backend
- yarn install
- yarn build
- cd ..
- yarn install
+ docker load < /tmp/images/neo4j.tar
+ docker load < /tmp/images/backend.tar
+ docker load < /tmp/images/webapp.tar
+ docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps webapp neo4j backend
+ sleep 90s
- name: Full stack tests | run tests
id: e2e-tests
@@ -44,17 +88,25 @@ jobs:
run: |
cd cypress/
node create-cucumber-html-report.js
-
- - name: End-to-end tests | if tests failed, get pr number
- id: pr
- if: ${{ failure() && steps.e2e-tests.conclusion == 'failure' }}
- uses: 8BitJonny/gh-get-current-pr@2.2.0
- - name: End-to-end tests | if tests failed, upload report
+ - name: Full stack tests | if tests failed, upload report
id: e2e-report
if: ${{ failure() && steps.e2e-tests.conclusion == 'failure' }}
uses: actions/upload-artifact@v3
with:
- name: ocelot-e2e-test-report-pr${{ steps.pr.outputs.number }}
+ name: ocelot-e2e-test-report-pr${{ needs.docker_preparation.outputs.pr-number }}
path: /home/runner/work/Ocelot-Social/Ocelot-Social/cypress/reports/cucumber_html_report
+ cleanup:
+ name: Cleanup
+ needs: [docker_preparation, fullstack_tests]
+ runs-on: ubuntu-latest
+ continue-on-error: true
+ steps:
+ - name: Delete cache
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ gh extension install actions/gh-actions-cache
+ KEY="${{ github.run_id }}-e2e-preparation-cache"
+ gh actions-cache delete $KEY -R Ocelot-Social-Community/Ocelot-Social --confirm
\ No newline at end of file
diff --git a/.github/workflows/test-webapp.yml b/.github/workflows/test-webapp.yml
index c1aee47cf..2b1e144a5 100644
--- a/.github/workflows/test-webapp.yml
+++ b/.github/workflows/test-webapp.yml
@@ -1,7 +1,7 @@
name: ocelot.social webapp test CI
-on: [push]
+on: push
jobs:
files-changed:
@@ -34,7 +34,7 @@ jobs:
run: |
scripts/translations/sort.sh
scripts/translations/missing-keys.sh
-
+
build_test_webapp:
name: Docker Build Test - Webapp
if: needs.files-changed.outputs.docker == 'true' || needs.files-changed.outputs.webapp == 'true'
@@ -44,16 +44,16 @@ jobs:
- name: Checkout code
uses: actions/checkout@v3
- - name: webapp | Build 'test' image
+ - name: Webapp | Build 'test' image
run: |
docker build --target test -t "ocelotsocialnetwork/webapp:test" webapp/
docker save "ocelotsocialnetwork/webapp:test" > /tmp/webapp.tar
- - name: Upload Artifact
- uses: actions/upload-artifact@v3
+ - name: Cache docker image
+ uses: actions/cache/save@v3.3.1
with:
- name: docker-webapp-test
path: /tmp/webapp.tar
+ key: ${{ github.run_id }}-webapp-cache
lint_webapp:
name: Lint Webapp
@@ -78,20 +78,19 @@ jobs:
- name: Checkout code
uses: actions/checkout@v3
- - name: Download Docker Image (Webapp)
- uses: actions/download-artifact@v3
+ - name: Restore webapp cache
+ uses: actions/cache/restore@v3.3.1
with:
- name: docker-webapp-test
- path: /tmp
+ path: /tmp/webapp.tar
+ key: ${{ github.run_id }}-webapp-cache
- name: Load Docker Image
run: docker load < /tmp/webapp.tar
- - name: backend | copy env files webapp
- run: cp webapp/.env.template webapp/.env
-
- - name: backend | copy env files backend
- run: cp backend/.env.template backend/.env
+ - name: Copy env files
+ run: |
+ cp webapp/.env.template webapp/.env
+ cp backend/.env.template backend/.env
- name: backend | docker-compose
run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps webapp
@@ -99,3 +98,18 @@ jobs:
- name: webapp | Unit tests incl. coverage check
run: docker-compose exec -T webapp yarn test
+ cleanup:
+ name: Cleanup
+ if: ${{ needs.files-changed.outputs.docker == 'true' || needs.files-changed.outputs.webapp == 'true' }}
+ needs: [files-changed, unit_test_webapp]
+ runs-on: ubuntu-latest
+ continue-on-error: true
+ steps:
+ - name: Delete cache
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ gh extension install actions/gh-actions-cache
+ KEY="${{ github.run_id }}-webapp-cache"
+ gh actions-cache delete $KEY -R Ocelot-Social-Community/Ocelot-Social --confirm
+
diff --git a/backend/src/db/seed.ts b/backend/src/db/seed.ts
index a717ff7a6..53cd4cea6 100644
--- a/backend/src/db/seed.ts
+++ b/backend/src/db/seed.ts
@@ -11,6 +11,8 @@ import {
changeGroupMemberRoleMutation,
} from '../graphql/groups'
import { createPostMutation } from '../graphql/posts'
+import { createRoomMutation } from '../graphql/rooms'
+import { createMessageMutation } from '../graphql/messages'
import { createCommentMutation } from '../graphql/comments'
import { categories } from '../constants/categories'
@@ -267,17 +269,17 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
await dagobert.relateTo(louie, 'blocked')
// categories
- await Promise.all(
- categories.map(({ icon, name }, index) => {
- return Factory.build('category', {
- id: `cat${index + 1}`,
- slug: name,
- name,
- icon,
- })
- }),
- )
+ let i = 0
+ for (const category of categories) {
+ await Factory.build('category', {
+ id: `cat${i++}`,
+ slug: category.name,
+ naem: category.name,
+ icon: category.icon,
+ })
+ }
+ // tags
const environment = await Factory.build('tag', {
id: 'Environment',
})
@@ -293,361 +295,324 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
// 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',
- },
- }),
- ])
+ await 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 mutate({
+ mutation: joinGroupMutation(),
+ variables: {
+ groupId: 'g0',
+ userId: 'u2',
+ },
+ })
+ await mutate({
+ mutation: joinGroupMutation(),
+ variables: {
+ groupId: 'g0',
+ userId: 'u4',
+ },
+ })
+ await mutate({
+ mutation: joinGroupMutation(),
+ variables: {
+ groupId: 'g0',
+ userId: 'u6',
+ },
+ })
+
+ await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables: {
+ groupId: 'g0',
+ userId: 'u2',
+ roleInGroup: 'usual',
+ },
+ })
+
+ await 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'],
- },
- }),
- ])
+ await 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'],
- },
- }),
- ])
+ await 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',
- },
- }),
- ])
+ await 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 mutate({
+ mutation: joinGroupMutation(),
+ variables: {
+ groupId: 'g1',
+ userId: 'u1',
+ },
+ })
+ await mutate({
+ mutation: joinGroupMutation(),
+ variables: {
+ groupId: 'g1',
+ userId: 'u2',
+ },
+ })
+ await mutate({
+ mutation: joinGroupMutation(),
+ variables: {
+ groupId: 'g1',
+ userId: 'u5',
+ },
+ })
+ await mutate({
+ mutation: joinGroupMutation(),
+ variables: {
+ groupId: 'g1',
+ userId: 'u6',
+ },
+ })
+ await mutate({
+ mutation: joinGroupMutation(),
+ variables: {
+ groupId: 'g1',
+ userId: 'u7',
+ },
+ })
+
+ await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables: {
+ groupId: 'g1',
+ userId: 'u1',
+ roleInGroup: 'usual',
+ },
+ })
+ await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables: {
+ groupId: 'g1',
+ userId: 'u5',
+ roleInGroup: 'admin',
+ },
+ })
+ await 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'],
- },
- }),
- ])
+ await 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'],
- },
- }),
- ])
+ await mutate({
+ mutation: createPostMutation(),
+ variables: {
+ id: 'p1-g1',
+ groupId: 'g1',
+ title: `Can we push this idea out of France?`,
+ content: 'This idea is too inportant to have the scope only on France.',
+ categoryIds: ['cat14'],
+ },
+ })
authenticatedUser = await bobDerBaumeister.toJson()
- await Promise.all([
- mutate({
- mutation: createGroupMutation(),
- variables: {
- id: 'g2',
- name: 'Yoga Practice',
- about: 'We do yoga around the clock.',
- description: `What Is yoga?
Yoga is not just about practicing asanas. It's about how we do it.
And practicing asanas doesn't have to be yoga, it can be more athletic than yogic.
What makes practicing asanas yogic?
The important thing is:
`,
- groupType: 'public',
- actionRadius: 'interplanetary',
- categoryIds: ['cat4', 'cat5', 'cat17'],
- },
- }),
- ])
- await Promise.all([
- mutate({
- mutation: joinGroupMutation(),
- variables: {
- groupId: 'g2',
- userId: 'u3',
- },
- }),
- mutate({
- mutation: joinGroupMutation(),
- variables: {
- groupId: 'g2',
- userId: 'u4',
- },
- }),
- mutate({
- mutation: joinGroupMutation(),
- variables: {
- groupId: 'g2',
- userId: 'u5',
- },
- }),
- mutate({
- mutation: joinGroupMutation(),
- variables: {
- groupId: 'g2',
- userId: 'u6',
- },
- }),
- mutate({
- mutation: joinGroupMutation(),
- variables: {
- groupId: 'g2',
- userId: 'u7',
- },
- }),
- ])
- await Promise.all([
- mutate({
- mutation: changeGroupMemberRoleMutation(),
- variables: {
- groupId: 'g2',
- userId: 'u3',
- roleInGroup: 'usual',
- },
- }),
- mutate({
- mutation: changeGroupMemberRoleMutation(),
- variables: {
- groupId: 'g2',
- userId: 'u4',
- roleInGroup: 'pending',
- },
- }),
- mutate({
- mutation: changeGroupMemberRoleMutation(),
- variables: {
- groupId: 'g2',
- userId: 'u5',
- roleInGroup: 'admin',
- },
- }),
- mutate({
- mutation: changeGroupMemberRoleMutation(),
- variables: {
- groupId: 'g2',
- userId: 'u6',
- roleInGroup: 'usual',
- },
- }),
- ])
+ await mutate({
+ mutation: createGroupMutation(),
+ variables: {
+ id: 'g2',
+ name: 'Yoga Practice',
+ about: 'We do yoga around the clock.',
+ description: `What Is yoga?
Yoga is not just about practicing asanas. It's about how we do it.
And practicing asanas doesn't have to be yoga, it can be more athletic than yogic.
What makes practicing asanas yogic?
The important thing is:
`,
+ groupType: 'public',
+ actionRadius: 'interplanetary',
+ categoryIds: ['cat4', 'cat5', 'cat17'],
+ },
+ })
+ await mutate({
+ mutation: joinGroupMutation(),
+ variables: {
+ groupId: 'g2',
+ userId: 'u3',
+ },
+ })
+ await mutate({
+ mutation: joinGroupMutation(),
+ variables: {
+ groupId: 'g2',
+ userId: 'u4',
+ },
+ })
+ await mutate({
+ mutation: joinGroupMutation(),
+ variables: {
+ groupId: 'g2',
+ userId: 'u5',
+ },
+ })
+ await mutate({
+ mutation: joinGroupMutation(),
+ variables: {
+ groupId: 'g2',
+ userId: 'u6',
+ },
+ })
+ await mutate({
+ mutation: joinGroupMutation(),
+ variables: {
+ groupId: 'g2',
+ userId: 'u7',
+ },
+ })
+
+ await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables: {
+ groupId: 'g2',
+ userId: 'u3',
+ roleInGroup: 'usual',
+ },
+ })
+ await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables: {
+ groupId: 'g2',
+ userId: 'u4',
+ roleInGroup: 'pending',
+ },
+ })
+ await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables: {
+ groupId: 'g2',
+ userId: 'u5',
+ roleInGroup: 'admin',
+ },
+ })
+ await 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'],
- },
- }),
- ])
+ await 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 Events (by peter lustig)
authenticatedUser = await peterLustig.toJson()
const now = new Date()
- await Promise.all([
- mutate({
- mutation: createPostMutation(),
- variables: {
- id: 'e0',
- title: 'Illegaler Kindergeburtstag',
- content: 'Elli hat nächste Woche Geburtstag. Wir feiern das!',
- categoryIds: ['cat4'],
- postType: 'Event',
- eventInput: {
- eventStart: new Date(
- now.getFullYear(),
- now.getMonth(),
- now.getDate() + 7,
- ).toISOString(),
- eventVenue: 'Ellis Kinderzimmer',
- eventLocationName: 'Deutschland',
- },
+ await mutate({
+ mutation: createPostMutation(),
+ variables: {
+ id: 'e0',
+ title: 'Illegaler Kindergeburtstag',
+ content: 'Elli hat nächste Woche Geburtstag. Wir feiern das!',
+ categoryIds: ['cat4'],
+ postType: 'Event',
+ eventInput: {
+ eventStart: new Date(now.getFullYear(), now.getMonth(), now.getDate() + 7).toISOString(),
+ eventVenue: 'Ellis Kinderzimmer',
+ eventLocationName: 'Deutschland',
},
- }),
- mutate({
- mutation: createPostMutation(),
- variables: {
- id: 'e1',
- title: 'Wir Schützen den Stuttgarter Schlossgarten',
- content: 'Kein Baum wird gefällt werden!',
- categoryIds: ['cat5'],
- postType: 'Event',
- eventInput: {
- eventStart: new Date(
- now.getFullYear(),
- now.getMonth(),
- now.getDate() + 1,
- ).toISOString(),
- eventVenue: 'Schlossgarten',
- eventLocationName: 'Stuttgart',
- },
+ },
+ })
+ await mutate({
+ mutation: createPostMutation(),
+ variables: {
+ id: 'e1',
+ title: 'Wir Schützen den Stuttgarter Schlossgarten',
+ content: 'Kein Baum wird gefällt werden!',
+ categoryIds: ['cat5'],
+ postType: 'Event',
+ eventInput: {
+ eventStart: new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1).toISOString(),
+ eventVenue: 'Schlossgarten',
+ eventLocationName: 'Stuttgart',
},
- }),
- mutate({
- mutation: createPostMutation(),
- variables: {
- id: 'e2',
- title: 'IT 4 Change Treffen',
- content: 'Wir sitzen eine Woche zusammen rum und glotzen uns blöde an.',
- categoryIds: ['cat5'],
- postType: 'Event',
- eventInput: {
- eventStart: new Date(
- now.getFullYear(),
- now.getMonth(),
- now.getDate() + 1,
- ).toISOString(),
- eventEnd: new Date(now.getFullYear(), now.getMonth(), now.getDate() + 4).toISOString(),
- eventVenue: 'Ferienlager',
- eventLocationName: 'Bahra, Sachsen',
- },
+ },
+ })
+ await mutate({
+ mutation: createPostMutation(),
+ variables: {
+ id: 'e2',
+ title: 'IT 4 Change Treffen',
+ content: 'Wir sitzen eine Woche zusammen rum und glotzen uns blöde an.',
+ categoryIds: ['cat5'],
+ postType: 'Event',
+ eventInput: {
+ eventStart: new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1).toISOString(),
+ eventEnd: new Date(now.getFullYear(), now.getMonth(), now.getDate() + 4).toISOString(),
+ eventVenue: 'Ferienlager',
+ eventLocationName: 'Bahra, Sachsen',
},
- }),
- ])
+ },
+ })
let passedEvent = await neode.find('Post', 'e1')
await passedEvent.update({ eventStart: new Date(2010, 8, 30, 10).toISOString() })
@@ -834,48 +799,49 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
const hashtagAndMention1 =
'The new physics of #QuantenFlussTheorie can explain #QuantumGravity! @peter-lustig got that already. ;-)'
- await Promise.all([
- mutate({
- mutation: createPostMutation(),
- variables: {
- id: 'p2',
- title: `Nature Philosophy Yoga`,
- content: hashtag1,
- categoryIds: ['cat2'],
- },
- }),
- mutate({
- mutation: createPostMutation(),
- variables: {
- id: 'p7',
- title: 'This is post #7',
- content: `${mention1} ${faker.lorem.paragraph()}`,
- categoryIds: ['cat7'],
- },
- }),
- mutate({
- mutation: createPostMutation(),
- variables: {
- id: 'p8',
- image: faker.image.unsplash.nature(),
- title: `Quantum Flow Theory explains Quantum Gravity`,
- content: hashtagAndMention1,
- categoryIds: ['cat8'],
- },
- }),
- mutate({
- mutation: createPostMutation(),
- variables: {
- id: 'p12',
- title: 'This is post #12',
- content: `${mention2} ${faker.lorem.paragraph()}`,
- categoryIds: ['cat12'],
- },
- }),
- ])
- const [p2, p7, p8, p12] = await Promise.all(
- ['p2', 'p7', 'p8', 'p12'].map((id) => neode.find('Post', id)),
- )
+ await mutate({
+ mutation: createPostMutation(),
+ variables: {
+ id: 'p2',
+ title: `Nature Philosophy Yoga`,
+ content: hashtag1,
+ categoryIds: ['cat2'],
+ },
+ })
+ await mutate({
+ mutation: createPostMutation(),
+ variables: {
+ id: 'p7',
+ title: 'This is post #7',
+ content: `${mention1} ${faker.lorem.paragraph()}`,
+ categoryIds: ['cat7'],
+ },
+ })
+ await mutate({
+ mutation: createPostMutation(),
+ variables: {
+ id: 'p8',
+ image: faker.image.unsplash.nature(),
+ title: `Quantum Flow Theory explains Quantum Gravity`,
+ content: hashtagAndMention1,
+ categoryIds: ['cat8'],
+ },
+ })
+ await mutate({
+ mutation: createPostMutation(),
+ variables: {
+ id: 'p12',
+ title: 'This is post #12',
+ content: `${mention2} ${faker.lorem.paragraph()}`,
+ categoryIds: ['cat12'],
+ },
+ })
+
+ const p2 = await neode.find('Post', 'p2')
+ const p7 = await neode.find('Post', 'p7')
+ const p8 = await neode.find('Post', 'p8')
+ const p12 = await neode.find('Post', 'p12')
+
authenticatedUser = null
authenticatedUser = await dewey.toJson()
@@ -883,31 +849,30 @@ 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?'
- await Promise.all([
- mutate({
- mutation: createCommentMutation,
- variables: {
- id: 'c4',
- postId: 'p2',
- content: mentionInComment1,
- },
- }),
- mutate({
- mutation: createCommentMutation,
- variables: {
- id: 'c4-1',
- postId: 'p2',
- content: mentionInComment2,
- },
- }),
- mutate({
- mutation: createCommentMutation,
- variables: {
- postId: 'p14',
- content: faker.lorem.paragraph(),
- },
- }), // should send a notification
- ])
+ await mutate({
+ mutation: createCommentMutation,
+ variables: {
+ id: 'c4',
+ postId: 'p2',
+ content: mentionInComment1,
+ },
+ })
+ await mutate({
+ mutation: createCommentMutation,
+ variables: {
+ id: 'c4-1',
+ postId: 'p2',
+ content: mentionInComment2,
+ },
+ })
+ await mutate({
+ mutation: createCommentMutation,
+ variables: {
+ postId: 'p14',
+ content: faker.lorem.paragraph(),
+ },
+ }) // should send a notification
+
authenticatedUser = null
const comments: any[] = []
@@ -1191,368 +1156,410 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
closed: true,
})
- const additionalUsers = await Promise.all(
- [...Array(30).keys()].map(() => Factory.build('user')),
- )
+ const additionalUsers: any[] = []
+ for (let i = 0; i < 30; i++) {
+ const user = await Factory.build('user')
+ await jennyRostock.relateTo(user, 'following')
+ await user.relateTo(jennyRostock, 'following')
+ additionalUsers.push(user)
+ }
- await Promise.all(
- additionalUsers.map(async (user) => {
- await jennyRostock.relateTo(user, 'following')
- await user.relateTo(jennyRostock, 'following')
- }),
- )
+ // Jenny users
+ for (let i = 0; i < 30; i++) {
+ await Factory.build('user', { name: `Jenny${i}` })
+ }
- await Promise.all(
- [...Array(30).keys()].map((index) => Factory.build('user', { name: `Jenny${index}` })),
- )
+ // Jenny posts
+ for (let i = 0; i < 30; i++) {
+ await Factory.build(
+ 'post',
+ { content: `Jenny ${faker.lorem.sentence()}` },
+ {
+ categoryIds: ['cat1'],
+ author: jennyRostock,
+ image: Factory.build('image', {
+ url: faker.image.unsplash.objects(),
+ }),
+ },
+ )
+ }
- await Promise.all(
- [...Array(30).keys()].map(() =>
- Factory.build(
- 'post',
- { content: `Jenny ${faker.lorem.sentence()}` },
- {
- categoryIds: ['cat1'],
- author: jennyRostock,
- image: Factory.build('image', {
- url: faker.image.unsplash.objects(),
- }),
- },
- ),
- ),
- )
+ // comments on p2 jenny
+ for (let i = 0; i < 6; i++) {
+ await Factory.build(
+ 'comment',
+ {},
+ {
+ author: jennyRostock,
+ postId: 'p2',
+ },
+ )
+ }
- await Promise.all(
- [...Array(30).keys()].map(() =>
- Factory.build(
- 'post',
- {},
- {
- categoryIds: ['cat1'],
- author: jennyRostock,
- image: Factory.build('image', {
- url: faker.image.unsplash.objects(),
- }),
- },
- ),
- ),
- )
+ // comments on p15 jenny
+ for (let i = 0; i < 4; i++) {
+ await Factory.build(
+ 'comment',
+ {},
+ {
+ author: jennyRostock,
+ postId: 'p15',
+ },
+ )
+ }
- await Promise.all(
- [...Array(6).keys()].map(() =>
- Factory.build(
- 'comment',
- {},
- {
- author: jennyRostock,
- postId: 'p2',
- },
- ),
- ),
- )
+ // comments on p4 jenny
+ for (let i = 0; i < 2; i++) {
+ await Factory.build(
+ 'comment',
+ {},
+ {
+ author: jennyRostock,
+ postId: 'p4',
+ },
+ )
+ }
- await Promise.all(
- [...Array(4).keys()].map(() =>
- Factory.build(
- 'comment',
- {},
- {
- author: jennyRostock,
- postId: 'p15',
- },
- ),
- ),
- )
+ // Posts Peter Lustig
+ for (let i = 0; i < 21; i++) {
+ await Factory.build(
+ 'post',
+ {},
+ {
+ categoryIds: ['cat1'],
+ author: peterLustig,
+ image: Factory.build('image', {
+ url: faker.image.unsplash.buildings(),
+ }),
+ },
+ )
+ }
- await Promise.all(
- [...Array(2).keys()].map(() =>
- Factory.build(
- 'comment',
- {},
- {
- author: jennyRostock,
- postId: 'p4',
- },
- ),
- ),
- )
+ // comments p4 peter
+ for (let i = 0; i < 3; i++) {
+ await Factory.build(
+ 'comment',
+ {},
+ {
+ author: peterLustig,
+ postId: 'p4',
+ },
+ )
+ }
- await Promise.all(
- [...Array(21).keys()].map(() =>
- Factory.build(
- 'post',
- {},
- {
- categoryIds: ['cat1'],
- author: peterLustig,
- image: Factory.build('image', {
- url: faker.image.unsplash.buildings(),
- }),
- },
- ),
- ),
- )
+ // comments p14 peter
+ for (let i = 0; i < 3; i++) {
+ await Factory.build(
+ 'comment',
+ {},
+ {
+ author: peterLustig,
+ postId: 'p14',
+ },
+ )
+ }
- await Promise.all(
- [...Array(3).keys()].map(() =>
- Factory.build(
- 'comment',
- {},
- {
- author: peterLustig,
- postId: 'p4',
- },
- ),
- ),
- )
+ // comments p0 peter
+ for (let i = 0; i < 3; i++) {
+ await Factory.build(
+ 'comment',
+ {},
+ {
+ author: peterLustig,
+ postId: 'p0',
+ },
+ )
+ }
- await Promise.all(
- [...Array(3).keys()].map(() =>
- Factory.build(
- 'comment',
- {},
- {
- author: peterLustig,
- postId: 'p14',
- },
- ),
- ),
- )
+ // Posts dewey
+ for (let i = 0; i < 11; i++) {
+ await Factory.build(
+ 'post',
+ {},
+ {
+ categoryIds: ['cat1'],
+ author: dewey,
+ image: Factory.build('image', {
+ url: faker.image.unsplash.food(),
+ }),
+ },
+ )
+ }
- await Promise.all(
- [...Array(6).keys()].map(() =>
- Factory.build(
- 'comment',
- {},
- {
- author: peterLustig,
- postId: 'p0',
- },
- ),
- ),
- )
+ // Comments p2 dewey
+ for (let i = 0; i < 7; i++) {
+ await Factory.build(
+ 'comment',
+ {},
+ {
+ author: dewey,
+ postId: 'p2',
+ },
+ )
+ }
- await Promise.all(
- [...Array(11).keys()].map(() =>
- Factory.build(
- 'post',
- {},
- {
- categoryIds: ['cat1'],
- author: dewey,
- image: Factory.build('image', {
- url: faker.image.unsplash.food(),
- }),
- },
- ),
- ),
- )
+ // Comments p6 dewey
+ for (let i = 0; i < 5; i++) {
+ await Factory.build(
+ 'comment',
+ {},
+ {
+ author: dewey,
+ postId: 'p6',
+ },
+ )
+ }
- await Promise.all(
- [...Array(7).keys()].map(() =>
- Factory.build(
- 'comment',
- {},
- {
- author: dewey,
- postId: 'p2',
- },
- ),
- ),
- )
+ // Comments p9 dewey
+ for (let i = 0; i < 2; i++) {
+ await Factory.build(
+ 'comment',
+ {},
+ {
+ author: dewey,
+ postId: 'p9',
+ },
+ )
+ }
- await Promise.all(
- [...Array(5).keys()].map(() =>
- Factory.build(
- 'comment',
- {},
- {
- author: dewey,
- postId: 'p6',
- },
- ),
- ),
- )
+ // Posts louie
+ for (let i = 0; i < 16; i++) {
+ await Factory.build(
+ 'post',
+ {},
+ {
+ categoryIds: ['cat1'],
+ author: louie,
+ image: Factory.build('image', {
+ url: faker.image.unsplash.technology(),
+ }),
+ },
+ )
+ }
- await Promise.all(
- [...Array(2).keys()].map(() =>
- Factory.build(
- 'comment',
- {},
- {
- author: dewey,
- postId: 'p9',
- },
- ),
- ),
- )
+ // Comments p1 louie
+ for (let i = 0; i < 4; i++) {
+ await Factory.build(
+ 'comment',
+ {},
+ {
+ postId: 'p1',
+ author: louie,
+ },
+ )
+ }
- await Promise.all(
- [...Array(16).keys()].map(() =>
- Factory.build(
- 'post',
- {},
- {
- categoryIds: ['cat1'],
- author: louie,
- image: Factory.build('image', {
- url: faker.image.unsplash.technology(),
- }),
- },
- ),
- ),
- )
+ // Comments p10 louie
+ for (let i = 0; i < 8; i++) {
+ await Factory.build(
+ 'comment',
+ {},
+ {
+ author: louie,
+ postId: 'p10',
+ },
+ )
+ }
- await Promise.all(
- [...Array(4).keys()].map(() =>
- Factory.build(
- 'comment',
- {},
- {
- postId: 'p1',
- author: louie,
- },
- ),
- ),
- )
+ // Comments p13 louie
+ for (let i = 0; i < 5; i++) {
+ await Factory.build(
+ 'comment',
+ {},
+ {
+ author: louie,
+ postId: 'p13',
+ },
+ )
+ }
- await Promise.all(
- [...Array(8).keys()].map(() =>
- Factory.build(
- 'comment',
- {},
- {
- author: louie,
- postId: 'p10',
- },
- ),
- ),
- )
+ // Posts Bob der Baumeister
+ for (let i = 0; i < 45; i++) {
+ await Factory.build(
+ 'post',
+ {},
+ {
+ categoryIds: ['cat1'],
+ author: bobDerBaumeister,
+ image: Factory.build('image', {
+ url: faker.image.unsplash.people(),
+ }),
+ },
+ )
+ }
- await Promise.all(
- [...Array(5).keys()].map(() =>
- Factory.build(
- 'comment',
- {},
- {
- author: louie,
- postId: 'p13',
- },
- ),
- ),
- )
+ // Comments p2 bob
+ for (let i = 0; i < 2; i++) {
+ await Factory.build(
+ 'comment',
+ {},
+ {
+ author: bobDerBaumeister,
+ postId: 'p2',
+ },
+ )
+ }
- await Promise.all(
- [...Array(45).keys()].map(() =>
- Factory.build(
- 'post',
- {},
- {
- categoryIds: ['cat1'],
- author: bobDerBaumeister,
- image: Factory.build('image', {
- url: faker.image.unsplash.people(),
- }),
- },
- ),
- ),
- )
+ // Comments p12 bob
+ for (let i = 0; i < 3; i++) {
+ await Factory.build(
+ 'comment',
+ {},
+ {
+ author: bobDerBaumeister,
+ postId: 'p12',
+ },
+ )
+ }
- await Promise.all(
- [...Array(2).keys()].map(() =>
- Factory.build(
- 'comment',
- {},
- {
- author: bobDerBaumeister,
- postId: 'p2',
- },
- ),
- ),
- )
+ // Comments p13 bob
+ for (let i = 0; i < 7; i++) {
+ await Factory.build(
+ 'comment',
+ {},
+ {
+ author: bobDerBaumeister,
+ postId: 'p13',
+ },
+ )
+ }
- await Promise.all(
- [...Array(3).keys()].map(() =>
- Factory.build(
- 'comment',
- {},
- {
- author: bobDerBaumeister,
- postId: 'p12',
- },
- ),
- ),
- )
+ // Posts huey
+ for (let i = 0; i < 8; i++) {
+ await Factory.build(
+ 'post',
+ {},
+ {
+ categoryIds: ['cat1'],
+ author: huey,
+ image: Factory.build('image', {
+ url: faker.image.unsplash.nature(),
+ }),
+ },
+ )
+ }
- await Promise.all(
- [...Array(7).keys()].map(() =>
- Factory.build(
- 'comment',
- {},
- {
- author: bobDerBaumeister,
- postId: 'p13',
- },
- ),
- ),
- )
+ // Comments p0 huey
+ for (let i = 0; i < 6; i++) {
+ await Factory.build(
+ 'comment',
+ {},
+ {
+ author: huey,
+ postId: 'p0',
+ },
+ )
+ }
- await Promise.all(
- [...Array(8).keys()].map(() =>
- Factory.build(
- 'post',
- {},
- {
- categoryIds: ['cat1'],
- author: huey,
- image: Factory.build('image', {
- url: faker.image.unsplash.nature(),
- }),
- },
- ),
- ),
- )
+ // Comments p13 huey
+ for (let i = 0; i < 8; i++) {
+ await Factory.build(
+ 'comment',
+ {},
+ {
+ author: huey,
+ postId: 'p13',
+ },
+ )
+ }
- await Promise.all(
- [...Array(6).keys()].map(() =>
- Factory.build(
- 'comment',
- {},
- {
- author: huey,
- postId: 'p0',
- },
- ),
- ),
- )
-
- await Promise.all(
- [...Array(8).keys()].map(() =>
- Factory.build(
- 'comment',
- {},
- {
- author: huey,
- postId: 'p13',
- },
- ),
- ),
- )
-
- await Promise.all(
- [...Array(8).keys()].map(() =>
- Factory.build(
- 'comment',
- {},
- {
- author: huey,
- postId: 'p15',
- },
- ),
- ),
- )
+ // Comments p15 huey
+ for (let i = 0; i < 8; i++) {
+ await Factory.build(
+ 'comment',
+ {},
+ {
+ author: huey,
+ postId: 'p15',
+ },
+ )
+ }
await Factory.build('donations')
+
+ // Chat
+ authenticatedUser = await huey.toJson()
+ const { data: roomHueyPeter } = await mutate({
+ mutation: createRoomMutation(),
+ variables: {
+ userId: (await peterLustig.toJson()).id,
+ },
+ })
+
+ for (let i = 0; i < 30; i++) {
+ authenticatedUser = await huey.toJson()
+ await mutate({
+ mutation: createMessageMutation(),
+ variables: {
+ roomId: roomHueyPeter?.CreateRoom.id,
+ content: faker.lorem.sentence(),
+ },
+ })
+ authenticatedUser = await peterLustig.toJson()
+ await mutate({
+ mutation: createMessageMutation(),
+ variables: {
+ roomId: roomHueyPeter?.CreateRoom.id,
+ content: faker.lorem.sentence(),
+ },
+ })
+ }
+
+ authenticatedUser = await huey.toJson()
+ const { data: roomHueyJenny } = await mutate({
+ mutation: createRoomMutation(),
+ variables: {
+ userId: (await jennyRostock.toJson()).id,
+ },
+ })
+ for (let i = 0; i < 1000; i++) {
+ authenticatedUser = await huey.toJson()
+ await mutate({
+ mutation: createMessageMutation(),
+ variables: {
+ roomId: roomHueyJenny?.CreateRoom.id,
+ content: faker.lorem.sentence(),
+ },
+ })
+ authenticatedUser = await jennyRostock.toJson()
+ await mutate({
+ mutation: createMessageMutation(),
+ variables: {
+ roomId: roomHueyJenny?.CreateRoom.id,
+ content: faker.lorem.sentence(),
+ },
+ })
+ }
+
+ for (const user of additionalUsers) {
+ authenticatedUser = await jennyRostock.toJson()
+ const { data: room } = await mutate({
+ mutation: createRoomMutation(),
+ variables: {
+ userId: (await user.toJson()).id,
+ },
+ })
+
+ for (let i = 0; i < 29; i++) {
+ authenticatedUser = await jennyRostock.toJson()
+ await mutate({
+ mutation: createMessageMutation(),
+ variables: {
+ roomId: room?.CreateRoom.id,
+ content: faker.lorem.sentence(),
+ },
+ })
+ authenticatedUser = await user.toJson()
+ await mutate({
+ mutation: createMessageMutation(),
+ variables: {
+ roomId: room?.CreateRoom.id,
+ content: faker.lorem.sentence(),
+ },
+ })
+ }
+ }
+
/* eslint-disable-next-line no-console */
console.log('Seeded Data...')
await driver.close()
diff --git a/backend/src/graphql/messages.ts b/backend/src/graphql/messages.ts
index 34c7d559b..2842c7230 100644
--- a/backend/src/graphql/messages.ts
+++ b/backend/src/graphql/messages.ts
@@ -6,6 +6,10 @@ export const createMessageMutation = () => {
CreateMessage(roomId: $roomId, content: $content) {
id
content
+ senderId
+ username
+ avatar
+ date
saved
distributed
seen
@@ -16,12 +20,16 @@ export const createMessageMutation = () => {
export const messageQuery = () => {
return gql`
- query ($roomId: ID!) {
- Message(roomId: $roomId) {
+ query ($roomId: ID!, $first: Int, $offset: Int) {
+ Message(roomId: $roomId, first: $first, offset: $offset, orderBy: indexId_desc) {
_id
id
+ indexId
content
senderId
+ author {
+ id
+ }
username
avatar
date
diff --git a/backend/src/graphql/rooms.ts b/backend/src/graphql/rooms.ts
index 109bf1d55..7612641f3 100644
--- a/backend/src/graphql/rooms.ts
+++ b/backend/src/graphql/rooms.ts
@@ -6,18 +6,10 @@ export const createRoomMutation = () => {
CreateRoom(userId: $userId) {
id
roomId
- }
- }
- `
-}
-
-export const roomQuery = () => {
- return gql`
- query {
- Room {
- id
- roomId
roomName
+ lastMessageAt
+ unreadCount
+ #avatar
users {
_id
id
@@ -30,3 +22,46 @@ export const roomQuery = () => {
}
`
}
+
+export const roomQuery = () => {
+ return gql`
+ query Room($first: Int, $offset: Int, $id: ID) {
+ Room(first: $first, offset: $offset, id: $id, orderBy: lastMessageAt_desc) {
+ id
+ roomId
+ roomName
+ avatar
+ lastMessageAt
+ unreadCount
+ lastMessage {
+ _id
+ id
+ content
+ senderId
+ username
+ avatar
+ date
+ saved
+ distributed
+ seen
+ }
+ users {
+ _id
+ id
+ name
+ avatar {
+ url
+ }
+ }
+ }
+ }
+ `
+}
+
+export const unreadRoomsQuery = () => {
+ return gql`
+ query {
+ UnreadRooms
+ }
+ `
+}
diff --git a/backend/src/helpers/walkRecursive.ts b/backend/src/helpers/walkRecursive.ts
index f560cf9cb..f3be67575 100644
--- a/backend/src/helpers/walkRecursive.ts
+++ b/backend/src/helpers/walkRecursive.ts
@@ -9,10 +9,9 @@ function walkRecursive(data, fields, fieldName, callback, _key?) {
if (!Array.isArray(fields)) {
throw new Error('please provide an fields array for the walkRecursive helper')
}
- if (data && typeof data === 'string' && fields.includes(_key)) {
- // well we found what we searched for, lets replace the value with our callback result
- const key = _key.split('!')
- if (key.length === 1 || key[1] !== fieldName) data = callback(data, key[0])
+ const fieldDef = fields.find((f) => f.field === _key)
+ if (data && typeof data === 'string' && fieldDef) {
+ if (!fieldDef.excludes?.includes(fieldName)) data = callback(data, _key)
} else if (data && Array.isArray(data)) {
// go into the rabbit hole and dig through that array
data.forEach((res, index) => {
diff --git a/backend/src/middleware/chatMiddleware.ts b/backend/src/middleware/chatMiddleware.ts
new file mode 100644
index 000000000..8ae252e13
--- /dev/null
+++ b/backend/src/middleware/chatMiddleware.ts
@@ -0,0 +1,60 @@
+import { isArray } from 'lodash'
+
+const setRoomProps = (room) => {
+ if (room.users) {
+ room.users.forEach((user) => {
+ user._id = user.id
+ })
+ }
+ if (room.lastMessage) {
+ room.lastMessage._id = room.lastMessage.id
+ }
+}
+
+const setMessageProps = (message, context) => {
+ message._id = message.id
+ if (message.senderId !== context.user.id) {
+ message.distributed = true
+ }
+}
+
+const roomProperties = async (resolve, root, args, context, info) => {
+ const resolved = await resolve(root, args, context, info)
+ if (resolved) {
+ if (isArray(resolved)) {
+ resolved.forEach((room) => {
+ setRoomProps(room)
+ })
+ } else {
+ setRoomProps(resolved)
+ }
+ }
+ return resolved
+}
+
+const messageProperties = async (resolve, root, args, context, info) => {
+ const resolved = await resolve(root, args, context, info)
+ if (resolved) {
+ if (isArray(resolved)) {
+ resolved.forEach((message) => {
+ setMessageProps(message, context)
+ })
+ } else {
+ setMessageProps(resolved, context)
+ }
+ }
+ return resolved
+}
+
+export default {
+ Query: {
+ Room: roomProperties,
+ Message: messageProperties,
+ },
+ Mutation: {
+ CreateRoom: roomProperties,
+ },
+ Subscription: {
+ chatMessageAdded: messageProperties,
+ },
+}
diff --git a/backend/src/middleware/helpers/cleanHtml.ts b/backend/src/middleware/helpers/cleanHtml.ts
index ac71f6bdc..84497760d 100644
--- a/backend/src/middleware/helpers/cleanHtml.ts
+++ b/backend/src/middleware/helpers/cleanHtml.ts
@@ -30,6 +30,7 @@ const standardSanitizeHtmlOptions = {
'strike',
'span',
'blockquote',
+ 'usertag',
],
allowedAttributes: {
a: ['href', 'class', 'target', 'data-*', 'contenteditable'],
diff --git a/backend/src/middleware/index.ts b/backend/src/middleware/index.ts
index 813bbe9a7..08c872db7 100644
--- a/backend/src/middleware/index.ts
+++ b/backend/src/middleware/index.ts
@@ -14,6 +14,7 @@ import login from './login/loginMiddleware'
import sentry from './sentryMiddleware'
import languages from './languages/languages'
import userInteractions from './userInteractions'
+import chatMiddleware from './chatMiddleware'
export default (schema) => {
const middlewares = {
@@ -31,6 +32,7 @@ export default (schema) => {
orderBy,
languages,
userInteractions,
+ chatMiddleware,
}
let order = [
@@ -49,6 +51,7 @@ export default (schema) => {
'softDelete',
'includedFields',
'orderBy',
+ 'chatMiddleware',
]
// add permisions middleware at the first position (unless we're seeding)
diff --git a/backend/src/middleware/permissionsMiddleware.ts b/backend/src/middleware/permissionsMiddleware.ts
index c07098a3c..f87f4b079 100644
--- a/backend/src/middleware/permissionsMiddleware.ts
+++ b/backend/src/middleware/permissionsMiddleware.ts
@@ -408,6 +408,7 @@ export default shield(
getInviteCode: isAuthenticated, // and inviteRegistration
Room: isAuthenticated,
Message: isAuthenticated,
+ UnreadRooms: isAuthenticated,
},
Mutation: {
'*': deny,
diff --git a/backend/src/middleware/xssMiddleware.ts b/backend/src/middleware/xssMiddleware.ts
index ede0cc199..c10997e8d 100644
--- a/backend/src/middleware/xssMiddleware.ts
+++ b/backend/src/middleware/xssMiddleware.ts
@@ -3,11 +3,11 @@ import { cleanHtml } from '../middleware/helpers/cleanHtml'
// exclamation mark separetes field names, that should not be sanitized
const fields = [
- 'content',
- 'contentExcerpt',
- 'reasonDescription',
- 'description!embed',
- 'descriptionExcerpt',
+ { field: 'content', excludes: ['CreateMessage', 'Message'] },
+ { field: 'contentExcerpt' },
+ { field: 'reasonDescription' },
+ { field: 'description', excludes: ['embed'] },
+ { field: 'descriptionExcerpt' },
]
export default {
diff --git a/backend/src/schema/resolvers/messages.spec.ts b/backend/src/schema/resolvers/messages.spec.ts
index 0deccb4e9..83d9fdc6b 100644
--- a/backend/src/schema/resolvers/messages.spec.ts
+++ b/backend/src/schema/resolvers/messages.spec.ts
@@ -1,13 +1,15 @@
import { createTestClient } from 'apollo-server-testing'
import Factory, { cleanDatabase } from '../../db/factories'
import { getNeode, getDriver } from '../../db/neo4j'
-import { createRoomMutation } from '../../graphql/rooms'
+import { createRoomMutation, roomQuery } from '../../graphql/rooms'
import { createMessageMutation, messageQuery, markMessagesAsSeen } from '../../graphql/messages'
-import createServer from '../../server'
+import createServer, { pubsub } from '../../server'
const driver = getDriver()
const neode = getNeode()
+const pubsubSpy = jest.spyOn(pubsub, 'publish')
+
let query
let mutate
let authenticatedUser
@@ -22,6 +24,9 @@ beforeAll(async () => {
driver,
neode,
user: authenticatedUser,
+ cypherParams: {
+ currentUserId: authenticatedUser ? authenticatedUser.id : null,
+ },
}
},
})
@@ -55,6 +60,10 @@ describe('Message', () => {
})
describe('create message', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
describe('unauthenticated', () => {
it('throws authorization error', async () => {
await expect(
@@ -77,7 +86,7 @@ describe('Message', () => {
})
describe('room does not exist', () => {
- it('returns null', async () => {
+ it('returns null and does not publish subscription', async () => {
await expect(
mutate({
mutation: createMessageMutation(),
@@ -92,6 +101,7 @@ describe('Message', () => {
CreateMessage: null,
},
})
+ expect(pubsubSpy).not.toBeCalled()
})
})
@@ -107,7 +117,7 @@ describe('Message', () => {
})
describe('user chats in room', () => {
- it('returns the message', async () => {
+ it('returns the message and publishes subscriptions', async () => {
await expect(
mutate({
mutation: createMessageMutation(),
@@ -122,12 +132,92 @@ describe('Message', () => {
CreateMessage: {
id: expect.any(String),
content: 'Some nice message to other chatting user',
+ senderId: 'chatting-user',
+ username: 'Chatting User',
+ avatar: expect.any(String),
+ date: expect.any(String),
saved: true,
distributed: false,
seen: false,
},
},
})
+ expect(pubsubSpy).toBeCalledWith('ROOM_COUNT_UPDATED', {
+ roomCountUpdated: '1',
+ userId: 'other-chatting-user',
+ })
+ expect(pubsubSpy).toBeCalledWith('CHAT_MESSAGE_ADDED', {
+ chatMessageAdded: expect.objectContaining({
+ id: expect.any(String),
+ content: 'Some nice message to other chatting user',
+ senderId: 'chatting-user',
+ username: 'Chatting User',
+ avatar: expect.any(String),
+ date: expect.any(String),
+ saved: true,
+ distributed: false,
+ seen: false,
+ }),
+ userId: 'other-chatting-user',
+ })
+ })
+
+ describe('room is updated as well', () => {
+ it('has last message set', async () => {
+ const result = await query({ query: roomQuery() })
+ await expect(result).toMatchObject({
+ errors: undefined,
+ data: {
+ Room: [
+ expect.objectContaining({
+ lastMessageAt: expect.any(String),
+ unreadCount: 0,
+ lastMessage: expect.objectContaining({
+ _id: result.data.Room[0].lastMessage.id,
+ id: expect.any(String),
+ content: 'Some nice message to other chatting user',
+ senderId: 'chatting-user',
+ username: 'Chatting User',
+ avatar: expect.any(String),
+ date: expect.any(String),
+ saved: true,
+ distributed: false,
+ seen: false,
+ }),
+ }),
+ ],
+ },
+ })
+ })
+ })
+
+ describe('unread count for other user', () => {
+ it('has unread count = 1', async () => {
+ authenticatedUser = await otherChattingUser.toJson()
+ await expect(query({ query: roomQuery() })).resolves.toMatchObject({
+ errors: undefined,
+ data: {
+ Room: [
+ expect.objectContaining({
+ lastMessageAt: expect.any(String),
+ unreadCount: 1,
+ lastMessage: expect.objectContaining({
+ _id: expect.any(String),
+ id: expect.any(String),
+ content: 'Some nice message to other chatting user',
+ senderId: 'chatting-user',
+ username: 'Chatting User',
+ avatar: expect.any(String),
+ date: expect.any(String),
+ saved: true,
+ distributed: false,
+ seen: false,
+ }),
+ }),
+ ],
+ },
+ })
+ })
})
})
@@ -215,6 +305,7 @@ describe('Message', () => {
{
id: expect.any(String),
_id: result.data.Message[0].id,
+ indexId: 0,
content: 'Some nice message to other chatting user',
senderId: 'chatting-user',
username: 'Chatting User',
@@ -259,9 +350,10 @@ describe('Message', () => {
).resolves.toMatchObject({
errors: undefined,
data: {
- Message: expect.arrayContaining([
+ Message: [
expect.objectContaining({
id: expect.any(String),
+ indexId: 0,
content: 'Some nice message to other chatting user',
senderId: 'chatting-user',
username: 'Chatting User',
@@ -273,6 +365,7 @@ describe('Message', () => {
}),
expect.objectContaining({
id: expect.any(String),
+ indexId: 1,
content: 'A nice response message to chatting user',
senderId: 'other-chatting-user',
username: 'Other Chatting User',
@@ -284,6 +377,7 @@ describe('Message', () => {
}),
expect.objectContaining({
id: expect.any(String),
+ indexId: 2,
content: 'And another nice message to other chatting user',
senderId: 'chatting-user',
username: 'Chatting User',
@@ -293,7 +387,70 @@ describe('Message', () => {
distributed: false,
seen: false,
}),
- ]),
+ ],
+ },
+ })
+ })
+
+ it('returns the messages paginated', async () => {
+ await expect(
+ query({
+ query: messageQuery(),
+ variables: {
+ roomId,
+ first: 2,
+ offset: 0,
+ },
+ }),
+ ).resolves.toMatchObject({
+ errors: undefined,
+ data: {
+ Message: [
+ expect.objectContaining({
+ id: expect.any(String),
+ indexId: 1,
+ content: 'A nice response message to chatting user',
+ senderId: 'other-chatting-user',
+ username: 'Other Chatting User',
+ avatar: expect.any(String),
+ date: expect.any(String),
+ }),
+ expect.objectContaining({
+ id: expect.any(String),
+ indexId: 2,
+ content: 'And another nice message to other chatting user',
+ senderId: 'chatting-user',
+ username: 'Chatting User',
+ avatar: expect.any(String),
+ date: expect.any(String),
+ }),
+ ],
+ },
+ })
+
+ await expect(
+ query({
+ query: messageQuery(),
+ variables: {
+ roomId,
+ first: 2,
+ offset: 2,
+ },
+ }),
+ ).resolves.toMatchObject({
+ errors: undefined,
+ data: {
+ Message: [
+ expect.objectContaining({
+ id: expect.any(String),
+ indexId: 0,
+ content: 'Some nice message to other chatting user',
+ senderId: 'chatting-user',
+ username: 'Chatting User',
+ avatar: expect.any(String),
+ date: expect.any(String),
+ }),
+ ],
},
})
})
diff --git a/backend/src/schema/resolvers/messages.ts b/backend/src/schema/resolvers/messages.ts
index 45de0b4a4..b7e7a7a73 100644
--- a/backend/src/schema/resolvers/messages.ts
+++ b/backend/src/schema/resolvers/messages.ts
@@ -1,7 +1,36 @@
import { neo4jgraphql } from 'neo4j-graphql-js'
import Resolver from './helpers/Resolver'
+import { getUnreadRoomsCount } from './rooms'
+import { pubsub, ROOM_COUNT_UPDATED, CHAT_MESSAGE_ADDED } from '../../server'
+import { withFilter } from 'graphql-subscriptions'
+
+const setMessagesAsDistributed = async (undistributedMessagesIds, session) => {
+ return session.writeTransaction(async (transaction) => {
+ const setDistributedCypher = `
+ MATCH (m:Message) WHERE m.id IN $undistributedMessagesIds
+ SET m.distributed = true
+ RETURN m { .* }
+ `
+ const setDistributedTxResponse = await transaction.run(setDistributedCypher, {
+ undistributedMessagesIds,
+ })
+ const messages = await setDistributedTxResponse.records.map((record) => record.get('m'))
+ return messages
+ })
+}
+
export default {
+ Subscription: {
+ chatMessageAdded: {
+ subscribe: withFilter(
+ () => pubsub.asyncIterator(CHAT_MESSAGE_ADDED),
+ (payload, variables) => {
+ return payload.userId === variables.userId
+ },
+ ),
+ },
+ },
Query: {
Message: async (object, params, context, resolveInfo) => {
const { roomId } = params
@@ -13,41 +42,24 @@ export default {
id: context.user.id,
},
}
+
const resolved = await neo4jgraphql(object, params, context, resolveInfo)
if (resolved) {
const undistributedMessagesIds = resolved
.filter((msg) => !msg.distributed && msg.senderId !== context.user.id)
.map((msg) => msg.id)
- if (undistributedMessagesIds.length > 0) {
- const session = context.driver.session()
- const writeTxResultPromise = session.writeTransaction(async (transaction) => {
- const setDistributedCypher = `
- MATCH (m:Message) WHERE m.id IN $undistributedMessagesIds
- SET m.distributed = true
- RETURN m { .* }
- `
- const setDistributedTxResponse = await transaction.run(setDistributedCypher, {
- undistributedMessagesIds,
- })
- const messages = await setDistributedTxResponse.records.map((record) => record.get('m'))
- return messages
- })
- try {
- await writeTxResultPromise
- } finally {
- session.close()
+ const session = context.driver.session()
+ try {
+ if (undistributedMessagesIds.length > 0) {
+ await setMessagesAsDistributed(undistributedMessagesIds, session)
}
- // send subscription to author to updated the messages
+ } finally {
+ session.close()
}
- resolved.forEach((message) => {
- message._id = message.id
- if (message.senderId !== context.user.id) {
- message.distributed = true
- }
- })
+ // send subscription to author to updated the messages
}
- return resolved
+ return resolved.reverse()
},
},
Mutation: {
@@ -60,28 +72,59 @@ export default {
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
const createMessageCypher = `
MATCH (currentUser:User { id: $currentUserId })-[:CHATS_IN]->(room:Room { id: $roomId })
+ OPTIONAL MATCH (currentUser)-[:AVATAR_IMAGE]->(image:Image)
+ OPTIONAL MATCH (m:Message)-[:INSIDE]->(room)
+ OPTIONAL MATCH (room)<-[:CHATS_IN]-(recipientUser:User)
+ WHERE NOT recipientUser.id = $currentUserId
+ WITH MAX(m.indexId) as maxIndex, room, currentUser, image, recipientUser
CREATE (currentUser)-[:CREATED]->(message:Message {
createdAt: toString(datetime()),
id: apoc.create.uuid(),
- content: $content,
+ indexId: CASE WHEN maxIndex IS NOT NULL THEN maxIndex + 1 ELSE 0 END,
+ content: LEFT($content,2000),
saved: true,
distributed: false,
seen: false
})-[:INSIDE]->(room)
- RETURN message { .* }
+ SET room.lastMessageAt = toString(datetime())
+ RETURN message {
+ .*,
+ indexId: toString(message.indexId),
+ recipientId: recipientUser.id,
+ senderId: currentUser.id,
+ username: currentUser.name,
+ avatar: image.url,
+ date: message.createdAt
+ }
`
const createMessageTxResponse = await transaction.run(createMessageCypher, {
currentUserId,
roomId,
content,
})
+
const [message] = await createMessageTxResponse.records.map((record) =>
record.get('message'),
)
+
return message
})
try {
const message = await writeTxResultPromise
+ if (message) {
+ const roomCountUpdated = await getUnreadRoomsCount(message.recipientId, session)
+
+ // send subscriptions
+ void pubsub.publish(ROOM_COUNT_UPDATED, {
+ roomCountUpdated,
+ userId: message.recipientId,
+ })
+ void pubsub.publish(CHAT_MESSAGE_ADDED, {
+ chatMessageAdded: message,
+ userId: message.recipientId,
+ })
+ }
+
return message
} catch (error) {
throw new Error(error)
diff --git a/backend/src/schema/resolvers/rooms.spec.ts b/backend/src/schema/resolvers/rooms.spec.ts
index 03c3d4456..2e26dc1e3 100644
--- a/backend/src/schema/resolvers/rooms.spec.ts
+++ b/backend/src/schema/resolvers/rooms.spec.ts
@@ -1,7 +1,8 @@
import { createTestClient } from 'apollo-server-testing'
import Factory, { cleanDatabase } from '../../db/factories'
import { getNeode, getDriver } from '../../db/neo4j'
-import { createRoomMutation, roomQuery } from '../../graphql/rooms'
+import { createRoomMutation, roomQuery, unreadRoomsQuery } from '../../graphql/rooms'
+import { createMessageMutation } from '../../graphql/messages'
import createServer from '../../server'
const driver = getDriver()
@@ -21,6 +22,9 @@ beforeAll(async () => {
driver,
neode,
user: authenticatedUser,
+ cypherParams: {
+ currentUserId: authenticatedUser ? authenticatedUser.id : null,
+ },
}
},
})
@@ -34,6 +38,8 @@ afterAll(async () => {
})
describe('Room', () => {
+ let roomId: string
+
beforeAll(async () => {
;[chattingUser, otherChattingUser, notChattingUser] = await Promise.all([
Factory.build('user', {
@@ -48,6 +54,14 @@ describe('Room', () => {
id: 'not-chatting-user',
name: 'Not Chatting User',
}),
+ Factory.build('user', {
+ id: 'second-chatting-user',
+ name: 'Second Chatting User',
+ }),
+ Factory.build('user', {
+ id: 'third-chatting-user',
+ name: 'Third Chatting User',
+ }),
])
})
@@ -68,8 +82,6 @@ describe('Room', () => {
})
describe('authenticated', () => {
- let roomId: string
-
beforeAll(async () => {
authenticatedUser = await chattingUser.toJson()
})
@@ -122,6 +134,26 @@ describe('Room', () => {
CreateRoom: {
id: expect.any(String),
roomId: result.data.CreateRoom.id,
+ roomName: 'Other Chatting User',
+ unreadCount: 0,
+ users: expect.arrayContaining([
+ {
+ _id: 'chatting-user',
+ id: 'chatting-user',
+ name: 'Chatting User',
+ avatar: {
+ url: expect.any(String),
+ },
+ },
+ {
+ _id: 'other-chatting-user',
+ id: 'other-chatting-user',
+ name: 'Other Chatting User',
+ avatar: {
+ url: expect.any(String),
+ },
+ },
+ ]),
},
},
})
@@ -219,6 +251,7 @@ describe('Room', () => {
id: expect.any(String),
roomId: result.data.Room[0].id,
roomName: 'Chatting User',
+ unreadCount: 0,
users: expect.arrayContaining([
{
_id: 'chatting-user',
@@ -260,4 +293,319 @@ describe('Room', () => {
})
})
})
+
+ describe('unread rooms query', () => {
+ describe('unauthenticated', () => {
+ it('throws authorization error', async () => {
+ authenticatedUser = null
+ await expect(
+ query({
+ query: unreadRoomsQuery(),
+ }),
+ ).resolves.toMatchObject({
+ errors: [{ message: 'Not Authorized!' }],
+ })
+ })
+ })
+
+ describe('authenticated', () => {
+ let otherRoomId: string
+
+ beforeAll(async () => {
+ authenticatedUser = await chattingUser.toJson()
+ const result = await mutate({
+ mutation: createRoomMutation(),
+ variables: {
+ userId: 'not-chatting-user',
+ },
+ })
+ otherRoomId = result.data.CreateRoom.roomId
+ await mutate({
+ mutation: createMessageMutation(),
+ variables: {
+ roomId: otherRoomId,
+ content: 'Message to not chatting user',
+ },
+ })
+ await mutate({
+ mutation: createMessageMutation(),
+ variables: {
+ roomId,
+ content: '1st message to other chatting user',
+ },
+ })
+ await mutate({
+ mutation: createMessageMutation(),
+ variables: {
+ roomId,
+ content: '2nd message to other chatting user',
+ },
+ })
+ authenticatedUser = await otherChattingUser.toJson()
+ const result2 = await mutate({
+ mutation: createRoomMutation(),
+ variables: {
+ userId: 'not-chatting-user',
+ },
+ })
+ otherRoomId = result2.data.CreateRoom.roomId
+ await mutate({
+ mutation: createMessageMutation(),
+ variables: {
+ roomId: otherRoomId,
+ content: 'Other message to not chatting user',
+ },
+ })
+ })
+
+ describe('as chatting user', () => {
+ it('has 0 unread rooms', async () => {
+ authenticatedUser = await chattingUser.toJson()
+ await expect(
+ query({
+ query: unreadRoomsQuery(),
+ }),
+ ).resolves.toMatchObject({
+ data: {
+ UnreadRooms: 0,
+ },
+ })
+ })
+ })
+
+ describe('as other chatting user', () => {
+ it('has 1 unread rooms', async () => {
+ authenticatedUser = await otherChattingUser.toJson()
+ await expect(
+ query({
+ query: unreadRoomsQuery(),
+ }),
+ ).resolves.toMatchObject({
+ data: {
+ UnreadRooms: 1,
+ },
+ })
+ })
+ })
+
+ describe('as not chatting user', () => {
+ it('has 2 unread rooms', async () => {
+ authenticatedUser = await notChattingUser.toJson()
+ await expect(
+ query({
+ query: unreadRoomsQuery(),
+ }),
+ ).resolves.toMatchObject({
+ data: {
+ UnreadRooms: 2,
+ },
+ })
+ })
+ })
+ })
+ })
+
+ describe('query several rooms', () => {
+ beforeAll(async () => {
+ authenticatedUser = await chattingUser.toJson()
+ await mutate({
+ mutation: createRoomMutation(),
+ variables: {
+ userId: 'second-chatting-user',
+ },
+ })
+ await mutate({
+ mutation: createRoomMutation(),
+ variables: {
+ userId: 'third-chatting-user',
+ },
+ })
+ })
+
+ it('returns the rooms paginated', async () => {
+ await expect(
+ query({ query: roomQuery(), variables: { first: 3, offset: 0 } }),
+ ).resolves.toMatchObject({
+ errors: undefined,
+ data: {
+ Room: expect.arrayContaining([
+ expect.objectContaining({
+ id: expect.any(String),
+ roomId: expect.any(String),
+ roomName: 'Third Chatting User',
+ lastMessageAt: null,
+ unreadCount: 0,
+ lastMessage: null,
+ users: expect.arrayContaining([
+ expect.objectContaining({
+ _id: 'chatting-user',
+ id: 'chatting-user',
+ name: 'Chatting User',
+ avatar: {
+ url: expect.any(String),
+ },
+ }),
+ expect.objectContaining({
+ _id: 'third-chatting-user',
+ id: 'third-chatting-user',
+ name: 'Third Chatting User',
+ avatar: {
+ url: expect.any(String),
+ },
+ }),
+ ]),
+ }),
+ expect.objectContaining({
+ id: expect.any(String),
+ roomId: expect.any(String),
+ roomName: 'Second Chatting User',
+ lastMessageAt: null,
+ unreadCount: 0,
+ lastMessage: null,
+ users: expect.arrayContaining([
+ expect.objectContaining({
+ _id: 'chatting-user',
+ id: 'chatting-user',
+ name: 'Chatting User',
+ avatar: {
+ url: expect.any(String),
+ },
+ }),
+ expect.objectContaining({
+ _id: 'second-chatting-user',
+ id: 'second-chatting-user',
+ name: 'Second Chatting User',
+ avatar: {
+ url: expect.any(String),
+ },
+ }),
+ ]),
+ }),
+ expect.objectContaining({
+ id: expect.any(String),
+ roomId: expect.any(String),
+ roomName: 'Other Chatting User',
+ lastMessageAt: expect.any(String),
+ unreadCount: 0,
+ lastMessage: {
+ _id: expect.any(String),
+ id: expect.any(String),
+ content: '2nd message to other chatting user',
+ senderId: 'chatting-user',
+ username: 'Chatting User',
+ avatar: expect.any(String),
+ date: expect.any(String),
+ saved: true,
+ distributed: false,
+ seen: false,
+ },
+ users: expect.arrayContaining([
+ expect.objectContaining({
+ _id: 'chatting-user',
+ id: 'chatting-user',
+ name: 'Chatting User',
+ avatar: {
+ url: expect.any(String),
+ },
+ }),
+ expect.objectContaining({
+ _id: 'other-chatting-user',
+ id: 'other-chatting-user',
+ name: 'Other Chatting User',
+ avatar: {
+ url: expect.any(String),
+ },
+ }),
+ ]),
+ }),
+ ]),
+ },
+ })
+ await expect(
+ query({ query: roomQuery(), variables: { first: 3, offset: 3 } }),
+ ).resolves.toMatchObject({
+ errors: undefined,
+ data: {
+ Room: [
+ expect.objectContaining({
+ id: expect.any(String),
+ roomId: expect.any(String),
+ roomName: 'Not Chatting User',
+ users: expect.arrayContaining([
+ {
+ _id: 'chatting-user',
+ id: 'chatting-user',
+ name: 'Chatting User',
+ avatar: {
+ url: expect.any(String),
+ },
+ },
+ {
+ _id: 'not-chatting-user',
+ id: 'not-chatting-user',
+ name: 'Not Chatting User',
+ avatar: {
+ url: expect.any(String),
+ },
+ },
+ ]),
+ }),
+ ],
+ },
+ })
+ })
+ })
+
+ describe('query single room', () => {
+ let result: any = null
+
+ beforeAll(async () => {
+ authenticatedUser = await chattingUser.toJson()
+ result = await query({ query: roomQuery() })
+ })
+
+ describe('as chatter of room', () => {
+ it('returns the room', async () => {
+ expect(
+ await query({
+ query: roomQuery(),
+ variables: { first: 2, offset: 0, id: result.data.Room[0].id },
+ }),
+ ).toMatchObject({
+ errors: undefined,
+ data: {
+ Room: [
+ {
+ id: expect.any(String),
+ roomId: expect.any(String),
+ roomName: result.data.Room[0].roomName,
+ users: expect.any(Array),
+ },
+ ],
+ },
+ })
+ })
+
+ describe('as not chatter of room', () => {
+ beforeAll(async () => {
+ authenticatedUser = await notChattingUser.toJson()
+ })
+
+ it('returns no room', async () => {
+ authenticatedUser = await notChattingUser.toJson()
+ expect(
+ await query({
+ query: roomQuery(),
+ variables: { first: 2, offset: 0, id: result.data.Room[0].id },
+ }),
+ ).toMatchObject({
+ errors: undefined,
+ data: {
+ Room: [],
+ },
+ })
+ })
+ })
+ })
+ })
})
diff --git a/backend/src/schema/resolvers/rooms.ts b/backend/src/schema/resolvers/rooms.ts
index d5015a03b..5e931a446 100644
--- a/backend/src/schema/resolvers/rooms.ts
+++ b/backend/src/schema/resolvers/rooms.ts
@@ -1,29 +1,50 @@
import { neo4jgraphql } from 'neo4j-graphql-js'
import Resolver from './helpers/Resolver'
+import { pubsub, ROOM_COUNT_UPDATED } from '../../server'
+import { withFilter } from 'graphql-subscriptions'
+
+export const getUnreadRoomsCount = async (userId, session) => {
+ return session.readTransaction(async (transaction) => {
+ const unreadRoomsCypher = `
+ MATCH (:User { id: $userId })-[:CHATS_IN]->(room:Room)<-[:INSIDE]-(message:Message)<-[:CREATED]-(sender:User)
+ WHERE NOT sender.id = $userId AND NOT message.seen
+ RETURN toString(COUNT(DISTINCT room)) AS count
+ `
+ const unreadRoomsTxResponse = await transaction.run(unreadRoomsCypher, { userId })
+ return unreadRoomsTxResponse.records.map((record) => record.get('count'))[0]
+ })
+}
export default {
+ Subscription: {
+ roomCountUpdated: {
+ subscribe: withFilter(
+ () => pubsub.asyncIterator(ROOM_COUNT_UPDATED),
+ (payload, variables) => {
+ return payload.userId === variables.userId
+ },
+ ),
+ },
+ },
Query: {
Room: async (object, params, context, resolveInfo) => {
if (!params.filter) params.filter = {}
params.filter.users_some = {
id: context.user.id,
}
- const resolved = await neo4jgraphql(object, params, context, resolveInfo)
- if (resolved) {
- resolved.forEach((room) => {
- if (room.users) {
- // buggy, you must query the username for this to function correctly
- room.roomName = room.users.filter((user) => user.id !== context.user.id)[0].name
- room.avatar =
- room.users.filter((user) => user.id !== context.user.id)[0].avatar?.url ||
- 'default-avatar'
- room.users.forEach((user) => {
- user._id = user.id
- })
- }
- })
+ return neo4jgraphql(object, params, context, resolveInfo)
+ },
+ UnreadRooms: async (object, params, context, resolveInfo) => {
+ const {
+ user: { id: currentUserId },
+ } = context
+ const session = context.driver.session()
+ try {
+ const count = await getUnreadRoomsCount(currentUserId, session)
+ return count
+ } finally {
+ session.close()
}
- return resolved
},
},
Mutation: {
@@ -44,7 +65,17 @@ export default {
ON CREATE SET
room.createdAt = toString(datetime()),
room.id = apoc.create.uuid()
- RETURN room { .* }
+ WITH room, user, currentUser
+ OPTIONAL MATCH (room)<-[:INSIDE]-(message:Message)<-[:CREATED]-(sender:User)
+ WHERE NOT sender.id = $currentUserId AND NOT message.seen
+ WITH room, user, currentUser, message,
+ user.name AS roomName
+ RETURN room {
+ .*,
+ users: [properties(currentUser), properties(user)],
+ roomName: roomName,
+ unreadCount: toString(COUNT(DISTINCT message))
+ }
`
const createRommTxResponse = await transaction.run(createRoomCypher, {
userId,
@@ -68,6 +99,7 @@ export default {
},
Room: {
...Resolver('Room', {
+ undefinedToNull: ['lastMessageAt'],
hasMany: {
users: '<-[:CHATS_IN]-(related:User)',
},
diff --git a/backend/src/schema/types/type/Message.gql b/backend/src/schema/types/type/Message.gql
index 8b9263336..71d175e1c 100644
--- a/backend/src/schema/types/type/Message.gql
+++ b/backend/src/schema/types/type/Message.gql
@@ -2,8 +2,13 @@
# room: _RoomFilter
# }
+enum _MessageOrdering {
+ indexId_desc
+}
+
type Message {
id: ID!
+ indexId: Int!
createdAt: String
updatedAt: String
@@ -32,5 +37,14 @@ type Mutation {
}
type Query {
- Message(roomId: ID!): [Message]
+ Message(
+ roomId: ID!,
+ first: Int
+ offset: Int
+ orderBy: [_MessageOrdering]
+ ): [Message]
+}
+
+type Subscription {
+ chatMessageAdded(userId: ID!): Message
}
diff --git a/backend/src/schema/types/type/Room.gql b/backend/src/schema/types/type/Room.gql
index 2ce6556f6..0cf5b22c8 100644
--- a/backend/src/schema/types/type/Room.gql
+++ b/backend/src/schema/types/type/Room.gql
@@ -5,6 +5,11 @@
# users_some: _UserFilter
# }
+# TODO change this to last message date
+enum _RoomOrdering {
+ lastMessageAt_desc
+}
+
type Room {
id: ID!
createdAt: String
@@ -13,8 +18,28 @@ type Room {
users: [User]! @relation(name: "CHATS_IN", direction: "IN")
roomId: String! @cypher(statement: "RETURN this.id")
- roomName: String! ## @cypher(statement: "MATCH (this)<-[:CHATS_IN]-(user:User) WHERE NOT user.id = $cypherParams.currentUserId RETURN user[0].name")
- avatar: String! ## @cypher match not own user in users array
+ roomName: String! @cypher(statement: "MATCH (this)<-[:CHATS_IN]-(user:User) WHERE NOT user.id = $cypherParams.currentUserId RETURN user.name")
+ avatar: String @cypher(statement: """
+ MATCH (this)<-[:CHATS_IN]-(user:User)
+ WHERE NOT user.id = $cypherParams.currentUserId
+ OPTIONAL MATCH (user)-[:AVATAR_IMAGE]->(image:Image)
+ RETURN image.url
+ """)
+
+ lastMessageAt: String
+
+ lastMessage: Message @cypher(statement: """
+ MATCH (this)<-[:INSIDE]-(message:Message)
+ WITH message ORDER BY message.indexId DESC LIMIT 1
+ RETURN message
+ """)
+
+ unreadCount: Int @cypher(statement: """
+ MATCH (this)<-[:INSIDE]-(message:Message)<-[:CREATED]-(user:User)
+ WHERE NOT user.id = $cypherParams.currentUserId
+ AND NOT message.seen
+ RETURN count(message)
+ """)
}
type Mutation {
@@ -24,5 +49,13 @@ type Mutation {
}
type Query {
- Room: [Room]
+ Room(
+ id: ID
+ orderBy: [_RoomOrdering]
+ ): [Room]
+ UnreadRooms: Int
+}
+
+type Subscription {
+ roomCountUpdated(userId: ID!): Int
}
diff --git a/backend/src/server.ts b/backend/src/server.ts
index b4d63c007..0522f5fc8 100644
--- a/backend/src/server.ts
+++ b/backend/src/server.ts
@@ -14,6 +14,8 @@ import bodyParser from 'body-parser'
import { graphqlUploadExpress } from 'graphql-upload'
export const NOTIFICATION_ADDED = 'NOTIFICATION_ADDED'
+export const CHAT_MESSAGE_ADDED = 'CHAT_MESSAGE_ADDED'
+export const ROOM_COUNT_UPDATED = 'ROOM_COUNT_UPDATED'
const { REDIS_DOMAIN, REDIS_PORT, REDIS_PASSWORD } = CONFIG
let prodPubsub, devPubsub
const options = {
diff --git a/webapp/assets/_new/styles/export.scss b/webapp/assets/_new/styles/export.scss
index 88b42bfc9..5b866d6b7 100644
--- a/webapp/assets/_new/styles/export.scss
+++ b/webapp/assets/_new/styles/export.scss
@@ -27,4 +27,8 @@
chatMessageBgOthers: $chat-message-bg-others;
chatNewMessageColor: $chat-new-message-color;
+
+ chatMessageTimestamp: $chat-message-timestamp;
+ chatMessageCheckmarkSeen: $chat-message-checkmark-seen;
+ chatMessageCheckmark: $chat-message-checkmark;
}
\ No newline at end of file
diff --git a/webapp/assets/_new/styles/tokens.scss b/webapp/assets/_new/styles/tokens.scss
index e001ffa84..ef5086240 100644
--- a/webapp/assets/_new/styles/tokens.scss
+++ b/webapp/assets/_new/styles/tokens.scss
@@ -417,3 +417,6 @@ $chat-message-color: $text-color-base;
$chat-message-bg-others: $color-neutral-80;
$chat-sidemenu-bg: $color-secondary-active;
$chat-new-message-color: $color-secondary-active;
+$chat-message-timestamp: $text-color-soft;
+$chat-message-checkmark-seen: $text-color-secondary;
+$chat-message-checkmark: $text-color-soft;
diff --git a/webapp/components/Chat/Chat.vue b/webapp/components/Chat/Chat.vue
index cca6c4319..63cf045e8 100644
--- a/webapp/components/Chat/Chat.vue
+++ b/webapp/components/Chat/Chat.vue
@@ -4,23 +4,27 @@