Merge branch 'master' of github.com:Ocelot-Social-Community/Ocelot-Social into brand-reformer-network-first-step

# Conflicts:
#	backend/src/config/logos.ts
#	webapp/constants/logos.js
This commit is contained in:
Wolfgang Huß 2025-05-09 19:08:26 +02:00
commit dea77e8c31
58 changed files with 2438 additions and 858 deletions

View File

@ -26,6 +26,8 @@ SMTP_DKIM_PRIVATKEY=
# SMTP_IGNORE_TLS=true
# SMTP_USERNAME=
# SMTP_PASSWORD=
# SMTP_MAX_CONNECTIONS=1
# SMTP_MAX_MESSAGES= 10
JWT_SECRET="b/&&7b78BF&fv/Vd"
JWT_EXPIRES="2y"

View File

@ -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

View File

@ -87,9 +87,9 @@ A fresh database needs to be initialized and migrated.
# in folder backend while database is running
yarn db:migrate init
# for docker environments:
docker exec backend yarn db:migrate init
docker exec ocelot-social-backend-1 yarn db:migrate init
# for docker production:
docker exec backend yarn prod:migrate init
docker exec ocelot-social-backend-1 yarn prod:migrate init
```
```sh
@ -97,9 +97,9 @@ docker exec backend yarn prod:migrate init
yarn db:migrate up
# for docker development:
docker exec backend yarn db:migrate up
docker exec ocelot-social-backend-1 yarn db:migrate up
# for docker production
docker exec backend yarn prod:migrate up
docker exec ocelot-social-backend-1 yarn prod:migrate up
```
### Optional Data
@ -131,7 +131,7 @@ To do so, run:
yarn db:data:branding
# for docker
docker exec backend yarn db:data:branding
docker exec ocelot-social-backend-1 yarn db:data:branding
```
### Seed Data
@ -143,7 +143,7 @@ For a predefined set of test data you can seed the database with:
yarn db:seed
# for docker
docker exec backend yarn db:seed
docker exec ocelot-social-backend-1 yarn db:seed
```
### Reset Data
@ -157,9 +157,9 @@ yarn db:reset
yarn db:reset:withmigrations
# for docker
docker exec backend yarn db:reset
docker exec ocelot-social-backend-1 yarn db:reset
# or deleting the migrations as well
docker exec backend yarn db:reset:withmigrations
docker exec ocelot-social-backend-1 yarn db:reset:withmigrations
# you could also wipe out your neo4j database and delete all volumes with:
docker compose down -v
```
@ -180,7 +180,7 @@ $ yarn run db:migrate:create your_data_migration
# for docker
# in main folder while docker compose is running
$ docker compose exec backend yarn run db:migrate:create your_data_migration
$ docker compose exec ocelot-social-backend-1 yarn run db:migrate:create your_data_migration
# Edit the file in ./src/db/migrations/
```
@ -208,5 +208,12 @@ $ yarn run test
# for docker
# in main folder while docker compose is running
$ docker exec backend yarn run test
$ docker exec ocelot-social-backend-1 yarn run test
```
If the snapshots of the emails must be updated, you have to run the tests in docker! Otherwise the CI will fail.
```sh
# in main folder while docker compose is running
$ docker exec ocelot-social-backend-1 yarn run test -u src/emails/
```

View File

@ -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",

View File

@ -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,
}

View File

@ -0,0 +1,32 @@
// this file is duplicated in `backend/src/config/logos.ts` and `webapp/constants/logos.js` and replaced on rebranding
// this are the paths in the webapp
import { merge } from 'lodash'
import logos from '@config/logos'
const defaultLogos = {
LOGO_HEADER_PATH: '/img/custom/logo-horizontal.svg',
LOGO_HEADER_MOBILE_PATH: '/img/custom/logo-horizontal.svg',
LOGO_HEADER_WIDTH: '130px',
LOGO_HEADER_MOBILE_WIDTH: '100px',
LOGO_HEADER_CLICK: {
// externalLink: {
// url: 'https://ocelot.social',
// target: '_blank',
// },
externalLink: null,
internalPath: {
to: {
name: 'index',
},
scrollTo: '.main-navigation',
},
},
LOGO_SIGNUP_PATH: '/img/custom/logo-squared.svg',
LOGO_WELCOME_PATH: '/img/custom/logo-squared.svg',
LOGO_LOGOUT_PATH: '/img/custom/logo-squared.svg',
LOGO_PASSWORD_RESET_PATH: '/img/custom/logo-squared.svg',
LOGO_MAINTENACE_RESET_PATH: '/img/custom/logo-squared.svg',
}
export default merge(defaultLogos, logos)

View File

@ -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: '',
},
]

View File

@ -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),
}
}

View File

@ -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 () {

View File

@ -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
})

View File

@ -14,4 +14,10 @@ export default {
target: 'User',
direction: 'in',
},
invitesTo: {
type: 'relationship',
relationship: 'INVITES_TO',
target: 'Group',
direction: 'out',
},
}

View File

