Merge branch 'master' into clear-old-password-junk

This commit is contained in:
Moriz Wahl 2022-12-13 20:52:06 +01:00
commit 35f9157f41
82 changed files with 2109 additions and 1210 deletions

View File

@ -1,10 +1,28 @@
# Docker More Closely
# Contributing
## Apple M1 Platform
If you contribute to our project, please consider the following points.
## Localization
### Quotation Marks
The following characters are different from the programming quotation mark:
`"` or `\"`
Please copy and paste the following quotes for the languages:
- de: „Dies ist ein Beispielsatz.“
- en: “This is a sample sentence.”
- See <https://grammar.collinsdictionary.com/easy-learning/when-do-you-use-quotation-marks-or-in-english>
## Docker More Closely
### Apple M1 Platform
***Attention:** For using Docker commands in Apple M1 environments!*
### Enviroment Variable For Apple M1 Platform
#### Environment Variable For Apple M1 Platform
To set the Docker platform environment variable in your terminal tab, run:
@ -13,7 +31,7 @@ To set the Docker platform environment variable in your terminal tab, run:
$ export DOCKER_DEFAULT_PLATFORM=linux/amd64
```
### Docker Compose Override File For Apple M1 Platform
#### Docker Compose Override File For Apple M1 Platform
For Docker compose `up` or `build` commands, you can use our Apple M1 override file that specifies the M1 platform:
@ -27,7 +45,7 @@ $ docker compose -f docker-compose.yml -f docker-compose.override.yml -f docker-
$ docker compose -f docker-compose.yml -f docker-compose.apple-m1.override.yml up
```
## Analysing Docker Builds
### Analyzing Docker Builds
To analyze a Docker build, there is a wonderful tool called [dive](https://github.com/wagoodman/dive). Please sponsor if you're using it!

View File

@ -38,10 +38,12 @@ export default {
form: {
text: '',
},
loading: false,
}
},
methods: {
onSubmit(event) {
this.loading = true
this.$apollo
.mutate({
mutation: adminCreateContributionMessage,
@ -55,9 +57,11 @@ export default {
this.$emit('update-state', this.contributionId)
this.form.text = ''
this.toastSuccess(this.$t('message.request'))
this.loading = false
})
.catch((error) => {
this.toastError(error.message)
this.loading = false
})
},
onReset(event) {
@ -66,10 +70,7 @@ export default {
},
computed: {
disabled() {
if (this.form.text !== '') {
return false
}
return true
return this.form.text === '' || this.loading
},
},
}

View File

@ -98,10 +98,18 @@ COPY --from=build ${DOCKER_WORKDIR}/../database/build ../database/build
# We also copy the node_modules express and serve-static for the run script
COPY --from=build ${DOCKER_WORKDIR}/node_modules ./node_modules
COPY --from=build ${DOCKER_WORKDIR}/../database/node_modules ../database/node_modules
# Copy static files
# COPY --from=build ${DOCKER_WORKDIR}/public ./public
# Copy package.json for script definitions (lock file should not be needed)
COPY --from=build ${DOCKER_WORKDIR}/package.json ./package.json
# Copy tsconfig.json to provide alias path definitions
COPY --from=build ${DOCKER_WORKDIR}/tsconfig.json ./tsconfig.json
# Copy log4js-config.json to provide log configuration
COPY --from=build ${DOCKER_WORKDIR}/log4js-config.json ./log4js-config.json
# Copy memonic type since its referenced in the sources
# TODO: remove
COPY --from=build ${DOCKER_WORKDIR}/src/config/mnemonic.uncompressed_buffer13116.txt ./src/config/mnemonic.uncompressed_buffer13116.txt
# Copy run scripts run/
# COPY --from=build ${DOCKER_WORKDIR}/run ./run

View File

@ -8,7 +8,7 @@
"license": "Apache-2.0",
"private": false,
"scripts": {
"build": "tsc --build",
"build": "tsc --build && mkdir -p build/src/emails/templates/ && cp -r src/emails/templates/* build/src/emails/templates/ && mkdir -p build/src/locales/ && cp -r src/locales/*.json build/src/locales/",
"clean": "tsc --build --clean",
"start": "cross-env TZ=UTC TS_NODE_BASEURL=./build node -r tsconfig-paths/register build/src/index.js",
"dev": "cross-env TZ=UTC nodemon -w src --ext ts --exec ts-node -r tsconfig-paths/register src/index.ts",

View File

@ -10,7 +10,7 @@ Decimal.set({
})
const constants = {
DB_VERSION: '0055-clear_old_password_junk',
DB_VERSION: '0057-clear_old_password_junk',
DECAY_START_TIME: new Date('2021-05-13 17:46:31-0000'), // GMT+0
LOG4JS_CONFIG: 'log4js-config.json',
// default log level on production should be info

View File

@ -1 +0,0 @@
= t('emails.accountMultiRegistration.subject')

View File

@ -89,7 +89,7 @@ describe('sendEmailTranslated', () => {
originalMessage: expect.objectContaining({
to: 'receiver@mail.org',
cc: 'support@gradido.net',
from: 'Gradido (nicht antworten) <info@gradido.net>',
from: 'Gradido (do not answer) <info@gradido.net>',
attachments: [],
subject: 'Gradido: Try To Register Again With Your Email',
html: expect.stringContaining('Gradido: Try To Register Again With Your Email'),
@ -107,4 +107,41 @@ describe('sendEmailTranslated', () => {
expect(i18n.__).toBeCalled()
})
})
describe('with email EMAIL_TEST_MODUS true', () => {
beforeEach(async () => {
jest.clearAllMocks()
CONFIG.EMAIL = true
CONFIG.EMAIL_TEST_MODUS = true
result = await sendEmailTranslated({
receiver: {
to: 'receiver@mail.org',
cc: 'support@gradido.net',
},
template: 'accountMultiRegistration',
locals: {
locale: 'en',
},
})
})
it('call of "sendEmailTranslated" with faked "to"', () => {
expect(result).toMatchObject({
envelope: {
from: CONFIG.EMAIL_SENDER,
to: [CONFIG.EMAIL_TEST_RECEIVER, 'support@gradido.net'],
},
message: expect.any(String),
originalMessage: expect.objectContaining({
to: CONFIG.EMAIL_TEST_RECEIVER,
cc: 'support@gradido.net',
from: `Gradido (do not answer) <${CONFIG.EMAIL_SENDER}>`,
attachments: [],
subject: 'Gradido: Try To Register Again With Your Email',
html: expect.stringContaining('Gradido: Try To Register Again With Your Email'),
text: expect.stringContaining('GRADIDO: TRY TO REGISTER AGAIN WITH YOUR EMAIL'),
}),
})
})
})
})

View File

@ -1,18 +1,17 @@
import CONFIG from '@/config'
import { backendLogger as logger } from '@/server/logger'
import path from 'path'
import { createTransport } from 'nodemailer'
import Email from 'email-templates'
import i18n from 'i18n'
import CONFIG from '@/config'
export const sendEmailTranslated = async (params: {
receiver: {
to: string
cc?: string
}
template: string
locals: Record<string, string>
locals: Record<string, unknown>
}): Promise<Record<string, unknown> | null> => {
let resultSend: Record<string, unknown> | null = null
@ -32,8 +31,7 @@ export const sendEmailTranslated = async (params: {
logger.info(`Emails are disabled via config...`)
return null
}
// because 'CONFIG.EMAIL_TEST_MODUS' can be boolean 'true' or string '`false`'
if (CONFIG.EMAIL_TEST_MODUS === true) {
if (CONFIG.EMAIL_TEST_MODUS) {
logger.info(
`Testmodus=ON: change receiver from ${params.receiver.to} to ${CONFIG.EMAIL_TEST_RECEIVER}`,
)
@ -50,12 +48,12 @@ export const sendEmailTranslated = async (params: {
},
})
i18n.setLocale(params.locals.locale) // for email
i18n.setLocale(params.locals.locale as string) // for email
// TESTING: see 'README.md'
const email = new Email({
message: {
from: `Gradido (nicht antworten) <${CONFIG.EMAIL_SENDER}>`,
from: `Gradido (${i18n.__('emails.general.doNotAnswer')}) <${CONFIG.EMAIL_SENDER}>`,
},
transport,
preview: false,
@ -65,7 +63,7 @@ export const sendEmailTranslated = async (params: {
// ATTENTION: await is needed, because otherwise on send the email gets send in the language of the current user, because below the language gets reset
await email
.send({
template: path.join(__dirname, params.template),
template: path.join(__dirname, 'templates', params.template),
message: params.receiver,
locals: params.locals, // the 'locale' in here seems not to be used by 'email-template', because it doesn't work if the language isn't set before by 'i18n.setLocale'
})

View File

@ -1,12 +1,34 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import Decimal from 'decimal.js-light'
import { testEnvironment } from '@test/helpers'
import { logger, i18n as localization } from '@test/testSetup'
import CONFIG from '@/config'
import { sendAccountMultiRegistrationEmail } from './sendEmailVariants'
import {
sendAddedContributionMessageEmail,
sendAccountActivationEmail,
sendAccountMultiRegistrationEmail,
sendContributionConfirmedEmail,
sendContributionRejectedEmail,
sendResetPasswordEmail,
sendTransactionLinkRedeemedEmail,
sendTransactionReceivedEmail,
} from './sendEmailVariants'
import { sendEmailTranslated } from './sendEmailTranslated'
CONFIG.EMAIL = true
CONFIG.EMAIL_SMTP_URL = 'EMAIL_SMTP_URL'
CONFIG.EMAIL_SMTP_PORT = '1234'
CONFIG.EMAIL_USERNAME = 'user'
CONFIG.EMAIL_PASSWORD = 'pwd'
let con: any
let testEnv: any
beforeAll(async () => {
testEnv = await testEnvironment(logger, localization)
con = testEnv.con
// await cleanDB()
})
afterAll(async () => {
// await cleanDB()
await con.close()
})
jest.mock('./sendEmailTranslated', () => {
const originalModule = jest.requireActual('./sendEmailTranslated')
@ -17,7 +39,154 @@ jest.mock('./sendEmailTranslated', () => {
})
describe('sendEmailVariants', () => {
let result: Record<string, unknown> | null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let result: any
describe('sendAddedContributionMessageEmail', () => {
beforeAll(async () => {
result = await sendAddedContributionMessageEmail({
firstName: 'Peter',
lastName: 'Lustig',
email: 'peter@lustig.de',
language: 'en',
senderFirstName: 'Bibi',
senderLastName: 'Bloxberg',
contributionMemo: 'My contribution.',
})
})
describe('calls "sendEmailTranslated"', () => {
it('with expected parameters', () => {
expect(sendEmailTranslated).toBeCalledWith({
receiver: {
to: 'Peter Lustig <peter@lustig.de>',
},
template: 'addedContributionMessage',
locals: {
firstName: 'Peter',
lastName: 'Lustig',
locale: 'en',
senderFirstName: 'Bibi',
senderLastName: 'Bloxberg',
contributionMemo: 'My contribution.',
overviewURL: CONFIG.EMAIL_LINK_OVERVIEW,
},
})
})
it('has expected result', () => {
expect(result).toMatchObject({
envelope: {
from: 'info@gradido.net',
to: ['peter@lustig.de'],
},
message: expect.any(String),
originalMessage: expect.objectContaining({
to: 'Peter Lustig <peter@lustig.de>',
from: 'Gradido (do not answer) <info@gradido.net>',
attachments: [],
subject: 'Gradido: Message about your common good contribution',
html: expect.any(String),
text: expect.stringContaining('GRADIDO: MESSAGE ABOUT YOUR COMMON GOOD CONTRIBUTION'),
}),
})
expect(result.originalMessage.html).toContain('<!DOCTYPE html>')
expect(result.originalMessage.html).toContain('<html lang="en">')
expect(result.originalMessage.html).toContain(
'<title>Gradido: Message about your common good contribution</title>',
)
expect(result.originalMessage.html).toContain(
'>Gradido: Message about your common good contribution</h1>',
)
expect(result.originalMessage.html).toContain('Hello Peter Lustig')
expect(result.originalMessage.html).toContain(
'you have received a message from Bibi Bloxberg regarding your common good contribution “My contribution.”.',
)
expect(result.originalMessage.html).toContain(
'To view and reply to the message, go to the “Community” menu in your Gradido account and click on the “My contributions to the common good” tab!',
)
expect(result.originalMessage.html).toContain(
`Link to your account:<span> </span><a href="${CONFIG.EMAIL_LINK_OVERVIEW}">${CONFIG.EMAIL_LINK_OVERVIEW}</a>`,
)
expect(result.originalMessage.html).toContain('Please do not reply to this email!')
expect(result.originalMessage.html).toContain('Kind regards,<br><span>your Gradido team')
})
})
})
describe('sendAccountActivationEmail', () => {
beforeAll(async () => {
result = await sendAccountActivationEmail({
firstName: 'Peter',
lastName: 'Lustig',
email: 'peter@lustig.de',
language: 'en',
activationLink: 'http://localhost/checkEmail/6627633878930542284',
timeDurationObject: { hours: 23, minutes: 30 },
})
})
describe('calls "sendEmailTranslated"', () => {
it('with expected parameters', () => {
expect(sendEmailTranslated).toBeCalledWith({
receiver: {
to: 'Peter Lustig <peter@lustig.de>',
},
template: 'accountActivation',
locals: {
firstName: 'Peter',
lastName: 'Lustig',
locale: 'en',
activationLink: 'http://localhost/checkEmail/6627633878930542284',
timeDurationObject: { hours: 23, minutes: 30 },
resendLink: CONFIG.EMAIL_LINK_FORGOTPASSWORD,
},
})
})
it('has expected result', () => {
expect(result).toMatchObject({
envelope: {
from: 'info@gradido.net',
to: ['peter@lustig.de'],
},
message: expect.any(String),
originalMessage: expect.objectContaining({
to: 'Peter Lustig <peter@lustig.de>',
from: 'Gradido (do not answer) <info@gradido.net>',
attachments: [],
subject: 'Gradido: Email Verification',
html: expect.any(String),
text: expect.stringContaining('GRADIDO: EMAIL VERIFICATION'),
}),
})
expect(result.originalMessage.html).toContain('<!DOCTYPE html>')
expect(result.originalMessage.html).toContain('<html lang="en">')
expect(result.originalMessage.html).toContain('<title>Gradido: Email Verification</title>')
expect(result.originalMessage.html).toContain('>Gradido: Email Verification</h1>')
expect(result.originalMessage.html).toContain('Hello Peter Lustig')
expect(result.originalMessage.html).toContain(
'Your email address has just been registered with Gradido.',
)
expect(result.originalMessage.html).toContain(
'Please click on this link to complete the registration and activate your Gradido account:',
)
expect(result.originalMessage.html).toContain(
'<a href="http://localhost/checkEmail/6627633878930542284">http://localhost/checkEmail/6627633878930542284</a>',
)
expect(result.originalMessage.html).toContain(
'or copy the link above into your browser window.',
)
expect(result.originalMessage.html).toContain(
'The link has a validity of 23 hours and 30 minutes. If the validity of the link has already expired, you can have a new link sent to you here by entering your email address:',
)
expect(result.originalMessage.html).toContain(
`<a href="${CONFIG.EMAIL_LINK_FORGOTPASSWORD}">${CONFIG.EMAIL_LINK_FORGOTPASSWORD}</a>`,
)
expect(result.originalMessage.html).toContain('Kind regards,<br><span>your Gradido team')
})
})
})
describe('sendAccountMultiRegistrationEmail', () => {
beforeAll(async () => {
@ -54,34 +223,401 @@ describe('sendEmailVariants', () => {
message: expect.any(String),
originalMessage: expect.objectContaining({
to: 'Peter Lustig <peter@lustig.de>',
from: 'Gradido (nicht antworten) <info@gradido.net>',
from: 'Gradido (do not answer) <info@gradido.net>',
attachments: [],
subject: 'Gradido: Try To Register Again With Your Email',
html:
expect.stringContaining(
'<title>Gradido: Try To Register Again With Your Email</title>',
) &&
expect.stringContaining('>Gradido: Try To Register Again With Your Email</h1>') &&
expect.stringContaining(
'Your email address has just been used again to register an account with Gradido.',
) &&
expect.stringContaining(
'However, an account already exists for your email address.',
) &&
expect.stringContaining(
'Please click on the following link if you have forgotten your password:',
) &&
expect.stringContaining(
`<a href="${CONFIG.EMAIL_LINK_FORGOTPASSWORD}">${CONFIG.EMAIL_LINK_FORGOTPASSWORD}</a>`,
) &&
expect.stringContaining('or copy the link above into your browser window.') &&
expect.stringContaining(
'If you are not the one who tried to register again, please contact our support:',
) &&
expect.stringContaining('Sincerely yours,<br><span>your Gradido team'),
html: expect.any(String),
text: expect.stringContaining('GRADIDO: TRY TO REGISTER AGAIN WITH YOUR EMAIL'),
}),
})
expect(result.originalMessage.html).toContain('<!DOCTYPE html>')
expect(result.originalMessage.html).toContain('<html lang="en">')
expect(result.originalMessage.html).toContain(
'<title>Gradido: Try To Register Again With Your Email</title>',
)
expect(result.originalMessage.html).toContain(
'>Gradido: Try To Register Again With Your Email</h1>',
)
expect(result.originalMessage.html).toContain('Hello Peter Lustig')
expect(result.originalMessage.html).toContain(
'Your email address has just been used again to register an account with Gradido.',
)
expect(result.originalMessage.html).toContain(
'However, an account already exists for your email address.',
)
expect(result.originalMessage.html).toContain(
'Please click on the following link if you have forgotten your password:',
)
expect(result.originalMessage.html).toContain(
`<a href="${CONFIG.EMAIL_LINK_FORGOTPASSWORD}">${CONFIG.EMAIL_LINK_FORGOTPASSWORD}</a>`,
)
expect(result.originalMessage.html).toContain(
'or copy the link above into your browser window.',
)
expect(result.originalMessage.html).toContain(
'If you are not the one who tried to register again, please contact our support:',
)
expect(result.originalMessage.html).toContain('Kind regards,<br><span>your Gradido team')
})
})
})
describe('sendContributionConfirmedEmail', () => {
beforeAll(async () => {
result = await sendContributionConfirmedEmail({
firstName: 'Peter',
lastName: 'Lustig',
email: 'peter@lustig.de',
language: 'en',
senderFirstName: 'Bibi',
senderLastName: 'Bloxberg',
contributionMemo: 'My contribution.',
contributionAmount: new Decimal(23.54),
})
})
describe('calls "sendEmailTranslated"', () => {
it('with expected parameters', () => {
expect(sendEmailTranslated).toBeCalledWith({
receiver: {
to: 'Peter Lustig <peter@lustig.de>',
},
template: 'contributionConfirmed',
locals: {
firstName: 'Peter',
lastName: 'Lustig',
locale: 'en',
senderFirstName: 'Bibi',
senderLastName: 'Bloxberg',
contributionMemo: 'My contribution.',
contributionAmount: '23.54',
overviewURL: CONFIG.EMAIL_LINK_OVERVIEW,
},
})
})
it('has expected result', () => {
expect(result).toMatchObject({
envelope: {
from: 'info@gradido.net',
to: ['peter@lustig.de'],
},
message: expect.any(String),
originalMessage: expect.objectContaining({
to: 'Peter Lustig <peter@lustig.de>',
from: 'Gradido (do not answer) <info@gradido.net>',
attachments: [],
subject: 'Gradido: Your common good contribution was confirmed',
html: expect.any(String),
text: expect.stringContaining('GRADIDO: YOUR COMMON GOOD CONTRIBUTION WAS CONFIRMED'),
}),
})
expect(result.originalMessage.html).toContain('<!DOCTYPE html>')
expect(result.originalMessage.html).toContain('<html lang="en">')
expect(result.originalMessage.html).toContain(
'<title>Gradido: Your common good contribution was confirmed</title>',
)
expect(result.originalMessage.html).toContain(
'>Gradido: Your common good contribution was confirmed</h1>',
)
expect(result.originalMessage.html).toContain('Hello Peter Lustig')
expect(result.originalMessage.html).toContain(
'Your public good contribution “My contribution.” has just been confirmed by Bibi Bloxberg and credited to your Gradido account.',
)
expect(result.originalMessage.html).toContain('Amount: 23.54 GDD')
expect(result.originalMessage.html).toContain(
`Link to your account:<span> </span><a href="${CONFIG.EMAIL_LINK_OVERVIEW}">${CONFIG.EMAIL_LINK_OVERVIEW}</a>`,
)
expect(result.originalMessage.html).toContain('Please do not reply to this email!')
expect(result.originalMessage.html).toContain('Kind regards,<br><span>your Gradido team')
})
})
})
describe('sendContributionRejectedEmail', () => {
beforeAll(async () => {
result = await sendContributionRejectedEmail({
firstName: 'Peter',
lastName: 'Lustig',
email: 'peter@lustig.de',
language: 'en',
senderFirstName: 'Bibi',
senderLastName: 'Bloxberg',
contributionMemo: 'My contribution.',
})
})
describe('calls "sendEmailTranslated"', () => {
it('with expected parameters', () => {
expect(sendEmailTranslated).toBeCalledWith({
receiver: {
to: 'Peter Lustig <peter@lustig.de>',
},
template: 'contributionRejected',
locals: {
firstName: 'Peter',
lastName: 'Lustig',
locale: 'en',
senderFirstName: 'Bibi',
senderLastName: 'Bloxberg',
contributionMemo: 'My contribution.',
overviewURL: CONFIG.EMAIL_LINK_OVERVIEW,
},
})
})
it('has expected result', () => {
expect(result).toMatchObject({
envelope: {
from: 'info@gradido.net',
to: ['peter@lustig.de'],
},
message: expect.any(String),
originalMessage: expect.objectContaining({
to: 'Peter Lustig <peter@lustig.de>',
from: 'Gradido (do not answer) <info@gradido.net>',
attachments: [],
subject: 'Gradido: Your common good contribution was rejected',
html: expect.any(String),
text: expect.stringContaining('GRADIDO: YOUR COMMON GOOD CONTRIBUTION WAS REJECTED'),
}),
})
expect(result.originalMessage.html).toContain('<!DOCTYPE html>')
expect(result.originalMessage.html).toContain('<html lang="en">')
expect(result.originalMessage.html).toContain(
'<title>Gradido: Your common good contribution was rejected</title>',
)
expect(result.originalMessage.html).toContain(
'>Gradido: Your common good contribution was rejected</h1>',
)
expect(result.originalMessage.html).toContain('Hello Peter Lustig')
expect(result.originalMessage.html).toContain(
'Your public good contribution “My contribution.” was rejected by Bibi Bloxberg.',
)
expect(result.originalMessage.html).toContain(
'To see your common good contributions and related messages, go to the “Community” menu in your Gradido account and click on the “My contributions to the common good” tab!',
)
expect(result.originalMessage.html).toContain(
`Link to your account:<span> </span><a href="${CONFIG.EMAIL_LINK_OVERVIEW}">${CONFIG.EMAIL_LINK_OVERVIEW}</a>`,
)
expect(result.originalMessage.html).toContain('Please do not reply to this email!')
expect(result.originalMessage.html).toContain('Kind regards,<br><span>your Gradido team')
})
})
})
describe('sendResetPasswordEmail', () => {
beforeAll(async () => {
result = await sendResetPasswordEmail({
firstName: 'Peter',
lastName: 'Lustig',
email: 'peter@lustig.de',
language: 'en',
resetLink: 'http://localhost/reset-password/3762660021544901417',
timeDurationObject: { hours: 23, minutes: 30 },
})
})
describe('calls "sendEmailTranslated"', () => {
it('with expected parameters', () => {
expect(sendEmailTranslated).toBeCalledWith({
receiver: {
to: 'Peter Lustig <peter@lustig.de>',
},
template: 'resetPassword',
locals: {
firstName: 'Peter',
lastName: 'Lustig',
locale: 'en',
resetLink: 'http://localhost/reset-password/3762660021544901417',
timeDurationObject: { hours: 23, minutes: 30 },
resendLink: CONFIG.EMAIL_LINK_FORGOTPASSWORD,
},
})
})
it('has expected result', () => {
expect(result).toMatchObject({
envelope: {
from: 'info@gradido.net',
to: ['peter@lustig.de'],
},
message: expect.any(String),
originalMessage: expect.objectContaining({
to: 'Peter Lustig <peter@lustig.de>',
from: 'Gradido (do not answer) <info@gradido.net>',
attachments: [],
subject: 'Gradido: Reset password',
html: expect.any(String),
text: expect.stringContaining('GRADIDO: RESET PASSWORD'),
}),
})
expect(result.originalMessage.html).toContain('<!DOCTYPE html>')
expect(result.originalMessage.html).toContain('<html lang="en">')
expect(result.originalMessage.html).toContain('<title>Gradido: Reset password</title>')
expect(result.originalMessage.html).toContain('>Gradido: Reset password</h1>')
expect(result.originalMessage.html).toContain('Hello Peter Lustig')
expect(result.originalMessage.html).toContain(
'You, or someone else, requested a password reset for this account.',
)
expect(result.originalMessage.html).toContain('If it was you, please click on the link:')
expect(result.originalMessage.html).toContain(
'<a href="http://localhost/reset-password/3762660021544901417">http://localhost/reset-password/3762660021544901417</a>',
)
expect(result.originalMessage.html).toContain(
'or copy the link above into your browser window.',
)
expect(result.originalMessage.html).toContain(
'The link has a validity of 23 hours and 30 minutes. If the validity of the link has already expired, you can have a new link sent to you here by entering your email address:',
)
expect(result.originalMessage.html).toContain(
`<a href="${CONFIG.EMAIL_LINK_FORGOTPASSWORD}">${CONFIG.EMAIL_LINK_FORGOTPASSWORD}</a>`,
)
expect(result.originalMessage.html).toContain('Kind regards,<br><span>your Gradido team')
})
})
})
describe('sendTransactionLinkRedeemedEmail', () => {
beforeAll(async () => {
result = await sendTransactionLinkRedeemedEmail({
firstName: 'Peter',
lastName: 'Lustig',
email: 'peter@lustig.de',
language: 'en',
senderFirstName: 'Bibi',
senderLastName: 'Bloxberg',
senderEmail: 'bibi@bloxberg.de',
transactionMemo: 'You deserve it! 🙏🏼',
transactionAmount: new Decimal(17.65),
})
})
describe('calls "sendEmailTranslated"', () => {
it('with expected parameters', () => {
expect(sendEmailTranslated).toBeCalledWith({
receiver: {
to: 'Peter Lustig <peter@lustig.de>',
},
template: 'transactionLinkRedeemed',
locals: {
firstName: 'Peter',
lastName: 'Lustig',
locale: 'en',
senderFirstName: 'Bibi',
senderLastName: 'Bloxberg',
senderEmail: 'bibi@bloxberg.de',
transactionMemo: 'You deserve it! 🙏🏼',
transactionAmount: '17.65',
overviewURL: CONFIG.EMAIL_LINK_OVERVIEW,
},
})
})
it('has expected result', () => {
expect(result).toMatchObject({
envelope: {
from: 'info@gradido.net',
to: ['peter@lustig.de'],
},
message: expect.any(String),
originalMessage: expect.objectContaining({
to: 'Peter Lustig <peter@lustig.de>',
from: 'Gradido (do not answer) <info@gradido.net>',
attachments: [],
subject: 'Gradido: Your Gradido link has been redeemed',
html: expect.any(String),
text: expect.stringContaining('GRADIDO: YOUR GRADIDO LINK HAS BEEN REDEEMED'),
}),
})
expect(result.originalMessage.html).toContain('<!DOCTYPE html>')
expect(result.originalMessage.html).toContain('<html lang="en">')
expect(result.originalMessage.html).toContain(
'<title>Gradido: Your Gradido link has been redeemed</title>',
)
expect(result.originalMessage.html).toContain(
'>Gradido: Your Gradido link has been redeemed</h1>',
)
expect(result.originalMessage.html).toContain('Hello Peter Lustig')
expect(result.originalMessage.html).toContain(
'Bibi Bloxberg (bibi@bloxberg.de) has just redeemed your link.',
)
expect(result.originalMessage.html).toContain('Amount: 17.65 GDD')
expect(result.originalMessage.html).toContain('Memo: You deserve it! 🙏🏼')
expect(result.originalMessage.html).toContain(
`You can find transaction details in your Gradido account:<span> </span><a href="${CONFIG.EMAIL_LINK_OVERVIEW}">${CONFIG.EMAIL_LINK_OVERVIEW}</a>`,
)
expect(result.originalMessage.html).toContain('Please do not reply to this email!')
expect(result.originalMessage.html).toContain('Kind regards,<br><span>your Gradido team')
})
})
})
describe('sendTransactionReceivedEmail', () => {
beforeAll(async () => {
result = await sendTransactionReceivedEmail({
firstName: 'Peter',
lastName: 'Lustig',
email: 'peter@lustig.de',
language: 'en',
senderFirstName: 'Bibi',
senderLastName: 'Bloxberg',
senderEmail: 'bibi@bloxberg.de',
transactionAmount: new Decimal(37.4),
})
})
describe('calls "sendEmailTranslated"', () => {
it('with expected parameters', () => {
expect(sendEmailTranslated).toBeCalledWith({
receiver: {
to: 'Peter Lustig <peter@lustig.de>',
},
template: 'transactionReceived',
locals: {
firstName: 'Peter',
lastName: 'Lustig',
locale: 'en',
senderFirstName: 'Bibi',
senderLastName: 'Bloxberg',
senderEmail: 'bibi@bloxberg.de',
transactionAmount: '37.40',
overviewURL: CONFIG.EMAIL_LINK_OVERVIEW,
},
})
})
it('has expected result', () => {
expect(result).toMatchObject({
envelope: {
from: 'info@gradido.net',
to: ['peter@lustig.de'],
},
message: expect.any(String),
originalMessage: expect.objectContaining({
to: 'Peter Lustig <peter@lustig.de>',
from: 'Gradido (do not answer) <info@gradido.net>',
attachments: [],
subject: 'Gradido: You have received Gradidos',
html: expect.any(String),
text: expect.stringContaining('GRADIDO: YOU HAVE RECEIVED GRADIDOS'),
}),
})
expect(result.originalMessage.html).toContain('<!DOCTYPE html>')
expect(result.originalMessage.html).toContain('<html lang="en">')
expect(result.originalMessage.html).toContain(
'<title>Gradido: You have received Gradidos</title>',
)
expect(result.originalMessage.html).toContain('>Gradido: You have received Gradidos</h1>')
expect(result.originalMessage.html).toContain('Hello Peter Lustig')
expect(result.originalMessage.html).toContain(
'You have just received 37.40 GDD from Bibi Bloxberg (bibi@bloxberg.de).',
)
expect(result.originalMessage.html).toContain(
`You can find transaction details in your Gradido account:<span> </span><a href="${CONFIG.EMAIL_LINK_OVERVIEW}">${CONFIG.EMAIL_LINK_OVERVIEW}</a>`,
)
expect(result.originalMessage.html).toContain('Please do not reply to this email!')
expect(result.originalMessage.html).toContain('Kind regards,<br><span>your Gradido team')
})
})
})

View File

@ -1,6 +1,56 @@
import Decimal from 'decimal.js-light'
import CONFIG from '@/config'
import { decimalSeparatorByLanguage } from '@/util/utilities'
import { sendEmailTranslated } from './sendEmailTranslated'
export const sendAddedContributionMessageEmail = (data: {
firstName: string
lastName: string
email: string
language: string
senderFirstName: string
senderLastName: string
contributionMemo: string
}): Promise<Record<string, unknown> | null> => {
return sendEmailTranslated({
receiver: {
to: `${data.firstName} ${data.lastName} <${data.email}>`,
},
template: 'addedContributionMessage',
locals: {
firstName: data.firstName,
lastName: data.lastName,
locale: data.language,
senderFirstName: data.senderFirstName,
senderLastName: data.senderLastName,
contributionMemo: data.contributionMemo,
overviewURL: CONFIG.EMAIL_LINK_OVERVIEW,
},
})
}
export const sendAccountActivationEmail = (data: {
firstName: string
lastName: string
email: string
language: string
activationLink: string
timeDurationObject: Record<string, unknown>
}): Promise<Record<string, unknown> | null> => {
return sendEmailTranslated({
receiver: { to: `${data.firstName} ${data.lastName} <${data.email}>` },
template: 'accountActivation',
locals: {
firstName: data.firstName,
lastName: data.lastName,
locale: data.language,
activationLink: data.activationLink,
timeDurationObject: data.timeDurationObject,
resendLink: CONFIG.EMAIL_LINK_FORGOTPASSWORD,
},
})
}
export const sendAccountMultiRegistrationEmail = (data: {
firstName: string
lastName: string
@ -11,10 +61,136 @@ export const sendAccountMultiRegistrationEmail = (data: {
receiver: { to: `${data.firstName} ${data.lastName} <${data.email}>` },
template: 'accountMultiRegistration',
locals: {
locale: data.language,
firstName: data.firstName,
lastName: data.lastName,
locale: data.language,
resendLink: CONFIG.EMAIL_LINK_FORGOTPASSWORD,
},
})
}
export const sendContributionConfirmedEmail = (data: {
firstName: string
lastName: string
email: string
language: string
senderFirstName: string
senderLastName: string
contributionMemo: string
contributionAmount: Decimal
}): Promise<Record<string, unknown> | null> => {
return sendEmailTranslated({
receiver: { to: `${data.firstName} ${data.lastName} <${data.email}>` },
template: 'contributionConfirmed',
locals: {
firstName: data.firstName,
lastName: data.lastName,
locale: data.language,
senderFirstName: data.senderFirstName,
senderLastName: data.senderLastName,
contributionMemo: data.contributionMemo,
contributionAmount: decimalSeparatorByLanguage(data.contributionAmount, data.language),
overviewURL: CONFIG.EMAIL_LINK_OVERVIEW,
},
})
}
export const sendContributionRejectedEmail = (data: {
firstName: string
lastName: string
email: string
language: string
senderFirstName: string
senderLastName: string
contributionMemo: string
}): Promise<Record<string, unknown> | null> => {
return sendEmailTranslated({
receiver: { to: `${data.firstName} ${data.lastName} <${data.email}>` },
template: 'contributionRejected',
locals: {
firstName: data.firstName,
lastName: data.lastName,
locale: data.language,
senderFirstName: data.senderFirstName,
senderLastName: data.senderLastName,
contributionMemo: data.contributionMemo,
overviewURL: CONFIG.EMAIL_LINK_OVERVIEW,
},
})
}
export const sendResetPasswordEmail = (data: {
firstName: string
lastName: string
email: string
language: string
resetLink: string
timeDurationObject: Record<string, unknown>
}): Promise<Record<string, unknown> | null> => {
return sendEmailTranslated({
receiver: { to: `${data.firstName} ${data.lastName} <${data.email}>` },
template: 'resetPassword',
locals: {
firstName: data.firstName,
lastName: data.lastName,
locale: data.language,
resetLink: data.resetLink,
timeDurationObject: data.timeDurationObject,
resendLink: CONFIG.EMAIL_LINK_FORGOTPASSWORD,
},
})
}
export const sendTransactionLinkRedeemedEmail = (data: {
firstName: string
lastName: string
email: string
language: string
senderFirstName: string
senderLastName: string
senderEmail: string
transactionMemo: string
transactionAmount: Decimal
}): Promise<Record<string, unknown> | null> => {
return sendEmailTranslated({
receiver: { to: `${data.firstName} ${data.lastName} <${data.email}>` },
template: 'transactionLinkRedeemed',
locals: {
firstName: data.firstName,
lastName: data.lastName,
locale: data.language,
senderFirstName: data.senderFirstName,
senderLastName: data.senderLastName,
senderEmail: data.senderEmail,
transactionMemo: data.transactionMemo,
transactionAmount: decimalSeparatorByLanguage(data.transactionAmount, data.language),
overviewURL: CONFIG.EMAIL_LINK_OVERVIEW,
},
})
}
export const sendTransactionReceivedEmail = (data: {
firstName: string
lastName: string
email: string
language: string
senderFirstName: string
senderLastName: string
senderEmail: string
transactionAmount: Decimal
}): Promise<Record<string, unknown> | null> => {
return sendEmailTranslated({
receiver: { to: `${data.firstName} ${data.lastName} <${data.email}>` },
template: 'transactionReceived',
locals: {
firstName: data.firstName,
lastName: data.lastName,
locale: data.language,
senderFirstName: data.senderFirstName,
senderLastName: data.senderLastName,
senderEmail: data.senderEmail,
transactionAmount: decimalSeparatorByLanguage(data.transactionAmount, data.language),
overviewURL: CONFIG.EMAIL_LINK_OVERVIEW,
},
})
}

View File

@ -0,0 +1,20 @@
doctype html
html(lang=locale)
head
title= t('emails.accountActivation.subject')
body
h1(style='margin-bottom: 24px;')= t('emails.accountActivation.subject')
#container.col
p(style='margin-bottom: 24px;')= t('emails.general.helloName', { firstName, lastName })
p= t('emails.accountActivation.emailRegistered')
p= t('emails.accountActivation.pleaseClickLink')
br
a(href=activationLink) #{activationLink}
br
span= t('emails.general.orCopyLink')
p= t('emails.accountActivation.duration', { hours: timeDurationObject.hours, minutes: timeDurationObject.minutes })
br
a(href=resendLink) #{resendLink}
p(style='margin-top: 24px;')= t('emails.general.sincerelyYours')
br
span= t('emails.general.yourGradidoTeam')

View File

@ -0,0 +1 @@
= t('emails.accountActivation.subject')

View File

@ -5,7 +5,7 @@ html(lang=locale)
body
h1(style='margin-bottom: 24px;')= t('emails.accountMultiRegistration.subject')
#container.col
p(style='margin-bottom: 24px;')= t('emails.accountMultiRegistration.helloName', { firstName, lastName })
p(style='margin-bottom: 24px;')= t('emails.general.helloName', { firstName, lastName })
p= t('emails.accountMultiRegistration.emailReused')
br
span= t('emails.accountMultiRegistration.emailExists')
@ -17,6 +17,6 @@ html(lang=locale)
p= t('emails.accountMultiRegistration.ifYouAreNotTheOne')
br
a(href='https://gradido.net/de/contact/') https://gradido.net/de/contact/
p(style='margin-top: 24px;')= t('emails.accountMultiRegistration.sincerelyYours')
p(style='margin-top: 24px;')= t('emails.general.sincerelyYours')
br
span= t('emails.accountMultiRegistration.yourGradidoTeam')
span= t('emails.general.yourGradidoTeam')

View File

@ -0,0 +1 @@
= t('emails.accountMultiRegistration.subject')

View File

@ -0,0 +1,17 @@
doctype html
html(lang=locale)
head
title= t('emails.addedContributionMessage.subject')
body
h1(style='margin-bottom: 24px;')= t('emails.addedContributionMessage.subject')
#container.col
p(style='margin-bottom: 24px;')= t('emails.general.helloName', { firstName, lastName })
p= t('emails.addedContributionMessage.commonGoodContributionMessage', { senderFirstName, senderLastName, contributionMemo })
p= t('emails.addedContributionMessage.toSeeAndAnswerMessage')
p= t('emails.general.linkToYourAccount')
span= " "
a(href=overviewURL) #{overviewURL}
p= t('emails.general.pleaseDoNotReply')
p(style='margin-top: 24px;')= t('emails.general.sincerelyYours')
br
span= t('emails.general.yourGradidoTeam')

View File

@ -0,0 +1 @@
= t('emails.addedContributionMessage.subject')

View File

@ -0,0 +1,17 @@
doctype html
html(lang=locale)
head
title= t('emails.contributionConfirmed.subject')
body
h1(style='margin-bottom: 24px;')= t('emails.contributionConfirmed.subject')
#container.col
p(style='margin-bottom: 24px;')= t('emails.general.helloName', { firstName, lastName })
p= t('emails.contributionConfirmed.commonGoodContributionConfirmed', { senderFirstName, senderLastName, contributionMemo })
p= t('emails.general.amountGDD', { amountGDD: contributionAmount })
p= t('emails.general.linkToYourAccount')
span= " "
a(href=overviewURL) #{overviewURL}
p= t('emails.general.pleaseDoNotReply')
p(style='margin-top: 24px;')= t('emails.general.sincerelyYours')
br
span= t('emails.general.yourGradidoTeam')

View File

@ -0,0 +1 @@
= t('emails.contributionConfirmed.subject')

View File

@ -0,0 +1,17 @@
doctype html
html(lang=locale)
head
title= t('emails.contributionRejected.subject')
body
h1(style='margin-bottom: 24px;')= t('emails.contributionRejected.subject')
#container.col
p(style='margin-bottom: 24px;')= t('emails.general.helloName', { firstName, lastName })
p= t('emails.contributionRejected.commonGoodContributionRejected', { senderFirstName, senderLastName, contributionMemo })
p= t('emails.contributionRejected.toSeeContributionsAndMessages')
p= t('emails.general.linkToYourAccount')
span= " "
a(href=overviewURL) #{overviewURL}
p= t('emails.general.pleaseDoNotReply')
p(style='margin-top: 24px;')= t('emails.general.sincerelyYours')
br
span= t('emails.general.yourGradidoTeam')

View File

@ -0,0 +1 @@
= t('emails.contributionRejected.subject')

View File

@ -0,0 +1,20 @@
doctype html
html(lang=locale)
head
title= t('emails.resetPassword.subject')
body
h1(style='margin-bottom: 24px;')= t('emails.resetPassword.subject')
#container.col
p(style='margin-bottom: 24px;')= t('emails.general.helloName', { firstName, lastName })
p= t('emails.resetPassword.youOrSomeoneResetPassword')
p= t('emails.resetPassword.pleaseClickLink')
br
a(href=resetLink) #{resetLink}
br
span= t('emails.general.orCopyLink')
p= t('emails.resetPassword.duration', { hours: timeDurationObject.hours, minutes: timeDurationObject.minutes })
br
a(href=resendLink) #{resendLink}
p(style='margin-top: 24px;')= t('emails.general.sincerelyYours')
br
span= t('emails.general.yourGradidoTeam')

View File

@ -0,0 +1 @@
= t('emails.resetPassword.subject')

View File

@ -0,0 +1,19 @@
doctype html
html(lang=locale)
head
title= t('emails.transactionLinkRedeemed.subject')
body
h1(style='margin-bottom: 24px;')= t('emails.transactionLinkRedeemed.subject')
#container.col
p(style='margin-bottom: 24px;')= t('emails.general.helloName', { firstName, lastName })
p= t('emails.transactionLinkRedeemed.hasRedeemedYourLink', { senderFirstName, senderLastName, senderEmail })
p= t('emails.general.amountGDD', { amountGDD: transactionAmount })
br
span= t('emails.transactionLinkRedeemed.memo', { transactionMemo })
p= t('emails.general.detailsYouFindOnLinkToYourAccount')
span= " "
a(href=overviewURL) #{overviewURL}
p= t('emails.general.pleaseDoNotReply')
p(style='margin-top: 24px;')= t('emails.general.sincerelyYours')
br
span= t('emails.general.yourGradidoTeam')

View File

@ -0,0 +1 @@
= t('emails.transactionLinkRedeemed.subject')

View File

@ -0,0 +1,16 @@
doctype html
html(lang=locale)
head
title= t('emails.transactionReceived.subject')
body
h1(style='margin-bottom: 24px;')= t('emails.transactionReceived.subject')
#container.col
p(style='margin-bottom: 24px;')= t('emails.general.helloName', { firstName, lastName })
p= t('emails.transactionReceived.haveReceivedAmountGDDFrom', { transactionAmount, senderFirstName, senderLastName, senderEmail })
p= t('emails.general.detailsYouFindOnLinkToYourAccount')
span= " "
a(href=overviewURL) #{overviewURL}
p= t('emails.general.pleaseDoNotReply')
p(style='margin-top: 24px;')= t('emails.general.sincerelyYours')
br
span= t('emails.general.yourGradidoTeam')

View File

@ -0,0 +1 @@
= t('emails.transactionReceived.subject')

View File

@ -42,7 +42,9 @@ export class Transaction {
this.creationDate = transaction.creationDate
this.linkedUser = linkedUser
this.linkedTransactionId = transaction.linkedTransactionId
this.transactionLinkId = transaction.transactionLinkId
this.linkId = transaction.contribution
? transaction.contribution.contributionLinkId
: transaction.transactionLinkId
}
@Field(() => Number)
@ -81,7 +83,7 @@ export class Transaction {
@Field(() => Number, { nullable: true })
linkedTransactionId?: number | null
// Links to the TransactionLink when transaction was created by a link
// Links to the TransactionLink/ContributionLink when transaction was created by a link
@Field(() => Number, { nullable: true })
transactionLinkId?: number | null
linkId?: number | null
}

View File

@ -3,6 +3,7 @@
import { objectValuesToArray } from '@/util/utilities'
import { testEnvironment, resetToken, cleanDB, contributionDateFormatter } from '@test/helpers'
import { logger, i18n as localization } from '@test/testSetup'
import { userFactory } from '@/seeds/factory/user'
import { creationFactory } from '@/seeds/factory/creation'
import { creations } from '@/seeds/creation/index'
@ -35,30 +36,31 @@ import {
} from '@/seeds/graphql/queries'
import { GraphQLError } from 'graphql'
import { User } from '@entity/User'
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail'
import {
// sendAccountActivationEmail,
sendContributionConfirmedEmail,
// sendContributionRejectedEmail,
} from '@/emails/sendEmailVariants'
import Decimal from 'decimal.js-light'
import { Contribution } from '@entity/Contribution'
import { Transaction as DbTransaction } from '@entity/Transaction'
import { ContributionLink as DbContributionLink } from '@entity/ContributionLink'
import { sendContributionConfirmedEmail } from '@/mailer/sendContributionConfirmedEmail'
import { EventProtocol } from '@entity/EventProtocol'
import { EventProtocolType } from '@/event/EventProtocolType'
import { logger } from '@test/testSetup'
// mock account activation email to avoid console spam
jest.mock('@/mailer/sendAccountActivationEmail', () => {
jest.mock('@/emails/sendEmailVariants', () => {
const originalModule = jest.requireActual('@/emails/sendEmailVariants')
return {
__esModule: true,
sendAccountActivationEmail: jest.fn(),
}
})
// mock account activation email to avoid console spam
jest.mock('@/mailer/sendContributionConfirmedEmail', () => {
return {
__esModule: true,
sendContributionConfirmedEmail: jest.fn(),
...originalModule,
// TODO: test the call of …
// sendAccountActivationEmail: jest.fn((a) => originalModule.sendAccountActivationEmail(a)),
sendContributionConfirmedEmail: jest.fn((a) =>
originalModule.sendContributionConfirmedEmail(a),
),
// TODO: test the call of …
// sendContributionRejectedEmail: jest.fn((a) => originalModule.sendContributionRejectedEmail(a)),
}
})
@ -66,7 +68,7 @@ let mutate: any, query: any, con: any
let testEnv: any
beforeAll(async () => {
testEnv = await testEnvironment()
testEnv = await testEnvironment(logger, localization)
mutate = testEnv.mutate
query = testEnv.query
con = testEnv.con
@ -366,6 +368,19 @@ describe('AdminResolver', () => {
expect(new Date(result.data.deleteUser)).toEqual(expect.any(Date))
})
it('has deleted_at set in users and user contacts', async () => {
await expect(
User.findOneOrFail({
where: { id: user.id },
withDeleted: true,
relations: ['emailContact'],
}),
).resolves.toMatchObject({
deletedAt: expect.any(Date),
emailContact: expect.objectContaining({ deletedAt: expect.any(Date) }),
})
})
describe('delete deleted user', () => {
it('throws an error', async () => {
jest.clearAllMocks()
@ -489,6 +504,15 @@ describe('AdminResolver', () => {
}),
)
})
it('has deleted_at set to null in users and user contacts', async () => {
await expect(
User.findOneOrFail({ where: { id: user.id }, relations: ['emailContact'] }),
).resolves.toMatchObject({
deletedAt: null,
emailContact: expect.objectContaining({ deletedAt: null }),
})
})
})
})
})
@ -1715,17 +1739,16 @@ describe('AdminResolver', () => {
})
it('calls sendContributionConfirmedEmail', async () => {
expect(sendContributionConfirmedEmail).toBeCalledWith(
expect.objectContaining({
contributionMemo: 'Herzlich Willkommen bei Gradido liebe Bibi!',
overviewURL: 'http://localhost/overview',
recipientEmail: 'bibi@bloxberg.de',
recipientFirstName: 'Bibi',
recipientLastName: 'Bloxberg',
senderFirstName: 'Peter',
senderLastName: 'Lustig',
}),
)
expect(sendContributionConfirmedEmail).toBeCalledWith({
firstName: 'Bibi',
lastName: 'Bloxberg',
email: 'bibi@bloxberg.de',
language: 'de',
senderFirstName: 'Peter',
senderLastName: 'Lustig',
contributionMemo: 'Herzlich Willkommen bei Gradido liebe Bibi!',
contributionAmount: expect.decimalEqual(450),
})
})
it('stores the send confirmation email event in the database', async () => {

View File

@ -39,8 +39,14 @@ import { Decay } from '@model/Decay'
import Paginated from '@arg/Paginated'
import TransactionLinkFilters from '@arg/TransactionLinkFilters'
import { Order } from '@enum/Order'
import { findUserByEmail, activationLink, printTimeDuration } from './UserResolver'
import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail'
import { getTimeDurationObject } from '@/util/time'
import { findUserByEmail, activationLink } from './UserResolver'
import {
sendAddedContributionMessageEmail,
sendAccountActivationEmail,
sendContributionConfirmedEmail,
sendContributionRejectedEmail,
} from '@/emails/sendEmailVariants'
import { transactionLinkCode as contributionLinkCode } from './TransactionLinkResolver'
import CONFIG from '@/config'
import {
@ -63,9 +69,6 @@ import { ContributionMessage as DbContributionMessage } from '@entity/Contributi
import ContributionMessageArgs from '@arg/ContributionMessageArgs'
import { ContributionMessageType } from '@enum/MessageType'
import { ContributionMessage } from '@model/ContributionMessage'
import { sendContributionConfirmedEmail } from '@/mailer/sendContributionConfirmedEmail'
import { sendContributionRejectedEmail } from '@/mailer/sendContributionRejectedEmail'
import { sendAddedContributionMessageEmail } from '@/mailer/sendAddedContributionMessageEmail'
import { eventProtocol } from '@/event/EventProtocolEmitter'
import {
Event,
@ -200,7 +203,7 @@ export class AdminResolver {
@Arg('userId', () => Int) userId: number,
@Ctx() context: Context,
): Promise<Date | null> {
const user = await dbUser.findOne({ id: userId })
const user = await dbUser.findOne({ where: { id: userId }, relations: ['emailContact'] })
// user exists ?
if (!user) {
logger.error(`Could not find user with userId: ${userId}`)
@ -214,6 +217,7 @@ export class AdminResolver {
}
// soft-delete user
await user.softRemove()
await user.emailContact.softRemove()
const newUser = await dbUser.findOne({ id: userId }, { withDeleted: true })
return newUser ? newUser.deletedAt : null
}
@ -221,7 +225,10 @@ export class AdminResolver {
@Authorized([RIGHTS.UNDELETE_USER])
@Mutation(() => Date, { nullable: true })
async unDeleteUser(@Arg('userId', () => Int) userId: number): Promise<Date | null> {
const user = await dbUser.findOne({ id: userId }, { withDeleted: true })
const user = await dbUser.findOne(
{ id: userId },
{ withDeleted: true, relations: ['emailContact'] },
)
if (!user) {
logger.error(`Could not find user with userId: ${userId}`)
throw new Error(`Could not find user with userId: ${userId}`)
@ -231,6 +238,7 @@ export class AdminResolver {
throw new Error('User is not deleted')
}
await user.recover()
await user.emailContact.recover()
return null
}
@ -487,14 +495,13 @@ export class AdminResolver {
event.setEventAdminContributionDelete(eventAdminContributionDelete),
)
sendContributionRejectedEmail({
firstName: user.firstName,
lastName: user.lastName,
email: user.emailContact.email,
language: user.language,
senderFirstName: moderator.firstName,
senderLastName: moderator.lastName,
recipientEmail: user.emailContact.email,
recipientFirstName: user.firstName,
recipientLastName: user.lastName,
contributionMemo: contribution.memo,
contributionAmount: contribution.amount,
overviewURL: CONFIG.EMAIL_LINK_OVERVIEW,
})
return !!res
@ -582,14 +589,14 @@ export class AdminResolver {
await queryRunner.commitTransaction()
logger.info('creation commited successfuly.')
sendContributionConfirmedEmail({
firstName: user.firstName,
lastName: user.lastName,
email: user.emailContact.email,
language: user.language,
senderFirstName: moderatorUser.firstName,
senderLastName: moderatorUser.lastName,
recipientFirstName: user.firstName,
recipientLastName: user.lastName,
recipientEmail: user.emailContact.email,
contributionMemo: contribution.memo,
contributionAmount: contribution.amount,
overviewURL: CONFIG.EMAIL_LINK_OVERVIEW,
})
} catch (e) {
await queryRunner.rollbackTransaction()
@ -650,17 +657,21 @@ export class AdminResolver {
}
const emailContact = user.emailContact
if (emailContact.deletedAt) {
logger.error(`The emailContact: ${email} of htis User is deleted.`)
throw new Error(`The emailContact: ${email} of htis User is deleted.`)
logger.error(`The emailContact: ${email} of this User is deleted.`)
throw new Error(`The emailContact: ${email} of this User is deleted.`)
}
emailContact.emailResendCount++
await emailContact.save()
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const emailSent = await sendAccountActivationEmail({
link: activationLink(emailContact.emailVerificationCode),
firstName: user.firstName,
lastName: user.lastName,
email,
duration: printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME),
language: user.language,
activationLink: activationLink(emailContact.emailVerificationCode),
timeDurationObject: getTimeDurationObject(CONFIG.EMAIL_CODE_VALID_TIME),
})
// In case EMails are disabled log the activation link for the user
@ -895,15 +906,13 @@ export class AdminResolver {
}
await sendAddedContributionMessageEmail({
firstName: contribution.user.firstName,
lastName: contribution.user.lastName,
email: contribution.user.emailContact.email,
language: contribution.user.language,
senderFirstName: user.firstName,
senderLastName: user.lastName,
recipientFirstName: contribution.user.firstName,
recipientLastName: contribution.user.lastName,
recipientEmail: contribution.user.emailContact.email,
senderEmail: user.emailContact.email,
contributionMemo: contribution.memo,
message,
overviewURL: CONFIG.EMAIL_LINK_OVERVIEW,
})
await queryRunner.commitTransaction()
} catch (e) {

View File

@ -2,6 +2,7 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { cleanDB, resetToken, testEnvironment } from '@test/helpers'
import { logger, i18n as localization } from '@test/testSetup'
import { GraphQLError } from 'graphql'
import {
adminCreateContributionMessage,
@ -13,12 +14,16 @@ import { listContributionMessages } from '@/seeds/graphql/queries'
import { userFactory } from '@/seeds/factory/user'
import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg'
import { peterLustig } from '@/seeds/users/peter-lustig'
import { sendAddedContributionMessageEmail } from '@/mailer/sendAddedContributionMessageEmail'
import { sendAddedContributionMessageEmail } from '@/emails/sendEmailVariants'
jest.mock('@/mailer/sendAddedContributionMessageEmail', () => {
jest.mock('@/emails/sendEmailVariants', () => {
const originalModule = jest.requireActual('@/emails/sendEmailVariants')
return {
__esModule: true,
sendAddedContributionMessageEmail: jest.fn(),
...originalModule,
sendAddedContributionMessageEmail: jest.fn((a) =>
originalModule.sendAddedContributionMessageEmail(a),
),
}
})
@ -27,7 +32,7 @@ let testEnv: any
let result: any
beforeAll(async () => {
testEnv = await testEnvironment()
testEnv = await testEnvironment(logger, localization)
mutate = testEnv.mutate
con = testEnv.con
await cleanDB()
@ -162,15 +167,13 @@ describe('ContributionMessageResolver', () => {
it('calls sendAddedContributionMessageEmail', async () => {
expect(sendAddedContributionMessageEmail).toBeCalledWith({
firstName: 'Bibi',
lastName: 'Bloxberg',
email: 'bibi@bloxberg.de',
language: 'de',
senderFirstName: 'Peter',
senderLastName: 'Lustig',
recipientFirstName: 'Bibi',
recipientLastName: 'Bloxberg',
recipientEmail: 'bibi@bloxberg.de',
senderEmail: 'peter@lustig.de',
contributionMemo: 'Test env contribution',
message: 'Admin Test',
overviewURL: 'http://localhost/overview',
})
})
})

View File

@ -0,0 +1,126 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { testEnvironment, cleanDB } from '@test/helpers'
import { User as DbUser } from '@entity/User'
import { createUser, setPassword, forgotPassword } from '@/seeds/graphql/mutations'
import { queryOptIn } from '@/seeds/graphql/queries'
import CONFIG from '@/config'
import { GraphQLError } from 'graphql'
let mutate: any, query: any, con: any
let testEnv: any
CONFIG.EMAIL_CODE_VALID_TIME = 1440
CONFIG.EMAIL_CODE_REQUEST_TIME = 10
CONFIG.EMAIL = false
beforeAll(async () => {
testEnv = await testEnvironment()
mutate = testEnv.mutate
query = testEnv.query
con = testEnv.con
await cleanDB()
})
afterAll(async () => {
await cleanDB()
await con.close()
})
describe('EmailOptinCodes', () => {
let optinCode: string
beforeAll(async () => {
const variables = {
email: 'peter@lustig.de',
firstName: 'Peter',
lastName: 'Lustig',
language: 'de',
}
const {
data: { createUser: user },
} = await mutate({ mutation: createUser, variables })
const dbObject = await DbUser.findOneOrFail({
where: { id: user.id },
relations: ['emailContact'],
})
optinCode = dbObject.emailContact.emailVerificationCode.toString()
})
describe('queryOptIn', () => {
it('has a valid optin code', async () => {
await expect(
query({ query: queryOptIn, variables: { optIn: optinCode } }),
).resolves.toMatchObject({
data: {
queryOptIn: true,
},
errors: undefined,
})
})
describe('run time forward until code must be expired', () => {
beforeAll(() => {
jest.useFakeTimers()
setTimeout(jest.fn(), CONFIG.EMAIL_CODE_VALID_TIME * 60 * 1000)
jest.runAllTimers()
})
afterAll(() => {
jest.useRealTimers()
})
it('throws an error', async () => {
await expect(
query({ query: queryOptIn, variables: { optIn: optinCode } }),
).resolves.toMatchObject({
data: null,
errors: [new GraphQLError('email was sent more than 24 hours ago')],
})
})
it('does not allow to set password', async () => {
await expect(
mutate({ mutation: setPassword, variables: { code: optinCode, password: 'Aa12345_' } }),
).resolves.toMatchObject({
data: null,
errors: [new GraphQLError('email was sent more than 24 hours ago')],
})
})
})
})
describe('forgotPassword', () => {
it('throws an error', async () => {
await expect(
mutate({ mutation: forgotPassword, variables: { email: 'peter@lustig.de' } }),
).resolves.toMatchObject({
data: null,
errors: [new GraphQLError('email already sent less than 10 minutes minutes ago')],
})
})
describe('run time forward until code can be resent', () => {
beforeAll(() => {
jest.useFakeTimers()
setTimeout(jest.fn(), CONFIG.EMAIL_CODE_REQUEST_TIME * 60 * 1000)
jest.runAllTimers()
})
afterAll(() => {
jest.useRealTimers()
})
it('cann send email again', async () => {
await expect(
mutate({ mutation: forgotPassword, variables: { email: 'peter@lustig.de' } }),
).resolves.toMatchObject({
data: {
forgotPassword: true,
},
errors: undefined,
})
})
})
})
})

View File

@ -291,7 +291,6 @@ describe('send coins', () => {
await cleanDB()
})
/*
describe('trying to send negative amount', () => {
it('throws an error', async () => {
expect(
@ -305,18 +304,15 @@ describe('send coins', () => {
}),
).toEqual(
expect.objectContaining({
errors: [new GraphQLError(`user hasn't enough GDD or amount is < 0`)],
errors: [new GraphQLError(`Amount to send must be positive`)],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
`user hasn't enough GDD or amount is < 0 : balance=null`,
)
expect(logger.error).toBeCalledWith(`Amount to send must be positive`)
})
})
*/
describe('good transaction', () => {
it('sends the coins', async () => {

View File

@ -2,14 +2,11 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { backendLogger as logger } from '@/server/logger'
import CONFIG from '@/config'
import { Context, getUser } from '@/server/context'
import { Resolver, Query, Args, Authorized, Ctx, Mutation } from 'type-graphql'
import { getCustomRepository, getConnection, In } from '@dbTools/typeorm'
import { sendTransactionReceivedEmail } from '@/mailer/sendTransactionReceivedEmail'
import { Transaction } from '@model/Transaction'
import { TransactionList } from '@model/TransactionList'
@ -36,7 +33,10 @@ import Decimal from 'decimal.js-light'
import { BalanceResolver } from './BalanceResolver'
import { MEMO_MAX_CHARS, MEMO_MIN_CHARS } from './const/const'
import { findUserByEmail } from './UserResolver'
import { sendTransactionLinkRedeemedEmail } from '@/mailer/sendTransactionLinkRedeemed'
import {
sendTransactionLinkRedeemedEmail,
sendTransactionReceivedEmail,
} from '@/emails/sendEmailVariants'
import { Event, EventTransactionReceive, EventTransactionSend } from '@/event/Event'
import { eventProtocol } from '@/event/EventProtocolEmitter'
@ -159,29 +159,27 @@ export const executeTransaction = async (
await queryRunner.release()
}
logger.debug(`prepare Email for transaction received...`)
// send notification email
// TODO: translate
await sendTransactionReceivedEmail({
firstName: recipient.firstName,
lastName: recipient.lastName,
email: recipient.emailContact.email,
language: recipient.language,
senderFirstName: sender.firstName,
senderLastName: sender.lastName,
recipientFirstName: recipient.firstName,
recipientLastName: recipient.lastName,
email: recipient.emailContact.email,
senderEmail: sender.emailContact.email,
amount,
overviewURL: CONFIG.EMAIL_LINK_OVERVIEW,
transactionAmount: amount,
})
if (transactionLink) {
await sendTransactionLinkRedeemedEmail({
firstName: sender.firstName,
lastName: sender.lastName,
email: sender.emailContact.email,
language: sender.language,
senderFirstName: recipient.firstName,
senderLastName: recipient.lastName,
recipientFirstName: sender.firstName,
recipientLastName: sender.lastName,
email: sender.emailContact.email,
senderEmail: recipient.emailContact.email,
amount,
memo,
overviewURL: CONFIG.EMAIL_LINK_OVERVIEW,
transactionAmount: amount,
transactionMemo: memo,
})
}
logger.info(`finished executeTransaction successfully`)
@ -206,7 +204,7 @@ export class TransactionResolver {
// find current balance
const lastTransaction = await dbTransaction.findOne(
{ userId: user.id },
{ order: { balanceDate: 'DESC' } },
{ order: { balanceDate: 'DESC' }, relations: ['contribution'] },
)
logger.debug(`lastTransaction=${lastTransaction}`)
@ -314,28 +312,16 @@ export class TransactionResolver {
@Ctx() context: Context,
): Promise<boolean> {
logger.info(`sendCoins(email=${email}, amount=${amount}, memo=${memo})`)
if (amount.lte(0)) {
logger.error(`Amount to send must be positive`)
throw new Error('Amount to send must be positive')
}
// TODO this is subject to replay attacks
const senderUser = getUser(context)
// validate recipient user
const recipientUser = await findUserByEmail(email)
/*
const emailContact = await UserContact.findOne({ email }, { withDeleted: true })
if (!emailContact) {
logger.error(`Could not find UserContact with email: ${email}`)
throw new Error(`Could not find UserContact with email: ${email}`)
}
*/
// const recipientUser = await dbUser.findOne({ id: emailContact.userId })
/* Code inside this if statement is unreachable (useless by so),
in findUserByEmail() an error is already thrown if the user is not found
*/
if (!recipientUser) {
logger.error(`unknown recipient to UserContact: email=${email}`)
throw new Error('unknown recipient')
}
if (recipientUser.deletedAt) {
logger.error(`The recipient account was deleted: recipientUser=${recipientUser}`)
throw new Error('The recipient account was deleted')

View File

@ -2,6 +2,8 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { testEnvironment, headerPushMock, resetToken, cleanDB } from '@test/helpers'
import { logger, i18n as localization } from '@test/testSetup'
import { printTimeDuration } from '@/util/time'
import { userFactory } from '@/seeds/factory/user'
import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg'
import {
@ -18,18 +20,17 @@ import { verifyLogin, queryOptIn, searchAdminUsers } from '@/seeds/graphql/queri
import { GraphQLError } from 'graphql'
import { User } from '@entity/User'
import CONFIG from '@/config'
import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail'
import { sendAccountMultiRegistrationEmail } from '@/emails/sendEmailVariants'
import { sendResetPasswordEmail } from '@/mailer/sendResetPasswordEmail'
import { printTimeDuration, activationLink } from './UserResolver'
import {
sendAccountActivationEmail,
sendAccountMultiRegistrationEmail,
sendResetPasswordEmail,
} from '@/emails/sendEmailVariants'
import { contributionLinkFactory } from '@/seeds/factory/contributionLink'
import { transactionLinkFactory } from '@/seeds/factory/transactionLink'
import { ContributionLink } from '@model/ContributionLink'
import { TransactionLink } from '@entity/TransactionLink'
import { EventProtocolType } from '@/event/EventProtocolType'
import { EventProtocol } from '@entity/EventProtocol'
import { logger, i18n as localization } from '@test/testSetup'
import { validate as validateUUID, version as versionUUID } from 'uuid'
import { peterLustig } from '@/seeds/users/peter-lustig'
import { UserContact } from '@entity/UserContact'
@ -42,24 +43,16 @@ import { SecretKeyCryptographyCreateKey } from '@/password/EncryptorUtils'
// import { klicktippSignIn } from '@/apis/KlicktippController'
jest.mock('@/mailer/sendAccountActivationEmail', () => {
return {
__esModule: true,
sendAccountActivationEmail: jest.fn(),
}
})
jest.mock('@/emails/sendEmailVariants', () => {
const originalModule = jest.requireActual('@/emails/sendEmailVariants')
return {
__esModule: true,
sendAccountMultiRegistrationEmail: jest.fn(),
}
})
jest.mock('@/mailer/sendResetPasswordEmail', () => {
return {
__esModule: true,
sendResetPasswordEmail: jest.fn(),
...originalModule,
sendAccountActivationEmail: jest.fn((a) => originalModule.sendAccountActivationEmail(a)),
sendAccountMultiRegistrationEmail: jest.fn((a) =>
originalModule.sendAccountMultiRegistrationEmail(a),
),
sendResetPasswordEmail: jest.fn((a) => originalModule.sendResetPasswordEmail(a)),
}
})
@ -180,11 +173,15 @@ describe('UserResolver', () => {
emailVerificationCode,
).replace(/{code}/g, '')
expect(sendAccountActivationEmail).toBeCalledWith({
link: activationLink,
firstName: 'Peter',
lastName: 'Lustig',
email: 'peter@lustig.de',
duration: expect.any(String),
language: 'de',
activationLink,
timeDurationObject: expect.objectContaining({
hours: expect.any(Number),
minutes: expect.any(Number),
}),
})
})
@ -805,12 +802,8 @@ describe('UserResolver', () => {
})
describe('user exists in DB', () => {
let emailContact: UserContact
beforeAll(async () => {
await userFactory(testEnv, bibiBloxberg)
// await resetEntity(LoginEmailOptIn)
emailContact = await UserContact.findOneOrFail(variables)
})
afterAll(async () => {
@ -819,7 +812,7 @@ describe('UserResolver', () => {
})
describe('duration not expired', () => {
it('returns true', async () => {
it('throws an error', async () => {
await expect(mutate({ mutation: forgotPassword, variables })).resolves.toEqual(
expect.objectContaining({
errors: [
@ -845,15 +838,19 @@ describe('UserResolver', () => {
}),
)
})
})
it('sends reset password email', () => {
expect(sendResetPasswordEmail).toBeCalledWith({
link: activationLink(emailContact.emailVerificationCode),
firstName: 'Bibi',
lastName: 'Bloxberg',
email: 'bibi@bloxberg.de',
duration: expect.any(String),
it('sends reset password email', () => {
expect(sendResetPasswordEmail).toBeCalledWith({
firstName: 'Bibi',
lastName: 'Bloxberg',
email: 'bibi@bloxberg.de',
language: 'de',
resetLink: expect.any(String),
timeDurationObject: expect.objectContaining({
hours: expect.any(Number),
minutes: expect.any(Number),
}),
})
})
})

View File

@ -8,6 +8,7 @@ import { User } from '@model/User'
import { User as DbUser } from '@entity/User'
import { UserContact as DbUserContact } from '@entity/UserContact'
import { communityDbUser } from '@/util/communityUser'
import { getTimeDurationObject, printTimeDuration } from '@/util/time'
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
import { ContributionLink as dbContributionLink } from '@entity/ContributionLink'
import { encode } from '@/auth/JWT'
@ -16,9 +17,11 @@ import UnsecureLoginArgs from '@arg/UnsecureLoginArgs'
import UpdateUserInfosArgs from '@arg/UpdateUserInfosArgs'
import { klicktippNewsletterStateMiddleware } from '@/middleware/klicktippMiddleware'
import { OptInType } from '@enum/OptInType'
import { sendResetPasswordEmail as sendResetPasswordEmailMailer } from '@/mailer/sendResetPasswordEmail'
import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail'
import { sendAccountMultiRegistrationEmail } from '@/emails/sendEmailVariants'
import {
sendAccountActivationEmail,
sendAccountMultiRegistrationEmail,
sendResetPasswordEmail,
} from '@/emails/sendEmailVariants'
import { klicktippSignIn } from '@/apis/KlicktippController'
import { RIGHTS } from '@/auth/RIGHTS'
import { hasElopageBuys } from '@/util/hasElopageBuys'
@ -66,91 +69,6 @@ const newEmailContact = (email: string, userId: number): DbUserContact => {
logger.debug(`newEmailContact...successful: ${emailContact}`)
return emailContact
}
/*
const newEmailOptIn = (userId: number): LoginEmailOptIn => {
logger.trace('newEmailOptIn...')
const emailOptIn = new LoginEmailOptIn()
emailOptIn.verificationCode = random(64)
emailOptIn.userId = userId
emailOptIn.emailOptInTypeId = OptInType.EMAIL_OPT_IN_REGISTER
logger.debug(`newEmailOptIn...successful: ${emailOptIn}`)
return emailOptIn
}
*/
/*
// needed by AdminResolver
// checks if given code exists and can be resent
// if optIn does not exits, it is created
export const checkOptInCode = async (
optInCode: LoginEmailOptIn | undefined,
user: DbUser,
optInType: OptInType = OptInType.EMAIL_OPT_IN_REGISTER,
): Promise<LoginEmailOptIn> => {
logger.info(`checkOptInCode... ${optInCode}`)
if (optInCode) {
if (!canResendOptIn(optInCode)) {
logger.error(
`email already sent less than ${printTimeDuration(
CONFIG.EMAIL_CODE_REQUEST_TIME,
)} minutes ago`,
)
throw new Error(
`email already sent less than ${printTimeDuration(
CONFIG.EMAIL_CODE_REQUEST_TIME,
)} minutes ago`,
)
}
optInCode.updatedAt = new Date()
optInCode.resendCount++
} else {
logger.trace('create new OptIn for userId=' + user.id)
optInCode = newEmailOptIn(user.id)
}
if (user.emailChecked) {
optInCode.emailOptInTypeId = optInType
}
await LoginEmailOptIn.save(optInCode).catch(() => {
logger.error('Unable to save optin code= ' + optInCode)
throw new Error('Unable to save optin code.')
})
logger.debug(`checkOptInCode...successful: ${optInCode} for userid=${user.id}`)
return optInCode
}
*/
export const checkEmailVerificationCode = async (
emailContact: DbUserContact,
optInType: OptInType = OptInType.EMAIL_OPT_IN_REGISTER,
): Promise<DbUserContact> => {
logger.info(`checkEmailVerificationCode... ${emailContact}`)
if (emailContact.updatedAt) {
if (!canEmailResend(emailContact.updatedAt)) {
logger.error(
`email already sent less than ${printTimeDuration(
CONFIG.EMAIL_CODE_REQUEST_TIME,
)} minutes ago`,
)
throw new Error(
`email already sent less than ${printTimeDuration(
CONFIG.EMAIL_CODE_REQUEST_TIME,
)} minutes ago`,
)
}
emailContact.updatedAt = new Date()
emailContact.emailResendCount++
} else {
logger.trace('create new EmailVerificationCode for userId=' + emailContact.userId)
emailContact.emailChecked = false
emailContact.emailVerificationCode = random(64)
}
emailContact.emailOptInTypeId = optInType
await DbUserContact.save(emailContact).catch(() => {
logger.error('Unable to save email verification code= ' + emailContact)
throw new Error('Unable to save email verification code.')
})
logger.debug(`checkEmailVerificationCode...successful: ${emailContact}`)
return emailContact
}
export const activationLink = (verificationCode: BigInt): string => {
logger.debug(`activationLink(${verificationCode})...`)
@ -255,6 +173,7 @@ export class UserResolver {
@Authorized([RIGHTS.LOGOUT])
@Mutation(() => String)
async logout(): Promise<boolean> {
// TODO: Event still missing here!!
// TODO: We dont need this anymore, but might need this in the future in oder to invalidate a valid JWT-Token.
// Furthermore this hook can be useful for tracking user behaviour (did he logout or not? Warn him if he didn't on next login)
// The functionality is fully client side - the client just needs to delete his token with the current implementation.
@ -405,11 +324,12 @@ export class UserResolver {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const emailSent = await sendAccountActivationEmail({
link: activationLink,
firstName,
lastName,
email,
duration: printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME),
language,
activationLink,
timeDurationObject: getTimeDurationObject(CONFIG.EMAIL_CODE_VALID_TIME),
})
logger.info(`sendAccountActivationEmail of ${firstName}.${lastName} to ${email}`)
eventSendConfirmEmail.userId = dbUser.id
@ -455,31 +375,45 @@ export class UserResolver {
return true
}
// can be both types: REGISTER and RESET_PASSWORD
// let optInCode = await LoginEmailOptIn.findOne({
// userId: user.id,
// })
// let optInCode = user.emailContact.emailVerificationCode
const dbUserContact = await checkEmailVerificationCode(
user.emailContact,
OptInType.EMAIL_OPT_IN_RESET_PASSWORD,
)
if (!canEmailResend(user.emailContact.updatedAt || user.emailContact.createdAt)) {
logger.error(
`email already sent less than ${printTimeDuration(
CONFIG.EMAIL_CODE_REQUEST_TIME,
)} minutes ago`,
)
throw new Error(
`email already sent less than ${printTimeDuration(
CONFIG.EMAIL_CODE_REQUEST_TIME,
)} minutes ago`,
)
}
// optInCode = await checkOptInCode(optInCode, user, OptInType.EMAIL_OPT_IN_RESET_PASSWORD)
logger.info(`optInCode for ${email}=${dbUserContact}`)
user.emailContact.updatedAt = new Date()
user.emailContact.emailResendCount++
user.emailContact.emailVerificationCode = random(64)
user.emailContact.emailOptInTypeId = OptInType.EMAIL_OPT_IN_RESET_PASSWORD
await user.emailContact.save().catch(() => {
logger.error('Unable to save email verification code= ' + user.emailContact)
throw new Error('Unable to save email verification code.')
})
logger.info(`optInCode for ${email}=${user.emailContact}`)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const emailSent = await sendResetPasswordEmailMailer({
link: activationLink(dbUserContact.emailVerificationCode),
const emailSent = await sendResetPasswordEmail({
firstName: user.firstName,
lastName: user.lastName,
email,
duration: printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME),
language: user.language,
resetLink: activationLink(user.emailContact.emailVerificationCode),
timeDurationObject: getTimeDurationObject(CONFIG.EMAIL_CODE_VALID_TIME),
})
/* uncomment this, when you need the activation link on the console */
// In case EMails are disabled log the activation link for the user
if (!emailSent) {
logger.debug(`Reset password link: ${activationLink(dbUserContact.emailVerificationCode)}`)
logger.debug(
`Reset password link: ${activationLink(user.emailContact.emailVerificationCode)}`,
)
}
logger.info(`forgotPassword(${email}) successful...`)
@ -517,7 +451,7 @@ export class UserResolver {
})
logger.debug('userContact loaded...')
// Code is only valid for `CONFIG.EMAIL_CODE_VALID_TIME` minutes
if (!isEmailVerificationCodeValid(userContact.updatedAt)) {
if (!isEmailVerificationCodeValid(userContact.updatedAt || userContact.createdAt)) {
logger.error(
`email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`,
)
@ -599,7 +533,7 @@ export class UserResolver {
const userContact = await DbUserContact.findOneOrFail({ emailVerificationCode: optIn })
logger.debug(`found optInCode=${userContact}`)
// Code is only valid for `CONFIG.EMAIL_CODE_VALID_TIME` minutes
if (!isEmailVerificationCodeValid(userContact.updatedAt)) {
if (!isEmailVerificationCodeValid(userContact.updatedAt || userContact.createdAt)) {
logger.error(
`email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`,
)
@ -759,10 +693,7 @@ const isOptInValid = (optIn: LoginEmailOptIn): boolean => {
return isTimeExpired(optIn, CONFIG.EMAIL_CODE_VALID_TIME)
}
*/
const isEmailVerificationCodeValid = (updatedAt: Date | null): boolean => {
if (updatedAt == null) {
return true
}
const isEmailVerificationCodeValid = (updatedAt: Date): boolean => {
return isTimeExpired(updatedAt, CONFIG.EMAIL_CODE_VALID_TIME)
}
/*
@ -773,20 +704,3 @@ const canResendOptIn = (optIn: LoginEmailOptIn): boolean => {
const canEmailResend = (updatedAt: Date): boolean => {
return !isTimeExpired(updatedAt, CONFIG.EMAIL_CODE_REQUEST_TIME)
}
const getTimeDurationObject = (time: number): { hours?: number; minutes: number } => {
if (time > 60) {
return {
hours: Math.floor(time / 60),
minutes: time % 60,
}
}
return { minutes: time }
}
export const printTimeDuration = (duration: number): string => {
const time = getTimeDurationObject(duration)
const result = time.minutes > 0 ? `${time.minutes} minutes` : ''
if (time.hours) return `${time.hours} hours` + (result !== '' ? ` and ${result}` : '')
return result
}

View File

@ -170,8 +170,11 @@ describe('util/creation', () => {
const targetDate = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 0, 0)
beforeAll(() => {
const halfMsToRun = (targetDate.getTime() - now.getTime()) / 2
jest.useFakeTimers()
setTimeout(jest.fn(), targetDate.getTime() - now.getTime())
setTimeout(jest.fn(), halfMsToRun)
jest.runAllTimers()
setTimeout(jest.fn(), halfMsToRun)
jest.runAllTimers()
})
@ -225,8 +228,10 @@ describe('util/creation', () => {
})
it('has the clock set correctly', () => {
const targetMonth = nextMonthTargetDate.getMonth() + 1
const targetMonthString = (targetMonth < 10 ? '0' : '') + String(targetMonth)
expect(new Date().toISOString()).toContain(
`${nextMonthTargetDate.getFullYear()}-${nextMonthTargetDate.getMonth() + 1}-01T01:`,
`${nextMonthTargetDate.getFullYear()}-${targetMonthString}-01T01:`,
)
})

View File

@ -103,6 +103,9 @@ export const getUserCreation = async (
const getCreationMonths = (timezoneOffset: number): number[] => {
const clientNow = new Date()
clientNow.setTime(clientNow.getTime() - timezoneOffset * 60 * 1000)
logger.info(
`getCreationMonths -- offset: ${timezoneOffset} -- clientNow: ${clientNow.toISOString()}`,
)
return [
new Date(clientNow.getFullYear(), clientNow.getMonth() - 2, 1).getMonth() + 1,
new Date(clientNow.getFullYear(), clientNow.getMonth() - 1, 1).getMonth() + 1,

View File

@ -1,15 +1,61 @@
{
"emails": {
"addedContributionMessage": {
"commonGoodContributionMessage": "du hast zu deinem Gemeinwohl-Beitrag „{contributionMemo}“ eine Nachricht von {senderFirstName} {senderLastName} erhalten.",
"subject": "Gradido: Nachricht zu deinem Gemeinwohl-Beitrag",
"toSeeAndAnswerMessage": "Um die Nachricht zu sehen und darauf zu antworten, gehe in deinem Gradido-Konto ins Menü „Gemeinschaft“ auf den Tab „Meine Beiträge zum Gemeinwohl“!"
},
"accountActivation": {
"duration": "Der Link hat eine Gültigkeit von {hours} Stunden und {minutes} Minuten. Sollte die Gültigkeit des Links bereits abgelaufen sein, kannst du dir hier einen neuen Link schicken lassen, in dem du deine E-Mail-Adresse eingibst:",
"emailRegistered": "deine E-Mail-Adresse wurde soeben bei Gradido registriert.",
"pleaseClickLink": "Klicke bitte auf diesen Link, um die Registrierung abzuschließen und dein Gradido-Konto zu aktivieren:",
"subject": "Gradido: E-Mail Überprüfung"
},
"accountMultiRegistration": {
"emailExists": "Es existiert jedoch zu deiner E-Mail-Adresse schon ein Konto.",
"emailReused": "Deine E-Mail-Adresse wurde soeben erneut benutzt, um bei Gradido ein Konto zu registrieren.",
"helloName": "Hallo {firstName} {lastName}",
"emailReused": "deine E-Mail-Adresse wurde soeben erneut benutzt, um bei Gradido ein Konto zu registrieren.",
"ifYouAreNotTheOne": "Wenn du nicht derjenige bist, der sich versucht hat erneut zu registrieren, wende dich bitte an unseren support:",
"onForgottenPasswordClickLink": "Klicke bitte auf den folgenden Link, falls du dein Passwort vergessen haben solltest:",
"onForgottenPasswordCopyLink": "oder kopiere den obigen Link in dein Browserfenster.",
"sincerelyYours": "Mit freundlichen Grüßen,",
"subject": "Gradido: Erneuter Registrierungsversuch mit deiner E-Mail",
"subject": "Gradido: Erneuter Registrierungsversuch mit deiner E-Mail"
},
"contributionConfirmed": {
"commonGoodContributionConfirmed": "dein Gemeinwohl-Beitrag „{contributionMemo}“ wurde soeben von {senderFirstName} {senderLastName} bestätigt und in deinem Gradido-Konto gutgeschrieben.",
"subject": "Gradido: Dein Gemeinwohl-Beitrag wurde bestätigt"
},
"contributionRejected": {
"commonGoodContributionRejected": "dein Gemeinwohl-Beitrag „{contributionMemo}“ wurde von {senderFirstName} {senderLastName} abgelehnt.",
"subject": "Gradido: Dein Gemeinwohl-Beitrag wurde abgelehnt",
"toSeeContributionsAndMessages": "Um deine Gemeinwohl-Beiträge und dazugehörige Nachrichten zu sehen, gehe in deinem Gradido-Konto ins Menü „Gemeinschaft“ auf den Tab „Meine Beiträge zum Gemeinwohl“!"
},
"general": {
"amountGDD": "Betrag: {amountGDD} GDD",
"detailsYouFindOnLinkToYourAccount": "Details zur Transaktion findest du in deinem Gradido-Konto:",
"doNotAnswer": "nicht antworten",
"helloName": "Hallo {firstName} {lastName},",
"linkToYourAccount": "Link zu deinem Konto:",
"orCopyLink": "oder kopiere den obigen Link in dein Browserfenster.",
"pleaseDoNotReply": "Bitte antworte nicht auf diese E-Mail!",
"sincerelyYours": "Liebe Grüße",
"yourGradidoTeam": "dein Gradido-Team"
},
"resetPassword": {
"duration": "Der Link hat eine Gültigkeit von {hours} Stunden und {minutes} Minuten. Sollte die Gültigkeit des Links bereits abgelaufen sein, kannst du dir hier einen neuen Link schicken lassen, in dem du deine E-Mail-Adresse eingibst:",
"pleaseClickLink": "Wenn du es warst, klicke bitte auf den Link:",
"subject": "Gradido: Passwort zurücksetzen",
"youOrSomeoneResetPassword": "du, oder jemand anderes, hast für dieses Konto ein Zurücksetzen des Passworts angefordert."
},
"transactionLinkRedeemed": {
"hasRedeemedYourLink": "{senderFirstName} {senderLastName} ({senderEmail}) hat soeben deinen Link eingelöst.",
"memo": "Memo: {transactionMemo}",
"subject": "Gradido: Dein Gradido-Link wurde eingelöst"
},
"transactionReceived": {
"haveReceivedAmountGDDFrom": "du hast soeben {transactionAmount} GDD von {senderFirstName} {senderLastName} ({senderEmail}) erhalten.",
"subject": "Gradido: Du hast Gradidos erhalten"
}
},
"general": {
"decimalSeparator": ","
}
}
}

View File

@ -1,15 +1,61 @@
{
"emails": {
"accountMultiRegistration": {
"emails": {
"addedContributionMessage": {
"commonGoodContributionMessage": "you have received a message from {senderFirstName} {senderLastName} regarding your common good contribution “{contributionMemo}”.",
"subject": "Gradido: Message about your common good contribution",
"toSeeAndAnswerMessage": "To view and reply to the message, go to the “Community” menu in your Gradido account and click on the “My contributions to the common good” tab!"
},
"accountActivation": {
"duration": "The link has a validity of {hours} hours and {minutes} minutes. If the validity of the link has already expired, you can have a new link sent to you here by entering your email address:",
"emailRegistered": "Your email address has just been registered with Gradido.",
"pleaseClickLink": "Please click on this link to complete the registration and activate your Gradido account:",
"subject": "Gradido: Email Verification"
},
"accountMultiRegistration": {
"emailExists": "However, an account already exists for your email address.",
"emailReused": "Your email address has just been used again to register an account with Gradido.",
"helloName": "Hello {firstName} {lastName}",
"ifYouAreNotTheOne": "If you are not the one who tried to register again, please contact our support:",
"onForgottenPasswordClickLink": "Please click on the following link if you have forgotten your password:",
"onForgottenPasswordCopyLink": "or copy the link above into your browser window.",
"sincerelyYours": "Sincerely yours,",
"subject": "Gradido: Try To Register Again With Your Email",
"subject": "Gradido: Try To Register Again With Your Email"
},
"contributionConfirmed": {
"commonGoodContributionConfirmed": "Your public good contribution “{contributionMemo}” has just been confirmed by {senderFirstName} {senderLastName} and credited to your Gradido account.",
"subject": "Gradido: Your common good contribution was confirmed"
},
"contributionRejected": {
"commonGoodContributionRejected": "Your public good contribution “{contributionMemo}” was rejected by {senderFirstName} {senderLastName}.",
"subject": "Gradido: Your common good contribution was rejected",
"toSeeContributionsAndMessages": "To see your common good contributions and related messages, go to the “Community” menu in your Gradido account and click on the “My contributions to the common good” tab!"
},
"general": {
"amountGDD": "Amount: {amountGDD} GDD",
"detailsYouFindOnLinkToYourAccount": "You can find transaction details in your Gradido account:",
"doNotAnswer": "do not answer",
"helloName": "Hello {firstName} {lastName}",
"linkToYourAccount": "Link to your account:",
"orCopyLink": "or copy the link above into your browser window.",
"pleaseDoNotReply": "Please do not reply to this email!",
"sincerelyYours": "Kind regards,",
"yourGradidoTeam": "your Gradido team"
}
}
}
},
"resetPassword": {
"duration": "The link has a validity of {hours} hours and {minutes} minutes. If the validity of the link has already expired, you can have a new link sent to you here by entering your email address:",
"pleaseClickLink": "If it was you, please click on the link:",
"subject": "Gradido: Reset password",
"youOrSomeoneResetPassword": "You, or someone else, requested a password reset for this account."
},
"transactionLinkRedeemed": {
"hasRedeemedYourLink": "{senderFirstName} {senderLastName} ({senderEmail}) has just redeemed your link.",
"memo": "Memo: {transactionMemo}",
"subject": "Gradido: Your Gradido link has been redeemed"
},
"transactionReceived": {
"haveReceivedAmountGDDFrom": "You have just received {transactionAmount} GDD from {senderFirstName} {senderLastName} ({senderEmail}).",
"subject": "Gradido: You have received Gradidos"
}
},
"general": {
"decimalSeparator": "."
}
}

View File

@ -1,32 +0,0 @@
import { sendAccountActivationEmail } from './sendAccountActivationEmail'
import { sendEMail } from './sendEMail'
jest.mock('./sendEMail', () => {
return {
__esModule: true,
sendEMail: jest.fn(),
}
})
describe('sendAccountActivationEmail', () => {
beforeEach(async () => {
await sendAccountActivationEmail({
link: 'activationLink',
firstName: 'Peter',
lastName: 'Lustig',
email: 'peter@lustig.de',
duration: '23 hours and 30 minutes',
})
})
it('calls sendEMail', () => {
expect(sendEMail).toBeCalledWith({
to: `Peter Lustig <peter@lustig.de>`,
subject: 'Gradido: E-Mail Überprüfung',
text:
expect.stringContaining('Hallo Peter Lustig') &&
expect.stringContaining('activationLink') &&
expect.stringContaining('23 Stunden und 30 Minuten'),
})
})
})

View File

@ -1,17 +0,0 @@
import { sendEMail } from './sendEMail'
import { accountActivation } from './text/accountActivation'
import CONFIG from '@/config'
export const sendAccountActivationEmail = (data: {
link: string
firstName: string
lastName: string
email: string
duration: string
}): Promise<boolean> => {
return sendEMail({
to: `${data.firstName} ${data.lastName} <${data.email}>`,
subject: accountActivation.de.subject,
text: accountActivation.de.text({ ...data, resendLink: CONFIG.EMAIL_LINK_FORGOTPASSWORD }),
})
}

View File

@ -1,40 +0,0 @@
import { sendAddedContributionMessageEmail } from './sendAddedContributionMessageEmail'
import { sendEMail } from './sendEMail'
jest.mock('./sendEMail', () => {
return {
__esModule: true,
sendEMail: jest.fn(),
}
})
describe('sendAddedContributionMessageEmail', () => {
beforeEach(async () => {
await sendAddedContributionMessageEmail({
senderFirstName: 'Peter',
senderLastName: 'Lustig',
recipientFirstName: 'Bibi',
recipientLastName: 'Bloxberg',
recipientEmail: 'bibi@bloxberg.de',
senderEmail: 'peter@lustig.de',
contributionMemo: 'Vielen herzlichen Dank für den neuen Hexenbesen!',
message: 'Was für ein Besen ist es geworden?',
overviewURL: 'http://localhost/overview',
})
})
it('calls sendEMail', () => {
expect(sendEMail).toBeCalledWith({
to: `Bibi Bloxberg <bibi@bloxberg.de>`,
subject: 'Nachricht zu deinem Gemeinwohl-Beitrag',
text:
expect.stringContaining('Hallo Bibi Bloxberg') &&
expect.stringContaining('Peter Lustig') &&
expect.stringContaining(
'du hast zu deinem Gemeinwohl-Beitrag "Vielen herzlichen Dank für den neuen Hexenbesen!" eine Nachricht von Peter Lustig erhalten.',
) &&
expect.stringContaining('Was für ein Besen ist es geworden?') &&
expect.stringContaining('http://localhost/overview'),
})
})
})

View File

@ -1,26 +0,0 @@
import { backendLogger as logger } from '@/server/logger'
import { sendEMail } from './sendEMail'
import { contributionMessageReceived } from './text/contributionMessageReceived'
export const sendAddedContributionMessageEmail = (data: {
senderFirstName: string
senderLastName: string
recipientFirstName: string
recipientLastName: string
recipientEmail: string
senderEmail: string
contributionMemo: string
message: string
overviewURL: string
}): Promise<boolean> => {
logger.info(
`sendEmail(): to=${data.recipientFirstName} ${data.recipientLastName} <${data.recipientEmail}>,
subject=${contributionMessageReceived.de.subject},
text=${contributionMessageReceived.de.text(data)}`,
)
return sendEMail({
to: `${data.recipientFirstName} ${data.recipientLastName} <${data.recipientEmail}>`,
subject: contributionMessageReceived.de.subject,
text: contributionMessageReceived.de.text(data),
})
}

View File

@ -1,39 +0,0 @@
import Decimal from 'decimal.js-light'
import { sendContributionConfirmedEmail } from './sendContributionConfirmedEmail'
import { sendEMail } from './sendEMail'
jest.mock('./sendEMail', () => {
return {
__esModule: true,
sendEMail: jest.fn(),
}
})
describe('sendContributionConfirmedEmail', () => {
beforeEach(async () => {
await sendContributionConfirmedEmail({
senderFirstName: 'Peter',
senderLastName: 'Lustig',
recipientFirstName: 'Bibi',
recipientLastName: 'Bloxberg',
recipientEmail: 'bibi@bloxberg.de',
contributionMemo: 'Vielen herzlichen Dank für den neuen Hexenbesen!',
contributionAmount: new Decimal(200.0),
overviewURL: 'http://localhost/overview',
})
})
it('calls sendEMail', () => {
expect(sendEMail).toBeCalledWith({
to: 'Bibi Bloxberg <bibi@bloxberg.de>',
subject: 'Dein Gemeinwohl-Beitrag wurde bestätigt',
text:
expect.stringContaining('Hallo Bibi Bloxberg') &&
expect.stringContaining(
'dein Gemeinwohl-Beitrag "Vielen herzlichen Dank für den neuen Hexenbesen!" wurde soeben von Peter Lustig bestätigt und in deinem Gradido-Konto gutgeschrieben.',
) &&
expect.stringContaining('Betrag: 200,00 GDD') &&
expect.stringContaining('Link zu deinem Konto: http://localhost/overview'),
})
})
})

View File

@ -1,26 +0,0 @@
import { backendLogger as logger } from '@/server/logger'
import Decimal from 'decimal.js-light'
import { sendEMail } from './sendEMail'
import { contributionConfirmed } from './text/contributionConfirmed'
export const sendContributionConfirmedEmail = (data: {
senderFirstName: string
senderLastName: string
recipientFirstName: string
recipientLastName: string
recipientEmail: string
contributionMemo: string
contributionAmount: Decimal
overviewURL: string
}): Promise<boolean> => {
logger.info(
`sendEmail(): to=${data.recipientFirstName} ${data.recipientLastName} <${data.recipientEmail}>,
subject=${contributionConfirmed.de.subject},
text=${contributionConfirmed.de.text(data)}`,
)
return sendEMail({
to: `${data.recipientFirstName} ${data.recipientLastName} <${data.recipientEmail}>`,
subject: contributionConfirmed.de.subject,
text: contributionConfirmed.de.text(data),
})
}

View File

@ -1,38 +0,0 @@
import Decimal from 'decimal.js-light'
import { sendContributionRejectedEmail } from './sendContributionRejectedEmail'
import { sendEMail } from './sendEMail'
jest.mock('./sendEMail', () => {
return {
__esModule: true,
sendEMail: jest.fn(),
}
})
describe('sendContributionConfirmedEmail', () => {
beforeEach(async () => {
await sendContributionRejectedEmail({
senderFirstName: 'Peter',
senderLastName: 'Lustig',
recipientFirstName: 'Bibi',
recipientLastName: 'Bloxberg',
recipientEmail: 'bibi@bloxberg.de',
contributionMemo: 'Vielen herzlichen Dank für den neuen Hexenbesen!',
contributionAmount: new Decimal(200.0),
overviewURL: 'http://localhost/overview',
})
})
it('calls sendEMail', () => {
expect(sendEMail).toBeCalledWith({
to: 'Bibi Bloxberg <bibi@bloxberg.de>',
subject: 'Dein Gemeinwohl-Beitrag wurde abgelehnt',
text:
expect.stringContaining('Hallo Bibi Bloxberg') &&
expect.stringContaining(
'dein Gemeinwohl-Beitrag "Vielen herzlichen Dank für den neuen Hexenbesen!" wurde von Peter Lustig abgelehnt.',
) &&
expect.stringContaining('Link zu deinem Konto: http://localhost/overview'),
})
})
})

View File

@ -1,26 +0,0 @@
import { backendLogger as logger } from '@/server/logger'
import Decimal from 'decimal.js-light'
import { sendEMail } from './sendEMail'
import { contributionRejected } from './text/contributionRejected'
export const sendContributionRejectedEmail = (data: {
senderFirstName: string
senderLastName: string
recipientFirstName: string
recipientLastName: string
recipientEmail: string
contributionMemo: string
contributionAmount: Decimal
overviewURL: string
}): Promise<boolean> => {
logger.info(
`sendEmail(): to=${data.recipientFirstName} ${data.recipientLastName} <${data.recipientEmail}>,
subject=${contributionRejected.de.subject},
text=${contributionRejected.de.text(data)}`,
)
return sendEMail({
to: `${data.recipientFirstName} ${data.recipientLastName} <${data.recipientEmail}>`,
subject: contributionRejected.de.subject,
text: contributionRejected.de.text(data),
})
}

View File

@ -1,113 +0,0 @@
import { sendEMail } from './sendEMail'
import { createTransport } from 'nodemailer'
import CONFIG from '@/config'
import { logger } from '@test/testSetup'
CONFIG.EMAIL = false
CONFIG.EMAIL_SMTP_URL = 'EMAIL_SMTP_URL'
CONFIG.EMAIL_SMTP_PORT = '1234'
CONFIG.EMAIL_USERNAME = 'user'
CONFIG.EMAIL_PASSWORD = 'pwd'
jest.mock('nodemailer', () => {
return {
__esModule: true,
createTransport: jest.fn(() => {
return {
sendMail: jest.fn(() => {
return {
messageId: 'message',
}
}),
}
}),
}
})
describe('sendEMail', () => {
let result: boolean
describe('config email is false', () => {
beforeEach(async () => {
jest.clearAllMocks()
result = await sendEMail({
to: 'receiver@mail.org',
cc: 'support@gradido.net',
subject: 'Subject',
text: 'Text text text',
})
})
it('logs warning', () => {
expect(logger.info).toBeCalledWith('Emails are disabled via config...')
})
it('returns false', () => {
expect(result).toBeFalsy()
})
})
describe('config email is true', () => {
beforeEach(async () => {
jest.clearAllMocks()
CONFIG.EMAIL = true
result = await sendEMail({
to: 'receiver@mail.org',
cc: 'support@gradido.net',
subject: 'Subject',
text: 'Text text text',
})
})
it('calls the transporter', () => {
expect(createTransport).toBeCalledWith({
host: 'EMAIL_SMTP_URL',
port: 1234,
secure: false,
requireTLS: true,
auth: {
user: 'user',
pass: 'pwd',
},
})
})
it('calls sendMail of transporter', () => {
expect((createTransport as jest.Mock).mock.results[0].value.sendMail).toBeCalledWith({
from: `Gradido (nicht antworten) <${CONFIG.EMAIL_SENDER}>`,
to: 'receiver@mail.org',
cc: 'support@gradido.net',
subject: 'Subject',
text: 'Text text text',
})
})
it('returns true', () => {
expect(result).toBeTruthy()
})
})
describe('with email EMAIL_TEST_MODUS true', () => {
beforeEach(async () => {
jest.clearAllMocks()
CONFIG.EMAIL = true
CONFIG.EMAIL_TEST_MODUS = true
result = await sendEMail({
to: 'receiver@mail.org',
cc: 'support@gradido.net',
subject: 'Subject',
text: 'Text text text',
})
})
it('calls sendMail of transporter with faked to', () => {
expect((createTransport as jest.Mock).mock.results[0].value.sendMail).toBeCalledWith({
from: `Gradido (nicht antworten) <${CONFIG.EMAIL_SENDER}>`,
to: CONFIG.EMAIL_TEST_RECEIVER,
cc: 'support@gradido.net',
subject: 'Subject',
text: 'Text text text',
})
})
})
})

View File

@ -1,48 +0,0 @@
import { backendLogger as logger } from '@/server/logger'
import { createTransport } from 'nodemailer'
import CONFIG from '@/config'
export const sendEMail = async (emailDef: {
to: string
cc?: string
subject: string
text: string
}): Promise<boolean> => {
logger.info(
`send Email: to=${emailDef.to}` +
(emailDef.cc ? `, cc=${emailDef.cc}` : '') +
`, subject=${emailDef.subject}, text=${emailDef.text}`,
)
if (!CONFIG.EMAIL) {
logger.info(`Emails are disabled via config...`)
return false
}
if (CONFIG.EMAIL_TEST_MODUS) {
logger.info(
`Testmodus=ON: change receiver from ${emailDef.to} to ${CONFIG.EMAIL_TEST_RECEIVER}`,
)
emailDef.to = CONFIG.EMAIL_TEST_RECEIVER
}
const transporter = createTransport({
host: CONFIG.EMAIL_SMTP_URL,
port: Number(CONFIG.EMAIL_SMTP_PORT),
secure: false, // true for 465, false for other ports
requireTLS: true,
auth: {
user: CONFIG.EMAIL_USERNAME,
pass: CONFIG.EMAIL_PASSWORD,
},
})
const info = await transporter.sendMail({
...emailDef,
from: `Gradido (nicht antworten) <${CONFIG.EMAIL_SENDER}>`,
})
if (!info.messageId) {
logger.error('error sending notification email, but transaction succeed')
throw new Error('error sending notification email, but transaction succeed')
}
logger.info('send Email successfully.')
return true
}

View File

@ -1,32 +0,0 @@
import { sendResetPasswordEmail } from './sendResetPasswordEmail'
import { sendEMail } from './sendEMail'
jest.mock('./sendEMail', () => {
return {
__esModule: true,
sendEMail: jest.fn(),
}
})
describe('sendResetPasswordEmail', () => {
beforeEach(async () => {
await sendResetPasswordEmail({
link: 'resetLink',
firstName: 'Peter',
lastName: 'Lustig',
email: 'peter@lustig.de',
duration: '23 hours and 30 minutes',
})
})
it('calls sendEMail', () => {
expect(sendEMail).toBeCalledWith({
to: `Peter Lustig <peter@lustig.de>`,
subject: 'Gradido: Passwort zurücksetzen',
text:
expect.stringContaining('Hallo Peter Lustig') &&
expect.stringContaining('resetLink') &&
expect.stringContaining('23 Stunden und 30 Minuten'),
})
})
})

View File

@ -1,17 +0,0 @@
import { sendEMail } from './sendEMail'
import { resetPassword } from './text/resetPassword'
import CONFIG from '@/config'
export const sendResetPasswordEmail = (data: {
link: string
firstName: string
lastName: string
email: string
duration: string
}): Promise<boolean> => {
return sendEMail({
to: `${data.firstName} ${data.lastName} <${data.email}>`,
subject: resetPassword.de.subject,
text: resetPassword.de.text({ ...data, resendLink: CONFIG.EMAIL_LINK_FORGOTPASSWORD }),
})
}

View File

@ -1,44 +0,0 @@
import { sendEMail } from './sendEMail'
import Decimal from 'decimal.js-light'
import { sendTransactionLinkRedeemedEmail } from './sendTransactionLinkRedeemed'
jest.mock('./sendEMail', () => {
return {
__esModule: true,
sendEMail: jest.fn(),
}
})
describe('sendTransactionLinkRedeemedEmail', () => {
beforeEach(async () => {
await sendTransactionLinkRedeemedEmail({
email: 'bibi@bloxberg.de',
senderFirstName: 'Peter',
senderLastName: 'Lustig',
recipientFirstName: 'Bibi',
recipientLastName: 'Bloxberg',
senderEmail: 'peter@lustig.de',
amount: new Decimal(42.0),
memo: 'Vielen Dank dass Du dabei bist',
overviewURL: 'http://localhost/overview',
})
})
it('calls sendEMail', () => {
expect(sendEMail).toBeCalledWith({
to: `Bibi Bloxberg <bibi@bloxberg.de>`,
subject: 'Gradido-Link wurde eingelöst',
text:
expect.stringContaining('Hallo Bibi Bloxberg') &&
expect.stringContaining(
'Peter Lustig (peter@lustig.de) hat soeben deinen Link eingelöst.',
) &&
expect.stringContaining('Betrag: 42,00 GDD,') &&
expect.stringContaining('Memo: Vielen Dank dass Du dabei bist') &&
expect.stringContaining(
'Details zur Transaktion findest du in deinem Gradido-Konto: http://localhost/overview',
) &&
expect.stringContaining('Bitte antworte nicht auf diese E-Mail!'),
})
})
})

View File

@ -1,28 +0,0 @@
import { backendLogger as logger } from '@/server/logger'
import Decimal from 'decimal.js-light'
import { sendEMail } from './sendEMail'
import { transactionLinkRedeemed } from './text/transactionLinkRedeemed'
export const sendTransactionLinkRedeemedEmail = (data: {
email: string
senderFirstName: string
senderLastName: string
recipientFirstName: string
recipientLastName: string
senderEmail: string
amount: Decimal
memo: string
overviewURL: string
}): Promise<boolean> => {
logger.info(
`sendEmail(): to=${data.recipientFirstName} ${data.recipientLastName},
<${data.email}>,
subject=${transactionLinkRedeemed.de.subject},
text=${transactionLinkRedeemed.de.text(data)}`,
)
return sendEMail({
to: `${data.recipientFirstName} ${data.recipientLastName} <${data.email}>`,
subject: transactionLinkRedeemed.de.subject,
text: transactionLinkRedeemed.de.text(data),
})
}

View File

@ -1,38 +0,0 @@
import { sendTransactionReceivedEmail } from './sendTransactionReceivedEmail'
import { sendEMail } from './sendEMail'
import Decimal from 'decimal.js-light'
jest.mock('./sendEMail', () => {
return {
__esModule: true,
sendEMail: jest.fn(),
}
})
describe('sendTransactionReceivedEmail', () => {
beforeEach(async () => {
await sendTransactionReceivedEmail({
senderFirstName: 'Bibi',
senderLastName: 'Bloxberg',
recipientFirstName: 'Peter',
recipientLastName: 'Lustig',
email: 'peter@lustig.de',
senderEmail: 'bibi@bloxberg.de',
amount: new Decimal(42.0),
overviewURL: 'http://localhost/overview',
})
})
it('calls sendEMail', () => {
expect(sendEMail).toBeCalledWith({
to: `Peter Lustig <peter@lustig.de>`,
subject: 'Du hast Gradidos erhalten',
text:
expect.stringContaining('Hallo Peter Lustig') &&
expect.stringContaining('42,00 GDD') &&
expect.stringContaining('Bibi Bloxberg') &&
expect.stringContaining('(bibi@bloxberg.de)') &&
expect.stringContaining('http://localhost/overview'),
})
})
})

View File

@ -1,27 +0,0 @@
import { backendLogger as logger } from '@/server/logger'
import Decimal from 'decimal.js-light'
import { sendEMail } from './sendEMail'
import { transactionReceived } from './text/transactionReceived'
export const sendTransactionReceivedEmail = (data: {
senderFirstName: string
senderLastName: string
recipientFirstName: string
recipientLastName: string
email: string
senderEmail: string
amount: Decimal
overviewURL: string
}): Promise<boolean> => {
logger.info(
`sendEmail(): to=${data.recipientFirstName} ${data.recipientLastName},
<${data.email}>,
subject=${transactionReceived.de.subject},
text=${transactionReceived.de.text(data)}`,
)
return sendEMail({
to: `${data.recipientFirstName} ${data.recipientLastName} <${data.email}>`,
subject: transactionReceived.de.subject,
text: transactionReceived.de.text(data),
})
}

View File

@ -1,32 +0,0 @@
export const accountActivation = {
de: {
subject: 'Gradido: E-Mail Überprüfung',
text: (data: {
link: string
firstName: string
lastName: string
email: string
duration: string
resendLink: string
}): string =>
`Hallo ${data.firstName} ${data.lastName},
Deine E-Mail-Adresse wurde soeben bei Gradido registriert.
Klicke bitte auf diesen Link, um die Registrierung abzuschließen und dein Gradido-Konto zu aktivieren:
${data.link}
oder kopiere den obigen Link in dein Browserfenster.
Der Link hat eine Gültigkeit von ${data.duration
.replace('hours', 'Stunden')
.replace('minutes', 'Minuten')
.replace(
' and ',
' und ',
)}. Sollte die Gültigkeit des Links bereits abgelaufen sein, kannst du dir hier einen neuen Link schicken lassen, in dem du deine E-Mail-Adresse eingibst:
${data.resendLink}
Mit freundlichen Grüßen,
dein Gradido-Team`,
},
}

View File

@ -1,25 +0,0 @@
export const accountMultiRegistration = {
de: {
subject: 'Gradido: Erneuter Registrierungsversuch mit deiner E-Mail',
text: (data: {
firstName: string
lastName: string
email: string
resendLink: string
}): string =>
`Hallo ${data.firstName} ${data.lastName},
Deine E-Mail-Adresse wurde soeben erneut benutzt, um bei Gradido ein Konto zu registrieren.
Es existiert jedoch zu deiner E-Mail-Adresse schon ein Konto.
Klicke bitte auf den folgenden Link, falls du dein Passwort vergessen haben solltest:
${data.resendLink}
oder kopiere den obigen Link in dein Browserfenster.
Wenn du nicht derjenige bist, der sich versucht hat erneut zu registrieren, wende dich bitte an unseren support:
https://gradido.net/de/contact/
Mit freundlichen Grüßen,
dein Gradido-Team`,
},
}

View File

@ -1,30 +0,0 @@
import Decimal from 'decimal.js-light'
export const contributionConfirmed = {
de: {
subject: 'Dein Gemeinwohl-Beitrag wurde bestätigt',
text: (data: {
senderFirstName: string
senderLastName: string
recipientFirstName: string
recipientLastName: string
contributionMemo: string
contributionAmount: Decimal
overviewURL: string
}): string =>
`Hallo ${data.recipientFirstName} ${data.recipientLastName},
dein Gemeinwohl-Beitrag "${data.contributionMemo}" wurde soeben von ${data.senderFirstName} ${
data.senderLastName
} bestätigt und in deinem Gradido-Konto gutgeschrieben.
Betrag: ${data.contributionAmount.toFixed(2).replace('.', ',')} GDD
Link zu deinem Konto: ${data.overviewURL}
Bitte antworte nicht auf diese E-Mail!
Liebe Grüße
dein Gradido-Team`,
},
}

View File

@ -1,28 +0,0 @@
export const contributionMessageReceived = {
de: {
subject: 'Nachricht zu deinem Gemeinwohl-Beitrag',
text: (data: {
senderFirstName: string
senderLastName: string
recipientFirstName: string
recipientLastName: string
recipientEmail: string
senderEmail: string
contributionMemo: string
message: string
overviewURL: string
}): string =>
`Hallo ${data.recipientFirstName} ${data.recipientLastName},
du hast zu deinem Gemeinwohl-Beitrag "${data.contributionMemo}" eine Nachricht von ${data.senderFirstName} ${data.senderLastName} erhalten.
Um die Nachricht zu sehen und darauf zu antworten, gehe in deinem Gradido-Konto ins Menü "Gemeinschaft" auf den Tab "Meine Beiträge zum Gemeinwohl"!
Link zu deinem Konto: ${data.overviewURL}
Bitte antworte nicht auf diese E-Mail!
Liebe Grüße
dein Gradido-Team`,
},
}

View File

@ -1,28 +0,0 @@
import Decimal from 'decimal.js-light'
export const contributionRejected = {
de: {
subject: 'Dein Gemeinwohl-Beitrag wurde abgelehnt',
text: (data: {
senderFirstName: string
senderLastName: string
recipientFirstName: string
recipientLastName: string
contributionMemo: string
contributionAmount: Decimal
overviewURL: string
}): string =>
`Hallo ${data.recipientFirstName} ${data.recipientLastName},
dein Gemeinwohl-Beitrag "${data.contributionMemo}" wurde von ${data.senderFirstName} ${data.senderLastName} abgelehnt.
Um deine Gemeinwohl-Beiträge und dazugehörige Nachrichten zu sehen, gehe in deinem Gradido-Konto ins Menü "Gemeinschaft" auf den Tab "Meine Beiträge zum Gemeinwohl"!
Link zu deinem Konto: ${data.overviewURL}
Bitte antworte nicht auf diese E-Mail!
Liebe Grüße
dein Gradido-Team`,
},
}

View File

@ -1,30 +0,0 @@
export const resetPassword = {
de: {
subject: 'Gradido: Passwort zurücksetzen',
text: (data: {
link: string
firstName: string
lastName: string
email: string
duration: string
resendLink: string
}): string =>
`Hallo ${data.firstName} ${data.lastName},
Du oder jemand anderes hat für dieses Konto ein Zurücksetzen des Passworts angefordert.
Wenn du es warst, klicke bitte auf den Link: ${data.link}
oder kopiere den obigen Link in Dein Browserfenster.
Der Link hat eine Gültigkeit von ${data.duration
.replace('hours', 'Stunden')
.replace('minutes', 'Minuten')
.replace(
' and ',
' und ',
)}. Sollte die Gültigkeit des Links bereits abgelaufen sein, kannst du dir hier einen neuen Link schicken lassen, in dem du deine E-Mail-Adresse eingibst:
${data.resendLink}
Mit freundlichen Grüßen,
dein Gradido-Team`,
},
}

View File

@ -1,33 +0,0 @@
import Decimal from 'decimal.js-light'
export const transactionLinkRedeemed = {
de: {
subject: 'Gradido-Link wurde eingelöst',
text: (data: {
email: string
senderFirstName: string
senderLastName: string
recipientFirstName: string
recipientLastName: string
senderEmail: string
amount: Decimal
memo: string
overviewURL: string
}): string =>
`Hallo ${data.recipientFirstName} ${data.recipientLastName},
${data.senderFirstName} ${data.senderLastName} (${
data.senderEmail
}) hat soeben deinen Link eingelöst.
Betrag: ${data.amount.toFixed(2).replace('.', ',')} GDD,
Memo: ${data.memo}
Details zur Transaktion findest du in deinem Gradido-Konto: ${data.overviewURL}
Bitte antworte nicht auf diese E-Mail!
Liebe Grüße
dein Gradido-Team`,
},
}

View File

@ -1,29 +0,0 @@
import Decimal from 'decimal.js-light'
export const transactionReceived = {
de: {
subject: 'Du hast Gradidos erhalten',
text: (data: {
senderFirstName: string
senderLastName: string
recipientFirstName: string
recipientLastName: string
email: string
senderEmail: string
amount: Decimal
overviewURL: string
}): string =>
`Hallo ${data.recipientFirstName} ${data.recipientLastName},
du hast soeben ${data.amount.toFixed(2).replace('.', ',')} GDD von ${data.senderFirstName} ${
data.senderLastName
} (${data.senderEmail}) erhalten.
Details zur Transaktion findest du in deinem Gradido-Konto: ${data.overviewURL}
Bitte antworte nicht auf diese E-Mail!
Liebe Grüße
dein Gradido-Team`,
},
}

View File

@ -12,29 +12,24 @@ export class TransactionRepository extends Repository<Transaction> {
order: Order,
onlyCreation?: boolean,
): Promise<[Transaction[], number]> {
if (onlyCreation) {
return this.createQueryBuilder('userTransaction')
.where('userTransaction.userId = :userId', { userId })
.andWhere('userTransaction.typeId = :typeId', {
typeId: TransactionTypeId.CREATION,
})
.orderBy('userTransaction.balanceDate', order)
.limit(limit)
.offset(offset)
.getManyAndCount()
}
return this.createQueryBuilder('userTransaction')
const query = this.createQueryBuilder('userTransaction')
.leftJoinAndSelect(
'userTransaction.contribution',
'contribution',
'userTransaction.id = contribution.transactionId',
)
.where('userTransaction.userId = :userId', { userId })
if (onlyCreation) {
query.andWhere('userTransaction.typeId = :typeId', {
typeId: TransactionTypeId.CREATION,
})
}
return query
.orderBy('userTransaction.balanceDate', order)
.limit(limit)
.offset(offset)
.getManyAndCount()
}
findLastForUser(userId: number): Promise<Transaction | undefined> {
return this.createQueryBuilder('userTransaction')
.where('userTransaction.userId = :userId', { userId })
.orderBy('userTransaction.balanceDate', 'DESC')
.getOne()
}
}

16
backend/src/util/time.ts Normal file
View File

@ -0,0 +1,16 @@
export const getTimeDurationObject = (time: number): { hours?: number; minutes: number } => {
if (time > 60) {
return {
hours: Math.floor(time / 60),
minutes: time % 60,
}
}
return { minutes: time }
}
export const printTimeDuration = (duration: number): string => {
const time = getTimeDurationObject(duration)
const result = time.minutes > 0 ? `${time.minutes} minutes` : ''
if (time.hours) return `${time.hours} hours` + (result !== '' ? ` and ${result}` : '')
return result
}

View File

@ -1,5 +1,16 @@
import Decimal from 'decimal.js-light'
import i18n from 'i18n'
export const objectValuesToArray = (obj: { [x: string]: string }): Array<string> => {
return Object.keys(obj).map(function (key) {
return obj[key]
})
}
export const decimalSeparatorByLanguage = (a: Decimal, language: string): string => {
const rememberLocaleToRestore = i18n.getLocale()
i18n.setLocale(language)
const result = a.toFixed(2).replace('.', i18n.__('general.decimalSeparator'))
i18n.setLocale(rememberLocaleToRestore)
return result
}

View File

@ -49,6 +49,7 @@ const virtualLinkTransaction = (
decay: decay.toDecimalPlaces(2, Decimal.ROUND_FLOOR),
memo: '',
creationDate: null,
contribution: null,
...defaultModelFunctions,
}
return new Transaction(linkDbTransaction, user)
@ -78,6 +79,7 @@ const virtualDecayTransaction = (
decayStart: decay.start,
memo: '',
creationDate: null,
contribution: null,
...defaultModelFunctions,
}
return new Transaction(decayDbTransaction, user)

View File

@ -5,6 +5,7 @@ import { createTestClient } from 'apollo-server-testing'
import createServer from '../src/server/createServer'
import { initialize } from '@dbTools/helpers'
import { entities } from '@entity/index'
import { i18n, logger } from './testSetup'
export const headerPushMock = jest.fn((t) => {
context.token = t.value
@ -26,8 +27,8 @@ export const cleanDB = async () => {
}
}
export const testEnvironment = async (logger?: any, localization?: any) => {
const server = await createServer(context, logger, localization)
export const testEnvironment = async (testLogger: any = logger, testI18n: any = i18n) => {
const server = await createServer(context, testLogger, testI18n)
const con = server.con
const testClient = createTestClient(server.apollo)
const mutate = testClient.mutate

View File

@ -1,6 +1,10 @@
import CONFIG from '@/config'
import { backendLogger as logger } from '@/server/logger'
import { i18n } from '@/server/localization'
CONFIG.EMAIL = true
CONFIG.EMAIL_TEST_MODUS = false
jest.setTimeout(1000000)
jest.mock('@/server/logger', () => {

View File

@ -1,6 +1,7 @@
import Decimal from 'decimal.js-light'
import { BaseEntity, Entity, PrimaryGeneratedColumn, Column } from 'typeorm'
import { BaseEntity, Entity, PrimaryGeneratedColumn, Column, OneToOne, JoinColumn } from 'typeorm'
import { DecimalTransformer } from '../../src/typeorm/DecimalTransformer'
import { Contribution } from '../Contribution'
@Entity('transactions')
export class Transaction extends BaseEntity {
@ -91,4 +92,8 @@ export class Transaction extends BaseEntity {
default: null,
})
transactionLinkId?: number | null
@OneToOne(() => Contribution, (contribution) => contribution.transaction)
@JoinColumn({ name: 'id', referencedColumnName: 'transactionId' })
contribution?: Contribution | null
}

View File

@ -8,10 +8,12 @@ import {
JoinColumn,
ManyToOne,
OneToMany,
OneToOne,
} from 'typeorm'
import { DecimalTransformer } from '../../src/typeorm/DecimalTransformer'
import { User } from '../User'
import { ContributionMessage } from '../ContributionMessage'
import { Transaction } from '../Transaction'
@Entity('contributions')
export class Contribution extends BaseEntity {
@ -92,4 +94,8 @@ export class Contribution extends BaseEntity {
@OneToMany(() => ContributionMessage, (message) => message.contribution)
@JoinColumn({ name: 'contribution_id' })
messages?: ContributionMessage[]
@OneToOne(() => Transaction, (transaction) => transaction.contribution)
@JoinColumn({ name: 'transaction_id' })
transaction?: Transaction | null
}

View File

@ -0,0 +1,16 @@
/* MIGRATION TO soft delete user contacts of soft deleted users */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
/* eslint-disable @typescript-eslint/no-explicit-any */
export async function upgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn(`
UPDATE user_contacts LEFT JOIN users ON users.email_id = user_contacts.id
SET user_contacts.deleted_at = users.deleted_at
WHERE user_contacts.deleted_at IS NULL
AND users.deleted_at IS NOT NULL;`)
}
/* eslint-disable @typescript-eslint/no-empty-function */
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {}

View File

@ -108,7 +108,7 @@ services:
#env_file:
# - ./frontend/.env
volumes:
# <host_machine_directy>:<container_directory> mirror bidirectional path in local context with path in Docker container
# <host_machine_directory>:<container_directory> mirror bidirectional path in local context with path in Docker container
- ./logs/backend:/logs/backend
########################################################

View File

@ -0,0 +1,379 @@
# Zeitzonen
Die Gradido-Anwendung läuft im Backend in der Zeitzone UTC und im Frontend in der jeweiligen lokalen Zeitzone, in der der User sich anmeldet. Dadurch kann es zu zeitlichen Diskrepanzen kommen, die innerhalb der Anwendungslogik aufgelöst bzw. entsprechend behandelt werden müssen. In den folgenden Kapiteln werden die verschiedenen zeitlichen Konstellationen dargestellt und für die verschiedenen fachlichen Prozesse die daraus resultierenden Problemlösungen beschrieben.
![img](./image/ZeitzonenKonstellationen.png)
## Beispiel 1
Ein User meldet sich in einer Zeitzone t0 - 4 an. Das bedeutet der User liegt 4 Stunden gegenüber der Backend-Zeit zurück.
Konkret hat der User die Zeit 31.08.2022 21:00:00 auf dem Server ist aber die Zeit bei 01.09.2022 01:00:00
Für die Erstellung einer Contribution hat der User noch folgende Gültigkeitsmonate und Beträge zur Wahl:
Juni 2022: 500 GDD | Juli 2022: 200 GDD | August 2022: 1000 GDD
**aber das Backend liefert nur die Beträge, die eigentlich so korrekt wären!!!!!**
**Juli 2022: 200 GDD | August 2022: 1000 GDD | September 2022: 1000 GDD**
Er möchte für den Juni 2022 eine Contribution mit 500 GDD erfassen. **Wird ihm der Juni noch als Schöpfungsmonat angezeigt?**
Falls ja, dann wählt er dabei im FE im Kalender den 30.06.2022. Dann liefert das FE folgende Contribution-Daten an das Backend:
* Gültigkeitsdatum: 30.06.2022 00:00:00
* Memo: text
* Betrag: 500 GDD
* **Zeitzone: wird eine Zeitzone des User aus dem Context geliefert? Das fehlt: entweder über eine Zeit vom FE zum BE und ermitteln Offset im BE**
Im Backend wird dieses dann interpretiert und verarbeitet mit:
* **Belegung des Schöpfungsmonate-Arrays: [ 6, 7, 8] oder [7, 8, 9] da auf dem Server ja schon der 01.09.2022 ist?**
* Gültigkeitsdatum: **30.06.2022 00:00:00 oder 01.07.2022 04:00:00 ?**
* Memo: text
* Betrag 500 GDD
* created_at: 01.07.2022 04:00:00
**Frage: wird die Contribution dem Juni (6) oder dem Juli (7) zugeordnet?**
1. falls Juni zugeordnet kann die Contribution mit 500 GDD eingelöst werden
2. falls Juli zugeordnet muss die Contribution mit 500 GDD abgelehnt werden, da möglicher Schöpfungsbetrag überschritten
## Beispiel 2
Ein User meldet sich in einer Zeitzone t0 + 1 an. Das bedeutet der User liegt 1 Stunde gegenüber der Backend-Zeit voraus.
Konkret hat der User die Zeit 01.09.2022 00:20:00 auf dem Server ist aber die Zeit bei 31.08.2022 23:20:00
Für die Erstellung einer Contribution hat der User noch folgende Gültigkeitsmonate und Beträge zur Wahl:
Juli 2022: 200 GDD | August 2022: 1000 GDD | September 2022: 1000 GDD
**oder wird ihm**
**
Juni 2022: 500 GDD | Juli 2022: 200 GDD | August 2022: 1000 GDD**
**angezeigt, da auf dem BE noch der 31.08.2022 ist?**
Er möchte für den September 2022 eine Contribution mit 500 GDD erfassen und wählt dabei im FE im Kalender den 01.09.2022. Dann liefert das FE folgende Contribution-Daten an das Backend:
* Gültigkeitsdatum: 01.09.2022 00:00:00 (siehe Logauszüge der Fehleranalyse im Ticket #2179)
* Memo: text
* Betrag: 500 GDD
* **Zeitzone: wird eine Zeitzone des User aus dem Context geliefert?**
Im Backend wird dieses dann interpretiert und verarbeitet mit:
* Belegung des Schöpfungsmonate-Arrays: [ 6, 7, 8] **wie kann der User dann aber vorher September 2022 für die Schöpfung auswählen?**
* Gültigkeitsdatum: 01.09.2022 00:00:00
* Memo: text
* Betrag 500 GDD
* created_at: 31.08.2022 23:20:00
Es kommt zu einem **Fehler im Backend**, da im Schöpfungsmonate-Array kein September (9) vorhanden ist, da auf dem Server noch der 31.08.2022 und damit das Array nur die Monate Juni, Juli, August und nicht September beinhaltet.
## Erkenntnisse:
* die dem User angezeigten Schöpfungsmonate errechnen sich aus der lokalen User-Zeit und nicht aus der Backend-Zeit
* das Backend muss somit für Ermittlung der möglichen Schöpfungsmonate und deren noch freien Schöpfungssummen den UserTimeOffset berücksichten
* der gewählte Schöpfungsmonat muss 1:1 vom Frontend in das Backend übertragen werden
* es darf kein Mapping in die Backend-Zeit erfolgen
* sondern es muss der jeweilige UserTimeOffset mitgespeichert werden
* die Logik im BE muss den übertragenen bzw. ermittelten Offset der FE-Zeit entsprechend berücksichten und nicht die Backendzeit in der Logik anwenden
* im BE darf es kein einfaches now = new Date() geben
* im BE muss stattdessen ein userNow = new Date() + UserTimeOffset verwendet werden
* ein CreatedAt / UpdatedAt / DeletedAt / ConfirmedAt wird wie bisher in BE-Zeit gespeichert
* **NEIN nicht notwendig:** plus in einer jeweils neuen Spalte CreatedOffset / UpdatedOffset / DeletedOffset / ConfirmedOffset der dabei gültige UserTimeOffset
* im FE wird immer im Request-Header der aktuelle Zeitpunkt mit Zeitzone geschrieben
*
## Entscheidung
* in den HTTP-Request-Header wird generell der aktuelle Timestamp des Clients eingetragen, sodass die aktuelle Uhrzeit des Users ohne weitere Signatur-Änderungen in jedem Aufruf am Backend ankommt. Moritz erstellt Ticket
* es wird eine Analyse aller Backend-Aufrufe gemacht, die die Auswertung der User-Time und dessen evtl. Timezone-Differenz in der Logik des Backend-Aufrufs benötigt.
* diese Backend-Methoden müssen fachlich so überarbeitet werden, dass immer aus dem Timezone-Offset die korrekte fachliche Logik als Ergebnis heraus kommt. In der Datenbank wird aber immer die UTC-Zeit gespeichert.
* Es werden keine zusätzlichen Datanbank-Attribute zur Speicherung des User-TimeOffsets benötigt.
## Analyse der Backend-Aufrufe
Es werden alle Resolver und ihre Methoden sowie im Resolver exportierte Attribute/Methoden untersucht.
Mit + gekennzeichnet sind diese, die mit dem UserTimeOffset interagieren und überarbeitet werden müssen.
Mit - gekennzeichnet sind diese, die keiner weiteren Aktion bedürfen.
### AdminResolver
#### + adminCreateContribution
Hier wird der User zur übergebenen Email inklusive der Summen über die letzten drei Schöpfungsmonate aus seinen vorhandenen Contributions, egal ob bestätigt oder noch offen ermittelt.
Hier muss der User-TimeOffset berücksichtigt werden, um die korrekten drei Schöpfungsmonate und dann daraus die korrekten Beträge der Contributions zu ermitteln.
Zusätzlich wird als Parameter ein *creationDate* vom User mitgeliefert, das dem User-TimeOffset unterliegt. Auch dieses muss entsprechend beachtet und beim internen Aufruf von *validateContribution()* und der Initialisierung der Contribution berücksichtigt werden.
#### - adminCreateContributionMessage
nothing to do
#### + adminCreateContributions
Hier wird eine Liste von übergebenen Contributions über den internen Aufruf von *adminCreateContribution()* verarbeitet. Da dort eine Berücksichtigung des User-TimeOffsets notwendig ist, muss hier die UserTime entsprechen im Context weitergereicht werden.
#### - adminDeleteContribution
nothing to do
#### + adminUpdateContribution
analog adminCreateContribution() muss hier der User-TimeOffset berücksichtigt werden.
#### + confirmContribution
Hier wird intern *getUserCreation()* und *validateContribution()* aufgerufen, daher analog adminCreateContribution()
#### + createContributionLink
Hier werden zwar ein *ValidFrom* und ein *ValidTo* Datum übergeben, doch dürften diese keiner Beachtung eines User-TimezoneOffsets unterliegen. Trotzdem bitte noch einmal verifizieren.
#### - creationTransactionList
nothing to do
#### - deleteContributionLink
Es wird zwar der *deletedAt*-Zeitpunkt als Rückgabewert geliefert, doch m.E. dürft hier keine Berücksichtigung des User-TimezoneOffsets notwendig sein.
#### - deleteUser
Es wird zwar der *deletedAt*-Zeitpunkt als Rückgabewert geliefert, doch m.E. dürft hier keine Berücksichtigung des User-TimezoneOffsets notwendig sein.
#### - listContributionLinks
nothing to do
#### + listTransactionLinksAdmin
Hier wird die BE-Zeit für die Suche nach ValidUntil verwendet. Dies sollte nocheinmal verifiziert werden.
#### + listUnconfirmedContributions
Hier wird intern *getUserCreations()* aufgerufen für die Summen der drei Schöpfungsmonate, somit ist der User-TimezoneOffset zu berücksichtigen.
#### + searchUsers
Hier wird intern *getUserCreations()* aufgerufen für die Summen der drei Schöpfungsmonate, somit ist der User-TimezoneOffset zu berücksichtigen.
#### - sendActivationEmail
analog *UserResolver.checkOptInCode*
#### - setUserRole
nothing to do
#### - unDeleteUser
nothing to do
#### + updateContributionLink
Hier werden zwar ein *ValidFrom* und ein *ValidTo* Datum übergeben, doch dürften diese keiner Beachtung eines User-TimezoneOffsets unterliegen. Trotzdem bitte noch einmal verifizieren.
### BalanceResolver
#### + balance
Hier wird der aktuelle Zeitpunkt des BE verwendet, um den Decay und die Summen der Kontostände zu ermitteln. Dies müsste eigentlich von dem User-TimezoneOffset unabhängig sein. Sollte aber noch einmal dahingehend verifiziert werden.
### CommunityResolver
#### - communities
nothing to do
#### - getCommunityInfo
nothing to do
### ContributionMessageResolver
#### - createContributionMessage
nothing to do
#### - listContributionMessages
nothing to do
### ContributionResolver
#### + createContribution
Hier wird der User inklusive der Summen über die letzten drei Schöpfungsmonate aus seinen vorhandenen Contributions, egal ob bestätigt oder noch offen ermittelt.
Hier muss der User-TimeOffset berücksichtigt werden, um die korrekten drei Schöpfungsmonate und dann daraus die korrekten Beträge der Contributions zu ermitteln.
Zusätzlich wird als Parameter ein *creationDate* vom User mitgeliefert, das dem User-TimeOffset unterliegt. Auch dieses muss entsprechend beachtet und beim internen Aufruf von *validateContribution()* und der Initialisierung der Contribution berücksichtigt werden.
#### - deleteContribution
nothing to do
#### - listAllContributions
nothing to do
#### - listContributions
nothing to do
#### + updateContribution
Hier werden die Contributions des Users inklusive der Summen über die letzten drei Schöpfungsmonate aus seinen vorhandenen Contributions, egal ob bestätigt oder noch offen ermittelt.
Hier muss der User-TimeOffset berücksichtigt werden, um die korrekten drei Schöpfungsmonate und dann daraus die korrekten Beträge der Contributions zu ermitteln.
Zusätzlich wird als Parameter ein *creationDate* vom User mitgeliefert, das dem User-TimeOffset unterliegt. Auch dieses muss entsprechend beachtet und beim internen Aufruf von *validateContribution()* und dem Update der Contribution berücksichtigt werden.
### GdtResolver
#### - existPid
nothing to do
#### - gdtBalance
nothing to do
#### - listGDTEntries
nothing to do
### KlicktippResolver
nothing to do
### StatisticsResolver
#### + communityStatistics
Hier werden die Daten zum aktuellen BE-Zeitpunkt ermittelt und dem User angezeigt. Aber der User hat ggf. einen anderen TimeOffset. Daher die Frage, ob die Ermittlung der Statistik-Daten mit dem User-TimeOffset stattfinden muss.
### TransactionLinkResolver
#### - transactionLinkCode
nothing to do
#### - transactionLinkExpireDate
nothing to do
#### - createTransactionLink
nothing to do
#### - deleteTransactionLink
nothing to do
#### - listTransactionLinks
nothing to do
#### - queryTransactionLink
nothing to do
#### - redeemTransactionLink
nothing to do
### TransactionResolver
#### - executeTransaction
nothing to do
#### - sendCoins
nothing to do
#### + transactionList
Hier wird der aktuelle BE-Zeitpunkt verwendet, um die Summen der vorhandenen Transactions bis zu diesem Zeitpunkt zu ermitteln. Nach ersten Einschätzungen dürfte es hier nichts zu tun geben. Aber es sollte noch einmal geprüft werden.
### UserResolver
#### - activationLink
nothing to do
#### - checkOptInCode
Hier wird der übergebene OptIn-Code geprüft, ob schon wieder eine erneute Email gesendet werden kann. Die Zeiten werden auf reiner BE-Zeit verglichen, von daher gibt es hier nichts zu tun.
#### - createUser
nothing to do
#### - forgotPassword
In dieser Methode wird am Ende in der Methode *sendResetPasswordEmailMailer()* die Zeit berechnet, wie lange der OptIn-Code im Link gültig ist, default 1440 min oder 24 h.
Es ist keine User-TimeOffset zu berücksichten, da der OptInCode direkt als Parameter im Aufruf von queryOptIn verwendet und dann dort mit der BE-Time verglichen wird.
#### - hasElopage
nothing to do
#### - login
nothing to do
#### - logout
nothing to do
#### - queryOptIn
Hier wird der OptIn-Code aus der *sendResetPasswordEmailMailer()* als Parameter geliefert. Da dessen Gültigkeit zuvor in forgotPassword mit der BE-Zeit gesetzt wurde, benögt man hier keine Berücksichtigung des User-TimeOffsets.
#### - searchAdminUsers
nothing to do
#### - setPassword
nothing to do, analog *queryOptIn*
#### - printTimeDuration
nothing to do
#### - updateUserInfos
nothing to do
#### + verifyLogin
Hier wird der User inklusive der Summen über die letzten drei Schöpfungsmonate aus seinen vorhandenen Contribtutions, egal ob bestätigt oder noch offen ermittelt.
Hier muss der User-TimeOffset berücksichtigt werden, um die korrekten drei Schöpfungsmonate und dann daraus die korrekten Beträge der Contributions zu ermitteln.

View File

@ -0,0 +1,217 @@
<mxfile host="65bd71144e">
<diagram id="-PxXzgsMUT8aslXVdGG0" name="Seite-1">
<mxGraphModel dx="1022" dy="800" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="2336" pageHeight="1654" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="2" value="" style="endArrow=classic;html=1;strokeWidth=5;fillColor=#60a917;strokeColor=#2D7600;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="160" y="320" as="sourcePoint"/>
<mxPoint x="2160" y="320" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="3" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="1160" y="360" as="sourcePoint"/>
<mxPoint x="1160" y="320" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="4" value="&lt;font style=&quot;font-size: 24px&quot;&gt;&lt;b&gt;t&lt;/b&gt;&lt;/font&gt;&lt;font size=&quot;1&quot;&gt;&lt;b style=&quot;font-size: 13px&quot;&gt;0&lt;/b&gt;&lt;/font&gt;" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="1">
<mxGeometry x="1130" y="370" width="60" height="30" as="geometry"/>
</mxCell>
<mxCell id="5" value="Backend&lt;br style=&quot;font-size: 18px;&quot;&gt;Zeitzone UTC" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=18;fontStyle=1" vertex="1" parent="1">
<mxGeometry x="40" y="360" width="200" height="30" as="geometry"/>
</mxCell>
<mxCell id="6" value="Frontend&lt;br style=&quot;font-size: 18px;&quot;&gt;verschiedene Zeitzonen" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=18;fontStyle=1" vertex="1" parent="1">
<mxGeometry x="40" y="240" width="240" height="30" as="geometry"/>
</mxCell>
<mxCell id="7" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="1160" y="320" as="sourcePoint"/>
<mxPoint x="1160" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="8" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="1080" y="320" as="sourcePoint"/>
<mxPoint x="1080" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="9" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="1000" y="320" as="sourcePoint"/>
<mxPoint x="1000" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="10" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="920" y="320" as="sourcePoint"/>
<mxPoint x="920" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="11" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="840" y="320" as="sourcePoint"/>
<mxPoint x="840" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="12" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="760" y="320" as="sourcePoint"/>
<mxPoint x="760" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="13" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="680" y="320" as="sourcePoint"/>
<mxPoint x="680" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="14" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="600" y="320" as="sourcePoint"/>
<mxPoint x="600" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="15" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="520" y="320" as="sourcePoint"/>
<mxPoint x="520" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="16" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="440" y="320" as="sourcePoint"/>
<mxPoint x="440" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="17" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="360" y="320" as="sourcePoint"/>
<mxPoint x="360" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="18" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="279.75" y="320" as="sourcePoint"/>
<mxPoint x="279.75" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="19" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="200" y="320" as="sourcePoint"/>
<mxPoint x="200" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="20" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="2120" y="320" as="sourcePoint"/>
<mxPoint x="2120" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="21" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="2040" y="320" as="sourcePoint"/>
<mxPoint x="2040" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="22" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="1960" y="320" as="sourcePoint"/>
<mxPoint x="1960" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="23" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="1880" y="320" as="sourcePoint"/>
<mxPoint x="1880" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="24" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="1800" y="320" as="sourcePoint"/>
<mxPoint x="1800" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="25" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="1720" y="320" as="sourcePoint"/>
<mxPoint x="1720" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="26" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="1640" y="320" as="sourcePoint"/>
<mxPoint x="1640" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="27" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="1560" y="320" as="sourcePoint"/>
<mxPoint x="1560" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="28" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="1480" y="320" as="sourcePoint"/>
<mxPoint x="1480" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="29" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="1400" y="320" as="sourcePoint"/>
<mxPoint x="1400" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="30" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="1319.75" y="320" as="sourcePoint"/>
<mxPoint x="1319.75" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="31" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="1240" y="320" as="sourcePoint"/>
<mxPoint x="1240" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="32" value="&lt;font style=&quot;font-size: 24px&quot;&gt;&lt;b&gt;t&lt;/b&gt;&lt;/font&gt;&lt;font size=&quot;1&quot;&gt;&lt;b style=&quot;font-size: 13px&quot;&gt;0&lt;/b&gt;&lt;/font&gt;" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="1">
<mxGeometry x="1130" y="240" width="60" height="30" as="geometry"/>
</mxCell>
<mxCell id="33" value="&lt;font style=&quot;font-size: 24px&quot;&gt;&lt;b&gt;t&lt;/b&gt;&lt;/font&gt;&lt;font size=&quot;1&quot;&gt;&lt;b style=&quot;font-size: 13px&quot;&gt;0&lt;/b&gt;&lt;b style=&quot;font-size: 20px&quot;&gt; - 2&lt;/b&gt;&lt;/font&gt;" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="1">
<mxGeometry x="970" y="240" width="60" height="30" as="geometry"/>
</mxCell>
<mxCell id="36" value="&lt;font style=&quot;font-size: 24px&quot;&gt;&lt;b&gt;t&lt;/b&gt;&lt;/font&gt;&lt;font size=&quot;1&quot;&gt;&lt;b style=&quot;font-size: 13px&quot;&gt;0&lt;/b&gt;&lt;b style=&quot;font-size: 20px&quot;&gt; - 4&lt;/b&gt;&lt;/font&gt;" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="1">
<mxGeometry x="810" y="240" width="60" height="30" as="geometry"/>
</mxCell>
<mxCell id="38" value="&lt;font style=&quot;font-size: 24px&quot;&gt;&lt;b&gt;t&lt;/b&gt;&lt;/font&gt;&lt;font size=&quot;1&quot;&gt;&lt;b style=&quot;font-size: 13px&quot;&gt;0&lt;/b&gt;&lt;b style=&quot;font-size: 20px&quot;&gt; - 6&lt;/b&gt;&lt;/font&gt;" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="1">
<mxGeometry x="650" y="240" width="60" height="30" as="geometry"/>
</mxCell>
<mxCell id="40" value="&lt;font style=&quot;font-size: 24px&quot;&gt;&lt;b&gt;t&lt;/b&gt;&lt;/font&gt;&lt;font size=&quot;1&quot;&gt;&lt;b style=&quot;font-size: 13px&quot;&gt;0&lt;/b&gt;&lt;b style=&quot;font-size: 20px&quot;&gt; - 8&lt;/b&gt;&lt;/font&gt;" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="1">
<mxGeometry x="490" y="240" width="60" height="30" as="geometry"/>
</mxCell>
<mxCell id="42" value="&lt;font style=&quot;font-size: 24px&quot;&gt;&lt;b&gt;t&lt;/b&gt;&lt;/font&gt;&lt;font size=&quot;1&quot;&gt;&lt;b style=&quot;font-size: 13px&quot;&gt;0&lt;/b&gt;&lt;b style=&quot;font-size: 20px&quot;&gt; - 10&lt;/b&gt;&lt;/font&gt;" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="1">
<mxGeometry x="330" y="240" width="60" height="30" as="geometry"/>
</mxCell>
<mxCell id="43" value="&lt;font style=&quot;font-size: 24px&quot;&gt;&lt;b&gt;t&lt;/b&gt;&lt;/font&gt;&lt;font size=&quot;1&quot;&gt;&lt;b style=&quot;font-size: 13px&quot;&gt;0&lt;/b&gt;&lt;b style=&quot;font-size: 20px&quot;&gt;&amp;nbsp;+ 10&lt;/b&gt;&lt;/font&gt;" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="1">
<mxGeometry x="1930" y="240" width="70" height="30" as="geometry"/>
</mxCell>
<mxCell id="44" value="&lt;font style=&quot;font-size: 24px&quot;&gt;&lt;b&gt;t&lt;/b&gt;&lt;/font&gt;&lt;font size=&quot;1&quot;&gt;&lt;b style=&quot;font-size: 13px&quot;&gt;0&lt;/b&gt;&lt;b style=&quot;font-size: 20px&quot;&gt;&amp;nbsp;+ 8&lt;/b&gt;&lt;/font&gt;" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="1">
<mxGeometry x="1770" y="240" width="60" height="30" as="geometry"/>
</mxCell>
<mxCell id="45" value="&lt;font style=&quot;font-size: 24px&quot;&gt;&lt;b&gt;t&lt;/b&gt;&lt;/font&gt;&lt;font size=&quot;1&quot;&gt;&lt;b style=&quot;font-size: 13px&quot;&gt;0&lt;/b&gt;&lt;b style=&quot;font-size: 20px&quot;&gt;&amp;nbsp;+ 6&lt;/b&gt;&lt;/font&gt;" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="1">
<mxGeometry x="1610" y="240" width="60" height="30" as="geometry"/>
</mxCell>
<mxCell id="46" value="&lt;font style=&quot;font-size: 24px&quot;&gt;&lt;b&gt;t&lt;/b&gt;&lt;/font&gt;&lt;font size=&quot;1&quot;&gt;&lt;b style=&quot;font-size: 13px&quot;&gt;0&lt;/b&gt;&lt;b style=&quot;font-size: 20px&quot;&gt;&amp;nbsp;+ 4&lt;/b&gt;&lt;/font&gt;" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="1">
<mxGeometry x="1450" y="240" width="60" height="30" as="geometry"/>
</mxCell>
<mxCell id="47" value="&lt;font style=&quot;font-size: 24px&quot;&gt;&lt;b&gt;t&lt;/b&gt;&lt;/font&gt;&lt;font size=&quot;1&quot;&gt;&lt;b style=&quot;font-size: 13px&quot;&gt;0&lt;/b&gt;&lt;b style=&quot;font-size: 20px&quot;&gt;&amp;nbsp;+ 2&lt;/b&gt;&lt;/font&gt;" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="1">
<mxGeometry x="1290" y="240" width="60" height="30" as="geometry"/>
</mxCell>
<mxCell id="89" value="mögliche Zeitzonen-Konstellationen" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=22;fontStyle=1" vertex="1" parent="1">
<mxGeometry x="40" y="130" width="400" height="30" as="geometry"/>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@ -38,10 +38,12 @@ export default {
form: {
text: '',
},
isSubmitting: false,
}
},
methods: {
onSubmit() {
this.isSubmitting = true
this.$apollo
.mutate({
mutation: createContributionMessage,
@ -55,9 +57,11 @@ export default {
this.$emit('update-state', this.contributionId)
this.form.text = ''
this.toastSuccess(this.$t('message.reply'))
this.isSubmitting = false
})
.catch((error) => {
this.toastError(error.message)
this.isSubmitting = false
})
},
onReset() {
@ -66,10 +70,7 @@ export default {
},
computed: {
disabled() {
if (this.form.text !== '') {
return false
}
return true
return this.form.text === '' || this.isSubmitting
},
},
}

View File

@ -10,21 +10,21 @@
</b-col>
<b-col cols="7">
<div class="gdd-transaction-list-item-name">
<div v-if="linkedUser && linkedUser.email">
<span v-if="linkedUser && linkedUser.email">
<b-link @click.stop="tunnelEmail">
{{ itemText }}
</b-link>
<span v-if="transactionLinkId">
{{ $t('via_link') }}
<b-icon
icon="link45deg"
variant="muted"
class="m-mb-1"
:title="$t('gdd_per_link.redeemed-title')"
/>
</span>
</div>
</span>
<span v-else>{{ itemText }}</span>
<span v-if="linkId">
{{ $t('via_link') }}
<b-icon
icon="link45deg"
variant="muted"
class="m-mb-1"
:title="$t('gdd_per_link.redeemed-title')"
/>
</span>
</div>
</b-col>
</b-row>
@ -46,7 +46,7 @@ export default {
type: String,
required: false,
},
transactionLinkId: {
linkId: {
type: Number,
required: false,
default: null,

View File

@ -12,7 +12,12 @@
<b-col cols="11">
<!-- Amount / Name || Text -->
<amount-and-name-row :amount="amount" :linkedUser="linkedUser" v-on="$listeners" />
<amount-and-name-row
:amount="amount"
:linkedUser="linkedUser"
v-on="$listeners"
:linkId="linkId"
/>
<!-- Nachricht Memo -->
<memo-row :memo="memo" />
@ -77,6 +82,10 @@ export default {
type: String,
required: true,
},
linkId: {
type: Number,
required: false,
},
previousBookedBalance: {
type: String,
required: true,

View File

@ -17,7 +17,7 @@
v-on="$listeners"
:amount="amount"
:linkedUser="linkedUser"
:transactionLinkId="transactionLinkId"
:linkId="linkId"
/>
<!-- Nachricht Memo -->
@ -82,7 +82,7 @@ export default {
typeId: {
type: String,
},
transactionLinkId: {
linkId: {
type: Number,
required: false,
},

View File

@ -17,7 +17,7 @@
v-on="$listeners"
:amount="amount"
:linkedUser="linkedUser"
:transactionLinkId="transactionLinkId"
:linkId="linkId"
/>
<!-- Memo -->
@ -83,7 +83,7 @@ export default {
type: String,
required: true,
},
transactionLinkId: {
linkId: {
type: Number,
required: false,
},

View File

@ -45,7 +45,7 @@ export const transactionsQuery = gql`
end
duration
}
transactionLinkId
linkId
}
}
}