From 4f955f6ce1438310054f3f4598ec4d32bd6fd4de Mon Sep 17 00:00:00 2001 From: einhorn_b Date: Fri, 3 Nov 2023 13:49:42 +0100 Subject: [PATCH 01/33] add new right for moderator to update contribution memo --- backend/src/auth/MODERATOR_RIGHTS.ts | 1 + backend/src/auth/RIGHTS.ts | 1 + 2 files changed, 2 insertions(+) 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', From 5c7ffe7018b5815bc1294e97aa3e21b010e70c84 Mon Sep 17 00:00:00 2001 From: einhorn_b Date: Fri, 3 Nov 2023 14:09:11 +0100 Subject: [PATCH 02/33] add updatedBy field to Contributions in db --- .../Contribution.ts | 104 ++++++++++++++++++ database/entity/Contribution.ts | 2 +- .../0074-add_updated_by_contribution.ts | 9 ++ 3 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 database/entity/0074-add_updated_by_contribution/Contribution.ts create mode 100644 database/migrations/0074-add_updated_by_contribution.ts diff --git a/database/entity/0074-add_updated_by_contribution/Contribution.ts b/database/entity/0074-add_updated_by_contribution/Contribution.ts new file mode 100644 index 000000000..f2b6987f1 --- /dev/null +++ b/database/entity/0074-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' }) + updatedBy?: number + + @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..ef43b88df 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 './0074-add_updated_by_contribution/Contribution' diff --git a/database/migrations/0074-add_updated_by_contribution.ts b/database/migrations/0074-add_updated_by_contribution.ts new file mode 100644 index 000000000..6403326ca --- /dev/null +++ b/database/migrations/0074-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\` boolean NULL DEFAULT false AFTER \`updated_at\`;`, + ) +} + +export async function downgrade(queryFn: (query: string, values?: any[]) => Promise>) { + await queryFn(`ALTER TABLE \`contributions\` DROP COLUMN \`updated_by\`;`) +} From 8418e55c99d2d9a7db4d68c3ffd491ae111988dd Mon Sep 17 00:00:00 2001 From: einhorn_b Date: Fri, 3 Nov 2023 14:10:33 +0100 Subject: [PATCH 03/33] update db version in config --- backend/src/config/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 25e901491..1fda26728 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -12,7 +12,7 @@ Decimal.set({ }) const constants = { - DB_VERSION: '0073-introduce_foreign_user_in_users_table', + DB_VERSION: '0074-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 From b1d99c5fb4cc3070dcbecab8227bf015ee90093b Mon Sep 17 00:00:00 2001 From: einhorn_b Date: Sat, 4 Nov 2023 18:39:57 +0100 Subject: [PATCH 04/33] move code for update contribution message into context/role/logic/builder classes, reduce double code --- backend/src/data/Contribution.logic.ts | 52 ++++++ .../src/data/ContributionMessage.builder.ts | 89 +++++++++++ backend/src/data/UserLogic.ts | 11 ++ backend/src/graphql/model/Contribution.ts | 8 + .../graphql/resolver/ContributionResolver.ts | 149 +++++------------- .../UnconfirmedContribution.role.ts | 53 +++++++ .../UnconfirmedContributionAdmin.role.ts | 40 +++++ .../UnconfirmedContributionUser.role.ts | 46 ++++++ .../UpdateUnconfirmedContribution.context.ts | 84 ++++++++++ 9 files changed, 420 insertions(+), 112 deletions(-) create mode 100644 backend/src/data/Contribution.logic.ts create mode 100644 backend/src/data/ContributionMessage.builder.ts create mode 100644 backend/src/data/UserLogic.ts create mode 100644 backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContribution.role.ts create mode 100644 backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionAdmin.role.ts create mode 100644 backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionUser.role.ts create mode 100644 backend/src/interactions/updateUnconfirmedContribution/UpdateUnconfirmedContribution.context.ts 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..0c2e25dd7 --- /dev/null +++ b/backend/src/data/ContributionMessage.builder.ts @@ -0,0 +1,89 @@ +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 + this.contributionMessage.isModerator = false + 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/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.ts b/backend/src/graphql/resolver/ContributionResolver.ts index 1ffa53b27..9a267e1db 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' @@ -38,6 +37,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,12 +45,7 @@ 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 { getUserCreation, validateContribution, getOpenCreations } from './util/creations' import { findContributions } from './util/findContributions' import { getLastTransaction } from './util/getLastTransaction' import { sendTransactionsToDltConnector } from './util/sendTransactionsToDltConnector' @@ -169,75 +164,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,54 +240,33 @@ 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 moderator = getUser(context) - - const contributionToUpdate = await DbContribution.findOne({ - where: { id, confirmedAt: IsNull(), deniedAt: IsNull() }, + const updateUnconfirmedContributionContext = new UpdateUnconfirmedContributionContext( + adminUpdateContributionArgs.id, + adminUpdateContributionArgs, + context, + ) + const { contribution, contributionMessage } = 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', 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 moderator = getUser(context) + const { amount } = adminUpdateContributionArgs const result = new AdminUpdateContribution() result.amount = amount - result.memo = contributionToUpdate.memo - result.date = contributionToUpdate.contributionDate + 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, + contribution, amount, ) diff --git a/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContribution.role.ts b/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContribution.role.ts new file mode 100644 index 000000000..dfffbb0af --- /dev/null +++ b/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContribution.role.ts @@ -0,0 +1,53 @@ +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 { LogError } from '@/server/LogError' + +export abstract class UnconfirmedContributionRole { + private availableCreationSums?: Decimal[] + + public constructor( + protected self: Contribution, + private updatedAmount: Decimal, + private 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 + public abstract checkAuthorization(user: User, role: Role): void + // second, check if contribution is still valid after update + public async validate(clientTimezoneOffset: number): Promise { + // TODO: 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 + public abstract update(): void + + public getAvailableCreationSums(): Decimal[] { + if (!this.availableCreationSums) { + throw new LogError('availableCreationSums is empty, please call validate before!') + } + return this.availableCreationSums + } +} diff --git a/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionAdmin.role.ts b/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionAdmin.role.ts new file mode 100644 index 000000000..562125c82 --- /dev/null +++ b/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionAdmin.role.ts @@ -0,0 +1,40 @@ +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 { UnconfirmedContributionRole } from './UnconfirmedContribution.role' + +export class UnconfirmedContributionAdminRole extends UnconfirmedContributionRole { + public constructor( + contribution: Contribution, + private updateData: AdminUpdateContributionArgs, + private moderator: User, + ) { + super(contribution, updateData.amount, new Date(updateData.creationDate)) + } + + public 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() + this.self.updatedBy = this.moderator.id + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public checkAuthorization(user: User, role: Role): UnconfirmedContributionRole { + 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 + } +} diff --git a/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionUser.role.ts b/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionUser.role.ts new file mode 100644 index 000000000..27aedeca8 --- /dev/null +++ b/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionUser.role.ts @@ -0,0 +1,46 @@ +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 { UnconfirmedContributionRole } from './UnconfirmedContribution.role' + +export class UnconfirmedContributionUserRole extends UnconfirmedContributionRole { + public constructor(contribution: Contribution, private updateData: ContributionArgs) { + super(contribution, updateData.amount, new Date(updateData.creationDate)) + } + + public 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 = undefined + } + + public checkAuthorization(user: User): UnconfirmedContributionRole { + 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 init it + // TODO: rethink + if (this.self.moderatorId) { + throw new LogError('Cannot update contribution of moderator', this.self, user.id) + } + return this + } +} diff --git a/backend/src/interactions/updateUnconfirmedContribution/UpdateUnconfirmedContribution.context.ts b/backend/src/interactions/updateUnconfirmedContribution/UpdateUnconfirmedContribution.context.ts new file mode 100644 index 000000000..f2f9a6257 --- /dev/null +++ b/backend/src/interactions/updateUnconfirmedContribution/UpdateUnconfirmedContribution.context.ts @@ -0,0 +1,84 @@ +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, getClientTimezoneOffset } from '@/server/context' +import { LogError } from '@/server/LogError' + +import { UnconfirmedContributionRole } from './UnconfirmedContribution.role' +import { UnconfirmedContributionAdminRole } from './UnconfirmedContributionAdmin.role' +import { UnconfirmedContributionUserRole } from './UnconfirmedContributionUser.role' + +export class UpdateUnconfirmedContributionContext { + /** + * + * @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[] + }> { + 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) + } + const contributionMessageBuilder = new ContributionMessageBuilder() + contributionMessageBuilder + .setParentContribution(contributionToUpdate) + .setHistoryType(contributionToUpdate) + .setUser(this.context.user) + + // choose correct role + let unconfirmedContributionRole: UnconfirmedContributionRole | 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, + ) + 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 functions + unconfirmedContributionRole.checkAuthorization(this.context.user, this.context.role) + await unconfirmedContributionRole.validate(getClientTimezoneOffset(this.context)) + unconfirmedContributionRole.update() + + return { + contribution: contributionToUpdate, + contributionMessage: contributionMessageBuilder.build(), + availableCreationSums: unconfirmedContributionRole.getAvailableCreationSums(), + } + } +} From f03f428477d5ed914d3cfc245457f1f57fea0183 Mon Sep 17 00:00:00 2001 From: einhorn_b Date: Sat, 4 Nov 2023 18:43:48 +0100 Subject: [PATCH 05/33] fix bug in migration --- database/migrations/0074-add_updated_by_contribution.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/database/migrations/0074-add_updated_by_contribution.ts b/database/migrations/0074-add_updated_by_contribution.ts index 6403326ca..520830149 100644 --- a/database/migrations/0074-add_updated_by_contribution.ts +++ b/database/migrations/0074-add_updated_by_contribution.ts @@ -1,6 +1,6 @@ export async function upgrade(queryFn: (query: string, values?: any[]) => Promise>) { await queryFn( - `ALTER TABLE \`contributions\` ADD COLUMN \`updated_by\` boolean NULL DEFAULT false AFTER \`updated_at\`;`, + `ALTER TABLE \`contributions\` ADD COLUMN \`updated_by\` int(10) unsigned NULL DEFAULT NULL AFTER \`updated_at\`;`, ) } From dfb85d036bd6c3f8fa90ece6092bea5a1c69b9d2 Mon Sep 17 00:00:00 2001 From: einhorn_b Date: Mon, 6 Nov 2023 10:10:08 +0100 Subject: [PATCH 06/33] fix --- .../resolver/ContributionResolver.test.ts | 25 ++----------------- .../UnconfirmedContributionUser.role.ts | 2 +- .../Contribution.ts | 4 +-- 3 files changed, 5 insertions(+), 26 deletions(-) diff --git a/backend/src/graphql/resolver/ContributionResolver.test.ts b/backend/src/graphql/resolver/ContributionResolver.test.ts index e6cb485a3..c2f3fc3ae 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,8 @@ describe('ContributionResolver', () => { } = await query({ query: adminListContributions, }) - // console.log('17 contributions: %s', JSON.stringify(contributionListObject, null, 2)) + // console.log('18 contributions: %s', JSON.stringify(contributionListObject, null, 2)) + // console.log(contributionListObject) expect(contributionListObject.contributionList).toHaveLength(18) expect(contributionListObject).toMatchObject({ contributionCount: 18, diff --git a/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionUser.role.ts b/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionUser.role.ts index 27aedeca8..fad8ac56f 100644 --- a/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionUser.role.ts +++ b/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionUser.role.ts @@ -19,7 +19,7 @@ export class UnconfirmedContributionUserRole extends UnconfirmedContributionRole this.self.contributionStatus = ContributionStatus.PENDING this.self.updatedAt = new Date() // null because updated by user them self - this.self.updatedBy = undefined + this.self.updatedBy = null } public checkAuthorization(user: User): UnconfirmedContributionRole { diff --git a/database/entity/0074-add_updated_by_contribution/Contribution.ts b/database/entity/0074-add_updated_by_contribution/Contribution.ts index f2b6987f1..8ed8c82d5 100644 --- a/database/entity/0074-add_updated_by_contribution/Contribution.ts +++ b/database/entity/0074-add_updated_by_contribution/Contribution.ts @@ -85,8 +85,8 @@ export class Contribution extends BaseEntity { @Column({ nullable: true, name: 'updated_at' }) updatedAt: Date - @Column({ nullable: true, unsigned: true, name: 'updated_by' }) - updatedBy?: number + @Column({ nullable: true, unsigned: true, name: 'updated_by', type: 'int' }) + updatedBy: number | null @DeleteDateColumn({ name: 'deleted_at' }) deletedAt: Date | null From 1165b346eb1cd1ade13ab55fc2cd38dd1d13b63c Mon Sep 17 00:00:00 2001 From: einhorn_b Date: Mon, 6 Nov 2023 15:28:21 +0100 Subject: [PATCH 07/33] update message count in test, with updated code updating contribution from admin also create a history contributionMessage --- .../src/graphql/resolver/ContributionResolver.test.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/backend/src/graphql/resolver/ContributionResolver.test.ts b/backend/src/graphql/resolver/ContributionResolver.test.ts index c2f3fc3ae..8b2bf141e 100644 --- a/backend/src/graphql/resolver/ContributionResolver.test.ts +++ b/backend/src/graphql/resolver/ContributionResolver.test.ts @@ -2802,8 +2802,7 @@ describe('ContributionResolver', () => { } = await query({ query: adminListContributions, }) - // console.log('18 contributions: %s', JSON.stringify(contributionListObject, null, 2)) - // console.log(contributionListObject) + expect(contributionListObject.contributionList).toHaveLength(18) expect(contributionListObject).toMatchObject({ contributionCount: 18, @@ -2886,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({ @@ -3071,7 +3070,7 @@ describe('ContributionResolver', () => { id: expect.any(Number), lastName: 'Lustig', memo: 'Das war leider zu Viel!', - messagesCount: 0, + messagesCount: 1, status: 'DELETED', }), ]), @@ -3116,7 +3115,7 @@ describe('ContributionResolver', () => { id: expect.any(Number), lastName: 'Lustig', memo: 'Das war leider zu Viel!', - messagesCount: 0, + messagesCount: 1, status: 'DELETED', }), ]), From e7ad986389edeafbd72620a99d3614c80963e785 Mon Sep 17 00:00:00 2001 From: einhorn_b Date: Mon, 6 Nov 2023 18:48:12 +0100 Subject: [PATCH 08/33] update interactions, better code --- .../UnconfirmedContribution.role.ts | 16 +++++++++++++--- .../UnconfirmedContributionAdmin.role.ts | 4 ++-- .../UnconfirmedContributionUser.role.ts | 4 ++-- .../UpdateUnconfirmedContribution.context.ts | 6 ++---- 4 files changed, 19 insertions(+), 11 deletions(-) diff --git a/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContribution.role.ts b/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContribution.role.ts index dfffbb0af..e1cada46b 100644 --- a/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContribution.role.ts +++ b/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContribution.role.ts @@ -4,6 +4,7 @@ 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 UnconfirmedContributionRole { @@ -21,9 +22,9 @@ export abstract class UnconfirmedContributionRole { // steps which return void throw on each error // first, check if it can be updated - public abstract checkAuthorization(user: User, role: Role): void + protected abstract checkAuthorization(user: User, role: Role): void // second, check if contribution is still valid after update - public async validate(clientTimezoneOffset: number): Promise { + protected async validate(clientTimezoneOffset: number): Promise { // TODO: remove this restriction if (this.self.contributionDate.getMonth() !== this.updatedCreationDate.getMonth()) { throw new LogError('Month of contribution can not be changed') @@ -42,7 +43,16 @@ export abstract class UnconfirmedContributionRole { } // third, actually update entity - public abstract update(): void + protected abstract update(): void + + 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) { diff --git a/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionAdmin.role.ts b/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionAdmin.role.ts index 562125c82..4c395b42b 100644 --- a/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionAdmin.role.ts +++ b/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionAdmin.role.ts @@ -18,7 +18,7 @@ export class UnconfirmedContributionAdminRole extends UnconfirmedContributionRol super(contribution, updateData.amount, new Date(updateData.creationDate)) } - public update(): void { + protected update(): void { this.self.amount = this.updateData.amount this.self.memo = this.updateData.memo this.self.contributionDate = new Date(this.updateData.creationDate) @@ -28,7 +28,7 @@ export class UnconfirmedContributionAdminRole extends UnconfirmedContributionRol } // eslint-disable-next-line @typescript-eslint/no-unused-vars - public checkAuthorization(user: User, role: Role): UnconfirmedContributionRole { + protected checkAuthorization(user: User, role: Role): UnconfirmedContributionRole { if ( !role.hasRight(RIGHTS.MODERATOR_UPDATE_CONTRIBUTION_MEMO) && this.self.moderatorId === null diff --git a/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionUser.role.ts b/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionUser.role.ts index fad8ac56f..6316c35f1 100644 --- a/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionUser.role.ts +++ b/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionUser.role.ts @@ -12,7 +12,7 @@ export class UnconfirmedContributionUserRole extends UnconfirmedContributionRole super(contribution, updateData.amount, new Date(updateData.creationDate)) } - public update(): void { + protected update(): void { this.self.amount = this.updateData.amount this.self.memo = this.updateData.memo this.self.contributionDate = new Date(this.updateData.creationDate) @@ -22,7 +22,7 @@ export class UnconfirmedContributionUserRole extends UnconfirmedContributionRole this.self.updatedBy = null } - public checkAuthorization(user: User): UnconfirmedContributionRole { + protected checkAuthorization(user: User): UnconfirmedContributionRole { if (this.self.userId !== user.id) { throw new LogError('Can not update contribution of another user', this.self, user.id) } diff --git a/backend/src/interactions/updateUnconfirmedContribution/UpdateUnconfirmedContribution.context.ts b/backend/src/interactions/updateUnconfirmedContribution/UpdateUnconfirmedContribution.context.ts index f2f9a6257..869870bef 100644 --- a/backend/src/interactions/updateUnconfirmedContribution/UpdateUnconfirmedContribution.context.ts +++ b/backend/src/interactions/updateUnconfirmedContribution/UpdateUnconfirmedContribution.context.ts @@ -70,10 +70,8 @@ export class UpdateUnconfirmedContributionContext { 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 functions - unconfirmedContributionRole.checkAuthorization(this.context.user, this.context.role) - await unconfirmedContributionRole.validate(getClientTimezoneOffset(this.context)) - unconfirmedContributionRole.update() + // all possible cases not to be true are thrown in the next function + await unconfirmedContributionRole.checkAndUpdate(this.context) return { contribution: contributionToUpdate, From 450b3d263919b45e9a20d8709fa248e3311d7b06 Mon Sep 17 00:00:00 2001 From: einhorn_b Date: Mon, 6 Nov 2023 18:58:35 +0100 Subject: [PATCH 09/33] lint and comments --- .../UnconfirmedContribution.role.ts | 1 + .../UnconfirmedContributionUser.role.ts | 2 +- .../UpdateUnconfirmedContribution.context.ts | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContribution.role.ts b/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContribution.role.ts index e1cada46b..53ea21f45 100644 --- a/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContribution.role.ts +++ b/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContribution.role.ts @@ -45,6 +45,7 @@ export abstract class UnconfirmedContributionRole { // 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') diff --git a/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionUser.role.ts b/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionUser.role.ts index 6316c35f1..3d7a15b63 100644 --- a/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionUser.role.ts +++ b/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionUser.role.ts @@ -36,7 +36,7 @@ export class UnconfirmedContributionUserRole extends UnconfirmedContributionRole this.self.contributionStatus, ) } - // if a contribution was created from a moderator, user cannot init it + // 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) diff --git a/backend/src/interactions/updateUnconfirmedContribution/UpdateUnconfirmedContribution.context.ts b/backend/src/interactions/updateUnconfirmedContribution/UpdateUnconfirmedContribution.context.ts index 869870bef..db98c87e5 100644 --- a/backend/src/interactions/updateUnconfirmedContribution/UpdateUnconfirmedContribution.context.ts +++ b/backend/src/interactions/updateUnconfirmedContribution/UpdateUnconfirmedContribution.context.ts @@ -6,7 +6,7 @@ import { AdminUpdateContributionArgs } from '@arg/AdminUpdateContributionArgs' import { ContributionArgs } from '@arg/ContributionArgs' import { ContributionMessageBuilder } from '@/data/ContributionMessage.builder' -import { Context, getClientTimezoneOffset } from '@/server/context' +import { Context } from '@/server/context' import { LogError } from '@/server/LogError' import { UnconfirmedContributionRole } from './UnconfirmedContribution.role' From 5c5cd286f65cd76fc48e0e23c117ae9f4585ad0f Mon Sep 17 00:00:00 2001 From: einhorn_b Date: Fri, 10 Nov 2023 19:58:36 +0100 Subject: [PATCH 10/33] update admin interface --- .../ContributionMessagesFormular.vue | 123 ++++++++++++++---- .../ContributionMessagesList.vue | 5 + .../slots/ContributionMessagesListItem.vue | 8 +- .../components/Tables/OpenCreationsTable.vue | 8 ++ admin/src/graphql/adminListContributions.js | 2 + admin/src/locales/de.json | 5 + admin/src/locales/en.json | 5 + .../arg/AdminUpdateContributionArgs.ts | 12 +- .../graphql/resolver/ContributionResolver.ts | 5 +- .../UnconfirmedContribution.role.ts | 4 +- .../UnconfirmedContributionAdmin.role.ts | 12 +- 11 files changed, 144 insertions(+), 45 deletions(-) diff --git a/admin/src/components/ContributionMessages/ContributionMessagesFormular.vue b/admin/src/components/ContributionMessages/ContributionMessagesFormular.vue index 1286104a4..3d1ceafb2 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,15 @@ diff --git a/admin/src/components/Tables/OpenCreationsTable.vue b/admin/src/components/Tables/OpenCreationsTable.vue index 7d1b5ab84..f351fe228 100644 --- a/admin/src/components/Tables/OpenCreationsTable.vue +++ b/admin/src/components/Tables/OpenCreationsTable.vue @@ -113,6 +113,7 @@ :contributionUserId="row.item.userId" :contributionMemo="row.item.memo" @update-status="updateStatus" + @reload-contribution="reloadContribution" />
@@ -172,6 +173,9 @@ export default { updateStatus(id) { this.$emit('update-status', id) }, + reloadContribution(id) { + this.$emit('reload-contribution', id) + }, }, } diff --git a/admin/src/pages/CreationConfirm.vue b/admin/src/pages/CreationConfirm.vue index bd4c58983..593603e43 100644 --- a/admin/src/pages/CreationConfirm.vue +++ b/admin/src/pages/CreationConfirm.vue @@ -49,6 +49,7 @@ :fields="fields" @show-overlay="showOverlay" @update-status="updateStatus" + @reload-contribution="reloadContribution" @update-contributions="$apollo.queries.ListAllContributions.refetch()" /> @@ -95,6 +96,33 @@ import { adminListContributions } from '../graphql/adminListContributions' import { adminDeleteContribution } from '../graphql/adminDeleteContribution' import { confirmContribution } from '../graphql/confirmContribution' import { denyContribution } from '../graphql/denyContribution' +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 + } + } +` const FILTER_TAB_MAP = [ ['IN_PROGRESS', 'PENDING'], @@ -131,6 +159,22 @@ export default { }, }, methods: { + 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) + }) + }, swapNoHashtag() { this.query() }, diff --git a/backend/src/graphql/resolver/ContributionResolver.ts b/backend/src/graphql/resolver/ContributionResolver.ts index 3cf0119b0..cd5493230 100644 --- a/backend/src/graphql/resolver/ContributionResolver.ts +++ b/backend/src/graphql/resolver/ContributionResolver.ts @@ -45,6 +45,7 @@ import { calculateDecay } from '@/util/decay' import { TRANSACTIONS_LOCK } from '@/util/TRANSACTIONS_LOCK' import { fullName } from '@/util/utilities' +import { findContribution } from './util/contributions' import { getUserCreation, validateContribution, getOpenCreations } from './util/creations' import { findContributions } from './util/findContributions' import { getLastTransaction } from './util/getLastTransaction' @@ -52,6 +53,16 @@ import { sendTransactionsToDltConnector } from './util/sendTransactionsToDltConn @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( 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 } }) +} From 99a98f6e84f1bb46663ee373e08bae0ed44a2c06 Mon Sep 17 00:00:00 2001 From: einhorn_b Date: Fri, 10 Nov 2023 21:07:56 +0100 Subject: [PATCH 12/33] show notice for moderator modified memo in frontend --- .../src/components/Contributions/ContributionListItem.vue | 7 +++++++ frontend/src/graphql/queries.js | 4 ++++ frontend/src/locales/de.json | 3 ++- frontend/src/locales/en.json | 1 + 4 files changed, 14 insertions(+), 1 deletion(-) 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') }} +
Date: Sat, 11 Nov 2023 17:30:49 +0100 Subject: [PATCH 13/33] add email builder class for replacing sendEmailVariants later --- backend/src/emails/Email.builder.ts | 220 ++++++++++++++++++++++++++++ backend/src/util/utilities.ts | 14 ++ 2 files changed, 234 insertions(+) create mode 100644 backend/src/emails/Email.builder.ts diff --git a/backend/src/emails/Email.builder.ts b/backend/src/emails/Email.builder.ts new file mode 100644 index 000000000..66785f656 --- /dev/null +++ b/backend/src/emails/Email.builder.ts @@ -0,0 +1,220 @@ +import { Contribution } from '@entity/Contribution' +import { Transaction } from '@entity/Transaction' +import { User } from '@entity/User' + +import { CONFIG } from '@/config' +import { LogError } from '@/server/LogError' +import { decimalSeparatorByLanguage, resetInterface } from '@/util/utilities' + +import { sendEmailTranslated } from './sendEmailTranslated' + +export interface EmailLocals { + firstName: string + lastName: string + locale: string + supportEmail: string + communityURL: string + senderFirstName?: string + senderLastName?: string + senderEmail?: string + contributionMemo?: string + contributionAmount?: string + overviewURL?: string + activationLink?: string + timeDurationObject?: Date + resendLink?: string + resetLink?: string + transactionMemo?: string + transactionAmount?: string + [key: string]: string | Date | undefined +} + +export enum EmailType { + NONE = 'none', + ACCOUNT_ACTIVATION = 'accountActivation', + ACCOUNT_MULTI_REGISTRATION = 'accountMultiRegistration', + ADDED_CONTRIBUTION_MESSAGE = 'addedContributionMessage', + CONTRIBUTION_CONFIRMED = 'contributionConfirmed', + CONTRIBUTION_DELETED = 'contributionDeleted', + CONTRIBUTION_DENIED = 'contributionDenied', + CONTRIBUTION_CHANGED_BY_MODERATOR = 'contributionChangedByModerator', + RESET_PASSWORD = 'resetPassword', + TRANSACTION_LINK_REDEEMED = 'transactionLinkRedeemed', + TRANSACTION_RECEIVED = 'transactionReceived', +} + +// eslint-disable-next-line @typescript-eslint/no-extraneous-class +export class EmailBuilder { + private receiver: { to: string } + private type: EmailType + private locals: EmailLocals + + // 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.receiver.to = '' + this.type = EmailType.NONE + this.locals = resetInterface(this.locals) + } + + protected setLocalsFromConfig(): void { + this.locals.overviewURL = CONFIG.EMAIL_LINK_OVERVIEW + this.locals.supportEmail = CONFIG.COMMUNITY_SUPPORT_MAIL + this.locals.communityURL = CONFIG.COMMUNITY_URL + switch (this.type) { + case EmailType.ACCOUNT_ACTIVATION: + case EmailType.ACCOUNT_MULTI_REGISTRATION: + case EmailType.RESET_PASSWORD: + this.locals.resendLink = CONFIG.EMAIL_LINK_FORGOTPASSWORD + } + } + + protected checkIfFieldsSet(names: string[]): void { + for (const name of names) { + // eslint-disable-next-line security/detect-object-injection + if (!this.locals[name]) { + throw new LogError(`missing field with ${name}`) + } + } + } + + /** + * check if non default fields a set for type + */ + protected checkRequiredFields(): void { + switch (this.type) { + case EmailType.NONE: + throw new LogError('please call setType before to set email type') + case EmailType.ACCOUNT_ACTIVATION: + this.checkIfFieldsSet(['activationLink', 'timeDurationObject', 'resendLink']) + break + case EmailType.ACCOUNT_MULTI_REGISTRATION: + this.checkIfFieldsSet(['resendLink']) + break + // CONTRIBUTION_CONFIRMED has same required fields as ADDED_CONTRIBUTION_MESSAGE plus contributionAmount + case EmailType.CONTRIBUTION_CONFIRMED: + this.checkIfFieldsSet(['contributionAmount']) + // eslint-disable-next-line no-fallthrough + case EmailType.ADDED_CONTRIBUTION_MESSAGE: + case EmailType.CONTRIBUTION_DELETED: + case EmailType.CONTRIBUTION_DENIED: + this.checkIfFieldsSet(['senderFirstName', 'senderLastName', 'contributionMemo']) + break + case EmailType.CONTRIBUTION_CHANGED_BY_MODERATOR: + // this.checkIfFieldsSet(['']) + break + case EmailType.RESET_PASSWORD: + this.checkIfFieldsSet(['resetLink', 'timeDurationObject', 'resendLink']) + break + // TRANSACTION_LINK_REDEEMED has same required fields as TRANSACTION_RECEIVED plus transactionMemo + case EmailType.TRANSACTION_LINK_REDEEMED: + this.checkIfFieldsSet(['transactionMemo']) + // eslint-disable-next-line no-fallthrough + case EmailType.TRANSACTION_RECEIVED: + this.checkIfFieldsSet([ + 'senderFirstName', + 'senderLastName', + 'senderEmail', + 'transactionAmount', + ]) + break + } + } + + /** + * 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 sendEmail(): Promise | boolean | null> { + this.setLocalsFromConfig() + // will throw if a field is missing + this.checkRequiredFields() + const result = sendEmailTranslated({ + receiver: this.receiver, + template: this.type.toString(), + locals: this.locals, + }) + this.reset() + return result + } + + public setRecipient(recipient: User): this { + this.receiver.to = `${recipient.firstName} ${recipient.lastName} <${recipient.emailContact.email}>` + this.locals.firstName = recipient.firstName + this.locals.lastName = recipient.lastName + return this + } + + public setSender(sender: User): this { + this.locals.senderEmail = sender.emailContact.email + this.locals.senderFirstName = sender.firstName + this.locals.senderLastName = sender.lastName + return this + } + + public setType(type: EmailType): this { + this.type = type + return this + } + + public setLanguage(locale: string): this { + this.locals.locale = locale + return this + } + + public setResetLink(resetLink: string): this { + this.locals.resentLink = resetLink + return this + } + + public setContribution(contribution: Contribution): this { + this.locals.contributionMemo = contribution.memo + if (!this.locals.locale || this.locals.locale === '') { + throw new LogError('missing locale please call setLanguage before') + } + this.locals.contributionAmount = decimalSeparatorByLanguage( + contribution.amount, + this.locals.locale, + ) + return this + } + + public setTransaction(transaction: Transaction): this { + this.locals.transactionMemo = transaction.memo + if (!this.locals.locale || this.locals.locale === '') { + throw new LogError('missing locale please call setLanguage before') + } + this.locals.transactionAmount = decimalSeparatorByLanguage( + transaction.amount, + this.locals.locale, + ) + return this + } + + public setActivationLink(activationLink: string): this { + this.locals.activationLink = activationLink + return this + } + + public setTimeDurationObject(timeDurationObject: Date): this { + this.locals.timeDurationObject = timeDurationObject + return this + } +} 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 +} From 402be3a0cd643d2689f486b7f1660d0bef41cc77 Mon Sep 17 00:00:00 2001 From: einhorn_b Date: Sun, 12 Nov 2023 11:05:21 +0100 Subject: [PATCH 14/33] use email builder instead of emailVariants through the code --- backend/src/emails/Email.builder.ts | 17 +++---- .../resolver/ContributionMessageResolver.ts | 18 +++---- .../graphql/resolver/ContributionResolver.ts | 51 ++++++++----------- backend/src/graphql/resolver/UserResolver.ts | 51 +++++++++---------- backend/src/util/time.ts | 7 ++- 5 files changed, 66 insertions(+), 78 deletions(-) diff --git a/backend/src/emails/Email.builder.ts b/backend/src/emails/Email.builder.ts index 66785f656..7e137cd48 100644 --- a/backend/src/emails/Email.builder.ts +++ b/backend/src/emails/Email.builder.ts @@ -4,6 +4,7 @@ import { User } from '@entity/User' import { CONFIG } from '@/config' import { LogError } from '@/server/LogError' +import { TimeDuration } from '@/util/time' import { decimalSeparatorByLanguage, resetInterface } from '@/util/utilities' import { sendEmailTranslated } from './sendEmailTranslated' @@ -21,12 +22,12 @@ export interface EmailLocals { contributionAmount?: string overviewURL?: string activationLink?: string - timeDurationObject?: Date + timeDurationObject?: TimeDuration resendLink?: string resetLink?: string transactionMemo?: string transactionAmount?: string - [key: string]: string | Date | undefined + [key: string]: string | TimeDuration | undefined } export enum EmailType { @@ -159,6 +160,7 @@ export class EmailBuilder { this.receiver.to = `${recipient.firstName} ${recipient.lastName} <${recipient.emailContact.email}>` this.locals.firstName = recipient.firstName this.locals.lastName = recipient.lastName + this.locals.locale = recipient.language return this } @@ -174,11 +176,6 @@ export class EmailBuilder { return this } - public setLanguage(locale: string): this { - this.locals.locale = locale - return this - } - public setResetLink(resetLink: string): this { this.locals.resentLink = resetLink return this @@ -187,7 +184,7 @@ export class EmailBuilder { public setContribution(contribution: Contribution): this { this.locals.contributionMemo = contribution.memo if (!this.locals.locale || this.locals.locale === '') { - throw new LogError('missing locale please call setLanguage before') + throw new LogError('missing locale please call setRecipient before') } this.locals.contributionAmount = decimalSeparatorByLanguage( contribution.amount, @@ -199,7 +196,7 @@ export class EmailBuilder { public setTransaction(transaction: Transaction): this { this.locals.transactionMemo = transaction.memo if (!this.locals.locale || this.locals.locale === '') { - throw new LogError('missing locale please call setLanguage before') + throw new LogError('missing locale please call setRecipient before') } this.locals.transactionAmount = decimalSeparatorByLanguage( transaction.amount, @@ -213,7 +210,7 @@ export class EmailBuilder { return this } - public setTimeDurationObject(timeDurationObject: Date): this { + public setTimeDurationObject(timeDurationObject: TimeDuration): this { this.locals.timeDurationObject = timeDurationObject return this } diff --git a/backend/src/graphql/resolver/ContributionMessageResolver.ts b/backend/src/graphql/resolver/ContributionMessageResolver.ts index 5910befa1..a5af00f69 100644 --- a/backend/src/graphql/resolver/ContributionMessageResolver.ts +++ b/backend/src/graphql/resolver/ContributionMessageResolver.ts @@ -14,7 +14,7 @@ import { Order } from '@enum/Order' import { ContributionMessage, ContributionMessageListResult } from '@model/ContributionMessage' import { RIGHTS } from '@/auth/RIGHTS' -import { sendAddedContributionMessageEmail } from '@/emails/sendEmailVariants' +import { EmailBuilder, EmailType } from '@/emails/Email.builder' import { EVENT_ADMIN_CONTRIBUTION_MESSAGE_CREATE, EVENT_CONTRIBUTION_MESSAGE_CREATE, @@ -170,15 +170,13 @@ export class ContributionMessageResolver { } // send email (never for moderator messages) - void sendAddedContributionMessageEmail({ - firstName: contribution.user.firstName, - lastName: contribution.user.lastName, - email: contribution.user.emailContact.email, - language: contribution.user.language, - senderFirstName: moderator.firstName, - senderLastName: moderator.lastName, - contributionMemo: contribution.memo, - }) + const emailBuilder = new EmailBuilder() + void emailBuilder + .setRecipient(contribution.user) + .setSender(moderator) + .setContribution(contribution) + .setType(EmailType.ADDED_CONTRIBUTION_MESSAGE) + .sendEmail() } await queryRunner.commitTransaction() await EVENT_ADMIN_CONTRIBUTION_MESSAGE_CREATE( diff --git a/backend/src/graphql/resolver/ContributionResolver.ts b/backend/src/graphql/resolver/ContributionResolver.ts index cd5493230..9dabe1193 100644 --- a/backend/src/graphql/resolver/ContributionResolver.ts +++ b/backend/src/graphql/resolver/ContributionResolver.ts @@ -50,6 +50,7 @@ import { getUserCreation, validateContribution, getOpenCreations } from './util/ import { findContributions } from './util/findContributions' import { getLastTransaction } from './util/getLastTransaction' import { sendTransactionsToDltConnector } from './util/sendTransactionsToDltConnector' +import { EmailBuilder, EmailType } from '@/emails/Email.builder' @Resolver() export class ContributionResolver { @@ -336,16 +337,13 @@ export class ContributionResolver { contribution, contribution.amount, ) - - void sendContributionDeletedEmail({ - firstName: user.firstName, - lastName: user.lastName, - email: user.emailContact.email, - language: user.language, - senderFirstName: moderator.firstName, - senderLastName: moderator.lastName, - contributionMemo: contribution.memo, - }) + const emailBuilder = new EmailBuilder() + void emailBuilder + .setRecipient(user) + .setSender(moderator) + .setContribution(contribution) + .setType(EmailType.CONTRIBUTION_DELETED) + .sendEmail() return !!res } @@ -438,16 +436,13 @@ export class ContributionResolver { void sendTransactionsToDltConnector() logger.info('creation commited successfuly.') - void sendContributionConfirmedEmail({ - firstName: user.firstName, - lastName: user.lastName, - email: user.emailContact.email, - language: user.language, - senderFirstName: moderatorUser.firstName, - senderLastName: moderatorUser.lastName, - contributionMemo: contribution.memo, - contributionAmount: contribution.amount, - }) + const emailBuilder = new EmailBuilder() + void emailBuilder + .setRecipient(user) + .setSender(moderatorUser) + .setContribution(contribution) + .setType(EmailType.CONTRIBUTION_CONFIRMED) + .sendEmail() } catch (e) { await queryRunner.rollbackTransaction() throw new LogError('Creation was not successful', e) @@ -521,15 +516,13 @@ export class ContributionResolver { contributionToUpdate.amount, ) - void sendContributionDeniedEmail({ - firstName: user.firstName, - lastName: user.lastName, - email: user.emailContact.email, - language: user.language, - senderFirstName: moderator.firstName, - senderLastName: moderator.lastName, - contributionMemo: contributionToUpdate.memo, - }) + const emailBuilder = new EmailBuilder() + void emailBuilder + .setRecipient(user) + .setSender(moderator) + .setContribution(contributionToUpdate) + .setType(EmailType.CONTRIBUTION_DENIED) + .sendEmail() return !!res } diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index 45ccd720e..c1561b523 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -31,11 +31,8 @@ import { subscribe } from '@/apis/KlicktippController' import { encode } from '@/auth/JWT' import { RIGHTS } from '@/auth/RIGHTS' import { CONFIG } from '@/config' -import { - sendAccountActivationEmail, - sendAccountMultiRegistrationEmail, - sendResetPasswordEmail, -} from '@/emails/sendEmailVariants' +import { EmailBuilder, EmailType } from '@/emails/Email.builder' +import { sendResetPasswordEmail } from '@/emails/sendEmailVariants' import { Event, EventType, @@ -248,12 +245,12 @@ export class UserResolver { } logger.debug('partly faked user', user) - void sendAccountMultiRegistrationEmail({ - firstName: foundUser.firstName, // this is the real name of the email owner, but just "firstName" would be the name of the new registrant which shall not be passed to the outside - lastName: foundUser.lastName, // this is the real name of the email owner, but just "lastName" would be the name of the new registrant which shall not be passed to the outside - email, - language: foundUser.language, // use language of the emails owner for sending - }) + const emailBuilder = new EmailBuilder() + void emailBuilder + .setRecipient(foundUser) // this is the real name of the email owner, but just "firstName" and "lastName" would be the name of the new registrant which shall not be passed to the outside + .setType(EmailType.ACCOUNT_MULTI_REGISTRATION) + .sendEmail() + await EVENT_EMAIL_ACCOUNT_MULTIREGISTRATION(foundUser) logger.info( @@ -328,14 +325,14 @@ export class UserResolver { emailContact.emailVerificationCode.toString(), ).replace(/{code}/g, redeemCode ? '/' + redeemCode : '') - void sendAccountActivationEmail({ - firstName, - lastName, - email, - language, - activationLink, - timeDurationObject: getTimeDurationObject(CONFIG.EMAIL_CODE_VALID_TIME), - }) + const emailBuilder = new EmailBuilder() + void emailBuilder + .setRecipient(dbUser) + .setActivationLink(activationLink) + .setTimeDurationObject(getTimeDurationObject(CONFIG.EMAIL_CODE_VALID_TIME)) + .setType(EmailType.ACCOUNT_ACTIVATION) + .sendEmail() + logger.info(`sendAccountActivationEmail of ${firstName}.${lastName} to ${email}`) await EVENT_EMAIL_CONFIRMATION(dbUser) @@ -794,15 +791,13 @@ export class UserResolver { user.emailContact.emailResendCount++ await user.emailContact.save() - // eslint-disable-next-line @typescript-eslint/no-unused-vars - void sendAccountActivationEmail({ - firstName: user.firstName, - lastName: user.lastName, - email, - language: user.language, - activationLink: activationLink(user.emailContact.emailVerificationCode), - timeDurationObject: getTimeDurationObject(CONFIG.EMAIL_CODE_VALID_TIME), - }) + const emailBuilder = new EmailBuilder() + void emailBuilder + .setRecipient(user) + .setActivationLink(activationLink(user.emailContact.emailVerificationCode)) + .setTimeDurationObject(getTimeDurationObject(CONFIG.EMAIL_CODE_VALID_TIME)) + .setType(EmailType.ACCOUNT_ACTIVATION) + .sendEmail() await EVENT_EMAIL_ADMIN_CONFIRMATION(user, getUser(context)) diff --git a/backend/src/util/time.ts b/backend/src/util/time.ts index d429c8d6b..7b8d09671 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 interface TimeDuration { + hours?: number + minutes: number +} + +export const getTimeDurationObject = (time: number): TimeDuration => { if (time > 60) { return { hours: Math.floor(time / 60), From e60ab28f7717f9e48bc181fa0567c6b9f4268863 Mon Sep 17 00:00:00 2001 From: einhornimmond Date: Mon, 13 Nov 2023 13:14:22 +0100 Subject: [PATCH 15/33] rewrite sendEmailVariants.test.ts to use EmailBuilder, add email for from moderator changed memo --- backend/package.json | 6 +- ...Variants.test.ts => Email.builder.test.ts} | 186 +++++++----------- backend/src/emails/Email.builder.ts | 34 +++- .../contributionChangedByModerator/html.pug | 10 + .../subject.pug | 1 + .../graphql/resolver/ContributionResolver.ts | 7 +- .../graphql/resolver/TransactionResolver.ts | 36 ++-- backend/src/graphql/resolver/UserResolver.ts | 16 +- backend/src/locales/de.json | 5 + backend/yarn.lock | 12 +- database/package.json | 6 +- database/yarn.lock | 10 + 12 files changed, 170 insertions(+), 159 deletions(-) rename backend/src/emails/{sendEmailVariants.test.ts => Email.builder.test.ts} (81%) create mode 100644 backend/src/emails/templates/contributionChangedByModerator/html.pug create mode 100644 backend/src/emails/templates/contributionChangedByModerator/subject.pug diff --git a/backend/package.json b/backend/package.json index 1f4ea67af..76174b52c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -8,7 +8,7 @@ "license": "Apache-2.0", "private": false, "scripts": { - "build": "tsc --build && mkdir -p build/src/emails/templates/ && cp -r src/emails/templates/* build/src/emails/templates/ && mkdir -p build/src/locales/ && cp -r src/locales/*.json build/src/locales/", + "build": "tsc --build && mkdirp build/src/emails/templates/ && ncp src/emails/templates build/src/emails/templates && mkdirp build/src/locales/ && ncp src/locales build/src/locales", "clean": "tsc --build --clean", "start": "cross-env TZ=UTC TS_NODE_BASEURL=./build node -r tsconfig-paths/register build/src/index.js", "dev": "cross-env TZ=UTC nodemon -w src --ext ts,pug,json,css --exec ts-node -r tsconfig-paths/register src/index.ts", @@ -80,7 +80,9 @@ "ts-jest": "^27.0.5", "ts-node": "^10.0.0", "tsconfig-paths": "^3.14.0", - "typescript": "^4.3.4" + "typescript": "^4.3.4", + "mkdirp": "^3.0.1", + "ncp": "^2.0.0" }, "nodemonConfig": { "ignore": [ diff --git a/backend/src/emails/sendEmailVariants.test.ts b/backend/src/emails/Email.builder.test.ts similarity index 81% rename from backend/src/emails/sendEmailVariants.test.ts rename to backend/src/emails/Email.builder.test.ts index 3340a361d..b30e4835b 100644 --- a/backend/src/emails/sendEmailVariants.test.ts +++ b/backend/src/emails/Email.builder.test.ts @@ -2,43 +2,17 @@ /* eslint-disable @typescript-eslint/no-unsafe-return */ /* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import { Connection } from '@dbTools/typeorm' -import { ApolloServerTestClient } from 'apollo-server-testing' import { Decimal } from 'decimal.js-light' -import { testEnvironment } from '@test/helpers' import { logger, i18n as localization } from '@test/testSetup' import { CONFIG } from '@/config' import { sendEmailTranslated } from './sendEmailTranslated' -import { - sendAddedContributionMessageEmail, - sendAccountActivationEmail, - sendAccountMultiRegistrationEmail, - sendContributionConfirmedEmail, - sendContributionDeniedEmail, - sendContributionDeletedEmail, - sendResetPasswordEmail, - sendTransactionLinkRedeemedEmail, - sendTransactionReceivedEmail, -} from './sendEmailVariants' - -let con: Connection -let testEnv: { - mutate: ApolloServerTestClient['mutate'] - query: ApolloServerTestClient['query'] - con: Connection -} - -beforeAll(async () => { - testEnv = await testEnvironment(logger, localization) - con = testEnv.con -}) - -afterAll(async () => { - await con.close() -}) +import { User } from '@entity/User' +import { UserContact } from '@entity/UserContact' +import { Contribution } from '@entity/Contribution' +import { EmailBuilder, EmailType } from './Email.builder' jest.mock('./sendEmailTranslated', () => { const originalModule = jest.requireActual('./sendEmailTranslated') @@ -51,18 +25,34 @@ jest.mock('./sendEmailTranslated', () => { describe('sendEmailVariants', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any let result: any + const recipientUser = User.create() + recipientUser.firstName = 'Peter' + recipientUser.lastName = 'Lustig' + recipientUser.language = 'en' + const recipientUserContact = UserContact.create() + recipientUserContact.email = 'peter@lustig.de' + recipientUser.emailContact = recipientUserContact + + const senderUser = User.create() + senderUser.firstName = 'Bibi' + senderUser.lastName = 'Bloxberg' + const senderUserContact = UserContact.create() + senderUserContact.email = 'bibi@bloxberg.de' + + const contribution = Contribution.create() + contribution.memo = 'My contribution.' + contribution.amount = new Decimal(23.54) + + const emailBuilder = new EmailBuilder() describe('sendAddedContributionMessageEmail', () => { beforeAll(async () => { - result = await sendAddedContributionMessageEmail({ - firstName: 'Peter', - lastName: 'Lustig', - email: 'peter@lustig.de', - language: 'en', - senderFirstName: 'Bibi', - senderLastName: 'Bloxberg', - contributionMemo: 'My contribution.', - }) + result = await emailBuilder + .setSender(senderUser) + .setRecipient(recipientUser) + .setContribution(contribution) + .setType(EmailType.ADDED_CONTRIBUTION_MESSAGE) + .sendEmail() }) describe('calls "sendEmailTranslated"', () => { @@ -114,14 +104,13 @@ describe('sendEmailVariants', () => { describe('sendAccountActivationEmail', () => { beforeAll(async () => { - result = await sendAccountActivationEmail({ - firstName: 'Peter', - lastName: 'Lustig', - email: 'peter@lustig.de', - language: 'en', - activationLink: 'http://localhost/checkEmail/6627633878930542284', - timeDurationObject: { hours: 23, minutes: 30 }, - }) + result = await emailBuilder + .setRecipient(recipientUser) + .setSender(senderUser) + .setActivationLink('http://localhost/checkEmail/6627633878930542284') + .setTimeDurationObject({ hours: 23, minutes: 30 }) + .setType(EmailType.ACCOUNT_ACTIVATION) + .sendEmail() }) describe('calls "sendEmailTranslated"', () => { @@ -172,12 +161,10 @@ describe('sendEmailVariants', () => { describe('sendAccountMultiRegistrationEmail', () => { beforeAll(async () => { - result = await sendAccountMultiRegistrationEmail({ - firstName: 'Peter', - lastName: 'Lustig', - email: 'peter@lustig.de', - language: 'en', - }) + result = await emailBuilder + .setRecipient(recipientUser) + .setType(EmailType.ACCOUNT_MULTI_REGISTRATION) + .sendEmail() }) describe('calls "sendEmailTranslated"', () => { @@ -226,16 +213,12 @@ describe('sendEmailVariants', () => { describe('sendContributionConfirmedEmail', () => { beforeAll(async () => { - result = await sendContributionConfirmedEmail({ - firstName: 'Peter', - lastName: 'Lustig', - email: 'peter@lustig.de', - language: 'en', - senderFirstName: 'Bibi', - senderLastName: 'Bloxberg', - contributionMemo: 'My contribution.', - contributionAmount: new Decimal(23.54), - }) + result = await emailBuilder + .setRecipient(recipientUser) + .setSender(senderUser) + .setContribution(contribution) + .setType(EmailType.CONTRIBUTION_CONFIRMED) + .sendEmail() }) describe('calls "sendEmailTranslated"', () => { @@ -288,15 +271,12 @@ describe('sendEmailVariants', () => { describe('sendContributionDeniedEmail', () => { beforeAll(async () => { - result = await sendContributionDeniedEmail({ - firstName: 'Peter', - lastName: 'Lustig', - email: 'peter@lustig.de', - language: 'en', - senderFirstName: 'Bibi', - senderLastName: 'Bloxberg', - contributionMemo: 'My contribution.', - }) + result = await emailBuilder + .setRecipient(recipientUser) + .setSender(senderUser) + .setContribution(contribution) + .setType(EmailType.CONTRIBUTION_DENIED) + .sendEmail() }) describe('calls "sendEmailTranslated"', () => { @@ -348,15 +328,12 @@ describe('sendEmailVariants', () => { describe('sendContributionDeletedEmail', () => { beforeAll(async () => { - result = await sendContributionDeletedEmail({ - firstName: 'Peter', - lastName: 'Lustig', - email: 'peter@lustig.de', - language: 'en', - senderFirstName: 'Bibi', - senderLastName: 'Bloxberg', - contributionMemo: 'My contribution.', - }) + result = await emailBuilder + .setRecipient(recipientUser) + .setSender(senderUser) + .setContribution(contribution) + .setType(EmailType.CONTRIBUTION_DELETED) + .sendEmail() }) describe('calls "sendEmailTranslated"', () => { @@ -408,14 +385,12 @@ describe('sendEmailVariants', () => { describe('sendResetPasswordEmail', () => { beforeAll(async () => { - result = await sendResetPasswordEmail({ - firstName: 'Peter', - lastName: 'Lustig', - email: 'peter@lustig.de', - language: 'en', - resetLink: 'http://localhost/reset-password/3762660021544901417', - timeDurationObject: { hours: 23, minutes: 30 }, - }) + result = await emailBuilder + .setRecipient(recipientUser) + .setResetLink('http://localhost/reset-password/3762660021544901417') + .setTimeDurationObject({ hours: 23, minutes: 30 }) + .setType(EmailType.RESET_PASSWORD) + .sendEmail() }) describe('calls "sendEmailTranslated"', () => { @@ -466,17 +441,12 @@ describe('sendEmailVariants', () => { describe('sendTransactionLinkRedeemedEmail', () => { beforeAll(async () => { - result = await sendTransactionLinkRedeemedEmail({ - firstName: 'Peter', - lastName: 'Lustig', - email: 'peter@lustig.de', - language: 'en', - senderFirstName: 'Bibi', - senderLastName: 'Bloxberg', - senderEmail: 'bibi@bloxberg.de', - transactionMemo: 'You deserve it! 🙏🏼', - transactionAmount: new Decimal(17.65), - }) + result = await emailBuilder + .setRecipient(recipientUser) + .setSender(senderUser) + .setTransaction(new Decimal(17.65), 'You deserve it! 🙏🏼') + .setType(EmailType.TRANSACTION_LINK_REDEEMED) + .sendEmail() }) describe('calls "sendEmailTranslated"', () => { @@ -530,16 +500,12 @@ describe('sendEmailVariants', () => { describe('sendTransactionReceivedEmail', () => { beforeAll(async () => { - result = await sendTransactionReceivedEmail({ - firstName: 'Peter', - lastName: 'Lustig', - email: 'peter@lustig.de', - language: 'en', - senderFirstName: 'Bibi', - senderLastName: 'Bloxberg', - senderEmail: 'bibi@bloxberg.de', - transactionAmount: new Decimal(37.4), - }) + result = await emailBuilder + .setRecipient(recipientUser) + .setSender(senderUser) + .setTransactionAmount(new Decimal(37.4)) + .setType(EmailType.TRANSACTION_RECEIVED) + .sendEmail() }) describe('calls "sendEmailTranslated"', () => { diff --git a/backend/src/emails/Email.builder.ts b/backend/src/emails/Email.builder.ts index 7e137cd48..0129e2ed8 100644 --- a/backend/src/emails/Email.builder.ts +++ b/backend/src/emails/Email.builder.ts @@ -1,5 +1,4 @@ import { Contribution } from '@entity/Contribution' -import { Transaction } from '@entity/Transaction' import { User } from '@entity/User' import { CONFIG } from '@/config' @@ -8,6 +7,7 @@ import { TimeDuration } from '@/util/time' import { decimalSeparatorByLanguage, resetInterface } from '@/util/utilities' import { sendEmailTranslated } from './sendEmailTranslated' +import { Decimal } from 'decimal.js-light' export interface EmailLocals { firstName: string @@ -27,6 +27,7 @@ export interface EmailLocals { resetLink?: string transactionMemo?: string transactionAmount?: string + contributionMemoUpdated?: string [key: string]: string | TimeDuration | undefined } @@ -109,7 +110,12 @@ export class EmailBuilder { this.checkIfFieldsSet(['senderFirstName', 'senderLastName', 'contributionMemo']) break case EmailType.CONTRIBUTION_CHANGED_BY_MODERATOR: - // this.checkIfFieldsSet(['']) + this.checkIfFieldsSet([ + 'contributionMemoUpdated', + 'senderFirstName', + 'senderLastName', + 'contributionMemo', + ]) break case EmailType.RESET_PASSWORD: this.checkIfFieldsSet(['resetLink', 'timeDurationObject', 'resendLink']) @@ -193,15 +199,27 @@ export class EmailBuilder { return this } - public setTransaction(transaction: Transaction): this { - this.locals.transactionMemo = transaction.memo + public setUpdatedContributionMemo(updatedMemo: string): this { + this.locals.contributionMemoUpdated = updatedMemo + return this + } + + public setTransaction(amount: Decimal, memo: string): this { + this.setTransactionMemo(memo) + this.setTransactionAmount(amount) + return this + } + + public setTransactionAmount(amount: Decimal): this { if (!this.locals.locale || this.locals.locale === '') { throw new LogError('missing locale please call setRecipient before') } - this.locals.transactionAmount = decimalSeparatorByLanguage( - transaction.amount, - this.locals.locale, - ) + this.locals.transactionAmount = decimalSeparatorByLanguage(amount, this.locals.locale) + return this + } + + public setTransactionMemo(memo: string): this { + this.locals.transactionMemo = memo return this } diff --git a/backend/src/emails/templates/contributionChangedByModerator/html.pug b/backend/src/emails/templates/contributionChangedByModerator/html.pug new file mode 100644 index 000000000..ed29864ec --- /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.commonGoodContributionConfirmed', { 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/resolver/ContributionResolver.ts b/backend/src/graphql/resolver/ContributionResolver.ts index 9dabe1193..775260c84 100644 --- a/backend/src/graphql/resolver/ContributionResolver.ts +++ b/backend/src/graphql/resolver/ContributionResolver.ts @@ -22,11 +22,7 @@ import { OpenCreation } from '@model/OpenCreation' import { UnconfirmedContribution } from '@model/UnconfirmedContribution' import { RIGHTS } from '@/auth/RIGHTS' -import { - sendContributionConfirmedEmail, - sendContributionDeletedEmail, - sendContributionDeniedEmail, -} from '@/emails/sendEmailVariants' +import { EmailBuilder, EmailType } from '@/emails/Email.builder' import { EVENT_CONTRIBUTION_CREATE, EVENT_CONTRIBUTION_DELETE, @@ -50,7 +46,6 @@ import { getUserCreation, validateContribution, getOpenCreations } from './util/ import { findContributions } from './util/findContributions' import { getLastTransaction } from './util/getLastTransaction' import { sendTransactionsToDltConnector } from './util/sendTransactionsToDltConnector' -import { EmailBuilder, EmailType } from '@/emails/Email.builder' @Resolver() export class ContributionResolver { diff --git a/backend/src/graphql/resolver/TransactionResolver.ts b/backend/src/graphql/resolver/TransactionResolver.ts index 8d35708a6..ba259e601 100644 --- a/backend/src/graphql/resolver/TransactionResolver.ts +++ b/backend/src/graphql/resolver/TransactionResolver.ts @@ -22,6 +22,7 @@ import { User } from '@model/User' import { RIGHTS } from '@/auth/RIGHTS' import { CONFIG } from '@/config' +import { EmailBuilder, EmailType } from '@/emails/Email.builder' import { sendTransactionLinkRedeemedEmail, sendTransactionReceivedEmail, @@ -180,28 +181,21 @@ export const executeTransaction = async ( } finally { await queryRunner.release() } - void sendTransactionReceivedEmail({ - firstName: recipient.firstName, - lastName: recipient.lastName, - email: recipient.emailContact.email, - language: recipient.language, - senderFirstName: sender.firstName, - senderLastName: sender.lastName, - senderEmail: sender.emailContact.email, - transactionAmount: amount, - }) + const emailBuilder = new EmailBuilder() + void emailBuilder + .setRecipient(recipient) + .setSender(sender) + .setTransactionAmount(amount) + .setType(EmailType.TRANSACTION_RECEIVED) + .sendEmail() + if (transactionLink) { - void sendTransactionLinkRedeemedEmail({ - firstName: sender.firstName, - lastName: sender.lastName, - email: sender.emailContact.email, - language: sender.language, - senderFirstName: recipient.firstName, - senderLastName: recipient.lastName, - senderEmail: recipient.emailContact.email, - transactionAmount: amount, - transactionMemo: memo, - }) + void emailBuilder + .setRecipient(sender) + .setSender(recipient) + .setTransaction(amount, memo) + .setType(EmailType.TRANSACTION_LINK_REDEEMED) + .sendEmail() } logger.info(`finished executeTransaction successfully`) } finally { diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index c1561b523..b7c64eed9 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -387,15 +387,13 @@ export class UserResolver { }) logger.info('optInCode for', email, user.emailContact) - - void sendResetPasswordEmail({ - firstName: user.firstName, - lastName: user.lastName, - email, - language: user.language, - resetLink: activationLink(user.emailContact.emailVerificationCode), - timeDurationObject: getTimeDurationObject(CONFIG.EMAIL_CODE_VALID_TIME), - }) + const emailBuilder = new EmailBuilder() + void emailBuilder + .setRecipient(user) + .setResetLink(activationLink(user.emailContact.emailVerificationCode)) + .setTimeDurationObject(getTimeDurationObject(CONFIG.EMAIL_CODE_VALID_TIME)) + .setType(EmailType.RESET_PASSWORD) + .sendEmail() logger.info(`forgotPassword(${email}) successful...`) await EVENT_EMAIL_FORGOT_PASSWORD(user) diff --git a/backend/src/locales/de.json b/backend/src/locales/de.json index 349c5089e..4bf9fc8be 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": { + "commonGoodContributionConfirmed": "dein Gemeinwohl-Beitrag „{contributionMemo}“ wurde soeben von {senderFirstName} {senderLastName} geändert und lautet jetzt „{contributionMemoUpdated}“", + "subject": "Dein Gemeinwohl-Beitrag wurde geändert", + "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/yarn.lock b/backend/yarn.lock index 84553d73e..66e4a0687 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -3679,7 +3679,7 @@ graceful-fs@^4.1.6, graceful-fs@^4.2.0: integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA== "gradido-database@file:../database": - version "1.22.0" + version "2.0.0" dependencies: "@types/uuid" "^8.3.4" cross-env "^7.0.3" @@ -5280,6 +5280,11 @@ mkdirp@^2.1.3: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-2.1.6.tgz#964fbcb12b2d8c5d6fbc62a963ac95a273e2cc19" integrity sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A== +mkdirp@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-3.0.1.tgz#e44e4c5607fb279c168241713cc6e0fea9adcb50" + integrity sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg== + moo@^0.5.0, moo@^0.5.1: version "0.5.1" resolved "https://registry.yarnpkg.com/moo/-/moo-0.5.1.tgz#7aae7f384b9b09f620b6abf6f74ebbcd1b65dbc4" @@ -5371,6 +5376,11 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= +ncp@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ncp/-/ncp-2.0.0.tgz#195a21d6c46e361d2fb1281ba38b91e9df7bdbb3" + integrity sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA== + nearley@^2.20.1: version "2.20.1" resolved "https://registry.yarnpkg.com/nearley/-/nearley-2.20.1.tgz#246cd33eff0d012faf197ff6774d7ac78acdd474" diff --git a/database/package.json b/database/package.json index efb310a5a..d065233bf 100644 --- a/database/package.json +++ b/database/package.json @@ -8,7 +8,7 @@ "license": "Apache-2.0", "private": false, "scripts": { - "build": "mkdir -p build/src/config/ && cp src/config/*.txt build/src/config/ && tsc --build", + "build": "mkdirp build/src/config/ && ncp src/config build/src/config && tsc --build", "clean": "tsc --build --clean", "up": "cross-env TZ=UTC node build/src/index.js up", "down": "cross-env TZ=UTC node build/src/index.js down", @@ -35,7 +35,9 @@ "eslint-plugin-security": "^1.7.1", "prettier": "^2.8.7", "ts-node": "^10.2.1", - "typescript": "^4.3.5" + "typescript": "^4.3.5", + "mkdirp": "^3.0.1", + "ncp": "^2.0.0" }, "dependencies": { "@types/uuid": "^8.3.4", diff --git a/database/yarn.lock b/database/yarn.lock index ac35e1eaa..d8a0d6ffb 100644 --- a/database/yarn.lock +++ b/database/yarn.lock @@ -1718,6 +1718,11 @@ mkdirp@^2.1.3: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-2.1.6.tgz#964fbcb12b2d8c5d6fbc62a963ac95a273e2cc19" integrity sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A== +mkdirp@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-3.0.1.tgz#e44e4c5607fb279c168241713cc6e0fea9adcb50" + integrity sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg== + ms@2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" @@ -1778,6 +1783,11 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= +ncp@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ncp/-/ncp-2.0.0.tgz#195a21d6c46e361d2fb1281ba38b91e9df7bdbb3" + integrity sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA== + npm-run-path@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" From 634a7a7b8b8decb22a4392b03255f2193accf75b Mon Sep 17 00:00:00 2001 From: einhornimmond Date: Mon, 13 Nov 2023 17:49:27 +0100 Subject: [PATCH 16/33] move EmailBuilder out to separat branch --- backend/src/emails/Email.builder.ts | 235 ------------------ ...lder.test.ts => sendEmailVariants.test.ts} | 186 ++++++++------ .../resolver/ContributionResolver.test.ts | 30 ++- .../graphql/resolver/ContributionResolver.ts | 51 ++-- .../graphql/resolver/TransactionResolver.ts | 36 +-- backend/src/graphql/resolver/UserResolver.ts | 67 ++--- database/entity/Contribution.ts | 2 +- 7 files changed, 224 insertions(+), 383 deletions(-) delete mode 100644 backend/src/emails/Email.builder.ts rename backend/src/emails/{Email.builder.test.ts => sendEmailVariants.test.ts} (81%) diff --git a/backend/src/emails/Email.builder.ts b/backend/src/emails/Email.builder.ts deleted file mode 100644 index 0129e2ed8..000000000 --- a/backend/src/emails/Email.builder.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { Contribution } from '@entity/Contribution' -import { User } from '@entity/User' - -import { CONFIG } from '@/config' -import { LogError } from '@/server/LogError' -import { TimeDuration } from '@/util/time' -import { decimalSeparatorByLanguage, resetInterface } from '@/util/utilities' - -import { sendEmailTranslated } from './sendEmailTranslated' -import { Decimal } from 'decimal.js-light' - -export interface EmailLocals { - firstName: string - lastName: string - locale: string - supportEmail: string - communityURL: string - senderFirstName?: string - senderLastName?: string - senderEmail?: string - contributionMemo?: string - contributionAmount?: string - overviewURL?: string - activationLink?: string - timeDurationObject?: TimeDuration - resendLink?: string - resetLink?: string - transactionMemo?: string - transactionAmount?: string - contributionMemoUpdated?: string - [key: string]: string | TimeDuration | undefined -} - -export enum EmailType { - NONE = 'none', - ACCOUNT_ACTIVATION = 'accountActivation', - ACCOUNT_MULTI_REGISTRATION = 'accountMultiRegistration', - ADDED_CONTRIBUTION_MESSAGE = 'addedContributionMessage', - CONTRIBUTION_CONFIRMED = 'contributionConfirmed', - CONTRIBUTION_DELETED = 'contributionDeleted', - CONTRIBUTION_DENIED = 'contributionDenied', - CONTRIBUTION_CHANGED_BY_MODERATOR = 'contributionChangedByModerator', - RESET_PASSWORD = 'resetPassword', - TRANSACTION_LINK_REDEEMED = 'transactionLinkRedeemed', - TRANSACTION_RECEIVED = 'transactionReceived', -} - -// eslint-disable-next-line @typescript-eslint/no-extraneous-class -export class EmailBuilder { - private receiver: { to: string } - private type: EmailType - private locals: EmailLocals - - // 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.receiver.to = '' - this.type = EmailType.NONE - this.locals = resetInterface(this.locals) - } - - protected setLocalsFromConfig(): void { - this.locals.overviewURL = CONFIG.EMAIL_LINK_OVERVIEW - this.locals.supportEmail = CONFIG.COMMUNITY_SUPPORT_MAIL - this.locals.communityURL = CONFIG.COMMUNITY_URL - switch (this.type) { - case EmailType.ACCOUNT_ACTIVATION: - case EmailType.ACCOUNT_MULTI_REGISTRATION: - case EmailType.RESET_PASSWORD: - this.locals.resendLink = CONFIG.EMAIL_LINK_FORGOTPASSWORD - } - } - - protected checkIfFieldsSet(names: string[]): void { - for (const name of names) { - // eslint-disable-next-line security/detect-object-injection - if (!this.locals[name]) { - throw new LogError(`missing field with ${name}`) - } - } - } - - /** - * check if non default fields a set for type - */ - protected checkRequiredFields(): void { - switch (this.type) { - case EmailType.NONE: - throw new LogError('please call setType before to set email type') - case EmailType.ACCOUNT_ACTIVATION: - this.checkIfFieldsSet(['activationLink', 'timeDurationObject', 'resendLink']) - break - case EmailType.ACCOUNT_MULTI_REGISTRATION: - this.checkIfFieldsSet(['resendLink']) - break - // CONTRIBUTION_CONFIRMED has same required fields as ADDED_CONTRIBUTION_MESSAGE plus contributionAmount - case EmailType.CONTRIBUTION_CONFIRMED: - this.checkIfFieldsSet(['contributionAmount']) - // eslint-disable-next-line no-fallthrough - case EmailType.ADDED_CONTRIBUTION_MESSAGE: - case EmailType.CONTRIBUTION_DELETED: - case EmailType.CONTRIBUTION_DENIED: - this.checkIfFieldsSet(['senderFirstName', 'senderLastName', 'contributionMemo']) - break - case EmailType.CONTRIBUTION_CHANGED_BY_MODERATOR: - this.checkIfFieldsSet([ - 'contributionMemoUpdated', - 'senderFirstName', - 'senderLastName', - 'contributionMemo', - ]) - break - case EmailType.RESET_PASSWORD: - this.checkIfFieldsSet(['resetLink', 'timeDurationObject', 'resendLink']) - break - // TRANSACTION_LINK_REDEEMED has same required fields as TRANSACTION_RECEIVED plus transactionMemo - case EmailType.TRANSACTION_LINK_REDEEMED: - this.checkIfFieldsSet(['transactionMemo']) - // eslint-disable-next-line no-fallthrough - case EmailType.TRANSACTION_RECEIVED: - this.checkIfFieldsSet([ - 'senderFirstName', - 'senderLastName', - 'senderEmail', - 'transactionAmount', - ]) - break - } - } - - /** - * 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 sendEmail(): Promise | boolean | null> { - this.setLocalsFromConfig() - // will throw if a field is missing - this.checkRequiredFields() - const result = sendEmailTranslated({ - receiver: this.receiver, - template: this.type.toString(), - locals: this.locals, - }) - this.reset() - return result - } - - public setRecipient(recipient: User): this { - this.receiver.to = `${recipient.firstName} ${recipient.lastName} <${recipient.emailContact.email}>` - this.locals.firstName = recipient.firstName - this.locals.lastName = recipient.lastName - this.locals.locale = recipient.language - return this - } - - public setSender(sender: User): this { - this.locals.senderEmail = sender.emailContact.email - this.locals.senderFirstName = sender.firstName - this.locals.senderLastName = sender.lastName - return this - } - - public setType(type: EmailType): this { - this.type = type - return this - } - - public setResetLink(resetLink: string): this { - this.locals.resentLink = resetLink - return this - } - - public setContribution(contribution: Contribution): this { - this.locals.contributionMemo = contribution.memo - if (!this.locals.locale || this.locals.locale === '') { - throw new LogError('missing locale please call setRecipient before') - } - this.locals.contributionAmount = decimalSeparatorByLanguage( - contribution.amount, - this.locals.locale, - ) - return this - } - - public setUpdatedContributionMemo(updatedMemo: string): this { - this.locals.contributionMemoUpdated = updatedMemo - return this - } - - public setTransaction(amount: Decimal, memo: string): this { - this.setTransactionMemo(memo) - this.setTransactionAmount(amount) - return this - } - - public setTransactionAmount(amount: Decimal): this { - if (!this.locals.locale || this.locals.locale === '') { - throw new LogError('missing locale please call setRecipient before') - } - this.locals.transactionAmount = decimalSeparatorByLanguage(amount, this.locals.locale) - return this - } - - public setTransactionMemo(memo: string): this { - this.locals.transactionMemo = memo - return this - } - - public setActivationLink(activationLink: string): this { - this.locals.activationLink = activationLink - return this - } - - public setTimeDurationObject(timeDurationObject: TimeDuration): this { - this.locals.timeDurationObject = timeDurationObject - return this - } -} diff --git a/backend/src/emails/Email.builder.test.ts b/backend/src/emails/sendEmailVariants.test.ts similarity index 81% rename from backend/src/emails/Email.builder.test.ts rename to backend/src/emails/sendEmailVariants.test.ts index b30e4835b..3340a361d 100644 --- a/backend/src/emails/Email.builder.test.ts +++ b/backend/src/emails/sendEmailVariants.test.ts @@ -2,17 +2,43 @@ /* eslint-disable @typescript-eslint/no-unsafe-return */ /* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { Connection } from '@dbTools/typeorm' +import { ApolloServerTestClient } from 'apollo-server-testing' import { Decimal } from 'decimal.js-light' +import { testEnvironment } from '@test/helpers' import { logger, i18n as localization } from '@test/testSetup' import { CONFIG } from '@/config' import { sendEmailTranslated } from './sendEmailTranslated' -import { User } from '@entity/User' -import { UserContact } from '@entity/UserContact' -import { Contribution } from '@entity/Contribution' -import { EmailBuilder, EmailType } from './Email.builder' +import { + sendAddedContributionMessageEmail, + sendAccountActivationEmail, + sendAccountMultiRegistrationEmail, + sendContributionConfirmedEmail, + sendContributionDeniedEmail, + sendContributionDeletedEmail, + sendResetPasswordEmail, + sendTransactionLinkRedeemedEmail, + sendTransactionReceivedEmail, +} from './sendEmailVariants' + +let con: Connection +let testEnv: { + mutate: ApolloServerTestClient['mutate'] + query: ApolloServerTestClient['query'] + con: Connection +} + +beforeAll(async () => { + testEnv = await testEnvironment(logger, localization) + con = testEnv.con +}) + +afterAll(async () => { + await con.close() +}) jest.mock('./sendEmailTranslated', () => { const originalModule = jest.requireActual('./sendEmailTranslated') @@ -25,34 +51,18 @@ jest.mock('./sendEmailTranslated', () => { describe('sendEmailVariants', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any let result: any - const recipientUser = User.create() - recipientUser.firstName = 'Peter' - recipientUser.lastName = 'Lustig' - recipientUser.language = 'en' - const recipientUserContact = UserContact.create() - recipientUserContact.email = 'peter@lustig.de' - recipientUser.emailContact = recipientUserContact - - const senderUser = User.create() - senderUser.firstName = 'Bibi' - senderUser.lastName = 'Bloxberg' - const senderUserContact = UserContact.create() - senderUserContact.email = 'bibi@bloxberg.de' - - const contribution = Contribution.create() - contribution.memo = 'My contribution.' - contribution.amount = new Decimal(23.54) - - const emailBuilder = new EmailBuilder() describe('sendAddedContributionMessageEmail', () => { beforeAll(async () => { - result = await emailBuilder - .setSender(senderUser) - .setRecipient(recipientUser) - .setContribution(contribution) - .setType(EmailType.ADDED_CONTRIBUTION_MESSAGE) - .sendEmail() + result = await sendAddedContributionMessageEmail({ + firstName: 'Peter', + lastName: 'Lustig', + email: 'peter@lustig.de', + language: 'en', + senderFirstName: 'Bibi', + senderLastName: 'Bloxberg', + contributionMemo: 'My contribution.', + }) }) describe('calls "sendEmailTranslated"', () => { @@ -104,13 +114,14 @@ describe('sendEmailVariants', () => { describe('sendAccountActivationEmail', () => { beforeAll(async () => { - result = await emailBuilder - .setRecipient(recipientUser) - .setSender(senderUser) - .setActivationLink('http://localhost/checkEmail/6627633878930542284') - .setTimeDurationObject({ hours: 23, minutes: 30 }) - .setType(EmailType.ACCOUNT_ACTIVATION) - .sendEmail() + result = await sendAccountActivationEmail({ + firstName: 'Peter', + lastName: 'Lustig', + email: 'peter@lustig.de', + language: 'en', + activationLink: 'http://localhost/checkEmail/6627633878930542284', + timeDurationObject: { hours: 23, minutes: 30 }, + }) }) describe('calls "sendEmailTranslated"', () => { @@ -161,10 +172,12 @@ describe('sendEmailVariants', () => { describe('sendAccountMultiRegistrationEmail', () => { beforeAll(async () => { - result = await emailBuilder - .setRecipient(recipientUser) - .setType(EmailType.ACCOUNT_MULTI_REGISTRATION) - .sendEmail() + result = await sendAccountMultiRegistrationEmail({ + firstName: 'Peter', + lastName: 'Lustig', + email: 'peter@lustig.de', + language: 'en', + }) }) describe('calls "sendEmailTranslated"', () => { @@ -213,12 +226,16 @@ describe('sendEmailVariants', () => { describe('sendContributionConfirmedEmail', () => { beforeAll(async () => { - result = await emailBuilder - .setRecipient(recipientUser) - .setSender(senderUser) - .setContribution(contribution) - .setType(EmailType.CONTRIBUTION_CONFIRMED) - .sendEmail() + result = await sendContributionConfirmedEmail({ + firstName: 'Peter', + lastName: 'Lustig', + email: 'peter@lustig.de', + language: 'en', + senderFirstName: 'Bibi', + senderLastName: 'Bloxberg', + contributionMemo: 'My contribution.', + contributionAmount: new Decimal(23.54), + }) }) describe('calls "sendEmailTranslated"', () => { @@ -271,12 +288,15 @@ describe('sendEmailVariants', () => { describe('sendContributionDeniedEmail', () => { beforeAll(async () => { - result = await emailBuilder - .setRecipient(recipientUser) - .setSender(senderUser) - .setContribution(contribution) - .setType(EmailType.CONTRIBUTION_DENIED) - .sendEmail() + result = await sendContributionDeniedEmail({ + firstName: 'Peter', + lastName: 'Lustig', + email: 'peter@lustig.de', + language: 'en', + senderFirstName: 'Bibi', + senderLastName: 'Bloxberg', + contributionMemo: 'My contribution.', + }) }) describe('calls "sendEmailTranslated"', () => { @@ -328,12 +348,15 @@ describe('sendEmailVariants', () => { describe('sendContributionDeletedEmail', () => { beforeAll(async () => { - result = await emailBuilder - .setRecipient(recipientUser) - .setSender(senderUser) - .setContribution(contribution) - .setType(EmailType.CONTRIBUTION_DELETED) - .sendEmail() + result = await sendContributionDeletedEmail({ + firstName: 'Peter', + lastName: 'Lustig', + email: 'peter@lustig.de', + language: 'en', + senderFirstName: 'Bibi', + senderLastName: 'Bloxberg', + contributionMemo: 'My contribution.', + }) }) describe('calls "sendEmailTranslated"', () => { @@ -385,12 +408,14 @@ describe('sendEmailVariants', () => { describe('sendResetPasswordEmail', () => { beforeAll(async () => { - result = await emailBuilder - .setRecipient(recipientUser) - .setResetLink('http://localhost/reset-password/3762660021544901417') - .setTimeDurationObject({ hours: 23, minutes: 30 }) - .setType(EmailType.RESET_PASSWORD) - .sendEmail() + result = await sendResetPasswordEmail({ + firstName: 'Peter', + lastName: 'Lustig', + email: 'peter@lustig.de', + language: 'en', + resetLink: 'http://localhost/reset-password/3762660021544901417', + timeDurationObject: { hours: 23, minutes: 30 }, + }) }) describe('calls "sendEmailTranslated"', () => { @@ -441,12 +466,17 @@ describe('sendEmailVariants', () => { describe('sendTransactionLinkRedeemedEmail', () => { beforeAll(async () => { - result = await emailBuilder - .setRecipient(recipientUser) - .setSender(senderUser) - .setTransaction(new Decimal(17.65), 'You deserve it! 🙏🏼') - .setType(EmailType.TRANSACTION_LINK_REDEEMED) - .sendEmail() + result = await sendTransactionLinkRedeemedEmail({ + firstName: 'Peter', + lastName: 'Lustig', + email: 'peter@lustig.de', + language: 'en', + senderFirstName: 'Bibi', + senderLastName: 'Bloxberg', + senderEmail: 'bibi@bloxberg.de', + transactionMemo: 'You deserve it! 🙏🏼', + transactionAmount: new Decimal(17.65), + }) }) describe('calls "sendEmailTranslated"', () => { @@ -500,12 +530,16 @@ describe('sendEmailVariants', () => { describe('sendTransactionReceivedEmail', () => { beforeAll(async () => { - result = await emailBuilder - .setRecipient(recipientUser) - .setSender(senderUser) - .setTransactionAmount(new Decimal(37.4)) - .setType(EmailType.TRANSACTION_RECEIVED) - .sendEmail() + result = await sendTransactionReceivedEmail({ + firstName: 'Peter', + lastName: 'Lustig', + email: 'peter@lustig.de', + language: 'en', + senderFirstName: 'Bibi', + senderLastName: 'Bloxberg', + senderEmail: 'bibi@bloxberg.de', + transactionAmount: new Decimal(37.4), + }) }) describe('calls "sendEmailTranslated"', () => { diff --git a/backend/src/graphql/resolver/ContributionResolver.test.ts b/backend/src/graphql/resolver/ContributionResolver.test.ts index 8b2bf141e..e6cb485a3 100644 --- a/backend/src/graphql/resolver/ContributionResolver.test.ts +++ b/backend/src/graphql/resolver/ContributionResolver.test.ts @@ -497,6 +497,28 @@ 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({ @@ -2802,7 +2824,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, @@ -2885,7 +2907,7 @@ describe('ContributionResolver', () => { id: expect.any(Number), lastName: 'Lustig', memo: 'Das war leider zu Viel!', - messagesCount: 1, + messagesCount: 0, status: 'DELETED', }), expect.objectContaining({ @@ -3070,7 +3092,7 @@ describe('ContributionResolver', () => { id: expect.any(Number), lastName: 'Lustig', memo: 'Das war leider zu Viel!', - messagesCount: 1, + messagesCount: 0, status: 'DELETED', }), ]), @@ -3115,7 +3137,7 @@ describe('ContributionResolver', () => { id: expect.any(Number), lastName: 'Lustig', memo: 'Das war leider zu Viel!', - messagesCount: 1, + messagesCount: 0, status: 'DELETED', }), ]), diff --git a/backend/src/graphql/resolver/ContributionResolver.ts b/backend/src/graphql/resolver/ContributionResolver.ts index 775260c84..dc6d10364 100644 --- a/backend/src/graphql/resolver/ContributionResolver.ts +++ b/backend/src/graphql/resolver/ContributionResolver.ts @@ -22,7 +22,6 @@ import { OpenCreation } from '@model/OpenCreation' import { UnconfirmedContribution } from '@model/UnconfirmedContribution' import { RIGHTS } from '@/auth/RIGHTS' -import { EmailBuilder, EmailType } from '@/emails/Email.builder' import { EVENT_CONTRIBUTION_CREATE, EVENT_CONTRIBUTION_DELETE, @@ -46,6 +45,7 @@ import { getUserCreation, validateContribution, getOpenCreations } from './util/ import { findContributions } from './util/findContributions' import { getLastTransaction } from './util/getLastTransaction' import { sendTransactionsToDltConnector } from './util/sendTransactionsToDltConnector' +import { sendContributionConfirmedEmail, sendContributionDeletedEmail, sendContributionDeniedEmail } from '@/emails/sendEmailVariants' @Resolver() export class ContributionResolver { @@ -332,13 +332,15 @@ export class ContributionResolver { contribution, contribution.amount, ) - const emailBuilder = new EmailBuilder() - void emailBuilder - .setRecipient(user) - .setSender(moderator) - .setContribution(contribution) - .setType(EmailType.CONTRIBUTION_DELETED) - .sendEmail() + void sendContributionDeletedEmail({ + firstName: user.firstName, + lastName: user.lastName, + email: user.emailContact.email, + language: user.language, + senderFirstName: moderator.firstName, + senderLastName: moderator.lastName, + contributionMemo: contribution.memo, + }) return !!res } @@ -431,13 +433,16 @@ export class ContributionResolver { void sendTransactionsToDltConnector() logger.info('creation commited successfuly.') - const emailBuilder = new EmailBuilder() - void emailBuilder - .setRecipient(user) - .setSender(moderatorUser) - .setContribution(contribution) - .setType(EmailType.CONTRIBUTION_CONFIRMED) - .sendEmail() + void sendContributionConfirmedEmail({ + firstName: user.firstName, + lastName: user.lastName, + email: user.emailContact.email, + language: user.language, + senderFirstName: moderatorUser.firstName, + senderLastName: moderatorUser.lastName, + contributionMemo: contribution.memo, + contributionAmount: contribution.amount, + }) } catch (e) { await queryRunner.rollbackTransaction() throw new LogError('Creation was not successful', e) @@ -511,13 +516,15 @@ export class ContributionResolver { contributionToUpdate.amount, ) - const emailBuilder = new EmailBuilder() - void emailBuilder - .setRecipient(user) - .setSender(moderator) - .setContribution(contributionToUpdate) - .setType(EmailType.CONTRIBUTION_DENIED) - .sendEmail() + void sendContributionDeniedEmail({ + firstName: user.firstName, + lastName: user.lastName, + email: user.emailContact.email, + language: user.language, + senderFirstName: moderator.firstName, + senderLastName: moderator.lastName, + contributionMemo: contributionToUpdate.memo, + }) return !!res } diff --git a/backend/src/graphql/resolver/TransactionResolver.ts b/backend/src/graphql/resolver/TransactionResolver.ts index ba259e601..8d35708a6 100644 --- a/backend/src/graphql/resolver/TransactionResolver.ts +++ b/backend/src/graphql/resolver/TransactionResolver.ts @@ -22,7 +22,6 @@ import { User } from '@model/User' import { RIGHTS } from '@/auth/RIGHTS' import { CONFIG } from '@/config' -import { EmailBuilder, EmailType } from '@/emails/Email.builder' import { sendTransactionLinkRedeemedEmail, sendTransactionReceivedEmail, @@ -181,21 +180,28 @@ export const executeTransaction = async ( } finally { await queryRunner.release() } - const emailBuilder = new EmailBuilder() - void emailBuilder - .setRecipient(recipient) - .setSender(sender) - .setTransactionAmount(amount) - .setType(EmailType.TRANSACTION_RECEIVED) - .sendEmail() - + void sendTransactionReceivedEmail({ + firstName: recipient.firstName, + lastName: recipient.lastName, + email: recipient.emailContact.email, + language: recipient.language, + senderFirstName: sender.firstName, + senderLastName: sender.lastName, + senderEmail: sender.emailContact.email, + transactionAmount: amount, + }) if (transactionLink) { - void emailBuilder - .setRecipient(sender) - .setSender(recipient) - .setTransaction(amount, memo) - .setType(EmailType.TRANSACTION_LINK_REDEEMED) - .sendEmail() + void sendTransactionLinkRedeemedEmail({ + firstName: sender.firstName, + lastName: sender.lastName, + email: sender.emailContact.email, + language: sender.language, + senderFirstName: recipient.firstName, + senderLastName: recipient.lastName, + senderEmail: recipient.emailContact.email, + transactionAmount: amount, + transactionMemo: memo, + }) } logger.info(`finished executeTransaction successfully`) } finally { diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index 4a7a1fa2f..1f21abbb9 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -31,8 +31,11 @@ import { subscribe } from '@/apis/KlicktippController' import { encode } from '@/auth/JWT' import { RIGHTS } from '@/auth/RIGHTS' import { CONFIG } from '@/config' -import { EmailBuilder, EmailType } from '@/emails/Email.builder' -import { sendResetPasswordEmail } from '@/emails/sendEmailVariants' +import { + sendAccountActivationEmail, + sendAccountMultiRegistrationEmail, + sendResetPasswordEmail, +} from '@/emails/sendEmailVariants' import { Event, EventType, @@ -245,12 +248,12 @@ export class UserResolver { } logger.debug('partly faked user', user) - const emailBuilder = new EmailBuilder() - void emailBuilder - .setRecipient(foundUser) // this is the real name of the email owner, but just "firstName" and "lastName" would be the name of the new registrant which shall not be passed to the outside - .setType(EmailType.ACCOUNT_MULTI_REGISTRATION) - .sendEmail() - + void sendAccountMultiRegistrationEmail({ + firstName: foundUser.firstName, // this is the real name of the email owner, but just "firstName" would be the name of the new registrant which shall not be passed to the outside + lastName: foundUser.lastName, // this is the real name of the email owner, but just "lastName" would be the name of the new registrant which shall not be passed to the outside + email, + language: foundUser.language, // use language of the emails owner for sending + }) await EVENT_EMAIL_ACCOUNT_MULTIREGISTRATION(foundUser) logger.info( @@ -329,14 +332,14 @@ export class UserResolver { emailContact.emailVerificationCode.toString(), ).replace(/{code}/g, redeemCode ? '/' + redeemCode : '') - const emailBuilder = new EmailBuilder() - void emailBuilder - .setRecipient(dbUser) - .setActivationLink(activationLink) - .setTimeDurationObject(getTimeDurationObject(CONFIG.EMAIL_CODE_VALID_TIME)) - .setType(EmailType.ACCOUNT_ACTIVATION) - .sendEmail() - + void sendAccountActivationEmail({ + firstName, + lastName, + email, + language, + activationLink, + timeDurationObject: getTimeDurationObject(CONFIG.EMAIL_CODE_VALID_TIME), + }) logger.info(`sendAccountActivationEmail of ${firstName}.${lastName} to ${email}`) await EVENT_EMAIL_CONFIRMATION(dbUser) @@ -391,13 +394,15 @@ export class UserResolver { }) logger.info('optInCode for', email, user.emailContact) - const emailBuilder = new EmailBuilder() - void emailBuilder - .setRecipient(user) - .setResetLink(activationLink(user.emailContact.emailVerificationCode)) - .setTimeDurationObject(getTimeDurationObject(CONFIG.EMAIL_CODE_VALID_TIME)) - .setType(EmailType.RESET_PASSWORD) - .sendEmail() + + void sendResetPasswordEmail({ + firstName: user.firstName, + lastName: user.lastName, + email, + language: user.language, + resetLink: activationLink(user.emailContact.emailVerificationCode), + timeDurationObject: getTimeDurationObject(CONFIG.EMAIL_CODE_VALID_TIME), + }) logger.info(`forgotPassword(${email}) successful...`) await EVENT_EMAIL_FORGOT_PASSWORD(user) @@ -793,13 +798,15 @@ export class UserResolver { user.emailContact.emailResendCount++ await user.emailContact.save() - const emailBuilder = new EmailBuilder() - void emailBuilder - .setRecipient(user) - .setActivationLink(activationLink(user.emailContact.emailVerificationCode)) - .setTimeDurationObject(getTimeDurationObject(CONFIG.EMAIL_CODE_VALID_TIME)) - .setType(EmailType.ACCOUNT_ACTIVATION) - .sendEmail() + // eslint-disable-next-line @typescript-eslint/no-unused-vars + void sendAccountActivationEmail({ + firstName: user.firstName, + lastName: user.lastName, + email, + language: user.language, + activationLink: activationLink(user.emailContact.emailVerificationCode), + timeDurationObject: getTimeDurationObject(CONFIG.EMAIL_CODE_VALID_TIME), + }) await EVENT_EMAIL_ADMIN_CONFIRMATION(user, getUser(context)) diff --git a/database/entity/Contribution.ts b/database/entity/Contribution.ts index ef43b88df..29914255b 100644 --- a/database/entity/Contribution.ts +++ b/database/entity/Contribution.ts @@ -1 +1 @@ -export { Contribution } from './0074-add_updated_by_contribution/Contribution' +export { Contribution } from './0075-add_updated_by_contribution/Contribution' From 50dc59cbd674dda7c98c07cffd7e750a08a4a913 Mon Sep 17 00:00:00 2001 From: einhornimmond Date: Tue, 14 Nov 2023 09:48:18 +0100 Subject: [PATCH 17/33] fix test, add english translation for new email --- .../sendEmailVariants.test.ts.snap | 167 ++++++++++++++++++ .../src/emails/sendEmailTranslated.test.ts | 1 + backend/src/emails/sendEmailVariants.test.ts | 66 +++++++ backend/src/emails/sendEmailVariants.ts | 28 +++ .../resolver/ContributionMessageResolver.ts | 18 +- .../resolver/ContributionResolver.test.ts | 30 +--- .../graphql/resolver/ContributionResolver.ts | 26 ++- .../UnconfirmedContribution.role.ts | 4 + .../UpdateUnconfirmedContribution.context.ts | 8 +- backend/src/locales/en.json | 5 + backend/src/util/time.ts | 6 +- 11 files changed, 318 insertions(+), 41 deletions(-) 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..f0c431586 100644 --- a/backend/src/emails/sendEmailVariants.test.ts +++ b/backend/src/emails/sendEmailVariants.test.ts @@ -11,6 +11,8 @@ import { logger, i18n as localization } from '@test/testSetup' import { CONFIG } from '@/config' +CONFIG.EMAIL_SENDER = 'info@gradido.net' + import { sendEmailTranslated } from './sendEmailTranslated' import { sendAddedContributionMessageEmail, @@ -22,6 +24,7 @@ import { sendResetPasswordEmail, sendTransactionLinkRedeemedEmail, sendTransactionReceivedEmail, + sendContributionChangedByModeratorEmail, } from './sendEmailVariants' let con: Connection @@ -286,6 +289,69 @@ 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', () => { + console.log(result.originalMessage.text) + 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/graphql/resolver/ContributionMessageResolver.ts b/backend/src/graphql/resolver/ContributionMessageResolver.ts index a5af00f69..5910befa1 100644 --- a/backend/src/graphql/resolver/ContributionMessageResolver.ts +++ b/backend/src/graphql/resolver/ContributionMessageResolver.ts @@ -14,7 +14,7 @@ import { Order } from '@enum/Order' import { ContributionMessage, ContributionMessageListResult } from '@model/ContributionMessage' import { RIGHTS } from '@/auth/RIGHTS' -import { EmailBuilder, EmailType } from '@/emails/Email.builder' +import { sendAddedContributionMessageEmail } from '@/emails/sendEmailVariants' import { EVENT_ADMIN_CONTRIBUTION_MESSAGE_CREATE, EVENT_CONTRIBUTION_MESSAGE_CREATE, @@ -170,13 +170,15 @@ export class ContributionMessageResolver { } // send email (never for moderator messages) - const emailBuilder = new EmailBuilder() - void emailBuilder - .setRecipient(contribution.user) - .setSender(moderator) - .setContribution(contribution) - .setType(EmailType.ADDED_CONTRIBUTION_MESSAGE) - .sendEmail() + void sendAddedContributionMessageEmail({ + firstName: contribution.user.firstName, + lastName: contribution.user.lastName, + email: contribution.user.emailContact.email, + language: contribution.user.language, + senderFirstName: moderator.firstName, + senderLastName: moderator.lastName, + contributionMemo: contribution.memo, + }) } await queryRunner.commitTransaction() await EVENT_ADMIN_CONTRIBUTION_MESSAGE_CREATE( 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 dc6d10364..2cbbbc27a 100644 --- a/backend/src/graphql/resolver/ContributionResolver.ts +++ b/backend/src/graphql/resolver/ContributionResolver.ts @@ -45,7 +45,7 @@ import { getUserCreation, validateContribution, getOpenCreations } from './util/ import { findContributions } from './util/findContributions' import { getLastTransaction } from './util/getLastTransaction' import { sendTransactionsToDltConnector } from './util/sendTransactionsToDltConnector' -import { sendContributionConfirmedEmail, sendContributionDeletedEmail, sendContributionDeniedEmail } from '@/emails/sendEmailVariants' +import { sendContributionChangedByModeratorEmail, sendContributionConfirmedEmail, sendContributionDeletedEmail, sendContributionDeniedEmail } from '@/emails/sendEmailVariants' @Resolver() export class ContributionResolver { @@ -255,7 +255,7 @@ export class ContributionResolver { adminUpdateContributionArgs, context, ) - const { contribution, contributionMessage } = await updateUnconfirmedContributionContext.run() + const { contribution, contributionMessage, createdByUserChangedByModerator } = await updateUnconfirmedContributionContext.run() await getConnection().transaction(async (transactionalEntityManager: EntityManager) => { await Promise.all([ transactionalEntityManager.save(contribution), @@ -275,9 +275,31 @@ export class ContributionResolver { contribution, contribution.amount, ) + if (createdByUserChangedByModerator && adminUpdateContributionArgs.memo) { + void sendContributionChangedByModeratorEmail({ + firstName: contribution.user.firstName, + lastName: contribution.user.lastName, + email: contribution.user.emailContact.email, + language: contribution.user.language, + senderFirstName: moderator.firstName, + senderLastName: moderator.lastName, + contributionMemo: contribution.memo, + contributionMemoUpdated: adminUpdateContributionArgs.memo + }) + } return result } + /* + firstName: string + lastName: string + email: string + language: string + senderFirstName: string + senderLastName: string + contributionMemo: string + contributionMemoUpdated: string + */ @Authorized([RIGHTS.ADMIN_LIST_CONTRIBUTIONS]) @Query(() => ContributionListResult) diff --git a/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContribution.role.ts b/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContribution.role.ts index 472db4a96..acdb7b750 100644 --- a/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContribution.role.ts +++ b/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContribution.role.ts @@ -61,4 +61,8 @@ export abstract class UnconfirmedContributionRole { } return this.availableCreationSums } + + public isCreatedFromUser(): boolean { + return !this.self.moderatorId + } } diff --git a/backend/src/interactions/updateUnconfirmedContribution/UpdateUnconfirmedContribution.context.ts b/backend/src/interactions/updateUnconfirmedContribution/UpdateUnconfirmedContribution.context.ts index db98c87e5..0d70662d3 100644 --- a/backend/src/interactions/updateUnconfirmedContribution/UpdateUnconfirmedContribution.context.ts +++ b/backend/src/interactions/updateUnconfirmedContribution/UpdateUnconfirmedContribution.context.ts @@ -33,8 +33,10 @@ export class UpdateUnconfirmedContributionContext { public async run(): Promise<{ contribution: Contribution contributionMessage: ContributionMessage - availableCreationSums: Decimal[] + availableCreationSums: Decimal[], + createdByUserChangedByModerator: boolean }> { + let createdByUserChangedByModerator = false if (!this.context.role || !this.context.user) { throw new LogError("context didn't contain role or user") } @@ -64,6 +66,9 @@ export class UpdateUnconfirmedContributionContext { this.input, this.context.user, ) + if (unconfirmedContributionRole.isCreatedFromUser()) { + createdByUserChangedByModerator = true + } contributionMessageBuilder.setIsModerator(true) } if (!unconfirmedContributionRole) { @@ -77,6 +82,7 @@ export class UpdateUnconfirmedContributionContext { contribution: contributionToUpdate, contributionMessage: contributionMessageBuilder.build(), availableCreationSums: unconfirmedContributionRole.getAvailableCreationSums(), + createdByUserChangedByModerator } } } diff --git a/backend/src/locales/en.json b/backend/src/locales/en.json index cf4bbef47..0bcd5e42f 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": { + "commonGoodContributionConfirmed": "your common good contribution '{contributionMemo}' has just been changed by {senderFirstName} {senderLastName} and now reads as '{contributionMemoUpdated}'", + "subject": "Your common good contribution has been changed", + "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 7b8d09671..45b886702 100644 --- a/backend/src/util/time.ts +++ b/backend/src/util/time.ts @@ -1,9 +1,7 @@ -export interface TimeDuration { +export const getTimeDurationObject = (time: number): { hours?: number minutes: number -} - -export const getTimeDurationObject = (time: number): TimeDuration => { +} => { if (time > 60) { return { hours: Math.floor(time / 60), From 140349a6e0f14f8c288ef6b7f9430ee313f792cb Mon Sep 17 00:00:00 2001 From: einhornimmond Date: Tue, 14 Nov 2023 10:59:19 +0100 Subject: [PATCH 18/33] test and fix email --- .../src/graphql/resolver/ContributionResolver.ts | 16 ++++++++++------ .../UnconfirmedContribution.role.ts | 5 ----- .../UpdateUnconfirmedContribution.context.ts | 6 ++++++ dht-node/src/config/index.ts | 2 +- federation/src/config/index.ts | 2 +- 5 files changed, 18 insertions(+), 13 deletions(-) diff --git a/backend/src/graphql/resolver/ContributionResolver.ts b/backend/src/graphql/resolver/ContributionResolver.ts index 2cbbbc27a..84106aa37 100644 --- a/backend/src/graphql/resolver/ContributionResolver.ts +++ b/backend/src/graphql/resolver/ContributionResolver.ts @@ -263,6 +263,10 @@ export class ContributionResolver { ]) }) const moderator = getUser(context) + const user = await DbUser.findOneOrFail({ + where: { id: contribution.userId }, + relations: ['emailContact'], + }) const result = new AdminUpdateContribution() result.amount = contribution.amount @@ -277,14 +281,14 @@ export class ContributionResolver { ) if (createdByUserChangedByModerator && adminUpdateContributionArgs.memo) { void sendContributionChangedByModeratorEmail({ - firstName: contribution.user.firstName, - lastName: contribution.user.lastName, - email: contribution.user.emailContact.email, - language: contribution.user.language, + firstName: user.firstName, + lastName: user.lastName, + email: user.emailContact.email, + language: user.language, senderFirstName: moderator.firstName, senderLastName: moderator.lastName, - contributionMemo: contribution.memo, - contributionMemoUpdated: adminUpdateContributionArgs.memo + contributionMemo: updateUnconfirmedContributionContext.getOldMemo(), + contributionMemoUpdated: contribution.memo }) } diff --git a/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContribution.role.ts b/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContribution.role.ts index acdb7b750..b4926a640 100644 --- a/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContribution.role.ts +++ b/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContribution.role.ts @@ -25,11 +25,6 @@ export abstract class UnconfirmedContributionRole { protected abstract checkAuthorization(user: User, role: Role): void // second, check if contribution is still valid after update protected async validate(clientTimezoneOffset: number): Promise { - // TODO: 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, diff --git a/backend/src/interactions/updateUnconfirmedContribution/UpdateUnconfirmedContribution.context.ts b/backend/src/interactions/updateUnconfirmedContribution/UpdateUnconfirmedContribution.context.ts index 0d70662d3..561866597 100644 --- a/backend/src/interactions/updateUnconfirmedContribution/UpdateUnconfirmedContribution.context.ts +++ b/backend/src/interactions/updateUnconfirmedContribution/UpdateUnconfirmedContribution.context.ts @@ -14,6 +14,7 @@ import { UnconfirmedContributionAdminRole } from './UnconfirmedContributionAdmin import { UnconfirmedContributionUserRole } from './UnconfirmedContributionUser.role' export class UpdateUnconfirmedContributionContext { + private oldMemoText: string /** * * @param id contribution id for update @@ -46,6 +47,7 @@ export class UpdateUnconfirmedContributionContext { if (!contributionToUpdate) { throw new LogError('Contribution not found', this.id) } + this.oldMemoText = contributionToUpdate.memo const contributionMessageBuilder = new ContributionMessageBuilder() contributionMessageBuilder .setParentContribution(contributionToUpdate) @@ -85,4 +87,8 @@ export class UpdateUnconfirmedContributionContext { createdByUserChangedByModerator } } + + public getOldMemo(): string { + return this.oldMemoText + } } diff --git a/dht-node/src/config/index.ts b/dht-node/src/config/index.ts index 7aed88ccd..109675869 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: '0074-insert_communityuuid in_existing_users', + DB_VERSION: '0075-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 2770ada06..b663bea91 100644 --- a/federation/src/config/index.ts +++ b/federation/src/config/index.ts @@ -10,7 +10,7 @@ Decimal.set({ }) const constants = { - DB_VERSION: '0074-insert_communityuuid in_existing_users', + DB_VERSION: '0075-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 From 68be43212cf70809b9dfe0741bfbb116b507787b Mon Sep 17 00:00:00 2001 From: einhornimmond Date: Tue, 14 Nov 2023 12:06:06 +0100 Subject: [PATCH 19/33] put back restriction for date editing in backend, because frontend currently not support it --- .../UnconfirmedContribution.role.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContribution.role.ts b/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContribution.role.ts index b4926a640..fc822c41c 100644 --- a/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContribution.role.ts +++ b/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContribution.role.ts @@ -25,6 +25,11 @@ export abstract class UnconfirmedContributionRole { 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, From c61067c86877b586aa658c0a0f5f2c772e0b0b5c Mon Sep 17 00:00:00 2001 From: einhornimmond Date: Tue, 14 Nov 2023 12:22:51 +0100 Subject: [PATCH 20/33] fix lint and locale warnings --- admin/src/locales/de.json | 1 - admin/src/locales/en.json | 1 - .../emails/templates/contributionChangedByModerator/html.pug | 2 +- backend/src/locales/de.json | 2 +- backend/src/locales/en.json | 2 +- frontend/src/locales/de.json | 2 +- 6 files changed, 4 insertions(+), 6 deletions(-) diff --git a/admin/src/locales/de.json b/admin/src/locales/de.json index 2277d64ed..fbfbc61d0 100644 --- a/admin/src/locales/de.json +++ b/admin/src/locales/de.json @@ -113,7 +113,6 @@ "history": "Die Daten wurden geändert. Dies sind die alten Daten.", "moderator": "Moderator", "notice": "Moderator Notiz", - "notice_tooltip": "Nur Moderatoren können die Nachricht sehen", "memo": "Memo", "memo-modify": "Memo bearbeiten", "memo-modified": "Memo vom Moderator bearbeitet", diff --git a/admin/src/locales/en.json b/admin/src/locales/en.json index f9d612606..66c974ddf 100644 --- a/admin/src/locales/en.json +++ b/admin/src/locales/en.json @@ -113,7 +113,6 @@ "history": "The data has been changed. This is the old data.", "moderator": "Moderator", "notice": "Moderator note", - "notice_tooltip": "Only moderators can see the message", "memo": "Memo", "memo-modify": "Modify Memo", "memo-modified": "Memo edited by moderator", diff --git a/backend/src/emails/templates/contributionChangedByModerator/html.pug b/backend/src/emails/templates/contributionChangedByModerator/html.pug index ed29864ec..46bcd4ae1 100644 --- a/backend/src/emails/templates/contributionChangedByModerator/html.pug +++ b/backend/src/emails/templates/contributionChangedByModerator/html.pug @@ -4,7 +4,7 @@ block content h2= t('emails.contributionChangedByModerator.title') .text-block include ../includes/salutation.pug - p= t('emails.contributionChangedByModerator.commonGoodContributionConfirmed', { contributionMemo, senderFirstName, senderLastName, contributionMemoUpdated }) + 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/locales/de.json b/backend/src/locales/de.json index 4bf9fc8be..c99e1657e 100644 --- a/backend/src/locales/de.json +++ b/backend/src/locales/de.json @@ -27,8 +27,8 @@ "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": { - "commonGoodContributionConfirmed": "dein Gemeinwohl-Beitrag „{contributionMemo}“ wurde soeben von {senderFirstName} {senderLastName} geändert und lautet jetzt „{contributionMemoUpdated}“", "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": { diff --git a/backend/src/locales/en.json b/backend/src/locales/en.json index 0bcd5e42f..15a56577c 100644 --- a/backend/src/locales/en.json +++ b/backend/src/locales/en.json @@ -27,8 +27,8 @@ "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": { - "commonGoodContributionConfirmed": "your common good contribution '{contributionMemo}' has just been changed by {senderFirstName} {senderLastName} and now reads as '{contributionMemoUpdated}'", "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": { diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index e74358b90..763f6e099 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -256,7 +256,7 @@ "unsetPassword": "Dein Passwort wurde noch nicht gesetzt. Bitte setze es neu." }, "moderatorChangedMemo": "Memo vom Moderator bearbeitet", - "moderatorChat": "Moderator Chat", + "moderatorChat": "Moderator Chat", "navigation": { "admin_area": "Adminbereich", "community": "Gemeinschaft", From e7683fb05ac89878fd320cf3847c9dd121b082e5 Mon Sep 17 00:00:00 2001 From: einhornimmond Date: Thu, 16 Nov 2023 12:00:35 +0100 Subject: [PATCH 21/33] work on fixing tests --- .../ContributionMessagesFormular.spec.js | 4 ++- .../ContributionMessagesFormular.vue | 1 + .../ContributionMessagesList.spec.js | 1 + .../CreationTransactionList.spec.js | 4 +++ admin/src/graphql/getContribution.js | 27 +++++++++++++++ admin/src/pages/CreationConfirm.spec.js | 31 ++++++++++++++--- admin/src/pages/CreationConfirm.vue | 33 ++----------------- admin/src/pages/Overview.spec.js | 4 +++ .../UnconfirmedContributionAdmin.role.ts | 9 +++++ .../UnconfirmedContributionUser.role.ts | 10 ++++++ .../Contributions/ContributionForm.vue | 1 - frontend/src/pages/Community.vue | 6 ++-- 12 files changed, 92 insertions(+), 39 deletions(-) create mode 100644 admin/src/graphql/getContribution.js diff --git a/admin/src/components/ContributionMessages/ContributionMessagesFormular.spec.js b/admin/src/components/ContributionMessages/ContributionMessagesFormular.spec.js index b7f01f8b8..60395c68b 100644 --- a/admin/src/components/ContributionMessages/ContributionMessagesFormular.spec.js +++ b/admin/src/components/ContributionMessages/ContributionMessagesFormular.spec.js @@ -12,6 +12,7 @@ describe('ContributionMessagesFormular', () => { const propsData = { contributionId: 42, + contributionMemo: 'It is a test memo', } const mocks = { @@ -52,9 +53,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', }) }) }) diff --git a/admin/src/components/ContributionMessages/ContributionMessagesFormular.vue b/admin/src/components/ContributionMessages/ContributionMessagesFormular.vue index b61a65447..96ecef6c1 100644 --- a/admin/src/components/ContributionMessages/ContributionMessagesFormular.vue +++ b/admin/src/components/ContributionMessages/ContributionMessagesFormular.vue @@ -148,6 +148,7 @@ export default { }, onReset(event) { this.form.text = '' + this.form.memo = this.contributionMemo }, enableMemo() { this.chatOrMemo = 1 diff --git a/admin/src/components/ContributionMessages/ContributionMessagesList.spec.js b/admin/src/components/ContributionMessages/ContributionMessagesList.spec.js index 023f65974..671a05f83 100644 --- a/admin/src/components/ContributionMessages/ContributionMessagesList.spec.js +++ b/admin/src/components/ContributionMessages/ContributionMessagesList.spec.js @@ -86,6 +86,7 @@ describe('ContributionMessagesList', () => { const propsData = { contributionId: 42, + contributionMemo: 'test memo', contributionUserId: 108, contributionStatus: 'PENDING', } diff --git a/admin/src/components/CreationTransactionList.spec.js b/admin/src/components/CreationTransactionList.spec.js index 28aa1707d..394cfd9ea 100644 --- a/admin/src/components/CreationTransactionList.spec.js +++ b/admin/src/components/CreationTransactionList.spec.js @@ -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, }, diff --git a/admin/src/graphql/getContribution.js b/admin/src/graphql/getContribution.js new file mode 100644 index 000000000..bc42bceb1 --- /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 + } + } +` \ No newline at end of file diff --git a/admin/src/pages/CreationConfirm.spec.js b/admin/src/pages/CreationConfirm.spec.js index 36e2479aa..d26b183e6 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,11 @@ describe('CreationConfirm', () => { confirmContributionMock.mockResolvedValue({ data: { confirmContribution: true } }), ) + mockClient.setRequestHandler( + getContribution, + getContributionMock.mockResolvedValue({ data: {} }), + ) + const Wrapper = () => { return mount(CreationConfirm, { localVue, mocks, apolloProvider }) } @@ -141,7 +152,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() }) @@ -150,7 +161,7 @@ describe('CreationConfirm', () => { expect(wrapper.find('tbody').findAll('tr')).toHaveLength(2) }) }) - + describe('actions in overlay', () => { describe('delete creation', () => { beforeEach(async () => { @@ -219,7 +230,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 +289,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 +521,18 @@ 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 593603e43..3ca382c43 100644 --- a/admin/src/pages/CreationConfirm.vue +++ b/admin/src/pages/CreationConfirm.vue @@ -3,7 +3,7 @@
@@ -96,33 +96,7 @@ import { adminListContributions } from '../graphql/adminListContributions' import { adminDeleteContribution } from '../graphql/adminDeleteContribution' import { confirmContribution } from '../graphql/confirmContribution' import { denyContribution } from '../graphql/denyContribution' -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 - } - } -` +import { getContribution } from '../graphql/getContribution' const FILTER_TAB_MAP = [ ['IN_PROGRESS', 'PENDING'], @@ -175,9 +149,6 @@ export default { this.toastError(error.message) }) }, - swapNoHashtag() { - this.query() - }, deleteCreation() { this.$apollo .mutate({ 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/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionAdmin.role.ts b/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionAdmin.role.ts index 593308a7c..c55283272 100644 --- a/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionAdmin.role.ts +++ b/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionAdmin.role.ts @@ -41,4 +41,13 @@ export class UnconfirmedContributionAdminRole extends UnconfirmedContributionRol } 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 index 3d7a15b63..85374e3e8 100644 --- a/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionUser.role.ts +++ b/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionUser.role.ts @@ -43,4 +43,14 @@ export class UnconfirmedContributionUserRole extends UnconfirmedContributionRole } 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/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/pages/Community.vue b/frontend/src/pages/Community.vue index c4df3a47e..2381b5be1 100644 --- a/frontend/src/pages/Community.vue +++ b/frontend/src/pages/Community.vue @@ -91,6 +91,7 @@ export default { hours: 0, amount: '', }, + originalContributionDate: '', updateAmount: '', maximalDate: new Date(), openCreations: [], @@ -183,10 +184,10 @@ 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 +281,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 From b6821a56fc42a1fdbf44fa4b0fb98ba2fd5ca7f6 Mon Sep 17 00:00:00 2001 From: einhorn_b Date: Thu, 16 Nov 2023 14:08:52 +0100 Subject: [PATCH 22/33] extend admin test to keep coverage --- .../ContributionMessagesFormular.spec.js | 27 +++++++++++++++++++ .../ContributionMessagesFormular.vue | 10 +------ .../ContributionMessagesList.spec.js | 21 +++++++++++++++ .../Tables/OpenCreationsTable.spec.js | 11 ++++++++ admin/src/graphql/adminUpdateContribution.js | 2 +- 5 files changed, 61 insertions(+), 10 deletions(-) diff --git a/admin/src/components/ContributionMessages/ContributionMessagesFormular.spec.js b/admin/src/components/ContributionMessages/ContributionMessagesFormular.spec.js index 60395c68b..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 @@ -136,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 96ecef6c1..be97d9c13 100644 --- a/admin/src/components/ContributionMessages/ContributionMessagesFormular.vue +++ b/admin/src/components/ContributionMessages/ContributionMessagesFormular.vue @@ -64,15 +64,7 @@