Merge branch 'master' into 3244-devops-change-databse-backup-filename-to-orderable-date-pattern

This commit is contained in:
clauspeterhuebner 2023-11-29 22:49:02 +01:00 committed by GitHub
commit 336903b5a0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
71 changed files with 1671 additions and 247 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,8 @@ describe('ContributionMessagesFormular', () => {
const propsData = {
contributionId: 42,
contributionMemo: 'It is a test memo',
hideResubmission: true,
}
const mocks = {
@ -52,9 +55,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',
})
})
})
@ -92,13 +96,14 @@ describe('ContributionMessagesFormular', () => {
await wrapper.find('button[data-test="submit-dialog"]').trigger('click')
})
it('moderatorMesage has `DIALOG`', () => {
it('moderatorMessage has `DIALOG`', () => {
expect(apolloMutateMock).toBeCalledWith({
mutation: adminCreateContributionMessage,
variables: {
contributionId: 42,
message: 'text form message',
messageType: 'DIALOG',
resubmissionAt: null,
},
})
})
@ -125,6 +130,80 @@ describe('ContributionMessagesFormular', () => {
contributionId: 42,
message: 'text form message',
messageType: 'MODERATOR',
resubmissionAt: null,
},
})
})
it('toasts an success message', () => {
expect(toastSuccessSpy).toBeCalledWith('message.request')
})
})
describe('send resubmission contribution message with success', () => {
const futureDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days in milliseconds
beforeEach(async () => {
await wrapper.setData({
form: {
text: 'text form message',
},
showResubmissionDate: true,
resubmissionDate: futureDate,
resubmissionTime: '08:46',
})
await wrapper.find('button[data-test="submit-moderator"]').trigger('click')
})
it('graphql payload contain resubmission date', () => {
const futureDateExactTime = futureDate
futureDateExactTime.setHours(8)
futureDateExactTime.setMinutes(46)
expect(apolloMutateMock).toBeCalledWith({
mutation: adminCreateContributionMessage,
variables: {
contributionId: 42,
message: 'text form message',
messageType: 'MODERATOR',
resubmissionAt: futureDateExactTime.toString(),
},
})
})
it('toasts an success message', () => {
expect(toastSuccessSpy).toBeCalledWith('message.request')
})
})
describe('set memo', () => {
beforeEach(async () => {
await wrapper.setData({
chatOrMemo: 0,
})
await wrapper.find('button[data-test="submit-memo"]').trigger('click')
})
it('check chatOrMemo value is 1', () => {
expect(wrapper.vm.chatOrMemo).toBe(1)
})
})
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',
},
})
})

View File

@ -2,12 +2,33 @@
<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-group>
<b-form-checkbox v-model="showResubmissionDate">
{{ $t('moderator.show-submission-form') }}
</b-form-checkbox>
</b-form-group>
<b-form-group v-if="showResubmissionDate">
<b-form-datepicker v-model="resubmissionDate"></b-form-datepicker>
<time-picker v-model="resubmissionTime"></time-picker>
</b-form-group>
<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 +38,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,21 +73,39 @@
</template>
<script>
import { adminCreateContributionMessage } from '@/graphql/adminCreateContributionMessage'
import { adminUpdateContribution } from '@/graphql/adminUpdateContribution'
import TimePicker from '@/components/input/TimePicker'
export default {
components: {
TimePicker,
},
name: 'ContributionMessagesFormular',
props: {
contributionId: {
type: Number,
required: true,
},
contributionMemo: {
type: String,
required: true,
},
hideResubmission: {
type: Boolean,
required: true,
},
},
data() {
return {
form: {
text: '',
memo: this.contributionMemo,
},
loading: false,
resubmissionDate: null,
resubmissionTime: '00:00',
showResubmissionDate: false,
chatOrMemo: 0, // 0 = Chat, 1 = Memo
messageType: {
DIALOG: 'DIALOG',
MODERATOR: 'MODERATOR',
@ -65,36 +113,91 @@ export default {
}
},
methods: {
combineResubmissionDateAndTime() {
const formattedDate = new Date(this.resubmissionDate)
const [hours, minutes] = this.resubmissionTime.split(':')
formattedDate.setHours(parseInt(hours))
formattedDate.setMinutes(parseInt(minutes))
return formattedDate
},
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,
resubmissionAt: this.showResubmissionDate
? this.combineResubmissionDateAndTime().toString()
: null,
},
})
.then((result) => {
if (
this.hideResubmission &&
this.showResubmissionDate &&
this.combineResubmissionDateAndTime() > new Date()
) {
this.$emit('update-contributions')
} else {
this.$emit('get-list-contribution-messages', this.contributionId)
this.$emit('update-status', this.contributionId)
}
this.onReset()
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
this.showResubmissionDate = false
this.resubmissionDate = null
this.resubmissionTime = '00:00'
},
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) ||
(this.showResubmissionDate && !this.resubmissionDate)
)
},
moderatorDisabled() {
return this.form.text === '' || this.loading || this.chatOrMemo === 1
},
},
}

