diff --git a/backend/src/auth/RIGHTS.ts b/backend/src/auth/RIGHTS.ts index fc2b5342c..c10fc96de 100644 --- a/backend/src/auth/RIGHTS.ts +++ b/backend/src/auth/RIGHTS.ts @@ -25,6 +25,7 @@ export enum RIGHTS { REDEEM_TRANSACTION_LINK = 'REDEEM_TRANSACTION_LINK', LIST_TRANSACTION_LINKS = 'LIST_TRANSACTION_LINKS', GDT_BALANCE = 'GDT_BALANCE', + CREATE_CONTRIBUTION = 'CREATE_CONTRIBUTION', // Admin SEARCH_USERS = 'SEARCH_USERS', SET_USER_ROLE = 'SET_USER_ROLE', diff --git a/backend/src/auth/ROLES.ts b/backend/src/auth/ROLES.ts index 891fe1844..2d9ac2deb 100644 --- a/backend/src/auth/ROLES.ts +++ b/backend/src/auth/ROLES.ts @@ -23,6 +23,7 @@ export const ROLE_USER = new Role('user', [ RIGHTS.REDEEM_TRANSACTION_LINK, RIGHTS.LIST_TRANSACTION_LINKS, RIGHTS.GDT_BALANCE, + RIGHTS.CREATE_CONTRIBUTION, ]) export const ROLE_ADMIN = new Role('admin', Object.values(RIGHTS)) // all rights diff --git a/backend/src/graphql/arg/ContributionArgs.ts b/backend/src/graphql/arg/ContributionArgs.ts new file mode 100644 index 000000000..2fa1c5ced --- /dev/null +++ b/backend/src/graphql/arg/ContributionArgs.ts @@ -0,0 +1,15 @@ +import { ArgsType, Field, InputType } from 'type-graphql' +import Decimal from 'decimal.js-light' + +@InputType() +@ArgsType() +export default class ContributionArgs { + @Field(() => Decimal) + amount: Decimal + + @Field(() => String) + memo: string + + @Field(() => String) + creationDate: string +} diff --git a/backend/src/graphql/model/UnconfirmedContribution.ts b/backend/src/graphql/model/UnconfirmedContribution.ts index 69001c19b..1d697a971 100644 --- a/backend/src/graphql/model/UnconfirmedContribution.ts +++ b/backend/src/graphql/model/UnconfirmedContribution.ts @@ -1,8 +1,22 @@ import { ObjectType, Field, Int } from 'type-graphql' import Decimal from 'decimal.js-light' +import { Contribution } from '@entity/Contribution' +import { User } from '@entity/User' @ObjectType() export class UnconfirmedContribution { + constructor(contribution: Contribution, user: User, creations: Decimal[]) { + this.id = contribution.id + this.userId = contribution.userId + this.amount = contribution.amount + this.memo = contribution.memo + this.date = contribution.contributionDate + this.firstName = user ? user.firstName : '' + this.lastName = user ? user.lastName : '' + this.email = user ? user.email : '' + this.creation = creations + } + @Field(() => String) firstName: string @@ -27,8 +41,8 @@ export class UnconfirmedContribution { @Field(() => Decimal) amount: Decimal - @Field(() => Number) - moderator: number + @Field(() => Number, { nullable: true }) + moderator: number | null @Field(() => [Decimal]) creation: Decimal[] diff --git a/backend/src/graphql/resolver/AdminResolver.ts b/backend/src/graphql/resolver/AdminResolver.ts index 300a482cb..75175edc2 100644 --- a/backend/src/graphql/resolver/AdminResolver.ts +++ b/backend/src/graphql/resolver/AdminResolver.ts @@ -46,15 +46,23 @@ import { checkOptInCode, activationLink, printTimeDuration } from './UserResolve import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail' import { transactionLinkCode as contributionLinkCode } from './TransactionLinkResolver' import CONFIG from '@/config' +import { + getCreationIndex, + getUserCreation, + getUserCreations, + validateContribution, + isStartEndDateValid, +} from './util/creations' +import { + CONTRIBUTIONLINK_MEMO_MAX_CHARS, + CONTRIBUTIONLINK_MEMO_MIN_CHARS, + CONTRIBUTIONLINK_NAME_MAX_CHARS, + CONTRIBUTIONLINK_NAME_MIN_CHARS, + FULL_CREATION_AVAILABLE, +} from './const/const' // const EMAIL_OPT_IN_REGISTER = 1 // const EMAIL_OPT_UNKNOWN = 3 // elopage? -const MAX_CREATION_AMOUNT = new Decimal(1000) -const FULL_CREATION_AVAILABLE = [MAX_CREATION_AMOUNT, MAX_CREATION_AMOUNT, MAX_CREATION_AMOUNT] -const CONTRIBUTIONLINK_NAME_MAX_CHARS = 100 -const CONTRIBUTIONLINK_NAME_MIN_CHARS = 5 -const CONTRIBUTIONLINK_MEMO_MAX_CHARS = 255 -const CONTRIBUTIONLINK_MEMO_MIN_CHARS = 5 @Resolver() export class AdminResolver { @@ -244,18 +252,17 @@ export class AdminResolver { const creations = await getUserCreation(user.id) logger.trace('creations', creations) const creationDateObj = new Date(creationDate) - if (isContributionValid(creations, amount, creationDateObj)) { - const contribution = Contribution.create() - contribution.userId = user.id - contribution.amount = amount - contribution.createdAt = new Date() - contribution.contributionDate = creationDateObj - contribution.memo = memo - contribution.moderatorId = moderator.id + validateContribution(creations, amount, creationDateObj) + const contribution = Contribution.create() + contribution.userId = user.id + contribution.amount = amount + contribution.createdAt = new Date() + contribution.contributionDate = creationDateObj + contribution.memo = memo + contribution.moderatorId = moderator.id - logger.trace('contribution to save', contribution) - await Contribution.save(contribution) - } + logger.trace('contribution to save', contribution) + await Contribution.save(contribution) return getUserCreation(user.id) } @@ -321,7 +328,7 @@ export class AdminResolver { } // all possible cases not to be true are thrown in this function - isContributionValid(creations, amount, creationDateObj) + validateContribution(creations, amount, creationDateObj) contributionToUpdate.amount = amount contributionToUpdate.memo = memo contributionToUpdate.contributionDate = new Date(creationDate) @@ -398,9 +405,7 @@ export class AdminResolver { if (user.deletedAt) throw new Error('This user was deleted. Cannot confirm a contribution.') const creations = await getUserCreation(contribution.userId, false) - if (!isContributionValid(creations, contribution.amount, contribution.contributionDate)) { - throw new Error('Creation is not valid!!') - } + validateContribution(creations, contribution.amount, contribution.contributionDate) const receivedCallDate = new Date() @@ -684,64 +689,6 @@ export class AdminResolver { } } -interface CreationMap { - id: number - creations: Decimal[] -} - -export const getUserCreation = async (id: number, includePending = true): Promise => { - logger.trace('getUserCreation', id, includePending) - const creations = await getUserCreations([id], includePending) - return creations[0] ? creations[0].creations : FULL_CREATION_AVAILABLE -} - -async function getUserCreations(ids: number[], includePending = true): Promise { - logger.trace('getUserCreations:', ids, includePending) - const months = getCreationMonths() - logger.trace('getUserCreations months', months) - - const queryRunner = getConnection().createQueryRunner() - await queryRunner.connect() - - const dateFilter = 'last_day(curdate() - interval 3 month) + interval 1 day' - logger.trace('getUserCreations dateFilter', dateFilter) - - const unionString = includePending - ? ` - UNION - SELECT contribution_date AS date, amount AS amount, user_id AS userId FROM contributions - WHERE user_id IN (${ids.toString()}) - AND contribution_date >= ${dateFilter} - AND confirmed_at IS NULL AND deleted_at IS NULL` - : '' - - const unionQuery = await queryRunner.manager.query(` - SELECT MONTH(date) AS month, sum(amount) AS sum, userId AS id FROM - (SELECT creation_date AS date, amount AS amount, user_id AS userId FROM transactions - WHERE user_id IN (${ids.toString()}) - AND type_id = ${TransactionTypeId.CREATION} - AND creation_date >= ${dateFilter} - ${unionString}) AS result - GROUP BY month, userId - ORDER BY date DESC - `) - - await queryRunner.release() - - return ids.map((id) => { - return { - id, - creations: months.map((month) => { - const creation = unionQuery.find( - (raw: { month: string; id: string; creation: number[] }) => - parseInt(raw.month) === month && parseInt(raw.id) === id, - ) - return MAX_CREATION_AMOUNT.minus(creation ? creation.sum : 0) - }), - } - }) -} - function updateCreations(creations: Decimal[], contribution: Contribution): Decimal[] { const index = getCreationIndex(contribution.contributionDate.getMonth()) @@ -751,58 +698,3 @@ function updateCreations(creations: Decimal[], contribution: Contribution): Deci creations[index] = creations[index].plus(contribution.amount.toString()) return creations } - -export const isContributionValid = ( - creations: Decimal[], - amount: Decimal, - creationDate: Date, -): boolean => { - logger.trace('isContributionValid', creations, amount, creationDate) - const index = getCreationIndex(creationDate.getMonth()) - - if (index < 0) { - throw new Error('No information for available creations for the given date') - } - - if (amount.greaterThan(creations[index].toString())) { - throw new Error( - `The amount (${amount} GDD) to be created exceeds the amount (${creations[index]} GDD) still available for this month.`, - ) - } - - return true -} - -const isStartEndDateValid = ( - startDate: string | null | undefined, - endDate: string | null | undefined, -): void => { - if (!startDate) { - logger.error('Start-Date is not initialized. A Start-Date must be set!') - throw new Error('Start-Date is not initialized. A Start-Date must be set!') - } - - if (!endDate) { - logger.error('End-Date is not initialized. An End-Date must be set!') - throw new Error('End-Date is not initialized. An End-Date must be set!') - } - - // check if endDate is before startDate - if (new Date(endDate).getTime() - new Date(startDate).getTime() < 0) { - logger.error(`The value of validFrom must before or equals the validTo!`) - throw new Error(`The value of validFrom must before or equals the validTo!`) - } -} - -const getCreationMonths = (): number[] => { - const now = new Date(Date.now()) - return [ - now.getMonth() + 1, - new Date(now.getFullYear(), now.getMonth() - 1, 1).getMonth() + 1, - new Date(now.getFullYear(), now.getMonth() - 2, 1).getMonth() + 1, - ].reverse() -} - -const getCreationIndex = (month: number): number => { - return getCreationMonths().findIndex((el) => el === month + 1) -} diff --git a/backend/src/graphql/resolver/ContributionResolver.test.ts b/backend/src/graphql/resolver/ContributionResolver.test.ts new file mode 100644 index 000000000..01e9b123e --- /dev/null +++ b/backend/src/graphql/resolver/ContributionResolver.test.ts @@ -0,0 +1,124 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ + +import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg' +import { createContribution } from '@/seeds/graphql/mutations' +import { login } from '@/seeds/graphql/queries' +import { cleanDB, resetToken, testEnvironment } from '@test/helpers' +import { GraphQLError } from 'graphql' +import { userFactory } from '@/seeds/factory/user' + +let mutate: any, query: any, con: any +let testEnv: any + +beforeAll(async () => { + testEnv = await testEnvironment() + mutate = testEnv.mutate + query = testEnv.query + con = testEnv.con + await cleanDB() +}) + +afterAll(async () => { + await cleanDB() + await con.close() +}) + +describe('ContributionResolver', () => { + describe('createContribution', () => { + describe('unauthenticated', () => { + it('returns an error', async () => { + await expect( + mutate({ + mutation: createContribution, + variables: { amount: 100.0, memo: 'Test Contribution', creationDate: 'not-valid' }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('authenticated with valid user', () => { + beforeAll(async () => { + await userFactory(testEnv, bibiBloxberg) + await query({ + query: login, + variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + }) + }) + + afterAll(async () => { + await cleanDB() + resetToken() + }) + + describe('input not valid', () => { + it('throws error when creationDate not-valid', async () => { + await expect( + mutate({ + mutation: createContribution, + variables: { + amount: 100.0, + memo: 'Test env contribution', + creationDate: 'not-valid', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError('No information for available creations for the given date'), + ], + }), + ) + }) + + it('throws error when creationDate 3 month behind', async () => { + const date = new Date() + await expect( + mutate({ + mutation: createContribution, + variables: { + amount: 100.0, + memo: 'Test env contribution', + creationDate: date.setMonth(date.getMonth() - 3).toString(), + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError('No information for available creations for the given date'), + ], + }), + ) + }) + }) + + describe('valid input', () => { + it('creates contribution', async () => { + await expect( + mutate({ + mutation: createContribution, + variables: { + amount: 100.0, + memo: 'Test env contribution', + creationDate: new Date().toString(), + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + createContribution: { + amount: '100', + memo: 'Test env contribution', + }, + }, + }), + ) + }) + }) + }) + }) +}) diff --git a/backend/src/graphql/resolver/ContributionResolver.ts b/backend/src/graphql/resolver/ContributionResolver.ts new file mode 100644 index 000000000..98492b510 --- /dev/null +++ b/backend/src/graphql/resolver/ContributionResolver.ts @@ -0,0 +1,35 @@ +import { RIGHTS } from '@/auth/RIGHTS' +import { Context, getUser } from '@/server/context' +import { backendLogger as logger } from '@/server/logger' +import { Contribution } from '@entity/Contribution' +import { Args, Authorized, Ctx, Mutation, Resolver } from 'type-graphql' +import ContributionArgs from '../arg/ContributionArgs' +import { UnconfirmedContribution } from '../model/UnconfirmedContribution' +import { validateContribution, getUserCreation } from './util/creations' + +@Resolver() +export class ContributionResolver { + @Authorized([RIGHTS.CREATE_CONTRIBUTION]) + @Mutation(() => UnconfirmedContribution) + async createContribution( + @Args() { amount, memo, creationDate }: ContributionArgs, + @Ctx() context: Context, + ): Promise { + const user = getUser(context) + const creations = await getUserCreation(user.id) + logger.trace('creations', creations) + const creationDateObj = new Date(creationDate) + validateContribution(creations, amount, creationDateObj) + + const contribution = Contribution.create() + contribution.userId = user.id + contribution.amount = amount + contribution.createdAt = new Date() + contribution.contributionDate = creationDateObj + contribution.memo = memo + + logger.trace('contribution to save', contribution) + await Contribution.save(contribution) + return new UnconfirmedContribution(contribution, user, creations) + } +} diff --git a/backend/src/graphql/resolver/TransactionLinkResolver.ts b/backend/src/graphql/resolver/TransactionLinkResolver.ts index c607247b9..8696065ed 100644 --- a/backend/src/graphql/resolver/TransactionLinkResolver.ts +++ b/backend/src/graphql/resolver/TransactionLinkResolver.ts @@ -28,7 +28,7 @@ import { executeTransaction } from './TransactionResolver' import { Order } from '@enum/Order' import { Contribution as DbContribution } from '@entity/Contribution' import { ContributionLink as DbContributionLink } from '@entity/ContributionLink' -import { getUserCreation, isContributionValid } from './AdminResolver' +import { getUserCreation, validateContribution } from './util/creations' import { Decay } from '@model/Decay' import Decimal from 'decimal.js-light' import { TransactionTypeId } from '@enum/TransactionTypeId' @@ -223,13 +223,7 @@ export class TransactionLinkResolver { const creations = await getUserCreation(user.id, false) logger.info('open creations', creations) - if (!isContributionValid(creations, contributionLink.amount, now)) { - logger.error( - 'Amount of Contribution link exceeds available amount for this month', - contributionLink.amount, - ) - throw new Error('Amount of Contribution link exceeds available amount') - } + validateContribution(creations, contributionLink.amount, now) const contribution = new DbContribution() contribution.userId = user.id contribution.createdAt = now diff --git a/backend/src/graphql/resolver/const/const.ts b/backend/src/graphql/resolver/const/const.ts new file mode 100644 index 000000000..d5ba08784 --- /dev/null +++ b/backend/src/graphql/resolver/const/const.ts @@ -0,0 +1,12 @@ +import Decimal from 'decimal.js-light' + +export const MAX_CREATION_AMOUNT = new Decimal(1000) +export const FULL_CREATION_AVAILABLE = [ + MAX_CREATION_AMOUNT, + MAX_CREATION_AMOUNT, + MAX_CREATION_AMOUNT, +] +export const CONTRIBUTIONLINK_NAME_MAX_CHARS = 100 +export const CONTRIBUTIONLINK_NAME_MIN_CHARS = 5 +export const CONTRIBUTIONLINK_MEMO_MAX_CHARS = 255 +export const CONTRIBUTIONLINK_MEMO_MIN_CHARS = 5 diff --git a/backend/src/graphql/resolver/util/creations.ts b/backend/src/graphql/resolver/util/creations.ts new file mode 100644 index 000000000..dcdce2bfa --- /dev/null +++ b/backend/src/graphql/resolver/util/creations.ts @@ -0,0 +1,119 @@ +import { TransactionTypeId } from '@/graphql/enum/TransactionTypeId' +import { backendLogger as logger } from '@/server/logger' +import { getConnection } from '@dbTools/typeorm' +import Decimal from 'decimal.js-light' +import { FULL_CREATION_AVAILABLE, MAX_CREATION_AMOUNT } from '../const/const' + +interface CreationMap { + id: number + creations: Decimal[] +} + +export const validateContribution = ( + creations: Decimal[], + amount: Decimal, + creationDate: Date, +): void => { + logger.trace('isContributionValid', creations, amount, creationDate) + const index = getCreationIndex(creationDate.getMonth()) + + if (index < 0) { + throw new Error('No information for available creations for the given date') + } + + if (amount.greaterThan(creations[index].toString())) { + throw new Error( + `The amount (${amount} GDD) to be created exceeds the amount (${creations[index]} GDD) still available for this month.`, + ) + } +} + +export const getUserCreations = async ( + ids: number[], + includePending = true, +): Promise => { + logger.trace('getUserCreations:', ids, includePending) + const months = getCreationMonths() + logger.trace('getUserCreations months', months) + + const queryRunner = getConnection().createQueryRunner() + await queryRunner.connect() + + const dateFilter = 'last_day(curdate() - interval 3 month) + interval 1 day' + logger.trace('getUserCreations dateFilter', dateFilter) + + const unionString = includePending + ? ` + UNION + SELECT contribution_date AS date, amount AS amount, user_id AS userId FROM contributions + WHERE user_id IN (${ids.toString()}) + AND contribution_date >= ${dateFilter} + AND confirmed_at IS NULL AND deleted_at IS NULL` + : '' + + const unionQuery = await queryRunner.manager.query(` + SELECT MONTH(date) AS month, sum(amount) AS sum, userId AS id FROM + (SELECT creation_date AS date, amount AS amount, user_id AS userId FROM transactions + WHERE user_id IN (${ids.toString()}) + AND type_id = ${TransactionTypeId.CREATION} + AND creation_date >= ${dateFilter} + ${unionString}) AS result + GROUP BY month, userId + ORDER BY date DESC + `) + + await queryRunner.release() + + return ids.map((id) => { + return { + id, + creations: months.map((month) => { + const creation = unionQuery.find( + (raw: { month: string; id: string; creation: number[] }) => + parseInt(raw.month) === month && parseInt(raw.id) === id, + ) + return MAX_CREATION_AMOUNT.minus(creation ? creation.sum : 0) + }), + } + }) +} + +export const getUserCreation = async (id: number, includePending = true): Promise => { + logger.trace('getUserCreation', id, includePending) + const creations = await getUserCreations([id], includePending) + return creations[0] ? creations[0].creations : FULL_CREATION_AVAILABLE +} + +export const getCreationMonths = (): number[] => { + const now = new Date(Date.now()) + return [ + now.getMonth() + 1, + new Date(now.getFullYear(), now.getMonth() - 1, 1).getMonth() + 1, + new Date(now.getFullYear(), now.getMonth() - 2, 1).getMonth() + 1, + ].reverse() +} + +export const getCreationIndex = (month: number): number => { + return getCreationMonths().findIndex((el) => el === month + 1) +} + +export const isStartEndDateValid = ( + startDate: string | null | undefined, + endDate: string | null | undefined, +): void => { + if (!startDate) { + logger.error('Start-Date is not initialized. A Start-Date must be set!') + throw new Error('Start-Date is not initialized. A Start-Date must be set!') + } + + if (!endDate) { + logger.error('End-Date is not initialized. An End-Date must be set!') + throw new Error('End-Date is not initialized. An End-Date must be set!') + } + + // check if endDate is before startDate + if (new Date(endDate).getTime() - new Date(startDate).getTime() < 0) { + logger.error(`The value of validFrom must before or equals the validTo!`) + throw new Error(`The value of validFrom must before or equals the validTo!`) + } +} diff --git a/backend/src/seeds/graphql/mutations.ts b/backend/src/seeds/graphql/mutations.ts index f2edf0821..4926f706f 100644 --- a/backend/src/seeds/graphql/mutations.ts +++ b/backend/src/seeds/graphql/mutations.ts @@ -230,3 +230,12 @@ export const deleteContributionLink = gql` deleteContributionLink(id: $id) } ` + +export const createContribution = gql` + mutation ($amount: Decimal!, $memo: String!, $creationDate: String!) { + createContribution(amount: $amount, memo: $memo, creationDate: $creationDate) { + amount + memo + } + } +`