Merge branch 'master' into jest_is_dev_depenency

This commit is contained in:
Ulf Gebhardt 2022-11-21 17:19:12 +01:00 committed by GitHub
commit 41891c1b57
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
61 changed files with 2258 additions and 262 deletions

View File

@ -4,8 +4,44 @@ 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). Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [1.14.1](https://github.com/gradido/gradido/compare/1.14.0...1.14.1)
- fix(frontend): load contributionMessages is fixed [`#2390`](https://github.com/gradido/gradido/pull/2390)
#### [1.14.0](https://github.com/gradido/gradido/compare/1.13.3...1.14.0)
> 14 November 2022
- chore(release): version 1.14.0 [`#2389`](https://github.com/gradido/gradido/pull/2389)
- fix(frontend): close all open collapse by change tabs in community [`#2388`](https://github.com/gradido/gradido/pull/2388)
- fix(backend): corrected E-Mail texts [`#2386`](https://github.com/gradido/gradido/pull/2386)
- fix(frontend): better history messages [`#2381`](https://github.com/gradido/gradido/pull/2381)
- fix(frontend): mailto link [`#2383`](https://github.com/gradido/gradido/pull/2383)
- fix(admin): fix text in admin area to uppercase [`#2365`](https://github.com/gradido/gradido/pull/2365)
- feat(frontend): move the information about gradido being free to the auth layout [`#2349`](https://github.com/gradido/gradido/pull/2349)
- fix(admin): load error fixed for contribution link [`#2364`](https://github.com/gradido/gradido/pull/2364)
- fix(admin): edit contribution link does not take old values [`#2362`](https://github.com/gradido/gradido/pull/2362)
- fix(other): corrected dockerfile descriptions [`#2346`](https://github.com/gradido/gradido/pull/2346)
- feat(backend): 🍰 Send email for rejected contributions [`#2340`](https://github.com/gradido/gradido/pull/2340)
- feat(admin): edit automatic contribution link [`#2309`](https://github.com/gradido/gradido/pull/2309)
- refactor(backend): fix logger mocks [`#2308`](https://github.com/gradido/gradido/pull/2308)
- fix(admin): update contribution list after admin updates contribution [`#2330`](https://github.com/gradido/gradido/pull/2330)
- fix(frontend): inconsistent labeling on login register [`#2350`](https://github.com/gradido/gradido/pull/2350)
- feat(backend): setup hyperswarm [`#1874`](https://github.com/gradido/gradido/pull/1874)
- feat(other): lint pull request workflow [`#2338`](https://github.com/gradido/gradido/pull/2338)
- Feature: 🍰 add updated at to contributions [`#2237`](https://github.com/gradido/gradido/pull/2237)
- Refactor: GitHub test workflow - disable video recording and reduce wait time [`#2336`](https://github.com/gradido/gradido/pull/2336)
- 2274 feature concept manuel user registration for admins [`#2289`](https://github.com/gradido/gradido/pull/2289)
- 1574 concept to introduce gradidoID and change password encryption [`#2252`](https://github.com/gradido/gradido/pull/2252)
- contributionlink stage-2 and stage-3 of capturing and activation [`#2241`](https://github.com/gradido/gradido/pull/2241)
- Github workflow: update actions to the current API version using Node v 16 [`#2323`](https://github.com/gradido/gradido/pull/2323)
- feature: Fullstack tests in GitHub workflow [`#2319`](https://github.com/gradido/gradido/pull/2319)
#### [1.13.3](https://github.com/gradido/gradido/compare/1.13.2...1.13.3) #### [1.13.3](https://github.com/gradido/gradido/compare/1.13.2...1.13.3)
> 1 November 2022
- release: Version 1.13.3 [`#2322`](https://github.com/gradido/gradido/pull/2322)
- 2294 contribution links on its own page [`#2312`](https://github.com/gradido/gradido/pull/2312) - 2294 contribution links on its own page [`#2312`](https://github.com/gradido/gradido/pull/2312)
- fix: Change Orange Color [`#2302`](https://github.com/gradido/gradido/pull/2302) - fix: Change Orange Color [`#2302`](https://github.com/gradido/gradido/pull/2302)
- fix: Release Statistic Query Runner [`#2320`](https://github.com/gradido/gradido/pull/2320) - fix: Release Statistic Query Runner [`#2320`](https://github.com/gradido/gradido/pull/2320)

View File

@ -3,7 +3,7 @@
"description": "Administraion Interface for Gradido", "description": "Administraion Interface for Gradido",
"main": "index.js", "main": "index.js",
"author": "Moriz Wahl", "author": "Moriz Wahl",
"version": "1.13.3", "version": "1.14.1",
"license": "Apache-2.0", "license": "Apache-2.0",
"private": false, "private": false,
"scripts": { "scripts": {

View File

@ -1,7 +1,15 @@
<template> <template>
<div class="mt-2"> <div class="mt-2">
<span v-for="({ type, text }, index) in linkifiedMessage" :key="index"> <span v-for="({ type, text }, index) in parsedMessage" :key="index">
<b-link v-if="type === 'link'" :href="text" target="_blank">{{ text }}</b-link> <b-link v-if="type === 'link'" :href="text" target="_blank">{{ text }}</b-link>
<span v-else-if="type === 'date'">
{{ $d(new Date(text), 'short') }}
<br />
</span>
<span v-else-if="type === 'amount'">
<br />
{{ `${$n(Number(text), 'decimal')} GDD` }}
</span>
<span v-else>{{ text }}</span> <span v-else>{{ text }}</span>
</span> </span>
</div> </div>
@ -12,17 +20,28 @@ const LINK_REGEX_PATTERN =
/(https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*))/i /(https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*))/i
export default { export default {
name: 'LinkifyMessage', name: 'ParseMessage',
props: { props: {
message: { message: {
type: String, type: String,
required: true, required: true,
}, },
type: {
type: String,
reuired: true,
},
}, },
computed: { computed: {
linkifiedMessage() { parsedMessage() {
const linkified = []
let string = this.message let string = this.message
const linkified = []
let amount
if (this.type === 'HISTORY') {
const split = string.split(/\n\s*---\n\s*/)
string = split[1]
linkified.push({ type: 'date', text: split[0].trim() })
amount = split[2].trim()
}
let match let match
while ((match = string.match(LINK_REGEX_PATTERN))) { while ((match = string.match(LINK_REGEX_PATTERN))) {
if (match.index > 0) if (match.index > 0)
@ -31,6 +50,7 @@ export default {
string = string.substring(match.index + match[0].length) string = string.substring(match.index + match[0].length)
} }
if (string.length > 0) linkified.push({ type: 'text', text: string }) if (string.length > 0) linkified.push({ type: 'text', text: string })
if (amount) linkified.push({ type: 'amount', text: amount })
return linkified return linkified
}, },
}, },

View File

@ -3,12 +3,16 @@ import ContributionMessagesListItem from './ContributionMessagesListItem.vue'
const localVue = global.localVue const localVue = global.localVue
const dateMock = jest.fn((d) => d)
const numberMock = jest.fn((n) => n)
describe('ContributionMessagesListItem', () => { describe('ContributionMessagesListItem', () => {
let wrapper let wrapper
const mocks = { const mocks = {
$t: jest.fn((t) => t), $t: jest.fn((t) => t),
$d: jest.fn((d) => d), $d: dateMock,
$n: numberMock,
} }
describe('if message author has moderator role', () => { describe('if message author has moderator role', () => {
@ -189,4 +193,64 @@ and here is the link to the repository: https://github.com/gradido/gradido`)
}) })
}) })
}) })
describe('contribution message type HISTORY', () => {
const propsData = {
message: {
id: 111,
message: `Sun Nov 13 2022 13:05:48 GMT+0100 (Central European Standard Time)
---
This message also contains a link: https://gradido.net/de/
---
350.00`,
createdAt: '2022-08-29T12:23:27.000Z',
updatedAt: null,
type: 'HISTORY',
userFirstName: 'Peter',
userLastName: 'Lustig',
userId: 107,
__typename: 'ContributionMessage',
},
}
const itemWrapper = () => {
return mount(ContributionMessagesListItem, {
localVue,
mocks,
propsData,
})
}
let messageField
describe('render HISTORY message', () => {
beforeEach(() => {
jest.clearAllMocks()
wrapper = itemWrapper()
messageField = wrapper.find('div.is-not-moderator.text-left > div:nth-child(4)')
})
it('renders the date', () => {
expect(dateMock).toBeCalledWith(
new Date('Sun Nov 13 2022 13:05:48 GMT+0100 (Central European Standard Time'),
'short',
)
})
it('renders the amount', () => {
expect(numberMock).toBeCalledWith(350, 'decimal')
expect(messageField.text()).toContain('350 GDD')
})
it('contains the link as text', () => {
expect(messageField.text()).toContain(
'This message also contains a link: https://gradido.net/de/',
)
})
it('contains a link to the given address', () => {
expect(messageField.find('a').attributes('href')).toBe('https://gradido.net/de/')
})
})
})
}) })

View File

@ -5,23 +5,23 @@
<span class="ml-2 mr-2">{{ message.userFirstName }} {{ message.userLastName }}</span> <span class="ml-2 mr-2">{{ message.userFirstName }} {{ message.userLastName }}</span>
<span class="ml-2">{{ $d(new Date(message.createdAt), 'short') }}</span> <span class="ml-2">{{ $d(new Date(message.createdAt), 'short') }}</span>
<small class="ml-4 text-success">{{ $t('moderator') }}</small> <small class="ml-4 text-success">{{ $t('moderator') }}</small>
<linkify-message :message="message.message"></linkify-message> <parse-message v-bind="message"></parse-message>
</div> </div>
<div v-else class="text-left is-not-moderator"> <div v-else class="text-left is-not-moderator">
<b-avatar variant="info"></b-avatar> <b-avatar variant="info"></b-avatar>
<span class="ml-2 mr-2">{{ message.userFirstName }} {{ message.userLastName }}</span> <span class="ml-2 mr-2">{{ message.userFirstName }} {{ message.userLastName }}</span>
<span class="ml-2">{{ $d(new Date(message.createdAt), 'short') }}</span> <span class="ml-2">{{ $d(new Date(message.createdAt), 'short') }}</span>
<linkify-message :message="message.message"></linkify-message> <parse-message v-bind="message"></parse-message>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import LinkifyMessage from '@/components/ContributionMessages/LinkifyMessage.vue' import ParseMessage from '@/components/ContributionMessages/ParseMessage.vue'
export default { export default {
name: 'ContributionMessagesListItem', name: 'ContributionMessagesListItem',
components: { components: {
LinkifyMessage, ParseMessage,
}, },
props: { props: {
message: { message: {

View File

@ -10,7 +10,7 @@ const authLink = new ApolloLink((operation, forward) => {
operation.setContext({ operation.setContext({
headers: { headers: {
Authorization: token && token.length > 0 ? `Bearer ${token}` : '', Authorization: token && token.length > 0 ? `Bearer ${token}` : '',
clientRequestTime: new Date().toString(), clientTimezoneOffset: new Date().getTimezoneOffset(),
}, },
}) })
return forward(operation).map((response) => { return forward(operation).map((response) => {

View File

@ -94,7 +94,7 @@ describe('apolloProvider', () => {
expect(setContextMock).toBeCalledWith({ expect(setContextMock).toBeCalledWith({
headers: { headers: {
Authorization: 'Bearer some-token', Authorization: 'Bearer some-token',
clientRequestTime: expect.any(String), clientTimezoneOffset: expect.any(Number),
}, },
}) })
}) })
@ -110,7 +110,7 @@ describe('apolloProvider', () => {
expect(setContextMock).toBeCalledWith({ expect(setContextMock).toBeCalledWith({
headers: { headers: {
Authorization: '', Authorization: '',
clientRequestTime: expect.any(String), clientTimezoneOffset: expect.any(Number),
}, },
}) })
}) })

View File

@ -1,7 +1,7 @@
################################################################################## ##################################################################################
# BASE ########################################################################### # BASE ###########################################################################
################################################################################## ##################################################################################
FROM node:12.19.0-alpine3.10 as base FROM node:18.7.0-alpine3.16 as base
# ENVs (available in production aswell, can be overwritten by commandline or env file) # ENVs (available in production aswell, can be overwritten by commandline or env file)
## DOCKER_WORKDIR would be a classical ARG, but that is not multi layer persistent - shame ## DOCKER_WORKDIR would be a classical ARG, but that is not multi layer persistent - shame

View File

@ -1,6 +1,6 @@
{ {
"name": "gradido-backend", "name": "gradido-backend",
"version": "1.13.3", "version": "1.14.1",
"description": "Gradido unified backend providing an API-Service for Gradido Transactions", "description": "Gradido unified backend providing an API-Service for Gradido Transactions",
"main": "src/index.ts", "main": "src/index.ts",
"repository": "https://github.com/gradido/gradido/backend", "repository": "https://github.com/gradido/gradido/backend",
@ -26,13 +26,17 @@
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"decimal.js-light": "^2.5.1", "decimal.js-light": "^2.5.1",
"dotenv": "^10.0.0", "dotenv": "^10.0.0",
"email-templates": "^10.0.1",
"express": "^4.17.1", "express": "^4.17.1",
"graphql": "^15.5.1", "graphql": "^15.5.1",
"i18n": "^0.15.1",
"jest": "^27.2.4",
"jsonwebtoken": "^8.5.1", "jsonwebtoken": "^8.5.1",
"lodash.clonedeep": "^4.5.0", "lodash.clonedeep": "^4.5.0",
"log4js": "^6.4.6", "log4js": "^6.4.6",
"mysql2": "^2.3.0", "mysql2": "^2.3.0",
"nodemailer": "^6.6.5", "nodemailer": "^6.6.5",
"pug": "^3.0.2",
"random-bigint": "^0.0.1", "random-bigint": "^0.0.1",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"sodium-native": "^3.3.0", "sodium-native": "^3.3.0",
@ -40,8 +44,10 @@
"uuid": "^8.3.2" "uuid": "^8.3.2"
}, },
"devDependencies": { "devDependencies": {
"@types/email-templates": "^10.0.1",
"@types/express": "^4.17.12", "@types/express": "^4.17.12",
"@types/faker": "^5.5.9", "@types/faker": "^5.5.9",
"@types/i18n": "^0.13.4",
"@types/jest": "^27.0.2", "@types/jest": "^27.0.2",
"@types/jsonwebtoken": "^8.5.2", "@types/jsonwebtoken": "^8.5.2",
"@types/lodash.clonedeep": "^4.5.6", "@types/lodash.clonedeep": "^4.5.6",

View File

@ -0,0 +1,50 @@
# Using `forwardemail``email-templates` With `pug` Package
You'll find the GitHub repository of the `email-templates` package and the `pug` package here:
- [email-templates](https://github.com/forwardemail/email-templates)
- [pug](https://www.npmjs.com/package/pug)
## `pug` Documentation
The full `pug` documentation you'll find here:
- [pugjs.org](https://pugjs.org/)
### Caching Possibility
In case we are sending many emails in the future there is the possibility to cache the `pug` templates:
- [cache-pug-templates](https://github.com/ladjs/cache-pug-templates)
## Testing
To test your send emails you have different possibilities:
### In General
To send emails to yourself while developing set in `.env` the value `EMAIL_TEST_MODUS=true` and `EMAIL_TEST_RECEIVER` to your preferred email address.
### Unit Or Integration Tests
To change the behavior to show previews etc. you have the following options to be set in `sendEmailTranslated.ts` on creating the email object:
```js
const email = new Email({
// send emails in development/test env:
send: true,
// to open send emails in the browser
preview: true,
// or
// to open send emails in a specific the browser
preview: {
open: {
app: 'firefox',
wait: false,
},
},
})
```

View File

@ -0,0 +1,22 @@
doctype html
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= t('emails.accountMultiRegistration.emailReused')
br
span= t('emails.accountMultiRegistration.emailExists')
p= t('emails.accountMultiRegistration.onForgottenPasswordClickLink')
br
a(href=resendLink) #{resendLink}
br
span= t('emails.accountMultiRegistration.onForgottenPasswordCopyLink')
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')
br
span= t('emails.accountMultiRegistration.yourGradidoTeam')

View File

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

View File

@ -0,0 +1,110 @@
import { createTransport } from 'nodemailer'
import { logger, i18n } from '@test/testSetup'
import CONFIG from '@/config'
import { sendEmailTranslated } from './sendEmailTranslated'
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('sendEmailTranslated', () => {
let result: Record<string, unknown> | null
describe('config email is false', () => {
beforeEach(async () => {
result = await sendEmailTranslated({
receiver: {
to: 'receiver@mail.org',
cc: 'support@gradido.net',
},
template: 'accountMultiRegistration',
locals: {
locale: 'en',
},
})
})
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 () => {
CONFIG.EMAIL = true
result = await sendEmailTranslated({
receiver: {
to: 'receiver@mail.org',
cc: 'support@gradido.net',
},
template: 'accountMultiRegistration',
locals: {
locale: 'en',
},
})
})
it('calls the transporter', () => {
expect(createTransport).toBeCalledWith({
host: 'EMAIL_SMTP_URL',
port: 1234,
secure: false,
requireTLS: true,
auth: {
user: 'user',
pass: 'pwd',
},
})
})
describe('call of "sendEmailTranslated"', () => {
it('has expected result', () => {
expect(result).toMatchObject({
envelope: {
from: 'info@gradido.net',
to: ['receiver@mail.org', 'support@gradido.net'],
},
message: expect.any(String),
originalMessage: expect.objectContaining({
to: 'receiver@mail.org',
cc: 'support@gradido.net',
from: 'Gradido (nicht antworten) <info@gradido.net>',
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'),
}),
})
})
})
it.skip('calls "i18n.setLocale" with "en"', () => {
expect(i18n.setLocale).toBeCalledWith('en')
})
it.skip('calls "i18n.__" for translation', () => {
expect(i18n.__).toBeCalled()
})
})
})

View File

@ -0,0 +1,85 @@
import { backendLogger as logger } from '@/server/logger'
import path from 'path'
import { createTransport } from 'nodemailer'
import Email from 'email-templates'
import i18n from 'i18n'
import CONFIG from '@/config'
export const sendEmailTranslated = async (params: {
receiver: {
to: string
cc?: string
}
template: string
locals: Record<string, string>
}): Promise<Record<string, unknown> | null> => {
let resultSend: Record<string, unknown> | null = null
// TODO: test the calling order of 'i18n.setLocale' for example: language of logging 'en', language of email receiver 'es', reset language of current user 'de'
// because language of receiver can differ from language of current user who triggers the sending
const rememberLocaleToRestore = i18n.getLocale()
i18n.setLocale('en') // for logging
logger.info(
`send Email: language=${params.locals.locale} to=${params.receiver.to}` +
(params.receiver.cc ? `, cc=${params.receiver.cc}` : '') +
`, subject=${i18n.__('emails.' + params.template + '.subject')}`,
)
if (!CONFIG.EMAIL) {
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) {
logger.info(
`Testmodus=ON: change receiver from ${params.receiver.to} to ${CONFIG.EMAIL_TEST_RECEIVER}`,
)
params.receiver.to = CONFIG.EMAIL_TEST_RECEIVER
}
const transport = 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,
},
})
i18n.setLocale(params.locals.locale) // for email
// TESTING: see 'README.md'
const email = new Email({
message: {
from: `Gradido (nicht antworten) <${CONFIG.EMAIL_SENDER}>`,
},
transport,
preview: false,
// i18n, // is only needed if you don't install i18n
})
// 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),
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'
})
.then((result: Record<string, unknown>) => {
resultSend = result
logger.info('Send email successfully !!!')
logger.info('Result: ', result)
})
.catch((error: unknown) => {
logger.error('Error sending notification email: ', error)
throw new Error('Error sending notification email!')
})
i18n.setLocale(rememberLocaleToRestore)
return resultSend
}

View File

@ -0,0 +1,88 @@
import CONFIG from '@/config'
import { sendAccountMultiRegistrationEmail } 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'
jest.mock('./sendEmailTranslated', () => {
const originalModule = jest.requireActual('./sendEmailTranslated')
return {
__esModule: true,
sendEmailTranslated: jest.fn((a) => originalModule.sendEmailTranslated(a)),
}
})
describe('sendEmailVariants', () => {
let result: Record<string, unknown> | null
describe('sendAccountMultiRegistrationEmail', () => {
beforeAll(async () => {
result = await sendAccountMultiRegistrationEmail({
firstName: 'Peter',
lastName: 'Lustig',
email: 'peter@lustig.de',
language: 'en',
})
})
describe('calls "sendEmailTranslated"', () => {
it('with expected parameters', () => {
expect(sendEmailTranslated).toBeCalledWith({
receiver: {
to: 'Peter Lustig <peter@lustig.de>',
},
template: 'accountMultiRegistration',
locals: {
firstName: 'Peter',
lastName: 'Lustig',
locale: 'en',
resendLink: CONFIG.EMAIL_LINK_FORGOTPASSWORD,
},
})
})
it('has expected result', () => {
expect(result).toMatchObject({
envelope: {
from: 'info@gradido.net',
to: ['peter@lustig.de'],
},
message: expect.any(String),
originalMessage: expect.objectContaining({
to: 'Peter Lustig <peter@lustig.de>',
from: 'Gradido (nicht antworten) <info@gradido.net>',
attachments: [],
subject: 'Gradido: Try To Register Again With Your Email',
html:
expect.stringContaining(
'<title>Gradido: Try To Register Again With Your Email</title>',
) &&
expect.stringContaining('>Gradido: Try To Register Again With Your Email</h1>') &&
expect.stringContaining(
'Your email address has just been used again to register an account with Gradido.',
) &&
expect.stringContaining(
'However, an account already exists for your email address.',
) &&
expect.stringContaining(
'Please click on the following link if you have forgotten your password:',
) &&
expect.stringContaining(
`<a href="${CONFIG.EMAIL_LINK_FORGOTPASSWORD}">${CONFIG.EMAIL_LINK_FORGOTPASSWORD}</a>`,
) &&
expect.stringContaining('or copy the link above into your browser window.') &&
expect.stringContaining(
'If you are not the one who tried to register again, please contact our support:',
) &&
expect.stringContaining('Sincerely yours,<br><span>your Gradido team'),
text: expect.stringContaining('GRADIDO: TRY TO REGISTER AGAIN WITH YOUR EMAIL'),
}),
})
})
})
})
})

View File

@ -0,0 +1,20 @@
import CONFIG from '@/config'
import { sendEmailTranslated } from './sendEmailTranslated'
export const sendAccountMultiRegistrationEmail = (data: {
firstName: string
lastName: string
email: string
language: string
}): Promise<Record<string, unknown> | null> => {
return sendEmailTranslated({
receiver: { to: `${data.firstName} ${data.lastName} <${data.email}>` },
template: 'accountMultiRegistration',
locals: {
locale: data.language,
firstName: data.firstName,
lastName: data.lastName,
resendLink: CONFIG.EMAIL_LINK_FORGOTPASSWORD,
},
})
}

View File

@ -2,7 +2,7 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { objectValuesToArray } from '@/util/utilities' import { objectValuesToArray } from '@/util/utilities'
import { testEnvironment, resetToken, cleanDB } from '@test/helpers' import { testEnvironment, resetToken, cleanDB, contributionDateFormatter } from '@test/helpers'
import { userFactory } from '@/seeds/factory/user' import { userFactory } from '@/seeds/factory/user'
import { creationFactory } from '@/seeds/factory/creation' import { creationFactory } from '@/seeds/factory/creation'
import { creations } from '@/seeds/creation/index' import { creations } from '@/seeds/creation/index'
@ -83,6 +83,12 @@ let user: User
let creation: Contribution | void let creation: Contribution | void
let result: any 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('AdminResolver', () => {
describe('set user role', () => { describe('set user role', () => {
describe('unauthenticated', () => { describe('unauthenticated', () => {
@ -751,7 +757,7 @@ describe('AdminResolver', () => {
email: 'bibi@bloxberg.de', email: 'bibi@bloxberg.de',
amount: new Decimal(300), amount: new Decimal(300),
memo: 'Danke Bibi!', memo: 'Danke Bibi!',
creationDate: new Date().toString(), creationDate: contributionDateFormatter(new Date()),
}, },
}), }),
).resolves.toEqual( ).resolves.toEqual(
@ -861,7 +867,7 @@ describe('AdminResolver', () => {
email: 'bibi@bloxberg.de', email: 'bibi@bloxberg.de',
amount: new Decimal(300), amount: new Decimal(300),
memo: 'Danke Bibi!', memo: 'Danke Bibi!',
creationDate: new Date().toString(), creationDate: contributionDateFormatter(new Date()),
}, },
}), }),
).resolves.toEqual( ).resolves.toEqual(
@ -936,19 +942,25 @@ describe('AdminResolver', () => {
}) })
describe('adminCreateContribution', () => { describe('adminCreateContribution', () => {
beforeAll(async () => {
const now = new Date() const now = new Date()
beforeAll(async () => {
creation = await creationFactory(testEnv, { creation = await creationFactory(testEnv, {
email: 'peter@lustig.de', email: 'peter@lustig.de',
amount: 400, amount: 400,
memo: 'Herzlich Willkommen bei Gradido!', memo: 'Herzlich Willkommen bei Gradido!',
creationDate: new Date(now.getFullYear(), now.getMonth() - 1, 1).toISOString(), creationDate: contributionDateFormatter(
new Date(now.getFullYear(), now.getMonth() - 1, 1),
),
}) })
}) })
describe('user to create for does not exist', () => { describe('user to create for does not exist', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks() jest.clearAllMocks()
variables.creationDate = contributionDateFormatter(
new Date(now.getFullYear(), now.getMonth() - 1, 1),
)
await expect( await expect(
mutate({ mutation: adminCreateContribution, variables }), mutate({ mutation: adminCreateContribution, variables }),
).resolves.toEqual( ).resolves.toEqual(
@ -969,6 +981,9 @@ describe('AdminResolver', () => {
beforeAll(async () => { beforeAll(async () => {
user = await userFactory(testEnv, stephenHawking) user = await userFactory(testEnv, stephenHawking)
variables.email = 'stephen@hawking.uk' variables.email = 'stephen@hawking.uk'
variables.creationDate = contributionDateFormatter(
new Date(now.getFullYear(), now.getMonth() - 1, 1),
)
}) })
it('throws an error', async () => { it('throws an error', async () => {
@ -995,6 +1010,9 @@ describe('AdminResolver', () => {
beforeAll(async () => { beforeAll(async () => {
user = await userFactory(testEnv, garrickOllivander) user = await userFactory(testEnv, garrickOllivander)
variables.email = 'garrick@ollivander.com' variables.email = 'garrick@ollivander.com'
variables.creationDate = contributionDateFormatter(
new Date(now.getFullYear(), now.getMonth() - 1, 1),
)
}) })
it('throws an error', async () => { it('throws an error', async () => {
@ -1021,6 +1039,7 @@ describe('AdminResolver', () => {
beforeAll(async () => { beforeAll(async () => {
user = await userFactory(testEnv, bibiBloxberg) user = await userFactory(testEnv, bibiBloxberg)
variables.email = 'bibi@bloxberg.de' variables.email = 'bibi@bloxberg.de'
variables.creationDate = 'invalid-date'
}) })
describe('date of creation is not a date string', () => { describe('date of creation is not a date string', () => {
@ -1030,30 +1049,22 @@ describe('AdminResolver', () => {
mutate({ mutation: adminCreateContribution, variables }), mutate({ mutation: adminCreateContribution, variables }),
).resolves.toEqual( ).resolves.toEqual(
expect.objectContaining({ expect.objectContaining({
errors: [ errors: [new GraphQLError(`invalid Date for creationDate=invalid-date`)],
new GraphQLError('No information for available creations for the given date'),
],
}), }),
) )
}) })
it('logs the error thrown', () => { it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith( expect(logger.error).toBeCalledWith(`invalid Date for creationDate=invalid-date`)
'No information for available creations with the given creationDate=',
'Invalid Date',
)
}) })
}) })
describe('date of creation is four months ago', () => { describe('date of creation is four months ago', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks() jest.clearAllMocks()
const now = new Date() variables.creationDate = contributionDateFormatter(
variables.creationDate = new Date( new Date(now.getFullYear(), now.getMonth() - 4, 1),
now.getFullYear(), )
now.getMonth() - 4,
1,
).toString()
await expect( await expect(
mutate({ mutation: adminCreateContribution, variables }), mutate({ mutation: adminCreateContribution, variables }),
).resolves.toEqual( ).resolves.toEqual(
@ -1068,7 +1079,7 @@ describe('AdminResolver', () => {
it('logs the error thrown', () => { it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith( expect(logger.error).toBeCalledWith(
'No information for available creations with the given creationDate=', 'No information for available creations with the given creationDate=',
variables.creationDate, new Date(variables.creationDate).toString(),
) )
}) })
}) })
@ -1076,12 +1087,9 @@ describe('AdminResolver', () => {
describe('date of creation is in the future', () => { describe('date of creation is in the future', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks() jest.clearAllMocks()
const now = new Date() variables.creationDate = contributionDateFormatter(
variables.creationDate = new Date( new Date(now.getFullYear(), now.getMonth() + 4, 1),
now.getFullYear(), )
now.getMonth() + 4,
1,
).toString()
await expect( await expect(
mutate({ mutation: adminCreateContribution, variables }), mutate({ mutation: adminCreateContribution, variables }),
).resolves.toEqual( ).resolves.toEqual(
@ -1096,7 +1104,7 @@ describe('AdminResolver', () => {
it('logs the error thrown', () => { it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith( expect(logger.error).toBeCalledWith(
'No information for available creations with the given creationDate=', 'No information for available creations with the given creationDate=',
variables.creationDate, new Date(variables.creationDate).toString(),
) )
}) })
}) })
@ -1104,7 +1112,7 @@ describe('AdminResolver', () => {
describe('amount of creation is too high', () => { describe('amount of creation is too high', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks() jest.clearAllMocks()
variables.creationDate = new Date().toString() variables.creationDate = contributionDateFormatter(now)
await expect( await expect(
mutate({ mutation: adminCreateContribution, variables }), mutate({ mutation: adminCreateContribution, variables }),
).resolves.toEqual( ).resolves.toEqual(
@ -1192,7 +1200,7 @@ describe('AdminResolver', () => {
email, email,
amount: new Decimal(500), amount: new Decimal(500),
memo: 'Grundeinkommen', memo: 'Grundeinkommen',
creationDate: new Date().toString(), creationDate: contributionDateFormatter(new Date()),
} }
}) })
@ -1238,7 +1246,7 @@ describe('AdminResolver', () => {
email: 'bob@baumeister.de', email: 'bob@baumeister.de',
amount: new Decimal(300), amount: new Decimal(300),
memo: 'Danke Bibi!', memo: 'Danke Bibi!',
creationDate: new Date().toString(), creationDate: contributionDateFormatter(new Date()),
}, },
}), }),
).resolves.toEqual( ).resolves.toEqual(
@ -1268,7 +1276,7 @@ describe('AdminResolver', () => {
email: 'stephen@hawking.uk', email: 'stephen@hawking.uk',
amount: new Decimal(300), amount: new Decimal(300),
memo: 'Danke Bibi!', memo: 'Danke Bibi!',
creationDate: new Date().toString(), creationDate: contributionDateFormatter(new Date()),
}, },
}), }),
).resolves.toEqual( ).resolves.toEqual(
@ -1294,7 +1302,7 @@ describe('AdminResolver', () => {
email: 'bibi@bloxberg.de', email: 'bibi@bloxberg.de',
amount: new Decimal(300), amount: new Decimal(300),
memo: 'Danke Bibi!', memo: 'Danke Bibi!',
creationDate: new Date().toString(), creationDate: contributionDateFormatter(new Date()),
}, },
}), }),
).resolves.toEqual( ).resolves.toEqual(
@ -1321,8 +1329,8 @@ describe('AdminResolver', () => {
amount: new Decimal(300), amount: new Decimal(300),
memo: 'Danke Bibi!', memo: 'Danke Bibi!',
creationDate: creation creationDate: creation
? creation.contributionDate.toString() ? contributionDateFormatter(creation.contributionDate)
: new Date().toString(), : contributionDateFormatter(new Date()),
}, },
}), }),
).resolves.toEqual( ).resolves.toEqual(
@ -1356,8 +1364,8 @@ describe('AdminResolver', () => {
amount: new Decimal(1900), amount: new Decimal(1900),
memo: 'Danke Peter!', memo: 'Danke Peter!',
creationDate: creation creationDate: creation
? creation.contributionDate.toString() ? contributionDateFormatter(creation.contributionDate)
: new Date().toString(), : contributionDateFormatter(new Date()),
}, },
}), }),
).resolves.toEqual( ).resolves.toEqual(
@ -1390,8 +1398,8 @@ describe('AdminResolver', () => {
amount: new Decimal(300), amount: new Decimal(300),
memo: 'Danke Peter!', memo: 'Danke Peter!',
creationDate: creation creationDate: creation
? creation.contributionDate.toString() ? contributionDateFormatter(creation.contributionDate)
: new Date().toString(), : contributionDateFormatter(new Date()),
}, },
}), }),
).resolves.toEqual( ).resolves.toEqual(
@ -1430,8 +1438,8 @@ describe('AdminResolver', () => {
amount: new Decimal(200), amount: new Decimal(200),
memo: 'Das war leider zu Viel!', memo: 'Das war leider zu Viel!',
creationDate: creation creationDate: creation
? creation.contributionDate.toString() ? contributionDateFormatter(creation.contributionDate)
: new Date().toString(), : contributionDateFormatter(new Date()),
}, },
}), }),
).resolves.toEqual( ).resolves.toEqual(
@ -1554,7 +1562,7 @@ describe('AdminResolver', () => {
variables: { variables: {
amount: 100.0, amount: 100.0,
memo: 'Test env contribution', memo: 'Test env contribution',
creationDate: new Date().toString(), creationDate: contributionDateFormatter(new Date()),
}, },
}) })
}) })
@ -1633,7 +1641,9 @@ describe('AdminResolver', () => {
email: 'peter@lustig.de', email: 'peter@lustig.de',
amount: 400, amount: 400,
memo: 'Herzlich Willkommen bei Gradido!', memo: 'Herzlich Willkommen bei Gradido!',
creationDate: new Date(now.getFullYear(), now.getMonth() - 1, 1).toISOString(), creationDate: contributionDateFormatter(
new Date(now.getFullYear(), now.getMonth() - 1, 1),
),
}) })
}) })
@ -1664,7 +1674,9 @@ describe('AdminResolver', () => {
email: 'bibi@bloxberg.de', email: 'bibi@bloxberg.de',
amount: 450, amount: 450,
memo: 'Herzlich Willkommen bei Gradido liebe Bibi!', memo: 'Herzlich Willkommen bei Gradido liebe Bibi!',
creationDate: new Date(now.getFullYear(), now.getMonth() - 2, 1).toISOString(), creationDate: contributionDateFormatter(
new Date(now.getFullYear(), now.getMonth() - 2, 1),
),
}) })
}) })
@ -1735,13 +1747,17 @@ describe('AdminResolver', () => {
email: 'bibi@bloxberg.de', email: 'bibi@bloxberg.de',
amount: 50, amount: 50,
memo: 'Herzlich Willkommen bei Gradido liebe Bibi!', memo: 'Herzlich Willkommen bei Gradido liebe Bibi!',
creationDate: new Date(now.getFullYear(), now.getMonth() - 2, 1).toISOString(), creationDate: contributionDateFormatter(
new Date(now.getFullYear(), now.getMonth() - 2, 1),
),
}) })
c2 = await creationFactory(testEnv, { c2 = await creationFactory(testEnv, {
email: 'bibi@bloxberg.de', email: 'bibi@bloxberg.de',
amount: 50, amount: 50,
memo: 'Herzlich Willkommen bei Gradido liebe Bibi!', memo: 'Herzlich Willkommen bei Gradido liebe Bibi!',
creationDate: new Date(now.getFullYear(), now.getMonth() - 2, 1).toISOString(), creationDate: contributionDateFormatter(
new Date(now.getFullYear(), now.getMonth() - 2, 1),
),
}) })
}) })

View File

@ -1,4 +1,4 @@
import { Context, getUser } from '@/server/context' import { Context, getUser, getClientTimezoneOffset } from '@/server/context'
import { backendLogger as logger } from '@/server/logger' import { backendLogger as logger } from '@/server/logger'
import { Resolver, Query, Arg, Args, Authorized, Mutation, Ctx, Int } from 'type-graphql' import { Resolver, Query, Arg, Args, Authorized, Mutation, Ctx, Int } from 'type-graphql'
import { import {
@ -49,6 +49,7 @@ import {
validateContribution, validateContribution,
isStartEndDateValid, isStartEndDateValid,
updateCreations, updateCreations,
isValidDateString,
} from './util/creations' } from './util/creations'
import { import {
CONTRIBUTIONLINK_NAME_MAX_CHARS, CONTRIBUTIONLINK_NAME_MAX_CHARS,
@ -86,7 +87,9 @@ export class AdminResolver {
async searchUsers( async searchUsers(
@Args() @Args()
{ searchText, currentPage = 1, pageSize = 25, filters }: SearchUsersArgs, { searchText, currentPage = 1, pageSize = 25, filters }: SearchUsersArgs,
@Ctx() context: Context,
): Promise<SearchUsersResult> { ): Promise<SearchUsersResult> {
const clientTimezoneOffset = getClientTimezoneOffset(context)
const userRepository = getCustomRepository(UserRepository) const userRepository = getCustomRepository(UserRepository)
const userFields = [ const userFields = [
'id', 'id',
@ -114,7 +117,10 @@ export class AdminResolver {
} }
} }
const creations = await getUserCreations(users.map((u) => u.id)) const creations = await getUserCreations(
users.map((u) => u.id),
clientTimezoneOffset,
)
const adminUsers = await Promise.all( const adminUsers = await Promise.all(
users.map(async (user) => { users.map(async (user) => {
@ -237,6 +243,11 @@ export class AdminResolver {
logger.info( logger.info(
`adminCreateContribution(email=${email}, amount=${amount}, memo=${memo}, creationDate=${creationDate})`, `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({ const emailContact = await UserContact.findOne({
where: { email }, where: { email },
withDeleted: true, withDeleted: true,
@ -262,11 +273,11 @@ export class AdminResolver {
const event = new Event() const event = new Event()
const moderator = getUser(context) const moderator = getUser(context)
logger.trace('moderator: ', moderator.id) logger.trace('moderator: ', moderator.id)
const creations = await getUserCreation(emailContact.userId) const creations = await getUserCreation(emailContact.userId, clientTimezoneOffset)
logger.trace('creations:', creations) logger.trace('creations:', creations)
const creationDateObj = new Date(creationDate) const creationDateObj = new Date(creationDate)
logger.trace('creationDateObj:', creationDateObj) logger.trace('creationDateObj:', creationDateObj)
validateContribution(creations, amount, creationDateObj) validateContribution(creations, amount, creationDateObj, clientTimezoneOffset)
const contribution = DbContribution.create() const contribution = DbContribution.create()
contribution.userId = emailContact.userId contribution.userId = emailContact.userId
contribution.amount = amount contribution.amount = amount
@ -289,7 +300,7 @@ export class AdminResolver {
event.setEventAdminContributionCreate(eventAdminCreateContribution), event.setEventAdminContributionCreate(eventAdminCreateContribution),
) )
return getUserCreation(emailContact.userId) return getUserCreation(emailContact.userId, clientTimezoneOffset)
} }
@Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTIONS]) @Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTIONS])
@ -325,6 +336,7 @@ export class AdminResolver {
@Args() { id, email, amount, memo, creationDate }: AdminUpdateContributionArgs, @Args() { id, email, amount, memo, creationDate }: AdminUpdateContributionArgs,
@Ctx() context: Context, @Ctx() context: Context,
): Promise<AdminUpdateContribution> { ): Promise<AdminUpdateContribution> {
const clientTimezoneOffset = getClientTimezoneOffset(context)
const emailContact = await UserContact.findOne({ const emailContact = await UserContact.findOne({
where: { email }, where: { email },
withDeleted: true, withDeleted: true,
@ -365,17 +377,17 @@ export class AdminResolver {
} }
const creationDateObj = new Date(creationDate) const creationDateObj = new Date(creationDate)
let creations = await getUserCreation(user.id) let creations = await getUserCreation(user.id, clientTimezoneOffset)
if (contributionToUpdate.contributionDate.getMonth() === creationDateObj.getMonth()) { if (contributionToUpdate.contributionDate.getMonth() === creationDateObj.getMonth()) {
creations = updateCreations(creations, contributionToUpdate) creations = updateCreations(creations, contributionToUpdate, clientTimezoneOffset)
} else { } else {
logger.error('Currently the month of the contribution cannot change.') logger.error('Currently the month of the contribution cannot change.')
throw new 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 // all possible cases not to be true are thrown in this function
validateContribution(creations, amount, creationDateObj) validateContribution(creations, amount, creationDateObj, clientTimezoneOffset)
contributionToUpdate.amount = amount contributionToUpdate.amount = amount
contributionToUpdate.memo = memo contributionToUpdate.memo = memo
contributionToUpdate.contributionDate = new Date(creationDate) contributionToUpdate.contributionDate = new Date(creationDate)
@ -389,7 +401,7 @@ export class AdminResolver {
result.memo = contributionToUpdate.memo result.memo = contributionToUpdate.memo
result.date = contributionToUpdate.contributionDate result.date = contributionToUpdate.contributionDate
result.creation = await getUserCreation(user.id) result.creation = await getUserCreation(user.id, clientTimezoneOffset)
const event = new Event() const event = new Event()
const eventAdminContributionUpdate = new EventAdminContributionUpdate() const eventAdminContributionUpdate = new EventAdminContributionUpdate()
@ -405,7 +417,8 @@ export class AdminResolver {
@Authorized([RIGHTS.LIST_UNCONFIRMED_CONTRIBUTIONS]) @Authorized([RIGHTS.LIST_UNCONFIRMED_CONTRIBUTIONS])
@Query(() => [UnconfirmedContribution]) @Query(() => [UnconfirmedContribution])
async listUnconfirmedContributions(): Promise<UnconfirmedContribution[]> { async listUnconfirmedContributions(@Ctx() context: Context): Promise<UnconfirmedContribution[]> {
const clientTimezoneOffset = getClientTimezoneOffset(context)
const contributions = await getConnection() const contributions = await getConnection()
.createQueryBuilder() .createQueryBuilder()
.select('c') .select('c')
@ -419,7 +432,7 @@ export class AdminResolver {
} }
const userIds = contributions.map((p) => p.userId) const userIds = contributions.map((p) => p.userId)
const userCreations = await getUserCreations(userIds) const userCreations = await getUserCreations(userIds, clientTimezoneOffset)
const users = await dbUser.find({ const users = await dbUser.find({
where: { id: In(userIds) }, where: { id: In(userIds) },
withDeleted: true, withDeleted: true,
@ -493,6 +506,7 @@ export class AdminResolver {
@Arg('id', () => Int) id: number, @Arg('id', () => Int) id: number,
@Ctx() context: Context, @Ctx() context: Context,
): Promise<boolean> { ): Promise<boolean> {
const clientTimezoneOffset = getClientTimezoneOffset(context)
const contribution = await DbContribution.findOne(id) const contribution = await DbContribution.findOne(id)
if (!contribution) { if (!contribution) {
logger.error(`Contribution not found for given id: ${id}`) logger.error(`Contribution not found for given id: ${id}`)
@ -511,8 +525,13 @@ export class AdminResolver {
logger.error('This user was deleted. Cannot confirm a contribution.') logger.error('This user was deleted. Cannot confirm a contribution.')
throw new 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, false) const creations = await getUserCreation(contribution.userId, clientTimezoneOffset, false)
validateContribution(creations, contribution.amount, contribution.contributionDate) validateContribution(
creations,
contribution.amount,
contribution.contributionDate,
clientTimezoneOffset,
)
const receivedCallDate = new Date() const receivedCallDate = new Date()

View File

@ -1,5 +1,5 @@
import { RIGHTS } from '@/auth/RIGHTS' import { RIGHTS } from '@/auth/RIGHTS'
import { Context, getUser } from '@/server/context' import { Context, getUser, getClientTimezoneOffset } from '@/server/context'
import { backendLogger as logger } from '@/server/logger' 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 { Arg, Args, Authorized, Ctx, Int, Mutation, Query, Resolver } from 'type-graphql'
@ -31,6 +31,7 @@ export class ContributionResolver {
@Args() { amount, memo, creationDate }: ContributionArgs, @Args() { amount, memo, creationDate }: ContributionArgs,
@Ctx() context: Context, @Ctx() context: Context,
): Promise<UnconfirmedContribution> { ): Promise<UnconfirmedContribution> {
const clientTimezoneOffset = getClientTimezoneOffset(context)
if (memo.length > MEMO_MAX_CHARS) { if (memo.length > MEMO_MAX_CHARS) {
logger.error(`memo text is too long: memo.length=${memo.length} > ${MEMO_MAX_CHARS}`) logger.error(`memo text is too long: memo.length=${memo.length} > ${MEMO_MAX_CHARS}`)
throw new Error(`memo text is too long (${MEMO_MAX_CHARS} characters maximum)`) throw new Error(`memo text is too long (${MEMO_MAX_CHARS} characters maximum)`)
@ -44,10 +45,10 @@ export class ContributionResolver {
const event = new Event() const event = new Event()
const user = getUser(context) const user = getUser(context)
const creations = await getUserCreation(user.id) const creations = await getUserCreation(user.id, clientTimezoneOffset)
logger.trace('creations', creations) logger.trace('creations', creations)
const creationDateObj = new Date(creationDate) const creationDateObj = new Date(creationDate)
validateContribution(creations, amount, creationDateObj) validateContribution(creations, amount, creationDateObj, clientTimezoneOffset)
const contribution = dbContribution.create() const contribution = dbContribution.create()
contribution.userId = user.id contribution.userId = user.id
@ -171,6 +172,7 @@ export class ContributionResolver {
@Args() { amount, memo, creationDate }: ContributionArgs, @Args() { amount, memo, creationDate }: ContributionArgs,
@Ctx() context: Context, @Ctx() context: Context,
): Promise<UnconfirmedContribution> { ): Promise<UnconfirmedContribution> {
const clientTimezoneOffset = getClientTimezoneOffset(context)
if (memo.length > MEMO_MAX_CHARS) { if (memo.length > MEMO_MAX_CHARS) {
logger.error(`memo text is too long: memo.length=${memo.length} > ${MEMO_MAX_CHARS}`) logger.error(`memo text is too long: memo.length=${memo.length} > ${MEMO_MAX_CHARS}`)
throw new Error(`memo text is too long (${MEMO_MAX_CHARS} characters maximum)`) throw new Error(`memo text is too long (${MEMO_MAX_CHARS} characters maximum)`)
@ -206,16 +208,16 @@ export class ContributionResolver {
) )
} }
const creationDateObj = new Date(creationDate) const creationDateObj = new Date(creationDate)
let creations = await getUserCreation(user.id) let creations = await getUserCreation(user.id, clientTimezoneOffset)
if (contributionToUpdate.contributionDate.getMonth() === creationDateObj.getMonth()) { if (contributionToUpdate.contributionDate.getMonth() === creationDateObj.getMonth()) {
creations = updateCreations(creations, contributionToUpdate) creations = updateCreations(creations, contributionToUpdate, clientTimezoneOffset)
} else { } else {
logger.error('Currently the month of the contribution cannot change.') logger.error('Currently the month of the contribution cannot change.')
throw new 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 // all possible cases not to be true are thrown in this function
validateContribution(creations, amount, creationDateObj) validateContribution(creations, amount, creationDateObj, clientTimezoneOffset)
const contributionMessage = ContributionMessage.create() const contributionMessage = ContributionMessage.create()
contributionMessage.contributionId = contributionId contributionMessage.contributionId = contributionId

View File

@ -1,5 +1,5 @@
import { backendLogger as logger } from '@/server/logger' import { backendLogger as logger } from '@/server/logger'
import { Context, getUser } from '@/server/context' import { Context, getUser, getClientTimezoneOffset } from '@/server/context'
import { getConnection } from '@dbTools/typeorm' import { getConnection } from '@dbTools/typeorm'
import { import {
Resolver, Resolver,
@ -169,6 +169,7 @@ export class TransactionLinkResolver {
@Arg('code', () => String) code: string, @Arg('code', () => String) code: string,
@Ctx() context: Context, @Ctx() context: Context,
): Promise<boolean> { ): Promise<boolean> {
const clientTimezoneOffset = getClientTimezoneOffset(context)
const user = getUser(context) const user = getUser(context)
const now = new Date() const now = new Date()
@ -258,9 +259,9 @@ export class TransactionLinkResolver {
} }
} }
const creations = await getUserCreation(user.id) const creations = await getUserCreation(user.id, clientTimezoneOffset)
logger.info('open creations', creations) logger.info('open creations', creations)
validateContribution(creations, contributionLink.amount, now) validateContribution(creations, contributionLink.amount, now, clientTimezoneOffset)
const contribution = new DbContribution() const contribution = new DbContribution()
contribution.userId = user.id contribution.userId = user.id
contribution.createdAt = now contribution.createdAt = now

View File

@ -19,7 +19,7 @@ import { GraphQLError } from 'graphql'
import { User } from '@entity/User' import { User } from '@entity/User'
import CONFIG from '@/config' import CONFIG from '@/config'
import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail' import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail'
import { sendAccountMultiRegistrationEmail } from '@/mailer/sendAccountMultiRegistrationEmail' import { sendAccountMultiRegistrationEmail } from '@/emails/sendEmailVariants'
import { sendResetPasswordEmail } from '@/mailer/sendResetPasswordEmail' import { sendResetPasswordEmail } from '@/mailer/sendResetPasswordEmail'
import { printTimeDuration, activationLink } from './UserResolver' import { printTimeDuration, activationLink } from './UserResolver'
import { contributionLinkFactory } from '@/seeds/factory/contributionLink' import { contributionLinkFactory } from '@/seeds/factory/contributionLink'
@ -29,7 +29,7 @@ import { TransactionLink } from '@entity/TransactionLink'
import { EventProtocolType } from '@/event/EventProtocolType' import { EventProtocolType } from '@/event/EventProtocolType'
import { EventProtocol } from '@entity/EventProtocol' import { EventProtocol } from '@entity/EventProtocol'
import { logger } from '@test/testSetup' import { logger, i18n as localization } from '@test/testSetup'
import { validate as validateUUID, version as versionUUID } from 'uuid' import { validate as validateUUID, version as versionUUID } from 'uuid'
import { peterLustig } from '@/seeds/users/peter-lustig' import { peterLustig } from '@/seeds/users/peter-lustig'
import { UserContact } from '@entity/UserContact' import { UserContact } from '@entity/UserContact'
@ -46,7 +46,7 @@ jest.mock('@/mailer/sendAccountActivationEmail', () => {
} }
}) })
jest.mock('@/mailer/sendAccountMultiRegistrationEmail', () => { jest.mock('@/emails/sendEmailVariants', () => {
return { return {
__esModule: true, __esModule: true,
sendAccountMultiRegistrationEmail: jest.fn(), sendAccountMultiRegistrationEmail: jest.fn(),
@ -73,7 +73,7 @@ let mutate: any, query: any, con: any
let testEnv: any let testEnv: any
beforeAll(async () => { beforeAll(async () => {
testEnv = await testEnvironment(logger) testEnv = await testEnvironment(logger, localization)
mutate = testEnv.mutate mutate = testEnv.mutate
query = testEnv.query query = testEnv.query
con = testEnv.con con = testEnv.con
@ -213,6 +213,7 @@ describe('UserResolver', () => {
firstName: 'Peter', firstName: 'Peter',
lastName: 'Lustig', lastName: 'Lustig',
email: 'peter@lustig.de', email: 'peter@lustig.de',
language: 'de',
}) })
}) })

View File

@ -1,6 +1,7 @@
import fs from 'fs' import fs from 'fs'
import { backendLogger as logger } from '@/server/logger' import { backendLogger as logger } from '@/server/logger'
import { Context, getUser } from '@/server/context' 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 } from 'type-graphql'
import { getConnection, getCustomRepository, IsNull, Not } from '@dbTools/typeorm' import { getConnection, getCustomRepository, IsNull, Not } from '@dbTools/typeorm'
import CONFIG from '@/config' import CONFIG from '@/config'
@ -18,7 +19,7 @@ import { klicktippNewsletterStateMiddleware } from '@/middleware/klicktippMiddle
import { OptInType } from '@enum/OptInType' import { OptInType } from '@enum/OptInType'
import { sendResetPasswordEmail as sendResetPasswordEmailMailer } from '@/mailer/sendResetPasswordEmail' import { sendResetPasswordEmail as sendResetPasswordEmailMailer } from '@/mailer/sendResetPasswordEmail'
import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail' import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail'
import { sendAccountMultiRegistrationEmail } from '@/mailer/sendAccountMultiRegistrationEmail' import { sendAccountMultiRegistrationEmail } from '@/emails/sendEmailVariants'
import { klicktippSignIn } from '@/apis/KlicktippController' import { klicktippSignIn } from '@/apis/KlicktippController'
import { RIGHTS } from '@/auth/RIGHTS' import { RIGHTS } from '@/auth/RIGHTS'
import { hasElopageBuys } from '@/util/hasElopageBuys' import { hasElopageBuys } from '@/util/hasElopageBuys'
@ -305,8 +306,9 @@ export class UserResolver {
async verifyLogin(@Ctx() context: Context): Promise<User> { async verifyLogin(@Ctx() context: Context): Promise<User> {
logger.info('verifyLogin...') logger.info('verifyLogin...')
// TODO refactor and do not have duplicate code with login(see below) // TODO refactor and do not have duplicate code with login(see below)
const clientTimezoneOffset = getClientTimezoneOffset(context)
const userEntity = getUser(context) const userEntity = getUser(context)
const user = new User(userEntity, await getUserCreation(userEntity.id)) const user = new User(userEntity, await getUserCreation(userEntity.id, clientTimezoneOffset))
// user.pubkey = userEntity.pubKey.toString('hex') // user.pubkey = userEntity.pubKey.toString('hex')
// Elopage Status & Stored PublisherId // Elopage Status & Stored PublisherId
user.hasElopage = await this.hasElopage(context) user.hasElopage = await this.hasElopage(context)
@ -323,6 +325,7 @@ export class UserResolver {
@Ctx() context: Context, @Ctx() context: Context,
): Promise<User> { ): Promise<User> {
logger.info(`login with ${email}, ***, ${publisherId} ...`) logger.info(`login with ${email}, ***, ${publisherId} ...`)
const clientTimezoneOffset = getClientTimezoneOffset(context)
email = email.trim().toLowerCase() email = email.trim().toLowerCase()
const dbUser = await findUserByEmail(email) const dbUser = await findUserByEmail(email)
if (dbUser.deletedAt) { if (dbUser.deletedAt) {
@ -353,9 +356,11 @@ export class UserResolver {
logger.addContext('user', dbUser.id) logger.addContext('user', dbUser.id)
logger.debug('validation of login credentials successful...') logger.debug('validation of login credentials successful...')
const user = new User(dbUser, await getUserCreation(dbUser.id)) const user = new User(dbUser, await getUserCreation(dbUser.id, clientTimezoneOffset))
logger.debug(`user= ${JSON.stringify(user, null, 2)}`) logger.debug(`user= ${JSON.stringify(user, null, 2)}`)
i18n.setLocale(user.language)
// Elopage Status & Stored PublisherId // Elopage Status & Stored PublisherId
user.hasElopage = await this.hasElopage({ ...context, user: dbUser }) user.hasElopage = await this.hasElopage({ ...context, user: dbUser })
logger.info('user.hasElopage=' + user.hasElopage) logger.info('user.hasElopage=' + user.hasElopage)
@ -408,6 +413,7 @@ export class UserResolver {
if (!language || !isLanguage(language)) { if (!language || !isLanguage(language)) {
language = DEFAULT_LANGUAGE language = DEFAULT_LANGUAGE
} }
i18n.setLocale(language)
// check if user with email still exists? // check if user with email still exists?
email = email.trim().toLowerCase() email = email.trim().toLowerCase()
@ -416,8 +422,11 @@ export class UserResolver {
logger.info(`DbUser.findOne(email=${email}) = ${foundUser}`) logger.info(`DbUser.findOne(email=${email}) = ${foundUser}`)
if (foundUser) { if (foundUser) {
// ATTENTION: this logger-message will be exactly expected during tests // ATTENTION: this logger-message will be exactly expected during tests, next line
logger.info(`User already exists with this email=${email}`) logger.info(`User already exists with this email=${email}`)
logger.info(
`Specified username when trying to register multiple times with this email: firstName=${firstName}, lastName=${lastName}`,
)
// TODO: this is unsecure, but the current implementation of the login server. This way it can be queried if the user with given EMail is existent. // TODO: this is unsecure, but the current implementation of the login server. This way it can be queried if the user with given EMail is existent.
const user = new User(communityDbUser) const user = new User(communityDbUser)
@ -430,18 +439,20 @@ export class UserResolver {
user.publisherId = publisherId user.publisherId = publisherId
logger.debug('partly faked user=' + user) logger.debug('partly faked user=' + user)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const emailSent = await sendAccountMultiRegistrationEmail({ const emailSent = await sendAccountMultiRegistrationEmail({
firstName, firstName: foundUser.firstName, // this is the real name of the email owner, but just "firstName" would be the name of the new registrant which shall not be passed to the outside
lastName, lastName: foundUser.lastName, // this is the real name of the email owner, but just "lastName" would be the name of the new registrant which shall not be passed to the outside
email, email,
language: foundUser.language, // use language of the emails owner for sending
}) })
const eventSendAccountMultiRegistrationEmail = new EventSendAccountMultiRegistrationEmail() const eventSendAccountMultiRegistrationEmail = new EventSendAccountMultiRegistrationEmail()
eventSendAccountMultiRegistrationEmail.userId = foundUser.id eventSendAccountMultiRegistrationEmail.userId = foundUser.id
eventProtocol.writeEvent( eventProtocol.writeEvent(
event.setEventSendConfirmationEmail(eventSendAccountMultiRegistrationEmail), event.setEventSendConfirmationEmail(eventSendAccountMultiRegistrationEmail),
) )
logger.info(`sendAccountMultiRegistrationEmail of ${firstName}.${lastName} to ${email}`) logger.info(
`sendAccountMultiRegistrationEmail by ${firstName} ${lastName} to ${foundUser.firstName} ${foundUser.lastName} <${email}>`,
)
/* uncomment this, when you need the activation link on the console */ /* uncomment this, when you need the activation link on the console */
// In case EMails are disabled log the activation link for the user // In case EMails are disabled log the activation link for the user
if (!emailSent) { if (!emailSent) {
@ -785,6 +796,7 @@ export class UserResolver {
throw new Error(`"${language}" isn't a valid language`) throw new Error(`"${language}" isn't a valid language`)
} }
userEntity.language = language userEntity.language = language
i18n.setLocale(language)
} }
if (password && passwordNew) { if (password && passwordNew) {

View File

@ -0,0 +1,266 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { testEnvironment, cleanDB, contributionDateFormatter } from '@test/helpers'
import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg'
import { peterLustig } from '@/seeds/users/peter-lustig'
import { User } from '@entity/User'
import { Contribution } from '@entity/Contribution'
import { userFactory } from '@/seeds/factory/user'
import { login, createContribution, adminCreateContribution } from '@/seeds/graphql/mutations'
import { getUserCreation } from './creations'
let mutate: any, con: any
let testEnv: any
beforeAll(async () => {
testEnv = await testEnvironment()
mutate = testEnv.mutate
con = testEnv.con
await cleanDB()
})
afterAll(async () => {
await cleanDB()
await con.close()
})
const setZeroHours = (date: Date): Date => {
return new Date(date.getFullYear(), date.getMonth(), date.getDate())
}
describe('util/creation', () => {
let user: User
let admin: User
const now = new Date()
beforeAll(async () => {
user = await userFactory(testEnv, bibiBloxberg)
admin = await userFactory(testEnv, peterLustig)
})
describe('getUserCreations', () => {
beforeAll(async () => {
await mutate({
mutation: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
})
await mutate({
mutation: adminCreateContribution,
variables: {
email: 'bibi@bloxberg.de',
amount: 250.0,
memo: 'Admin contribution for this month',
creationDate: contributionDateFormatter(now),
},
})
await mutate({
mutation: adminCreateContribution,
variables: {
email: 'bibi@bloxberg.de',
amount: 160.0,
memo: 'Admin contribution for the last month',
creationDate: contributionDateFormatter(
new Date(now.getFullYear(), now.getMonth() - 1, now.getDate()),
),
},
})
await mutate({
mutation: adminCreateContribution,
variables: {
email: 'bibi@bloxberg.de',
amount: 450.0,
memo: 'Admin contribution for two months ago',
creationDate: contributionDateFormatter(
new Date(now.getFullYear(), now.getMonth() - 2, now.getDate()),
),
},
})
await mutate({
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
await mutate({
mutation: createContribution,
variables: {
amount: 400.0,
memo: 'Contribution for this month',
creationDate: contributionDateFormatter(now),
},
})
await mutate({
mutation: createContribution,
variables: {
amount: 500.0,
memo: 'Contribution for the last month',
creationDate: contributionDateFormatter(
new Date(now.getFullYear(), now.getMonth() - 1, now.getDate()),
),
},
})
})
it('has the correct data setup', async () => {
await expect(Contribution.find()).resolves.toEqual([
expect.objectContaining({
userId: user.id,
contributionDate: setZeroHours(now),
amount: expect.decimalEqual(250),
memo: 'Admin contribution for this month',
moderatorId: admin.id,
contributionType: 'ADMIN',
contributionStatus: 'PENDING',
}),
expect.objectContaining({
userId: user.id,
contributionDate: setZeroHours(
new Date(now.getFullYear(), now.getMonth() - 1, now.getDate()),
),
amount: expect.decimalEqual(160),
memo: 'Admin contribution for the last month',
moderatorId: admin.id,
contributionType: 'ADMIN',
contributionStatus: 'PENDING',
}),
expect.objectContaining({
userId: user.id,
contributionDate: setZeroHours(
new Date(now.getFullYear(), now.getMonth() - 2, now.getDate()),
),
amount: expect.decimalEqual(450),
memo: 'Admin contribution for two months ago',
moderatorId: admin.id,
contributionType: 'ADMIN',
contributionStatus: 'PENDING',
}),
expect.objectContaining({
userId: user.id,
contributionDate: setZeroHours(now),
amount: expect.decimalEqual(400),
memo: 'Contribution for this month',
moderatorId: null,
contributionType: 'USER',
contributionStatus: 'PENDING',
}),
expect.objectContaining({
userId: user.id,
contributionDate: setZeroHours(
new Date(now.getFullYear(), now.getMonth() - 1, now.getDate()),
),
amount: expect.decimalEqual(500),
memo: 'Contribution for the last month',
moderatorId: null,
contributionType: 'USER',
contributionStatus: 'PENDING',
}),
])
})
describe('call getUserCreation now', () => {
it('returns the expected open contributions', async () => {
await expect(getUserCreation(user.id, 0)).resolves.toEqual([
expect.decimalEqual(550),
expect.decimalEqual(340),
expect.decimalEqual(350),
])
})
describe('run forward in time one hour before next month', () => {
const targetDate = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 0, 0)
beforeAll(() => {
jest.useFakeTimers()
setTimeout(jest.fn(), targetDate.getTime() - now.getTime())
jest.runAllTimers()
})
afterAll(() => {
jest.useRealTimers()
})
it('has the clock set correctly', () => {
expect(new Date().toISOString()).toContain(
`${targetDate.getFullYear()}-${targetDate.getMonth() + 1}-${targetDate.getDate()}T23:`,
)
})
describe('call getUserCreation with UTC', () => {
it('returns the expected open contributions', async () => {
await expect(getUserCreation(user.id, 0)).resolves.toEqual([
expect.decimalEqual(550),
expect.decimalEqual(340),
expect.decimalEqual(350),
])
})
})
describe('call getUserCreation with JST (GMT+0900)', () => {
it('returns the expected open contributions', async () => {
await expect(getUserCreation(user.id, -540, true)).resolves.toEqual([
expect.decimalEqual(340),
expect.decimalEqual(350),
expect.decimalEqual(1000),
])
})
})
describe('call getUserCreation with PST (GMT-0800)', () => {
it('returns the expected open contributions', async () => {
await expect(getUserCreation(user.id, 480, true)).resolves.toEqual([
expect.decimalEqual(550),
expect.decimalEqual(340),
expect.decimalEqual(350),
])
})
})
describe('run two hours forward to be in the next month in UTC', () => {
const nextMonthTargetDate = new Date()
nextMonthTargetDate.setTime(targetDate.getTime() + 2 * 60 * 60 * 1000)
beforeAll(() => {
setTimeout(jest.fn(), 2 * 60 * 60 * 1000)
jest.runAllTimers()
})
it('has the clock set correctly', () => {
expect(new Date().toISOString()).toContain(
`${nextMonthTargetDate.getFullYear()}-${nextMonthTargetDate.getMonth() + 1}-01T01:`,
)
})
describe('call getUserCreation with UTC', () => {
it('returns the expected open contributions', async () => {
await expect(getUserCreation(user.id, 0, true)).resolves.toEqual([
expect.decimalEqual(340),
expect.decimalEqual(350),
expect.decimalEqual(1000),
])
})
})
describe('call getUserCreation with JST (GMT+0900)', () => {
it('returns the expected open contributions', async () => {
await expect(getUserCreation(user.id, -540, true)).resolves.toEqual([
expect.decimalEqual(340),
expect.decimalEqual(350),
expect.decimalEqual(1000),
])
})
})
describe('call getUserCreation with PST (GMT-0800)', () => {
it('returns the expected open contributions', async () => {
await expect(getUserCreation(user.id, 450, true)).resolves.toEqual([
expect.decimalEqual(550),
expect.decimalEqual(340),
expect.decimalEqual(350),
])
})
})
})
})
})
})
})

View File

@ -13,9 +13,10 @@ export const validateContribution = (
creations: Decimal[], creations: Decimal[],
amount: Decimal, amount: Decimal,
creationDate: Date, creationDate: Date,
timezoneOffset: number,
): void => { ): void => {
logger.trace('isContributionValid: ', creations, amount, creationDate) logger.trace('isContributionValid: ', creations, amount, creationDate)
const index = getCreationIndex(creationDate.getMonth()) const index = getCreationIndex(creationDate.getMonth(), timezoneOffset)
if (index < 0) { if (index < 0) {
logger.error( logger.error(
@ -37,10 +38,11 @@ export const validateContribution = (
export const getUserCreations = async ( export const getUserCreations = async (
ids: number[], ids: number[],
timezoneOffset: number,
includePending = true, includePending = true,
): Promise<CreationMap[]> => { ): Promise<CreationMap[]> => {
logger.trace('getUserCreations:', ids, includePending) logger.trace('getUserCreations:', ids, includePending)
const months = getCreationMonths() const months = getCreationMonths(timezoneOffset)
logger.trace('getUserCreations months', months) logger.trace('getUserCreations months', months)
const queryRunner = getConnection().createQueryRunner() const queryRunner = getConnection().createQueryRunner()
@ -87,24 +89,29 @@ export const getUserCreations = async (
}) })
} }
export const getUserCreation = async (id: number, includePending = true): Promise<Decimal[]> => { export const getUserCreation = async (
logger.trace('getUserCreation', id, includePending) id: number,
const creations = await getUserCreations([id], includePending) timezoneOffset: number,
includePending = true,
): Promise<Decimal[]> => {
logger.trace('getUserCreation', id, includePending, timezoneOffset)
const creations = await getUserCreations([id], timezoneOffset, includePending)
logger.trace('getUserCreation creations=', creations) logger.trace('getUserCreation creations=', creations)
return creations[0] ? creations[0].creations : FULL_CREATION_AVAILABLE return creations[0] ? creations[0].creations : FULL_CREATION_AVAILABLE
} }
export const getCreationMonths = (): number[] => { const getCreationMonths = (timezoneOffset: number): number[] => {
const now = new Date(Date.now()) const clientNow = new Date()
clientNow.setTime(clientNow.getTime() - timezoneOffset * 60 * 1000)
return [ return [
now.getMonth() + 1, new Date(clientNow.getFullYear(), clientNow.getMonth() - 2, 1).getMonth() + 1,
new Date(now.getFullYear(), now.getMonth() - 1, 1).getMonth() + 1, new Date(clientNow.getFullYear(), clientNow.getMonth() - 1, 1).getMonth() + 1,
new Date(now.getFullYear(), now.getMonth() - 2, 1).getMonth() + 1, clientNow.getMonth() + 1,
].reverse() ]
} }
export const getCreationIndex = (month: number): number => { const getCreationIndex = (month: number, timezoneOffset: number): number => {
return getCreationMonths().findIndex((el) => el === month + 1) return getCreationMonths(timezoneOffset).findIndex((el) => el === month + 1)
} }
export const isStartEndDateValid = ( export const isStartEndDateValid = (
@ -128,8 +135,12 @@ export const isStartEndDateValid = (
} }
} }
export const updateCreations = (creations: Decimal[], contribution: Contribution): Decimal[] => { export const updateCreations = (
const index = getCreationIndex(contribution.contributionDate.getMonth()) creations: Decimal[],
contribution: Contribution,
timezoneOffset: number,
): Decimal[] => {
const index = getCreationIndex(contribution.contributionDate.getMonth(), timezoneOffset)
if (index < 0) { if (index < 0) {
throw new Error('You cannot create GDD for a month older than the last three months.') throw new Error('You cannot create GDD for a month older than the last three months.')
@ -137,3 +148,7 @@ export const updateCreations = (creations: Decimal[], contribution: Contribution
creations[index] = creations[index].plus(contribution.amount.toString()) creations[index] = creations[index].plus(contribution.amount.toString())
return creations return creations
} }
export const isValidDateString = (dateString: string): boolean => {
return new Date(dateString).toString() !== 'Invalid Date'
}

View File

@ -0,0 +1,15 @@
{
"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.",
"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.",
"sincerelyYours": "Mit freundlichen Grüßen,",
"subject": "Gradido: Erneuter Registrierungsversuch mit deiner E-Mail",
"yourGradidoTeam": "dein Gradido-Team"
}
}
}

View File

@ -0,0 +1,15 @@
{
"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.",
"sincerelyYours": "Sincerely yours,",
"subject": "Gradido: Try To Register Again With Your Email",
"yourGradidoTeam": "your Gradido team"
}
}
}

View File

@ -1,31 +0,0 @@
import CONFIG from '@/config'
import { sendAccountMultiRegistrationEmail } from './sendAccountMultiRegistrationEmail'
import { sendEMail } from './sendEMail'
jest.mock('./sendEMail', () => {
return {
__esModule: true,
sendEMail: jest.fn(),
}
})
describe('sendAccountMultiRegistrationEmail', () => {
beforeEach(async () => {
await sendAccountMultiRegistrationEmail({
firstName: 'Peter',
lastName: 'Lustig',
email: 'peter@lustig.de',
})
})
it('calls sendEMail', () => {
expect(sendEMail).toBeCalledWith({
to: `Peter Lustig <peter@lustig.de>`,
subject: 'Gradido: Erneuter Registrierungsversuch mit deiner E-Mail',
text:
expect.stringContaining('Hallo Peter Lustig') &&
expect.stringContaining(CONFIG.EMAIL_LINK_FORGOTPASSWORD) &&
expect.stringContaining('https://gradido.net/de/contact/'),
})
})
})

View File

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

View File

@ -26,12 +26,12 @@ describe('sendAddedContributionMessageEmail', () => {
it('calls sendEMail', () => { it('calls sendEMail', () => {
expect(sendEMail).toBeCalledWith({ expect(sendEMail).toBeCalledWith({
to: `Bibi Bloxberg <bibi@bloxberg.de>`, to: `Bibi Bloxberg <bibi@bloxberg.de>`,
subject: 'Rückfrage zu Deinem Gemeinwohl-Beitrag', subject: 'Nachricht zu deinem Gemeinwohl-Beitrag',
text: text:
expect.stringContaining('Hallo Bibi Bloxberg') && expect.stringContaining('Hallo Bibi Bloxberg') &&
expect.stringContaining('Peter Lustig') && expect.stringContaining('Peter Lustig') &&
expect.stringContaining( expect.stringContaining(
'Du hast soeben zu deinem eingereichten Gradido Schöpfungsantrag "Vielen herzlichen Dank für den neuen Hexenbesen!" eine Rückfrage von Peter Lustig erhalten.', '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('Was für ein Besen ist es geworden?') &&
expect.stringContaining('http://localhost/overview'), expect.stringContaining('http://localhost/overview'),

View File

@ -26,11 +26,11 @@ describe('sendContributionConfirmedEmail', () => {
it('calls sendEMail', () => { it('calls sendEMail', () => {
expect(sendEMail).toBeCalledWith({ expect(sendEMail).toBeCalledWith({
to: 'Bibi Bloxberg <bibi@bloxberg.de>', to: 'Bibi Bloxberg <bibi@bloxberg.de>',
subject: 'Schöpfung wurde bestätigt', subject: 'Dein Gemeinwohl-Beitrag wurde bestätigt',
text: text:
expect.stringContaining('Hallo Bibi Bloxberg') && expect.stringContaining('Hallo Bibi Bloxberg') &&
expect.stringContaining( expect.stringContaining(
'Dein Gradido Schöpfungsantrag "Vielen herzlichen Dank für den neuen Hexenbesen!" wurde soeben bestätigt.', '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('Betrag: 200,00 GDD') &&
expect.stringContaining('Link zu deinem Konto: http://localhost/overview'), expect.stringContaining('Link zu deinem Konto: http://localhost/overview'),

View File

@ -26,11 +26,11 @@ describe('sendContributionConfirmedEmail', () => {
it('calls sendEMail', () => { it('calls sendEMail', () => {
expect(sendEMail).toBeCalledWith({ expect(sendEMail).toBeCalledWith({
to: 'Bibi Bloxberg <bibi@bloxberg.de>', to: 'Bibi Bloxberg <bibi@bloxberg.de>',
subject: 'Schöpfung wurde abgelehnt', subject: 'Dein Gemeinwohl-Beitrag wurde abgelehnt',
text: text:
expect.stringContaining('Hallo Bibi Bloxberg') && expect.stringContaining('Hallo Bibi Bloxberg') &&
expect.stringContaining( expect.stringContaining(
'Dein Gradido Schöpfungsantrag "Vielen herzlichen Dank für den neuen Hexenbesen!" wurde soeben von Peter Lustig abgelehnt.', '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'), expect.stringContaining('Link zu deinem Konto: http://localhost/overview'),
}) })

View File

@ -38,7 +38,7 @@ describe('sendEMail', () => {
}) })
}) })
it('logs warining', () => { it('logs warning', () => {
expect(logger.info).toBeCalledWith('Emails are disabled via config...') expect(logger.info).toBeCalledWith('Emails are disabled via config...')
}) })

View File

@ -26,7 +26,7 @@ describe('sendTransactionReceivedEmail', () => {
it('calls sendEMail', () => { it('calls sendEMail', () => {
expect(sendEMail).toBeCalledWith({ expect(sendEMail).toBeCalledWith({
to: `Peter Lustig <peter@lustig.de>`, to: `Peter Lustig <peter@lustig.de>`,
subject: 'Gradido Überweisung', subject: 'Du hast Gradidos erhalten',
text: text:
expect.stringContaining('Hallo Peter Lustig') && expect.stringContaining('Hallo Peter Lustig') &&
expect.stringContaining('42,00 GDD') && expect.stringContaining('42,00 GDD') &&

View File

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

View File

@ -1,6 +1,6 @@
export const contributionMessageReceived = { export const contributionMessageReceived = {
de: { de: {
subject: 'Rückfrage zu Deinem Gemeinwohl-Beitrag', subject: 'Nachricht zu deinem Gemeinwohl-Beitrag',
text: (data: { text: (data: {
senderFirstName: string senderFirstName: string
senderLastName: string senderLastName: string
@ -14,15 +14,15 @@ export const contributionMessageReceived = {
}): string => }): string =>
`Hallo ${data.recipientFirstName} ${data.recipientLastName}, `Hallo ${data.recipientFirstName} ${data.recipientLastName},
du hast soeben zu deinem eingereichten Gemeinwohl-Beitrag "${data.contributionMemo}" eine Rückfrage von ${data.senderFirstName} ${data.senderLastName} erhalten. du hast zu deinem Gemeinwohl-Beitrag "${data.contributionMemo}" eine Nachricht von ${data.senderFirstName} ${data.senderLastName} erhalten.
Bitte beantworte die Rückfrage in deinem Gradido-Konto im Menü "Gemeinschaft" im Tab "Meine Beiträge zum Gemeinwohl"! 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} Link zu deinem Konto: ${data.overviewURL}
Bitte antworte nicht auf diese E-Mail! Bitte antworte nicht auf diese E-Mail!
Mit freundlichen Grüßen, Liebe Grüße
dein Gradido-Team`, dein Gradido-Team`,
}, },
} }

View File

@ -2,7 +2,7 @@ import Decimal from 'decimal.js-light'
export const contributionRejected = { export const contributionRejected = {
de: { de: {
subject: 'Schöpfung wurde abgelehnt', subject: 'Dein Gemeinwohl-Beitrag wurde abgelehnt',
text: (data: { text: (data: {
senderFirstName: string senderFirstName: string
senderLastName: string senderLastName: string
@ -14,14 +14,15 @@ export const contributionRejected = {
}): string => }): string =>
`Hallo ${data.recipientFirstName} ${data.recipientLastName}, `Hallo ${data.recipientFirstName} ${data.recipientLastName},
Dein eingereichter Gemeinwohl-Beitrag "${data.contributionMemo}" wurde soeben von ${data.senderFirstName} ${data.senderLastName} abgelehnt. 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! Bitte antworte nicht auf diese E-Mail!
Mit freundlichen Grüßen, Liebe Grüße
dein Gradido-Team dein Gradido-Team`,
Link zu deinem Konto: ${data.overviewURL}`,
}, },
} }

View File

@ -14,7 +14,7 @@ export const transactionLinkRedeemed = {
memo: string memo: string
overviewURL: string overviewURL: string
}): string => }): string =>
`Hallo ${data.recipientFirstName} ${data.recipientLastName} `Hallo ${data.recipientFirstName} ${data.recipientLastName},
${data.senderFirstName} ${data.senderLastName} (${ ${data.senderFirstName} ${data.senderLastName} (${
data.senderEmail data.senderEmail
@ -27,7 +27,7 @@ export const transactionLinkRedeemed = {
Bitte antworte nicht auf diese E-Mail! Bitte antworte nicht auf diese E-Mail!
Mit freundlichen Grüßen, Liebe Grüße
dein Gradido-Team`, dein Gradido-Team`,
}, },
} }

View File

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

View File

@ -29,6 +29,7 @@ const context = {
// eslint-disable-next-line @typescript-eslint/no-empty-function // eslint-disable-next-line @typescript-eslint/no-empty-function
forEach: (): void => {}, forEach: (): void => {},
}, },
clientTimezoneOffset: 0,
} }
export const cleanDB = async () => { export const cleanDB = async () => {

View File

@ -9,7 +9,7 @@ export interface Context {
setHeaders: { key: string; value: string }[] setHeaders: { key: string; value: string }[]
role?: Role role?: Role
user?: dbUser user?: dbUser
clientRequestTime?: string clientTimezoneOffset?: number
// hack to use less DB calls for Balance Resolver // hack to use less DB calls for Balance Resolver
lastTransaction?: dbTransaction lastTransaction?: dbTransaction
transactionCount?: number transactionCount?: number
@ -19,7 +19,7 @@ export interface Context {
const context = (args: ExpressContext): Context => { const context = (args: ExpressContext): Context => {
const authorization = args.req.headers.authorization const authorization = args.req.headers.authorization
const clientRequestTime = args.req.headers.clientrequesttime const clientTimezoneOffset = args.req.headers.clienttimezoneoffset
const context: Context = { const context: Context = {
token: null, token: null,
setHeaders: [], setHeaders: [],
@ -27,8 +27,8 @@ const context = (args: ExpressContext): Context => {
if (authorization) { if (authorization) {
context.token = authorization.replace(/^Bearer /, '') context.token = authorization.replace(/^Bearer /, '')
} }
if (clientRequestTime && typeof clientRequestTime === 'string') { if (clientTimezoneOffset && typeof clientTimezoneOffset === 'string') {
context.clientRequestTime = clientRequestTime context.clientTimezoneOffset = Number(clientTimezoneOffset)
} }
return context return context
} }
@ -38,4 +38,14 @@ export const getUser = (context: Context): dbUser => {
throw new Error('No user given in context!') throw new Error('No user given in context!')
} }
export const getClientTimezoneOffset = (context: Context): number => {
if (
(context.clientTimezoneOffset || context.clientTimezoneOffset === 0) &&
Math.abs(context.clientTimezoneOffset) <= 27 * 60
) {
return context.clientTimezoneOffset
}
throw new Error('No valid client time zone offset in context!')
}
export default context export default context

View File

@ -25,6 +25,9 @@ import { Connection } from '@dbTools/typeorm'
import { apolloLogger } from './logger' import { apolloLogger } from './logger'
import { Logger } from 'log4js' import { Logger } from 'log4js'
// i18n
import { i18n } from './localization'
// TODO implement // TODO implement
// import queryComplexity, { simpleEstimator, fieldConfigEstimator } from "graphql-query-complexity"; // import queryComplexity, { simpleEstimator, fieldConfigEstimator } from "graphql-query-complexity";
@ -34,6 +37,7 @@ const createServer = async (
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
context: any = serverContext, context: any = serverContext,
logger: Logger = apolloLogger, logger: Logger = apolloLogger,
localization: i18n.I18n = i18n,
): Promise<ServerDef> => { ): Promise<ServerDef> => {
logger.addContext('user', 'unknown') logger.addContext('user', 'unknown')
logger.debug('createServer...') logger.debug('createServer...')
@ -63,6 +67,9 @@ const createServer = async (
// bodyparser urlencoded for elopage // bodyparser urlencoded for elopage
app.use(express.urlencoded({ extended: true })) app.use(express.urlencoded({ extended: true }))
// i18n
app.use(localization.init)
// Elopage Webhook // Elopage Webhook
app.post('/hook/elopage/' + CONFIG.WEBHOOK_ELOPAGE_SECRET, elopageWebhook) app.post('/hook/elopage/' + CONFIG.WEBHOOK_ELOPAGE_SECRET, elopageWebhook)
@ -80,6 +87,7 @@ const createServer = async (
`running with PRODUCTION=${CONFIG.PRODUCTION}, sending EMAIL enabled=${CONFIG.EMAIL} and EMAIL_TEST_MODUS=${CONFIG.EMAIL_TEST_MODUS} ...`, `running with PRODUCTION=${CONFIG.PRODUCTION}, sending EMAIL enabled=${CONFIG.EMAIL} and EMAIL_TEST_MODUS=${CONFIG.EMAIL_TEST_MODUS} ...`,
) )
logger.debug('createServer...successful') logger.debug('createServer...successful')
return { apollo, app, con } return { apollo, app, con }
} }

View File

@ -0,0 +1,28 @@
import path from 'path'
import { backendLogger } from './logger'
import i18n from 'i18n'
i18n.configure({
locales: ['en', 'de'],
defaultLocale: 'en',
retryInDefaultLocale: false,
directory: path.join(__dirname, '..', 'locales'),
// autoReload: true, // if this is activated the seeding hangs at the very end
updateFiles: false,
objectNotation: true,
logDebugFn: (msg) => backendLogger.debug(msg),
logWarnFn: (msg) => backendLogger.info(msg),
logErrorFn: (msg) => backendLogger.error(msg),
// this api is needed for email-template pug files
api: {
__: 't', // now req.__ becomes req.t
__n: 'tn', // and req.__n can be called as req.tn
},
register: global,
mustacheConfig: {
tags: ['{', '}'],
disable: false,
},
})
export { i18n }

View File

@ -16,6 +16,7 @@ const context = {
push: headerPushMock, push: headerPushMock,
forEach: jest.fn(), forEach: jest.fn(),
}, },
clientTimezoneOffset: 0,
} }
export const cleanDB = async () => { export const cleanDB = async () => {
@ -25,8 +26,8 @@ export const cleanDB = async () => {
} }
} }
export const testEnvironment = async (logger?: any) => { export const testEnvironment = async (logger?: any, localization?: any) => {
const server = await createServer(context, logger) const server = await createServer(context, logger, localization)
const con = server.con const con = server.con
const testClient = createTestClient(server.apollo) const testClient = createTestClient(server.apollo)
const mutate = testClient.mutate const mutate = testClient.mutate
@ -46,3 +47,12 @@ export const resetEntity = async (entity: any) => {
export const resetToken = () => { export const resetToken = () => {
context.token = '' context.token = ''
} }
// format date string as it comes from the frontend for the contribution date
export const contributionDateFormatter = (date: Date): string => {
return `${date.getMonth() + 1}/${date.getDate()}/${date.getFullYear()}`
}
export const setClientTimezoneOffset = (offset: number): void => {
context.clientTimezoneOffset = offset
}

View File

@ -1,4 +1,5 @@
import { backendLogger as logger } from '@/server/logger' import { backendLogger as logger } from '@/server/logger'
import { i18n } from '@/server/localization'
jest.setTimeout(1000000) jest.setTimeout(1000000)
@ -19,4 +20,18 @@ jest.mock('@/server/logger', () => {
} }
}) })
export { logger } jest.mock('@/server/localization', () => {
const originalModule = jest.requireActual('@/server/localization')
return {
__esModule: true,
...originalModule,
i18n: {
init: jest.fn(),
// configure: jest.fn(),
// __: jest.fn(),
// setLocale: jest.fn(),
},
}
})
export { logger, i18n }

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "gradido-database", "name": "gradido-database",
"version": "1.13.3", "version": "1.14.1",
"description": "Gradido Database Tool to execute database migrations", "description": "Gradido Database Tool to execute database migrations",
"main": "src/index.ts", "main": "src/index.ts",
"repository": "https://github.com/gradido/gradido/database", "repository": "https://github.com/gradido/gradido/database",

View File

@ -1,6 +1,6 @@
{ {
"name": "bootstrap-vue-gradido-wallet", "name": "bootstrap-vue-gradido-wallet",
"version": "1.13.3", "version": "1.14.1",
"private": true, "private": true,
"scripts": { "scripts": {
"start": "node run/server.js", "start": "node run/server.js",

View File

@ -67,9 +67,9 @@ describe('ContributionMessagesFormular', () => {
await wrapper.find('form').trigger('submit') await wrapper.find('form').trigger('submit')
}) })
it('emitted "get-list-contribution-messages" with data', async () => { it('emitted "get-list-contribution-messages" with false', async () => {
expect(wrapper.emitted('get-list-contribution-messages')).toEqual( expect(wrapper.emitted('get-list-contribution-messages')).toEqual(
expect.arrayContaining([expect.arrayContaining([42])]), expect.arrayContaining([expect.arrayContaining([false])]),
) )
}) })

View File

@ -51,7 +51,7 @@ export default {
}, },
}) })
.then((result) => { .then((result) => {
this.$emit('get-list-contribution-messages', this.contributionId) this.$emit('get-list-contribution-messages', false)
this.$emit('update-state', this.contributionId) this.$emit('update-state', this.contributionId)
this.form.text = '' this.form.text = ''
this.toastSuccess(this.$t('message.reply')) this.toastSuccess(this.$t('message.reply'))

View File

@ -40,16 +40,6 @@ describe('ContributionMessagesList', () => {
expect(wrapper.findComponent({ name: 'ContributionMessagesFormular' }).exists()).toBe(true) expect(wrapper.findComponent({ name: 'ContributionMessagesFormular' }).exists()).toBe(true)
}) })
describe('get List Contribution Messages', () => {
beforeEach(() => {
wrapper.vm.getListContributionMessages()
})
it('emits getListContributionMessages', async () => {
expect(wrapper.vm.$emit('get-list-contribution-messages')).toBeTruthy()
})
})
describe('update State', () => { describe('update State', () => {
beforeEach(() => { beforeEach(() => {
wrapper.vm.updateState() wrapper.vm.updateState()

View File

@ -9,7 +9,7 @@
<contribution-messages-formular <contribution-messages-formular
v-if="['PENDING', 'IN_PROGRESS'].includes(state)" v-if="['PENDING', 'IN_PROGRESS'].includes(state)"
:contributionId="contributionId" :contributionId="contributionId"
@get-list-contribution-messages="getListContributionMessages" v-on="$listeners"
@update-state="updateState" @update-state="updateState"
/> />
</b-container> </b-container>
@ -50,9 +50,6 @@ export default {
}, },
}, },
methods: { methods: {
getListContributionMessages() {
this.$emit('get-list-contribution-messages', this.contributionId)
},
updateState(id) { updateState(id) {
this.$emit('update-state', id) this.$emit('update-state', id)
}, },

View File

@ -5,9 +5,11 @@ import ContributionMessagesListItem from './ContributionMessagesListItem.vue'
const localVue = global.localVue const localVue = global.localVue
let wrapper let wrapper
const dateMock = jest.fn((d) => d)
const mocks = { const mocks = {
$t: jest.fn((t) => t), $t: jest.fn((t) => t),
$d: jest.fn((d) => d), $d: dateMock,
$store: { $store: {
state: { state: {
firstName: 'Peter', firstName: 'Peter',
@ -239,4 +241,63 @@ and here is the link to the repository: https://github.com/gradido/gradido`)
}) })
}) })
}) })
describe('contribution message type HISTORY', () => {
const propsData = {
message: {
id: 111,
message: `Sun Nov 13 2022 13:05:48 GMT+0100 (Central European Standard Time)
---
This message also contains a link: https://gradido.net/de/
---
350.00`,
createdAt: '2022-08-29T12:23:27.000Z',
updatedAt: null,
type: 'HISTORY',
userFirstName: 'Peter',
userLastName: 'Lustig',
userId: 107,
__typename: 'ContributionMessage',
},
}
const itemWrapper = () => {
return mount(ContributionMessagesListItem, {
localVue,
mocks,
propsData,
})
}
let messageField
describe('render HISTORY message', () => {
beforeEach(() => {
jest.clearAllMocks()
wrapper = itemWrapper()
messageField = wrapper.find('div.is-not-moderator.text-right > div:nth-child(4)')
})
it('renders the date', () => {
expect(dateMock).toBeCalledWith(
new Date('Sun Nov 13 2022 13:05:48 GMT+0100 (Central European Standard Time'),
'short',
)
})
it('renders the amount', () => {
expect(messageField.text()).toContain('350.00 GDD')
})
it('contains the link as text', () => {
expect(messageField.text()).toContain(
'This message also contains a link: https://gradido.net/de/',
)
})
it('contains a link to the given address', () => {
expect(messageField.find('a').attributes('href')).toBe('https://gradido.net/de/')
})
})
})
}) })

View File

@ -4,25 +4,25 @@
<b-avatar variant="info"></b-avatar> <b-avatar variant="info"></b-avatar>
<span class="ml-2 mr-2">{{ message.userFirstName }} {{ message.userLastName }}</span> <span class="ml-2 mr-2">{{ message.userFirstName }} {{ message.userLastName }}</span>
<span class="ml-2">{{ $d(new Date(message.createdAt), 'short') }}</span> <span class="ml-2">{{ $d(new Date(message.createdAt), 'short') }}</span>
<linkify-message :message="message.message"></linkify-message> <parse-message v-bind="message"></parse-message>
</div> </div>
<div v-else class="is-moderator text-left"> <div v-else class="is-moderator text-left">
<b-avatar square variant="warning"></b-avatar> <b-avatar square variant="warning"></b-avatar>
<span class="ml-2 mr-2">{{ message.userFirstName }} {{ message.userLastName }}</span> <span class="ml-2 mr-2">{{ message.userFirstName }} {{ message.userLastName }}</span>
<span class="ml-2">{{ $d(new Date(message.createdAt), 'short') }}</span> <span class="ml-2">{{ $d(new Date(message.createdAt), 'short') }}</span>
<small class="ml-4 text-success">{{ $t('community.moderator') }}</small> <small class="ml-4 text-success">{{ $t('community.moderator') }}</small>
<linkify-message :message="message.message"></linkify-message> <parse-message v-bind="message"></parse-message>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import LinkifyMessage from '@/components/ContributionMessages/LinkifyMessage.vue' import ParseMessage from '@/components/ContributionMessages/ParseMessage.vue'
export default { export default {
name: 'ContributionMessagesListItem', name: 'ContributionMessagesListItem',
components: { components: {
LinkifyMessage, ParseMessage,
}, },
props: { props: {
message: { message: {

View File

@ -1,7 +1,15 @@
<template> <template>
<div class="mt-2"> <div class="mt-2">
<span v-for="({ type, text }, index) in linkifiedMessage" :key="index"> <span v-for="({ type, text }, index) in parsedMessage" :key="index">
<b-link v-if="type === 'link'" :href="text" target="_blank">{{ text }}</b-link> <b-link v-if="type === 'link'" :href="text" target="_blank">{{ text }}</b-link>
<span v-else-if="type === 'date'">
{{ $d(new Date(text), 'short') }}
<br />
</span>
<span v-else-if="type === 'amount'">
<br />
{{ text | GDD }}
</span>
<span v-else>{{ text }}</span> <span v-else>{{ text }}</span>
</span> </span>
</div> </div>
@ -11,17 +19,28 @@
const LINK_REGEX_PATTERN = /(https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*))/i const LINK_REGEX_PATTERN = /(https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*))/i
export default { export default {
name: 'LinkifyMessage', name: 'ParseMessage',
props: { props: {
message: { message: {
type: String, type: String,
required: true, required: true,
}, },
type: {
type: String,
reuired: true,
},
}, },
computed: { computed: {
linkifiedMessage() { parsedMessage() {
const linkified = []
let string = this.message let string = this.message
const linkified = []
let amount
if (this.type === 'HISTORY') {
const split = string.split(/\n\s*---\n\s*/)
string = split[1]
linkified.push({ type: 'date', text: split[0].trim() })
amount = split[2].trim()
}
let match let match
while ((match = string.match(LINK_REGEX_PATTERN))) { while ((match = string.match(LINK_REGEX_PATTERN))) {
if (match.index > 0) if (match.index > 0)
@ -30,6 +49,7 @@ export default {
string = string.substring(match.index + match[0].length) string = string.substring(match.index + match[0].length)
} }
if (string.length > 0) linkified.push({ type: 'text', text: string }) if (string.length > 0) linkified.push({ type: 'text', text: string })
if (amount) linkified.push({ type: 'amount', text: amount })
return linkified return linkified
}, },
}, },

View File

@ -3,6 +3,7 @@
<div class="list-group" v-for="item in items" :key="item.id"> <div class="list-group" v-for="item in items" :key="item.id">
<contribution-list-item <contribution-list-item
v-bind="item" v-bind="item"
@closeAllOpenCollapse="$emit('closeAllOpenCollapse')"
:contributionId="item.id" :contributionId="item.id"
:allContribution="allContribution" :allContribution="allContribution"
@update-contribution-form="updateContributionForm" @update-contribution-form="updateContributionForm"

View File

@ -9,6 +9,7 @@ describe('ContributionListItem', () => {
const mocks = { const mocks = {
$t: jest.fn((t) => t), $t: jest.fn((t) => t),
$d: jest.fn((d) => d), $d: jest.fn((d) => d),
$apollo: { query: jest.fn().mockResolvedValue() },
} }
const propsData = { const propsData = {
@ -132,6 +133,27 @@ describe('ContributionListItem', () => {
expect(wrapper.emitted('delete-contribution')).toBeFalsy() expect(wrapper.emitted('delete-contribution')).toBeFalsy()
}) })
}) })
describe('updateState', () => {
beforeEach(async () => {
await wrapper.vm.updateState()
})
it('emit update-state', () => {
expect(wrapper.vm.$emit('update-state')).toBeTruthy()
})
})
})
describe('getListContributionMessages', () => {
beforeEach(() => {
wrapper
.findComponent({ name: 'ContributionMessagesList' })
.vm.$emit('get-list-contribution-messages')
})
it('emits closeAllOpenCollapse', () => {
expect(wrapper.emitted('closeAllOpenCollapse')).toBeTruthy()
})
}) })
}) })
}) })