@ -91,7 +91,7 @@ footer {
<h2>Hello chatReceiver,</h2>
<div class="wrapper">
<div class="content"></div>
<p>you have received a new chat message from <a class="user" href="http://webapp:3000/user/chatSender/chatsender">chatSender</a>.
<p>you have received a new chat message from <a class="user" href="http://webapp:3000/profile/chatSender/chatsender">chatSender</a>.
</p><a class="button" href="http://webapp:3000/chat">Show Chat</a>
<div class="text-block">
<p>See you soon on <a class="organization" href="https://ocelot.social">ocelot.social</a>!</p>
@ -109,7 +109,7 @@ footer {
"text": "HELLO CHATRECEIVER,
you have received a new chat message from chatSender
[http://webapp:3000/user/chatSender/chatsender].
[http://webapp:3000/profile/chatSender/chatsender].
Show Chat [http://webapp:3000/chat]
@ -218,7 +218,7 @@ footer {
<h2>Hallo chatReceiver,</h2>
<div class="wrapper">
<div class="content"></div>
<p>du hast eine neue Chat-Nachricht von <a class="user" href="http://webapp:3000/user/chatSender/chatsender">chatSender</a> erhalten.
<p>du hast eine neue Chat-Nachricht von <a class="user" href="http://webapp:3000/profile/chatSender/chatsender">chatSender</a> erhalten.
</p><a class="button" href="http://webapp:3000/chat">Chat anzeigen</a>
<div class="text-block">
<p>Bis bald bei <a class="organization" href="https://ocelot.social">ocelot.social</a>!</p>
@ -236,7 +236,7 @@ footer {
"text": "HALLO CHATRECEIVER,
du hast eine neue Chat-Nachricht von chatSender
[http://webapp:3000/user/chatSender/chatsender] erhalten.
[http://webapp:3000/profile/chatSender/chatsender] erhalten.
Chat anzeigen [http://webapp:3000/chat]

View File

@ -221,9 +221,9 @@ footer {
<h2>Hallo User,</h2>
<div class="wrapper">
<div class="content"></div>
<p>Du möchtest also deine E-Mail ändern? Kein Problem! Mit Klick auf diesen Button kannst Du Deine neue E-Mail Adresse bestätigen:</p><a class="button" href="http://webapp:3000/settings/my-email-address/verify?email=user%40example.org&amp;nonce=123456">E-Mail Adresse bestätigen</a>
<p>Falls Du deine E-Mail Adresse doch nicht ändern möchtest, kannst du diese Nachricht einfach ignorieren. </p>
<p>Sollte der Button für Dich nicht funktionieren, kannst Du auch folgenden Code in Dein Browserfenster kopieren: <span>123456</span></p>
<p>Du möchtest also deine E-Mail ändern? Kein Problem! Mit Klick auf diesen Button kannst du deine neue E-Mail Adresse bestätigen:</p><a class="button" href="http://webapp:3000/settings/my-email-address/verify?email=user%40example.org&amp;nonce=123456">E-Mail Adresse bestätigen</a>
<p>Falls du deine E-Mail Adresse doch nicht ändern möchtest, kannst du diese Nachricht einfach ignorieren. </p>
<p>Sollte der Button für dich nicht funktionieren, kannst du auch folgenden Code in dein Browserfenster kopieren: <span>123456</span></p>
<div class="text-block">
<p>Bis bald bei <a class="organization" href="https://ocelot.social">ocelot.social</a>!</p>
<p> Dein ocelot.social Team</p>
@ -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]!

View File

@ -91,7 +91,7 @@ footer {
<h2>Hello Jenny Rostock,</h2>
<div class="wrapper">
<div class="content"></div>
<p>your role in the group “The Group” has been changed. Click on the button to view this group:</p><a class="button" href="http://webapp:3000/group/g1/the-group">View group</a>
<p>your role in the group “The Group” has been changed. Click on the button to view this group:</p><a class="button" href="http://webapp:3000/groups/g1/the-group">View group</a>
<div class="text-block">
<p>See you soon on <a class="organization" href="https://ocelot.social">ocelot.social</a>!</p>
<p> The ocelot.social Team</p><br>
@ -110,7 +110,7 @@ footer {
your role in the group “The Group” has been changed. Click on the button to view
this group:
View group [http://webapp:3000/group/g1/the-group]
View group [http://webapp:3000/groups/g1/the-group]
See you soon on ocelot.social [https://ocelot.social]!
@ -217,7 +217,7 @@ footer {
<h2>Hello Jenny Rostock,</h2>
<div class="wrapper">
<div class="content"></div>
<p><a class="user" href="http://webapp:3000/user/u2/peter-lustig">Peter Lustig</a> commented on a post that you are observing with the title “New Post”. Click on the button to view this comment:
<p><a class="user" href="http://webapp:3000/profile/u2/peter-lustig">Peter Lustig</a> commented on a post that you are observing with the title “New Post”. Click on the button to view this comment:
</p><a class="button" href="http://webapp:3000/post/p1/new-post#commentId-c1">View comment</a>
<div class="text-block">
<p>See you soon on <a class="organization" href="https://ocelot.social">ocelot.social</a>!</p>
@ -234,9 +234,9 @@ footer {
"subject": "ocelot.social Notification: New comment on post",
"text": "HELLO JENNY ROSTOCK,
Peter Lustig [http://webapp:3000/user/u2/peter-lustig] commented on a post that
you are observing with the title “New Post”. Click on the button to view this
comment:
Peter Lustig [http://webapp:3000/profile/u2/peter-lustig] commented on a post
that you are observing with the title “New Post”. Click on the button to view
this comment:
View comment [http://webapp:3000/post/p1/new-post#commentId-c1]
@ -473,7 +473,7 @@ footer {
<h2>Hello Jenny Rostock,</h2>
<div class="wrapper">
<div class="content"></div>
<p><a class="user" href="http://webapp:3000/user/u2/peter-lustig">Peter Lustig</a> mentioned you in a comment to the post with the title “New Post”. Click on the button to view this comment:
<p><a class="user" href="http://webapp:3000/profile/u2/peter-lustig">Peter Lustig</a> mentioned you in a comment to the post with the title “New Post”. Click on the button to view this comment:
</p><a class="button" href="http://webapp:3000/post/p1/new-post#commentId-c1">View comment</a>
<div class="text-block">
<p>See you soon on <a class="organization" href="https://ocelot.social">ocelot.social</a>!</p>
@ -490,7 +490,7 @@ footer {
"subject": "ocelot.social Notification: Mentioned in comment",
"text": "HELLO JENNY ROSTOCK,
Peter Lustig [http://webapp:3000/user/u2/peter-lustig] mentioned you in a
Peter Lustig [http://webapp:3000/profile/u2/peter-lustig] mentioned you in a
comment to the post with the title “New Post”. Click on the button to view this
comment:
@ -977,8 +977,8 @@ footer {
<h2>Hello Jenny Rostock,</h2>
<div class="wrapper">
<div class="content"></div>
<p><a class="user" href="http://webapp:3000/user/u2/peter-lustig">Peter Lustig</a> joined the group “The Group”. Click on the button to view this group:
</p><a class="button" href="http://webapp:3000/group/g1/the-group">View group</a>
<p><a class="user" href="http://webapp:3000/profile/u2/peter-lustig">Peter Lustig</a> joined the group “The Group”. Click on the button to view this group:
</p><a class="button" href="http://webapp:3000/groups/g1/the-group">View group</a>
<div class="text-block">
<p>See you soon on <a class="organization" href="https://ocelot.social">ocelot.social</a>!</p>
<p> The ocelot.social Team</p><br>
@ -994,10 +994,10 @@ footer {
"subject": "ocelot.social Notification: User joined group",
"text": "HELLO JENNY ROSTOCK,
Peter Lustig [http://webapp:3000/user/u2/peter-lustig] joined the group “The
Peter Lustig [http://webapp:3000/profile/u2/peter-lustig] joined the group “The
Group”. Click on the button to view this group:
View group [http://webapp:3000/group/g1/the-group]
View group [http://webapp:3000/groups/g1/the-group]
See you soon on ocelot.social [https://ocelot.social]!
@ -1104,8 +1104,8 @@ footer {
<h2>Hello Jenny Rostock,</h2>
<div class="wrapper">
<div class="content"></div>
<p><a class="user" href="http://webapp:3000/user/u2/peter-lustig">Peter Lustig</a> left the group “The Group”. Click on the button to view this group:
</p><a class="button" href="http://webapp:3000/group/g1/the-group">View group</a>
<p><a class="user" href="http://webapp:3000/profile/u2/peter-lustig">Peter Lustig</a> left the group “The Group”. Click on the button to view this group:
</p><a class="button" href="http://webapp:3000/groups/g1/the-group">View group</a>
<div class="text-block">
<p>See you soon on <a class="organization" href="https://ocelot.social">ocelot.social</a>!</p>
<p> The ocelot.social Team</p><br>
@ -1121,10 +1121,10 @@ footer {
"subject": "ocelot.social Notification: User left group",
"text": "HELLO JENNY ROSTOCK,
Peter Lustig [http://webapp:3000/user/u2/peter-lustig] left the group “The
Peter Lustig [http://webapp:3000/profile/u2/peter-lustig] left the group “The
Group”. Click on the button to view this group:
View group [http://webapp:3000/group/g1/the-group]
View group [http://webapp:3000/groups/g1/the-group]
See you soon on ocelot.social [https://ocelot.social]!
@ -1231,7 +1231,7 @@ footer {
<h2>Hallo Jenny Rostock,</h2>
<div class="wrapper">
<div class="content"></div>
<p>deine Rolle in der Gruppe „The Group“ wurde geändert. Klicke auf den Knopf, um diese Gruppe zu sehen:</p><a class="button" href="http://webapp:3000/group/g1/the-group">Gruppe ansehen</a>
<p>deine Rolle in der Gruppe „The Group“ wurde geändert. Klicke auf den Knopf, um diese Gruppe zu sehen:</p><a class="button" href="http://webapp:3000/groups/g1/the-group">Gruppe ansehen</a>
<div class="text-block">
<p>Bis bald bei <a class="organization" href="https://ocelot.social">ocelot.social</a>!</p>
<p> Dein ocelot.social Team</p><br>
@ -1250,7 +1250,7 @@ footer {
deine Rolle in der Gruppe „The Group“ wurde geändert. Klicke auf den Knopf, um
diese Gruppe zu sehen:
Gruppe ansehen [http://webapp:3000/group/g1/the-group]
Gruppe ansehen [http://webapp:3000/groups/g1/the-group]
Bis bald bei ocelot.social [https://ocelot.social]!
@ -1357,7 +1357,7 @@ footer {
<h2>Hallo Jenny Rostock,</h2>
<div class="wrapper">
<div class="content"></div>
<p><a class="user" href="http://webapp:3000/user/u2/peter-lustig">Peter Lustig</a> hat einen Beitrag den du beobachtest mit dem Titel „New Post“ kommentiert. Klicke auf den Knopf, um diesen Kommentar zu sehen:
<p><a class="user" href="http://webapp:3000/profile/u2/peter-lustig">Peter Lustig</a> hat einen Beitrag den du beobachtest mit dem Titel „New Post“ kommentiert. Klicke auf den Knopf, um diesen Kommentar zu sehen:
</p><a class="button" href="http://webapp:3000/post/p1/new-post#commentId-c1">Kommentar ansehen</a>
<div class="text-block">
<p>Bis bald bei <a class="organization" href="https://ocelot.social">ocelot.social</a>!</p>
@ -1374,8 +1374,8 @@ footer {
"subject": "ocelot.social Benachrichtigung: Neuer Kommentar zu Beitrag",
"text": "HALLO JENNY ROSTOCK,
Peter Lustig [http://webapp:3000/user/u2/peter-lustig] hat einen Beitrag den du
beobachtest mit dem Titel „New Post“ kommentiert. Klicke auf den Knopf, um
Peter Lustig [http://webapp:3000/profile/u2/peter-lustig] hat einen Beitrag den
du beobachtest mit dem Titel „New Post“ kommentiert. Klicke auf den Knopf, um
diesen Kommentar zu sehen:
Kommentar ansehen [http://webapp:3000/post/p1/new-post#commentId-c1]
@ -1613,7 +1613,7 @@ footer {
<h2>Hallo Jenny Rostock,</h2>
<div class="wrapper">
<div class="content"></div>
<p><a class="user" href="http://webapp:3000/user/u2/peter-lustig">Peter Lustig</a> hat dich in einem Kommentar zu dem Beitrag mit dem Titel „New Post“ erwähnt. Klicke auf den Knopf, um den Kommentar zu sehen:
<p><a class="user" href="http://webapp:3000/profile/u2/peter-lustig">Peter Lustig</a> hat dich in einem Kommentar zu dem Beitrag mit dem Titel „New Post“ erwähnt. Klicke auf den Knopf, um den Kommentar zu sehen:
</p><a class="button" href="http://webapp:3000/post/p1/new-post#commentId-c1">Kommentar ansehen</a>
<div class="text-block">
<p>Bis bald bei <a class="organization" href="https://ocelot.social">ocelot.social</a>!</p>
@ -1630,7 +1630,7 @@ footer {
"subject": "ocelot.social Benachrichtigung: Erwähnung in Kommentar",
"text": "HALLO JENNY ROSTOCK,
Peter Lustig [http://webapp:3000/user/u2/peter-lustig] hat dich in einem
Peter Lustig [http://webapp:3000/profile/u2/peter-lustig] hat dich in einem
Kommentar zu dem Beitrag mit dem Titel „New Post“ erwähnt. Klicke auf den Knopf,
um den Kommentar zu sehen:
@ -1741,7 +1741,7 @@ footer {
<h2>Hallo Jenny Rostock,</h2>
<div class="wrapper">
<div class="content"></div>
<p><a class="user" href="http://webapp:3000/user/u2/peter-lustig">Peter Lustig</a> hat Dich in einem Beitrag mit dem Titel „New Post“ erwähnt. Klicke auf den Knopf, um den Beitrag zu sehen:
<p><a class="user" href="http://webapp:3000/user/u2/peter-lustig">Peter Lustig</a> hat dich in einem Beitrag mit dem Titel „New Post“ erwähnt. Klicke auf den Knopf, um den Beitrag zu sehen:
</p><a class="button" href="http://webapp:3000/post/p1/new-post">Beitrag ansehen</a>
<div class="text-block">
<p>Bis bald bei <a class="organization" href="https://ocelot.social">ocelot.social</a>!</p>
@ -1758,7 +1758,7 @@ footer {
"subject": "ocelot.social Benachrichtigung: Erwähnung in Beitrag",
"text": "HALLO JENNY ROSTOCK,
Peter Lustig [http://webapp:3000/user/u2/peter-lustig] hat Dich in einem Beitrag
Peter Lustig [http://webapp:3000/user/u2/peter-lustig] hat dich in einem Beitrag
mit dem Titel „New Post“ erwähnt. Klicke auf den Knopf, um den Beitrag zu sehen:
Beitrag ansehen [http://webapp:3000/post/p1/new-post]
@ -2117,8 +2117,8 @@ footer {
<h2>Hallo Jenny Rostock,</h2>
<div class="wrapper">
<div class="content"></div>
<p><a class="user" href="http://webapp:3000/user/u2/peter-lustig">Peter Lustig</a> ist der Gruppe „The Group“ beigetreten. Klicke auf den Knopf, um diese Gruppe zu sehen:
</p><a class="button" href="http://webapp:3000/group/g1/the-group">Gruppe ansehen</a>
<p><a class="user" href="http://webapp:3000/profile/u2/peter-lustig">Peter Lustig</a> ist der Gruppe „The Group“ beigetreten. Klicke auf den Knopf, um diese Gruppe zu sehen:
</p><a class="button" href="http://webapp:3000/groups/g1/the-group">Gruppe ansehen</a>
<div class="text-block">
<p>Bis bald bei <a class="organization" href="https://ocelot.social">ocelot.social</a>!</p>
<p> Dein ocelot.social Team</p><br>
@ -2134,10 +2134,10 @@ footer {
"subject": "ocelot.social Benachrichtigung: Nutzer tritt Gruppe bei",
"text": "HALLO JENNY ROSTOCK,
Peter Lustig [http://webapp:3000/user/u2/peter-lustig] ist der Gruppe „The
Peter Lustig [http://webapp:3000/profile/u2/peter-lustig] ist der Gruppe „The
Group“ beigetreten. Klicke auf den Knopf, um diese Gruppe zu sehen:
Gruppe ansehen [http://webapp:3000/group/g1/the-group]
Gruppe ansehen [http://webapp:3000/groups/g1/the-group]
Bis bald bei ocelot.social [https://ocelot.social]!
@ -2244,8 +2244,8 @@ footer {
<h2>Hallo Jenny Rostock,</h2>
<div class="wrapper">
<div class="content"></div>
<p><a class="user" href="http://webapp:3000/user/u2/peter-lustig">Peter Lustig</a> hat die Gruppe „The Group“ verlassen. Klicke auf den Knopf, um diese Gruppe zu sehen:
</p><a class="button" href="http://webapp:3000/group/g1/the-group">Gruppe ansehen</a>
<p><a class="user" href="http://webapp:3000/profile/u2/peter-lustig">Peter Lustig</a> hat die Gruppe „The Group“ verlassen. Klicke auf den Knopf, um diese Gruppe zu sehen:
</p><a class="button" href="http://webapp:3000/groups/g1/the-group">Gruppe ansehen</a>
<div class="text-block">
<p>Bis bald bei <a class="organization" href="https://ocelot.social">ocelot.social</a>!</p>
<p> Dein ocelot.social Team</p><br>
@ -2261,10 +2261,10 @@ footer {
"subject": "ocelot.social Benachrichtigung: Nutzer verlässt Gruppe",
"text": "HALLO JENNY ROSTOCK,
Peter Lustig [http://webapp:3000/user/u2/peter-lustig] hat die Gruppe „The
Peter Lustig [http://webapp:3000/profile/u2/peter-lustig] hat die Gruppe „The
Group“ verlassen. Klicke auf den Knopf, um diese Gruppe zu sehen:
Gruppe ansehen [http://webapp:3000/group/g1/the-group]
Gruppe ansehen [http://webapp:3000/groups/g1/the-group]
Bis bald bei ocelot.social [https://ocelot.social]!

View File

@ -230,12 +230,12 @@ footer {
<h2>Willkommen bei ocelot.social!</h2>
<div class="wrapper">
<div class="content"></div>
<p>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:</p><a class="button" href="http://webapp:3000/registration?email=user%40example.org&amp;nonce=123456&amp;inviteCode=welcome&amp;method=invite-code">Bestätige Deine E-Mail Adresse</a>
<p>Sollte der Button für Dich nicht funktionieren, kannst Du auch folgenden Code in Dein Browserfenster kopieren: <span>123456</span></p>
<p>Das funktioniert allerdings nur, wenn du Dich über unsere Website registriert hast.</p>
<p>Falls Du Dich nicht selbst bei <a>ocelot.social</a> angemeldet hast, schau doch mal vorbei! Wir sind ein gemeinnütziges Aktionsnetzwerk von Menschen für Menschen.
<p>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:</p><a class="button" href="http://webapp:3000/registration?email=user%40example.org&amp;nonce=123456&amp;inviteCode=welcome&amp;method=invite-code">Bestätige deine E-Mail Adresse</a>
<p>Sollte der Button für dich nicht funktionieren, kannst du auch folgenden Code in dein Browserfenster kopieren: <span>123456</span></p>
<p>Das funktioniert allerdings nur, wenn du dich über unsere Website registriert hast.</p>
<p>Falls du dich nicht selbst bei <a>ocelot.social</a> angemeldet hast, schau doch mal vorbei! Wir sind ein gemeinnütziges Aktionsnetzwerk von Menschen für Menschen.
</p>
<p>PS: Wenn Du keinen Account bei uns möchtest, kannst Du diese E-Mail einfach ignorieren. ;)</p>
<p>PS: Wenn du keinen Account bei uns möchtest, kannst du diese E-Mail einfach ignorieren. ;)</p>
<div class="text-block">
<p>Bis bald bei <a class="organization" href="https://ocelot.social">ocelot.social</a>!</p>
<p> Dein ocelot.social Team</p>
@ -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 {
<h2>Willkommen bei ocelot.social!</h2>
<div class="wrapper">
<div class="content"></div>
<p>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:</p><a class="button" href="http://webapp:3000/registration?email=user%40example.org&amp;nonce=123456&amp;method=invite-mail">Bestätige Deine E-Mail Adresse</a>
<p>Sollte der Button für Dich nicht funktionieren, kannst Du auch folgenden Code in Dein Browserfenster kopieren: <span>123456</span></p>
<p>Das funktioniert allerdings nur, wenn du Dich über unsere Website registriert hast.</p>
<p>Falls Du Dich nicht selbst bei <a>ocelot.social</a> angemeldet hast, schau doch mal vorbei! Wir sind ein gemeinnütziges Aktionsnetzwerk von Menschen für Menschen.
<p>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:</p><a class="button" href="http://webapp:3000/registration?email=user%40example.org&amp;nonce=123456&amp;method=invite-mail">Bestätige deine E-Mail Adresse</a>
<p>Sollte der Button für dich nicht funktionieren, kannst du auch folgenden Code in dein Browserfenster kopieren: <span>123456</span></p>
<p>Das funktioniert allerdings nur, wenn du dich über unsere Website registriert hast.</p>
<p>Falls du dich nicht selbst bei <a>ocelot.social</a> angemeldet hast, schau doch mal vorbei! Wir sind ein gemeinnütziges Aktionsnetzwerk von Menschen für Menschen.
</p>
<p>PS: Wenn Du keinen Account bei uns möchtest, kannst Du diese E-Mail einfach ignorieren. ;)</p>
<p>PS: Wenn du keinen Account bei uns möchtest, kannst du diese E-Mail einfach ignorieren. ;)</p>
<div class="text-block">
<p>Bis bald bei <a class="organization" href="https://ocelot.social">ocelot.social</a>!</p>
<p> Dein ocelot.social Team</p>
@ -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]!

View File

@ -220,9 +220,9 @@ footer {
<h2>Hallo Jenny Rostock,</h2>
<div class="wrapper">
<div class="content"></div>
<p>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:</p><a class="button" href="http://webapp:3000/password-reset/change-password?email=user%40example.org&amp;nonce=123456">Bestätige Deine E-Mail Adresse</a>
<p>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:</p><a class="button" href="http://webapp:3000/password-reset/change-password?email=user%40example.org&amp;nonce=123456">Bestätige deine E-Mail Adresse</a>
<p>Falls du kein neues Passwort angefordert hast, kannst du diese E-Mail einfach ignorieren.</p>
<p>Sollte der Button für dich nicht funktionieren, kannst du auch folgenden Code in Dein Browserfenster kopieren: <span>123456</span></p>
<p>Sollte der Button für dich nicht funktionieren, kannst du auch folgenden Code in dein Browserfenster kopieren: <span>123456</span></p>
<div class="text-block">
<p>Bis bald bei <a class="organization" href="https://ocelot.social">ocelot.social</a>!</p>
<p> Dein ocelot.social Team</p>
@ -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]!

View File

@ -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:",

View File

@ -11,7 +11,7 @@ import { createTransport } from 'nodemailer'
// import type Email as EmailType from '@types/email-templates'
import CONFIG, { nodemailerTransportOptions } from '@config/index'
import logosWebapp from '@config/logos'
import logosWebapp from '@config/logosBranded'
import metadata from '@config/metadata'
import { UserDbProperties } from '@db/types/User'
@ -115,7 +115,7 @@ export const sendNotificationMail = async (notification: any): Promise<OriginalM
commenterUrl:
notification?.from?.__typename === 'Comment'
? new URL(
`/user/${notification?.from?.author?.id}/${notification?.from?.author?.slug}`,
`/profile/${notification?.from?.author?.id}/${notification?.from?.author?.slug}`,
CONFIG.CLIENT_URI,
)
: undefined,
@ -131,7 +131,7 @@ export const sendNotificationMail = async (notification: any): Promise<OriginalM
groupUrl:
notification?.from?.__typename === 'Group'
? new URL(
`/group/${notification?.from?.id}/${notification?.from?.slug}`,
`/groups/${notification?.from?.id}/${notification?.from?.slug}`,
CONFIG.CLIENT_URI,
)
: undefined,
@ -142,7 +142,7 @@ export const sendNotificationMail = async (notification: any): Promise<OriginalM
groupRelatedUserUrl:
notification?.from?.__typename === 'Group'
? new URL(
`/user/${notification?.relatedUser?.id}/${notification?.relatedUser?.slug}`,
`/profile/${notification?.relatedUser?.id}/${notification?.relatedUser?.slug}`,
CONFIG.CLIENT_URI,
)
: undefined,
@ -176,7 +176,7 @@ export const sendChatMessageMail = async (
locale: recipientUser.locale,
name: recipientUser.name,
chattingUser: senderUser.name,
chattingUserUrl: new URL(`/user/${senderUser.id}/${senderUser.slug}`, CONFIG.CLIENT_URI),
chattingUserUrl: new URL(`/profile/${senderUser.id}/${senderUser.slug}`, CONFIG.CLIENT_URI),
chatUrl: new URL('/chat', CONFIG.CLIENT_URI),
},
})

View File

@ -0,0 +1,40 @@
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
inviteCodes {
code
redeemedByCount
}
}
}
`

View File

@ -0,0 +1,12 @@
import gql from 'graphql-tag'
export const GroupMembers = gql`
query GroupMembers($id: ID!) {
GroupMembers(id: $id) {
id
name
slug
myRoleInGroup
}
}
`

View File

@ -0,0 +1,15 @@
import gql from 'graphql-tag'
export const currentUser = gql`
query currentUser {
currentUser {
following {
name
}
inviteCodes {
code
redeemedByCount
}
}
}
`

View File

@ -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
}
}
`

View File

@ -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
}
}
`

View File

@ -1,14 +0,0 @@
import gql from 'graphql-tag'
export const groupMembersQuery = () => {
return gql`
query ($id: ID!) {
GroupMembers(id: $id) {
id
name
slug
myRoleInGroup
}
}
`
}

View File

@ -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
}
}
`
}

View File

@ -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
}
}
`

View File

@ -0,0 +1,7 @@
import gql from 'graphql-tag'
export const redeemInviteCode = gql`
mutation redeemInviteCode($code: String!) {
redeemInviteCode(code: $code)
}
`

View File

@ -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
}
}
`

View File

@ -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()
})

View File

@ -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,
},

View File

@ -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
},
},
}

View File

@ -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('')
}

File diff suppressed because it is too large Load Diff

View File

@ -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)',
},

View File

@ -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) => {

View File

@ -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<typeof Image>('Image', { sensitive: true }, undefined),
database.neode.first<typeof Image>('Image', { sensitive: true }, undefined),
).resolves.toBeFalsy()
await mutate({ mutation: updatePostMutation, variables })
await expect(
neode.first<typeof Image>('Image', { sensitive: true }, undefined),
database.neode.first<typeof Image>('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<typeof Image>('Image', { sensitive: true }, undefined),
database.neode.first<typeof Image>('Image', { sensitive: true }, undefined),
).resolves.toBeFalsy()
await mutate({ mutation: updatePostMutation, variables })
await expect(
neode.first<typeof Image>('Image', { sensitive: true }, undefined),
database.neode.first<typeof Image>('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' }
})

View File

@ -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<typeof EmailAddress>(
const emailAddress = await database.neode.first<typeof EmailAddress>(
'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<typeof EmailAddress>(
const emailAddress = await database.neode.first<typeof EmailAddress>(
'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<typeof User>('User', { name: 'John Doe' }, undefined)
const user = await database.neode.first<typeof User>(
'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)
})

View File

@ -7,10 +7,12 @@ import { UserInputError } from 'apollo-server'
import { hash } from 'bcryptjs'
import { getNeode } from '@db/neo4j'
import { Context } from '@src/server'
import existingEmailAddress from './helpers/existingEmailAddress'
import generateNonce from './helpers/generateNonce'
import normalizeEmail from './helpers/normalizeEmail'
import { redeemInviteCode } from './inviteCodes'
const neode = getNeode()
@ -33,7 +35,7 @@ export default {
throw new UserInputError(e.message)
}
},
SignupVerification: async (_parent, args, context) => {
SignupVerification: async (_parent, args, context: Context) => {
const { termsAndConditionsAgreedVersion } = args
const regEx = /^[0-9]+\.[0-9]+\.[0-9]+$/g
if (!regEx.test(termsAndConditionsAgreedVersion)) {
@ -52,69 +54,60 @@ export default {
const { driver } = context
const session = driver.session()
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
const createUserTransactionResponse = await transaction.run(signupCypher(inviteCode), {
args,
nonce,
email,
inviteCode,
})
const createUserTransactionResponse = await transaction.run(
`
MATCH (email:EmailAddress {nonce: $nonce, email: $email})
WHERE NOT (email)-[:BELONGS_TO]->()
CREATE (user:User)
MERGE (user)-[:PRIMARY_EMAIL]->(email)
MERGE (user)<-[:BELONGS_TO]-(email)
SET user += $args
SET user.id = randomUUID()
SET user.role = 'user'
SET user.createdAt = toString(datetime())
SET user.updatedAt = toString(datetime())
SET user.allowEmbedIframes = false
SET user.showShoutsPublicly = false
SET email.verifiedAt = toString(datetime())
WITH user
OPTIONAL MATCH (post:Post)-[:IN]->(group:Group)
WHERE NOT group.groupType = 'public'
WITH user, collect(post) AS invisiblePosts
FOREACH (invisiblePost IN invisiblePosts |
MERGE (user)-[:CANNOT_SEE]->(invisiblePost)
)
RETURN user {.*}
`,
{
args,
nonce,
email,
inviteCode,
},
)
const [user] = createUserTransactionResponse.records.map((record) => record.get('user'))
if (!user) throw new UserInputError('Invalid email or nonce')
return user
})
try {
const user = await writeTxResultPromise
// To allow redeeming and return an User object we require a User in the context
context.user = user
if (inviteCode) {
await redeemInviteCode(context, inviteCode, true)
}
return user
} catch (e) {
if (e.code === 'Neo.ClientError.Schema.ConstraintValidationFailed')
throw new UserInputError('User with this slug already exists!')
throw new UserInputError(e.message)
} finally {
session.close()
await session.close()
}
},
},
}
const signupCypher = (inviteCode) => {
let optionalMatch = ''
let optionalMerge = ''
if (inviteCode) {
optionalMatch = `
OPTIONAL MATCH
(inviteCode:InviteCode {code: $inviteCode})<-[:GENERATED]-(host:User)
`
optionalMerge = `
MERGE (user)-[:REDEEMED { createdAt: toString(datetime()) }]->(inviteCode)
MERGE (host)-[:INVITED { createdAt: toString(datetime()) }]->(user)
MERGE (user)-[:FOLLOWS { createdAt: toString(datetime()) }]->(host)
MERGE (host)-[:FOLLOWS { createdAt: toString(datetime()) }]->(user)
`
}
const cypher = `
MATCH (email:EmailAddress {nonce: $nonce, email: $email})
WHERE NOT (email)-[:BELONGS_TO]->()
${optionalMatch}
CREATE (user:User)
MERGE (user)-[:PRIMARY_EMAIL]->(email)
MERGE (user)<-[:BELONGS_TO]-(email)
${optionalMerge}
SET user += $args
SET user.id = randomUUID()
SET user.role = 'user'
SET user.createdAt = toString(datetime())
SET user.updatedAt = toString(datetime())
SET user.allowEmbedIframes = false
SET user.showShoutsPublicly = false
SET email.verifiedAt = toString(datetime())
WITH user
OPTIONAL MATCH (post:Post)-[:IN]->(group:Group)
WHERE NOT group.groupType = 'public'
WITH user, collect(post) AS invisiblePosts
FOREACH (invisiblePost IN invisiblePosts |
MERGE (user)-[:CANNOT_SEE]->(invisiblePost)
)
RETURN user {.*}
`
return cypher
}

View File

@ -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()
}
}

View File

@ -10,6 +10,7 @@ import { neo4jgraphql } from 'neo4j-graphql-js'
import { TROPHY_BADGES_SELECTED_MAX } from '@constants/badges'
import { getNeode } from '@db/neo4j'
import { Context } from '@src/server'
import { defaultTrophyBadge, defaultVerificationBadge } from './badges'
import Resolver from './helpers/Resolver'
@ -467,6 +468,19 @@ export default {
},
},
User: {
inviteCodes: async (_parent, _args, context: Context, _resolveInfo) => {
return (
await context.database.query({
query: `
MATCH (user:User {id: $user.id})-[:GENERATED]->(inviteCodes:InviteCode)
WHERE NOT (inviteCodes)-[:INVITES_TO]->(:Group)
RETURN inviteCodes {.*}
ORDER BY inviteCodes.createdAt ASC
`,
variables: { user: context.user },
})
).records.map((record) => record.get('inviteCodes'))
},
emailNotificationSettings: async (parent, _params, _context, _resolveInfo) => {
return [
{
@ -668,7 +682,6 @@ export default {
shouted: '-[:SHOUTED]->(related:Post)',
categories: '-[:CATEGORIZED]->(related:Category)',
badgeTrophies: '<-[:REWARDED]-(related:Badge)',
inviteCodes: '-[:GENERATED]->(related:InviteCode)',
},
}),
},

View File

@ -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
}

View File

@ -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!
}

View File

@ -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(

View File

@ -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'

View File

@ -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',

View File

@ -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,

View File

@ -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 () => {

View File

@ -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()

View File

@ -17,6 +17,7 @@
:loading-rooms="loadingRooms"
show-files="false"
show-audio="false"
:height="'calc(100dvh - 190px)'"
:styles="JSON.stringify(computedChatStyle)"
:show-footer="true"
:responsive-breakpoint="responsiveBreakpoint"

View File

@ -136,12 +136,12 @@
<ds-flex-item class="mobile-hamburger-menu">
<client-only>
<!-- chat menu -->
<div style="display: inline-flex">
<div>
<chat-notification-menu />
</div>
<!-- notification menu -->
<div style="display: inline-flex; padding-right: clamp(10px, 2.5vw, 20px)">
<notification-menu />
<div>
<notification-menu no-menu />
</div>
</client-only>
<!-- hamburger menu -->
@ -284,7 +284,7 @@ import { mapGetters } from 'vuex'
import isEmpty from 'lodash/isEmpty'
import { SHOW_GROUP_BUTTON_IN_HEADER } from '~/constants/groups.js'
import { SHOW_CONTENT_FILTER_HEADER_MENU } from '~/constants/filter.js'
import LOGOS from '~/constants/logos.js'
import LOGOS from '~/constants/logosBranded.js'
import AvatarMenu from '~/components/AvatarMenu/AvatarMenu'
import ChatNotificationMenu from '~/components/ChatNotificationMenu/ChatNotificationMenu'
import CustomButton from '~/components/CustomButton/CustomButton'
@ -409,6 +409,25 @@ export default {
flex-flow: row nowrap;
align-items: center;
justify-content: flex-end;
& > div {
display: inline-flex;
padding-right: 15px;
&:first-child {
padding-right: 10px;
}
button {
overflow: visible;
.svg {
height: 1.8em;
}
}
}
.hamburger-button .svg {
height: 1.5em;
}
}
.mobile-menu {
margin: 0 20px;

View File

@ -1,29 +1,30 @@
<template>
<component :is="tag" class="ds-logo" :class="[inverse && 'ds-logo-inverse']">
<!-- Desktop logo -->
<img
v-if="!inverse"
class="ds-logo-svg"
class="ds-logo-svg ds-logo-desktop"
:alt="metadata.APPLICATION_NAME + ' ' + logo.alt"
:src="logo.path"
:style="logoWidthStyle"
/>
<!-- Mobile logo (falls back to desktop if not provided) -->
<img
v-else
class="ds-logo-svg"
:alt="metadata.APPLICATION_NAME + ' ' + logo.alt"
:src="logo.path"
:style="logoWidthStyle"
class="ds-logo-svg ds-logo-mobile"
:alt="metadata.APPLICATION_NAME + ' ' + logo.alt + ' Mobile'"
:src="logo.mobilePath || logo.path"
:style="mobileLogoWidthStyle"
/>
</component>
</template>
<script>
import logos from '~/constants/logos.js'
import logos from '~/constants/logosBranded.js'
import metadata from '~/constants/metadata.js'
/**
* This component displays the brand's logo.
* @version 1.0.0
* @version 1.1.0
*/
export default {
name: 'Logo',
@ -42,6 +43,13 @@ export default {
type: String,
default: null,
},
/**
* Mobile logo width
*/
mobileLogoWidth: {
type: String,
default: null,
},
/**
* Inverse the logo
*/
@ -61,8 +69,10 @@ export default {
const logosObject = {
header: {
path: logos.LOGO_HEADER_PATH,
mobilePath: logos.LOGO_HEADER_MOBILE_PATH || null,
alt: 'Header',
widthDefault: logos.LOGO_HEADER_WIDTH,
mobileWidthDefault: logos.LOGO_HEADER_MOBILE_WIDTH || logos.LOGO_HEADER_WIDTH,
},
welcome: { path: logos.LOGO_WELCOME_PATH, alt: 'Welcome', widthDefault: '200px' },
signup: { path: logos.LOGO_SIGNUP_PATH, alt: 'Sign Up', widthDefault: '200px' },
@ -85,12 +95,12 @@ export default {
},
computed: {
logoWidthStyle() {
let width = ''
if (this.logoWidth === null) {
width = this.logo.widthDefault
} else {
width = this.logoWidth
}
const width = this.logoWidth === null ? this.logo.widthDefault : this.logoWidth
return `width: ${width};`
},
mobileLogoWidthStyle() {
const width =
this.mobileLogoWidth === null ? this.logo.mobileWidthDefault : this.mobileLogoWidth
return `width: ${width};`
},
},
@ -115,6 +125,25 @@ export default {
fill: #000000;
max-width: 100%;
}
/* Show desktop logo by default and hide mobile logo */
.ds-logo-desktop {
display: block;
}
.ds-logo-mobile {
display: none;
}
@media (max-width: 767px) {
.ds-logo-desktop {
display: none;
}
.ds-logo-mobile {
display: block;
}
}
</style>
<docs src="./demo.md"></docs>

View File

@ -14,6 +14,18 @@
}"
/>
</nuxt-link>
<nuxt-link v-else-if="noMenu" class="notifications-menu" :to="{ name: 'notifications' }">
<base-button
ghost
circle
v-tooltip="{
content: $t('header.notifications.tooltip'),
placement: 'bottom-start',
}"
>
<counter-icon icon="bell" :count="unreadNotificationsCount" danger />
</base-button>
</nuxt-link>
<dropdown v-else class="notifications-menu" offset="8" :placement="placement">
<template #default="{ toggleMenu }">
<base-button
@ -29,9 +41,6 @@
</base-button>
</template>
<template #popover="{ closeMenu }">
<div class="notifications-menu-popover">
<notification-list :notifications="notifications" @markAsRead="markAsRead" />
</div>
<ds-flex class="notifications-link-container">
<ds-flex-item class="notifications-link-container-item" :width="{ base: '100%' }" centered>
<nuxt-link :to="{ name: 'notifications' }">
@ -51,6 +60,9 @@
</base-button>
</ds-flex-item>
</ds-flex>
<div class="notifications-menu-popover">
<notification-list :notifications="notifications" @markAsRead="markAsRead" />
</div>
</template>
</dropdown>
</template>
@ -82,6 +94,7 @@ export default {
},
props: {
placement: { type: String },
noMenu: { type: Boolean, default: false },
},
methods: {
async markAsRead(notificationSourceId) {

View File

@ -32,3 +32,16 @@ export default {
},
}
</script>
<style lang="scss" scoped>
@media only screen and (max-width: 500px) {
.ds-container {
padding-left: 0 !important;
padding-right: 0 !important;
.base-card {
padding: 16px !important;
}
}
}
</style>

View File

@ -1,6 +1,6 @@
import { storiesOf } from '@storybook/vue'
import helpers from '~/storybook/helpers'
import logos from '~/constants/logos.js'
import logos from '~/constants/logosBranded.js'
import BaseCard from './BaseCard.vue'
storiesOf('Generic/BaseCard', module)

View File

@ -0,0 +1,31 @@
// this file is duplicated in `backend/src/config/logos.js` and `webapp/constants/logos.js` and replaced on rebranding
// this are the paths in the webapp
import { merge } from 'lodash'
import logos from '~/constants/logos'
const defaultLogos = {
LOGO_HEADER_PATH: '/img/custom/logo-horizontal.svg',
LOGO_HEADER_MOBILE_PATH: '/img/custom/logo-horizontal.svg',
LOGO_HEADER_WIDTH: '130px',
LOGO_HEADER_MOBILE_WIDTH: '100px',
LOGO_HEADER_CLICK: {
// externalLink: {
// url: 'https://ocelot.social',
// target: '_blank',
// },
externalLink: null,
internalPath: {
to: {
name: 'index',
},
scrollTo: '.main-navigation',
},
},
LOGO_SIGNUP_PATH: '/img/custom/logo-squared.svg',
LOGO_WELCOME_PATH: '/img/custom/logo-squared.svg',
LOGO_LOGOUT_PATH: '/img/custom/logo-squared.svg',
LOGO_PASSWORD_RESET_PATH: '/img/custom/logo-squared.svg',
LOGO_MAINTENACE_RESET_PATH: '/img/custom/logo-squared.svg',
}
export default merge(defaultLogos, logos)

View File

@ -23,7 +23,7 @@
</ds-container>
</div>
<ds-container>
<div style="padding: 5rem 2rem">
<div class="content">
<nuxt />
</div>
</ds-container>
@ -61,7 +61,7 @@ export default {
}
</script>
<style lang="scss">
<style lang="scss" scoped>
.main-navigation-right {
display: flex;
justify-content: flex-end;
@ -69,4 +69,14 @@ export default {
.main-navigation-right .desktop-view {
float: right;
}
.layout-blank .content {
padding: 5rem 2rem;
}
@media only screen and (max-width: 500px) {
.layout-blank .content {
padding-left: 0 !important;
padding-right: 0 !important;
}
}
</style>

View File

@ -65,7 +65,7 @@
"tagCountUnique": "Nutzer"
},
"invites": {
"description": "Einladungen sind eine wunderbare Möglichkeit, Deine Freunde in Deinem Netzwerk zu haben …",
"description": "Einladungen sind eine wunderbare Möglichkeit, deine Freunde in deinem Netzwerk zu haben …",
"name": "Nutzer einladen",
"title": "Leute einladen"
},
@ -109,7 +109,7 @@
"isOnline": "online",
"isTyping": "tippt...",
"lastSeen": "zuletzt gesehen ",
"messageDeleted": "Diese Nachricht wuerde gelöscht",
"messageDeleted": "Diese Nachricht wurde gelöscht",
"messagesEmpty": "Keine Nachrichten",
"newMessages": "Neue Nachrichten",
"page": {
@ -222,7 +222,7 @@
"buttonTitle": "Weiter",
"form": {
"click-next": "Click auf Weiter.",
"description": "Öffne Dein E-Mail Postfach und gib den Code ein, den wir geschickt haben.",
"description": "Öffne dein E-Mail Postfach und gib den Code ein, den wir geschickt haben.",
"next": "Weiter",
"nonce": "E-Mail-Code: 32143",
"validations": {
@ -252,12 +252,12 @@
"signup": {
"form": {
"data-privacy": "Ich habe die Datenschutzerklärung gelesen und verstanden.",
"description": "Um loszulegen, kannst Du Dich hier kostenfrei registrieren:",
"description": "Um loszulegen, kannst du dich hier kostenfrei registrieren:",
"errors": {
"email-exists": "Es gibt schon ein Nutzerkonto mit dieser E-Mail-Adresse!"
},
"submit": "Konto erstellen",
"success": "Eine E-Mail mit einem Link zum Abschließen Deiner Registrierung wurde an <b>{email}</b> geschickt",
"success": "Eine E-Mail mit einem Link zum Abschließen deiner Registrierung wurde an <b>{email}</b> geschickt",
"terms-and-condition": "Ich stimme den <a href=\"/terms-and-conditions\" target=\"_blank\"><ds-text bold color=\"primary\">Nutzungsbedingungen</ds-text></a> zu."
},
"title": "Mach mit bei {APPLICATION_NAME}!",
@ -269,7 +269,7 @@
"amount-clicks": "{amount} Klicks",
"amount-comments": "{amount} Kommentare",
"amount-shouts": "{amount} Empfehlungen",
"amount-views": "{amount} Aurufe",
"amount-views": "{amount} Aufrufe",
"categories": {
"infoSelectedNoOfMaxCategories": "{chosen} von {max} Themen ausgewählt"
},
@ -335,9 +335,9 @@
},
"filterMyGroups": "Meine Gruppen",
"inappropriatePicture": "Dieses Bild kann für einige Menschen unangemessen sein.",
"languageSelectLabel": "Sprache Deines Beitrags",
"languageSelectLabel": "Sprache deines Beitrags",
"languageSelectText": "Sprache wählen",
"newEvent": "Erstelle einen neue Veranstaltung",
"newEvent": "Erstelle eine neue Veranstaltung",
"newPost": "Erstelle einen neuen Beitrag",
"success": "Gespeichert!",
"teaserImage": {
@ -355,13 +355,13 @@
"delete": {
"cancel": "Abbrechen",
"comment": {
"message": "Bist Du sicher, dass Du den Kommentar „<b>{name}</b>“ löschen möchtest?",
"message": "Bist du sicher, dass du den Kommentar „<b>{name}</b>“ löschen möchtest?",
"success": "Kommentar erfolgreich gelöscht!",
"title": "Lösche Kommentar",
"type": "Kommentar"
},
"contribution": {
"message": "Bist Du sicher, dass Du den Beitrag „<b>{name}</b>“ löschen möchtest?",
"message": "Bist du sicher, dass du den Beitrag „<b>{name}</b>“ 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 „<b>{name}</b>“ deaktivieren möchtest?",
"message": "Bist du sicher, dass du den Kommentar „<b>{name}</b>“ deaktivieren möchtest?",
"title": "Kommentar sperren",
"type": "Kommentar"
},
"contribution": {
"message": "Bist Du sicher, dass Du den Beitrag von „<b>{name}</b>“ deaktivieren möchtest?",
"message": "Bist du sicher, dass du den Beitrag von „<b>{name}</b>“ deaktivieren möchtest?",
"title": "Beitrag sperren",
"type": "Beitrag"
},
"submit": "Deaktivieren",
"success": "Erfolgreich deaktiviert",
"user": {
"message": "Bist Du sicher, dass Du den Nutzer „<b>{name}</b>“ sperren möchtest?",
"message": "Bist du sicher, dass du den Nutzer „<b>{name}</b>“ 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 „<b>{name}</b>“ wirklich <b>gesperrt</b> lassen?",
"message": "Möchtest du den Kommentar „<b>{name}</b>“ wirklich <b>gesperrt</b> lassen?",
"title": "Sperre den Kommentar abschließend"
},
"enable": {
"message": "Möchtest Du den Kommentar „<b>{name}</b>“ wirklich <b>entsperrt</b> lassen?",
"message": "Möchtest du den Kommentar „<b>{name}</b>“ wirklich <b>entsperrt</b> lassen?",
"title": "Entsperre den Kommentar abschließend"
}
},
"Post": {
"disable": {
"message": "Möchtest Du den Beitrag „<b>{name}</b>“ wirklich <b>gesperrt</b> lassen?",
"message": "Möchtest du den Beitrag „<b>{name}</b>“ wirklich <b>gesperrt</b> lassen?",
"title": "Sperre den Beitrag abschließend"
},
"enable": {
"message": "Möchtest Du den Beitrag „<b>{name}</b>“ wirklich <b>entsperrt</b> lassen?",
"message": "Möchtest du den Beitrag „<b>{name}</b>“ wirklich <b>entsperrt</b> lassen?",
"title": "Entsperre den Beitrag abschließend"
}
},
"submit": "Bestätige Entscheidung",
"User": {
"disable": {
"message": "Möchtest Du den Nutzer „<b>{name}</b>“ wirklich <b>gesperrt</b> lassen?",
"message": "Möchtest du den Nutzer „<b>{name}</b>“ wirklich <b>gesperrt</b> lassen?",
"title": "Sperre den Nutzer abschließend"
},
"enable": {
"message": "Möchtest Du den Nutzer „<b>{name}</b>“ wirklich <b>entsperrt</b> lassen?",
"message": "Möchtest du den Nutzer „<b>{name}</b>“ wirklich <b>entsperrt</b> 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 „<b>{name}</b>“ freigeben möchtest?",
"error": "Den Kommentar hast du schon gemeldet!",
"message": "Bist du sicher, dass du den Kommentar „<b>{name}</b>“ freigeben möchtest?",
"title": "Kommentar freigeben",
"type": "Kommentar"
},
"contribution": {
"error": "Den Beitrag hast Du schon gemeldet!",
"message": "Bist Du sicher, dass Du den Beitrag „<b>{name}</b>“ freigeben möchtest?",
"error": "Den Beitrag hast du schon gemeldet!",
"message": "Bist du sicher, dass du den Beitrag „<b>{name}</b>“ 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 „<b>{name}</b>“ freigeben möchtest?",
"error": "Den Nutzer hast du schon gemeldet!",
"message": "Bist du sicher, dass du den Nutzer „<b>{name}</b>“ 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 „<b>{name}</b>“ melden möchtest?",
"message": "Bist du sicher, dass du den Kommentar von „<b>{name}</b>“ melden möchtest?",
"title": "Kommentar melden",
"type": "Kommentar"
},
"contribution": {
"error": "Du hast den Beitrag bereits gemeldet!",
"message": "Bist Du sicher, dass Du den Beitrag „<b>{name}</b>“ melden möchtest?",
"message": "Bist du sicher, dass du den Beitrag „<b>{name}</b>“ 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 „<b>{name}</b>“ melden möchtest?",
"message": "Bist du sicher, dass du den Nutzer „<b>{name}</b>“ 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 <b>{email}</b> gesendet.",
"submitted": "Eine E-Mail zur Bestätigung deiner Adresse wurde an <b>{email}</b> 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",

View File

@ -1,12 +1,10 @@
<template>
<div>
<ds-heading tag="h1">{{ $t('chat.page.headline') }}</ds-heading>
<add-chat-room-by-user-search
v-if="showUserSearch"
@add-chat-room="addChatRoom"
@close-user-search="showUserSearch = false"
/>
<ds-space margin-bottom="small" />
<chat
:roomId="getShowChat.showChat ? getShowChat.roomID : null"
ref="chat"