From 290a17640723fc1bc04360d7b6c4c81fc22058bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Wed, 7 May 2025 21:57:35 +0800 Subject: [PATCH 1/6] refactor(backend): types for global config (#8485) * refactor(backend): types for global config I saw merge conflicts in these files for #8463 so let's get some parts of this PR into `master` already. I believe this fixes a small bug. They guard clause didn't ensure that all of REDIS_ configurations were set. * remove old email mechanism * refactor(backend: react to @ulfgebhardt's review See: https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/8485#pullrequestreview-2813528327 * build(backend): optional commit @ulfgebhardt this is how I tested the configurations. We don't need to include this commit but I wouldn't expect to send out real emails from a `docker-compose` setup. --------- Co-authored-by: Moriz Wahl --- backend/src/config/index.ts | 75 +++-- backend/src/context/pubsub.ts | 10 +- .../20200312140328-bulk_upload_to_s3.ts | 117 -------- backend/src/db/neo4j.ts | 3 - backend/src/emails/sendEmail.ts | 26 +- .../src/graphql/resolvers/images/images.ts | 6 + backend/src/index.ts | 2 +- .../src/middleware/helpers/email/sendMail.ts | 80 ----- .../helpers/email/templateBuilder.spec.ts | 283 ------------------ .../helpers/email/templateBuilder.ts | 140 --------- .../helpers/email/templates/chatMessage.html | 105 ------- .../helpers/email/templates/de/index.ts | 9 - .../email/templates/de/notification.html | 83 ----- .../email/templates/emailVerification.html | 186 ------------ .../helpers/email/templates/en/index.ts | 9 - .../email/templates/en/notification.html | 83 ----- .../helpers/email/templates/index.ts | 15 - .../helpers/email/templates/layout.html | 197 ------------ .../email/templates/resetPassword.html | 185 ------------ .../helpers/email/templates/signup.html | 214 ------------- .../helpers/email/templates/wrongAccount.html | 185 ------------ backend/src/middleware/index.ts | 4 +- docker-compose.override.yml | 2 + 23 files changed, 67 insertions(+), 1952 deletions(-) delete mode 100644 backend/src/db/migrations-examples/20200312140328-bulk_upload_to_s3.ts delete mode 100644 backend/src/middleware/helpers/email/sendMail.ts delete mode 100644 backend/src/middleware/helpers/email/templateBuilder.spec.ts delete mode 100644 backend/src/middleware/helpers/email/templateBuilder.ts delete mode 100644 backend/src/middleware/helpers/email/templates/chatMessage.html delete mode 100644 backend/src/middleware/helpers/email/templates/de/index.ts delete mode 100644 backend/src/middleware/helpers/email/templates/de/notification.html delete mode 100644 backend/src/middleware/helpers/email/templates/emailVerification.html delete mode 100644 backend/src/middleware/helpers/email/templates/en/index.ts delete mode 100644 backend/src/middleware/helpers/email/templates/en/notification.html delete mode 100644 backend/src/middleware/helpers/email/templates/index.ts delete mode 100644 backend/src/middleware/helpers/email/templates/layout.html delete mode 100644 backend/src/middleware/helpers/email/templates/resetPassword.html delete mode 100644 backend/src/middleware/helpers/email/templates/signup.html delete mode 100644 backend/src/middleware/helpers/email/templates/wrongAccount.html diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index e50f96dd2..658c7e97c 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -1,8 +1,10 @@ /* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ + /* eslint-disable n/no-process-env */ import { config } from 'dotenv' +// eslint-disable-next-line import/no-namespace +import * as SMTPTransport from 'nodemailer/lib/smtp-pool' import emails from './emails' import metadata from './metadata' @@ -13,16 +15,17 @@ config() // Use Cypress env or process.env // eslint-disable-next-line @typescript-eslint/no-explicit-any declare let Cypress: any | undefined -const env = typeof Cypress !== 'undefined' ? Cypress.env() : process.env +const env = (typeof Cypress !== 'undefined' ? Cypress.env() : process.env) as typeof process.env const environment = { - NODE_ENV: env.NODE_ENV || process.env.NODE_ENV, + NODE_ENV: env.NODE_ENV ?? process.env.NODE_ENV, DEBUG: env.NODE_ENV !== 'production' && env.DEBUG, TEST: env.NODE_ENV === 'test', PRODUCTION: env.NODE_ENV === 'production', // used for staging enviroments if 'PRODUCTION=true' and 'PRODUCTION_DB_CLEAN_ALLOW=true' PRODUCTION_DB_CLEAN_ALLOW: env.PRODUCTION_DB_CLEAN_ALLOW === 'true' || false, // default = false - DISABLED_MIDDLEWARES: ['test', 'development'].includes(env.NODE_ENV as string) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + DISABLED_MIDDLEWARES: ['test', 'development'].includes(env.NODE_ENV!) ? (env.DISABLED_MIDDLEWARES?.split(',') ?? []) : [], SEND_MAIL: env.NODE_ENV !== 'test', @@ -35,32 +38,51 @@ const required = { } const server = { - CLIENT_URI: env.CLIENT_URI || 'http://localhost:3000', - GRAPHQL_URI: env.GRAPHQL_URI || 'http://localhost:4000', - JWT_EXPIRES: env.JWT_EXPIRES || '2y', + CLIENT_URI: env.CLIENT_URI ?? 'http://localhost:3000', + GRAPHQL_URI: env.GRAPHQL_URI ?? 'http://localhost:4000', + JWT_EXPIRES: env.JWT_EXPIRES ?? '2y', } -const hasDKIMData = env.SMTP_DKIM_DOMAINNAME && env.SMTP_DKIM_KEYSELECTOR && env.SMTP_DKIM_PRIVATKEY +const SMTP_HOST = env.SMTP_HOST +const SMTP_PORT = (env.SMTP_PORT && parseInt(env.SMTP_PORT)) || undefined +const SMTP_IGNORE_TLS = env.SMTP_IGNORE_TLS !== 'false' // default = true +const SMTP_SECURE = env.SMTP_SECURE === 'true' +const SMTP_USERNAME = env.SMTP_USERNAME +const SMTP_PASSWORD = env.SMTP_PASSWORD +const SMTP_DKIM_DOMAINNAME = env.SMTP_DKIM_DOMAINNAME +const SMTP_DKIM_KEYSELECTOR = env.SMTP_DKIM_KEYSELECTOR +// PEM format = https://docs.progress.com/bundle/datadirect-hybrid-data-pipeline-installation-46/page/PEM-file-format.html +const SMTP_DKIM_PRIVATKEY = env.SMTP_DKIM_PRIVATKEY?.replace(/\\n/g, '\n') // replace all "\n" in .env string by real line break +const SMTP_MAX_CONNECTIONS = (env.SMTP_MAX_CONNECTIONS && parseInt(env.SMTP_MAX_CONNECTIONS)) || 5 +const SMTP_MAX_MESSAGES = (env.SMTP_MAX_MESSAGES && parseInt(env.SMTP_MAX_MESSAGES)) || 100 -const smtp = { - SMTP_HOST: env.SMTP_HOST, - SMTP_PORT: env.SMTP_PORT, - SMTP_IGNORE_TLS: env.SMTP_IGNORE_TLS !== 'false', // default = true - SMTP_SECURE: env.SMTP_SECURE === 'true', - SMTP_USERNAME: env.SMTP_USERNAME, - SMTP_PASSWORD: env.SMTP_PASSWORD, - SMTP_DKIM_DOMAINNAME: hasDKIMData && env.SMTP_DKIM_DOMAINNAME, - SMTP_DKIM_KEYSELECTOR: hasDKIMData && env.SMTP_DKIM_KEYSELECTOR, - // PEM format: https://docs.progress.com/bundle/datadirect-hybrid-data-pipeline-installation-46/page/PEM-file-format.html - SMTP_DKIM_PRIVATKEY: hasDKIMData && env.SMTP_DKIM_PRIVATKEY.replace(/\\n/g, '\n'), // replace all "\n" in .env string by real line break - SMTP_MAX_CONNECTIONS: env.SMTP_MAX_CONNECTIONS || 5, - SMTP_MAX_MESSAGES: env.SMTP_MAX_MESSAGES || 100, +const nodemailerTransportOptions: SMTPTransport.Options = { + host: SMTP_HOST, + port: SMTP_PORT, + ignoreTLS: SMTP_IGNORE_TLS, + secure: SMTP_SECURE, // true for 465, false for other ports + pool: true, + maxConnections: SMTP_MAX_CONNECTIONS, + maxMessages: SMTP_MAX_MESSAGES, +} +if (SMTP_USERNAME && SMTP_PASSWORD) { + nodemailerTransportOptions.auth = { + user: SMTP_USERNAME, + pass: SMTP_PASSWORD, + } +} +if (SMTP_DKIM_DOMAINNAME && SMTP_DKIM_KEYSELECTOR && SMTP_DKIM_PRIVATKEY) { + nodemailerTransportOptions.dkim = { + domainName: SMTP_DKIM_DOMAINNAME, + keySelector: SMTP_DKIM_KEYSELECTOR, + privateKey: SMTP_DKIM_PRIVATKEY, + } } const neo4j = { - NEO4J_URI: env.NEO4J_URI || 'bolt://localhost:7687', - NEO4J_USERNAME: env.NEO4J_USERNAME || 'neo4j', - NEO4J_PASSWORD: env.NEO4J_PASSWORD || 'neo4j', + NEO4J_URI: env.NEO4J_URI ?? 'bolt://localhost:7687', + NEO4J_USERNAME: env.NEO4J_USERNAME ?? 'neo4j', + NEO4J_PASSWORD: env.NEO4J_PASSWORD ?? 'neo4j', } const sentry = { @@ -70,7 +92,7 @@ const sentry = { const redis = { REDIS_DOMAIN: env.REDIS_DOMAIN, - REDIS_PORT: env.REDIS_PORT, + REDIS_PORT: (env.REDIS_PORT && parseInt(env.REDIS_PORT)) || undefined, REDIS_PASSWORD: env.REDIS_PASSWORD, } @@ -110,10 +132,11 @@ export default { ...environment, ...server, ...required, - ...smtp, ...neo4j, ...sentry, ...redis, ...s3, ...options, } + +export { nodemailerTransportOptions } diff --git a/backend/src/context/pubsub.ts b/backend/src/context/pubsub.ts index 003347b16..3d99bba6d 100644 --- a/backend/src/context/pubsub.ts +++ b/backend/src/context/pubsub.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { RedisPubSub } from 'graphql-redis-subscriptions' import { PubSub } from 'graphql-subscriptions' import Redis from 'ioredis' @@ -6,14 +5,15 @@ import Redis from 'ioredis' import CONFIG from '@config/index' export default () => { - if (!CONFIG.REDIS_DOMAIN || CONFIG.REDIS_PORT || CONFIG.REDIS_PASSWORD) { + const { REDIS_DOMAIN, REDIS_PORT, REDIS_PASSWORD } = CONFIG + if (!(REDIS_DOMAIN && REDIS_PORT && REDIS_PASSWORD)) { return new PubSub() } const options = { - host: CONFIG.REDIS_DOMAIN, - port: CONFIG.REDIS_PORT, - password: CONFIG.REDIS_PASSWORD, + host: REDIS_DOMAIN, + port: REDIS_PORT, + password: REDIS_PASSWORD, retryStrategy: (times) => { return Math.min(times * 50, 2000) }, diff --git a/backend/src/db/migrations-examples/20200312140328-bulk_upload_to_s3.ts b/backend/src/db/migrations-examples/20200312140328-bulk_upload_to_s3.ts deleted file mode 100644 index 0307a2e6e..000000000 --- a/backend/src/db/migrations-examples/20200312140328-bulk_upload_to_s3.ts +++ /dev/null @@ -1,117 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unsafe-argument */ -/* eslint-disable @typescript-eslint/no-unsafe-return */ -/* 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-fs-filename */ -import https from 'https' -import { existsSync, createReadStream } from 'node:fs' -import path from 'node:path' - -import { S3 } from 'aws-sdk' -import mime from 'mime-types' - -import s3Configs from '@config/index' -import { getDriver } from '@db/neo4j' - -export const description = ` -Upload all image files to a S3 compatible object storage in order to reduce -load on our backend. -` - -export async function up(next) { - const driver = getDriver() - const session = driver.session() - const transaction = session.beginTransaction() - const agent = new https.Agent({ - maxSockets: 5, - }) - - const { - AWS_ENDPOINT: endpoint, - AWS_REGION: region, - AWS_BUCKET: Bucket, - S3_CONFIGURED, - } = s3Configs - - if (!S3_CONFIGURED) { - // eslint-disable-next-line no-console - console.log('No S3 given, cannot upload image files') - return - } - - const s3 = new S3({ region, endpoint, httpOptions: { agent } }) - try { - // Implement your migration here. - const { records } = await transaction.run('MATCH (image:Image) RETURN image.url as url') - let urls = records.map((r) => r.get('url')) - urls = urls.filter((url) => url.startsWith('/uploads')) - const locations = await Promise.all( - urls - .map((url) => { - return async () => { - const { pathname } = new URL(url, 'http://example.org') - const fileLocation = path.join(__dirname, `../../../public/${pathname}`) - const s3Location = `original${pathname}` - // eslint-disable-next-line n/no-sync - if (existsSync(fileLocation)) { - const mimeType = mime.lookup(fileLocation) - const params = { - Bucket, - Key: s3Location, - ACL: 'public-read', - ContentType: mimeType || 'image/jpeg', - Body: createReadStream(fileLocation), - } - - const data = await s3.upload(params).promise() - const { Location: spacesUrl } = data - - const updatedRecord = await transaction.run( - 'MATCH (image:Image {url: $url}) SET image.url = $spacesUrl RETURN image.url as url', - { url, spacesUrl }, - ) - const [updatedUrl] = updatedRecord.records.map((record) => record.get('url')) - return updatedUrl - } - } - }) - .map((p) => p()), - ) - // eslint-disable-next-line no-console - console.log('this is locations', locations) - await transaction.commit() - next() - } catch (error) { - // eslint-disable-next-line no-console - console.log(error) - await transaction.rollback() - // eslint-disable-next-line no-console - console.log('rolled back') - throw new Error(error) - } finally { - await session.close() - } -} - -export async function down(next) { - const driver = getDriver() - const session = driver.session() - const transaction = session.beginTransaction() - - try { - // Implement your migration here. - await transaction.run(``) - await transaction.commit() - next() - // eslint-disable-next-line no-catch-all/no-catch-all - } catch (error) { - // eslint-disable-next-line no-console - console.log(error) - await transaction.rollback() - // eslint-disable-next-line no-console - console.log('rolled back') - } finally { - await session.close() - } -} diff --git a/backend/src/db/neo4j.ts b/backend/src/db/neo4j.ts index 5d084099a..ecaef68b5 100644 --- a/backend/src/db/neo4j.ts +++ b/backend/src/db/neo4j.ts @@ -1,6 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-argument */ - -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable import/no-named-as-default-member */ import neo4j, { Driver } from 'neo4j-driver' import Neode from 'neode' diff --git a/backend/src/emails/sendEmail.ts b/backend/src/emails/sendEmail.ts index 7b7ea76b3..8e5d8e83c 100644 --- a/backend/src/emails/sendEmail.ts +++ b/backend/src/emails/sendEmail.ts @@ -7,17 +7,14 @@ import path from 'node:path' import Email from 'email-templates' import { createTransport } from 'nodemailer' + // import type Email as EmailType from '@types/email-templates' -import CONFIG from '@config/index' +import CONFIG, { nodemailerTransportOptions } from '@config/index' import logosWebapp from '@config/logos' import metadata from '@config/metadata' import { UserDbProperties } from '@db/types/User' -const hasAuthData = CONFIG.SMTP_USERNAME && CONFIG.SMTP_PASSWORD -const hasDKIMData = - CONFIG.SMTP_DKIM_DOMAINNAME && CONFIG.SMTP_DKIM_KEYSELECTOR && CONFIG.SMTP_DKIM_PRIVATKEY - const welcomeImageUrl = new URL(logosWebapp.LOGO_WELCOME_PATH, CONFIG.CLIENT_URI) const settingsUrl = new URL('/settings/notifications', CONFIG.CLIENT_URI) @@ -31,24 +28,7 @@ const defaultParams = { renderSettingsUrl: true, } -export const transport = createTransport({ - host: CONFIG.SMTP_HOST, - port: CONFIG.SMTP_PORT, - ignoreTLS: CONFIG.SMTP_IGNORE_TLS, - secure: CONFIG.SMTP_SECURE, // true for 465, false for other ports - pool: true, - maxConnections: CONFIG.SMTP_MAX_CONNECTIONS, - maxMessages: CONFIG.SMTP_MAX_MESSAGES, - auth: hasAuthData && { - user: CONFIG.SMTP_USERNAME, - pass: CONFIG.SMTP_PASSWORD, - }, - dkim: hasDKIMData && { - domainName: CONFIG.SMTP_DKIM_DOMAINNAME, - keySelector: CONFIG.SMTP_DKIM_KEYSELECTOR, - privateKey: CONFIG.SMTP_DKIM_PRIVATKEY, - }, -}) +const transport = createTransport(nodemailerTransportOptions) const email = new Email({ message: { diff --git a/backend/src/graphql/resolvers/images/images.ts b/backend/src/graphql/resolvers/images/images.ts index 2e76a7889..217d26553 100644 --- a/backend/src/graphql/resolvers/images/images.ts +++ b/backend/src/graphql/resolvers/images/images.ts @@ -138,6 +138,9 @@ const s3Upload = async ({ createReadStream, uniqueFilename, mimetype }) => { const s3 = new S3({ region, endpoint }) const s3Location = `original/${uniqueFilename}` + if (!Bucket) { + throw new Error('AWS_BUCKET is undefined') + } const params = { Bucket, Key: s3Location, @@ -160,6 +163,9 @@ const s3Delete = async (url) => { const s3 = new S3({ region, endpoint }) let { pathname } = new URL(url, 'http://example.org') // dummy domain to avoid invalid URL error pathname = pathname.substring(1) // remove first character '/' + if (!Bucket) { + throw new Error('AWS_BUCKET is undefined') + } const params = { Bucket, Key: pathname, diff --git a/backend/src/index.ts b/backend/src/index.ts index 3da4ec7ae..612141733 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/restrict-template-expressions */ -/* eslint-disable @typescript-eslint/no-unsafe-argument */ + import CONFIG from './config' import createServer from './server' diff --git a/backend/src/middleware/helpers/email/sendMail.ts b/backend/src/middleware/helpers/email/sendMail.ts deleted file mode 100644 index fc50107fb..000000000 --- a/backend/src/middleware/helpers/email/sendMail.ts +++ /dev/null @@ -1,80 +0,0 @@ -/* eslint-disable @typescript-eslint/require-await */ -/* eslint-disable @typescript-eslint/no-unsafe-argument */ -/* eslint-disable @typescript-eslint/restrict-plus-operands */ -/* eslint-disable @typescript-eslint/no-unsafe-call */ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import { createTransport } from 'nodemailer' -import { htmlToText } from 'nodemailer-html-to-text' - -import CONFIG from '@config/index' -import { cleanHtml } from '@middleware/helpers/cleanHtml' - -const hasEmailConfig = CONFIG.SMTP_HOST && CONFIG.SMTP_PORT -const hasAuthData = CONFIG.SMTP_USERNAME && CONFIG.SMTP_PASSWORD -const hasDKIMData = - CONFIG.SMTP_DKIM_DOMAINNAME && CONFIG.SMTP_DKIM_KEYSELECTOR && CONFIG.SMTP_DKIM_PRIVATKEY - -const transporter = createTransport({ - host: CONFIG.SMTP_HOST, - port: CONFIG.SMTP_PORT, - ignoreTLS: CONFIG.SMTP_IGNORE_TLS, - secure: CONFIG.SMTP_SECURE, // true for 465, false for other ports - pool: true, - maxConnections: CONFIG.SMTP_MAX_CONNECTIONS, - maxMessages: CONFIG.SMTP_MAX_MESSAGES, - auth: hasAuthData && { - user: CONFIG.SMTP_USERNAME, - pass: CONFIG.SMTP_PASSWORD, - }, - dkim: hasDKIMData && { - domainName: CONFIG.SMTP_DKIM_DOMAINNAME, - keySelector: CONFIG.SMTP_DKIM_KEYSELECTOR, - privateKey: CONFIG.SMTP_DKIM_PRIVATKEY, - }, -}) - -// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-function -let sendMailCallback: any = async () => {} -if (!hasEmailConfig) { - if (!CONFIG.TEST) { - // eslint-disable-next-line no-console - console.log('Warning: Middlewares will not try to send mails.') - // TODO: disable e-mail logging on database seeding? - // TODO: implement general logging like 'log4js', see Gradido project: https://github.com/gradido/gradido/blob/master/backend/log4js-config.json - sendMailCallback = async (templateArgs) => { - // eslint-disable-next-line no-console - console.log('--- Log Unsend E-Mail ---') - // eslint-disable-next-line no-console - console.log('To: ' + templateArgs.to) - // eslint-disable-next-line no-console - console.log('From: ' + templateArgs.from) - // eslint-disable-next-line no-console - console.log('Subject: ' + templateArgs.subject) - // eslint-disable-next-line no-console - console.log('Content:') - // eslint-disable-next-line no-console - console.log( - cleanHtml(templateArgs.html, 'dummyKey', { - allowedTags: ['a'], - allowedAttributes: { a: ['href'] }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any).replace(/&/g, '&'), - ) - } - } -} else { - sendMailCallback = async (templateArgs) => { - transporter.use( - 'compile', - htmlToText({ - ignoreImage: true, - wordwrap: false, - }), - ) - - await transporter.sendMail(templateArgs) - } -} - -export const sendMail = sendMailCallback diff --git a/backend/src/middleware/helpers/email/templateBuilder.spec.ts b/backend/src/middleware/helpers/email/templateBuilder.spec.ts deleted file mode 100644 index 85608b55a..000000000 --- a/backend/src/middleware/helpers/email/templateBuilder.spec.ts +++ /dev/null @@ -1,283 +0,0 @@ -/* eslint-disable @typescript-eslint/require-await */ -/* eslint-disable @typescript-eslint/no-unsafe-return */ -/* eslint-disable @typescript-eslint/no-unsafe-call */ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ -/* eslint-disable @typescript-eslint/no-unsafe-argument */ -import CONFIG from '@config/index' -import logosWebapp from '@config/logos' - -import { - signupTemplate, - emailVerificationTemplate, - resetPasswordTemplate, - wrongAccountTemplate, - notificationTemplate, - chatMessageTemplate, -} from './templateBuilder' - -const englishHint = 'English version below!' -const welcomeImageUrl = new URL(logosWebapp.LOGO_WELCOME_PATH, CONFIG.CLIENT_URI) -const supportUrl = CONFIG.SUPPORT_URL.toString() -let actionUrl, name, settingsUrl - -const signupTemplateData = () => ({ - email: 'test@example.org', - variables: { - nonce: '12345', - inviteCode: 'AAAAAA', - }, -}) -const emailVerificationTemplateData = () => ({ - email: 'test@example.org', - variables: { - nonce: '12345', - name: 'Mr Example', - }, -}) -const resetPasswordTemplateData = () => ({ - email: 'test@example.org', - variables: { - nonce: '12345', - name: 'Mr Example', - }, -}) -const chatMessageTemplateData = { - email: 'test@example.org', - variables: { - senderUser: { - name: 'Sender', - }, - recipientUser: { - name: 'Recipient', - }, - }, -} -const wrongAccountTemplateData = () => ({ - email: 'test@example.org', - variables: {}, -}) -const notificationTemplateData = (locale) => ({ - email: 'test@example.org', - variables: { - notification: { - to: { name: 'Mr Example', locale }, - }, - }, -}) -const textsStandard = [ - { - templPropName: 'from', - isContaining: false, - text: CONFIG.EMAIL_DEFAULT_SENDER, - }, - { - templPropName: 'to', - isContaining: false, - text: 'test@example.org', - }, - // is contained in html - welcomeImageUrl.toString(), - CONFIG.ORGANIZATION_URL, - CONFIG.APPLICATION_NAME, -] -const testEmailData = (emailTemplate, templateBuilder, templateData, texts) => { - if (!emailTemplate) { - emailTemplate = templateBuilder(templateData) - } - texts.forEach((element) => { - if (typeof element === 'object') { - if (element.isContaining) { - expect(emailTemplate[element.templPropName]).toEqual(expect.stringContaining(element.text)) - } else { - expect(emailTemplate[element.templPropName]).toEqual(element.text) - } - } else { - expect(emailTemplate.html).toEqual(expect.stringContaining(element)) - } - }) - return emailTemplate -} - -describe('templateBuilder', () => { - describe('signupTemplate', () => { - describe('multi language', () => { - it('e-mail is build with all data', () => { - const subject = `Willkommen, Bienvenue, Welcome to ${CONFIG.APPLICATION_NAME}!` - const actionUrl = new URL('/registration', CONFIG.CLIENT_URI).toString() - const theSignupTemplateData = signupTemplateData() - const enContent = "Thank you for joining our cause – it's awesome to have you on board." - const deContent = - 'Danke, dass Du dich angemeldet hast – wir freuen uns, Dich dabei zu haben.' - testEmailData(null, signupTemplate, theSignupTemplateData, [ - ...textsStandard, - { - templPropName: 'subject', - isContaining: false, - text: subject, - }, - englishHint, - actionUrl, - theSignupTemplateData.variables.nonce, - theSignupTemplateData.variables.inviteCode, - enContent, - deContent, - supportUrl, - ]) - }) - }) - }) - - describe('emailVerificationTemplate', () => { - describe('multi language', () => { - it('e-mail is build with all data', () => { - const subject = 'Neue E-Mail Adresse | New E-Mail Address' - const actionUrl = new URL('/settings/my-email-address/verify', CONFIG.CLIENT_URI).toString() - const theEmailVerificationTemplateData = emailVerificationTemplateData() - const enContent = 'So, you want to change your e-mail? No problem!' - const deContent = 'Du möchtest also deine E-Mail ändern? Kein Problem!' - testEmailData(null, emailVerificationTemplate, theEmailVerificationTemplateData, [ - ...textsStandard, - { - templPropName: 'subject', - isContaining: false, - text: subject, - }, - englishHint, - actionUrl, - theEmailVerificationTemplateData.variables.nonce, - theEmailVerificationTemplateData.variables.name, - enContent, - deContent, - supportUrl, - ]) - }) - }) - }) - - describe('resetPasswordTemplate', () => { - describe('multi language', () => { - it('e-mail is build with all data', () => { - const subject = 'Neues Passwort | Reset Password' - const actionUrl = new URL('/password-reset/change-password', CONFIG.CLIENT_URI).toString() - const theResetPasswordTemplateData = resetPasswordTemplateData() - const enContent = 'So, you forgot your password? No problem!' - const deContent = 'Du hast also dein Passwort vergessen? Kein Problem!' - testEmailData(null, resetPasswordTemplate, theResetPasswordTemplateData, [ - ...textsStandard, - { - templPropName: 'subject', - isContaining: false, - text: subject, - }, - englishHint, - actionUrl, - theResetPasswordTemplateData.variables.nonce, - theResetPasswordTemplateData.variables.name, - enContent, - deContent, - supportUrl, - ]) - }) - }) - }) - - describe('chatMessageTemplate', () => { - describe('multi language', () => { - it('e-mail is build with all data', () => { - const subject = `Neue Chat-Nachricht | New chat message - ${chatMessageTemplateData.variables.senderUser.name}` - const actionUrl = new URL('/chat', CONFIG.CLIENT_URI).toString() - const enContent = `You have received a new chat message from ${chatMessageTemplateData.variables.senderUser.name}.` - const deContent = `Du hast eine neue Chat-Nachricht von ${chatMessageTemplateData.variables.senderUser.name} erhalten.` - testEmailData(null, chatMessageTemplate, chatMessageTemplateData, [ - ...textsStandard, - { - templPropName: 'subject', - isContaining: false, - text: subject, - }, - englishHint, - actionUrl, - chatMessageTemplateData.variables.senderUser, - chatMessageTemplateData.variables.recipientUser, - enContent, - deContent, - supportUrl, - ]) - }) - }) - }) - - describe('wrongAccountTemplate', () => { - describe('multi language', () => { - it('e-mail is build with all data', () => { - const subject = 'Falsche Mailadresse? | Wrong E-mail?' - const actionUrl = new URL('/password-reset/request', CONFIG.CLIENT_URI).toString() - const theWrongAccountTemplateData = wrongAccountTemplateData() - const enContent = - "You requested a password reset but unfortunately we couldn't find an account associated with your e-mail address." - const deContent = - 'Du hast bei uns ein neues Passwort angefordert – leider haben wir aber keinen Account mit Deiner E-Mailadresse gefunden.' - testEmailData(null, wrongAccountTemplate, theWrongAccountTemplateData, [ - ...textsStandard, - { - templPropName: 'subject', - isContaining: false, - text: subject, - }, - englishHint, - actionUrl, - enContent, - deContent, - supportUrl, - ]) - }) - }) - }) - - describe('notificationTemplate', () => { - beforeEach(() => { - actionUrl = new URL('/notifications', CONFIG.CLIENT_URI).toString() - name = notificationTemplateData('en').variables.notification.to.name - settingsUrl = new URL('/settings/notifications', CONFIG.CLIENT_URI) - }) - - describe('en', () => { - it('e-mail is build with all data', () => { - const subject = `${CONFIG.APPLICATION_NAME} – Notification` - const content = 'You received at least one notification. Click on this button to view them:' - testEmailData(null, notificationTemplate, notificationTemplateData('en'), [ - ...textsStandard, - { - templPropName: 'subject', - isContaining: false, - text: subject, - }, - actionUrl, - name, - content, - settingsUrl, - ]) - }) - }) - - describe('de', () => { - it('e-mail is build with all data', async () => { - const subject = `${CONFIG.APPLICATION_NAME} – Benachrichtigung` - const content = `Du hast mindestens eine Benachrichtigung erhalten. Klick auf diesen Button, um sie anzusehen:` - testEmailData(null, notificationTemplate, notificationTemplateData('de'), [ - ...textsStandard, - { - templPropName: 'subject', - isContaining: false, - text: subject, - }, - actionUrl, - name, - content, - settingsUrl, - ]) - }) - }) - }) -}) diff --git a/backend/src/middleware/helpers/email/templateBuilder.ts b/backend/src/middleware/helpers/email/templateBuilder.ts deleted file mode 100644 index ffceb49f6..000000000 --- a/backend/src/middleware/helpers/email/templateBuilder.ts +++ /dev/null @@ -1,140 +0,0 @@ -/* eslint-disable @typescript-eslint/restrict-template-expressions */ -/* eslint-disable @typescript-eslint/no-unsafe-call */ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable @typescript-eslint/no-unsafe-argument */ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ -/* eslint-disable import/no-namespace */ -import mustache from 'mustache' - -import CONFIG from '@config/index' -import logosWebapp from '@config/logos' -import metadata from '@config/metadata' - -import * as templates from './templates' -import * as templatesDE from './templates/de' -import * as templatesEN from './templates/en' - -const from = CONFIG.EMAIL_DEFAULT_SENDER -const welcomeImageUrl = new URL(logosWebapp.LOGO_WELCOME_PATH, CONFIG.CLIENT_URI) - -const defaultParams = { - welcomeImageUrl, - APPLICATION_NAME: CONFIG.APPLICATION_NAME, - ORGANIZATION_NAME: metadata.ORGANIZATION_NAME, - ORGANIZATION_URL: CONFIG.ORGANIZATION_URL, - supportUrl: CONFIG.SUPPORT_URL, -} -const englishHint = 'English version below!' - -export const signupTemplate = ({ email, variables: { nonce, inviteCode = null } }) => { - const subject = `Willkommen, Bienvenue, Welcome to ${CONFIG.APPLICATION_NAME}!` - // dev format example: http://localhost:3000/registration?method=invite-mail&email=huss%40pjannto.com&nonce=64853 - const actionUrl = new URL('/registration', CONFIG.CLIENT_URI) - actionUrl.searchParams.set('email', email) - actionUrl.searchParams.set('nonce', nonce) - if (inviteCode) { - actionUrl.searchParams.set('inviteCode', inviteCode) - actionUrl.searchParams.set('method', 'invite-code') - } else { - actionUrl.searchParams.set('method', 'invite-mail') - } - const renderParams = { ...defaultParams, englishHint, actionUrl, nonce, subject } - - return { - from, - to: email, - subject, - html: mustache.render(templates.layout, renderParams, { content: templates.signup }), - } -} - -export const emailVerificationTemplate = ({ email, variables: { nonce, name } }) => { - const subject = 'Neue E-Mail Adresse | New E-Mail Address' - const actionUrl = new URL('/settings/my-email-address/verify', CONFIG.CLIENT_URI) - actionUrl.searchParams.set('email', email) - actionUrl.searchParams.set('nonce', nonce) - const renderParams = { ...defaultParams, englishHint, actionUrl, name, nonce, subject } - - return { - from, - to: email, - subject, - html: mustache.render(templates.layout, renderParams, { content: templates.emailVerification }), - } -} - -export const resetPasswordTemplate = ({ email, variables: { nonce, name } }) => { - const subject = 'Neues Passwort | Reset Password' - const actionUrl = new URL('/password-reset/change-password', CONFIG.CLIENT_URI) - actionUrl.searchParams.set('nonce', nonce) - actionUrl.searchParams.set('email', email) - const renderParams = { ...defaultParams, englishHint, actionUrl, name, nonce, subject } - - return { - from, - to: email, - subject, - html: mustache.render(templates.layout, renderParams, { content: templates.passwordReset }), - } -} - -export const chatMessageTemplate = ({ email, variables: { senderUser, recipientUser } }) => { - const subject = `Neue Chat-Nachricht | New chat message - ${senderUser.name}` - const actionUrl = new URL('/chat', CONFIG.CLIENT_URI) - const renderParams = { - ...defaultParams, - subject, - englishHint, - actionUrl, - senderUser, - recipientUser, - } - - return { - from, - to: email, - subject, - html: mustache.render(templates.layout, renderParams, { content: templates.chatMessage }), - } -} - -export const wrongAccountTemplate = ({ email, _variables = {} }) => { - const subject = 'Falsche Mailadresse? | Wrong E-mail?' - const actionUrl = new URL('/password-reset/request', CONFIG.CLIENT_URI) - const renderParams = { ...defaultParams, englishHint, actionUrl } - - return { - from, - to: email, - subject, - html: mustache.render(templates.layout, renderParams, { content: templates.wrongAccount }), - } -} - -export const notificationTemplate = ({ email, variables: { notification } }) => { - const actionUrl = new URL('/notifications', CONFIG.CLIENT_URI) - const settingsUrl = new URL('/settings/notifications', CONFIG.CLIENT_URI) - const renderParams = { ...defaultParams, name: notification.to.name, settingsUrl, actionUrl } - let content - switch (notification.to.locale) { - case 'de': - content = templatesDE.notification - break - case 'en': - content = templatesEN.notification - break - - default: - content = templatesEN.notification - break - } - const subjectUnrendered = content.split('\n')[0].split('"')[1] - const subject = mustache.render(subjectUnrendered, renderParams, {}) - - return { - from, - to: email, - subject, - html: mustache.render(templates.layout, renderParams, { content }), - } -} diff --git a/backend/src/middleware/helpers/email/templates/chatMessage.html b/backend/src/middleware/helpers/email/templates/chatMessage.html deleted file mode 100644 index 49fc69bf2..000000000 --- a/backend/src/middleware/helpers/email/templates/chatMessage.html +++ /dev/null @@ -1,105 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/backend/src/middleware/helpers/email/templates/de/index.ts b/backend/src/middleware/helpers/email/templates/de/index.ts deleted file mode 100644 index 4aa323b9f..000000000 --- a/backend/src/middleware/helpers/email/templates/de/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unsafe-argument */ -/* eslint-disable security/detect-non-literal-fs-filename */ -import fs from 'node:fs' -import path from 'node:path' - -// eslint-disable-next-line n/no-sync -const readFile = (fileName) => fs.readFileSync(path.join(__dirname, fileName), 'utf-8') - -export const notification = readFile('./notification.html') diff --git a/backend/src/middleware/helpers/email/templates/de/notification.html b/backend/src/middleware/helpers/email/templates/de/notification.html deleted file mode 100644 index a54943310..000000000 --- a/backend/src/middleware/helpers/email/templates/de/notification.html +++ /dev/null @@ -1,83 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/backend/src/middleware/helpers/email/templates/emailVerification.html b/backend/src/middleware/helpers/email/templates/emailVerification.html deleted file mode 100644 index 35ce27e5a..000000000 --- a/backend/src/middleware/helpers/email/templates/emailVerification.html +++ /dev/null @@ -1,186 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/backend/src/middleware/helpers/email/templates/en/index.ts b/backend/src/middleware/helpers/email/templates/en/index.ts deleted file mode 100644 index 4aa323b9f..000000000 --- a/backend/src/middleware/helpers/email/templates/en/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unsafe-argument */ -/* eslint-disable security/detect-non-literal-fs-filename */ -import fs from 'node:fs' -import path from 'node:path' - -// eslint-disable-next-line n/no-sync -const readFile = (fileName) => fs.readFileSync(path.join(__dirname, fileName), 'utf-8') - -export const notification = readFile('./notification.html') diff --git a/backend/src/middleware/helpers/email/templates/en/notification.html b/backend/src/middleware/helpers/email/templates/en/notification.html deleted file mode 100644 index 168b21864..000000000 --- a/backend/src/middleware/helpers/email/templates/en/notification.html +++ /dev/null @@ -1,83 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/backend/src/middleware/helpers/email/templates/index.ts b/backend/src/middleware/helpers/email/templates/index.ts deleted file mode 100644 index 9a64192ce..000000000 --- a/backend/src/middleware/helpers/email/templates/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unsafe-argument */ -/* eslint-disable security/detect-non-literal-fs-filename */ -import fs from 'node:fs' -import path from 'node:path' - -// eslint-disable-next-line n/no-sync -const readFile = (fileName) => fs.readFileSync(path.join(__dirname, fileName), 'utf-8') - -export const signup = readFile('./signup.html') -export const passwordReset = readFile('./resetPassword.html') -export const wrongAccount = readFile('./wrongAccount.html') -export const emailVerification = readFile('./emailVerification.html') -export const chatMessage = readFile('./chatMessage.html') - -export const layout = readFile('./layout.html') diff --git a/backend/src/middleware/helpers/email/templates/layout.html b/backend/src/middleware/helpers/email/templates/layout.html deleted file mode 100644 index 0c68d6309..000000000 --- a/backend/src/middleware/helpers/email/templates/layout.html +++ /dev/null @@ -1,197 +0,0 @@ - - - - - - - - - - {{ subject }} - - - - - - - - - - - - - - - - - -
- - - - - -
- - - diff --git a/backend/src/middleware/helpers/email/templates/resetPassword.html b/backend/src/middleware/helpers/email/templates/resetPassword.html deleted file mode 100644 index 43c45455e..000000000 --- a/backend/src/middleware/helpers/email/templates/resetPassword.html +++ /dev/null @@ -1,185 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/backend/src/middleware/helpers/email/templates/signup.html b/backend/src/middleware/helpers/email/templates/signup.html deleted file mode 100644 index 4bf17fd61..000000000 --- a/backend/src/middleware/helpers/email/templates/signup.html +++ /dev/null @@ -1,214 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/backend/src/middleware/helpers/email/templates/wrongAccount.html b/backend/src/middleware/helpers/email/templates/wrongAccount.html deleted file mode 100644 index e8f71e9ea..000000000 --- a/backend/src/middleware/helpers/email/templates/wrongAccount.html +++ /dev/null @@ -1,185 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/backend/src/middleware/index.ts b/backend/src/middleware/index.ts index 896e5b33b..cc3af6bfc 100644 --- a/backend/src/middleware/index.ts +++ b/backend/src/middleware/index.ts @@ -1,7 +1,5 @@ /* eslint-disable @typescript-eslint/no-unsafe-argument */ -/* eslint-disable @typescript-eslint/restrict-template-expressions */ -/* eslint-disable @typescript-eslint/no-unsafe-call */ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ + /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { applyMiddleware, IMiddleware } from 'graphql-middleware' diff --git a/docker-compose.override.yml b/docker-compose.override.yml index e0f91c358..ae77abd5e 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -30,6 +30,8 @@ services: environment: - NODE_ENV="development" - DEBUG=true + - SMTP_PORT=1025 + - SMTP_HOST=mailserver volumes: - ./backend:/app From 50bc62428ef4987959b6e63b0074aaf056b99d83 Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Wed, 7 May 2025 19:42:39 +0200 Subject: [PATCH 2/6] fix(backend): correct email from (#8501) --- .../sendChatMessageMail.spec.ts.snap | 4 +-- .../sendEmailVerification.spec.ts.snap | 4 +-- .../sendNotificationMail.spec.ts.snap | 36 +++++++++---------- .../sendRegistrationMail.spec.ts.snap | 8 ++--- .../sendResetPasswordMail.spec.ts.snap | 4 +-- .../__snapshots__/sendWrongEmail.spec.ts.snap | 4 +-- backend/src/emails/sendEmail.ts | 4 ++- 7 files changed, 33 insertions(+), 31 deletions(-) diff --git a/backend/src/emails/__snapshots__/sendChatMessageMail.spec.ts.snap b/backend/src/emails/__snapshots__/sendChatMessageMail.spec.ts.snap index 57b256a12..786bad9c0 100644 --- a/backend/src/emails/__snapshots__/sendChatMessageMail.spec.ts.snap +++ b/backend/src/emails/__snapshots__/sendChatMessageMail.spec.ts.snap @@ -3,7 +3,7 @@ exports[`sendChatMessageMail English chat_message template 1`] = ` { "attachments": [], - "from": "ocelot.social", + "from": "ocelot.social ", "html": " @@ -130,7 +130,7 @@ ocelot.social Community [https://ocelot.social]", exports[`sendChatMessageMail German chat_message template 1`] = ` { "attachments": [], - "from": "ocelot.social", + "from": "ocelot.social ", "html": " diff --git a/backend/src/emails/__snapshots__/sendEmailVerification.spec.ts.snap b/backend/src/emails/__snapshots__/sendEmailVerification.spec.ts.snap index 34c945d65..87815f5e6 100644 --- a/backend/src/emails/__snapshots__/sendEmailVerification.spec.ts.snap +++ b/backend/src/emails/__snapshots__/sendEmailVerification.spec.ts.snap @@ -3,7 +3,7 @@ exports[`sendEmailVerification English renders correctly 1`] = ` { "attachments": [], - "from": "ocelot.social", + "from": "ocelot.social ", "html": " @@ -133,7 +133,7 @@ ocelot.social Community [https://ocelot.social]", exports[`sendEmailVerification German renders correctly 1`] = ` { "attachments": [], - "from": "ocelot.social", + "from": "ocelot.social ", "html": " diff --git a/backend/src/emails/__snapshots__/sendNotificationMail.spec.ts.snap b/backend/src/emails/__snapshots__/sendNotificationMail.spec.ts.snap index 0fec27b7c..1c4f0dc8e 100644 --- a/backend/src/emails/__snapshots__/sendNotificationMail.spec.ts.snap +++ b/backend/src/emails/__snapshots__/sendNotificationMail.spec.ts.snap @@ -3,7 +3,7 @@ exports[`sendNotificationMail English changed_group_member_role template 1`] = ` { "attachments": [], - "from": "ocelot.social", + "from": "ocelot.social ", "html": " @@ -129,7 +129,7 @@ ocelot.social Community [https://ocelot.social]", exports[`sendNotificationMail English commented_on_post template 1`] = ` { "attachments": [], - "from": "ocelot.social", + "from": "ocelot.social ", "html": " @@ -257,7 +257,7 @@ ocelot.social Community [https://ocelot.social]", exports[`sendNotificationMail English followed_user_posted template 1`] = ` { "attachments": [], - "from": "ocelot.social", + "from": "ocelot.social ", "html": " @@ -385,7 +385,7 @@ ocelot.social Community [https://ocelot.social]", exports[`sendNotificationMail English mentioned_in_comment template 1`] = ` { "attachments": [], - "from": "ocelot.social", + "from": "ocelot.social ", "html": " @@ -513,7 +513,7 @@ ocelot.social Community [https://ocelot.social]", exports[`sendNotificationMail English mentioned_in_post template 1`] = ` { "attachments": [], - "from": "ocelot.social", + "from": "ocelot.social ", "html": " @@ -640,7 +640,7 @@ ocelot.social Community [https://ocelot.social]", exports[`sendNotificationMail English post_in_group template 1`] = ` { "attachments": [], - "from": "ocelot.social", + "from": "ocelot.social ", "html": " @@ -766,7 +766,7 @@ ocelot.social Community [https://ocelot.social]", exports[`sendNotificationMail English removed_user_from_group template 1`] = ` { "attachments": [], - "from": "ocelot.social", + "from": "ocelot.social ", "html": " @@ -889,7 +889,7 @@ ocelot.social Community [https://ocelot.social]", exports[`sendNotificationMail English user_joined_group template 1`] = ` { "attachments": [], - "from": "ocelot.social", + "from": "ocelot.social ", "html": " @@ -1016,7 +1016,7 @@ ocelot.social Community [https://ocelot.social]", exports[`sendNotificationMail English user_left_group template 1`] = ` { "attachments": [], - "from": "ocelot.social", + "from": "ocelot.social ", "html": " @@ -1143,7 +1143,7 @@ ocelot.social Community [https://ocelot.social]", exports[`sendNotificationMail German changed_group_member_role template 1`] = ` { "attachments": [], - "from": "ocelot.social", + "from": "ocelot.social ", "html": " @@ -1269,7 +1269,7 @@ ocelot.social Community [https://ocelot.social]", exports[`sendNotificationMail German commented_on_post template 1`] = ` { "attachments": [], - "from": "ocelot.social", + "from": "ocelot.social ", "html": " @@ -1397,7 +1397,7 @@ ocelot.social Community [https://ocelot.social]", exports[`sendNotificationMail German followed_user_posted template 1`] = ` { "attachments": [], - "from": "ocelot.social", + "from": "ocelot.social ", "html": " @@ -1525,7 +1525,7 @@ ocelot.social Community [https://ocelot.social]", exports[`sendNotificationMail German mentioned_in_comment template 1`] = ` { "attachments": [], - "from": "ocelot.social", + "from": "ocelot.social ", "html": " @@ -1653,7 +1653,7 @@ ocelot.social Community [https://ocelot.social]", exports[`sendNotificationMail German mentioned_in_post template 1`] = ` { "attachments": [], - "from": "ocelot.social", + "from": "ocelot.social ", "html": " @@ -1780,7 +1780,7 @@ ocelot.social Community [https://ocelot.social]", exports[`sendNotificationMail German post_in_group template 1`] = ` { "attachments": [], - "from": "ocelot.social", + "from": "ocelot.social ", "html": " @@ -1906,7 +1906,7 @@ ocelot.social Community [https://ocelot.social]", exports[`sendNotificationMail German removed_user_from_group template 1`] = ` { "attachments": [], - "from": "ocelot.social", + "from": "ocelot.social ", "html": " @@ -2029,7 +2029,7 @@ ocelot.social Community [https://ocelot.social]", exports[`sendNotificationMail German user_joined_group template 1`] = ` { "attachments": [], - "from": "ocelot.social", + "from": "ocelot.social ", "html": " @@ -2156,7 +2156,7 @@ ocelot.social Community [https://ocelot.social]", exports[`sendNotificationMail German user_left_group template 1`] = ` { "attachments": [], - "from": "ocelot.social", + "from": "ocelot.social ", "html": " diff --git a/backend/src/emails/__snapshots__/sendRegistrationMail.spec.ts.snap b/backend/src/emails/__snapshots__/sendRegistrationMail.spec.ts.snap index 3b8d1c077..d4a1ded8a 100644 --- a/backend/src/emails/__snapshots__/sendRegistrationMail.spec.ts.snap +++ b/backend/src/emails/__snapshots__/sendRegistrationMail.spec.ts.snap @@ -3,7 +3,7 @@ exports[`sendRegistrationMail with invite code English renders correctly 1`] = ` { "attachments": [], - "from": "ocelot.social", + "from": "ocelot.social ", "html": " @@ -142,7 +142,7 @@ ocelot.social Community [https://ocelot.social]", exports[`sendRegistrationMail with invite code German renders correctly 1`] = ` { "attachments": [], - "from": "ocelot.social", + "from": "ocelot.social ", "html": " @@ -282,7 +282,7 @@ ocelot.social Community [https://ocelot.social]", exports[`sendRegistrationMail without invite code English renders correctly 1`] = ` { "attachments": [], - "from": "ocelot.social", + "from": "ocelot.social ", "html": " @@ -421,7 +421,7 @@ ocelot.social Community [https://ocelot.social]", exports[`sendRegistrationMail without invite code German renders correctly 1`] = ` { "attachments": [], - "from": "ocelot.social", + "from": "ocelot.social ", "html": " diff --git a/backend/src/emails/__snapshots__/sendResetPasswordMail.spec.ts.snap b/backend/src/emails/__snapshots__/sendResetPasswordMail.spec.ts.snap index 3d8c6ac27..da62c9a34 100644 --- a/backend/src/emails/__snapshots__/sendResetPasswordMail.spec.ts.snap +++ b/backend/src/emails/__snapshots__/sendResetPasswordMail.spec.ts.snap @@ -3,7 +3,7 @@ exports[`sendResetPasswordMail English renders correctly 1`] = ` { "attachments": [], - "from": "ocelot.social", + "from": "ocelot.social ", "html": " @@ -132,7 +132,7 @@ ocelot.social Community [https://ocelot.social]", exports[`sendResetPasswordMail German renders correctly 1`] = ` { "attachments": [], - "from": "ocelot.social", + "from": "ocelot.social ", "html": " diff --git a/backend/src/emails/__snapshots__/sendWrongEmail.spec.ts.snap b/backend/src/emails/__snapshots__/sendWrongEmail.spec.ts.snap index 72acc52cd..b2052d808 100644 --- a/backend/src/emails/__snapshots__/sendWrongEmail.spec.ts.snap +++ b/backend/src/emails/__snapshots__/sendWrongEmail.spec.ts.snap @@ -3,7 +3,7 @@ exports[`sendWrongEmail English renders correctly 1`] = ` { "attachments": [], - "from": "ocelot.social", + "from": "ocelot.social ", "html": " @@ -130,7 +130,7 @@ ocelot.social Community [https://ocelot.social]", exports[`sendWrongEmail German renders correctly 1`] = ` { "attachments": [], - "from": "ocelot.social", + "from": "ocelot.social ", "html": " diff --git a/backend/src/emails/sendEmail.ts b/backend/src/emails/sendEmail.ts index 8e5d8e83c..580cc2f58 100644 --- a/backend/src/emails/sendEmail.ts +++ b/backend/src/emails/sendEmail.ts @@ -28,11 +28,13 @@ const defaultParams = { renderSettingsUrl: true, } +const from = `${CONFIG.APPLICATION_NAME} <${CONFIG.EMAIL_DEFAULT_SENDER}>` + const transport = createTransport(nodemailerTransportOptions) const email = new Email({ message: { - from: `${CONFIG.APPLICATION_NAME}`, + from, }, transport, i18n: { From 20d14f3a2ff0163e48bfdadce4dc5e030184bef3 Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Wed, 7 May 2025 20:44:44 +0200 Subject: [PATCH 3/6] chore(release): v3.5.3 (#8503) * Release v3.5.3 --- CHANGELOG.md | 35 ++++++++++++++++--- backend/package.json | 2 +- .../helm/charts/ocelot-neo4j/Chart.yaml | 2 +- .../helm/charts/ocelot-social/Chart.yaml | 2 +- frontend/package-lock.json | 4 +-- frontend/package.json | 2 +- package.json | 2 +- webapp/maintenance/source/package.json | 2 +- webapp/package.json | 2 +- 9 files changed, 39 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95c747d24..332b24ceb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,17 @@ All notable changes to this project will be documented in this file. Dates are d Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). +#### [3.5.3](https://github.com/Ocelot-Social-Community/Ocelot-Social/compare/3.5.2...3.5.3) + +- fix(backend): correct email from [`#8501`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/8501) +- refactor(backend): types for global config [`#8485`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/8485) +- fix warning in workflow for lower case as [`#8494`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/8494) + #### [3.5.2](https://github.com/Ocelot-Social-Community/Ocelot-Social/compare/3.5.1...3.5.2) +> 6 May 2025 + +- v3.5.2 [`#8498`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/8498) - fix emails2 [`#8497`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/8497) #### [3.5.1](https://github.com/Ocelot-Social-Community/Ocelot-Social/compare/3.5.0...3.5.1) @@ -1429,15 +1438,31 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - updated CHANGELOG.md [`9d9075f`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/9d9075f2117b2eb4b607e7d59ab18c7e655c6ea7) -#### [0.6.4](https://github.com/Ocelot-Social-Community/Ocelot-Social/compare/0.6.3...0.6.4) +#### [0.6.4](https://github.com/Ocelot-Social-Community/Ocelot-Social/compare/v0.6.4...0.6.4) > 8 February 2021 -- regenerated `CHANGELOG.md` [`ee688ec`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/ee688ece24cf592b3989e83340701ca8772e876e) -- fetch full history [`5ecee4d`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/5ecee4d73a92d2e5c5ae971d79848ed27f65a72c) -- don't fail if tag exists (release) [`39c82fc`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/39c82fcb37d5c8e7e78a79288e1ef6280f8d0892) +- - adjusted changelog to ocelot-social repo [`9603882`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/9603882edebf8967e05abfa94e4e1ebf452d4e24) +- - first steps towards docker image deployment & github autotagging [`5503216`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/5503216ad4a0230ac533042e4a69806590fc2a5a) +- - deploy structure image [`a60400b`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/a60400b4fe6f59bbb80e1073db4def3ba205e1a7) -#### [0.6.3](https://github.com/Ocelot-Social-Community/Ocelot-Social/compare/0.6.0...0.6.3) +#### [v0.6.4](https://github.com/Ocelot-Social-Community/Ocelot-Social/compare/0.6.3...v0.6.4) + +> 9 February 2021 + +- chore(release): 0.6.4 [`8b7570d`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/8b7570dc35d0ea431f673a711ac051f1e1320acb) +- change user roles is working, test fails [`8c3310a`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/8c3310abaf87c0e5597fec4f93fb37d27122c9e7) +- change user role: tests are working [`f10da4b`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/f10da4b09388fe1e2b85abd53f6ffc67c785d4c1) + +#### [0.6.3](https://github.com/Ocelot-Social-Community/Ocelot-Social/compare/v0.6.3...0.6.3) + +> 8 February 2021 + +- - adjusted changelog to ocelot-social repo [`9603882`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/9603882edebf8967e05abfa94e4e1ebf452d4e24) +- - fixed changelog [`cf70b12`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/cf70b12ed74011924ea788ab932fc9d7ac0e6bd9) +- - yarn install to allow yarn auto-changelog [`fc496aa`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/fc496aa04cb7e804da4335da0cb5cda26f874ea2) + +#### [v0.6.3](https://github.com/Ocelot-Social-Community/Ocelot-Social/compare/0.6.0...v0.6.3) > 8 February 2021 diff --git a/backend/package.json b/backend/package.json index aa706eb78..38bf966ac 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ocelot-social-backend", - "version": "3.5.2", + "version": "3.5.3", "description": "GraphQL Backend for ocelot.social", "repository": "https://github.com/Ocelot-Social-Community/Ocelot-Social", "author": "ocelot.social Community", diff --git a/deployment/helm/charts/ocelot-neo4j/Chart.yaml b/deployment/helm/charts/ocelot-neo4j/Chart.yaml index da08678d3..461ed90a3 100644 --- a/deployment/helm/charts/ocelot-neo4j/Chart.yaml +++ b/deployment/helm/charts/ocelot-neo4j/Chart.yaml @@ -21,4 +21,4 @@ version: 0.1.0 # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "3.5.2" +appVersion: "3.5.3" diff --git a/deployment/helm/charts/ocelot-social/Chart.yaml b/deployment/helm/charts/ocelot-social/Chart.yaml index 7d2b93821..81febe4a7 100644 --- a/deployment/helm/charts/ocelot-social/Chart.yaml +++ b/deployment/helm/charts/ocelot-social/Chart.yaml @@ -21,4 +21,4 @@ version: 0.1.0 # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "3.5.2" +appVersion: "3.5.3" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e4965713c..250de7cce 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ocelot-social-frontend", - "version": "3.5.2", + "version": "3.5.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ocelot-social-frontend", - "version": "3.5.2", + "version": "3.5.3", "license": "Apache-2.0", "dependencies": { "@intlify/unplugin-vue-i18n": "^2.0.0", diff --git a/frontend/package.json b/frontend/package.json index a8487569b..62532f76f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "ocelot-social-frontend", - "version": "3.5.2", + "version": "3.5.3", "description": "ocelot.social new Frontend (in development and not fully implemented) by IT4C Boilerplate for frontends", "main": "build/index.js", "type": "module", diff --git a/package.json b/package.json index b93be2e39..4581d134a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ocelot-social", - "version": "3.5.2", + "version": "3.5.3", "description": "Free and open source software program code available to run social networks.", "author": "ocelot.social Community", "license": "MIT", diff --git a/webapp/maintenance/source/package.json b/webapp/maintenance/source/package.json index 969a66db7..da4027f84 100644 --- a/webapp/maintenance/source/package.json +++ b/webapp/maintenance/source/package.json @@ -1,6 +1,6 @@ { "name": "@ocelot-social/maintenance", - "version": "3.5.2", + "version": "3.5.3", "description": "Maintenance page for ocelot.social", "repository": "https://github.com/Ocelot-Social-Community/Ocelot-Social", "author": "ocelot.social Community", diff --git a/webapp/package.json b/webapp/package.json index 05a4e0169..de3c0cc2d 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -1,6 +1,6 @@ { "name": "ocelot-social-webapp", - "version": "3.5.2", + "version": "3.5.3", "description": "ocelot.social Frontend", "repository": "https://github.com/Ocelot-Social-Community/Ocelot-Social", "author": "ocelot.social Community", From e3864b1f9d8c922b06e1b5d52b628bfa001a4e4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Thu, 8 May 2025 18:48:26 +0200 Subject: [PATCH 4/6] feat(webapp): change german to `du` and `dich` (#8507) * Change 'Du' -> 'du' * Change 'Dich' -> 'dich' * Change backend e-mails 'Dich' -> 'dich' * Change backend e-mails 'Du' -> 'du' * Fix e-mail snapshots * Revert "Auxiliary commit to revert individual files from eea84f60ba9c17d48a735632709a66452f0494e9" This reverts commit d80994c3b8bff311422cb44aab1275c31286595a. * Change backend e-mails 'Du' -> 'du', 'Dich' -> 'dich' * Change webapp 'Dein' -> 'dein' * Change backend 'Dein' -> 'dein' --- .../sendEmailVerification.spec.ts.snap | 14 +-- .../sendNotificationMail.spec.ts.snap | 4 +- .../sendRegistrationMail.spec.ts.snap | 48 +++++----- .../sendResetPasswordMail.spec.ts.snap | 8 +- backend/src/emails/locales/de.json | 24 ++--- webapp/locales/de.json | 96 +++++++++---------- 6 files changed, 97 insertions(+), 97 deletions(-) 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..7fcf4d833 100644 --- a/backend/src/emails/__snapshots__/sendNotificationMail.spec.ts.snap +++ b/backend/src/emails/__snapshots__/sendNotificationMail.spec.ts.snap @@ -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] 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/webapp/locales/de.json b/webapp/locales/de.json index e2b641b08..ea7b8aeb2 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" }, @@ -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}!", @@ -335,7 +335,7 @@ }, "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", "newPost": "Erstelle einen neuen Beitrag", @@ -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", From 3f4d648562db8682cb8f8c6f63997a3bdffb4cd0 Mon Sep 17 00:00:00 2001 From: Ulf Gebhardt Date: Thu, 8 May 2025 21:18:40 +0200 Subject: [PATCH 5/6] feat(backend): group invite codes (#8499) * invite codes refactor typo * lint fixes * remove duplicate initeCodes on User * fix typo * clean permissionMiddleware * dummy permissions * separate validateInviteCode call * permissions group & user * test validateInviteCode + adjustments * more validateInviteCode fixes * missing test * generatePersonalInviteCode * generateGroupInviteCode * old tests * lint fixes * more lint fixes * fix validateInviteCode * fix redeemInviteCode, fix signup * fix all tests * fix lint * uniform types in config * test & fix invalidateInviteCode * cleanup test * fix & test redeemInviteCode * permissions * fix Group->inviteCodes * more cleanup * improve tests * fix code generation * cleanup * order inviteCodes result on User and Group * lint * test max invite codes + fix * better description of collision * tests: properly define group ids * reused old group query * reuse old Groupmembers query * remove duplicate skip * update comment * fix uniqueInviteCode * fix test --- backend/src/config/index.ts | 4 + backend/src/context/database.ts | 8 +- backend/src/db/factories.ts | 16 +- backend/src/db/models/InviteCode.ts | 6 + backend/src/graphql/queries/Group.ts | 36 + backend/src/graphql/queries/GroupMembers.ts | 12 + backend/src/graphql/queries/currentUser.ts | 15 + .../queries/generateGroupInviteCode.ts | 36 + .../queries/generatePersonalInviteCode.ts | 36 + .../src/graphql/queries/groupMembersQuery.ts | 14 - backend/src/graphql/queries/groupQuery.ts | 38 - .../graphql/queries/invalidateInviteCode.ts | 36 + .../src/graphql/queries/redeemInviteCode.ts | 7 + .../src/graphql/queries/validateInviteCode.ts | 49 + backend/src/graphql/resolvers/badges.spec.ts | 61 +- backend/src/graphql/resolvers/groups.spec.ts | 60 +- backend/src/graphql/resolvers/groups.ts | 30 + .../resolvers/helpers/generateInviteCode.ts | 13 - .../src/graphql/resolvers/inviteCodes.spec.ts | 1311 +++++++++++++++-- backend/src/graphql/resolvers/inviteCodes.ts | 376 +++-- backend/src/graphql/resolvers/locations.ts | 3 + backend/src/graphql/resolvers/posts.spec.ts | 86 +- .../graphql/resolvers/registration.spec.ts | 75 +- backend/src/graphql/resolvers/registration.ts | 92 +- .../resolvers/transactions/inviteCodes.ts | 26 - backend/src/graphql/resolvers/users.ts | 18 + backend/src/graphql/types/type/Group.gql | 3 + backend/src/graphql/types/type/InviteCode.gql | 19 +- backend/src/graphql/types/type/User.gql | 9 +- backend/src/middleware/index.ts | 1 + .../middleware/permissionsMiddleware.spec.ts | 65 +- .../src/middleware/permissionsMiddleware.ts | 62 +- .../src/middleware/slugifyMiddleware.spec.ts | 41 +- backend/src/server.ts | 1 + 34 files changed, 2034 insertions(+), 631 deletions(-) create mode 100644 backend/src/graphql/queries/Group.ts create mode 100644 backend/src/graphql/queries/GroupMembers.ts create mode 100644 backend/src/graphql/queries/currentUser.ts create mode 100644 backend/src/graphql/queries/generateGroupInviteCode.ts create mode 100644 backend/src/graphql/queries/generatePersonalInviteCode.ts delete mode 100644 backend/src/graphql/queries/groupMembersQuery.ts delete mode 100644 backend/src/graphql/queries/groupQuery.ts create mode 100644 backend/src/graphql/queries/invalidateInviteCode.ts create mode 100644 backend/src/graphql/queries/redeemInviteCode.ts create mode 100644 backend/src/graphql/queries/validateInviteCode.ts delete mode 100644 backend/src/graphql/resolvers/helpers/generateInviteCode.ts delete mode 100644 backend/src/graphql/resolvers/transactions/inviteCodes.ts 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/context/database.ts b/backend/src/context/database.ts index f6ccdc9ca..c1dc244d9 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/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/graphql/queries/Group.ts b/backend/src/graphql/queries/Group.ts new file mode 100644 index 000000000..ee01a9177 --- /dev/null +++ b/backend/src/graphql/queries/Group.ts @@ -0,0 +1,36 @@ +import gql from 'graphql-tag' + +export const Group = gql` + query Group($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/GroupMembers.ts b/backend/src/graphql/queries/GroupMembers.ts new file mode 100644 index 000000000..5950952cb --- /dev/null +++ b/backend/src/graphql/queries/GroupMembers.ts @@ -0,0 +1,12 @@ +import gql from 'graphql-tag' + +export const GroupMembers = gql` + query GroupMembers($id: ID!) { + GroupMembers(id: $id) { + id + name + slug + myRoleInGroup + } + } +` diff --git a/backend/src/graphql/queries/currentUser.ts b/backend/src/graphql/queries/currentUser.ts new file mode 100644 index 000000000..753fe5288 --- /dev/null +++ b/backend/src/graphql/queries/currentUser.ts @@ -0,0 +1,15 @@ +import gql from 'graphql-tag' + +export const currentUser = gql` + query currentUser { + currentUser { + following { + name + } + inviteCodes { + code + redeemedByCount + } + } + } +` diff --git a/backend/src/graphql/queries/generateGroupInviteCode.ts b/backend/src/graphql/queries/generateGroupInviteCode.ts new file mode 100644 index 000000000..5633b41b7 --- /dev/null +++ b/backend/src/graphql/queries/generateGroupInviteCode.ts @@ -0,0 +1,36 @@ +import gql from 'graphql-tag' + +export const generateGroupInviteCode = gql` + mutation generateGroupInviteCode($groupId: ID!, $expiresAt: String, $comment: String) { + generateGroupInviteCode(groupId: $groupId, expiresAt: $expiresAt, comment: $comment) { + 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/generatePersonalInviteCode.ts b/backend/src/graphql/queries/generatePersonalInviteCode.ts new file mode 100644 index 000000000..429b25549 --- /dev/null +++ b/backend/src/graphql/queries/generatePersonalInviteCode.ts @@ -0,0 +1,36 @@ +import gql from 'graphql-tag' + +export const generatePersonalInviteCode = gql` + mutation generatePersonalInviteCode($expiresAt: String, $comment: String) { + generatePersonalInviteCode(expiresAt: $expiresAt, comment: $comment) { + 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/groupMembersQuery.ts b/backend/src/graphql/queries/groupMembersQuery.ts deleted file mode 100644 index b1b8cb313..000000000 --- a/backend/src/graphql/queries/groupMembersQuery.ts +++ /dev/null @@ -1,14 +0,0 @@ -import gql from 'graphql-tag' - -export const groupMembersQuery = () => { - 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..94829553c 100644 --- a/backend/src/graphql/resolvers/inviteCodes.spec.ts +++ b/backend/src/graphql/resolvers/inviteCodes.spec.ts @@ -1,214 +1,1205 @@ /* 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: expect.arrayContaining([ + { + 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: currentUser })).resolves.toMatchObject({ + data: { + currentUser: { + inviteCodes: expect.arrayContaining([ + { + code: 'GRPPBL', + redeemedByCount: 1, + }, + { + code: 'GRPHDN', + redeemedByCount: 0, + }, + ]), + }, + }, + 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: currentUser })).resolves.toMatchObject({ + data: { + currentUser: { + inviteCodes: expect.arrayContaining([ + { + code: 'GRPPBL', + redeemedByCount: 0, + }, + { + 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: expect.arrayContaining([ + { + 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: currentUser })).resolves.toMatchObject({ + data: { + currentUser: { + following: [], + inviteCodes: expect.arrayContaining([ + { + code: 'GRPPBL', + redeemedByCount: 0, + }, + { + code: 'GRPHDN', + redeemedByCount: 0, + }, + ]), + }, + }, + errors: undefined, + }) }) }) }) diff --git a/backend/src/graphql/resolvers/inviteCodes.ts b/backend/src/graphql/resolvers/inviteCodes.ts index 02680b5bc..5d4638d1d 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..de5eb962e 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,14 +54,45 @@ 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') + + // To allow redeeming and return an User object we require a User in the context + context.user = user + // join Group via invite Code + await redeemInviteCode(context, inviteCode, true) + return user }) try { @@ -70,51 +103,8 @@ export default { 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..294dd771f 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,23 @@ export default { }, }, User: { + inviteCodes: async (_parent, _args, context: Context, _resolveInfo) => { + const { + user: { id: userId }, + } = context + + return ( + await context.database.query({ + query: ` + MATCH (user:User {id: $userId})-[:GENERATED]->(inviteCodes:InviteCode) + WHERE NOT (inviteCodes)-[:INVITES_TO]->(:Group) + RETURN inviteCodes {.*} + ORDER BY inviteCodes.createdAt ASC + `, + variables: { userId }, + }) + ).records + }, emailNotificationSettings: async (parent, _params, _context, _resolveInfo) => { return [ { 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() From fbec8288b2417e6ac4f7b5b64a32b78296328f3f Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Thu, 8 May 2025 22:27:41 +0200 Subject: [PATCH 6/6] refactor(backend): category seed (#8505) * define ids and slugs in categories, check for existing ids, only seed the new ids * seed categories respecting existing categories --------- Co-authored-by: Ulf Gebhardt --- backend/Dockerfile | 2 + backend/package.json | 3 +- backend/src/constants/categories.ts | 58 +++++++++++++++++---------- backend/src/context/database.ts | 4 +- backend/src/db/categories.ts | 62 ++++++++++++++++------------- 5 files changed, 78 insertions(+), 51 deletions(-) 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/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/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 c1dc244d9..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 = {} }: { query: string; variables: object }) => { + async ({ query, variables = {} }: { query: string; variables?: object }) => { const session = driver.session() const result = session.readTransaction(async (transaction) => { @@ -21,7 +21,7 @@ export const query = export const write = (driver: Driver) => - async ({ query, variables = {} }: { query: string; variables: object }) => { + async ({ query, variables = {} }: { query: string; variables?: object }) => { const session = driver.session() const result = session.writeTransaction(async (transaction) => { 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 () {