View File

@ -86,8 +86,10 @@ describe('ContributionMessagesList', () => {
const propsData = {
contributionId: 42,
contributionMemo: 'test memo',
contributionUserId: 108,
contributionStatus: 'PENDING',
hideResubmission: true,
}
const mocks = {
@ -133,5 +135,36 @@ 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])
})
})
describe('test update-contributions', () => {
beforeEach(() => {
wrapper.vm.updateContributions()
})
it('emits update-contributions', () => {
expect(wrapper.emitted('update-contributions')).toBeTruthy()
})
})
})
})

View File

@ -11,8 +11,12 @@
<div v-if="contributionStatus === 'PENDING' || contributionStatus === 'IN_PROGRESS'">
<contribution-messages-formular
:contributionId="contributionId"
:contributionMemo="contributionMemo"
:hideResubmission="hideResubmission"
@get-list-contribution-messages="$apollo.queries.Messages.refetch()"
@update-status="updateStatus"
@reload-contribution="reloadContribution"
@update-contributions="updateContributions"
/>
</div>
</div>
@ -33,6 +37,10 @@ export default {
type: Number,
required: true,
},
contributionMemo: {
type: String,
required: true,
},
contributionStatus: {
type: String,
required: true,
@ -41,6 +49,10 @@ export default {
type: Number,
required: true,
},
hideResubmission: {
type: Boolean,
required: true,
},
},
data() {
return {
@ -70,6 +82,12 @@ export default {
updateStatus(id) {
this.$emit('update-status', id)
},
reloadContribution(id) {
this.$emit('reload-contribution', id)
},
updateContributions() {
this.$emit('update-contributions')
},
},
}
</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

@ -70,6 +70,7 @@ const propsData = {
{ key: 'confirm', label: 'save' },
],
toggleDetails: false,
hideResubmission: true,
}
const mocks = {
@ -140,5 +141,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,11 @@
:contributionId="row.item.id"
:contributionStatus="row.item.status"
:contributionUserId="row.item.userId"
:contributionMemo="row.item.memo"
:hideResubmission="hideResubmission"
@update-status="updateStatus"
@reload-contribution="reloadContribution"
@update-contributions="updateContributions"
/>
</div>
</template>
@ -145,6 +156,10 @@ export default {
type: Array,
required: true,
},
hideResubmission: {
type: Boolean,
required: true,
},
},
methods: {
myself(item) {
@ -164,6 +179,12 @@ export default {
updateStatus(id) {
this.$emit('update-status', id)
},
reloadContribution(id) {
this.$emit('reload-contribution', id)
},
updateContributions() {
this.$emit('update-contributions')
},
},
}
</script>

View File

@ -0,0 +1,63 @@
import { mount } from '@vue/test-utils'
import TimePicker from './TimePicker.vue'
describe('TimePicker', () => {
it('updates timeValue on input and emits input event', async () => {
const wrapper = mount(TimePicker, {
propsData: {
value: '12:34', // Set an initial value for testing
},
})
const input = wrapper.find('input[type="text"]')
// Simulate user input
await input.setValue('23:45')
// Check if timeValue is updated
expect(wrapper.vm.timeValue).toBe('23:45')
// Check if input event is emitted with updated value
expect(wrapper.emitted().input).toBeTruthy()
expect(wrapper.emitted().input[0]).toEqual(['23:45'])
})
it('validates and corrects time format on blur', async () => {
const wrapper = mount(TimePicker)
const input = wrapper.find('input[type="text"]')
// Simulate user input
await input.setValue('99:99')
expect(wrapper.emitted().input).toBeTruthy()
expect(wrapper.emitted().input[0]).toEqual(['99:99'])
// Trigger blur event
await input.trigger('blur')
// Check if timeValue is corrected to valid format
expect(wrapper.vm.timeValue).toBe('23:59') // Maximum allowed value for hours and minutes
// Check if input event is emitted with corrected value
expect(wrapper.emitted().input).toBeTruthy()
expect(wrapper.emitted().input[1]).toEqual(['23:59'])
})
it('check handling of empty input', async () => {
const wrapper = mount(TimePicker)
const input = wrapper.find('input[type="text"]')
// Simulate user input with non-numeric characters
await input.setValue('')
// Trigger blur event
await input.trigger('blur')
// Check if non-numeric characters are filtered out
expect(wrapper.vm.timeValue).toBe('00:00')
// Check if input event is emitted with filtered value
expect(wrapper.emitted().input).toBeTruthy()
expect(wrapper.emitted().input[1]).toEqual(['00:00'])
})
})

View File

@ -0,0 +1,48 @@
<template>
<div>
<input
type="text"
v-model="timeValue"
@input="updateValues"
@blur="validateAndCorrect"
placeholder="hh:mm"
/>
</div>
</template>
<script>
export default {
// Code written from chatGPT 3.5
name: 'TimePicker',
props: {
value: {
type: String,
default: '00:00',
},
},
data() {
return {
timeValue: this.value,
}
},
methods: {
updateValues(event) {
// Allow only numbers and ":"
const inputValue = event.target.value.replace(/[^0-9:]/g, '')
this.timeValue = inputValue
this.$emit('input', inputValue)
},
validateAndCorrect() {
let [hours, minutes] = this.timeValue.split(':')
// Validate hours and minutes
hours = Math.min(parseInt(hours) || 0, 23)
minutes = Math.min(parseInt(minutes) || 0, 59)
// Update the value with correct format
this.timeValue = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`
this.$emit('input', this.timeValue)
},
},
}
</script>

View File

@ -1,11 +1,17 @@
import gql from 'graphql-tag'
export const adminCreateContributionMessage = gql`
mutation ($contributionId: Int!, $message: String!, $messageType: ContributionMessageType) {
mutation (
$contributionId: Int!
$message: String!
$messageType: ContributionMessageType
$resubmissionAt: String
) {
adminCreateContributionMessage(
contributionId: $contributionId
message: $message
messageType: $messageType
resubmissionAt: $resubmissionAt
) {
id
message

View File

@ -9,6 +9,7 @@ export const adminListContributions = gql`
$userId: Int
$query: String
$noHashtag: Boolean
$hideResubmission: Boolean
) {
adminListContributions(
currentPage: $currentPage
@ -18,6 +19,7 @@ export const adminListContributions = gql`
userId: $userId
query: $query
noHashtag: $noHashtag
hideResubmission: $hideResubmission
) {
contributionCount
contributionList {
@ -30,6 +32,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": {
@ -99,6 +99,8 @@
}
},
"hide_details": "Details verbergen",
"hide_resubmission": "Wiedervorlage verbergen",
"hide_resubmission_tooltip": "Verbirgt alle Schöpfungen für die ein Moderator ein Erinnerungsdatum festgelegt hat.",
"lastname": "Nachname",
"math": {
"equals": "=",
@ -109,9 +111,14 @@
"request": "Die Anfrage wurde gesendet."
},
"moderator": {
"chat": "Chat",
"history": "Die Daten wurden geändert. Dies sind die alten Daten.",
"show-submission-form": "warten auf Erinnerung?",
"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 +130,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 +203,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 +212,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": {
@ -99,6 +99,8 @@
}
},
"hide_details": "Hide details",
"hide_resubmission": "Hide resubmission",
"hide_resubmission_tooltip": "Hides all creations for which a moderator has set a reminder date.",
"lastname": "Lastname",
"math": {
"equals": "=",
@ -109,9 +111,14 @@
"request": "Request has been sent."
},
"moderator": {
"chat": "Chat",
"history": "The data has been changed. This is the old data.",
"show-submission-form": "wait for reminder?",
"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 +130,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 +203,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 +212,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')
@ -339,6 +347,7 @@ describe('CreationConfirm', () => {
it('refetches contributions with proper filter', () => {
expect(adminListContributionsMock).toBeCalledWith({
currentPage: 1,
hideResubmission: false,
noHashtag: null,
order: 'DESC',
pageSize: 25,
@ -356,6 +365,7 @@ describe('CreationConfirm', () => {
it('refetches contributions with proper filter', () => {
expect(adminListContributionsMock).toBeCalledWith({
currentPage: 1,
hideResubmission: true,
noHashtag: null,
order: 'DESC',
pageSize: 25,
@ -374,6 +384,7 @@ describe('CreationConfirm', () => {
it('refetches contributions with proper filter', () => {
expect(adminListContributionsMock).toBeCalledWith({
currentPage: 1,
hideResubmission: false,
noHashtag: null,
order: 'DESC',
pageSize: 25,
@ -392,6 +403,7 @@ describe('CreationConfirm', () => {
it('refetches contributions with proper filter', () => {
expect(adminListContributionsMock).toBeCalledWith({
currentPage: 1,
hideResubmission: false,
noHashtag: null,
order: 'DESC',
pageSize: 25,
@ -410,6 +422,7 @@ describe('CreationConfirm', () => {
it('refetches contributions with proper filter', () => {
expect(adminListContributionsMock).toBeCalledWith({
currentPage: 1,
hideResubmission: false,
noHashtag: null,
order: 'DESC',
pageSize: 25,
@ -432,6 +445,7 @@ describe('CreationConfirm', () => {
it('calls the API again', () => {
expect(adminListContributionsMock).toBeCalledWith({
currentPage: 2,
hideResubmission: false,
noHashtag: null,
order: 'DESC',
pageSize: 25,
@ -449,6 +463,7 @@ describe('CreationConfirm', () => {
it('refetches contributions with proper filter and current page = 1', () => {
expect(adminListContributionsMock).toBeCalledWith({
currentPage: 1,
hideResubmission: true,
noHashtag: null,
order: 'DESC',
pageSize: 25,
@ -472,6 +487,7 @@ describe('CreationConfirm', () => {
it('calls the API with query', () => {
expect(adminListContributionsMock).toBeCalledWith({
currentPage: 1,
hideResubmission: true,
noHashtag: null,
order: 'DESC',
pageSize: 25,
@ -488,6 +504,7 @@ describe('CreationConfirm', () => {
it('calls the API with empty query', () => {
expect(adminListContributionsMock).toBeCalledWith({
currentPage: 1,
hideResubmission: true,
noHashtag: null,
order: 'DESC',
pageSize: 25,
@ -510,6 +527,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

@ -2,10 +2,16 @@
<template>
<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" />
<p class="mb-2">
<input type="checkbox" class="noHashtag" v-model="noHashtag" />
<span class="ml-2" v-b-tooltip="$t('no_hashtag_tooltip')">{{ $t('no_hashtag') }}</span>
</label>
</p>
<p class="mb-4" v-if="showResubmissionCheckbox">
<input type="checkbox" class="hideResubmission" v-model="hideResubmissionModel" />
<span class="ml-2" v-b-tooltip="$t('hide_resubmission_tooltip')">
{{ $t('hide_resubmission') }}
</span>
</p>
<div>
<b-tabs v-model="tabIndex" content-class="mt-3" fill>
<b-tab active :title-link-attributes="{ 'data-test': 'open' }">
@ -47,8 +53,10 @@
class="mt-4"
:items="items"
:fields="fields"
:hideResubmission="hideResubmission"
@show-overlay="showOverlay"
@update-status="updateStatus"
@reload-contribution="reloadContribution"
@update-contributions="$apollo.queries.ListAllContributions.refetch()"
/>
@ -95,6 +103,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'],
@ -123,6 +132,7 @@ export default {
pageSize: 25,
query: '',
noHashtag: null,
hideResubmissionModel: true,
}
},
watch: {
@ -131,8 +141,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
@ -410,6 +433,12 @@ export default {
return 'info'
}
},
showResubmissionCheckbox() {
return this.tabIndex === 0
},
hideResubmission() {
return this.showResubmissionCheckbox ? this.hideResubmissionModel : false
},
},
apollo: {
ListAllContributions: {
@ -423,6 +452,7 @@ export default {
statusFilter: this.statusFilter,
query: this.query,
noHashtag: this.noHashtag,
hideResubmission: this.hideResubmission,
}
},
fetchPolicy: 'no-cache',

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(),
},
],
@ -112,6 +116,7 @@ describe('Overview', () => {
it('calls the adminListContributions query', () => {
expect(adminListContributionsMock).toBeCalledWith({
currentPage: 1,
hideResubmission: true,
order: 'DESC',
pageSize: 25,
statusFilter: ['IN_PROGRESS', 'PENDING'],

View File

@ -49,6 +49,7 @@ export default {
// may be at some point we need a pagination here
return {
statusFilter: this.statusFilter,
hideResubmission: true,
}
},
update({ adminListContributions }) {

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: '0075-contribution_message_add_index',
DB_VERSION: '0077-add_resubmission_date_contribution_message',
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,86 @@
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.contribution = contribution
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

@ -506,6 +506,173 @@ exports[`sendEmailVariants sendAddedContributionMessageEmail result has the corr
</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>
</div>
</footer>
</div>
</body>
</html>"
`;
exports[`sendEmailVariants sendContributionConfirmedEmail result has the correct html as snapshot 1`] = `
"<!DOCTYPE html>
<html lang=\\"en\\">

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

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

@ -3,6 +3,8 @@ import { ArgsType, Field, Int, InputType } from 'type-graphql'
import { ContributionMessageType } from '@enum/ContributionMessageType'
import { isValidDateString } from '@/graphql/validator/DateString'
@InputType()
@ArgsType()
export class ContributionMessageArgs {
@ -17,4 +19,8 @@ export class ContributionMessageArgs {
@Field(() => ContributionMessageType, { defaultValue: ContributionMessageType.DIALOG })
@IsEnum(ContributionMessageType)
messageType: ContributionMessageType
@Field(() => String, { nullable: true })
@isValidDateString()
resubmissionAt?: string | null
}

View File

@ -22,4 +22,8 @@ export class SearchContributionsFilterArgs {
@Field(() => Boolean, { nullable: true })
@IsBoolean()
noHashtag?: boolean | null
@Field(() => Boolean, { nullable: true })
@IsBoolean()
hideResubmission?: boolean | 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

@ -125,7 +125,7 @@ export class ContributionMessageResolver {
@Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTION_MESSAGE])
@Mutation(() => ContributionMessage)
async adminCreateContributionMessage(
@Args() { contributionId, message, messageType }: ContributionMessageArgs,
@Args() { contributionId, message, messageType, resubmissionAt }: ContributionMessageArgs,
@Ctx() context: Context,
): Promise<ContributionMessage> {
const moderator = getUser(context)
@ -156,6 +156,9 @@ export class ContributionMessageResolver {
contributionMessage.userId = moderator.id
contributionMessage.type = messageType
contributionMessage.isModerator = true
if (resubmissionAt) {
contributionMessage.resubmissionAt = new Date(resubmissionAt)
}
await queryRunner.manager.insert(DbContributionMessage, contributionMessage)
if (messageType !== ContributionMessageType.MODERATOR) {

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

@ -1,6 +1,7 @@
/* eslint-disable security/detect-object-injection */
import { Brackets, In, Like, Not, SelectQueryBuilder } from '@dbTools/typeorm'
import { Contribution as DbContribution } from '@entity/Contribution'
import { ContributionMessage } from '@entity/ContributionMessage'
import { Paginated } from '@arg/Paginated'
import { SearchContributionsFilterArgs } from '@arg/SearchContributionsFilterArgs'
@ -19,7 +20,6 @@ function joinRelationsRecursive(
currentPath: string,
): void {
for (const key in relations) {
// console.log('leftJoin: %s, %s', `${currentPath}.${key}`, key)
queryBuilder.leftJoinAndSelect(`${currentPath}.${key}`, key)
if (typeof relations[key] === 'object') {
// If it's a nested relation
@ -46,6 +46,31 @@ export const findContributions = async (
...(filter.userId && { userId: filter.userId }),
...(filter.noHashtag && { memo: Not(Like(`%#%`)) }),
})
if (filter.hideResubmission) {
queryBuilder
.leftJoinAndSelect(
(qb: SelectQueryBuilder<ContributionMessage>) => {
return qb
.select('resubmission_at', 'resubmissionAt')
.addSelect('id', 'latestMessageId')
.addSelect('contribution_id', 'latestMessageContributionId')
.addSelect(
'ROW_NUMBER() OVER (PARTITION BY latestMessageContributionId ORDER BY created_at DESC)',
'rn',
)
.from(ContributionMessage, 'contributionMessage')
},
'latestContributionMessage',
'latestContributionMessage.latestMessageContributionId = Contribution.id AND latestContributionMessage.rn = 1',
)
.andWhere(
new Brackets((qb) => {
qb.where('latestContributionMessage.resubmissionAt IS NULL').orWhere(
'latestContributionMessage.resubmissionAt <= NOW()',
)
}),
)
}
queryBuilder.printSql()
if (filter.query) {
const queryString = '%' + filter.query + '%'

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

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

@ -0,0 +1,63 @@
import {
BaseEntity,
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} 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
@CreateDateColumn()
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP', name: 'created_at' })
createdAt: Date
@UpdateDateColumn()
@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({ type: 'datetime', name: 'resubmission_at', default: null, nullable: true })
resubmissionAt: Date | null
@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

@ -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 './0075-contribution_message_add_index/ContributionMessage'
export { ContributionMessage } from './0077-add_resubmission_date_contribution_message/ContributionMessage'

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

@ -0,0 +1,12 @@
/* 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 COLUMN \`resubmission_at\` datetime NULL DEFAULT NULL AFTER \`deleted_by\`;`,
)
}
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn(`ALTER TABLE \`contribution_messages\` DROP COLUMN \`resubmission_at\`;`)
}

View File

@ -4,7 +4,7 @@ import dotenv from 'dotenv'
dotenv.config()
const constants = {
DB_VERSION: '0075-contribution_message_add_index',
DB_VERSION: '0077-add_resubmission_date_contribution_message',
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: '0075-contribution_message_add_index',
DB_VERSION: '0077-add_resubmission_date_contribution_message',
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

@ -4,9 +4,12 @@
<b-row class="mb-3 border border-197 p-1">
<b-col cols="10">
<small>{{ $d(new Date(message.createdAt), 'short') }}</small>
<div class="font-weight-bold" data-test="username">
<div class="font-weight-bold" data-test="username" v-if="isNotModerator">
{{ storeName.username }} {{ $t('contribution.isEdited') }}
</div>
<div class="font-weight-bold" data-test="moderator-name" v-else>
{{ $t('community.moderator') }} {{ $t('contribution.isEdited') }}
</div>
<div class="small">
{{ $t('contribution.oldContribution') }}
</div>

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