Merge branch 'master' into docu-env-vars

This commit is contained in:
Moriz Wahl 2022-07-05 11:52:48 +02:00 committed by GitHub
commit 332de5082b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 360 additions and 144 deletions

View File

@ -25,6 +25,7 @@ export enum RIGHTS {
REDEEM_TRANSACTION_LINK = 'REDEEM_TRANSACTION_LINK', REDEEM_TRANSACTION_LINK = 'REDEEM_TRANSACTION_LINK',
LIST_TRANSACTION_LINKS = 'LIST_TRANSACTION_LINKS', LIST_TRANSACTION_LINKS = 'LIST_TRANSACTION_LINKS',
GDT_BALANCE = 'GDT_BALANCE', GDT_BALANCE = 'GDT_BALANCE',
CREATE_CONTRIBUTION = 'CREATE_CONTRIBUTION',
// Admin // Admin
SEARCH_USERS = 'SEARCH_USERS', SEARCH_USERS = 'SEARCH_USERS',
SET_USER_ROLE = 'SET_USER_ROLE', SET_USER_ROLE = 'SET_USER_ROLE',

View File

@ -23,6 +23,7 @@ export const ROLE_USER = new Role('user', [
RIGHTS.REDEEM_TRANSACTION_LINK, RIGHTS.REDEEM_TRANSACTION_LINK,
RIGHTS.LIST_TRANSACTION_LINKS, RIGHTS.LIST_TRANSACTION_LINKS,
RIGHTS.GDT_BALANCE, RIGHTS.GDT_BALANCE,
RIGHTS.CREATE_CONTRIBUTION,
]) ])
export const ROLE_ADMIN = new Role('admin', Object.values(RIGHTS)) // all rights export const ROLE_ADMIN = new Role('admin', Object.values(RIGHTS)) // all rights

View File

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

View File

@ -1,8 +1,22 @@
import { ObjectType, Field, Int } from 'type-graphql' import { ObjectType, Field, Int } from 'type-graphql'
import Decimal from 'decimal.js-light' import Decimal from 'decimal.js-light'
import { Contribution } from '@entity/Contribution'
import { User } from '@entity/User'
@ObjectType() @ObjectType()
export class UnconfirmedContribution { 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) @Field(() => String)
firstName: string firstName: string
@ -27,8 +41,8 @@ export class UnconfirmedContribution {
@Field(() => Decimal) @Field(() => Decimal)
amount: Decimal amount: Decimal
@Field(() => Number) @Field(() => Number, { nullable: true })
moderator: number moderator: number | null
@Field(() => [Decimal]) @Field(() => [Decimal])
creation: Decimal[] creation: Decimal[]

View File

@ -46,15 +46,23 @@ import { checkOptInCode, activationLink, printTimeDuration } from './UserResolve
import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail' import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail'
import { transactionLinkCode as contributionLinkCode } from './TransactionLinkResolver' import { transactionLinkCode as contributionLinkCode } from './TransactionLinkResolver'
import CONFIG from '@/config' 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_IN_REGISTER = 1
// const EMAIL_OPT_UNKNOWN = 3 // elopage? // 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() @Resolver()
export class AdminResolver { export class AdminResolver {
@ -244,18 +252,17 @@ export class AdminResolver {
const creations = await getUserCreation(user.id) const creations = await getUserCreation(user.id)
logger.trace('creations', creations) logger.trace('creations', creations)
const creationDateObj = new Date(creationDate) const creationDateObj = new Date(creationDate)
if (isContributionValid(creations, amount, creationDateObj)) { validateContribution(creations, amount, creationDateObj)
const contribution = Contribution.create() const contribution = Contribution.create()
contribution.userId = user.id contribution.userId = user.id
contribution.amount = amount contribution.amount = amount
contribution.createdAt = new Date() contribution.createdAt = new Date()
contribution.contributionDate = creationDateObj contribution.contributionDate = creationDateObj
contribution.memo = memo contribution.memo = memo
contribution.moderatorId = moderator.id contribution.moderatorId = moderator.id
logger.trace('contribution to save', contribution) logger.trace('contribution to save', contribution)
await Contribution.save(contribution) await Contribution.save(contribution)
}
return getUserCreation(user.id) return getUserCreation(user.id)
} }
@ -321,7 +328,7 @@ export class AdminResolver {
} }
// all possible cases not to be true are thrown in this function // all possible cases not to be true are thrown in this function
isContributionValid(creations, amount, creationDateObj) validateContribution(creations, amount, creationDateObj)
contributionToUpdate.amount = amount contributionToUpdate.amount = amount
contributionToUpdate.memo = memo contributionToUpdate.memo = memo
contributionToUpdate.contributionDate = new Date(creationDate) 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.') if (user.deletedAt) throw new Error('This user was deleted. Cannot confirm a contribution.')
const creations = await getUserCreation(contribution.userId, false) const creations = await getUserCreation(contribution.userId, false)
if (!isContributionValid(creations, contribution.amount, contribution.contributionDate)) { validateContribution(creations, contribution.amount, contribution.contributionDate)
throw new Error('Creation is not valid!!')
}
const receivedCallDate = new Date() 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<Decimal[]> => {
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<CreationMap[]> {
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[] { function updateCreations(creations: Decimal[], contribution: Contribution): Decimal[] {
const index = getCreationIndex(contribution.contributionDate.getMonth()) 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()) creations[index] = creations[index].plus(contribution.amount.toString())
return creations 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)
}

View File

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

View File

@ -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<UnconfirmedContribution> {
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)
}
}

View File

@ -28,7 +28,7 @@ import { executeTransaction } from './TransactionResolver'
import { Order } from '@enum/Order' import { Order } from '@enum/Order'
import { Contribution as DbContribution } from '@entity/Contribution' import { Contribution as DbContribution } from '@entity/Contribution'
import { ContributionLink as DbContributionLink } from '@entity/ContributionLink' import { ContributionLink as DbContributionLink } from '@entity/ContributionLink'
import { getUserCreation, isContributionValid } from './AdminResolver' import { getUserCreation, validateContribution } from './util/creations'
import { Decay } from '@model/Decay' import { Decay } from '@model/Decay'
import Decimal from 'decimal.js-light' import Decimal from 'decimal.js-light'
import { TransactionTypeId } from '@enum/TransactionTypeId' import { TransactionTypeId } from '@enum/TransactionTypeId'
@ -223,13 +223,7 @@ export class TransactionLinkResolver {
const creations = await getUserCreation(user.id, false) const creations = await getUserCreation(user.id, false)
logger.info('open creations', creations) logger.info('open creations', creations)
if (!isContributionValid(creations, contributionLink.amount, now)) { validateContribution(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')
}
const contribution = new DbContribution() const contribution = new DbContribution()
contribution.userId = user.id contribution.userId = user.id
contribution.createdAt = now contribution.createdAt = now

View File

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

View File

@ -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<CreationMap[]> => {
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<Decimal[]> => {
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!`)
}
}

View File

@ -230,3 +230,12 @@ export const deleteContributionLink = gql`
deleteContributionLink(id: $id) deleteContributionLink(id: $id)
} }
` `
export const createContribution = gql`
mutation ($amount: Decimal!, $memo: String!, $creationDate: String!) {
createContribution(amount: $amount, memo: $memo, creationDate: $creationDate) {
amount
memo
}
}
`