diff --git a/backend/.env.template b/backend/.env.template
index 4d7ae42d2..eb710101e 100644
--- a/backend/.env.template
+++ b/backend/.env.template
@@ -26,6 +26,8 @@ SMTP_DKIM_PRIVATKEY=
# SMTP_IGNORE_TLS=true
# SMTP_USERNAME=
# SMTP_PASSWORD=
+# SMTP_MAX_CONNECTIONS=1
+# SMTP_MAX_MESSAGES= 10
JWT_SECRET="b/&&7b78BF&fv/Vd"
JWT_EXPIRES="2y"
diff --git a/backend/Dockerfile b/backend/Dockerfile
index 2897fe2f6..e481da5a3 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -22,6 +22,8 @@ FROM base AS build
COPY . .
ONBUILD COPY ./branding/constants/ src/config/tmp
ONBUILD RUN tools/replace-constants.sh
+# copy categories to brand them (use yarn prod:db:data:categories)
+ONBUILD COPY branding/constants/ src/constants/
ONBUILD COPY ./branding/email/ src/middleware/helpers/email/
ONBUILD COPY ./branding/middlewares/ src/middleware/branding/
ONBUILD COPY ./branding/data/ src/db/data
diff --git a/backend/README.md b/backend/README.md
index 7d8bbfb15..e6a828848 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -87,9 +87,9 @@ A fresh database needs to be initialized and migrated.
# in folder backend while database is running
yarn db:migrate init
# for docker environments:
-docker exec backend yarn db:migrate init
+docker exec ocelot-social-backend-1 yarn db:migrate init
# for docker production:
-docker exec backend yarn prod:migrate init
+docker exec ocelot-social-backend-1 yarn prod:migrate init
```
```sh
@@ -97,9 +97,9 @@ docker exec backend yarn prod:migrate init
yarn db:migrate up
# for docker development:
-docker exec backend yarn db:migrate up
+docker exec ocelot-social-backend-1 yarn db:migrate up
# for docker production
-docker exec backend yarn prod:migrate up
+docker exec ocelot-social-backend-1 yarn prod:migrate up
```
### Optional Data
@@ -131,7 +131,7 @@ To do so, run:
yarn db:data:branding
# for docker
-docker exec backend yarn db:data:branding
+docker exec ocelot-social-backend-1 yarn db:data:branding
```
### Seed Data
@@ -143,7 +143,7 @@ For a predefined set of test data you can seed the database with:
yarn db:seed
# for docker
-docker exec backend yarn db:seed
+docker exec ocelot-social-backend-1 yarn db:seed
```
### Reset Data
@@ -157,9 +157,9 @@ yarn db:reset
yarn db:reset:withmigrations
# for docker
-docker exec backend yarn db:reset
+docker exec ocelot-social-backend-1 yarn db:reset
# or deleting the migrations as well
-docker exec backend yarn db:reset:withmigrations
+docker exec ocelot-social-backend-1 yarn db:reset:withmigrations
# you could also wipe out your neo4j database and delete all volumes with:
docker compose down -v
```
@@ -180,7 +180,7 @@ $ yarn run db:migrate:create your_data_migration
# for docker
# in main folder while docker compose is running
-$ docker compose exec backend yarn run db:migrate:create your_data_migration
+$ docker compose exec ocelot-social-backend-1 yarn run db:migrate:create your_data_migration
# Edit the file in ./src/db/migrations/
```
@@ -208,5 +208,12 @@ $ yarn run test
# for docker
# in main folder while docker compose is running
-$ docker exec backend yarn run test
+$ docker exec ocelot-social-backend-1 yarn run test
+```
+
+If the snapshots of the emails must be updated, you have to run the tests in docker! Otherwise the CI will fail.
+
+```sh
+# in main folder while docker compose is running
+$ docker exec ocelot-social-backend-1 yarn run test -u src/emails/
```
diff --git a/backend/package.json b/backend/package.json
index 38bf966ac..0cfa5a080 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -24,7 +24,8 @@
"db:migrate": "migrate --compiler 'ts:./src/db/compiler.ts' --migrations-dir ./src/db/migrations --store ./src/db/migrate/store.ts",
"db:migrate:create": "migrate --compiler 'ts:./src/db/compiler.ts' --migrations-dir ./src/db/migrations --template-file ./src/db/migrate/template.ts --date-format 'yyyymmddHHmmss' create",
"prod:migrate": "migrate --migrations-dir ./build/src/db/migrations --store ./build/src/db/migrate/store.js",
- "prod:db:data:branding": "node build/src/db/data-branding.js"
+ "prod:db:data:branding": "node build/src/db/data-branding.js",
+ "prod:db:data:categories": "node build/src/db/categories.js"
},
"dependencies": {
"@sentry/node": "^5.15.4",
diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts
index 658c7e97c..a079c2ae5 100644
--- a/backend/src/config/index.ts
+++ b/backend/src/config/index.ts
@@ -117,6 +117,10 @@ const options = {
ORGANIZATION_URL: emails.ORGANIZATION_LINK,
PUBLIC_REGISTRATION: env.PUBLIC_REGISTRATION === 'true' || false,
INVITE_REGISTRATION: env.INVITE_REGISTRATION !== 'false', // default = true
+ INVITE_CODES_PERSONAL_PER_USER:
+ (env.INVITE_CODES_PERSONAL_PER_USER && parseInt(env.INVITE_CODES_PERSONAL_PER_USER)) || 7,
+ INVITE_CODES_GROUP_PER_USER:
+ (env.INVITE_CODES_GROUP_PER_USER && parseInt(env.INVITE_CODES_GROUP_PER_USER)) || 7,
CATEGORIES_ACTIVE: process.env.CATEGORIES_ACTIVE === 'true' || false,
}
diff --git a/backend/src/config/logosBranded.ts b/backend/src/config/logosBranded.ts
new file mode 100644
index 000000000..3c9a85861
--- /dev/null
+++ b/backend/src/config/logosBranded.ts
@@ -0,0 +1,32 @@
+// this file is duplicated in `backend/src/config/logos.ts` and `webapp/constants/logos.js` and replaced on rebranding
+// this are the paths in the webapp
+import { merge } from 'lodash'
+
+import logos from '@config/logos'
+
+const defaultLogos = {
+ LOGO_HEADER_PATH: '/img/custom/logo-horizontal.svg',
+ LOGO_HEADER_MOBILE_PATH: '/img/custom/logo-horizontal.svg',
+ LOGO_HEADER_WIDTH: '130px',
+ LOGO_HEADER_MOBILE_WIDTH: '100px',
+ LOGO_HEADER_CLICK: {
+ // externalLink: {
+ // url: 'https://ocelot.social',
+ // target: '_blank',
+ // },
+ externalLink: null,
+ internalPath: {
+ to: {
+ name: 'index',
+ },
+ scrollTo: '.main-navigation',
+ },
+ },
+ LOGO_SIGNUP_PATH: '/img/custom/logo-squared.svg',
+ LOGO_WELCOME_PATH: '/img/custom/logo-squared.svg',
+ LOGO_LOGOUT_PATH: '/img/custom/logo-squared.svg',
+ LOGO_PASSWORD_RESET_PATH: '/img/custom/logo-squared.svg',
+ LOGO_MAINTENACE_RESET_PATH: '/img/custom/logo-squared.svg',
+}
+
+export default merge(defaultLogos, logos)
diff --git a/backend/src/constants/categories.ts b/backend/src/constants/categories.ts
index 6365d268a..b6fce03ca 100644
--- a/backend/src/constants/categories.ts
+++ b/backend/src/constants/categories.ts
@@ -5,98 +5,116 @@ export const CATEGORIES_MAX = 3
export const categories = [
{
icon: 'networking',
+ id: 'cat0',
+ slug: 'networking',
name: 'networking',
- description: 'Kooperation, Aktionsbündnisse, Solidarität, Hilfe',
},
{
icon: 'home',
+ id: 'cat1',
+ slug: 'home',
name: 'home',
- description: 'Bauen, Lebensgemeinschaften, Tiny Houses, Gemüsegarten',
},
{
icon: 'energy',
+ id: 'cat2',
+ slug: 'energy',
name: 'energy',
- description: 'Öl, Gas, Kohle, Wind, Wasserkraft, Biogas, Atomenergie, ...',
},
{
icon: 'psyche',
+ id: 'cat3',
+ slug: 'psyche',
name: 'psyche',
- description: 'Seele, Gefühle, Glück',
},
{
icon: 'movement',
+ id: 'cat4',
+ slug: 'body-and-excercise',
name: 'body-and-excercise',
- description: 'Sport, Yoga, Massage, Tanzen, Entspannung',
},
{
icon: 'balance-scale',
+ id: 'cat5',
+ slug: 'law',
name: 'law',
- description: 'Menschenrechte, Gesetze, Verordnungen',
},
{
icon: 'finance',
+ id: 'cat6',
+ slug: 'finance',
name: 'finance',
- description: 'Geld, Finanzsystem, Alternativwährungen, ...',
},
{
icon: 'child',
+ id: 'cat7',
+ slug: 'children',
name: 'children',
- description: 'Familie, Pädagogik, Schule, Prägung',
},
{
icon: 'mobility',
+ id: 'cat8',
+ slug: 'mobility',
name: 'mobility',
- description: 'Reise, Verkehr, Elektromobilität',
},
{
icon: 'shopping-cart',
+ id: 'cat9',
+ slug: 'economy',
name: 'economy',
- description: 'Handel, Konsum, Marketing, Lebensmittel, Lieferketten, ...',
},
{
icon: 'peace',
+ id: 'cat10',
+ slug: 'peace',
name: 'peace',
- description: 'Krieg, Militär, soziale Verteidigung, Waffen, Cyberattacken',
},
{
icon: 'politics',
+ id: 'cat11',
+ slug: 'politics',
name: 'politics',
- description: 'Demokratie, Mitbestimmung, Wahlen, Korruption, Parteien',
},
{
icon: 'nature',
+ id: 'cat12',
+ slug: 'nature',
name: 'nature',
- description: 'Tiere, Pflanzen, Landwirtschaft, Ökologie, Artenvielfalt',
},
{
icon: 'science',
+ id: 'cat13',
+ slug: 'science',
name: 'science',
- description: 'Bildung, Hochschule, Publikationen, ...',
},
{
icon: 'health',
+ id: 'cat14',
+ slug: 'health',
name: 'health',
- description: 'Medizin, Ernährung, WHO, Impfungen, Schadstoffe, ...',
},
{
icon: 'media',
+ id: 'cat15',
+ slug: 'it-and-media',
name: 'it-and-media',
- description:
- 'Nachrichten, Manipulation, Datenschutz, Überwachung, Datenkraken, AI, Software, Apps',
},
{
icon: 'spirituality',
+ id: 'cat16',
+ slug: 'spirituality',
name: 'spirituality',
- description: 'Religion, Werte, Ethik',
},
{
icon: 'culture',
+ id: 'cat17',
+ slug: 'culture',
name: 'culture',
- description: 'Kunst, Theater, Musik, Fotografie, Film',
},
{
icon: 'miscellaneous',
+ id: 'cat18',
+ slug: 'miscellaneous',
name: 'miscellaneous',
- description: '',
},
]
diff --git a/backend/src/context/database.ts b/backend/src/context/database.ts
index f6ccdc9ca..dc623470d 100644
--- a/backend/src/context/database.ts
+++ b/backend/src/context/database.ts
@@ -4,7 +4,7 @@ import type { Driver } from 'neo4j-driver'
export const query =
(driver: Driver) =>
- async ({ query, variables = {} }: { driver; query: string; variables: object }) => {
+ async ({ query, variables = {} }: { query: string; variables?: object }) => {
const session = driver.session()
const result = session.readTransaction(async (transaction) => {
@@ -19,9 +19,9 @@ export const query =
}
}
-export const mutate =
+export const write =
(driver: Driver) =>
- async ({ query, variables = {} }: { driver; query: string; variables: object }) => {
+ async ({ query, variables = {} }: { query: string; variables?: object }) => {
const session = driver.session()
const result = session.writeTransaction(async (transaction) => {
@@ -44,6 +44,6 @@ export default () => {
driver,
neode,
query: query(driver),
- mutate: mutate(driver),
+ write: write(driver),
}
}
diff --git a/backend/src/db/categories.ts b/backend/src/db/categories.ts
index a007b25ae..24421a400 100644
--- a/backend/src/db/categories.ts
+++ b/backend/src/db/categories.ts
@@ -1,38 +1,44 @@
/* eslint-disable @typescript-eslint/no-floating-promises */
-/* eslint-disable @typescript-eslint/restrict-template-expressions */
-/* eslint-disable @typescript-eslint/require-await */
+/* eslint-disable @typescript-eslint/no-unsafe-return */
+/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { categories } from '@constants/categories'
+import databaseContext from '@context/database'
-import { getDriver } from './neo4j'
+const { query, write, driver } = databaseContext()
const createCategories = async () => {
- const driver = getDriver()
- const session = driver.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())
- })`,
- )
- })
+ const result = await query({
+ query: 'MATCH (category:Category) RETURN category { .* }',
})
- try {
- await createCategoriesTxResultPromise
- console.log('Successfully created categories!') // eslint-disable-line no-console
- // eslint-disable-next-line no-catch-all/no-catch-all
- } catch (error) {
- console.log(`Error creating categories: ${error}`) // eslint-disable-line no-console
- } finally {
- session.close()
- driver.close()
- }
+
+ const existingCategories = result.records.map((r) => r.get('category'))
+ const existingCategoryIds = existingCategories.map((c) => c.id)
+
+ const newCategories = categories.filter((c) => !existingCategoryIds.includes(c.id))
+
+ await write({
+ query: `UNWIND $newCategories AS map
+ CREATE (category:Category)
+ SET category = map
+ SET category.createdAt = toString(datetime())`,
+ variables: {
+ newCategories,
+ },
+ })
+
+ const categoryIds = categories.map((c) => c.id)
+ await write({
+ query: `MATCH (category:Category)
+ WHERE NOT category.id IN $categoryIds
+ DETACH DELETE category`,
+ variables: {
+ categoryIds,
+ },
+ })
+ // eslint-disable-next-line no-console
+ console.log('Successfully created categories!')
+ await driver.close()
}
;(async function () {
diff --git a/backend/src/db/factories.ts b/backend/src/db/factories.ts
index 95db5a859..a5237dada 100644
--- a/backend/src/db/factories.ts
+++ b/backend/src/db/factories.ts
@@ -10,7 +10,7 @@ import { Factory } from 'rosie'
import slugify from 'slug'
import { v4 as uuid } from 'uuid'
-import generateInviteCode from '@graphql/resolvers/helpers/generateInviteCode'
+import { generateInviteCode } from '@graphql/resolvers/inviteCodes'
import { getDriver, getNeode } from './neo4j'
@@ -268,17 +268,27 @@ const inviteCodeDefaults = {
Factory.define('inviteCode')
.attrs(inviteCodeDefaults)
+ .option('groupId', null)
+ .option('group', ['groupId'], (groupId) => {
+ if (groupId) {
+ return neode.find('Group', groupId)
+ }
+ })
.option('generatedById', null)
.option('generatedBy', ['generatedById'], (generatedById) => {
if (generatedById) return neode.find('User', generatedById)
return Factory.build('user')
})
.after(async (buildObject, options) => {
- const [inviteCode, generatedBy] = await Promise.all([
+ const [inviteCode, generatedBy, group] = await Promise.all([
neode.create('InviteCode', buildObject),
options.generatedBy,
+ options.group,
])
- await Promise.all([inviteCode.relateTo(generatedBy, 'generated')])
+ await inviteCode.relateTo(generatedBy, 'generated')
+ if (group) {
+ await inviteCode.relateTo(group, 'invitesTo')
+ }
return inviteCode
})
diff --git a/backend/src/db/models/InviteCode.ts b/backend/src/db/models/InviteCode.ts
index 7204f1b38..0617529ac 100644
--- a/backend/src/db/models/InviteCode.ts
+++ b/backend/src/db/models/InviteCode.ts
@@ -14,4 +14,10 @@ export default {
target: 'User',
direction: 'in',
},
+ invitesTo: {
+ type: 'relationship',
+ relationship: 'INVITES_TO',
+ target: 'Group',
+ direction: 'out',
+ },
}
diff --git a/backend/src/emails/__snapshots__/sendChatMessageMail.spec.ts.snap b/backend/src/emails/__snapshots__/sendChatMessageMail.spec.ts.snap
index 786bad9c0..67c141c0e 100644
--- a/backend/src/emails/__snapshots__/sendChatMessageMail.spec.ts.snap
+++ b/backend/src/emails/__snapshots__/sendChatMessageMail.spec.ts.snap
@@ -91,7 +91,7 @@ footer {
Hello chatReceiver,
-
you have received a new chat message from chatSender.
+
you have received a new chat message from chatSender.
Show Chat
See you soon on ocelot.social!
@@ -109,7 +109,7 @@ footer {
"text": "HELLO CHATRECEIVER,
you have received a new chat message from chatSender
-[http://webapp:3000/user/chatSender/chatsender].
+[http://webapp:3000/profile/chatSender/chatsender].
Show Chat [http://webapp:3000/chat]
@@ -218,7 +218,7 @@ footer {
Hallo chatReceiver,
-
du hast eine neue Chat-Nachricht von chatSender erhalten.
+
du hast eine neue Chat-Nachricht von chatSender erhalten.
Chat anzeigen
Bis bald bei ocelot.social!
@@ -236,7 +236,7 @@ footer {
"text": "HALLO CHATRECEIVER,
du hast eine neue Chat-Nachricht von chatSender
-[http://webapp:3000/user/chatSender/chatsender] erhalten.
+[http://webapp:3000/profile/chatSender/chatsender] erhalten.
Chat anzeigen [http://webapp:3000/chat]
diff --git a/backend/src/emails/__snapshots__/sendEmailVerification.spec.ts.snap b/backend/src/emails/__snapshots__/sendEmailVerification.spec.ts.snap
index 87815f5e6..7f718d936 100644
--- a/backend/src/emails/__snapshots__/sendEmailVerification.spec.ts.snap
+++ b/backend/src/emails/__snapshots__/sendEmailVerification.spec.ts.snap
@@ -221,9 +221,9 @@ footer {
Hallo User,
-
Du möchtest also deine E-Mail ändern? Kein Problem! Mit Klick auf diesen Button kannst Du Deine neue E-Mail Adresse bestätigen:
E-Mail Adresse bestätigen
-
Falls Du deine E-Mail Adresse doch nicht ändern möchtest, kannst du diese Nachricht einfach ignorieren.
-
Sollte der Button für Dich nicht funktionieren, kannst Du auch folgenden Code in Dein Browserfenster kopieren: 123456
+
Du möchtest also deine E-Mail ändern? Kein Problem! Mit Klick auf diesen Button kannst du deine neue E-Mail Adresse bestätigen:
E-Mail Adresse bestätigen
+
Falls du deine E-Mail Adresse doch nicht ändern möchtest, kannst du diese Nachricht einfach ignorieren.
+
Sollte der Button für dich nicht funktionieren, kannst du auch folgenden Code in dein Browserfenster kopieren: 123456
Bis bald bei ocelot.social!
– Dein ocelot.social Team
@@ -239,16 +239,16 @@ footer {
"text": "HALLO USER,
Du möchtest also deine E-Mail ändern? Kein Problem! Mit Klick auf diesen Button
-kannst Du Deine neue E-Mail Adresse bestätigen:
+kannst du deine neue E-Mail Adresse bestätigen:
E-Mail Adresse bestätigen
[http://webapp:3000/settings/my-email-address/verify?email=user%40example.org&nonce=123456]
-Falls Du deine E-Mail Adresse doch nicht ändern möchtest, kannst du diese
+Falls du deine E-Mail Adresse doch nicht ändern möchtest, kannst du diese
Nachricht einfach ignorieren.
-Sollte der Button für Dich nicht funktionieren, kannst Du auch folgenden Code in
-Dein Browserfenster kopieren: 123456
+Sollte der Button für dich nicht funktionieren, kannst du auch folgenden Code in
+dein Browserfenster kopieren: 123456
Bis bald bei ocelot.social [https://ocelot.social]!
diff --git a/backend/src/emails/__snapshots__/sendNotificationMail.spec.ts.snap b/backend/src/emails/__snapshots__/sendNotificationMail.spec.ts.snap
index 1c4f0dc8e..05ec17e94 100644
--- a/backend/src/emails/__snapshots__/sendNotificationMail.spec.ts.snap
+++ b/backend/src/emails/__snapshots__/sendNotificationMail.spec.ts.snap
@@ -91,7 +91,7 @@ footer {
Hello Jenny Rostock,
-
your role in the group “The Group” has been changed. Click on the button to view this group:
View group
+
your role in the group “The Group” has been changed. Click on the button to view this group:
View group
See you soon on ocelot.social!
– The ocelot.social Team
@@ -110,7 +110,7 @@ footer {
your role in the group “The Group” has been changed. Click on the button to view
this group:
-View group [http://webapp:3000/group/g1/the-group]
+View group [http://webapp:3000/groups/g1/the-group]
See you soon on ocelot.social [https://ocelot.social]!
@@ -217,7 +217,7 @@ footer {
Hello Jenny Rostock,
-
Peter Lustig commented on a post that you are observing with the title “New Post”. Click on the button to view this comment:
+
Peter Lustig commented on a post that you are observing with the title “New Post”. Click on the button to view this comment:
View comment
See you soon on ocelot.social!
@@ -234,9 +234,9 @@ footer {
"subject": "ocelot.social – Notification: New comment on post",
"text": "HELLO JENNY ROSTOCK,
-Peter Lustig [http://webapp:3000/user/u2/peter-lustig] commented on a post that
-you are observing with the title “New Post”. Click on the button to view this
-comment:
+Peter Lustig [http://webapp:3000/profile/u2/peter-lustig] commented on a post
+that you are observing with the title “New Post”. Click on the button to view
+this comment:
View comment [http://webapp:3000/post/p1/new-post#commentId-c1]
@@ -473,7 +473,7 @@ footer {
Hello Jenny Rostock,
-
Peter Lustig mentioned you in a comment to the post with the title “New Post”. Click on the button to view this comment:
+
Peter Lustig mentioned you in a comment to the post with the title “New Post”. Click on the button to view this comment:
View comment
See you soon on ocelot.social!
@@ -490,7 +490,7 @@ footer {
"subject": "ocelot.social – Notification: Mentioned in comment",
"text": "HELLO JENNY ROSTOCK,
-Peter Lustig [http://webapp:3000/user/u2/peter-lustig] mentioned you in a
+Peter Lustig [http://webapp:3000/profile/u2/peter-lustig] mentioned you in a
comment to the post with the title “New Post”. Click on the button to view this
comment:
@@ -977,8 +977,8 @@ footer {
Hello Jenny Rostock,
-
Peter Lustig joined the group “The Group”. Click on the button to view this group:
-
View group
+
Peter Lustig joined the group “The Group”. Click on the button to view this group:
+
View group
See you soon on ocelot.social!
– The ocelot.social Team
@@ -994,10 +994,10 @@ footer {
"subject": "ocelot.social – Notification: User joined group",
"text": "HELLO JENNY ROSTOCK,
-Peter Lustig [http://webapp:3000/user/u2/peter-lustig] joined the group “The
+Peter Lustig [http://webapp:3000/profile/u2/peter-lustig] joined the group “The
Group”. Click on the button to view this group:
-View group [http://webapp:3000/group/g1/the-group]
+View group [http://webapp:3000/groups/g1/the-group]
See you soon on ocelot.social [https://ocelot.social]!
@@ -1104,8 +1104,8 @@ footer {
Hello Jenny Rostock,
-
Peter Lustig left the group “The Group”. Click on the button to view this group:
-
View group
+
Peter Lustig left the group “The Group”. Click on the button to view this group:
+
View group
See you soon on ocelot.social!
– The ocelot.social Team
@@ -1121,10 +1121,10 @@ footer {
"subject": "ocelot.social – Notification: User left group",
"text": "HELLO JENNY ROSTOCK,
-Peter Lustig [http://webapp:3000/user/u2/peter-lustig] left the group “The
+Peter Lustig [http://webapp:3000/profile/u2/peter-lustig] left the group “The
Group”. Click on the button to view this group:
-View group [http://webapp:3000/group/g1/the-group]
+View group [http://webapp:3000/groups/g1/the-group]
See you soon on ocelot.social [https://ocelot.social]!
@@ -1231,7 +1231,7 @@ footer {
Hallo Jenny Rostock,
-
deine Rolle in der Gruppe „The Group“ wurde geändert. Klicke auf den Knopf, um diese Gruppe zu sehen:
Gruppe ansehen
+
deine Rolle in der Gruppe „The Group“ wurde geändert. Klicke auf den Knopf, um diese Gruppe zu sehen:
Gruppe ansehen
Bis bald bei ocelot.social!
– Dein ocelot.social Team
@@ -1250,7 +1250,7 @@ footer {
deine Rolle in der Gruppe „The Group“ wurde geändert. Klicke auf den Knopf, um
diese Gruppe zu sehen:
-Gruppe ansehen [http://webapp:3000/group/g1/the-group]
+Gruppe ansehen [http://webapp:3000/groups/g1/the-group]
Bis bald bei ocelot.social [https://ocelot.social]!
@@ -1357,7 +1357,7 @@ footer {
Hallo Jenny Rostock,
-
Peter Lustig hat einen Beitrag den du beobachtest mit dem Titel „New Post“ kommentiert. Klicke auf den Knopf, um diesen Kommentar zu sehen:
+
Peter Lustig hat einen Beitrag den du beobachtest mit dem Titel „New Post“ kommentiert. Klicke auf den Knopf, um diesen Kommentar zu sehen:
Kommentar ansehen
Bis bald bei ocelot.social!
@@ -1374,8 +1374,8 @@ footer {
"subject": "ocelot.social – Benachrichtigung: Neuer Kommentar zu Beitrag",
"text": "HALLO JENNY ROSTOCK,
-Peter Lustig [http://webapp:3000/user/u2/peter-lustig] hat einen Beitrag den du
-beobachtest mit dem Titel „New Post“ kommentiert. Klicke auf den Knopf, um
+Peter Lustig [http://webapp:3000/profile/u2/peter-lustig] hat einen Beitrag den
+du beobachtest mit dem Titel „New Post“ kommentiert. Klicke auf den Knopf, um
diesen Kommentar zu sehen:
Kommentar ansehen [http://webapp:3000/post/p1/new-post#commentId-c1]
@@ -1613,7 +1613,7 @@ footer {
Hallo Jenny Rostock,
-
Peter Lustig hat dich in einem Kommentar zu dem Beitrag mit dem Titel „New Post“ erwähnt. Klicke auf den Knopf, um den Kommentar zu sehen:
+
Peter Lustig hat dich in einem Kommentar zu dem Beitrag mit dem Titel „New Post“ erwähnt. Klicke auf den Knopf, um den Kommentar zu sehen:
Kommentar ansehen
Bis bald bei ocelot.social!
@@ -1630,7 +1630,7 @@ footer {
"subject": "ocelot.social – Benachrichtigung: Erwähnung in Kommentar",
"text": "HALLO JENNY ROSTOCK,
-Peter Lustig [http://webapp:3000/user/u2/peter-lustig] hat dich in einem
+Peter Lustig [http://webapp:3000/profile/u2/peter-lustig] hat dich in einem
Kommentar zu dem Beitrag mit dem Titel „New Post“ erwähnt. Klicke auf den Knopf,
um den Kommentar zu sehen:
@@ -1741,7 +1741,7 @@ footer {
Hallo Jenny Rostock,
-
Peter Lustig hat Dich in einem Beitrag mit dem Titel „New Post“ erwähnt. Klicke auf den Knopf, um den Beitrag zu sehen:
+
Peter Lustig hat dich in einem Beitrag mit dem Titel „New Post“ erwähnt. Klicke auf den Knopf, um den Beitrag zu sehen:
Beitrag ansehen
Bis bald bei ocelot.social!
@@ -1758,7 +1758,7 @@ footer {
"subject": "ocelot.social – Benachrichtigung: Erwähnung in Beitrag",
"text": "HALLO JENNY ROSTOCK,
-Peter Lustig [http://webapp:3000/user/u2/peter-lustig] hat Dich in einem Beitrag
+Peter Lustig [http://webapp:3000/user/u2/peter-lustig] hat dich in einem Beitrag
mit dem Titel „New Post“ erwähnt. Klicke auf den Knopf, um den Beitrag zu sehen:
Beitrag ansehen [http://webapp:3000/post/p1/new-post]
@@ -2117,8 +2117,8 @@ footer {
Hallo Jenny Rostock,
-
Peter Lustig ist der Gruppe „The Group“ beigetreten. Klicke auf den Knopf, um diese Gruppe zu sehen:
-
Gruppe ansehen
+
Peter Lustig ist der Gruppe „The Group“ beigetreten. Klicke auf den Knopf, um diese Gruppe zu sehen:
+
Gruppe ansehen
Bis bald bei ocelot.social!
– Dein ocelot.social Team
@@ -2134,10 +2134,10 @@ footer {
"subject": "ocelot.social – Benachrichtigung: Nutzer tritt Gruppe bei",
"text": "HALLO JENNY ROSTOCK,
-Peter Lustig [http://webapp:3000/user/u2/peter-lustig] ist der Gruppe „The
+Peter Lustig [http://webapp:3000/profile/u2/peter-lustig] ist der Gruppe „The
Group“ beigetreten. Klicke auf den Knopf, um diese Gruppe zu sehen:
-Gruppe ansehen [http://webapp:3000/group/g1/the-group]
+Gruppe ansehen [http://webapp:3000/groups/g1/the-group]
Bis bald bei ocelot.social [https://ocelot.social]!
@@ -2244,8 +2244,8 @@ footer {
Hallo Jenny Rostock,
-
Peter Lustig hat die Gruppe „The Group“ verlassen. Klicke auf den Knopf, um diese Gruppe zu sehen:
-
Gruppe ansehen
+
Peter Lustig hat die Gruppe „The Group“ verlassen. Klicke auf den Knopf, um diese Gruppe zu sehen:
+
Gruppe ansehen
Bis bald bei ocelot.social!
– Dein ocelot.social Team
@@ -2261,10 +2261,10 @@ footer {
"subject": "ocelot.social – Benachrichtigung: Nutzer verlässt Gruppe",
"text": "HALLO JENNY ROSTOCK,
-Peter Lustig [http://webapp:3000/user/u2/peter-lustig] hat die Gruppe „The
+Peter Lustig [http://webapp:3000/profile/u2/peter-lustig] hat die Gruppe „The
Group“ verlassen. Klicke auf den Knopf, um diese Gruppe zu sehen:
-Gruppe ansehen [http://webapp:3000/group/g1/the-group]
+Gruppe ansehen [http://webapp:3000/groups/g1/the-group]
Bis bald bei ocelot.social [https://ocelot.social]!
diff --git a/backend/src/emails/__snapshots__/sendRegistrationMail.spec.ts.snap b/backend/src/emails/__snapshots__/sendRegistrationMail.spec.ts.snap
index d4a1ded8a..16f7584e5 100644
--- a/backend/src/emails/__snapshots__/sendRegistrationMail.spec.ts.snap
+++ b/backend/src/emails/__snapshots__/sendRegistrationMail.spec.ts.snap
@@ -230,12 +230,12 @@ footer {
Willkommen bei ocelot.social!
-
Danke, dass du dich angemeldet hast – wir freuen uns, dich dabei zu haben. Jetzt fehlt nur noch eine Kleinigkeit, bevor wir gemeinsam die Welt verbessern können … Bitte bestätige Deine E-Mail Adresse:
Bestätige Deine E-Mail Adresse
-
Sollte der Button für Dich nicht funktionieren, kannst Du auch folgenden Code in Dein Browserfenster kopieren: 123456
-
Das funktioniert allerdings nur, wenn du Dich über unsere Website registriert hast.
-
Falls Du Dich nicht selbst bei ocelot.social angemeldet hast, schau doch mal vorbei! Wir sind ein gemeinnütziges Aktionsnetzwerk – von Menschen für Menschen.
+
Danke, dass du dich angemeldet hast – wir freuen uns, dich dabei zu haben. Jetzt fehlt nur noch eine Kleinigkeit, bevor wir gemeinsam die Welt verbessern können … Bitte bestätige deine E-Mail Adresse:
Bestätige deine E-Mail Adresse
+
Sollte der Button für dich nicht funktionieren, kannst du auch folgenden Code in dein Browserfenster kopieren: 123456
+
Das funktioniert allerdings nur, wenn du dich über unsere Website registriert hast.
+
Falls du dich nicht selbst bei ocelot.social angemeldet hast, schau doch mal vorbei! Wir sind ein gemeinnütziges Aktionsnetzwerk – von Menschen für Menschen.
-
PS: Wenn Du keinen Account bei uns möchtest, kannst Du diese E-Mail einfach ignorieren. ;)
+
PS: Wenn du keinen Account bei uns möchtest, kannst du diese E-Mail einfach ignorieren. ;)
Bis bald bei ocelot.social!
– Dein ocelot.social Team
@@ -252,21 +252,21 @@ footer {
Danke, dass du dich angemeldet hast – wir freuen uns, dich dabei zu haben. Jetzt
fehlt nur noch eine Kleinigkeit, bevor wir gemeinsam die Welt verbessern können
-… Bitte bestätige Deine E-Mail Adresse:
+… Bitte bestätige deine E-Mail Adresse:
-Bestätige Deine E-Mail Adresse
+Bestätige deine E-Mail Adresse
[http://webapp:3000/registration?email=user%40example.org&nonce=123456&inviteCode=welcome&method=invite-code]
-Sollte der Button für Dich nicht funktionieren, kannst Du auch folgenden Code in
-Dein Browserfenster kopieren: 123456
+Sollte der Button für dich nicht funktionieren, kannst du auch folgenden Code in
+dein Browserfenster kopieren: 123456
-Das funktioniert allerdings nur, wenn du Dich über unsere Website registriert
+Das funktioniert allerdings nur, wenn du dich über unsere Website registriert
hast.
-Falls Du Dich nicht selbst bei ocelot.social angemeldet hast, schau doch mal
+Falls du dich nicht selbst bei ocelot.social angemeldet hast, schau doch mal
vorbei! Wir sind ein gemeinnütziges Aktionsnetzwerk – von Menschen für Menschen.
-PS: Wenn Du keinen Account bei uns möchtest, kannst Du diese E-Mail einfach
+PS: Wenn du keinen Account bei uns möchtest, kannst du diese E-Mail einfach
ignorieren. ;)
Bis bald bei ocelot.social [https://ocelot.social]!
@@ -509,12 +509,12 @@ footer {
Willkommen bei ocelot.social!
-
Danke, dass du dich angemeldet hast – wir freuen uns, dich dabei zu haben. Jetzt fehlt nur noch eine Kleinigkeit, bevor wir gemeinsam die Welt verbessern können … Bitte bestätige Deine E-Mail Adresse:
Bestätige Deine E-Mail Adresse
-
Sollte der Button für Dich nicht funktionieren, kannst Du auch folgenden Code in Dein Browserfenster kopieren: 123456
-
Das funktioniert allerdings nur, wenn du Dich über unsere Website registriert hast.
-
Falls Du Dich nicht selbst bei ocelot.social angemeldet hast, schau doch mal vorbei! Wir sind ein gemeinnütziges Aktionsnetzwerk – von Menschen für Menschen.
+
Danke, dass du dich angemeldet hast – wir freuen uns, dich dabei zu haben. Jetzt fehlt nur noch eine Kleinigkeit, bevor wir gemeinsam die Welt verbessern können … Bitte bestätige deine E-Mail Adresse:
Bestätige deine E-Mail Adresse
+
Sollte der Button für dich nicht funktionieren, kannst du auch folgenden Code in dein Browserfenster kopieren: 123456
+
Das funktioniert allerdings nur, wenn du dich über unsere Website registriert hast.
+
Falls du dich nicht selbst bei ocelot.social angemeldet hast, schau doch mal vorbei! Wir sind ein gemeinnütziges Aktionsnetzwerk – von Menschen für Menschen.
-
PS: Wenn Du keinen Account bei uns möchtest, kannst Du diese E-Mail einfach ignorieren. ;)
+
PS: Wenn du keinen Account bei uns möchtest, kannst du diese E-Mail einfach ignorieren. ;)
Bis bald bei ocelot.social!
– Dein ocelot.social Team
@@ -531,21 +531,21 @@ footer {
Danke, dass du dich angemeldet hast – wir freuen uns, dich dabei zu haben. Jetzt
fehlt nur noch eine Kleinigkeit, bevor wir gemeinsam die Welt verbessern können
-… Bitte bestätige Deine E-Mail Adresse:
+… Bitte bestätige deine E-Mail Adresse:
-Bestätige Deine E-Mail Adresse
+Bestätige deine E-Mail Adresse
[http://webapp:3000/registration?email=user%40example.org&nonce=123456&method=invite-mail]
-Sollte der Button für Dich nicht funktionieren, kannst Du auch folgenden Code in
-Dein Browserfenster kopieren: 123456
+Sollte der Button für dich nicht funktionieren, kannst du auch folgenden Code in
+dein Browserfenster kopieren: 123456
-Das funktioniert allerdings nur, wenn du Dich über unsere Website registriert
+Das funktioniert allerdings nur, wenn du dich über unsere Website registriert
hast.
-Falls Du Dich nicht selbst bei ocelot.social angemeldet hast, schau doch mal
+Falls du dich nicht selbst bei ocelot.social angemeldet hast, schau doch mal
vorbei! Wir sind ein gemeinnütziges Aktionsnetzwerk – von Menschen für Menschen.
-PS: Wenn Du keinen Account bei uns möchtest, kannst Du diese E-Mail einfach
+PS: Wenn du keinen Account bei uns möchtest, kannst du diese E-Mail einfach
ignorieren. ;)
Bis bald bei ocelot.social [https://ocelot.social]!
diff --git a/backend/src/emails/__snapshots__/sendResetPasswordMail.spec.ts.snap b/backend/src/emails/__snapshots__/sendResetPasswordMail.spec.ts.snap
index da62c9a34..da8c041cb 100644
--- a/backend/src/emails/__snapshots__/sendResetPasswordMail.spec.ts.snap
+++ b/backend/src/emails/__snapshots__/sendResetPasswordMail.spec.ts.snap
@@ -220,9 +220,9 @@ footer {
Hallo Jenny Rostock,
-
Du hast also dein Passwort vergessen? Kein Problem! Mit Klick auf diesen Button kannst du innerhalb der nächsten 24 Stunden dein Passwort zurücksetzen:
Bestätige Deine E-Mail Adresse
+
Du hast also dein Passwort vergessen? Kein Problem! Mit Klick auf diesen Button kannst du innerhalb der nächsten 24 Stunden dein Passwort zurücksetzen:
Bestätige deine E-Mail Adresse
Falls du kein neues Passwort angefordert hast, kannst du diese E-Mail einfach ignorieren.
-
Sollte der Button für dich nicht funktionieren, kannst du auch folgenden Code in Dein Browserfenster kopieren: 123456
+
Sollte der Button für dich nicht funktionieren, kannst du auch folgenden Code in dein Browserfenster kopieren: 123456
Bis bald bei ocelot.social!
– Dein ocelot.social Team
@@ -240,14 +240,14 @@ footer {
Du hast also dein Passwort vergessen? Kein Problem! Mit Klick auf diesen Button
kannst du innerhalb der nächsten 24 Stunden dein Passwort zurücksetzen:
-Bestätige Deine E-Mail Adresse
+Bestätige deine E-Mail Adresse
[http://webapp:3000/password-reset/change-password?email=user%40example.org&nonce=123456]
Falls du kein neues Passwort angefordert hast, kannst du diese E-Mail einfach
ignorieren.
Sollte der Button für dich nicht funktionieren, kannst du auch folgenden Code in
-Dein Browserfenster kopieren: 123456
+dein Browserfenster kopieren: 123456
Bis bald bei ocelot.social [https://ocelot.social]!
diff --git a/backend/src/emails/locales/de.json b/backend/src/emails/locales/de.json
index 9e0ce843a..677c3b7f1 100644
--- a/backend/src/emails/locales/de.json
+++ b/backend/src/emails/locales/de.json
@@ -16,20 +16,20 @@
"wrongEmail": "Falsche Mailaddresse?"
},
"registration": {
- "introduction": "Danke, dass du dich angemeldet hast – wir freuen uns, dich dabei zu haben. Jetzt fehlt nur noch eine Kleinigkeit, bevor wir gemeinsam die Welt verbessern können … Bitte bestätige Deine E-Mail Adresse:",
- "codeHint": "Sollte der Button für Dich nicht funktionieren, kannst Du auch folgenden Code in Dein Browserfenster kopieren: ",
- "codeHintException": "Das funktioniert allerdings nur, wenn du Dich über unsere Website registriert hast.",
- "notYouStart": "Falls Du Dich nicht selbst bei ",
+ "introduction": "Danke, dass du dich angemeldet hast – wir freuen uns, dich dabei zu haben. Jetzt fehlt nur noch eine Kleinigkeit, bevor wir gemeinsam die Welt verbessern können … Bitte bestätige deine E-Mail Adresse:",
+ "codeHint": "Sollte der Button für dich nicht funktionieren, kannst du auch folgenden Code in dein Browserfenster kopieren: ",
+ "codeHintException": "Das funktioniert allerdings nur, wenn du dich über unsere Website registriert hast.",
+ "notYouStart": "Falls du dich nicht selbst bei ",
"notYouEnd": " angemeldet hast, schau doch mal vorbei! Wir sind ein gemeinnütziges Aktionsnetzwerk – von Menschen für Menschen.",
- "ps": "PS: Wenn Du keinen Account bei uns möchtest, kannst Du diese E-Mail einfach ignorieren. ;)"
+ "ps": "PS: Wenn du keinen Account bei uns möchtest, kannst du diese E-Mail einfach ignorieren. ;)"
},
"emailVerification": {
- "codeHint": "Sollte der Button für Dich nicht funktionieren, kannst Du auch folgenden Code in Dein Browserfenster kopieren: ",
- "introduction": "Du möchtest also deine E-Mail ändern? Kein Problem! Mit Klick auf diesen Button kannst Du Deine neue E-Mail Adresse bestätigen:",
- "doNotChange": "Falls Du deine E-Mail Adresse doch nicht ändern möchtest, kannst du diese Nachricht einfach ignorieren. "
+ "codeHint": "Sollte der Button für dich nicht funktionieren, kannst du auch folgenden Code in dein Browserfenster kopieren: ",
+ "introduction": "Du möchtest also deine E-Mail ändern? Kein Problem! Mit Klick auf diesen Button kannst du deine neue E-Mail Adresse bestätigen:",
+ "doNotChange": "Falls du deine E-Mail Adresse doch nicht ändern möchtest, kannst du diese Nachricht einfach ignorieren. "
},
"buttons": {
- "confirmEmail": "Bestätige Deine E-Mail Adresse",
+ "confirmEmail": "Bestätige deine E-Mail Adresse",
"resetPassword": "Passwort zurücksetzen",
"tryAgain": "Versuch' es mit einer anderen E-Mail",
"verifyEmail": "E-Mail Adresse bestätigen",
@@ -47,12 +47,12 @@
"welcome": "Willkommen bei"
},
"resetPassword": {
- "codeHint": "Sollte der Button für dich nicht funktionieren, kannst du auch folgenden Code in Dein Browserfenster kopieren: ",
+ "codeHint": "Sollte der Button für dich nicht funktionieren, kannst du auch folgenden Code in dein Browserfenster kopieren: ",
"ignore": "Falls du kein neues Passwort angefordert hast, kannst du diese E-Mail einfach ignorieren.",
"introduction": "Du hast also dein Passwort vergessen? Kein Problem! Mit Klick auf diesen Button kannst du innerhalb der nächsten 24 Stunden dein Passwort zurücksetzen:"
},
"wrongEmail": {
- "codeHint": "Sollte der Button für Dich nicht funktionieren, kannst Du auch folgenden Code in Dein Browserfenster kopieren: ",
+ "codeHint": "Sollte der Button für dich nicht funktionieren, kannst du auch folgenden Code in dein Browserfenster kopieren: ",
"ignoreEnd": " hast oder dein Password gar nicht ändern willst, kannst du diese E-Mail einfach ignorieren!",
"ignoreStart": "Wenn du noch keinen Account bei ",
"introduction": "Du hast bei uns ein neues Passwort angefordert – leider haben wir aber keinen Account mit deiner E-Mailadresse gefunden. Kann es sein, dass du mit einer anderen Adresse bei uns angemeldet bist?"
@@ -63,7 +63,7 @@
"commentedOnPost": " hat einen Beitrag den du beobachtest mit dem Titel „{postTitle}“ kommentiert. Klicke auf den Knopf, um diesen Kommentar zu sehen:",
"followedUserPosted": ", ein Nutzer dem du folgst, hat einen neuen Beitrag mit dem Titel „{postTitle}“ geschrieben. Klicke auf den Knopf, um diesen Beitrag zu sehen:",
"mentionedInComment": " hat dich in einem Kommentar zu dem Beitrag mit dem Titel „{postTitle}“ erwähnt. Klicke auf den Knopf, um den Kommentar zu sehen:",
- "mentionedInPost": " hat Dich in einem Beitrag mit dem Titel „{postTitle}“ erwähnt. Klicke auf den Knopf, um den Beitrag zu sehen:",
+ "mentionedInPost": " hat dich in einem Beitrag mit dem Titel „{postTitle}“ erwähnt. Klicke auf den Knopf, um den Beitrag zu sehen:",
"postInGroup": "jemand hat einen neuen Beitrag mit dem Titel „{postTitle}“ in einer deiner Gruppen geschrieben. Klicke auf den Knopf, um diesen Beitrag zu sehen:",
"removedUserFromGroup": "du wurdest aus der Gruppe „{groupName}“ entfernt.",
"userJoinedGroup": " ist der Gruppe „{groupName}“ beigetreten. Klicke auf den Knopf, um diese Gruppe zu sehen:",
diff --git a/backend/src/emails/sendEmail.ts b/backend/src/emails/sendEmail.ts
index 580cc2f58..c8e14d74d 100644
--- a/backend/src/emails/sendEmail.ts
+++ b/backend/src/emails/sendEmail.ts
@@ -11,7 +11,7 @@ import { createTransport } from 'nodemailer'
// import type Email as EmailType from '@types/email-templates'
import CONFIG, { nodemailerTransportOptions } from '@config/index'
-import logosWebapp from '@config/logos'
+import logosWebapp from '@config/logosBranded'
import metadata from '@config/metadata'
import { UserDbProperties } from '@db/types/User'
@@ -115,7 +115,7 @@ export const sendNotificationMail = async (notification: any): Promise
{
- return gql`
- query ($id: ID!) {
- GroupMembers(id: $id) {
- id
- name
- slug
- myRoleInGroup
- }
- }
- `
-}
diff --git a/backend/src/graphql/queries/groupQuery.ts b/backend/src/graphql/queries/groupQuery.ts
deleted file mode 100644
index 463e9e13e..000000000
--- a/backend/src/graphql/queries/groupQuery.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import gql from 'graphql-tag'
-
-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
- }
- }
- `
-}
diff --git a/backend/src/graphql/queries/invalidateInviteCode.ts b/backend/src/graphql/queries/invalidateInviteCode.ts
new file mode 100644
index 000000000..1b8581be3
--- /dev/null
+++ b/backend/src/graphql/queries/invalidateInviteCode.ts
@@ -0,0 +1,36 @@
+import gql from 'graphql-tag'
+
+export const invalidateInviteCode = gql`
+ mutation invalidateInviteCode($code: String!) {
+ invalidateInviteCode(code: $code) {
+ code
+ createdAt
+ generatedBy {
+ id
+ name
+ avatar {
+ url
+ }
+ }
+ redeemedBy {
+ id
+ name
+ avatar {
+ url
+ }
+ }
+ expiresAt
+ comment
+ invitedTo {
+ id
+ groupType
+ name
+ about
+ avatar {
+ url
+ }
+ }
+ isValid
+ }
+ }
+`
diff --git a/backend/src/graphql/queries/redeemInviteCode.ts b/backend/src/graphql/queries/redeemInviteCode.ts
new file mode 100644
index 000000000..0852c564a
--- /dev/null
+++ b/backend/src/graphql/queries/redeemInviteCode.ts
@@ -0,0 +1,7 @@
+import gql from 'graphql-tag'
+
+export const redeemInviteCode = gql`
+ mutation redeemInviteCode($code: String!) {
+ redeemInviteCode(code: $code)
+ }
+`
diff --git a/backend/src/graphql/queries/validateInviteCode.ts b/backend/src/graphql/queries/validateInviteCode.ts
new file mode 100644
index 000000000..bcae09254
--- /dev/null
+++ b/backend/src/graphql/queries/validateInviteCode.ts
@@ -0,0 +1,49 @@
+import gql from 'graphql-tag'
+
+export const unauthenticatedValidateInviteCode = gql`
+ query validateInviteCode($code: String!) {
+ validateInviteCode(code: $code) {
+ code
+ invitedTo {
+ groupType
+ name
+ about
+ avatar {
+ url
+ }
+ }
+ generatedBy {
+ name
+ avatar {
+ url
+ }
+ }
+ isValid
+ }
+ }
+`
+
+export const authenticatedValidateInviteCode = gql`
+ query validateInviteCode($code: String!) {
+ validateInviteCode(code: $code) {
+ code
+ invitedTo {
+ id
+ groupType
+ name
+ about
+ avatar {
+ url
+ }
+ }
+ generatedBy {
+ id
+ name
+ avatar {
+ url
+ }
+ }
+ isValid
+ }
+ }
+`
diff --git a/backend/src/graphql/resolvers/badges.spec.ts b/backend/src/graphql/resolvers/badges.spec.ts
index e6b5173a9..dd0cf4730 100644
--- a/backend/src/graphql/resolvers/badges.spec.ts
+++ b/backend/src/graphql/resolvers/badges.spec.ts
@@ -1,41 +1,43 @@
-/* eslint-disable @typescript-eslint/require-await */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
+import { ApolloServer } from 'apollo-server-express'
import { createTestClient } from 'apollo-server-testing'
import gql from 'graphql-tag'
+import databaseContext from '@context/database'
import Factory, { cleanDatabase } from '@db/factories'
-import { getNeode, getDriver } from '@db/neo4j'
-import createServer from '@src/server'
+import createServer, { getContext } from '@src/server'
-const driver = getDriver()
-const instance = getNeode()
+let regularUser, administrator, moderator, badge, verification
-let authenticatedUser, regularUser, administrator, moderator, badge, verification, query, mutate
+const database = databaseContext()
+
+let server: ApolloServer
+let authenticatedUser
+let query, mutate
+
+beforeAll(async () => {
+ await cleanDatabase()
+
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/require-await
+ const contextUser = async (_req) => authenticatedUser
+ const context = getContext({ user: contextUser, database })
+
+ server = createServer({ context }).server
+
+ const createTestClientResult = createTestClient(server)
+ query = createTestClientResult.query
+ mutate = createTestClientResult.mutate
+})
+
+afterAll(() => {
+ void server.stop()
+ void database.driver.close()
+ database.neode.close()
+})
describe('Badges', () => {
- beforeAll(async () => {
- await cleanDatabase()
-
- const { server } = createServer({
- context: () => {
- return {
- driver,
- neode: instance,
- user: authenticatedUser,
- }
- },
- })
- query = createTestClient(server).query
- mutate = createTestClient(server).mutate
- })
-
- afterAll(async () => {
- await cleanDatabase()
- await driver.close()
- })
-
beforeEach(async () => {
regularUser = await Factory.build(
'user',
@@ -83,7 +85,6 @@ describe('Badges', () => {
})
})
- // 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()
})
@@ -122,7 +123,7 @@ describe('Badges', () => {
})
describe('authenticated as moderator', () => {
- beforeEach(async () => {
+ beforeEach(() => {
authenticatedUser = moderator.toJson()
})
@@ -322,7 +323,7 @@ describe('Badges', () => {
})
describe('authenticated as moderator', () => {
- beforeEach(async () => {
+ beforeEach(() => {
authenticatedUser = moderator.toJson()
})
diff --git a/backend/src/graphql/resolvers/groups.spec.ts b/backend/src/graphql/resolvers/groups.spec.ts
index 545865c20..333bc03c1 100644
--- a/backend/src/graphql/resolvers/groups.spec.ts
+++ b/backend/src/graphql/resolvers/groups.spec.ts
@@ -10,8 +10,8 @@ import databaseContext from '@context/database'
import Factory, { cleanDatabase } from '@db/factories'
import { changeGroupMemberRoleMutation } from '@graphql/queries/changeGroupMemberRoleMutation'
import { createGroupMutation } from '@graphql/queries/createGroupMutation'
-import { groupMembersQuery } from '@graphql/queries/groupMembersQuery'
-import { groupQuery } from '@graphql/queries/groupQuery'
+import { Group as groupQuery } from '@graphql/queries/Group'
+import { GroupMembers as groupMembersQuery } from '@graphql/queries/GroupMembers'
import { joinGroupMutation } from '@graphql/queries/joinGroupMutation'
import { leaveGroupMutation } from '@graphql/queries/leaveGroupMutation'
import { removeUserFromGroupMutation } from '@graphql/queries/removeUserFromGroupMutation'
@@ -423,7 +423,7 @@ describe('in mode', () => {
describe('unauthenticated', () => {
it('throws authorization error', async () => {
- const { errors } = await query({ query: groupQuery(), variables: {} })
+ const { errors } = await query({ query: groupQuery, variables: {} })
expect(errors![0]).toHaveProperty('message', 'Not Authorized!')
})
})
@@ -541,7 +541,7 @@ describe('in mode', () => {
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: {} })
+ const result = await query({ query: groupQuery, variables: {} })
expect(result).toMatchObject({
data: {
Group: expect.arrayContaining([
@@ -586,9 +586,7 @@ describe('in mode', () => {
})
it('has set categories', async () => {
- await expect(
- query({ query: groupQuery(), variables: {} }),
- ).resolves.toMatchObject({
+ await expect(query({ query: groupQuery, variables: {} })).resolves.toMatchObject({
data: {
Group: expect.arrayContaining([
expect.objectContaining({
@@ -622,7 +620,7 @@ describe('in mode', () => {
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' } })
+ const result = await query({ query: groupQuery, variables: { id: 'my-group' } })
expect(result).toMatchObject({
data: {
Group: [
@@ -642,7 +640,7 @@ describe('in mode', () => {
describe("id = 'third-hidden-group'", () => {
it("finds only the hidden group where I'm 'usual' member", async () => {
const result = await query({
- query: groupQuery(),
+ query: groupQuery,
variables: { id: 'third-hidden-group' },
})
expect(result).toMatchObject({
@@ -664,7 +662,7 @@ describe('in mode', () => {
describe("id = 'second-hidden-group'", () => {
it("finds no hidden group where I'm 'pending' member", async () => {
const result = await query({
- query: groupQuery(),
+ query: groupQuery,
variables: { id: 'second-hidden-group' },
})
expect(result.data?.Group.length).toBe(0)
@@ -674,7 +672,7 @@ describe('in mode', () => {
describe("id = 'hidden-group'", () => {
it("finds no hidden group where I'm not(!) a member at all", async () => {
const result = await query({
- query: groupQuery(),
+ query: groupQuery,
variables: { id: 'hidden-group' },
})
expect(result.data?.Group.length).toBe(0)
@@ -686,7 +684,7 @@ describe('in mode', () => {
describe("slug = 'the-best-group'", () => {
it('finds only the listed group with this slug', async () => {
const result = await query({
- query: groupQuery(),
+ query: groupQuery,
variables: { slug: 'the-best-group' },
})
expect(result).toMatchObject({
@@ -708,7 +706,7 @@ describe('in mode', () => {
describe("slug = 'third-investigative-journalism-group'", () => {
it("finds only the hidden group where I'm 'usual' member", async () => {
const result = await query({
- query: groupQuery(),
+ query: groupQuery,
variables: { slug: 'third-investigative-journalism-group' },
})
expect(result).toMatchObject({
@@ -730,7 +728,7 @@ describe('in mode', () => {
describe("slug = 'second-investigative-journalism-group'", () => {
it("finds no hidden group where I'm 'pending' member", async () => {
const result = await query({
- query: groupQuery(),
+ query: groupQuery,
variables: { slug: 'second-investigative-journalism-group' },
})
expect(result.data?.Group.length).toBe(0)
@@ -740,7 +738,7 @@ describe('in mode', () => {
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(),
+ query: groupQuery,
variables: { slug: 'investigative-journalism-group' },
})
expect(result.data?.Group.length).toBe(0)
@@ -750,7 +748,7 @@ describe('in mode', () => {
describe('isMember = true', () => {
it('finds only listed groups where user is member', async () => {
- const result = await query({ query: groupQuery(), variables: { isMember: true } })
+ const result = await query({ query: groupQuery, variables: { isMember: true } })
expect(result).toMatchObject({
data: {
Group: expect.arrayContaining([
@@ -774,7 +772,7 @@ describe('in mode', () => {
describe('isMember = false', () => {
it('finds only listed groups where user is not(!) member', async () => {
- const result = await query({ query: groupQuery(), variables: { isMember: false } })
+ const result = await query({ query: groupQuery, variables: { isMember: false } })
expect(result).toMatchObject({
data: {
Group: expect.arrayContaining([
@@ -1039,7 +1037,7 @@ describe('in mode', () => {
variables = {
id: 'not-existing-group',
}
- const { errors } = await query({ query: groupMembersQuery(), variables })
+ const { errors } = await query({ query: groupMembersQuery, variables })
expect(errors![0]).toHaveProperty('message', 'Not Authorized!')
})
})
@@ -1212,7 +1210,7 @@ describe('in mode', () => {
it('finds all members', async () => {
const result = await query({
- query: groupMembersQuery(),
+ query: groupMembersQuery,
variables,
})
expect(result).toMatchObject({
@@ -1245,7 +1243,7 @@ describe('in mode', () => {
it('finds all members', async () => {
const result = await query({
- query: groupMembersQuery(),
+ query: groupMembersQuery,
variables,
})
expect(result).toMatchObject({
@@ -1278,7 +1276,7 @@ describe('in mode', () => {
it('finds all members', async () => {
const result = await query({
- query: groupMembersQuery(),
+ query: groupMembersQuery,
variables,
})
expect(result).toMatchObject({
@@ -1321,7 +1319,7 @@ describe('in mode', () => {
it('finds all members', async () => {
const result = await query({
- query: groupMembersQuery(),
+ query: groupMembersQuery,
variables,
})
expect(result).toMatchObject({
@@ -1354,7 +1352,7 @@ describe('in mode', () => {
it('finds all members', async () => {
const result = await query({
- query: groupMembersQuery(),
+ query: groupMembersQuery,
variables,
})
expect(result).toMatchObject({
@@ -1386,7 +1384,7 @@ describe('in mode', () => {
})
it('throws authorization error', async () => {
- const { errors } = await query({ query: groupMembersQuery(), variables })
+ const { errors } = await query({ query: groupMembersQuery, variables })
expect(errors![0]).toHaveProperty('message', 'Not Authorized!')
})
})
@@ -1397,7 +1395,7 @@ describe('in mode', () => {
})
it('throws authorization error', async () => {
- const { errors } = await query({ query: groupMembersQuery(), variables })
+ const { errors } = await query({ query: groupMembersQuery, variables })
expect(errors![0]).toHaveProperty('message', 'Not Authorized!')
})
})
@@ -1419,7 +1417,7 @@ describe('in mode', () => {
it('finds all members', async () => {
const result = await query({
- query: groupMembersQuery(),
+ query: groupMembersQuery,
variables,
})
expect(result).toMatchObject({
@@ -1456,7 +1454,7 @@ describe('in mode', () => {
it('finds all members', async () => {
const result = await query({
- query: groupMembersQuery(),
+ query: groupMembersQuery,
variables,
})
expect(result).toMatchObject({
@@ -1493,7 +1491,7 @@ describe('in mode', () => {
it('finds all members', async () => {
const result = await query({
- query: groupMembersQuery(),
+ query: groupMembersQuery,
variables,
})
expect(result).toMatchObject({
@@ -1529,7 +1527,7 @@ describe('in mode', () => {
})
it('throws authorization error', async () => {
- const { errors } = await query({ query: groupMembersQuery(), variables })
+ const { errors } = await query({ query: groupMembersQuery, variables })
expect(errors![0]).toHaveProperty('message', 'Not Authorized!')
})
})
@@ -1540,7 +1538,7 @@ describe('in mode', () => {
})
it('throws authorization error', async () => {
- const { errors } = await query({ query: groupMembersQuery(), variables })
+ const { errors } = await query({ query: groupMembersQuery, variables })
expect(errors![0]).toHaveProperty('message', 'Not Authorized!')
})
})
@@ -2418,7 +2416,7 @@ describe('in mode', () => {
describe('here "closed-group" for example', () => {
const memberInGroup = async (userId, groupId) => {
const result = await query({
- query: groupMembersQuery(),
+ query: groupMembersQuery,
variables: {
id: groupId,
},
diff --git a/backend/src/graphql/resolvers/groups.ts b/backend/src/graphql/resolvers/groups.ts
index 8e24117e1..a3ce3285a 100644
--- a/backend/src/graphql/resolvers/groups.ts
+++ b/backend/src/graphql/resolvers/groups.ts
@@ -436,6 +436,24 @@ export default {
},
},
Group: {
+ inviteCodes: async (parent, _args, context: Context, _resolveInfo) => {
+ if (!parent.id) {
+ throw new Error('Can not identify selected Group!')
+ }
+ return (
+ await context.database.query({
+ query: `
+ MATCH (user:User {id: $user.id})-[:GENERATED]->(inviteCodes:InviteCode)-[:INVITES_TO]->(g:Group {id: $parent.id})
+ RETURN inviteCodes {.*}
+ ORDER BY inviteCodes.createdAt ASC
+ `,
+ variables: {
+ user: context.user,
+ parent,
+ },
+ })
+ ).records.map((r) => r.get('inviteCodes'))
+ },
...Resolver('Group', {
undefinedToNull: ['deleted', 'disabled', 'locationName', 'about'],
hasMany: {
@@ -451,6 +469,18 @@ export default {
'MATCH (this) RETURN EXISTS( (this)<-[:MUTED]-(:User {id: $cypherParams.currentUserId}) )',
},
}),
+ name: async (parent, _args, context: Context, _resolveInfo) => {
+ if (!context.user) {
+ return parent.groupType === 'hidden' ? '' : parent.name
+ }
+ return parent.name
+ },
+ about: async (parent, _args, context: Context, _resolveInfo) => {
+ if (!context.user) {
+ return parent.groupType === 'hidden' ? '' : parent.about
+ }
+ return parent.about
+ },
},
}
diff --git a/backend/src/graphql/resolvers/helpers/generateInviteCode.ts b/backend/src/graphql/resolvers/helpers/generateInviteCode.ts
deleted file mode 100644
index 980af4593..000000000
--- a/backend/src/graphql/resolvers/helpers/generateInviteCode.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import registrationConstants from '@constants/registrationBranded'
-
-export default function generateInviteCode() {
- // 6 random numbers in [ 0, 35 ] are 36 possible numbers (10 [0-9] + 26 [A-Z])
- return Array.from(
- { length: registrationConstants.INVITE_CODE_LENGTH },
- (n: number = Math.floor(Math.random() * 36)) => {
- // n > 9: it is a letter (ASCII 65 is A) -> 10 + 55 = 65
- // else: it is a number (ASCII 48 is 0) -> 0 + 48 = 48
- return String.fromCharCode(n > 9 ? n + 55 : n + 48)
- },
- ).join('')
-}
diff --git a/backend/src/graphql/resolvers/inviteCodes.spec.ts b/backend/src/graphql/resolvers/inviteCodes.spec.ts
index f44721cc9..d38788087 100644
--- a/backend/src/graphql/resolvers/inviteCodes.spec.ts
+++ b/backend/src/graphql/resolvers/inviteCodes.spec.ts
@@ -1,214 +1,1198 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
-/* eslint-disable security/detect-non-literal-regexp */
+import { ApolloServer } from 'apollo-server-express'
import { createTestClient } from 'apollo-server-testing'
-import gql from 'graphql-tag'
-import registrationConstants from '@constants/registrationBranded'
+import CONFIG from '@config/index'
+import databaseContext from '@context/database'
import Factory, { cleanDatabase } from '@db/factories'
-import { getDriver } from '@db/neo4j'
-import createServer from '@src/server'
+import { createGroupMutation } from '@graphql/queries/createGroupMutation'
+import { currentUser } from '@graphql/queries/currentUser'
+import { generateGroupInviteCode } from '@graphql/queries/generateGroupInviteCode'
+import { generatePersonalInviteCode } from '@graphql/queries/generatePersonalInviteCode'
+import { Group } from '@graphql/queries/Group'
+import { GroupMembers } from '@graphql/queries/GroupMembers'
+import { invalidateInviteCode } from '@graphql/queries/invalidateInviteCode'
+import { joinGroupMutation } from '@graphql/queries/joinGroupMutation'
+import { redeemInviteCode } from '@graphql/queries/redeemInviteCode'
+import {
+ authenticatedValidateInviteCode,
+ unauthenticatedValidateInviteCode,
+} from '@graphql/queries/validateInviteCode'
+import createServer, { getContext } from '@src/server'
-let user
-let query
-let mutate
+const database = databaseContext()
-const driver = getDriver()
-
-const generateInviteCodeMutation = gql`
- mutation ($expiresAt: String = null) {
- GenerateInviteCode(expiresAt: $expiresAt) {
- code
- createdAt
- expiresAt
- }
- }
-`
-const myInviteCodesQuery = gql`
- query {
- MyInviteCodes {
- code
- createdAt
- expiresAt
- }
- }
-`
-const isValidInviteCodeQuery = gql`
- query ($code: ID!) {
- isValidInviteCode(code: $code)
- }
-`
+let server: ApolloServer
+let authenticatedUser
+let query, mutate
beforeAll(async () => {
await cleanDatabase()
- const { server } = createServer({
- context: () => {
- return {
- driver,
- user,
- }
- },
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/require-await
+ const contextUser = async (_req) => authenticatedUser
+ const context = getContext({ user: contextUser, database })
+
+ server = createServer({ context }).server
+
+ const createTestClientResult = createTestClient(server)
+ query = createTestClientResult.query
+ mutate = createTestClientResult.mutate
+})
+
+afterAll(() => {
+ void server.stop()
+ void database.driver.close()
+ database.neode.close()
+})
+
+describe('validateInviteCode', () => {
+ let invitingUser, user
+ beforeEach(async () => {
+ await cleanDatabase()
+ invitingUser = await Factory.build('user', {
+ id: 'inviting-user',
+ role: 'user',
+ name: 'Inviting User',
+ })
+ user = await Factory.build('user', {
+ id: 'normal-user',
+ role: 'user',
+ name: 'Normal User',
+ })
+
+ authenticatedUser = await invitingUser.toJson()
+ await mutate({
+ mutation: createGroupMutation(),
+ variables: {
+ id: 'hidden-group',
+ name: 'Hidden Group',
+ about: 'We are hidden',
+ description: 'anything',
+ groupType: 'hidden',
+ actionRadius: 'global',
+ categoryIds: ['cat6', 'cat12', 'cat16'],
+ locationName: 'Hamburg, Germany',
+ },
+ })
+
+ await mutate({
+ mutation: createGroupMutation(),
+ variables: {
+ id: 'public-group',
+ name: 'Public Group',
+ about: 'We are public',
+ description: 'anything',
+ groupType: 'public',
+ actionRadius: 'interplanetary',
+ categoryIds: ['cat4', 'cat5', 'cat17'],
+ },
+ })
+
+ await Factory.build(
+ 'inviteCode',
+ {
+ code: 'EXPIRD',
+ expiresAt: new Date(1970, 1).toISOString(),
+ },
+ {
+ generatedBy: invitingUser,
+ },
+ )
+ await Factory.build(
+ 'inviteCode',
+ {
+ code: 'PERSNL',
+ },
+ {
+ generatedBy: invitingUser,
+ },
+ )
+ await Factory.build(
+ 'inviteCode',
+ {
+ code: 'GRPPBL',
+ },
+ {
+ generatedBy: invitingUser,
+ groupId: 'public-group',
+ },
+ )
+ await Factory.build(
+ 'inviteCode',
+ {
+ code: 'GRPHDN',
+ },
+ {
+ generatedBy: invitingUser,
+ groupId: 'hidden-group',
+ },
+ )
})
- query = createTestClient(server).query
- mutate = createTestClient(server).mutate
-})
-
-afterAll(async () => {
- await cleanDatabase()
- await driver.close()
-})
-
-describe('inviteCodes', () => {
describe('as unauthenticated user', () => {
- it('cannot generate invite codes', async () => {
- await expect(mutate({ mutation: generateInviteCodeMutation })).resolves.toEqual(
+ beforeEach(() => {
+ authenticatedUser = null
+ })
+
+ it('returns null when the code does not exist', async () => {
+ await expect(
+ query({ query: unauthenticatedValidateInviteCode, variables: { code: 'INVALD' } }),
+ ).resolves.toEqual(
expect.objectContaining({
- errors: expect.arrayContaining([
- expect.objectContaining({
- extensions: { code: 'INTERNAL_SERVER_ERROR' },
- }),
- ]),
data: {
- GenerateInviteCode: null,
+ validateInviteCode: null,
},
+ errors: undefined,
}),
)
})
- it('cannot query invite codes', async () => {
- await expect(query({ query: myInviteCodesQuery })).resolves.toEqual(
+ it('returns null when the code has expired', async () => {
+ await expect(
+ query({ query: unauthenticatedValidateInviteCode, variables: { code: 'EXPIRD' } }),
+ ).resolves.toEqual(
expect.objectContaining({
- errors: expect.arrayContaining([
- expect.objectContaining({
- extensions: { code: 'INTERNAL_SERVER_ERROR' },
- }),
- ]),
data: {
- MyInviteCodes: null,
+ validateInviteCode: null,
},
+ errors: undefined,
}),
)
})
+
+ it('returns the inviteCode when the code exists and hs not expired', async () => {
+ await expect(
+ query({ query: unauthenticatedValidateInviteCode, variables: { code: 'PERSNL' } }),
+ ).resolves.toEqual(
+ expect.objectContaining({
+ data: {
+ validateInviteCode: {
+ code: 'PERSNL',
+ generatedBy: {
+ avatar: {
+ url: expect.any(String),
+ },
+ name: 'Inviting User',
+ },
+ invitedTo: null,
+ isValid: true,
+ },
+ },
+ errors: undefined,
+ }),
+ )
+ })
+
+ it('returns the inviteCode with group details if the code invites to a public group', async () => {
+ await expect(
+ query({ query: unauthenticatedValidateInviteCode, variables: { code: 'GRPPBL' } }),
+ ).resolves.toEqual(
+ expect.objectContaining({
+ data: {
+ validateInviteCode: {
+ code: 'GRPPBL',
+ generatedBy: {
+ avatar: {
+ url: expect.any(String),
+ },
+ name: 'Inviting User',
+ },
+ invitedTo: {
+ groupType: 'public',
+ name: 'Public Group',
+ about: 'We are public',
+ avatar: null,
+ },
+ isValid: true,
+ },
+ },
+ errors: undefined,
+ }),
+ )
+ })
+
+ it('returns the inviteCode with redacted group details if the code invites to a hidden group', async () => {
+ await expect(
+ query({ query: unauthenticatedValidateInviteCode, variables: { code: 'GRPHDN' } }),
+ ).resolves.toEqual(
+ expect.objectContaining({
+ data: {
+ validateInviteCode: {
+ code: 'GRPHDN',
+ generatedBy: {
+ avatar: {
+ url: expect.any(String),
+ },
+ name: 'Inviting User',
+ },
+ invitedTo: {
+ groupType: 'hidden',
+ name: '',
+ about: '',
+ avatar: null,
+ },
+ isValid: true,
+ },
+ },
+ errors: undefined,
+ }),
+ )
+ })
+
+ it('throws authorization error when querying extended fields', async () => {
+ await expect(
+ query({ query: authenticatedValidateInviteCode, variables: { code: 'PERSNL' } }),
+ ).resolves.toMatchObject({
+ data: {
+ validateInviteCode: {
+ code: 'PERSNL',
+ generatedBy: null,
+ invitedTo: null,
+ isValid: true,
+ },
+ },
+ errors: [{ message: 'Not Authorized!' }],
+ })
+ })
})
describe('as authenticated user', () => {
beforeAll(async () => {
- const authenticatedUser = await Factory.build(
- 'user',
- {
- role: 'user',
- },
- {
- email: 'user@example.org',
- password: '1234',
- },
- )
- user = await authenticatedUser.toJson()
+ authenticatedUser = await user.toJson()
})
- it('generates an invite code without expiresAt', async () => {
- await expect(mutate({ mutation: generateInviteCodeMutation })).resolves.toEqual(
- expect.objectContaining({
- errors: undefined,
- data: {
- GenerateInviteCode: {
- code: expect.stringMatching(
- new RegExp(
- `^[0-9A-Z]{${registrationConstants.INVITE_CODE_LENGTH},${registrationConstants.INVITE_CODE_LENGTH}}$`,
- ),
- ),
- expiresAt: null,
- createdAt: expect.any(String),
+ it('throws no authorization error when querying extended fields', async () => {
+ await expect(
+ query({ query: authenticatedValidateInviteCode, variables: { code: 'PERSNL' } }),
+ ).resolves.toMatchObject({
+ data: {
+ validateInviteCode: {
+ code: 'PERSNL',
+ generatedBy: {
+ id: 'inviting-user',
+ name: 'Inviting User',
+ avatar: {
+ url: expect.any(String),
+ },
},
+ invitedTo: null,
+ isValid: true,
},
- }),
- )
+ },
+ errors: undefined,
+ })
})
- it('generates an invite code with expiresAt', async () => {
- const nextWeek = new Date()
- nextWeek.setDate(nextWeek.getDate() + 7)
+ it('throws no authorization error when querying extended public group fields', async () => {
+ await expect(
+ query({ query: authenticatedValidateInviteCode, variables: { code: 'GRPPBL' } }),
+ ).resolves.toMatchObject({
+ data: {
+ validateInviteCode: {
+ code: 'GRPPBL',
+ generatedBy: {
+ id: 'inviting-user',
+ name: 'Inviting User',
+ avatar: {
+ url: expect.any(String),
+ },
+ },
+ invitedTo: {
+ id: 'public-group',
+ groupType: 'public',
+ name: 'Public Group',
+ about: 'We are public',
+ avatar: null,
+ },
+ isValid: true,
+ },
+ },
+ errors: undefined,
+ })
+ })
+
+ // This doesn't work because group permissions are fucked
+ // eslint-disable-next-line jest/no-disabled-tests
+ it.skip('throws authorization error when querying extended hidden group fields', async () => {
+ await expect(
+ query({ query: authenticatedValidateInviteCode, variables: { code: 'GRPHDN' } }),
+ ).resolves.toMatchObject({
+ data: {
+ validateInviteCode: {
+ code: 'GRPHDN',
+ generatedBy: null,
+ invitedTo: null,
+ isValid: true,
+ },
+ },
+ errors: [{ message: 'Not Authorized!' }],
+ })
+ })
+
+ // eslint-disable-next-line jest/no-disabled-tests, @typescript-eslint/no-empty-function
+ it.skip('throws no authorization error when querying extended hidden group fields as member', async () => {})
+ })
+})
+
+describe('generatePersonalInviteCode', () => {
+ let invitingUser
+ beforeEach(async () => {
+ await cleanDatabase()
+ invitingUser = await Factory.build('user', {
+ id: 'inviting-user',
+ role: 'user',
+ name: 'Inviting User',
+ })
+ })
+ describe('as unauthenticated user', () => {
+ beforeEach(() => {
+ authenticatedUser = null
+ })
+
+ it('throws authorization error', async () => {
+ await expect(mutate({ mutation: generatePersonalInviteCode })).resolves.toMatchObject({
+ data: null,
+ errors: [{ message: 'Not Authorized!' }],
+ })
+ })
+ })
+
+ describe('as authenticated user', () => {
+ beforeEach(async () => {
+ authenticatedUser = await invitingUser.toJson()
+ })
+
+ it('returns a new invite code', async () => {
+ await expect(mutate({ mutation: generatePersonalInviteCode })).resolves.toMatchObject({
+ data: {
+ generatePersonalInviteCode: {
+ code: expect.any(String),
+ comment: null,
+ createdAt: expect.any(String),
+ expiresAt: null,
+ generatedBy: {
+ avatar: {
+ url: expect.any(String),
+ },
+ id: 'inviting-user',
+ name: 'Inviting User',
+ },
+ invitedTo: null,
+ isValid: true,
+ redeemedBy: [],
+ },
+ },
+ errors: undefined,
+ })
+ })
+
+ it('returns a new invite code with comment', async () => {
+ await expect(
+ mutate({ mutation: generatePersonalInviteCode, variables: { comment: 'some text' } }),
+ ).resolves.toMatchObject({
+ data: {
+ generatePersonalInviteCode: {
+ code: expect.any(String),
+ comment: 'some text',
+ createdAt: expect.any(String),
+ expiresAt: null,
+ generatedBy: {
+ avatar: {
+ url: expect.any(String),
+ },
+ id: 'inviting-user',
+ name: 'Inviting User',
+ },
+ invitedTo: null,
+ isValid: true,
+ redeemedBy: [],
+ },
+ },
+ errors: undefined,
+ })
+ })
+
+ it('returns a new invite code with expireDate', async () => {
+ const date = new Date()
+ date.setFullYear(date.getFullYear() + 1)
await expect(
mutate({
- mutation: generateInviteCodeMutation,
- variables: { expiresAt: nextWeek.toISOString() },
+ mutation: generatePersonalInviteCode,
+ variables: { expiresAt: date.toISOString() },
}),
- ).resolves.toEqual(
- expect.objectContaining({
- errors: undefined,
- data: {
- GenerateInviteCode: {
- code: expect.stringMatching(
- new RegExp(
- `^[0-9A-Z]{${registrationConstants.INVITE_CODE_LENGTH},${registrationConstants.INVITE_CODE_LENGTH}}$`,
- ),
- ),
- expiresAt: nextWeek.toISOString(),
- createdAt: expect.any(String),
+ ).resolves.toMatchObject({
+ data: {
+ generatePersonalInviteCode: {
+ code: expect.any(String),
+ comment: null,
+ createdAt: expect.any(String),
+ expiresAt: date.toISOString(),
+ generatedBy: {
+ avatar: {
+ url: expect.any(String),
+ },
+ id: 'inviting-user',
+ name: 'Inviting User',
},
+ invitedTo: null,
+ isValid: true,
+ redeemedBy: [],
},
+ },
+ errors: undefined,
+ })
+ })
+
+ it('returns a new invalid invite code with expireDate in the past', async () => {
+ const date = new Date()
+ date.setFullYear(date.getFullYear() - 1)
+ await expect(
+ mutate({
+ mutation: generatePersonalInviteCode,
+ variables: { expiresAt: date.toISOString() },
}),
- )
- })
-
- let inviteCodes
-
- it('returns the created invite codes when queried', async () => {
- const response = await query({ query: myInviteCodesQuery })
- inviteCodes = response.data.MyInviteCodes
- expect(inviteCodes).toHaveLength(2)
- })
-
- it('does not return the created invite codes of other users when queried', async () => {
- await Factory.build('inviteCode')
- const response = await query({ query: myInviteCodesQuery })
- inviteCodes = response.data.MyInviteCodes
- expect(inviteCodes).toHaveLength(2)
- })
-
- it('validates an invite code without expiresAt', async () => {
- const unExpiringInviteCode = inviteCodes.filter((ic) => ic.expiresAt === null)[0].code
- const result = await query({
- query: isValidInviteCodeQuery,
- variables: { code: unExpiringInviteCode },
+ ).resolves.toMatchObject({
+ data: {
+ generatePersonalInviteCode: {
+ code: expect.any(String),
+ comment: null,
+ createdAt: expect.any(String),
+ expiresAt: date.toISOString(),
+ generatedBy: {
+ avatar: {
+ url: expect.any(String),
+ },
+ id: 'inviting-user',
+ name: 'Inviting User',
+ },
+ invitedTo: null,
+ isValid: false,
+ redeemedBy: [],
+ },
+ },
+ errors: undefined,
})
- expect(result.data.isValidInviteCode).toBeTruthy()
})
- it('validates an invite code in lower case', async () => {
- const unExpiringInviteCode = inviteCodes.filter((ic) => ic.expiresAt === null)[0].code
- const result = await query({
- query: isValidInviteCodeQuery,
- variables: { code: unExpiringInviteCode.toLowerCase() },
+ it('throws an error when the max amount of invite links was reached', async () => {
+ let lastCode
+ for (let i = 0; i < CONFIG.INVITE_CODES_PERSONAL_PER_USER; i++) {
+ lastCode = await mutate({ mutation: generatePersonalInviteCode })
+ expect(lastCode).toMatchObject({
+ errors: undefined,
+ })
+ }
+ await expect(mutate({ mutation: generatePersonalInviteCode })).resolves.toMatchObject({
+ errors: [
+ {
+ message: 'You have reached the maximum of Invite Codes you can generate',
+ },
+ ],
})
- expect(result.data.isValidInviteCode).toBeTruthy()
- })
-
- it('validates an invite code with expiresAt in the future', async () => {
- const expiringInviteCode = inviteCodes.filter((ic) => ic.expiresAt !== null)[0].code
- const result = await query({
- query: isValidInviteCodeQuery,
- variables: { code: expiringInviteCode },
+ await mutate({
+ mutation: invalidateInviteCode,
+ variables: { code: lastCode.data.generatePersonalInviteCode.code },
})
- expect(result.data.isValidInviteCode).toBeTruthy()
- })
-
- it('does not validate an invite code which expired in the past', async () => {
- const lastWeek = new Date()
- lastWeek.setDate(lastWeek.getDate() - 7)
- const inviteCode = await Factory.build('inviteCode', {
- expiresAt: lastWeek.toISOString(),
+ await expect(mutate({ mutation: generatePersonalInviteCode })).resolves.toMatchObject({
+ errors: undefined,
})
- const code = inviteCode.get('code')
- const result = await query({ query: isValidInviteCodeQuery, variables: { code } })
- expect(result.data.isValidInviteCode).toBeFalsy()
})
- it('does not validate an invite code which does not exits', async () => {
- const result = await query({ query: isValidInviteCodeQuery, variables: { code: 'AAA' } })
- expect(result.data.isValidInviteCode).toBeFalsy()
+ // eslint-disable-next-line jest/no-disabled-tests, @typescript-eslint/no-empty-function
+ it.skip('returns a new invite code when colliding with an existing one', () => {})
+ })
+})
+
+describe('generateGroupInviteCode', () => {
+ let invitingUser, notMemberUser, pendingMemberUser
+ beforeEach(async () => {
+ await cleanDatabase()
+ invitingUser = await Factory.build('user', {
+ id: 'inviting-user',
+ role: 'user',
+ name: 'Inviting User',
+ })
+
+ notMemberUser = await Factory.build('user', {
+ id: 'not-member-user',
+ role: 'user',
+ name: 'Not a Member User',
+ })
+
+ pendingMemberUser = await Factory.build('user', {
+ id: 'pending-member-user',
+ role: 'user',
+ name: 'Pending Member User',
+ })
+
+ authenticatedUser = await invitingUser.toJson()
+ await mutate({
+ mutation: createGroupMutation(),
+ variables: {
+ id: 'hidden-group',
+ name: 'Hidden Group',
+ about: 'We are hidden',
+ description: 'anything',
+ groupType: 'hidden',
+ actionRadius: 'global',
+ categoryIds: ['cat6', 'cat12', 'cat16'],
+ locationName: 'Hamburg, Germany',
+ },
+ })
+
+ await mutate({
+ mutation: createGroupMutation(),
+ variables: {
+ id: 'public-group',
+ name: 'Public Group',
+ about: 'We are public',
+ description: 'anything',
+ groupType: 'public',
+ actionRadius: 'interplanetary',
+ categoryIds: ['cat4', 'cat5', 'cat17'],
+ },
+ })
+
+ await mutate({
+ mutation: createGroupMutation(),
+ variables: {
+ id: 'closed-group',
+ name: 'Closed Group',
+ about: 'We are closed',
+ description: 'anything',
+ groupType: 'closed',
+ actionRadius: 'interplanetary',
+ categoryIds: ['cat4', 'cat5', 'cat17'],
+ },
+ })
+
+ await mutate({
+ mutation: joinGroupMutation(),
+ variables: {
+ groupId: 'closed-group',
+ userId: 'pending-member-user',
+ },
+ })
+ })
+
+ describe('as unauthenticated user', () => {
+ beforeEach(() => {
+ authenticatedUser = null
+ })
+
+ it('throws authorization error', async () => {
+ await expect(
+ mutate({ mutation: generateGroupInviteCode, variables: { groupId: 'public-group' } }),
+ ).resolves.toMatchObject({
+ data: null,
+ errors: [{ message: 'Not Authorized!' }],
+ })
+ })
+ })
+ describe('as authenticated member', () => {
+ beforeEach(async () => {
+ authenticatedUser = await invitingUser.toJson()
+ })
+
+ it('returns a new group invite code', async () => {
+ await expect(
+ mutate({ mutation: generateGroupInviteCode, variables: { groupId: 'public-group' } }),
+ ).resolves.toMatchObject({
+ data: {
+ generateGroupInviteCode: {
+ code: expect.any(String),
+ comment: null,
+ createdAt: expect.any(String),
+ expiresAt: null,
+ generatedBy: {
+ avatar: {
+ url: expect.any(String),
+ },
+ id: 'inviting-user',
+ name: 'Inviting User',
+ },
+ invitedTo: {
+ id: 'public-group',
+ groupType: 'public',
+ name: 'Public Group',
+ about: 'We are public',
+ avatar: null,
+ },
+ isValid: true,
+ redeemedBy: [],
+ },
+ },
+ errors: undefined,
+ })
+ })
+
+ it('returns a new group invite code with comment', async () => {
+ await expect(
+ mutate({
+ mutation: generateGroupInviteCode,
+ variables: { groupId: 'public-group', comment: 'some text' },
+ }),
+ ).resolves.toMatchObject({
+ data: {
+ generateGroupInviteCode: {
+ code: expect.any(String),
+ comment: 'some text',
+ createdAt: expect.any(String),
+ expiresAt: null,
+ generatedBy: {
+ avatar: {
+ url: expect.any(String),
+ },
+ id: 'inviting-user',
+ name: 'Inviting User',
+ },
+ invitedTo: {
+ id: 'public-group',
+ groupType: 'public',
+ name: 'Public Group',
+ about: 'We are public',
+ avatar: null,
+ },
+ isValid: true,
+ redeemedBy: [],
+ },
+ },
+ errors: undefined,
+ })
+ })
+
+ it('returns a new group invite code with expireDate', async () => {
+ const date = new Date()
+ date.setFullYear(date.getFullYear() + 1)
+ await expect(
+ mutate({
+ mutation: generateGroupInviteCode,
+ variables: { groupId: 'public-group', expiresAt: date.toISOString() },
+ }),
+ ).resolves.toMatchObject({
+ data: {
+ generateGroupInviteCode: {
+ code: expect.any(String),
+ comment: null,
+ createdAt: expect.any(String),
+ expiresAt: date.toISOString(),
+ generatedBy: {
+ avatar: {
+ url: expect.any(String),
+ },
+ id: 'inviting-user',
+ name: 'Inviting User',
+ },
+ invitedTo: {
+ id: 'public-group',
+ groupType: 'public',
+ name: 'Public Group',
+ about: 'We are public',
+ avatar: null,
+ },
+ isValid: true,
+ redeemedBy: [],
+ },
+ },
+ errors: undefined,
+ })
+ })
+
+ it('returns a new invalid group invite code with expireDate in the past', async () => {
+ const date = new Date()
+ date.setFullYear(date.getFullYear() - 1)
+ await expect(
+ mutate({
+ mutation: generateGroupInviteCode,
+ variables: { groupId: 'public-group', expiresAt: date.toISOString() },
+ }),
+ ).resolves.toMatchObject({
+ data: {
+ generateGroupInviteCode: {
+ code: expect.any(String),
+ comment: null,
+ createdAt: expect.any(String),
+ expiresAt: date.toISOString(),
+ generatedBy: {
+ avatar: {
+ url: expect.any(String),
+ },
+ id: 'inviting-user',
+ name: 'Inviting User',
+ },
+ invitedTo: {
+ id: 'public-group',
+ groupType: 'public',
+ name: 'Public Group',
+ about: 'We are public',
+ avatar: null,
+ },
+ isValid: false,
+ redeemedBy: [],
+ },
+ },
+ errors: undefined,
+ })
+ })
+
+ it('throws an error when the max amount of invite links was reached', async () => {
+ let lastCode
+ for (let i = 0; i < CONFIG.INVITE_CODES_GROUP_PER_USER; i++) {
+ lastCode = await mutate({
+ mutation: generateGroupInviteCode,
+ variables: { groupId: 'public-group' },
+ })
+ expect(lastCode).toMatchObject({
+ errors: undefined,
+ })
+ }
+ await expect(
+ mutate({ mutation: generateGroupInviteCode, variables: { groupId: 'public-group' } }),
+ ).resolves.toMatchObject({
+ errors: [
+ {
+ message: 'You have reached the maximum of Invite Codes you can generate for this group',
+ },
+ ],
+ })
+ await mutate({
+ mutation: invalidateInviteCode,
+ variables: { code: lastCode.data.generateGroupInviteCode.code },
+ })
+ await expect(
+ mutate({ mutation: generateGroupInviteCode, variables: { groupId: 'public-group' } }),
+ ).resolves.toMatchObject({
+ errors: undefined,
+ })
+ })
+
+ // eslint-disable-next-line jest/no-disabled-tests, @typescript-eslint/no-empty-function
+ it.skip('returns a new group invite code when colliding with an existing one', () => {})
+ })
+
+ describe('as authenticated not-member', () => {
+ beforeEach(async () => {
+ authenticatedUser = await notMemberUser.toJson()
+ })
+
+ it('throws authorization error', async () => {
+ const date = new Date()
+ date.setFullYear(date.getFullYear() - 1)
+ await expect(
+ mutate({
+ mutation: generateGroupInviteCode,
+ variables: { groupId: 'public-group' },
+ }),
+ ).resolves.toMatchObject({
+ data: null,
+ errors: [{ message: 'Not Authorized!' }],
+ })
+ })
+ })
+
+ describe('as pending-member user', () => {
+ beforeEach(async () => {
+ authenticatedUser = await pendingMemberUser.toJson()
+ })
+
+ it('throws authorization error', async () => {
+ await expect(
+ mutate({
+ mutation: generateGroupInviteCode,
+ variables: { groupId: 'hidden-group' },
+ }),
+ ).resolves.toMatchObject({
+ data: null,
+ errors: [{ message: 'Not Authorized!' }],
+ })
+ })
+ })
+})
+
+describe('invalidateInviteCode', () => {
+ let invitingUser, otherUser
+ beforeEach(async () => {
+ await cleanDatabase()
+ invitingUser = await Factory.build('user', {
+ id: 'inviting-user',
+ role: 'user',
+ name: 'Inviting User',
+ })
+
+ otherUser = await Factory.build('user', {
+ id: 'other-user',
+ role: 'user',
+ name: 'Other User',
+ })
+
+ await Factory.build(
+ 'inviteCode',
+ {
+ code: 'CODE33',
+ },
+ {
+ generatedBy: invitingUser,
+ },
+ )
+ })
+
+ describe('as unauthenticated user', () => {
+ beforeEach(() => {
+ authenticatedUser = null
+ })
+
+ it('throws authorization error', async () => {
+ await expect(
+ mutate({ mutation: invalidateInviteCode, variables: { code: 'CODE33' } }),
+ ).resolves.toMatchObject({
+ data: {
+ invalidateInviteCode: null,
+ },
+ errors: [{ message: 'Not Authorized!' }],
+ })
+ })
+ })
+
+ describe('as authenticated user', () => {
+ describe('as link owner', () => {
+ beforeEach(async () => {
+ authenticatedUser = await invitingUser.toJson()
+ })
+
+ it('returns the invalidated InviteCode', async () => {
+ await expect(
+ mutate({ mutation: invalidateInviteCode, variables: { code: 'CODE33' } }),
+ ).resolves.toMatchObject({
+ data: {
+ invalidateInviteCode: {
+ code: expect.any(String),
+ comment: null,
+ createdAt: expect.any(String),
+ expiresAt: expect.any(String),
+ generatedBy: {
+ avatar: {
+ url: expect.any(String),
+ },
+ id: 'inviting-user',
+ name: 'Inviting User',
+ },
+ invitedTo: null,
+ isValid: false,
+ redeemedBy: [],
+ },
+ },
+ errors: undefined,
+ })
+ })
+ })
+
+ describe('as not link owner', () => {
+ beforeEach(async () => {
+ authenticatedUser = await otherUser.toJson()
+ })
+
+ it('throws authorization error', async () => {
+ await expect(
+ mutate({ mutation: invalidateInviteCode, variables: { code: 'CODE33' } }),
+ ).resolves.toMatchObject({
+ data: {
+ invalidateInviteCode: null,
+ },
+ errors: [{ message: 'Not Authorized!' }],
+ })
+ })
+ })
+ })
+})
+
+describe('redeemInviteCode', () => {
+ let invitingUser, otherUser
+ beforeEach(async () => {
+ await cleanDatabase()
+ invitingUser = await Factory.build('user', {
+ id: 'inviting-user',
+ role: 'user',
+ name: 'Inviting User',
+ })
+
+ otherUser = await Factory.build('user', {
+ id: 'other-user',
+ role: 'user',
+ name: 'Other User',
+ })
+
+ authenticatedUser = await invitingUser.toJson()
+ await mutate({
+ mutation: createGroupMutation(),
+ variables: {
+ id: 'hidden-group',
+ name: 'Hidden Group',
+ about: 'We are hidden',
+ description: 'anything',
+ groupType: 'hidden',
+ actionRadius: 'global',
+ categoryIds: ['cat6', 'cat12', 'cat16'],
+ locationName: 'Hamburg, Germany',
+ },
+ })
+
+ await mutate({
+ mutation: createGroupMutation(),
+ variables: {
+ id: 'public-group',
+ name: 'Public Group',
+ about: 'We are public',
+ description: 'anything',
+ groupType: 'public',
+ actionRadius: 'interplanetary',
+ categoryIds: ['cat4', 'cat5', 'cat17'],
+ },
+ })
+
+ await Factory.build(
+ 'inviteCode',
+ {
+ code: 'CODE33',
+ },
+ {
+ generatedBy: invitingUser,
+ },
+ )
+ await Factory.build(
+ 'inviteCode',
+ {
+ code: 'GRPPBL',
+ },
+ {
+ generatedBy: invitingUser,
+ groupId: 'public-group',
+ },
+ )
+ await Factory.build(
+ 'inviteCode',
+ {
+ code: 'GRPHDN',
+ },
+ {
+ generatedBy: invitingUser,
+ groupId: 'hidden-group',
+ },
+ )
+ })
+
+ describe('as unauthenticated user', () => {
+ beforeEach(() => {
+ authenticatedUser = null
+ })
+
+ it('throws authorization error', async () => {
+ await expect(
+ mutate({ mutation: redeemInviteCode, variables: { code: 'CODE33' } }),
+ ).resolves.toMatchObject({
+ data: null,
+ errors: [{ message: 'Not Authorized!' }],
+ })
+ })
+ })
+
+ describe('as authenticated user', () => {
+ beforeEach(async () => {
+ authenticatedUser = await otherUser.toJson()
+ })
+
+ it('returns false for an invalid inviteCode', async () => {
+ await expect(
+ mutate({ mutation: redeemInviteCode, variables: { code: 'INVALD' } }),
+ ).resolves.toMatchObject({
+ data: {
+ redeemInviteCode: false,
+ },
+ errors: undefined,
+ })
+ })
+
+ it('returns true for a personal inviteCode, but does nothing', async () => {
+ await expect(
+ mutate({ mutation: redeemInviteCode, variables: { code: 'CODE33' } }),
+ ).resolves.toMatchObject({
+ data: {
+ redeemInviteCode: true,
+ },
+ errors: undefined,
+ })
+ authenticatedUser = await invitingUser.toJson()
+ await expect(query({ query: currentUser })).resolves.toMatchObject({
+ data: {
+ currentUser: {
+ following: [],
+ inviteCodes: [
+ {
+ code: 'CODE33',
+ redeemedByCount: 0,
+ },
+ ],
+ },
+ },
+ errors: undefined,
+ })
+ })
+
+ it('returns true for a public group inviteCode and makes the user a group member', async () => {
+ await expect(
+ mutate({ mutation: redeemInviteCode, variables: { code: 'GRPPBL' } }),
+ ).resolves.toMatchObject({
+ data: {
+ redeemInviteCode: true,
+ },
+ errors: undefined,
+ })
+ await expect(
+ query({ query: Group, variables: { id: 'public-group' } }),
+ ).resolves.toMatchObject({
+ data: {
+ Group: [
+ {
+ myRole: 'usual',
+ },
+ ],
+ },
+ errors: undefined,
+ })
+ authenticatedUser = await invitingUser.toJson()
+ await expect(query({ query: Group })).resolves.toMatchObject({
+ data: {
+ Group: expect.arrayContaining([
+ expect.objectContaining({
+ inviteCodes: expect.arrayContaining([
+ {
+ code: 'GRPPBL',
+ redeemedByCount: 1,
+ },
+ ]),
+ }),
+ ]),
+ },
+ errors: undefined,
+ })
+ })
+
+ it('returns true for a hidden group inviteCode and makes the user a pending member', async () => {
+ await expect(
+ mutate({ mutation: redeemInviteCode, variables: { code: 'GRPHDN' } }),
+ ).resolves.toMatchObject({
+ data: {
+ redeemInviteCode: true,
+ },
+ errors: undefined,
+ })
+ authenticatedUser = await invitingUser.toJson()
+ await expect(
+ query({ query: GroupMembers, variables: { id: 'hidden-group' } }),
+ ).resolves.toMatchObject({
+ data: {
+ GroupMembers: expect.arrayContaining([
+ {
+ id: 'inviting-user',
+ myRoleInGroup: 'owner',
+ name: 'Inviting User',
+ slug: 'inviting-user',
+ },
+ {
+ id: 'other-user',
+ myRoleInGroup: 'pending',
+ name: 'Other User',
+ slug: 'other-user',
+ },
+ ]),
+ },
+ errors: undefined,
+ })
+ await expect(query({ query: Group })).resolves.toMatchObject({
+ data: {
+ Group: expect.arrayContaining([
+ expect.objectContaining({
+ inviteCodes: expect.arrayContaining([
+ {
+ code: 'GRPHDN',
+ redeemedByCount: 1,
+ },
+ ]),
+ }),
+ ]),
+ },
+ errors: undefined,
+ })
+ })
+ })
+
+ describe('as authenticated self', () => {
+ beforeEach(async () => {
+ authenticatedUser = await invitingUser.toJson()
+ })
+
+ it('returns true for a personal inviteCode, but does nothing', async () => {
+ await expect(
+ mutate({ mutation: redeemInviteCode, variables: { code: 'CODE33' } }),
+ ).resolves.toMatchObject({
+ data: {
+ redeemInviteCode: true,
+ },
+ errors: undefined,
+ })
+ await expect(query({ query: currentUser })).resolves.toMatchObject({
+ data: {
+ currentUser: {
+ following: [],
+ inviteCodes: [
+ {
+ code: 'CODE33',
+ redeemedByCount: 0,
+ },
+ ],
+ },
+ },
+ errors: undefined,
+ })
+ })
+
+ it('returns true for a public group inviteCode, but does nothing', async () => {
+ await expect(
+ mutate({ mutation: redeemInviteCode, variables: { code: 'GRPPBL' } }),
+ ).resolves.toMatchObject({
+ data: {
+ redeemInviteCode: true,
+ },
+ errors: undefined,
+ })
+ await expect(
+ query({ query: Group, variables: { id: 'public-group' } }),
+ ).resolves.toMatchObject({
+ data: {
+ Group: [
+ {
+ myRole: 'owner',
+ },
+ ],
+ },
+ errors: undefined,
+ })
+ await expect(query({ query: Group })).resolves.toMatchObject({
+ data: {
+ Group: expect.arrayContaining([
+ expect.objectContaining({
+ inviteCodes: expect.arrayContaining([
+ {
+ code: 'GRPPBL',
+ redeemedByCount: 0,
+ },
+ ]),
+ }),
+ ]),
+ },
+ errors: undefined,
+ })
})
})
})
diff --git a/backend/src/graphql/resolvers/inviteCodes.ts b/backend/src/graphql/resolvers/inviteCodes.ts
index 02680b5bc..b17d32dd8 100644
--- a/backend/src/graphql/resolvers/inviteCodes.ts
+++ b/backend/src/graphql/resolvers/inviteCodes.ts
@@ -1,136 +1,294 @@
-/* eslint-disable @typescript-eslint/require-await */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
-/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-return */
-import generateInviteCode from './helpers/generateInviteCode'
-import Resolver from './helpers/Resolver'
-import { validateInviteCode } from './transactions/inviteCodes'
+import CONFIG from '@config/index'
+import registrationConstants from '@constants/registrationBranded'
+// eslint-disable-next-line import/no-cycle
+import { Context } from '@src/server'
-const uniqueInviteCode = async (session, code) => {
- return session.readTransaction(async (txc) => {
- const result = await txc.run(`MATCH (ic:InviteCode { id: $code }) RETURN count(ic) AS count`, {
- code,
+import Resolver from './helpers/Resolver'
+
+export const generateInviteCode = () => {
+ // 6 random numbers in [ 0, 35 ] are 36 possible numbers (10 [0-9] + 26 [A-Z])
+ return Array.from(
+ { length: registrationConstants.INVITE_CODE_LENGTH },
+ (n: number = Math.floor(Math.random() * 36)) => {
+ // n > 9: it is a letter (ASCII 65 is A) -> 10 + 55 = 65
+ // else: it is a number (ASCII 48 is 0) -> 0 + 48 = 48
+ return String.fromCharCode(n > 9 ? n + 55 : n + 48)
+ },
+ ).join('')
+}
+
+const uniqueInviteCode = async (context: Context, code: string) => {
+ return (
+ (
+ await context.database.query({
+ query: `MATCH (inviteCode:InviteCode { code: toUpper($code) })
+ WHERE inviteCode.expiresAt IS NULL
+ OR inviteCode.expiresAt >= datetime()
+ RETURN toString(count(inviteCode)) AS count`,
+ variables: { code },
+ })
+ ).records[0].get('count') === '0'
+ )
+}
+
+export const validateInviteCode = async (context: Context, inviteCode) => {
+ const result = (
+ await context.database.query({
+ query: `
+ OPTIONAL MATCH (inviteCode:InviteCode { code: toUpper($inviteCode) })
+ RETURN
+ CASE
+ WHEN inviteCode IS NULL THEN false
+ WHEN inviteCode.expiresAt IS NULL THEN true
+ WHEN datetime(inviteCode.expiresAt) >= datetime() THEN true
+ ELSE false END AS result
+ `,
+ variables: { inviteCode },
})
- return parseInt(String(result.records[0].get('count'))) === 0
- })
+ ).records
+ return result[0].get('result') === true
+}
+
+export const redeemInviteCode = async (context: Context, code, newUser = false) => {
+ const result = (
+ await context.database.query({
+ query: `
+ MATCH (inviteCode:InviteCode {code: toUpper($code)})<-[:GENERATED]-(host:User)
+ OPTIONAL MATCH (inviteCode)-[:INVITES_TO]->(group:Group)
+ WHERE inviteCode.expiresAt IS NULL
+ OR datetime(inviteCode.expiresAt) >= datetime()
+ RETURN inviteCode {.*}, group {.*}, host {.*}`,
+ variables: { code },
+ })
+ ).records
+
+ if (result.length !== 1) {
+ return false
+ }
+
+ const inviteCode = result[0].get('inviteCode')
+ const group = result[0].get('group')
+ const host = result[0].get('host')
+
+ if (!inviteCode || !host) {
+ return false
+ }
+
+ // self
+ if (host.id === context.user.id) {
+ return true
+ }
+
+ // Personal Invite Link
+ if (!group) {
+ // We redeemed this link while having an account, hence we do nothing, but return true
+ if (!newUser) {
+ return true
+ }
+
+ await context.database.write({
+ query: `
+ MATCH (user:User {id: $user.id}), (inviteCode:InviteCode {code: toUpper($code)})<-[:GENERATED]-(host: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)
+ `,
+ variables: { user: context.user, code },
+ })
+ // Group Invite Link
+ } else {
+ const role = ['closed', 'hidden'].includes(group.groupType as string) ? 'pending' : 'usual'
+
+ const optionalInvited = newUser
+ ? 'MERGE (host)-[:INVITED { createdAt: toString(datetime()) }]->(user)'
+ : ''
+
+ await context.database.write({
+ query: `
+ MATCH (user:User {id: $user.id}), (group:Group)<-[:INVITES_TO]-(inviteCode:InviteCode {code: toUpper($code)})<-[:GENERATED]-(host:User)
+ MERGE (user)-[:REDEEMED { createdAt: toString(datetime()) }]->(inviteCode)
+ ${optionalInvited}
+ MERGE (user)-[membership:MEMBER_OF]->(group)
+ ON CREATE SET
+ membership.createdAt = toString(datetime()),
+ membership.updatedAt = null,
+ membership.role = $role
+ `,
+ variables: { user: context.user, code, role },
+ })
+ }
+ return true
}
export default {
Query: {
- getInviteCode: async (_parent, args, context, _resolveInfo) => {
- const {
- user: { id: userId },
- } = context
- const session = context.driver.session()
- const readTxResultPromise = session.readTransaction(async (txc) => {
- const result = await txc.run(
- `MATCH (user:User {id: $userId})-[:GENERATED]->(ic:InviteCode)
- WHERE ic.expiresAt IS NULL
- OR datetime(ic.expiresAt) >= datetime()
- RETURN properties(ic) AS inviteCodes`,
- {
- userId,
- },
- )
- return result.records.map((record) => record.get('inviteCodes'))
- })
- try {
- const inviteCode = await readTxResultPromise
- if (inviteCode && inviteCode.length > 0) return inviteCode[0]
- let code = generateInviteCode()
- while (!(await uniqueInviteCode(session, code))) {
- code = generateInviteCode()
- }
- const writeTxResultPromise = session.writeTransaction(async (txc) => {
- const result = await txc.run(
- `MATCH (user:User {id: $userId})
- MERGE (user)-[:GENERATED]->(ic:InviteCode { code: $code })
- ON CREATE SET
- ic.createdAt = toString(datetime()),
- ic.expiresAt = $expiresAt
- RETURN ic AS inviteCode`,
- {
- userId,
- code,
- expiresAt: null,
- },
- )
- return result.records.map((record) => record.get('inviteCode').properties)
+ validateInviteCode: async (_parent, args, context: Context, _resolveInfo) => {
+ const result = (
+ await context.database.query({
+ query: `
+ MATCH (inviteCode:InviteCode { code: toUpper($args.code) })
+ WHERE inviteCode.expiresAt IS NULL
+ OR datetime(inviteCode.expiresAt) >= datetime()
+ RETURN inviteCode {.*}`,
+ variables: { args },
})
- const txResult = await writeTxResultPromise
- return txResult[0]
- } finally {
- session.close()
+ ).records
+
+ if (result.length !== 1) {
+ return null
}
- },
- MyInviteCodes: async (_parent, args, context, _resolveInfo) => {
- const {
- user: { id: userId },
- } = context
- const session = context.driver.session()
- const readTxResultPromise = session.readTransaction(async (txc) => {
- const result = await txc.run(
- `MATCH (user:User {id: $userId})-[:GENERATED]->(ic:InviteCode)
- RETURN properties(ic) AS inviteCodes`,
- {
- userId,
- },
- )
- return result.records.map((record) => record.get('inviteCodes'))
- })
- try {
- const txResult = await readTxResultPromise
- return txResult
- } finally {
- session.close()
- }
- },
- isValidInviteCode: async (_parent, args, context, _resolveInfo) => {
- const { code } = args
- const session = context.driver.session()
- if (!code) return false
- return validateInviteCode(session, code)
+
+ return result[0].get('inviteCode')
},
},
Mutation: {
- GenerateInviteCode: async (_parent, args, context, _resolveInfo) => {
- const {
- user: { id: userId },
- } = context
- const session = context.driver.session()
+ generatePersonalInviteCode: async (_parent, args, context: Context, _resolveInfo) => {
+ const userInviteCodeAmount = (
+ await context.database.query({
+ query: `
+ MATCH (inviteCode:InviteCode)<-[:GENERATED]-(user:User {id: $user.id})
+ WHERE NOT (inviteCode)-[:INVITES_TO]->(:Group)
+ AND (inviteCode.expiresAt IS NULL OR inviteCode.expiresAt >= datetime())
+ RETURN toString(count(inviteCode)) as count
+ `,
+ variables: { user: context.user },
+ })
+ ).records[0].get('count')
+
+ if (parseInt(userInviteCodeAmount as string) >= CONFIG.INVITE_CODES_PERSONAL_PER_USER) {
+ throw new Error('You have reached the maximum of Invite Codes you can generate')
+ }
+
let code = generateInviteCode()
- while (!(await uniqueInviteCode(session, code))) {
+ while (!(await uniqueInviteCode(context, code))) {
code = generateInviteCode()
}
- const writeTxResultPromise = session.writeTransaction(async (txc) => {
- const result = await txc.run(
- `MATCH (user:User {id: $userId})
- MERGE (user)-[:GENERATED]->(ic:InviteCode { code: $code })
- ON CREATE SET
- ic.createdAt = toString(datetime()),
- ic.expiresAt = $expiresAt
- RETURN ic AS inviteCode`,
- {
- userId,
- code,
- expiresAt: args.expiresAt,
- },
+
+ return (
+ await context.database.write({
+ // We delete a potential old invite code if there is a collision on an expired code
+ query: `
+ MATCH (user:User {id: $user.id})
+ OPTIONAL MATCH (oldInviteCode:InviteCode { code: toUpper($code) })
+ DETACH DELETE oldInviteCode
+ MERGE (user)-[:GENERATED]->(inviteCode:InviteCode { code: toUpper($code)})
+ ON CREATE SET
+ inviteCode.createdAt = toString(datetime()),
+ inviteCode.expiresAt = $args.expiresAt,
+ inviteCode.comment = $args.comment
+ RETURN inviteCode {.*}`,
+ variables: { user: context.user, code, args },
+ })
+ ).records[0].get('inviteCode')
+ },
+ generateGroupInviteCode: async (_parent, args, context: Context, _resolveInfo) => {
+ const userInviteCodeAmount = (
+ await context.database.query({
+ query: `
+ MATCH (:Group {id: $args.groupId})<-[:INVITES_TO]-(inviteCode:InviteCode)<-[:GENERATED]-(user:User {id: $user.id})
+ WHERE inviteCode.expiresAt IS NULL
+ OR inviteCode.expiresAt >= datetime()
+ RETURN toString(count(inviteCode)) as count
+ `,
+ variables: { user: context.user, args },
+ })
+ ).records[0].get('count')
+
+ if (parseInt(userInviteCodeAmount as string) >= CONFIG.INVITE_CODES_GROUP_PER_USER) {
+ throw new Error(
+ 'You have reached the maximum of Invite Codes you can generate for this group',
)
- return result.records.map((record) => record.get('inviteCode').properties)
- })
- try {
- const txResult = await writeTxResultPromise
- return txResult[0]
- } finally {
- session.close()
}
+
+ let code = generateInviteCode()
+ while (!(await uniqueInviteCode(context, code))) {
+ code = generateInviteCode()
+ }
+
+ const inviteCode = (
+ await context.database.write({
+ query: `
+ MATCH
+ (user:User {id: $user.id})-[membership:MEMBER_OF]->(group:Group {id: $args.groupId})
+ WHERE NOT membership.role = 'pending'
+ OPTIONAL MATCH (oldInviteCode:InviteCode { code: toUpper($code) })
+ DETACH DELETE oldInviteCode
+ MERGE (user)-[:GENERATED]->(inviteCode:InviteCode { code: toUpper($code) })-[:INVITES_TO]->(group)
+ ON CREATE SET
+ inviteCode.createdAt = toString(datetime()),
+ inviteCode.expiresAt = $args.expiresAt,
+ inviteCode.comment = $args.comment
+ RETURN inviteCode {.*}`,
+ variables: { user: context.user, code, args },
+ })
+ ).records
+
+ if (inviteCode.length !== 1) {
+ // Not a member
+ throw new Error('Not Authorized!')
+ }
+
+ return inviteCode[0].get('inviteCode')
+ },
+ invalidateInviteCode: async (_parent, args, context: Context, _resolveInfo) => {
+ const result = (
+ await context.database.write({
+ query: `
+ MATCH (user:User {id: $user.id})-[:GENERATED]-(inviteCode:InviteCode {code: toUpper($args.code)})
+ SET inviteCode.expiresAt = toString(datetime())
+ RETURN inviteCode {.*}`,
+ variables: { args, user: context.user },
+ })
+ ).records
+
+ if (result.length !== 1) {
+ // Link not generated by this user or does not exist
+ throw new Error('Not Authorized!')
+ }
+
+ return result[0].get('inviteCode')
+ },
+ redeemInviteCode: async (_parent, args, context: Context, _resolveInfo) => {
+ return redeemInviteCode(context, args.code)
},
},
InviteCode: {
+ invitedTo: async (parent, _args, context: Context, _resolveInfo) => {
+ if (!parent.code) {
+ return null
+ }
+
+ const result = (
+ await context.database.query({
+ query: `
+ MATCH (inviteCode:InviteCode {code: $parent.code})-[:INVITES_TO]->(group:Group)
+ RETURN group {.*}
+ `,
+ variables: { parent },
+ })
+ ).records
+
+ if (result.length !== 1) {
+ return null
+ }
+ return result[0].get('group')
+ },
+ isValid: async (parent, _args, context: Context, _resolveInfo) => {
+ if (!parent.code) {
+ return false
+ }
+ return validateInviteCode(context, parent.code)
+ },
...Resolver('InviteCode', {
idAttribute: 'code',
- undefinedToNull: ['expiresAt'],
+ undefinedToNull: ['expiresAt', 'comment'],
+ count: {
+ redeemedByCount: '<-[:REDEEMED]-(related:User)',
+ },
hasOne: {
generatedBy: '<-[:GENERATED]-(related:User)',
},
diff --git a/backend/src/graphql/resolvers/locations.ts b/backend/src/graphql/resolvers/locations.ts
index f375f287f..fc69fab94 100644
--- a/backend/src/graphql/resolvers/locations.ts
+++ b/backend/src/graphql/resolvers/locations.ts
@@ -24,6 +24,9 @@ export default {
],
}),
distanceToMe: async (parent, _params, context, _resolveInfo) => {
+ if (!parent.id) {
+ throw new Error('Can not identify selected Location!')
+ }
const session = context.driver.session()
const query = session.readTransaction(async (transaction) => {
diff --git a/backend/src/graphql/resolvers/posts.spec.ts b/backend/src/graphql/resolvers/posts.spec.ts
index 7574bef17..8d9bf355b 100644
--- a/backend/src/graphql/resolvers/posts.spec.ts
+++ b/backend/src/graphql/resolvers/posts.spec.ts
@@ -1,55 +1,51 @@
-/* eslint-disable @typescript-eslint/require-await */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
+import { ApolloServer } from 'apollo-server-express'
import { createTestClient } from 'apollo-server-testing'
import gql from 'graphql-tag'
import CONFIG from '@config/index'
+import databaseContext from '@context/database'
import Factory, { cleanDatabase } from '@db/factories'
import Image from '@db/models/Image'
-import { getNeode, getDriver } from '@db/neo4j'
import { createPostMutation } from '@graphql/queries/createPostMutation'
-import createServer from '@src/server'
+import createServer, { getContext } from '@src/server'
CONFIG.CATEGORIES_ACTIVE = true
-const driver = getDriver()
-const neode = getNeode()
-
-let query
-let mutate
-let authenticatedUser
let user
-const categoryIds = ['cat9', 'cat4', 'cat15']
-let variables
+const database = databaseContext()
+
+let server: ApolloServer
+let authenticatedUser
+let query, mutate
beforeAll(async () => {
await cleanDatabase()
- const { server } = createServer({
- context: () => {
- return {
- driver,
- neode,
- user: authenticatedUser,
- cypherParams: {
- currentUserId: authenticatedUser ? authenticatedUser.id : null,
- },
- }
- },
- })
- query = createTestClient(server).query
- mutate = createTestClient(server).mutate
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/require-await
+ const contextUser = async (_req) => authenticatedUser
+ const context = getContext({ user: contextUser, database })
+
+ server = createServer({ context }).server
+
+ const createTestClientResult = createTestClient(server)
+ mutate = createTestClientResult.mutate
+ query = createTestClientResult.query
})
-afterAll(async () => {
- await cleanDatabase()
- await driver.close()
+afterAll(() => {
+ void server.stop()
+ void database.driver.close()
+ database.neode.close()
})
+const categoryIds = ['cat9', 'cat4', 'cat15']
+let variables
+
beforeEach(async () => {
variables = {}
user = await Factory.build(
@@ -64,22 +60,22 @@ beforeEach(async () => {
},
)
await Promise.all([
- neode.create('Category', {
+ database.neode.create('Category', {
id: 'cat9',
name: 'Democracy & Politics',
icon: 'university',
}),
- neode.create('Category', {
+ database.neode.create('Category', {
id: 'cat4',
name: 'Environment & Nature',
icon: 'tree',
}),
- neode.create('Category', {
+ database.neode.create('Category', {
id: 'cat15',
name: 'Consumption & Sustainability',
icon: 'shopping-cart',
}),
- neode.create('Category', {
+ database.neode.create('Category', {
id: 'cat27',
name: 'Animal Protection',
icon: 'paw',
@@ -88,7 +84,6 @@ beforeEach(async () => {
authenticatedUser = null
})
-// TODO: avoid database clean after each test in the future if possible for performance and flakyness reasons by filling the database step by step, see issue https://github.com/Ocelot-Social-Community/Ocelot-Social/issues/4543
afterEach(async () => {
await cleanDatabase()
})
@@ -233,7 +228,6 @@ describe('Post', () => {
Post(filter: $filter) {
id
author {
- id
name
}
}
@@ -249,7 +243,7 @@ describe('Post', () => {
Post: [
{
id: 'post-by-followed-user',
- author: { id: 'followed-by-me', name: 'Followed User' },
+ author: { name: 'Followed User' },
},
],
},
@@ -976,11 +970,11 @@ describe('UpdatePost', () => {
})
it('updates the image', async () => {
await expect(
- neode.first('Image', { sensitive: true }, undefined),
+ database.neode.first('Image', { sensitive: true }, undefined),
).resolves.toBeFalsy()
await mutate({ mutation: updatePostMutation, variables })
await expect(
- neode.first('Image', { sensitive: true }, undefined),
+ database.neode.first('Image', { sensitive: true }, undefined),
).resolves.toBeTruthy()
})
})
@@ -990,9 +984,9 @@ describe('UpdatePost', () => {
variables = { ...variables, image: null }
})
it('deletes the image', async () => {
- await expect(neode.all('Image')).resolves.toHaveLength(6)
+ await expect(database.neode.all('Image')).resolves.toHaveLength(6)
await mutate({ mutation: updatePostMutation, variables })
- await expect(neode.all('Image')).resolves.toHaveLength(5)
+ await expect(database.neode.all('Image')).resolves.toHaveLength(5)
})
})
@@ -1002,11 +996,11 @@ describe('UpdatePost', () => {
})
it('keeps the image unchanged', async () => {
await expect(
- neode.first('Image', { sensitive: true }, undefined),
+ database.neode.first('Image', { sensitive: true }, undefined),
).resolves.toBeFalsy()
await mutate({ mutation: updatePostMutation, variables })
await expect(
- neode.first('Image', { sensitive: true }, undefined),
+ database.neode.first('Image', { sensitive: true }, undefined),
).resolves.toBeFalsy()
})
})
@@ -1253,18 +1247,18 @@ describe('pin posts', () => {
it('removes previous `pinned` attribute', async () => {
const cypher = 'MATCH (post:Post) WHERE post.pinned IS NOT NULL RETURN post'
- pinnedPost = await neode.cypher(cypher, {})
+ pinnedPost = await database.neode.cypher(cypher, {})
expect(pinnedPost.records).toHaveLength(1)
variables = { ...variables, id: 'only-pinned-post' }
await mutate({ mutation: pinPostMutation, variables })
- pinnedPost = await neode.cypher(cypher, {})
+ pinnedPost = await database.neode.cypher(cypher, {})
expect(pinnedPost.records).toHaveLength(1)
})
it('removes previous PINNED relationship', async () => {
variables = { ...variables, id: 'only-pinned-post' }
await mutate({ mutation: pinPostMutation, variables })
- pinnedPost = await neode.cypher(
+ pinnedPost = await database.neode.cypher(
`MATCH (:User)-[pinned:PINNED]->(post:Post) RETURN post, pinned`,
{},
)
@@ -1593,7 +1587,7 @@ describe('emotions', () => {
`
beforeEach(async () => {
- author = await neode.create('User', { id: 'u257' })
+ author = await database.neode.create('User', { id: 'u257' })
postToEmote = await Factory.build(
'post',
{
@@ -1628,7 +1622,7 @@ describe('emotions', () => {
`
let postsEmotionsQueryVariables
- beforeEach(async () => {
+ beforeEach(() => {
postsEmotionsQueryVariables = { id: 'p1376' }
})
diff --git a/backend/src/graphql/resolvers/registration.spec.ts b/backend/src/graphql/resolvers/registration.spec.ts
index d959b348a..fe8dc40e0 100644
--- a/backend/src/graphql/resolvers/registration.spec.ts
+++ b/backend/src/graphql/resolvers/registration.spec.ts
@@ -1,49 +1,48 @@
-/* eslint-disable @typescript-eslint/require-await */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
+import { ApolloServer } from 'apollo-server-express'
import { createTestClient } from 'apollo-server-testing'
import gql from 'graphql-tag'
import CONFIG from '@config/index'
+import databaseContext from '@context/database'
import Factory, { cleanDatabase } from '@db/factories'
import EmailAddress from '@db/models/EmailAddress'
import User from '@db/models/User'
-import { getDriver, getNeode } from '@db/neo4j'
-import createServer from '@src/server'
+import createServer, { getContext } from '@src/server'
-const neode = getNeode()
-
-let mutate
-let authenticatedUser
let variables
-const driver = getDriver()
+
+const database = databaseContext()
+
+let server: ApolloServer
+let authenticatedUser
+let mutate
beforeAll(async () => {
await cleanDatabase()
- const { server } = createServer({
- context: () => {
- return {
- driver,
- neode,
- user: authenticatedUser,
- }
- },
- })
- mutate = createTestClient(server).mutate
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/require-await
+ const contextUser = async (_req) => authenticatedUser
+ const context = getContext({ user: contextUser, database })
+
+ server = createServer({ context }).server
+
+ const createTestClientResult = createTestClient(server)
+ mutate = createTestClientResult.mutate
})
-afterAll(async () => {
- await cleanDatabase()
- await driver.close()
+afterAll(() => {
+ void server.stop()
+ void database.driver.close()
+ database.neode.close()
})
-beforeEach(async () => {
+beforeEach(() => {
variables = {}
})
-// 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()
})
@@ -98,7 +97,7 @@ describe('Signup', () => {
describe('creates a EmailAddress node', () => {
it('with `createdAt` attribute', async () => {
await mutate({ mutation, variables })
- const emailAddress = await neode.first(
+ const emailAddress = await database.neode.first(
'EmailAddress',
{ email: 'someuser@example.org' },
undefined,
@@ -112,7 +111,7 @@ describe('Signup', () => {
it('with a cryptographic `nonce`', async () => {
await mutate({ mutation, variables })
- const emailAddress = await neode.first(
+ const emailAddress = await database.neode.first(
'EmailAddress',
{ email: 'someuser@example.org' },
undefined,
@@ -153,12 +152,12 @@ describe('Signup', () => {
it('creates no additional `EmailAddress` node', async () => {
// admin account and the already existing user
- await expect(neode.all('EmailAddress')).resolves.toHaveLength(2)
+ await expect(database.neode.all('EmailAddress')).resolves.toHaveLength(2)
await expect(mutate({ mutation, variables })).resolves.toMatchObject({
data: { Signup: { email: 'someuser@example.org' } },
errors: undefined,
})
- await expect(neode.all('EmailAddress')).resolves.toHaveLength(2)
+ await expect(database.neode.all('EmailAddress')).resolves.toHaveLength(2)
})
})
})
@@ -194,7 +193,7 @@ describe('SignupVerification', () => {
}
`
describe('given valid password and email', () => {
- beforeEach(async () => {
+ beforeEach(() => {
variables = {
...variables,
nonce: '12345',
@@ -207,7 +206,7 @@ describe('SignupVerification', () => {
})
describe('unauthenticated', () => {
- beforeEach(async () => {
+ beforeEach(() => {
authenticatedUser = null
})
@@ -215,8 +214,8 @@ describe('SignupVerification', () => {
beforeEach(async () => {
const { email, nonce } = variables
const [emailAddress, user] = await Promise.all([
- neode.model('EmailAddress').create({ email, nonce }),
- neode
+ database.neode.model('EmailAddress').create({ email, nonce }),
+ database.neode
.model('User')
.create({ name: 'Somebody', password: '1234', email: 'john@example.org' }),
])
@@ -242,7 +241,7 @@ describe('SignupVerification', () => {
email: 'john@example.org',
nonce: '12345',
}
- await neode.model('EmailAddress').create(args)
+ await database.neode.model('EmailAddress').create(args)
})
describe('sending a valid nonce', () => {
@@ -258,7 +257,7 @@ describe('SignupVerification', () => {
it('sets `verifiedAt` attribute of EmailAddress', async () => {
await mutate({ mutation, variables })
- const email = await neode.first(
+ const email = await database.neode.first(
'EmailAddress',
{ email: 'john@example.org' },
undefined,
@@ -276,14 +275,18 @@ describe('SignupVerification', () => {
RETURN email
`
await mutate({ mutation, variables })
- const { records: emails } = await neode.cypher(cypher, { name: 'John Doe' })
+ const { records: emails } = await database.neode.cypher(cypher, { name: 'John Doe' })
expect(emails).toHaveLength(1)
})
it('sets `about` attribute of User', async () => {
variables = { ...variables, about: 'Find this description in the user profile' }
await mutate({ mutation, variables })
- const user = await neode.first('User', { name: 'John Doe' }, undefined)
+ const user = await database.neode.first(
+ 'User',
+ { name: 'John Doe' },
+ undefined,
+ )
await expect(user.toJson()).resolves.toMatchObject({
about: 'Find this description in the user profile',
})
@@ -306,7 +309,7 @@ describe('SignupVerification', () => {
RETURN email
`
await mutate({ mutation, variables })
- const { records: emails } = await neode.cypher(cypher, { name: 'John Doe' })
+ const { records: emails } = await database.neode.cypher(cypher, { name: 'John Doe' })
expect(emails).toHaveLength(1)
})
diff --git a/backend/src/graphql/resolvers/registration.ts b/backend/src/graphql/resolvers/registration.ts
index d37d3663a..fb8e83ec2 100644
--- a/backend/src/graphql/resolvers/registration.ts
+++ b/backend/src/graphql/resolvers/registration.ts
@@ -7,10 +7,12 @@ import { UserInputError } from 'apollo-server'
import { hash } from 'bcryptjs'
import { getNeode } from '@db/neo4j'
+import { Context } from '@src/server'
import existingEmailAddress from './helpers/existingEmailAddress'
import generateNonce from './helpers/generateNonce'
import normalizeEmail from './helpers/normalizeEmail'
+import { redeemInviteCode } from './inviteCodes'
const neode = getNeode()
@@ -33,7 +35,7 @@ export default {
throw new UserInputError(e.message)
}
},
- SignupVerification: async (_parent, args, context) => {
+ SignupVerification: async (_parent, args, context: Context) => {
const { termsAndConditionsAgreedVersion } = args
const regEx = /^[0-9]+\.[0-9]+\.[0-9]+$/g
if (!regEx.test(termsAndConditionsAgreedVersion)) {
@@ -52,69 +54,60 @@ export default {
const { driver } = context
const session = driver.session()
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
- const createUserTransactionResponse = await transaction.run(signupCypher(inviteCode), {
- args,
- nonce,
- email,
- inviteCode,
- })
+ const createUserTransactionResponse = await transaction.run(
+ `
+ MATCH (email:EmailAddress {nonce: $nonce, email: $email})
+ WHERE NOT (email)-[:BELONGS_TO]->()
+ CREATE (user:User)
+ MERGE (user)-[:PRIMARY_EMAIL]->(email)
+ MERGE (user)<-[:BELONGS_TO]-(email)
+ SET user += $args
+ SET user.id = randomUUID()
+ SET user.role = 'user'
+ SET user.createdAt = toString(datetime())
+ SET user.updatedAt = toString(datetime())
+ SET user.allowEmbedIframes = false
+ SET user.showShoutsPublicly = false
+ 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 {.*}
+ `,
+ {
+ args,
+ nonce,
+ email,
+ inviteCode,
+ },
+ )
const [user] = createUserTransactionResponse.records.map((record) => record.get('user'))
if (!user) throw new UserInputError('Invalid email or nonce')
+
return user
})
try {
const user = await writeTxResultPromise
+
+ // To allow redeeming and return an User object we require a User in the context
+ context.user = user
+
+ if (inviteCode) {
+ await redeemInviteCode(context, inviteCode, true)
+ }
+
return user
} catch (e) {
if (e.code === 'Neo.ClientError.Schema.ConstraintValidationFailed')
throw new UserInputError('User with this slug already exists!')
throw new UserInputError(e.message)
} finally {
- session.close()
+ await session.close()
}
},
},
}
-
-const signupCypher = (inviteCode) => {
- let optionalMatch = ''
- let optionalMerge = ''
- if (inviteCode) {
- optionalMatch = `
- OPTIONAL MATCH
- (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)
- `
- }
- const cypher = `
- 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)
- ${optionalMerge}
- SET user += $args
- SET user.id = randomUUID()
- SET user.role = 'user'
- SET user.createdAt = toString(datetime())
- SET user.updatedAt = toString(datetime())
- SET user.allowEmbedIframes = false
- SET user.showShoutsPublicly = false
- 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/graphql/resolvers/transactions/inviteCodes.ts b/backend/src/graphql/resolvers/transactions/inviteCodes.ts
deleted file mode 100644
index 0381893ad..000000000
--- a/backend/src/graphql/resolvers/transactions/inviteCodes.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-/* eslint-disable @typescript-eslint/no-unsafe-member-access */
-/* eslint-disable @typescript-eslint/no-unsafe-call */
-/* eslint-disable @typescript-eslint/no-unsafe-return */
-/* eslint-disable @typescript-eslint/no-unsafe-assignment */
-export async function validateInviteCode(session, inviteCode) {
- const readTxResultPromise = session.readTransaction(async (txc) => {
- const result = await txc.run(
- `MATCH (ic:InviteCode { code: toUpper($inviteCode) })
- RETURN
- CASE
- WHEN ic.expiresAt IS NULL THEN true
- WHEN datetime(ic.expiresAt) >= datetime() THEN true
- ELSE false END AS result`,
- {
- inviteCode,
- },
- )
- return result.records.map((record) => record.get('result'))
- })
- try {
- const txResult = await readTxResultPromise
- return !!txResult[0]
- } finally {
- session.close()
- }
-}
diff --git a/backend/src/graphql/resolvers/users.ts b/backend/src/graphql/resolvers/users.ts
index f549e79a3..ac1964beb 100644
--- a/backend/src/graphql/resolvers/users.ts
+++ b/backend/src/graphql/resolvers/users.ts
@@ -10,6 +10,7 @@ import { neo4jgraphql } from 'neo4j-graphql-js'
import { TROPHY_BADGES_SELECTED_MAX } from '@constants/badges'
import { getNeode } from '@db/neo4j'
+import { Context } from '@src/server'
import { defaultTrophyBadge, defaultVerificationBadge } from './badges'
import Resolver from './helpers/Resolver'
@@ -467,6 +468,19 @@ export default {
},
},
User: {
+ inviteCodes: async (_parent, _args, context: Context, _resolveInfo) => {
+ return (
+ await context.database.query({
+ query: `
+ MATCH (user:User {id: $user.id})-[:GENERATED]->(inviteCodes:InviteCode)
+ WHERE NOT (inviteCodes)-[:INVITES_TO]->(:Group)
+ RETURN inviteCodes {.*}
+ ORDER BY inviteCodes.createdAt ASC
+ `,
+ variables: { user: context.user },
+ })
+ ).records.map((record) => record.get('inviteCodes'))
+ },
emailNotificationSettings: async (parent, _params, _context, _resolveInfo) => {
return [
{
@@ -668,7 +682,6 @@ export default {
shouted: '-[:SHOUTED]->(related:Post)',
categories: '-[:CATEGORIZED]->(related:Category)',
badgeTrophies: '<-[:REWARDED]-(related:Badge)',
- inviteCodes: '-[:GENERATED]->(related:InviteCode)',
},
}),
},
diff --git a/backend/src/graphql/types/type/Group.gql b/backend/src/graphql/types/type/Group.gql
index 9bcac5047..0adc7853b 100644
--- a/backend/src/graphql/types/type/Group.gql
+++ b/backend/src/graphql/types/type/Group.gql
@@ -43,6 +43,9 @@ type Group {
posts: [Post] @relation(name: "IN", direction: "IN")
isMutedByMe: Boolean! @cypher(statement: "MATCH (this) RETURN EXISTS( (this)<-[:MUTED]-(:User {id: $cypherParams.currentUserId}) )")
+
+ "inviteCodes to this group the current user has generated"
+ inviteCodes: [InviteCode]! @neo4j_ignore
}
diff --git a/backend/src/graphql/types/type/InviteCode.gql b/backend/src/graphql/types/type/InviteCode.gql
index 3293c735b..e0c83796a 100644
--- a/backend/src/graphql/types/type/InviteCode.gql
+++ b/backend/src/graphql/types/type/InviteCode.gql
@@ -3,16 +3,23 @@ type InviteCode {
createdAt: String!
generatedBy: User @relation(name: "GENERATED", direction: "IN")
redeemedBy: [User] @relation(name: "REDEEMED", direction: "IN")
+ redeemedByCount: Int! @cypher(statement: "MATCH (this)<-[:REDEEMED]-(related:User)")
expiresAt: String
-}
+ comment: String
+ invitedTo: Group @neo4j_ignore
+ # invitedFrom: User! @neo4j_ignore # -> see generatedBy
-type Mutation {
- GenerateInviteCode(expiresAt: String = null): InviteCode
+ isValid: Boolean! @neo4j_ignore
}
type Query {
- MyInviteCodes: [InviteCode]
- isValidInviteCode(code: ID!): Boolean
- getInviteCode: InviteCode
+ validateInviteCode(code: String!): InviteCode
+}
+
+type Mutation {
+ generatePersonalInviteCode(expiresAt: String = null, comment: String = null): InviteCode!
+ generateGroupInviteCode(groupId: ID!, expiresAt: String = null, comment: String = null): InviteCode!
+ invalidateInviteCode(code: String!): InviteCode
+ redeemInviteCode(code: String!): Boolean!
}
diff --git a/backend/src/graphql/types/type/User.gql b/backend/src/graphql/types/type/User.gql
index 83de35c37..7c78b38ec 100644
--- a/backend/src/graphql/types/type/User.gql
+++ b/backend/src/graphql/types/type/User.gql
@@ -72,9 +72,6 @@ type User {
followedBy: [User]! @relation(name: "FOLLOWS", direction: "IN")
followedByCount: Int! @cypher(statement: "MATCH (this)<-[:FOLLOWS]-(r:User) RETURN COUNT(DISTINCT r)")
- inviteCodes: [InviteCode] @relation(name: "GENERATED", direction: "OUT")
- redeemedInviteCode: InviteCode @relation(name: "REDEEMED", direction: "OUT")
-
# Is the currently logged in user following that user?
followedByCurrentUser: Boolean! @cypher(
statement: """
@@ -125,6 +122,7 @@ type User {
categories: [Category] @relation(name: "CATEGORIZED", direction: "OUT")
+ # Badges
badgeVerification: Badge! @neo4j_ignore
badgeTrophies: [Badge]! @relation(name: "REWARDED", direction: "IN")
badgeTrophiesCount: Int! @cypher(statement: "MATCH (this)<-[:REWARDED]-(r:Badge) RETURN COUNT(r)")
@@ -132,6 +130,11 @@ type User {
badgeTrophiesUnused: [Badge]! @neo4j_ignore
badgeTrophiesUnusedCount: Int! @neo4j_ignore
+ "personal inviteCodes the user has generated"
+ inviteCodes: [InviteCode]! @neo4j_ignore
+ # inviteCodes: [InviteCode]! @relation(name: "GENERATED", direction: "OUT")
+ redeemedInviteCode: InviteCode @relation(name: "REDEEMED", direction: "OUT")
+
emotions: [EMOTED]
activeCategories: [String] @cypher(
diff --git a/backend/src/middleware/index.ts b/backend/src/middleware/index.ts
index cc3af6bfc..558b0fdd3 100644
--- a/backend/src/middleware/index.ts
+++ b/backend/src/middleware/index.ts
@@ -15,6 +15,7 @@ import languages from './languages/languages'
import login from './login/loginMiddleware'
import notifications from './notifications/notificationsMiddleware'
import orderBy from './orderByMiddleware'
+// eslint-disable-next-line import/no-cycle
import permissions from './permissionsMiddleware'
import sentry from './sentryMiddleware'
import sluggify from './sluggifyMiddleware'
diff --git a/backend/src/middleware/permissionsMiddleware.spec.ts b/backend/src/middleware/permissionsMiddleware.spec.ts
index e8089b7f3..f7422f59f 100644
--- a/backend/src/middleware/permissionsMiddleware.spec.ts
+++ b/backend/src/middleware/permissionsMiddleware.spec.ts
@@ -1,42 +1,45 @@
-/* eslint-disable @typescript-eslint/require-await */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
+import { ApolloServer } from 'apollo-server-express'
import { createTestClient } from 'apollo-server-testing'
import gql from 'graphql-tag'
import CONFIG from '@config/index'
+import databaseContext from '@context/database'
import Factory, { cleanDatabase } from '@db/factories'
-import { getDriver, getNeode } from '@db/neo4j'
-import createServer from '@src/server'
+import createServer, { getContext } from '@src/server'
-const instance = getNeode()
-const driver = getDriver()
+let variables
+let owner, anotherRegularUser, administrator, moderator
-let query, mutate, variables
-let authenticatedUser, owner, anotherRegularUser, administrator, moderator
+const database = databaseContext()
+
+let server: ApolloServer
+let authenticatedUser
+let query, mutate
+
+beforeAll(async () => {
+ await cleanDatabase()
+
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/require-await
+ const contextUser = async (_req) => authenticatedUser
+ const context = getContext({ user: contextUser, database })
+
+ server = createServer({ context }).server
+
+ const createTestClientResult = createTestClient(server)
+ query = createTestClientResult.query
+ mutate = createTestClientResult.mutate
+})
+
+afterAll(() => {
+ void server.stop()
+ void database.driver.close()
+ database.neode.close()
+})
describe('authorization', () => {
- beforeAll(async () => {
- await cleanDatabase()
-
- const { server } = createServer({
- context: () => ({
- driver,
- instance,
- user: authenticatedUser,
- }),
- })
- query = createTestClient(server).query
- mutate = createTestClient(server).mutate
- })
-
- afterAll(async () => {
- await cleanDatabase()
- await driver.close()
- })
-
- // 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()
})
@@ -109,7 +112,7 @@ describe('authorization', () => {
query({ query: userQuery, variables: { name: 'Owner' } }),
).resolves.toMatchObject({
errors: [{ message: 'Not Authorized!' }],
- data: { User: [null] },
+ data: { User: null },
})
})
})
@@ -242,7 +245,7 @@ describe('authorization', () => {
})
describe('as anyone', () => {
- beforeEach(async () => {
+ beforeEach(() => {
authenticatedUser = null
})
@@ -267,7 +270,7 @@ describe('authorization', () => {
})
describe('as anyone with valid invite code', () => {
- beforeEach(async () => {
+ beforeEach(() => {
variables = {
email: 'some@email.org',
inviteCode: 'ABCDEF',
@@ -287,7 +290,7 @@ describe('authorization', () => {
})
describe('as anyone without valid invite', () => {
- beforeEach(async () => {
+ beforeEach(() => {
variables = {
email: 'some@email.org',
inviteCode: 'no valid invite code',
diff --git a/backend/src/middleware/permissionsMiddleware.ts b/backend/src/middleware/permissionsMiddleware.ts
index 5725b2d98..1a598b972 100644
--- a/backend/src/middleware/permissionsMiddleware.ts
+++ b/backend/src/middleware/permissionsMiddleware.ts
@@ -9,7 +9,9 @@ import { rule, shield, deny, allow, or, and } from 'graphql-shield'
import CONFIG from '@config/index'
import SocialMedia from '@db/models/SocialMedia'
import { getNeode } from '@db/neo4j'
-import { validateInviteCode } from '@graphql/resolvers/transactions/inviteCodes'
+// eslint-disable-next-line import/no-cycle
+import { validateInviteCode } from '@graphql/resolvers/inviteCodes'
+import { Context } from '@src/server'
const debug = !!CONFIG.DEBUG
const allowExternalErrors = true
@@ -370,11 +372,28 @@ const noEmailFilter = rule({
const publicRegistration = rule()(() => CONFIG.PUBLIC_REGISTRATION)
-const inviteRegistration = rule()(async (_parent, args, { _user, driver }) => {
+const inviteRegistration = rule()(async (_parent, args, context: Context) => {
if (!CONFIG.INVITE_REGISTRATION) return false
const { inviteCode } = args
- const session = driver.session()
- return validateInviteCode(session, inviteCode)
+ return validateInviteCode(context, inviteCode)
+})
+
+const isAllowedToGenerateGroupInviteCode = rule({
+ cache: 'no_cache',
+})(async (_parent, args, context: Context) => {
+ if (!context.user) return false
+
+ return !!(
+ await context.database.query({
+ query: `
+ MATCH (user:User{id: user.id})-[membership:MEMBER_OF]->(group:Group {id: $args.groupId})
+ WHERE (group.type IN ['closed','hidden'] AND membership.role IN ['admin', 'owner'])
+ OR (NOT group.type IN ['closed','hidden'] AND NOT membership.role = 'pending')
+ RETURN count(group) as count
+ `,
+ variables: { user: context.user, args },
+ })
+ ).records[0].get('count')
})
// Permissions
@@ -399,7 +418,7 @@ export default shield(
Post: allow,
profilePagePosts: allow,
Comment: allow,
- User: or(noEmailFilter, isAdmin),
+ User: and(isAuthenticated, or(noEmailFilter, isAdmin)),
Badge: allow,
PostsEmotionsCountByEmotion: allow,
PostsEmotionsByCurrentUser: isAuthenticated,
@@ -408,15 +427,15 @@ export default shield(
notifications: isAuthenticated,
Donations: isAuthenticated,
userData: isAuthenticated,
- MyInviteCodes: isAuthenticated,
- isValidInviteCode: allow,
VerifyNonce: allow,
queryLocations: isAuthenticated,
availableRoles: isAdmin,
- getInviteCode: isAuthenticated, // and inviteRegistration
Room: isAuthenticated,
Message: isAuthenticated,
UnreadRooms: isAuthenticated,
+
+ // Invite Code
+ validateInviteCode: allow,
},
Mutation: {
'*': deny,
@@ -465,7 +484,13 @@ export default shield(
pinPost: isAdmin,
unpinPost: isAdmin,
UpdateDonations: isAdmin,
- GenerateInviteCode: isAuthenticated,
+
+ // InviteCode
+ generatePersonalInviteCode: isAuthenticated,
+ generateGroupInviteCode: isAllowedToGenerateGroupInviteCode,
+ invalidateInviteCode: isAuthenticated,
+ redeemInviteCode: isAuthenticated,
+
switchUserRole: isAdmin,
markTeaserAsViewed: allow,
saveCategorySettings: isAuthenticated,
@@ -480,8 +505,27 @@ export default shield(
resetTrophyBadgesSelected: isAuthenticated,
},
User: {
+ '*': isAuthenticated,
+ name: allow,
+ avatar: allow,
email: or(isMyOwn, isAdmin),
emailNotificationSettings: isMyOwn,
+ inviteCodes: isMyOwn,
+ },
+ Group: {
+ '*': isAuthenticated, // TODO - only those who are allowed to see the group
+ avatar: allow,
+ name: allow,
+ about: allow,
+ groupType: allow,
+ },
+ InviteCode: {
+ '*': allow,
+ redeemedBy: isAuthenticated, // TODO only for self generated, must be done in resolver
+ redeemedByCount: isAuthenticated, // TODO only for self generated, must be done in resolver
+ createdAt: isAuthenticated, // TODO only for self generated, must be done in resolver
+ expiresAt: isAuthenticated, // TODO only for self generated, must be done in resolver
+ comment: isAuthenticated, // TODO only for self generated, must be done in resolver
},
Location: {
distanceToMe: isAuthenticated,
diff --git a/backend/src/middleware/slugifyMiddleware.spec.ts b/backend/src/middleware/slugifyMiddleware.spec.ts
index 75a52e4cf..f40c2064a 100644
--- a/backend/src/middleware/slugifyMiddleware.spec.ts
+++ b/backend/src/middleware/slugifyMiddleware.spec.ts
@@ -2,47 +2,46 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
+import { ApolloServer } from 'apollo-server-express'
import { createTestClient } from 'apollo-server-testing'
+import databaseContext from '@context/database'
import Factory, { cleanDatabase } from '@db/factories'
-import { getNeode, getDriver } from '@db/neo4j'
import { createGroupMutation } from '@graphql/queries/createGroupMutation'
import { createPostMutation } from '@graphql/queries/createPostMutation'
import { signupVerificationMutation } from '@graphql/queries/signupVerificationMutation'
import { updateGroupMutation } from '@graphql/queries/updateGroupMutation'
-import createServer from '@src/server'
+import createServer, { getContext } from '@src/server'
-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,
- cypherParams: {
- currentUserId: authenticatedUser ? authenticatedUser.id : null,
- },
- }
- },
-})
+const database = databaseContext()
-const { mutate } = createTestClient(server)
+let server: ApolloServer
+let authenticatedUser
+let mutate
beforeAll(async () => {
await cleanDatabase()
+
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/require-await
+ const contextUser = async (_req) => authenticatedUser
+ const context = getContext({ user: contextUser, database })
+
+ server = createServer({ context }).server
+
+ const createTestClientResult = createTestClient(server)
+ mutate = createTestClientResult.mutate
})
-afterAll(async () => {
- await cleanDatabase()
- await driver.close()
+afterAll(() => {
+ void server.stop()
+ void database.driver.close()
+ database.neode.close()
})
beforeEach(async () => {
diff --git a/backend/src/server.ts b/backend/src/server.ts
index 1f98aab2d..f56b01f34 100644
--- a/backend/src/server.ts
+++ b/backend/src/server.ts
@@ -19,6 +19,7 @@ import pubsubContext from '@context/pubsub'
import CONFIG from './config'
import schema from './graphql/schema'
import decode from './jwt/decode'
+// eslint-disable-next-line import/no-cycle
import middleware from './middleware'
const serverDatabase = databaseContext()
diff --git a/webapp/components/Chat/Chat.vue b/webapp/components/Chat/Chat.vue
index faffa45e0..3a52982a5 100644
--- a/webapp/components/Chat/Chat.vue
+++ b/webapp/components/Chat/Chat.vue
@@ -17,6 +17,7 @@
:loading-rooms="loadingRooms"
show-files="false"
show-audio="false"
+ :height="'calc(100dvh - 190px)'"
:styles="JSON.stringify(computedChatStyle)"
:show-footer="true"
:responsive-breakpoint="responsiveBreakpoint"
diff --git a/webapp/components/HeaderMenu/HeaderMenu.vue b/webapp/components/HeaderMenu/HeaderMenu.vue
index 26e6aede7..78813f51b 100644
--- a/webapp/components/HeaderMenu/HeaderMenu.vue
+++ b/webapp/components/HeaderMenu/HeaderMenu.vue
@@ -136,12 +136,12 @@