Merge branch 'master' into dlt_connector_try_dci

This commit is contained in:
einhornimmond 2023-11-21 16:45:53 +01:00 committed by GitHub
commit 695b402537
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
69 changed files with 1396 additions and 275 deletions

View File

@ -2,24 +2,23 @@
ROOT_DIR=$(dirname "$0")/..
tmp=$(mktemp)
exit_code=0
for locale_file in $ROOT_DIR/src/locales/*.json
do
jq -f $(dirname "$0")/sort_filter.jq $locale_file > "$tmp"
jq -M 'to_entries | sort_by(.key) | from_entries' "$locale_file" > tmp.json
if [ "$*" == "--fix" ]
then
mv "$tmp" $locale_file
mv tmp.json "$locale_file"
else
if diff -q "$tmp" $locale_file > /dev/null ;
if ! diff -q tmp.json "$locale_file" > /dev/null ;
then
: # all good
else
exit_code=$?
echo "$(basename -- $locale_file) is not sorted by keys"
exit_code=1
echo "$(basename -- "$locale_file") is not sorted by keys"
fi
fi
done
rm -f tmp.json
exit $exit_code

View File

@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils'
import ContributionMessagesFormular from './ContributionMessagesFormular'
import { toastErrorSpy, toastSuccessSpy } from '../../../test/testSetup'
import { adminCreateContributionMessage } from '@/graphql/adminCreateContributionMessage'
import { adminUpdateContribution } from '@/graphql/adminUpdateContribution'
const localVue = global.localVue
@ -12,6 +13,7 @@ describe('ContributionMessagesFormular', () => {
const propsData = {
contributionId: 42,
contributionMemo: 'It is a test memo',
}
const mocks = {
@ -52,9 +54,10 @@ describe('ContributionMessagesFormular', () => {
await wrapper.find('form').trigger('reset')
})
it('form has empty text', () => {
it('form has empty text and memo reset to contribution memo input', () => {
expect(wrapper.vm.form).toEqual({
text: '',
memo: 'It is a test memo',
})
})
})
@ -134,6 +137,32 @@ describe('ContributionMessagesFormular', () => {
})
})
describe('update contribution memo from moderator for user created contributions', () => {
beforeEach(async () => {
await wrapper.setData({
form: {
memo: 'changed memo',
},
chatOrMemo: 1,
})
await wrapper.find('button[data-test="submit-dialog"]').trigger('click')
})
it('adminUpdateContribution was called with contributionId and updated memo', () => {
expect(apolloMutateMock).toBeCalledWith({
mutation: adminUpdateContribution,
variables: {
id: 42,
memo: 'changed memo',
},
})
})
it('toasts an success message', () => {
expect(toastSuccessSpy).toBeCalledWith('message.request')
})
})
describe('send contribution message with error', () => {
beforeEach(async () => {
apolloMutateMock.mockRejectedValue({ message: 'OUCH!' })

View File

@ -2,12 +2,24 @@
<div class="contribution-messages-formular">
<div class="mt-5">
<b-form @reset.prevent="onReset" @submit="onSubmit(messageType.DIALOG)">
<b-form-textarea
id="textarea"
v-model="form.text"
:placeholder="$t('contributionLink.memo')"
rows="3"
></b-form-textarea>
<b-tabs content-class="mt-3" v-model="chatOrMemo">
<b-tab :title="$t('moderator.chat')" active>
<b-form-textarea
id="textarea"
v-model="form.text"
:placeholder="$t('contributionLink.memo')"
rows="3"
></b-form-textarea>
</b-tab>
<b-tab :title="$t('moderator.memo')">
<b-form-textarea
id="textarea"
v-model="form.memo"
:placeholder="$t('contributionLink.memo')"
rows="3"
></b-form-textarea>
</b-tab>
</b-tabs>
<b-row class="mt-4 mb-6">
<b-col>
<b-button type="reset" variant="danger">{{ $t('form.cancel') }}</b-button>
@ -17,7 +29,16 @@
type="button"
variant="warning"
class="text-black"
:disabled="disabled"
@click.prevent="enableMemo()"
data-test="submit-memo"
>
{{ $t('moderator.memo-modify') }}
</b-button>
<b-button
type="button"
variant="warning"
class="text-black"
:disabled="moderatorDisabled"
@click.prevent="onSubmit(messageType.MODERATOR)"
data-test="submit-moderator"
>
@ -43,6 +64,7 @@
</template>
<script>
import { adminCreateContributionMessage } from '@/graphql/adminCreateContributionMessage'
import { adminUpdateContribution } from '@/graphql/adminUpdateContribution'
export default {
name: 'ContributionMessagesFormular',
@ -51,13 +73,19 @@ export default {
type: Number,
required: true,
},
contributionMemo: {
type: String,
required: true,
},
},
data() {
return {
form: {
text: '',
memo: this.contributionMemo,
},
loading: false,
chatOrMemo: 0, // 0 = Chat, 1 = Memo
messageType: {
DIALOG: 'DIALOG',
MODERATOR: 'MODERATOR',
@ -67,34 +95,67 @@ export default {
methods: {
onSubmit(mType) {
this.loading = true
this.$apollo
.mutate({
mutation: adminCreateContributionMessage,
variables: {
contributionId: this.contributionId,
message: this.form.text,
messageType: mType,
},
})
.then((result) => {
this.$emit('get-list-contribution-messages', this.contributionId)
this.$emit('update-status', this.contributionId)
this.form.text = ''
this.toastSuccess(this.$t('message.request'))
this.loading = false
})
.catch((error) => {
this.toastError(error.message)
this.loading = false
})
if (this.chatOrMemo === 0) {
this.$apollo
.mutate({
mutation: adminCreateContributionMessage,
variables: {
contributionId: this.contributionId,
message: this.form.text,
messageType: mType,
},
})
.then((result) => {
this.$emit('get-list-contribution-messages', this.contributionId)
this.$emit('update-status', this.contributionId)
this.form.text = ''
this.toastSuccess(this.$t('message.request'))
this.loading = false
})
.catch((error) => {
this.toastError(error.message)
this.loading = false
})
} else {
this.$apollo
.mutate({
mutation: adminUpdateContribution,
variables: {
id: this.contributionId,
memo: this.form.memo,
},
})
.then((result) => {
this.$emit('get-list-contribution-messages', this.contributionId)
this.$emit('update-status', this.contributionId)
this.$emit('reload-contribution', this.contributionId)
this.toastSuccess(this.$t('message.request'))
this.loading = false
})
.catch((error) => {
this.toastError(error.message)
this.loading = false
})
}
},
onReset(event) {
this.form.text = ''
this.form.memo = this.contributionMemo
},
enableMemo() {
this.chatOrMemo = 1
},
},
computed: {
disabled() {
return this.form.text === '' || this.loading
return (
(this.chatOrMemo === 0 && this.form.text === '') ||
this.loading ||
(this.chatOrMemo === 1 && this.form.memo.length < 5)
)
},
moderatorDisabled() {
return this.form.text === '' || this.loading || this.chatOrMemo === 1
},
},
}

View File

@ -86,6 +86,7 @@ describe('ContributionMessagesList', () => {
const propsData = {
contributionId: 42,
contributionMemo: 'test memo',
contributionUserId: 108,
contributionStatus: 'PENDING',
}
@ -133,5 +134,26 @@ describe('ContributionMessagesList', () => {
expect(wrapper.findComponent({ name: 'ContributionMessagesFormular' }).exists()).toBe(true)
})
})
describe('call updateStatus', () => {
beforeEach(() => {
wrapper.vm.updateStatus(4)
})
it('emits update-status', () => {
expect(wrapper.vm.$root.$emit('update-status', 4)).toBeTruthy()
})
})
describe('test reload-contribution', () => {
beforeEach(() => {
wrapper.vm.reloadContribution(3)
})
it('emits reload-contribution', () => {
expect(wrapper.emitted('reload-contribution')).toBeTruthy()
expect(wrapper.emitted('reload-contribution')[0]).toEqual([3])
})
})
})
})

View File

@ -11,8 +11,10 @@
<div v-if="contributionStatus === 'PENDING' || contributionStatus === 'IN_PROGRESS'">
<contribution-messages-formular
:contributionId="contributionId"
:contributionMemo="contributionMemo"
@get-list-contribution-messages="$apollo.queries.Messages.refetch()"
@update-status="updateStatus"
@reload-contribution="reloadContribution"
/>
</div>
</div>
@ -33,6 +35,10 @@ export default {
type: Number,
required: true,
},
contributionMemo: {
type: String,
required: true,
},
contributionStatus: {
type: String,
required: true,
@ -70,6 +76,9 @@ export default {
updateStatus(id) {
this.$emit('update-status', id)
},
reloadContribution(id) {
this.$emit('reload-contribution', id)
},
},
}
</script>

View File

@ -1,5 +1,5 @@
<template>
<div class="contribution-messages-list-item">
<div class="contribution-messages-list-item clearfix">
<div v-if="isModeratorMessage" class="text-right p-2 rounded-sm mb-3" :class="boxClass">
<small class="ml-4" data-test="moderator-label">
{{ $t('moderator.moderator') }}
@ -11,7 +11,11 @@
{{ message.userFirstName }} {{ message.userLastName }}
</span>
<b-avatar square variant="warning"></b-avatar>
<small v-if="isHistory">
<hr />
{{ $t('moderator.history') }}
<hr />
</small>
<parse-message v-bind="message" data-test="moderator-message"></parse-message>
<small v-if="isModeratorHiddenMessage">
<hr />

View File

@ -38,6 +38,8 @@ const defaultData = () => {
contributionDate: new Date(),
deletedBy: null,
deletedAt: null,
updatedAt: null,
updatedBy: null,
createdAt: new Date(),
moderatorId: null,
},
@ -61,6 +63,8 @@ const defaultData = () => {
contributionDate: new Date(),
deletedBy: null,
deletedAt: null,
updatedAt: null,
updatedBy: null,
createdAt: new Date(),
moderatorId: null,
},

View File

@ -140,5 +140,16 @@ describe('OpenCreationsTable', () => {
expect(wrapper.vm.$root.$emit('update-status', 4)).toBeTruthy()
})
})
describe('test reload-contribution', () => {
beforeEach(() => {
wrapper.vm.reloadContribution(3)
})
it('emits reload-contribution', () => {
expect(wrapper.emitted('reload-contribution')).toBeTruthy()
expect(wrapper.emitted('reload-contribution')[0]).toEqual([3])
})
})
})
})

View File

@ -24,6 +24,13 @@
</b-button>
</div>
</template>
<template #cell(memo)="row">
{{ row.value }}
<small v-if="row.item.updatedBy > 0">
<hr />
{{ $t('moderator.memo-modified') }}
</small>
</template>
<template #cell(editCreation)="row">
<div v-if="!myself(row.item)">
<b-button
@ -104,7 +111,9 @@
:contributionId="row.item.id"
:contributionStatus="row.item.status"
:contributionUserId="row.item.userId"
:contributionMemo="row.item.memo"
@update-status="updateStatus"
@reload-contribution="reloadContribution"
/>
</div>
</template>
@ -164,6 +173,9 @@ export default {
updateStatus(id) {
this.$emit('update-status', id)
},
reloadContribution(id) {
this.$emit('reload-contribution', id)
},
},
}
</script>

View File

@ -30,6 +30,8 @@ export const adminListContributions = gql`
contributionDate
confirmedAt
confirmedBy
updatedAt
updatedBy
status
messagesCount
deniedAt

View File

@ -1,7 +1,7 @@
import gql from 'graphql-tag'
export const adminUpdateContribution = gql`
mutation ($id: Int!, $amount: Decimal!, $memo: String!, $creationDate: String!) {
mutation ($id: Int!, $amount: Decimal, $memo: String, $creationDate: String) {
adminUpdateContribution(id: $id, amount: $amount, memo: $memo, creationDate: $creationDate) {
amount
date

View File

@ -0,0 +1,27 @@
import gql from 'graphql-tag'
export const getContribution = gql`
query ($id: Int!) {
contribution(id: $id) {
id
firstName
lastName
amount
memo
createdAt
contributionDate
confirmedAt
confirmedBy
updatedAt
updatedBy
status
messagesCount
deniedAt
deniedBy
deletedAt
deletedBy
moderatorId
userId
}
}
`

View File

@ -1,4 +1,5 @@
{
"GDD": "GDD",
"all_emails": "Alle Nutzer",
"back": "zurück",
"change_user_role": "Nutzerrolle ändern",
@ -42,6 +43,7 @@
"createdAt": "Angelegt",
"creation": "Schöpfung",
"creationList": "Schöpfungsliste",
"creation_for_month": "Schöpfung für Monat",
"creation_form": {
"creation_for": "Aktives Grundeinkommen für",
"enter_text": "Text eintragen",
@ -58,16 +60,15 @@
"toasted_update": "`Offene Schöpfung {value} GDD) für {email} wurde geändert und liegt zur Bestätigung bereit",
"update_creation": "Schöpfung aktualisieren"
},
"creation_for_month": "Schöpfung für Monat",
"delete": "Löschen",
"delete_user": "Nutzer löschen",
"deleted": "gelöscht",
"deleted_user": "Alle gelöschten Nutzer",
"delete_user": "Nutzer löschen",
"deny": "Ablehnen",
"e_mail": "E-Mail",
"enabled": "aktiviert",
"error": "Fehler",
"expired": "abgelaufen",
"e_mail": "E-Mail",
"federation": {
"createdAt": "Erstellt am",
"gradidoInstances": "Gradido Instanzen",
@ -88,7 +89,6 @@
"cancel": "Abbrechen",
"submit": "Senden"
},
"GDD": "GDD",
"help": {
"help": "Hilfe",
"transactionlist": {
@ -109,9 +109,13 @@
"request": "Die Anfrage wurde gesendet."
},
"moderator": {
"chat": "Chat",
"history": "Die Daten wurden geändert. Dies sind die alten Daten.",
"moderator": "Moderator",
"notice": "Moderator Notiz",
"memo": "Memo",
"memo-modify": "Memo bearbeiten",
"memo-modified": "Memo vom Moderator bearbeitet",
"request": "Diese Nachricht ist nur für die Moderatoren sichtbar!"
},
"name": "Name",
@ -123,9 +127,9 @@
"statistic": "Statistik",
"user_search": "Nutzersuche"
},
"not_open_creations": "Keine offenen Schöpfungen",
"no_hashtag": "#Hashtags verbergen",
"no_hashtag_tooltip": "Zeigt nur Beiträge ohne Hashtag im Text",
"not_open_creations": "Keine offenen Schöpfungen",
"open": "offen",
"open_creations": "Offene Schöpfungen",
"overlay": {
@ -196,7 +200,6 @@
"title": "Alle geschöpften Transaktionen für den Nutzer"
},
"undelete_user": "Nutzer wiederherstellen",
"unregistered_emails": "Nur unregistrierte Nutzer",
"unregister_mail": {
"button": "Registrierungs-Email bestätigen, jetzt senden",
"error": "Fehler beim Senden des Bestätigungs-Links an den Benutzer: {message}",
@ -206,6 +209,7 @@
"text_false": " Die letzte Email wurde am {date} Uhr an das Mitglied ({email}) gesendet.",
"text_true": " Die Email wurde bestätigt."
},
"unregistered_emails": "Nur unregistrierte Nutzer",
"userRole": {
"notChangeYourSelf": "Als Admin/Moderator kannst du nicht selber deine Rolle ändern.",
"selectLabel": "Rolle:",

View File

@ -1,4 +1,5 @@
{
"GDD": "GDD",
"all_emails": "All users",
"back": "back",
"change_user_role": "Change user role",
@ -42,6 +43,7 @@
"createdAt": "Created at",
"creation": "Creation",
"creationList": "Creation list",
"creation_for_month": "Creation for month",
"creation_form": {
"creation_for": "Active Basic Income for",
"enter_text": "Enter text",
@ -58,16 +60,15 @@
"toasted_update": "Open creation {value} GDD) for {email} has been changed and is ready for confirmation.",
"update_creation": "Creation update"
},
"creation_for_month": "Creation for month",
"delete": "Delete",
"delete_user": "Delete user",
"deleted": "deleted",
"deleted_user": "All deleted user",
"delete_user": "Delete user",
"deny": "Reject",
"e_mail": "E-mail",
"enabled": "enabled",
"error": "Error",
"expired": "expired",
"e_mail": "E-mail",
"federation": {
"createdAt": "Created At ",
"gradidoInstances": "Gradido Instances",
@ -88,7 +89,6 @@
"cancel": "Cancel",
"submit": "Send"
},
"GDD": "GDD",
"help": {
"help": "Help",
"transactionlist": {
@ -109,9 +109,13 @@
"request": "Request has been sent."
},
"moderator": {
"chat": "Chat",
"history": "The data has been changed. This is the old data.",
"moderator": "Moderator",
"notice": "Moderator note",
"memo": "Memo",
"memo-modify": "Modify Memo",
"memo-modified": "Memo edited by moderator",
"request": "This message is only visible to the moderators!"
},
"name": "Name",
@ -123,9 +127,9 @@
"statistic": "Statistic",
"user_search": "User search"
},
"not_open_creations": "No open creations",
"no_hashtag": "Hide #hashtags",
"no_hashtag_tooltip": "Shows only contributions without hashtag in text",
"not_open_creations": "No open creations",
"open": "open",
"open_creations": "Open creations",
"overlay": {
@ -196,7 +200,6 @@
"title": "All creation-transactions for the user"
},
"undelete_user": "Undelete User",
"unregistered_emails": "Only unregistered users",
"unregister_mail": {
"button": "Confirm registration email, send now",
"error": "Error sending the confirmation link to the user: {message}",
@ -206,6 +209,7 @@
"text_false": "The last email was sent to the member ({email}) on {date}.",
"text_true": "The email was confirmed."
},
"unregistered_emails": "Only unregistered users",
"userRole": {
"notChangeYourSelf": "As an admin/moderator, you cannot change your own role.",
"selectLabel": "Role:",

View File

@ -4,6 +4,7 @@ import { adminDeleteContribution } from '../graphql/adminDeleteContribution'
import { denyContribution } from '../graphql/denyContribution'
import { adminListContributions } from '../graphql/adminListContributions'
import { confirmContribution } from '../graphql/confirmContribution'
import { getContribution } from '../graphql/getContribution'
import { toastErrorSpy, toastSuccessSpy } from '../../test/testSetup'
import VueApollo from 'vue-apollo'
import { createMockClient } from 'mock-apollo-client'
@ -61,6 +62,8 @@ const defaultData = () => {
contributionDate: new Date(),
deletedBy: null,
deletedAt: null,
updatedAt: null,
updatedBy: null,
createdAt: new Date(),
},
{
@ -83,6 +86,8 @@ const defaultData = () => {
contributionDate: new Date(),
deletedBy: null,
deletedAt: null,
updatedAt: null,
updatedBy: null,
createdAt: new Date(),
},
],
@ -96,6 +101,7 @@ describe('CreationConfirm', () => {
const adminDeleteContributionMock = jest.fn()
const adminDenyContributionMock = jest.fn()
const confirmContributionMock = jest.fn()
const getContributionMock = jest.fn()
mockClient.setRequestHandler(
adminListContributions,
@ -121,6 +127,8 @@ describe('CreationConfirm', () => {
confirmContributionMock.mockResolvedValue({ data: { confirmContribution: true } }),
)
mockClient.setRequestHandler(getContribution, getContributionMock.mockResolvedValue({ data: {} }))
const Wrapper = () => {
return mount(CreationConfirm, { localVue, mocks, apolloProvider })
}
@ -141,7 +149,7 @@ describe('CreationConfirm', () => {
})
})
describe('server response is succes', () => {
describe('server response is success', () => {
it('has a DIV element with the class.creation-confirm', () => {
expect(wrapper.find('div.creation-confirm').exists()).toBeTruthy()
})
@ -219,7 +227,7 @@ describe('CreationConfirm', () => {
expect(wrapper.find('#overlay').isVisible()).toBeTruthy()
})
describe('with succes', () => {
describe('with success', () => {
describe('cancel confirmation', () => {
beforeEach(async () => {
await wrapper.find('#overlay').findAll('button').at(0).trigger('click')
@ -278,7 +286,7 @@ describe('CreationConfirm', () => {
expect(wrapper.find('#overlay').isVisible()).toBeTruthy()
})
describe('with succes', () => {
describe('with success', () => {
describe('cancel deny', () => {
beforeEach(async () => {
await wrapper.find('#overlay').findAll('button').at(0).trigger('click')
@ -510,6 +518,20 @@ describe('CreationConfirm', () => {
})
})
describe('reload contribution', () => {
beforeEach(async () => {
await wrapper
.findComponent({ name: 'OpenCreationsTable' })
.vm.$emit('reload-contribution', 1)
})
it('reloaded contribution', () => {
expect(getContributionMock).toBeCalledWith({
id: 1,
})
})
})
describe('unknown variant', () => {
beforeEach(async () => {
await wrapper.setData({ variant: 'unknown' })

View File

@ -3,7 +3,7 @@
<div class="creation-confirm">
<user-query class="mb-2 mt-2" v-model="query" :placeholder="$t('user_memo_search')" />
<label class="mb-4">
<input type="checkbox" class="noHashtag" v-model="noHashtag" @change="swapNoHashtag" />
<input type="checkbox" class="noHashtag" v-model="noHashtag" />
<span class="ml-2" v-b-tooltip="$t('no_hashtag_tooltip')">{{ $t('no_hashtag') }}</span>
</label>
<div>
@ -49,6 +49,7 @@
:fields="fields"
@show-overlay="showOverlay"
@update-status="updateStatus"
@reload-contribution="reloadContribution"
@update-contributions="$apollo.queries.ListAllContributions.refetch()"
/>
@ -95,6 +96,7 @@ import { adminListContributions } from '../graphql/adminListContributions'
import { adminDeleteContribution } from '../graphql/adminDeleteContribution'
import { confirmContribution } from '../graphql/confirmContribution'
import { denyContribution } from '../graphql/denyContribution'
import { getContribution } from '../graphql/getContribution'
const FILTER_TAB_MAP = [
['IN_PROGRESS', 'PENDING'],
@ -131,8 +133,21 @@ export default {
},
},
methods: {
swapNoHashtag() {
this.query()
reloadContribution(id) {
this.$apollo
.query({ query: getContribution, variables: { id } })
.then((result) => {
const contribution = result.data.contribution
this.$set(
this.items,
this.items.findIndex((obj) => obj.id === contribution.id),
contribution,
)
})
.catch((error) => {
this.overlay = false
this.toastError(error.message)
})
},
deleteCreation() {
this.$apollo

View File

@ -53,6 +53,8 @@ const defaultData = () => {
contributionDate: new Date(),
deletedBy: null,
deletedAt: null,
updatedAt: null,
updatedBy: null,
createdAt: new Date(),
},
{
@ -75,6 +77,8 @@ const defaultData = () => {
contributionDate: new Date(),
deletedBy: null,
deletedAt: null,
updatedAt: null,
updatedBy: null,
createdAt: new Date(),
},
],

View File

@ -8,7 +8,7 @@
"license": "Apache-2.0",
"private": false,
"scripts": {
"build": "tsc --build && mkdir -p build/src/emails/templates/ && cp -r src/emails/templates/* build/src/emails/templates/ && mkdir -p build/src/locales/ && cp -r src/locales/*.json build/src/locales/",
"build": "tsc --build && mkdirp build/src/emails/templates/ && ncp src/emails/templates build/src/emails/templates && mkdirp build/src/locales/ && ncp src/locales build/src/locales",
"clean": "tsc --build --clean",
"start": "cross-env TZ=UTC TS_NODE_BASEURL=./build node -r tsconfig-paths/register build/src/index.js",
"dev": "cross-env TZ=UTC nodemon -w src --ext ts,pug,json,css --exec ts-node -r tsconfig-paths/register src/index.ts",
@ -80,7 +80,9 @@
"ts-jest": "^27.0.5",
"ts-node": "^10.0.0",
"tsconfig-paths": "^3.14.0",
"typescript": "^4.3.4"
"typescript": "^4.3.4",
"mkdirp": "^3.0.1",
"ncp": "^2.0.0"
},
"nodemonConfig": {
"ignore": [

View File

@ -2,24 +2,23 @@
ROOT_DIR=$(dirname "$0")/..
tmp=$(mktemp)
exit_code=0
for locale_file in $ROOT_DIR/src/locales/*.json
do
jq -f $(dirname "$0")/sort_filter.jq $locale_file > "$tmp"
jq -M 'to_entries | sort_by(.key) | from_entries' "$locale_file" > tmp.json
if [ "$*" == "--fix" ]
then
mv "$tmp" $locale_file
mv tmp.json "$locale_file"
else
if diff -q "$tmp" $locale_file > /dev/null ;
if ! diff -q tmp.json "$locale_file" > /dev/null ;
then
: # all good
else
exit_code=$?
echo "$(basename -- $locale_file) is not sorted by keys"
exit_code=1
echo "$(basename -- "$locale_file") is not sorted by keys"
fi
fi
done
rm -f tmp.json
exit $exit_code

View File

@ -13,6 +13,7 @@ export const MODERATOR_RIGHTS = [
RIGHTS.DELETE_CONTRIBUTION_LINK,
RIGHTS.UPDATE_CONTRIBUTION_LINK,
RIGHTS.ADMIN_CREATE_CONTRIBUTION_MESSAGE,
RIGHTS.MODERATOR_UPDATE_CONTRIBUTION_MEMO,
RIGHTS.ADMIN_LIST_ALL_CONTRIBUTION_MESSAGES,
RIGHTS.DENY_CONTRIBUTION,
RIGHTS.ADMIN_OPEN_CREATIONS,

View File

@ -50,6 +50,7 @@ export enum RIGHTS {
DELETE_CONTRIBUTION_LINK = 'DELETE_CONTRIBUTION_LINK',
UPDATE_CONTRIBUTION_LINK = 'UPDATE_CONTRIBUTION_LINK',
ADMIN_CREATE_CONTRIBUTION_MESSAGE = 'ADMIN_CREATE_CONTRIBUTION_MESSAGE',
MODERATOR_UPDATE_CONTRIBUTION_MEMO = 'MODERATOR_UPDATE_CONTRIBUTION_MEMO',
DENY_CONTRIBUTION = 'DENY_CONTRIBUTION',
ADMIN_OPEN_CREATIONS = 'ADMIN_OPEN_CREATIONS',
ADMIN_LIST_ALL_CONTRIBUTION_MESSAGES = 'ADMIN_LIST_ALL_CONTRIBUTION_MESSAGES',

View File

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

View File

@ -0,0 +1,52 @@
import { Contribution } from '@entity/Contribution'
import { Decimal } from 'decimal.js-light'
import {
getUserCreation,
updateCreations,
validateContribution,
} from '@/graphql/resolver/util/creations'
import { LogError } from '@/server/LogError'
export class ContributionLogic {
// how much gradido can be still created
private availableCreationSums?: Decimal[]
public constructor(private self: Contribution) {}
/**
* retrieve from db and return available creation sums array
* @param clientTimezoneOffset
* @param putThisBack if true, amount from this contribution will be added back to the availableCreationSums array,
* as if this creation wasn't part of it, used for update contribution
* @returns
*/
public async getAvailableCreationSums(
clientTimezoneOffset: number,
putThisBack = false,
): Promise<Decimal[]> {
// TODO: move code from getUserCreation and updateCreations inside this function/class
this.availableCreationSums = await getUserCreation(this.self.userId, clientTimezoneOffset)
if (putThisBack) {
this.availableCreationSums = updateCreations(
this.availableCreationSums,
this.self,
clientTimezoneOffset,
)
}
return this.availableCreationSums
}
public checkAvailableCreationSumsNotExceeded(
amount: Decimal,
creationDate: Date,
clientTimezoneOffset: number,
): void {
if (!this.availableCreationSums) {
throw new LogError(
'missing available creation sums, please call getAvailableCreationSums first',
)
}
// all possible cases not to be true are thrown in this function
validateContribution(this.availableCreationSums, amount, creationDate, clientTimezoneOffset)
}
}

