From 1b2644d32494f53e6214896a589316dc6e22c831 Mon Sep 17 00:00:00 2001 From: Claus-Peter Huebner Date: Tue, 14 Nov 2023 22:52:55 +0100 Subject: [PATCH 01/22] change filename date-pattern, stop all modules with db-write-access --- deployment/bare_metal/backup.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/deployment/bare_metal/backup.sh b/deployment/bare_metal/backup.sh index 73a997646..63788c6db 100755 --- a/deployment/bare_metal/backup.sh +++ b/deployment/bare_metal/backup.sh @@ -20,9 +20,13 @@ fi # Stop Services pm2 stop gradido-backend +pm2 stop gradido-dht-node +pm2 stop gradido-federation-1_0 # Backup data -mysqldump --databases --single-transaction --quick --hex-blob --lock-tables=false > ${SCRIPT_DIR}/backup/mariadb-backup-$(date +%d-%m-%Y_%H-%M-%S).sql -u ${DB_USER} -p${DB_PASSWORD} ${DB_DATABASE} +mysqldump --databases --single-transaction --quick --hex-blob --lock-tables=false > ${SCRIPT_DIR}/backup/mariadb-backup-$(date +%Y-%m-%d_%H-%M-%S).sql -u ${DB_USER} -p${DB_PASSWORD} ${DB_DATABASE} # Start Services -pm2 start gradido-backend \ No newline at end of file +pm2 start gradido-backend +pm2 start gradido-dht-node +pm2 start gradido-federation-1_0 \ No newline at end of file From afb8e872df49ac06734b419b82df3853d127ee02 Mon Sep 17 00:00:00 2001 From: Claus-Peter Huebner Date: Tue, 14 Nov 2023 23:22:36 +0100 Subject: [PATCH 02/22] add eof --- deployment/bare_metal/backup.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deployment/bare_metal/backup.sh b/deployment/bare_metal/backup.sh index 63788c6db..ea42ca832 100755 --- a/deployment/bare_metal/backup.sh +++ b/deployment/bare_metal/backup.sh @@ -29,4 +29,4 @@ mysqldump --databases --single-transaction --quick --hex-blob --lock-tables=fals # Start Services pm2 start gradido-backend pm2 start gradido-dht-node -pm2 start gradido-federation-1_0 \ No newline at end of file +pm2 start gradido-federation-1_0 From 4c97552dc5592e8c486973d2ff05616c211adec2 Mon Sep 17 00:00:00 2001 From: einhorn_b Date: Mon, 27 Nov 2023 11:38:03 +0100 Subject: [PATCH 03/22] move resubmission_date into Contribution --- backend/src/config/index.ts | 2 +- .../Contribution.ts | 107 ++++++++++++++++++ .../ContributionMessage.ts | 60 ++++++++++ database/entity/Contribution.ts | 2 +- database/entity/ContributionMessage.ts | 2 +- .../migrations/0078-move_resubmission_date.ts | 16 +++ dht-node/src/config/index.ts | 2 +- federation/src/config/index.ts | 2 +- 8 files changed, 188 insertions(+), 5 deletions(-) create mode 100644 database/entity/0078-move_resubmission_date/Contribution.ts create mode 100644 database/entity/0078-move_resubmission_date/ContributionMessage.ts create mode 100644 database/migrations/0078-move_resubmission_date.ts diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 4dc092c86..6f03d21b9 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -12,7 +12,7 @@ Decimal.set({ }) const constants = { - DB_VERSION: '0077-add_resubmission_date_contribution_message', + DB_VERSION: '0078-move_resubmission_date', DECAY_START_TIME: new Date('2021-05-13 17:46:31-0000'), // GMT+0 LOG4JS_CONFIG: 'log4js-config.json', // default log level on production should be info diff --git a/database/entity/0078-move_resubmission_date/Contribution.ts b/database/entity/0078-move_resubmission_date/Contribution.ts new file mode 100644 index 000000000..70e8960c8 --- /dev/null +++ b/database/entity/0078-move_resubmission_date/Contribution.ts @@ -0,0 +1,107 @@ +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', name: 'resubmission_at', default: null, nullable: true }) + resubmissionAt: Date | null + + @Column({ type: 'datetime', nullable: false, name: 'contribution_date' }) + contributionDate: Date + + @Column({ length: 255, nullable: false, collation: 'utf8mb4_unicode_ci' }) + memo: string + + @Column({ + type: 'decimal', + precision: 40, + scale: 20, + nullable: false, + transformer: DecimalTransformer, + }) + amount: Decimal + + @Column({ unsigned: true, nullable: true, name: 'moderator_id' }) + moderatorId: number + + @Column({ unsigned: true, nullable: true, name: 'contribution_link_id' }) + contributionLinkId: number + + @Column({ unsigned: true, nullable: true, name: 'confirmed_by' }) + confirmedBy: number + + @Column({ nullable: true, name: 'confirmed_at' }) + confirmedAt: Date + + @Column({ unsigned: true, nullable: true, name: 'denied_by' }) + deniedBy: number + + @Column({ nullable: true, name: 'denied_at' }) + deniedAt: Date + + @Column({ + name: 'contribution_type', + length: 12, + nullable: false, + collation: 'utf8mb4_unicode_ci', + }) + contributionType: string + + @Column({ + name: 'contribution_status', + length: 12, + nullable: false, + collation: 'utf8mb4_unicode_ci', + }) + contributionStatus: string + + @Column({ unsigned: true, nullable: true, name: 'transaction_id' }) + transactionId: number + + @Column({ nullable: true, name: 'updated_at' }) + updatedAt: Date + + @Column({ nullable: true, unsigned: true, name: 'updated_by', type: 'int' }) + updatedBy: number | null + + @DeleteDateColumn({ name: 'deleted_at' }) + deletedAt: Date | null + + @DeleteDateColumn({ unsigned: true, nullable: true, name: 'deleted_by' }) + deletedBy: number + + @OneToMany(() => ContributionMessage, (message) => message.contribution) + @JoinColumn({ name: 'contribution_id' }) + messages?: ContributionMessage[] + + @OneToOne(() => Transaction, (transaction) => transaction.contribution) + @JoinColumn({ name: 'transaction_id' }) + transaction?: Transaction | null +} diff --git a/database/entity/0078-move_resubmission_date/ContributionMessage.ts b/database/entity/0078-move_resubmission_date/ContributionMessage.ts new file mode 100644 index 000000000..93bcce4ed --- /dev/null +++ b/database/entity/0078-move_resubmission_date/ContributionMessage.ts @@ -0,0 +1,60 @@ +import { + BaseEntity, + Column, + CreateDateColumn, + DeleteDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm' +import { Contribution } from '../Contribution' +import { User } from '../User' + +@Entity('contribution_messages', { + engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci', +}) +export class ContributionMessage extends BaseEntity { + @PrimaryGeneratedColumn('increment', { unsigned: true }) + id: number + + @Index() + @Column({ name: 'contribution_id', unsigned: true, nullable: false }) + contributionId: number + + @ManyToOne(() => Contribution, (contribution) => contribution.messages) + @JoinColumn({ name: 'contribution_id' }) + contribution: Contribution + + @Column({ name: 'user_id', unsigned: true, nullable: false }) + userId: number + + @ManyToOne(() => User, (user) => user.messages) + @JoinColumn({ name: 'user_id' }) + user: User + + @Column({ length: 2000, nullable: false, collation: 'utf8mb4_unicode_ci' }) + message: string + + @CreateDateColumn() + @Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP', name: 'created_at' }) + createdAt: Date + + @UpdateDateColumn() + @Column({ type: 'datetime', default: null, nullable: true, name: 'updated_at' }) + updatedAt: Date + + @DeleteDateColumn({ name: 'deleted_at' }) + deletedAt: Date | null + + @Column({ name: 'deleted_by', default: null, unsigned: true, nullable: true }) + deletedBy: number + + @Column({ length: 12, nullable: false, collation: 'utf8mb4_unicode_ci' }) + type: string + + @Column({ name: 'is_moderator', type: 'bool', nullable: false, default: false }) + isModerator: boolean +} diff --git a/database/entity/Contribution.ts b/database/entity/Contribution.ts index 2a2e89cfa..82715c0c6 100644 --- a/database/entity/Contribution.ts +++ b/database/entity/Contribution.ts @@ -1 +1 @@ -export { Contribution } from './0076-add_updated_by_contribution/Contribution' +export { Contribution } from './0078-move_resubmission_date/Contribution' diff --git a/database/entity/ContributionMessage.ts b/database/entity/ContributionMessage.ts index d1512863b..b4b1180da 100644 --- a/database/entity/ContributionMessage.ts +++ b/database/entity/ContributionMessage.ts @@ -1 +1 @@ -export { ContributionMessage } from './0077-add_resubmission_date_contribution_message/ContributionMessage' +export { ContributionMessage } from './0078-move_resubmission_date/ContributionMessage' diff --git a/database/migrations/0078-move_resubmission_date.ts b/database/migrations/0078-move_resubmission_date.ts new file mode 100644 index 000000000..3cc49b240 --- /dev/null +++ b/database/migrations/0078-move_resubmission_date.ts @@ -0,0 +1,16 @@ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export async function upgrade(queryFn: (query: string, values?: any[]) => Promise>) { + await queryFn(`ALTER TABLE \`contribution_messages\` DROP COLUMN \`resubmission_at\`;`) + await queryFn( + `ALTER TABLE \`contributions\` ADD COLUMN \`resubmission_at\` datetime NULL DEFAULT NULL AFTER \`created_at \`;`, + ) +} + +export async function downgrade(queryFn: (query: string, values?: any[]) => Promise>) { + await queryFn( + `ALTER TABLE \`contribution_messages\` ADD COLUMN \`resubmission_at\` datetime NULL DEFAULT NULL AFTER \`deleted_by\`;`, + ) + await queryFn(`ALTER TABLE \`contributions\` DROP COLUMN \`resubmission_at\`;`) +} diff --git a/dht-node/src/config/index.ts b/dht-node/src/config/index.ts index 49660e856..2548166f4 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: '0077-add_resubmission_date_contribution_message', + DB_VERSION: '0078-move_resubmission_date', 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 a4acbd1cf..036ce67ee 100644 --- a/federation/src/config/index.ts +++ b/federation/src/config/index.ts @@ -10,7 +10,7 @@ Decimal.set({ }) const constants = { - DB_VERSION: '0077-add_resubmission_date_contribution_message', + DB_VERSION: '0078-move_resubmission_date', 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 af1df0fabe425943d8d0ddc5cbcefe1d7ab1a0a3 Mon Sep 17 00:00:00 2001 From: einhorn_b Date: Mon, 27 Nov 2023 18:34:37 +0100 Subject: [PATCH 04/22] refactor contributionMessage with dci --- .../src/data/ContributionMessage.builder.ts | 18 ++ .../arg/AdminUpdateContributionArgs.ts | 4 + .../graphql/arg/ContributionMessageArgs.ts | 6 - .../resolver/ContributionMessageResolver.ts | 196 +++++++++--------- .../graphql/resolver/ContributionResolver.ts | 9 +- .../AbstractUnconfirmedContribution.role.ts | 16 ++ .../UnconfirmedContributionAdmin.role.ts | 10 + ...nfirmedContributionAdminAddMessage.role.ts | 55 +++++ .../UnconfirmedContributionUser.role.ts | 6 + ...onfirmedContributionUserAddMessage.role.ts | 51 +++++ .../UpdateUnconfirmedContribution.context.ts | 48 +++-- .../migrations/0078-move_resubmission_date.ts | 4 +- 12 files changed, 294 insertions(+), 129 deletions(-) create mode 100644 backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionAdminAddMessage.role.ts create mode 100644 backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionUserAddMessage.role.ts diff --git a/backend/src/data/ContributionMessage.builder.ts b/backend/src/data/ContributionMessage.builder.ts index 0f5ade7ff..02ac28a69 100644 --- a/backend/src/data/ContributionMessage.builder.ts +++ b/backend/src/data/ContributionMessage.builder.ts @@ -63,6 +63,24 @@ export class ContributionMessageBuilder { return this } + /** + * set contribution message type dialog and copy message + * @param contribution + * @param message + * @returns + */ + public setDialogType(message: string): this { + this.contributionMessage.message = message + this.contributionMessage.type = ContributionMessageType.DIALOG + return this + } + + public setMessageAndType(message: string, type: ContributionMessageType): this { + this.contributionMessage.message = message + this.contributionMessage.type = type + return this + } + public setUser(user: User): this { this.contributionMessage.user = user this.contributionMessage.userId = user.id diff --git a/backend/src/graphql/arg/AdminUpdateContributionArgs.ts b/backend/src/graphql/arg/AdminUpdateContributionArgs.ts index 5fb2228f1..fdac962b1 100644 --- a/backend/src/graphql/arg/AdminUpdateContributionArgs.ts +++ b/backend/src/graphql/arg/AdminUpdateContributionArgs.ts @@ -24,4 +24,8 @@ export class AdminUpdateContributionArgs { @Field(() => String, { nullable: true }) @isValidDateString() creationDate?: string | null + + @Field(() => String, { nullable: true }) + @isValidDateString() + resubmissionAt?: string | null } diff --git a/backend/src/graphql/arg/ContributionMessageArgs.ts b/backend/src/graphql/arg/ContributionMessageArgs.ts index e20686187..847cb5b33 100644 --- a/backend/src/graphql/arg/ContributionMessageArgs.ts +++ b/backend/src/graphql/arg/ContributionMessageArgs.ts @@ -3,8 +3,6 @@ import { ArgsType, Field, Int, InputType } from 'type-graphql' import { ContributionMessageType } from '@enum/ContributionMessageType' -import { isValidDateString } from '@/graphql/validator/DateString' - @InputType() @ArgsType() export class ContributionMessageArgs { @@ -19,8 +17,4 @@ export class ContributionMessageArgs { @Field(() => ContributionMessageType, { defaultValue: ContributionMessageType.DIALOG }) @IsEnum(ContributionMessageType) messageType: ContributionMessageType - - @Field(() => String, { nullable: true }) - @isValidDateString() - resubmissionAt?: string | null } diff --git a/backend/src/graphql/resolver/ContributionMessageResolver.ts b/backend/src/graphql/resolver/ContributionMessageResolver.ts index 502d2821d..1a0e5756f 100644 --- a/backend/src/graphql/resolver/ContributionMessageResolver.ts +++ b/backend/src/graphql/resolver/ContributionMessageResolver.ts @@ -1,15 +1,13 @@ /* eslint-disable @typescript-eslint/restrict-template-expressions */ -import { getConnection } from '@dbTools/typeorm' -import { Contribution as DbContribution } from '@entity/Contribution' +import { EntityManager, FindOptionsRelations, getConnection } from '@dbTools/typeorm' +import { Contribution, Contribution as DbContribution } from '@entity/Contribution' import { ContributionMessage as DbContributionMessage } from '@entity/ContributionMessage' import { User as DbUser } from '@entity/User' -import { UserContact as DbUserContact } from '@entity/UserContact' import { Arg, Args, Authorized, Ctx, Int, Mutation, Query, Resolver } from 'type-graphql' import { ContributionMessageArgs } from '@arg/ContributionMessageArgs' import { Paginated } from '@arg/Paginated' import { ContributionMessageType } from '@enum/ContributionMessageType' -import { ContributionStatus } from '@enum/ContributionStatus' import { Order } from '@enum/Order' import { ContributionMessage, ContributionMessageListResult } from '@model/ContributionMessage' @@ -19,6 +17,7 @@ import { EVENT_ADMIN_CONTRIBUTION_MESSAGE_CREATE, EVENT_CONTRIBUTION_MESSAGE_CREATE, } from '@/event/Events' +import { UpdateUnconfirmedContributionContext } from '@/interactions/updateUnconfirmedContribution/UpdateUnconfirmedContribution.context' import { Context, getUser } from '@/server/context' import { LogError } from '@/server/LogError' @@ -29,52 +28,52 @@ export class ContributionMessageResolver { @Authorized([RIGHTS.CREATE_CONTRIBUTION_MESSAGE]) @Mutation(() => ContributionMessage) async createContributionMessage( - @Args() { contributionId, message }: ContributionMessageArgs, + @Args() contributionMessageArgs: ContributionMessageArgs, @Ctx() context: Context, ): Promise { - const user = getUser(context) - const queryRunner = getConnection().createQueryRunner() - await queryRunner.connect() - await queryRunner.startTransaction('REPEATABLE READ') - const contributionMessage = DbContributionMessage.create() + const { contributionId } = contributionMessageArgs + const updateUnconfirmedContributionContext = new UpdateUnconfirmedContributionContext( + contributionId, + contributionMessageArgs, + context, + ) + let finalContribution: DbContribution | undefined + let finalContributionMessage: DbContributionMessage | undefined + try { - const contribution = await DbContribution.findOne({ where: { id: contributionId } }) - if (!contribution) { - throw new LogError('Contribution not found', contributionId) - } - if (contribution.userId !== user.id) { - throw new LogError( - 'Can not send message to contribution of another user', - contribution.userId, - user.id, - ) - } + await getConnection().transaction( + 'REPEATABLE READ', + async (transactionalEntityManager: EntityManager) => { + const { contribution, contributionMessage, contributionChanged } = + await updateUnconfirmedContributionContext.run(transactionalEntityManager) - contributionMessage.contributionId = contributionId - contributionMessage.createdAt = new Date() - contributionMessage.message = message - contributionMessage.userId = user.id - contributionMessage.type = ContributionMessageType.DIALOG - contributionMessage.isModerator = false - await queryRunner.manager.insert(DbContributionMessage, contributionMessage) + if (contributionChanged) { + await transactionalEntityManager.update( + Contribution, + { id: contributionId }, + contribution, + ) + } + await transactionalEntityManager.insert(ContributionMessage, contributionMessage) - if (contribution.contributionStatus === ContributionStatus.IN_PROGRESS) { - contribution.contributionStatus = ContributionStatus.PENDING - await queryRunner.manager.update(DbContribution, { id: contributionId }, contribution) - } - await queryRunner.commitTransaction() - await EVENT_CONTRIBUTION_MESSAGE_CREATE( - user, - { id: contributionMessage.contributionId } as DbContribution, - contributionMessage, + finalContribution = contribution + finalContributionMessage = contributionMessage + }, ) } catch (e) { - await queryRunner.rollbackTransaction() throw new LogError(`ContributionMessage was not sent successfully: ${e}`, e) - } finally { - await queryRunner.release() } - return new ContributionMessage(contributionMessage, user) + if (!finalContribution || !finalContributionMessage) { + throw new LogError('ContributionMessage was not sent successfully') + } + const user = getUser(context) + + await EVENT_CONTRIBUTION_MESSAGE_CREATE( + user, + { id: contributionId } as DbContribution, + finalContributionMessage, + ) + return new ContributionMessage(finalContributionMessage, user) } @Authorized([RIGHTS.LIST_ALL_CONTRIBUTION_MESSAGES]) @@ -125,77 +124,68 @@ export class ContributionMessageResolver { @Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTION_MESSAGE]) @Mutation(() => ContributionMessage) async adminCreateContributionMessage( - @Args() { contributionId, message, messageType, resubmissionAt }: ContributionMessageArgs, + @Args() contributionMessageArgs: ContributionMessageArgs, @Ctx() context: Context, ): Promise { - const moderator = getUser(context) + const { contributionId, messageType } = contributionMessageArgs + const updateUnconfirmedContributionContext = new UpdateUnconfirmedContributionContext( + contributionId, + contributionMessageArgs, + context, + ) + const relations: FindOptionsRelations = + messageType === ContributionMessageType.DIALOG + ? { user: { emailContact: true } } + : { user: true } + let finalContribution: DbContribution | undefined + let finalContributionMessage: DbContributionMessage | undefined - const queryRunner = getConnection().createQueryRunner() - await queryRunner.connect() - await queryRunner.startTransaction('REPEATABLE READ') - const contributionMessage = DbContributionMessage.create() try { - const contribution = await DbContribution.findOne({ - where: { id: contributionId }, - relations: ['user'], - }) - if (!contribution) { - throw new LogError('Contribution not found', contributionId) - } - if (contribution.userId === moderator.id) { - throw new LogError('Admin can not answer on his own contribution', contributionId) - } - if (!contribution.user.emailContact && contribution.user.emailId) { - contribution.user.emailContact = await DbUserContact.findOneOrFail({ - where: { id: contribution.user.emailId }, - }) - } - contributionMessage.contributionId = contributionId - contributionMessage.createdAt = new Date() - contributionMessage.message = message - contributionMessage.userId = moderator.id - contributionMessage.type = messageType - contributionMessage.isModerator = true - if (resubmissionAt) { - contributionMessage.resubmissionAt = new Date(resubmissionAt) - } - await queryRunner.manager.insert(DbContributionMessage, contributionMessage) + await getConnection().transaction( + 'REPEATABLE READ', + async (transactionalEntityManager: EntityManager) => { + const { contribution, contributionMessage, contributionChanged } = + await updateUnconfirmedContributionContext.run(transactionalEntityManager, relations) - if (messageType !== ContributionMessageType.MODERATOR) { - // change status (does not apply to moderator messages) - if ( - contribution.contributionStatus === ContributionStatus.DELETED || - contribution.contributionStatus === ContributionStatus.DENIED || - contribution.contributionStatus === ContributionStatus.PENDING - ) { - contribution.contributionStatus = ContributionStatus.IN_PROGRESS - await queryRunner.manager.update(DbContribution, { id: contributionId }, contribution) - } + if (contributionChanged) { + await transactionalEntityManager.update( + Contribution, + { id: contributionId }, + contribution, + ) + } + await transactionalEntityManager.insert(ContributionMessage, contributionMessage) - // 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, - }) - } - await queryRunner.commitTransaction() - await EVENT_ADMIN_CONTRIBUTION_MESSAGE_CREATE( - { id: contribution.userId } as DbUser, - moderator, - contribution, - contributionMessage, + finalContribution = contribution + finalContributionMessage = contributionMessage + }, ) } catch (e) { - await queryRunner.rollbackTransaction() throw new LogError(`ContributionMessage was not sent successfully: ${e}`, e) - } finally { - await queryRunner.release() } - return new ContributionMessage(contributionMessage, moderator) + if (!finalContribution || !finalContributionMessage) { + throw new LogError('ContributionMessage was not sent successfully') + } + const moderator = getUser(context) + if (messageType === ContributionMessageType.DIALOG) { + // send email (never for moderator messages) + void sendAddedContributionMessageEmail({ + firstName: finalContribution.user.firstName, + lastName: finalContribution.user.lastName, + email: finalContribution.user.emailContact.email, + language: finalContribution.user.language, + senderFirstName: moderator.firstName, + senderLastName: moderator.lastName, + contributionMemo: finalContribution.memo, + }) + } + + await EVENT_ADMIN_CONTRIBUTION_MESSAGE_CREATE( + { id: finalContribution.userId } as DbUser, + moderator, + finalContribution, + finalContributionMessage, + ) + return new ContributionMessage(finalContributionMessage, moderator) } } diff --git a/backend/src/graphql/resolver/ContributionResolver.ts b/backend/src/graphql/resolver/ContributionResolver.ts index 638cbbdb3..d181b7849 100644 --- a/backend/src/graphql/resolver/ContributionResolver.ts +++ b/backend/src/graphql/resolver/ContributionResolver.ts @@ -269,10 +269,6 @@ 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 @@ -286,6 +282,11 @@ export class ContributionResolver { contribution.amount, ) if (createdByUserChangedByModerator && adminUpdateContributionArgs.memo) { + const user = await DbUser.findOneOrFail({ + where: { id: contribution.userId }, + relations: ['emailContact'], + }) + void sendContributionChangedByModeratorEmail({ firstName: user.firstName, lastName: user.lastName, diff --git a/backend/src/interactions/updateUnconfirmedContribution/AbstractUnconfirmedContribution.role.ts b/backend/src/interactions/updateUnconfirmedContribution/AbstractUnconfirmedContribution.role.ts index 473d30605..8c850785a 100644 --- a/backend/src/interactions/updateUnconfirmedContribution/AbstractUnconfirmedContribution.role.ts +++ b/backend/src/interactions/updateUnconfirmedContribution/AbstractUnconfirmedContribution.role.ts @@ -4,11 +4,14 @@ import { Decimal } from 'decimal.js-light' import { Role } from '@/auth/Role' import { ContributionLogic } from '@/data/Contribution.logic' +import { ContributionMessageBuilder } from '@/data/ContributionMessage.builder' +import { ContributionStatus } from '@/graphql/enum/ContributionStatus' import { Context, getClientTimezoneOffset } from '@/server/context' import { LogError } from '@/server/LogError' export abstract class AbstractUnconfirmedContributionRole { private availableCreationSums?: Decimal[] + protected changed = true public constructor( protected self: Contribution, @@ -20,6 +23,10 @@ export abstract class AbstractUnconfirmedContributionRole { } } + public isChanged(): boolean { + return this.changed + } + // steps which return void throw on each error // first, check if it can be updated protected abstract checkAuthorization(user: User, role: Role): void @@ -30,6 +37,10 @@ export abstract class AbstractUnconfirmedContributionRole { throw new LogError('Month of contribution can not be changed') } + if (this.self.contributionStatus === ContributionStatus.CONFIRMED) { + throw new LogError('the contribution is already confirmed, cannot be changed anymore') + } + const contributionLogic = new ContributionLogic(this.self) this.availableCreationSums = await contributionLogic.getAvailableCreationSums( clientTimezoneOffset, @@ -55,6 +66,11 @@ export abstract class AbstractUnconfirmedContributionRole { this.update() } + public createContributionMessage(): ContributionMessageBuilder { + const contributionMessageBuilder = new ContributionMessageBuilder() + return contributionMessageBuilder.setParentContribution(this.self).setHistoryType(this.self) + } + public getAvailableCreationSums(): Decimal[] { if (!this.availableCreationSums) { throw new LogError('availableCreationSums is empty, please call validate before!') diff --git a/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionAdmin.role.ts b/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionAdmin.role.ts index 72053d3ab..6e9bed147 100644 --- a/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionAdmin.role.ts +++ b/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionAdmin.role.ts @@ -3,6 +3,7 @@ import { User } from '@entity/User' import { RIGHTS } from '@/auth/RIGHTS' import { Role } from '@/auth/Role' +import { ContributionMessageBuilder } from '@/data/ContributionMessage.builder' import { AdminUpdateContributionArgs } from '@/graphql/arg/AdminUpdateContributionArgs' import { ContributionStatus } from '@/graphql/enum/ContributionStatus' import { LogError } from '@/server/LogError' @@ -29,6 +30,11 @@ export class UnconfirmedContributionAdminRole extends AbstractUnconfirmedContrib this.self.contributionStatus = ContributionStatus.PENDING this.self.updatedAt = new Date() this.self.updatedBy = this.moderator.id + if (this.updateData.resubmissionAt) { + this.self.resubmissionAt = new Date(this.updateData.resubmissionAt) + } else { + this.self.resubmissionAt = null + } } // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -53,4 +59,8 @@ export class UnconfirmedContributionAdminRole extends AbstractUnconfirmedContrib throw new LogError("the contribution wasn't changed at all") } } + + public createContributionMessage(): ContributionMessageBuilder { + return super.createContributionMessage().setIsModerator(true) + } } diff --git a/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionAdminAddMessage.role.ts b/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionAdminAddMessage.role.ts new file mode 100644 index 000000000..90853137e --- /dev/null +++ b/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionAdminAddMessage.role.ts @@ -0,0 +1,55 @@ +import { Contribution } from '@entity/Contribution' +import { User } from '@entity/User' + +import { ContributionMessageBuilder } from '@/data/ContributionMessage.builder' +import { ContributionMessageArgs } from '@/graphql/arg/ContributionMessageArgs' +import { ContributionMessageType } from '@/graphql/enum/ContributionMessageType' +import { ContributionStatus } from '@/graphql/enum/ContributionStatus' +import { LogError } from '@/server/LogError' + +import { AbstractUnconfirmedContributionRole } from './AbstractUnconfirmedContribution.role' + +export class UnconfirmedContributionAdminAddMessageRole extends AbstractUnconfirmedContributionRole { + public constructor(contribution: Contribution, private updateData: ContributionMessageArgs) { + super(contribution, contribution.amount, contribution.contributionDate) + } + + protected update(): void { + let newStatus = this.self.contributionStatus + // change status (does not apply to moderator messages) + if (this.updateData.messageType !== ContributionMessageType.MODERATOR) { + newStatus = ContributionStatus.IN_PROGRESS + } + if (this.self.contributionStatus !== newStatus || this.self.resubmissionAt != null) { + this.self.contributionStatus = newStatus + this.self.resubmissionAt = null + } else { + this.changed = false + } + } + + protected checkAuthorization(user: User): AbstractUnconfirmedContributionRole { + // TODO: think if there are cases in which admin comment his own contribution + if ( + this.self.userId === user.id && + this.updateData.messageType === ContributionMessageType.MODERATOR + ) { + throw new LogError( + 'Moderator|Admin can not make a moderator comment on his own contribution', + this.self.id, + ) + } + return this + } + + protected async validate(clientTimezoneOffset: number): Promise { + await super.validate(clientTimezoneOffset) + } + + public createContributionMessage(): ContributionMessageBuilder { + return super + .createContributionMessage() + .setIsModerator(true) + .setMessageAndType(this.updateData.message, this.updateData.messageType) + } +} diff --git a/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionUser.role.ts b/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionUser.role.ts index 944204802..4b3438035 100644 --- a/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionUser.role.ts +++ b/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionUser.role.ts @@ -1,6 +1,7 @@ import { Contribution } from '@entity/Contribution' import { User } from '@entity/User' +import { ContributionMessageBuilder } from '@/data/ContributionMessage.builder' import { ContributionArgs } from '@/graphql/arg/ContributionArgs' import { ContributionStatus } from '@/graphql/enum/ContributionStatus' import { LogError } from '@/server/LogError' @@ -20,6 +21,7 @@ export class UnconfirmedContributionUserRole extends AbstractUnconfirmedContribu this.self.updatedAt = new Date() // null because updated by user them self this.self.updatedBy = null + this.self.resubmissionAt = null } protected checkAuthorization(user: User): AbstractUnconfirmedContributionRole { @@ -55,4 +57,8 @@ export class UnconfirmedContributionUserRole extends AbstractUnconfirmedContribu throw new LogError("the contribution wasn't changed at all") } } + + public createContributionMessage(): ContributionMessageBuilder { + return super.createContributionMessage().setIsModerator(false) + } } diff --git a/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionUserAddMessage.role.ts b/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionUserAddMessage.role.ts new file mode 100644 index 000000000..9e39769f3 --- /dev/null +++ b/backend/src/interactions/updateUnconfirmedContribution/UnconfirmedContributionUserAddMessage.role.ts @@ -0,0 +1,51 @@ +import { Contribution } from '@entity/Contribution' +import { User } from '@entity/User' + +import { ContributionMessageBuilder } from '@/data/ContributionMessage.builder' +import { ContributionMessageArgs } from '@/graphql/arg/ContributionMessageArgs' +import { ContributionStatus } from '@/graphql/enum/ContributionStatus' +import { LogError } from '@/server/LogError' + +import { AbstractUnconfirmedContributionRole } from './AbstractUnconfirmedContribution.role' + +export class UnconfirmedContributionUserAddMessageRole extends AbstractUnconfirmedContributionRole { + public constructor(contribution: Contribution, private updateData: ContributionMessageArgs) { + super(contribution, contribution.amount, contribution.contributionDate) + } + + protected update(): void { + if ( + this.self.contributionStatus === ContributionStatus.IN_PROGRESS || + this.self.resubmissionAt !== null + ) { + this.self.contributionStatus = ContributionStatus.PENDING + this.self.resubmissionAt = null + } else { + this.changed = false + } + } + + protected checkAuthorization(user: User): AbstractUnconfirmedContributionRole { + if (this.self.userId !== user.id) { + throw new LogError('Can not update contribution of another user', this.self, user.id) + } + // only admins and moderators can update it when status is other than progress or pending + if ( + this.self.contributionStatus !== ContributionStatus.IN_PROGRESS && + this.self.contributionStatus !== ContributionStatus.PENDING + ) { + throw new LogError( + 'Contribution can not be updated due to status', + this.self.contributionStatus, + ) + } + return this + } + + public createContributionMessage(): ContributionMessageBuilder { + return super + .createContributionMessage() + .setIsModerator(false) + .setDialogType(this.updateData.message) + } +} diff --git a/backend/src/interactions/updateUnconfirmedContribution/UpdateUnconfirmedContribution.context.ts b/backend/src/interactions/updateUnconfirmedContribution/UpdateUnconfirmedContribution.context.ts index 1836c2923..04da9166f 100644 --- a/backend/src/interactions/updateUnconfirmedContribution/UpdateUnconfirmedContribution.context.ts +++ b/backend/src/interactions/updateUnconfirmedContribution/UpdateUnconfirmedContribution.context.ts @@ -1,3 +1,4 @@ +import { EntityManager, FindOneOptions, FindOptionsRelations } from '@dbTools/typeorm' import { Contribution } from '@entity/Contribution' import { ContributionMessage } from '@entity/ContributionMessage' import { Decimal } from 'decimal.js-light' @@ -5,13 +6,15 @@ import { Decimal } from 'decimal.js-light' import { AdminUpdateContributionArgs } from '@arg/AdminUpdateContributionArgs' import { ContributionArgs } from '@arg/ContributionArgs' -import { ContributionMessageBuilder } from '@/data/ContributionMessage.builder' +import { ContributionMessageArgs } from '@/graphql/arg/ContributionMessageArgs' import { Context } from '@/server/context' import { LogError } from '@/server/LogError' import { AbstractUnconfirmedContributionRole } from './AbstractUnconfirmedContribution.role' import { UnconfirmedContributionAdminRole } from './UnconfirmedContributionAdmin.role' +import { UnconfirmedContributionAdminAddMessageRole } from './UnconfirmedContributionAdminAddMessage.role' import { UnconfirmedContributionUserRole } from './UnconfirmedContributionUser.role' +import { UnconfirmedContributionUserAddMessageRole } from './UnconfirmedContributionUserAddMessage.role' export class UpdateUnconfirmedContributionContext { private oldMemoText: string @@ -23,7 +26,7 @@ export class UpdateUnconfirmedContributionContext { */ public constructor( private id: number, - private input: ContributionArgs | AdminUpdateContributionArgs, + private input: ContributionArgs | AdminUpdateContributionArgs | ContributionMessageArgs, private context: Context, ) { if (!context.role || !context.user) { @@ -31,28 +34,31 @@ export class UpdateUnconfirmedContributionContext { } } - public async run(): Promise<{ + public async run( + transactionEntityManager?: EntityManager, + relations?: FindOptionsRelations, + ): Promise<{ contribution: Contribution contributionMessage: ContributionMessage availableCreationSums: Decimal[] createdByUserChangedByModerator: boolean + contributionChanged: boolean }> { let createdByUserChangedByModerator = false if (!this.context.role || !this.context.user) { throw new LogError("context didn't contain role or user") } - const contributionToUpdate = await Contribution.findOne({ - where: { id: this.id }, - }) + const options: FindOneOptions = { where: { id: this.id }, relations } + let contributionToUpdate: Contribution | null + if (transactionEntityManager) { + contributionToUpdate = await transactionEntityManager.findOne(Contribution, options) + } else { + contributionToUpdate = await Contribution.findOne(options) + } if (!contributionToUpdate) { throw new LogError('Contribution not found', this.id) } this.oldMemoText = contributionToUpdate.memo - const contributionMessageBuilder = new ContributionMessageBuilder() - contributionMessageBuilder - .setParentContribution(contributionToUpdate) - .setHistoryType(contributionToUpdate) - .setUser(this.context.user) // choose correct role let unconfirmedContributionRole: AbstractUnconfirmedContributionRole | null = null @@ -61,7 +67,6 @@ export class UpdateUnconfirmedContributionContext { contributionToUpdate, this.input, ) - contributionMessageBuilder.setIsModerator(false) } else if (this.input instanceof AdminUpdateContributionArgs) { unconfirmedContributionRole = new UnconfirmedContributionAdminRole( contributionToUpdate, @@ -71,7 +76,18 @@ export class UpdateUnconfirmedContributionContext { if (unconfirmedContributionRole.isCreatedFromUser()) { createdByUserChangedByModerator = true } - contributionMessageBuilder.setIsModerator(true) + } else if (this.input instanceof ContributionMessageArgs) { + if (contributionToUpdate.userId !== this.context.user.id) { + unconfirmedContributionRole = new UnconfirmedContributionAdminAddMessageRole( + contributionToUpdate, + this.input, + ) + } else { + unconfirmedContributionRole = new UnconfirmedContributionUserAddMessageRole( + contributionToUpdate, + this.input, + ) + } } if (!unconfirmedContributionRole) { throw new LogError("don't recognize input type, maybe not implemented yet?") @@ -82,9 +98,13 @@ export class UpdateUnconfirmedContributionContext { return { contribution: contributionToUpdate, - contributionMessage: contributionMessageBuilder.build(), + contributionMessage: unconfirmedContributionRole + .createContributionMessage() + .setUser(this.context.user) + .build(), availableCreationSums: unconfirmedContributionRole.getAvailableCreationSums(), createdByUserChangedByModerator, + contributionChanged: unconfirmedContributionRole.isChanged(), } } diff --git a/database/migrations/0078-move_resubmission_date.ts b/database/migrations/0078-move_resubmission_date.ts index 3cc49b240..07a226540 100644 --- a/database/migrations/0078-move_resubmission_date.ts +++ b/database/migrations/0078-move_resubmission_date.ts @@ -2,10 +2,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ export async function upgrade(queryFn: (query: string, values?: any[]) => Promise>) { - await queryFn(`ALTER TABLE \`contribution_messages\` DROP COLUMN \`resubmission_at\`;`) await queryFn( - `ALTER TABLE \`contributions\` ADD COLUMN \`resubmission_at\` datetime NULL DEFAULT NULL AFTER \`created_at \`;`, + `ALTER TABLE \`contributions\` ADD COLUMN \`resubmission_at\` datetime NULL DEFAULT NULL AFTER \`created_at\`;`, ) + await queryFn(`ALTER TABLE \`contribution_messages\` DROP COLUMN \`resubmission_at\`;`) } export async function downgrade(queryFn: (query: string, values?: any[]) => Promise>) { From 993d364eccba9ad0c6151398a1217657c3c21792 Mon Sep 17 00:00:00 2001 From: einhorn_b Date: Mon, 27 Nov 2023 18:59:08 +0100 Subject: [PATCH 05/22] update mysql query --- .../resolver/util/findContributions.ts | 31 +++++-------------- 1 file changed, 7 insertions(+), 24 deletions(-) diff --git a/backend/src/graphql/resolver/util/findContributions.ts b/backend/src/graphql/resolver/util/findContributions.ts index 9d26d212c..53935ef06 100644 --- a/backend/src/graphql/resolver/util/findContributions.ts +++ b/backend/src/graphql/resolver/util/findContributions.ts @@ -1,5 +1,5 @@ /* eslint-disable security/detect-object-injection */ -import { Brackets, In, Like, Not, SelectQueryBuilder } from '@dbTools/typeorm' +import { Brackets, In, IsNull, LessThanOrEqual, Like, Not, SelectQueryBuilder } from '@dbTools/typeorm' import { Contribution as DbContribution } from '@entity/Contribution' import { ContributionMessage } from '@entity/ContributionMessage' @@ -47,29 +47,12 @@ export const findContributions = async ( ...(filter.noHashtag && { memo: Not(Like(`%#%`)) }), }) if (filter.hideResubmission) { - queryBuilder - .leftJoinAndSelect( - (qb: SelectQueryBuilder) => { - return qb - .select('resubmission_at', 'resubmissionAt') - .addSelect('id', 'latestMessageId') - .addSelect('contribution_id', 'latestMessageContributionId') - .addSelect( - 'ROW_NUMBER() OVER (PARTITION BY latestMessageContributionId ORDER BY created_at DESC)', - 'rn', - ) - .from(ContributionMessage, 'contributionMessage') - }, - 'latestContributionMessage', - 'latestContributionMessage.latestMessageContributionId = Contribution.id AND latestContributionMessage.rn = 1', - ) - .andWhere( - new Brackets((qb) => { - qb.where('latestContributionMessage.resubmissionAt IS NULL').orWhere( - 'latestContributionMessage.resubmissionAt <= NOW()', - ) - }), - ) + const now = new Date() + queryBuilder.andWhere( + new Brackets((qb) => { + qb.where({ resubmissionAt: IsNull() }).orWhere({ resubmissionAt: LessThanOrEqual(now) }) + }), + ) } queryBuilder.printSql() if (filter.query) { From a36f8962e6b04ca020970def7f1adeba758ca407 Mon Sep 17 00:00:00 2001 From: einhorn_b Date: Tue, 28 Nov 2023 21:08:27 +0100 Subject: [PATCH 06/22] test and fix, make code more compact --- .../ContributionMessagesFormular.vue | 206 ++++++++++-------- .../ContributionMessagesList.vue | 5 + .../components/Tables/OpenCreationsTable.vue | 5 + admin/src/graphql/adminListContributions.js | 1 + admin/src/graphql/adminUpdateContribution.js | 16 +- admin/src/graphql/getContribution.js | 1 + admin/src/locales/de.json | 9 +- admin/src/locales/en.json | 9 +- .../graphql/arg/ContributionMessageArgs.ts | 6 + backend/src/graphql/model/Contribution.ts | 4 + .../resolver/ContributionMessageResolver.ts | 9 +- .../graphql/resolver/ContributionResolver.ts | 17 +- .../AbstractUnconfirmedContribution.role.ts | 15 +- .../UnconfirmedContributionAdmin.role.ts | 52 ++++- ...nfirmedContributionAdminAddMessage.role.ts | 22 +- .../UnconfirmedContributionUser.role.ts | 7 +- ...onfirmedContributionUserAddMessage.role.ts | 10 +- .../UpdateUnconfirmedContribution.context.ts | 15 +- 18 files changed, 267 insertions(+), 142 deletions(-) diff --git a/admin/src/components/ContributionMessages/ContributionMessagesFormular.vue b/admin/src/components/ContributionMessages/ContributionMessagesFormular.vue index 9b27f34a8..b3687a38d 100644 --- a/admin/src/components/ContributionMessages/ContributionMessagesFormular.vue +++ b/admin/src/components/ContributionMessages/ContributionMessagesFormular.vue @@ -1,18 +1,24 @@