From 8caf482943ad3cdc9fa30a82ddda339597afb5f1 Mon Sep 17 00:00:00 2001 From: elweyn Date: Thu, 10 Nov 2022 10:52:36 +0100 Subject: [PATCH 01/90] Store transactionLinkId to the transaction --- backend/src/graphql/resolver/TransactionLinkResolver.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/graphql/resolver/TransactionLinkResolver.ts b/backend/src/graphql/resolver/TransactionLinkResolver.ts index 74c531c54..a3156497a 100644 --- a/backend/src/graphql/resolver/TransactionLinkResolver.ts +++ b/backend/src/graphql/resolver/TransactionLinkResolver.ts @@ -300,6 +300,7 @@ export class TransactionLinkResolver { transaction.balanceDate = now transaction.decay = decay ? decay.decay : new Decimal(0) transaction.decayStart = decay ? decay.start : null + transaction.transactionLinkId = contributionLink.id await queryRunner.manager.insert(DbTransaction, transaction) contribution.confirmedAt = now From 69af52f21a0c4a3b1ed0e2201f55be63145efacd Mon Sep 17 00:00:00 2001 From: elweyn Date: Thu, 10 Nov 2022 10:53:08 +0100 Subject: [PATCH 02/90] Show via link for creations. --- .../TransactionRows/AmountAndNameRow.vue | 22 +++++++++---------- .../Transactions/TransactionCreation.vue | 11 +++++++++- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/TransactionRows/AmountAndNameRow.vue b/frontend/src/components/TransactionRows/AmountAndNameRow.vue index 322ad7dfa..96a31dcf3 100644 --- a/frontend/src/components/TransactionRows/AmountAndNameRow.vue +++ b/frontend/src/components/TransactionRows/AmountAndNameRow.vue @@ -10,21 +10,21 @@
-
+ {{ itemText }} - - {{ $t('via_link') }} - - -
+ {{ itemText }} + + {{ $t('via_link') }} + +
diff --git a/frontend/src/components/Transactions/TransactionCreation.vue b/frontend/src/components/Transactions/TransactionCreation.vue index 694d907ed..a832b9b1a 100644 --- a/frontend/src/components/Transactions/TransactionCreation.vue +++ b/frontend/src/components/Transactions/TransactionCreation.vue @@ -12,7 +12,12 @@ - + @@ -77,6 +82,10 @@ export default { type: String, required: true, }, + transactionLinkId: { + type: Number, + required: false, + }, previousBookedBalance: { type: String, required: true, From 369752783cc07685a9e73bbc710d86ee9a1ce14e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Thu, 17 Nov 2022 13:46:59 +0100 Subject: [PATCH 03/90] Translate and HTML'lize email 'sendAccountActivation' --- .../emails/accountMultiRegistration/html.pug | 8 +++---- .../src/emails/sendAccountActivation/html.pug | 20 ++++++++++++++++ .../emails/sendAccountActivation/subject.pug | 1 + backend/src/emails/sendEmailTranslated.ts | 4 ++-- backend/src/emails/sendEmailVariants.ts | 24 ++++++++++++++++++- backend/src/graphql/resolver/UserResolver.ts | 11 +++++---- backend/src/locales/de.json | 13 ++++++++-- backend/src/locales/en.json | 21 +++++++++++----- 8 files changed, 83 insertions(+), 19 deletions(-) create mode 100644 backend/src/emails/sendAccountActivation/html.pug create mode 100644 backend/src/emails/sendAccountActivation/subject.pug diff --git a/backend/src/emails/accountMultiRegistration/html.pug b/backend/src/emails/accountMultiRegistration/html.pug index e285c940b..b3764403b 100644 --- a/backend/src/emails/accountMultiRegistration/html.pug +++ b/backend/src/emails/accountMultiRegistration/html.pug @@ -1,11 +1,11 @@ doctype html -html(lang="en") +html(lang=locale) head title= t('emails.accountMultiRegistration.subject') 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="en") 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') diff --git a/backend/src/emails/sendAccountActivation/html.pug b/backend/src/emails/sendAccountActivation/html.pug new file mode 100644 index 000000000..18bce36b5 --- /dev/null +++ b/backend/src/emails/sendAccountActivation/html.pug @@ -0,0 +1,20 @@ +doctype html +html(lang=locale) + head + title= t('emails.sendAccountActivation.subject') + body + h1(style='margin-bottom: 24px;')= t('emails.sendAccountActivation.subject') + #container.col + p(style='margin-bottom: 24px;')= t('emails.general.helloName', { firstName, lastName }) + p= t('emails.sendAccountActivation.emailRegistered') + p= t('emails.sendAccountActivation.pleaseClickLink') + br + a(href=activationLink) #{activationLink} + br + span= t('emails.sendAccountActivation.orCopyLink') + p= t('emails.sendAccountActivation.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') diff --git a/backend/src/emails/sendAccountActivation/subject.pug b/backend/src/emails/sendAccountActivation/subject.pug new file mode 100644 index 000000000..09586310f --- /dev/null +++ b/backend/src/emails/sendAccountActivation/subject.pug @@ -0,0 +1 @@ += t('emails.sendAccountActivation.subject') \ No newline at end of file diff --git a/backend/src/emails/sendEmailTranslated.ts b/backend/src/emails/sendEmailTranslated.ts index 3fe4177f4..fc1161b8a 100644 --- a/backend/src/emails/sendEmailTranslated.ts +++ b/backend/src/emails/sendEmailTranslated.ts @@ -12,7 +12,7 @@ export const sendEmailTranslated = async (params: { cc?: string } template: string - locals: Record + locals: Record }): Promise | null> => { let resultSend: Record | null = null @@ -50,7 +50,7 @@ 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({ diff --git a/backend/src/emails/sendEmailVariants.ts b/backend/src/emails/sendEmailVariants.ts index fb142f206..3ee749354 100644 --- a/backend/src/emails/sendEmailVariants.ts +++ b/backend/src/emails/sendEmailVariants.ts @@ -11,9 +11,31 @@ 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 sendAccountActivationEmail = (data: { + firstName: string + lastName: string + email: string + language: string + activationLink: string + timeDurationObject: Record +}): Promise | null> => { + return sendEmailTranslated({ + receiver: { to: `${data.firstName} ${data.lastName} <${data.email}>` }, + template: 'sendAccountActivation', + locals: { + firstName: data.firstName, + lastName: data.lastName, + locale: data.language, + activationLink: data.activationLink, + timeDurationObject: data.timeDurationObject, resendLink: CONFIG.EMAIL_LINK_FORGOTPASSWORD, }, }) diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index 81d0bab0f..d9e7cefa7 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -18,8 +18,10 @@ 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, +} from '@/emails/sendEmailVariants' import { klicktippSignIn } from '@/apis/KlicktippController' import { RIGHTS } from '@/auth/RIGHTS' import { hasElopageBuys } from '@/util/hasElopageBuys' @@ -543,11 +545,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 diff --git a/backend/src/locales/de.json b/backend/src/locales/de.json index 6c270f148..2b97d19f0 100644 --- a/backend/src/locales/de.json +++ b/backend/src/locales/de.json @@ -3,13 +3,22 @@ "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}", "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.", + "subject": "Gradido: Erneuter Registrierungsversuch mit deiner E-Mail" + }, + "general": { + "helloName": "Hallo {firstName} {lastName}", "sincerelyYours": "Mit freundlichen Grüßen,", - "subject": "Gradido: Erneuter Registrierungsversuch mit deiner E-Mail", "yourGradidoTeam": "dein Gradido-Team" + }, + "sendAccountActivation": { + "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:", + "orCopyLink": "oder kopiere den obigen Link in dein Browserfenster.", + "subject": "Gradido: E-Mail Überprüfung" } } } \ No newline at end of file diff --git a/backend/src/locales/en.json b/backend/src/locales/en.json index 7655aae6a..e9f43e416 100644 --- a/backend/src/locales/en.json +++ b/backend/src/locales/en.json @@ -1,15 +1,24 @@ { - "emails": { - "accountMultiRegistration": { + "emails": { + "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.", + "subject": "Gradido: Try To Register Again With Your Email" + }, + "general": { + "helloName": "Hello {firstName} {lastName}", "sincerelyYours": "Sincerely yours,", - "subject": "Gradido: Try To Register Again With Your Email", "yourGradidoTeam": "your Gradido team" - } - } + }, + "sendAccountActivation": { + "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:", + "orCopyLink": "or copy the link above into your browser window.", + "subject": "Gradido: Email Verification" + } + } } \ No newline at end of file From 0fdb3e4401686f52128ecb07209e0c1ee83f4a2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Thu, 17 Nov 2022 15:27:07 +0100 Subject: [PATCH 04/90] Test 'sendAccountActivationEmail' --- backend/src/emails/sendEmailVariants.test.ts | 71 +++++++++++++++++++- backend/src/emails/sendEmailVariants.ts | 36 +++++----- 2 files changed, 88 insertions(+), 19 deletions(-) diff --git a/backend/src/emails/sendEmailVariants.test.ts b/backend/src/emails/sendEmailVariants.test.ts index 4ac8221a7..017efc270 100644 --- a/backend/src/emails/sendEmailVariants.test.ts +++ b/backend/src/emails/sendEmailVariants.test.ts @@ -1,5 +1,5 @@ import CONFIG from '@/config' -import { sendAccountMultiRegistrationEmail } from './sendEmailVariants' +import { sendAccountActivationEmail, sendAccountMultiRegistrationEmail } from './sendEmailVariants' import { sendEmailTranslated } from './sendEmailTranslated' CONFIG.EMAIL = true @@ -19,6 +19,75 @@ jest.mock('./sendEmailTranslated', () => { describe('sendEmailVariants', () => { let result: Record | null + describe('sendAccountActivationEmail', () => { + beforeAll(async () => { + result = await sendAccountActivationEmail({ + firstName: 'Peter', + lastName: 'Lustig', + email: 'peter@lustig.de', + language: 'en', + activationLink: 'http://localhost/checkEmail/6627633878930542284', + timeDurationObject: { hours: 24, minutes: 0 }, + }) + }) + + describe('calls "sendEmailTranslated"', () => { + it('with expected parameters', () => { + expect(sendEmailTranslated).toBeCalledWith({ + receiver: { + to: 'Peter Lustig ', + }, + template: 'sendAccountActivation', + locals: { + firstName: 'Peter', + lastName: 'Lustig', + locale: 'en', + activationLink: 'http://localhost/checkEmail/6627633878930542284', + timeDurationObject: { hours: 24, minutes: 0 }, + 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 ', + from: 'Gradido (nicht antworten) ', + attachments: [], + subject: 'Gradido: Email Verification', + html: + expect.stringContaining('Gradido: Email Verification') && + expect.stringContaining('>Gradido: Email Verification') && + expect.stringContaining( + 'Your email address has just been registered with Gradido.', + ) && + expect.stringContaining( + 'Please click on this link to complete the registration and activate your Gradido account:', + ) && + expect.stringContaining( + 'http://localhost/checkEmail/6627633878930542284', + ) && + expect.stringContaining('or copy the link above into your browser window.') && + expect.stringContaining( + '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:', + ) && + expect.stringContaining( + `${CONFIG.EMAIL_LINK_FORGOTPASSWORD}`, + ) && + expect.stringContaining('Sincerely yours,
your Gradido team'), + text: expect.stringContaining('GRADIDO: EMAIL VERIFICATION'), + }), + }) + }) + }) + }) + describe('sendAccountMultiRegistrationEmail', () => { beforeAll(async () => { result = await sendAccountMultiRegistrationEmail({ diff --git a/backend/src/emails/sendEmailVariants.ts b/backend/src/emails/sendEmailVariants.ts index 3ee749354..c507f6ace 100644 --- a/backend/src/emails/sendEmailVariants.ts +++ b/backend/src/emails/sendEmailVariants.ts @@ -1,24 +1,6 @@ import CONFIG from '@/config' import { sendEmailTranslated } from './sendEmailTranslated' -export const sendAccountMultiRegistrationEmail = (data: { - firstName: string - lastName: string - email: string - language: string -}): Promise | null> => { - return sendEmailTranslated({ - receiver: { to: `${data.firstName} ${data.lastName} <${data.email}>` }, - template: 'accountMultiRegistration', - locals: { - firstName: data.firstName, - lastName: data.lastName, - locale: data.language, - resendLink: CONFIG.EMAIL_LINK_FORGOTPASSWORD, - }, - }) -} - export const sendAccountActivationEmail = (data: { firstName: string lastName: string @@ -40,3 +22,21 @@ export const sendAccountActivationEmail = (data: { }, }) } + +export const sendAccountMultiRegistrationEmail = (data: { + firstName: string + lastName: string + email: string + language: string +}): Promise | null> => { + return sendEmailTranslated({ + receiver: { to: `${data.firstName} ${data.lastName} <${data.email}>` }, + template: 'accountMultiRegistration', + locals: { + firstName: data.firstName, + lastName: data.lastName, + locale: data.language, + resendLink: CONFIG.EMAIL_LINK_FORGOTPASSWORD, + }, + }) +} From 560aed056aa97de953e38e2f8d0d6f0063a5a51b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Mon, 21 Nov 2022 12:20:51 +0100 Subject: [PATCH 05/90] Fix spelling and punctuation in German email translations --- backend/src/locales/de.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/src/locales/de.json b/backend/src/locales/de.json index 2b97d19f0..78d9d50b3 100644 --- a/backend/src/locales/de.json +++ b/backend/src/locales/de.json @@ -2,20 +2,20 @@ "emails": { "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.", + "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.", "subject": "Gradido: Erneuter Registrierungsversuch mit deiner E-Mail" }, "general": { - "helloName": "Hallo {firstName} {lastName}", + "helloName": "Hallo {firstName} {lastName},", "sincerelyYours": "Mit freundlichen Grüßen,", "yourGradidoTeam": "dein Gradido-Team" }, "sendAccountActivation": { "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.", + "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:", "orCopyLink": "oder kopiere den obigen Link in dein Browserfenster.", "subject": "Gradido: E-Mail Überprüfung" From b78229b646311ec991421261e4f7ea4763ac357a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Mon, 21 Nov 2022 12:22:25 +0100 Subject: [PATCH 06/90] Fix faulty email content tests --- backend/src/emails/sendEmailVariants.test.ts | 99 +++++++++++--------- 1 file changed, 54 insertions(+), 45 deletions(-) diff --git a/backend/src/emails/sendEmailVariants.test.ts b/backend/src/emails/sendEmailVariants.test.ts index 017efc270..698ba32d9 100644 --- a/backend/src/emails/sendEmailVariants.test.ts +++ b/backend/src/emails/sendEmailVariants.test.ts @@ -17,7 +17,8 @@ jest.mock('./sendEmailTranslated', () => { }) describe('sendEmailVariants', () => { - let result: Record | null + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let result: any describe('sendAccountActivationEmail', () => { beforeAll(async () => { @@ -27,7 +28,7 @@ describe('sendEmailVariants', () => { email: 'peter@lustig.de', language: 'en', activationLink: 'http://localhost/checkEmail/6627633878930542284', - timeDurationObject: { hours: 24, minutes: 0 }, + timeDurationObject: { hours: 23, minutes: 30 }, }) }) @@ -43,7 +44,7 @@ describe('sendEmailVariants', () => { lastName: 'Lustig', locale: 'en', activationLink: 'http://localhost/checkEmail/6627633878930542284', - timeDurationObject: { hours: 24, minutes: 0 }, + timeDurationObject: { hours: 23, minutes: 30 }, resendLink: CONFIG.EMAIL_LINK_FORGOTPASSWORD, }, }) @@ -61,29 +62,32 @@ describe('sendEmailVariants', () => { from: 'Gradido (nicht antworten) ', attachments: [], subject: 'Gradido: Email Verification', - html: - expect.stringContaining('Gradido: Email Verification') && - expect.stringContaining('>Gradido: Email Verification') && - expect.stringContaining( - 'Your email address has just been registered with Gradido.', - ) && - expect.stringContaining( - 'Please click on this link to complete the registration and activate your Gradido account:', - ) && - expect.stringContaining( - 'http://localhost/checkEmail/6627633878930542284', - ) && - expect.stringContaining('or copy the link above into your browser window.') && - expect.stringContaining( - '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:', - ) && - expect.stringContaining( - `${CONFIG.EMAIL_LINK_FORGOTPASSWORD}`, - ) && - expect.stringContaining('Sincerely yours,
your Gradido team'), + html: expect.any(String), text: expect.stringContaining('GRADIDO: EMAIL VERIFICATION'), }), }) + expect(result.originalMessage.html).toContain('Gradido: Email Verification') + expect(result.originalMessage.html).toContain('>Gradido: Email Verification') + 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( + 'http://localhost/checkEmail/6627633878930542284', + ) + 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( + `${CONFIG.EMAIL_LINK_FORGOTPASSWORD}`, + ) + expect(result.originalMessage.html).toContain('Sincerely yours,
your Gradido team') }) }) }) @@ -126,31 +130,36 @@ describe('sendEmailVariants', () => { from: 'Gradido (nicht antworten) ', attachments: [], subject: 'Gradido: Try To Register Again With Your Email', - html: - expect.stringContaining( - 'Gradido: Try To Register Again With Your Email', - ) && - expect.stringContaining('>Gradido: Try To Register Again With Your Email') && - 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( - `${CONFIG.EMAIL_LINK_FORGOTPASSWORD}`, - ) && - 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,
your Gradido team'), + html: expect.any(String), text: expect.stringContaining('GRADIDO: TRY TO REGISTER AGAIN WITH YOUR EMAIL'), }), }) + expect(result.originalMessage.html).toContain( + 'Gradido: Try To Register Again With Your Email', + ) + expect(result.originalMessage.html).toContain( + '>Gradido: Try To Register Again With Your Email', + ) + 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( + `${CONFIG.EMAIL_LINK_FORGOTPASSWORD}`, + ) + 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('Sincerely yours,
your Gradido team') }) }) }) From 18a4408e8aca1c9bbbe39fedbcfd447b2cb26d65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Mon, 21 Nov 2022 13:37:29 +0100 Subject: [PATCH 07/90] Adjust the tests for new translatable 'sendAccountActivationEmail' --- .../graphql/resolver/AdminResolver.test.ts | 5 ++-- backend/src/graphql/resolver/AdminResolver.ts | 9 ++++---- .../src/graphql/resolver/UserResolver.test.ts | 23 +++++++++---------- backend/src/graphql/resolver/UserResolver.ts | 2 +- 4 files changed, 20 insertions(+), 19 deletions(-) diff --git a/backend/src/graphql/resolver/AdminResolver.test.ts b/backend/src/graphql/resolver/AdminResolver.test.ts index 503bab472..ea1bb848e 100644 --- a/backend/src/graphql/resolver/AdminResolver.test.ts +++ b/backend/src/graphql/resolver/AdminResolver.test.ts @@ -36,7 +36,7 @@ import { 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 } from '@/emails/sendEmailVariants' import Decimal from 'decimal.js-light' import { Contribution } from '@entity/Contribution' import { Transaction as DbTransaction } from '@entity/Transaction' @@ -47,9 +47,10 @@ 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', () => { return { __esModule: true, + // TODO: test the call of … sendAccountActivationEmail: jest.fn(), } }) diff --git a/backend/src/graphql/resolver/AdminResolver.ts b/backend/src/graphql/resolver/AdminResolver.ts index 80c69a864..bd3ed20dc 100644 --- a/backend/src/graphql/resolver/AdminResolver.ts +++ b/backend/src/graphql/resolver/AdminResolver.ts @@ -39,8 +39,8 @@ 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 { findUserByEmail, activationLink, getTimeDurationObject } from './UserResolver' +import { sendAccountActivationEmail } from '@/emails/sendEmailVariants' import { transactionLinkCode as contributionLinkCode } from './TransactionLinkResolver' import CONFIG from '@/config' import { @@ -656,11 +656,12 @@ export class AdminResolver { // 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 diff --git a/backend/src/graphql/resolver/UserResolver.test.ts b/backend/src/graphql/resolver/UserResolver.test.ts index 6323abfde..fac4618bf 100644 --- a/backend/src/graphql/resolver/UserResolver.test.ts +++ b/backend/src/graphql/resolver/UserResolver.test.ts @@ -18,15 +18,16 @@ 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 { + sendAccountActivationEmail, + sendAccountMultiRegistrationEmail, +} from '@/emails/sendEmailVariants' import { sendResetPasswordEmail } from '@/mailer/sendResetPasswordEmail' import { printTimeDuration, activationLink } from './UserResolver' 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' @@ -39,16 +40,10 @@ import { bobBaumeister } from '@/seeds/users/bob-baumeister' // import { klicktippSignIn } from '@/apis/KlicktippController' -jest.mock('@/mailer/sendAccountActivationEmail', () => { - return { - __esModule: true, - sendAccountActivationEmail: jest.fn(), - } -}) - jest.mock('@/emails/sendEmailVariants', () => { return { __esModule: true, + sendAccountActivationEmail: jest.fn(), sendAccountMultiRegistrationEmail: jest.fn(), } }) @@ -180,11 +175,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), + }), }) }) diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index 62636ebb7..23cf9eaeb 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -953,7 +953,7 @@ const canEmailResend = (updatedAt: Date): boolean => { return !isTimeExpired(updatedAt, CONFIG.EMAIL_CODE_REQUEST_TIME) } -const getTimeDurationObject = (time: number): { hours?: number; minutes: number } => { +export const getTimeDurationObject = (time: number): { hours?: number; minutes: number } => { if (time > 60) { return { hours: Math.floor(time / 60), From b8fdb59d73582ac5231a9dd8fe7cf1fcfbeb3661 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Mon, 21 Nov 2022 13:38:18 +0100 Subject: [PATCH 08/90] Remove old email 'sendAccountActivationEmail' --- .../mailer/sendAccountActivationEmail.test.ts | 32 ------------------- .../src/mailer/sendAccountActivationEmail.ts | 17 ---------- backend/src/mailer/text/accountActivation.ts | 32 ------------------- .../mailer/text/accountMultiRegistration.ts | 25 --------------- 4 files changed, 106 deletions(-) delete mode 100644 backend/src/mailer/sendAccountActivationEmail.test.ts delete mode 100644 backend/src/mailer/sendAccountActivationEmail.ts delete mode 100644 backend/src/mailer/text/accountActivation.ts delete mode 100644 backend/src/mailer/text/accountMultiRegistration.ts diff --git a/backend/src/mailer/sendAccountActivationEmail.test.ts b/backend/src/mailer/sendAccountActivationEmail.test.ts deleted file mode 100644 index 08ddae166..000000000 --- a/backend/src/mailer/sendAccountActivationEmail.test.ts +++ /dev/null @@ -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 `, - subject: 'Gradido: E-Mail Überprüfung', - text: - expect.stringContaining('Hallo Peter Lustig') && - expect.stringContaining('activationLink') && - expect.stringContaining('23 Stunden und 30 Minuten'), - }) - }) -}) diff --git a/backend/src/mailer/sendAccountActivationEmail.ts b/backend/src/mailer/sendAccountActivationEmail.ts deleted file mode 100644 index 335f80a82..000000000 --- a/backend/src/mailer/sendAccountActivationEmail.ts +++ /dev/null @@ -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 => { - return sendEMail({ - to: `${data.firstName} ${data.lastName} <${data.email}>`, - subject: accountActivation.de.subject, - text: accountActivation.de.text({ ...data, resendLink: CONFIG.EMAIL_LINK_FORGOTPASSWORD }), - }) -} diff --git a/backend/src/mailer/text/accountActivation.ts b/backend/src/mailer/text/accountActivation.ts deleted file mode 100644 index 2755c4c0a..000000000 --- a/backend/src/mailer/text/accountActivation.ts +++ /dev/null @@ -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`, - }, -} diff --git a/backend/src/mailer/text/accountMultiRegistration.ts b/backend/src/mailer/text/accountMultiRegistration.ts deleted file mode 100644 index c5b55bac5..000000000 --- a/backend/src/mailer/text/accountMultiRegistration.ts +++ /dev/null @@ -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`, - }, -} From 624cd6e4bc25d9ef3da04b933a622bbd2a2d028c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Tue, 22 Nov 2022 08:34:48 +0100 Subject: [PATCH 09/90] Fix spelling of error message in 'sendActivationEmail' --- backend/src/graphql/resolver/AdminResolver.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/graphql/resolver/AdminResolver.ts b/backend/src/graphql/resolver/AdminResolver.ts index bd3ed20dc..047f9bc4e 100644 --- a/backend/src/graphql/resolver/AdminResolver.ts +++ b/backend/src/graphql/resolver/AdminResolver.ts @@ -650,8 +650,8 @@ 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.`) } // eslint-disable-next-line @typescript-eslint/no-unused-vars From 0695ac282b89ade1217f88b2de2ba0d388838891 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Tue, 22 Nov 2022 11:17:26 +0100 Subject: [PATCH 10/90] Adjust the fixed 'CONFIG.EMAIL_TEST_MODUS' problem in 'sendEmailTranslated.ts' --- backend/src/emails/sendEmailTranslated.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/src/emails/sendEmailTranslated.ts b/backend/src/emails/sendEmailTranslated.ts index fc1161b8a..39291a0ac 100644 --- a/backend/src/emails/sendEmailTranslated.ts +++ b/backend/src/emails/sendEmailTranslated.ts @@ -32,8 +32,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}`, ) From 53b29db25ec5388d733ca9f22ef8fd2ef9420372 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Tue, 22 Nov 2022 11:24:13 +0100 Subject: [PATCH 11/90] =?UTF-8?q?Add=20README's=20for=20locales=20?= =?UTF-8?q?=E2=80=93=20especially=20for=20quotation=20marks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/src/locales/README.md | 3 +++ backend/src/locales/README.md | 3 +++ frontend/src/locales/README.md | 25 +++++++++++++++++++++++++ 3 files changed, 31 insertions(+) create mode 100644 admin/src/locales/README.md create mode 100644 backend/src/locales/README.md create mode 100644 frontend/src/locales/README.md diff --git a/admin/src/locales/README.md b/admin/src/locales/README.md new file mode 100644 index 000000000..5d6bf75b1 --- /dev/null +++ b/admin/src/locales/README.md @@ -0,0 +1,3 @@ +# Localizations + +Please see [frontend localization](/frontend/src/locales/README.md). diff --git a/backend/src/locales/README.md b/backend/src/locales/README.md new file mode 100644 index 000000000..5d6bf75b1 --- /dev/null +++ b/backend/src/locales/README.md @@ -0,0 +1,3 @@ +# Localizations + +Please see [frontend localization](/frontend/src/locales/README.md). diff --git a/frontend/src/locales/README.md b/frontend/src/locales/README.md new file mode 100644 index 000000000..2c03abbd4 --- /dev/null +++ b/frontend/src/locales/README.md @@ -0,0 +1,25 @@ +# Localizations + +## Quotation Marks + +The following characters are different from the programming quotation mark: + +`"` + +### English + +In English, we use these double-barreled quotation marks: + +“This is a sample sentence.” + +Please copy and paste … + +See + +### German + +In German, we use these double-barreled quotation marks: + +„Dies ist ein Beispielsatz.“ + +Please copy and paste … From db1a11a2b5dba3a159c016c1a30b06419176e4d8 Mon Sep 17 00:00:00 2001 From: Ulf Gebhardt Date: Tue, 22 Nov 2022 12:41:08 +0100 Subject: [PATCH 12/90] seperate admin resolver into existing resolvers --- backend/src/graphql/resolver/AdminResolver.ts | 918 ------------------ .../resolver/ContributionMessageResolver.ts | 80 +- .../graphql/resolver/ContributionResolver.ts | 446 ++++++++- .../resolver/TransactionLinkResolver.ts | 197 +++- backend/src/graphql/resolver/UserResolver.ts | 213 +++- 5 files changed, 910 insertions(+), 944 deletions(-) delete mode 100644 backend/src/graphql/resolver/AdminResolver.ts diff --git a/backend/src/graphql/resolver/AdminResolver.ts b/backend/src/graphql/resolver/AdminResolver.ts deleted file mode 100644 index 80c69a864..000000000 --- a/backend/src/graphql/resolver/AdminResolver.ts +++ /dev/null @@ -1,918 +0,0 @@ -import { Context, getUser, getClientTimezoneOffset } from '@/server/context' -import { backendLogger as logger } from '@/server/logger' -import { Resolver, Query, Arg, Args, Authorized, Mutation, Ctx, Int } from 'type-graphql' -import { - getCustomRepository, - IsNull, - getConnection, - In, - MoreThan, - FindOperator, -} from '@dbTools/typeorm' -import { UserAdmin, SearchUsersResult } from '@model/UserAdmin' -import { UnconfirmedContribution } from '@model/UnconfirmedContribution' -import { AdminCreateContributions } from '@model/AdminCreateContributions' -import { AdminUpdateContribution } from '@model/AdminUpdateContribution' -import { ContributionLink } from '@model/ContributionLink' -import { ContributionLinkList } from '@model/ContributionLinkList' -import { Contribution } from '@model/Contribution' -import { RIGHTS } from '@/auth/RIGHTS' -import { UserRepository } from '@repository/User' -import AdminCreateContributionArgs from '@arg/AdminCreateContributionArgs' -import AdminUpdateContributionArgs from '@arg/AdminUpdateContributionArgs' -import SearchUsersArgs from '@arg/SearchUsersArgs' -import ContributionLinkArgs from '@arg/ContributionLinkArgs' -import { Transaction as DbTransaction } from '@entity/Transaction' -import { ContributionLink as DbContributionLink } from '@entity/ContributionLink' -import { TransactionLink, TransactionLinkResult } from '@model/TransactionLink' -import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink' -import { calculateDecay } from '@/util/decay' -import { Contribution as DbContribution } from '@entity/Contribution' -import { hasElopageBuys } from '@/util/hasElopageBuys' -import { User as dbUser } from '@entity/User' -import { User } from '@model/User' -import { TransactionTypeId } from '@enum/TransactionTypeId' -import { ContributionType } from '@enum/ContributionType' -import { ContributionStatus } from '@enum/ContributionStatus' -import Decimal from 'decimal.js-light' -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 { transactionLinkCode as contributionLinkCode } from './TransactionLinkResolver' -import CONFIG from '@/config' -import { - getUserCreation, - getUserCreations, - validateContribution, - isStartEndDateValid, - updateCreations, - isValidDateString, -} from './util/creations' -import { - CONTRIBUTIONLINK_NAME_MAX_CHARS, - CONTRIBUTIONLINK_NAME_MIN_CHARS, - FULL_CREATION_AVAILABLE, - MEMO_MAX_CHARS, - MEMO_MIN_CHARS, -} from './const/const' -import { UserContact } from '@entity/UserContact' -import { ContributionMessage as DbContributionMessage } from '@entity/ContributionMessage' -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, - EventAdminContributionCreate, - EventAdminContributionDelete, - EventAdminContributionUpdate, - EventContributionConfirm, - EventSendConfirmationEmail, -} from '@/event/Event' -import { ContributionListResult } from '../model/Contribution' - -// const EMAIL_OPT_IN_REGISTER = 1 -// const EMAIL_OPT_UNKNOWN = 3 // elopage? - -@Resolver() -export class AdminResolver { - @Authorized([RIGHTS.SEARCH_USERS]) - @Query(() => SearchUsersResult) - async searchUsers( - @Args() - { searchText, currentPage = 1, pageSize = 25, filters }: SearchUsersArgs, - @Ctx() context: Context, - ): Promise { - const clientTimezoneOffset = getClientTimezoneOffset(context) - const userRepository = getCustomRepository(UserRepository) - const userFields = [ - 'id', - 'firstName', - 'lastName', - 'emailId', - 'emailContact', - 'deletedAt', - 'isAdmin', - ] - const [users, count] = await userRepository.findBySearchCriteriaPagedFiltered( - userFields.map((fieldName) => { - return 'user.' + fieldName - }), - searchText, - filters, - currentPage, - pageSize, - ) - - if (users.length === 0) { - return { - userCount: 0, - userList: [], - } - } - - const creations = await getUserCreations( - users.map((u) => u.id), - clientTimezoneOffset, - ) - - const adminUsers = await Promise.all( - users.map(async (user) => { - let emailConfirmationSend = '' - if (!user.emailContact.emailChecked) { - if (user.emailContact.updatedAt) { - emailConfirmationSend = user.emailContact.updatedAt.toISOString() - } else { - emailConfirmationSend = user.emailContact.createdAt.toISOString() - } - } - const userCreations = creations.find((c) => c.id === user.id) - const adminUser = new UserAdmin( - user, - userCreations ? userCreations.creations : FULL_CREATION_AVAILABLE, - await hasElopageBuys(user.emailContact.email), - emailConfirmationSend, - ) - return adminUser - }), - ) - return { - userCount: count, - userList: adminUsers, - } - } - - @Authorized([RIGHTS.SET_USER_ROLE]) - @Mutation(() => Date, { nullable: true }) - async setUserRole( - @Arg('userId', () => Int) - userId: number, - @Arg('isAdmin', () => Boolean) - isAdmin: boolean, - @Ctx() - context: Context, - ): Promise { - const user = await dbUser.findOne({ id: userId }) - // user exists ? - if (!user) { - logger.error(`Could not find user with userId: ${userId}`) - throw new Error(`Could not find user with userId: ${userId}`) - } - // administrator user changes own role? - const moderatorUser = getUser(context) - if (moderatorUser.id === userId) { - logger.error('Administrator can not change his own role!') - throw new Error('Administrator can not change his own role!') - } - // change isAdmin - switch (user.isAdmin) { - case null: - if (isAdmin === true) { - user.isAdmin = new Date() - } else { - logger.error('User is already a usual user!') - throw new Error('User is already a usual user!') - } - break - default: - if (isAdmin === false) { - user.isAdmin = null - } else { - logger.error('User is already admin!') - throw new Error('User is already admin!') - } - break - } - await user.save() - const newUser = await dbUser.findOne({ id: userId }) - return newUser ? newUser.isAdmin : null - } - - @Authorized([RIGHTS.DELETE_USER]) - @Mutation(() => Date, { nullable: true }) - async deleteUser( - @Arg('userId', () => Int) userId: number, - @Ctx() context: Context, - ): Promise { - const user = await dbUser.findOne({ id: userId }) - // user exists ? - if (!user) { - logger.error(`Could not find user with userId: ${userId}`) - throw new Error(`Could not find user with userId: ${userId}`) - } - // moderator user disabled own account? - const moderatorUser = getUser(context) - if (moderatorUser.id === userId) { - logger.error('Moderator can not delete his own account!') - throw new Error('Moderator can not delete his own account!') - } - // soft-delete user - await user.softRemove() - const newUser = await dbUser.findOne({ id: userId }, { withDeleted: true }) - return newUser ? newUser.deletedAt : null - } - - @Authorized([RIGHTS.UNDELETE_USER]) - @Mutation(() => Date, { nullable: true }) - async unDeleteUser(@Arg('userId', () => Int) userId: number): Promise { - const user = await dbUser.findOne({ id: userId }, { withDeleted: true }) - if (!user) { - logger.error(`Could not find user with userId: ${userId}`) - throw new Error(`Could not find user with userId: ${userId}`) - } - if (!user.deletedAt) { - logger.error('User is not deleted') - throw new Error('User is not deleted') - } - await user.recover() - return null - } - - @Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTION]) - @Mutation(() => [Number]) - async adminCreateContribution( - @Args() { email, amount, memo, creationDate }: AdminCreateContributionArgs, - @Ctx() context: Context, - ): Promise { - logger.info( - `adminCreateContribution(email=${email}, amount=${amount}, memo=${memo}, creationDate=${creationDate})`, - ) - const clientTimezoneOffset = getClientTimezoneOffset(context) - if (!isValidDateString(creationDate)) { - logger.error(`invalid Date for creationDate=${creationDate}`) - throw new Error(`invalid Date for creationDate=${creationDate}`) - } - const emailContact = await UserContact.findOne({ - where: { email }, - withDeleted: true, - relations: ['user'], - }) - if (!emailContact) { - logger.error(`Could not find user with email: ${email}`) - throw new Error(`Could not find user with email: ${email}`) - } - if (emailContact.deletedAt) { - logger.error('This emailContact was deleted. Cannot create a contribution.') - throw new Error('This emailContact was deleted. Cannot create a contribution.') - } - if (emailContact.user.deletedAt) { - logger.error('This user was deleted. Cannot create a contribution.') - throw new Error('This user was deleted. Cannot create a contribution.') - } - if (!emailContact.emailChecked) { - logger.error('Contribution could not be saved, Email is not activated') - throw new Error('Contribution could not be saved, Email is not activated') - } - - const event = new Event() - const moderator = getUser(context) - logger.trace('moderator: ', moderator.id) - const creations = await getUserCreation(emailContact.userId, clientTimezoneOffset) - logger.trace('creations:', creations) - const creationDateObj = new Date(creationDate) - logger.trace('creationDateObj:', creationDateObj) - validateContribution(creations, amount, creationDateObj, clientTimezoneOffset) - const contribution = DbContribution.create() - contribution.userId = emailContact.userId - contribution.amount = amount - contribution.createdAt = new Date() - contribution.contributionDate = creationDateObj - contribution.memo = memo - contribution.moderatorId = moderator.id - contribution.contributionType = ContributionType.ADMIN - contribution.contributionStatus = ContributionStatus.PENDING - - logger.trace('contribution to save', contribution) - - await DbContribution.save(contribution) - - const eventAdminCreateContribution = new EventAdminContributionCreate() - eventAdminCreateContribution.userId = moderator.id - eventAdminCreateContribution.amount = amount - eventAdminCreateContribution.contributionId = contribution.id - await eventProtocol.writeEvent( - event.setEventAdminContributionCreate(eventAdminCreateContribution), - ) - - return getUserCreation(emailContact.userId, clientTimezoneOffset) - } - - @Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTIONS]) - @Mutation(() => AdminCreateContributions) - async adminCreateContributions( - @Arg('pendingCreations', () => [AdminCreateContributionArgs]) - contributions: AdminCreateContributionArgs[], - @Ctx() context: Context, - ): Promise { - let success = false - const successfulContribution: string[] = [] - const failedContribution: string[] = [] - for (const contribution of contributions) { - await this.adminCreateContribution(contribution, context) - .then(() => { - successfulContribution.push(contribution.email) - success = true - }) - .catch(() => { - failedContribution.push(contribution.email) - }) - } - return { - success, - successfulContribution, - failedContribution, - } - } - - @Authorized([RIGHTS.ADMIN_UPDATE_CONTRIBUTION]) - @Mutation(() => AdminUpdateContribution) - async adminUpdateContribution( - @Args() { id, email, amount, memo, creationDate }: AdminUpdateContributionArgs, - @Ctx() context: Context, - ): Promise { - const clientTimezoneOffset = getClientTimezoneOffset(context) - const emailContact = await UserContact.findOne({ - where: { email }, - withDeleted: true, - relations: ['user'], - }) - if (!emailContact) { - logger.error(`Could not find UserContact with email: ${email}`) - throw new Error(`Could not find UserContact with email: ${email}`) - } - const user = emailContact.user - if (!user) { - logger.error(`Could not find User to emailContact: ${email}`) - throw new Error(`Could not find User to emailContact: ${email}`) - } - if (user.deletedAt) { - logger.error(`User was deleted (${email})`) - throw new Error(`User was deleted (${email})`) - } - - const moderator = getUser(context) - - const contributionToUpdate = await DbContribution.findOne({ - where: { id, confirmedAt: IsNull() }, - }) - if (!contributionToUpdate) { - logger.error('No contribution found to given id.') - throw new Error('No contribution found to given id.') - } - - if (contributionToUpdate.userId !== user.id) { - logger.error('user of the pending contribution and send user does not correspond') - throw new Error('user of the pending contribution and send user does not correspond') - } - - if (contributionToUpdate.moderatorId === null) { - logger.error('An admin is not allowed to update a user contribution.') - throw new Error('An admin is not allowed to update a user contribution.') - } - - const creationDateObj = new Date(creationDate) - let creations = await getUserCreation(user.id, clientTimezoneOffset) - - if (contributionToUpdate.contributionDate.getMonth() === creationDateObj.getMonth()) { - creations = updateCreations(creations, contributionToUpdate, clientTimezoneOffset) - } else { - logger.error('Currently the month of the contribution cannot change.') - throw new Error('Currently the month of the contribution cannot change.') - } - - // all possible cases not to be true are thrown in this function - validateContribution(creations, amount, creationDateObj, clientTimezoneOffset) - contributionToUpdate.amount = amount - contributionToUpdate.memo = memo - contributionToUpdate.contributionDate = new Date(creationDate) - contributionToUpdate.moderatorId = moderator.id - contributionToUpdate.contributionStatus = ContributionStatus.PENDING - - await DbContribution.save(contributionToUpdate) - - const result = new AdminUpdateContribution() - result.amount = amount - result.memo = contributionToUpdate.memo - result.date = contributionToUpdate.contributionDate - - result.creation = await getUserCreation(user.id, clientTimezoneOffset) - - const event = new Event() - const eventAdminContributionUpdate = new EventAdminContributionUpdate() - eventAdminContributionUpdate.userId = user.id - eventAdminContributionUpdate.amount = amount - eventAdminContributionUpdate.contributionId = contributionToUpdate.id - await eventProtocol.writeEvent( - event.setEventAdminContributionUpdate(eventAdminContributionUpdate), - ) - - return result - } - - @Authorized([RIGHTS.LIST_UNCONFIRMED_CONTRIBUTIONS]) - @Query(() => [UnconfirmedContribution]) - async listUnconfirmedContributions(@Ctx() context: Context): Promise { - const clientTimezoneOffset = getClientTimezoneOffset(context) - const contributions = await getConnection() - .createQueryBuilder() - .select('c') - .from(DbContribution, 'c') - .leftJoinAndSelect('c.messages', 'm') - .where({ confirmedAt: IsNull() }) - .getMany() - - if (contributions.length === 0) { - return [] - } - - const userIds = contributions.map((p) => p.userId) - const userCreations = await getUserCreations(userIds, clientTimezoneOffset) - const users = await dbUser.find({ - where: { id: In(userIds) }, - withDeleted: true, - relations: ['emailContact'], - }) - - return contributions.map((contribution) => { - const user = users.find((u) => u.id === contribution.userId) - const creation = userCreations.find((c) => c.id === contribution.userId) - - return new UnconfirmedContribution( - contribution, - user, - creation ? creation.creations : FULL_CREATION_AVAILABLE, - ) - }) - } - - @Authorized([RIGHTS.ADMIN_DELETE_CONTRIBUTION]) - @Mutation(() => Boolean) - async adminDeleteContribution( - @Arg('id', () => Int) id: number, - @Ctx() context: Context, - ): Promise { - const contribution = await DbContribution.findOne(id) - if (!contribution) { - logger.error(`Contribution not found for given id: ${id}`) - throw new Error('Contribution not found for given id.') - } - const moderator = getUser(context) - if ( - contribution.contributionType === ContributionType.USER && - contribution.userId === moderator.id - ) { - throw new Error('Own contribution can not be deleted as admin') - } - const user = await dbUser.findOneOrFail( - { id: contribution.userId }, - { relations: ['emailContact'] }, - ) - contribution.contributionStatus = ContributionStatus.DELETED - contribution.deletedBy = moderator.id - await contribution.save() - const res = await contribution.softRemove() - - const event = new Event() - const eventAdminContributionDelete = new EventAdminContributionDelete() - eventAdminContributionDelete.userId = contribution.userId - eventAdminContributionDelete.amount = contribution.amount - eventAdminContributionDelete.contributionId = contribution.id - await eventProtocol.writeEvent( - event.setEventAdminContributionDelete(eventAdminContributionDelete), - ) - sendContributionRejectedEmail({ - 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 - } - - @Authorized([RIGHTS.CONFIRM_CONTRIBUTION]) - @Mutation(() => Boolean) - async confirmContribution( - @Arg('id', () => Int) id: number, - @Ctx() context: Context, - ): Promise { - const clientTimezoneOffset = getClientTimezoneOffset(context) - const contribution = await DbContribution.findOne(id) - if (!contribution) { - logger.error(`Contribution not found for given id: ${id}`) - throw new Error('Contribution not found to given id.') - } - const moderatorUser = getUser(context) - if (moderatorUser.id === contribution.userId) { - logger.error('Moderator can not confirm own contribution') - throw new Error('Moderator can not confirm own contribution') - } - const user = await dbUser.findOneOrFail( - { id: contribution.userId }, - { withDeleted: true, relations: ['emailContact'] }, - ) - if (user.deletedAt) { - logger.error('This user was deleted. Cannot confirm a contribution.') - throw new Error('This user was deleted. Cannot confirm a contribution.') - } - const creations = await getUserCreation(contribution.userId, clientTimezoneOffset, false) - validateContribution( - creations, - contribution.amount, - contribution.contributionDate, - clientTimezoneOffset, - ) - - const receivedCallDate = new Date() - - const queryRunner = getConnection().createQueryRunner() - await queryRunner.connect() - await queryRunner.startTransaction('REPEATABLE READ') // 'READ COMMITTED') - try { - const lastTransaction = await queryRunner.manager - .createQueryBuilder() - .select('transaction') - .from(DbTransaction, 'transaction') - .where('transaction.userId = :id', { id: contribution.userId }) - .orderBy('transaction.balanceDate', 'DESC') - .getOne() - logger.info('lastTransaction ID', lastTransaction ? lastTransaction.id : 'undefined') - - let newBalance = new Decimal(0) - let decay: Decay | null = null - if (lastTransaction) { - decay = calculateDecay( - lastTransaction.balance, - lastTransaction.balanceDate, - receivedCallDate, - ) - newBalance = decay.balance - } - newBalance = newBalance.add(contribution.amount.toString()) - - const transaction = new DbTransaction() - transaction.typeId = TransactionTypeId.CREATION - transaction.memo = contribution.memo - transaction.userId = contribution.userId - transaction.previous = lastTransaction ? lastTransaction.id : null - transaction.amount = contribution.amount - transaction.creationDate = contribution.contributionDate - transaction.balance = newBalance - transaction.balanceDate = receivedCallDate - transaction.decay = decay ? decay.decay : new Decimal(0) - transaction.decayStart = decay ? decay.start : null - await queryRunner.manager.insert(DbTransaction, transaction) - - contribution.confirmedAt = receivedCallDate - contribution.confirmedBy = moderatorUser.id - contribution.transactionId = transaction.id - contribution.contributionStatus = ContributionStatus.CONFIRMED - await queryRunner.manager.update(DbContribution, { id: contribution.id }, contribution) - - await queryRunner.commitTransaction() - logger.info('creation commited successfuly.') - sendContributionConfirmedEmail({ - 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() - logger.error(`Creation was not successful: ${e}`) - throw new Error(`Creation was not successful.`) - } finally { - await queryRunner.release() - } - - const event = new Event() - const eventContributionConfirm = new EventContributionConfirm() - eventContributionConfirm.userId = user.id - eventContributionConfirm.amount = contribution.amount - eventContributionConfirm.contributionId = contribution.id - await eventProtocol.writeEvent(event.setEventContributionConfirm(eventContributionConfirm)) - return true - } - - @Authorized([RIGHTS.CREATION_TRANSACTION_LIST]) - @Query(() => ContributionListResult) - async creationTransactionList( - @Args() - { currentPage = 1, pageSize = 25, order = Order.DESC }: Paginated, - @Arg('userId', () => Int) userId: number, - ): Promise { - const offset = (currentPage - 1) * pageSize - const [contributionResult, count] = await getConnection() - .createQueryBuilder() - .select('c') - .from(DbContribution, 'c') - .leftJoinAndSelect('c.user', 'u') - .where(`user_id = ${userId}`) - .limit(pageSize) - .offset(offset) - .orderBy('c.created_at', order) - .getManyAndCount() - - return new ContributionListResult( - count, - contributionResult.map((contribution) => new Contribution(contribution, contribution.user)), - ) - // return userTransactions.map((t) => new Transaction(t, new User(user), communityUser)) - } - - @Authorized([RIGHTS.SEND_ACTIVATION_EMAIL]) - @Mutation(() => Boolean) - async sendActivationEmail(@Arg('email') email: string): Promise { - email = email.trim().toLowerCase() - // const user = await dbUser.findOne({ id: emailContact.userId }) - const user = await findUserByEmail(email) - if (!user) { - logger.error(`Could not find User to emailContact: ${email}`) - throw new Error(`Could not find User to emailContact: ${email}`) - } - if (user.deletedAt) { - logger.error(`User with emailContact: ${email} is deleted.`) - throw new Error(`User with emailContact: ${email} is deleted.`) - } - 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.`) - } - - // 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), - }) - - // In case EMails are disabled log the activation link for the user - if (!emailSent) { - logger.info(`Account confirmation link: ${activationLink}`) - } else { - const event = new Event() - const eventSendConfirmationEmail = new EventSendConfirmationEmail() - eventSendConfirmationEmail.userId = user.id - await eventProtocol.writeEvent( - event.setEventSendConfirmationEmail(eventSendConfirmationEmail), - ) - } - - return true - } - - @Authorized([RIGHTS.LIST_TRANSACTION_LINKS_ADMIN]) - @Query(() => TransactionLinkResult) - async listTransactionLinksAdmin( - @Args() - { currentPage = 1, pageSize = 5, order = Order.DESC }: Paginated, - @Arg('filters', () => TransactionLinkFilters, { nullable: true }) - filters: TransactionLinkFilters, - @Arg('userId', () => Int) - userId: number, - ): Promise { - const user = await dbUser.findOneOrFail({ id: userId }) - const where: { - userId: number - redeemedBy?: number | null - validUntil?: FindOperator | null - } = { - userId, - redeemedBy: null, - validUntil: MoreThan(new Date()), - } - if (filters) { - if (filters.withRedeemed) delete where.redeemedBy - if (filters.withExpired) delete where.validUntil - } - const [transactionLinks, count] = await dbTransactionLink.findAndCount({ - where, - withDeleted: filters ? filters.withDeleted : false, - order: { - createdAt: order, - }, - skip: (currentPage - 1) * pageSize, - take: pageSize, - }) - - return { - linkCount: count, - linkList: transactionLinks.map((tl) => new TransactionLink(tl, new User(user))), - } - } - - @Authorized([RIGHTS.CREATE_CONTRIBUTION_LINK]) - @Mutation(() => ContributionLink) - async createContributionLink( - @Args() - { - amount, - name, - memo, - cycle, - validFrom, - validTo, - maxAmountPerMonth, - maxPerCycle, - }: ContributionLinkArgs, - ): Promise { - isStartEndDateValid(validFrom, validTo) - if (!name) { - logger.error(`The name must be initialized!`) - throw new Error(`The name must be initialized!`) - } - if ( - name.length < CONTRIBUTIONLINK_NAME_MIN_CHARS || - name.length > CONTRIBUTIONLINK_NAME_MAX_CHARS - ) { - const msg = `The value of 'name' with a length of ${name.length} did not fulfill the requested bounderies min=${CONTRIBUTIONLINK_NAME_MIN_CHARS} and max=${CONTRIBUTIONLINK_NAME_MAX_CHARS}` - logger.error(`${msg}`) - throw new Error(`${msg}`) - } - if (!memo) { - logger.error(`The memo must be initialized!`) - throw new Error(`The memo must be initialized!`) - } - if (memo.length < MEMO_MIN_CHARS || memo.length > MEMO_MAX_CHARS) { - const msg = `The value of 'memo' with a length of ${memo.length} did not fulfill the requested bounderies min=${MEMO_MIN_CHARS} and max=${MEMO_MAX_CHARS}` - logger.error(`${msg}`) - throw new Error(`${msg}`) - } - if (!amount) { - logger.error(`The amount must be initialized!`) - throw new Error('The amount must be initialized!') - } - if (!new Decimal(amount).isPositive()) { - logger.error(`The amount=${amount} must be initialized with a positiv value!`) - throw new Error(`The amount=${amount} must be initialized with a positiv value!`) - } - const dbContributionLink = new DbContributionLink() - dbContributionLink.amount = amount - dbContributionLink.name = name - dbContributionLink.memo = memo - dbContributionLink.createdAt = new Date() - dbContributionLink.code = contributionLinkCode(dbContributionLink.createdAt) - dbContributionLink.cycle = cycle - if (validFrom) dbContributionLink.validFrom = new Date(validFrom) - if (validTo) dbContributionLink.validTo = new Date(validTo) - dbContributionLink.maxAmountPerMonth = maxAmountPerMonth - dbContributionLink.maxPerCycle = maxPerCycle - await dbContributionLink.save() - logger.debug(`createContributionLink successful!`) - return new ContributionLink(dbContributionLink) - } - - @Authorized([RIGHTS.LIST_CONTRIBUTION_LINKS]) - @Query(() => ContributionLinkList) - async listContributionLinks( - @Args() - { currentPage = 1, pageSize = 5, order = Order.DESC }: Paginated, - ): Promise { - const [links, count] = await DbContributionLink.findAndCount({ - where: [{ validTo: MoreThan(new Date()) }, { validTo: IsNull() }], - order: { createdAt: order }, - skip: (currentPage - 1) * pageSize, - take: pageSize, - }) - return { - links: links.map((link: DbContributionLink) => new ContributionLink(link)), - count, - } - } - - @Authorized([RIGHTS.DELETE_CONTRIBUTION_LINK]) - @Mutation(() => Date, { nullable: true }) - async deleteContributionLink(@Arg('id', () => Int) id: number): Promise { - const contributionLink = await DbContributionLink.findOne(id) - if (!contributionLink) { - logger.error(`Contribution Link not found to given id: ${id}`) - throw new Error('Contribution Link not found to given id.') - } - await contributionLink.softRemove() - logger.debug(`deleteContributionLink successful!`) - const newContributionLink = await DbContributionLink.findOne({ id }, { withDeleted: true }) - return newContributionLink ? newContributionLink.deletedAt : null - } - - @Authorized([RIGHTS.UPDATE_CONTRIBUTION_LINK]) - @Mutation(() => ContributionLink) - async updateContributionLink( - @Args() - { - amount, - name, - memo, - cycle, - validFrom, - validTo, - maxAmountPerMonth, - maxPerCycle, - }: ContributionLinkArgs, - @Arg('id', () => Int) id: number, - ): Promise { - const dbContributionLink = await DbContributionLink.findOne(id) - if (!dbContributionLink) { - logger.error(`Contribution Link not found to given id: ${id}`) - throw new Error('Contribution Link not found to given id.') - } - dbContributionLink.amount = amount - dbContributionLink.name = name - dbContributionLink.memo = memo - dbContributionLink.cycle = cycle - if (validFrom) dbContributionLink.validFrom = new Date(validFrom) - if (validTo) dbContributionLink.validTo = new Date(validTo) - dbContributionLink.maxAmountPerMonth = maxAmountPerMonth - dbContributionLink.maxPerCycle = maxPerCycle - await dbContributionLink.save() - logger.debug(`updateContributionLink successful!`) - return new ContributionLink(dbContributionLink) - } - - @Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTION_MESSAGE]) - @Mutation(() => ContributionMessage) - async adminCreateContributionMessage( - @Args() { contributionId, message }: ContributionMessageArgs, - @Ctx() context: Context, - ): Promise { - const user = getUser(context) - if (!user.emailContact) { - user.emailContact = await UserContact.findOneOrFail({ where: { id: user.emailId } }) - } - const queryRunner = getConnection().createQueryRunner() - await queryRunner.connect() - await queryRunner.startTransaction('REPEATABLE READ') - const contributionMessage = DbContributionMessage.create() - try { - const contribution = await DbContribution.findOne({ - where: { id: contributionId }, - relations: ['user'], - }) - if (!contribution) { - logger.error('Contribution not found') - throw new Error('Contribution not found') - } - if (contribution.userId === user.id) { - logger.error('Admin can not answer on own contribution') - throw new Error('Admin can not answer on own contribution') - } - if (!contribution.user.emailContact) { - contribution.user.emailContact = await UserContact.findOneOrFail({ - where: { id: contribution.user.emailId }, - }) - } - contributionMessage.contributionId = contributionId - contributionMessage.createdAt = new Date() - contributionMessage.message = message - contributionMessage.userId = user.id - contributionMessage.type = ContributionMessageType.DIALOG - contributionMessage.isModerator = true - await queryRunner.manager.insert(DbContributionMessage, contributionMessage) - - if ( - contribution.contributionStatus === ContributionStatus.DELETED || - contribution.contributionStatus === ContributionStatus.DENIED || - contribution.contributionStatus === ContributionStatus.PENDING - ) { - contribution.contributionStatus = ContributionStatus.IN_PROGRESS - await queryRunner.manager.update(DbContribution, { id: contributionId }, contribution) - } - - await sendAddedContributionMessageEmail({ - 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) { - await queryRunner.rollbackTransaction() - logger.error(`ContributionMessage was not successful: ${e}`) - throw new Error(`ContributionMessage was not successful: ${e}`) - } finally { - await queryRunner.release() - } - return new ContributionMessage(contributionMessage, user) - } -} diff --git a/backend/src/graphql/resolver/ContributionMessageResolver.ts b/backend/src/graphql/resolver/ContributionMessageResolver.ts index 0b33c4722..84eccf5ca 100644 --- a/backend/src/graphql/resolver/ContributionMessageResolver.ts +++ b/backend/src/graphql/resolver/ContributionMessageResolver.ts @@ -4,13 +4,16 @@ import { Context, getUser } from '@/server/context' import { ContributionMessage as DbContributionMessage } from '@entity/ContributionMessage' import { Arg, Args, Authorized, Ctx, Mutation, Query, Resolver } from 'type-graphql' import ContributionMessageArgs from '@arg/ContributionMessageArgs' -import { Contribution } from '@entity/Contribution' +import { Contribution as DbContribution } from '@entity/Contribution' import { ContributionMessageType } from '@enum/MessageType' import { ContributionStatus } from '@enum/ContributionStatus' import { getConnection } from '@dbTools/typeorm' import { ContributionMessage, ContributionMessageListResult } from '@model/ContributionMessage' import Paginated from '@arg/Paginated' import { Order } from '@enum/Order' +import { UserContact } from '@entity/UserContact' +import { sendAddedContributionMessageEmail } from '@/mailer/sendAddedContributionMessageEmail' +import CONFIG from '@/config' @Resolver() export class ContributionMessageResolver { @@ -26,7 +29,7 @@ export class ContributionMessageResolver { await queryRunner.startTransaction('REPEATABLE READ') const contributionMessage = DbContributionMessage.create() try { - const contribution = await Contribution.findOne({ id: contributionId }) + const contribution = await DbContribution.findOne({ id: contributionId }) if (!contribution) { throw new Error('Contribution not found') } @@ -44,7 +47,7 @@ export class ContributionMessageResolver { if (contribution.contributionStatus === ContributionStatus.IN_PROGRESS) { contribution.contributionStatus = ContributionStatus.PENDING - await queryRunner.manager.update(Contribution, { id: contributionId }, contribution) + await queryRunner.manager.update(DbContribution, { id: contributionId }, contribution) } await queryRunner.commitTransaction() } catch (e) { @@ -82,4 +85,75 @@ export class ContributionMessageResolver { ), } } + + @Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTION_MESSAGE]) + @Mutation(() => ContributionMessage) + async adminCreateContributionMessage( + @Args() { contributionId, message }: ContributionMessageArgs, + @Ctx() context: Context, + ): Promise { + const user = getUser(context) + if (!user.emailContact) { + user.emailContact = await UserContact.findOneOrFail({ where: { id: user.emailId } }) + } + const queryRunner = getConnection().createQueryRunner() + await queryRunner.connect() + await queryRunner.startTransaction('REPEATABLE READ') + const contributionMessage = DbContributionMessage.create() + try { + const contribution = await DbContribution.findOne({ + where: { id: contributionId }, + relations: ['user'], + }) + if (!contribution) { + logger.error('Contribution not found') + throw new Error('Contribution not found') + } + if (contribution.userId === user.id) { + logger.error('Admin can not answer on own contribution') + throw new Error('Admin can not answer on own contribution') + } + if (!contribution.user.emailContact) { + contribution.user.emailContact = await UserContact.findOneOrFail({ + where: { id: contribution.user.emailId }, + }) + } + contributionMessage.contributionId = contributionId + contributionMessage.createdAt = new Date() + contributionMessage.message = message + contributionMessage.userId = user.id + contributionMessage.type = ContributionMessageType.DIALOG + contributionMessage.isModerator = true + await queryRunner.manager.insert(DbContributionMessage, contributionMessage) + + if ( + contribution.contributionStatus === ContributionStatus.DELETED || + contribution.contributionStatus === ContributionStatus.DENIED || + contribution.contributionStatus === ContributionStatus.PENDING + ) { + contribution.contributionStatus = ContributionStatus.IN_PROGRESS + await queryRunner.manager.update(DbContribution, { id: contributionId }, contribution) + } + + await sendAddedContributionMessageEmail({ + 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) { + await queryRunner.rollbackTransaction() + logger.error(`ContributionMessage was not successful: ${e}`) + throw new Error(`ContributionMessage was not successful: ${e}`) + } finally { + await queryRunner.release() + } + return new ContributionMessage(contributionMessage, user) + } } diff --git a/backend/src/graphql/resolver/ContributionResolver.ts b/backend/src/graphql/resolver/ContributionResolver.ts index 15bdbfc2e..6d0716915 100644 --- a/backend/src/graphql/resolver/ContributionResolver.ts +++ b/backend/src/graphql/resolver/ContributionResolver.ts @@ -1,9 +1,9 @@ import { RIGHTS } from '@/auth/RIGHTS' import { Context, getUser, getClientTimezoneOffset } from '@/server/context' import { backendLogger as logger } from '@/server/logger' -import { Contribution as dbContribution } from '@entity/Contribution' +import { Contribution as DbContribution } from '@entity/Contribution' import { Arg, Args, Authorized, Ctx, Int, Mutation, Query, Resolver } from 'type-graphql' -import { FindOperator, IsNull, getConnection } from '@dbTools/typeorm' +import { FindOperator, IsNull, In, getConnection } from '@dbTools/typeorm' import ContributionArgs from '@arg/ContributionArgs' import Paginated from '@arg/Paginated' import { Order } from '@enum/Order' @@ -11,8 +11,14 @@ import { ContributionType } from '@enum/ContributionType' import { ContributionStatus } from '@enum/ContributionStatus' import { Contribution, ContributionListResult } from '@model/Contribution' import { UnconfirmedContribution } from '@model/UnconfirmedContribution' -import { validateContribution, getUserCreation, updateCreations } from './util/creations' -import { MEMO_MAX_CHARS, MEMO_MIN_CHARS } from './const/const' +import { + getUserCreation, + getUserCreations, + validateContribution, + updateCreations, + isValidDateString, +} from './util/creations' +import { MEMO_MAX_CHARS, MEMO_MIN_CHARS, FULL_CREATION_AVAILABLE } from './const/const' import { ContributionMessage } from '@entity/ContributionMessage' import { ContributionMessageType } from '@enum/MessageType' import { @@ -20,8 +26,26 @@ import { EventContributionCreate, EventContributionDelete, EventContributionUpdate, + EventContributionConfirm, + EventAdminContributionCreate, + EventAdminContributionDelete, + EventAdminContributionUpdate, } from '@/event/Event' import { eventProtocol } from '@/event/EventProtocolEmitter' +import AdminCreateContributionArgs from '@arg/AdminCreateContributionArgs' +import AdminUpdateContributionArgs from '@arg/AdminUpdateContributionArgs' +import Decimal from 'decimal.js-light' +import CONFIG from '@/config' +import { UserContact } from '@entity/UserContact' +import { AdminCreateContributions } from '@model/AdminCreateContributions' +import { AdminUpdateContribution } from '@model/AdminUpdateContribution' +import { User as DbUser } from '@entity/User' +import { sendContributionRejectedEmail } from '@/mailer/sendContributionRejectedEmail' +import { Transaction as DbTransaction } from '@entity/Transaction' +import { Decay } from '@model/Decay' +import { TransactionTypeId } from '@enum/TransactionTypeId' +import { calculateDecay } from '@/util/decay' +import { sendContributionConfirmedEmail } from '@/mailer/sendContributionConfirmedEmail' @Resolver() export class ContributionResolver { @@ -50,7 +74,7 @@ export class ContributionResolver { const creationDateObj = new Date(creationDate) validateContribution(creations, amount, creationDateObj, clientTimezoneOffset) - const contribution = dbContribution.create() + const contribution = DbContribution.create() contribution.userId = user.id contribution.amount = amount contribution.createdAt = new Date() @@ -60,7 +84,7 @@ export class ContributionResolver { contribution.contributionStatus = ContributionStatus.PENDING logger.trace('contribution to save', contribution) - await dbContribution.save(contribution) + await DbContribution.save(contribution) const eventCreateContribution = new EventContributionCreate() eventCreateContribution.userId = user.id @@ -79,7 +103,7 @@ export class ContributionResolver { ): Promise { const event = new Event() const user = getUser(context) - const contribution = await dbContribution.findOne(id) + const contribution = await DbContribution.findOne(id) if (!contribution) { logger.error('Contribution not found for given id') throw new Error('Contribution not found for given id.') @@ -128,7 +152,7 @@ export class ContributionResolver { const [contributions, count] = await getConnection() .createQueryBuilder() .select('c') - .from(dbContribution, 'c') + .from(DbContribution, 'c') .leftJoinAndSelect('c.messages', 'm') .where(where) .withDeleted() @@ -152,7 +176,7 @@ export class ContributionResolver { const [dbContributions, count] = await getConnection() .createQueryBuilder() .select('c') - .from(dbContribution, 'c') + .from(DbContribution, 'c') .innerJoinAndSelect('c.user', 'u') .orderBy('c.createdAt', order) .limit(pageSize) @@ -185,7 +209,7 @@ export class ContributionResolver { const user = getUser(context) - const contributionToUpdate = await dbContribution.findOne({ + const contributionToUpdate = await DbContribution.findOne({ where: { id: contributionId, confirmedAt: IsNull() }, }) if (!contributionToUpdate) { @@ -240,7 +264,7 @@ export class ContributionResolver { contributionToUpdate.contributionDate = new Date(creationDate) contributionToUpdate.contributionStatus = ContributionStatus.PENDING contributionToUpdate.updatedAt = new Date() - dbContribution.save(contributionToUpdate) + DbContribution.save(contributionToUpdate) const event = new Event() @@ -252,4 +276,404 @@ export class ContributionResolver { return new UnconfirmedContribution(contributionToUpdate, user, creations) } + + @Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTION]) + @Mutation(() => [Number]) + async adminCreateContribution( + @Args() { email, amount, memo, creationDate }: AdminCreateContributionArgs, + @Ctx() context: Context, + ): Promise { + logger.info( + `adminCreateContribution(email=${email}, amount=${amount}, memo=${memo}, creationDate=${creationDate})`, + ) + const clientTimezoneOffset = getClientTimezoneOffset(context) + if (!isValidDateString(creationDate)) { + logger.error(`invalid Date for creationDate=${creationDate}`) + throw new Error(`invalid Date for creationDate=${creationDate}`) + } + const emailContact = await UserContact.findOne({ + where: { email }, + withDeleted: true, + relations: ['user'], + }) + if (!emailContact) { + logger.error(`Could not find user with email: ${email}`) + throw new Error(`Could not find user with email: ${email}`) + } + if (emailContact.deletedAt) { + logger.error('This emailContact was deleted. Cannot create a contribution.') + throw new Error('This emailContact was deleted. Cannot create a contribution.') + } + if (emailContact.user.deletedAt) { + logger.error('This user was deleted. Cannot create a contribution.') + throw new Error('This user was deleted. Cannot create a contribution.') + } + if (!emailContact.emailChecked) { + logger.error('Contribution could not be saved, Email is not activated') + throw new Error('Contribution could not be saved, Email is not activated') + } + + const event = new Event() + const moderator = getUser(context) + logger.trace('moderator: ', moderator.id) + const creations = await getUserCreation(emailContact.userId, clientTimezoneOffset) + logger.trace('creations:', creations) + const creationDateObj = new Date(creationDate) + logger.trace('creationDateObj:', creationDateObj) + validateContribution(creations, amount, creationDateObj, clientTimezoneOffset) + const contribution = DbContribution.create() + contribution.userId = emailContact.userId + contribution.amount = amount + contribution.createdAt = new Date() + contribution.contributionDate = creationDateObj + contribution.memo = memo + contribution.moderatorId = moderator.id + contribution.contributionType = ContributionType.ADMIN + contribution.contributionStatus = ContributionStatus.PENDING + + logger.trace('contribution to save', contribution) + + await DbContribution.save(contribution) + + const eventAdminCreateContribution = new EventAdminContributionCreate() + eventAdminCreateContribution.userId = moderator.id + eventAdminCreateContribution.amount = amount + eventAdminCreateContribution.contributionId = contribution.id + await eventProtocol.writeEvent( + event.setEventAdminContributionCreate(eventAdminCreateContribution), + ) + + return getUserCreation(emailContact.userId, clientTimezoneOffset) + } + + @Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTIONS]) + @Mutation(() => AdminCreateContributions) + async adminCreateContributions( + @Arg('pendingCreations', () => [AdminCreateContributionArgs]) + contributions: AdminCreateContributionArgs[], + @Ctx() context: Context, + ): Promise { + let success = false + const successfulContribution: string[] = [] + const failedContribution: string[] = [] + for (const contribution of contributions) { + await this.adminCreateContribution(contribution, context) + .then(() => { + successfulContribution.push(contribution.email) + success = true + }) + .catch(() => { + failedContribution.push(contribution.email) + }) + } + return { + success, + successfulContribution, + failedContribution, + } + } + + @Authorized([RIGHTS.ADMIN_UPDATE_CONTRIBUTION]) + @Mutation(() => AdminUpdateContribution) + async adminUpdateContribution( + @Args() { id, email, amount, memo, creationDate }: AdminUpdateContributionArgs, + @Ctx() context: Context, + ): Promise { + const clientTimezoneOffset = getClientTimezoneOffset(context) + const emailContact = await UserContact.findOne({ + where: { email }, + withDeleted: true, + relations: ['user'], + }) + if (!emailContact) { + logger.error(`Could not find UserContact with email: ${email}`) + throw new Error(`Could not find UserContact with email: ${email}`) + } + const user = emailContact.user + if (!user) { + logger.error(`Could not find User to emailContact: ${email}`) + throw new Error(`Could not find User to emailContact: ${email}`) + } + if (user.deletedAt) { + logger.error(`User was deleted (${email})`) + throw new Error(`User was deleted (${email})`) + } + + const moderator = getUser(context) + + const contributionToUpdate = await DbContribution.findOne({ + where: { id, confirmedAt: IsNull() }, + }) + if (!contributionToUpdate) { + logger.error('No contribution found to given id.') + throw new Error('No contribution found to given id.') + } + + if (contributionToUpdate.userId !== user.id) { + logger.error('user of the pending contribution and send user does not correspond') + throw new Error('user of the pending contribution and send user does not correspond') + } + + if (contributionToUpdate.moderatorId === null) { + logger.error('An admin is not allowed to update a user contribution.') + throw new Error('An admin is not allowed to update a user contribution.') + } + + const creationDateObj = new Date(creationDate) + let creations = await getUserCreation(user.id, clientTimezoneOffset) + + if (contributionToUpdate.contributionDate.getMonth() === creationDateObj.getMonth()) { + creations = updateCreations(creations, contributionToUpdate, clientTimezoneOffset) + } else { + logger.error('Currently the month of the contribution cannot change.') + throw new Error('Currently the month of the contribution cannot change.') + } + + // all possible cases not to be true are thrown in this function + validateContribution(creations, amount, creationDateObj, clientTimezoneOffset) + contributionToUpdate.amount = amount + contributionToUpdate.memo = memo + contributionToUpdate.contributionDate = new Date(creationDate) + contributionToUpdate.moderatorId = moderator.id + contributionToUpdate.contributionStatus = ContributionStatus.PENDING + + await DbContribution.save(contributionToUpdate) + + const result = new AdminUpdateContribution() + result.amount = amount + result.memo = contributionToUpdate.memo + result.date = contributionToUpdate.contributionDate + + result.creation = await getUserCreation(user.id, clientTimezoneOffset) + + const event = new Event() + const eventAdminContributionUpdate = new EventAdminContributionUpdate() + eventAdminContributionUpdate.userId = user.id + eventAdminContributionUpdate.amount = amount + eventAdminContributionUpdate.contributionId = contributionToUpdate.id + await eventProtocol.writeEvent( + event.setEventAdminContributionUpdate(eventAdminContributionUpdate), + ) + + return result + } + + @Authorized([RIGHTS.LIST_UNCONFIRMED_CONTRIBUTIONS]) + @Query(() => [UnconfirmedContribution]) + async listUnconfirmedContributions(@Ctx() context: Context): Promise { + const clientTimezoneOffset = getClientTimezoneOffset(context) + const contributions = await getConnection() + .createQueryBuilder() + .select('c') + .from(DbContribution, 'c') + .leftJoinAndSelect('c.messages', 'm') + .where({ confirmedAt: IsNull() }) + .getMany() + + if (contributions.length === 0) { + return [] + } + + const userIds = contributions.map((p) => p.userId) + const userCreations = await getUserCreations(userIds, clientTimezoneOffset) + const users = await DbUser.find({ + where: { id: In(userIds) }, + withDeleted: true, + relations: ['emailContact'], + }) + + return contributions.map((contribution) => { + const user = users.find((u) => u.id === contribution.userId) + const creation = userCreations.find((c) => c.id === contribution.userId) + + return new UnconfirmedContribution( + contribution, + user, + creation ? creation.creations : FULL_CREATION_AVAILABLE, + ) + }) + } + + @Authorized([RIGHTS.ADMIN_DELETE_CONTRIBUTION]) + @Mutation(() => Boolean) + async adminDeleteContribution( + @Arg('id', () => Int) id: number, + @Ctx() context: Context, + ): Promise { + const contribution = await DbContribution.findOne(id) + if (!contribution) { + logger.error(`Contribution not found for given id: ${id}`) + throw new Error('Contribution not found for given id.') + } + const moderator = getUser(context) + if ( + contribution.contributionType === ContributionType.USER && + contribution.userId === moderator.id + ) { + throw new Error('Own contribution can not be deleted as admin') + } + const user = await DbUser.findOneOrFail( + { id: contribution.userId }, + { relations: ['emailContact'] }, + ) + contribution.contributionStatus = ContributionStatus.DELETED + contribution.deletedBy = moderator.id + await contribution.save() + const res = await contribution.softRemove() + + const event = new Event() + const eventAdminContributionDelete = new EventAdminContributionDelete() + eventAdminContributionDelete.userId = contribution.userId + eventAdminContributionDelete.amount = contribution.amount + eventAdminContributionDelete.contributionId = contribution.id + await eventProtocol.writeEvent( + event.setEventAdminContributionDelete(eventAdminContributionDelete), + ) + sendContributionRejectedEmail({ + 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 + } + + @Authorized([RIGHTS.CONFIRM_CONTRIBUTION]) + @Mutation(() => Boolean) + async confirmContribution( + @Arg('id', () => Int) id: number, + @Ctx() context: Context, + ): Promise { + const clientTimezoneOffset = getClientTimezoneOffset(context) + const contribution = await DbContribution.findOne(id) + if (!contribution) { + logger.error(`Contribution not found for given id: ${id}`) + throw new Error('Contribution not found to given id.') + } + const moderatorUser = getUser(context) + if (moderatorUser.id === contribution.userId) { + logger.error('Moderator can not confirm own contribution') + throw new Error('Moderator can not confirm own contribution') + } + const user = await DbUser.findOneOrFail( + { id: contribution.userId }, + { withDeleted: true, relations: ['emailContact'] }, + ) + if (user.deletedAt) { + logger.error('This user was deleted. Cannot confirm a contribution.') + throw new Error('This user was deleted. Cannot confirm a contribution.') + } + const creations = await getUserCreation(contribution.userId, clientTimezoneOffset, false) + validateContribution( + creations, + contribution.amount, + contribution.contributionDate, + clientTimezoneOffset, + ) + + const receivedCallDate = new Date() + + const queryRunner = getConnection().createQueryRunner() + await queryRunner.connect() + await queryRunner.startTransaction('REPEATABLE READ') // 'READ COMMITTED') + try { + const lastTransaction = await queryRunner.manager + .createQueryBuilder() + .select('transaction') + .from(DbTransaction, 'transaction') + .where('transaction.userId = :id', { id: contribution.userId }) + .orderBy('transaction.balanceDate', 'DESC') + .getOne() + logger.info('lastTransaction ID', lastTransaction ? lastTransaction.id : 'undefined') + + let newBalance = new Decimal(0) + let decay: Decay | null = null + if (lastTransaction) { + decay = calculateDecay( + lastTransaction.balance, + lastTransaction.balanceDate, + receivedCallDate, + ) + newBalance = decay.balance + } + newBalance = newBalance.add(contribution.amount.toString()) + + const transaction = new DbTransaction() + transaction.typeId = TransactionTypeId.CREATION + transaction.memo = contribution.memo + transaction.userId = contribution.userId + transaction.previous = lastTransaction ? lastTransaction.id : null + transaction.amount = contribution.amount + transaction.creationDate = contribution.contributionDate + transaction.balance = newBalance + transaction.balanceDate = receivedCallDate + transaction.decay = decay ? decay.decay : new Decimal(0) + transaction.decayStart = decay ? decay.start : null + await queryRunner.manager.insert(DbTransaction, transaction) + + contribution.confirmedAt = receivedCallDate + contribution.confirmedBy = moderatorUser.id + contribution.transactionId = transaction.id + contribution.contributionStatus = ContributionStatus.CONFIRMED + await queryRunner.manager.update(DbContribution, { id: contribution.id }, contribution) + + await queryRunner.commitTransaction() + logger.info('creation commited successfuly.') + sendContributionConfirmedEmail({ + 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() + logger.error(`Creation was not successful: ${e}`) + throw new Error(`Creation was not successful.`) + } finally { + await queryRunner.release() + } + + const event = new Event() + const eventContributionConfirm = new EventContributionConfirm() + eventContributionConfirm.userId = user.id + eventContributionConfirm.amount = contribution.amount + eventContributionConfirm.contributionId = contribution.id + await eventProtocol.writeEvent(event.setEventContributionConfirm(eventContributionConfirm)) + return true + } + + @Authorized([RIGHTS.CREATION_TRANSACTION_LIST]) + @Query(() => ContributionListResult) + async creationTransactionList( + @Args() + { currentPage = 1, pageSize = 25, order = Order.DESC }: Paginated, + @Arg('userId', () => Int) userId: number, + ): Promise { + const offset = (currentPage - 1) * pageSize + const [contributionResult, count] = await getConnection() + .createQueryBuilder() + .select('c') + .from(DbContribution, 'c') + .leftJoinAndSelect('c.user', 'u') + .where(`user_id = ${userId}`) + .limit(pageSize) + .offset(offset) + .orderBy('c.created_at', order) + .getManyAndCount() + + return new ContributionListResult( + count, + contributionResult.map((contribution) => new Contribution(contribution, contribution.user)), + ) + // return userTransactions.map((t) => new Transaction(t, new User(user), communityUser)) + } } diff --git a/backend/src/graphql/resolver/TransactionLinkResolver.ts b/backend/src/graphql/resolver/TransactionLinkResolver.ts index a5c4a5f01..9de8efa35 100644 --- a/backend/src/graphql/resolver/TransactionLinkResolver.ts +++ b/backend/src/graphql/resolver/TransactionLinkResolver.ts @@ -1,6 +1,6 @@ import { backendLogger as logger } from '@/server/logger' import { Context, getUser, getClientTimezoneOffset } from '@/server/context' -import { getConnection } from '@dbTools/typeorm' +import { getConnection, MoreThan, FindOperator, IsNull } from '@dbTools/typeorm' import { Resolver, Args, @@ -12,9 +12,8 @@ import { Int, createUnionType, } from 'type-graphql' -import { TransactionLink } from '@model/TransactionLink' import { ContributionLink } from '@model/ContributionLink' -import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink' +import { TransactionLink as DbTransactionLink } from '@entity/TransactionLink' import { Transaction as DbTransaction } from '@entity/Transaction' import { User as dbUser } from '@entity/User' import TransactionLinkArgs from '@arg/TransactionLinkArgs' @@ -30,11 +29,22 @@ import { ContributionType } from '@enum/ContributionType' import { ContributionStatus } from '@enum/ContributionStatus' import { Contribution as DbContribution } from '@entity/Contribution' import { ContributionLink as DbContributionLink } from '@entity/ContributionLink' -import { getUserCreation, validateContribution } from './util/creations' +import { getUserCreation, validateContribution, isStartEndDateValid } from './util/creations' import { Decay } from '@model/Decay' import Decimal from 'decimal.js-light' import { TransactionTypeId } from '@enum/TransactionTypeId' import { ContributionCycleType } from '@enum/ContributionCycleType' +import { TransactionLink, TransactionLinkResult } from '@model/TransactionLink' +import TransactionLinkFilters from '@arg/TransactionLinkFilters' +import { + CONTRIBUTIONLINK_NAME_MAX_CHARS, + CONTRIBUTIONLINK_NAME_MIN_CHARS, + MEMO_MAX_CHARS, + MEMO_MIN_CHARS, +} from './const/const' +import ContributionLinkArgs from '@arg/ContributionLinkArgs' +import { transactionLinkCode as contributionLinkCode } from './TransactionLinkResolver' +import { ContributionLinkList } from '@model/ContributionLinkList' const QueryLinkResult = createUnionType({ name: 'QueryLinkResult', // the name of the GraphQL union @@ -76,7 +86,7 @@ export class TransactionLinkResolver { // validate amount await calculateBalance(user.id, holdAvailableAmount, createdDate) - const transactionLink = dbTransactionLink.create() + const transactionLink = DbTransactionLink.create() transactionLink.userId = user.id transactionLink.amount = amount transactionLink.memo = memo @@ -84,7 +94,7 @@ export class TransactionLinkResolver { transactionLink.code = transactionLinkCode(createdDate) transactionLink.createdAt = createdDate transactionLink.validUntil = validUntil - await dbTransactionLink.save(transactionLink).catch(() => { + await DbTransactionLink.save(transactionLink).catch(() => { throw new Error('Unable to save transaction link') }) @@ -99,7 +109,7 @@ export class TransactionLinkResolver { ): Promise { const user = getUser(context) - const transactionLink = await dbTransactionLink.findOne({ id }) + const transactionLink = await DbTransactionLink.findOne({ id }) if (!transactionLink) { throw new Error('Transaction Link not found!') } @@ -129,7 +139,7 @@ export class TransactionLinkResolver { ) return new ContributionLink(contributionLink) } else { - const transactionLink = await dbTransactionLink.findOneOrFail({ code }, { withDeleted: true }) + const transactionLink = await DbTransactionLink.findOneOrFail({ code }, { withDeleted: true }) const user = await dbUser.findOneOrFail({ id: transactionLink.userId }) let redeemedBy: User | null = null if (transactionLink && transactionLink.redeemedBy) { @@ -148,7 +158,7 @@ export class TransactionLinkResolver { ): Promise { const user = getUser(context) // const now = new Date() - const transactionLinks = await dbTransactionLink.find({ + const transactionLinks = await DbTransactionLink.find({ where: { userId: user.id, redeemedBy: null, @@ -318,7 +328,7 @@ export class TransactionLinkResolver { } return true } else { - const transactionLink = await dbTransactionLink.findOneOrFail({ code }) + const transactionLink = await DbTransactionLink.findOneOrFail({ code }) const linkedUser = await dbUser.findOneOrFail( { id: transactionLink.userId }, { relations: ['emailContact'] }, @@ -347,4 +357,171 @@ export class TransactionLinkResolver { return true } } + + @Authorized([RIGHTS.LIST_TRANSACTION_LINKS_ADMIN]) + @Query(() => TransactionLinkResult) + async listTransactionLinksAdmin( + @Args() + { currentPage = 1, pageSize = 5, order = Order.DESC }: Paginated, + @Arg('filters', () => TransactionLinkFilters, { nullable: true }) + filters: TransactionLinkFilters, + @Arg('userId', () => Int) + userId: number, + ): Promise { + const user = await dbUser.findOneOrFail({ id: userId }) + const where: { + userId: number + redeemedBy?: number | null + validUntil?: FindOperator | null + } = { + userId, + redeemedBy: null, + validUntil: MoreThan(new Date()), + } + if (filters) { + if (filters.withRedeemed) delete where.redeemedBy + if (filters.withExpired) delete where.validUntil + } + const [transactionLinks, count] = await DbTransactionLink.findAndCount({ + where, + withDeleted: filters ? filters.withDeleted : false, + order: { + createdAt: order, + }, + skip: (currentPage - 1) * pageSize, + take: pageSize, + }) + + return { + linkCount: count, + linkList: transactionLinks.map((tl) => new TransactionLink(tl, new User(user))), + } + } + + @Authorized([RIGHTS.CREATE_CONTRIBUTION_LINK]) + @Mutation(() => ContributionLink) + async createContributionLink( + @Args() + { + amount, + name, + memo, + cycle, + validFrom, + validTo, + maxAmountPerMonth, + maxPerCycle, + }: ContributionLinkArgs, + ): Promise { + isStartEndDateValid(validFrom, validTo) + if (!name) { + logger.error(`The name must be initialized!`) + throw new Error(`The name must be initialized!`) + } + if ( + name.length < CONTRIBUTIONLINK_NAME_MIN_CHARS || + name.length > CONTRIBUTIONLINK_NAME_MAX_CHARS + ) { + const msg = `The value of 'name' with a length of ${name.length} did not fulfill the requested bounderies min=${CONTRIBUTIONLINK_NAME_MIN_CHARS} and max=${CONTRIBUTIONLINK_NAME_MAX_CHARS}` + logger.error(`${msg}`) + throw new Error(`${msg}`) + } + if (!memo) { + logger.error(`The memo must be initialized!`) + throw new Error(`The memo must be initialized!`) + } + if (memo.length < MEMO_MIN_CHARS || memo.length > MEMO_MAX_CHARS) { + const msg = `The value of 'memo' with a length of ${memo.length} did not fulfill the requested bounderies min=${MEMO_MIN_CHARS} and max=${MEMO_MAX_CHARS}` + logger.error(`${msg}`) + throw new Error(`${msg}`) + } + if (!amount) { + logger.error(`The amount must be initialized!`) + throw new Error('The amount must be initialized!') + } + if (!new Decimal(amount).isPositive()) { + logger.error(`The amount=${amount} must be initialized with a positiv value!`) + throw new Error(`The amount=${amount} must be initialized with a positiv value!`) + } + const dbContributionLink = new DbContributionLink() + dbContributionLink.amount = amount + dbContributionLink.name = name + dbContributionLink.memo = memo + dbContributionLink.createdAt = new Date() + dbContributionLink.code = contributionLinkCode(dbContributionLink.createdAt) + dbContributionLink.cycle = cycle + if (validFrom) dbContributionLink.validFrom = new Date(validFrom) + if (validTo) dbContributionLink.validTo = new Date(validTo) + dbContributionLink.maxAmountPerMonth = maxAmountPerMonth + dbContributionLink.maxPerCycle = maxPerCycle + await dbContributionLink.save() + logger.debug(`createContributionLink successful!`) + return new ContributionLink(dbContributionLink) + } + + @Authorized([RIGHTS.LIST_CONTRIBUTION_LINKS]) + @Query(() => ContributionLinkList) + async listContributionLinks( + @Args() + { currentPage = 1, pageSize = 5, order = Order.DESC }: Paginated, + ): Promise { + const [links, count] = await DbContributionLink.findAndCount({ + where: [{ validTo: MoreThan(new Date()) }, { validTo: IsNull() }], + order: { createdAt: order }, + skip: (currentPage - 1) * pageSize, + take: pageSize, + }) + return { + links: links.map((link: DbContributionLink) => new ContributionLink(link)), + count, + } + } + + @Authorized([RIGHTS.DELETE_CONTRIBUTION_LINK]) + @Mutation(() => Date, { nullable: true }) + async deleteContributionLink(@Arg('id', () => Int) id: number): Promise { + const contributionLink = await DbContributionLink.findOne(id) + if (!contributionLink) { + logger.error(`Contribution Link not found to given id: ${id}`) + throw new Error('Contribution Link not found to given id.') + } + await contributionLink.softRemove() + logger.debug(`deleteContributionLink successful!`) + const newContributionLink = await DbContributionLink.findOne({ id }, { withDeleted: true }) + return newContributionLink ? newContributionLink.deletedAt : null + } + + @Authorized([RIGHTS.UPDATE_CONTRIBUTION_LINK]) + @Mutation(() => ContributionLink) + async updateContributionLink( + @Args() + { + amount, + name, + memo, + cycle, + validFrom, + validTo, + maxAmountPerMonth, + maxPerCycle, + }: ContributionLinkArgs, + @Arg('id', () => Int) id: number, + ): Promise { + const dbContributionLink = await DbContributionLink.findOne(id) + if (!dbContributionLink) { + logger.error(`Contribution Link not found to given id: ${id}`) + throw new Error('Contribution Link not found to given id.') + } + dbContributionLink.amount = amount + dbContributionLink.name = name + dbContributionLink.memo = memo + dbContributionLink.cycle = cycle + if (validFrom) dbContributionLink.validFrom = new Date(validFrom) + if (validTo) dbContributionLink.validTo = new Date(validTo) + dbContributionLink.maxAmountPerMonth = maxAmountPerMonth + dbContributionLink.maxPerCycle = maxPerCycle + await dbContributionLink.save() + logger.debug(`updateContributionLink successful!`) + return new ContributionLink(dbContributionLink) + } } diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index 707b7ac49..067b7a0d4 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -2,7 +2,17 @@ import fs from 'fs' import { backendLogger as logger } from '@/server/logger' import i18n from 'i18n' import { Context, getUser, getClientTimezoneOffset } from '@/server/context' -import { Resolver, Query, Args, Arg, Authorized, Ctx, UseMiddleware, Mutation } from 'type-graphql' +import { + Resolver, + Query, + Args, + Arg, + Authorized, + Ctx, + UseMiddleware, + Mutation, + Int, +} from 'type-graphql' import { getConnection, getCustomRepository, IsNull, Not } from '@dbTools/typeorm' import CONFIG from '@/config' import { User } from '@model/User' @@ -33,13 +43,16 @@ import { EventSendConfirmationEmail, EventActivateAccount, } from '@/event/Event' -import { getUserCreation } from './util/creations' +import { getUserCreation, getUserCreations } from './util/creations' import { UserContactType } from '../enum/UserContactType' import { UserRepository } from '@/typeorm/repository/User' import { SearchAdminUsersResult } from '@model/AdminUser' +import { UserAdmin, SearchUsersResult } from '@model/UserAdmin' import Paginated from '@arg/Paginated' import { Order } from '@enum/Order' import { v4 as uuidv4 } from 'uuid' +import SearchUsersArgs from '@arg/SearchUsersArgs' +import { FULL_CREATION_AVAILABLE } from './const/const' // eslint-disable-next-line @typescript-eslint/no-var-requires const sodium = require('sodium-native') @@ -895,6 +908,202 @@ export class UserResolver { }), } } + + @Authorized([RIGHTS.SEARCH_USERS]) + @Query(() => SearchUsersResult) + async searchUsers( + @Args() + { searchText, currentPage = 1, pageSize = 25, filters }: SearchUsersArgs, + @Ctx() context: Context, + ): Promise { + const clientTimezoneOffset = getClientTimezoneOffset(context) + const userRepository = getCustomRepository(UserRepository) + const userFields = [ + 'id', + 'firstName', + 'lastName', + 'emailId', + 'emailContact', + 'deletedAt', + 'isAdmin', + ] + const [users, count] = await userRepository.findBySearchCriteriaPagedFiltered( + userFields.map((fieldName) => { + return 'user.' + fieldName + }), + searchText, + filters, + currentPage, + pageSize, + ) + + if (users.length === 0) { + return { + userCount: 0, + userList: [], + } + } + + const creations = await getUserCreations( + users.map((u) => u.id), + clientTimezoneOffset, + ) + + const adminUsers = await Promise.all( + users.map(async (user) => { + let emailConfirmationSend = '' + if (!user.emailContact.emailChecked) { + if (user.emailContact.updatedAt) { + emailConfirmationSend = user.emailContact.updatedAt.toISOString() + } else { + emailConfirmationSend = user.emailContact.createdAt.toISOString() + } + } + const userCreations = creations.find((c) => c.id === user.id) + const adminUser = new UserAdmin( + user, + userCreations ? userCreations.creations : FULL_CREATION_AVAILABLE, + await hasElopageBuys(user.emailContact.email), + emailConfirmationSend, + ) + return adminUser + }), + ) + return { + userCount: count, + userList: adminUsers, + } + } + + @Authorized([RIGHTS.SET_USER_ROLE]) + @Mutation(() => Date, { nullable: true }) + async setUserRole( + @Arg('userId', () => Int) + userId: number, + @Arg('isAdmin', () => Boolean) + isAdmin: boolean, + @Ctx() + context: Context, + ): Promise { + const user = await DbUser.findOne({ id: userId }) + // user exists ? + if (!user) { + logger.error(`Could not find user with userId: ${userId}`) + throw new Error(`Could not find user with userId: ${userId}`) + } + // administrator user changes own role? + const moderatorUser = getUser(context) + if (moderatorUser.id === userId) { + logger.error('Administrator can not change his own role!') + throw new Error('Administrator can not change his own role!') + } + // change isAdmin + switch (user.isAdmin) { + case null: + if (isAdmin === true) { + user.isAdmin = new Date() + } else { + logger.error('User is already a usual user!') + throw new Error('User is already a usual user!') + } + break + default: + if (isAdmin === false) { + user.isAdmin = null + } else { + logger.error('User is already admin!') + throw new Error('User is already admin!') + } + break + } + await user.save() + const newUser = await DbUser.findOne({ id: userId }) + return newUser ? newUser.isAdmin : null + } + + @Authorized([RIGHTS.DELETE_USER]) + @Mutation(() => Date, { nullable: true }) + async deleteUser( + @Arg('userId', () => Int) userId: number, + @Ctx() context: Context, + ): Promise { + const user = await DbUser.findOne({ id: userId }) + // user exists ? + if (!user) { + logger.error(`Could not find user with userId: ${userId}`) + throw new Error(`Could not find user with userId: ${userId}`) + } + // moderator user disabled own account? + const moderatorUser = getUser(context) + if (moderatorUser.id === userId) { + logger.error('Moderator can not delete his own account!') + throw new Error('Moderator can not delete his own account!') + } + // soft-delete user + await user.softRemove() + const newUser = await DbUser.findOne({ id: userId }, { withDeleted: true }) + return newUser ? newUser.deletedAt : null + } + + @Authorized([RIGHTS.UNDELETE_USER]) + @Mutation(() => Date, { nullable: true }) + async unDeleteUser(@Arg('userId', () => Int) userId: number): Promise { + const user = await DbUser.findOne({ id: userId }, { withDeleted: true }) + if (!user) { + logger.error(`Could not find user with userId: ${userId}`) + throw new Error(`Could not find user with userId: ${userId}`) + } + if (!user.deletedAt) { + logger.error('User is not deleted') + throw new Error('User is not deleted') + } + await user.recover() + return null + } + + @Authorized([RIGHTS.SEND_ACTIVATION_EMAIL]) + @Mutation(() => Boolean) + async sendActivationEmail(@Arg('email') email: string): Promise { + email = email.trim().toLowerCase() + // const user = await dbUser.findOne({ id: emailContact.userId }) + const user = await findUserByEmail(email) + if (!user) { + logger.error(`Could not find User to emailContact: ${email}`) + throw new Error(`Could not find User to emailContact: ${email}`) + } + if (user.deletedAt) { + logger.error(`User with emailContact: ${email} is deleted.`) + throw new Error(`User with emailContact: ${email} is deleted.`) + } + 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.`) + } + + // 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), + }) + + // In case EMails are disabled log the activation link for the user + if (!emailSent) { + logger.info(`Account confirmation link: ${activationLink}`) + } else { + const event = new Event() + const eventSendConfirmationEmail = new EventSendConfirmationEmail() + eventSendConfirmationEmail.userId = user.id + await eventProtocol.writeEvent( + event.setEventSendConfirmationEmail(eventSendConfirmationEmail), + ) + } + + return true + } } export async function findUserByEmail(email: string): Promise { From 4554b01b892a65ac3908f9bc3f1f55f52bbffb70 Mon Sep 17 00:00:00 2001 From: Ulf Gebhardt Date: Tue, 22 Nov 2022 13:01:38 +0100 Subject: [PATCH 13/90] order and correct imports of resolvers --- .../src/graphql/resolver/BalanceResolver.ts | 19 +++--- .../src/graphql/resolver/CommunityResolver.ts | 4 +- .../resolver/ContributionMessageResolver.ts | 25 ++++---- .../graphql/resolver/ContributionResolver.ts | 43 +++++++------- backend/src/graphql/resolver/GdtResolver.ts | 10 ++-- .../src/graphql/resolver/KlicktippResolver.ts | 4 +- .../graphql/resolver/StatisticsResolver.ts | 15 +++-- .../resolver/TransactionLinkResolver.ts | 58 ++++++++++--------- .../graphql/resolver/TransactionResolver.ts | 41 ++++++------- backend/src/graphql/resolver/UserResolver.ts | 41 +++++++------ 10 files changed, 140 insertions(+), 120 deletions(-) diff --git a/backend/src/graphql/resolver/BalanceResolver.ts b/backend/src/graphql/resolver/BalanceResolver.ts index 176b45354..a0016e8f2 100644 --- a/backend/src/graphql/resolver/BalanceResolver.ts +++ b/backend/src/graphql/resolver/BalanceResolver.ts @@ -1,16 +1,19 @@ -import { backendLogger as logger } from '@/server/logger' - -import { Context, getUser } from '@/server/context' +import Decimal from 'decimal.js-light' import { Resolver, Query, Ctx, Authorized } from 'type-graphql' +import { getCustomRepository } from '@dbTools/typeorm' + +import { Transaction as dbTransaction } from '@entity/Transaction' +import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink' + +import { TransactionLinkRepository } from '@repository/TransactionLink' + import { Balance } from '@model/Balance' + +import { backendLogger as logger } from '@/server/logger' +import { Context, getUser } from '@/server/context' import { calculateDecay } from '@/util/decay' import { RIGHTS } from '@/auth/RIGHTS' -import { Transaction as dbTransaction } from '@entity/Transaction' -import Decimal from 'decimal.js-light' import { GdtResolver } from './GdtResolver' -import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink' -import { getCustomRepository } from '@dbTools/typeorm' -import { TransactionLinkRepository } from '@repository/TransactionLink' @Resolver() export class BalanceResolver { diff --git a/backend/src/graphql/resolver/CommunityResolver.ts b/backend/src/graphql/resolver/CommunityResolver.ts index c194cdf1a..f56254e1f 100644 --- a/backend/src/graphql/resolver/CommunityResolver.ts +++ b/backend/src/graphql/resolver/CommunityResolver.ts @@ -1,7 +1,9 @@ import { Resolver, Query, Authorized } from 'type-graphql' + +import { Community } from '@model/Community' + import { RIGHTS } from '@/auth/RIGHTS' import CONFIG from '@/config' -import { Community } from '@model/Community' @Resolver() export class CommunityResolver { diff --git a/backend/src/graphql/resolver/ContributionMessageResolver.ts b/backend/src/graphql/resolver/ContributionMessageResolver.ts index 84eccf5ca..1f47a14d6 100644 --- a/backend/src/graphql/resolver/ContributionMessageResolver.ts +++ b/backend/src/graphql/resolver/ContributionMessageResolver.ts @@ -1,17 +1,20 @@ +import { Arg, Args, Authorized, Ctx, Mutation, Query, Resolver } from 'type-graphql' +import { getConnection } from '@dbTools/typeorm' + +import { ContributionMessage as DbContributionMessage } from '@entity/ContributionMessage' +import { Contribution as DbContribution } from '@entity/Contribution' +import { UserContact } from '@entity/UserContact' + +import { ContributionMessage, ContributionMessageListResult } from '@model/ContributionMessage' +import ContributionMessageArgs from '@arg/ContributionMessageArgs' +import { ContributionMessageType } from '@enum/MessageType' +import { ContributionStatus } from '@enum/ContributionStatus' +import { Order } from '@enum/Order' +import Paginated from '@arg/Paginated' + import { backendLogger as logger } from '@/server/logger' import { RIGHTS } from '@/auth/RIGHTS' import { Context, getUser } from '@/server/context' -import { ContributionMessage as DbContributionMessage } from '@entity/ContributionMessage' -import { Arg, Args, Authorized, Ctx, Mutation, Query, Resolver } from 'type-graphql' -import ContributionMessageArgs from '@arg/ContributionMessageArgs' -import { Contribution as DbContribution } from '@entity/Contribution' -import { ContributionMessageType } from '@enum/MessageType' -import { ContributionStatus } from '@enum/ContributionStatus' -import { getConnection } from '@dbTools/typeorm' -import { ContributionMessage, ContributionMessageListResult } from '@model/ContributionMessage' -import Paginated from '@arg/Paginated' -import { Order } from '@enum/Order' -import { UserContact } from '@entity/UserContact' import { sendAddedContributionMessageEmail } from '@/mailer/sendAddedContributionMessageEmail' import CONFIG from '@/config' diff --git a/backend/src/graphql/resolver/ContributionResolver.ts b/backend/src/graphql/resolver/ContributionResolver.ts index 6d0716915..d3e72c2ff 100644 --- a/backend/src/graphql/resolver/ContributionResolver.ts +++ b/backend/src/graphql/resolver/ContributionResolver.ts @@ -1,16 +1,31 @@ -import { RIGHTS } from '@/auth/RIGHTS' -import { Context, getUser, getClientTimezoneOffset } from '@/server/context' -import { backendLogger as logger } from '@/server/logger' -import { Contribution as DbContribution } from '@entity/Contribution' +import Decimal from 'decimal.js-light' import { Arg, Args, Authorized, Ctx, Int, Mutation, Query, Resolver } from 'type-graphql' import { FindOperator, IsNull, In, getConnection } from '@dbTools/typeorm' -import ContributionArgs from '@arg/ContributionArgs' -import Paginated from '@arg/Paginated' + +import { Contribution as DbContribution } from '@entity/Contribution' +import { ContributionMessage } from '@entity/ContributionMessage' +import { UserContact } from '@entity/UserContact' +import { User as DbUser } from '@entity/User' +import { Transaction as DbTransaction } from '@entity/Transaction' + +import { AdminCreateContributions } from '@model/AdminCreateContributions' +import { AdminUpdateContribution } from '@model/AdminUpdateContribution' +import { Contribution, ContributionListResult } from '@model/Contribution' +import { UnconfirmedContribution } from '@model/UnconfirmedContribution' +import { Decay } from '@model/Decay' +import { TransactionTypeId } from '@enum/TransactionTypeId' import { Order } from '@enum/Order' import { ContributionType } from '@enum/ContributionType' import { ContributionStatus } from '@enum/ContributionStatus' -import { Contribution, ContributionListResult } from '@model/Contribution' -import { UnconfirmedContribution } from '@model/UnconfirmedContribution' +import { ContributionMessageType } from '@enum/MessageType' +import ContributionArgs from '@arg/ContributionArgs' +import Paginated from '@arg/Paginated' +import AdminCreateContributionArgs from '@arg/AdminCreateContributionArgs' +import AdminUpdateContributionArgs from '@arg/AdminUpdateContributionArgs' + +import { RIGHTS } from '@/auth/RIGHTS' +import { Context, getUser, getClientTimezoneOffset } from '@/server/context' +import { backendLogger as logger } from '@/server/logger' import { getUserCreation, getUserCreations, @@ -19,8 +34,6 @@ import { isValidDateString, } from './util/creations' import { MEMO_MAX_CHARS, MEMO_MIN_CHARS, FULL_CREATION_AVAILABLE } from './const/const' -import { ContributionMessage } from '@entity/ContributionMessage' -import { ContributionMessageType } from '@enum/MessageType' import { Event, EventContributionCreate, @@ -32,18 +45,8 @@ import { EventAdminContributionUpdate, } from '@/event/Event' import { eventProtocol } from '@/event/EventProtocolEmitter' -import AdminCreateContributionArgs from '@arg/AdminCreateContributionArgs' -import AdminUpdateContributionArgs from '@arg/AdminUpdateContributionArgs' -import Decimal from 'decimal.js-light' import CONFIG from '@/config' -import { UserContact } from '@entity/UserContact' -import { AdminCreateContributions } from '@model/AdminCreateContributions' -import { AdminUpdateContribution } from '@model/AdminUpdateContribution' -import { User as DbUser } from '@entity/User' import { sendContributionRejectedEmail } from '@/mailer/sendContributionRejectedEmail' -import { Transaction as DbTransaction } from '@entity/Transaction' -import { Decay } from '@model/Decay' -import { TransactionTypeId } from '@enum/TransactionTypeId' import { calculateDecay } from '@/util/decay' import { sendContributionConfirmedEmail } from '@/mailer/sendContributionConfirmedEmail' diff --git a/backend/src/graphql/resolver/GdtResolver.ts b/backend/src/graphql/resolver/GdtResolver.ts index a1d75e946..6f9691cd9 100644 --- a/backend/src/graphql/resolver/GdtResolver.ts +++ b/backend/src/graphql/resolver/GdtResolver.ts @@ -1,10 +1,12 @@ -import { Context, getUser } from '@/server/context' import { Resolver, Query, Args, Ctx, Authorized, Arg } from 'type-graphql' -import CONFIG from '@/config' + import { GdtEntryList } from '@model/GdtEntryList' -import Paginated from '@arg/Paginated' -import { apiGet, apiPost } from '@/apis/HttpRequest' import { Order } from '@enum/Order' +import Paginated from '@arg/Paginated' + +import { Context, getUser } from '@/server/context' +import CONFIG from '@/config' +import { apiGet, apiPost } from '@/apis/HttpRequest' import { RIGHTS } from '@/auth/RIGHTS' @Resolver() diff --git a/backend/src/graphql/resolver/KlicktippResolver.ts b/backend/src/graphql/resolver/KlicktippResolver.ts index ce9a097e2..4f88ccdc1 100644 --- a/backend/src/graphql/resolver/KlicktippResolver.ts +++ b/backend/src/graphql/resolver/KlicktippResolver.ts @@ -1,4 +1,7 @@ import { Resolver, Query, Authorized, Arg, Mutation, Args } from 'type-graphql' + +import SubscribeNewsletterArgs from '@arg/SubscribeNewsletterArgs' + import { getKlickTippUser, getKlicktippTagMap, @@ -6,7 +9,6 @@ import { klicktippSignIn, } from '@/apis/KlicktippController' import { RIGHTS } from '@/auth/RIGHTS' -import SubscribeNewsletterArgs from '@arg/SubscribeNewsletterArgs' @Resolver() export class KlicktippResolver { diff --git a/backend/src/graphql/resolver/StatisticsResolver.ts b/backend/src/graphql/resolver/StatisticsResolver.ts index 7bfae319e..f6c2b9e22 100644 --- a/backend/src/graphql/resolver/StatisticsResolver.ts +++ b/backend/src/graphql/resolver/StatisticsResolver.ts @@ -1,10 +1,13 @@ -import { Resolver, Query, Authorized } from 'type-graphql' -import { RIGHTS } from '@/auth/RIGHTS' -import { CommunityStatistics } from '@model/CommunityStatistics' -import { User as DbUser } from '@entity/User' -import { Transaction as DbTransaction } from '@entity/Transaction' -import { getConnection } from '@dbTools/typeorm' import Decimal from 'decimal.js-light' +import { Resolver, Query, Authorized } from 'type-graphql' +import { getConnection } from '@dbTools/typeorm' + +import { Transaction as DbTransaction } from '@entity/Transaction' +import { User as DbUser } from '@entity/User' + +import { CommunityStatistics } from '@model/CommunityStatistics' + +import { RIGHTS } from '@/auth/RIGHTS' import { calculateDecay } from '@/util/decay' /* eslint-disable @typescript-eslint/no-explicit-any */ diff --git a/backend/src/graphql/resolver/TransactionLinkResolver.ts b/backend/src/graphql/resolver/TransactionLinkResolver.ts index 9de8efa35..297a96ce9 100644 --- a/backend/src/graphql/resolver/TransactionLinkResolver.ts +++ b/backend/src/graphql/resolver/TransactionLinkResolver.ts @@ -1,6 +1,31 @@ +import { randomBytes } from 'crypto' +import Decimal from 'decimal.js-light' + +import { getConnection, MoreThan, FindOperator, IsNull } from '@dbTools/typeorm' + +import { TransactionLink as DbTransactionLink } from '@entity/TransactionLink' +import { User as DbUser } from '@entity/User' +import { Transaction as DbTransaction } from '@entity/Transaction' +import { Contribution as DbContribution } from '@entity/Contribution' +import { ContributionLink as DbContributionLink } from '@entity/ContributionLink' + +import { User } from '@model/User' +import { ContributionLink } from '@model/ContributionLink' +import { Decay } from '@model/Decay' +import { TransactionLink, TransactionLinkResult } from '@model/TransactionLink' +import { ContributionLinkList } from '@model/ContributionLinkList' +import { Order } from '@enum/Order' +import { ContributionType } from '@enum/ContributionType' +import { ContributionStatus } from '@enum/ContributionStatus' +import { TransactionTypeId } from '@enum/TransactionTypeId' +import { ContributionCycleType } from '@enum/ContributionCycleType' +import TransactionLinkArgs from '@arg/TransactionLinkArgs' +import Paginated from '@arg/Paginated' +import TransactionLinkFilters from '@arg/TransactionLinkFilters' +import ContributionLinkArgs from '@arg/ContributionLinkArgs' + import { backendLogger as logger } from '@/server/logger' import { Context, getUser, getClientTimezoneOffset } from '@/server/context' -import { getConnection, MoreThan, FindOperator, IsNull } from '@dbTools/typeorm' import { Resolver, Args, @@ -12,39 +37,18 @@ import { Int, createUnionType, } from 'type-graphql' -import { ContributionLink } from '@model/ContributionLink' -import { TransactionLink as DbTransactionLink } from '@entity/TransactionLink' -import { Transaction as DbTransaction } from '@entity/Transaction' -import { User as dbUser } from '@entity/User' -import TransactionLinkArgs from '@arg/TransactionLinkArgs' -import Paginated from '@arg/Paginated' import { calculateBalance } from '@/util/validate' import { RIGHTS } from '@/auth/RIGHTS' -import { randomBytes } from 'crypto' -import { User } from '@model/User' import { calculateDecay } from '@/util/decay' -import { executeTransaction } from './TransactionResolver' -import { Order } from '@enum/Order' -import { ContributionType } from '@enum/ContributionType' -import { ContributionStatus } from '@enum/ContributionStatus' -import { Contribution as DbContribution } from '@entity/Contribution' -import { ContributionLink as DbContributionLink } from '@entity/ContributionLink' import { getUserCreation, validateContribution, isStartEndDateValid } from './util/creations' -import { Decay } from '@model/Decay' -import Decimal from 'decimal.js-light' -import { TransactionTypeId } from '@enum/TransactionTypeId' -import { ContributionCycleType } from '@enum/ContributionCycleType' -import { TransactionLink, TransactionLinkResult } from '@model/TransactionLink' -import TransactionLinkFilters from '@arg/TransactionLinkFilters' import { CONTRIBUTIONLINK_NAME_MAX_CHARS, CONTRIBUTIONLINK_NAME_MIN_CHARS, MEMO_MAX_CHARS, MEMO_MIN_CHARS, } from './const/const' -import ContributionLinkArgs from '@arg/ContributionLinkArgs' +import { executeTransaction } from './TransactionResolver' import { transactionLinkCode as contributionLinkCode } from './TransactionLinkResolver' -import { ContributionLinkList } from '@model/ContributionLinkList' const QueryLinkResult = createUnionType({ name: 'QueryLinkResult', // the name of the GraphQL union @@ -140,10 +144,10 @@ export class TransactionLinkResolver { return new ContributionLink(contributionLink) } else { const transactionLink = await DbTransactionLink.findOneOrFail({ code }, { withDeleted: true }) - const user = await dbUser.findOneOrFail({ id: transactionLink.userId }) + const user = await DbUser.findOneOrFail({ id: transactionLink.userId }) let redeemedBy: User | null = null if (transactionLink && transactionLink.redeemedBy) { - redeemedBy = new User(await dbUser.findOneOrFail({ id: transactionLink.redeemedBy })) + redeemedBy = new User(await DbUser.findOneOrFail({ id: transactionLink.redeemedBy })) } return new TransactionLink(transactionLink, new User(user), redeemedBy) } @@ -329,7 +333,7 @@ export class TransactionLinkResolver { return true } else { const transactionLink = await DbTransactionLink.findOneOrFail({ code }) - const linkedUser = await dbUser.findOneOrFail( + const linkedUser = await DbUser.findOneOrFail( { id: transactionLink.userId }, { relations: ['emailContact'] }, ) @@ -368,7 +372,7 @@ export class TransactionLinkResolver { @Arg('userId', () => Int) userId: number, ): Promise { - const user = await dbUser.findOneOrFail({ id: userId }) + const user = await DbUser.findOneOrFail({ id: userId }) const where: { userId: number redeemedBy?: number | null diff --git a/backend/src/graphql/resolver/TransactionResolver.ts b/backend/src/graphql/resolver/TransactionResolver.ts index f0fb2f452..57fe3bd3c 100644 --- a/backend/src/graphql/resolver/TransactionResolver.ts +++ b/backend/src/graphql/resolver/TransactionResolver.ts @@ -1,45 +1,40 @@ /* eslint-disable new-cap */ /* 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 Decimal from 'decimal.js-light' 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' - -import TransactionSendArgs from '@arg/TransactionSendArgs' -import Paginated from '@arg/Paginated' - -import { Order } from '@enum/Order' - -import { TransactionRepository } from '@repository/Transaction' -import { TransactionLinkRepository } from '@repository/TransactionLink' - import { User as dbUser } from '@entity/User' import { Transaction as dbTransaction } from '@entity/Transaction' import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink' +import { TransactionRepository } from '@repository/Transaction' +import { TransactionLinkRepository } from '@repository/TransactionLink' +import { Decay } from '@model/Decay' +import { User } from '@model/User' +import { Transaction } from '@model/Transaction' +import { TransactionList } from '@model/TransactionList' +import { Order } from '@enum/Order' import { TransactionTypeId } from '@enum/TransactionTypeId' +import TransactionSendArgs from '@arg/TransactionSendArgs' +import Paginated from '@arg/Paginated' + +import { backendLogger as logger } from '@/server/logger' +import CONFIG from '@/config' +import { Context, getUser } from '@/server/context' +import { sendTransactionReceivedEmail } from '@/mailer/sendTransactionReceivedEmail' import { calculateBalance, isHexPublicKey } from '@/util/validate' import { RIGHTS } from '@/auth/RIGHTS' -import { User } from '@model/User' import { communityUser } from '@/util/communityUser' import { virtualLinkTransaction, virtualDecayTransaction } from '@/util/virtualTransactions' -import Decimal from 'decimal.js-light' +import { sendTransactionLinkRedeemedEmail } from '@/mailer/sendTransactionLinkRedeemed' +import { Event, EventTransactionReceive, EventTransactionSend } from '@/event/Event' +import { eventProtocol } from '@/event/EventProtocolEmitter' import { BalanceResolver } from './BalanceResolver' import { MEMO_MAX_CHARS, MEMO_MIN_CHARS } from './const/const' import { findUserByEmail } from './UserResolver' -import { sendTransactionLinkRedeemedEmail } from '@/mailer/sendTransactionLinkRedeemed' -import { Event, EventTransactionReceive, EventTransactionSend } from '@/event/Event' -import { eventProtocol } from '@/event/EventProtocolEmitter' -import { Decay } from '../model/Decay' export const executeTransaction = async ( amount: Decimal, diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index 067b7a0d4..39f7783e1 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -1,7 +1,6 @@ import fs from 'fs' -import { backendLogger as logger } from '@/server/logger' import i18n from 'i18n' -import { Context, getUser, getClientTimezoneOffset } from '@/server/context' +import { v4 as uuidv4 } from 'uuid' import { Resolver, Query, @@ -14,19 +13,31 @@ import { Int, } from 'type-graphql' import { getConnection, getCustomRepository, IsNull, Not } from '@dbTools/typeorm' -import CONFIG from '@/config' -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 { TransactionLink as dbTransactionLink } from '@entity/TransactionLink' -import { ContributionLink as dbContributionLink } from '@entity/ContributionLink' -import { encode } from '@/auth/JWT' +import { TransactionLink as DbTransactionLink } from '@entity/TransactionLink' +import { ContributionLink as DbContributionLink } from '@entity/ContributionLink' +import { UserRepository } from '@repository/User' + +import { User } from '@model/User' +import { SearchAdminUsersResult } from '@model/AdminUser' +import { UserAdmin, SearchUsersResult } from '@model/UserAdmin' +import { OptInType } from '@enum/OptInType' +import { Order } from '@enum/Order' +import { UserContactType } from '@enum/UserContactType' import CreateUserArgs from '@arg/CreateUserArgs' import UnsecureLoginArgs from '@arg/UnsecureLoginArgs' import UpdateUserInfosArgs from '@arg/UpdateUserInfosArgs' +import Paginated from '@arg/Paginated' +import SearchUsersArgs from '@arg/SearchUsersArgs' + +import { backendLogger as logger } from '@/server/logger' +import { Context, getUser, getClientTimezoneOffset } from '@/server/context' +import CONFIG from '@/config' +import { communityDbUser } from '@/util/communityUser' +import { encode } from '@/auth/JWT' 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' @@ -44,14 +55,6 @@ import { EventActivateAccount, } from '@/event/Event' import { getUserCreation, getUserCreations } from './util/creations' -import { UserContactType } from '../enum/UserContactType' -import { UserRepository } from '@/typeorm/repository/User' -import { SearchAdminUsersResult } from '@model/AdminUser' -import { UserAdmin, SearchUsersResult } from '@model/UserAdmin' -import Paginated from '@arg/Paginated' -import { Order } from '@enum/Order' -import { v4 as uuidv4 } from 'uuid' -import SearchUsersArgs from '@arg/SearchUsersArgs' import { FULL_CREATION_AVAILABLE } from './const/const' // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -498,7 +501,7 @@ export class UserResolver { logger.debug('new dbUser=' + dbUser) if (redeemCode) { if (redeemCode.match(/^CL-/)) { - const contributionLink = await dbContributionLink.findOne({ + const contributionLink = await DbContributionLink.findOne({ code: redeemCode.replace('CL-', ''), }) logger.info('redeemCode found contributionLink=' + contributionLink) @@ -507,7 +510,7 @@ export class UserResolver { eventRedeemRegister.contributionId = contributionLink.id } } else { - const transactionLink = await dbTransactionLink.findOne({ code: redeemCode }) + const transactionLink = await DbTransactionLink.findOne({ code: redeemCode }) logger.info('redeemCode found transactionLink=' + transactionLink) if (transactionLink) { dbUser.referrerId = transactionLink.userId From 76fa42552931783b7bd819e787070f6fd3d427df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Tue, 22 Nov 2022 13:18:32 +0100 Subject: [PATCH 14/90] Refactor 'sendAddedContributionMessageEmail' email to HTML and translatable --- backend/src/emails/accountActivation/html.pug | 20 +++++++++++++ .../src/emails/accountActivation/subject.pug | 1 + .../emails/addedContributionMessage/html.pug | 17 +++++++++++ .../addedContributionMessage/subject.pug | 1 + .../src/emails/sendAccountActivation/html.pug | 20 ------------- .../emails/sendAccountActivation/subject.pug | 1 - backend/src/emails/sendEmailVariants.test.ts | 2 +- backend/src/emails/sendEmailVariants.ts | 28 ++++++++++++++++++- backend/src/graphql/resolver/AdminResolver.ts | 16 +++++------ backend/src/locales/de.json | 23 +++++++++------ backend/src/locales/en.json | 23 +++++++++------ 11 files changed, 105 insertions(+), 47 deletions(-) create mode 100644 backend/src/emails/accountActivation/html.pug create mode 100644 backend/src/emails/accountActivation/subject.pug create mode 100644 backend/src/emails/addedContributionMessage/html.pug create mode 100644 backend/src/emails/addedContributionMessage/subject.pug delete mode 100644 backend/src/emails/sendAccountActivation/html.pug delete mode 100644 backend/src/emails/sendAccountActivation/subject.pug diff --git a/backend/src/emails/accountActivation/html.pug b/backend/src/emails/accountActivation/html.pug new file mode 100644 index 000000000..9c631c960 --- /dev/null +++ b/backend/src/emails/accountActivation/html.pug @@ -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.accountActivation.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') diff --git a/backend/src/emails/accountActivation/subject.pug b/backend/src/emails/accountActivation/subject.pug new file mode 100644 index 000000000..378053bbf --- /dev/null +++ b/backend/src/emails/accountActivation/subject.pug @@ -0,0 +1 @@ += t('emails.accountActivation.subject') \ No newline at end of file diff --git a/backend/src/emails/addedContributionMessage/html.pug b/backend/src/emails/addedContributionMessage/html.pug new file mode 100644 index 000000000..020f36c33 --- /dev/null +++ b/backend/src/emails/addedContributionMessage/html.pug @@ -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.addedContributionMessage.linkToYourAccount') + span= " " + a(href=overviewURL) #{overviewURL} + p= t('emails.addedContributionMessage.pleaseDoNotReply') + p(style='margin-top: 24px;')= t('emails.general.sincerelyYours') + br + span= t('emails.general.yourGradidoTeam') diff --git a/backend/src/emails/addedContributionMessage/subject.pug b/backend/src/emails/addedContributionMessage/subject.pug new file mode 100644 index 000000000..8620725f8 --- /dev/null +++ b/backend/src/emails/addedContributionMessage/subject.pug @@ -0,0 +1 @@ += t('emails.addedContributionMessage.subject') \ No newline at end of file diff --git a/backend/src/emails/sendAccountActivation/html.pug b/backend/src/emails/sendAccountActivation/html.pug deleted file mode 100644 index 18bce36b5..000000000 --- a/backend/src/emails/sendAccountActivation/html.pug +++ /dev/null @@ -1,20 +0,0 @@ -doctype html -html(lang=locale) - head - title= t('emails.sendAccountActivation.subject') - body - h1(style='margin-bottom: 24px;')= t('emails.sendAccountActivation.subject') - #container.col - p(style='margin-bottom: 24px;')= t('emails.general.helloName', { firstName, lastName }) - p= t('emails.sendAccountActivation.emailRegistered') - p= t('emails.sendAccountActivation.pleaseClickLink') - br - a(href=activationLink) #{activationLink} - br - span= t('emails.sendAccountActivation.orCopyLink') - p= t('emails.sendAccountActivation.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') diff --git a/backend/src/emails/sendAccountActivation/subject.pug b/backend/src/emails/sendAccountActivation/subject.pug deleted file mode 100644 index 09586310f..000000000 --- a/backend/src/emails/sendAccountActivation/subject.pug +++ /dev/null @@ -1 +0,0 @@ -= t('emails.sendAccountActivation.subject') \ No newline at end of file diff --git a/backend/src/emails/sendEmailVariants.test.ts b/backend/src/emails/sendEmailVariants.test.ts index 698ba32d9..4b8e184ae 100644 --- a/backend/src/emails/sendEmailVariants.test.ts +++ b/backend/src/emails/sendEmailVariants.test.ts @@ -38,7 +38,7 @@ describe('sendEmailVariants', () => { receiver: { to: 'Peter Lustig ', }, - template: 'sendAccountActivation', + template: 'accountActivation', locals: { firstName: 'Peter', lastName: 'Lustig', diff --git a/backend/src/emails/sendEmailVariants.ts b/backend/src/emails/sendEmailVariants.ts index c507f6ace..4d0cd6e5c 100644 --- a/backend/src/emails/sendEmailVariants.ts +++ b/backend/src/emails/sendEmailVariants.ts @@ -1,6 +1,32 @@ import CONFIG from '@/config' import { sendEmailTranslated } from './sendEmailTranslated' +export const sendAddedContributionMessageEmail = (data: { + firstName: string + lastName: string + email: string + language: string + senderFirstName: string + senderLastName: string + contributionMemo: string +}): Promise | 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 @@ -11,7 +37,7 @@ export const sendAccountActivationEmail = (data: { }): Promise | null> => { return sendEmailTranslated({ receiver: { to: `${data.firstName} ${data.lastName} <${data.email}>` }, - template: 'sendAccountActivation', + template: 'accountActivation', locals: { firstName: data.firstName, lastName: data.lastName, diff --git a/backend/src/graphql/resolver/AdminResolver.ts b/backend/src/graphql/resolver/AdminResolver.ts index 047f9bc4e..1c1e8968b 100644 --- a/backend/src/graphql/resolver/AdminResolver.ts +++ b/backend/src/graphql/resolver/AdminResolver.ts @@ -40,7 +40,10 @@ import Paginated from '@arg/Paginated' import TransactionLinkFilters from '@arg/TransactionLinkFilters' import { Order } from '@enum/Order' import { findUserByEmail, activationLink, getTimeDurationObject } from './UserResolver' -import { sendAccountActivationEmail } from '@/emails/sendEmailVariants' +import { + sendAddedContributionMessageEmail, + sendAccountActivationEmail, +} from '@/emails/sendEmailVariants' import { transactionLinkCode as contributionLinkCode } from './TransactionLinkResolver' import CONFIG from '@/config' import { @@ -65,7 +68,6 @@ 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, @@ -896,15 +898,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) { diff --git a/backend/src/locales/de.json b/backend/src/locales/de.json index 78d9d50b3..85bac73c8 100644 --- a/backend/src/locales/de.json +++ b/backend/src/locales/de.json @@ -1,5 +1,19 @@ { "emails": { + "addedContributionMessage": { + "commonGoodContributionMessage": "du hast zu deinem Gemeinwohl-Beitrag „{contributionMemo}“ eine Nachricht von {senderFirstName} {senderLastName} erhalten.", + "linkToYourAccount": "Link zu deinem Konto:", + "pleaseDoNotReply": "Bitte antworte nicht auf diese E-Mail!", + "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:", + "orCopyLink": "oder kopiere den obigen Link in dein Browserfenster.", + "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.", @@ -10,15 +24,8 @@ }, "general": { "helloName": "Hallo {firstName} {lastName},", - "sincerelyYours": "Mit freundlichen Grüßen,", + "sincerelyYours": "Liebe Grüße", "yourGradidoTeam": "dein Gradido-Team" - }, - "sendAccountActivation": { - "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:", - "orCopyLink": "oder kopiere den obigen Link in dein Browserfenster.", - "subject": "Gradido: E-Mail Überprüfung" } } } \ No newline at end of file diff --git a/backend/src/locales/en.json b/backend/src/locales/en.json index e9f43e416..5207696da 100644 --- a/backend/src/locales/en.json +++ b/backend/src/locales/en.json @@ -1,5 +1,19 @@ { "emails": { + "addedContributionMessage": { + "commonGoodContributionMessage": "you have received a message from {senderFirstName} {senderLastName} regarding your common good contribution “{contributionMemo}”.", + "linkToYourAccount": "Link to your account:", + "pleaseDoNotReply": "Please do not reply to this email!", + "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:", + "orCopyLink": "or copy the link above into your browser window.", + "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.", @@ -10,15 +24,8 @@ }, "general": { "helloName": "Hello {firstName} {lastName}", - "sincerelyYours": "Sincerely yours,", + "sincerelyYours": "Kind regards,", "yourGradidoTeam": "your Gradido team" - }, - "sendAccountActivation": { - "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:", - "orCopyLink": "or copy the link above into your browser window.", - "subject": "Gradido: Email Verification" } } } \ No newline at end of file From fb015671698b28b5d1d4f425c3e0888557707d55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Tue, 22 Nov 2022 14:13:01 +0100 Subject: [PATCH 15/90] Test 'sendAddedContributionMessageEmail' in 'sendEmailVariants.test.ts' --- backend/src/emails/sendEmailVariants.test.ts | 86 +++++++++++++++++++- 1 file changed, 83 insertions(+), 3 deletions(-) diff --git a/backend/src/emails/sendEmailVariants.test.ts b/backend/src/emails/sendEmailVariants.test.ts index 4b8e184ae..38e901828 100644 --- a/backend/src/emails/sendEmailVariants.test.ts +++ b/backend/src/emails/sendEmailVariants.test.ts @@ -1,5 +1,9 @@ import CONFIG from '@/config' -import { sendAccountActivationEmail, sendAccountMultiRegistrationEmail } from './sendEmailVariants' +import { + sendAddedContributionMessageEmail, + sendAccountActivationEmail, + sendAccountMultiRegistrationEmail, +} from './sendEmailVariants' import { sendEmailTranslated } from './sendEmailTranslated' CONFIG.EMAIL = true @@ -20,6 +24,78 @@ describe('sendEmailVariants', () => { // 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 ', + }, + 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 ', + from: 'Gradido (nicht antworten) ', + 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('') + expect(result.originalMessage.html).toContain('') + expect(result.originalMessage.html).toContain( + 'Gradido: Message about your common good contribution', + ) + expect(result.originalMessage.html).toContain( + '>Gradido: Message about your common good contribution', + ) + 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: http://localhost/overview', + ) + expect(result.originalMessage.html).toContain('Please do not reply to this email!') + expect(result.originalMessage.html).toContain('Kind regards,
your Gradido team') + }) + }) + }) + describe('sendAccountActivationEmail', () => { beforeAll(async () => { result = await sendAccountActivationEmail({ @@ -66,6 +142,8 @@ describe('sendEmailVariants', () => { text: expect.stringContaining('GRADIDO: EMAIL VERIFICATION'), }), }) + expect(result.originalMessage.html).toContain('') + expect(result.originalMessage.html).toContain('') expect(result.originalMessage.html).toContain('Gradido: Email Verification') expect(result.originalMessage.html).toContain('>Gradido: Email Verification') expect(result.originalMessage.html).toContain('Hello Peter Lustig') @@ -87,7 +165,7 @@ describe('sendEmailVariants', () => { expect(result.originalMessage.html).toContain( `${CONFIG.EMAIL_LINK_FORGOTPASSWORD}`, ) - expect(result.originalMessage.html).toContain('Sincerely yours,
your Gradido team') + expect(result.originalMessage.html).toContain('Kind regards,
your Gradido team') }) }) }) @@ -134,6 +212,8 @@ describe('sendEmailVariants', () => { text: expect.stringContaining('GRADIDO: TRY TO REGISTER AGAIN WITH YOUR EMAIL'), }), }) + expect(result.originalMessage.html).toContain('') + expect(result.originalMessage.html).toContain('') expect(result.originalMessage.html).toContain( 'Gradido: Try To Register Again With Your Email', ) @@ -159,7 +239,7 @@ describe('sendEmailVariants', () => { 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('Sincerely yours,
your Gradido team') + expect(result.originalMessage.html).toContain('Kind regards,
your Gradido team') }) }) }) From a1ec8dfa4f8b2a477b2ce65949f4e082478e9a7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Tue, 22 Nov 2022 14:16:10 +0100 Subject: [PATCH 16/90] Remove old untranslated email 'sendAddedContributionMessageEmail' --- .../sendAddedContributionMessageEmail.test.ts | 40 ------------------- .../sendAddedContributionMessageEmail.ts | 26 ------------ .../text/contributionMessageReceived.ts | 28 ------------- 3 files changed, 94 deletions(-) delete mode 100644 backend/src/mailer/sendAddedContributionMessageEmail.test.ts delete mode 100644 backend/src/mailer/sendAddedContributionMessageEmail.ts delete mode 100644 backend/src/mailer/text/contributionMessageReceived.ts diff --git a/backend/src/mailer/sendAddedContributionMessageEmail.test.ts b/backend/src/mailer/sendAddedContributionMessageEmail.test.ts deleted file mode 100644 index 9a2ec1aa1..000000000 --- a/backend/src/mailer/sendAddedContributionMessageEmail.test.ts +++ /dev/null @@ -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 `, - 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'), - }) - }) -}) diff --git a/backend/src/mailer/sendAddedContributionMessageEmail.ts b/backend/src/mailer/sendAddedContributionMessageEmail.ts deleted file mode 100644 index 14d5f6d31..000000000 --- a/backend/src/mailer/sendAddedContributionMessageEmail.ts +++ /dev/null @@ -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 => { - 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), - }) -} diff --git a/backend/src/mailer/text/contributionMessageReceived.ts b/backend/src/mailer/text/contributionMessageReceived.ts deleted file mode 100644 index 301ebef22..000000000 --- a/backend/src/mailer/text/contributionMessageReceived.ts +++ /dev/null @@ -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`, - }, -} From 98798d33228e35a0c93f4509345cc10b353ac89d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Tue, 22 Nov 2022 17:24:36 +0100 Subject: [PATCH 17/90] Adjust test of 'sendAddedContributionMessageEmail' in 'ContributionMessageResolver.test.ts' --- .../graphql/resolver/AdminResolver.test.ts | 8 ++++--- .../ContributionMessageResolver.test.ts | 23 +++++++++++-------- .../src/graphql/resolver/UserResolver.test.ts | 10 +++++--- backend/test/testSetup.ts | 4 ++++ 4 files changed, 29 insertions(+), 16 deletions(-) diff --git a/backend/src/graphql/resolver/AdminResolver.test.ts b/backend/src/graphql/resolver/AdminResolver.test.ts index ea1bb848e..2ff7b0437 100644 --- a/backend/src/graphql/resolver/AdminResolver.test.ts +++ b/backend/src/graphql/resolver/AdminResolver.test.ts @@ -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' @@ -44,14 +45,15 @@ 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('@/emails/sendEmailVariants', () => { + const originalModule = jest.requireActual('@/emails/sendEmailVariants') return { __esModule: true, + ...originalModule, // TODO: test the call of … - sendAccountActivationEmail: jest.fn(), + sendAccountActivationEmail: jest.fn((a) => originalModule.sendAccountActivationEmail(a)), } }) @@ -67,7 +69,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 diff --git a/backend/src/graphql/resolver/ContributionMessageResolver.test.ts b/backend/src/graphql/resolver/ContributionMessageResolver.test.ts index 612c2d20b..436830c2c 100644 --- a/backend/src/graphql/resolver/ContributionMessageResolver.test.ts +++ b/backend/src/graphql/resolver/ContributionMessageResolver.test.ts @@ -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', }) }) }) diff --git a/backend/src/graphql/resolver/UserResolver.test.ts b/backend/src/graphql/resolver/UserResolver.test.ts index fac4618bf..1303b8aaf 100644 --- a/backend/src/graphql/resolver/UserResolver.test.ts +++ b/backend/src/graphql/resolver/UserResolver.test.ts @@ -2,6 +2,7 @@ /* 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 { userFactory } from '@/seeds/factory/user' import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg' import { @@ -30,7 +31,6 @@ 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' @@ -41,10 +41,14 @@ import { bobBaumeister } from '@/seeds/users/bob-baumeister' // import { klicktippSignIn } from '@/apis/KlicktippController' jest.mock('@/emails/sendEmailVariants', () => { + const originalModule = jest.requireActual('@/emails/sendEmailVariants') return { __esModule: true, - sendAccountActivationEmail: jest.fn(), - sendAccountMultiRegistrationEmail: jest.fn(), + ...originalModule, + sendAccountActivationEmail: jest.fn((a) => originalModule.sendAccountActivationEmail(a)), + sendAccountMultiRegistrationEmail: jest.fn((a) => + originalModule.sendAccountMultiRegistrationEmail(a), + ), } }) diff --git a/backend/test/testSetup.ts b/backend/test/testSetup.ts index 06779674d..300a9cbf3 100644 --- a/backend/test/testSetup.ts +++ b/backend/test/testSetup.ts @@ -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', () => { From ea62a7f7100d62309b10a1a2a564671b301d7e7d Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Tue, 22 Nov 2022 18:01:36 +0100 Subject: [PATCH 18/90] fix(backend): email verification code never expired --- .../graphql/resolver/EmailOptinCodes.test.ts | 125 ++++++++++++++++++ .../src/graphql/resolver/UserResolver.test.ts | 24 ++-- backend/src/graphql/resolver/UserResolver.ts | 106 +++------------ backend/test/helpers.ts | 5 +- 4 files changed, 157 insertions(+), 103 deletions(-) create mode 100644 backend/src/graphql/resolver/EmailOptinCodes.test.ts diff --git a/backend/src/graphql/resolver/EmailOptinCodes.test.ts b/backend/src/graphql/resolver/EmailOptinCodes.test.ts new file mode 100644 index 000000000..1cf22850d --- /dev/null +++ b/backend/src/graphql/resolver/EmailOptinCodes.test.ts @@ -0,0 +1,125 @@ +/* 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 + +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, + }) + }) + }) + }) +}) diff --git a/backend/src/graphql/resolver/UserResolver.test.ts b/backend/src/graphql/resolver/UserResolver.test.ts index 6323abfde..c382d8bc2 100644 --- a/backend/src/graphql/resolver/UserResolver.test.ts +++ b/backend/src/graphql/resolver/UserResolver.test.ts @@ -21,7 +21,7 @@ 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 { printTimeDuration } from './UserResolver' import { contributionLinkFactory } from '@/seeds/factory/contributionLink' import { transactionLinkFactory } from '@/seeds/factory/transactionLink' import { ContributionLink } from '@model/ContributionLink' @@ -804,12 +804,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 () => { @@ -818,7 +814,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: [ @@ -844,15 +840,15 @@ 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({ + link: expect.any(String), + firstName: 'Bibi', + lastName: 'Bloxberg', + email: 'bibi@bloxberg.de', + duration: expect.any(String), + }) }) }) diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index 707b7ac49..e6a86bba5 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -149,16 +149,6 @@ const SecretKeyCryptographyCreateKey = (salt: string, password: string): Buffer[ return [encryptionKeyHash, encryptionKey] } -/* -const getEmailHash = (email: string): Buffer => { - logger.trace('getEmailHash...') - const emailHash = Buffer.alloc(sodium.crypto_generichash_BYTES) - sodium.crypto_generichash(emailHash, Buffer.from(email)) - logger.debug(`getEmailHash...successful: ${emailHash}`) - return emailHash -} -*/ - const SecretKeyCryptographyEncrypt = (message: Buffer, encryptionKey: Buffer): Buffer => { logger.trace('SecretKeyCryptographyEncrypt...') const encrypted = Buffer.alloc(message.length + sodium.crypto_secretbox_MACBYTES) @@ -194,89 +184,33 @@ 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 => { - 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 => { 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) + if (!canEmailResend(emailContact.updatedAt || 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`, + ) } + emailContact.updatedAt = new Date() + emailContact.emailResendCount++ + 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}`) + logger.info(`checkEmailVerificationCode...successful: ${emailContact}`) return emailContact } @@ -384,6 +318,7 @@ export class UserResolver { @Authorized([RIGHTS.LOGOUT]) @Mutation(() => String) async logout(): Promise { + // 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. @@ -657,7 +592,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`, ) @@ -760,7 +695,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`, ) @@ -935,10 +870,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) } /* diff --git a/backend/test/helpers.ts b/backend/test/helpers.ts index 7ee8e6052..1935b01a0 100644 --- a/backend/test/helpers.ts +++ b/backend/test/helpers.ts @@ -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 From a2c1b0ff963226c7df51b77b645c01292b71a6b7 Mon Sep 17 00:00:00 2001 From: Ulf Gebhardt Date: Wed, 23 Nov 2022 22:52:22 +0100 Subject: [PATCH 19/90] separate AdminResolver.test into the corresponding parts --- .../graphql/resolver/AdminResolver.test.ts | 2668 ----------------- .../resolver/ContributionResolver.test.ts | 1115 ++++++- .../resolver/TransactionLinkResolver.test.ts | 896 +++++- .../src/graphql/resolver/UserResolver.test.ts | 639 +++- backend/test/helpers.test.ts | 7 + 5 files changed, 2649 insertions(+), 2676 deletions(-) delete mode 100644 backend/src/graphql/resolver/AdminResolver.test.ts create mode 100644 backend/test/helpers.test.ts diff --git a/backend/src/graphql/resolver/AdminResolver.test.ts b/backend/src/graphql/resolver/AdminResolver.test.ts deleted file mode 100644 index 503bab472..000000000 --- a/backend/src/graphql/resolver/AdminResolver.test.ts +++ /dev/null @@ -1,2668 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ - -import { objectValuesToArray } from '@/util/utilities' -import { testEnvironment, resetToken, cleanDB, contributionDateFormatter } from '@test/helpers' -import { userFactory } from '@/seeds/factory/user' -import { creationFactory } from '@/seeds/factory/creation' -import { creations } from '@/seeds/creation/index' -import { transactionLinkFactory } from '@/seeds/factory/transactionLink' -import { transactionLinks } from '@/seeds/transactionLink/index' -import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg' -import { peterLustig } from '@/seeds/users/peter-lustig' -import { stephenHawking } from '@/seeds/users/stephen-hawking' -import { garrickOllivander } from '@/seeds/users/garrick-ollivander' -import { - login, - setUserRole, - deleteUser, - unDeleteUser, - createContribution, - adminCreateContribution, - adminCreateContributions, - adminUpdateContribution, - adminDeleteContribution, - confirmContribution, - createContributionLink, - deleteContributionLink, - updateContributionLink, -} from '@/seeds/graphql/mutations' -import { - listUnconfirmedContributions, - searchUsers, - listTransactionLinksAdmin, - listContributionLinks, -} 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 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', () => { - return { - __esModule: true, - sendAccountActivationEmail: jest.fn(), - } -}) - -// mock account activation email to avoid console spam -jest.mock('@/mailer/sendContributionConfirmedEmail', () => { - return { - __esModule: true, - sendContributionConfirmedEmail: jest.fn(), - } -}) - -let mutate: any, query: any, con: any -let testEnv: any - -beforeAll(async () => { - testEnv = await testEnvironment() - mutate = testEnv.mutate - query = testEnv.query - con = testEnv.con - await cleanDB() -}) - -afterAll(async () => { - await cleanDB() - await con.close() -}) - -let admin: User -let user: User -let creation: Contribution | void -let result: any - -describe('contributionDateFormatter', () => { - it('formats the date correctly', () => { - expect(contributionDateFormatter(new Date('Thu Feb 29 2024 13:12:11'))).toEqual('2/29/2024') - }) -}) - -describe('AdminResolver', () => { - describe('set user role', () => { - describe('unauthenticated', () => { - it('returns an error', async () => { - await expect( - mutate({ mutation: setUserRole, variables: { userId: 1, isAdmin: true } }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('authenticated', () => { - describe('without admin rights', () => { - beforeAll(async () => { - user = await userFactory(testEnv, bibiBloxberg) - await mutate({ - mutation: login, - variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, - }) - }) - - afterAll(async () => { - await cleanDB() - resetToken() - }) - - it('returns an error', async () => { - await expect( - mutate({ mutation: setUserRole, variables: { userId: user.id + 1, isAdmin: true } }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('with admin rights', () => { - beforeAll(async () => { - admin = await userFactory(testEnv, peterLustig) - await mutate({ - mutation: login, - variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, - }) - }) - - afterAll(async () => { - await cleanDB() - resetToken() - }) - - describe('user to get a new role does not exist', () => { - it('throws an error', async () => { - jest.clearAllMocks() - await expect( - mutate({ mutation: setUserRole, variables: { userId: admin.id + 1, isAdmin: true } }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError(`Could not find user with userId: ${admin.id + 1}`)], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith(`Could not find user with userId: ${admin.id + 1}`) - }) - }) - - describe('change role with success', () => { - beforeAll(async () => { - user = await userFactory(testEnv, bibiBloxberg) - }) - - describe('user gets new role', () => { - describe('to admin', () => { - it('returns date string', async () => { - const result = await mutate({ - mutation: setUserRole, - variables: { userId: user.id, isAdmin: true }, - }) - expect(result).toEqual( - expect.objectContaining({ - data: { - setUserRole: expect.any(String), - }, - }), - ) - expect(new Date(result.data.setUserRole)).toEqual(expect.any(Date)) - }) - }) - - describe('to usual user', () => { - it('returns null', async () => { - await expect( - mutate({ mutation: setUserRole, variables: { userId: user.id, isAdmin: false } }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - setUserRole: null, - }, - }), - ) - }) - }) - }) - }) - - describe('change role with error', () => { - describe('is own role', () => { - it('throws an error', async () => { - jest.clearAllMocks() - await expect( - mutate({ mutation: setUserRole, variables: { userId: admin.id, isAdmin: false } }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('Administrator can not change his own role!')], - }), - ) - }) - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith('Administrator can not change his own role!') - }) - }) - - describe('user has already role to be set', () => { - describe('to admin', () => { - it('throws an error', async () => { - jest.clearAllMocks() - await mutate({ - mutation: setUserRole, - variables: { userId: user.id, isAdmin: true }, - }) - await expect( - mutate({ mutation: setUserRole, variables: { userId: user.id, isAdmin: true } }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('User is already admin!')], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith('User is already admin!') - }) - }) - - describe('to usual user', () => { - it('throws an error', async () => { - jest.clearAllMocks() - await mutate({ - mutation: setUserRole, - variables: { userId: user.id, isAdmin: false }, - }) - await expect( - mutate({ mutation: setUserRole, variables: { userId: user.id, isAdmin: false } }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('User is already a usual user!')], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith('User is already a usual user!') - }) - }) - }) - }) - }) - }) - }) - - describe('delete user', () => { - describe('unauthenticated', () => { - it('returns an error', async () => { - await expect(mutate({ mutation: deleteUser, variables: { userId: 1 } })).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('authenticated', () => { - describe('without admin rights', () => { - beforeAll(async () => { - user = await userFactory(testEnv, bibiBloxberg) - await mutate({ - mutation: login, - variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, - }) - }) - - afterAll(async () => { - await cleanDB() - resetToken() - }) - - it('returns an error', async () => { - await expect( - mutate({ mutation: deleteUser, variables: { userId: user.id + 1 } }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('with admin rights', () => { - beforeAll(async () => { - admin = await userFactory(testEnv, peterLustig) - await mutate({ - mutation: login, - variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, - }) - }) - - afterAll(async () => { - await cleanDB() - resetToken() - }) - - describe('user to be deleted does not exist', () => { - it('throws an error', async () => { - jest.clearAllMocks() - await expect( - mutate({ mutation: deleteUser, variables: { userId: admin.id + 1 } }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError(`Could not find user with userId: ${admin.id + 1}`)], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith(`Could not find user with userId: ${admin.id + 1}`) - }) - }) - - describe('delete self', () => { - it('throws an error', async () => { - jest.clearAllMocks() - await expect( - mutate({ mutation: deleteUser, variables: { userId: admin.id } }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('Moderator can not delete his own account!')], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith('Moderator can not delete his own account!') - }) - }) - - describe('delete with success', () => { - beforeAll(async () => { - user = await userFactory(testEnv, bibiBloxberg) - }) - - it('returns date string', async () => { - const result = await mutate({ mutation: deleteUser, variables: { userId: user.id } }) - expect(result).toEqual( - expect.objectContaining({ - data: { - deleteUser: expect.any(String), - }, - }), - ) - expect(new Date(result.data.deleteUser)).toEqual(expect.any(Date)) - }) - - describe('delete deleted user', () => { - it('throws an error', async () => { - jest.clearAllMocks() - await expect( - mutate({ mutation: deleteUser, variables: { userId: user.id } }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError(`Could not find user with userId: ${user.id}`)], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith(`Could not find user with userId: ${user.id}`) - }) - }) - }) - }) - }) - }) - - describe('unDelete user', () => { - describe('unauthenticated', () => { - it('returns an error', async () => { - await expect(mutate({ mutation: unDeleteUser, variables: { userId: 1 } })).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('authenticated', () => { - describe('without admin rights', () => { - beforeAll(async () => { - user = await userFactory(testEnv, bibiBloxberg) - await mutate({ - mutation: login, - variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, - }) - }) - - afterAll(async () => { - await cleanDB() - resetToken() - }) - - it('returns an error', async () => { - await expect( - mutate({ mutation: unDeleteUser, variables: { userId: user.id + 1 } }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('with admin rights', () => { - beforeAll(async () => { - admin = await userFactory(testEnv, peterLustig) - await mutate({ - mutation: login, - variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, - }) - }) - - afterAll(async () => { - await cleanDB() - resetToken() - }) - - describe('user to be undelete does not exist', () => { - it('throws an error', async () => { - jest.clearAllMocks() - await expect( - mutate({ mutation: unDeleteUser, variables: { userId: admin.id + 1 } }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError(`Could not find user with userId: ${admin.id + 1}`)], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith(`Could not find user with userId: ${admin.id + 1}`) - }) - }) - - describe('user to undelete is not deleted', () => { - beforeAll(async () => { - user = await userFactory(testEnv, bibiBloxberg) - }) - - it('throws an error', async () => { - jest.clearAllMocks() - await expect( - mutate({ mutation: unDeleteUser, variables: { userId: user.id } }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('User is not deleted')], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith('User is not deleted') - }) - - describe('undelete deleted user', () => { - beforeAll(async () => { - await mutate({ mutation: deleteUser, variables: { userId: user.id } }) - }) - - it('returns null', async () => { - await expect( - mutate({ mutation: unDeleteUser, variables: { userId: user.id } }), - ).resolves.toEqual( - expect.objectContaining({ - data: { unDeleteUser: null }, - }), - ) - }) - }) - }) - }) - }) - }) - - describe('search users', () => { - const variablesWithoutTextAndFilters = { - searchText: '', - currentPage: 1, - pageSize: 25, - filters: null, - } - - describe('unauthenticated', () => { - it('returns an error', async () => { - await expect( - query({ - query: searchUsers, - variables: { - ...variablesWithoutTextAndFilters, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('authenticated', () => { - describe('without admin rights', () => { - beforeAll(async () => { - user = await userFactory(testEnv, bibiBloxberg) - await mutate({ - mutation: login, - variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, - }) - }) - - afterAll(async () => { - await cleanDB() - resetToken() - }) - - it('returns an error', async () => { - await expect( - query({ - query: searchUsers, - variables: { - ...variablesWithoutTextAndFilters, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('with admin rights', () => { - const allUsers = { - bibi: expect.objectContaining({ - email: 'bibi@bloxberg.de', - }), - garrick: expect.objectContaining({ - email: 'garrick@ollivander.com', - }), - peter: expect.objectContaining({ - email: 'peter@lustig.de', - }), - stephen: expect.objectContaining({ - email: 'stephen@hawking.uk', - }), - } - - beforeAll(async () => { - admin = await userFactory(testEnv, peterLustig) - await mutate({ - mutation: login, - variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, - }) - - await userFactory(testEnv, bibiBloxberg) - await userFactory(testEnv, stephenHawking) - await userFactory(testEnv, garrickOllivander) - }) - - afterAll(async () => { - await cleanDB() - resetToken() - }) - - describe('without any filters', () => { - it('finds all users', async () => { - await expect( - query({ - query: searchUsers, - variables: { - ...variablesWithoutTextAndFilters, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - searchUsers: { - userCount: 4, - userList: expect.arrayContaining(objectValuesToArray(allUsers)), - }, - }, - }), - ) - }) - }) - - describe('all filters are null', () => { - it('finds all users', async () => { - await expect( - query({ - query: searchUsers, - variables: { - ...variablesWithoutTextAndFilters, - filters: { - byActivated: null, - byDeleted: null, - }, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - searchUsers: { - userCount: 4, - userList: expect.arrayContaining(objectValuesToArray(allUsers)), - }, - }, - }), - ) - }) - }) - - describe('filter by unchecked email', () => { - it('finds only users with unchecked email', async () => { - await expect( - query({ - query: searchUsers, - variables: { - ...variablesWithoutTextAndFilters, - filters: { - byActivated: false, - byDeleted: null, - }, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - searchUsers: { - userCount: 1, - userList: expect.arrayContaining([allUsers.garrick]), - }, - }, - }), - ) - }) - }) - - describe('filter by deleted users', () => { - it('finds only users with deleted account', async () => { - await expect( - query({ - query: searchUsers, - variables: { - ...variablesWithoutTextAndFilters, - filters: { - byActivated: null, - byDeleted: true, - }, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - searchUsers: { - userCount: 1, - userList: expect.arrayContaining([allUsers.stephen]), - }, - }, - }), - ) - }) - }) - - describe('filter by deleted account and unchecked email', () => { - it('finds no users', async () => { - await expect( - query({ - query: searchUsers, - variables: { - ...variablesWithoutTextAndFilters, - filters: { - byActivated: false, - byDeleted: true, - }, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - searchUsers: { - userCount: 0, - userList: [], - }, - }, - }), - ) - }) - }) - }) - }) - }) - - describe('creations', () => { - const variables = { - email: 'bibi@bloxberg.de', - amount: new Decimal(2000), - memo: 'Aktives Grundeinkommen', - creationDate: 'not-valid', - } - - describe('unauthenticated', () => { - describe('adminCreateContribution', () => { - it('returns an error', async () => { - await expect(mutate({ mutation: adminCreateContribution, variables })).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('adminCreateContributions', () => { - it('returns an error', async () => { - await expect( - mutate({ - mutation: adminCreateContributions, - variables: { pendingCreations: [variables] }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('adminUpdateContribution', () => { - it('returns an error', async () => { - await expect( - mutate({ - mutation: adminUpdateContribution, - variables: { - id: 1, - email: 'bibi@bloxberg.de', - amount: new Decimal(300), - memo: 'Danke Bibi!', - creationDate: contributionDateFormatter(new Date()), - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('listUnconfirmedContributions', () => { - it('returns an error', async () => { - await expect( - query({ - query: listUnconfirmedContributions, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('adminDeleteContribution', () => { - it('returns an error', async () => { - await expect( - mutate({ - mutation: adminDeleteContribution, - variables: { - id: 1, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('confirmContribution', () => { - it('returns an error', async () => { - await expect( - mutate({ - mutation: confirmContribution, - variables: { - id: 1, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - }) - - describe('authenticated', () => { - describe('without admin rights', () => { - beforeAll(async () => { - user = await userFactory(testEnv, bibiBloxberg) - await mutate({ - mutation: login, - variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, - }) - }) - - afterAll(async () => { - await cleanDB() - resetToken() - }) - - describe('adminCreateContribution', () => { - it('returns an error', async () => { - await expect(mutate({ mutation: adminCreateContribution, variables })).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('adminCreateContributions', () => { - it('returns an error', async () => { - await expect( - mutate({ - mutation: adminCreateContributions, - variables: { pendingCreations: [variables] }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('adminUpdateContribution', () => { - it('returns an error', async () => { - await expect( - mutate({ - mutation: adminUpdateContribution, - variables: { - id: 1, - email: 'bibi@bloxberg.de', - amount: new Decimal(300), - memo: 'Danke Bibi!', - creationDate: contributionDateFormatter(new Date()), - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('listUnconfirmedContributions', () => { - it('returns an error', async () => { - await expect( - query({ - query: listUnconfirmedContributions, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('adminDeleteContribution', () => { - it('returns an error', async () => { - await expect( - mutate({ - mutation: adminDeleteContribution, - variables: { - id: 1, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('confirmContribution', () => { - it('returns an error', async () => { - await expect( - mutate({ - mutation: confirmContribution, - variables: { - id: 1, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - }) - - describe('with admin rights', () => { - beforeAll(async () => { - admin = await userFactory(testEnv, peterLustig) - await mutate({ - mutation: login, - variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, - }) - }) - - afterAll(async () => { - await cleanDB() - resetToken() - }) - - describe('adminCreateContribution', () => { - const now = new Date() - - beforeAll(async () => { - creation = await creationFactory(testEnv, { - email: 'peter@lustig.de', - amount: 400, - memo: 'Herzlich Willkommen bei Gradido!', - creationDate: contributionDateFormatter( - new Date(now.getFullYear(), now.getMonth() - 1, 1), - ), - }) - }) - - describe('user to create for does not exist', () => { - it('throws an error', async () => { - jest.clearAllMocks() - variables.creationDate = contributionDateFormatter( - new Date(now.getFullYear(), now.getMonth() - 1, 1), - ) - await expect( - mutate({ mutation: adminCreateContribution, variables }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('Could not find user with email: bibi@bloxberg.de')], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - 'Could not find user with email: bibi@bloxberg.de', - ) - }) - }) - - describe('user to create for is deleted', () => { - beforeAll(async () => { - user = await userFactory(testEnv, stephenHawking) - variables.email = 'stephen@hawking.uk' - variables.creationDate = contributionDateFormatter( - new Date(now.getFullYear(), now.getMonth() - 1, 1), - ) - }) - - it('throws an error', async () => { - jest.clearAllMocks() - await expect( - mutate({ mutation: adminCreateContribution, variables }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError('This user was deleted. Cannot create a contribution.'), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - 'This user was deleted. Cannot create a contribution.', - ) - }) - }) - - describe('user to create for has email not confirmed', () => { - beforeAll(async () => { - user = await userFactory(testEnv, garrickOllivander) - variables.email = 'garrick@ollivander.com' - variables.creationDate = contributionDateFormatter( - new Date(now.getFullYear(), now.getMonth() - 1, 1), - ) - }) - - it('throws an error', async () => { - jest.clearAllMocks() - await expect( - mutate({ mutation: adminCreateContribution, variables }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError('Contribution could not be saved, Email is not activated'), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - 'Contribution could not be saved, Email is not activated', - ) - }) - }) - - describe('valid user to create for', () => { - beforeAll(async () => { - user = await userFactory(testEnv, bibiBloxberg) - variables.email = 'bibi@bloxberg.de' - variables.creationDate = 'invalid-date' - }) - - describe('date of creation is not a date string', () => { - it('throws an error', async () => { - jest.clearAllMocks() - await expect( - mutate({ mutation: adminCreateContribution, variables }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError(`invalid Date for creationDate=invalid-date`)], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith(`invalid Date for creationDate=invalid-date`) - }) - }) - - describe('date of creation is four months ago', () => { - it('throws an error', async () => { - jest.clearAllMocks() - variables.creationDate = contributionDateFormatter( - new Date(now.getFullYear(), now.getMonth() - 4, 1), - ) - await expect( - mutate({ mutation: adminCreateContribution, variables }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError('No information for available creations for the given date'), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - 'No information for available creations with the given creationDate=', - new Date(variables.creationDate).toString(), - ) - }) - }) - - describe('date of creation is in the future', () => { - it('throws an error', async () => { - jest.clearAllMocks() - variables.creationDate = contributionDateFormatter( - new Date(now.getFullYear(), now.getMonth() + 4, 1), - ) - await expect( - mutate({ mutation: adminCreateContribution, variables }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError('No information for available creations for the given date'), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - 'No information for available creations with the given creationDate=', - new Date(variables.creationDate).toString(), - ) - }) - }) - - describe('amount of creation is too high', () => { - it('throws an error', async () => { - jest.clearAllMocks() - variables.creationDate = contributionDateFormatter(now) - await expect( - mutate({ mutation: adminCreateContribution, variables }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError( - 'The amount (2000 GDD) to be created exceeds the amount (1000 GDD) still available for this month.', - ), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - 'The amount (2000 GDD) to be created exceeds the amount (1000 GDD) still available for this month.', - ) - }) - }) - - describe('creation is valid', () => { - it('returns an array of the open creations for the last three months', async () => { - variables.amount = new Decimal(200) - await expect( - mutate({ mutation: adminCreateContribution, variables }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - adminCreateContribution: [1000, 1000, 800], - }, - }), - ) - }) - - it('stores the admin create contribution event in the database', async () => { - await expect(EventProtocol.find()).resolves.toContainEqual( - expect.objectContaining({ - type: EventProtocolType.ADMIN_CONTRIBUTION_CREATE, - userId: admin.id, - }), - ) - }) - }) - - describe('second creation surpasses the available amount ', () => { - it('returns an array of the open creations for the last three months', async () => { - variables.amount = new Decimal(1000) - await expect( - mutate({ mutation: adminCreateContribution, variables }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError( - 'The amount (1000 GDD) to be created exceeds the amount (800 GDD) still available for this month.', - ), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - 'The amount (1000 GDD) to be created exceeds the amount (800 GDD) still available for this month.', - ) - }) - }) - }) - }) - - describe('adminCreateContributions', () => { - // at this point we have this data in DB: - // bibi@bloxberg.de: [1000, 1000, 800] - // peter@lustig.de: [1000, 600, 1000] - // stephen@hawking.uk: [1000, 1000, 1000] - deleted - // garrick@ollivander.com: [1000, 1000, 1000] - not activated - - const massCreationVariables = [ - 'bibi@bloxberg.de', - 'peter@lustig.de', - 'stephen@hawking.uk', - 'garrick@ollivander.com', - 'bob@baumeister.de', - ].map((email) => { - return { - email, - amount: new Decimal(500), - memo: 'Grundeinkommen', - creationDate: contributionDateFormatter(new Date()), - } - }) - - it('returns success, two successful creation and three failed creations', async () => { - await expect( - mutate({ - mutation: adminCreateContributions, - variables: { pendingCreations: massCreationVariables }, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - adminCreateContributions: { - success: true, - successfulContribution: ['bibi@bloxberg.de', 'peter@lustig.de'], - failedContribution: [ - 'stephen@hawking.uk', - 'garrick@ollivander.com', - 'bob@baumeister.de', - ], - }, - }, - }), - ) - }) - }) - - describe('adminUpdateContribution', () => { - // at this I expect to have this data in DB: - // bibi@bloxberg.de: [1000, 1000, 300] - // peter@lustig.de: [1000, 600, 500] - // stephen@hawking.uk: [1000, 1000, 1000] - deleted - // garrick@ollivander.com: [1000, 1000, 1000] - not activated - - describe('user for creation to update does not exist', () => { - it('throws an error', async () => { - jest.clearAllMocks() - await expect( - mutate({ - mutation: adminUpdateContribution, - variables: { - id: 1, - email: 'bob@baumeister.de', - amount: new Decimal(300), - memo: 'Danke Bibi!', - creationDate: contributionDateFormatter(new Date()), - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError('Could not find UserContact with email: bob@baumeister.de'), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - 'Could not find UserContact with email: bob@baumeister.de', - ) - }) - }) - - describe('user for creation to update is deleted', () => { - it('throws an error', async () => { - jest.clearAllMocks() - await expect( - mutate({ - mutation: adminUpdateContribution, - variables: { - id: 1, - email: 'stephen@hawking.uk', - amount: new Decimal(300), - memo: 'Danke Bibi!', - creationDate: contributionDateFormatter(new Date()), - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('User was deleted (stephen@hawking.uk)')], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith('User was deleted (stephen@hawking.uk)') - }) - }) - - describe('creation does not exist', () => { - it('throws an error', async () => { - jest.clearAllMocks() - await expect( - mutate({ - mutation: adminUpdateContribution, - variables: { - id: -1, - email: 'bibi@bloxberg.de', - amount: new Decimal(300), - memo: 'Danke Bibi!', - creationDate: contributionDateFormatter(new Date()), - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('No contribution found to given id.')], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith('No contribution found to given id.') - }) - }) - - describe('user email does not match creation user', () => { - it('throws an error', async () => { - jest.clearAllMocks() - await expect( - mutate({ - mutation: adminUpdateContribution, - variables: { - id: creation ? creation.id : -1, - email: 'bibi@bloxberg.de', - amount: new Decimal(300), - memo: 'Danke Bibi!', - creationDate: creation - ? contributionDateFormatter(creation.contributionDate) - : contributionDateFormatter(new Date()), - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError( - 'user of the pending contribution and send user does not correspond', - ), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - 'user of the pending contribution and send user does not correspond', - ) - }) - }) - - describe('creation update is not valid', () => { - // as this test has not clearly defined that date, it is a false positive - it('throws an error', async () => { - jest.clearAllMocks() - await expect( - mutate({ - mutation: adminUpdateContribution, - variables: { - id: creation ? creation.id : -1, - email: 'peter@lustig.de', - amount: new Decimal(1900), - memo: 'Danke Peter!', - creationDate: creation - ? contributionDateFormatter(creation.contributionDate) - : contributionDateFormatter(new Date()), - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError( - 'The amount (1900 GDD) to be created exceeds the amount (1000 GDD) still available for this month.', - ), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - 'The amount (1900 GDD) to be created exceeds the amount (1000 GDD) still available for this month.', - ) - }) - }) - - describe.skip('creation update is successful changing month', () => { - // skipped as changing the month is currently disable - it('returns update creation object', async () => { - await expect( - mutate({ - mutation: adminUpdateContribution, - variables: { - id: creation ? creation.id : -1, - email: 'peter@lustig.de', - amount: new Decimal(300), - memo: 'Danke Peter!', - creationDate: creation - ? contributionDateFormatter(creation.contributionDate) - : contributionDateFormatter(new Date()), - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - adminUpdateContribution: { - date: expect.any(String), - memo: 'Danke Peter!', - amount: '300', - creation: ['1000', '700', '500'], - }, - }, - }), - ) - }) - - it('stores the admin update contribution event in the database', async () => { - await expect(EventProtocol.find()).resolves.toContainEqual( - expect.objectContaining({ - type: EventProtocolType.ADMIN_CONTRIBUTION_UPDATE, - userId: admin.id, - }), - ) - }) - }) - - describe('creation update is successful without changing month', () => { - // actually this mutation IS changing the month - it('returns update creation object', async () => { - await expect( - mutate({ - mutation: adminUpdateContribution, - variables: { - id: creation ? creation.id : -1, - email: 'peter@lustig.de', - amount: new Decimal(200), - memo: 'Das war leider zu Viel!', - creationDate: creation - ? contributionDateFormatter(creation.contributionDate) - : contributionDateFormatter(new Date()), - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - adminUpdateContribution: { - date: expect.any(String), - memo: 'Das war leider zu Viel!', - amount: '200', - creation: ['1000', '800', '500'], - }, - }, - }), - ) - }) - - it('stores the admin update contribution event in the database', async () => { - await expect(EventProtocol.find()).resolves.toContainEqual( - expect.objectContaining({ - type: EventProtocolType.ADMIN_CONTRIBUTION_UPDATE, - userId: admin.id, - }), - ) - }) - }) - }) - - describe('listUnconfirmedContributions', () => { - it('returns four pending creations', async () => { - await expect( - query({ - query: listUnconfirmedContributions, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - listUnconfirmedContributions: expect.arrayContaining([ - { - id: expect.any(Number), - firstName: 'Peter', - lastName: 'Lustig', - email: 'peter@lustig.de', - date: expect.any(String), - memo: 'Das war leider zu Viel!', - amount: '200', - moderator: admin.id, - creation: ['1000', '800', '500'], - }, - { - id: expect.any(Number), - firstName: 'Peter', - lastName: 'Lustig', - email: 'peter@lustig.de', - date: expect.any(String), - memo: 'Grundeinkommen', - amount: '500', - moderator: admin.id, - creation: ['1000', '800', '500'], - }, - { - id: expect.any(Number), - firstName: 'Bibi', - lastName: 'Bloxberg', - email: 'bibi@bloxberg.de', - date: expect.any(String), - memo: 'Grundeinkommen', - amount: '500', - moderator: admin.id, - creation: ['1000', '1000', '300'], - }, - { - id: expect.any(Number), - firstName: 'Bibi', - lastName: 'Bloxberg', - email: 'bibi@bloxberg.de', - date: expect.any(String), - memo: 'Aktives Grundeinkommen', - amount: '200', - moderator: admin.id, - creation: ['1000', '1000', '300'], - }, - ]), - }, - }), - ) - }) - }) - - describe('adminDeleteContribution', () => { - describe('creation id does not exist', () => { - it('throws an error', async () => { - jest.clearAllMocks() - await expect( - mutate({ - mutation: adminDeleteContribution, - variables: { - id: -1, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('Contribution not found for given id.')], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith('Contribution not found for given id: -1') - }) - }) - - describe('admin deletes own user contribution', () => { - beforeAll(async () => { - await query({ - query: login, - variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, - }) - result = await mutate({ - mutation: createContribution, - variables: { - amount: 100.0, - memo: 'Test env contribution', - creationDate: contributionDateFormatter(new Date()), - }, - }) - }) - - it('throws an error', async () => { - jest.clearAllMocks() - await expect( - mutate({ - mutation: adminDeleteContribution, - variables: { - id: result.data.createContribution.id, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('Own contribution can not be deleted as admin')], - }), - ) - }) - }) - - describe('creation id does exist', () => { - it('returns true', async () => { - await expect( - mutate({ - mutation: adminDeleteContribution, - variables: { - id: creation ? creation.id : -1, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { adminDeleteContribution: true }, - }), - ) - }) - - it('stores the admin delete contribution event in the database', async () => { - await expect(EventProtocol.find()).resolves.toContainEqual( - expect.objectContaining({ - type: EventProtocolType.ADMIN_CONTRIBUTION_DELETE, - userId: admin.id, - }), - ) - }) - }) - }) - - describe('confirmContribution', () => { - describe('creation does not exits', () => { - it('throws an error', async () => { - jest.clearAllMocks() - await expect( - mutate({ - mutation: confirmContribution, - variables: { - id: -1, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('Contribution not found to given id.')], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith('Contribution not found for given id: -1') - }) - }) - - describe('confirm own creation', () => { - beforeAll(async () => { - const now = new Date() - creation = await creationFactory(testEnv, { - email: 'peter@lustig.de', - amount: 400, - memo: 'Herzlich Willkommen bei Gradido!', - creationDate: contributionDateFormatter( - new Date(now.getFullYear(), now.getMonth() - 1, 1), - ), - }) - }) - - it('thows an error', async () => { - await expect( - mutate({ - mutation: confirmContribution, - variables: { - id: creation ? creation.id : -1, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('Moderator can not confirm own contribution')], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith('Moderator can not confirm own contribution') - }) - }) - - describe('confirm creation for other user', () => { - beforeAll(async () => { - const now = new Date() - creation = await creationFactory(testEnv, { - email: 'bibi@bloxberg.de', - amount: 450, - memo: 'Herzlich Willkommen bei Gradido liebe Bibi!', - creationDate: contributionDateFormatter( - new Date(now.getFullYear(), now.getMonth() - 2, 1), - ), - }) - }) - - it('returns true', async () => { - await expect( - mutate({ - mutation: confirmContribution, - variables: { - id: creation ? creation.id : -1, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { confirmContribution: true }, - }), - ) - }) - - it('stores the contribution confirm event in the database', async () => { - await expect(EventProtocol.find()).resolves.toContainEqual( - expect.objectContaining({ - type: EventProtocolType.CONTRIBUTION_CONFIRM, - }), - ) - }) - - it('creates a transaction', async () => { - const transaction = await DbTransaction.find() - expect(transaction[0].amount.toString()).toBe('450') - expect(transaction[0].memo).toBe('Herzlich Willkommen bei Gradido liebe Bibi!') - expect(transaction[0].linkedTransactionId).toEqual(null) - expect(transaction[0].transactionLinkId).toEqual(null) - expect(transaction[0].previous).toEqual(null) - expect(transaction[0].linkedUserId).toEqual(null) - expect(transaction[0].typeId).toEqual(1) - }) - - 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', - }), - ) - }) - - it('stores the send confirmation email event in the database', async () => { - await expect(EventProtocol.find()).resolves.toContainEqual( - expect.objectContaining({ - type: EventProtocolType.SEND_CONFIRMATION_EMAIL, - }), - ) - }) - }) - - describe('confirm two creations one after the other quickly', () => { - let c1: Contribution | void - let c2: Contribution | void - - beforeAll(async () => { - const now = new Date() - c1 = await creationFactory(testEnv, { - email: 'bibi@bloxberg.de', - amount: 50, - memo: 'Herzlich Willkommen bei Gradido liebe Bibi!', - creationDate: contributionDateFormatter( - new Date(now.getFullYear(), now.getMonth() - 2, 1), - ), - }) - c2 = await creationFactory(testEnv, { - email: 'bibi@bloxberg.de', - amount: 50, - memo: 'Herzlich Willkommen bei Gradido liebe Bibi!', - creationDate: contributionDateFormatter( - new Date(now.getFullYear(), now.getMonth() - 2, 1), - ), - }) - }) - - // In the futrue this should not throw anymore - it('throws an error for the second confirmation', async () => { - const r1 = mutate({ - mutation: confirmContribution, - variables: { - id: c1 ? c1.id : -1, - }, - }) - const r2 = mutate({ - mutation: confirmContribution, - variables: { - id: c2 ? c2.id : -1, - }, - }) - await expect(r1).resolves.toEqual( - expect.objectContaining({ - data: { confirmContribution: true }, - }), - ) - await expect(r2).resolves.toEqual( - expect.objectContaining({ - // data: { confirmContribution: true }, - errors: [new GraphQLError('Creation was not successful.')], - }), - ) - }) - }) - }) - }) - }) - }) - - describe('transaction links list', () => { - const variables = { - userId: 1, // dummy, may be replaced - filters: null, - currentPage: 1, - pageSize: 5, - } - - describe('unauthenticated', () => { - it('returns an error', async () => { - await expect( - query({ - query: listTransactionLinksAdmin, - variables, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('authenticated', () => { - describe('without admin rights', () => { - beforeAll(async () => { - user = await userFactory(testEnv, bibiBloxberg) - await mutate({ - mutation: login, - variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, - }) - }) - - afterAll(async () => { - await cleanDB() - resetToken() - }) - - it('returns an error', async () => { - await expect( - query({ - query: listTransactionLinksAdmin, - variables, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('with admin rights', () => { - beforeAll(async () => { - // admin 'peter@lustig.de' has to exists for 'creationFactory' - admin = await userFactory(testEnv, peterLustig) - - user = await userFactory(testEnv, bibiBloxberg) - variables.userId = user.id - variables.pageSize = 25 - // bibi needs GDDs - const bibisCreation = creations.find((creation) => creation.email === 'bibi@bloxberg.de') - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await creationFactory(testEnv, bibisCreation!) - // bibis transaktion links - const bibisTransaktionLinks = transactionLinks.filter( - (transactionLink) => transactionLink.email === 'bibi@bloxberg.de', - ) - for (let i = 0; i < bibisTransaktionLinks.length; i++) { - await transactionLinkFactory(testEnv, bibisTransaktionLinks[i]) - } - - // admin: only now log in - await mutate({ - mutation: login, - variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, - }) - }) - - afterAll(async () => { - await cleanDB() - resetToken() - }) - - describe('without any filters', () => { - it('finds 6 open transaction links and no deleted or redeemed', async () => { - await expect( - query({ - query: listTransactionLinksAdmin, - variables, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - listTransactionLinksAdmin: { - linkCount: 6, - linkList: expect.not.arrayContaining([ - expect.objectContaining({ - memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(', - createdAt: expect.any(String), - }), - expect.objectContaining({ - memo: 'Da habe ich mich wohl etwas übernommen.', - deletedAt: expect.any(String), - }), - ]), - }, - }, - }), - ) - }) - }) - - describe('all filters are null', () => { - it('finds 6 open transaction links and no deleted or redeemed', async () => { - await expect( - query({ - query: listTransactionLinksAdmin, - variables: { - ...variables, - filters: { - withDeleted: null, - withExpired: null, - withRedeemed: null, - }, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - listTransactionLinksAdmin: { - linkCount: 6, - linkList: expect.not.arrayContaining([ - expect.objectContaining({ - memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(', - createdAt: expect.any(String), - }), - expect.objectContaining({ - memo: 'Da habe ich mich wohl etwas übernommen.', - deletedAt: expect.any(String), - }), - ]), - }, - }, - }), - ) - }) - }) - - describe('filter with deleted', () => { - it('finds 6 open transaction links, 1 deleted, and no redeemed', async () => { - await expect( - query({ - query: listTransactionLinksAdmin, - variables: { - ...variables, - filters: { - withDeleted: true, - }, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - listTransactionLinksAdmin: { - linkCount: 7, - linkList: expect.arrayContaining([ - expect.not.objectContaining({ - memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(', - createdAt: expect.any(String), - }), - expect.objectContaining({ - memo: 'Da habe ich mich wohl etwas übernommen.', - deletedAt: expect.any(String), - }), - ]), - }, - }, - }), - ) - }) - }) - - describe('filter by expired', () => { - it('finds 5 open transaction links, 1 expired, and no redeemed', async () => { - await expect( - query({ - query: listTransactionLinksAdmin, - variables: { - ...variables, - filters: { - withExpired: true, - }, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - listTransactionLinksAdmin: { - linkCount: 7, - linkList: expect.arrayContaining([ - expect.objectContaining({ - memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(', - createdAt: expect.any(String), - }), - expect.not.objectContaining({ - memo: 'Da habe ich mich wohl etwas übernommen.', - deletedAt: expect.any(String), - }), - ]), - }, - }, - }), - ) - }) - }) - - // TODO: works not as expected, because 'redeemedAt' and 'redeemedBy' have to be added to the transaktion link factory - describe.skip('filter by redeemed', () => { - it('finds 6 open transaction links, 1 deleted, and no redeemed', async () => { - await expect( - query({ - query: listTransactionLinksAdmin, - variables: { - ...variables, - filters: { - withDeleted: null, - withExpired: null, - withRedeemed: true, - }, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - listTransactionLinksAdmin: { - linkCount: 6, - linkList: expect.arrayContaining([ - expect.not.objectContaining({ - memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(', - createdAt: expect.any(String), - }), - expect.objectContaining({ - memo: 'Yeah, eingelöst!', - redeemedAt: expect.any(String), - redeemedBy: expect.any(Number), - }), - expect.not.objectContaining({ - memo: 'Da habe ich mich wohl etwas übernommen.', - deletedAt: expect.any(String), - }), - ]), - }, - }, - }), - ) - }) - }) - }) - }) - }) - - describe('Contribution Links', () => { - const now = new Date() - const variables = { - amount: new Decimal(200), - name: 'Dokumenta 2022', - memo: 'Danke für deine Teilnahme an der Dokumenta 2022', - cycle: 'once', - validFrom: new Date(2022, 5, 18).toISOString(), - validTo: new Date(now.getFullYear() + 1, 7, 14).toISOString(), - maxAmountPerMonth: new Decimal(200), - maxPerCycle: 1, - } - - describe('unauthenticated', () => { - describe('createContributionLink', () => { - it('returns an error', async () => { - await expect(mutate({ mutation: createContributionLink, variables })).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('listContributionLinks', () => { - it('returns an error', async () => { - await expect(query({ query: listContributionLinks })).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('updateContributionLink', () => { - it('returns an error', async () => { - await expect( - mutate({ - mutation: updateContributionLink, - variables: { - ...variables, - id: -1, - amount: new Decimal(400), - name: 'Dokumenta 2023', - memo: 'Danke für deine Teilnahme an der Dokumenta 2023', - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('deleteContributionLink', () => { - it('returns an error', async () => { - await expect( - mutate({ mutation: deleteContributionLink, variables: { id: -1 } }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - }) - - describe('authenticated', () => { - describe('without admin rights', () => { - beforeAll(async () => { - user = await userFactory(testEnv, bibiBloxberg) - await mutate({ - mutation: login, - variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, - }) - }) - - afterAll(async () => { - await cleanDB() - resetToken() - }) - - describe('createContributionLink', () => { - it('returns an error', async () => { - await expect(mutate({ mutation: createContributionLink, variables })).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - // TODO: Set this test in new location to have datas - describe('listContributionLinks', () => { - it('returns an empty object', async () => { - await expect(query({ query: listContributionLinks })).resolves.toEqual( - expect.objectContaining({ - data: { - listContributionLinks: { - count: 0, - links: [], - }, - }, - }), - ) - }) - }) - - describe('updateContributionLink', () => { - it('returns an error', async () => { - await expect( - mutate({ - mutation: updateContributionLink, - variables: { - ...variables, - id: -1, - amount: new Decimal(400), - name: 'Dokumenta 2023', - memo: 'Danke für deine Teilnahme an der Dokumenta 2023', - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('deleteContributionLink', () => { - it('returns an error', async () => { - await expect( - mutate({ mutation: deleteContributionLink, variables: { id: -1 } }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - }) - - describe('with admin rights', () => { - beforeAll(async () => { - user = await userFactory(testEnv, peterLustig) - await mutate({ - mutation: login, - variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, - }) - }) - - afterAll(async () => { - await cleanDB() - resetToken() - }) - - describe('createContributionLink', () => { - it('returns a contribution link object', async () => { - await expect(mutate({ mutation: createContributionLink, variables })).resolves.toEqual( - expect.objectContaining({ - data: { - createContributionLink: expect.objectContaining({ - id: expect.any(Number), - amount: '200', - code: expect.stringMatching(/^[0-9a-f]{24,24}$/), - link: expect.stringMatching(/^.*?\/CL-[0-9a-f]{24,24}$/), - createdAt: expect.any(String), - name: 'Dokumenta 2022', - memo: 'Danke für deine Teilnahme an der Dokumenta 2022', - validFrom: expect.any(String), - validTo: expect.any(String), - maxAmountPerMonth: '200', - cycle: 'once', - maxPerCycle: 1, - }), - }, - }), - ) - }) - - it('has a contribution link stored in db', async () => { - const cls = await DbContributionLink.find() - expect(cls).toHaveLength(1) - expect(cls[0]).toEqual( - expect.objectContaining({ - id: expect.any(Number), - name: 'Dokumenta 2022', - memo: 'Danke für deine Teilnahme an der Dokumenta 2022', - validFrom: new Date('2022-06-18T00:00:00.000Z'), - validTo: expect.any(Date), - cycle: 'once', - maxPerCycle: 1, - totalMaxCountOfContribution: null, - maxAccountBalance: null, - minGapHours: null, - createdAt: expect.any(Date), - deletedAt: null, - code: expect.stringMatching(/^[0-9a-f]{24,24}$/), - linkEnabled: true, - amount: expect.decimalEqual(200), - maxAmountPerMonth: expect.decimalEqual(200), - }), - ) - }) - - it('returns an error if missing startDate', async () => { - await expect( - mutate({ - mutation: createContributionLink, - variables: { - ...variables, - validFrom: null, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError('Start-Date is not initialized. A Start-Date must be set!'), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - 'Start-Date is not initialized. A Start-Date must be set!', - ) - }) - - it('returns an error if missing endDate', async () => { - await expect( - mutate({ - mutation: createContributionLink, - variables: { - ...variables, - validTo: null, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('End-Date is not initialized. An End-Date must be set!')], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - 'End-Date is not initialized. An End-Date must be set!', - ) - }) - - it('returns an error if endDate is before startDate', async () => { - await expect( - mutate({ - mutation: createContributionLink, - variables: { - ...variables, - validFrom: new Date('2022-06-18T00:00:00.001Z').toISOString(), - validTo: new Date('2022-06-18T00:00:00.000Z').toISOString(), - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError(`The value of validFrom must before or equals the validTo!`), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - `The value of validFrom must before or equals the validTo!`, - ) - }) - - it('returns an error if name is an empty string', async () => { - await expect( - mutate({ - mutation: createContributionLink, - variables: { - ...variables, - name: '', - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('The name must be initialized!')], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith('The name must be initialized!') - }) - - it('returns an error if name is shorter than 5 characters', async () => { - await expect( - mutate({ - mutation: createContributionLink, - variables: { - ...variables, - name: '123', - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError( - `The value of 'name' with a length of 3 did not fulfill the requested bounderies min=5 and max=100`, - ), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - `The value of 'name' with a length of 3 did not fulfill the requested bounderies min=5 and max=100`, - ) - }) - - it('returns an error if name is longer than 100 characters', async () => { - await expect( - mutate({ - mutation: createContributionLink, - variables: { - ...variables, - name: '12345678901234567892123456789312345678941234567895123456789612345678971234567898123456789912345678901', - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError( - `The value of 'name' with a length of 101 did not fulfill the requested bounderies min=5 and max=100`, - ), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - `The value of 'name' with a length of 101 did not fulfill the requested bounderies min=5 and max=100`, - ) - }) - - it('returns an error if memo is an empty string', async () => { - await expect( - mutate({ - mutation: createContributionLink, - variables: { - ...variables, - memo: '', - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('The memo must be initialized!')], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith('The memo must be initialized!') - }) - - it('returns an error if memo is shorter than 5 characters', async () => { - await expect( - mutate({ - mutation: createContributionLink, - variables: { - ...variables, - memo: '123', - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError( - `The value of 'memo' with a length of 3 did not fulfill the requested bounderies min=5 and max=255`, - ), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - `The value of 'memo' with a length of 3 did not fulfill the requested bounderies min=5 and max=255`, - ) - }) - - it('returns an error if memo is longer than 255 characters', async () => { - await expect( - mutate({ - mutation: createContributionLink, - variables: { - ...variables, - memo: '1234567890123456789212345678931234567894123456789512345678961234567897123456789812345678991234567890123456789012345678921234567893123456789412345678951234567896123456789712345678981234567899123456789012345678901234567892123456789312345678941234567895123456', - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError( - `The value of 'memo' with a length of 256 did not fulfill the requested bounderies min=5 and max=255`, - ), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - `The value of 'memo' with a length of 256 did not fulfill the requested bounderies min=5 and max=255`, - ) - }) - - it('returns an error if amount is not positive', async () => { - await expect( - mutate({ - mutation: createContributionLink, - variables: { - ...variables, - amount: new Decimal(0), - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError('The amount=0 must be initialized with a positiv value!'), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - 'The amount=0 must be initialized with a positiv value!', - ) - }) - }) - - describe('listContributionLinks', () => { - describe('one link in DB', () => { - it('returns the link and count 1', async () => { - await expect(query({ query: listContributionLinks })).resolves.toEqual( - expect.objectContaining({ - data: { - listContributionLinks: { - links: expect.arrayContaining([ - expect.objectContaining({ - amount: '200', - code: expect.stringMatching(/^[0-9a-f]{24,24}$/), - link: expect.stringMatching(/^.*?\/CL-[0-9a-f]{24,24}$/), - createdAt: expect.any(String), - name: 'Dokumenta 2022', - memo: 'Danke für deine Teilnahme an der Dokumenta 2022', - validFrom: expect.any(String), - validTo: expect.any(String), - maxAmountPerMonth: '200', - cycle: 'once', - maxPerCycle: 1, - }), - ]), - count: 1, - }, - }, - }), - ) - }) - }) - }) - - describe('updateContributionLink', () => { - describe('no valid id', () => { - it('returns an error', async () => { - await expect( - mutate({ - mutation: updateContributionLink, - variables: { - ...variables, - id: -1, - amount: new Decimal(400), - name: 'Dokumenta 2023', - memo: 'Danke für deine Teilnahme an der Dokumenta 2023', - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('Contribution Link not found to given id.')], - }), - ) - }) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith('Contribution Link not found to given id: -1') - }) - - describe('valid id', () => { - let linkId: number - beforeAll(async () => { - const links = await query({ query: listContributionLinks }) - linkId = links.data.listContributionLinks.links[0].id - }) - - it('returns updated contribution link object', async () => { - await expect( - mutate({ - mutation: updateContributionLink, - variables: { - ...variables, - id: linkId, - amount: new Decimal(400), - name: 'Dokumenta 2023', - memo: 'Danke für deine Teilnahme an der Dokumenta 2023', - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - updateContributionLink: { - id: linkId, - amount: '400', - code: expect.stringMatching(/^[0-9a-f]{24,24}$/), - link: expect.stringMatching(/^.*?\/CL-[0-9a-f]{24,24}$/), - createdAt: expect.any(String), - name: 'Dokumenta 2023', - memo: 'Danke für deine Teilnahme an der Dokumenta 2023', - validFrom: expect.any(String), - validTo: expect.any(String), - maxAmountPerMonth: '200', - cycle: 'once', - maxPerCycle: 1, - }, - }, - }), - ) - }) - - it('updated the DB record', async () => { - await expect(DbContributionLink.findOne(linkId)).resolves.toEqual( - expect.objectContaining({ - id: linkId, - name: 'Dokumenta 2023', - memo: 'Danke für deine Teilnahme an der Dokumenta 2023', - amount: expect.decimalEqual(400), - }), - ) - }) - }) - }) - - describe('deleteContributionLink', () => { - describe('no valid id', () => { - it('returns an error', async () => { - await expect( - mutate({ mutation: deleteContributionLink, variables: { id: -1 } }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('Contribution Link not found to given id.')], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith('Contribution Link not found to given id: -1') - }) - }) - - describe('valid id', () => { - let linkId: number - beforeAll(async () => { - const links = await query({ query: listContributionLinks }) - linkId = links.data.listContributionLinks.links[0].id - }) - - it('returns a date string', async () => { - await expect( - mutate({ mutation: deleteContributionLink, variables: { id: linkId } }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - deleteContributionLink: expect.any(String), - }, - }), - ) - }) - - it('does not list this contribution link anymore', async () => { - await expect(query({ query: listContributionLinks })).resolves.toEqual( - expect.objectContaining({ - data: { - listContributionLinks: { - links: [], - count: 0, - }, - }, - }), - ) - }) - }) - }) - }) - }) - }) -}) diff --git a/backend/src/graphql/resolver/ContributionResolver.test.ts b/backend/src/graphql/resolver/ContributionResolver.test.ts index e512961e7..0b1113df9 100644 --- a/backend/src/graphql/resolver/ContributionResolver.test.ts +++ b/backend/src/graphql/resolver/ContributionResolver.test.ts @@ -1,28 +1,53 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +import Decimal from 'decimal.js-light' import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg' +import { stephenHawking } from '@/seeds/users/stephen-hawking' +import { garrickOllivander } from '@/seeds/users/garrick-ollivander' import { - adminUpdateContribution, - confirmContribution, createContribution, - deleteContribution, updateContribution, + deleteContribution, + confirmContribution, + adminCreateContribution, + adminCreateContributions, + adminUpdateContribution, + adminDeleteContribution, login, } from '@/seeds/graphql/mutations' -import { listAllContributions, listContributions } from '@/seeds/graphql/queries' -import { cleanDB, resetToken, testEnvironment } from '@test/helpers' +import { + listAllContributions, + listContributions, + listUnconfirmedContributions, +} from '@/seeds/graphql/queries' +import { sendContributionConfirmedEmail } from '@/mailer/sendContributionConfirmedEmail' +import { cleanDB, resetToken, testEnvironment, contributionDateFormatter } from '@test/helpers' import { GraphQLError } from 'graphql' import { userFactory } from '@/seeds/factory/user' import { creationFactory } from '@/seeds/factory/creation' import { creations } from '@/seeds/creation/index' import { peterLustig } from '@/seeds/users/peter-lustig' import { EventProtocol } from '@entity/EventProtocol' +import { Contribution } from '@entity/Contribution' +import { Transaction as DbTransaction } from '@entity/Transaction' +import { User } from '@entity/User' import { EventProtocolType } from '@/event/EventProtocolType' import { logger } from '@test/testSetup' +// mock account activation email to avoid console spam +jest.mock('@/mailer/sendContributionConfirmedEmail', () => { + return { + __esModule: true, + sendContributionConfirmedEmail: jest.fn(), + } +}) + let mutate: any, query: any, con: any let testEnv: any +let creation: Contribution | void +let user: User +let admin: User let result: any beforeAll(async () => { @@ -876,4 +901,1084 @@ describe('ContributionResolver', () => { }) }) }) + + describe('contributions', () => { + const variables = { + email: 'bibi@bloxberg.de', + amount: new Decimal(2000), + memo: 'Aktives Grundeinkommen', + creationDate: 'not-valid', + } + + describe('unauthenticated', () => { + describe('adminCreateContribution', () => { + it('returns an error', async () => { + await expect(mutate({ mutation: adminCreateContribution, variables })).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('adminCreateContributions', () => { + it('returns an error', async () => { + await expect( + mutate({ + mutation: adminCreateContributions, + variables: { pendingCreations: [variables] }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('adminUpdateContribution', () => { + it('returns an error', async () => { + await expect( + mutate({ + mutation: adminUpdateContribution, + variables: { + id: 1, + email: 'bibi@bloxberg.de', + amount: new Decimal(300), + memo: 'Danke Bibi!', + creationDate: contributionDateFormatter(new Date()), + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('listUnconfirmedContributions', () => { + it('returns an error', async () => { + await expect( + query({ + query: listUnconfirmedContributions, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('adminDeleteContribution', () => { + it('returns an error', async () => { + await expect( + mutate({ + mutation: adminDeleteContribution, + variables: { + id: 1, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('confirmContribution', () => { + it('returns an error', async () => { + await expect( + mutate({ + mutation: confirmContribution, + variables: { + id: 1, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + }) + + describe('authenticated', () => { + describe('without admin rights', () => { + beforeAll(async () => { + user = await userFactory(testEnv, bibiBloxberg) + await mutate({ + mutation: login, + variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + }) + }) + + afterAll(async () => { + await cleanDB() + resetToken() + }) + + describe('adminCreateContribution', () => { + it('returns an error', async () => { + await expect(mutate({ mutation: adminCreateContribution, variables })).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('adminCreateContributions', () => { + it('returns an error', async () => { + await expect( + mutate({ + mutation: adminCreateContributions, + variables: { pendingCreations: [variables] }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('adminUpdateContribution', () => { + it('returns an error', async () => { + await expect( + mutate({ + mutation: adminUpdateContribution, + variables: { + id: 1, + email: 'bibi@bloxberg.de', + amount: new Decimal(300), + memo: 'Danke Bibi!', + creationDate: contributionDateFormatter(new Date()), + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('listUnconfirmedContributions', () => { + it('returns an error', async () => { + await expect( + query({ + query: listUnconfirmedContributions, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('adminDeleteContribution', () => { + it('returns an error', async () => { + await expect( + mutate({ + mutation: adminDeleteContribution, + variables: { + id: 1, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('confirmContribution', () => { + it('returns an error', async () => { + await expect( + mutate({ + mutation: confirmContribution, + variables: { + id: 1, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + }) + + describe('with admin rights', () => { + beforeAll(async () => { + admin = await userFactory(testEnv, peterLustig) + await mutate({ + mutation: login, + variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, + }) + }) + + afterAll(async () => { + await cleanDB() + resetToken() + }) + + describe('adminCreateContribution', () => { + const now = new Date() + + beforeAll(async () => { + creation = await creationFactory(testEnv, { + email: 'peter@lustig.de', + amount: 400, + memo: 'Herzlich Willkommen bei Gradido!', + creationDate: contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() - 1, 1), + ), + }) + }) + + describe('user to create for does not exist', () => { + it('throws an error', async () => { + jest.clearAllMocks() + variables.creationDate = contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() - 1, 1), + ) + await expect( + mutate({ mutation: adminCreateContribution, variables }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('Could not find user with email: bibi@bloxberg.de')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'Could not find user with email: bibi@bloxberg.de', + ) + }) + }) + + describe('user to create for is deleted', () => { + beforeAll(async () => { + user = await userFactory(testEnv, stephenHawking) + variables.email = 'stephen@hawking.uk' + variables.creationDate = contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() - 1, 1), + ) + }) + + it('throws an error', async () => { + jest.clearAllMocks() + await expect( + mutate({ mutation: adminCreateContribution, variables }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError('This user was deleted. Cannot create a contribution.'), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'This user was deleted. Cannot create a contribution.', + ) + }) + }) + + describe('user to create for has email not confirmed', () => { + beforeAll(async () => { + user = await userFactory(testEnv, garrickOllivander) + variables.email = 'garrick@ollivander.com' + variables.creationDate = contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() - 1, 1), + ) + }) + + it('throws an error', async () => { + jest.clearAllMocks() + await expect( + mutate({ mutation: adminCreateContribution, variables }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError('Contribution could not be saved, Email is not activated'), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'Contribution could not be saved, Email is not activated', + ) + }) + }) + + describe('valid user to create for', () => { + beforeAll(async () => { + user = await userFactory(testEnv, bibiBloxberg) + variables.email = 'bibi@bloxberg.de' + variables.creationDate = 'invalid-date' + }) + + describe('date of creation is not a date string', () => { + it('throws an error', async () => { + jest.clearAllMocks() + await expect( + mutate({ mutation: adminCreateContribution, variables }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError(`invalid Date for creationDate=invalid-date`)], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith(`invalid Date for creationDate=invalid-date`) + }) + }) + + describe('date of creation is four months ago', () => { + it('throws an error', async () => { + jest.clearAllMocks() + variables.creationDate = contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() - 4, 1), + ) + await expect( + mutate({ mutation: adminCreateContribution, variables }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError('No information for available creations for the given date'), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'No information for available creations with the given creationDate=', + new Date(variables.creationDate).toString(), + ) + }) + }) + + describe('date of creation is in the future', () => { + it('throws an error', async () => { + jest.clearAllMocks() + variables.creationDate = contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() + 4, 1), + ) + await expect( + mutate({ mutation: adminCreateContribution, variables }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError('No information for available creations for the given date'), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'No information for available creations with the given creationDate=', + new Date(variables.creationDate).toString(), + ) + }) + }) + + describe('amount of creation is too high', () => { + it('throws an error', async () => { + jest.clearAllMocks() + variables.creationDate = contributionDateFormatter(now) + await expect( + mutate({ mutation: adminCreateContribution, variables }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError( + 'The amount (2000 GDD) to be created exceeds the amount (1000 GDD) still available for this month.', + ), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'The amount (2000 GDD) to be created exceeds the amount (1000 GDD) still available for this month.', + ) + }) + }) + + describe('creation is valid', () => { + it('returns an array of the open creations for the last three months', async () => { + variables.amount = new Decimal(200) + await expect( + mutate({ mutation: adminCreateContribution, variables }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + adminCreateContribution: [1000, 1000, 800], + }, + }), + ) + }) + + it('stores the admin create contribution event in the database', async () => { + await expect(EventProtocol.find()).resolves.toContainEqual( + expect.objectContaining({ + type: EventProtocolType.ADMIN_CONTRIBUTION_CREATE, + userId: admin.id, + }), + ) + }) + }) + + describe('second creation surpasses the available amount ', () => { + it('returns an array of the open creations for the last three months', async () => { + variables.amount = new Decimal(1000) + await expect( + mutate({ mutation: adminCreateContribution, variables }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError( + 'The amount (1000 GDD) to be created exceeds the amount (800 GDD) still available for this month.', + ), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'The amount (1000 GDD) to be created exceeds the amount (800 GDD) still available for this month.', + ) + }) + }) + }) + }) + + describe('adminCreateContributions', () => { + // at this point we have this data in DB: + // bibi@bloxberg.de: [1000, 1000, 800] + // peter@lustig.de: [1000, 600, 1000] + // stephen@hawking.uk: [1000, 1000, 1000] - deleted + // garrick@ollivander.com: [1000, 1000, 1000] - not activated + + const massCreationVariables = [ + 'bibi@bloxberg.de', + 'peter@lustig.de', + 'stephen@hawking.uk', + 'garrick@ollivander.com', + 'bob@baumeister.de', + ].map((email) => { + return { + email, + amount: new Decimal(500), + memo: 'Grundeinkommen', + creationDate: contributionDateFormatter(new Date()), + } + }) + + it('returns success, two successful creation and three failed creations', async () => { + await expect( + mutate({ + mutation: adminCreateContributions, + variables: { pendingCreations: massCreationVariables }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + adminCreateContributions: { + success: true, + successfulContribution: ['bibi@bloxberg.de', 'peter@lustig.de'], + failedContribution: [ + 'stephen@hawking.uk', + 'garrick@ollivander.com', + 'bob@baumeister.de', + ], + }, + }, + }), + ) + }) + }) + + describe('adminUpdateContribution', () => { + // at this I expect to have this data in DB: + // bibi@bloxberg.de: [1000, 1000, 300] + // peter@lustig.de: [1000, 600, 500] + // stephen@hawking.uk: [1000, 1000, 1000] - deleted + // garrick@ollivander.com: [1000, 1000, 1000] - not activated + + describe('user for creation to update does not exist', () => { + it('throws an error', async () => { + jest.clearAllMocks() + await expect( + mutate({ + mutation: adminUpdateContribution, + variables: { + id: 1, + email: 'bob@baumeister.de', + amount: new Decimal(300), + memo: 'Danke Bibi!', + creationDate: contributionDateFormatter(new Date()), + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError('Could not find UserContact with email: bob@baumeister.de'), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'Could not find UserContact with email: bob@baumeister.de', + ) + }) + }) + + describe('user for creation to update is deleted', () => { + it('throws an error', async () => { + jest.clearAllMocks() + await expect( + mutate({ + mutation: adminUpdateContribution, + variables: { + id: 1, + email: 'stephen@hawking.uk', + amount: new Decimal(300), + memo: 'Danke Bibi!', + creationDate: contributionDateFormatter(new Date()), + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('User was deleted (stephen@hawking.uk)')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('User was deleted (stephen@hawking.uk)') + }) + }) + + describe('creation does not exist', () => { + it('throws an error', async () => { + jest.clearAllMocks() + await expect( + mutate({ + mutation: adminUpdateContribution, + variables: { + id: -1, + email: 'bibi@bloxberg.de', + amount: new Decimal(300), + memo: 'Danke Bibi!', + creationDate: contributionDateFormatter(new Date()), + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('No contribution found to given id.')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('No contribution found to given id.') + }) + }) + + describe('user email does not match creation user', () => { + it('throws an error', async () => { + jest.clearAllMocks() + await expect( + mutate({ + mutation: adminUpdateContribution, + variables: { + id: creation ? creation.id : -1, + email: 'bibi@bloxberg.de', + amount: new Decimal(300), + memo: 'Danke Bibi!', + creationDate: creation + ? contributionDateFormatter(creation.contributionDate) + : contributionDateFormatter(new Date()), + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError( + 'user of the pending contribution and send user does not correspond', + ), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'user of the pending contribution and send user does not correspond', + ) + }) + }) + + describe('creation update is not valid', () => { + // as this test has not clearly defined that date, it is a false positive + it('throws an error', async () => { + jest.clearAllMocks() + await expect( + mutate({ + mutation: adminUpdateContribution, + variables: { + id: creation ? creation.id : -1, + email: 'peter@lustig.de', + amount: new Decimal(1900), + memo: 'Danke Peter!', + creationDate: creation + ? contributionDateFormatter(creation.contributionDate) + : contributionDateFormatter(new Date()), + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError( + 'The amount (1900 GDD) to be created exceeds the amount (1000 GDD) still available for this month.', + ), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'The amount (1900 GDD) to be created exceeds the amount (1000 GDD) still available for this month.', + ) + }) + }) + + describe.skip('creation update is successful changing month', () => { + // skipped as changing the month is currently disable + it('returns update creation object', async () => { + await expect( + mutate({ + mutation: adminUpdateContribution, + variables: { + id: creation ? creation.id : -1, + email: 'peter@lustig.de', + amount: new Decimal(300), + memo: 'Danke Peter!', + creationDate: creation + ? contributionDateFormatter(creation.contributionDate) + : contributionDateFormatter(new Date()), + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + adminUpdateContribution: { + date: expect.any(String), + memo: 'Danke Peter!', + amount: '300', + creation: ['1000', '700', '500'], + }, + }, + }), + ) + }) + + it('stores the admin update contribution event in the database', async () => { + await expect(EventProtocol.find()).resolves.toContainEqual( + expect.objectContaining({ + type: EventProtocolType.ADMIN_CONTRIBUTION_UPDATE, + userId: admin.id, + }), + ) + }) + }) + + describe('creation update is successful without changing month', () => { + // actually this mutation IS changing the month + it('returns update creation object', async () => { + await expect( + mutate({ + mutation: adminUpdateContribution, + variables: { + id: creation ? creation.id : -1, + email: 'peter@lustig.de', + amount: new Decimal(200), + memo: 'Das war leider zu Viel!', + creationDate: creation + ? contributionDateFormatter(creation.contributionDate) + : contributionDateFormatter(new Date()), + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + adminUpdateContribution: { + date: expect.any(String), + memo: 'Das war leider zu Viel!', + amount: '200', + creation: ['1000', '800', '500'], + }, + }, + }), + ) + }) + + it('stores the admin update contribution event in the database', async () => { + await expect(EventProtocol.find()).resolves.toContainEqual( + expect.objectContaining({ + type: EventProtocolType.ADMIN_CONTRIBUTION_UPDATE, + userId: admin.id, + }), + ) + }) + }) + }) + + describe('listUnconfirmedContributions', () => { + it('returns four pending creations', async () => { + await expect( + query({ + query: listUnconfirmedContributions, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + listUnconfirmedContributions: expect.arrayContaining([ + { + id: expect.any(Number), + firstName: 'Peter', + lastName: 'Lustig', + email: 'peter@lustig.de', + date: expect.any(String), + memo: 'Das war leider zu Viel!', + amount: '200', + moderator: admin.id, + creation: ['1000', '800', '500'], + }, + { + id: expect.any(Number), + firstName: 'Peter', + lastName: 'Lustig', + email: 'peter@lustig.de', + date: expect.any(String), + memo: 'Grundeinkommen', + amount: '500', + moderator: admin.id, + creation: ['1000', '800', '500'], + }, + { + id: expect.any(Number), + firstName: 'Bibi', + lastName: 'Bloxberg', + email: 'bibi@bloxberg.de', + date: expect.any(String), + memo: 'Grundeinkommen', + amount: '500', + moderator: admin.id, + creation: ['1000', '1000', '300'], + }, + { + id: expect.any(Number), + firstName: 'Bibi', + lastName: 'Bloxberg', + email: 'bibi@bloxberg.de', + date: expect.any(String), + memo: 'Aktives Grundeinkommen', + amount: '200', + moderator: admin.id, + creation: ['1000', '1000', '300'], + }, + ]), + }, + }), + ) + }) + }) + + describe('adminDeleteContribution', () => { + describe('creation id does not exist', () => { + it('throws an error', async () => { + jest.clearAllMocks() + await expect( + mutate({ + mutation: adminDeleteContribution, + variables: { + id: -1, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('Contribution not found for given id.')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('Contribution not found for given id: -1') + }) + }) + + describe('admin deletes own user contribution', () => { + beforeAll(async () => { + await query({ + query: login, + variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, + }) + result = await mutate({ + mutation: createContribution, + variables: { + amount: 100.0, + memo: 'Test env contribution', + creationDate: contributionDateFormatter(new Date()), + }, + }) + }) + + it('throws an error', async () => { + jest.clearAllMocks() + await expect( + mutate({ + mutation: adminDeleteContribution, + variables: { + id: result.data.createContribution.id, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('Own contribution can not be deleted as admin')], + }), + ) + }) + }) + + describe('creation id does exist', () => { + it('returns true', async () => { + await expect( + mutate({ + mutation: adminDeleteContribution, + variables: { + id: creation ? creation.id : -1, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { adminDeleteContribution: true }, + }), + ) + }) + + it('stores the admin delete contribution event in the database', async () => { + await expect(EventProtocol.find()).resolves.toContainEqual( + expect.objectContaining({ + type: EventProtocolType.ADMIN_CONTRIBUTION_DELETE, + userId: admin.id, + }), + ) + }) + }) + }) + + describe('confirmContribution', () => { + describe('creation does not exits', () => { + it('throws an error', async () => { + jest.clearAllMocks() + await expect( + mutate({ + mutation: confirmContribution, + variables: { + id: -1, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('Contribution not found to given id.')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('Contribution not found for given id: -1') + }) + }) + + describe('confirm own creation', () => { + beforeAll(async () => { + const now = new Date() + creation = await creationFactory(testEnv, { + email: 'peter@lustig.de', + amount: 400, + memo: 'Herzlich Willkommen bei Gradido!', + creationDate: contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() - 1, 1), + ), + }) + }) + + it('thows an error', async () => { + await expect( + mutate({ + mutation: confirmContribution, + variables: { + id: creation ? creation.id : -1, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('Moderator can not confirm own contribution')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('Moderator can not confirm own contribution') + }) + }) + + describe('confirm creation for other user', () => { + beforeAll(async () => { + const now = new Date() + creation = await creationFactory(testEnv, { + email: 'bibi@bloxberg.de', + amount: 450, + memo: 'Herzlich Willkommen bei Gradido liebe Bibi!', + creationDate: contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() - 2, 1), + ), + }) + }) + + it('returns true', async () => { + await expect( + mutate({ + mutation: confirmContribution, + variables: { + id: creation ? creation.id : -1, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { confirmContribution: true }, + }), + ) + }) + + it('stores the contribution confirm event in the database', async () => { + await expect(EventProtocol.find()).resolves.toContainEqual( + expect.objectContaining({ + type: EventProtocolType.CONTRIBUTION_CONFIRM, + }), + ) + }) + + it('creates a transaction', async () => { + const transaction = await DbTransaction.find() + expect(transaction[0].amount.toString()).toBe('450') + expect(transaction[0].memo).toBe('Herzlich Willkommen bei Gradido liebe Bibi!') + expect(transaction[0].linkedTransactionId).toEqual(null) + expect(transaction[0].transactionLinkId).toEqual(null) + expect(transaction[0].previous).toEqual(null) + expect(transaction[0].linkedUserId).toEqual(null) + expect(transaction[0].typeId).toEqual(1) + }) + + 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', + }), + ) + }) + + it('stores the send confirmation email event in the database', async () => { + await expect(EventProtocol.find()).resolves.toContainEqual( + expect.objectContaining({ + type: EventProtocolType.SEND_CONFIRMATION_EMAIL, + }), + ) + }) + }) + + describe('confirm two creations one after the other quickly', () => { + let c1: Contribution | void + let c2: Contribution | void + + beforeAll(async () => { + const now = new Date() + c1 = await creationFactory(testEnv, { + email: 'bibi@bloxberg.de', + amount: 50, + memo: 'Herzlich Willkommen bei Gradido liebe Bibi!', + creationDate: contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() - 2, 1), + ), + }) + c2 = await creationFactory(testEnv, { + email: 'bibi@bloxberg.de', + amount: 50, + memo: 'Herzlich Willkommen bei Gradido liebe Bibi!', + creationDate: contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() - 2, 1), + ), + }) + }) + + // In the futrue this should not throw anymore + it('throws an error for the second confirmation', async () => { + const r1 = mutate({ + mutation: confirmContribution, + variables: { + id: c1 ? c1.id : -1, + }, + }) + const r2 = mutate({ + mutation: confirmContribution, + variables: { + id: c2 ? c2.id : -1, + }, + }) + await expect(r1).resolves.toEqual( + expect.objectContaining({ + data: { confirmContribution: true }, + }), + ) + await expect(r2).resolves.toEqual( + expect.objectContaining({ + // data: { confirmContribution: true }, + errors: [new GraphQLError('Creation was not successful.')], + }), + ) + }) + }) + }) + }) + }) + }) }) diff --git a/backend/src/graphql/resolver/TransactionLinkResolver.test.ts b/backend/src/graphql/resolver/TransactionLinkResolver.test.ts index 275242bd3..5d8e7ec91 100644 --- a/backend/src/graphql/resolver/TransactionLinkResolver.test.ts +++ b/backend/src/graphql/resolver/TransactionLinkResolver.test.ts @@ -4,26 +4,39 @@ import { transactionLinkCode } from './TransactionLinkResolver' import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg' import { peterLustig } from '@/seeds/users/peter-lustig' -import { cleanDB, testEnvironment } from '@test/helpers' +import { cleanDB, testEnvironment, resetToken } from '@test/helpers' +import { creationFactory } from '@/seeds/factory/creation' +import { creations } from '@/seeds/creation/index' import { userFactory } from '@/seeds/factory/user' +import { transactionLinkFactory } from '@/seeds/factory/transactionLink' +import { transactionLinks } from '@/seeds/transactionLink/index' import { login, createContributionLink, + deleteContributionLink, + updateContributionLink, redeemTransactionLink, createContribution, updateContribution, } from '@/seeds/graphql/mutations' +import { listTransactionLinksAdmin, listContributionLinks } from '@/seeds/graphql/queries' import { ContributionLink as DbContributionLink } from '@entity/ContributionLink' +import { User } from '@entity/User' import { UnconfirmedContribution } from '@model/UnconfirmedContribution' import Decimal from 'decimal.js-light' import { GraphQLError } from 'graphql' +import { logger } from '@test/testSetup' -let mutate: any, con: any +let mutate: any, query: any, con: any let testEnv: any +let user: User +let admin: User + beforeAll(async () => { testEnv = await testEnvironment() mutate = testEnv.mutate + query = testEnv.query con = testEnv.con await cleanDB() await userFactory(testEnv, bibiBloxberg) @@ -223,6 +236,885 @@ describe('TransactionLinkResolver', () => { }) }) }) + + describe('transaction links list', () => { + const variables = { + userId: 1, // dummy, may be replaced + filters: null, + currentPage: 1, + pageSize: 5, + } + + // TODO: there is a test not cleaning up after itself! Fix it! + beforeAll(async () => { + await cleanDB() + resetToken() + }) + + describe('unauthenticated', () => { + it('returns an error', async () => { + await expect( + query({ + query: listTransactionLinksAdmin, + variables, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('authenticated', () => { + describe('without admin rights', () => { + beforeAll(async () => { + user = await userFactory(testEnv, bibiBloxberg) + await mutate({ + mutation: login, + variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + }) + }) + + afterAll(async () => { + await cleanDB() + resetToken() + }) + + it('returns an error', async () => { + await expect( + query({ + query: listTransactionLinksAdmin, + variables, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('with admin rights', () => { + beforeAll(async () => { + // admin 'peter@lustig.de' has to exists for 'creationFactory' + admin = await userFactory(testEnv, peterLustig) + + user = await userFactory(testEnv, bibiBloxberg) + variables.userId = user.id + variables.pageSize = 25 + // bibi needs GDDs + const bibisCreation = creations.find((creation) => creation.email === 'bibi@bloxberg.de') + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + await creationFactory(testEnv, bibisCreation!) + // bibis transaktion links + const bibisTransaktionLinks = transactionLinks.filter( + (transactionLink) => transactionLink.email === 'bibi@bloxberg.de', + ) + for (let i = 0; i < bibisTransaktionLinks.length; i++) { + await transactionLinkFactory(testEnv, bibisTransaktionLinks[i]) + } + + // admin: only now log in + await mutate({ + mutation: login, + variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, + }) + }) + + afterAll(async () => { + await cleanDB() + resetToken() + }) + + describe('without any filters', () => { + it('finds 6 open transaction links and no deleted or redeemed', async () => { + await expect( + query({ + query: listTransactionLinksAdmin, + variables, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + listTransactionLinksAdmin: { + linkCount: 6, + linkList: expect.not.arrayContaining([ + expect.objectContaining({ + memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(', + createdAt: expect.any(String), + }), + expect.objectContaining({ + memo: 'Da habe ich mich wohl etwas übernommen.', + deletedAt: expect.any(String), + }), + ]), + }, + }, + }), + ) + }) + }) + + describe('all filters are null', () => { + it('finds 6 open transaction links and no deleted or redeemed', async () => { + await expect( + query({ + query: listTransactionLinksAdmin, + variables: { + ...variables, + filters: { + withDeleted: null, + withExpired: null, + withRedeemed: null, + }, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + listTransactionLinksAdmin: { + linkCount: 6, + linkList: expect.not.arrayContaining([ + expect.objectContaining({ + memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(', + createdAt: expect.any(String), + }), + expect.objectContaining({ + memo: 'Da habe ich mich wohl etwas übernommen.', + deletedAt: expect.any(String), + }), + ]), + }, + }, + }), + ) + }) + }) + + describe('filter with deleted', () => { + it('finds 6 open transaction links, 1 deleted, and no redeemed', async () => { + await expect( + query({ + query: listTransactionLinksAdmin, + variables: { + ...variables, + filters: { + withDeleted: true, + }, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + listTransactionLinksAdmin: { + linkCount: 7, + linkList: expect.arrayContaining([ + expect.not.objectContaining({ + memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(', + createdAt: expect.any(String), + }), + expect.objectContaining({ + memo: 'Da habe ich mich wohl etwas übernommen.', + deletedAt: expect.any(String), + }), + ]), + }, + }, + }), + ) + }) + }) + + describe('filter by expired', () => { + it('finds 5 open transaction links, 1 expired, and no redeemed', async () => { + await expect( + query({ + query: listTransactionLinksAdmin, + variables: { + ...variables, + filters: { + withExpired: true, + }, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + listTransactionLinksAdmin: { + linkCount: 7, + linkList: expect.arrayContaining([ + expect.objectContaining({ + memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(', + createdAt: expect.any(String), + }), + expect.not.objectContaining({ + memo: 'Da habe ich mich wohl etwas übernommen.', + deletedAt: expect.any(String), + }), + ]), + }, + }, + }), + ) + }) + }) + + // TODO: works not as expected, because 'redeemedAt' and 'redeemedBy' have to be added to the transaktion link factory + describe.skip('filter by redeemed', () => { + it('finds 6 open transaction links, 1 deleted, and no redeemed', async () => { + await expect( + query({ + query: listTransactionLinksAdmin, + variables: { + ...variables, + filters: { + withDeleted: null, + withExpired: null, + withRedeemed: true, + }, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + listTransactionLinksAdmin: { + linkCount: 6, + linkList: expect.arrayContaining([ + expect.not.objectContaining({ + memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(', + createdAt: expect.any(String), + }), + expect.objectContaining({ + memo: 'Yeah, eingelöst!', + redeemedAt: expect.any(String), + redeemedBy: expect.any(Number), + }), + expect.not.objectContaining({ + memo: 'Da habe ich mich wohl etwas übernommen.', + deletedAt: expect.any(String), + }), + ]), + }, + }, + }), + ) + }) + }) + }) + }) + }) + + describe('Contribution Links', () => { + const now = new Date() + const variables = { + amount: new Decimal(200), + name: 'Dokumenta 2022', + memo: 'Danke für deine Teilnahme an der Dokumenta 2022', + cycle: 'once', + validFrom: new Date(2022, 5, 18).toISOString(), + validTo: new Date(now.getFullYear() + 1, 7, 14).toISOString(), + maxAmountPerMonth: new Decimal(200), + maxPerCycle: 1, + } + + describe('unauthenticated', () => { + describe('createContributionLink', () => { + it('returns an error', async () => { + await expect(mutate({ mutation: createContributionLink, variables })).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('listContributionLinks', () => { + it('returns an error', async () => { + await expect(query({ query: listContributionLinks })).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('updateContributionLink', () => { + it('returns an error', async () => { + await expect( + mutate({ + mutation: updateContributionLink, + variables: { + ...variables, + id: -1, + amount: new Decimal(400), + name: 'Dokumenta 2023', + memo: 'Danke für deine Teilnahme an der Dokumenta 2023', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('deleteContributionLink', () => { + it('returns an error', async () => { + await expect( + mutate({ mutation: deleteContributionLink, variables: { id: -1 } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + }) + + describe('authenticated', () => { + describe('without admin rights', () => { + beforeAll(async () => { + user = await userFactory(testEnv, bibiBloxberg) + await mutate({ + mutation: login, + variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + }) + }) + + afterAll(async () => { + await cleanDB() + resetToken() + }) + + describe('createContributionLink', () => { + it('returns an error', async () => { + await expect(mutate({ mutation: createContributionLink, variables })).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + // TODO: Set this test in new location to have datas + describe('listContributionLinks', () => { + it('returns an empty object', async () => { + await expect(query({ query: listContributionLinks })).resolves.toEqual( + expect.objectContaining({ + data: { + listContributionLinks: { + count: 0, + links: [], + }, + }, + }), + ) + }) + }) + + describe('updateContributionLink', () => { + it('returns an error', async () => { + await expect( + mutate({ + mutation: updateContributionLink, + variables: { + ...variables, + id: -1, + amount: new Decimal(400), + name: 'Dokumenta 2023', + memo: 'Danke für deine Teilnahme an der Dokumenta 2023', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('deleteContributionLink', () => { + it('returns an error', async () => { + await expect( + mutate({ mutation: deleteContributionLink, variables: { id: -1 } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + }) + + describe('with admin rights', () => { + beforeAll(async () => { + user = await userFactory(testEnv, peterLustig) + await mutate({ + mutation: login, + variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, + }) + }) + + afterAll(async () => { + await cleanDB() + resetToken() + }) + + describe('createContributionLink', () => { + it('returns a contribution link object', async () => { + await expect(mutate({ mutation: createContributionLink, variables })).resolves.toEqual( + expect.objectContaining({ + data: { + createContributionLink: expect.objectContaining({ + id: expect.any(Number), + amount: '200', + code: expect.stringMatching(/^[0-9a-f]{24,24}$/), + link: expect.stringMatching(/^.*?\/CL-[0-9a-f]{24,24}$/), + createdAt: expect.any(String), + name: 'Dokumenta 2022', + memo: 'Danke für deine Teilnahme an der Dokumenta 2022', + validFrom: expect.any(String), + validTo: expect.any(String), + maxAmountPerMonth: '200', + cycle: 'once', + maxPerCycle: 1, + }), + }, + }), + ) + }) + + it('has a contribution link stored in db', async () => { + const cls = await DbContributionLink.find() + expect(cls).toHaveLength(1) + expect(cls[0]).toEqual( + expect.objectContaining({ + id: expect.any(Number), + name: 'Dokumenta 2022', + memo: 'Danke für deine Teilnahme an der Dokumenta 2022', + validFrom: new Date('2022-06-18T00:00:00.000Z'), + validTo: expect.any(Date), + cycle: 'once', + maxPerCycle: 1, + totalMaxCountOfContribution: null, + maxAccountBalance: null, + minGapHours: null, + createdAt: expect.any(Date), + deletedAt: null, + code: expect.stringMatching(/^[0-9a-f]{24,24}$/), + linkEnabled: true, + amount: expect.decimalEqual(200), + maxAmountPerMonth: expect.decimalEqual(200), + }), + ) + }) + + it('returns an error if missing startDate', async () => { + await expect( + mutate({ + mutation: createContributionLink, + variables: { + ...variables, + validFrom: null, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError('Start-Date is not initialized. A Start-Date must be set!'), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'Start-Date is not initialized. A Start-Date must be set!', + ) + }) + + it('returns an error if missing endDate', async () => { + await expect( + mutate({ + mutation: createContributionLink, + variables: { + ...variables, + validTo: null, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('End-Date is not initialized. An End-Date must be set!')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'End-Date is not initialized. An End-Date must be set!', + ) + }) + + it('returns an error if endDate is before startDate', async () => { + await expect( + mutate({ + mutation: createContributionLink, + variables: { + ...variables, + validFrom: new Date('2022-06-18T00:00:00.001Z').toISOString(), + validTo: new Date('2022-06-18T00:00:00.000Z').toISOString(), + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError(`The value of validFrom must before or equals the validTo!`), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + `The value of validFrom must before or equals the validTo!`, + ) + }) + + it('returns an error if name is an empty string', async () => { + await expect( + mutate({ + mutation: createContributionLink, + variables: { + ...variables, + name: '', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('The name must be initialized!')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('The name must be initialized!') + }) + + it('returns an error if name is shorter than 5 characters', async () => { + await expect( + mutate({ + mutation: createContributionLink, + variables: { + ...variables, + name: '123', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError( + `The value of 'name' with a length of 3 did not fulfill the requested bounderies min=5 and max=100`, + ), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + `The value of 'name' with a length of 3 did not fulfill the requested bounderies min=5 and max=100`, + ) + }) + + it('returns an error if name is longer than 100 characters', async () => { + await expect( + mutate({ + mutation: createContributionLink, + variables: { + ...variables, + name: '12345678901234567892123456789312345678941234567895123456789612345678971234567898123456789912345678901', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError( + `The value of 'name' with a length of 101 did not fulfill the requested bounderies min=5 and max=100`, + ), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + `The value of 'name' with a length of 101 did not fulfill the requested bounderies min=5 and max=100`, + ) + }) + + it('returns an error if memo is an empty string', async () => { + await expect( + mutate({ + mutation: createContributionLink, + variables: { + ...variables, + memo: '', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('The memo must be initialized!')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('The memo must be initialized!') + }) + + it('returns an error if memo is shorter than 5 characters', async () => { + await expect( + mutate({ + mutation: createContributionLink, + variables: { + ...variables, + memo: '123', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError( + `The value of 'memo' with a length of 3 did not fulfill the requested bounderies min=5 and max=255`, + ), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + `The value of 'memo' with a length of 3 did not fulfill the requested bounderies min=5 and max=255`, + ) + }) + + it('returns an error if memo is longer than 255 characters', async () => { + await expect( + mutate({ + mutation: createContributionLink, + variables: { + ...variables, + memo: '1234567890123456789212345678931234567894123456789512345678961234567897123456789812345678991234567890123456789012345678921234567893123456789412345678951234567896123456789712345678981234567899123456789012345678901234567892123456789312345678941234567895123456', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError( + `The value of 'memo' with a length of 256 did not fulfill the requested bounderies min=5 and max=255`, + ), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + `The value of 'memo' with a length of 256 did not fulfill the requested bounderies min=5 and max=255`, + ) + }) + + it('returns an error if amount is not positive', async () => { + await expect( + mutate({ + mutation: createContributionLink, + variables: { + ...variables, + amount: new Decimal(0), + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError('The amount=0 must be initialized with a positiv value!'), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'The amount=0 must be initialized with a positiv value!', + ) + }) + }) + + describe('listContributionLinks', () => { + describe('one link in DB', () => { + it('returns the link and count 1', async () => { + await expect(query({ query: listContributionLinks })).resolves.toEqual( + expect.objectContaining({ + data: { + listContributionLinks: { + links: expect.arrayContaining([ + expect.objectContaining({ + amount: '200', + code: expect.stringMatching(/^[0-9a-f]{24,24}$/), + link: expect.stringMatching(/^.*?\/CL-[0-9a-f]{24,24}$/), + createdAt: expect.any(String), + name: 'Dokumenta 2022', + memo: 'Danke für deine Teilnahme an der Dokumenta 2022', + validFrom: expect.any(String), + validTo: expect.any(String), + maxAmountPerMonth: '200', + cycle: 'once', + maxPerCycle: 1, + }), + ]), + count: 1, + }, + }, + }), + ) + }) + }) + }) + + describe('updateContributionLink', () => { + describe('no valid id', () => { + it('returns an error', async () => { + await expect( + mutate({ + mutation: updateContributionLink, + variables: { + ...variables, + id: -1, + amount: new Decimal(400), + name: 'Dokumenta 2023', + memo: 'Danke für deine Teilnahme an der Dokumenta 2023', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('Contribution Link not found to given id.')], + }), + ) + }) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('Contribution Link not found to given id: -1') + }) + + describe('valid id', () => { + let linkId: number + beforeAll(async () => { + const links = await query({ query: listContributionLinks }) + linkId = links.data.listContributionLinks.links[0].id + }) + + it('returns updated contribution link object', async () => { + await expect( + mutate({ + mutation: updateContributionLink, + variables: { + ...variables, + id: linkId, + amount: new Decimal(400), + name: 'Dokumenta 2023', + memo: 'Danke für deine Teilnahme an der Dokumenta 2023', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + updateContributionLink: { + id: linkId, + amount: '400', + code: expect.stringMatching(/^[0-9a-f]{24,24}$/), + link: expect.stringMatching(/^.*?\/CL-[0-9a-f]{24,24}$/), + createdAt: expect.any(String), + name: 'Dokumenta 2023', + memo: 'Danke für deine Teilnahme an der Dokumenta 2023', + validFrom: expect.any(String), + validTo: expect.any(String), + maxAmountPerMonth: '200', + cycle: 'once', + maxPerCycle: 1, + }, + }, + }), + ) + }) + + it('updated the DB record', async () => { + await expect(DbContributionLink.findOne(linkId)).resolves.toEqual( + expect.objectContaining({ + id: linkId, + name: 'Dokumenta 2023', + memo: 'Danke für deine Teilnahme an der Dokumenta 2023', + amount: expect.decimalEqual(400), + }), + ) + }) + }) + }) + + describe('deleteContributionLink', () => { + describe('no valid id', () => { + it('returns an error', async () => { + await expect( + mutate({ mutation: deleteContributionLink, variables: { id: -1 } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('Contribution Link not found to given id.')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('Contribution Link not found to given id: -1') + }) + }) + + describe('valid id', () => { + let linkId: number + beforeAll(async () => { + const links = await query({ query: listContributionLinks }) + linkId = links.data.listContributionLinks.links[0].id + }) + + it('returns a date string', async () => { + await expect( + mutate({ mutation: deleteContributionLink, variables: { id: linkId } }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + deleteContributionLink: expect.any(String), + }, + }), + ) + }) + + it('does not list this contribution link anymore', async () => { + await expect(query({ query: listContributionLinks })).resolves.toEqual( + expect.objectContaining({ + data: { + listContributionLinks: { + links: [], + count: 0, + }, + }, + }), + ) + }) + }) + }) + }) + }) + }) }) describe('transactionLinkCode', () => { diff --git a/backend/src/graphql/resolver/UserResolver.test.ts b/backend/src/graphql/resolver/UserResolver.test.ts index 6323abfde..965a85c29 100644 --- a/backend/src/graphql/resolver/UserResolver.test.ts +++ b/backend/src/graphql/resolver/UserResolver.test.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +import { objectValuesToArray } from '@/util/utilities' import { testEnvironment, headerPushMock, resetToken, cleanDB } from '@test/helpers' import { userFactory } from '@/seeds/factory/user' import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg' @@ -13,8 +14,11 @@ import { updateUserInfos, createContribution, confirmContribution, + setUserRole, + deleteUser, + unDeleteUser, } from '@/seeds/graphql/mutations' -import { verifyLogin, queryOptIn, searchAdminUsers } from '@/seeds/graphql/queries' +import { verifyLogin, queryOptIn, searchAdminUsers, searchUsers } from '@/seeds/graphql/queries' import { GraphQLError } from 'graphql' import { User } from '@entity/User' import CONFIG from '@/config' @@ -36,6 +40,8 @@ import { UserContact } from '@entity/UserContact' import { OptInType } from '../enum/OptInType' import { UserContactType } from '../enum/UserContactType' import { bobBaumeister } from '@/seeds/users/bob-baumeister' +import { stephenHawking } from '@/seeds/users/stephen-hawking' +import { garrickOllivander } from '@/seeds/users/garrick-ollivander' // import { klicktippSignIn } from '@/apis/KlicktippController' @@ -69,6 +75,8 @@ jest.mock('@/apis/KlicktippController', () => { }) */ +let admin: User +let user: User let mutate: any, query: any, con: any let testEnv: any @@ -1159,6 +1167,635 @@ describe('UserResolver', () => { }) }) }) + + describe('set user role', () => { + // TODO: there is a test not cleaning up after itself! Fix it! + beforeAll(async () => { + await cleanDB() + resetToken() + }) + + describe('unauthenticated', () => { + it('returns an error', async () => { + await expect( + mutate({ mutation: setUserRole, variables: { userId: 1, isAdmin: true } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('authenticated', () => { + describe('without admin rights', () => { + beforeAll(async () => { + user = await userFactory(testEnv, bibiBloxberg) + await mutate({ + mutation: login, + variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + }) + }) + + afterAll(async () => { + await cleanDB() + resetToken() + }) + + it('returns an error', async () => { + await expect( + mutate({ mutation: setUserRole, variables: { userId: user.id + 1, isAdmin: true } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('with admin rights', () => { + beforeAll(async () => { + admin = await userFactory(testEnv, peterLustig) + await mutate({ + mutation: login, + variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, + }) + }) + + afterAll(async () => { + await cleanDB() + resetToken() + }) + + describe('user to get a new role does not exist', () => { + it('throws an error', async () => { + jest.clearAllMocks() + await expect( + mutate({ mutation: setUserRole, variables: { userId: admin.id + 1, isAdmin: true } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError(`Could not find user with userId: ${admin.id + 1}`)], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith(`Could not find user with userId: ${admin.id + 1}`) + }) + }) + + describe('change role with success', () => { + beforeAll(async () => { + user = await userFactory(testEnv, bibiBloxberg) + }) + + describe('user gets new role', () => { + describe('to admin', () => { + it('returns date string', async () => { + const result = await mutate({ + mutation: setUserRole, + variables: { userId: user.id, isAdmin: true }, + }) + expect(result).toEqual( + expect.objectContaining({ + data: { + setUserRole: expect.any(String), + }, + }), + ) + expect(new Date(result.data.setUserRole)).toEqual(expect.any(Date)) + }) + }) + + describe('to usual user', () => { + it('returns null', async () => { + await expect( + mutate({ mutation: setUserRole, variables: { userId: user.id, isAdmin: false } }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + setUserRole: null, + }, + }), + ) + }) + }) + }) + }) + + describe('change role with error', () => { + describe('is own role', () => { + it('throws an error', async () => { + jest.clearAllMocks() + await expect( + mutate({ mutation: setUserRole, variables: { userId: admin.id, isAdmin: false } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('Administrator can not change his own role!')], + }), + ) + }) + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('Administrator can not change his own role!') + }) + }) + + describe('user has already role to be set', () => { + describe('to admin', () => { + it('throws an error', async () => { + jest.clearAllMocks() + await mutate({ + mutation: setUserRole, + variables: { userId: user.id, isAdmin: true }, + }) + await expect( + mutate({ mutation: setUserRole, variables: { userId: user.id, isAdmin: true } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('User is already admin!')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('User is already admin!') + }) + }) + + describe('to usual user', () => { + it('throws an error', async () => { + jest.clearAllMocks() + await mutate({ + mutation: setUserRole, + variables: { userId: user.id, isAdmin: false }, + }) + await expect( + mutate({ mutation: setUserRole, variables: { userId: user.id, isAdmin: false } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('User is already a usual user!')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('User is already a usual user!') + }) + }) + }) + }) + }) + }) + }) + + describe('delete user', () => { + describe('unauthenticated', () => { + it('returns an error', async () => { + await expect(mutate({ mutation: deleteUser, variables: { userId: 1 } })).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('authenticated', () => { + describe('without admin rights', () => { + beforeAll(async () => { + user = await userFactory(testEnv, bibiBloxberg) + await mutate({ + mutation: login, + variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + }) + }) + + afterAll(async () => { + await cleanDB() + resetToken() + }) + + it('returns an error', async () => { + await expect( + mutate({ mutation: deleteUser, variables: { userId: user.id + 1 } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('with admin rights', () => { + beforeAll(async () => { + admin = await userFactory(testEnv, peterLustig) + await mutate({ + mutation: login, + variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, + }) + }) + + afterAll(async () => { + await cleanDB() + resetToken() + }) + + describe('user to be deleted does not exist', () => { + it('throws an error', async () => { + jest.clearAllMocks() + await expect( + mutate({ mutation: deleteUser, variables: { userId: admin.id + 1 } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError(`Could not find user with userId: ${admin.id + 1}`)], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith(`Could not find user with userId: ${admin.id + 1}`) + }) + }) + + describe('delete self', () => { + it('throws an error', async () => { + jest.clearAllMocks() + await expect( + mutate({ mutation: deleteUser, variables: { userId: admin.id } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('Moderator can not delete his own account!')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('Moderator can not delete his own account!') + }) + }) + + describe('delete with success', () => { + beforeAll(async () => { + user = await userFactory(testEnv, bibiBloxberg) + }) + + it('returns date string', async () => { + const result = await mutate({ mutation: deleteUser, variables: { userId: user.id } }) + expect(result).toEqual( + expect.objectContaining({ + data: { + deleteUser: expect.any(String), + }, + }), + ) + expect(new Date(result.data.deleteUser)).toEqual(expect.any(Date)) + }) + + describe('delete deleted user', () => { + it('throws an error', async () => { + jest.clearAllMocks() + await expect( + mutate({ mutation: deleteUser, variables: { userId: user.id } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError(`Could not find user with userId: ${user.id}`)], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith(`Could not find user with userId: ${user.id}`) + }) + }) + }) + }) + }) + }) + + describe('unDelete user', () => { + describe('unauthenticated', () => { + it('returns an error', async () => { + await expect(mutate({ mutation: unDeleteUser, variables: { userId: 1 } })).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('authenticated', () => { + describe('without admin rights', () => { + beforeAll(async () => { + user = await userFactory(testEnv, bibiBloxberg) + await mutate({ + mutation: login, + variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + }) + }) + + afterAll(async () => { + await cleanDB() + resetToken() + }) + + it('returns an error', async () => { + await expect( + mutate({ mutation: unDeleteUser, variables: { userId: user.id + 1 } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('with admin rights', () => { + beforeAll(async () => { + admin = await userFactory(testEnv, peterLustig) + await mutate({ + mutation: login, + variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, + }) + }) + + afterAll(async () => { + await cleanDB() + resetToken() + }) + + describe('user to be undelete does not exist', () => { + it('throws an error', async () => { + jest.clearAllMocks() + await expect( + mutate({ mutation: unDeleteUser, variables: { userId: admin.id + 1 } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError(`Could not find user with userId: ${admin.id + 1}`)], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith(`Could not find user with userId: ${admin.id + 1}`) + }) + }) + + describe('user to undelete is not deleted', () => { + beforeAll(async () => { + user = await userFactory(testEnv, bibiBloxberg) + }) + + it('throws an error', async () => { + jest.clearAllMocks() + await expect( + mutate({ mutation: unDeleteUser, variables: { userId: user.id } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('User is not deleted')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('User is not deleted') + }) + + describe('undelete deleted user', () => { + beforeAll(async () => { + await mutate({ mutation: deleteUser, variables: { userId: user.id } }) + }) + + it('returns null', async () => { + await expect( + mutate({ mutation: unDeleteUser, variables: { userId: user.id } }), + ).resolves.toEqual( + expect.objectContaining({ + data: { unDeleteUser: null }, + }), + ) + }) + }) + }) + }) + }) + }) + + describe('search users', () => { + const variablesWithoutTextAndFilters = { + searchText: '', + currentPage: 1, + pageSize: 25, + filters: null, + } + + describe('unauthenticated', () => { + it('returns an error', async () => { + await expect( + query({ + query: searchUsers, + variables: { + ...variablesWithoutTextAndFilters, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('authenticated', () => { + describe('without admin rights', () => { + beforeAll(async () => { + user = await userFactory(testEnv, bibiBloxberg) + await mutate({ + mutation: login, + variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + }) + }) + + afterAll(async () => { + await cleanDB() + resetToken() + }) + + it('returns an error', async () => { + await expect( + query({ + query: searchUsers, + variables: { + ...variablesWithoutTextAndFilters, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('with admin rights', () => { + const allUsers = { + bibi: expect.objectContaining({ + email: 'bibi@bloxberg.de', + }), + garrick: expect.objectContaining({ + email: 'garrick@ollivander.com', + }), + peter: expect.objectContaining({ + email: 'peter@lustig.de', + }), + stephen: expect.objectContaining({ + email: 'stephen@hawking.uk', + }), + } + + beforeAll(async () => { + admin = await userFactory(testEnv, peterLustig) + await mutate({ + mutation: login, + variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, + }) + + await userFactory(testEnv, bibiBloxberg) + await userFactory(testEnv, stephenHawking) + await userFactory(testEnv, garrickOllivander) + }) + + afterAll(async () => { + await cleanDB() + resetToken() + }) + + describe('without any filters', () => { + it('finds all users', async () => { + await expect( + query({ + query: searchUsers, + variables: { + ...variablesWithoutTextAndFilters, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + searchUsers: { + userCount: 4, + userList: expect.arrayContaining(objectValuesToArray(allUsers)), + }, + }, + }), + ) + }) + }) + + describe('all filters are null', () => { + it('finds all users', async () => { + await expect( + query({ + query: searchUsers, + variables: { + ...variablesWithoutTextAndFilters, + filters: { + byActivated: null, + byDeleted: null, + }, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + searchUsers: { + userCount: 4, + userList: expect.arrayContaining(objectValuesToArray(allUsers)), + }, + }, + }), + ) + }) + }) + + describe('filter by unchecked email', () => { + it('finds only users with unchecked email', async () => { + await expect( + query({ + query: searchUsers, + variables: { + ...variablesWithoutTextAndFilters, + filters: { + byActivated: false, + byDeleted: null, + }, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + searchUsers: { + userCount: 1, + userList: expect.arrayContaining([allUsers.garrick]), + }, + }, + }), + ) + }) + }) + + describe('filter by deleted users', () => { + it('finds only users with deleted account', async () => { + await expect( + query({ + query: searchUsers, + variables: { + ...variablesWithoutTextAndFilters, + filters: { + byActivated: null, + byDeleted: true, + }, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + searchUsers: { + userCount: 1, + userList: expect.arrayContaining([allUsers.stephen]), + }, + }, + }), + ) + }) + }) + + describe('filter by deleted account and unchecked email', () => { + it('finds no users', async () => { + await expect( + query({ + query: searchUsers, + variables: { + ...variablesWithoutTextAndFilters, + filters: { + byActivated: false, + byDeleted: true, + }, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + searchUsers: { + userCount: 0, + userList: [], + }, + }, + }), + ) + }) + }) + }) + }) + }) }) describe('printTimeDuration', () => { diff --git a/backend/test/helpers.test.ts b/backend/test/helpers.test.ts new file mode 100644 index 000000000..69d8f3fa4 --- /dev/null +++ b/backend/test/helpers.test.ts @@ -0,0 +1,7 @@ +import { contributionDateFormatter } from '@test/helpers' + +describe('contributionDateFormatter', () => { + it('formats the date correctly', () => { + expect(contributionDateFormatter(new Date('Thu Feb 29 2024 13:12:11'))).toEqual('2/29/2024') + }) +}) From ecb99bd603480dc97d7f7ec1d224fd28506f7f7a Mon Sep 17 00:00:00 2001 From: Ulf Gebhardt Date: Wed, 23 Nov 2022 23:37:47 +0100 Subject: [PATCH 20/90] separate Contribution- and TransactionLink Resolvers --- backend/jest.config.js | 3 +- .../resolver/ContributionLinkResolver.test.ts | 650 ++++++++++++++++++ .../resolver/ContributionLinkResolver.ts | 152 ++++ .../resolver/TransactionLinkResolver.test.ts | 617 +---------------- .../resolver/TransactionLinkResolver.ts | 158 +---- backend/src/graphql/union/QueryLinkResult.ts | 7 + backend/tsconfig.json | 1 + 7 files changed, 818 insertions(+), 770 deletions(-) create mode 100644 backend/src/graphql/resolver/ContributionLinkResolver.test.ts create mode 100644 backend/src/graphql/resolver/ContributionLinkResolver.ts create mode 100644 backend/src/graphql/union/QueryLinkResult.ts diff --git a/backend/jest.config.js b/backend/jest.config.js index a472df316..d6683d292 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -9,9 +9,10 @@ module.exports = { modulePathIgnorePatterns: ['/build/'], moduleNameMapper: { '@/(.*)': '/src/$1', - '@model/(.*)': '/src/graphql/model/$1', '@arg/(.*)': '/src/graphql/arg/$1', '@enum/(.*)': '/src/graphql/enum/$1', + '@model/(.*)': '/src/graphql/model/$1', + '@union/(.*)': '/src/graphql/union/$1', '@repository/(.*)': '/src/typeorm/repository/$1', '@test/(.*)': '/test/$1', '@entity/(.*)': diff --git a/backend/src/graphql/resolver/ContributionLinkResolver.test.ts b/backend/src/graphql/resolver/ContributionLinkResolver.test.ts new file mode 100644 index 000000000..b5f9e27e1 --- /dev/null +++ b/backend/src/graphql/resolver/ContributionLinkResolver.test.ts @@ -0,0 +1,650 @@ +import Decimal from 'decimal.js-light' +import { logger } from '@test/testSetup' +import { GraphQLError } from 'graphql' +import { + login, + createContributionLink, + deleteContributionLink, + updateContributionLink, +} from '@/seeds/graphql/mutations' +import { listContributionLinks } from '@/seeds/graphql/queries' +import { cleanDB, testEnvironment, resetToken } from '@test/helpers' +import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg' +import { peterLustig } from '@/seeds/users/peter-lustig' +import { User } from '@entity/User' +import { userFactory } from '@/seeds/factory/user' +import { ContributionLink as DbContributionLink } from '@entity/ContributionLink' + +let mutate: any, query: any, con: any +let testEnv: any + +let user: User + +beforeAll(async () => { + testEnv = await testEnvironment() + mutate = testEnv.mutate + query = testEnv.query + con = testEnv.con + await cleanDB() + await userFactory(testEnv, bibiBloxberg) + await userFactory(testEnv, peterLustig) +}) + +afterAll(async () => { + await cleanDB() + await con.close() +}) + +describe('Contribution Links', () => { + const now = new Date() + const variables = { + amount: new Decimal(200), + name: 'Dokumenta 2022', + memo: 'Danke für deine Teilnahme an der Dokumenta 2022', + cycle: 'once', + validFrom: new Date(2022, 5, 18).toISOString(), + validTo: new Date(now.getFullYear() + 1, 7, 14).toISOString(), + maxAmountPerMonth: new Decimal(200), + maxPerCycle: 1, + } + + describe('unauthenticated', () => { + afterAll(async () => { + await cleanDB() + resetToken() + }) + + describe('createContributionLink', () => { + it('returns an error', async () => { + await expect(mutate({ mutation: createContributionLink, variables })).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('listContributionLinks', () => { + it('returns an error', async () => { + await expect(query({ query: listContributionLinks })).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('updateContributionLink', () => { + it('returns an error', async () => { + await expect( + mutate({ + mutation: updateContributionLink, + variables: { + ...variables, + id: -1, + amount: new Decimal(400), + name: 'Dokumenta 2023', + memo: 'Danke für deine Teilnahme an der Dokumenta 2023', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('deleteContributionLink', () => { + it('returns an error', async () => { + await expect( + mutate({ mutation: deleteContributionLink, variables: { id: -1 } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + }) + + describe('authenticated', () => { + describe('without admin rights', () => { + beforeAll(async () => { + user = await userFactory(testEnv, bibiBloxberg) + await mutate({ + mutation: login, + variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + }) + }) + + afterAll(async () => { + await cleanDB() + resetToken() + }) + + describe('createContributionLink', () => { + it('returns an error', async () => { + await expect(mutate({ mutation: createContributionLink, variables })).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + // TODO: Set this test in new location to have datas + describe('listContributionLinks', () => { + it('returns an empty object', async () => { + await expect(query({ query: listContributionLinks })).resolves.toEqual( + expect.objectContaining({ + data: { + listContributionLinks: { + count: 0, + links: [], + }, + }, + }), + ) + }) + }) + + describe('updateContributionLink', () => { + it('returns an error', async () => { + await expect( + mutate({ + mutation: updateContributionLink, + variables: { + ...variables, + id: -1, + amount: new Decimal(400), + name: 'Dokumenta 2023', + memo: 'Danke für deine Teilnahme an der Dokumenta 2023', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('deleteContributionLink', () => { + it('returns an error', async () => { + await expect( + mutate({ mutation: deleteContributionLink, variables: { id: -1 } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + }) + + describe('with admin rights', () => { + beforeAll(async () => { + user = await userFactory(testEnv, peterLustig) + await mutate({ + mutation: login, + variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, + }) + }) + + afterAll(async () => { + await cleanDB() + resetToken() + }) + + describe('createContributionLink', () => { + it('returns a contribution link object', async () => { + await expect(mutate({ mutation: createContributionLink, variables })).resolves.toEqual( + expect.objectContaining({ + data: { + createContributionLink: expect.objectContaining({ + id: expect.any(Number), + amount: '200', + code: expect.stringMatching(/^[0-9a-f]{24,24}$/), + link: expect.stringMatching(/^.*?\/CL-[0-9a-f]{24,24}$/), + createdAt: expect.any(String), + name: 'Dokumenta 2022', + memo: 'Danke für deine Teilnahme an der Dokumenta 2022', + validFrom: expect.any(String), + validTo: expect.any(String), + maxAmountPerMonth: '200', + cycle: 'once', + maxPerCycle: 1, + }), + }, + }), + ) + }) + + it('has a contribution link stored in db', async () => { + const cls = await DbContributionLink.find() + expect(cls).toHaveLength(1) + expect(cls[0]).toEqual( + expect.objectContaining({ + id: expect.any(Number), + name: 'Dokumenta 2022', + memo: 'Danke für deine Teilnahme an der Dokumenta 2022', + validFrom: new Date('2022-06-18T00:00:00.000Z'), + validTo: expect.any(Date), + cycle: 'once', + maxPerCycle: 1, + totalMaxCountOfContribution: null, + maxAccountBalance: null, + minGapHours: null, + createdAt: expect.any(Date), + deletedAt: null, + code: expect.stringMatching(/^[0-9a-f]{24,24}$/), + linkEnabled: true, + amount: expect.decimalEqual(200), + maxAmountPerMonth: expect.decimalEqual(200), + }), + ) + }) + + it('returns an error if missing startDate', async () => { + await expect( + mutate({ + mutation: createContributionLink, + variables: { + ...variables, + validFrom: null, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError('Start-Date is not initialized. A Start-Date must be set!'), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'Start-Date is not initialized. A Start-Date must be set!', + ) + }) + + it('returns an error if missing endDate', async () => { + await expect( + mutate({ + mutation: createContributionLink, + variables: { + ...variables, + validTo: null, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('End-Date is not initialized. An End-Date must be set!')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'End-Date is not initialized. An End-Date must be set!', + ) + }) + + it('returns an error if endDate is before startDate', async () => { + await expect( + mutate({ + mutation: createContributionLink, + variables: { + ...variables, + validFrom: new Date('2022-06-18T00:00:00.001Z').toISOString(), + validTo: new Date('2022-06-18T00:00:00.000Z').toISOString(), + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError(`The value of validFrom must before or equals the validTo!`), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + `The value of validFrom must before or equals the validTo!`, + ) + }) + + it('returns an error if name is an empty string', async () => { + await expect( + mutate({ + mutation: createContributionLink, + variables: { + ...variables, + name: '', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('The name must be initialized!')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('The name must be initialized!') + }) + + it('returns an error if name is shorter than 5 characters', async () => { + await expect( + mutate({ + mutation: createContributionLink, + variables: { + ...variables, + name: '123', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError( + `The value of 'name' with a length of 3 did not fulfill the requested bounderies min=5 and max=100`, + ), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + `The value of 'name' with a length of 3 did not fulfill the requested bounderies min=5 and max=100`, + ) + }) + + it('returns an error if name is longer than 100 characters', async () => { + await expect( + mutate({ + mutation: createContributionLink, + variables: { + ...variables, + name: '12345678901234567892123456789312345678941234567895123456789612345678971234567898123456789912345678901', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError( + `The value of 'name' with a length of 101 did not fulfill the requested bounderies min=5 and max=100`, + ), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + `The value of 'name' with a length of 101 did not fulfill the requested bounderies min=5 and max=100`, + ) + }) + + it('returns an error if memo is an empty string', async () => { + await expect( + mutate({ + mutation: createContributionLink, + variables: { + ...variables, + memo: '', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('The memo must be initialized!')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('The memo must be initialized!') + }) + + it('returns an error if memo is shorter than 5 characters', async () => { + await expect( + mutate({ + mutation: createContributionLink, + variables: { + ...variables, + memo: '123', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError( + `The value of 'memo' with a length of 3 did not fulfill the requested bounderies min=5 and max=255`, + ), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + `The value of 'memo' with a length of 3 did not fulfill the requested bounderies min=5 and max=255`, + ) + }) + + it('returns an error if memo is longer than 255 characters', async () => { + await expect( + mutate({ + mutation: createContributionLink, + variables: { + ...variables, + memo: '1234567890123456789212345678931234567894123456789512345678961234567897123456789812345678991234567890123456789012345678921234567893123456789412345678951234567896123456789712345678981234567899123456789012345678901234567892123456789312345678941234567895123456', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError( + `The value of 'memo' with a length of 256 did not fulfill the requested bounderies min=5 and max=255`, + ), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + `The value of 'memo' with a length of 256 did not fulfill the requested bounderies min=5 and max=255`, + ) + }) + + it('returns an error if amount is not positive', async () => { + await expect( + mutate({ + mutation: createContributionLink, + variables: { + ...variables, + amount: new Decimal(0), + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('The amount=0 must be initialized with a positiv value!')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'The amount=0 must be initialized with a positiv value!', + ) + }) + }) + + describe('listContributionLinks', () => { + describe('one link in DB', () => { + it('returns the link and count 1', async () => { + await expect(query({ query: listContributionLinks })).resolves.toEqual( + expect.objectContaining({ + data: { + listContributionLinks: { + links: expect.arrayContaining([ + expect.objectContaining({ + amount: '200', + code: expect.stringMatching(/^[0-9a-f]{24,24}$/), + link: expect.stringMatching(/^.*?\/CL-[0-9a-f]{24,24}$/), + createdAt: expect.any(String), + name: 'Dokumenta 2022', + memo: 'Danke für deine Teilnahme an der Dokumenta 2022', + validFrom: expect.any(String), + validTo: expect.any(String), + maxAmountPerMonth: '200', + cycle: 'once', + maxPerCycle: 1, + }), + ]), + count: 1, + }, + }, + }), + ) + }) + }) + }) + + describe('updateContributionLink', () => { + describe('no valid id', () => { + it('returns an error', async () => { + await expect( + mutate({ + mutation: updateContributionLink, + variables: { + ...variables, + id: -1, + amount: new Decimal(400), + name: 'Dokumenta 2023', + memo: 'Danke für deine Teilnahme an der Dokumenta 2023', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('Contribution Link not found to given id.')], + }), + ) + }) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('Contribution Link not found to given id: -1') + }) + + describe('valid id', () => { + let linkId: number + beforeAll(async () => { + const links = await query({ query: listContributionLinks }) + linkId = links.data.listContributionLinks.links[0].id + }) + + it('returns updated contribution link object', async () => { + await expect( + mutate({ + mutation: updateContributionLink, + variables: { + ...variables, + id: linkId, + amount: new Decimal(400), + name: 'Dokumenta 2023', + memo: 'Danke für deine Teilnahme an der Dokumenta 2023', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + updateContributionLink: { + id: linkId, + amount: '400', + code: expect.stringMatching(/^[0-9a-f]{24,24}$/), + link: expect.stringMatching(/^.*?\/CL-[0-9a-f]{24,24}$/), + createdAt: expect.any(String), + name: 'Dokumenta 2023', + memo: 'Danke für deine Teilnahme an der Dokumenta 2023', + validFrom: expect.any(String), + validTo: expect.any(String), + maxAmountPerMonth: '200', + cycle: 'once', + maxPerCycle: 1, + }, + }, + }), + ) + }) + + it('updated the DB record', async () => { + await expect(DbContributionLink.findOne(linkId)).resolves.toEqual( + expect.objectContaining({ + id: linkId, + name: 'Dokumenta 2023', + memo: 'Danke für deine Teilnahme an der Dokumenta 2023', + amount: expect.decimalEqual(400), + }), + ) + }) + }) + }) + + describe('deleteContributionLink', () => { + describe('no valid id', () => { + it('returns an error', async () => { + await expect( + mutate({ mutation: deleteContributionLink, variables: { id: -1 } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('Contribution Link not found to given id.')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('Contribution Link not found to given id: -1') + }) + }) + + describe('valid id', () => { + let linkId: number + beforeAll(async () => { + const links = await query({ query: listContributionLinks }) + linkId = links.data.listContributionLinks.links[0].id + }) + + it('returns a date string', async () => { + await expect( + mutate({ mutation: deleteContributionLink, variables: { id: linkId } }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + deleteContributionLink: expect.any(String), + }, + }), + ) + }) + + it('does not list this contribution link anymore', async () => { + await expect(query({ query: listContributionLinks })).resolves.toEqual( + expect.objectContaining({ + data: { + listContributionLinks: { + links: [], + count: 0, + }, + }, + }), + ) + }) + }) + }) + }) + }) +}) diff --git a/backend/src/graphql/resolver/ContributionLinkResolver.ts b/backend/src/graphql/resolver/ContributionLinkResolver.ts new file mode 100644 index 000000000..0a6bb971c --- /dev/null +++ b/backend/src/graphql/resolver/ContributionLinkResolver.ts @@ -0,0 +1,152 @@ +import Decimal from 'decimal.js-light' +import { Resolver, Args, Arg, Authorized, Mutation, Query, Int } from 'type-graphql' +import { MoreThan, IsNull } from '@dbTools/typeorm' + +import { + CONTRIBUTIONLINK_NAME_MAX_CHARS, + CONTRIBUTIONLINK_NAME_MIN_CHARS, + MEMO_MAX_CHARS, + MEMO_MIN_CHARS, +} from './const/const' +import { isStartEndDateValid } from './util/creations' +import { ContributionLinkList } from '@model/ContributionLinkList' +import { ContributionLink } from '@model/ContributionLink' +import ContributionLinkArgs from '@arg/ContributionLinkArgs' +import { backendLogger as logger } from '@/server/logger' +import { RIGHTS } from '@/auth/RIGHTS' +import { ContributionLink as DbContributionLink } from '@entity/ContributionLink' +import { Order } from '@enum/Order' +import Paginated from '@arg/Paginated' + +// TODO: this is a strange construct +import { transactionLinkCode as contributionLinkCode } from './TransactionLinkResolver' + +@Resolver() +export class ContributionLinkResolver { + @Authorized([RIGHTS.CREATE_CONTRIBUTION_LINK]) + @Mutation(() => ContributionLink) + async createContributionLink( + @Args() + { + amount, + name, + memo, + cycle, + validFrom, + validTo, + maxAmountPerMonth, + maxPerCycle, + }: ContributionLinkArgs, + ): Promise { + isStartEndDateValid(validFrom, validTo) + if (!name) { + logger.error(`The name must be initialized!`) + throw new Error(`The name must be initialized!`) + } + if ( + name.length < CONTRIBUTIONLINK_NAME_MIN_CHARS || + name.length > CONTRIBUTIONLINK_NAME_MAX_CHARS + ) { + const msg = `The value of 'name' with a length of ${name.length} did not fulfill the requested bounderies min=${CONTRIBUTIONLINK_NAME_MIN_CHARS} and max=${CONTRIBUTIONLINK_NAME_MAX_CHARS}` + logger.error(`${msg}`) + throw new Error(`${msg}`) + } + if (!memo) { + logger.error(`The memo must be initialized!`) + throw new Error(`The memo must be initialized!`) + } + if (memo.length < MEMO_MIN_CHARS || memo.length > MEMO_MAX_CHARS) { + const msg = `The value of 'memo' with a length of ${memo.length} did not fulfill the requested bounderies min=${MEMO_MIN_CHARS} and max=${MEMO_MAX_CHARS}` + logger.error(`${msg}`) + throw new Error(`${msg}`) + } + if (!amount) { + logger.error(`The amount must be initialized!`) + throw new Error('The amount must be initialized!') + } + if (!new Decimal(amount).isPositive()) { + logger.error(`The amount=${amount} must be initialized with a positiv value!`) + throw new Error(`The amount=${amount} must be initialized with a positiv value!`) + } + const dbContributionLink = new DbContributionLink() + dbContributionLink.amount = amount + dbContributionLink.name = name + dbContributionLink.memo = memo + dbContributionLink.createdAt = new Date() + dbContributionLink.code = contributionLinkCode(dbContributionLink.createdAt) + dbContributionLink.cycle = cycle + if (validFrom) dbContributionLink.validFrom = new Date(validFrom) + if (validTo) dbContributionLink.validTo = new Date(validTo) + dbContributionLink.maxAmountPerMonth = maxAmountPerMonth + dbContributionLink.maxPerCycle = maxPerCycle + await dbContributionLink.save() + logger.debug(`createContributionLink successful!`) + return new ContributionLink(dbContributionLink) + } + + @Authorized([RIGHTS.LIST_CONTRIBUTION_LINKS]) + @Query(() => ContributionLinkList) + async listContributionLinks( + @Args() + { currentPage = 1, pageSize = 5, order = Order.DESC }: Paginated, + ): Promise { + const [links, count] = await DbContributionLink.findAndCount({ + where: [{ validTo: MoreThan(new Date()) }, { validTo: IsNull() }], + order: { createdAt: order }, + skip: (currentPage - 1) * pageSize, + take: pageSize, + }) + return { + links: links.map((link: DbContributionLink) => new ContributionLink(link)), + count, + } + } + + @Authorized([RIGHTS.DELETE_CONTRIBUTION_LINK]) + @Mutation(() => Date, { nullable: true }) + async deleteContributionLink(@Arg('id', () => Int) id: number): Promise { + const contributionLink = await DbContributionLink.findOne(id) + if (!contributionLink) { + logger.error(`Contribution Link not found to given id: ${id}`) + throw new Error('Contribution Link not found to given id.') + } + await contributionLink.softRemove() + logger.debug(`deleteContributionLink successful!`) + const newContributionLink = await DbContributionLink.findOne({ id }, { withDeleted: true }) + return newContributionLink ? newContributionLink.deletedAt : null + } + + @Authorized([RIGHTS.UPDATE_CONTRIBUTION_LINK]) + @Mutation(() => ContributionLink) + async updateContributionLink( + @Args() + { + amount, + name, + memo, + cycle, + validFrom, + validTo, + maxAmountPerMonth, + maxPerCycle, + }: ContributionLinkArgs, + @Arg('id', () => Int) id: number, + ): Promise { + const dbContributionLink = await DbContributionLink.findOne(id) + if (!dbContributionLink) { + logger.error(`Contribution Link not found to given id: ${id}`) + throw new Error('Contribution Link not found to given id.') + } + dbContributionLink.amount = amount + dbContributionLink.name = name + dbContributionLink.memo = memo + dbContributionLink.cycle = cycle + if (validFrom) dbContributionLink.validFrom = new Date(validFrom) + if (validTo) dbContributionLink.validTo = new Date(validTo) + dbContributionLink.maxAmountPerMonth = maxAmountPerMonth + dbContributionLink.maxPerCycle = maxPerCycle + await dbContributionLink.save() + logger.debug(`updateContributionLink successful!`) + return new ContributionLink(dbContributionLink) + } +} diff --git a/backend/src/graphql/resolver/TransactionLinkResolver.test.ts b/backend/src/graphql/resolver/TransactionLinkResolver.test.ts index 5d8e7ec91..6f500db0a 100644 --- a/backend/src/graphql/resolver/TransactionLinkResolver.test.ts +++ b/backend/src/graphql/resolver/TransactionLinkResolver.test.ts @@ -13,19 +13,16 @@ import { transactionLinks } from '@/seeds/transactionLink/index' import { login, createContributionLink, - deleteContributionLink, - updateContributionLink, redeemTransactionLink, createContribution, updateContribution, } from '@/seeds/graphql/mutations' -import { listTransactionLinksAdmin, listContributionLinks } from '@/seeds/graphql/queries' +import { listTransactionLinksAdmin } from '@/seeds/graphql/queries' import { ContributionLink as DbContributionLink } from '@entity/ContributionLink' import { User } from '@entity/User' import { UnconfirmedContribution } from '@model/UnconfirmedContribution' import Decimal from 'decimal.js-light' import { GraphQLError } from 'graphql' -import { logger } from '@test/testSetup' let mutate: any, query: any, con: any let testEnv: any @@ -49,6 +46,7 @@ afterAll(async () => { }) describe('TransactionLinkResolver', () => { + // TODO: have this test separated into a transactionLink and a contributionLink part (if possible) describe('redeem daily Contribution Link', () => { const now = new Date() let contributionLink: DbContributionLink | undefined @@ -504,617 +502,6 @@ describe('TransactionLinkResolver', () => { }) }) }) - - describe('Contribution Links', () => { - const now = new Date() - const variables = { - amount: new Decimal(200), - name: 'Dokumenta 2022', - memo: 'Danke für deine Teilnahme an der Dokumenta 2022', - cycle: 'once', - validFrom: new Date(2022, 5, 18).toISOString(), - validTo: new Date(now.getFullYear() + 1, 7, 14).toISOString(), - maxAmountPerMonth: new Decimal(200), - maxPerCycle: 1, - } - - describe('unauthenticated', () => { - describe('createContributionLink', () => { - it('returns an error', async () => { - await expect(mutate({ mutation: createContributionLink, variables })).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('listContributionLinks', () => { - it('returns an error', async () => { - await expect(query({ query: listContributionLinks })).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('updateContributionLink', () => { - it('returns an error', async () => { - await expect( - mutate({ - mutation: updateContributionLink, - variables: { - ...variables, - id: -1, - amount: new Decimal(400), - name: 'Dokumenta 2023', - memo: 'Danke für deine Teilnahme an der Dokumenta 2023', - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('deleteContributionLink', () => { - it('returns an error', async () => { - await expect( - mutate({ mutation: deleteContributionLink, variables: { id: -1 } }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - }) - - describe('authenticated', () => { - describe('without admin rights', () => { - beforeAll(async () => { - user = await userFactory(testEnv, bibiBloxberg) - await mutate({ - mutation: login, - variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, - }) - }) - - afterAll(async () => { - await cleanDB() - resetToken() - }) - - describe('createContributionLink', () => { - it('returns an error', async () => { - await expect(mutate({ mutation: createContributionLink, variables })).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - // TODO: Set this test in new location to have datas - describe('listContributionLinks', () => { - it('returns an empty object', async () => { - await expect(query({ query: listContributionLinks })).resolves.toEqual( - expect.objectContaining({ - data: { - listContributionLinks: { - count: 0, - links: [], - }, - }, - }), - ) - }) - }) - - describe('updateContributionLink', () => { - it('returns an error', async () => { - await expect( - mutate({ - mutation: updateContributionLink, - variables: { - ...variables, - id: -1, - amount: new Decimal(400), - name: 'Dokumenta 2023', - memo: 'Danke für deine Teilnahme an der Dokumenta 2023', - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('deleteContributionLink', () => { - it('returns an error', async () => { - await expect( - mutate({ mutation: deleteContributionLink, variables: { id: -1 } }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - }) - - describe('with admin rights', () => { - beforeAll(async () => { - user = await userFactory(testEnv, peterLustig) - await mutate({ - mutation: login, - variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, - }) - }) - - afterAll(async () => { - await cleanDB() - resetToken() - }) - - describe('createContributionLink', () => { - it('returns a contribution link object', async () => { - await expect(mutate({ mutation: createContributionLink, variables })).resolves.toEqual( - expect.objectContaining({ - data: { - createContributionLink: expect.objectContaining({ - id: expect.any(Number), - amount: '200', - code: expect.stringMatching(/^[0-9a-f]{24,24}$/), - link: expect.stringMatching(/^.*?\/CL-[0-9a-f]{24,24}$/), - createdAt: expect.any(String), - name: 'Dokumenta 2022', - memo: 'Danke für deine Teilnahme an der Dokumenta 2022', - validFrom: expect.any(String), - validTo: expect.any(String), - maxAmountPerMonth: '200', - cycle: 'once', - maxPerCycle: 1, - }), - }, - }), - ) - }) - - it('has a contribution link stored in db', async () => { - const cls = await DbContributionLink.find() - expect(cls).toHaveLength(1) - expect(cls[0]).toEqual( - expect.objectContaining({ - id: expect.any(Number), - name: 'Dokumenta 2022', - memo: 'Danke für deine Teilnahme an der Dokumenta 2022', - validFrom: new Date('2022-06-18T00:00:00.000Z'), - validTo: expect.any(Date), - cycle: 'once', - maxPerCycle: 1, - totalMaxCountOfContribution: null, - maxAccountBalance: null, - minGapHours: null, - createdAt: expect.any(Date), - deletedAt: null, - code: expect.stringMatching(/^[0-9a-f]{24,24}$/), - linkEnabled: true, - amount: expect.decimalEqual(200), - maxAmountPerMonth: expect.decimalEqual(200), - }), - ) - }) - - it('returns an error if missing startDate', async () => { - await expect( - mutate({ - mutation: createContributionLink, - variables: { - ...variables, - validFrom: null, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError('Start-Date is not initialized. A Start-Date must be set!'), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - 'Start-Date is not initialized. A Start-Date must be set!', - ) - }) - - it('returns an error if missing endDate', async () => { - await expect( - mutate({ - mutation: createContributionLink, - variables: { - ...variables, - validTo: null, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('End-Date is not initialized. An End-Date must be set!')], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - 'End-Date is not initialized. An End-Date must be set!', - ) - }) - - it('returns an error if endDate is before startDate', async () => { - await expect( - mutate({ - mutation: createContributionLink, - variables: { - ...variables, - validFrom: new Date('2022-06-18T00:00:00.001Z').toISOString(), - validTo: new Date('2022-06-18T00:00:00.000Z').toISOString(), - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError(`The value of validFrom must before or equals the validTo!`), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - `The value of validFrom must before or equals the validTo!`, - ) - }) - - it('returns an error if name is an empty string', async () => { - await expect( - mutate({ - mutation: createContributionLink, - variables: { - ...variables, - name: '', - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('The name must be initialized!')], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith('The name must be initialized!') - }) - - it('returns an error if name is shorter than 5 characters', async () => { - await expect( - mutate({ - mutation: createContributionLink, - variables: { - ...variables, - name: '123', - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError( - `The value of 'name' with a length of 3 did not fulfill the requested bounderies min=5 and max=100`, - ), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - `The value of 'name' with a length of 3 did not fulfill the requested bounderies min=5 and max=100`, - ) - }) - - it('returns an error if name is longer than 100 characters', async () => { - await expect( - mutate({ - mutation: createContributionLink, - variables: { - ...variables, - name: '12345678901234567892123456789312345678941234567895123456789612345678971234567898123456789912345678901', - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError( - `The value of 'name' with a length of 101 did not fulfill the requested bounderies min=5 and max=100`, - ), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - `The value of 'name' with a length of 101 did not fulfill the requested bounderies min=5 and max=100`, - ) - }) - - it('returns an error if memo is an empty string', async () => { - await expect( - mutate({ - mutation: createContributionLink, - variables: { - ...variables, - memo: '', - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('The memo must be initialized!')], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith('The memo must be initialized!') - }) - - it('returns an error if memo is shorter than 5 characters', async () => { - await expect( - mutate({ - mutation: createContributionLink, - variables: { - ...variables, - memo: '123', - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError( - `The value of 'memo' with a length of 3 did not fulfill the requested bounderies min=5 and max=255`, - ), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - `The value of 'memo' with a length of 3 did not fulfill the requested bounderies min=5 and max=255`, - ) - }) - - it('returns an error if memo is longer than 255 characters', async () => { - await expect( - mutate({ - mutation: createContributionLink, - variables: { - ...variables, - memo: '1234567890123456789212345678931234567894123456789512345678961234567897123456789812345678991234567890123456789012345678921234567893123456789412345678951234567896123456789712345678981234567899123456789012345678901234567892123456789312345678941234567895123456', - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError( - `The value of 'memo' with a length of 256 did not fulfill the requested bounderies min=5 and max=255`, - ), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - `The value of 'memo' with a length of 256 did not fulfill the requested bounderies min=5 and max=255`, - ) - }) - - it('returns an error if amount is not positive', async () => { - await expect( - mutate({ - mutation: createContributionLink, - variables: { - ...variables, - amount: new Decimal(0), - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError('The amount=0 must be initialized with a positiv value!'), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - 'The amount=0 must be initialized with a positiv value!', - ) - }) - }) - - describe('listContributionLinks', () => { - describe('one link in DB', () => { - it('returns the link and count 1', async () => { - await expect(query({ query: listContributionLinks })).resolves.toEqual( - expect.objectContaining({ - data: { - listContributionLinks: { - links: expect.arrayContaining([ - expect.objectContaining({ - amount: '200', - code: expect.stringMatching(/^[0-9a-f]{24,24}$/), - link: expect.stringMatching(/^.*?\/CL-[0-9a-f]{24,24}$/), - createdAt: expect.any(String), - name: 'Dokumenta 2022', - memo: 'Danke für deine Teilnahme an der Dokumenta 2022', - validFrom: expect.any(String), - validTo: expect.any(String), - maxAmountPerMonth: '200', - cycle: 'once', - maxPerCycle: 1, - }), - ]), - count: 1, - }, - }, - }), - ) - }) - }) - }) - - describe('updateContributionLink', () => { - describe('no valid id', () => { - it('returns an error', async () => { - await expect( - mutate({ - mutation: updateContributionLink, - variables: { - ...variables, - id: -1, - amount: new Decimal(400), - name: 'Dokumenta 2023', - memo: 'Danke für deine Teilnahme an der Dokumenta 2023', - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('Contribution Link not found to given id.')], - }), - ) - }) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith('Contribution Link not found to given id: -1') - }) - - describe('valid id', () => { - let linkId: number - beforeAll(async () => { - const links = await query({ query: listContributionLinks }) - linkId = links.data.listContributionLinks.links[0].id - }) - - it('returns updated contribution link object', async () => { - await expect( - mutate({ - mutation: updateContributionLink, - variables: { - ...variables, - id: linkId, - amount: new Decimal(400), - name: 'Dokumenta 2023', - memo: 'Danke für deine Teilnahme an der Dokumenta 2023', - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - updateContributionLink: { - id: linkId, - amount: '400', - code: expect.stringMatching(/^[0-9a-f]{24,24}$/), - link: expect.stringMatching(/^.*?\/CL-[0-9a-f]{24,24}$/), - createdAt: expect.any(String), - name: 'Dokumenta 2023', - memo: 'Danke für deine Teilnahme an der Dokumenta 2023', - validFrom: expect.any(String), - validTo: expect.any(String), - maxAmountPerMonth: '200', - cycle: 'once', - maxPerCycle: 1, - }, - }, - }), - ) - }) - - it('updated the DB record', async () => { - await expect(DbContributionLink.findOne(linkId)).resolves.toEqual( - expect.objectContaining({ - id: linkId, - name: 'Dokumenta 2023', - memo: 'Danke für deine Teilnahme an der Dokumenta 2023', - amount: expect.decimalEqual(400), - }), - ) - }) - }) - }) - - describe('deleteContributionLink', () => { - describe('no valid id', () => { - it('returns an error', async () => { - await expect( - mutate({ mutation: deleteContributionLink, variables: { id: -1 } }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('Contribution Link not found to given id.')], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith('Contribution Link not found to given id: -1') - }) - }) - - describe('valid id', () => { - let linkId: number - beforeAll(async () => { - const links = await query({ query: listContributionLinks }) - linkId = links.data.listContributionLinks.links[0].id - }) - - it('returns a date string', async () => { - await expect( - mutate({ mutation: deleteContributionLink, variables: { id: linkId } }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - deleteContributionLink: expect.any(String), - }, - }), - ) - }) - - it('does not list this contribution link anymore', async () => { - await expect(query({ query: listContributionLinks })).resolves.toEqual( - expect.objectContaining({ - data: { - listContributionLinks: { - links: [], - count: 0, - }, - }, - }), - ) - }) - }) - }) - }) - }) - }) }) describe('transactionLinkCode', () => { diff --git a/backend/src/graphql/resolver/TransactionLinkResolver.ts b/backend/src/graphql/resolver/TransactionLinkResolver.ts index 297a96ce9..d983fe368 100644 --- a/backend/src/graphql/resolver/TransactionLinkResolver.ts +++ b/backend/src/graphql/resolver/TransactionLinkResolver.ts @@ -1,7 +1,7 @@ import { randomBytes } from 'crypto' import Decimal from 'decimal.js-light' -import { getConnection, MoreThan, FindOperator, IsNull } from '@dbTools/typeorm' +import { getConnection, MoreThan, FindOperator } from '@dbTools/typeorm' import { TransactionLink as DbTransactionLink } from '@entity/TransactionLink' import { User as DbUser } from '@entity/User' @@ -13,7 +13,6 @@ import { User } from '@model/User' import { ContributionLink } from '@model/ContributionLink' import { Decay } from '@model/Decay' import { TransactionLink, TransactionLinkResult } from '@model/TransactionLink' -import { ContributionLinkList } from '@model/ContributionLinkList' import { Order } from '@enum/Order' import { ContributionType } from '@enum/ContributionType' import { ContributionStatus } from '@enum/ContributionStatus' @@ -22,38 +21,16 @@ import { ContributionCycleType } from '@enum/ContributionCycleType' import TransactionLinkArgs from '@arg/TransactionLinkArgs' import Paginated from '@arg/Paginated' import TransactionLinkFilters from '@arg/TransactionLinkFilters' -import ContributionLinkArgs from '@arg/ContributionLinkArgs' import { backendLogger as logger } from '@/server/logger' import { Context, getUser, getClientTimezoneOffset } from '@/server/context' -import { - Resolver, - Args, - Arg, - Authorized, - Ctx, - Mutation, - Query, - Int, - createUnionType, -} from 'type-graphql' +import { Resolver, Args, Arg, Authorized, Ctx, Mutation, Query, Int } from 'type-graphql' import { calculateBalance } from '@/util/validate' import { RIGHTS } from '@/auth/RIGHTS' import { calculateDecay } from '@/util/decay' -import { getUserCreation, validateContribution, isStartEndDateValid } from './util/creations' -import { - CONTRIBUTIONLINK_NAME_MAX_CHARS, - CONTRIBUTIONLINK_NAME_MIN_CHARS, - MEMO_MAX_CHARS, - MEMO_MIN_CHARS, -} from './const/const' +import { getUserCreation, validateContribution } from './util/creations' import { executeTransaction } from './TransactionResolver' -import { transactionLinkCode as contributionLinkCode } from './TransactionLinkResolver' - -const QueryLinkResult = createUnionType({ - name: 'QueryLinkResult', // the name of the GraphQL union - types: () => [TransactionLink, ContributionLink] as const, // function that returns tuple of object types classes -}) +import QueryLinkResult from '@union/QueryLinkResult' // TODO: do not export, test it inside the resolver export const transactionLinkCode = (date: Date): string => { @@ -401,131 +378,4 @@ export class TransactionLinkResolver { linkList: transactionLinks.map((tl) => new TransactionLink(tl, new User(user))), } } - - @Authorized([RIGHTS.CREATE_CONTRIBUTION_LINK]) - @Mutation(() => ContributionLink) - async createContributionLink( - @Args() - { - amount, - name, - memo, - cycle, - validFrom, - validTo, - maxAmountPerMonth, - maxPerCycle, - }: ContributionLinkArgs, - ): Promise { - isStartEndDateValid(validFrom, validTo) - if (!name) { - logger.error(`The name must be initialized!`) - throw new Error(`The name must be initialized!`) - } - if ( - name.length < CONTRIBUTIONLINK_NAME_MIN_CHARS || - name.length > CONTRIBUTIONLINK_NAME_MAX_CHARS - ) { - const msg = `The value of 'name' with a length of ${name.length} did not fulfill the requested bounderies min=${CONTRIBUTIONLINK_NAME_MIN_CHARS} and max=${CONTRIBUTIONLINK_NAME_MAX_CHARS}` - logger.error(`${msg}`) - throw new Error(`${msg}`) - } - if (!memo) { - logger.error(`The memo must be initialized!`) - throw new Error(`The memo must be initialized!`) - } - if (memo.length < MEMO_MIN_CHARS || memo.length > MEMO_MAX_CHARS) { - const msg = `The value of 'memo' with a length of ${memo.length} did not fulfill the requested bounderies min=${MEMO_MIN_CHARS} and max=${MEMO_MAX_CHARS}` - logger.error(`${msg}`) - throw new Error(`${msg}`) - } - if (!amount) { - logger.error(`The amount must be initialized!`) - throw new Error('The amount must be initialized!') - } - if (!new Decimal(amount).isPositive()) { - logger.error(`The amount=${amount} must be initialized with a positiv value!`) - throw new Error(`The amount=${amount} must be initialized with a positiv value!`) - } - const dbContributionLink = new DbContributionLink() - dbContributionLink.amount = amount - dbContributionLink.name = name - dbContributionLink.memo = memo - dbContributionLink.createdAt = new Date() - dbContributionLink.code = contributionLinkCode(dbContributionLink.createdAt) - dbContributionLink.cycle = cycle - if (validFrom) dbContributionLink.validFrom = new Date(validFrom) - if (validTo) dbContributionLink.validTo = new Date(validTo) - dbContributionLink.maxAmountPerMonth = maxAmountPerMonth - dbContributionLink.maxPerCycle = maxPerCycle - await dbContributionLink.save() - logger.debug(`createContributionLink successful!`) - return new ContributionLink(dbContributionLink) - } - - @Authorized([RIGHTS.LIST_CONTRIBUTION_LINKS]) - @Query(() => ContributionLinkList) - async listContributionLinks( - @Args() - { currentPage = 1, pageSize = 5, order = Order.DESC }: Paginated, - ): Promise { - const [links, count] = await DbContributionLink.findAndCount({ - where: [{ validTo: MoreThan(new Date()) }, { validTo: IsNull() }], - order: { createdAt: order }, - skip: (currentPage - 1) * pageSize, - take: pageSize, - }) - return { - links: links.map((link: DbContributionLink) => new ContributionLink(link)), - count, - } - } - - @Authorized([RIGHTS.DELETE_CONTRIBUTION_LINK]) - @Mutation(() => Date, { nullable: true }) - async deleteContributionLink(@Arg('id', () => Int) id: number): Promise { - const contributionLink = await DbContributionLink.findOne(id) - if (!contributionLink) { - logger.error(`Contribution Link not found to given id: ${id}`) - throw new Error('Contribution Link not found to given id.') - } - await contributionLink.softRemove() - logger.debug(`deleteContributionLink successful!`) - const newContributionLink = await DbContributionLink.findOne({ id }, { withDeleted: true }) - return newContributionLink ? newContributionLink.deletedAt : null - } - - @Authorized([RIGHTS.UPDATE_CONTRIBUTION_LINK]) - @Mutation(() => ContributionLink) - async updateContributionLink( - @Args() - { - amount, - name, - memo, - cycle, - validFrom, - validTo, - maxAmountPerMonth, - maxPerCycle, - }: ContributionLinkArgs, - @Arg('id', () => Int) id: number, - ): Promise { - const dbContributionLink = await DbContributionLink.findOne(id) - if (!dbContributionLink) { - logger.error(`Contribution Link not found to given id: ${id}`) - throw new Error('Contribution Link not found to given id.') - } - dbContributionLink.amount = amount - dbContributionLink.name = name - dbContributionLink.memo = memo - dbContributionLink.cycle = cycle - if (validFrom) dbContributionLink.validFrom = new Date(validFrom) - if (validTo) dbContributionLink.validTo = new Date(validTo) - dbContributionLink.maxAmountPerMonth = maxAmountPerMonth - dbContributionLink.maxPerCycle = maxPerCycle - await dbContributionLink.save() - logger.debug(`updateContributionLink successful!`) - return new ContributionLink(dbContributionLink) - } } diff --git a/backend/src/graphql/union/QueryLinkResult.ts b/backend/src/graphql/union/QueryLinkResult.ts new file mode 100644 index 000000000..bcd0ad6b8 --- /dev/null +++ b/backend/src/graphql/union/QueryLinkResult.ts @@ -0,0 +1,7 @@ +import { createUnionType } from 'type-graphql' +import { TransactionLink } from '@model/TransactionLink' +import { ContributionLink } from '@model/ContributionLink' +export default createUnionType({ + name: 'QueryLinkResult', // the name of the GraphQL union + types: () => [TransactionLink, ContributionLink] as const, // function that returns tuple of object types classes +}) diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 2e5a8b5b2..52241a0a6 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -51,6 +51,7 @@ "@arg/*": ["src/graphql/arg/*"], "@enum/*": ["src/graphql/enum/*"], "@model/*": ["src/graphql/model/*"], + "@union/*": ["src/graphql/union/*"], "@repository/*": ["src/typeorm/repository/*"], "@test/*": ["test/*"], /* external */ From 7b2e6730bde20e093041ad109f985b98a0e9aaa1 Mon Sep 17 00:00:00 2001 From: Ulf Gebhardt Date: Wed, 23 Nov 2022 23:44:43 +0100 Subject: [PATCH 21/90] lint fixes --- .../graphql/resolver/ContributionLinkResolver.test.ts | 7 +++---- .../src/graphql/resolver/ContributionResolver.test.ts | 9 ++++----- .../src/graphql/resolver/TransactionLinkResolver.test.ts | 3 +-- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/backend/src/graphql/resolver/ContributionLinkResolver.test.ts b/backend/src/graphql/resolver/ContributionLinkResolver.test.ts index b5f9e27e1..c1a0895e2 100644 --- a/backend/src/graphql/resolver/ContributionLinkResolver.test.ts +++ b/backend/src/graphql/resolver/ContributionLinkResolver.test.ts @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + import Decimal from 'decimal.js-light' import { logger } from '@test/testSetup' import { GraphQLError } from 'graphql' @@ -11,15 +13,12 @@ import { listContributionLinks } from '@/seeds/graphql/queries' import { cleanDB, testEnvironment, resetToken } from '@test/helpers' import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg' import { peterLustig } from '@/seeds/users/peter-lustig' -import { User } from '@entity/User' import { userFactory } from '@/seeds/factory/user' import { ContributionLink as DbContributionLink } from '@entity/ContributionLink' let mutate: any, query: any, con: any let testEnv: any -let user: User - beforeAll(async () => { testEnv = await testEnvironment() mutate = testEnv.mutate @@ -185,7 +184,7 @@ describe('Contribution Links', () => { describe('with admin rights', () => { beforeAll(async () => { - user = await userFactory(testEnv, peterLustig) + await userFactory(testEnv, peterLustig) await mutate({ mutation: login, variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, diff --git a/backend/src/graphql/resolver/ContributionResolver.test.ts b/backend/src/graphql/resolver/ContributionResolver.test.ts index 0b1113df9..1223ded0e 100644 --- a/backend/src/graphql/resolver/ContributionResolver.test.ts +++ b/backend/src/graphql/resolver/ContributionResolver.test.ts @@ -46,7 +46,6 @@ jest.mock('@/mailer/sendContributionConfirmedEmail', () => { let mutate: any, query: any, con: any let testEnv: any let creation: Contribution | void -let user: User let admin: User let result: any @@ -1009,7 +1008,7 @@ describe('ContributionResolver', () => { describe('authenticated', () => { describe('without admin rights', () => { beforeAll(async () => { - user = await userFactory(testEnv, bibiBloxberg) + await userFactory(testEnv, bibiBloxberg) await mutate({ mutation: login, variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, @@ -1168,7 +1167,7 @@ describe('ContributionResolver', () => { describe('user to create for is deleted', () => { beforeAll(async () => { - user = await userFactory(testEnv, stephenHawking) + await userFactory(testEnv, stephenHawking) variables.email = 'stephen@hawking.uk' variables.creationDate = contributionDateFormatter( new Date(now.getFullYear(), now.getMonth() - 1, 1), @@ -1197,7 +1196,7 @@ describe('ContributionResolver', () => { describe('user to create for has email not confirmed', () => { beforeAll(async () => { - user = await userFactory(testEnv, garrickOllivander) + await userFactory(testEnv, garrickOllivander) variables.email = 'garrick@ollivander.com' variables.creationDate = contributionDateFormatter( new Date(now.getFullYear(), now.getMonth() - 1, 1), @@ -1226,7 +1225,7 @@ describe('ContributionResolver', () => { describe('valid user to create for', () => { beforeAll(async () => { - user = await userFactory(testEnv, bibiBloxberg) + await userFactory(testEnv, bibiBloxberg) variables.email = 'bibi@bloxberg.de' variables.creationDate = 'invalid-date' }) diff --git a/backend/src/graphql/resolver/TransactionLinkResolver.test.ts b/backend/src/graphql/resolver/TransactionLinkResolver.test.ts index 6f500db0a..28422af26 100644 --- a/backend/src/graphql/resolver/TransactionLinkResolver.test.ts +++ b/backend/src/graphql/resolver/TransactionLinkResolver.test.ts @@ -28,7 +28,6 @@ let mutate: any, query: any, con: any let testEnv: any let user: User -let admin: User beforeAll(async () => { testEnv = await testEnvironment() @@ -296,7 +295,7 @@ describe('TransactionLinkResolver', () => { describe('with admin rights', () => { beforeAll(async () => { // admin 'peter@lustig.de' has to exists for 'creationFactory' - admin = await userFactory(testEnv, peterLustig) + await userFactory(testEnv, peterLustig) user = await userFactory(testEnv, bibiBloxberg) variables.userId = user.id From 6f8212fe548944a02470b324e67c2a4bc149646d Mon Sep 17 00:00:00 2001 From: Ulf Gebhardt Date: Wed, 23 Nov 2022 23:52:53 +0100 Subject: [PATCH 22/90] test fix --- backend/src/graphql/resolver/ContributionLinkResolver.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/graphql/resolver/ContributionLinkResolver.test.ts b/backend/src/graphql/resolver/ContributionLinkResolver.test.ts index c1a0895e2..0cf27bf33 100644 --- a/backend/src/graphql/resolver/ContributionLinkResolver.test.ts +++ b/backend/src/graphql/resolver/ContributionLinkResolver.test.ts @@ -110,7 +110,7 @@ describe('Contribution Links', () => { describe('authenticated', () => { describe('without admin rights', () => { beforeAll(async () => { - user = await userFactory(testEnv, bibiBloxberg) + await userFactory(testEnv, bibiBloxberg) await mutate({ mutation: login, variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, From c87ffd5b8a54d57ec9617961b1a8a9174c9d5714 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Thu, 24 Nov 2022 14:44:50 +0100 Subject: [PATCH 23/90] Refactor 'sendContributionConfirmedEmail' email to HTML and translatable --- .../emails/addedContributionMessage/html.pug | 4 +-- .../src/emails/contributionConfirmed/html.pug | 17 ++++++++++ .../emails/contributionConfirmed/subject.pug | 1 + backend/src/emails/sendEmailTranslated.ts | 3 +- backend/src/emails/sendEmailVariants.ts | 34 +++++++++++++++++++ backend/src/graphql/resolver/AdminResolver.ts | 10 +++--- backend/src/locales/de.json | 10 ++++-- backend/src/locales/en.json | 10 ++++-- 8 files changed, 76 insertions(+), 13 deletions(-) create mode 100644 backend/src/emails/contributionConfirmed/html.pug create mode 100644 backend/src/emails/contributionConfirmed/subject.pug diff --git a/backend/src/emails/addedContributionMessage/html.pug b/backend/src/emails/addedContributionMessage/html.pug index 020f36c33..5e5d0975c 100644 --- a/backend/src/emails/addedContributionMessage/html.pug +++ b/backend/src/emails/addedContributionMessage/html.pug @@ -8,10 +8,10 @@ html(lang=locale) 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.addedContributionMessage.linkToYourAccount') + p= t('emails.general.linkToYourAccount') span= " " a(href=overviewURL) #{overviewURL} - p= t('emails.addedContributionMessage.pleaseDoNotReply') + p= t('emails.general.pleaseDoNotReply') p(style='margin-top: 24px;')= t('emails.general.sincerelyYours') br span= t('emails.general.yourGradidoTeam') diff --git a/backend/src/emails/contributionConfirmed/html.pug b/backend/src/emails/contributionConfirmed/html.pug new file mode 100644 index 000000000..e60e6c700 --- /dev/null +++ b/backend/src/emails/contributionConfirmed/html.pug @@ -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.contributionConfirmed.contributionAmount', { 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') diff --git a/backend/src/emails/contributionConfirmed/subject.pug b/backend/src/emails/contributionConfirmed/subject.pug new file mode 100644 index 000000000..7e74a77c6 --- /dev/null +++ b/backend/src/emails/contributionConfirmed/subject.pug @@ -0,0 +1 @@ += t('emails.contributionConfirmed.subject') \ No newline at end of file diff --git a/backend/src/emails/sendEmailTranslated.ts b/backend/src/emails/sendEmailTranslated.ts index 39291a0ac..9468e9f97 100644 --- a/backend/src/emails/sendEmailTranslated.ts +++ b/backend/src/emails/sendEmailTranslated.ts @@ -1,11 +1,10 @@ +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 diff --git a/backend/src/emails/sendEmailVariants.ts b/backend/src/emails/sendEmailVariants.ts index 4d0cd6e5c..8d1dcf8de 100644 --- a/backend/src/emails/sendEmailVariants.ts +++ b/backend/src/emails/sendEmailVariants.ts @@ -1,3 +1,5 @@ +import i18n from 'i18n' +import Decimal from 'decimal.js-light' import CONFIG from '@/config' import { sendEmailTranslated } from './sendEmailTranslated' @@ -66,3 +68,35 @@ export const sendAccountMultiRegistrationEmail = (data: { }, }) } + +export const sendContributionConfirmedEmail = (data: { + firstName: string + lastName: string + email: string + language: string + senderFirstName: string + senderLastName: string + contributionMemo: string + contributionAmount: Decimal +}): Promise | null> => { + const rememberLocaleToRestore = i18n.getLocale() + i18n.setLocale(data.language) + const contributionAmount = data.contributionAmount + .toFixed(2) + .replace('.', i18n.__('emails.general.decimalSeparator')) + i18n.setLocale(rememberLocaleToRestore) + 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, + overviewURL: CONFIG.EMAIL_LINK_OVERVIEW, + }, + }) +} diff --git a/backend/src/graphql/resolver/AdminResolver.ts b/backend/src/graphql/resolver/AdminResolver.ts index 1c1e8968b..8c4f2e00b 100644 --- a/backend/src/graphql/resolver/AdminResolver.ts +++ b/backend/src/graphql/resolver/AdminResolver.ts @@ -43,6 +43,7 @@ import { findUserByEmail, activationLink, getTimeDurationObject } from './UserRe import { sendAddedContributionMessageEmail, sendAccountActivationEmail, + sendContributionConfirmedEmail, } from '@/emails/sendEmailVariants' import { transactionLinkCode as contributionLinkCode } from './TransactionLinkResolver' import CONFIG from '@/config' @@ -66,7 +67,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 { eventProtocol } from '@/event/EventProtocolEmitter' import { @@ -584,14 +584,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() diff --git a/backend/src/locales/de.json b/backend/src/locales/de.json index 85bac73c8..cca40d630 100644 --- a/backend/src/locales/de.json +++ b/backend/src/locales/de.json @@ -2,8 +2,6 @@ "emails": { "addedContributionMessage": { "commonGoodContributionMessage": "du hast zu deinem Gemeinwohl-Beitrag „{contributionMemo}“ eine Nachricht von {senderFirstName} {senderLastName} erhalten.", - "linkToYourAccount": "Link zu deinem Konto:", - "pleaseDoNotReply": "Bitte antworte nicht auf diese E-Mail!", "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“!" }, @@ -22,8 +20,16 @@ "onForgottenPasswordCopyLink": "oder kopiere den obigen Link in dein Browserfenster.", "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.", + "contributionAmount": "Betrag: {contributionAmount} GDD", + "subject": "Gradido: Dein Gemeinwohl-Beitrag wurde bestätigt" + }, "general": { + "decimalSeparator": ",", "helloName": "Hallo {firstName} {lastName},", + "linkToYourAccount": "Link zu deinem Konto:", + "pleaseDoNotReply": "Bitte antworte nicht auf diese E-Mail!", "sincerelyYours": "Liebe Grüße", "yourGradidoTeam": "dein Gradido-Team" } diff --git a/backend/src/locales/en.json b/backend/src/locales/en.json index 5207696da..09cf61a9e 100644 --- a/backend/src/locales/en.json +++ b/backend/src/locales/en.json @@ -2,8 +2,6 @@ "emails": { "addedContributionMessage": { "commonGoodContributionMessage": "you have received a message from {senderFirstName} {senderLastName} regarding your common good contribution “{contributionMemo}”.", - "linkToYourAccount": "Link to your account:", - "pleaseDoNotReply": "Please do not reply to this email!", "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!" }, @@ -22,8 +20,16 @@ "onForgottenPasswordCopyLink": "or copy the link above into your browser window.", "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.", + "contributionAmount": "Amount: {contributionAmount} GDD", + "subject": "Gradido: Your common good contribution was confirmed" + }, "general": { + "decimalSeparator": ".", "helloName": "Hello {firstName} {lastName}", + "linkToYourAccount": "Link to your account:", + "pleaseDoNotReply": "Please do not reply to this email!", "sincerelyYours": "Kind regards,", "yourGradidoTeam": "your Gradido team" } From 727e5ca1f68d85f6f3d2a962fd9bed14bf1a6e99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Thu, 24 Nov 2022 14:45:33 +0100 Subject: [PATCH 24/90] Test 'sendContributionConfirmedEmail' --- backend/src/emails/sendEmailVariants.test.ts | 96 ++++++++++++++++++- .../graphql/resolver/AdminResolver.test.ts | 41 ++++---- 2 files changed, 109 insertions(+), 28 deletions(-) diff --git a/backend/src/emails/sendEmailVariants.test.ts b/backend/src/emails/sendEmailVariants.test.ts index 38e901828..1d562ebe9 100644 --- a/backend/src/emails/sendEmailVariants.test.ts +++ b/backend/src/emails/sendEmailVariants.test.ts @@ -1,16 +1,30 @@ +/* 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 { sendAddedContributionMessageEmail, sendAccountActivationEmail, sendAccountMultiRegistrationEmail, + sendContributionConfirmedEmail, } 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') @@ -243,4 +257,76 @@ describe('sendEmailVariants', () => { }) }) }) + + 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 ', + }, + 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 ', + from: 'Gradido (nicht antworten) ', + 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('') + expect(result.originalMessage.html).toContain('') + expect(result.originalMessage.html).toContain( + 'Gradido: Your common good contribution was confirmed', + ) + expect(result.originalMessage.html).toContain( + '>Gradido: Your common good contribution was confirmed', + ) + 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: http://localhost/overview', + ) + expect(result.originalMessage.html).toContain('Please do not reply to this email!') + expect(result.originalMessage.html).toContain('Kind regards,
your Gradido team') + }) + }) + }) }) diff --git a/backend/src/graphql/resolver/AdminResolver.test.ts b/backend/src/graphql/resolver/AdminResolver.test.ts index 2ff7b0437..d5b4cade5 100644 --- a/backend/src/graphql/resolver/AdminResolver.test.ts +++ b/backend/src/graphql/resolver/AdminResolver.test.ts @@ -36,13 +36,14 @@ 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 '@/emails/sendEmailVariants' +import { + // sendAccountActivationEmail, + sendContributionConfirmedEmail, +} 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' @@ -53,15 +54,10 @@ jest.mock('@/emails/sendEmailVariants', () => { __esModule: true, ...originalModule, // TODO: test the call of … - sendAccountActivationEmail: jest.fn((a) => originalModule.sendAccountActivationEmail(a)), - } -}) - -// mock account activation email to avoid console spam -jest.mock('@/mailer/sendContributionConfirmedEmail', () => { - return { - __esModule: true, - sendContributionConfirmedEmail: jest.fn(), + // sendAccountActivationEmail: jest.fn((a) => originalModule.sendAccountActivationEmail(a)), + sendContributionConfirmedEmail: jest.fn((a) => + originalModule.sendContributionConfirmedEmail(a), + ), } }) @@ -1718,17 +1714,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 () => { From dd33254acbc59f579e47eb8fe9967036857dd682 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Thu, 24 Nov 2022 14:56:09 +0100 Subject: [PATCH 25/90] Remove old untranslated email 'sendContributionConfirmedEmail' --- .../sendContributionConfirmedEmail.test.ts | 39 ------------------- .../mailer/sendContributionConfirmedEmail.ts | 26 ------------- .../src/mailer/text/contributionConfirmed.ts | 30 -------------- 3 files changed, 95 deletions(-) delete mode 100644 backend/src/mailer/sendContributionConfirmedEmail.test.ts delete mode 100644 backend/src/mailer/sendContributionConfirmedEmail.ts delete mode 100644 backend/src/mailer/text/contributionConfirmed.ts diff --git a/backend/src/mailer/sendContributionConfirmedEmail.test.ts b/backend/src/mailer/sendContributionConfirmedEmail.test.ts deleted file mode 100644 index bd89afa69..000000000 --- a/backend/src/mailer/sendContributionConfirmedEmail.test.ts +++ /dev/null @@ -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 ', - 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'), - }) - }) -}) diff --git a/backend/src/mailer/sendContributionConfirmedEmail.ts b/backend/src/mailer/sendContributionConfirmedEmail.ts deleted file mode 100644 index 439d240eb..000000000 --- a/backend/src/mailer/sendContributionConfirmedEmail.ts +++ /dev/null @@ -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 => { - 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), - }) -} diff --git a/backend/src/mailer/text/contributionConfirmed.ts b/backend/src/mailer/text/contributionConfirmed.ts deleted file mode 100644 index 106c3a4c5..000000000 --- a/backend/src/mailer/text/contributionConfirmed.ts +++ /dev/null @@ -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`, - }, -} From 72213988ac0b91b3d433305273cbbc590ed88d73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Thu, 24 Nov 2022 16:10:29 +0100 Subject: [PATCH 26/90] Refactor 'sendContributionRejectedEmail' email to HTML and translatable --- .../src/emails/contributionRejected/html.pug | 17 +++++++++++++ .../emails/contributionRejected/subject.pug | 1 + backend/src/emails/sendEmailVariants.ts | 24 +++++++++++++++++++ backend/src/graphql/resolver/AdminResolver.ts | 11 ++++----- backend/src/locales/de.json | 5 ++++ backend/src/locales/en.json | 7 +++++- 6 files changed, 58 insertions(+), 7 deletions(-) create mode 100644 backend/src/emails/contributionRejected/html.pug create mode 100644 backend/src/emails/contributionRejected/subject.pug diff --git a/backend/src/emails/contributionRejected/html.pug b/backend/src/emails/contributionRejected/html.pug new file mode 100644 index 000000000..07c014f92 --- /dev/null +++ b/backend/src/emails/contributionRejected/html.pug @@ -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') diff --git a/backend/src/emails/contributionRejected/subject.pug b/backend/src/emails/contributionRejected/subject.pug new file mode 100644 index 000000000..cdaae4157 --- /dev/null +++ b/backend/src/emails/contributionRejected/subject.pug @@ -0,0 +1 @@ += t('emails.contributionRejected.subject') \ No newline at end of file diff --git a/backend/src/emails/sendEmailVariants.ts b/backend/src/emails/sendEmailVariants.ts index 8d1dcf8de..1779cc297 100644 --- a/backend/src/emails/sendEmailVariants.ts +++ b/backend/src/emails/sendEmailVariants.ts @@ -100,3 +100,27 @@ export const sendContributionConfirmedEmail = (data: { }, }) } + +export const sendContributionRejectedEmail = (data: { + firstName: string + lastName: string + email: string + language: string + senderFirstName: string + senderLastName: string + contributionMemo: string +}): Promise | 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, + }, + }) +} diff --git a/backend/src/graphql/resolver/AdminResolver.ts b/backend/src/graphql/resolver/AdminResolver.ts index 8c4f2e00b..993fe8c3b 100644 --- a/backend/src/graphql/resolver/AdminResolver.ts +++ b/backend/src/graphql/resolver/AdminResolver.ts @@ -44,6 +44,7 @@ import { sendAddedContributionMessageEmail, sendAccountActivationEmail, sendContributionConfirmedEmail, + sendContributionRejectedEmail, } from '@/emails/sendEmailVariants' import { transactionLinkCode as contributionLinkCode } from './TransactionLinkResolver' import CONFIG from '@/config' @@ -67,7 +68,6 @@ import { ContributionMessage as DbContributionMessage } from '@entity/Contributi import ContributionMessageArgs from '@arg/ContributionMessageArgs' import { ContributionMessageType } from '@enum/MessageType' import { ContributionMessage } from '@model/ContributionMessage' -import { sendContributionRejectedEmail } from '@/mailer/sendContributionRejectedEmail' import { eventProtocol } from '@/event/EventProtocolEmitter' import { Event, @@ -489,14 +489,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 diff --git a/backend/src/locales/de.json b/backend/src/locales/de.json index cca40d630..277728439 100644 --- a/backend/src/locales/de.json +++ b/backend/src/locales/de.json @@ -25,6 +25,11 @@ "contributionAmount": "Betrag: {contributionAmount} GDD", "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": { "decimalSeparator": ",", "helloName": "Hallo {firstName} {lastName},", diff --git a/backend/src/locales/en.json b/backend/src/locales/en.json index 09cf61a9e..d4292b05f 100644 --- a/backend/src/locales/en.json +++ b/backend/src/locales/en.json @@ -3,7 +3,7 @@ "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!" + "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:", @@ -25,6 +25,11 @@ "contributionAmount": "Amount: {contributionAmount} GDD", "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": { "decimalSeparator": ".", "helloName": "Hello {firstName} {lastName}", From ae65af9df801b56bdc67b8a95544d2b9e118d2c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Thu, 24 Nov 2022 16:10:41 +0100 Subject: [PATCH 27/90] Test 'sendContributionRejectedEmail' --- backend/src/emails/sendEmailVariants.test.ts | 75 ++++++++++++++++++- .../graphql/resolver/AdminResolver.test.ts | 3 + 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/backend/src/emails/sendEmailVariants.test.ts b/backend/src/emails/sendEmailVariants.test.ts index 1d562ebe9..666fea994 100644 --- a/backend/src/emails/sendEmailVariants.test.ts +++ b/backend/src/emails/sendEmailVariants.test.ts @@ -9,6 +9,7 @@ import { sendAccountActivationEmail, sendAccountMultiRegistrationEmail, sendContributionConfirmedEmail, + sendContributionRejectedEmail, } from './sendEmailVariants' import { sendEmailTranslated } from './sendEmailTranslated' @@ -99,7 +100,7 @@ describe('sendEmailVariants', () => { '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!', + '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: http://localhost/overview', @@ -329,4 +330,76 @@ describe('sendEmailVariants', () => { }) }) }) + + 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 ', + }, + 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 ', + from: 'Gradido (nicht antworten) ', + 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('') + expect(result.originalMessage.html).toContain('') + expect(result.originalMessage.html).toContain( + 'Gradido: Your common good contribution was rejected', + ) + expect(result.originalMessage.html).toContain( + '>Gradido: Your common good contribution was rejected', + ) + 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: http://localhost/overview', + ) + expect(result.originalMessage.html).toContain('Please do not reply to this email!') + expect(result.originalMessage.html).toContain('Kind regards,
your Gradido team') + }) + }) + }) }) diff --git a/backend/src/graphql/resolver/AdminResolver.test.ts b/backend/src/graphql/resolver/AdminResolver.test.ts index d5b4cade5..5c9e3250e 100644 --- a/backend/src/graphql/resolver/AdminResolver.test.ts +++ b/backend/src/graphql/resolver/AdminResolver.test.ts @@ -39,6 +39,7 @@ import { User } from '@entity/User' import { // sendAccountActivationEmail, sendContributionConfirmedEmail, + // sendContributionRejectedEmail, } from '@/emails/sendEmailVariants' import Decimal from 'decimal.js-light' import { Contribution } from '@entity/Contribution' @@ -58,6 +59,8 @@ jest.mock('@/emails/sendEmailVariants', () => { sendContributionConfirmedEmail: jest.fn((a) => originalModule.sendContributionConfirmedEmail(a), ), + // TODO: test the call of … + // sendContributionRejectedEmail: jest.fn((a) => originalModule.sendContributionRejectedEmail(a)), } }) From 3b030e461270b1e2f48f534b9c7020207191a115 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Thu, 24 Nov 2022 16:12:48 +0100 Subject: [PATCH 28/90] Remove old untranslated email 'sendContributionRejectedEmail' --- .../sendContributionRejectedEmail.test.ts | 38 ------------------- .../mailer/sendContributionRejectedEmail.ts | 26 ------------- .../src/mailer/text/contributionRejected.ts | 28 -------------- 3 files changed, 92 deletions(-) delete mode 100644 backend/src/mailer/sendContributionRejectedEmail.test.ts delete mode 100644 backend/src/mailer/sendContributionRejectedEmail.ts delete mode 100644 backend/src/mailer/text/contributionRejected.ts diff --git a/backend/src/mailer/sendContributionRejectedEmail.test.ts b/backend/src/mailer/sendContributionRejectedEmail.test.ts deleted file mode 100644 index be41ff15f..000000000 --- a/backend/src/mailer/sendContributionRejectedEmail.test.ts +++ /dev/null @@ -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 ', - 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'), - }) - }) -}) diff --git a/backend/src/mailer/sendContributionRejectedEmail.ts b/backend/src/mailer/sendContributionRejectedEmail.ts deleted file mode 100644 index 9edb5ba2a..000000000 --- a/backend/src/mailer/sendContributionRejectedEmail.ts +++ /dev/null @@ -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 => { - 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), - }) -} diff --git a/backend/src/mailer/text/contributionRejected.ts b/backend/src/mailer/text/contributionRejected.ts deleted file mode 100644 index ff52c7b5a..000000000 --- a/backend/src/mailer/text/contributionRejected.ts +++ /dev/null @@ -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`, - }, -} From 44f2e6b06a03a049415f61900bb9b01b6eb7b225 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Thu, 24 Nov 2022 20:15:55 +0100 Subject: [PATCH 29/90] Move 'getTimeDurationObject' and 'printTimeDuration' into a separate file --- backend/src/graphql/resolver/AdminResolver.ts | 3 ++- .../src/graphql/resolver/UserResolver.test.ts | 3 ++- backend/src/graphql/resolver/UserResolver.ts | 18 +----------------- backend/src/util/time.ts | 16 ++++++++++++++++ 4 files changed, 21 insertions(+), 19 deletions(-) create mode 100644 backend/src/util/time.ts diff --git a/backend/src/graphql/resolver/AdminResolver.ts b/backend/src/graphql/resolver/AdminResolver.ts index 993fe8c3b..574377dce 100644 --- a/backend/src/graphql/resolver/AdminResolver.ts +++ b/backend/src/graphql/resolver/AdminResolver.ts @@ -39,7 +39,8 @@ import { Decay } from '@model/Decay' import Paginated from '@arg/Paginated' import TransactionLinkFilters from '@arg/TransactionLinkFilters' import { Order } from '@enum/Order' -import { findUserByEmail, activationLink, getTimeDurationObject } from './UserResolver' +import { getTimeDurationObject } from '@/util/time' +import { findUserByEmail, activationLink } from './UserResolver' import { sendAddedContributionMessageEmail, sendAccountActivationEmail, diff --git a/backend/src/graphql/resolver/UserResolver.test.ts b/backend/src/graphql/resolver/UserResolver.test.ts index dac45c289..2be2361e9 100644 --- a/backend/src/graphql/resolver/UserResolver.test.ts +++ b/backend/src/graphql/resolver/UserResolver.test.ts @@ -3,6 +3,7 @@ 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 { @@ -24,7 +25,7 @@ import { sendAccountMultiRegistrationEmail, } from '@/emails/sendEmailVariants' import { sendResetPasswordEmail } from '@/mailer/sendResetPasswordEmail' -import { printTimeDuration, activationLink } from './UserResolver' +import { activationLink } from './UserResolver' import { contributionLinkFactory } from '@/seeds/factory/contributionLink' import { transactionLinkFactory } from '@/seeds/factory/transactionLink' import { ContributionLink } from '@model/ContributionLink' diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index ddfa94eab..4ec4af166 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -9,6 +9,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' @@ -916,20 +917,3 @@ const canResendOptIn = (optIn: LoginEmailOptIn): boolean => { const canEmailResend = (updatedAt: Date): boolean => { return !isTimeExpired(updatedAt, CONFIG.EMAIL_CODE_REQUEST_TIME) } - -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 -} diff --git a/backend/src/util/time.ts b/backend/src/util/time.ts new file mode 100644 index 000000000..d429c8d6b --- /dev/null +++ b/backend/src/util/time.ts @@ -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 +} From a652654be76a49de237e5c35fd8d942c1bd98945 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Thu, 24 Nov 2022 22:09:36 +0100 Subject: [PATCH 30/90] Refactor 'sendResetPasswordEmail' email to HTML and translatable --- backend/src/emails/accountActivation/html.pug | 2 +- backend/src/emails/resetPassword/html.pug | 20 +++++++++++++++++ backend/src/emails/resetPassword/subject.pug | 1 + backend/src/emails/sendEmailVariants.ts | 22 +++++++++++++++++++ backend/src/graphql/resolver/UserResolver.ts | 9 ++++---- backend/src/locales/de.json | 8 ++++++- backend/src/locales/en.json | 12 +++++++--- 7 files changed, 65 insertions(+), 9 deletions(-) create mode 100644 backend/src/emails/resetPassword/html.pug create mode 100644 backend/src/emails/resetPassword/subject.pug diff --git a/backend/src/emails/accountActivation/html.pug b/backend/src/emails/accountActivation/html.pug index 9c631c960..f283e941e 100644 --- a/backend/src/emails/accountActivation/html.pug +++ b/backend/src/emails/accountActivation/html.pug @@ -11,7 +11,7 @@ html(lang=locale) br a(href=activationLink) #{activationLink} br - span= t('emails.accountActivation.orCopyLink') + span= t('emails.general.orCopyLink') p= t('emails.accountActivation.duration', { hours: timeDurationObject.hours, minutes: timeDurationObject.minutes }) br a(href=resendLink) #{resendLink} diff --git a/backend/src/emails/resetPassword/html.pug b/backend/src/emails/resetPassword/html.pug new file mode 100644 index 000000000..a3ced9a75 --- /dev/null +++ b/backend/src/emails/resetPassword/html.pug @@ -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') diff --git a/backend/src/emails/resetPassword/subject.pug b/backend/src/emails/resetPassword/subject.pug new file mode 100644 index 000000000..3d2b1f00f --- /dev/null +++ b/backend/src/emails/resetPassword/subject.pug @@ -0,0 +1 @@ += t('emails.resetPassword.subject') \ No newline at end of file diff --git a/backend/src/emails/sendEmailVariants.ts b/backend/src/emails/sendEmailVariants.ts index 1779cc297..e8f208eb0 100644 --- a/backend/src/emails/sendEmailVariants.ts +++ b/backend/src/emails/sendEmailVariants.ts @@ -124,3 +124,25 @@ export const sendContributionRejectedEmail = (data: { }, }) } + +export const sendResetPasswordEmail = (data: { + firstName: string + lastName: string + email: string + language: string + resetLink: string + timeDurationObject: Record +}): Promise | 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, + }, + }) +} diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index 4ec4af166..6bdddb7b7 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -18,10 +18,10 @@ 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, sendAccountMultiRegistrationEmail, + sendResetPasswordEmail, } from '@/emails/sendEmailVariants' import { klicktippSignIn } from '@/apis/KlicktippController' import { RIGHTS } from '@/auth/RIGHTS' @@ -574,12 +574,13 @@ export class UserResolver { // optInCode = await checkOptInCode(optInCode, user, OptInType.EMAIL_OPT_IN_RESET_PASSWORD) logger.info(`optInCode for ${email}=${dbUserContact}`) // 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(dbUserContact.emailVerificationCode), + timeDurationObject: getTimeDurationObject(CONFIG.EMAIL_CODE_VALID_TIME), }) /* uncomment this, when you need the activation link on the console */ diff --git a/backend/src/locales/de.json b/backend/src/locales/de.json index 277728439..fd9b74662 100644 --- a/backend/src/locales/de.json +++ b/backend/src/locales/de.json @@ -9,7 +9,6 @@ "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:", - "orCopyLink": "oder kopiere den obigen Link in dein Browserfenster.", "subject": "Gradido: E-Mail Überprüfung" }, "accountMultiRegistration": { @@ -30,10 +29,17 @@ "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“!" }, + "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." + }, "general": { "decimalSeparator": ",", "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" diff --git a/backend/src/locales/en.json b/backend/src/locales/en.json index d4292b05f..c688102c6 100644 --- a/backend/src/locales/en.json +++ b/backend/src/locales/en.json @@ -9,7 +9,6 @@ "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:", - "orCopyLink": "or copy the link above into your browser window.", "subject": "Gradido: Email Verification" }, "accountMultiRegistration": { @@ -21,19 +20,26 @@ "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.", + "commonGoodContributionConfirmed": "Your public good contribution “{contributionMemo}” has just been confirmed by {senderFirstName} {senderLastName} and credited to your Gradido account.", "contributionAmount": "Amount: {contributionAmount} GDD", "subject": "Gradido: Your common good contribution was confirmed" }, "contributionRejected": { - "commonGoodContributionRejected": "your public good contribution “{contributionMemo}” was rejected by {senderFirstName} {senderLastName}.", + "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!" }, + "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." + }, "general": { "decimalSeparator": ".", "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" From 2d634b4111cc789e80cfd0bf1546887c03b1b9f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Thu, 24 Nov 2022 22:10:07 +0100 Subject: [PATCH 31/90] Test 'sendResetPasswordEmail' --- backend/src/emails/sendEmailVariants.test.ts | 77 ++++++++++++++++++- .../src/graphql/resolver/UserResolver.test.ts | 18 ++--- 2 files changed, 83 insertions(+), 12 deletions(-) diff --git a/backend/src/emails/sendEmailVariants.test.ts b/backend/src/emails/sendEmailVariants.test.ts index 666fea994..dd5bb68e9 100644 --- a/backend/src/emails/sendEmailVariants.test.ts +++ b/backend/src/emails/sendEmailVariants.test.ts @@ -10,6 +10,7 @@ import { sendAccountMultiRegistrationEmail, sendContributionConfirmedEmail, sendContributionRejectedEmail, + sendResetPasswordEmail, } from './sendEmailVariants' import { sendEmailTranslated } from './sendEmailTranslated' @@ -319,7 +320,7 @@ describe('sendEmailVariants', () => { ) 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.', + '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( @@ -389,7 +390,7 @@ describe('sendEmailVariants', () => { ) expect(result.originalMessage.html).toContain('Hello Peter Lustig') expect(result.originalMessage.html).toContain( - 'your public good contribution “My contribution.” was rejected by Bibi Bloxberg.', + '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!', @@ -402,4 +403,76 @@ describe('sendEmailVariants', () => { }) }) }) + + 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 ', + }, + 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 ', + from: 'Gradido (nicht antworten) ', + attachments: [], + subject: 'Gradido: Reset password', + html: expect.any(String), + text: expect.stringContaining('GRADIDO: RESET PASSWORD'), + }), + }) + expect(result.originalMessage.html).toContain('') + expect(result.originalMessage.html).toContain('') + expect(result.originalMessage.html).toContain('Gradido: Reset password') + expect(result.originalMessage.html).toContain('>Gradido: Reset password') + 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( + 'http://localhost/reset-password/3762660021544901417', + ) + 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( + `${CONFIG.EMAIL_LINK_FORGOTPASSWORD}`, + ) + expect(result.originalMessage.html).toContain('Kind regards,
your Gradido team') + }) + }) + }) }) diff --git a/backend/src/graphql/resolver/UserResolver.test.ts b/backend/src/graphql/resolver/UserResolver.test.ts index 2be2361e9..ee88e58cc 100644 --- a/backend/src/graphql/resolver/UserResolver.test.ts +++ b/backend/src/graphql/resolver/UserResolver.test.ts @@ -23,8 +23,8 @@ import CONFIG from '@/config' import { sendAccountActivationEmail, sendAccountMultiRegistrationEmail, + sendResetPasswordEmail, } from '@/emails/sendEmailVariants' -import { sendResetPasswordEmail } from '@/mailer/sendResetPasswordEmail' import { activationLink } from './UserResolver' import { contributionLinkFactory } from '@/seeds/factory/contributionLink' import { transactionLinkFactory } from '@/seeds/factory/transactionLink' @@ -53,13 +53,7 @@ jest.mock('@/emails/sendEmailVariants', () => { sendAccountMultiRegistrationEmail: jest.fn((a) => originalModule.sendAccountMultiRegistrationEmail(a), ), - } -}) - -jest.mock('@/mailer/sendResetPasswordEmail', () => { - return { - __esModule: true, - sendResetPasswordEmail: jest.fn(), + sendResetPasswordEmail: jest.fn((a) => originalModule.sendResetPasswordEmail(a)), } }) @@ -857,11 +851,15 @@ 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), + language: 'de', + resetLink: activationLink(emailContact.emailVerificationCode), + timeDurationObject: expect.objectContaining({ + hours: expect.any(Number), + minutes: expect.any(Number), + }), }) }) From b68f550b6cc8952e5c91d6de2e8cd82fee8fec1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Thu, 24 Nov 2022 22:16:18 +0100 Subject: [PATCH 32/90] Remove old untranslated email 'sendResetPasswordEmail' --- .../src/mailer/sendResetPasswordEmail.test.ts | 32 ------------------- backend/src/mailer/sendResetPasswordEmail.ts | 17 ---------- backend/src/mailer/text/resetPassword.ts | 30 ----------------- 3 files changed, 79 deletions(-) delete mode 100644 backend/src/mailer/sendResetPasswordEmail.test.ts delete mode 100644 backend/src/mailer/sendResetPasswordEmail.ts delete mode 100644 backend/src/mailer/text/resetPassword.ts diff --git a/backend/src/mailer/sendResetPasswordEmail.test.ts b/backend/src/mailer/sendResetPasswordEmail.test.ts deleted file mode 100644 index 94f69cf8b..000000000 --- a/backend/src/mailer/sendResetPasswordEmail.test.ts +++ /dev/null @@ -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 `, - subject: 'Gradido: Passwort zurücksetzen', - text: - expect.stringContaining('Hallo Peter Lustig') && - expect.stringContaining('resetLink') && - expect.stringContaining('23 Stunden und 30 Minuten'), - }) - }) -}) diff --git a/backend/src/mailer/sendResetPasswordEmail.ts b/backend/src/mailer/sendResetPasswordEmail.ts deleted file mode 100644 index d9770f940..000000000 --- a/backend/src/mailer/sendResetPasswordEmail.ts +++ /dev/null @@ -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 => { - return sendEMail({ - to: `${data.firstName} ${data.lastName} <${data.email}>`, - subject: resetPassword.de.subject, - text: resetPassword.de.text({ ...data, resendLink: CONFIG.EMAIL_LINK_FORGOTPASSWORD }), - }) -} diff --git a/backend/src/mailer/text/resetPassword.ts b/backend/src/mailer/text/resetPassword.ts deleted file mode 100644 index ff660f76e..000000000 --- a/backend/src/mailer/text/resetPassword.ts +++ /dev/null @@ -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`, - }, -} From 4b097f6f66ddd35a47fbe76ef750365843377269 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Sat, 26 Nov 2022 12:59:06 +0100 Subject: [PATCH 33/90] Add line ends to all 'subject.pug' files --- backend/src/emails/accountActivation/subject.pug | 2 +- backend/src/emails/accountMultiRegistration/subject.pug | 2 +- backend/src/emails/addedContributionMessage/subject.pug | 2 +- backend/src/emails/contributionConfirmed/subject.pug | 2 +- backend/src/emails/contributionRejected/subject.pug | 2 +- backend/src/emails/resetPassword/subject.pug | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/src/emails/accountActivation/subject.pug b/backend/src/emails/accountActivation/subject.pug index 378053bbf..81749a38e 100644 --- a/backend/src/emails/accountActivation/subject.pug +++ b/backend/src/emails/accountActivation/subject.pug @@ -1 +1 @@ -= t('emails.accountActivation.subject') \ No newline at end of file += t('emails.accountActivation.subject') diff --git a/backend/src/emails/accountMultiRegistration/subject.pug b/backend/src/emails/accountMultiRegistration/subject.pug index 322f07c78..fb130f0e4 100644 --- a/backend/src/emails/accountMultiRegistration/subject.pug +++ b/backend/src/emails/accountMultiRegistration/subject.pug @@ -1 +1 @@ -= t('emails.accountMultiRegistration.subject') \ No newline at end of file += t('emails.accountMultiRegistration.subject') diff --git a/backend/src/emails/addedContributionMessage/subject.pug b/backend/src/emails/addedContributionMessage/subject.pug index 8620725f8..4ac85fa23 100644 --- a/backend/src/emails/addedContributionMessage/subject.pug +++ b/backend/src/emails/addedContributionMessage/subject.pug @@ -1 +1 @@ -= t('emails.addedContributionMessage.subject') \ No newline at end of file += t('emails.addedContributionMessage.subject') diff --git a/backend/src/emails/contributionConfirmed/subject.pug b/backend/src/emails/contributionConfirmed/subject.pug index 7e74a77c6..c5bd41421 100644 --- a/backend/src/emails/contributionConfirmed/subject.pug +++ b/backend/src/emails/contributionConfirmed/subject.pug @@ -1 +1 @@ -= t('emails.contributionConfirmed.subject') \ No newline at end of file += t('emails.contributionConfirmed.subject') diff --git a/backend/src/emails/contributionRejected/subject.pug b/backend/src/emails/contributionRejected/subject.pug index cdaae4157..40a7622b8 100644 --- a/backend/src/emails/contributionRejected/subject.pug +++ b/backend/src/emails/contributionRejected/subject.pug @@ -1 +1 @@ -= t('emails.contributionRejected.subject') \ No newline at end of file += t('emails.contributionRejected.subject') diff --git a/backend/src/emails/resetPassword/subject.pug b/backend/src/emails/resetPassword/subject.pug index 3d2b1f00f..21f277316 100644 --- a/backend/src/emails/resetPassword/subject.pug +++ b/backend/src/emails/resetPassword/subject.pug @@ -1 +1 @@ -= t('emails.resetPassword.subject') \ No newline at end of file += t('emails.resetPassword.subject') From 224613f6d44b53090b6e2de464568eba9c7548e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Sat, 26 Nov 2022 15:42:44 +0100 Subject: [PATCH 34/90] Refactor 'sendTransactionLinkRedeemedEmail' email to HTML and translatable --- .../src/emails/contributionConfirmed/html.pug | 2 +- backend/src/emails/sendEmailVariants.ts | 38 +++++++++++++++---- .../emails/transactionLinkRedeemed/html.pug | 19 ++++++++++ .../transactionLinkRedeemed/subject.pug | 1 + .../graphql/resolver/TransactionResolver.ts | 14 +++---- backend/src/locales/de.json | 26 ++++++++----- backend/src/locales/en.json | 26 ++++++++----- backend/src/util/utilities.ts | 9 +++++ 8 files changed, 101 insertions(+), 34 deletions(-) create mode 100644 backend/src/emails/transactionLinkRedeemed/html.pug create mode 100644 backend/src/emails/transactionLinkRedeemed/subject.pug diff --git a/backend/src/emails/contributionConfirmed/html.pug b/backend/src/emails/contributionConfirmed/html.pug index e60e6c700..32626b147 100644 --- a/backend/src/emails/contributionConfirmed/html.pug +++ b/backend/src/emails/contributionConfirmed/html.pug @@ -7,7 +7,7 @@ html(lang=locale) #container.col p(style='margin-bottom: 24px;')= t('emails.general.helloName', { firstName, lastName }) p= t('emails.contributionConfirmed.commonGoodContributionConfirmed', { senderFirstName, senderLastName, contributionMemo }) - p= t('emails.contributionConfirmed.contributionAmount', { contributionAmount }) + p= t('emails.general.amountGDD', { amountGDD: contributionAmount }) p= t('emails.general.linkToYourAccount') span= " " a(href=overviewURL) #{overviewURL} diff --git a/backend/src/emails/sendEmailVariants.ts b/backend/src/emails/sendEmailVariants.ts index e8f208eb0..953ac4af5 100644 --- a/backend/src/emails/sendEmailVariants.ts +++ b/backend/src/emails/sendEmailVariants.ts @@ -1,6 +1,6 @@ -import i18n from 'i18n' import Decimal from 'decimal.js-light' import CONFIG from '@/config' +import { decimalSeparatorByLanguage } from '@/util/utilities' import { sendEmailTranslated } from './sendEmailTranslated' export const sendAddedContributionMessageEmail = (data: { @@ -79,12 +79,6 @@ export const sendContributionConfirmedEmail = (data: { contributionMemo: string contributionAmount: Decimal }): Promise | null> => { - const rememberLocaleToRestore = i18n.getLocale() - i18n.setLocale(data.language) - const contributionAmount = data.contributionAmount - .toFixed(2) - .replace('.', i18n.__('emails.general.decimalSeparator')) - i18n.setLocale(rememberLocaleToRestore) return sendEmailTranslated({ receiver: { to: `${data.firstName} ${data.lastName} <${data.email}>` }, template: 'contributionConfirmed', @@ -95,7 +89,7 @@ export const sendContributionConfirmedEmail = (data: { senderFirstName: data.senderFirstName, senderLastName: data.senderLastName, contributionMemo: data.contributionMemo, - contributionAmount, + contributionAmount: decimalSeparatorByLanguage(data.contributionAmount, data.language), overviewURL: CONFIG.EMAIL_LINK_OVERVIEW, }, }) @@ -146,3 +140,31 @@ export const sendResetPasswordEmail = (data: { }, }) } + +export const sendTransactionLinkRedeemedEmail = (data: { + firstName: string + lastName: string + email: string + language: string + senderFirstName: string + senderLastName: string + senderEmail: string + transactionMemo: string + transactionAmount: Decimal +}): Promise | 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, + }, + }) +} diff --git a/backend/src/emails/transactionLinkRedeemed/html.pug b/backend/src/emails/transactionLinkRedeemed/html.pug new file mode 100644 index 000000000..f15a278c9 --- /dev/null +++ b/backend/src/emails/transactionLinkRedeemed/html.pug @@ -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.transactionLinkRedeemed.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') diff --git a/backend/src/emails/transactionLinkRedeemed/subject.pug b/backend/src/emails/transactionLinkRedeemed/subject.pug new file mode 100644 index 000000000..6f4f74f04 --- /dev/null +++ b/backend/src/emails/transactionLinkRedeemed/subject.pug @@ -0,0 +1 @@ += t('emails.transactionLinkRedeemed.subject') diff --git a/backend/src/graphql/resolver/TransactionResolver.ts b/backend/src/graphql/resolver/TransactionResolver.ts index f0fb2f452..d83d99132 100644 --- a/backend/src/graphql/resolver/TransactionResolver.ts +++ b/backend/src/graphql/resolver/TransactionResolver.ts @@ -36,7 +36,7 @@ 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 } from '@/emails/sendEmailVariants' import { Event, EventTransactionReceive, EventTransactionSend } from '@/event/Event' import { eventProtocol } from '@/event/EventProtocolEmitter' import { Decay } from '../model/Decay' @@ -182,15 +182,15 @@ export const executeTransaction = async ( }) 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`) diff --git a/backend/src/locales/de.json b/backend/src/locales/de.json index fd9b74662..de1e657fe 100644 --- a/backend/src/locales/de.json +++ b/backend/src/locales/de.json @@ -21,7 +21,6 @@ }, "contributionConfirmed": { "commonGoodContributionConfirmed": "dein Gemeinwohl-Beitrag „{contributionMemo}“ wurde soeben von {senderFirstName} {senderLastName} bestätigt und in deinem Gradido-Konto gutgeschrieben.", - "contributionAmount": "Betrag: {contributionAmount} GDD", "subject": "Gradido: Dein Gemeinwohl-Beitrag wurde bestätigt" }, "contributionRejected": { @@ -29,20 +28,29 @@ "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“!" }, - "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." - }, "general": { - "decimalSeparator": ",", + "amountGDD": "Betrag: {amountGDD} GDD", "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": { + "detailsYouFindOnLinkToYourAccount": "Details zur Transaktion findest du in deinem Gradido-Konto:", + "hasRedeemedYourLink": "{senderFirstName} {senderLastName} ({senderEmail}) hat soeben deinen Link eingelöst.", + "memo": "Memo: {transactionMemo}", + "subject": "Gradido: Dein Gradido-Link wurde eingelöst" } + }, + "general": { + "decimalSeparator": "," } -} \ No newline at end of file +} diff --git a/backend/src/locales/en.json b/backend/src/locales/en.json index c688102c6..34cf2512f 100644 --- a/backend/src/locales/en.json +++ b/backend/src/locales/en.json @@ -21,7 +21,6 @@ }, "contributionConfirmed": { "commonGoodContributionConfirmed": "Your public good contribution “{contributionMemo}” has just been confirmed by {senderFirstName} {senderLastName} and credited to your Gradido account.", - "contributionAmount": "Amount: {contributionAmount} GDD", "subject": "Gradido: Your common good contribution was confirmed" }, "contributionRejected": { @@ -29,20 +28,29 @@ "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!" }, - "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." - }, "general": { - "decimalSeparator": ".", + "amountGDD": "Amount: {amountGDD} GDD", "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": { + "detailsYouFindOnLinkToYourAccount": "You can find transaction details in your Gradido account:", + "hasRedeemedYourLink": "{senderFirstName} {senderLastName} ({senderEmail}) has just redeemed your link.", + "memo": "Memo: {transactionMemo}", + "subject": "Gradido: Your Gradido link has been redeemed" } + }, + "general": { + "decimalSeparator": "." } -} \ No newline at end of file +} diff --git a/backend/src/util/utilities.ts b/backend/src/util/utilities.ts index 65214ebb5..f24def721 100644 --- a/backend/src/util/utilities.ts +++ b/backend/src/util/utilities.ts @@ -1,4 +1,5 @@ import Decimal from 'decimal.js-light' +import i18n from 'i18n' export const objectValuesToArray = (obj: { [x: string]: string }): Array => { return Object.keys(obj).map(function (key) { @@ -15,3 +16,11 @@ export const decimalAddition = (a: Decimal, b: Decimal): Decimal => { export const decimalSubtraction = (a: Decimal, b: Decimal): Decimal => { return a.minus(b.toString()) } + +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 +} From 699daa8eec5c1851ab07ef7b6e890b370f88be6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Sat, 26 Nov 2022 15:43:44 +0100 Subject: [PATCH 35/90] Test 'sendTransactionLinkRedeemedEmail' --- backend/src/emails/sendEmailVariants.test.ts | 82 +++++++++++++++++++- 1 file changed, 79 insertions(+), 3 deletions(-) diff --git a/backend/src/emails/sendEmailVariants.test.ts b/backend/src/emails/sendEmailVariants.test.ts index dd5bb68e9..858b6426b 100644 --- a/backend/src/emails/sendEmailVariants.test.ts +++ b/backend/src/emails/sendEmailVariants.test.ts @@ -11,6 +11,7 @@ import { sendContributionConfirmedEmail, sendContributionRejectedEmail, sendResetPasswordEmail, + sendTransactionLinkRedeemedEmail, } from './sendEmailVariants' import { sendEmailTranslated } from './sendEmailTranslated' @@ -104,7 +105,7 @@ describe('sendEmailVariants', () => { '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: http://localhost/overview', + `Link to your account: ${CONFIG.EMAIL_LINK_OVERVIEW}`, ) expect(result.originalMessage.html).toContain('Please do not reply to this email!') expect(result.originalMessage.html).toContain('Kind regards,
your Gradido team') @@ -324,7 +325,7 @@ describe('sendEmailVariants', () => { ) expect(result.originalMessage.html).toContain('Amount: 23.54 GDD') expect(result.originalMessage.html).toContain( - 'Link to your account: http://localhost/overview', + `Link to your account: ${CONFIG.EMAIL_LINK_OVERVIEW}`, ) expect(result.originalMessage.html).toContain('Please do not reply to this email!') expect(result.originalMessage.html).toContain('Kind regards,
your Gradido team') @@ -396,7 +397,7 @@ describe('sendEmailVariants', () => { '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: http://localhost/overview', + `Link to your account: ${CONFIG.EMAIL_LINK_OVERVIEW}`, ) expect(result.originalMessage.html).toContain('Please do not reply to this email!') expect(result.originalMessage.html).toContain('Kind regards,
your Gradido team') @@ -475,4 +476,79 @@ describe('sendEmailVariants', () => { }) }) }) + + 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 ', + }, + 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 ', + from: 'Gradido (nicht antworten) ', + 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('') + expect(result.originalMessage.html).toContain('') + expect(result.originalMessage.html).toContain( + 'Gradido: Your Gradido link has been redeemed', + ) + expect(result.originalMessage.html).toContain( + '>Gradido: Your Gradido link has been redeemed', + ) + 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: ${CONFIG.EMAIL_LINK_OVERVIEW}`, + ) + expect(result.originalMessage.html).toContain('Please do not reply to this email!') + expect(result.originalMessage.html).toContain('Kind regards,
your Gradido team') + }) + }) + }) }) From 2b65c6b2613bab1d4576b07fb73e8c0cdac8c653 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Sat, 26 Nov 2022 15:45:41 +0100 Subject: [PATCH 36/90] Remove old untranslated email 'sendTransactionLinkRedeemedEmail' --- .../sendTransactionLinkRedeemed.test.ts | 44 ------------------- .../src/mailer/sendTransactionLinkRedeemed.ts | 28 ------------ .../mailer/text/transactionLinkRedeemed.ts | 33 -------------- 3 files changed, 105 deletions(-) delete mode 100644 backend/src/mailer/sendTransactionLinkRedeemed.test.ts delete mode 100644 backend/src/mailer/sendTransactionLinkRedeemed.ts delete mode 100644 backend/src/mailer/text/transactionLinkRedeemed.ts diff --git a/backend/src/mailer/sendTransactionLinkRedeemed.test.ts b/backend/src/mailer/sendTransactionLinkRedeemed.test.ts deleted file mode 100644 index b56ff40a1..000000000 --- a/backend/src/mailer/sendTransactionLinkRedeemed.test.ts +++ /dev/null @@ -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 `, - 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!'), - }) - }) -}) diff --git a/backend/src/mailer/sendTransactionLinkRedeemed.ts b/backend/src/mailer/sendTransactionLinkRedeemed.ts deleted file mode 100644 index a78f3b3c9..000000000 --- a/backend/src/mailer/sendTransactionLinkRedeemed.ts +++ /dev/null @@ -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 => { - 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), - }) -} diff --git a/backend/src/mailer/text/transactionLinkRedeemed.ts b/backend/src/mailer/text/transactionLinkRedeemed.ts deleted file mode 100644 index a63e5d275..000000000 --- a/backend/src/mailer/text/transactionLinkRedeemed.ts +++ /dev/null @@ -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`, - }, -} From 0cccdb56de511b9d16ffc0c3f44ad43e03d3071a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Mon, 28 Nov 2022 09:38:19 +0100 Subject: [PATCH 37/90] Test that 'sendEmailTranslated' on 'CONFIG.EMAIL_TEST_MODUS = true' replaces the receiver with by the fake test mail accaount --- .../src/emails/sendEmailTranslated.test.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/backend/src/emails/sendEmailTranslated.test.ts b/backend/src/emails/sendEmailTranslated.test.ts index 28327f779..72c4c508c 100644 --- a/backend/src/emails/sendEmailTranslated.test.ts +++ b/backend/src/emails/sendEmailTranslated.test.ts @@ -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 (nicht antworten) <${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'), + }), + }) + }) + }) }) From 3903e29e160d48e1adb02dc1cee45000e8d1b346 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Mon, 28 Nov 2022 10:54:10 +0100 Subject: [PATCH 38/90] Refactor 'sendTransactionReceivedEmail' email to HTML and translatable --- backend/src/emails/sendEmailVariants.ts | 26 +++++++++++++++++++ .../emails/transactionLinkRedeemed/html.pug | 2 +- .../src/emails/transactionReceived/html.pug | 16 ++++++++++++ .../emails/transactionReceived/subject.pug | 1 + .../graphql/resolver/TransactionResolver.ts | 20 +++++++------- backend/src/locales/de.json | 6 ++++- backend/src/locales/en.json | 6 ++++- 7 files changed, 63 insertions(+), 14 deletions(-) create mode 100644 backend/src/emails/transactionReceived/html.pug create mode 100644 backend/src/emails/transactionReceived/subject.pug diff --git a/backend/src/emails/sendEmailVariants.ts b/backend/src/emails/sendEmailVariants.ts index 953ac4af5..356f95e39 100644 --- a/backend/src/emails/sendEmailVariants.ts +++ b/backend/src/emails/sendEmailVariants.ts @@ -168,3 +168,29 @@ export const sendTransactionLinkRedeemedEmail = (data: { }, }) } + +export const sendTransactionReceivedEmail = (data: { + firstName: string + lastName: string + email: string + language: string + senderFirstName: string + senderLastName: string + senderEmail: string + transactionAmount: Decimal +}): Promise | 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, + }, + }) +} diff --git a/backend/src/emails/transactionLinkRedeemed/html.pug b/backend/src/emails/transactionLinkRedeemed/html.pug index f15a278c9..321d070b4 100644 --- a/backend/src/emails/transactionLinkRedeemed/html.pug +++ b/backend/src/emails/transactionLinkRedeemed/html.pug @@ -10,7 +10,7 @@ html(lang=locale) p= t('emails.general.amountGDD', { amountGDD: transactionAmount }) br span= t('emails.transactionLinkRedeemed.memo', { transactionMemo }) - p= t('emails.transactionLinkRedeemed.detailsYouFindOnLinkToYourAccount') + p= t('emails.general.detailsYouFindOnLinkToYourAccount') span= " " a(href=overviewURL) #{overviewURL} p= t('emails.general.pleaseDoNotReply') diff --git a/backend/src/emails/transactionReceived/html.pug b/backend/src/emails/transactionReceived/html.pug new file mode 100644 index 000000000..eaf57f975 --- /dev/null +++ b/backend/src/emails/transactionReceived/html.pug @@ -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') diff --git a/backend/src/emails/transactionReceived/subject.pug b/backend/src/emails/transactionReceived/subject.pug new file mode 100644 index 000000000..630752b02 --- /dev/null +++ b/backend/src/emails/transactionReceived/subject.pug @@ -0,0 +1 @@ += t('emails.transactionReceived.subject') diff --git a/backend/src/graphql/resolver/TransactionResolver.ts b/backend/src/graphql/resolver/TransactionResolver.ts index d83d99132..1a06f46d9 100644 --- a/backend/src/graphql/resolver/TransactionResolver.ts +++ b/backend/src/graphql/resolver/TransactionResolver.ts @@ -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 '@/emails/sendEmailVariants' +import { + sendTransactionLinkRedeemedEmail, + sendTransactionReceivedEmail, +} from '@/emails/sendEmailVariants' import { Event, EventTransactionReceive, EventTransactionSend } from '@/event/Event' import { eventProtocol } from '@/event/EventProtocolEmitter' import { Decay } from '../model/Decay' @@ -168,17 +168,15 @@ 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({ diff --git a/backend/src/locales/de.json b/backend/src/locales/de.json index de1e657fe..27a943ccc 100644 --- a/backend/src/locales/de.json +++ b/backend/src/locales/de.json @@ -30,6 +30,7 @@ }, "general": { "amountGDD": "Betrag: {amountGDD} GDD", + "detailsYouFindOnLinkToYourAccount": "Details zur Transaktion findest du in deinem Gradido-Konto:", "helloName": "Hallo {firstName} {lastName},", "linkToYourAccount": "Link zu deinem Konto:", "orCopyLink": "oder kopiere den obigen Link in dein Browserfenster.", @@ -44,10 +45,13 @@ "youOrSomeoneResetPassword": "du, oder jemand anderes, hast für dieses Konto ein Zurücksetzen des Passworts angefordert." }, "transactionLinkRedeemed": { - "detailsYouFindOnLinkToYourAccount": "Details zur Transaktion findest du in deinem Gradido-Konto:", "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": { diff --git a/backend/src/locales/en.json b/backend/src/locales/en.json index 34cf2512f..fb578bc40 100644 --- a/backend/src/locales/en.json +++ b/backend/src/locales/en.json @@ -30,6 +30,7 @@ }, "general": { "amountGDD": "Amount: {amountGDD} GDD", + "detailsYouFindOnLinkToYourAccount": "You can find transaction details in your Gradido account:", "helloName": "Hello {firstName} {lastName}", "linkToYourAccount": "Link to your account:", "orCopyLink": "or copy the link above into your browser window.", @@ -44,10 +45,13 @@ "youOrSomeoneResetPassword": "You, or someone else, requested a password reset for this account." }, "transactionLinkRedeemed": { - "detailsYouFindOnLinkToYourAccount": "You can find transaction details in your Gradido account:", "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": { From 9894b02f5110263c70031f95a555fa6fc78d8d61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Mon, 28 Nov 2022 10:54:31 +0100 Subject: [PATCH 39/90] Test 'sendTransactionReceivedEmail' --- backend/src/emails/sendEmailVariants.test.ts | 70 ++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/backend/src/emails/sendEmailVariants.test.ts b/backend/src/emails/sendEmailVariants.test.ts index 858b6426b..dd3c979dd 100644 --- a/backend/src/emails/sendEmailVariants.test.ts +++ b/backend/src/emails/sendEmailVariants.test.ts @@ -12,6 +12,7 @@ import { sendContributionRejectedEmail, sendResetPasswordEmail, sendTransactionLinkRedeemedEmail, + sendTransactionReceivedEmail, } from './sendEmailVariants' import { sendEmailTranslated } from './sendEmailTranslated' @@ -551,4 +552,73 @@ describe('sendEmailVariants', () => { }) }) }) + + 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 ', + }, + 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 ', + from: 'Gradido (nicht antworten) ', + attachments: [], + subject: 'Gradido: You have received Gradidos', + html: expect.any(String), + text: expect.stringContaining('GRADIDO: YOU HAVE RECEIVED GRADIDOS'), + }), + }) + expect(result.originalMessage.html).toContain('') + expect(result.originalMessage.html).toContain('') + expect(result.originalMessage.html).toContain( + 'Gradido: You have received Gradidos', + ) + expect(result.originalMessage.html).toContain('>Gradido: You have received Gradidos') + 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: ${CONFIG.EMAIL_LINK_OVERVIEW}`, + ) + expect(result.originalMessage.html).toContain('Please do not reply to this email!') + expect(result.originalMessage.html).toContain('Kind regards,
your Gradido team') + }) + }) + }) }) From 93e3562318a1bc135070eea2275c492d97b91798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Mon, 28 Nov 2022 11:04:34 +0100 Subject: [PATCH 40/90] Remove old untranslated email 'sendTransactionReceivedEmail' --- .../sendTransactionReceivedEmail.test.ts | 38 ------------------- .../mailer/sendTransactionReceivedEmail.ts | 27 ------------- .../src/mailer/text/transactionReceived.ts | 29 -------------- 3 files changed, 94 deletions(-) delete mode 100644 backend/src/mailer/sendTransactionReceivedEmail.test.ts delete mode 100644 backend/src/mailer/sendTransactionReceivedEmail.ts delete mode 100644 backend/src/mailer/text/transactionReceived.ts diff --git a/backend/src/mailer/sendTransactionReceivedEmail.test.ts b/backend/src/mailer/sendTransactionReceivedEmail.test.ts deleted file mode 100644 index ca813c033..000000000 --- a/backend/src/mailer/sendTransactionReceivedEmail.test.ts +++ /dev/null @@ -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 `, - 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'), - }) - }) -}) diff --git a/backend/src/mailer/sendTransactionReceivedEmail.ts b/backend/src/mailer/sendTransactionReceivedEmail.ts deleted file mode 100644 index 5e981659c..000000000 --- a/backend/src/mailer/sendTransactionReceivedEmail.ts +++ /dev/null @@ -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 => { - 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), - }) -} diff --git a/backend/src/mailer/text/transactionReceived.ts b/backend/src/mailer/text/transactionReceived.ts deleted file mode 100644 index 67758c0e1..000000000 --- a/backend/src/mailer/text/transactionReceived.ts +++ /dev/null @@ -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`, - }, -} From 3b47330c925a5577411cc52b4c01d65365274990 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Mon, 28 Nov 2022 11:05:12 +0100 Subject: [PATCH 41/90] Remove remainders of old untranslated emails --- backend/src/mailer/sendEMail.test.ts | 113 --------------------------- backend/src/mailer/sendEMail.ts | 48 ------------ 2 files changed, 161 deletions(-) delete mode 100644 backend/src/mailer/sendEMail.test.ts delete mode 100644 backend/src/mailer/sendEMail.ts diff --git a/backend/src/mailer/sendEMail.test.ts b/backend/src/mailer/sendEMail.test.ts deleted file mode 100644 index e062b71d8..000000000 --- a/backend/src/mailer/sendEMail.test.ts +++ /dev/null @@ -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', - }) - }) - }) -}) diff --git a/backend/src/mailer/sendEMail.ts b/backend/src/mailer/sendEMail.ts deleted file mode 100644 index 00282f232..000000000 --- a/backend/src/mailer/sendEMail.ts +++ /dev/null @@ -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 => { - 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 -} From 4ae96a221396b8e4632839ea93d93833e8378394 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Mon, 28 Nov 2022 11:13:04 +0100 Subject: [PATCH 42/90] Translate 'Gradido (nicht antworten)' --- backend/src/emails/sendEmailTranslated.test.ts | 4 ++-- backend/src/emails/sendEmailTranslated.ts | 2 +- backend/src/emails/sendEmailVariants.test.ts | 16 ++++++++-------- backend/src/locales/de.json | 1 + backend/src/locales/en.json | 1 + 5 files changed, 13 insertions(+), 11 deletions(-) diff --git a/backend/src/emails/sendEmailTranslated.test.ts b/backend/src/emails/sendEmailTranslated.test.ts index 72c4c508c..eb4b26b92 100644 --- a/backend/src/emails/sendEmailTranslated.test.ts +++ b/backend/src/emails/sendEmailTranslated.test.ts @@ -89,7 +89,7 @@ describe('sendEmailTranslated', () => { originalMessage: expect.objectContaining({ to: 'receiver@mail.org', cc: 'support@gradido.net', - from: 'Gradido (nicht antworten) ', + from: 'Gradido (do not answer) ', attachments: [], subject: 'Gradido: Try To Register Again With Your Email', html: expect.stringContaining('Gradido: Try To Register Again With Your Email'), @@ -135,7 +135,7 @@ describe('sendEmailTranslated', () => { originalMessage: expect.objectContaining({ to: CONFIG.EMAIL_TEST_RECEIVER, cc: 'support@gradido.net', - from: `Gradido (nicht antworten) <${CONFIG.EMAIL_SENDER}>`, + 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'), diff --git a/backend/src/emails/sendEmailTranslated.ts b/backend/src/emails/sendEmailTranslated.ts index 9468e9f97..3f5d9d9fd 100644 --- a/backend/src/emails/sendEmailTranslated.ts +++ b/backend/src/emails/sendEmailTranslated.ts @@ -53,7 +53,7 @@ export const sendEmailTranslated = async (params: { // 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, diff --git a/backend/src/emails/sendEmailVariants.test.ts b/backend/src/emails/sendEmailVariants.test.ts index dd3c979dd..262b91be2 100644 --- a/backend/src/emails/sendEmailVariants.test.ts +++ b/backend/src/emails/sendEmailVariants.test.ts @@ -83,7 +83,7 @@ describe('sendEmailVariants', () => { message: expect.any(String), originalMessage: expect.objectContaining({ to: 'Peter Lustig ', - from: 'Gradido (nicht antworten) ', + from: 'Gradido (do not answer) ', attachments: [], subject: 'Gradido: Message about your common good contribution', html: expect.any(String), @@ -153,7 +153,7 @@ describe('sendEmailVariants', () => { message: expect.any(String), originalMessage: expect.objectContaining({ to: 'Peter Lustig ', - from: 'Gradido (nicht antworten) ', + from: 'Gradido (do not answer) ', attachments: [], subject: 'Gradido: Email Verification', html: expect.any(String), @@ -223,7 +223,7 @@ describe('sendEmailVariants', () => { message: expect.any(String), originalMessage: expect.objectContaining({ to: 'Peter Lustig ', - from: 'Gradido (nicht antworten) ', + from: 'Gradido (do not answer) ', attachments: [], subject: 'Gradido: Try To Register Again With Your Email', html: expect.any(String), @@ -305,7 +305,7 @@ describe('sendEmailVariants', () => { message: expect.any(String), originalMessage: expect.objectContaining({ to: 'Peter Lustig ', - from: 'Gradido (nicht antworten) ', + from: 'Gradido (do not answer) ', attachments: [], subject: 'Gradido: Your common good contribution was confirmed', html: expect.any(String), @@ -375,7 +375,7 @@ describe('sendEmailVariants', () => { message: expect.any(String), originalMessage: expect.objectContaining({ to: 'Peter Lustig ', - from: 'Gradido (nicht antworten) ', + from: 'Gradido (do not answer) ', attachments: [], subject: 'Gradido: Your common good contribution was rejected', html: expect.any(String), @@ -445,7 +445,7 @@ describe('sendEmailVariants', () => { message: expect.any(String), originalMessage: expect.objectContaining({ to: 'Peter Lustig ', - from: 'Gradido (nicht antworten) ', + from: 'Gradido (do not answer) ', attachments: [], subject: 'Gradido: Reset password', html: expect.any(String), @@ -523,7 +523,7 @@ describe('sendEmailVariants', () => { message: expect.any(String), originalMessage: expect.objectContaining({ to: 'Peter Lustig ', - from: 'Gradido (nicht antworten) ', + from: 'Gradido (do not answer) ', attachments: [], subject: 'Gradido: Your Gradido link has been redeemed', html: expect.any(String), @@ -596,7 +596,7 @@ describe('sendEmailVariants', () => { message: expect.any(String), originalMessage: expect.objectContaining({ to: 'Peter Lustig ', - from: 'Gradido (nicht antworten) ', + from: 'Gradido (do not answer) ', attachments: [], subject: 'Gradido: You have received Gradidos', html: expect.any(String), diff --git a/backend/src/locales/de.json b/backend/src/locales/de.json index 27a943ccc..8aff6caa4 100644 --- a/backend/src/locales/de.json +++ b/backend/src/locales/de.json @@ -31,6 +31,7 @@ "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.", diff --git a/backend/src/locales/en.json b/backend/src/locales/en.json index fb578bc40..99217840e 100644 --- a/backend/src/locales/en.json +++ b/backend/src/locales/en.json @@ -31,6 +31,7 @@ "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.", From e350307e6ad6262713cf5dee26bb17d27f7b34ac Mon Sep 17 00:00:00 2001 From: joseji Date: Mon, 28 Nov 2022 23:59:57 +0100 Subject: [PATCH 43/90] removing keys and passphrases --- backend/src/auth/CustomJwtPayload.ts | 2 +- backend/src/auth/JWT.ts | 4 +- .../graphql/resolver/TransactionResolver.ts | 10 +--- .../src/graphql/resolver/UserResolver.test.ts | 4 -- backend/src/graphql/resolver/UserResolver.ts | 46 +------------------ backend/src/util/communityUser.ts | 3 -- .../0053-change_password_encryption/User.ts | 15 ------ .../UserContact.ts | 3 -- 8 files changed, 5 insertions(+), 82 deletions(-) diff --git a/backend/src/auth/CustomJwtPayload.ts b/backend/src/auth/CustomJwtPayload.ts index 2b52c3cea..7966b413e 100644 --- a/backend/src/auth/CustomJwtPayload.ts +++ b/backend/src/auth/CustomJwtPayload.ts @@ -1,5 +1,5 @@ import { JwtPayload } from 'jsonwebtoken' export interface CustomJwtPayload extends JwtPayload { - pubKey: Buffer + gradidoID: string } diff --git a/backend/src/auth/JWT.ts b/backend/src/auth/JWT.ts index e32e68223..8399c881b 100644 --- a/backend/src/auth/JWT.ts +++ b/backend/src/auth/JWT.ts @@ -11,8 +11,8 @@ export const decode = (token: string): CustomJwtPayload | null => { } } -export const encode = (pubKey: Buffer): string => { - const token = jwt.sign({ pubKey }, CONFIG.JWT_SECRET, { +export const encode = (gradidoID: string): string => { + const token = jwt.sign({ gradidoID }, CONFIG.JWT_SECRET, { expiresIn: CONFIG.JWT_EXPIRES_IN, }) return token diff --git a/backend/src/graphql/resolver/TransactionResolver.ts b/backend/src/graphql/resolver/TransactionResolver.ts index 594039cfd..18adcb5c8 100644 --- a/backend/src/graphql/resolver/TransactionResolver.ts +++ b/backend/src/graphql/resolver/TransactionResolver.ts @@ -26,7 +26,7 @@ import { Transaction as dbTransaction } from '@entity/Transaction' import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink' import { TransactionTypeId } from '@enum/TransactionTypeId' -import { calculateBalance, isHexPublicKey } from '@/util/validate' +import { calculateBalance } from '@/util/validate' import { RIGHTS } from '@/auth/RIGHTS' import { User } from '@model/User' import { communityUser } from '@/util/communityUser' @@ -317,10 +317,6 @@ export class TransactionResolver { // TODO this is subject to replay attacks const senderUser = getUser(context) - if (senderUser.pubKey.length !== 32) { - logger.error(`invalid sender public key:${senderUser.pubKey}`) - throw new Error('invalid sender public key') - } // validate recipient user const recipientUser = await findUserByEmail(email) @@ -349,10 +345,6 @@ export class TransactionResolver { logger.error(`The recipient account is not activated: recipientUser=${recipientUser}`) throw new Error('The recipient account is not activated') } - if (!isHexPublicKey(recipientUser.pubKey.toString('hex'))) { - logger.error(`invalid recipient public key: recipientUser=${recipientUser}`) - throw new Error('invalid recipient public key') - } await executeTransaction(amount, memo, senderUser, recipientUser) logger.info( diff --git a/backend/src/graphql/resolver/UserResolver.test.ts b/backend/src/graphql/resolver/UserResolver.test.ts index d8472fba9..411cd277a 100644 --- a/backend/src/graphql/resolver/UserResolver.test.ts +++ b/backend/src/graphql/resolver/UserResolver.test.ts @@ -137,12 +137,8 @@ describe('UserResolver', () => { firstName: 'Peter', lastName: 'Lustig', password: '0', - pubKey: null, - privKey: null, - // emailHash: expect.any(Buffer), createdAt: expect.any(Date), // emailChecked: false, - passphrase: expect.any(String), language: 'de', isAdmin: null, deletedAt: null, diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index 752c585fd..b28cb7c4b 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -297,11 +297,6 @@ export class UserResolver { // TODO we want to catch this on the frontend and ask the user to check his emails or resend code throw new Error('User has no password set yet') } - if (!dbUser.pubKey || !dbUser.privKey) { - logger.error('The User has no private or publicKey.') - // TODO we want to catch this on the frontend and ask the user to check his emails or resend code - throw new Error('User has no private or publicKey') - } if (!verifyPassword(dbUser, password)) { logger.error('The User has no valid credentials.') @@ -333,7 +328,7 @@ export class UserResolver { context.setHeaders.push({ key: 'token', - value: encode(dbUser.pubKey), + value: encode(dbUser.gradidoID), }) const ev = new EventLogin() ev.userId = user.id @@ -443,7 +438,6 @@ export class UserResolver { dbUser.language = language dbUser.publisherId = publisherId dbUser.passwordEncryptionType = PasswordEncryptionType.NO_PASSWORD - dbUser.passphrase = passphrase.join(' ') logger.debug('new dbUser=' + dbUser) if (redeemCode) { if (redeemCode.match(/^CL-/)) { @@ -633,34 +627,12 @@ export class UserResolver { const user = userContact.user logger.debug('user with EmailVerificationCode found...') - // Generate Passphrase if needed - if (!user.passphrase) { - const passphrase = PassphraseGenerate() - user.passphrase = passphrase.join(' ') - logger.debug('new Passphrase generated...') - } - - const passphrase = user.passphrase.split(' ') - if (passphrase.length < PHRASE_WORD_COUNT) { - logger.error('Could not load a correct passphrase') - // TODO if this can happen we cannot recover from that - // this seem to be good on production data, if we dont - // make a coding mistake we do not have a problem here - throw new Error('Could not load a correct passphrase') - } - logger.debug('Passphrase is valid...') - // Activate EMail userContact.emailChecked = true // Update Password user.passwordEncryptionType = PasswordEncryptionType.GRADIDO_ID - const passwordHash = SecretKeyCryptographyCreateKey(userContact.email, password) // return short and long hash - const keyPair = KeyPairEd25519Create(passphrase) // return pub, priv Key - const encryptedPrivkey = SecretKeyCryptographyEncrypt(keyPair[1], passwordHash[1]) user.password = encryptPassword(user, password) - user.pubKey = keyPair[0] - user.privKey = encryptedPrivkey logger.debug('User credentials updated ...') const queryRunner = getConnection().createQueryRunner() @@ -771,30 +743,14 @@ export class UserResolver { ) } - // TODO: This had some error cases defined - like missing private key. This is no longer checked. - const oldPasswordHash = SecretKeyCryptographyCreateKey( - userEntity.emailContact.email, - password, - ) if (!verifyPassword(userEntity, password)) { logger.error(`Old password is invalid`) throw new Error(`Old password is invalid`) } - const privKey = SecretKeyCryptographyDecrypt(userEntity.privKey, oldPasswordHash[1]) - logger.debug('oldPassword decrypted...') - const newPasswordHash = SecretKeyCryptographyCreateKey( - userEntity.emailContact.email, - passwordNew, - ) // return short and long hash - logger.debug('newPasswordHash created...') - const encryptedPrivkey = SecretKeyCryptographyEncrypt(privKey, newPasswordHash[1]) - logger.debug('PrivateKey encrypted...') - // Save new password hash and newly encrypted private key userEntity.passwordEncryptionType = PasswordEncryptionType.GRADIDO_ID userEntity.password = encryptPassword(userEntity, passwordNew) - userEntity.privKey = encryptedPrivkey } const queryRunner = getConnection().createQueryRunner() diff --git a/backend/src/util/communityUser.ts b/backend/src/util/communityUser.ts index 298348f0f..98279db15 100644 --- a/backend/src/util/communityUser.ts +++ b/backend/src/util/communityUser.ts @@ -16,8 +16,6 @@ const communityDbUser: dbUser = { emailId: -1, firstName: 'Gradido', lastName: 'Akademie', - pubKey: Buffer.from(''), - privKey: Buffer.from(''), deletedAt: null, password: BigInt(0), // emailHash: Buffer.from(''), @@ -26,7 +24,6 @@ const communityDbUser: dbUser = { language: '', isAdmin: null, publisherId: 0, - passphrase: '', // default password encryption type passwordEncryptionType: PasswordEncryptionType.NO_PASSWORD, hasId: function (): boolean { diff --git a/database/entity/0053-change_password_encryption/User.ts b/database/entity/0053-change_password_encryption/User.ts index 2a3332925..c511a98c8 100644 --- a/database/entity/0053-change_password_encryption/User.ts +++ b/database/entity/0053-change_password_encryption/User.ts @@ -34,21 +34,6 @@ export class User extends BaseEntity { }) alias: string - @Column({ name: 'public_key', type: 'binary', length: 32, default: null, nullable: true }) - pubKey: Buffer - - @Column({ name: 'privkey', type: 'binary', length: 80, default: null, nullable: true }) - privKey: Buffer - - @Column({ - type: 'text', - name: 'passphrase', - collation: 'utf8mb4_unicode_ci', - nullable: true, - default: null, - }) - passphrase: string - @OneToOne(() => UserContact, (emailContact: UserContact) => emailContact.user) @JoinColumn({ name: 'email_id' }) emailContact: UserContact diff --git a/database/entity/0053-change_password_encryption/UserContact.ts b/database/entity/0053-change_password_encryption/UserContact.ts index 97b12d4cd..c101fba4c 100644 --- a/database/entity/0053-change_password_encryption/UserContact.ts +++ b/database/entity/0053-change_password_encryption/UserContact.ts @@ -40,9 +40,6 @@ export class UserContact extends BaseEntity { @Column({ name: 'email_resend_count' }) emailResendCount: number - // @Column({ name: 'email_hash', type: 'binary', length: 32, default: null, nullable: true }) - // emailHash: Buffer - @Column({ name: 'email_checked', type: 'bool', nullable: false, default: false }) emailChecked: boolean From 7f9190f276bef05aca6d16169297fef0e06f9fa2 Mon Sep 17 00:00:00 2001 From: Ulf Gebhardt Date: Tue, 29 Nov 2022 06:31:57 +0100 Subject: [PATCH 44/90] lint fix --- backend/src/graphql/resolver/TransactionResolver.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/src/graphql/resolver/TransactionResolver.ts b/backend/src/graphql/resolver/TransactionResolver.ts index ec6b2597c..457d42f1d 100644 --- a/backend/src/graphql/resolver/TransactionResolver.ts +++ b/backend/src/graphql/resolver/TransactionResolver.ts @@ -11,7 +11,6 @@ import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink' import { TransactionRepository } from '@repository/Transaction' import { TransactionLinkRepository } from '@repository/TransactionLink' -import { Decay } from '@model/Decay' import { User } from '@model/User' import { Transaction } from '@model/Transaction' import { TransactionList } from '@model/TransactionList' From 7e78f1c893506c4d677b323005d105b5aa95c695 Mon Sep 17 00:00:00 2001 From: joseji Date: Tue, 29 Nov 2022 13:03:10 +0100 Subject: [PATCH 45/90] migration fixed --- backend/src/auth/CustomJwtPayload.ts | 2 +- backend/src/auth/JWT.ts | 2 +- backend/src/config/index.ts | 2 +- backend/src/graphql/resolver/UserResolver.ts | 2 +- .../0053-change_password_encryption/User.ts | 15 +++ .../UserContact.ts | 3 + .../0055-clear_old_password_junk/User.ts | 112 ++++++++++++++++++ .../UserContact.ts | 57 +++++++++ database/entity/User.ts | 2 +- database/entity/UserContact.ts | 2 +- .../0055-clear_old_password_junk.ts | 16 +++ 11 files changed, 209 insertions(+), 6 deletions(-) create mode 100644 database/entity/0055-clear_old_password_junk/User.ts create mode 100644 database/entity/0055-clear_old_password_junk/UserContact.ts create mode 100644 database/migrations/0055-clear_old_password_junk.ts diff --git a/backend/src/auth/CustomJwtPayload.ts b/backend/src/auth/CustomJwtPayload.ts index 7966b413e..346ff143a 100644 --- a/backend/src/auth/CustomJwtPayload.ts +++ b/backend/src/auth/CustomJwtPayload.ts @@ -1,5 +1,5 @@ import { JwtPayload } from 'jsonwebtoken' export interface CustomJwtPayload extends JwtPayload { - gradidoID: string + gradidoID: Buffer } diff --git a/backend/src/auth/JWT.ts b/backend/src/auth/JWT.ts index 8399c881b..961274eb3 100644 --- a/backend/src/auth/JWT.ts +++ b/backend/src/auth/JWT.ts @@ -11,7 +11,7 @@ export const decode = (token: string): CustomJwtPayload | null => { } } -export const encode = (gradidoID: string): string => { +export const encode = (gradidoID: Buffer): string => { const token = jwt.sign({ gradidoID }, CONFIG.JWT_SECRET, { expiresIn: CONFIG.JWT_EXPIRES_IN, }) diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index c9e5ea79f..ede230349 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -10,7 +10,7 @@ Decimal.set({ }) const constants = { - DB_VERSION: '0054-recalculate_balance_and_decay', + DB_VERSION: '0055-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 diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index b28cb7c4b..db8169db1 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -328,7 +328,7 @@ export class UserResolver { context.setHeaders.push({ key: 'token', - value: encode(dbUser.gradidoID), + value: encode(Buffer.from(dbUser.gradidoID)), }) const ev = new EventLogin() ev.userId = user.id diff --git a/database/entity/0053-change_password_encryption/User.ts b/database/entity/0053-change_password_encryption/User.ts index c511a98c8..2a3332925 100644 --- a/database/entity/0053-change_password_encryption/User.ts +++ b/database/entity/0053-change_password_encryption/User.ts @@ -34,6 +34,21 @@ export class User extends BaseEntity { }) alias: string + @Column({ name: 'public_key', type: 'binary', length: 32, default: null, nullable: true }) + pubKey: Buffer + + @Column({ name: 'privkey', type: 'binary', length: 80, default: null, nullable: true }) + privKey: Buffer + + @Column({ + type: 'text', + name: 'passphrase', + collation: 'utf8mb4_unicode_ci', + nullable: true, + default: null, + }) + passphrase: string + @OneToOne(() => UserContact, (emailContact: UserContact) => emailContact.user) @JoinColumn({ name: 'email_id' }) emailContact: UserContact diff --git a/database/entity/0053-change_password_encryption/UserContact.ts b/database/entity/0053-change_password_encryption/UserContact.ts index c101fba4c..97b12d4cd 100644 --- a/database/entity/0053-change_password_encryption/UserContact.ts +++ b/database/entity/0053-change_password_encryption/UserContact.ts @@ -40,6 +40,9 @@ export class UserContact extends BaseEntity { @Column({ name: 'email_resend_count' }) emailResendCount: number + // @Column({ name: 'email_hash', type: 'binary', length: 32, default: null, nullable: true }) + // emailHash: Buffer + @Column({ name: 'email_checked', type: 'bool', nullable: false, default: false }) emailChecked: boolean diff --git a/database/entity/0055-clear_old_password_junk/User.ts b/database/entity/0055-clear_old_password_junk/User.ts new file mode 100644 index 000000000..c511a98c8 --- /dev/null +++ b/database/entity/0055-clear_old_password_junk/User.ts @@ -0,0 +1,112 @@ +import { + BaseEntity, + Entity, + PrimaryGeneratedColumn, + Column, + DeleteDateColumn, + OneToMany, + JoinColumn, + OneToOne, +} from 'typeorm' +import { Contribution } from '../Contribution' +import { ContributionMessage } from '../ContributionMessage' +import { UserContact } from '../UserContact' + +@Entity('users', { engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' }) +export class User extends BaseEntity { + @PrimaryGeneratedColumn('increment', { unsigned: true }) + id: number + + @Column({ + name: 'gradido_id', + length: 36, + nullable: false, + collation: 'utf8mb4_unicode_ci', + }) + gradidoID: string + + @Column({ + name: 'alias', + length: 20, + nullable: true, + default: null, + collation: 'utf8mb4_unicode_ci', + }) + alias: string + + @OneToOne(() => UserContact, (emailContact: UserContact) => emailContact.user) + @JoinColumn({ name: 'email_id' }) + emailContact: UserContact + + @Column({ name: 'email_id', type: 'int', unsigned: true, nullable: true, default: null }) + emailId: number | null + + @Column({ + name: 'first_name', + length: 255, + nullable: true, + default: null, + collation: 'utf8mb4_unicode_ci', + }) + firstName: string + + @Column({ + name: 'last_name', + length: 255, + nullable: true, + default: null, + collation: 'utf8mb4_unicode_ci', + }) + lastName: string + + @Column({ name: 'created_at', default: () => 'CURRENT_TIMESTAMP', nullable: false }) + createdAt: Date + + @DeleteDateColumn({ name: 'deleted_at', nullable: true }) + deletedAt: Date | null + + @Column({ type: 'bigint', default: 0, unsigned: true }) + password: BigInt + + @Column({ + name: 'password_encryption_type', + type: 'int', + unsigned: true, + nullable: false, + default: 0, + }) + passwordEncryptionType: number + + @Column({ length: 4, default: 'de', collation: 'utf8mb4_unicode_ci', nullable: false }) + language: string + + @Column({ name: 'is_admin', type: 'datetime', nullable: true, default: null }) + isAdmin: Date | null + + @Column({ name: 'referrer_id', type: 'int', unsigned: true, nullable: true, default: null }) + referrerId?: number | null + + @Column({ + name: 'contribution_link_id', + type: 'int', + unsigned: true, + nullable: true, + default: null, + }) + contributionLinkId?: number | null + + @Column({ name: 'publisher_id', default: 0 }) + publisherId: number + + @OneToMany(() => Contribution, (contribution) => contribution.user) + @JoinColumn({ name: 'user_id' }) + contributions?: Contribution[] + + @OneToMany(() => ContributionMessage, (message) => message.user) + @JoinColumn({ name: 'user_id' }) + messages?: ContributionMessage[] + + @OneToMany(() => UserContact, (userContact: UserContact) => userContact.user) + @JoinColumn({ name: 'user_id' }) + userContacts?: UserContact[] +} diff --git a/database/entity/0055-clear_old_password_junk/UserContact.ts b/database/entity/0055-clear_old_password_junk/UserContact.ts new file mode 100644 index 000000000..c101fba4c --- /dev/null +++ b/database/entity/0055-clear_old_password_junk/UserContact.ts @@ -0,0 +1,57 @@ +import { + BaseEntity, + Entity, + PrimaryGeneratedColumn, + Column, + DeleteDateColumn, + OneToOne, +} from 'typeorm' +import { User } from './User' + +@Entity('user_contacts', { engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' }) +export class UserContact extends BaseEntity { + @PrimaryGeneratedColumn('increment', { unsigned: true }) + id: number + + @Column({ + name: 'type', + length: 100, + nullable: true, + default: null, + collation: 'utf8mb4_unicode_ci', + }) + type: string + + @OneToOne(() => User, (user) => user.emailContact) + user: User + + @Column({ name: 'user_id', type: 'int', unsigned: true, nullable: false }) + userId: number + + @Column({ length: 255, unique: true, nullable: false, collation: 'utf8mb4_unicode_ci' }) + email: string + + @Column({ name: 'email_verification_code', type: 'bigint', unsigned: true, unique: true }) + emailVerificationCode: BigInt + + @Column({ name: 'email_opt_in_type_id' }) + emailOptInTypeId: number + + @Column({ name: 'email_resend_count' }) + emailResendCount: number + + @Column({ name: 'email_checked', type: 'bool', nullable: false, default: false }) + emailChecked: boolean + + @Column({ length: 255, unique: false, nullable: true, collation: 'utf8mb4_unicode_ci' }) + phone: string + + @Column({ name: 'created_at', default: () => 'CURRENT_TIMESTAMP', nullable: false }) + createdAt: Date + + @Column({ name: 'updated_at', nullable: true, default: null, type: 'datetime' }) + updatedAt: Date | null + + @DeleteDateColumn({ name: 'deleted_at', nullable: true }) + deletedAt: Date | null +} diff --git a/database/entity/User.ts b/database/entity/User.ts index b3c00a9b4..07c0ef335 100644 --- a/database/entity/User.ts +++ b/database/entity/User.ts @@ -1 +1 @@ -export { User } from './0053-change_password_encryption/User' +export { User } from './0055-clear_old_password_junk/User' diff --git a/database/entity/UserContact.ts b/database/entity/UserContact.ts index dd74e65c4..5c923c92b 100644 --- a/database/entity/UserContact.ts +++ b/database/entity/UserContact.ts @@ -1 +1 @@ -export { UserContact } from './0053-change_password_encryption/UserContact' +export { UserContact } from './0055-clear_old_password_junk/UserContact' diff --git a/database/migrations/0055-clear_old_password_junk.ts b/database/migrations/0055-clear_old_password_junk.ts new file mode 100644 index 000000000..3e6f3f76a --- /dev/null +++ b/database/migrations/0055-clear_old_password_junk.ts @@ -0,0 +1,16 @@ +/* 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>) { + await queryFn('ALTER TABLE users DROP COLUMN public_key;') + await queryFn('ALTER TABLE users DROP COLUMN privkey;') + await queryFn('ALTER TABLE users DROP COLUMN email_hash;') + await queryFn('ALTER TABLE users DROP COLUMN passphrase;') +} + +export async function downgrade(queryFn: (query: string, values?: any[]) => Promise>) { + await queryFn('ALTER TABLE users ADD COLUMN public_key binary(32) DEFAULT NULL;') + await queryFn('ALTER TABLE users ADD COLUMN privkey binary(80) DEFAULT NULL;') + await queryFn('ALTER TABLE users ADD COLUMN email_hash binary(32) DEFAULT NULL;') + await queryFn('ALTER TABLE users ADD COLUMN passphrase text DEFAULT NULL;') +} From 81f60667543a836b0941bee042b4ff744dd88ccc Mon Sep 17 00:00:00 2001 From: joseji Date: Tue, 29 Nov 2022 13:06:17 +0100 Subject: [PATCH 46/90] removed unused code --- backend/src/graphql/resolver/UserResolver.ts | 95 -------------------- 1 file changed, 95 deletions(-) diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index db8169db1..626ff9705 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -55,89 +55,6 @@ const isLanguage = (language: string): boolean => { return LANGUAGES.includes(language) } -const PHRASE_WORD_COUNT = 24 -const WORDS = fs - .readFileSync('src/config/mnemonic.uncompressed_buffer13116.txt') - .toString() - .split(',') -const PassphraseGenerate = (): string[] => { - logger.trace('PassphraseGenerate...') - const result = [] - for (let i = 0; i < PHRASE_WORD_COUNT; i++) { - result.push(WORDS[sodium.randombytes_random() % 2048]) - } - return result -} - -const KeyPairEd25519Create = (passphrase: string[]): Buffer[] => { - logger.trace('KeyPairEd25519Create...') - if (!passphrase.length || passphrase.length < PHRASE_WORD_COUNT) { - logger.error('passphrase empty or to short') - throw new Error('passphrase empty or to short') - } - - const state = Buffer.alloc(sodium.crypto_hash_sha512_STATEBYTES) - sodium.crypto_hash_sha512_init(state) - - // To prevent breaking existing passphrase-hash combinations word indices will be put into 64 Bit Variable to mimic first implementation of algorithms - for (let i = 0; i < PHRASE_WORD_COUNT; i++) { - const value = Buffer.alloc(8) - const wordIndex = WORDS.indexOf(passphrase[i]) - value.writeBigInt64LE(BigInt(wordIndex)) - sodium.crypto_hash_sha512_update(state, value) - } - // trailing space is part of the login_server implementation - const clearPassphrase = passphrase.join(' ') + ' ' - sodium.crypto_hash_sha512_update(state, Buffer.from(clearPassphrase)) - const outputHashBuffer = Buffer.alloc(sodium.crypto_hash_sha512_BYTES) - sodium.crypto_hash_sha512_final(state, outputHashBuffer) - - const pubKey = Buffer.alloc(sodium.crypto_sign_PUBLICKEYBYTES) - const privKey = Buffer.alloc(sodium.crypto_sign_SECRETKEYBYTES) - - sodium.crypto_sign_seed_keypair( - pubKey, - privKey, - outputHashBuffer.slice(0, sodium.crypto_sign_SEEDBYTES), - ) - logger.debug(`KeyPair creation ready. pubKey=${pubKey}`) - - return [pubKey, privKey] -} - -/* -const getEmailHash = (email: string): Buffer => { - logger.trace('getEmailHash...') - const emailHash = Buffer.alloc(sodium.crypto_generichash_BYTES) - sodium.crypto_generichash(emailHash, Buffer.from(email)) - logger.debug(`getEmailHash...successful: ${emailHash}`) - return emailHash -} -*/ - -const SecretKeyCryptographyEncrypt = (message: Buffer, encryptionKey: Buffer): Buffer => { - logger.trace('SecretKeyCryptographyEncrypt...') - const encrypted = Buffer.alloc(message.length + sodium.crypto_secretbox_MACBYTES) - const nonce = Buffer.alloc(sodium.crypto_secretbox_NONCEBYTES) - nonce.fill(31) // static nonce - - sodium.crypto_secretbox_easy(encrypted, message, nonce, encryptionKey) - logger.debug(`SecretKeyCryptographyEncrypt...successful: ${encrypted}`) - return encrypted -} - -const SecretKeyCryptographyDecrypt = (encryptedMessage: Buffer, encryptionKey: Buffer): Buffer => { - logger.trace('SecretKeyCryptographyDecrypt...') - const message = Buffer.alloc(encryptedMessage.length - sodium.crypto_secretbox_MACBYTES) - const nonce = Buffer.alloc(sodium.crypto_secretbox_NONCEBYTES) - nonce.fill(31) // static nonce - - sodium.crypto_secretbox_open_easy(message, encryptedMessage, nonce, encryptionKey) - - logger.debug(`SecretKeyCryptographyDecrypt...successful: ${message}`) - return message -} - const newEmailContact = (email: string, userId: number): DbUserContact => { logger.trace(`newEmailContact...`) const emailContact = new DbUserContact() @@ -265,7 +182,6 @@ export class UserResolver { const clientTimezoneOffset = getClientTimezoneOffset(context) const userEntity = getUser(context) const user = new User(userEntity, await getUserCreation(userEntity.id, clientTimezoneOffset)) - // user.pubkey = userEntity.pubKey.toString('hex') // Elopage Status & Stored PublisherId user.hasElopage = await this.hasElopage(context) @@ -420,11 +336,6 @@ export class UserResolver { } } - const passphrase = PassphraseGenerate() - // const keyPair = KeyPairEd25519Create(passphrase) // return pub, priv Key - // const passwordHash = SecretKeyCryptographyCreateKey(email, password) // return short and long hash - // const encryptedPrivkey = SecretKeyCryptographyEncrypt(keyPair[1], passwordHash[1]) - // const emailHash = getEmailHash(email) const gradidoID = await newGradidoID() const eventRegister = new EventRegister() @@ -458,12 +369,6 @@ export class UserResolver { } } } - // TODO this field has no null allowed unlike the loginServer table - // dbUser.pubKey = Buffer.from(randomBytes(32)) // Buffer.alloc(32, 0) default to 0000... - // dbUser.pubkey = keyPair[0] - // loginUser.password = passwordHash[0].readBigUInt64LE() // using the shorthash - // loginUser.pubKey = keyPair[0] - // loginUser.privKey = encryptedPrivkey const queryRunner = getConnection().createQueryRunner() await queryRunner.connect() From 7fe7b98dfe8413f1275f551006ed617a00fc8c89 Mon Sep 17 00:00:00 2001 From: joseji Date: Tue, 29 Nov 2022 13:10:33 +0100 Subject: [PATCH 47/90] removing non necessary functions --- backend/src/graphql/resolver/UserResolver.ts | 3 +-- backend/src/util/validate.ts | 4 ---- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index 626ff9705..c1c4903f8 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -1,4 +1,3 @@ -import fs from 'fs' import { backendLogger as logger } from '@/server/logger' import i18n from 'i18n' import { Context, getUser, getClientTimezoneOffset } from '@/server/context' @@ -40,7 +39,7 @@ import { SearchAdminUsersResult } from '@model/AdminUser' import Paginated from '@arg/Paginated' import { Order } from '@enum/Order' import { v4 as uuidv4 } from 'uuid' -import { isValidPassword, SecretKeyCryptographyCreateKey } from '@/password/EncryptorUtils' +import { isValidPassword } from '@/password/EncryptorUtils' import { encryptPassword, verifyPassword } from '@/password/PasswordEncryptor' import { PasswordEncryptionType } from '../enum/PasswordEncryptionType' diff --git a/backend/src/util/validate.ts b/backend/src/util/validate.ts index edd8d55f6..837aef895 100644 --- a/backend/src/util/validate.ts +++ b/backend/src/util/validate.ts @@ -14,10 +14,6 @@ function isStringBoolean(value: string): boolean { return false } -function isHexPublicKey(publicKey: string): boolean { - return /^[0-9A-Fa-f]{64}$/i.test(publicKey) -} - async function calculateBalance( userId: number, amount: Decimal, From 88cae0e528eea80319306e8dcef8305f01140d84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Tue, 29 Nov 2022 13:40:08 +0100 Subject: [PATCH 48/90] Move email '*.pug' templates in 'templates' folder --- backend/src/emails/sendEmailTranslated.ts | 2 +- backend/src/emails/{ => templates}/accountActivation/html.pug | 0 .../src/emails/{ => templates}/accountActivation/subject.pug | 0 .../emails/{ => templates}/accountMultiRegistration/html.pug | 0 .../emails/{ => templates}/accountMultiRegistration/subject.pug | 0 .../emails/{ => templates}/addedContributionMessage/html.pug | 0 .../emails/{ => templates}/addedContributionMessage/subject.pug | 0 .../src/emails/{ => templates}/contributionConfirmed/html.pug | 0 .../emails/{ => templates}/contributionConfirmed/subject.pug | 0 .../src/emails/{ => templates}/contributionRejected/html.pug | 0 .../src/emails/{ => templates}/contributionRejected/subject.pug | 0 backend/src/emails/{ => templates}/resetPassword/html.pug | 0 backend/src/emails/{ => templates}/resetPassword/subject.pug | 0 .../src/emails/{ => templates}/transactionLinkRedeemed/html.pug | 0 .../emails/{ => templates}/transactionLinkRedeemed/subject.pug | 0 backend/src/emails/{ => templates}/transactionReceived/html.pug | 0 .../src/emails/{ => templates}/transactionReceived/subject.pug | 0 17 files changed, 1 insertion(+), 1 deletion(-) rename backend/src/emails/{ => templates}/accountActivation/html.pug (100%) rename backend/src/emails/{ => templates}/accountActivation/subject.pug (100%) rename backend/src/emails/{ => templates}/accountMultiRegistration/html.pug (100%) rename backend/src/emails/{ => templates}/accountMultiRegistration/subject.pug (100%) rename backend/src/emails/{ => templates}/addedContributionMessage/html.pug (100%) rename backend/src/emails/{ => templates}/addedContributionMessage/subject.pug (100%) rename backend/src/emails/{ => templates}/contributionConfirmed/html.pug (100%) rename backend/src/emails/{ => templates}/contributionConfirmed/subject.pug (100%) rename backend/src/emails/{ => templates}/contributionRejected/html.pug (100%) rename backend/src/emails/{ => templates}/contributionRejected/subject.pug (100%) rename backend/src/emails/{ => templates}/resetPassword/html.pug (100%) rename backend/src/emails/{ => templates}/resetPassword/subject.pug (100%) rename backend/src/emails/{ => templates}/transactionLinkRedeemed/html.pug (100%) rename backend/src/emails/{ => templates}/transactionLinkRedeemed/subject.pug (100%) rename backend/src/emails/{ => templates}/transactionReceived/html.pug (100%) rename backend/src/emails/{ => templates}/transactionReceived/subject.pug (100%) diff --git a/backend/src/emails/sendEmailTranslated.ts b/backend/src/emails/sendEmailTranslated.ts index 3f5d9d9fd..69008c00e 100644 --- a/backend/src/emails/sendEmailTranslated.ts +++ b/backend/src/emails/sendEmailTranslated.ts @@ -63,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' }) diff --git a/backend/src/emails/accountActivation/html.pug b/backend/src/emails/templates/accountActivation/html.pug similarity index 100% rename from backend/src/emails/accountActivation/html.pug rename to backend/src/emails/templates/accountActivation/html.pug diff --git a/backend/src/emails/accountActivation/subject.pug b/backend/src/emails/templates/accountActivation/subject.pug similarity index 100% rename from backend/src/emails/accountActivation/subject.pug rename to backend/src/emails/templates/accountActivation/subject.pug diff --git a/backend/src/emails/accountMultiRegistration/html.pug b/backend/src/emails/templates/accountMultiRegistration/html.pug similarity index 100% rename from backend/src/emails/accountMultiRegistration/html.pug rename to backend/src/emails/templates/accountMultiRegistration/html.pug diff --git a/backend/src/emails/accountMultiRegistration/subject.pug b/backend/src/emails/templates/accountMultiRegistration/subject.pug similarity index 100% rename from backend/src/emails/accountMultiRegistration/subject.pug rename to backend/src/emails/templates/accountMultiRegistration/subject.pug diff --git a/backend/src/emails/addedContributionMessage/html.pug b/backend/src/emails/templates/addedContributionMessage/html.pug similarity index 100% rename from backend/src/emails/addedContributionMessage/html.pug rename to backend/src/emails/templates/addedContributionMessage/html.pug diff --git a/backend/src/emails/addedContributionMessage/subject.pug b/backend/src/emails/templates/addedContributionMessage/subject.pug similarity index 100% rename from backend/src/emails/addedContributionMessage/subject.pug rename to backend/src/emails/templates/addedContributionMessage/subject.pug diff --git a/backend/src/emails/contributionConfirmed/html.pug b/backend/src/emails/templates/contributionConfirmed/html.pug similarity index 100% rename from backend/src/emails/contributionConfirmed/html.pug rename to backend/src/emails/templates/contributionConfirmed/html.pug diff --git a/backend/src/emails/contributionConfirmed/subject.pug b/backend/src/emails/templates/contributionConfirmed/subject.pug similarity index 100% rename from backend/src/emails/contributionConfirmed/subject.pug rename to backend/src/emails/templates/contributionConfirmed/subject.pug diff --git a/backend/src/emails/contributionRejected/html.pug b/backend/src/emails/templates/contributionRejected/html.pug similarity index 100% rename from backend/src/emails/contributionRejected/html.pug rename to backend/src/emails/templates/contributionRejected/html.pug diff --git a/backend/src/emails/contributionRejected/subject.pug b/backend/src/emails/templates/contributionRejected/subject.pug similarity index 100% rename from backend/src/emails/contributionRejected/subject.pug rename to backend/src/emails/templates/contributionRejected/subject.pug diff --git a/backend/src/emails/resetPassword/html.pug b/backend/src/emails/templates/resetPassword/html.pug similarity index 100% rename from backend/src/emails/resetPassword/html.pug rename to backend/src/emails/templates/resetPassword/html.pug diff --git a/backend/src/emails/resetPassword/subject.pug b/backend/src/emails/templates/resetPassword/subject.pug similarity index 100% rename from backend/src/emails/resetPassword/subject.pug rename to backend/src/emails/templates/resetPassword/subject.pug diff --git a/backend/src/emails/transactionLinkRedeemed/html.pug b/backend/src/emails/templates/transactionLinkRedeemed/html.pug similarity index 100% rename from backend/src/emails/transactionLinkRedeemed/html.pug rename to backend/src/emails/templates/transactionLinkRedeemed/html.pug diff --git a/backend/src/emails/transactionLinkRedeemed/subject.pug b/backend/src/emails/templates/transactionLinkRedeemed/subject.pug similarity index 100% rename from backend/src/emails/transactionLinkRedeemed/subject.pug rename to backend/src/emails/templates/transactionLinkRedeemed/subject.pug diff --git a/backend/src/emails/transactionReceived/html.pug b/backend/src/emails/templates/transactionReceived/html.pug similarity index 100% rename from backend/src/emails/transactionReceived/html.pug rename to backend/src/emails/templates/transactionReceived/html.pug diff --git a/backend/src/emails/transactionReceived/subject.pug b/backend/src/emails/templates/transactionReceived/subject.pug similarity index 100% rename from backend/src/emails/transactionReceived/subject.pug rename to backend/src/emails/templates/transactionReceived/subject.pug From 3d55c7da9e7ffc1435fece650e760b9de04a597d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Tue, 29 Nov 2022 13:41:42 +0100 Subject: [PATCH 49/90] Fix production by copying missing email templates and locale files into the build folder --- backend/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/package.json b/backend/package.json index 519f9e6c0..c6b3dabc2 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", From a7ee30fc3fd444ad28229c586d50939a302ac768 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Tue, 29 Nov 2022 13:42:37 +0100 Subject: [PATCH 50/90] Fix some comments and readme spellings --- DOCKER_MORE_CLOSELY.md | 4 ++-- docker-compose.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/DOCKER_MORE_CLOSELY.md b/DOCKER_MORE_CLOSELY.md index f2aae81c7..c21b99829 100644 --- a/DOCKER_MORE_CLOSELY.md +++ b/DOCKER_MORE_CLOSELY.md @@ -4,7 +4,7 @@ ***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: @@ -27,7 +27,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! diff --git a/docker-compose.yml b/docker-compose.yml index 5f0ab4dde..5eea075c6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -108,7 +108,7 @@ services: #env_file: # - ./frontend/.env volumes: - # : – mirror bidirectional path in local context with path in Docker container + # : – mirror bidirectional path in local context with path in Docker container - ./logs/backend:/logs/backend ######################################################## From 317224db7a08027531f489e32476983f1e80091a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Tue, 29 Nov 2022 14:09:15 +0100 Subject: [PATCH 51/90] Refactor some readme's by moving their content into 'CONTRIBUTING.md' --- DOCKER_MORE_CLOSELY.md => CONTRIBUTING.md | 28 +++++++++++++++++++---- admin/src/locales/README.md | 3 --- backend/src/locales/README.md | 3 --- frontend/src/locales/README.md | 25 -------------------- 4 files changed, 23 insertions(+), 36 deletions(-) rename DOCKER_MORE_CLOSELY.md => CONTRIBUTING.md (65%) delete mode 100644 admin/src/locales/README.md delete mode 100644 backend/src/locales/README.md delete mode 100644 frontend/src/locales/README.md diff --git a/DOCKER_MORE_CLOSELY.md b/CONTRIBUTING.md similarity index 65% rename from DOCKER_MORE_CLOSELY.md rename to CONTRIBUTING.md index c21b99829..3ebcdb73a 100644 --- a/DOCKER_MORE_CLOSELY.md +++ b/CONTRIBUTING.md @@ -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 + +## Docker – More Closely + +### Apple M1 Platform ***Attention:** For using Docker commands in Apple M1 environments!* -### Environment 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 ``` -## Analyzing 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! diff --git a/admin/src/locales/README.md b/admin/src/locales/README.md deleted file mode 100644 index 5d6bf75b1..000000000 --- a/admin/src/locales/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Localizations - -Please see [frontend localization](/frontend/src/locales/README.md). diff --git a/backend/src/locales/README.md b/backend/src/locales/README.md deleted file mode 100644 index 5d6bf75b1..000000000 --- a/backend/src/locales/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Localizations - -Please see [frontend localization](/frontend/src/locales/README.md). diff --git a/frontend/src/locales/README.md b/frontend/src/locales/README.md deleted file mode 100644 index 2c03abbd4..000000000 --- a/frontend/src/locales/README.md +++ /dev/null @@ -1,25 +0,0 @@ -# Localizations - -## Quotation Marks - -The following characters are different from the programming quotation mark: - -`"` - -### English - -In English, we use these double-barreled quotation marks: - -“This is a sample sentence.” - -Please copy and paste … - -See - -### German - -In German, we use these double-barreled quotation marks: - -„Dies ist ein Beispielsatz.“ - -Please copy and paste … From 10ad42babe2dde7f37b2c8f955f19668c0c8eb97 Mon Sep 17 00:00:00 2001 From: Ulf Gebhardt Date: Tue, 29 Nov 2022 16:37:25 +0100 Subject: [PATCH 52/90] missing files to be copied --- backend/Dockerfile | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/backend/Dockerfile b/backend/Dockerfile index 6225a4cd7..c09e5aaf8 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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 From c0096aa26c97d89439e5b09d317508a318cd6143 Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Wed, 30 Nov 2022 12:15:47 +0100 Subject: [PATCH 53/90] fix(backend): critical bug --- .../resolver/TransactionResolver.test.ts | 8 ++------ .../graphql/resolver/TransactionResolver.ts | 19 ++++--------------- 2 files changed, 6 insertions(+), 21 deletions(-) diff --git a/backend/src/graphql/resolver/TransactionResolver.test.ts b/backend/src/graphql/resolver/TransactionResolver.test.ts index f4315d359..1d4fe5708 100644 --- a/backend/src/graphql/resolver/TransactionResolver.test.ts +++ b/backend/src/graphql/resolver/TransactionResolver.test.ts @@ -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 () => { diff --git a/backend/src/graphql/resolver/TransactionResolver.ts b/backend/src/graphql/resolver/TransactionResolver.ts index 594039cfd..3dbd4afb9 100644 --- a/backend/src/graphql/resolver/TransactionResolver.ts +++ b/backend/src/graphql/resolver/TransactionResolver.ts @@ -314,6 +314,10 @@ export class TransactionResolver { @Ctx() context: Context, ): Promise { 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) @@ -324,22 +328,7 @@ export class TransactionResolver { // 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') From bc20bfa8f6e38e40fbcc67c692e926ad28fae1f0 Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Wed, 30 Nov 2022 12:46:46 +0100 Subject: [PATCH 54/90] fix(backend): delete / undelete email contact as well --- .../graphql/resolver/AdminResolver.test.ts | 22 +++++++++++++++++++ backend/src/graphql/resolver/AdminResolver.ts | 9 ++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/backend/src/graphql/resolver/AdminResolver.test.ts b/backend/src/graphql/resolver/AdminResolver.test.ts index 503bab472..757f552f8 100644 --- a/backend/src/graphql/resolver/AdminResolver.test.ts +++ b/backend/src/graphql/resolver/AdminResolver.test.ts @@ -366,6 +366,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 +502,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 }), + }) + }) }) }) }) diff --git a/backend/src/graphql/resolver/AdminResolver.ts b/backend/src/graphql/resolver/AdminResolver.ts index 80c69a864..9ff4824e5 100644 --- a/backend/src/graphql/resolver/AdminResolver.ts +++ b/backend/src/graphql/resolver/AdminResolver.ts @@ -200,7 +200,7 @@ export class AdminResolver { @Arg('userId', () => Int) userId: number, @Ctx() context: Context, ): Promise { - 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 +214,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 +222,10 @@ export class AdminResolver { @Authorized([RIGHTS.UNDELETE_USER]) @Mutation(() => Date, { nullable: true }) async unDeleteUser(@Arg('userId', () => Int) userId: number): Promise { - 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 +235,7 @@ export class AdminResolver { throw new Error('User is not deleted') } await user.recover() + await user.emailContact.recover() return null } From bbd163f1e05a589384e63d66c2adf5f6c62a448c Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Wed, 30 Nov 2022 14:34:09 +0100 Subject: [PATCH 55/90] integrate export const checkEmailVerificationCode = async ( --- backend/src/graphql/resolver/UserResolver.ts | 66 +++++++------------- 1 file changed, 24 insertions(+), 42 deletions(-) diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index 087654269..2e294196a 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -141,35 +141,6 @@ const newEmailContact = (email: string, userId: number): DbUserContact => { return emailContact } -export const checkEmailVerificationCode = async ( - emailContact: DbUserContact, - optInType: OptInType = OptInType.EMAIL_OPT_IN_REGISTER, -): Promise => { - logger.info(`checkEmailVerificationCode... ${emailContact}`) - if (!canEmailResend(emailContact.updatedAt || 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`, - ) - } - emailContact.updatedAt = new Date() - emailContact.emailResendCount++ - 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.info(`checkEmailVerificationCode...successful: ${emailContact}`) - return emailContact -} - export const activationLink = (verificationCode: BigInt): string => { logger.debug(`activationLink(${verificationCode})...`) return CONFIG.EMAIL_LINK_SETPASSWORD.replace(/{optin}/g, verificationCode.toString()) @@ -492,21 +463,32 @@ 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`, + ) + } + + 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.') + }) - // optInCode = await checkOptInCode(optInCode, user, OptInType.EMAIL_OPT_IN_RESET_PASSWORD) - logger.info(`optInCode for ${email}=${dbUserContact}`) + logger.info(`optInCode for ${email}=${user.emailContact}`) // eslint-disable-next-line @typescript-eslint/no-unused-vars const emailSent = await sendResetPasswordEmailMailer({ - link: activationLink(dbUserContact.emailVerificationCode), + link: activationLink(user.emailContact.emailVerificationCode), firstName: user.firstName, lastName: user.lastName, email, @@ -516,7 +498,7 @@ export class UserResolver { /* 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...`) From 796f5af2c6d76d6cdabe8de85f4074d601ce387f Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Wed, 30 Nov 2022 14:45:02 +0100 Subject: [PATCH 56/90] remove unused function, count resent by admin --- backend/src/graphql/resolver/AdminResolver.ts | 3 +++ backend/src/graphql/resolver/UserResolver.ts | 14 ++++++++------ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/backend/src/graphql/resolver/AdminResolver.ts b/backend/src/graphql/resolver/AdminResolver.ts index 80c69a864..40b7ae176 100644 --- a/backend/src/graphql/resolver/AdminResolver.ts +++ b/backend/src/graphql/resolver/AdminResolver.ts @@ -654,6 +654,9 @@ export class AdminResolver { throw new Error(`The emailContact: ${email} of htis 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), diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index 2e294196a..1d7cf49da 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -466,16 +466,16 @@ export class UserResolver { if (!canEmailResend(user.emailContact.updatedAt || user.emailContact.createdAt)) { logger.error( `email already sent less than ${printTimeDuration( - CONFIG.EMAIL_CODE_REQUEST_TIME, - )} minutes ago`, + CONFIG.EMAIL_CODE_REQUEST_TIME, + )} minutes ago`, ) throw new Error( `email already sent less than ${printTimeDuration( - CONFIG.EMAIL_CODE_REQUEST_TIME, - )} minutes ago`, + CONFIG.EMAIL_CODE_REQUEST_TIME, + )} minutes ago`, ) } - + user.emailContact.updatedAt = new Date() user.emailContact.emailResendCount++ user.emailContact.emailVerificationCode = random(64) @@ -498,7 +498,9 @@ export class UserResolver { /* 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(user.emailContact.emailVerificationCode)}`) + logger.debug( + `Reset password link: ${activationLink(user.emailContact.emailVerificationCode)}`, + ) } logger.info(`forgotPassword(${email}) successful...`) From 46014adc6d057496c4169d6591ccbfae01929fe3 Mon Sep 17 00:00:00 2001 From: elweyn Date: Thu, 1 Dec 2022 12:07:25 +0100 Subject: [PATCH 57/90] Database join contribution to transaction table, add new join to query. --- backend/src/graphql/model/Transaction.ts | 4 +- .../resolver/TransactionLinkResolver.ts | 3 +- .../graphql/resolver/TransactionResolver.ts | 3 +- backend/src/typeorm/repository/Transaction.ts | 17 ++++ backend/src/util/virtualTransactions.ts | 2 + .../Contribution.ts | 6 ++ .../Transaction.ts | 99 +++++++++++++++++++ database/entity/Transaction.ts | 2 +- 8 files changed, 131 insertions(+), 5 deletions(-) create mode 100644 database/entity/0052-add_updated_at_to_contributions/Transaction.ts diff --git a/backend/src/graphql/model/Transaction.ts b/backend/src/graphql/model/Transaction.ts index 24c66ac67..09379e0eb 100644 --- a/backend/src/graphql/model/Transaction.ts +++ b/backend/src/graphql/model/Transaction.ts @@ -42,7 +42,9 @@ export class Transaction { this.creationDate = transaction.creationDate this.linkedUser = linkedUser this.linkedTransactionId = transaction.linkedTransactionId - this.transactionLinkId = transaction.transactionLinkId + this.transactionLinkId = transaction.contribution + ? transaction.contribution.contributionLinkId + : transaction.transactionLinkId } @Field(() => Number) diff --git a/backend/src/graphql/resolver/TransactionLinkResolver.ts b/backend/src/graphql/resolver/TransactionLinkResolver.ts index 8d648bc65..a3c170999 100644 --- a/backend/src/graphql/resolver/TransactionLinkResolver.ts +++ b/backend/src/graphql/resolver/TransactionLinkResolver.ts @@ -278,6 +278,7 @@ export class TransactionLinkResolver { .createQueryBuilder() .select('transaction') .from(DbTransaction, 'transaction') + .innerJoinAndSelect('transaction.contribution', 'c') .where('transaction.userId = :id', { id: user.id }) .orderBy('transaction.balanceDate', 'DESC') .getOne() @@ -301,7 +302,7 @@ export class TransactionLinkResolver { transaction.balanceDate = now transaction.decay = decay ? decay.decay : new Decimal(0) transaction.decayStart = decay ? decay.start : null - transaction.transactionLinkId = contributionLink.id + transaction.contribution = contribution await queryRunner.manager.insert(DbTransaction, transaction) contribution.confirmedAt = now diff --git a/backend/src/graphql/resolver/TransactionResolver.ts b/backend/src/graphql/resolver/TransactionResolver.ts index f0fb2f452..e89870558 100644 --- a/backend/src/graphql/resolver/TransactionResolver.ts +++ b/backend/src/graphql/resolver/TransactionResolver.ts @@ -215,7 +215,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}`) @@ -238,7 +238,6 @@ export class TransactionResolver { order, ) context.transactionCount = userTransactionsCount - // find involved users; I am involved const involvedUserIds: number[] = [user.id] userTransactions.forEach((transaction: dbTransaction) => { diff --git a/backend/src/typeorm/repository/Transaction.ts b/backend/src/typeorm/repository/Transaction.ts index f84b57626..7952be0fc 100644 --- a/backend/src/typeorm/repository/Transaction.ts +++ b/backend/src/typeorm/repository/Transaction.ts @@ -1,4 +1,5 @@ import { EntityRepository, Repository } from '@dbTools/typeorm' +import { Contribution } from '@entity/Contribution' import { Transaction } from '@entity/Transaction' import { Order } from '@enum/Order' import { TransactionTypeId } from '@enum/TransactionTypeId' @@ -14,6 +15,11 @@ export class TransactionRepository extends Repository { ): Promise<[Transaction[], number]> { if (onlyCreation) { return this.createQueryBuilder('userTransaction') + .innerJoinAndSelect( + 'userTransaction.contribution', + 'c', + 'userTransaction.id = c.transactionId', + ) .where('userTransaction.userId = :userId', { userId }) .andWhere('userTransaction.typeId = :typeId', { typeId: TransactionTypeId.CREATION, @@ -24,6 +30,11 @@ export class TransactionRepository extends Repository { .getManyAndCount() } return this.createQueryBuilder('userTransaction') + .innerJoinAndSelect( + 'userTransaction.contribution', + 'c', + 'userTransaction.id = c.transactionId', + ) .where('userTransaction.userId = :userId', { userId }) .orderBy('userTransaction.balanceDate', order) .limit(limit) @@ -33,6 +44,12 @@ export class TransactionRepository extends Repository { findLastForUser(userId: number): Promise { return this.createQueryBuilder('userTransaction') + .innerJoinAndMapOne( + 'userTransaction.contribution', + Contribution, + 'c', + 'userTransaction.id = c.transactionId', + ) .where('userTransaction.userId = :userId', { userId }) .orderBy('userTransaction.balanceDate', 'DESC') .getOne() diff --git a/backend/src/util/virtualTransactions.ts b/backend/src/util/virtualTransactions.ts index 08d44b48d..b02f87ee5 100644 --- a/backend/src/util/virtualTransactions.ts +++ b/backend/src/util/virtualTransactions.ts @@ -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) diff --git a/database/entity/0052-add_updated_at_to_contributions/Contribution.ts b/database/entity/0052-add_updated_at_to_contributions/Contribution.ts index 2242a753f..4676c14af 100644 --- a/database/entity/0052-add_updated_at_to_contributions/Contribution.ts +++ b/database/entity/0052-add_updated_at_to_contributions/Contribution.ts @@ -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 } diff --git a/database/entity/0052-add_updated_at_to_contributions/Transaction.ts b/database/entity/0052-add_updated_at_to_contributions/Transaction.ts new file mode 100644 index 000000000..ef8d0abdc --- /dev/null +++ b/database/entity/0052-add_updated_at_to_contributions/Transaction.ts @@ -0,0 +1,99 @@ +import Decimal from 'decimal.js-light' +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 { + @PrimaryGeneratedColumn('increment', { unsigned: true }) + id: number + + @Column({ name: 'user_id', unsigned: true, nullable: false }) + userId: number + + @Column({ type: 'int', unsigned: true, unique: true, nullable: true, default: null }) + previous: number | null + + @Column({ name: 'type_id', unsigned: true, nullable: false }) + typeId: number + + @Column({ + type: 'decimal', + precision: 40, + scale: 20, + nullable: false, + transformer: DecimalTransformer, + }) + amount: Decimal + + @Column({ + type: 'decimal', + precision: 40, + scale: 20, + nullable: false, + transformer: DecimalTransformer, + }) + balance: Decimal + + @Column({ + name: 'balance_date', + type: 'datetime', + default: () => 'CURRENT_TIMESTAMP', + nullable: false, + }) + balanceDate: Date + + @Column({ + type: 'decimal', + precision: 40, + scale: 20, + nullable: false, + transformer: DecimalTransformer, + }) + decay: Decimal + + @Column({ + name: 'decay_start', + type: 'datetime', + nullable: true, + default: null, + }) + decayStart: Date | null + + @Column({ length: 255, nullable: false, collation: 'utf8mb4_unicode_ci' }) + memo: string + + @Column({ name: 'creation_date', type: 'datetime', nullable: true, default: null }) + creationDate: Date | null + + @Column({ + name: 'linked_user_id', + type: 'int', + unsigned: true, + nullable: true, + default: null, + }) + linkedUserId?: number | null + + @Column({ + name: 'linked_transaction_id', + type: 'int', + unsigned: true, + nullable: true, + default: null, + }) + linkedTransactionId?: number | null + + @Column({ + name: 'transaction_link_id', + type: 'int', + unsigned: true, + nullable: true, + default: null, + }) + transactionLinkId?: number | null + + @OneToOne(() => Contribution, (contribution) => contribution.transaction) + @JoinColumn({ name: 'id', referencedColumnName: 'transactionId' }) + contribution?: Contribution | null +} diff --git a/database/entity/Transaction.ts b/database/entity/Transaction.ts index 5365b0f70..b67dfaa97 100644 --- a/database/entity/Transaction.ts +++ b/database/entity/Transaction.ts @@ -1 +1 @@ -export { Transaction } from './0036-unique_previous_in_transactions/Transaction' +export { Transaction } from './0052-add_updated_at_to_contributions/Transaction' From ec396e85219889322bfbd7800e28cb3330b350c3 Mon Sep 17 00:00:00 2001 From: elweyn Date: Thu, 1 Dec 2022 12:45:15 +0100 Subject: [PATCH 58/90] Merge master. --- backend/src/graphql/resolver/TransactionResolver.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/backend/src/graphql/resolver/TransactionResolver.ts b/backend/src/graphql/resolver/TransactionResolver.ts index 25260e963..5fb13ba6b 100644 --- a/backend/src/graphql/resolver/TransactionResolver.ts +++ b/backend/src/graphql/resolver/TransactionResolver.ts @@ -206,7 +206,7 @@ export class TransactionResolver { // find current balance const lastTransaction = await dbTransaction.findOne( { userId: user.id }, - { order: { balanceDate: 'DESC' }, relations: ['contribution'] }, + { order: { balanceDate: 'DESC' } }, ) logger.debug(`lastTransaction=${lastTransaction}`) @@ -313,10 +313,6 @@ export class TransactionResolver { @Ctx() context: Context, ): Promise { 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) @@ -327,7 +323,6 @@ export class TransactionResolver { // validate recipient user const recipientUser = await findUserByEmail(email) - if (recipientUser.deletedAt) { logger.error(`The recipient account was deleted: recipientUser=${recipientUser}`) throw new Error('The recipient account was deleted') From 4a27fa07f0de17732c529e7b0c4dbc3c55b5fba3 Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Thu, 1 Dec 2022 14:49:56 +0100 Subject: [PATCH 59/90] fix(database): consistent deleted at bewteen users and user contacts --- .../0055-consistent_deleted_users.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 database/migrations/0055-consistent_deleted_users.ts diff --git a/database/migrations/0055-consistent_deleted_users.ts b/database/migrations/0055-consistent_deleted_users.ts new file mode 100644 index 000000000..e4f2df87f --- /dev/null +++ b/database/migrations/0055-consistent_deleted_users.ts @@ -0,0 +1,26 @@ +/* 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>) { + const contactsToFix = await queryFn(` + SELECT user_contacts.id, users.deleted_at + FROM user_contacts JOIN users ON users.email_id = user_contacts.id + WHERE user_contacts.deleted_at IS NULL + AND user_id IN (SELECT id FROM users WHERE deleted_at IS NOT NULL);`) + + for (let i = 0; i < contactsToFix.length; i++) { + const deletedAt = new Date(contactsToFix[i].deleted_at) + .toISOString() + .slice(0, 19) + .replace('T', ' ') + + await queryFn(` + UPDATE user_contacts SET deleted_at = '${deletedAt}' WHERE id = ${contactsToFix[i].id};`) + } +} + +/* 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>) {} From 7f1e69c1bdf0e109c9a48b1a254558e17b5e45d5 Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Thu, 1 Dec 2022 14:52:08 +0100 Subject: [PATCH 60/90] update database version --- backend/src/config/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index c9e5ea79f..ee99ef809 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -10,7 +10,7 @@ Decimal.set({ }) const constants = { - DB_VERSION: '0054-recalculate_balance_and_decay', + DB_VERSION: '0055-consistent_deleted_users', 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 From 0b2887b86fb90ddff3ebb9177d1413f544d59c02 Mon Sep 17 00:00:00 2001 From: elweyn Date: Thu, 1 Dec 2022 15:21:49 +0100 Subject: [PATCH 61/90] Correct joins between contribution and transaction --- backend/src/graphql/resolver/TransactionLinkResolver.ts | 5 +---- backend/src/graphql/resolver/TransactionResolver.ts | 6 +++++- backend/src/typeorm/repository/Transaction.ts | 6 +++--- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/backend/src/graphql/resolver/TransactionLinkResolver.ts b/backend/src/graphql/resolver/TransactionLinkResolver.ts index 79bfd375e..f831b7ff1 100644 --- a/backend/src/graphql/resolver/TransactionLinkResolver.ts +++ b/backend/src/graphql/resolver/TransactionLinkResolver.ts @@ -265,6 +265,7 @@ export class TransactionLinkResolver { const creations = await getUserCreation(user.id, clientTimezoneOffset) logger.info('open creations', creations) validateContribution(creations, contributionLink.amount, now, clientTimezoneOffset) + const contribution = new DbContribution() contribution.userId = user.id contribution.createdAt = now @@ -274,19 +275,16 @@ export class TransactionLinkResolver { contribution.contributionLinkId = contributionLink.id contribution.contributionType = ContributionType.LINK contribution.contributionStatus = ContributionStatus.CONFIRMED - await queryRunner.manager.insert(DbContribution, contribution) const lastTransaction = await queryRunner.manager .createQueryBuilder() .select('transaction') .from(DbTransaction, 'transaction') - .innerJoinAndSelect('transaction.contribution', 'c') .where('transaction.userId = :id', { id: user.id }) .orderBy('transaction.balanceDate', 'DESC') .getOne() let newBalance = new Decimal(0) - let decay: Decay | null = null if (lastTransaction) { decay = calculateDecay(lastTransaction.balance, lastTransaction.balanceDate, now) @@ -305,7 +303,6 @@ export class TransactionLinkResolver { transaction.balanceDate = now transaction.decay = decay ? decay.decay : new Decimal(0) transaction.decayStart = decay ? decay.start : null - transaction.contribution = contribution await queryRunner.manager.insert(DbTransaction, transaction) contribution.confirmedAt = now diff --git a/backend/src/graphql/resolver/TransactionResolver.ts b/backend/src/graphql/resolver/TransactionResolver.ts index 5fb13ba6b..7ec925181 100644 --- a/backend/src/graphql/resolver/TransactionResolver.ts +++ b/backend/src/graphql/resolver/TransactionResolver.ts @@ -206,7 +206,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}`) @@ -313,6 +313,10 @@ export class TransactionResolver { @Ctx() context: Context, ): Promise { 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) diff --git a/backend/src/typeorm/repository/Transaction.ts b/backend/src/typeorm/repository/Transaction.ts index 7952be0fc..943ad1081 100644 --- a/backend/src/typeorm/repository/Transaction.ts +++ b/backend/src/typeorm/repository/Transaction.ts @@ -15,7 +15,7 @@ export class TransactionRepository extends Repository { ): Promise<[Transaction[], number]> { if (onlyCreation) { return this.createQueryBuilder('userTransaction') - .innerJoinAndSelect( + .leftJoinAndSelect( 'userTransaction.contribution', 'c', 'userTransaction.id = c.transactionId', @@ -30,7 +30,7 @@ export class TransactionRepository extends Repository { .getManyAndCount() } return this.createQueryBuilder('userTransaction') - .innerJoinAndSelect( + .leftJoinAndSelect( 'userTransaction.contribution', 'c', 'userTransaction.id = c.transactionId', @@ -44,7 +44,7 @@ export class TransactionRepository extends Repository { findLastForUser(userId: number): Promise { return this.createQueryBuilder('userTransaction') - .innerJoinAndMapOne( + .leftJoinAndMapOne( 'userTransaction.contribution', Contribution, 'c', From 41495c2c654e9b8ff260a341b748441831defca1 Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Thu, 1 Dec 2022 15:53:09 +0100 Subject: [PATCH 62/90] fix(backend): run all timers for high values --- backend/src/graphql/resolver/util/creations.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/backend/src/graphql/resolver/util/creations.test.ts b/backend/src/graphql/resolver/util/creations.test.ts index 8d747e989..6be317e16 100644 --- a/backend/src/graphql/resolver/util/creations.test.ts +++ b/backend/src/graphql/resolver/util/creations.test.ts @@ -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:`, ) }) From c5f4e95847ef7554f2608bbd8756b094e598624e Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Thu, 1 Dec 2022 16:54:48 +0100 Subject: [PATCH 63/90] fix(database): create missing users for transactions --- .../0056-consistent_transactions_table.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 database/migrations/0056-consistent_transactions_table.ts diff --git a/database/migrations/0056-consistent_transactions_table.ts b/database/migrations/0056-consistent_transactions_table.ts new file mode 100644 index 000000000..968bcf3b0 --- /dev/null +++ b/database/migrations/0056-consistent_transactions_table.ts @@ -0,0 +1,32 @@ +/* MIGRATION TO add users that have a transaction but do not exist */ + +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { v4 as uuidv4 } from 'uuid' + +export async function upgrade(queryFn: (query: string, values?: any[]) => Promise>) { + const missingUserIds = await queryFn(` + SELECT user_id FROM transactions + WHERE NOT EXISTS (SELECT id FROM users WHERE id = user_id) GROUP BY user_id;`) + + for (let i = 0; i < missingUserIds.length; i++) { + let gradidoId = null + let countIds = null + do { + gradidoId = uuidv4() + countIds = await queryFn( + `SELECT COUNT(*) FROM \`users\` WHERE \`gradido_id\` = "${gradidoId}"`, + ) + } while (countIds[0] > 0) + + await queryFn(` + INSERT INTO users + (id, gradido_id, first_name, last_name, deleted_at, password_encryption_type, created_at, language) + VALUES + (${missingUserIds[i].user_id}, '${gradidoId}', 'DELETED', 'USER', NOW(), 0, NOW(), 'de');`) + } +} + +/* 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>) {} From ed3a76dfdcaaa17804eff3af9533acc31e16452f Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Thu, 1 Dec 2022 16:56:24 +0100 Subject: [PATCH 64/90] update database version --- backend/src/config/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index ee99ef809..2b79e6a08 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -10,7 +10,7 @@ Decimal.set({ }) const constants = { - DB_VERSION: '0055-consistent_deleted_users', + DB_VERSION: '0056-consistent_transactions_table', 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 From 345123af116a79f2aa2f19e3ed46b22d05d0015c Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Thu, 1 Dec 2022 17:10:35 +0100 Subject: [PATCH 65/90] add user contact for missing users --- .../migrations/0056-consistent_transactions_table.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/database/migrations/0056-consistent_transactions_table.ts b/database/migrations/0056-consistent_transactions_table.ts index 968bcf3b0..f3db927ba 100644 --- a/database/migrations/0056-consistent_transactions_table.ts +++ b/database/migrations/0056-consistent_transactions_table.ts @@ -19,11 +19,17 @@ export async function upgrade(queryFn: (query: string, values?: any[]) => Promis ) } while (countIds[0] > 0) + const userContact = await queryFn(` + INSERT INTO user_contacts + (type, user_id, email, email_checked, created_at, deleted_at) + VALUES + ('EMAIL', ${missingUserIds[i].user_id}, 'deleted.user${missingUserIds[i].user_id}@gradido.net', 0, NOW(), NOW());`) + await queryFn(` INSERT INTO users - (id, gradido_id, first_name, last_name, deleted_at, password_encryption_type, created_at, language) + (id, gradido_id, email_id, first_name, last_name, deleted_at, password_encryption_type, created_at, language) VALUES - (${missingUserIds[i].user_id}, '${gradidoId}', 'DELETED', 'USER', NOW(), 0, NOW(), 'de');`) + (${missingUserIds[i].user_id}, '${gradidoId}', ${userContact.insertId}, 'DELETED', 'USER', NOW(), 0, NOW(), 'de');`) } } From 251f87554c96f5172da3d7e3a4aa132b805db8bd Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Thu, 1 Dec 2022 17:14:44 +0100 Subject: [PATCH 66/90] kack typescript --- database/migrations/0056-consistent_transactions_table.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/database/migrations/0056-consistent_transactions_table.ts b/database/migrations/0056-consistent_transactions_table.ts index f3db927ba..6cd462552 100644 --- a/database/migrations/0056-consistent_transactions_table.ts +++ b/database/migrations/0056-consistent_transactions_table.ts @@ -29,7 +29,7 @@ export async function upgrade(queryFn: (query: string, values?: any[]) => Promis INSERT INTO users (id, gradido_id, email_id, first_name, last_name, deleted_at, password_encryption_type, created_at, language) VALUES - (${missingUserIds[i].user_id}, '${gradidoId}', ${userContact.insertId}, 'DELETED', 'USER', NOW(), 0, NOW(), 'de');`) + (${missingUserIds[i].user_id}, '${gradidoId}', ${userContact.insertId ? userContact.insertId : 0}, 'DELETED', 'USER', NOW(), 0, NOW(), 'de');`) } } From fed61bf8884d016108c9cb8c92f18df28495ec3b Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Thu, 1 Dec 2022 17:16:56 +0100 Subject: [PATCH 67/90] kack typescript --- database/migrations/0056-consistent_transactions_table.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/database/migrations/0056-consistent_transactions_table.ts b/database/migrations/0056-consistent_transactions_table.ts index 6cd462552..e8e7111ac 100644 --- a/database/migrations/0056-consistent_transactions_table.ts +++ b/database/migrations/0056-consistent_transactions_table.ts @@ -29,7 +29,7 @@ export async function upgrade(queryFn: (query: string, values?: any[]) => Promis INSERT INTO users (id, gradido_id, email_id, first_name, last_name, deleted_at, password_encryption_type, created_at, language) VALUES - (${missingUserIds[i].user_id}, '${gradidoId}', ${userContact.insertId ? userContact.insertId : 0}, 'DELETED', 'USER', NOW(), 0, NOW(), 'de');`) + (${missingUserIds[i].user_id}, '${gradidoId}', ${userContact[0].insertId}, 'DELETED', 'USER', NOW(), 0, NOW(), 'de');`) } } From caa16c04881be152a6550f3bac1c56a26bd43f47 Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Thu, 1 Dec 2022 17:36:17 +0100 Subject: [PATCH 68/90] insert correct email id --- database/migrations/0056-consistent_transactions_table.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/database/migrations/0056-consistent_transactions_table.ts b/database/migrations/0056-consistent_transactions_table.ts index e8e7111ac..af7d8988e 100644 --- a/database/migrations/0056-consistent_transactions_table.ts +++ b/database/migrations/0056-consistent_transactions_table.ts @@ -25,11 +25,13 @@ export async function upgrade(queryFn: (query: string, values?: any[]) => Promis VALUES ('EMAIL', ${missingUserIds[i].user_id}, 'deleted.user${missingUserIds[i].user_id}@gradido.net', 0, NOW(), NOW());`) + const emaiId = Object.values(userContact)[Object.keys(userContact).indexOf('insertId')] + await queryFn(` INSERT INTO users (id, gradido_id, email_id, first_name, last_name, deleted_at, password_encryption_type, created_at, language) VALUES - (${missingUserIds[i].user_id}, '${gradidoId}', ${userContact[0].insertId}, 'DELETED', 'USER', NOW(), 0, NOW(), 'de');`) + (${missingUserIds[i].user_id}, '${gradidoId}', ${emaiId}, 'DELETED', 'USER', NOW(), 0, NOW(), 'de');`) } } From d3678bb81c51672ceee0d07359a76500eac6cec3 Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Thu, 1 Dec 2022 17:44:56 +0100 Subject: [PATCH 69/90] feat(backend): log client timezone offset --- backend/src/graphql/resolver/util/creations.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/src/graphql/resolver/util/creations.ts b/backend/src/graphql/resolver/util/creations.ts index eb4b6394d..54286d2aa 100644 --- a/backend/src/graphql/resolver/util/creations.ts +++ b/backend/src/graphql/resolver/util/creations.ts @@ -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, From 3ba591637cd883ad186c49661b718d7a6f6d9c45 Mon Sep 17 00:00:00 2001 From: elweyn Date: Fri, 2 Dec 2022 07:32:08 +0100 Subject: [PATCH 70/90] Remove unused method. --- backend/src/typeorm/repository/Transaction.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/backend/src/typeorm/repository/Transaction.ts b/backend/src/typeorm/repository/Transaction.ts index 943ad1081..affed5f42 100644 --- a/backend/src/typeorm/repository/Transaction.ts +++ b/backend/src/typeorm/repository/Transaction.ts @@ -1,5 +1,4 @@ import { EntityRepository, Repository } from '@dbTools/typeorm' -import { Contribution } from '@entity/Contribution' import { Transaction } from '@entity/Transaction' import { Order } from '@enum/Order' import { TransactionTypeId } from '@enum/TransactionTypeId' @@ -41,17 +40,4 @@ export class TransactionRepository extends Repository { .offset(offset) .getManyAndCount() } - - findLastForUser(userId: number): Promise { - return this.createQueryBuilder('userTransaction') - .leftJoinAndMapOne( - 'userTransaction.contribution', - Contribution, - 'c', - 'userTransaction.id = c.transactionId', - ) - .where('userTransaction.userId = :userId', { userId }) - .orderBy('userTransaction.balanceDate', 'DESC') - .getOne() - } } From ffb5988760108f9df39eb1f8bb804d011890fab4 Mon Sep 17 00:00:00 2001 From: elweyn Date: Tue, 6 Dec 2022 11:58:17 +0100 Subject: [PATCH 71/90] Refactoring of some small smelling code. --- .../resolver/TransactionLinkResolver.ts | 3 +- .../graphql/resolver/TransactionResolver.ts | 1 + backend/src/typeorm/repository/Transaction.ts | 30 +++--- .../Transaction.ts | 7 +- .../Transaction.ts | 99 ------------------- database/entity/Transaction.ts | 2 +- 6 files changed, 21 insertions(+), 121 deletions(-) delete mode 100644 database/entity/0052-add_updated_at_to_contributions/Transaction.ts diff --git a/backend/src/graphql/resolver/TransactionLinkResolver.ts b/backend/src/graphql/resolver/TransactionLinkResolver.ts index f831b7ff1..1b3558bb2 100644 --- a/backend/src/graphql/resolver/TransactionLinkResolver.ts +++ b/backend/src/graphql/resolver/TransactionLinkResolver.ts @@ -265,7 +265,6 @@ export class TransactionLinkResolver { const creations = await getUserCreation(user.id, clientTimezoneOffset) logger.info('open creations', creations) validateContribution(creations, contributionLink.amount, now, clientTimezoneOffset) - const contribution = new DbContribution() contribution.userId = user.id contribution.createdAt = now @@ -275,6 +274,7 @@ export class TransactionLinkResolver { contribution.contributionLinkId = contributionLink.id contribution.contributionType = ContributionType.LINK contribution.contributionStatus = ContributionStatus.CONFIRMED + await queryRunner.manager.insert(DbContribution, contribution) const lastTransaction = await queryRunner.manager @@ -285,6 +285,7 @@ export class TransactionLinkResolver { .orderBy('transaction.balanceDate', 'DESC') .getOne() let newBalance = new Decimal(0) + let decay: Decay | null = null if (lastTransaction) { decay = calculateDecay(lastTransaction.balance, lastTransaction.balanceDate, now) diff --git a/backend/src/graphql/resolver/TransactionResolver.ts b/backend/src/graphql/resolver/TransactionResolver.ts index 7ec925181..f2edda2c1 100644 --- a/backend/src/graphql/resolver/TransactionResolver.ts +++ b/backend/src/graphql/resolver/TransactionResolver.ts @@ -229,6 +229,7 @@ export class TransactionResolver { order, ) context.transactionCount = userTransactionsCount + // find involved users; I am involved const involvedUserIds: number[] = [user.id] userTransactions.forEach((transaction: dbTransaction) => { diff --git a/backend/src/typeorm/repository/Transaction.ts b/backend/src/typeorm/repository/Transaction.ts index affed5f42..58e9ca30b 100644 --- a/backend/src/typeorm/repository/Transaction.ts +++ b/backend/src/typeorm/repository/Transaction.ts @@ -12,29 +12,21 @@ export class TransactionRepository extends Repository { order: Order, onlyCreation?: boolean, ): Promise<[Transaction[], number]> { - if (onlyCreation) { - return this.createQueryBuilder('userTransaction') - .leftJoinAndSelect( - 'userTransaction.contribution', - 'c', - 'userTransaction.id = c.transactionId', - ) - .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', - 'c', - 'userTransaction.id = c.transactionId', + '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) diff --git a/database/entity/0036-unique_previous_in_transactions/Transaction.ts b/database/entity/0036-unique_previous_in_transactions/Transaction.ts index 99202eee4..ef8d0abdc 100644 --- a/database/entity/0036-unique_previous_in_transactions/Transaction.ts +++ b/database/entity/0036-unique_previous_in_transactions/Transaction.ts @@ -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 } diff --git a/database/entity/0052-add_updated_at_to_contributions/Transaction.ts b/database/entity/0052-add_updated_at_to_contributions/Transaction.ts deleted file mode 100644 index ef8d0abdc..000000000 --- a/database/entity/0052-add_updated_at_to_contributions/Transaction.ts +++ /dev/null @@ -1,99 +0,0 @@ -import Decimal from 'decimal.js-light' -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 { - @PrimaryGeneratedColumn('increment', { unsigned: true }) - id: number - - @Column({ name: 'user_id', unsigned: true, nullable: false }) - userId: number - - @Column({ type: 'int', unsigned: true, unique: true, nullable: true, default: null }) - previous: number | null - - @Column({ name: 'type_id', unsigned: true, nullable: false }) - typeId: number - - @Column({ - type: 'decimal', - precision: 40, - scale: 20, - nullable: false, - transformer: DecimalTransformer, - }) - amount: Decimal - - @Column({ - type: 'decimal', - precision: 40, - scale: 20, - nullable: false, - transformer: DecimalTransformer, - }) - balance: Decimal - - @Column({ - name: 'balance_date', - type: 'datetime', - default: () => 'CURRENT_TIMESTAMP', - nullable: false, - }) - balanceDate: Date - - @Column({ - type: 'decimal', - precision: 40, - scale: 20, - nullable: false, - transformer: DecimalTransformer, - }) - decay: Decimal - - @Column({ - name: 'decay_start', - type: 'datetime', - nullable: true, - default: null, - }) - decayStart: Date | null - - @Column({ length: 255, nullable: false, collation: 'utf8mb4_unicode_ci' }) - memo: string - - @Column({ name: 'creation_date', type: 'datetime', nullable: true, default: null }) - creationDate: Date | null - - @Column({ - name: 'linked_user_id', - type: 'int', - unsigned: true, - nullable: true, - default: null, - }) - linkedUserId?: number | null - - @Column({ - name: 'linked_transaction_id', - type: 'int', - unsigned: true, - nullable: true, - default: null, - }) - linkedTransactionId?: number | null - - @Column({ - name: 'transaction_link_id', - type: 'int', - unsigned: true, - nullable: true, - default: null, - }) - transactionLinkId?: number | null - - @OneToOne(() => Contribution, (contribution) => contribution.transaction) - @JoinColumn({ name: 'id', referencedColumnName: 'transactionId' }) - contribution?: Contribution | null -} diff --git a/database/entity/Transaction.ts b/database/entity/Transaction.ts index b67dfaa97..5365b0f70 100644 --- a/database/entity/Transaction.ts +++ b/database/entity/Transaction.ts @@ -1 +1 @@ -export { Transaction } from './0052-add_updated_at_to_contributions/Transaction' +export { Transaction } from './0036-unique_previous_in_transactions/Transaction' From d52f7f9590dc9c1a7b842abdab31936ecfe67828 Mon Sep 17 00:00:00 2001 From: elweyn Date: Tue, 6 Dec 2022 12:20:21 +0100 Subject: [PATCH 72/90] change transactionLinkId to linkId. --- backend/src/graphql/model/Transaction.ts | 6 +++--- .../src/components/TransactionRows/AmountAndNameRow.vue | 4 ++-- .../src/components/Transactions/TransactionCreation.vue | 4 ++-- frontend/src/components/Transactions/TransactionReceive.vue | 4 ++-- frontend/src/components/Transactions/TransactionSend.vue | 4 ++-- frontend/src/graphql/queries.js | 2 +- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/backend/src/graphql/model/Transaction.ts b/backend/src/graphql/model/Transaction.ts index 09379e0eb..a7329bcef 100644 --- a/backend/src/graphql/model/Transaction.ts +++ b/backend/src/graphql/model/Transaction.ts @@ -42,7 +42,7 @@ export class Transaction { this.creationDate = transaction.creationDate this.linkedUser = linkedUser this.linkedTransactionId = transaction.linkedTransactionId - this.transactionLinkId = transaction.contribution + this.linkId = transaction.contribution ? transaction.contribution.contributionLinkId : transaction.transactionLinkId } @@ -83,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 } diff --git a/frontend/src/components/TransactionRows/AmountAndNameRow.vue b/frontend/src/components/TransactionRows/AmountAndNameRow.vue index 96a31dcf3..eb68d9f37 100644 --- a/frontend/src/components/TransactionRows/AmountAndNameRow.vue +++ b/frontend/src/components/TransactionRows/AmountAndNameRow.vue @@ -16,7 +16,7 @@ {{ itemText }} - + {{ $t('via_link') }} @@ -82,7 +82,7 @@ export default { type: String, required: true, }, - transactionLinkId: { + linkId: { type: Number, required: false, }, diff --git a/frontend/src/components/Transactions/TransactionReceive.vue b/frontend/src/components/Transactions/TransactionReceive.vue index 8899b3807..389ac9d5d 100644 --- a/frontend/src/components/Transactions/TransactionReceive.vue +++ b/frontend/src/components/Transactions/TransactionReceive.vue @@ -17,7 +17,7 @@ v-on="$listeners" :amount="amount" :linkedUser="linkedUser" - :transactionLinkId="transactionLinkId" + :linkId="linkId" /> @@ -82,7 +82,7 @@ export default { typeId: { type: String, }, - transactionLinkId: { + linkId: { type: Number, required: false, }, diff --git a/frontend/src/components/Transactions/TransactionSend.vue b/frontend/src/components/Transactions/TransactionSend.vue index f9125b89c..c02f230e7 100644 --- a/frontend/src/components/Transactions/TransactionSend.vue +++ b/frontend/src/components/Transactions/TransactionSend.vue @@ -17,7 +17,7 @@ v-on="$listeners" :amount="amount" :linkedUser="linkedUser" - :transactionLinkId="transactionLinkId" + :linkId="linkId" /> @@ -83,7 +83,7 @@ export default { type: String, required: true, }, - transactionLinkId: { + linkId: { type: Number, required: false, }, diff --git a/frontend/src/graphql/queries.js b/frontend/src/graphql/queries.js index 1c910a23e..d261797c2 100644 --- a/frontend/src/graphql/queries.js +++ b/frontend/src/graphql/queries.js @@ -45,7 +45,7 @@ export const transactionsQuery = gql` end duration } - transactionLinkId + linkId } } } From 9604a6309a805ddc7e9fcde26347ff65d4bd9e24 Mon Sep 17 00:00:00 2001 From: joseji Date: Tue, 6 Dec 2022 22:31:15 +0100 Subject: [PATCH 73/90] found more and more junk everywhere, almost cleared --- backend/src/auth/CustomJwtPayload.ts | 2 +- backend/src/auth/JWT.ts | 2 +- backend/src/graphql/directive/isAuthorized.ts | 12 +++++------- backend/src/graphql/resolver/UserResolver.ts | 2 +- backend/src/typeorm/repository/User.ts | 15 --------------- backend/src/util/validate.ts | 2 +- 6 files changed, 9 insertions(+), 26 deletions(-) diff --git a/backend/src/auth/CustomJwtPayload.ts b/backend/src/auth/CustomJwtPayload.ts index 346ff143a..7966b413e 100644 --- a/backend/src/auth/CustomJwtPayload.ts +++ b/backend/src/auth/CustomJwtPayload.ts @@ -1,5 +1,5 @@ import { JwtPayload } from 'jsonwebtoken' export interface CustomJwtPayload extends JwtPayload { - gradidoID: Buffer + gradidoID: string } diff --git a/backend/src/auth/JWT.ts b/backend/src/auth/JWT.ts index 961274eb3..8399c881b 100644 --- a/backend/src/auth/JWT.ts +++ b/backend/src/auth/JWT.ts @@ -11,7 +11,7 @@ export const decode = (token: string): CustomJwtPayload | null => { } } -export const encode = (gradidoID: Buffer): string => { +export const encode = (gradidoID: string): string => { const token = jwt.sign({ gradidoID }, CONFIG.JWT_SECRET, { expiresIn: CONFIG.JWT_EXPIRES_IN, }) diff --git a/backend/src/graphql/directive/isAuthorized.ts b/backend/src/graphql/directive/isAuthorized.ts index c24cde47a..8840810ea 100644 --- a/backend/src/graphql/directive/isAuthorized.ts +++ b/backend/src/graphql/directive/isAuthorized.ts @@ -5,9 +5,8 @@ import { AuthChecker } from 'type-graphql' import { decode, encode } from '@/auth/JWT' import { ROLE_UNAUTHORIZED, ROLE_USER, ROLE_ADMIN } from '@/auth/ROLES' import { RIGHTS } from '@/auth/RIGHTS' -import { getCustomRepository } from '@dbTools/typeorm' -import { UserRepository } from '@repository/User' import { INALIENABLE_RIGHTS } from '@/auth/INALIENABLE_RIGHTS' +import { User } from '@entity/User' const isAuthorized: AuthChecker = async ({ context }, rights) => { context.role = ROLE_UNAUTHORIZED // unauthorized user @@ -26,14 +25,13 @@ const isAuthorized: AuthChecker = async ({ context }, rights) => { if (!decoded) { throw new Error('403.13 - Client certificate revoked') } - // Set context pubKey - context.pubKey = Buffer.from(decoded.pubKey).toString('hex') + // Set context gradidoID + context.gradidoID = decoded.gradidoID // TODO - load from database dynamically & admin - maybe encode this in the token to prevent many database requests // TODO this implementation is bullshit - two database queries cause our user identifiers are not aligned and vary between email, id and pubKey - const userRepository = getCustomRepository(UserRepository) try { - const user = await userRepository.findByPubkeyHex(context.pubKey) + const user = await User.findOneOrFail({ where: { gradidoID: decoded.gradidoID } }) context.user = user context.role = user.isAdmin ? ROLE_ADMIN : ROLE_USER } catch { @@ -48,7 +46,7 @@ const isAuthorized: AuthChecker = async ({ context }, rights) => { } // set new header token - context.setHeaders.push({ key: 'token', value: encode(decoded.pubKey) }) + context.setHeaders.push({ key: 'token', value: encode(decoded.gradidoID) }) return true } diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index c1c4903f8..a4aba1e3c 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -243,7 +243,7 @@ export class UserResolver { context.setHeaders.push({ key: 'token', - value: encode(Buffer.from(dbUser.gradidoID)), + value: encode(dbUser.gradidoID), }) const ev = new EventLogin() ev.userId = user.id diff --git a/backend/src/typeorm/repository/User.ts b/backend/src/typeorm/repository/User.ts index c20ef85ff..4972aa9c4 100644 --- a/backend/src/typeorm/repository/User.ts +++ b/backend/src/typeorm/repository/User.ts @@ -4,21 +4,6 @@ import { User as DbUser } from '@entity/User' @EntityRepository(DbUser) export class UserRepository extends Repository { - async findByPubkeyHex(pubkeyHex: string): Promise { - const dbUser = await this.createQueryBuilder('user') - .leftJoinAndSelect('user.emailContact', 'emailContact') - .where('hex(user.pubKey) = :pubkeyHex', { pubkeyHex }) - .getOneOrFail() - /* - const dbUser = await this.findOneOrFail(`hex(user.pubKey) = { pubkeyHex }`) - const emailContact = await this.query( - `SELECT * from user_contacts where id = { dbUser.emailId }`, - ) - dbUser.emailContact = emailContact - */ - return dbUser - } - async findBySearchCriteriaPagedFiltered( select: string[], searchCriteria: string, diff --git a/backend/src/util/validate.ts b/backend/src/util/validate.ts index 837aef895..437e04189 100644 --- a/backend/src/util/validate.ts +++ b/backend/src/util/validate.ts @@ -41,4 +41,4 @@ async function calculateBalance( return { balance, lastTransactionId: lastTransaction.id, decay } } -export { isHexPublicKey, calculateBalance, isStringBoolean } +export { calculateBalance, isStringBoolean } From bd3f05d6c8c57b1dc010507f115f7e47f72ab986 Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Thu, 8 Dec 2022 19:11:01 +0100 Subject: [PATCH 74/90] Update backend/src/graphql/resolver/AdminResolver.test.ts Co-authored-by: Ulf Gebhardt --- backend/src/graphql/resolver/AdminResolver.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/graphql/resolver/AdminResolver.test.ts b/backend/src/graphql/resolver/AdminResolver.test.ts index 757f552f8..8554f990a 100644 --- a/backend/src/graphql/resolver/AdminResolver.test.ts +++ b/backend/src/graphql/resolver/AdminResolver.test.ts @@ -366,7 +366,7 @@ describe('AdminResolver', () => { expect(new Date(result.data.deleteUser)).toEqual(expect.any(Date)) }) - it('has deleted at set in users and user contacts', async () => { + it('has deleted_at set in users and user contacts', async () => { await expect( User.findOneOrFail({ where: { id: user.id }, From cbb688112f5f08ed7bb9fb38688dd2773d4b5d41 Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Thu, 8 Dec 2022 19:11:09 +0100 Subject: [PATCH 75/90] Update backend/src/graphql/resolver/AdminResolver.test.ts Co-authored-by: Ulf Gebhardt --- backend/src/graphql/resolver/AdminResolver.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/graphql/resolver/AdminResolver.test.ts b/backend/src/graphql/resolver/AdminResolver.test.ts index 8554f990a..4a55bd0ad 100644 --- a/backend/src/graphql/resolver/AdminResolver.test.ts +++ b/backend/src/graphql/resolver/AdminResolver.test.ts @@ -503,7 +503,7 @@ describe('AdminResolver', () => { ) }) - it('has deleted at set to null in users and user contacts', async () => { + 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({ From 7638d290521903a8b2eab64fcc76dc312f241f2a Mon Sep 17 00:00:00 2001 From: Ulf Gebhardt Date: Fri, 9 Dec 2022 14:09:43 +0100 Subject: [PATCH 76/90] merge --- .../resolver/ContributionMessageResolver.ts | 13 +++--- .../resolver/ContributionResolver.test.ts | 44 ++++++++++++------- .../graphql/resolver/ContributionResolver.ts | 24 +++++----- backend/src/graphql/resolver/UserResolver.ts | 9 ++-- 4 files changed, 50 insertions(+), 40 deletions(-) diff --git a/backend/src/graphql/resolver/ContributionMessageResolver.ts b/backend/src/graphql/resolver/ContributionMessageResolver.ts index 1f47a14d6..38bea804e 100644 --- a/backend/src/graphql/resolver/ContributionMessageResolver.ts +++ b/backend/src/graphql/resolver/ContributionMessageResolver.ts @@ -15,8 +15,7 @@ import Paginated from '@arg/Paginated' import { backendLogger as logger } from '@/server/logger' import { RIGHTS } from '@/auth/RIGHTS' import { Context, getUser } from '@/server/context' -import { sendAddedContributionMessageEmail } from '@/mailer/sendAddedContributionMessageEmail' -import CONFIG from '@/config' +import { sendAddedContributionMessageEmail } from '@/emails/sendEmailVariants' @Resolver() export class ContributionMessageResolver { @@ -139,15 +138,13 @@ export class ContributionMessageResolver { } 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) { diff --git a/backend/src/graphql/resolver/ContributionResolver.test.ts b/backend/src/graphql/resolver/ContributionResolver.test.ts index 1223ded0e..387018624 100644 --- a/backend/src/graphql/resolver/ContributionResolver.test.ts +++ b/backend/src/graphql/resolver/ContributionResolver.test.ts @@ -21,7 +21,11 @@ import { listContributions, listUnconfirmedContributions, } from '@/seeds/graphql/queries' -import { sendContributionConfirmedEmail } from '@/mailer/sendContributionConfirmedEmail' +import { + // sendAccountActivationEmail, + sendContributionConfirmedEmail, + // sendContributionRejectedEmail, +} from '@/emails/sendEmailVariants' import { cleanDB, resetToken, testEnvironment, contributionDateFormatter } from '@test/helpers' import { GraphQLError } from 'graphql' import { userFactory } from '@/seeds/factory/user' @@ -33,13 +37,22 @@ import { Contribution } from '@entity/Contribution' import { Transaction as DbTransaction } from '@entity/Transaction' import { User } from '@entity/User' import { EventProtocolType } from '@/event/EventProtocolType' -import { logger } from '@test/testSetup' +import { logger, i18n as localization } from '@test/testSetup' // mock account activation email to avoid console spam -jest.mock('@/mailer/sendContributionConfirmedEmail', () => { +// mock account activation email to avoid console spam +jest.mock('@/emails/sendEmailVariants', () => { + const originalModule = jest.requireActual('@/emails/sendEmailVariants') 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)), } }) @@ -50,7 +63,7 @@ let admin: User let result: any beforeAll(async () => { - testEnv = await testEnvironment() + testEnv = await testEnvironment(logger, localization) mutate = testEnv.mutate query = testEnv.query con = testEnv.con @@ -1903,17 +1916,16 @@ describe('ContributionResolver', () => { }) 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 () => { diff --git a/backend/src/graphql/resolver/ContributionResolver.ts b/backend/src/graphql/resolver/ContributionResolver.ts index d3e72c2ff..32c72b9b1 100644 --- a/backend/src/graphql/resolver/ContributionResolver.ts +++ b/backend/src/graphql/resolver/ContributionResolver.ts @@ -45,10 +45,11 @@ import { EventAdminContributionUpdate, } from '@/event/Event' import { eventProtocol } from '@/event/EventProtocolEmitter' -import CONFIG from '@/config' -import { sendContributionRejectedEmail } from '@/mailer/sendContributionRejectedEmail' import { calculateDecay } from '@/util/decay' -import { sendContributionConfirmedEmail } from '@/mailer/sendContributionConfirmedEmail' +import { + sendContributionConfirmedEmail, + sendContributionRejectedEmail, +} from '@/emails/sendEmailVariants' @Resolver() export class ContributionResolver { @@ -533,14 +534,13 @@ export class ContributionResolver { 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 @@ -628,14 +628,14 @@ export class ContributionResolver { 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() diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index 0f89110e8..711dc48af 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -1051,17 +1051,18 @@ export class UserResolver { } 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.`) } // 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 From 052f31d8ec1cb917e9d2305c3b6ab271ef7329d3 Mon Sep 17 00:00:00 2001 From: Ulf Gebhardt Date: Fri, 9 Dec 2022 14:10:46 +0100 Subject: [PATCH 77/90] lint --- backend/src/graphql/resolver/TransactionResolver.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/backend/src/graphql/resolver/TransactionResolver.ts b/backend/src/graphql/resolver/TransactionResolver.ts index 486222477..4b5754132 100644 --- a/backend/src/graphql/resolver/TransactionResolver.ts +++ b/backend/src/graphql/resolver/TransactionResolver.ts @@ -1,7 +1,6 @@ /* eslint-disable new-cap */ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -<<<<<<< HEAD import Decimal from 'decimal.js-light' import { Resolver, Query, Args, Authorized, Ctx, Mutation } from 'type-graphql' import { getCustomRepository, getConnection, In } from '@dbTools/typeorm' @@ -21,7 +20,6 @@ import TransactionSendArgs from '@arg/TransactionSendArgs' import Paginated from '@arg/Paginated' import { backendLogger as logger } from '@/server/logger' -import CONFIG from '@/config' import { Context, getUser } from '@/server/context' import { calculateBalance, isHexPublicKey } from '@/util/validate' import { RIGHTS } from '@/auth/RIGHTS' From c449e41cfb6fe3ac86a8b0b0ddfeb24d63a41c09 Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Mon, 12 Dec 2022 12:45:17 +0100 Subject: [PATCH 78/90] update only by SQL, thanks @ulfgebhardt --- .../0055-consistent_deleted_users.ts | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/database/migrations/0055-consistent_deleted_users.ts b/database/migrations/0055-consistent_deleted_users.ts index e4f2df87f..561e0541a 100644 --- a/database/migrations/0055-consistent_deleted_users.ts +++ b/database/migrations/0055-consistent_deleted_users.ts @@ -4,21 +4,11 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ export async function upgrade(queryFn: (query: string, values?: any[]) => Promise>) { - const contactsToFix = await queryFn(` - SELECT user_contacts.id, users.deleted_at - FROM user_contacts JOIN users ON users.email_id = user_contacts.id + 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 user_id IN (SELECT id FROM users WHERE deleted_at IS NOT NULL);`) - - for (let i = 0; i < contactsToFix.length; i++) { - const deletedAt = new Date(contactsToFix[i].deleted_at) - .toISOString() - .slice(0, 19) - .replace('T', ' ') - - await queryFn(` - UPDATE user_contacts SET deleted_at = '${deletedAt}' WHERE id = ${contactsToFix[i].id};`) - } + AND users.deleted_at IS NOT NULL;`) } /* eslint-disable @typescript-eslint/no-empty-function */ From 02656ee117be29f2486bb4f2337f2d23a6154d10 Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Tue, 13 Dec 2022 15:49:41 +0100 Subject: [PATCH 79/90] fix tests after merge --- .../graphql/resolver/EmailOptinCodes.test.ts | 1 + .../src/graphql/resolver/UserResolver.test.ts | 24 +++++++++---------- backend/src/graphql/resolver/UserResolver.ts | 2 +- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/backend/src/graphql/resolver/EmailOptinCodes.test.ts b/backend/src/graphql/resolver/EmailOptinCodes.test.ts index 1cf22850d..d7c0b9bd6 100644 --- a/backend/src/graphql/resolver/EmailOptinCodes.test.ts +++ b/backend/src/graphql/resolver/EmailOptinCodes.test.ts @@ -13,6 +13,7 @@ let testEnv: any CONFIG.EMAIL_CODE_VALID_TIME = 1440 CONFIG.EMAIL_CODE_REQUEST_TIME = 10 +CONFIG.EMAIL = false beforeAll(async () => { testEnv = await testEnvironment() diff --git a/backend/src/graphql/resolver/UserResolver.test.ts b/backend/src/graphql/resolver/UserResolver.test.ts index c6535ba2b..053905012 100644 --- a/backend/src/graphql/resolver/UserResolver.test.ts +++ b/backend/src/graphql/resolver/UserResolver.test.ts @@ -25,7 +25,6 @@ import { sendAccountMultiRegistrationEmail, sendResetPasswordEmail, } from '@/emails/sendEmailVariants' -import { activationLink } from './UserResolver' import { contributionLinkFactory } from '@/seeds/factory/contributionLink' import { transactionLinkFactory } from '@/seeds/factory/transactionLink' import { ContributionLink } from '@model/ContributionLink' @@ -844,17 +843,18 @@ describe('UserResolver', () => { ) }) - it('sends reset password email', () => { - expect(sendResetPasswordEmail).toBeCalledWith({ - firstName: 'Bibi', - lastName: 'Bloxberg', - email: 'bibi@bloxberg.de', - language: 'de', - resetLink: activationLink(emailContact.emailVerificationCode), - timeDurationObject: expect.objectContaining({ - hours: expect.any(Number), - minutes: expect.any(Number), - }), + 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), + }), + }) }) }) diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index eebff8344..ed10bb803 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -496,7 +496,7 @@ export class UserResolver { lastName: user.lastName, email, language: user.language, - resetLink: activationLink(dbUserContact.emailVerificationCode), + resetLink: activationLink(user.emailContact.emailVerificationCode), timeDurationObject: getTimeDurationObject(CONFIG.EMAIL_CODE_VALID_TIME), }) From 7b8d4e5c85e131c0e1bedac329a477916ee5e6f3 Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Tue, 13 Dec 2022 20:54:07 +0100 Subject: [PATCH 80/90] update database version --- .../User.ts | 0 .../UserContact.ts | 0 database/entity/User.ts | 2 +- database/entity/UserContact.ts | 2 +- ...ear_old_password_junk.ts => 0057-clear_old_password_junk.ts} | 0 5 files changed, 2 insertions(+), 2 deletions(-) rename database/entity/{0055-clear_old_password_junk => 0057-clear_old_password_junk}/User.ts (100%) rename database/entity/{0055-clear_old_password_junk => 0057-clear_old_password_junk}/UserContact.ts (100%) rename database/migrations/{0055-clear_old_password_junk.ts => 0057-clear_old_password_junk.ts} (100%) diff --git a/database/entity/0055-clear_old_password_junk/User.ts b/database/entity/0057-clear_old_password_junk/User.ts similarity index 100% rename from database/entity/0055-clear_old_password_junk/User.ts rename to database/entity/0057-clear_old_password_junk/User.ts diff --git a/database/entity/0055-clear_old_password_junk/UserContact.ts b/database/entity/0057-clear_old_password_junk/UserContact.ts similarity index 100% rename from database/entity/0055-clear_old_password_junk/UserContact.ts rename to database/entity/0057-clear_old_password_junk/UserContact.ts diff --git a/database/entity/User.ts b/database/entity/User.ts index 07c0ef335..5cffc688e 100644 --- a/database/entity/User.ts +++ b/database/entity/User.ts @@ -1 +1 @@ -export { User } from './0055-clear_old_password_junk/User' +export { User } from './0057-clear_old_password_junk/User' diff --git a/database/entity/UserContact.ts b/database/entity/UserContact.ts index 5c923c92b..17d4575b0 100644 --- a/database/entity/UserContact.ts +++ b/database/entity/UserContact.ts @@ -1 +1 @@ -export { UserContact } from './0055-clear_old_password_junk/UserContact' +export { UserContact } from './0057-clear_old_password_junk/UserContact' diff --git a/database/migrations/0055-clear_old_password_junk.ts b/database/migrations/0057-clear_old_password_junk.ts similarity index 100% rename from database/migrations/0055-clear_old_password_junk.ts rename to database/migrations/0057-clear_old_password_junk.ts From b52c62749c9f2dd812f457d0e61658fcf600d94e Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Tue, 13 Dec 2022 21:07:12 +0100 Subject: [PATCH 81/90] include user contact in user context object --- backend/src/graphql/directive/isAuthorized.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/src/graphql/directive/isAuthorized.ts b/backend/src/graphql/directive/isAuthorized.ts index 8840810ea..2843225ae 100644 --- a/backend/src/graphql/directive/isAuthorized.ts +++ b/backend/src/graphql/directive/isAuthorized.ts @@ -31,7 +31,10 @@ const isAuthorized: AuthChecker = async ({ context }, rights) => { // TODO - load from database dynamically & admin - maybe encode this in the token to prevent many database requests // TODO this implementation is bullshit - two database queries cause our user identifiers are not aligned and vary between email, id and pubKey try { - const user = await User.findOneOrFail({ where: { gradidoID: decoded.gradidoID } }) + const user = await User.findOneOrFail({ + where: { gradidoID: decoded.gradidoID }, + relations: ['emailContact'], + }) context.user = user context.role = user.isAdmin ? ROLE_ADMIN : ROLE_USER } catch { From 9b97b6c40ec108bbc2d3129f2c8f736f2a0e5e90 Mon Sep 17 00:00:00 2001 From: joseji Date: Tue, 13 Dec 2022 21:16:57 +0100 Subject: [PATCH 82/90] removed text files regarding passphrase --- backend/src/config/mnemonic.english.txt | 2048 ----------------- .../mnemonic.uncompressed_buffer13116.txt | 1 - 2 files changed, 2049 deletions(-) delete mode 100644 backend/src/config/mnemonic.english.txt delete mode 100644 backend/src/config/mnemonic.uncompressed_buffer13116.txt diff --git a/backend/src/config/mnemonic.english.txt b/backend/src/config/mnemonic.english.txt deleted file mode 100644 index 942040ed5..000000000 --- a/backend/src/config/mnemonic.english.txt +++ /dev/null @@ -1,2048 +0,0 @@ -abandon -ability -able -about -above -absent -absorb -abstract -absurd -abuse -access -accident -account -accuse -achieve -acid -acoustic -acquire -across -act -action -actor -actress -actual -adapt -add -addict -address -adjust -admit -adult -advance -advice -aerobic -affair -afford -afraid -again -age -agent -agree -ahead -aim -air -airport -aisle -alarm -album -alcohol -alert -alien -all -alley -allow -almost -alone -alpha -already -also -alter -always -amateur -amazing -among -amount -amused -analyst -anchor -ancient -anger -angle -angry -animal -ankle -announce -annual -another -answer -antenna -antique -anxiety -any -apart -apology -appear -apple -approve -april -arch -arctic -area -arena -argue -arm -armed -armor -army -around -arrange -arrest -arrive -arrow -art -artefact -artist -artwork -ask -aspect -assault -asset -assist -assume -asthma -athlete -atom -attack -attend -attitude -attract -auction -audit -august -aunt -author -auto -autumn -average -avocado -avoid -awake -aware -away -awesome -awful -awkward -axis -baby -bachelor -bacon -badge -bag -balance -balcony -ball -bamboo -banana -banner -bar -barely -bargain -barrel -base -basic -basket -battle -beach -bean -beauty -because -become -beef -before -begin -behave -behind -believe -below -belt -bench -benefit -best -betray -better -between -beyond -bicycle -bid -bike -bind -biology -bird -birth -bitter -black -blade -blame -blanket -blast -bleak -bless -blind -blood -blossom -blouse -blue -blur -blush -board -boat -body -boil -bomb -bone -bonus -book -boost -border -boring -borrow -boss -bottom -bounce -box -boy -bracket -brain -brand -brass -brave -bread -breeze -brick -bridge -brief -bright -bring -brisk -broccoli -broken -bronze -broom -brother -brown -brush -bubble -buddy -budget -buffalo -build -bulb -bulk -bullet -bundle -bunker -burden -burger -burst -bus -business -busy -butter -buyer -buzz -cabbage -cabin -cable -cactus -cage -cake -call -calm -camera -camp -can -canal -cancel -candy -cannon -canoe -canvas -canyon -capable -capital -captain -car -carbon -card -cargo -carpet -carry -cart -case -cash -casino -castle -casual -cat -catalog -catch -category -cattle -caught -cause -caution -cave -ceiling -celery -cement -census -century -cereal -certain -chair -chalk -champion -change -chaos -chapter -charge -chase -chat -cheap -check -cheese -chef -cherry -chest -chicken -chief -child -chimney -choice -choose -chronic -chuckle -chunk -churn -cigar -cinnamon -circle -citizen -city -civil -claim -clap -clarify -claw -clay -clean -clerk -clever -click -client -cliff -climb -clinic -clip -clock -clog -close -cloth -cloud -clown -club -clump -cluster -clutch -coach -coast -coconut -code -coffee -coil -coin -collect -color -column -combine -come -comfort -comic -common -company -concert -conduct -confirm -congress -connect -consider -control -convince -cook -cool -copper -copy -coral -core -corn -correct -cost -cotton -couch -country -couple -course -cousin -cover -coyote -crack -cradle -craft -cram -crane -crash -crater -crawl -crazy -cream -credit -creek -crew -cricket -crime -crisp -critic -crop -cross -crouch -crowd -crucial -cruel -cruise -crumble -crunch -crush -cry -crystal -cube -culture -cup -cupboard -curious -current -curtain -curve -cushion -custom -cute -cycle -dad -damage -damp -dance -danger -daring -dash -daughter -dawn -day -deal -debate -debris -decade -december -decide -decline -decorate -decrease -deer -defense -define -defy -degree -delay -deliver -demand -demise -denial -dentist -deny -depart -depend -deposit -depth -deputy -derive -describe -desert -design -desk -despair -destroy -detail -detect -develop -device -devote -diagram -dial -diamond -diary -dice -diesel -diet -differ -digital -dignity -dilemma -dinner -dinosaur -direct -dirt -disagree -discover -disease -dish -dismiss -disorder -display -distance -divert -divide -divorce -dizzy -doctor -document -dog -doll -dolphin -domain -donate -donkey -donor -door -dose -double -dove -draft -dragon -drama -drastic -draw -dream -dress -drift -drill -drink -drip -drive -drop -drum -dry -duck -dumb -dune -during -dust -dutch -duty -dwarf -dynamic -eager -eagle -early -earn -earth -easily -east -easy -echo -ecology -economy -edge -edit -educate -effort -egg -eight -either -elbow -elder -electric -elegant -element -elephant -elevator -elite -else -embark -embody -embrace -emerge -emotion -employ -empower -empty -enable -enact -end -endless -endorse -enemy -energy -enforce -engage -engine -enhance -enjoy -enlist -enough -enrich -enroll -ensure -enter -entire -entry -envelope -episode -equal -equip -era -erase -erode -erosion -error -erupt -escape -essay -essence -estate -eternal -ethics -evidence -evil -evoke -evolve -exact -example -excess -exchange -excite -exclude -excuse -execute -exercise -exhaust -exhibit -exile -exist -exit -exotic -expand -expect -expire -explain -expose -express -extend -extra -eye -eyebrow -fabric -face -faculty -fade -faint -faith -fall -false -fame -family -famous -fan -fancy -fantasy -farm -fashion -fat -fatal -father -fatigue -fault -favorite -feature -february -federal -fee -feed -feel -female -fence -festival -fetch -fever -few -fiber -fiction -field -figure -file -film -filter -final -find -fine -finger -finish -fire -firm -first -fiscal -fish -fit -fitness -fix -flag -flame -flash -flat -flavor -flee -flight -flip -float -flock -floor -flower -fluid -flush -fly -foam -focus -fog -foil -fold -follow -food -foot -force -forest -forget -fork -fortune -forum -forward -fossil -foster -found -fox -fragile -frame -frequent -fresh -friend -fringe -frog -front -frost -frown -frozen -fruit -fuel -fun -funny -furnace -fury -future -gadget -gain -galaxy -gallery -game -gap -garage -garbage -garden -garlic -garment -gas -gasp -gate -gather -gauge -gaze -general -genius -genre -gentle -genuine -gesture -ghost -giant -gift -giggle -ginger -giraffe -girl -give -glad -glance -glare -glass -glide -glimpse -globe -gloom -glory -glove -glow -glue -goat -goddess -gold -good -goose -gorilla -gospel -gossip -govern -gown -grab -grace -grain -grant -grape -grass -gravity -great -green -grid -grief -grit -grocery -group -grow -grunt -guard -guess -guide -guilt -guitar -gun -gym -habit -hair -half -hammer -hamster -hand -happy -harbor -hard -harsh -harvest -hat -have -hawk -hazard -head -health -heart -heavy -hedgehog -height -hello -helmet -help -hen -hero -hidden -high -hill -hint -hip -hire -history -hobby -hockey -hold -hole -holiday -hollow -home -honey -hood -hope -horn -horror -horse -hospital -host -hotel -hour -hover -hub -huge -human -humble -humor -hundred -hungry -hunt -hurdle -hurry -hurt -husband -hybrid -ice -icon -idea -identify -idle -ignore -ill -illegal -illness -image -imitate -immense -immune -impact -impose -improve -impulse -inch -include -income -increase -index -indicate -indoor -industry -infant -inflict -inform -inhale -inherit -initial -inject -injury -inmate -inner -innocent -input -inquiry -insane -insect -inside -inspire -install -intact -interest -into -invest -invite -involve -iron -island -isolate -issue -item -ivory -jacket -jaguar -jar -jazz -jealous -jeans -jelly -jewel -job -join -joke -journey -joy -judge -juice -jump -jungle -junior -junk -just -kangaroo -keen -keep -ketchup -key -kick -kid -kidney -kind -kingdom -kiss -kit -kitchen -kite -kitten -kiwi -knee -knife -knock -know -lab -label -labor -ladder -lady -lake -lamp -language -laptop -large -later -latin -laugh -laundry -lava -law -lawn -lawsuit -layer -lazy -leader -leaf -learn -leave -lecture -left -leg -legal -legend -leisure -lemon -lend -length -lens -leopard -lesson -letter -level -liar -liberty -library -license -life -lift -light -like -limb -limit -link -lion -liquid -list -little -live -lizard -load -loan -lobster -local -lock -logic -lonely -long -loop -lottery -loud -lounge -love -loyal -lucky -luggage -lumber -lunar -lunch -luxury -lyrics -machine -mad -magic -magnet -maid -mail -main -major -make -mammal -man -manage -mandate -mango -mansion -manual -maple -marble -march -margin -marine -market -marriage -mask -mass -master -match -material -math -matrix -matter -maximum -maze -meadow -mean -measure -meat -mechanic -medal -media -melody -melt -member -memory -mention -menu -mercy -merge -merit -merry -mesh -message -metal -method -middle -midnight -milk -million -mimic -mind -minimum -minor -minute -miracle -mirror -misery -miss -mistake -mix -mixed -mixture -mobile -model -modify -mom -moment -monitor -monkey -monster -month -moon -moral -more -morning -mosquito -mother -motion -motor -mountain -mouse -move -movie -much -muffin -mule -multiply -muscle -museum -mushroom -music -must -mutual -myself -mystery -myth -naive -name -napkin -narrow -nasty -nation -nature -near -neck -need -negative -neglect -neither -nephew -nerve -nest -net -network -neutral -never -news -next -nice -night -noble -noise -nominee -noodle -normal -north -nose -notable -note -nothing -notice -novel -now -nuclear -number -nurse -nut -oak -obey -object -oblige -obscure -observe -obtain -obvious -occur -ocean -october -odor -off -offer -office -often -oil -okay -old -olive -olympic -omit -once -one -onion -online -only -open -opera -opinion -oppose -option -orange -orbit -orchard -order -ordinary -organ -orient -original -orphan -ostrich -other -outdoor -outer -output -outside -oval -oven -over -own -owner -oxygen -oyster -ozone -pact -paddle -page -pair -palace -palm -panda -panel -panic -panther -paper -parade -parent -park -parrot -party -pass -patch -path -patient -patrol -pattern -pause -pave -payment -peace -peanut -pear -peasant -pelican -pen -penalty -pencil -people -pepper -perfect -permit -person -pet -phone -photo -phrase -physical -piano -picnic -picture -piece -pig -pigeon -pill -pilot -pink -pioneer -pipe -pistol -pitch -pizza -place -planet -plastic -plate -play -please -pledge -pluck -plug -plunge -poem -poet -point -polar -pole -police -pond -pony -pool -popular -portion -position -possible -post -potato -pottery -poverty -powder -power -practice -praise -predict -prefer -prepare -present -pretty -prevent -price -pride -primary -print -priority -prison -private -prize -problem -process -produce -profit -program -project -promote -proof -property -prosper -protect -proud -provide -public -pudding -pull -pulp -pulse -pumpkin -punch -pupil -puppy -purchase -purity -purpose -purse -push -put -puzzle -pyramid -quality -quantum -quarter -question -quick -quit -quiz -quote -rabbit -raccoon -race -rack -radar -radio -rail -rain -raise -rally -ramp -ranch -random -range -rapid -rare -rate -rather -raven -raw -razor -ready -real -reason -rebel -rebuild -recall -receive -recipe -record -recycle -reduce -reflect -reform -refuse -region -regret -regular -reject -relax -release -relief -rely -remain -remember -remind -remove -render -renew -rent -reopen -repair -repeat -replace -report -require -rescue -resemble -resist -resource -response -result -retire -retreat -return -reunion -reveal -review -reward -rhythm -rib -ribbon -rice -rich -ride -ridge -rifle -right -rigid -ring -riot -ripple -risk -ritual -rival -river -road -roast -robot -robust -rocket -romance -roof -rookie -room -rose -rotate -rough -round -route -royal -rubber -rude -rug -rule -run -runway -rural -sad -saddle -sadness -safe -sail -salad -salmon -salon -salt -salute -same -sample -sand -satisfy -satoshi -sauce -sausage -save -say -scale -scan -scare -scatter -scene -scheme -school -science -scissors -scorpion -scout -scrap -screen -script -scrub -sea -search -season -seat -second -secret -section -security -seed -seek -segment -select -sell -seminar -senior -sense -sentence -series -service -session -settle -setup -seven -shadow -shaft -shallow -share -shed -shell -sheriff -shield -shift -shine -ship -shiver -shock -shoe -shoot -shop -short -shoulder -shove -shrimp -shrug -shuffle -shy -sibling -sick -side -siege -sight -sign -silent -silk -silly -silver -similar -simple -since -sing -siren -sister -situate -six -size -skate -sketch -ski -skill -skin -skirt -skull -slab -slam -sleep -slender -slice -slide -slight -slim -slogan -slot -slow -slush -small -smart -smile -smoke -smooth -snack -snake -snap -sniff -snow -soap -soccer -social -sock -soda -soft -solar -soldier -solid -solution -solve -someone -song -soon -sorry -sort -soul -sound -soup -source -south -space -spare -spatial -spawn -speak -special -speed -spell -spend -sphere -spice -spider -spike -spin -spirit -split -spoil -sponsor -spoon -sport -spot -spray -spread -spring -spy -square -squeeze -squirrel -stable -stadium -staff -stage -stairs -stamp -stand -start -state -stay -steak -steel -stem -step -stereo -stick -still -sting -stock -stomach -stone -stool -story -stove -strategy -street -strike -strong -struggle -student -stuff -stumble -style -subject -submit -subway -success -such -sudden -suffer -sugar -suggest -suit -summer -sun -sunny -sunset -super -supply -supreme -sure -surface -surge -surprise -surround -survey -suspect -sustain -swallow -swamp -swap -swarm -swear -sweet -swift -swim -swing -switch -sword -symbol -symptom -syrup -system -table -tackle -tag -tail -talent -talk -tank -tape -target -task -taste -tattoo -taxi -teach -team -tell -ten -tenant -tennis -tent -term -test -text -thank -that -theme -then -theory -there -they -thing -this -thought -three -thrive -throw -thumb -thunder -ticket -tide -tiger -tilt -timber -time -tiny -tip -tired -tissue -title -toast -tobacco -today -toddler -toe -together -toilet -token -tomato -tomorrow -tone -tongue -tonight -tool -tooth -top -topic -topple -torch -tornado -tortoise -toss -total -tourist -toward -tower -town -toy -track -trade -traffic -tragic -train -transfer -trap -trash -travel -tray -treat -tree -trend -trial -tribe -trick -trigger -trim -trip -trophy -trouble -truck -true -truly -trumpet -trust -truth -try -tube -tuition -tumble -tuna -tunnel -turkey -turn -turtle -twelve -twenty -twice -twin -twist -two -type -typical -ugly -umbrella -unable -unaware -uncle -uncover -under -undo -unfair -unfold -unhappy -uniform -unique -unit -universe -unknown -unlock -until -unusual -unveil -update -upgrade -uphold -upon -upper -upset -urban -urge -usage -use -used -useful -useless -usual -utility -vacant -vacuum -vague -valid -valley -valve -van -vanish -vapor -various -vast -vault -vehicle -velvet -vendor -venture -venue -verb -verify -version -very -vessel -veteran -viable -vibrant -vicious -victory -video -view -village -vintage -violin -virtual -virus -visa -visit -visual -vital -vivid -vocal -voice -void -volcano -volume -vote -voyage -wage -wagon -wait -walk -wall -walnut -want -warfare -warm -warrior -wash -wasp -waste -water -wave -way -wealth -weapon -wear -weasel -weather -web -wedding -weekend -weird -welcome -west -wet -whale -what -wheat -wheel -when -where -whip -whisper -wide -width -wife -wild -will -win -window -wine -wing -wink -winner -winter -wire -wisdom -wise -wish -witness -wolf -woman -wonder -wood -wool -word -work -world -worry -worth -wrap -wreck -wrestle -wrist -write -wrong -yard -year -yellow -you -young -youth -zebra -zero -zone -zoo diff --git a/backend/src/config/mnemonic.uncompressed_buffer13116.txt b/backend/src/config/mnemonic.uncompressed_buffer13116.txt deleted file mode 100644 index 8eceb1e2f..000000000 --- a/backend/src/config/mnemonic.uncompressed_buffer13116.txt +++ /dev/null @@ -1 +0,0 @@ -abandon,ability,able,about,above,absent,absorb,abstract,absurd,abuse,access,accident,account,accuse,achieve,acid,acoustic,acquire,across,act,action,actor,actress,actual,adapt,add,addict,address,adjust,admit,adult,advance,advice,aerobic,affair,afford,afraid,again,age,agent,agree,ahead,aim,air,airport,aisle,alarm,album,alcohol,alert,alien,all,alley,allow,almost,alone,alpha,already,also,alter,always,amateur,amazing,among,amount,amused,analyst,anchor,ancient,anger,angle,angry,animal,ankle,announce,annual,another,answer,antenna,antique,anxiety,any,apart,apology,appear,apple,approve,april,arch,arctic,area,arena,argue,arm,armed,armor,army,around,arrange,arrest,arrive,arrow,art,artefact,artist,artwork,ask,aspect,assault,asset,assist,assume,asthma,athlete,atom,attack,attend,attitude,attract,auction,audit,august,aunt,author,auto,autumn,average,avocado,avoid,awake,aware,away,awesome,awful,awkward,axis,baby,bachelor,bacon,badge,bag,balance,balcony,ball,bamboo,banana,banner,bar,barely,bargain,barrel,base,basic,basket,battle,beach,bean,beauty,because,become,beef,before,begin,behave,behind,believe,below,belt,bench,benefit,best,betray,better,between,beyond,bicycle,bid,bike,bind,biology,bird,birth,bitter,black,blade,blame,blanket,blast,bleak,bless,blind,blood,blossom,blouse,blue,blur,blush,board,boat,body,boil,bomb,bone,bonus,book,boost,border,boring,borrow,boss,bottom,bounce,box,boy,bracket,brain,brand,brass,brave,bread,breeze,brick,bridge,brief,bright,bring,brisk,broccoli,broken,bronze,broom,brother,brown,brush,bubble,buddy,budget,buffalo,build,bulb,bulk,bullet,bundle,bunker,burden,burger,burst,bus,business,busy,butter,buyer,buzz,cabbage,cabin,cable,cactus,cage,cake,call,calm,camera,camp,can,canal,cancel,candy,cannon,canoe,canvas,canyon,capable,capital,captain,car,carbon,card,cargo,carpet,carry,cart,case,cash,casino,castle,casual,cat,catalog,catch,category,cattle,caught,cause,caution,cave,ceiling,celery,cement,census,century,cereal,certain,chair,chalk,champion,change,chaos,chapter,charge,chase,chat,cheap,check,cheese,chef,cherry,chest,chicken,chief,child,chimney,choice,choose,chronic,chuckle,chunk,churn,cigar,cinnamon,circle,citizen,city,civil,claim,clap,clarify,claw,clay,clean,clerk,clever,click,client,cliff,climb,clinic,clip,clock,clog,close,cloth,cloud,clown,club,clump,cluster,clutch,coach,coast,coconut,code,coffee,coil,coin,collect,color,column,combine,come,comfort,comic,common,company,concert,conduct,confirm,congress,connect,consider,control,convince,cook,cool,copper,copy,coral,core,corn,correct,cost,cotton,couch,country,couple,course,cousin,cover,coyote,crack,cradle,craft,cram,crane,crash,crater,crawl,crazy,cream,credit,creek,crew,cricket,crime,crisp,critic,crop,cross,crouch,crowd,crucial,cruel,cruise,crumble,crunch,crush,cry,crystal,cube,culture,cup,cupboard,curious,current,curtain,curve,cushion,custom,cute,cycle,dad,damage,damp,dance,danger,daring,dash,daughter,dawn,day,deal,debate,debris,decade,december,decide,decline,decorate,decrease,deer,defense,define,defy,degree,delay,deliver,demand,demise,denial,dentist,deny,depart,depend,deposit,depth,deputy,derive,describe,desert,design,desk,despair,destroy,detail,detect,develop,device,devote,diagram,dial,diamond,diary,dice,diesel,diet,differ,digital,dignity,dilemma,dinner,dinosaur,direct,dirt,disagree,discover,disease,dish,dismiss,disorder,display,distance,divert,divide,divorce,dizzy,doctor,document,dog,doll,dolphin,domain,donate,donkey,donor,door,dose,double,dove,draft,dragon,drama,drastic,draw,dream,dress,drift,drill,drink,drip,drive,drop,drum,dry,duck,dumb,dune,during,dust,dutch,duty,dwarf,dynamic,eager,eagle,early,earn,earth,easily,east,easy,echo,ecology,economy,edge,edit,educate,effort,egg,eight,either,elbow,elder,electric,elegant,element,elephant,elevator,elite,else,embark,embody,embrace,emerge,emotion,employ,empower,empty,enable,enact,end,endless,endorse,enemy,energy,enforce,engage,engine,enhance,enjoy,enlist,enough,enrich,enroll,ensure,enter,entire,entry,envelope,episode,equal,equip,era,erase,erode,erosion,error,erupt,escape,essay,essence,estate,eternal,ethics,evidence,evil,evoke,evolve,exact,example,excess,exchange,excite,exclude,excuse,execute,exercise,exhaust,exhibit,exile,exist,exit,exotic,expand,expect,expire,explain,expose,express,extend,extra,eye,eyebrow,fabric,face,faculty,fade,faint,faith,fall,false,fame,family,famous,fan,fancy,fantasy,farm,fashion,fat,fatal,father,fatigue,fault,favorite,feature,february,federal,fee,feed,feel,female,fence,festival,fetch,fever,few,fiber,fiction,field,figure,file,film,filter,final,find,fine,finger,finish,fire,firm,first,fiscal,fish,fit,fitness,fix,flag,flame,flash,flat,flavor,flee,flight,flip,float,flock,floor,flower,fluid,flush,fly,foam,focus,fog,foil,fold,follow,food,foot,force,forest,forget,fork,fortune,forum,forward,fossil,foster,found,fox,fragile,frame,frequent,fresh,friend,fringe,frog,front,frost,frown,frozen,fruit,fuel,fun,funny,furnace,fury,future,gadget,gain,galaxy,gallery,game,gap,garage,garbage,garden,garlic,garment,gas,gasp,gate,gather,gauge,gaze,general,genius,genre,gentle,genuine,gesture,ghost,giant,gift,giggle,ginger,giraffe,girl,give,glad,glance,glare,glass,glide,glimpse,globe,gloom,glory,glove,glow,glue,goat,goddess,gold,good,goose,gorilla,gospel,gossip,govern,gown,grab,grace,grain,grant,grape,grass,gravity,great,green,grid,grief,grit,grocery,group,grow,grunt,guard,guess,guide,guilt,guitar,gun,gym,habit,hair,half,hammer,hamster,hand,happy,harbor,hard,harsh,harvest,hat,have,hawk,hazard,head,health,heart,heavy,hedgehog,height,hello,helmet,help,hen,hero,hidden,high,hill,hint,hip,hire,history,hobby,hockey,hold,hole,holiday,hollow,home,honey,hood,hope,horn,horror,horse,hospital,host,hotel,hour,hover,hub,huge,human,humble,humor,hundred,hungry,hunt,hurdle,hurry,hurt,husband,hybrid,ice,icon,idea,identify,idle,ignore,ill,illegal,illness,image,imitate,immense,immune,impact,impose,improve,impulse,inch,include,income,increase,index,indicate,indoor,industry,infant,inflict,inform,inhale,inherit,initial,inject,injury,inmate,inner,innocent,input,inquiry,insane,insect,inside,inspire,install,intact,interest,into,invest,invite,involve,iron,island,isolate,issue,item,ivory,jacket,jaguar,jar,jazz,jealous,jeans,jelly,jewel,job,join,joke,journey,joy,judge,juice,jump,jungle,junior,junk,just,kangaroo,keen,keep,ketchup,key,kick,kid,kidney,kind,kingdom,kiss,kit,kitchen,kite,kitten,kiwi,knee,knife,knock,know,lab,label,labor,ladder,lady,lake,lamp,language,laptop,large,later,latin,laugh,laundry,lava,law,lawn,lawsuit,layer,lazy,leader,leaf,learn,leave,lecture,left,leg,legal,legend,leisure,lemon,lend,length,lens,leopard,lesson,letter,level,liar,liberty,library,license,life,lift,light,like,limb,limit,link,lion,liquid,list,little,live,lizard,load,loan,lobster,local,lock,logic,lonely,long,loop,lottery,loud,lounge,love,loyal,lucky,luggage,lumber,lunar,lunch,luxury,lyrics,machine,mad,magic,magnet,maid,mail,main,major,make,mammal,man,manage,mandate,mango,mansion,manual,maple,marble,march,margin,marine,market,marriage,mask,mass,master,match,material,math,matrix,matter,maximum,maze,meadow,mean,measure,meat,mechanic,medal,media,melody,melt,member,memory,mention,menu,mercy,merge,merit,merry,mesh,message,metal,method,middle,midnight,milk,million,mimic,mind,minimum,minor,minute,miracle,mirror,misery,miss,mistake,mix,mixed,mixture,mobile,model,modify,mom,moment,monitor,monkey,monster,month,moon,moral,more,morning,mosquito,mother,motion,motor,mountain,mouse,move,movie,much,muffin,mule,multiply,muscle,museum,mushroom,music,must,mutual,myself,mystery,myth,naive,name,napkin,narrow,nasty,nation,nature,near,neck,need,negative,neglect,neither,nephew,nerve,nest,net,network,neutral,never,news,next,nice,night,noble,noise,nominee,noodle,normal,north,nose,notable,note,nothing,notice,novel,now,nuclear,number,nurse,nut,oak,obey,object,oblige,obscure,observe,obtain,obvious,occur,ocean,october,odor,off,offer,office,often,oil,okay,old,olive,olympic,omit,once,one,onion,online,only,open,opera,opinion,oppose,option,orange,orbit,orchard,order,ordinary,organ,orient,original,orphan,ostrich,other,outdoor,outer,output,outside,oval,oven,over,own,owner,oxygen,oyster,ozone,pact,paddle,page,pair,palace,palm,panda,panel,panic,panther,paper,parade,parent,park,parrot,party,pass,patch,path,patient,patrol,pattern,pause,pave,payment,peace,peanut,pear,peasant,pelican,pen,penalty,pencil,people,pepper,perfect,permit,person,pet,phone,photo,phrase,physical,piano,picnic,picture,piece,pig,pigeon,pill,pilot,pink,pioneer,pipe,pistol,pitch,pizza,place,planet,plastic,plate,play,please,pledge,pluck,plug,plunge,poem,poet,point,polar,pole,police,pond,pony,pool,popular,portion,position,possible,post,potato,pottery,poverty,powder,power,practice,praise,predict,prefer,prepare,present,pretty,prevent,price,pride,primary,print,priority,prison,private,prize,problem,process,produce,profit,program,project,promote,proof,property,prosper,protect,proud,provide,public,pudding,pull,pulp,pulse,pumpkin,punch,pupil,puppy,purchase,purity,purpose,purse,push,put,puzzle,pyramid,quality,quantum,quarter,question,quick,quit,quiz,quote,rabbit,raccoon,race,rack,radar,radio,rail,rain,raise,rally,ramp,ranch,random,range,rapid,rare,rate,rather,raven,raw,razor,ready,real,reason,rebel,rebuild,recall,receive,recipe,record,recycle,reduce,reflect,reform,refuse,region,regret,regular,reject,relax,release,relief,rely,remain,remember,remind,remove,render,renew,rent,reopen,repair,repeat,replace,report,require,rescue,resemble,resist,resource,response,result,retire,retreat,return,reunion,reveal,review,reward,rhythm,rib,ribbon,rice,rich,ride,ridge,rifle,right,rigid,ring,riot,ripple,risk,ritual,rival,river,road,roast,robot,robust,rocket,romance,roof,rookie,room,rose,rotate,rough,round,route,royal,rubber,rude,rug,rule,run,runway,rural,sad,saddle,sadness,safe,sail,salad,salmon,salon,salt,salute,same,sample,sand,satisfy,satoshi,sauce,sausage,save,say,scale,scan,scare,scatter,scene,scheme,school,science,scissors,scorpion,scout,scrap,screen,script,scrub,sea,search,season,seat,second,secret,section,security,seed,seek,segment,select,sell,seminar,senior,sense,sentence,series,service,session,settle,setup,seven,shadow,shaft,shallow,share,shed,shell,sheriff,shield,shift,shine,ship,shiver,shock,shoe,shoot,shop,short,shoulder,shove,shrimp,shrug,shuffle,shy,sibling,sick,side,siege,sight,sign,silent,silk,silly,silver,similar,simple,since,sing,siren,sister,situate,six,size,skate,sketch,ski,skill,skin,skirt,skull,slab,slam,sleep,slender,slice,slide,slight,slim,slogan,slot,slow,slush,small,smart,smile,smoke,smooth,snack,snake,snap,sniff,snow,soap,soccer,social,sock,soda,soft,solar,soldier,solid,solution,solve,someone,song,soon,sorry,sort,soul,sound,soup,source,south,space,spare,spatial,spawn,speak,special,speed,spell,spend,sphere,spice,spider,spike,spin,spirit,split,spoil,sponsor,spoon,sport,spot,spray,spread,spring,spy,square,squeeze,squirrel,stable,stadium,staff,stage,stairs,stamp,stand,start,state,stay,steak,steel,stem,step,stereo,stick,still,sting,stock,stomach,stone,stool,story,stove,strategy,street,strike,strong,struggle,student,stuff,stumble,style,subject,submit,subway,success,such,sudden,suffer,sugar,suggest,suit,summer,sun,sunny,sunset,super,supply,supreme,sure,surface,surge,surprise,surround,survey,suspect,sustain,swallow,swamp,swap,swarm,swear,sweet,swift,swim,swing,switch,sword,symbol,symptom,syrup,system,table,tackle,tag,tail,talent,talk,tank,tape,target,task,taste,tattoo,taxi,teach,team,tell,ten,tenant,tennis,tent,term,test,text,thank,that,theme,then,theory,there,they,thing,this,thought,three,thrive,throw,thumb,thunder,ticket,tide,tiger,tilt,timber,time,tiny,tip,tired,tissue,title,toast,tobacco,today,toddler,toe,together,toilet,token,tomato,tomorrow,tone,tongue,tonight,tool,tooth,top,topic,topple,torch,tornado,tortoise,toss,total,tourist,toward,tower,town,toy,track,trade,traffic,tragic,train,transfer,trap,trash,travel,tray,treat,tree,trend,trial,tribe,trick,trigger,trim,trip,trophy,trouble,truck,true,truly,trumpet,trust,truth,try,tube,tuition,tumble,tuna,tunnel,turkey,turn,turtle,twelve,twenty,twice,twin,twist,two,type,typical,ugly,umbrella,unable,unaware,uncle,uncover,under,undo,unfair,unfold,unhappy,uniform,unique,unit,universe,unknown,unlock,until,unusual,unveil,update,upgrade,uphold,upon,upper,upset,urban,urge,usage,use,used,useful,useless,usual,utility,vacant,vacuum,vague,valid,valley,valve,van,vanish,vapor,various,vast,vault,vehicle,velvet,vendor,venture,venue,verb,verify,version,very,vessel,veteran,viable,vibrant,vicious,victory,video,view,village,vintage,violin,virtual,virus,visa,visit,visual,vital,vivid,vocal,voice,void,volcano,volume,vote,voyage,wage,wagon,wait,walk,wall,walnut,want,warfare,warm,warrior,wash,wasp,waste,water,wave,way,wealth,weapon,wear,weasel,weather,web,wedding,weekend,weird,welcome,west,wet,whale,what,wheat,wheel,when,where,whip,whisper,wide,width,wife,wild,will,win,window,wine,wing,wink,winner,winter,wire,wisdom,wise,wish,witness,wolf,woman,wonder,wood,wool,word,work,world,worry,worth,wrap,wreck,wrestle,wrist,write,wrong,yard,year,yellow,you,young,youth,zebra,zero,zone,zoo, \ No newline at end of file From 0bcd44183741d727d7a1c16fcf7aee705e8f3737 Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Tue, 13 Dec 2022 21:26:32 +0100 Subject: [PATCH 83/90] rename migrations and entity number --- .../User.ts | 0 .../UserContact.ts | 0 database/entity/User.ts | 2 +- database/entity/UserContact.ts | 2 +- ...ear_old_password_junk.ts => 0056-clear_old_password_junk.ts} | 0 5 files changed, 2 insertions(+), 2 deletions(-) rename database/entity/{0057-clear_old_password_junk => 0056-clear_old_password_junk}/User.ts (100%) rename database/entity/{0057-clear_old_password_junk => 0056-clear_old_password_junk}/UserContact.ts (100%) rename database/migrations/{0057-clear_old_password_junk.ts => 0056-clear_old_password_junk.ts} (100%) diff --git a/database/entity/0057-clear_old_password_junk/User.ts b/database/entity/0056-clear_old_password_junk/User.ts similarity index 100% rename from database/entity/0057-clear_old_password_junk/User.ts rename to database/entity/0056-clear_old_password_junk/User.ts diff --git a/database/entity/0057-clear_old_password_junk/UserContact.ts b/database/entity/0056-clear_old_password_junk/UserContact.ts similarity index 100% rename from database/entity/0057-clear_old_password_junk/UserContact.ts rename to database/entity/0056-clear_old_password_junk/UserContact.ts diff --git a/database/entity/User.ts b/database/entity/User.ts index 5cffc688e..aa5c5fa5b 100644 --- a/database/entity/User.ts +++ b/database/entity/User.ts @@ -1 +1 @@ -export { User } from './0057-clear_old_password_junk/User' +export { User } from './0056-clear_old_password_junk/User' diff --git a/database/entity/UserContact.ts b/database/entity/UserContact.ts index 17d4575b0..1787ff011 100644 --- a/database/entity/UserContact.ts +++ b/database/entity/UserContact.ts @@ -1 +1 @@ -export { UserContact } from './0057-clear_old_password_junk/UserContact' +export { UserContact } from './0056-clear_old_password_junk/UserContact' diff --git a/database/migrations/0057-clear_old_password_junk.ts b/database/migrations/0056-clear_old_password_junk.ts similarity index 100% rename from database/migrations/0057-clear_old_password_junk.ts rename to database/migrations/0056-clear_old_password_junk.ts From 1229a7f7df0df446209a6170c8a98a5fab65cce7 Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Tue, 13 Dec 2022 22:14:06 +0100 Subject: [PATCH 84/90] change database version --- backend/src/config/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 38a4fde05..f28dc394d 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -10,7 +10,7 @@ Decimal.set({ }) const constants = { - DB_VERSION: '0057-clear_old_password_junk', + DB_VERSION: '0056-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 From 798051f360b650340e395b869309f49b2f1bd190 Mon Sep 17 00:00:00 2001 From: joseji Date: Wed, 14 Dec 2022 00:10:45 +0100 Subject: [PATCH 85/90] docker error solved --- backend/Dockerfile | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index c09e5aaf8..945f92ac1 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -108,8 +108,7 @@ 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 From 00a7ac4eb5d5f89555301caf51f33f5c6ff76c29 Mon Sep 17 00:00:00 2001 From: Ulf Gebhardt Date: Thu, 15 Dec 2022 11:18:31 +0100 Subject: [PATCH 86/90] deleted old comment --- backend/Dockerfile | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 945f92ac1..910bdd504 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -107,7 +107,6 @@ COPY --from=build ${DOCKER_WORKDIR}/package.json ./package.json 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 # Copy run scripts run/ # COPY --from=build ${DOCKER_WORKDIR}/run ./run From 97b169da2e701bf4dfed931ad3c53c1b0da2d3c3 Mon Sep 17 00:00:00 2001 From: Ulf Gebhardt Date: Thu, 15 Dec 2022 11:49:28 +0100 Subject: [PATCH 87/90] properly typecast and do thing right --- .../0056-consistent_transactions_table.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/database/migrations/0056-consistent_transactions_table.ts b/database/migrations/0056-consistent_transactions_table.ts index af7d8988e..02ed3b7be 100644 --- a/database/migrations/0056-consistent_transactions_table.ts +++ b/database/migrations/0056-consistent_transactions_table.ts @@ -3,6 +3,7 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { v4 as uuidv4 } from 'uuid' +import { OkPacket } from 'mysql' export async function upgrade(queryFn: (query: string, values?: any[]) => Promise>) { const missingUserIds = await queryFn(` @@ -10,8 +11,8 @@ export async function upgrade(queryFn: (query: string, values?: any[]) => Promis WHERE NOT EXISTS (SELECT id FROM users WHERE id = user_id) GROUP BY user_id;`) for (let i = 0; i < missingUserIds.length; i++) { - let gradidoId = null - let countIds = null + let gradidoId = '' + let countIds: any[] = [] do { gradidoId = uuidv4() countIds = await queryFn( @@ -19,19 +20,17 @@ export async function upgrade(queryFn: (query: string, values?: any[]) => Promis ) } while (countIds[0] > 0) - const userContact = await queryFn(` + const userContact = (await queryFn(` INSERT INTO user_contacts (type, user_id, email, email_checked, created_at, deleted_at) VALUES - ('EMAIL', ${missingUserIds[i].user_id}, 'deleted.user${missingUserIds[i].user_id}@gradido.net', 0, NOW(), NOW());`) - - const emaiId = Object.values(userContact)[Object.keys(userContact).indexOf('insertId')] + ('EMAIL', ${missingUserIds[i].user_id}, 'deleted.user${missingUserIds[i].user_id}@gradido.net', 0, NOW(), NOW());`)) as unknown as OkPacket await queryFn(` INSERT INTO users (id, gradido_id, email_id, first_name, last_name, deleted_at, password_encryption_type, created_at, language) VALUES - (${missingUserIds[i].user_id}, '${gradidoId}', ${emaiId}, 'DELETED', 'USER', NOW(), 0, NOW(), 'de');`) + (${missingUserIds[i].user_id}, '${gradidoId}', ${userContact.insertId}, 'DELETED', 'USER', NOW(), 0, NOW(), 'de');`) } } From 873076ebfca6bd87ed855cadb0353c811c9aff2c Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Thu, 15 Dec 2022 13:02:19 +0100 Subject: [PATCH 88/90] remove unused import --- backend/src/graphql/resolver/TransactionResolver.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/graphql/resolver/TransactionResolver.ts b/backend/src/graphql/resolver/TransactionResolver.ts index c8dd27b3e..350db0986 100644 --- a/backend/src/graphql/resolver/TransactionResolver.ts +++ b/backend/src/graphql/resolver/TransactionResolver.ts @@ -21,7 +21,7 @@ import Paginated from '@arg/Paginated' import { backendLogger as logger } from '@/server/logger' import { Context, getUser } from '@/server/context' -import { calculateBalance, isHexPublicKey } from '@/util/validate' +import { calculateBalance } from '@/util/validate' import { RIGHTS } from '@/auth/RIGHTS' import { communityUser } from '@/util/communityUser' import { virtualLinkTransaction, virtualDecayTransaction } from '@/util/virtualTransactions' From ce0e306284f117c2e2d963098aa4fc96a7889dbe Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Thu, 15 Dec 2022 13:03:23 +0100 Subject: [PATCH 89/90] remove unused import --- backend/src/graphql/resolver/UserResolver.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index 11c95c9d3..63191b6b7 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -60,7 +60,7 @@ import { } from '@/event/Event' import { getUserCreation, getUserCreations } from './util/creations' import { FULL_CREATION_AVAILABLE } from './const/const' -import { isValidPassword, SecretKeyCryptographyCreateKey } from '@/password/EncryptorUtils' +import { isValidPassword } from '@/password/EncryptorUtils' import { encryptPassword, verifyPassword } from '@/password/PasswordEncryptor' import { PasswordEncryptionType } from '../enum/PasswordEncryptionType' From 78a780568a9d3ec1b7052ce91fb11b8b9248c684 Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Thu, 15 Dec 2022 17:07:35 +0100 Subject: [PATCH 90/90] feat(release): version 1.16.0 --- CHANGELOG.md | 22 ++++++++++++++++++++++ admin/package.json | 2 +- backend/package.json | 2 +- database/package.json | 2 +- frontend/package.json | 2 +- package.json | 2 +- 6 files changed, 27 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26b71ea03..19957a309 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,30 @@ All notable changes to this project will be documented in this file. Dates are d Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). +#### [1.16.0](https://github.com/gradido/gradido/compare/1.15.0...1.16.0) + +- refactor(backend): cleaning user related old password junk [`#2426`](https://github.com/gradido/gradido/pull/2426) +- fix(database): consistent transaction table [`#2453`](https://github.com/gradido/gradido/pull/2453) +- refactor(backend): dissolve admin resolver [`#2416`](https://github.com/gradido/gradido/pull/2416) +- fix(backend): email verification code never expired [`#2418`](https://github.com/gradido/gradido/pull/2418) +- fix(database): consistent deleted at bewteen users and user contacts [`#2451`](https://github.com/gradido/gradido/pull/2451) +- feat(backend): log client timezone offset [`#2454`](https://github.com/gradido/gradido/pull/2454) +- refactor(backend): refactor more emails to translatables [`#2398`](https://github.com/gradido/gradido/pull/2398) +- fix(backend): delete / undelete email contact as well [`#2444`](https://github.com/gradido/gradido/pull/2444) +- feat(backend): 🍰 Mark creation via link [`#2363`](https://github.com/gradido/gradido/pull/2363) +- fix(backend): run all timers for high values [`#2452`](https://github.com/gradido/gradido/pull/2452) +- fix(backend): critical bug [`#2443`](https://github.com/gradido/gradido/pull/2443) +- fix(other): missing files for docker production build [`#2442`](https://github.com/gradido/gradido/pull/2442) +- fix(frontend): in contribution messages formular a message can be send twice, when clicking the submit button fast [`#2424`](https://github.com/gradido/gradido/pull/2424) +- fix(backend): wrong month for contribution near turn of month [`#2201`](https://github.com/gradido/gradido/pull/2201) +- feat(backend): add federation config properties [`#2374`](https://github.com/gradido/gradido/pull/2374) +- fix(backend): moved all jest & type-definition related packages into the `devDependencies` section [`#2385`](https://github.com/gradido/gradido/pull/2385) + #### [1.15.0](https://github.com/gradido/gradido/compare/1.14.1...1.15.0) +> 26 November 2022 + +- chore(release): v1.15.0 [`#2425`](https://github.com/gradido/gradido/pull/2425) - fix(database): wrong balance and decay values [`#2423`](https://github.com/gradido/gradido/pull/2423) - fix(backend): wrong balance after transaction receive [`#2422`](https://github.com/gradido/gradido/pull/2422) - feat(other): feature gradido roadmap [`#2301`](https://github.com/gradido/gradido/pull/2301) diff --git a/admin/package.json b/admin/package.json index 75800a526..58eb48d09 100644 --- a/admin/package.json +++ b/admin/package.json @@ -3,7 +3,7 @@ "description": "Administraion Interface for Gradido", "main": "index.js", "author": "Moriz Wahl", - "version": "1.15.0", + "version": "1.16.0", "license": "Apache-2.0", "private": false, "scripts": { diff --git a/backend/package.json b/backend/package.json index c6b3dabc2..4e34ca566 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "gradido-backend", - "version": "1.15.0", + "version": "1.16.0", "description": "Gradido unified backend providing an API-Service for Gradido Transactions", "main": "src/index.ts", "repository": "https://github.com/gradido/gradido/backend", diff --git a/database/package.json b/database/package.json index abc7789c4..0c69941b4 100644 --- a/database/package.json +++ b/database/package.json @@ -1,6 +1,6 @@ { "name": "gradido-database", - "version": "1.15.0", + "version": "1.16.0", "description": "Gradido Database Tool to execute database migrations", "main": "src/index.ts", "repository": "https://github.com/gradido/gradido/database", diff --git a/frontend/package.json b/frontend/package.json index 6f1474521..35c5437f4 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "bootstrap-vue-gradido-wallet", - "version": "1.15.0", + "version": "1.16.0", "private": true, "scripts": { "start": "node run/server.js", diff --git a/package.json b/package.json index 22f444155..a4bedfdf2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gradido", - "version": "1.15.0", + "version": "1.16.0", "description": "Gradido", "main": "index.js", "repository": "git@github.com:gradido/gradido.git",