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 @@ -
+
-
- +
+
@@ -284,7 +284,7 @@ import { mapGetters } from 'vuex' import isEmpty from 'lodash/isEmpty' import { SHOW_GROUP_BUTTON_IN_HEADER } from '~/constants/groups.js' import { SHOW_CONTENT_FILTER_HEADER_MENU } from '~/constants/filter.js' -import LOGOS from '~/constants/logos.js' +import LOGOS from '~/constants/logosBranded.js' import AvatarMenu from '~/components/AvatarMenu/AvatarMenu' import ChatNotificationMenu from '~/components/ChatNotificationMenu/ChatNotificationMenu' import CustomButton from '~/components/CustomButton/CustomButton' @@ -409,6 +409,25 @@ export default { flex-flow: row nowrap; align-items: center; justify-content: flex-end; + + & > div { + display: inline-flex; + + padding-right: 15px; + &:first-child { + padding-right: 10px; + } + + button { + overflow: visible; + .svg { + height: 1.8em; + } + } + } + .hamburger-button .svg { + height: 1.5em; + } } .mobile-menu { margin: 0 20px; diff --git a/webapp/components/Logo/Logo.vue b/webapp/components/Logo/Logo.vue index fee0d9140..b49bfebfb 100644 --- a/webapp/components/Logo/Logo.vue +++ b/webapp/components/Logo/Logo.vue @@ -1,29 +1,30 @@ + + diff --git a/webapp/components/_new/generic/BaseCard/BaseCard.story.js b/webapp/components/_new/generic/BaseCard/BaseCard.story.js index 928aba5e6..87c905fda 100644 --- a/webapp/components/_new/generic/BaseCard/BaseCard.story.js +++ b/webapp/components/_new/generic/BaseCard/BaseCard.story.js @@ -1,6 +1,6 @@ import { storiesOf } from '@storybook/vue' import helpers from '~/storybook/helpers' -import logos from '~/constants/logos.js' +import logos from '~/constants/logosBranded.js' import BaseCard from './BaseCard.vue' storiesOf('Generic/BaseCard', module) diff --git a/webapp/constants/logosBranded.js b/webapp/constants/logosBranded.js new file mode 100644 index 000000000..25d1541a6 --- /dev/null +++ b/webapp/constants/logosBranded.js @@ -0,0 +1,31 @@ +// this file is duplicated in `backend/src/config/logos.js` and `webapp/constants/logos.js` and replaced on rebranding +// this are the paths in the webapp +import { merge } from 'lodash' +import logos from '~/constants/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/webapp/layouts/basic.vue b/webapp/layouts/basic.vue index 5eadb42af..ac0e63114 100644 --- a/webapp/layouts/basic.vue +++ b/webapp/layouts/basic.vue @@ -23,7 +23,7 @@
-
+
@@ -61,7 +61,7 @@ export default { } - diff --git a/webapp/locales/de.json b/webapp/locales/de.json index c2646a742..fb6949b7d 100644 --- a/webapp/locales/de.json +++ b/webapp/locales/de.json @@ -65,7 +65,7 @@ "tagCountUnique": "Nutzer" }, "invites": { - "description": "Einladungen sind eine wunderbare Möglichkeit, Deine Freunde in Deinem Netzwerk zu haben …", + "description": "Einladungen sind eine wunderbare Möglichkeit, deine Freunde in deinem Netzwerk zu haben …", "name": "Nutzer einladen", "title": "Leute einladen" }, @@ -109,7 +109,7 @@ "isOnline": "online", "isTyping": "tippt...", "lastSeen": "zuletzt gesehen ", - "messageDeleted": "Diese Nachricht wuerde gelöscht", + "messageDeleted": "Diese Nachricht wurde gelöscht", "messagesEmpty": "Keine Nachrichten", "newMessages": "Neue Nachrichten", "page": { @@ -222,7 +222,7 @@ "buttonTitle": "Weiter", "form": { "click-next": "Click auf Weiter.", - "description": "Öffne Dein E-Mail Postfach und gib den Code ein, den wir geschickt haben.", + "description": "Öffne dein E-Mail Postfach und gib den Code ein, den wir geschickt haben.", "next": "Weiter", "nonce": "E-Mail-Code: 32143", "validations": { @@ -252,12 +252,12 @@ "signup": { "form": { "data-privacy": "Ich habe die Datenschutzerklärung gelesen und verstanden.", - "description": "Um loszulegen, kannst Du Dich hier kostenfrei registrieren:", + "description": "Um loszulegen, kannst du dich hier kostenfrei registrieren:", "errors": { "email-exists": "Es gibt schon ein Nutzerkonto mit dieser E-Mail-Adresse!" }, "submit": "Konto erstellen", - "success": "Eine E-Mail mit einem Link zum Abschließen Deiner Registrierung wurde an {email} geschickt", + "success": "Eine E-Mail mit einem Link zum Abschließen deiner Registrierung wurde an {email} geschickt", "terms-and-condition": "Ich stimme den Nutzungsbedingungen zu." }, "title": "Mach mit bei {APPLICATION_NAME}!", @@ -269,7 +269,7 @@ "amount-clicks": "{amount} Klicks", "amount-comments": "{amount} Kommentare", "amount-shouts": "{amount} Empfehlungen", - "amount-views": "{amount} Aurufe", + "amount-views": "{amount} Aufrufe", "categories": { "infoSelectedNoOfMaxCategories": "{chosen} von {max} Themen ausgewählt" }, @@ -335,9 +335,9 @@ }, "filterMyGroups": "Meine Gruppen", "inappropriatePicture": "Dieses Bild kann für einige Menschen unangemessen sein.", - "languageSelectLabel": "Sprache Deines Beitrags", + "languageSelectLabel": "Sprache deines Beitrags", "languageSelectText": "Sprache wählen", - "newEvent": "Erstelle einen neue Veranstaltung", + "newEvent": "Erstelle eine neue Veranstaltung", "newPost": "Erstelle einen neuen Beitrag", "success": "Gespeichert!", "teaserImage": { @@ -355,13 +355,13 @@ "delete": { "cancel": "Abbrechen", "comment": { - "message": "Bist Du sicher, dass Du den Kommentar „{name}“ löschen möchtest?", + "message": "Bist du sicher, dass du den Kommentar „{name}“ löschen möchtest?", "success": "Kommentar erfolgreich gelöscht!", "title": "Lösche Kommentar", "type": "Kommentar" }, "contribution": { - "message": "Bist Du sicher, dass Du den Beitrag „{name}“ löschen möchtest?", + "message": "Bist du sicher, dass du den Beitrag „{name}“ löschen möchtest?", "success": "Beitrag erfolgreich gelöscht!", "title": "Lösche Beitrag", "type": "Beitrag" @@ -371,19 +371,19 @@ "disable": { "cancel": "Abbrechen", "comment": { - "message": "Bist Du sicher, dass Du den Kommentar „{name}“ deaktivieren möchtest?", + "message": "Bist du sicher, dass du den Kommentar „{name}“ deaktivieren möchtest?", "title": "Kommentar sperren", "type": "Kommentar" }, "contribution": { - "message": "Bist Du sicher, dass Du den Beitrag von „{name}“ deaktivieren möchtest?", + "message": "Bist du sicher, dass du den Beitrag von „{name}“ deaktivieren möchtest?", "title": "Beitrag sperren", "type": "Beitrag" }, "submit": "Deaktivieren", "success": "Erfolgreich deaktiviert", "user": { - "message": "Bist Du sicher, dass Du den Nutzer „{name}“ sperren möchtest?", + "message": "Bist du sicher, dass du den Nutzer „{name}“ sperren möchtest?", "title": "Nutzer sperren", "type": "Nutzer" } @@ -395,8 +395,8 @@ "editor": { "embed": { "always_allow": "Einzubettende Inhalte von Drittanbietern immer erlauben (diese Einstellung ist jederzeit änderbar)", - "data_privacy_info": "Deine Daten wurden noch nicht an Drittanbieter weitergegeben. Wenn Du diesen Inhalt jetzt abspielst, registriert der folgende Anbieter wahrscheinlich Deine Nutzerdaten:", - "data_privacy_warning": "Achte auf Deine Daten!", + "data_privacy_info": "Deine Daten wurden noch nicht an Drittanbieter weitergegeben. Wenn du diesen Inhalt jetzt abspielst, registriert der folgende Anbieter wahrscheinlich deine Nutzerdaten:", + "data_privacy_warning": "Achte auf deine Daten!", "play_now": "Jetzt ansehen" }, "hashtag": { @@ -656,7 +656,7 @@ }, "maintenance": { "explanation": "Derzeit führen wir einige geplante Wartungsarbeiten durch, bitte versuche es später erneut.", - "questions": "Bei Fragen oder Problemen erreichst Du uns per E-Mail an", + "questions": "Bei Fragen oder Problemen erreichst du uns per E-Mail an", "title": "{APPLICATION_NAME} befindet sich in der Wartung" }, "map": { @@ -697,32 +697,32 @@ "cancel": "Abbruch", "Comment": { "disable": { - "message": "Möchtest Du den Kommentar „{name}“ wirklich gesperrt lassen?", + "message": "Möchtest du den Kommentar „{name}“ wirklich gesperrt lassen?", "title": "Sperre den Kommentar abschließend" }, "enable": { - "message": "Möchtest Du den Kommentar „{name}“ wirklich entsperrt lassen?", + "message": "Möchtest du den Kommentar „{name}“ wirklich entsperrt lassen?", "title": "Entsperre den Kommentar abschließend" } }, "Post": { "disable": { - "message": "Möchtest Du den Beitrag „{name}“ wirklich gesperrt lassen?", + "message": "Möchtest du den Beitrag „{name}“ wirklich gesperrt lassen?", "title": "Sperre den Beitrag abschließend" }, "enable": { - "message": "Möchtest Du den Beitrag „{name}“ wirklich entsperrt lassen?", + "message": "Möchtest du den Beitrag „{name}“ wirklich entsperrt lassen?", "title": "Entsperre den Beitrag abschließend" } }, "submit": "Bestätige Entscheidung", "User": { "disable": { - "message": "Möchtest Du den Nutzer „{name}“ wirklich gesperrt lassen?", + "message": "Möchtest du den Nutzer „{name}“ wirklich gesperrt lassen?", "title": "Sperre den Nutzer abschließend" }, "enable": { - "message": "Möchtest Du den Nutzer „{name}“ wirklich entsperrt lassen?", + "message": "Möchtest du den Nutzer „{name}“ wirklich entsperrt lassen?", "title": "Entsperre den Nutzer abschließend" } } @@ -757,7 +757,7 @@ "notifications": { "comment": "Kommentar", "content": "Inhalt oder Beschreibung", - "empty": "Bedaure, Du hast momentan keinerlei Benachrichtigungen.", + "empty": "Bedaure, du hast momentan keinerlei Benachrichtigungen.", "filterLabel": { "all": "Alle", "read": "Gelesen", @@ -768,14 +768,14 @@ "pageLink": "Alle Benachrichtigungen", "post": "Beitrag oder Gruppe", "reason": { - "changed_group_member_role": "Hat Deine Rolle in der Gruppe geändert …", + "changed_group_member_role": "Hat deine Rolle in der Gruppe geändert …", "commented_on_post": "Hat einen Beitrag den du beobachtest kommentiert …", "followed_user_posted": "Hat einen neuen Betrag geschrieben …", - "mentioned_in_comment": "Hat Dich in einem Kommentar erwähnt …", - "mentioned_in_post": "Hat Dich in einem Beitrag erwähnt …", + "mentioned_in_comment": "Hat dich in einem Kommentar erwähnt …", + "mentioned_in_post": "Hat dich in einem Beitrag erwähnt …", "post_in_group": "Hat einen Beitrag in der Gruppe geschrieben …", - "removed_user_from_group": "Hat Dich aus der Gruppe entfernt …", - "user_joined_group": "Ist Deiner Gruppe beigetreten …", + "removed_user_from_group": "Hat dich aus der Gruppe entfernt …", + "user_joined_group": "Ist deiner Gruppe beigetreten …", "user_left_group": "Hat deine Gruppe verlassen …" }, "title": "Benachrichtigungen", @@ -879,22 +879,22 @@ "release": { "cancel": "Abbrechen", "comment": { - "error": "Den Kommentar hast Du schon gemeldet!", - "message": "Bist Du sicher, dass Du den Kommentar „{name}“ freigeben möchtest?", + "error": "Den Kommentar hast du schon gemeldet!", + "message": "Bist du sicher, dass du den Kommentar „{name}“ freigeben möchtest?", "title": "Kommentar freigeben", "type": "Kommentar" }, "contribution": { - "error": "Den Beitrag hast Du schon gemeldet!", - "message": "Bist Du sicher, dass Du den Beitrag „{name}“ freigeben möchtest?", + "error": "Den Beitrag hast du schon gemeldet!", + "message": "Bist du sicher, dass du den Beitrag „{name}“ freigeben möchtest?", "title": "Beitrag freigeben", "type": "Beitrag" }, "submit": "freigeben", "success": "Erfolgreich freigegeben!", "user": { - "error": "Den Nutzer hast Du schon gemeldet!", - "message": "Bist Du sicher, dass Du den Nutzer „{name}“ freigeben möchtest?", + "error": "Den Nutzer hast du schon gemeldet!", + "message": "Bist du sicher, dass du den Nutzer „{name}“ freigeben möchtest?", "title": "Nutzer freigeben", "type": "Nutzer" } @@ -903,13 +903,13 @@ "cancel": "Abbrechen", "comment": { "error": "Du hast den Kommentar bereits gemeldet!", - "message": "Bist Du sicher, dass Du den Kommentar von „{name}“ melden möchtest?", + "message": "Bist du sicher, dass du den Kommentar von „{name}“ melden möchtest?", "title": "Kommentar melden", "type": "Kommentar" }, "contribution": { "error": "Du hast den Beitrag bereits gemeldet!", - "message": "Bist Du sicher, dass Du den Beitrag „{name}“ melden möchtest?", + "message": "Bist du sicher, dass du den Beitrag „{name}“ melden möchtest?", "title": "Beitrag melden", "type": "Beitrag" }, @@ -930,7 +930,7 @@ "placeholder": "Thema …" }, "description": { - "label": "Bitte erkläre: Warum möchtest Du dies melden?", + "label": "Bitte erkläre: Warum möchtest du dies melden?", "placeholder": "Zusätzliche Information …" } }, @@ -938,7 +938,7 @@ "success": "Vielen Dank für diese Meldung!", "user": { "error": "Du hast den Nutzer bereits gemeldet!", - "message": "Bist Du sicher, dass Du den Nutzer „{name}“ melden möchtest?", + "message": "Bist du sicher, dass du den Nutzer „{name}“ melden möchtest?", "title": "Nutzer melden", "type": "Nutzer" } @@ -977,15 +977,15 @@ "slug": "Alias", "unblock": "Entsperren" }, - "empty": "Bislang hast Du niemanden blockiert.", + "empty": "Bislang hast du niemanden blockiert.", "explanation": { - "closing": "Das sollte fürs Erste genügen, damit blockierte Nutzer Dich nicht mehr länger belästigen können.", + "closing": "Das sollte fürs Erste genügen, damit blockierte Nutzer dich nicht mehr länger belästigen können.", "commenting-disabled": "Du kannst den Beitrag derzeit nicht kommentieren.", "commenting-explanation": "Dafür kann es mehrere Gründe geben, bitte schau in unsere ", - "intro": "Wenn ein anderer Nutzer durch Dich blockiert wurde, dann passiert Folgendes:", - "notifications": "Von Dir blockierte Nutzer werden keine Benachrichtigungen mehr erhalten, falls sie in Deinen Beiträgen erwähnt werden.", - "their-perspective": "Umgekehrt das gleiche: Die blockierte Person bekommt auch in ihren Benachrichtigungen Deine Beiträge nicht mehr zu sehen.", - "your-perspective": "In Deinen Benachrichtigungen tauchen keine Beiträge der blockierten Person mehr auf." + "intro": "Wenn ein anderer Nutzer durch dich blockiert wurde, dann passiert Folgendes:", + "notifications": "Von Dir blockierte Nutzer werden keine Benachrichtigungen mehr erhalten, falls sie in deinen Beiträgen erwähnt werden.", + "their-perspective": "Umgekehrt das gleiche: Die blockierte Person bekommt auch in ihren Benachrichtigungen deine Beiträge nicht mehr zu sehen.", + "your-perspective": "In deinen Benachrichtigungen tauchen keine Beiträge der blockierten Person mehr auf." }, "how-to": "Du kannst andere Nutzer auf deren Profilseite über das Inhaltsmenü blockieren.", "name": "Blockierte Nutzer", @@ -993,7 +993,7 @@ "unblocked": "{name} ist wieder entsperrt" }, "data": { - "labelBio": "Über Dich", + "labelBio": "Über dich", "labelCity": "Deine Stadt oder Region", "labelCityHint": "(zeigt ungefähre Position auf der Landkarte)", "labelName": "Dein Name", @@ -1026,17 +1026,17 @@ "labelNewEmail": "Neue E-Mail-Adresse", "labelNonce": "Bestätigungscode eingeben", "name": "Deine E-Mail", - "submitted": "Eine E-Mail zur Bestätigung Deiner Adresse wurde an {email} gesendet.", + "submitted": "Eine E-Mail zur Bestätigung deiner Adresse wurde an {email} gesendet.", "success": "Eine neue E-Mail-Adresse wurde registriert.", "validation": { - "same-email": "Das ist Deine aktuelle E-Mail-Adresse" + "same-email": "Das ist deine aktuelle E-Mail-Adresse" }, "verification-error": { "explanation": "Das kann verschiedene Ursachen haben:", "message": "Deine E-Mail-Adresse konnte nicht verifiziert werden.", "reason": { "invalid-nonce": "Ist der Bestätigungscode falsch?", - "no-email-request": "Bist Du Dir sicher, dass Du eine Änderung Deiner E-Mail-Adresse angefragt hattest?" + "no-email-request": "Bist du Dir sicher, dass du eine Änderung deiner E-Mail-Adresse angefragt hattest?" }, "support": "Wenn das Problem weiterhin besteht, kontaktiere uns gerne per E-Mail an" } @@ -1114,12 +1114,12 @@ "change-password": { "button": "Passwort ändern", "label-new-password": "Dein neues Passwort", - "label-new-password-confirm": "Bestätige Dein neues Passwort", + "label-new-password-confirm": "Bestätige dein neues Passwort", "label-old-password": "Dein altes Passwort", - "message-new-password-confirm-required": "Bestätige Dein neues Passwort", + "message-new-password-confirm-required": "Bestätige dein neues Passwort", "message-new-password-missmatch": "Gib dasselbe Passwort nochmals ein", "message-new-password-required": "Gib ein neues Passwort ein", - "message-old-password-required": "Gib Dein altes Passwort ein", + "message-old-password-required": "Gib dein altes Passwort ein", "passwordSecurity": "Passwortsicherheit", "passwordStrength0": "Sehr unsicheres Passwort", "passwordStrength1": "Unsicheres Passwort", diff --git a/webapp/pages/chat.vue b/webapp/pages/chat.vue index 9f93fabcb..0bb4def38 100644 --- a/webapp/pages/chat.vue +++ b/webapp/pages/chat.vue @@ -1,12 +1,10 @@