diff --git a/admin/scripts/sort.sh b/admin/scripts/sort.sh index e5c5c41c6..d24307d7c 100755 --- a/admin/scripts/sort.sh +++ b/admin/scripts/sort.sh @@ -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 diff --git a/admin/src/components/ContributionMessages/ContributionMessagesFormular.spec.js b/admin/src/components/ContributionMessages/ContributionMessagesFormular.spec.js index b7f01f8b8..f19459ce9 100644 --- a/admin/src/components/ContributionMessages/ContributionMessagesFormular.spec.js +++ b/admin/src/components/ContributionMessages/ContributionMessagesFormular.spec.js @@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils' import ContributionMessagesFormular from './ContributionMessagesFormular' import { toastErrorSpy, toastSuccessSpy } from '../../../test/testSetup' import { adminCreateContributionMessage } from '@/graphql/adminCreateContributionMessage' +import { adminUpdateContribution } from '@/graphql/adminUpdateContribution' const localVue = global.localVue @@ -12,6 +13,7 @@ describe('ContributionMessagesFormular', () => { const propsData = { contributionId: 42, + contributionMemo: 'It is a test memo', } const mocks = { @@ -52,9 +54,10 @@ describe('ContributionMessagesFormular', () => { await wrapper.find('form').trigger('reset') }) - it('form has empty text', () => { + it('form has empty text and memo reset to contribution memo input', () => { expect(wrapper.vm.form).toEqual({ text: '', + memo: 'It is a test memo', }) }) }) @@ -134,6 +137,32 @@ describe('ContributionMessagesFormular', () => { }) }) + describe('update contribution memo from moderator for user created contributions', () => { + beforeEach(async () => { + await wrapper.setData({ + form: { + memo: 'changed memo', + }, + chatOrMemo: 1, + }) + await wrapper.find('button[data-test="submit-dialog"]').trigger('click') + }) + + it('adminUpdateContribution was called with contributionId and updated memo', () => { + expect(apolloMutateMock).toBeCalledWith({ + mutation: adminUpdateContribution, + variables: { + id: 42, + memo: 'changed memo', + }, + }) + }) + + it('toasts an success message', () => { + expect(toastSuccessSpy).toBeCalledWith('message.request') + }) + }) + describe('send contribution message with error', () => { beforeEach(async () => { apolloMutateMock.mockRejectedValue({ message: 'OUCH!' }) diff --git a/admin/src/components/ContributionMessages/ContributionMessagesFormular.vue b/admin/src/components/ContributionMessages/ContributionMessagesFormular.vue index 1286104a4..1e395c183 100644 --- a/admin/src/components/ContributionMessages/ContributionMessagesFormular.vue +++ b/admin/src/components/ContributionMessages/ContributionMessagesFormular.vue @@ -2,12 +2,24 @@
- + + + + + + + + {{ $t('form.cancel') }} @@ -17,7 +29,16 @@ type="button" variant="warning" class="text-black" - :disabled="disabled" + @click.prevent="enableMemo()" + data-test="submit-memo" + > + {{ $t('moderator.memo-modify') }} + + @@ -43,6 +64,7 @@ diff --git a/admin/src/components/ContributionMessages/slots/ContributionMessagesListItem.vue b/admin/src/components/ContributionMessages/slots/ContributionMessagesListItem.vue index 4492fa88f..5abbb0a33 100644 --- a/admin/src/components/ContributionMessages/slots/ContributionMessagesListItem.vue +++ b/admin/src/components/ContributionMessages/slots/ContributionMessagesListItem.vue @@ -1,5 +1,5 @@ + @@ -164,6 +173,9 @@ export default { updateStatus(id) { this.$emit('update-status', id) }, + reloadContribution(id) { + this.$emit('reload-contribution', id) + }, }, } diff --git a/admin/src/graphql/adminListContributions.js b/admin/src/graphql/adminListContributions.js index 9d814b95d..e11ebfa05 100644 --- a/admin/src/graphql/adminListContributions.js +++ b/admin/src/graphql/adminListContributions.js @@ -30,6 +30,8 @@ export const adminListContributions = gql` contributionDate confirmedAt confirmedBy + updatedAt + updatedBy status messagesCount deniedAt diff --git a/admin/src/graphql/adminUpdateContribution.js b/admin/src/graphql/adminUpdateContribution.js index c52a0cbc4..db2f09072 100644 --- a/admin/src/graphql/adminUpdateContribution.js +++ b/admin/src/graphql/adminUpdateContribution.js @@ -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 diff --git a/admin/src/graphql/getContribution.js b/admin/src/graphql/getContribution.js new file mode 100644 index 000000000..34e260299 --- /dev/null +++ b/admin/src/graphql/getContribution.js @@ -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 + } + } +` diff --git a/admin/src/locales/de.json b/admin/src/locales/de.json index 33ef36053..264029cc6 100644 --- a/admin/src/locales/de.json +++ b/admin/src/locales/de.json @@ -1,4 +1,5 @@ { + "GDD": "GDD", "all_emails": "Alle Nutzer", "back": "zurück", "change_user_role": "Nutzerrolle ändern", @@ -42,6 +43,7 @@ "createdAt": "Angelegt", "creation": "Schöpfung", "creationList": "Schöpfungsliste", + "creation_for_month": "Schöpfung für Monat", "creation_form": { "creation_for": "Aktives Grundeinkommen für", "enter_text": "Text eintragen", @@ -58,16 +60,15 @@ "toasted_update": "`Offene Schöpfung {value} GDD) für {email} wurde geändert und liegt zur Bestätigung bereit", "update_creation": "Schöpfung aktualisieren" }, - "creation_for_month": "Schöpfung für Monat", "delete": "Löschen", + "delete_user": "Nutzer löschen", "deleted": "gelöscht", "deleted_user": "Alle gelöschten Nutzer", - "delete_user": "Nutzer löschen", "deny": "Ablehnen", + "e_mail": "E-Mail", "enabled": "aktiviert", "error": "Fehler", "expired": "abgelaufen", - "e_mail": "E-Mail", "federation": { "createdAt": "Erstellt am", "gradidoInstances": "Gradido Instanzen", @@ -88,7 +89,6 @@ "cancel": "Abbrechen", "submit": "Senden" }, - "GDD": "GDD", "help": { "help": "Hilfe", "transactionlist": { @@ -109,9 +109,13 @@ "request": "Die Anfrage wurde gesendet." }, "moderator": { + "chat": "Chat", "history": "Die Daten wurden geändert. Dies sind die alten Daten.", "moderator": "Moderator", "notice": "Moderator Notiz", + "memo": "Memo", + "memo-modify": "Memo bearbeiten", + "memo-modified": "Memo vom Moderator bearbeitet", "request": "Diese Nachricht ist nur für die Moderatoren sichtbar!" }, "name": "Name", @@ -123,9 +127,9 @@ "statistic": "Statistik", "user_search": "Nutzersuche" }, - "not_open_creations": "Keine offenen Schöpfungen", "no_hashtag": "#Hashtags verbergen", "no_hashtag_tooltip": "Zeigt nur Beiträge ohne Hashtag im Text", + "not_open_creations": "Keine offenen Schöpfungen", "open": "offen", "open_creations": "Offene Schöpfungen", "overlay": { @@ -196,7 +200,6 @@ "title": "Alle geschöpften Transaktionen für den Nutzer" }, "undelete_user": "Nutzer wiederherstellen", - "unregistered_emails": "Nur unregistrierte Nutzer", "unregister_mail": { "button": "Registrierungs-Email bestätigen, jetzt senden", "error": "Fehler beim Senden des Bestätigungs-Links an den Benutzer: {message}", @@ -206,6 +209,7 @@ "text_false": " Die letzte Email wurde am {date} Uhr an das Mitglied ({email}) gesendet.", "text_true": " Die Email wurde bestätigt." }, + "unregistered_emails": "Nur unregistrierte Nutzer", "userRole": { "notChangeYourSelf": "Als Admin/Moderator kannst du nicht selber deine Rolle ändern.", "selectLabel": "Rolle:", diff --git a/admin/src/locales/en.json b/admin/src/locales/en.json index 6c8b36f15..dbd831bb9 100644 --- a/admin/src/locales/en.json +++ b/admin/src/locales/en.json @@ -1,4 +1,5 @@ { + "GDD": "GDD", "all_emails": "All users", "back": "back", "change_user_role": "Change user role", @@ -42,6 +43,7 @@ "createdAt": "Created at", "creation": "Creation", "creationList": "Creation list", + "creation_for_month": "Creation for month", "creation_form": { "creation_for": "Active Basic Income for", "enter_text": "Enter text", @@ -58,16 +60,15 @@ "toasted_update": "Open creation {value} GDD) for {email} has been changed and is ready for confirmation.", "update_creation": "Creation update" }, - "creation_for_month": "Creation for month", "delete": "Delete", + "delete_user": "Delete user", "deleted": "deleted", "deleted_user": "All deleted user", - "delete_user": "Delete user", "deny": "Reject", + "e_mail": "E-mail", "enabled": "enabled", "error": "Error", "expired": "expired", - "e_mail": "E-mail", "federation": { "createdAt": "Created At ", "gradidoInstances": "Gradido Instances", @@ -88,7 +89,6 @@ "cancel": "Cancel", "submit": "Send" }, - "GDD": "GDD", "help": { "help": "Help", "transactionlist": { @@ -109,9 +109,13 @@ "request": "Request has been sent." }, "moderator": { + "chat": "Chat", "history": "The data has been changed. This is the old data.", "moderator": "Moderator", "notice": "Moderator note", + "memo": "Memo", + "memo-modify": "Modify Memo", + "memo-modified": "Memo edited by moderator", "request": "This message is only visible to the moderators!" }, "name": "Name", @@ -123,9 +127,9 @@ "statistic": "Statistic", "user_search": "User search" }, - "not_open_creations": "No open creations", "no_hashtag": "Hide #hashtags", "no_hashtag_tooltip": "Shows only contributions without hashtag in text", + "not_open_creations": "No open creations", "open": "open", "open_creations": "Open creations", "overlay": { @@ -196,7 +200,6 @@ "title": "All creation-transactions for the user" }, "undelete_user": "Undelete User", - "unregistered_emails": "Only unregistered users", "unregister_mail": { "button": "Confirm registration email, send now", "error": "Error sending the confirmation link to the user: {message}", @@ -206,6 +209,7 @@ "text_false": "The last email was sent to the member ({email}) on {date}.", "text_true": "The email was confirmed." }, + "unregistered_emails": "Only unregistered users", "userRole": { "notChangeYourSelf": "As an admin/moderator, you cannot change your own role.", "selectLabel": "Role:", diff --git a/admin/src/pages/CreationConfirm.spec.js b/admin/src/pages/CreationConfirm.spec.js index 36e2479aa..4842c8b3b 100644 --- a/admin/src/pages/CreationConfirm.spec.js +++ b/admin/src/pages/CreationConfirm.spec.js @@ -4,6 +4,7 @@ import { adminDeleteContribution } from '../graphql/adminDeleteContribution' import { denyContribution } from '../graphql/denyContribution' import { adminListContributions } from '../graphql/adminListContributions' import { confirmContribution } from '../graphql/confirmContribution' +import { getContribution } from '../graphql/getContribution' import { toastErrorSpy, toastSuccessSpy } from '../../test/testSetup' import VueApollo from 'vue-apollo' import { createMockClient } from 'mock-apollo-client' @@ -61,6 +62,8 @@ const defaultData = () => { contributionDate: new Date(), deletedBy: null, deletedAt: null, + updatedAt: null, + updatedBy: null, createdAt: new Date(), }, { @@ -83,6 +86,8 @@ const defaultData = () => { contributionDate: new Date(), deletedBy: null, deletedAt: null, + updatedAt: null, + updatedBy: null, createdAt: new Date(), }, ], @@ -96,6 +101,7 @@ describe('CreationConfirm', () => { const adminDeleteContributionMock = jest.fn() const adminDenyContributionMock = jest.fn() const confirmContributionMock = jest.fn() + const getContributionMock = jest.fn() mockClient.setRequestHandler( adminListContributions, @@ -121,6 +127,8 @@ describe('CreationConfirm', () => { confirmContributionMock.mockResolvedValue({ data: { confirmContribution: true } }), ) + mockClient.setRequestHandler(getContribution, getContributionMock.mockResolvedValue({ data: {} })) + const Wrapper = () => { return mount(CreationConfirm, { localVue, mocks, apolloProvider }) } @@ -141,7 +149,7 @@ describe('CreationConfirm', () => { }) }) - describe('server response is succes', () => { + describe('server response is success', () => { it('has a DIV element with the class.creation-confirm', () => { expect(wrapper.find('div.creation-confirm').exists()).toBeTruthy() }) @@ -219,7 +227,7 @@ describe('CreationConfirm', () => { expect(wrapper.find('#overlay').isVisible()).toBeTruthy() }) - describe('with succes', () => { + describe('with success', () => { describe('cancel confirmation', () => { beforeEach(async () => { await wrapper.find('#overlay').findAll('button').at(0).trigger('click') @@ -278,7 +286,7 @@ describe('CreationConfirm', () => { expect(wrapper.find('#overlay').isVisible()).toBeTruthy() }) - describe('with succes', () => { + describe('with success', () => { describe('cancel deny', () => { beforeEach(async () => { await wrapper.find('#overlay').findAll('button').at(0).trigger('click') @@ -510,6 +518,20 @@ describe('CreationConfirm', () => { }) }) + describe('reload contribution', () => { + beforeEach(async () => { + await wrapper + .findComponent({ name: 'OpenCreationsTable' }) + .vm.$emit('reload-contribution', 1) + }) + + it('reloaded contribution', () => { + expect(getContributionMock).toBeCalledWith({ + id: 1, + }) + }) + }) + describe('unknown variant', () => { beforeEach(async () => { await wrapper.setData({ variant: 'unknown' }) diff --git a/admin/src/pages/CreationConfirm.vue b/admin/src/pages/CreationConfirm.vue index bd4c58983..3ca382c43 100644 --- a/admin/src/pages/CreationConfirm.vue +++ b/admin/src/pages/CreationConfirm.vue @@ -3,7 +3,7 @@
@@ -49,6 +49,7 @@ :fields="fields" @show-overlay="showOverlay" @update-status="updateStatus" + @reload-contribution="reloadContribution" @update-contributions="$apollo.queries.ListAllContributions.refetch()" /> @@ -95,6 +96,7 @@ import { adminListContributions } from '../graphql/adminListContributions' import { adminDeleteContribution } from '../graphql/adminDeleteContribution' import { confirmContribution } from '../graphql/confirmContribution' import { denyContribution } from '../graphql/denyContribution' +import { getContribution } from '../graphql/getContribution' const FILTER_TAB_MAP = [ ['IN_PROGRESS', 'PENDING'], @@ -131,8 +133,21 @@ export default { }, }, methods: { - swapNoHashtag() { - this.query() + reloadContribution(id) { + this.$apollo + .query({ query: getContribution, variables: { id } }) + .then((result) => { + const contribution = result.data.contribution + this.$set( + this.items, + this.items.findIndex((obj) => obj.id === contribution.id), + contribution, + ) + }) + .catch((error) => { + this.overlay = false + this.toastError(error.message) + }) }, deleteCreation() { this.$apollo diff --git a/admin/src/pages/Overview.spec.js b/admin/src/pages/Overview.spec.js index b9ad77cdd..d5265f0e2 100644 --- a/admin/src/pages/Overview.spec.js +++ b/admin/src/pages/Overview.spec.js @@ -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(), }, ], diff --git a/backend/scripts/sort.sh b/backend/scripts/sort.sh index e5c5c41c6..d24307d7c 100755 --- a/backend/scripts/sort.sh +++ b/backend/scripts/sort.sh @@ -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 diff --git a/backend/src/auth/MODERATOR_RIGHTS.ts b/backend/src/auth/MODERATOR_RIGHTS.ts index 1ff689de6..61edad466 100644 --- a/backend/src/auth/MODERATOR_RIGHTS.ts +++ b/backend/src/auth/MODERATOR_RIGHTS.ts @@ -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, diff --git a/backend/src/auth/RIGHTS.ts b/backend/src/auth/RIGHTS.ts index 85ac3e3e7..0f6a4c00c 100644 --- a/backend/src/auth/RIGHTS.ts +++ b/backend/src/auth/RIGHTS.ts @@ -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', diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 57afee4cf..f96cb4fe4 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -12,7 +12,7 @@ Decimal.set({ }) const constants = { - DB_VERSION: '0075-contribution_message_add_index', + DB_VERSION: '0076-add_updated_by_contribution', DECAY_START_TIME: new Date('2021-05-13 17:46:31-0000'), // GMT+0 LOG4JS_CONFIG: 'log4js-config.json', // default log level on production should be info diff --git a/backend/src/data/Contribution.logic.ts b/backend/src/data/Contribution.logic.ts new file mode 100644 index 000000000..43b15bf3b --- /dev/null +++ b/backend/src/data/Contribution.logic.ts @@ -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 { + // 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) + } +} diff --git a/backend/src/data/ContributionMessage.builder.ts b/backend/src/data/ContributionMessage.builder.ts new file mode 100644 index 000000000..6d07bb81e --- /dev/null +++ b/backend/src/data/ContributionMessage.builder.ts @@ -0,0 +1,88 @@ +import { Contribution } from '@entity/Contribution' +import { ContributionMessage } from '@entity/ContributionMessage' +import { User } from '@entity/User' + +import { ContributionMessageType } from '@/graphql/enum/ContributionMessageType' + +// eslint-disable-next-line @typescript-eslint/no-extraneous-class +export class ContributionMessageBuilder { + private contributionMessage: ContributionMessage + + // https://refactoring.guru/design-patterns/builder/typescript/example + /** + * A fresh builder instance should contain a blank product object, which is + * used in further assembly. + */ + constructor() { + this.reset() + } + + public reset(): void { + this.contributionMessage = ContributionMessage.create() + } + + /** + * Concrete Builders are supposed to provide their own methods for + * retrieving results. That's because various types of builders may create + * entirely different products that don't follow the same interface. + * Therefore, such methods cannot be declared in the base Builder interface + * (at least in a statically typed programming language). + * + * Usually, after returning the end result to the client, a builder instance + * is expected to be ready to start producing another product. That's why + * it's a usual practice to call the reset method at the end of the + * `getProduct` method body. However, this behavior is not mandatory, and + * you can make your builders wait for an explicit reset call from the + * client code before disposing of the previous result. + */ + public build(): ContributionMessage { + const result = this.contributionMessage + this.reset() + return result + } + + public setParentContribution(contribution: Contribution): this { + this.contributionMessage.contributionId = contribution.id + this.contributionMessage.createdAt = contribution.updatedAt + ? contribution.updatedAt + : contribution.createdAt + return this + } + + /** + * set contribution message type to history and create message from contribution + * @param contribution + * @returns ContributionMessageBuilder for chaining function calls + */ + public setHistoryType(contribution: Contribution): this { + const changeMessage = `${contribution.contributionDate.toString()} + --- + ${contribution.memo} + --- + ${contribution.amount.toString()}` + this.contributionMessage.message = changeMessage + this.contributionMessage.type = ContributionMessageType.HISTORY + return this + } + + public setUser(user: User): this { + this.contributionMessage.user = user + this.contributionMessage.userId = user.id + return this + } + + public setUserId(userId: number): this { + this.contributionMessage.userId = userId + return this + } + + public setType(type: ContributionMessageType): this { + this.contributionMessage.type = type + return this + } + + public setIsModerator(value: boolean): this { + this.contributionMessage.isModerator = value + return this + } +} diff --git a/backend/src/data/UserLogic.ts b/backend/src/data/UserLogic.ts new file mode 100644 index 000000000..fbef2e609 --- /dev/null +++ b/backend/src/data/UserLogic.ts @@ -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()) + } +} diff --git a/backend/src/emails/__snapshots__/sendEmailVariants.test.ts.snap b/backend/src/emails/__snapshots__/sendEmailVariants.test.ts.snap index da50bbcaf..00ec365f0 100644 --- a/backend/src/emails/__snapshots__/sendEmailVariants.test.ts.snap +++ b/backend/src/emails/__snapshots__/sendEmailVariants.test.ts.snap @@ -506,6 +506,173 @@ exports[`sendEmailVariants sendAddedContributionMessageEmail result has the corr " `; +exports[`sendEmailVariants sendContributionChangedByModeratorEmail result has the correct html as snapshot 1`] = ` +" + + + + + + + + +
+
+
\\"Gradido
+
+
+

Your common good contribution has been changed

+
+

Hello Peter Lustig,

+

your common good contribution 'My contribution.' has just been changed by Bibi Bloxberg and now reads as 'This is a better contribution memo.'

+
+
+

Contribution details

+
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.
To account +
Or copy the link into your browser window.
http://localhost/community/contributions +
Please do not reply to this email.
+
+
+

Kind regards,
your Gradido team +

+
+
+
+
+
\\"facebook\\"\\"Telegram\\"\\"Twitter\\"\\"youtube\\"
+
+
+
If you have any further questions, please contact our support.
support@gradido.net\\"Gradido +
Privacy Policy +
Gradido-Akademie
Institut für Wirtschaftsbionik
Pfarrweg 2
74653 Künzelsau
Deutschland


+
+
+
+
+ +" +`; + exports[`sendEmailVariants sendContributionConfirmedEmail result has the correct html as snapshot 1`] = ` " diff --git a/backend/src/emails/sendEmailTranslated.test.ts b/backend/src/emails/sendEmailTranslated.test.ts index b6ec0fbb5..d09d11bd5 100644 --- a/backend/src/emails/sendEmailTranslated.test.ts +++ b/backend/src/emails/sendEmailTranslated.test.ts @@ -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 diff --git a/backend/src/emails/sendEmailVariants.test.ts b/backend/src/emails/sendEmailVariants.test.ts index 3340a361d..9a2cb4ce0 100644 --- a/backend/src/emails/sendEmailVariants.test.ts +++ b/backend/src/emails/sendEmailVariants.test.ts @@ -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 ', + }, + 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 ', + from: 'Gradido (emails.general.doNotAnswer) ', + 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({ diff --git a/backend/src/emails/sendEmailVariants.ts b/backend/src/emails/sendEmailVariants.ts index ff7709380..8bcc9accd 100644 --- a/backend/src/emails/sendEmailVariants.ts +++ b/backend/src/emails/sendEmailVariants.ts @@ -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 | 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 diff --git a/backend/src/emails/templates/contributionChangedByModerator/html.pug b/backend/src/emails/templates/contributionChangedByModerator/html.pug new file mode 100644 index 000000000..46bcd4ae1 --- /dev/null +++ b/backend/src/emails/templates/contributionChangedByModerator/html.pug @@ -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 \ No newline at end of file diff --git a/backend/src/emails/templates/contributionChangedByModerator/subject.pug b/backend/src/emails/templates/contributionChangedByModerator/subject.pug new file mode 100644 index 000000000..791cee555 --- /dev/null +++ b/backend/src/emails/templates/contributionChangedByModerator/subject.pug @@ -0,0 +1 @@ += t('emails.contributionChangedByModerator.subject') diff --git a/backend/src/graphql/arg/AdminUpdateContributionArgs.ts b/backend/src/graphql/arg/AdminUpdateContributionArgs.ts index f7288cb28..5fb2228f1 100644 --- a/backend/src/graphql/arg/AdminUpdateContributionArgs.ts +++ b/backend/src/graphql/arg/AdminUpdateContributionArgs.ts @@ -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 } diff --git a/backend/src/graphql/model/Contribution.ts b/backend/src/graphql/model/Contribution.ts index 6f36a9f64..105f646b1 100644 --- a/backend/src/graphql/model/Contribution.ts +++ b/backend/src/graphql/model/Contribution.ts @@ -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 diff --git a/backend/src/graphql/resolver/ContributionResolver.test.ts b/backend/src/graphql/resolver/ContributionResolver.test.ts index e6cb485a3..8b2bf141e 100644 --- a/backend/src/graphql/resolver/ContributionResolver.test.ts +++ b/backend/src/graphql/resolver/ContributionResolver.test.ts @@ -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', }), ]), diff --git a/backend/src/graphql/resolver/ContributionResolver.ts b/backend/src/graphql/resolver/ContributionResolver.ts index 1ffa53b27..638cbbdb3 100644 --- a/backend/src/graphql/resolver/ContributionResolver.ts +++ b/backend/src/graphql/resolver/ContributionResolver.ts @@ -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 { + 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 { - 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 { - 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, diff --git a/backend/src/graphql/resolver/util/contributions.ts b/backend/src/graphql/resolver/util/contributions.ts new file mode 100644 index 000000000..c4f0fb46a --- /dev/null +++ b/backend/src/graphql/resolver/util/contributions.ts @@ -0,0 +1,5 @@ +import { Contribution } from '@entity/Contribution' + +export const findContribution = async (id: number): Promise => { + return Contribution.findOne({ where: { id } }) +} diff --git a/backend/src/interactions/updateUnconfirmedContribution/AbstractUnconfirmedContribution.role.ts b/backend/src/interactions/updateUnconfirmedContribution/AbstractUnconfirmedContribution.role.ts new file mode 100644 index 000000000..473d30605 --- /dev/null +++ b/backend/src/interactions/updateUnconfirmedContribution/AbstractUnconfirmedContribution.role.ts @@ -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 { + // 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 { + 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 + } +} diff --git a/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionAdmin.role.ts b/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionAdmin.role.ts new file mode 100644 index 000000000..72053d3ab --- /dev/null +++ b/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionAdmin.role.ts @@ -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 { + 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") + } + } +} diff --git a/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionUser.role.ts b/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionUser.role.ts new file mode 100644 index 000000000..944204802 --- /dev/null +++ b/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionUser.role.ts @@ -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 { + 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") + } + } +} diff --git a/backend/src/interactions/updateUnconfirmedContribution/UpdateUnconfirmedContribution.context.ts b/backend/src/interactions/updateUnconfirmedContribution/UpdateUnconfirmedContribution.context.ts new file mode 100644 index 000000000..1836c2923 --- /dev/null +++ b/backend/src/interactions/updateUnconfirmedContribution/UpdateUnconfirmedContribution.context.ts @@ -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 + } +} diff --git a/backend/src/locales/de.json b/backend/src/locales/de.json index 349c5089e..c99e1657e 100644 --- a/backend/src/locales/de.json +++ b/backend/src/locales/de.json @@ -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", diff --git a/backend/src/locales/en.json b/backend/src/locales/en.json index cf4bbef47..15a56577c 100644 --- a/backend/src/locales/en.json +++ b/backend/src/locales/en.json @@ -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", diff --git a/backend/src/util/time.ts b/backend/src/util/time.ts index d429c8d6b..538735766 100644 --- a/backend/src/util/time.ts +++ b/backend/src/util/time.ts @@ -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), diff --git a/backend/src/util/utilities.ts b/backend/src/util/utilities.ts index 904c86226..c3895cb9e 100644 --- a/backend/src/util/utilities.ts +++ b/backend/src/util/utilities.ts @@ -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>(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] + } + } + return obj +} diff --git a/database/entity/0076-add_updated_by_contribution/Contribution.ts b/database/entity/0076-add_updated_by_contribution/Contribution.ts new file mode 100644 index 000000000..8ed8c82d5 --- /dev/null +++ b/database/entity/0076-add_updated_by_contribution/Contribution.ts @@ -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 +} diff --git a/database/entity/Contribution.ts b/database/entity/Contribution.ts index 0441e7a1f..2a2e89cfa 100644 --- a/database/entity/Contribution.ts +++ b/database/entity/Contribution.ts @@ -1 +1 @@ -export { Contribution } from './0052-add_updated_at_to_contributions/Contribution' +export { Contribution } from './0076-add_updated_by_contribution/Contribution' diff --git a/database/migrations/0076-add_updated_by_contribution.ts b/database/migrations/0076-add_updated_by_contribution.ts new file mode 100644 index 000000000..520830149 --- /dev/null +++ b/database/migrations/0076-add_updated_by_contribution.ts @@ -0,0 +1,9 @@ +export async function upgrade(queryFn: (query: string, values?: any[]) => Promise>) { + 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>) { + await queryFn(`ALTER TABLE \`contributions\` DROP COLUMN \`updated_by\`;`) +} diff --git a/dht-node/src/config/index.ts b/dht-node/src/config/index.ts index 3faa608a5..f3c2e0723 100644 --- a/dht-node/src/config/index.ts +++ b/dht-node/src/config/index.ts @@ -4,7 +4,7 @@ import dotenv from 'dotenv' dotenv.config() const constants = { - DB_VERSION: '0075-contribution_message_add_index', + DB_VERSION: '0076-add_updated_by_contribution', LOG4JS_CONFIG: 'log4js-config.json', // default log level on production should be info LOG_LEVEL: process.env.LOG_LEVEL || 'info', diff --git a/federation/src/config/index.ts b/federation/src/config/index.ts index cedced566..9dec6d878 100644 --- a/federation/src/config/index.ts +++ b/federation/src/config/index.ts @@ -10,7 +10,7 @@ Decimal.set({ }) const constants = { - DB_VERSION: '0075-contribution_message_add_index', + DB_VERSION: '0076-add_updated_by_contribution', DECAY_START_TIME: new Date('2021-05-13 17:46:31-0000'), // GMT+0 LOG4JS_CONFIG: 'log4js-config.json', // default log level on production should be info diff --git a/frontend/scripts/sort.sh b/frontend/scripts/sort.sh index e5c5c41c6..d24307d7c 100755 --- a/frontend/scripts/sort.sh +++ b/frontend/scripts/sort.sh @@ -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 diff --git a/frontend/src/components/Contributions/ContributionForm.vue b/frontend/src/components/Contributions/ContributionForm.vue index e8fb3eb30..8b2113e5d 100644 --- a/frontend/src/components/Contributions/ContributionForm.vue +++ b/frontend/src/components/Contributions/ContributionForm.vue @@ -16,7 +16,6 @@ reset-value="" :label-no-date-selected="$t('contribution.noDateSelected')" required - :disabled="this.form.id !== null" :no-flip="true" > diff --git a/frontend/src/components/Contributions/ContributionListItem.vue b/frontend/src/components/Contributions/ContributionListItem.vue index dd970d72c..323f1c925 100644 --- a/frontend/src/components/Contributions/ContributionListItem.vue +++ b/frontend/src/components/Contributions/ContributionListItem.vue @@ -25,6 +25,9 @@
{{ $t('contributionText') }}
{{ memo }}
+
+ {{ $t('moderatorChangedMemo') }} +
{ - 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