View File

@ -0,0 +1,88 @@
import { Contribution } from '@entity/Contribution'
import { ContributionMessage } from '@entity/ContributionMessage'
import { User } from '@entity/User'
import { ContributionMessageType } from '@/graphql/enum/ContributionMessageType'
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
export class ContributionMessageBuilder {
private contributionMessage: ContributionMessage
// https://refactoring.guru/design-patterns/builder/typescript/example
/**
* A fresh builder instance should contain a blank product object, which is
* used in further assembly.
*/
constructor() {
this.reset()
}
public reset(): void {
this.contributionMessage = ContributionMessage.create()
}
/**
* Concrete Builders are supposed to provide their own methods for
* retrieving results. That's because various types of builders may create
* entirely different products that don't follow the same interface.
* Therefore, such methods cannot be declared in the base Builder interface
* (at least in a statically typed programming language).
*
* Usually, after returning the end result to the client, a builder instance
* is expected to be ready to start producing another product. That's why
* it's a usual practice to call the reset method at the end of the
* `getProduct` method body. However, this behavior is not mandatory, and
* you can make your builders wait for an explicit reset call from the
* client code before disposing of the previous result.
*/
public build(): ContributionMessage {
const result = this.contributionMessage
this.reset()
return result
}
public setParentContribution(contribution: Contribution): this {
this.contributionMessage.contributionId = contribution.id
this.contributionMessage.createdAt = contribution.updatedAt
? contribution.updatedAt
: contribution.createdAt
return this
}
/**
* set contribution message type to history and create message from contribution
* @param contribution
* @returns ContributionMessageBuilder for chaining function calls
*/
public setHistoryType(contribution: Contribution): this {
const changeMessage = `${contribution.contributionDate.toString()}
---
${contribution.memo}
---
${contribution.amount.toString()}`
this.contributionMessage.message = changeMessage
this.contributionMessage.type = ContributionMessageType.HISTORY
return this
}
public setUser(user: User): this {
this.contributionMessage.user = user
this.contributionMessage.userId = user.id
return this
}
public setUserId(userId: number): this {
this.contributionMessage.userId = userId
return this
}
public setType(type: ContributionMessageType): this {
this.contributionMessage.type = type
return this
}
public setIsModerator(value: boolean): this {
this.contributionMessage.isModerator = value
return this
}
}