View File

@ -32,6 +32,7 @@
v-if="!['CONFIRMED', 'DELETED'].includes(state) && !allContribution" v-if="!['CONFIRMED', 'DELETED'].includes(state) && !allContribution"
class="pointer ml-5" class="pointer ml-5"
@click=" @click="
$emit('closeAllOpenCollapse'),
$emit('update-contribution-form', { $emit('update-contribution-form', {
id: id, id: id,
contributionDate: contributionDate, contributionDate: contributionDate,
@ -178,8 +179,10 @@ export default {
if (value) this.$emit('delete-contribution', item) if (value) this.$emit('delete-contribution', item)
}) })
}, },
getListContributionMessages() { getListContributionMessages(closeCollapse = true) {
// console.log('getListContributionMessages', this.contributionId) if (closeCollapse) {
this.$emit('closeAllOpenCollapse')
}
this.$apollo this.$apollo
.query({ .query({
query: listContributionMessages, query: listContributionMessages,

View File

@ -2,7 +2,7 @@
<div class="community-page"> <div class="community-page">
<div> <div>
<b-tabs v-model="tabIndex" content-class="mt-3" align="center"> <b-tabs v-model="tabIndex" content-class="mt-3" align="center">
<b-tab :title="$t('community.submitContribution')"> <b-tab :title="$t('community.submitContribution')" @click="closeAllOpenCollapse">
<contribution-form <contribution-form
@set-contribution="setContribution" @set-contribution="setContribution"
@update-contribution="updateContribution" @update-contribution="updateContribution"
@ -39,6 +39,7 @@
</b-alert> </b-alert>
</div> </div>
<contribution-list <contribution-list
@closeAllOpenCollapse="closeAllOpenCollapse"
:items="items" :items="items"
@update-list-contributions="updateListContributions" @update-list-contributions="updateListContributions"
@update-contribution-form="updateContributionForm" @update-contribution-form="updateContributionForm"
@ -49,7 +50,7 @@
:pageSize="pageSize" :pageSize="pageSize"
/> />
</b-tab> </b-tab>
<b-tab :title="$t('navigation.community')"> <b-tab :title="$t('navigation.community')" @click="closeAllOpenCollapse">
<b-alert show dismissible fade variant="secondary" class="text-dark"> <b-alert show dismissible fade variant="secondary" class="text-dark">
<h4 class="alert-heading">{{ $t('navigation.community') }}</h4> <h4 class="alert-heading">{{ $t('navigation.community') }}</h4>
<p> <p>
@ -112,6 +113,13 @@ export default {
} }
}, },
methods: { methods: {
closeAllOpenCollapse() {
// console.log('Community closeAllOpenCollapse ')
// console.log('closeAllOpenCollapse', this.$el.querySelectorAll('.collapse.show'))
this.$el.querySelectorAll('.collapse.show').forEach((value) => {
this.$root.$emit('bv::toggle::collapse', value.id)
})
},
setContribution(data) { setContribution(data) {
this.$apollo this.$apollo
.mutate({ .mutate({

View File

@ -12,7 +12,7 @@ const authLink = new ApolloLink((operation, forward) => {
operation.setContext({ operation.setContext({
headers: { headers: {
Authorization: token && token.length > 0 ? `Bearer ${token}` : '', Authorization: token && token.length > 0 ? `Bearer ${token}` : '',
clientRequestTime: new Date().toString(), clientTimezoneOffset: new Date().getTimezoneOffset(),
}, },
}) })
return forward(operation).map((response) => { return forward(operation).map((response) => {

View File

@ -98,7 +98,7 @@ describe('apolloProvider', () => {
expect(setContextMock).toBeCalledWith({ expect(setContextMock).toBeCalledWith({
headers: { headers: {
Authorization: 'Bearer some-token', Authorization: 'Bearer some-token',
clientRequestTime: expect.any(String), clientTimezoneOffset: expect.any(Number),
}, },
}) })
}) })
@ -114,7 +114,7 @@ describe('apolloProvider', () => {
expect(setContextMock).toBeCalledWith({ expect(setContextMock).toBeCalledWith({
headers: { headers: {
Authorization: '', Authorization: '',
clientRequestTime: expect.any(String), clientTimezoneOffset: expect.any(Number),
}, },
}) })
}) })

View File

@ -1,6 +1,6 @@
{ {
"name": "gradido", "name": "gradido",
"version": "1.13.3", "version": "1.14.1",
"description": "Gradido", "description": "Gradido",
"main": "index.js", "main": "index.js",
"repository": "git@github.com:gradido/gradido.git", "repository": "git@github.com:gradido/gradido.git",