mirror of
https://github.com/IT4Change/gradido.git
synced 2026-02-06 09:56:05 +00:00
Merge branch 'master' into 3244-devops-change-databse-backup-filename-to-orderable-date-pattern
This commit is contained in:
commit
336903b5a0
@ -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
|
||||
|
||||
@ -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',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
63
admin/src/components/input/TimePicker.spec.js
Normal file
63
admin/src/components/input/TimePicker.spec.js
Normal 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'])
|
||||
})
|
||||
})
|
||||
48
admin/src/components/input/TimePicker.vue
Normal file
48
admin/src/components/input/TimePicker.vue
Normal 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>
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
27
admin/src/graphql/getContribution.js
Normal file
27
admin/src/graphql/getContribution.js
Normal 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
|
||||
}
|
||||
}
|
||||
`
|
||||
@ -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:",
|
||||
|
||||
@ -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:",
|
||||
|
||||
@ -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' })
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -49,6 +49,7 @@ export default {
|
||||
// may be at some point we need a pagination here
|
||||
return {
|
||||
statusFilter: this.statusFilter,
|
||||
hideResubmission: true,
|
||||
}
|
||||
},
|
||||
update({ adminListContributions }) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
52
backend/src/data/Contribution.logic.ts
Normal file
52
backend/src/data/Contribution.logic.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
86
backend/src/data/ContributionMessage.builder.ts
Normal file
86
backend/src/data/ContributionMessage.builder.ts
Normal 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
|
||||
}
|
||||
}
|
||||
11
backend/src/data/UserLogic.ts
Normal file
11
backend/src/data/UserLogic.ts
Normal 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())
|
||||
}
|
||||
}
|
||||
@ -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\\">
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -0,0 +1 @@
|
||||
= t('emails.contributionChangedByModerator.subject')
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -22,4 +22,8 @@ export class SearchContributionsFilterArgs {
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
@IsBoolean()
|
||||
noHashtag?: boolean | null
|
||||
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
@IsBoolean()
|
||||
hideResubmission?: boolean | null
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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',
|
||||
}),
|
||||
]),
|
||||
|
||||
@ -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,
|
||||
|
||||
5
backend/src/graphql/resolver/util/contributions.ts
Normal file
5
backend/src/graphql/resolver/util/contributions.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { Contribution } from '@entity/Contribution'
|
||||
|
||||
export const findContribution = async (id: number): Promise<Contribution | null> => {
|
||||
return Contribution.findOne({ where: { id } })
|
||||
}
|
||||
@ -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 + '%'
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
104
database/entity/0076-add_updated_by_contribution/Contribution.ts
Normal file
104
database/entity/0076-add_updated_by_contribution/Contribution.ts
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -1 +1 @@
|
||||
export { Contribution } from './0052-add_updated_at_to_contributions/Contribution'
|
||||
export { Contribution } from './0076-add_updated_by_contribution/Contribution'
|
||||
|
||||
@ -1 +1 @@
|
||||
export { ContributionMessage } from './0075-contribution_message_add_index/ContributionMessage'
|
||||
export { ContributionMessage } from './0077-add_resubmission_date_contribution_message/ContributionMessage'
|
||||
|
||||
9
database/migrations/0076-add_updated_by_contribution.ts
Normal file
9
database/migrations/0076-add_updated_by_contribution.ts
Normal 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\`;`)
|
||||
}
|
||||
@ -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\`;`)
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 ",
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user