View File

@ -0,0 +1,11 @@
import { User } from '@entity/User'
import { UserRole } from '@entity/UserRole'
import { RoleNames } from '@enum/RoleNames'
export class UserLogic {
public constructor(private self: User) {}
public isRole(role: RoleNames): boolean {
return this.self.userRoles.some((value: UserRole) => value.role === role.toString())
}
}

View File

@ -160,8 +160,7 @@ If the validity of the link has already expired, you can have a new link sent to
<div class=\\"socialmedia\\" style=\\"display: flex; margin-top: 40px; max-width: 600px;\\"><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://www.facebook.com/groups/Gradido/\\" style=\\"width: 150px;\\"><img class=\\"bi-facebook\\" alt=\\"facebook\\" loading=\\"lazy\\" src=\\"cid:facebookicon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://t.me/GradidoGruppe\\" style=\\"width: 150px;\\"><img class=\\"bi-telegram\\" alt=\\"Telegram\\" loading=\\"lazy\\" src=\\"cid:telegramicon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://twitter.com/gradido\\" style=\\"width: 150px;\\"><img class=\\"bi-twitter\\" alt=\\"Twitter\\" loading=\\"lazy\\" src=\\"cid:twittericon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://www.youtube.com/c/GradidoNet\\" style=\\"width: 150px;\\"><img class=\\"bi-youtube\\" alt=\\"youtube\\" loading=\\"lazy\\" src=\\"cid:youtubeicon\\"></a></div>
<div class=\\"line\\" style=\\"width: 100%; height: 13px; margin-top: 40px; background-image: linear-gradient(90deg, #c58d38, #f3cd7c 40%, #dbb056 55%, #eec05f 71%, #cc9d3d);\\"></div>
<div class=\\"footer\\" style=\\"padding-bottom: 20px;\\">
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">If you have any further questions, please contact our support.</div>
<div class=\\"footer_p2\\" style=\\"color: #383838; font-weight: bold;\\">support@gradido.net</div><img class=\\"image\\" alt=\\"Gradido Logo\\" src=\\"https://gdd.gradido.net/img/brand/green.png\\" style=\\"width: 200px; margin-top: 30px; margin-bottom: 30px;\\" width=\\"200\\">
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">If you have any further questions, please contact our support.</div><a class=\\"footer_p2\\" href=\\"mailto:support@gradido.net\\" style=\\"color: #383838; font-weight: bold;\\">support@gradido.net</a><img class=\\"image\\" alt=\\"Gradido Logo\\" src=\\"https://gdd.gradido.net/img/brand/green.png\\" style=\\"width: 200px; margin-top: 30px; margin-bottom: 30px;\\" width=\\"200\\">
<div><a class=\\"terms_of_use\\" href=\\"https://gradido.net/de/impressum/\\" target=\\"_blank\\" style=\\"color: #9ca0a8;\\">Impressum</a></div><br><a class=\\"terms_of_use\\" href=\\"https://gradido.net/de/datenschutz/\\" target=\\"_blank\\" style=\\"color: #9ca0a8;\\">Privacy Policy</a>
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">Gradido-Akademie<br>Institut für Wirtschaftsbionik<br>Pfarrweg 2<br>74653 Künzelsau<br>Deutschland<br><br><br></div>
</div>
@ -330,8 +329,7 @@ exports[`sendEmailVariants sendAccountMultiRegistrationEmail calls "sendEmailTra
<div class=\\"socialmedia\\" style=\\"display: flex; margin-top: 40px; max-width: 600px;\\"><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://www.facebook.com/groups/Gradido/\\" style=\\"width: 150px;\\"><img class=\\"bi-facebook\\" alt=\\"facebook\\" loading=\\"lazy\\" src=\\"cid:facebookicon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://t.me/GradidoGruppe\\" style=\\"width: 150px;\\"><img class=\\"bi-telegram\\" alt=\\"Telegram\\" loading=\\"lazy\\" src=\\"cid:telegramicon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://twitter.com/gradido\\" style=\\"width: 150px;\\"><img class=\\"bi-twitter\\" alt=\\"Twitter\\" loading=\\"lazy\\" src=\\"cid:twittericon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://www.youtube.com/c/GradidoNet\\" style=\\"width: 150px;\\"><img class=\\"bi-youtube\\" alt=\\"youtube\\" loading=\\"lazy\\" src=\\"cid:youtubeicon\\"></a></div>
<div class=\\"line\\" style=\\"width: 100%; height: 13px; margin-top: 40px; background-image: linear-gradient(90deg, #c58d38, #f3cd7c 40%, #dbb056 55%, #eec05f 71%, #cc9d3d);\\"></div>
<div class=\\"footer\\" style=\\"padding-bottom: 20px;\\">
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">If you have any further questions, please contact our support.</div>
<div class=\\"footer_p2\\" style=\\"color: #383838; font-weight: bold;\\">support@gradido.net</div><img class=\\"image\\" alt=\\"Gradido Logo\\" src=\\"https://gdd.gradido.net/img/brand/green.png\\" style=\\"width: 200px; margin-top: 30px; margin-bottom: 30px;\\" width=\\"200\\">
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">If you have any further questions, please contact our support.</div><a class=\\"footer_p2\\" href=\\"mailto:support@gradido.net\\" style=\\"color: #383838; font-weight: bold;\\">support@gradido.net</a><img class=\\"image\\" alt=\\"Gradido Logo\\" src=\\"https://gdd.gradido.net/img/brand/green.png\\" style=\\"width: 200px; margin-top: 30px; margin-bottom: 30px;\\" width=\\"200\\">
<div><a class=\\"terms_of_use\\" href=\\"https://gradido.net/de/impressum/\\" target=\\"_blank\\" style=\\"color: #9ca0a8;\\">Impressum</a></div><br><a class=\\"terms_of_use\\" href=\\"https://gradido.net/de/datenschutz/\\" target=\\"_blank\\" style=\\"color: #9ca0a8;\\">Privacy Policy</a>
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">Gradido-Akademie<br>Institut für Wirtschaftsbionik<br>Pfarrweg 2<br>74653 Künzelsau<br>Deutschland<br><br><br></div>
</div>
@ -497,8 +495,174 @@ exports[`sendEmailVariants sendAddedContributionMessageEmail result has the corr
<div class=\\"socialmedia\\" style=\\"display: flex; margin-top: 40px; max-width: 600px;\\"><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://www.facebook.com/groups/Gradido/\\" style=\\"width: 150px;\\"><img class=\\"bi-facebook\\" alt=\\"facebook\\" loading=\\"lazy\\" src=\\"cid:facebookicon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://t.me/GradidoGruppe\\" style=\\"width: 150px;\\"><img class=\\"bi-telegram\\" alt=\\"Telegram\\" loading=\\"lazy\\" src=\\"cid:telegramicon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://twitter.com/gradido\\" style=\\"width: 150px;\\"><img class=\\"bi-twitter\\" alt=\\"Twitter\\" loading=\\"lazy\\" src=\\"cid:twittericon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://www.youtube.com/c/GradidoNet\\" style=\\"width: 150px;\\"><img class=\\"bi-youtube\\" alt=\\"youtube\\" loading=\\"lazy\\" src=\\"cid:youtubeicon\\"></a></div>
<div class=\\"line\\" style=\\"width: 100%; height: 13px; margin-top: 40px; background-image: linear-gradient(90deg, #c58d38, #f3cd7c 40%, #dbb056 55%, #eec05f 71%, #cc9d3d);\\"></div>
<div class=\\"footer\\" style=\\"padding-bottom: 20px;\\">
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">If you have any further questions, please contact our support.</div>
<div class=\\"footer_p2\\" style=\\"color: #383838; font-weight: bold;\\">support@gradido.net</div><img class=\\"image\\" alt=\\"Gradido Logo\\" src=\\"https://gdd.gradido.net/img/brand/green.png\\" style=\\"width: 200px; margin-top: 30px; margin-bottom: 30px;\\" width=\\"200\\">
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">If you have any further questions, please contact our support.</div><a class=\\"footer_p2\\" href=\\"mailto:support@gradido.net\\" style=\\"color: #383838; font-weight: bold;\\">support@gradido.net</a><img class=\\"image\\" alt=\\"Gradido Logo\\" src=\\"https://gdd.gradido.net/img/brand/green.png\\" style=\\"width: 200px; margin-top: 30px; margin-bottom: 30px;\\" width=\\"200\\">
<div><a class=\\"terms_of_use\\" href=\\"https://gradido.net/de/impressum/\\" target=\\"_blank\\" style=\\"color: #9ca0a8;\\">Impressum</a></div><br><a class=\\"terms_of_use\\" href=\\"https://gradido.net/de/datenschutz/\\" target=\\"_blank\\" style=\\"color: #9ca0a8;\\">Privacy Policy</a>
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">Gradido-Akademie<br>Institut für Wirtschaftsbionik<br>Pfarrweg 2<br>74653 Künzelsau<br>Deutschland<br><br><br></div>
</div>
</div>
</footer>
</div>
</body>
</html>"
`;
exports[`sendEmailVariants sendContributionChangedByModeratorEmail result has the correct html as snapshot 1`] = `
"<!DOCTYPE html>
<html lang=\\"en\\">
<head>
<meta content=\\"multipart/html; charset=UTF-8\\" http-equiv=\\"content-type\\">
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1\\">
<style>
.wf-force-outline-none[tabindex=\\"-1\\"]:focus {
outline: none;
}
</style>
<style>
@font-face {
font-family: 'Work Sans';
font-style: normal;
font-weight: 100;
src: url(https://fonts.gstatic.com/s/worksans/v18/QGYsz_wNahGAdqQ43Rh_cqDpp_k.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;}
@font-face {
font-family: 'Work Sans';
font-style: normal;
font-weight: 100;
src: url(https://fonts.gstatic.com/s/worksans/v18/QGYsz_wNahGAdqQ43Rh_fKDp.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;}
@font-face {
font-family: 'Work Sans';
font-style: normal;
font-weight: 200;
src: url(https://fonts.gstatic.com/s/worksans/v18/QGYsz_wNahGAdqQ43Rh_cqDpp_k.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;}
@font-face {
font-family: 'Work Sans';
font-style: normal;
font-weight: 200;
src: url(https://fonts.gstatic.com/s/worksans/v18/QGYsz_wNahGAdqQ43Rh_fKDp.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;}
@font-face {
font-family: 'Work Sans';
font-style: normal;
font-weight: 300;
src: url(https://fonts.gstatic.com/s/worksans/v18/QGYsz_wNahGAdqQ43Rh_cqDpp_k.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;}
@font-face {
font-family: 'Work Sans';
font-style: normal;
font-weight: 300;
src: url(https://fonts.gstatic.com/s/worksans/v18/QGYsz_wNahGAdqQ43Rh_fKDp.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;}
@font-face {
font-family: 'Work Sans';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/worksans/v18/QGYsz_wNahGAdqQ43Rh_cqDpp_k.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;}
@font-face {
font-family: 'Work Sans';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/worksans/v18/QGYsz_wNahGAdqQ43Rh_fKDp.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;}
@font-face {
font-family: 'Work Sans';
font-style: normal;
font-weight: 500;
src: url(https://fonts.gstatic.com/s/worksans/v18/QGYsz_wNahGAdqQ43Rh_cqDpp_k.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;}
@font-face {
font-family: 'Work Sans';
font-style: normal;
font-weight: 500;
src: url(https://fonts.gstatic.com/s/worksans/v18/QGYsz_wNahGAdqQ43Rh_fKDp.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;}
@font-face {
font-family: 'Work Sans';
font-style: normal;
font-weight: 600;
src: url(https://fonts.gstatic.com/s/worksans/v18/QGYsz_wNahGAdqQ43Rh_cqDpp_k.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;}
@font-face {
font-family: 'Work Sans';
font-style: normal;
font-weight: 600;
src: url(https://fonts.gstatic.com/s/worksans/v18/QGYsz_wNahGAdqQ43Rh_fKDp.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;}
@font-face {
font-family: 'Work Sans';
font-style: normal;
font-weight: 700;
src: url(https://fonts.gstatic.com/s/worksans/v18/QGYsz_wNahGAdqQ43Rh_cqDpp_k.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;}
@font-face {
font-family: 'Work Sans';
font-style: normal;
font-weight: 700;
src: url(https://fonts.gstatic.com/s/worksans/v18/QGYsz_wNahGAdqQ43Rh_fKDp.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;}
@font-face {
font-family: 'Work Sans';
font-style: normal;
font-weight: 800;
src: url(https://fonts.gstatic.com/s/worksans/v18/QGYsz_wNahGAdqQ43Rh_cqDpp_k.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;}
@font-face {
font-family: 'Work Sans';
font-style: normal;
font-weight: 800;
src: url(https://fonts.gstatic.com/s/worksans/v18/QGYsz_wNahGAdqQ43Rh_fKDp.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;}
@font-face {
font-family: 'Work Sans';
font-style: normal;
font-weight: 900;
src: url(https://fonts.gstatic.com/s/worksans/v18/QGYsz_wNahGAdqQ43Rh_cqDpp_k.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;}
@font-face {
font-family: 'Work Sans';
font-style: normal;
font-weight: 900;
src: url(https://fonts.gstatic.com/s/worksans/v18/QGYsz_wNahGAdqQ43Rh_fKDp.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;}
.clink {
line-break: anywhere;
margin-bottom: 40px;
}
.slink {
width: 150px;
}
</style>
</head>
<body style=\\"display: block; font-family: 'Work Sans', sans-serif; font-size: 17px; text-align: center; text-align: -webkit-center; justify-content: center; padding: 0px; margin: 0px;\\">
<div class=\\"container\\" style=\\"max-width: 680px; margin: 0 auto; display: block;\\">
<header>
<div class=\\"head\\"><img class=\\"head-logo\\" alt=\\"Gradido Logo\\" loading=\\"lazy\\" src=\\"cid:gradidoheader\\" style=\\"width: 100%; height: auto;\\"></div>
</header>
<div class=\\"wrapper\\">
<h2 style=\\"margin-top: 15px; color: #383838;\\">Your common good contribution has been changed</h2>
<div class=\\"text-block\\" style=\\"margin-top: 20px; color: #9ca0a8;\\">
<p>Hello Peter Lustig,</p>
<p>your common good contribution 'My contribution.' has just been changed by Bibi Bloxberg and now reads as 'This is a better contribution memo.'</p>
</div>
<div class=\\"content\\" style=\\"display: block; width: 78%; margin: 40px 1% 40px 1%; padding: 20px 10% 40px 10%; border-radius: 24px; background-image: linear-gradient(180deg, #f5f5f5, #f5f5f5);\\">
<h2 style=\\"margin-top: 15px; color: #383838;\\">Contribution details</h2>
<div class=\\"p_content\\" style=\\"margin: 15px 0 15px 0; line-height: 26px; color: #9ca0a8;\\">To see your common good contributions and related messages, go to the “Creation” menu in your Gradido account and click on the “My contributions” tab.</div><a class=\\"button-3\\" href=\\"http://localhost/community/contributions\\" style=\\"display: inline-block; padding: 9px 15px; color: white; border: 0; line-height: inherit; text-decoration: none; cursor: pointer; border-radius: 20px; background-image: radial-gradient(circle farthest-corner at 0% 0%, #f9cd69, #c58d38); box-shadow: 16px 13px 35px 0 rgba(56, 56, 56, 0.3); margin: 25px 0 25px 0; width: 50%;\\">To account</a>
<div class=\\"p_content\\" style=\\"margin: 15px 0 15px 0; line-height: 26px; color: #9ca0a8;\\">Or copy the link into your browser window.</div><a class=\\"clink\\" href=\\"http://localhost/community/contributions\\" style=\\"line-break: anywhere; margin-bottom: 40px;\\">http://localhost/community/contributions</a>
<div class=\\"p_content\\" style=\\"margin: 15px 0 15px 0; line-height: 26px; color: #9ca0a8;\\">Please do not reply to this email.</div>
</div>
<div class=\\"text-block\\" style=\\"margin-top: 20px; color: #9ca0a8;\\">
<p>Kind regards,<br>your Gradido team
</p>
</div>
</div>
<footer>
<div class=\\"w-container footer_01\\">
<div class=\\"socialmedia\\" style=\\"display: flex; margin-top: 40px; max-width: 600px;\\"><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://www.facebook.com/groups/Gradido/\\" style=\\"width: 150px;\\"><img class=\\"bi-facebook\\" alt=\\"facebook\\" loading=\\"lazy\\" src=\\"cid:facebookicon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://t.me/GradidoGruppe\\" style=\\"width: 150px;\\"><img class=\\"bi-telegram\\" alt=\\"Telegram\\" loading=\\"lazy\\" src=\\"cid:telegramicon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://twitter.com/gradido\\" style=\\"width: 150px;\\"><img class=\\"bi-twitter\\" alt=\\"Twitter\\" loading=\\"lazy\\" src=\\"cid:twittericon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://www.youtube.com/c/GradidoNet\\" style=\\"width: 150px;\\"><img class=\\"bi-youtube\\" alt=\\"youtube\\" loading=\\"lazy\\" src=\\"cid:youtubeicon\\"></a></div>
<div class=\\"line\\" style=\\"width: 100%; height: 13px; margin-top: 40px; background-image: linear-gradient(90deg, #c58d38, #f3cd7c 40%, #dbb056 55%, #eec05f 71%, #cc9d3d);\\"></div>
<div class=\\"footer\\" style=\\"padding-bottom: 20px;\\">
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">If you have any further questions, please contact our support.</div><a class=\\"footer_p2\\" href=\\"mailto:support@gradido.net\\" style=\\"color: #383838; font-weight: bold;\\">support@gradido.net</a><img class=\\"image\\" alt=\\"Gradido Logo\\" src=\\"https://gdd.gradido.net/img/brand/green.png\\" style=\\"width: 200px; margin-top: 30px; margin-bottom: 30px;\\" width=\\"200\\">
<div><a class=\\"terms_of_use\\" href=\\"https://gradido.net/de/impressum/\\" target=\\"_blank\\" style=\\"color: #9ca0a8;\\">Impressum</a></div><br><a class=\\"terms_of_use\\" href=\\"https://gradido.net/de/datenschutz/\\" target=\\"_blank\\" style=\\"color: #9ca0a8;\\">Privacy Policy</a>
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">Gradido-Akademie<br>Institut für Wirtschaftsbionik<br>Pfarrweg 2<br>74653 Künzelsau<br>Deutschland<br><br><br></div>
</div>
@ -665,8 +829,7 @@ exports[`sendEmailVariants sendContributionConfirmedEmail result has the correct
<div class=\\"socialmedia\\" style=\\"display: flex; margin-top: 40px; max-width: 600px;\\"><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://www.facebook.com/groups/Gradido/\\" style=\\"width: 150px;\\"><img class=\\"bi-facebook\\" alt=\\"facebook\\" loading=\\"lazy\\" src=\\"cid:facebookicon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://t.me/GradidoGruppe\\" style=\\"width: 150px;\\"><img class=\\"bi-telegram\\" alt=\\"Telegram\\" loading=\\"lazy\\" src=\\"cid:telegramicon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://twitter.com/gradido\\" style=\\"width: 150px;\\"><img class=\\"bi-twitter\\" alt=\\"Twitter\\" loading=\\"lazy\\" src=\\"cid:twittericon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://www.youtube.com/c/GradidoNet\\" style=\\"width: 150px;\\"><img class=\\"bi-youtube\\" alt=\\"youtube\\" loading=\\"lazy\\" src=\\"cid:youtubeicon\\"></a></div>
<div class=\\"line\\" style=\\"width: 100%; height: 13px; margin-top: 40px; background-image: linear-gradient(90deg, #c58d38, #f3cd7c 40%, #dbb056 55%, #eec05f 71%, #cc9d3d);\\"></div>
<div class=\\"footer\\" style=\\"padding-bottom: 20px;\\">
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">If you have any further questions, please contact our support.</div>
<div class=\\"footer_p2\\" style=\\"color: #383838; font-weight: bold;\\">support@gradido.net</div><img class=\\"image\\" alt=\\"Gradido Logo\\" src=\\"https://gdd.gradido.net/img/brand/green.png\\" style=\\"width: 200px; margin-top: 30px; margin-bottom: 30px;\\" width=\\"200\\">
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">If you have any further questions, please contact our support.</div><a class=\\"footer_p2\\" href=\\"mailto:support@gradido.net\\" style=\\"color: #383838; font-weight: bold;\\">support@gradido.net</a><img class=\\"image\\" alt=\\"Gradido Logo\\" src=\\"https://gdd.gradido.net/img/brand/green.png\\" style=\\"width: 200px; margin-top: 30px; margin-bottom: 30px;\\" width=\\"200\\">
<div><a class=\\"terms_of_use\\" href=\\"https://gradido.net/de/impressum/\\" target=\\"_blank\\" style=\\"color: #9ca0a8;\\">Impressum</a></div><br><a class=\\"terms_of_use\\" href=\\"https://gradido.net/de/datenschutz/\\" target=\\"_blank\\" style=\\"color: #9ca0a8;\\">Privacy Policy</a>
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">Gradido-Akademie<br>Institut für Wirtschaftsbionik<br>Pfarrweg 2<br>74653 Künzelsau<br>Deutschland<br><br><br></div>
</div>
@ -833,8 +996,7 @@ exports[`sendEmailVariants sendContributionDeletedEmail result has the correct h
<div class=\\"socialmedia\\" style=\\"display: flex; margin-top: 40px; max-width: 600px;\\"><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://www.facebook.com/groups/Gradido/\\" style=\\"width: 150px;\\"><img class=\\"bi-facebook\\" alt=\\"facebook\\" loading=\\"lazy\\" src=\\"cid:facebookicon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://t.me/GradidoGruppe\\" style=\\"width: 150px;\\"><img class=\\"bi-telegram\\" alt=\\"Telegram\\" loading=\\"lazy\\" src=\\"cid:telegramicon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://twitter.com/gradido\\" style=\\"width: 150px;\\"><img class=\\"bi-twitter\\" alt=\\"Twitter\\" loading=\\"lazy\\" src=\\"cid:twittericon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://www.youtube.com/c/GradidoNet\\" style=\\"width: 150px;\\"><img class=\\"bi-youtube\\" alt=\\"youtube\\" loading=\\"lazy\\" src=\\"cid:youtubeicon\\"></a></div>
<div class=\\"line\\" style=\\"width: 100%; height: 13px; margin-top: 40px; background-image: linear-gradient(90deg, #c58d38, #f3cd7c 40%, #dbb056 55%, #eec05f 71%, #cc9d3d);\\"></div>
<div class=\\"footer\\" style=\\"padding-bottom: 20px;\\">
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">If you have any further questions, please contact our support.</div>
<div class=\\"footer_p2\\" style=\\"color: #383838; font-weight: bold;\\">support@gradido.net</div><img class=\\"image\\" alt=\\"Gradido Logo\\" src=\\"https://gdd.gradido.net/img/brand/green.png\\" style=\\"width: 200px; margin-top: 30px; margin-bottom: 30px;\\" width=\\"200\\">
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">If you have any further questions, please contact our support.</div><a class=\\"footer_p2\\" href=\\"mailto:support@gradido.net\\" style=\\"color: #383838; font-weight: bold;\\">support@gradido.net</a><img class=\\"image\\" alt=\\"Gradido Logo\\" src=\\"https://gdd.gradido.net/img/brand/green.png\\" style=\\"width: 200px; margin-top: 30px; margin-bottom: 30px;\\" width=\\"200\\">
<div><a class=\\"terms_of_use\\" href=\\"https://gradido.net/de/impressum/\\" target=\\"_blank\\" style=\\"color: #9ca0a8;\\">Impressum</a></div><br><a class=\\"terms_of_use\\" href=\\"https://gradido.net/de/datenschutz/\\" target=\\"_blank\\" style=\\"color: #9ca0a8;\\">Privacy Policy</a>
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">Gradido-Akademie<br>Institut für Wirtschaftsbionik<br>Pfarrweg 2<br>74653 Künzelsau<br>Deutschland<br><br><br></div>
</div>
@ -1001,8 +1163,7 @@ exports[`sendEmailVariants sendContributionDeniedEmail result has the correct ht
<div class=\\"socialmedia\\" style=\\"display: flex; margin-top: 40px; max-width: 600px;\\"><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://www.facebook.com/groups/Gradido/\\" style=\\"width: 150px;\\"><img class=\\"bi-facebook\\" alt=\\"facebook\\" loading=\\"lazy\\" src=\\"cid:facebookicon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://t.me/GradidoGruppe\\" style=\\"width: 150px;\\"><img class=\\"bi-telegram\\" alt=\\"Telegram\\" loading=\\"lazy\\" src=\\"cid:telegramicon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://twitter.com/gradido\\" style=\\"width: 150px;\\"><img class=\\"bi-twitter\\" alt=\\"Twitter\\" loading=\\"lazy\\" src=\\"cid:twittericon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://www.youtube.com/c/GradidoNet\\" style=\\"width: 150px;\\"><img class=\\"bi-youtube\\" alt=\\"youtube\\" loading=\\"lazy\\" src=\\"cid:youtubeicon\\"></a></div>
<div class=\\"line\\" style=\\"width: 100%; height: 13px; margin-top: 40px; background-image: linear-gradient(90deg, #c58d38, #f3cd7c 40%, #dbb056 55%, #eec05f 71%, #cc9d3d);\\"></div>
<div class=\\"footer\\" style=\\"padding-bottom: 20px;\\">
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">If you have any further questions, please contact our support.</div>
<div class=\\"footer_p2\\" style=\\"color: #383838; font-weight: bold;\\">support@gradido.net</div><img class=\\"image\\" alt=\\"Gradido Logo\\" src=\\"https://gdd.gradido.net/img/brand/green.png\\" style=\\"width: 200px; margin-top: 30px; margin-bottom: 30px;\\" width=\\"200\\">
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">If you have any further questions, please contact our support.</div><a class=\\"footer_p2\\" href=\\"mailto:support@gradido.net\\" style=\\"color: #383838; font-weight: bold;\\">support@gradido.net</a><img class=\\"image\\" alt=\\"Gradido Logo\\" src=\\"https://gdd.gradido.net/img/brand/green.png\\" style=\\"width: 200px; margin-top: 30px; margin-bottom: 30px;\\" width=\\"200\\">
<div><a class=\\"terms_of_use\\" href=\\"https://gradido.net/de/impressum/\\" target=\\"_blank\\" style=\\"color: #9ca0a8;\\">Impressum</a></div><br><a class=\\"terms_of_use\\" href=\\"https://gradido.net/de/datenschutz/\\" target=\\"_blank\\" style=\\"color: #9ca0a8;\\">Privacy Policy</a>
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">Gradido-Akademie<br>Institut für Wirtschaftsbionik<br>Pfarrweg 2<br>74653 Künzelsau<br>Deutschland<br><br><br></div>
</div>
@ -1173,8 +1334,7 @@ If the validity of the link has already expired, you can have a new link sent to
<div class=\\"socialmedia\\" style=\\"display: flex; margin-top: 40px; max-width: 600px;\\"><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://www.facebook.com/groups/Gradido/\\" style=\\"width: 150px;\\"><img class=\\"bi-facebook\\" alt=\\"facebook\\" loading=\\"lazy\\" src=\\"cid:facebookicon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://t.me/GradidoGruppe\\" style=\\"width: 150px;\\"><img class=\\"bi-telegram\\" alt=\\"Telegram\\" loading=\\"lazy\\" src=\\"cid:telegramicon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://twitter.com/gradido\\" style=\\"width: 150px;\\"><img class=\\"bi-twitter\\" alt=\\"Twitter\\" loading=\\"lazy\\" src=\\"cid:twittericon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://www.youtube.com/c/GradidoNet\\" style=\\"width: 150px;\\"><img class=\\"bi-youtube\\" alt=\\"youtube\\" loading=\\"lazy\\" src=\\"cid:youtubeicon\\"></a></div>
<div class=\\"line\\" style=\\"width: 100%; height: 13px; margin-top: 40px; background-image: linear-gradient(90deg, #c58d38, #f3cd7c 40%, #dbb056 55%, #eec05f 71%, #cc9d3d);\\"></div>
<div class=\\"footer\\" style=\\"padding-bottom: 20px;\\">
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">If you have any further questions, please contact our support.</div>
<div class=\\"footer_p2\\" style=\\"color: #383838; font-weight: bold;\\">support@gradido.net</div><img class=\\"image\\" alt=\\"Gradido Logo\\" src=\\"https://gdd.gradido.net/img/brand/green.png\\" style=\\"width: 200px; margin-top: 30px; margin-bottom: 30px;\\" width=\\"200\\">
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">If you have any further questions, please contact our support.</div><a class=\\"footer_p2\\" href=\\"mailto:support@gradido.net\\" style=\\"color: #383838; font-weight: bold;\\">support@gradido.net</a><img class=\\"image\\" alt=\\"Gradido Logo\\" src=\\"https://gdd.gradido.net/img/brand/green.png\\" style=\\"width: 200px; margin-top: 30px; margin-bottom: 30px;\\" width=\\"200\\">
<div><a class=\\"terms_of_use\\" href=\\"https://gradido.net/de/impressum/\\" target=\\"_blank\\" style=\\"color: #9ca0a8;\\">Impressum</a></div><br><a class=\\"terms_of_use\\" href=\\"https://gradido.net/de/datenschutz/\\" target=\\"_blank\\" style=\\"color: #9ca0a8;\\">Privacy Policy</a>
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">Gradido-Akademie<br>Institut für Wirtschaftsbionik<br>Pfarrweg 2<br>74653 Künzelsau<br>Deutschland<br><br><br></div>
</div>
@ -1341,8 +1501,7 @@ exports[`sendEmailVariants sendTransactionLinkRedeemedEmail result has the corre
<div class=\\"socialmedia\\" style=\\"display: flex; margin-top: 40px; max-width: 600px;\\"><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://www.facebook.com/groups/Gradido/\\" style=\\"width: 150px;\\"><img class=\\"bi-facebook\\" alt=\\"facebook\\" loading=\\"lazy\\" src=\\"cid:facebookicon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://t.me/GradidoGruppe\\" style=\\"width: 150px;\\"><img class=\\"bi-telegram\\" alt=\\"Telegram\\" loading=\\"lazy\\" src=\\"cid:telegramicon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://twitter.com/gradido\\" style=\\"width: 150px;\\"><img class=\\"bi-twitter\\" alt=\\"Twitter\\" loading=\\"lazy\\" src=\\"cid:twittericon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://www.youtube.com/c/GradidoNet\\" style=\\"width: 150px;\\"><img class=\\"bi-youtube\\" alt=\\"youtube\\" loading=\\"lazy\\" src=\\"cid:youtubeicon\\"></a></div>
<div class=\\"line\\" style=\\"width: 100%; height: 13px; margin-top: 40px; background-image: linear-gradient(90deg, #c58d38, #f3cd7c 40%, #dbb056 55%, #eec05f 71%, #cc9d3d);\\"></div>
<div class=\\"footer\\" style=\\"padding-bottom: 20px;\\">
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">If you have any further questions, please contact our support.</div>
<div class=\\"footer_p2\\" style=\\"color: #383838; font-weight: bold;\\">support@gradido.net</div><img class=\\"image\\" alt=\\"Gradido Logo\\" src=\\"https://gdd.gradido.net/img/brand/green.png\\" style=\\"width: 200px; margin-top: 30px; margin-bottom: 30px;\\" width=\\"200\\">
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">If you have any further questions, please contact our support.</div><a class=\\"footer_p2\\" href=\\"mailto:support@gradido.net\\" style=\\"color: #383838; font-weight: bold;\\">support@gradido.net</a><img class=\\"image\\" alt=\\"Gradido Logo\\" src=\\"https://gdd.gradido.net/img/brand/green.png\\" style=\\"width: 200px; margin-top: 30px; margin-bottom: 30px;\\" width=\\"200\\">
<div><a class=\\"terms_of_use\\" href=\\"https://gradido.net/de/impressum/\\" target=\\"_blank\\" style=\\"color: #9ca0a8;\\">Impressum</a></div><br><a class=\\"terms_of_use\\" href=\\"https://gradido.net/de/datenschutz/\\" target=\\"_blank\\" style=\\"color: #9ca0a8;\\">Privacy Policy</a>
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">Gradido-Akademie<br>Institut für Wirtschaftsbionik<br>Pfarrweg 2<br>74653 Künzelsau<br>Deutschland<br><br><br></div>
</div>
@ -1508,8 +1667,7 @@ exports[`sendEmailVariants sendTransactionReceivedEmail result has the correct h
<div class=\\"socialmedia\\" style=\\"display: flex; margin-top: 40px; max-width: 600px;\\"><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://www.facebook.com/groups/Gradido/\\" style=\\"width: 150px;\\"><img class=\\"bi-facebook\\" alt=\\"facebook\\" loading=\\"lazy\\" src=\\"cid:facebookicon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://t.me/GradidoGruppe\\" style=\\"width: 150px;\\"><img class=\\"bi-telegram\\" alt=\\"Telegram\\" loading=\\"lazy\\" src=\\"cid:telegramicon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://twitter.com/gradido\\" style=\\"width: 150px;\\"><img class=\\"bi-twitter\\" alt=\\"Twitter\\" loading=\\"lazy\\" src=\\"cid:twittericon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://www.youtube.com/c/GradidoNet\\" style=\\"width: 150px;\\"><img class=\\"bi-youtube\\" alt=\\"youtube\\" loading=\\"lazy\\" src=\\"cid:youtubeicon\\"></a></div>
<div class=\\"line\\" style=\\"width: 100%; height: 13px; margin-top: 40px; background-image: linear-gradient(90deg, #c58d38, #f3cd7c 40%, #dbb056 55%, #eec05f 71%, #cc9d3d);\\"></div>
<div class=\\"footer\\" style=\\"padding-bottom: 20px;\\">
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">If you have any further questions, please contact our support.</div>
<div class=\\"footer_p2\\" style=\\"color: #383838; font-weight: bold;\\">support@gradido.net</div><img class=\\"image\\" alt=\\"Gradido Logo\\" src=\\"https://gdd.gradido.net/img/brand/green.png\\" style=\\"width: 200px; margin-top: 30px; margin-bottom: 30px;\\" width=\\"200\\">
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">If you have any further questions, please contact our support.</div><a class=\\"footer_p2\\" href=\\"mailto:support@gradido.net\\" style=\\"color: #383838; font-weight: bold;\\">support@gradido.net</a><img class=\\"image\\" alt=\\"Gradido Logo\\" src=\\"https://gdd.gradido.net/img/brand/green.png\\" style=\\"width: 200px; margin-top: 30px; margin-bottom: 30px;\\" width=\\"200\\">
<div><a class=\\"terms_of_use\\" href=\\"https://gradido.net/de/impressum/\\" target=\\"_blank\\" style=\\"color: #9ca0a8;\\">Impressum</a></div><br><a class=\\"terms_of_use\\" href=\\"https://gradido.net/de/datenschutz/\\" target=\\"_blank\\" style=\\"color: #9ca0a8;\\">Privacy Policy</a>
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">Gradido-Akademie<br>Institut für Wirtschaftsbionik<br>Pfarrweg 2<br>74653 Künzelsau<br>Deutschland<br><br><br></div>
</div>

View File

@ -10,6 +10,7 @@ import { sendEmailTranslated } from './sendEmailTranslated'
CONFIG.EMAIL = false
CONFIG.EMAIL_SMTP_URL = 'EMAIL_SMTP_URL'
CONFIG.EMAIL_SMTP_PORT = 1234
CONFIG.EMAIL_SENDER = 'info@gradido.net'
CONFIG.EMAIL_USERNAME = 'user'
CONFIG.EMAIL_PASSWORD = 'pwd'
CONFIG.EMAIL_TLS = true

View File

@ -22,8 +22,11 @@ import {
sendResetPasswordEmail,
sendTransactionLinkRedeemedEmail,
sendTransactionReceivedEmail,
sendContributionChangedByModeratorEmail,
} from './sendEmailVariants'
CONFIG.EMAIL_SENDER = 'info@gradido.net'
let con: Connection
let testEnv: {
mutate: ApolloServerTestClient['mutate']
@ -286,6 +289,68 @@ describe('sendEmailVariants', () => {
})
})
describe('sendContributionChangedByModeratorEmail', () => {
beforeAll(async () => {
result = await sendContributionChangedByModeratorEmail({
firstName: 'Peter',
lastName: 'Lustig',
email: 'peter@lustig.de',
language: 'en',
senderFirstName: 'Bibi',
senderLastName: 'Bloxberg',
contributionMemo: 'My contribution.',
contributionMemoUpdated: 'This is a better contribution memo.',
})
})
describe('calls "sendEmailTranslated"', () => {
it('with expected parameters', () => {
expect(sendEmailTranslated).toBeCalledWith({
receiver: {
to: 'Peter Lustig <peter@lustig.de>',
},
template: 'contributionChangedByModerator',
locals: {
firstName: 'Peter',
lastName: 'Lustig',
locale: 'en',
senderFirstName: 'Bibi',
senderLastName: 'Bloxberg',
contributionMemo: 'My contribution.',
contributionMemoUpdated: 'This is a better contribution memo.',
overviewURL: CONFIG.EMAIL_LINK_OVERVIEW,
supportEmail: CONFIG.COMMUNITY_SUPPORT_MAIL,
communityURL: CONFIG.COMMUNITY_URL,
},
})
})
})
describe('result', () => {
it('is the expected object', () => {
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 (emails.general.doNotAnswer) <info@gradido.net>',
attachments: expect.any(Array),
subject: 'Your common good contribution has been changed',
html: expect.any(String),
text: expect.stringContaining('YOUR COMMON GOOD CONTRIBUTION HAS BEEN CHANGED'),
}),
})
})
it('has the correct html as snapshot', () => {
expect(result.originalMessage.html).toMatchSnapshot()
})
})
})
describe('sendContributionDeniedEmail', () => {
beforeAll(async () => {
result = await sendContributionDeniedEmail({

View File

@ -105,6 +105,34 @@ export const sendContributionConfirmedEmail = (data: {
})
}
export const sendContributionChangedByModeratorEmail = (data: {
firstName: string
lastName: string
email: string
language: string
senderFirstName: string
senderLastName: string
contributionMemo: string
contributionMemoUpdated: string
}): Promise<Record<string, unknown> | boolean | null> => {
return sendEmailTranslated({
receiver: { to: `${data.firstName} ${data.lastName} <${data.email}>` },
template: 'contributionChangedByModerator',
locals: {
firstName: data.firstName,
lastName: data.lastName,
locale: data.language,
senderFirstName: data.senderFirstName,
senderLastName: data.senderLastName,
contributionMemo: data.contributionMemo,
contributionMemoUpdated: data.contributionMemoUpdated,
overviewURL: CONFIG.EMAIL_LINK_OVERVIEW,
supportEmail: CONFIG.COMMUNITY_SUPPORT_MAIL,
communityURL: CONFIG.COMMUNITY_URL,
},
})
}
export const sendContributionDeletedEmail = (data: {
firstName: string
lastName: string

View File

@ -0,0 +1,10 @@
extend ../layout.pug
block content
h2= t('emails.contributionChangedByModerator.title')
.text-block
include ../includes/salutation.pug
p= t('emails.contributionChangedByModerator.text', { contributionMemo, senderFirstName, senderLastName, contributionMemoUpdated })
.content
include ../includes/contributionDetailsCTA.pug
include ../includes/doNotReply.pug

View File

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

View File

@ -40,16 +40,19 @@ footer
.line
.footer
div(class="footer_p1")= t("emails.footer.contactOurSupport")
div(class="footer_p2")= t("emails.footer.supportEmail")
a(
class="footer_p2"
href='mailto:' + t("emails.footer.supportEmail")
)= t("emails.footer.supportEmail")
img.image(
alt="Gradido Logo"
src="https://gdd.gradido.net/img/brand/green.png"
)
div
a(
class="terms_of_use"
href="https://gradido.net/de/impressum/"
target="_blank"
class="terms_of_use"
href="https://gradido.net/de/impressum/"
target="_blank"
)= t("emails.footer.imprint")
br
a(

View File

@ -12,16 +12,16 @@ export class AdminUpdateContributionArgs {
@IsPositive()
id: number
@Field(() => Decimal)
@Field(() => Decimal, { nullable: true })
@IsPositiveDecimal()
amount: Decimal
amount?: Decimal | null
@Field(() => String)
@Field(() => String, { nullable: true })
@MaxLength(MEMO_MAX_CHARS)
@MinLength(MEMO_MIN_CHARS)
memo: string
memo?: string | null
@Field(() => String)
@Field(() => String, { nullable: true })
@isValidDateString()
creationDate: string
creationDate?: string | null
}

View File

@ -21,6 +21,8 @@ export class Contribution {
this.deniedBy = contribution.deniedBy
this.deletedAt = contribution.deletedAt
this.deletedBy = contribution.deletedBy
this.updatedAt = contribution.updatedAt
this.updatedBy = contribution.updatedBy
this.moderatorId = contribution.moderatorId
this.userId = contribution.userId
}
@ -61,6 +63,12 @@ export class Contribution {
@Field(() => Int, { nullable: true })
deletedBy: number | null
@Field(() => Date, { nullable: true })
updatedAt: Date | null
@Field(() => Int, { nullable: true })
updatedBy: number | null
@Field(() => Date)
contributionDate: Date

View File

@ -497,28 +497,6 @@ describe('ContributionResolver', () => {
})
})
it('throws an error', async () => {
jest.clearAllMocks()
const { errors: errorObjects } = await mutate({
mutation: adminUpdateContribution,
variables: {
id: pendingContribution.data.createContribution.id,
amount: 10.0,
memo: 'Test env contribution',
creationDate: new Date().toString(),
},
})
expect(errorObjects).toEqual([
new GraphQLError('An admin is not allowed to update an user contribution'),
])
})
it('logs the error "An admin is not allowed to update an user contribution"', () => {
expect(logger.error).toBeCalledWith(
'An admin is not allowed to update an user contribution',
)
})
describe('contribution has wrong status', () => {
beforeAll(async () => {
const contribution = await Contribution.findOneOrFail({
@ -2824,7 +2802,7 @@ describe('ContributionResolver', () => {
} = await query({
query: adminListContributions,
})
// console.log('17 contributions: %s', JSON.stringify(contributionListObject, null, 2))
expect(contributionListObject.contributionList).toHaveLength(18)
expect(contributionListObject).toMatchObject({
contributionCount: 18,
@ -2907,7 +2885,7 @@ describe('ContributionResolver', () => {
id: expect.any(Number),
lastName: 'Lustig',
memo: 'Das war leider zu Viel!',
messagesCount: 0,
messagesCount: 1,
status: 'DELETED',
}),
expect.objectContaining({
@ -3092,7 +3070,7 @@ describe('ContributionResolver', () => {
id: expect.any(Number),
lastName: 'Lustig',
memo: 'Das war leider zu Viel!',
messagesCount: 0,
messagesCount: 1,
status: 'DELETED',
}),
]),
@ -3137,7 +3115,7 @@ describe('ContributionResolver', () => {
id: expect.any(Number),
lastName: 'Lustig',
memo: 'Das war leider zu Viel!',
messagesCount: 0,
messagesCount: 1,
status: 'DELETED',
}),
]),

View File

@ -1,6 +1,5 @@
import { IsNull, getConnection } from '@dbTools/typeorm'
import { EntityManager, IsNull, getConnection } from '@dbTools/typeorm'
import { Contribution as DbContribution } from '@entity/Contribution'
import { ContributionMessage } from '@entity/ContributionMessage'
import { Transaction as DbTransaction } from '@entity/Transaction'
import { User as DbUser } from '@entity/User'
import { UserContact } from '@entity/UserContact'
@ -24,6 +23,7 @@ import { UnconfirmedContribution } from '@model/UnconfirmedContribution'
import { RIGHTS } from '@/auth/RIGHTS'
import {
sendContributionChangedByModeratorEmail,
sendContributionConfirmedEmail,
sendContributionDeletedEmail,
sendContributionDeniedEmail,
@ -38,6 +38,7 @@ import {
EVENT_ADMIN_CONTRIBUTION_CONFIRM,
EVENT_ADMIN_CONTRIBUTION_DENY,
} from '@/event/Events'
import { UpdateUnconfirmedContributionContext } from '@/interactions/updateUnconfirmedContribution/UpdateUnconfirmedContribution.context'
import { Context, getUser, getClientTimezoneOffset } from '@/server/context'
import { LogError } from '@/server/LogError'
import { backendLogger as logger } from '@/server/logger'
@ -45,18 +46,24 @@ import { calculateDecay } from '@/util/decay'
import { TRANSACTIONS_LOCK } from '@/util/TRANSACTIONS_LOCK'
import { fullName } from '@/util/utilities'
import {
getUserCreation,
validateContribution,
updateCreations,
getOpenCreations,
} from './util/creations'
import { findContribution } from './util/contributions'
import { getUserCreation, validateContribution, getOpenCreations } from './util/creations'
import { findContributions } from './util/findContributions'
import { getLastTransaction } from './util/getLastTransaction'
import { sendTransactionsToDltConnector } from './util/sendTransactionsToDltConnector'
@Resolver()
export class ContributionResolver {
@Authorized([RIGHTS.ADMIN_LIST_CONTRIBUTIONS])
@Query(() => Contribution)
async contribution(@Arg('id', () => Int) id: number): Promise<Contribution> {
const contribution = await findContribution(id)
if (!contribution) {
throw new LogError('Contribution not found', id)
}
return new Contribution(contribution)
}
@Authorized([RIGHTS.CREATE_CONTRIBUTION])
@Mutation(() => UnconfirmedContribution)
async createContribution(
@ -169,75 +176,26 @@ export class ContributionResolver {
async updateContribution(
@Arg('contributionId', () => Int)
contributionId: number,
@Args() { amount, memo, creationDate }: ContributionArgs,
@Args() contributionArgs: ContributionArgs,
@Ctx() context: Context,
): Promise<UnconfirmedContribution> {
const clientTimezoneOffset = getClientTimezoneOffset(context)
const user = getUser(context)
const contributionToUpdate = await DbContribution.findOne({
where: { id: contributionId, confirmedAt: IsNull(), deniedAt: IsNull() },
const updateUnconfirmedContributionContext = new UpdateUnconfirmedContributionContext(
contributionId,
contributionArgs,
context,
)
const { contribution, contributionMessage, availableCreationSums } =
await updateUnconfirmedContributionContext.run()
await getConnection().transaction(async (transactionalEntityManager: EntityManager) => {
await Promise.all([
transactionalEntityManager.save(contribution),
transactionalEntityManager.save(contributionMessage),
])
})
if (!contributionToUpdate) {
throw new LogError('Contribution not found', contributionId)
}
if (contributionToUpdate.userId !== user.id) {
throw new LogError(
'Can not update contribution of another user',
contributionToUpdate,
user.id,
)
}
if (contributionToUpdate.moderatorId) {
throw new LogError('Cannot update contribution of moderator', contributionToUpdate, user.id)
}
if (
contributionToUpdate.contributionStatus !== ContributionStatus.IN_PROGRESS &&
contributionToUpdate.contributionStatus !== ContributionStatus.PENDING
) {
throw new LogError(
'Contribution can not be updated due to status',
contributionToUpdate.contributionStatus,
)
}
const creationDateObj = new Date(creationDate)
let creations = await getUserCreation(user.id, clientTimezoneOffset)
if (contributionToUpdate.contributionDate.getMonth() === creationDateObj.getMonth()) {
creations = updateCreations(creations, contributionToUpdate, clientTimezoneOffset)
} else {
throw new LogError('Month of contribution can not be changed')
}
const user = getUser(context)
await EVENT_CONTRIBUTION_UPDATE(user, contribution, contributionArgs.amount)
// all possible cases not to be true are thrown in this function
validateContribution(creations, amount, creationDateObj, clientTimezoneOffset)
const contributionMessage = ContributionMessage.create()
contributionMessage.contributionId = contributionId
contributionMessage.createdAt = contributionToUpdate.updatedAt
? contributionToUpdate.updatedAt
: contributionToUpdate.createdAt
const changeMessage = `${contributionToUpdate.contributionDate.toString()}
---
${contributionToUpdate.memo}
---
${contributionToUpdate.amount.toString()}`
contributionMessage.message = changeMessage
contributionMessage.isModerator = false
contributionMessage.userId = user.id
contributionMessage.type = ContributionMessageType.HISTORY
await ContributionMessage.save(contributionMessage)
contributionToUpdate.amount = amount
contributionToUpdate.memo = memo
contributionToUpdate.contributionDate = new Date(creationDate)
contributionToUpdate.contributionStatus = ContributionStatus.PENDING
contributionToUpdate.updatedAt = new Date()
await DbContribution.save(contributionToUpdate)
await EVENT_CONTRIBUTION_UPDATE(user, contributionToUpdate, amount)
return new UnconfirmedContribution(contributionToUpdate, user, creations)
return new UnconfirmedContribution(contribution, user, availableCreationSums)
}
@Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTION])
@ -294,56 +252,51 @@ export class ContributionResolver {
@Authorized([RIGHTS.ADMIN_UPDATE_CONTRIBUTION])
@Mutation(() => AdminUpdateContribution)
async adminUpdateContribution(
@Args() { id, amount, memo, creationDate }: AdminUpdateContributionArgs,
@Args() adminUpdateContributionArgs: AdminUpdateContributionArgs,
@Ctx() context: Context,
): Promise<AdminUpdateContribution> {
const clientTimezoneOffset = getClientTimezoneOffset(context)
const updateUnconfirmedContributionContext = new UpdateUnconfirmedContributionContext(
adminUpdateContributionArgs.id,
adminUpdateContributionArgs,
context,
)
const { contribution, contributionMessage, createdByUserChangedByModerator } =
await updateUnconfirmedContributionContext.run()
await getConnection().transaction(async (transactionalEntityManager: EntityManager) => {
await Promise.all([
transactionalEntityManager.save(contribution),
transactionalEntityManager.save(contributionMessage),
])
})
const moderator = getUser(context)
const contributionToUpdate = await DbContribution.findOne({
where: { id, confirmedAt: IsNull(), deniedAt: IsNull() },
const user = await DbUser.findOneOrFail({
where: { id: contribution.userId },
relations: ['emailContact'],
})
if (!contributionToUpdate) {
throw new LogError('Contribution not found', id)
}
if (contributionToUpdate.moderatorId === null) {
throw new LogError('An admin is not allowed to update an user contribution')
}
const creationDateObj = new Date(creationDate)
let creations = await getUserCreation(contributionToUpdate.userId, clientTimezoneOffset)
// TODO: remove this restriction
if (contributionToUpdate.contributionDate.getMonth() === creationDateObj.getMonth()) {
creations = updateCreations(creations, contributionToUpdate, clientTimezoneOffset)
} else {
throw new LogError('Month of contribution can not be changed')
}
// all possible cases not to be true are thrown in this function
validateContribution(creations, amount, creationDateObj, clientTimezoneOffset)
contributionToUpdate.amount = amount
contributionToUpdate.memo = memo
contributionToUpdate.contributionDate = new Date(creationDate)
contributionToUpdate.moderatorId = moderator.id
contributionToUpdate.contributionStatus = ContributionStatus.PENDING
await DbContribution.save(contributionToUpdate)
const result = new AdminUpdateContribution()
result.amount = amount
result.memo = contributionToUpdate.memo
result.date = contributionToUpdate.contributionDate
result.amount = contribution.amount
result.memo = contribution.memo
result.date = contribution.contributionDate
await EVENT_ADMIN_CONTRIBUTION_UPDATE(
{ id: contributionToUpdate.userId } as DbUser,
{ id: contribution.userId } as DbUser,
moderator,
contributionToUpdate,
amount,
contribution,
contribution.amount,
)
if (createdByUserChangedByModerator && adminUpdateContributionArgs.memo) {
void sendContributionChangedByModeratorEmail({
firstName: user.firstName,
lastName: user.lastName,
email: user.emailContact.email,
language: user.language,
senderFirstName: moderator.firstName,
senderLastName: moderator.lastName,
contributionMemo: updateUnconfirmedContributionContext.getOldMemo(),
contributionMemoUpdated: contribution.memo,
})
}
return result
}
@ -401,7 +354,6 @@ export class ContributionResolver {
contribution,
contribution.amount,
)
void sendContributionDeletedEmail({
firstName: user.firstName,
lastName: user.lastName,

View File

@ -0,0 +1,5 @@
import { Contribution } from '@entity/Contribution'
export const findContribution = async (id: number): Promise<Contribution | null> => {
return Contribution.findOne({ where: { id } })
}

View File

@ -0,0 +1,68 @@
import { Contribution } from '@entity/Contribution'
import { User } from '@entity/User'
import { Decimal } from 'decimal.js-light'
import { Role } from '@/auth/Role'
import { ContributionLogic } from '@/data/Contribution.logic'
import { Context, getClientTimezoneOffset } from '@/server/context'
import { LogError } from '@/server/LogError'
export abstract class AbstractUnconfirmedContributionRole {
private availableCreationSums?: Decimal[]
public constructor(
protected self: Contribution,
protected updatedAmount: Decimal,
protected updatedCreationDate: Date,
) {
if (self.confirmedAt || self.deniedAt) {
throw new LogError("this contribution isn't unconfirmed!")
}
}
// steps which return void throw on each error
// first, check if it can be updated
protected abstract checkAuthorization(user: User, role: Role): void
// second, check if contribution is still valid after update
protected async validate(clientTimezoneOffset: number): Promise<void> {
// TODO: refactor frontend and remove this restriction
if (this.self.contributionDate.getMonth() !== this.updatedCreationDate.getMonth()) {
throw new LogError('Month of contribution can not be changed')
}
const contributionLogic = new ContributionLogic(this.self)
this.availableCreationSums = await contributionLogic.getAvailableCreationSums(
clientTimezoneOffset,
true,
)
contributionLogic.checkAvailableCreationSumsNotExceeded(
this.updatedAmount,
this.updatedCreationDate,
clientTimezoneOffset,
)
}
// third, actually update entity
protected abstract update(): void
// call all steps in order
public async checkAndUpdate(context: Context): Promise<void> {
if (!context.user || !context.role) {
throw new LogError('missing user or role on context')
}
this.checkAuthorization(context.user, context.role)
await this.validate(getClientTimezoneOffset(context))
this.update()
}
public getAvailableCreationSums(): Decimal[] {
if (!this.availableCreationSums) {
throw new LogError('availableCreationSums is empty, please call validate before!')
}
return this.availableCreationSums
}
public isCreatedFromUser(): boolean {
return !this.self.moderatorId
}
}

View File

@ -0,0 +1,56 @@
import { Contribution } from '@entity/Contribution'
import { User } from '@entity/User'
import { RIGHTS } from '@/auth/RIGHTS'
import { Role } from '@/auth/Role'
import { AdminUpdateContributionArgs } from '@/graphql/arg/AdminUpdateContributionArgs'
import { ContributionStatus } from '@/graphql/enum/ContributionStatus'
import { LogError } from '@/server/LogError'
import { AbstractUnconfirmedContributionRole } from './AbstractUnconfirmedContribution.role'
export class UnconfirmedContributionAdminRole extends AbstractUnconfirmedContributionRole {
public constructor(
contribution: Contribution,
private updateData: AdminUpdateContributionArgs,
private moderator: User,
) {
super(
contribution,
updateData.amount ?? contribution.amount,
updateData.creationDate ? new Date(updateData.creationDate) : contribution.contributionDate,
)
}
protected update(): void {
this.self.amount = this.updatedAmount
this.self.memo = this.updateData.memo ?? this.self.memo
this.self.contributionDate = this.updatedCreationDate
this.self.contributionStatus = ContributionStatus.PENDING
this.self.updatedAt = new Date()
this.self.updatedBy = this.moderator.id
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected checkAuthorization(user: User, role: Role): AbstractUnconfirmedContributionRole {
if (
!role.hasRight(RIGHTS.MODERATOR_UPDATE_CONTRIBUTION_MEMO) &&
this.self.moderatorId === null
) {
throw new LogError('An admin is not allowed to update an user contribution')
}
return this
}
protected async validate(clientTimezoneOffset: number): Promise<void> {
await super.validate(clientTimezoneOffset)
// creation date is currently not changeable
if (
this.self.memo === this.updateData.memo &&
this.self.amount === this.updatedAmount &&
this.self.contributionDate.getTime() === new Date(this.updatedCreationDate).getTime()
) {
throw new LogError("the contribution wasn't changed at all")
}
}
}

View File

@ -0,0 +1,58 @@
import { Contribution } from '@entity/Contribution'
import { User } from '@entity/User'
import { ContributionArgs } from '@/graphql/arg/ContributionArgs'
import { ContributionStatus } from '@/graphql/enum/ContributionStatus'
import { LogError } from '@/server/LogError'
import { AbstractUnconfirmedContributionRole } from './AbstractUnconfirmedContribution.role'
export class UnconfirmedContributionUserRole extends AbstractUnconfirmedContributionRole {
public constructor(contribution: Contribution, private updateData: ContributionArgs) {
super(contribution, updateData.amount, new Date(updateData.creationDate))
}
protected update(): void {
this.self.amount = this.updateData.amount
this.self.memo = this.updateData.memo
this.self.contributionDate = new Date(this.updateData.creationDate)
this.self.contributionStatus = ContributionStatus.PENDING
this.self.updatedAt = new Date()
// null because updated by user them self
this.self.updatedBy = null
}
protected checkAuthorization(user: User): AbstractUnconfirmedContributionRole {
if (this.self.userId !== user.id) {
throw new LogError('Can not update contribution of another user', this.self, user.id)
}
// only admins and moderators can update it when status is other than progress or pending
if (
this.self.contributionStatus !== ContributionStatus.IN_PROGRESS &&
this.self.contributionStatus !== ContributionStatus.PENDING
) {
throw new LogError(
'Contribution can not be updated due to status',
this.self.contributionStatus,
)
}
// if a contribution was created from a moderator, user cannot edit it
// TODO: rethink
if (this.self.moderatorId) {
throw new LogError('Cannot update contribution of moderator', this.self, user.id)
}
return this
}
protected async validate(clientTimezoneOffset: number): Promise<void> {
await super.validate(clientTimezoneOffset)
// creation date is currently not changeable
if (
this.self.memo === this.updateData.memo &&
this.self.amount === this.updatedAmount &&
this.self.contributionDate.getTime() === new Date(this.updatedCreationDate).getTime()
) {
throw new LogError("the contribution wasn't changed at all")
}
}
}

View File

@ -0,0 +1,94 @@
import { Contribution } from '@entity/Contribution'
import { ContributionMessage } from '@entity/ContributionMessage'
import { Decimal } from 'decimal.js-light'
import { AdminUpdateContributionArgs } from '@arg/AdminUpdateContributionArgs'
import { ContributionArgs } from '@arg/ContributionArgs'
import { ContributionMessageBuilder } from '@/data/ContributionMessage.builder'
import { Context } from '@/server/context'
import { LogError } from '@/server/LogError'
import { AbstractUnconfirmedContributionRole } from './AbstractUnconfirmedContribution.role'
import { UnconfirmedContributionAdminRole } from './UnconfirmedContributionAdmin.role'
import { UnconfirmedContributionUserRole } from './UnconfirmedContributionUser.role'
export class UpdateUnconfirmedContributionContext {
private oldMemoText: string
/**
*
* @param id contribution id for update
* @param input ContributionArgs or AdminUpdateContributionArgs depending on calling resolver function
* @param context
*/
public constructor(
private id: number,
private input: ContributionArgs | AdminUpdateContributionArgs,
private context: Context,
) {
if (!context.role || !context.user) {
throw new LogError("context didn't contain role or user")
}
}
public async run(): Promise<{
contribution: Contribution
contributionMessage: ContributionMessage
availableCreationSums: Decimal[]
createdByUserChangedByModerator: boolean
}> {
let createdByUserChangedByModerator = false
if (!this.context.role || !this.context.user) {
throw new LogError("context didn't contain role or user")
}
const contributionToUpdate = await Contribution.findOne({
where: { id: this.id },
})
if (!contributionToUpdate) {
throw new LogError('Contribution not found', this.id)
}
this.oldMemoText = contributionToUpdate.memo
const contributionMessageBuilder = new ContributionMessageBuilder()
contributionMessageBuilder
.setParentContribution(contributionToUpdate)
.setHistoryType(contributionToUpdate)
.setUser(this.context.user)
// choose correct role
let unconfirmedContributionRole: AbstractUnconfirmedContributionRole | null = null
if (this.input instanceof ContributionArgs) {
unconfirmedContributionRole = new UnconfirmedContributionUserRole(
contributionToUpdate,
this.input,
)
contributionMessageBuilder.setIsModerator(false)
} else if (this.input instanceof AdminUpdateContributionArgs) {
unconfirmedContributionRole = new UnconfirmedContributionAdminRole(
contributionToUpdate,
this.input,
this.context.user,
)
if (unconfirmedContributionRole.isCreatedFromUser()) {
createdByUserChangedByModerator = true
}
contributionMessageBuilder.setIsModerator(true)
}
if (!unconfirmedContributionRole) {
throw new LogError("don't recognize input type, maybe not implemented yet?")
}
// run steps
// all possible cases not to be true are thrown in the next function
await unconfirmedContributionRole.checkAndUpdate(this.context)
return {
contribution: contributionToUpdate,
contributionMessage: contributionMessageBuilder.build(),
availableCreationSums: unconfirmedContributionRole.getAvailableCreationSums(),
createdByUserChangedByModerator,
}
}
public getOldMemo(): string {
return this.oldMemoText
}
}

View File

@ -26,6 +26,11 @@
"contribution": {
"toSeeContributionsAndMessages": "Um deine Gemeinwohl-Beiträge und dazugehörige Nachrichten zu sehen, gehe in deinem Gradido-Konto ins Menü „Schöpfen“ auf den Tab „Meine Beiträge“."
},
"contributionChangedByModerator": {
"subject": "Dein Gemeinwohl-Beitrag wurde geändert",
"text": "dein Gemeinwohl-Beitrag „{contributionMemo}“ wurde soeben von {senderFirstName} {senderLastName} geändert und lautet jetzt „{contributionMemoUpdated}“",
"title": "Dein Gemeinwohl-Beitrag wurde geändert"
},
"contributionConfirmed": {
"commonGoodContributionConfirmed": "dein Gemeinwohl-Beitrag „{contributionMemo}“ wurde soeben von {senderFirstName} {senderLastName} bestätigt. Es wurden deinem Gradido-Konto {amountGDD} GDD gutgeschrieben.",
"subject": "Dein Gemeinwohl-Beitrag wurde bestätigt",

View File

@ -26,6 +26,11 @@
"contribution": {
"toSeeContributionsAndMessages": "To see your common good contributions and related messages, go to the “Creation” menu in your Gradido account and click on the “My contributions” tab."
},
"contributionChangedByModerator": {
"subject": "Your common good contribution has been changed",
"text": "your common good contribution '{contributionMemo}' has just been changed by {senderFirstName} {senderLastName} and now reads as '{contributionMemoUpdated}'",
"title": "Your common good contribution has been changed"
},
"contributionConfirmed": {
"commonGoodContributionConfirmed": "Your common good contribution “{contributionMemo}” has just been approved by {senderFirstName} {senderLastName}. Your Gradido account has been credited with {amountGDD} GDD.",
"subject": "Your contribution to the common good was confirmed",

View File

@ -1,4 +1,9 @@
export const getTimeDurationObject = (time: number): { hours?: number; minutes: number } => {
export const getTimeDurationObject = (
time: number,
): {
hours?: number
minutes: number
} => {
if (time > 60) {
return {
hours: Math.floor(time / 60),

View File

@ -15,3 +15,17 @@ export const decimalSeparatorByLanguage = (a: Decimal, language: string): string
export const fullName = (firstName: string, lastName: string): string =>
[firstName, lastName].filter(Boolean).join(' ')
// Function to reset an interface by chatGPT
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function resetInterface<T extends Record<string, any>>(obj: T): T {
// Iterate over all properties of the object
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
// Set all optional properties to undefined
// eslint-disable-next-line security/detect-object-injection
obj[key] = undefined as T[Extract<keyof T, string>]
}
}
return obj
}

View File

@ -3679,7 +3679,7 @@ graceful-fs@^4.1.6, graceful-fs@^4.2.0:
integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==
"gradido-database@file:../database":
version "1.22.0"
version "2.0.1"
dependencies:
"@types/uuid" "^8.3.4"
cross-env "^7.0.3"
@ -5280,6 +5280,11 @@ mkdirp@^2.1.3:
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-2.1.6.tgz#964fbcb12b2d8c5d6fbc62a963ac95a273e2cc19"
integrity sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A==
mkdirp@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-3.0.1.tgz#e44e4c5607fb279c168241713cc6e0fea9adcb50"
integrity sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==
moo@^0.5.0, moo@^0.5.1:
version "0.5.1"
resolved "https://registry.yarnpkg.com/moo/-/moo-0.5.1.tgz#7aae7f384b9b09f620b6abf6f74ebbcd1b65dbc4"
@ -5371,6 +5376,11 @@ natural-compare@^1.4.0:
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=
ncp@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/ncp/-/ncp-2.0.0.tgz#195a21d6c46e361d2fb1281ba38b91e9df7bdbb3"
integrity sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA==
nearley@^2.20.1:
version "2.20.1"
resolved "https://registry.yarnpkg.com/nearley/-/nearley-2.20.1.tgz#246cd33eff0d012faf197ff6774d7ac78acdd474"

View File

@ -0,0 +1,56 @@
import {
BaseEntity,
Column,
DeleteDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm'
import { Contribution } from '../Contribution'
import { User } from '../User'
@Entity('contribution_messages', {
engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci',
})
export class ContributionMessage extends BaseEntity {
@PrimaryGeneratedColumn('increment', { unsigned: true })
id: number
@Index()
@Column({ name: 'contribution_id', unsigned: true, nullable: false })
contributionId: number
@ManyToOne(() => Contribution, (contribution) => contribution.messages)
@JoinColumn({ name: 'contribution_id' })
contribution: Contribution
@Column({ name: 'user_id', unsigned: true, nullable: false })
userId: number
@ManyToOne(() => User, (user) => user.messages)
@JoinColumn({ name: 'user_id' })
user: User
@Column({ length: 2000, nullable: false, collation: 'utf8mb4_unicode_ci' })
message: string
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP', name: 'created_at' })
createdAt: Date
@Column({ type: 'datetime', default: null, nullable: true, name: 'updated_at' })
updatedAt: Date
@DeleteDateColumn({ name: 'deleted_at' })
deletedAt: Date | null
@Column({ name: 'deleted_by', default: null, unsigned: true, nullable: true })
deletedBy: number
@Column({ length: 12, nullable: false, collation: 'utf8mb4_unicode_ci' })
type: string
@Column({ name: 'is_moderator', type: 'bool', nullable: false, default: false })
isModerator: boolean
}

View File

@ -0,0 +1,104 @@
import { Decimal } from 'decimal.js-light'
import {
BaseEntity,
Column,
Entity,
PrimaryGeneratedColumn,
DeleteDateColumn,
JoinColumn,
ManyToOne,
OneToMany,
OneToOne,
} from 'typeorm'
import { DecimalTransformer } from '../../src/typeorm/DecimalTransformer'
import { User } from '../User'
import { ContributionMessage } from '../ContributionMessage'
import { Transaction } from '../Transaction'
@Entity('contributions')
export class Contribution extends BaseEntity {
@PrimaryGeneratedColumn('increment', { unsigned: true })
id: number
@Column({ unsigned: true, nullable: false, name: 'user_id' })
userId: number
@ManyToOne(() => User, (user) => user.contributions)
@JoinColumn({ name: 'user_id' })
user: User
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP', name: 'created_at' })
createdAt: Date
@Column({ type: 'datetime', nullable: false, name: 'contribution_date' })
contributionDate: Date
@Column({ length: 255, nullable: false, collation: 'utf8mb4_unicode_ci' })
memo: string
@Column({
type: 'decimal',
precision: 40,
scale: 20,
nullable: false,
transformer: DecimalTransformer,
})
amount: Decimal
@Column({ unsigned: true, nullable: true, name: 'moderator_id' })
moderatorId: number
@Column({ unsigned: true, nullable: true, name: 'contribution_link_id' })
contributionLinkId: number
@Column({ unsigned: true, nullable: true, name: 'confirmed_by' })
confirmedBy: number
@Column({ nullable: true, name: 'confirmed_at' })
confirmedAt: Date
@Column({ unsigned: true, nullable: true, name: 'denied_by' })
deniedBy: number
@Column({ nullable: true, name: 'denied_at' })
deniedAt: Date
@Column({
name: 'contribution_type',
length: 12,
nullable: false,
collation: 'utf8mb4_unicode_ci',
})
contributionType: string
@Column({
name: 'contribution_status',
length: 12,
nullable: false,
collation: 'utf8mb4_unicode_ci',
})
contributionStatus: string
@Column({ unsigned: true, nullable: true, name: 'transaction_id' })
transactionId: number
@Column({ nullable: true, name: 'updated_at' })
updatedAt: Date
@Column({ nullable: true, unsigned: true, name: 'updated_by', type: 'int' })
updatedBy: number | null
@DeleteDateColumn({ name: 'deleted_at' })
deletedAt: Date | null
@DeleteDateColumn({ unsigned: true, nullable: true, name: 'deleted_by' })
deletedBy: number
@OneToMany(() => ContributionMessage, (message) => message.contribution)
@JoinColumn({ name: 'contribution_id' })
messages?: ContributionMessage[]
@OneToOne(() => Transaction, (transaction) => transaction.contribution)
@JoinColumn({ name: 'transaction_id' })
transaction?: Transaction | null
}

View File

@ -1 +1 @@
export { Contribution } from './0052-add_updated_at_to_contributions/Contribution'
export { Contribution } from './0076-add_updated_by_contribution/Contribution'

View File

@ -1 +1 @@
export { ContributionMessage } from './0048-add_is_moderator_to_contribution_messages/ContributionMessage'
export { ContributionMessage } from './0075-contribution_message_add_index/ContributionMessage'

View File

@ -0,0 +1,15 @@
/* MIGRATION TO ADD PRIVATE KEY IN COMMUNITY TABLE
*
* This migration adds a field for the private key in the community.table
*/
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
/* eslint-disable @typescript-eslint/no-explicit-any */
export async function upgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn('ALTER TABLE `contribution_messages` ADD INDEX(`contribution_id`);')
}
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn('ALTER TABLE `contribution_messages` DROP INDEX `contribution_id`')
}

View File

@ -0,0 +1,9 @@
export async function upgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn(
`ALTER TABLE \`contributions\` ADD COLUMN \`updated_by\` int(10) unsigned NULL DEFAULT NULL AFTER \`updated_at\`;`,
)
}
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn(`ALTER TABLE \`contributions\` DROP COLUMN \`updated_by\`;`)
}

View File

@ -8,7 +8,7 @@
"license": "Apache-2.0",
"private": false,
"scripts": {
"build": "mkdir -p build/src/config/ && cp src/config/*.txt build/src/config/ && tsc --build",
"build": "mkdirp build/src/config/ && ncp src/config build/src/config && tsc --build",
"clean": "tsc --build --clean",
"up": "cross-env TZ=UTC node build/src/index.js up",
"down": "cross-env TZ=UTC node build/src/index.js down",
@ -35,7 +35,9 @@
"eslint-plugin-security": "^1.7.1",
"prettier": "^2.8.7",
"ts-node": "^10.2.1",
"typescript": "^4.3.5"
"typescript": "^4.3.5",
"mkdirp": "^3.0.1",
"ncp": "^2.0.0"
},
"dependencies": {
"@types/uuid": "^8.3.4",

View File

@ -1718,6 +1718,11 @@ mkdirp@^2.1.3:
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-2.1.6.tgz#964fbcb12b2d8c5d6fbc62a963ac95a273e2cc19"
integrity sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A==
mkdirp@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-3.0.1.tgz#e44e4c5607fb279c168241713cc6e0fea9adcb50"
integrity sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==
ms@2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
@ -1778,6 +1783,11 @@ natural-compare@^1.4.0:
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=
ncp@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/ncp/-/ncp-2.0.0.tgz#195a21d6c46e361d2fb1281ba38b91e9df7bdbb3"
integrity sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA==
npm-run-path@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea"

View File

@ -4,7 +4,7 @@ import dotenv from 'dotenv'
dotenv.config()
const constants = {
DB_VERSION: '0074-insert_communityuuid in_existing_users',
DB_VERSION: '0076-add_updated_by_contribution',
LOG4JS_CONFIG: 'log4js-config.json',
// default log level on production should be info
LOG_LEVEL: process.env.LOG_LEVEL || 'info',

View File

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

View File

@ -2,24 +2,23 @@
ROOT_DIR=$(dirname "$0")/..
tmp=$(mktemp)
exit_code=0
for locale_file in $ROOT_DIR/src/locales/*.json
do
jq -f $(dirname "$0")/sort_filter.jq $locale_file > "$tmp"
jq -M 'to_entries | sort_by(.key) | from_entries' "$locale_file" > tmp.json
if [ "$*" == "--fix" ]
then
mv "$tmp" $locale_file
mv tmp.json "$locale_file"
else
if diff -q "$tmp" $locale_file > /dev/null ;
if ! diff -q tmp.json "$locale_file" > /dev/null ;
then
: # all good
else
exit_code=$?
echo "$(basename -- $locale_file) is not sorted by keys"
exit_code=1
echo "$(basename -- "$locale_file") is not sorted by keys"
fi
fi
done
rm -f tmp.json
exit $exit_code

View File

@ -105,7 +105,7 @@ describe('ContentFooter', () => {
it('links to the support', () => {
expect(wrapper.findAll('a.nav-link').at(3).attributes('href')).toEqual(
'https://gradido.net/en/contact/',
'mailto:support@supportmail.com',
)
})
@ -137,12 +137,6 @@ describe('ContentFooter', () => {
'https://docs.google.com/document/d/1jZp-DiiMPI9ZPNXmjsvOQ1BtnfDFfx8BX7CDmA8KKjY/edit?usp=sharing',
)
})
it('links to the German support-page when locale is de', () => {
expect(wrapper.findAll('a.nav-link').at(3).attributes('href')).toEqual(
'https://gradido.net/de/contact/',
)
})
})
})
})

View File

@ -44,7 +44,7 @@
>
{{ $t('footer.whitepaper') }}
</b-nav-item>
<b-nav-item :href="`https://gradido.net/${$i18n.locale}/contact/`" target="_blank">
<b-nav-item :href="`mailto:${supportEmail}`" target="_blank">
{{ $t('navigation.support') }}
</b-nav-item>
</b-nav>
@ -62,6 +62,7 @@ export default {
version: CONFIG.APP_VERSION,
hash: CONFIG.BUILD_COMMIT,
shortHash: CONFIG.BUILD_COMMIT_SHORT,
supportEmail: CONFIG.COMMUNITY_SUPPORT_MAIL,
}
},
}

View File

@ -16,7 +16,6 @@
reset-value=""
:label-no-date-selected="$t('contribution.noDateSelected')"
required
:disabled="this.form.id !== null"
:no-flip="true"
>
<template #nav-prev-year><span></span></template>

View File

@ -25,6 +25,9 @@
</div>
<div class="mt-3 font-weight-bold">{{ $t('contributionText') }}</div>
<div class="mb-3 text-break word-break">{{ memo }}</div>
<div class="mt-2 mb-2 small" v-if="updatedBy > 0">
{{ $t('moderatorChangedMemo') }}
</div>
<div
v-if="status === 'IN_PROGRESS' && !allContribution"
class="text-205 pointer hover-font-bold"
@ -161,6 +164,10 @@ export default {
type: String,
required: false,
},
updatedBy: {
type: Number,
required: false,
},
status: {
type: String,
required: false,

View File

@ -197,6 +197,8 @@ export const listContributions = gql`
messagesCount
deniedAt
deniedBy
updatedBy
updatedAt
moderatorId
}
}
@ -221,6 +223,8 @@ export const listAllContributions = gql`
messagesCount
deniedAt
deniedBy
updatedBy
updatedAt
}
}
}

View File

@ -5,6 +5,9 @@
"1000thanks": "1000 Dank, weil du bei uns bist!",
"125": "125%",
"85": "85%",
"GDD": "GDD",
"GDT": "GDT",
"PersonalDetails": "Persönliche Angaben",
"advanced-calculation": "Vorausberechnung",
"asterisks": "****",
"auth": {
@ -179,7 +182,6 @@
},
"your_amount": "Dein Betrag"
},
"GDD": "GDD",
"gddKonto": "GDD Konto",
"gdd_per_link": {
"choose-amount": "Wähle einen Betrag aus, welchen du per Link versenden möchtest, und trage eine Nachricht ein. Die Nachricht ist ein Pflichtfeld.",
@ -214,7 +216,6 @@
"validUntil": "Gültig bis",
"validUntilDate": "Der Link ist bis zum {date} gültig."
},
"GDT": "GDT",
"gdt": {
"calculation": "Berechnung der Gradido Transform",
"contribution": "Beitrag",
@ -255,6 +256,7 @@
"title": "Danke!",
"unsetPassword": "Dein Passwort wurde noch nicht gesetzt. Bitte setze es neu."
},
"moderatorChangedMemo": "Memo vom Moderator bearbeitet",
"moderatorChat": "Moderator Chat",
"navigation": {
"admin_area": "Adminbereich",
@ -277,7 +279,6 @@
"settings": "Einstellungen",
"transactions": "Deine Transaktionen"
},
"PersonalDetails": "Persönliche Angaben",
"qrCode": "QR Code",
"send_gdd": "GDD versenden",
"send_per_link": "GDD versenden per Link",

View File

@ -5,6 +5,9 @@
"1000thanks": "1000 thanks for being with us!",
"125": "125%",
"85": "85%",
"GDD": "GDD",
"GDT": "GDT",
"PersonalDetails": "Personal details",
"advanced-calculation": "Advanced calculation",
"asterisks": "****",
"auth": {
@ -179,7 +182,6 @@
},
"your_amount": "Your amount"
},
"GDD": "GDD",
"gddKonto": "GDD Konto",
"gdd_per_link": {
"choose-amount": "Select an amount you want to send via link and enter a message. The message is mandatory.",
@ -214,7 +216,6 @@
"validUntil": "Valid until",
"validUntilDate": "The link is valid until {date}."
},
"GDT": "GDT",
"gdt": {
"calculation": "Calculation of Gradido Transform",
"contribution": "Contribution",
@ -255,6 +256,7 @@
"title": "Thank you!",
"unsetPassword": "Your password has not been set yet. Please set it again."
},
"moderatorChangedMemo": "Memo edited by moderator",
"moderatorChat": "Moderator Chat",
"navigation": {
"admin_area": "Admin Area",
@ -277,7 +279,6 @@
"settings": "Settings",
"transactions": "Your transactions"
},
"PersonalDetails": "Personal details",
"qrCode": "QR Code",
"send_gdd": "Send GDD",
"send_per_link": "Send GDD via Link",

View File

@ -3,6 +3,9 @@
"1000thanks": "1000 Gracias, por estar con nosotros!",
"125": "125%",
"85": "85%",
"GDD": "GDD",
"GDT": "GDT",
"PersonalDetails": "Datos personales",
"advanced-calculation": "Proyección",
"asterisks": "****",
"auth": {
@ -157,7 +160,6 @@
},
"your_amount": "Tu importe"
},
"GDD": "GDD",
"gdd_per_link": {
"choose-amount": "Selecciona una cantidad que te gustaría enviar a través de un enlace. También puedes ingresar un mensaje. Cuando haces clic en 'Generar ahora', se crea un enlace que puedes enviar.",
"copy-link": "Copiar enlace",

View File

@ -5,6 +5,9 @@
"1000thanks": "1000 mercis d'être avec nous!",
"125": "125%",
"85": "85%",
"GDD": "GDD",
"GDT": "GDT",
"PersonalDetails": "Informations personnelles",
"advanced-calculation": "Calcul avancé",
"asterisks": "****",
"auth": {
@ -163,7 +166,6 @@
},
"your_amount": "Votre montant"
},
"GDD": "GDD",
"gddKonto": "Compte GDD",
"gdd_per_link": {
"choose-amount": "Sélectionnez le montant que vous souhaitez envoyer via lien. Vous pouvez également joindre un message. Cliquez sur créer maintenant pour établir un lien que vous pourrez partager.",
@ -197,7 +199,6 @@
"validUntil": "Valide jusqu'au",
"validUntilDate": "Le lien est valide jusqu'au {date}."
},
"GDT": "GDT",
"gdt": {
"calculation": "Calcul de Gradido Transform",
"contribution": "Contribution",

View File

@ -3,6 +3,9 @@
"1000thanks": "1000 dank, omdat je bij ons bent!",
"125": "125%",
"85": "85%",
"GDD": "GDD",
"GDT": "GDT",
"PersonalDetails": "Persoonlijke gegevens",
"advanced-calculation": "Voorcalculatie",
"asterisks": "****",
"auth": {
@ -157,7 +160,6 @@
},
"your_amount": "Jouw bijdrage"
},
"GDD": "GDD",
"gdd_per_link": {
"choose-amount": "Kies een bedrag dat je per link versturen wil. Je kunt ook nog een bericht invullen. Wanneer je „Nu genereren“ klikt, wordt er een link gecreëerd die je kunt versturen.",
"copy-link": "Link kopiëren",

View File

@ -3,6 +3,9 @@
"1000thanks": "Bizimle olduğun için 1000lerce teşekkür!",
"125": "%125",
"85": "%85",
"GDD": "GDD",
"GDT": "GDT",
"PersonalDetails": "Kişisel bilgiler",
"advanced-calculation": "Önceden hesaplama",
"auth": {
"left": {
@ -148,7 +151,6 @@
},
"your_amount": "Sendeki tutar"
},
"GDD": "GDD",
"gdd_per_link": {
"choose-amount": "Linke tıklayıp göndermek istediğiniz tutarı seç. Ayrıca bir mesaj da girebilirsin. Paylaşabileceğin bir bağlantı oluşturmak için 'Şimdi oluştur'a tıkla.",
"copy-link": "Linki kopyala ",

View File

@ -91,6 +91,7 @@ export default {
hours: 0,
amount: '',
},
originalContributionDate: '',
updateAmount: '',
maximalDate: new Date(),
openCreations: [],
@ -183,10 +184,13 @@ export default {
return 0
},
maxForMonths() {
const formDate = new Date(this.form.date)
const originalContributionDate = new Date(this.originalContributionDate)
if (this.openCreations && this.openCreations.length)
return this.openCreations.slice(1).map((creation) => {
if (creation.year === formDate.getFullYear() && creation.month === formDate.getMonth())
if (
creation.year === originalContributionDate.getFullYear() &&
creation.month === originalContributionDate.getMonth()
)
return parseInt(creation.amount) + this.amountToAdd
return parseInt(creation.amount)
})
@ -280,6 +284,7 @@ export default {
updateContributionForm(item) {
this.form.id = item.id
this.form.date = item.contributionDate
this.originalContributionDate = item.contributionDate
this.form.memo = item.memo
this.form.amount = item.amount
this.form.hours = item.amount / 20