From b1d99c5fb4cc3070dcbecab8227bf015ee90093b Mon Sep 17 00:00:00 2001 From: einhorn_b Date: Sat, 4 Nov 2023 18:39:57 +0100 Subject: [PATCH] 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(), + } + } +}