move code for update contribution message into context/role/logic/builder classes, reduce double code

This commit is contained in:
einhorn_b 2023-11-04 18:39:57 +01:00
parent 8418e55c99
commit b1d99c5fb4
9 changed files with 420 additions and 112 deletions

View File

@ -0,0 +1,52 @@
import { Contribution } from '@entity/Contribution'
import { Decimal } from 'decimal.js-light'
import {
getUserCreation,
updateCreations,
validateContribution,
} from '@/graphql/resolver/util/creations'
import { LogError } from '@/server/LogError'
export class ContributionLogic {
// how much gradido can be still created
private availableCreationSums?: Decimal[]
public constructor(private self: Contribution) {}
/**
* retrieve from db and return available creation sums array
* @param clientTimezoneOffset
* @param putThisBack if true, amount from this contribution will be added back to the availableCreationSums array,
* as if this creation wasn't part of it, used for update contribution
* @returns
*/
public async getAvailableCreationSums(
clientTimezoneOffset: number,
putThisBack = false,
): Promise<Decimal[]> {
// TODO: move code from getUserCreation and updateCreations inside this function/class
this.availableCreationSums = await getUserCreation(this.self.userId, clientTimezoneOffset)
if (putThisBack) {
this.availableCreationSums = updateCreations(
this.availableCreationSums,
this.self,
clientTimezoneOffset,
)
}
return this.availableCreationSums
}
public checkAvailableCreationSumsNotExceeded(
amount: Decimal,
creationDate: Date,
clientTimezoneOffset: number,
): void {
if (!this.availableCreationSums) {
throw new LogError(
'missing available creation sums, please call getAvailableCreationSums first',
)
}
// all possible cases not to be true are thrown in this function
validateContribution(this.availableCreationSums, amount, creationDate, clientTimezoneOffset)
}
}

View File

@ -0,0 +1,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
}
}

View File

@ -0,0 +1,11 @@
import { User } from '@entity/User'
import { UserRole } from '@entity/UserRole'
import { RoleNames } from '@enum/RoleNames'
export class UserLogic {
public constructor(private self: User) {}
public isRole(role: RoleNames): boolean {
return this.self.userRoles.some((value: UserRole) => value.role === role.toString())
}
}

View File

@ -21,6 +21,8 @@ export class Contribution {
this.deniedBy = contribution.deniedBy
this.deletedAt = contribution.deletedAt
this.deletedBy = contribution.deletedBy
this.updatedAt = contribution.updatedAt
this.updatedBy = contribution.updatedBy
this.moderatorId = contribution.moderatorId
this.userId = contribution.userId
}
@ -61,6 +63,12 @@ export class Contribution {
@Field(() => Int, { nullable: true })
deletedBy: number | null
@Field(() => Date, { nullable: true })
updatedAt: Date | null
@Field(() => Int, { nullable: true })
updatedBy: number | null
@Field(() => Date)
contributionDate: Date

View File

@ -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<UnconfirmedContribution> {
const clientTimezoneOffset = getClientTimezoneOffset(context)
const user = getUser(context)
const contributionToUpdate = await DbContribution.findOne({
where: { id: contributionId, confirmedAt: IsNull(), deniedAt: IsNull() },
const updateUnconfirmedContributionContext = new UpdateUnconfirmedContributionContext(
contributionId,
contributionArgs,
context,
)
const { contribution, contributionMessage, availableCreationSums } =
await updateUnconfirmedContributionContext.run()
await getConnection().transaction(async (transactionalEntityManager: EntityManager) => {
await Promise.all([
transactionalEntityManager.save(contribution),
transactionalEntityManager.save(contributionMessage),
])
})
if (!contributionToUpdate) {
throw new LogError('Contribution not found', contributionId)
}
if (contributionToUpdate.userId !== user.id) {
throw new LogError(
'Can not update contribution of another user',
contributionToUpdate,
user.id,
)
}
if (contributionToUpdate.moderatorId) {
throw new LogError('Cannot update contribution of moderator', contributionToUpdate, user.id)
}
if (
contributionToUpdate.contributionStatus !== ContributionStatus.IN_PROGRESS &&
contributionToUpdate.contributionStatus !== ContributionStatus.PENDING
) {
throw new LogError(
'Contribution can not be updated due to status',
contributionToUpdate.contributionStatus,
)
}
const creationDateObj = new Date(creationDate)
let creations = await getUserCreation(user.id, clientTimezoneOffset)
if (contributionToUpdate.contributionDate.getMonth() === creationDateObj.getMonth()) {
creations = updateCreations(creations, contributionToUpdate, clientTimezoneOffset)
} else {
throw new LogError('Month of contribution can not be changed')
}
const user = getUser(context)
await EVENT_CONTRIBUTION_UPDATE(user, contribution, contributionArgs.amount)
// all possible cases not to be true are thrown in this function
validateContribution(creations, amount, creationDateObj, clientTimezoneOffset)
const contributionMessage = ContributionMessage.create()
contributionMessage.contributionId = contributionId
contributionMessage.createdAt = contributionToUpdate.updatedAt
? contributionToUpdate.updatedAt
: contributionToUpdate.createdAt
const changeMessage = `${contributionToUpdate.contributionDate.toString()}
---
${contributionToUpdate.memo}
---
${contributionToUpdate.amount.toString()}`
contributionMessage.message = changeMessage
contributionMessage.isModerator = false
contributionMessage.userId = user.id
contributionMessage.type = ContributionMessageType.HISTORY
await ContributionMessage.save(contributionMessage)
contributionToUpdate.amount = amount
contributionToUpdate.memo = memo
contributionToUpdate.contributionDate = new Date(creationDate)
contributionToUpdate.contributionStatus = ContributionStatus.PENDING
contributionToUpdate.updatedAt = new Date()
await DbContribution.save(contributionToUpdate)
await EVENT_CONTRIBUTION_UPDATE(user, contributionToUpdate, amount)
return new UnconfirmedContribution(contributionToUpdate, user, creations)
return new UnconfirmedContribution(contribution, user, availableCreationSums)
}
@Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTION])
@ -294,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<AdminUpdateContribution> {
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,
)

View File

@ -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<void> {
// 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
}
}

View File

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

View File

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

View File

@ -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(),
}
}
}