diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ac60cfdf2..82785db18 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -437,7 +437,7 @@ jobs: report_name: Coverage Frontend type: lcov result_path: ./coverage/lcov.info - min_coverage: 91 + min_coverage: 92 token: ${{ github.token }} ############################################################################## diff --git a/admin/src/graphql/communityStatistics.js b/admin/src/graphql/communityStatistics.js index 868bfd02a..3159ee258 100644 --- a/admin/src/graphql/communityStatistics.js +++ b/admin/src/graphql/communityStatistics.js @@ -4,12 +4,14 @@ export const communityStatistics = gql` query { communityStatistics { totalUsers - activeUsers deletedUsers totalGradidoCreated totalGradidoDecayed - totalGradidoAvailable - totalGradidoUnbookedDecayed + dynamicStatisticsFields { + activeUsers + totalGradidoAvailable + totalGradidoUnbookedDecayed + } } } ` diff --git a/admin/src/pages/CommunityStatistic.spec.js b/admin/src/pages/CommunityStatistic.spec.js index 50e04d11f..528548f02 100644 --- a/admin/src/pages/CommunityStatistic.spec.js +++ b/admin/src/pages/CommunityStatistic.spec.js @@ -17,12 +17,14 @@ const defaultData = () => { return { communityStatistics: { totalUsers: 3113, - activeUsers: 1057, deletedUsers: 35, totalGradidoCreated: '4083774.05000000000000000000', totalGradidoDecayed: '-1062639.13634129622923372197', - totalGradidoAvailable: '2513565.869444365732411569', - totalGradidoUnbookedDecayed: '-500474.6738366222166261272', + dynamicStatisticsFields: { + activeUsers: 1057, + totalGradidoAvailable: '2513565.869444365732411569', + totalGradidoUnbookedDecayed: '-500474.6738366222166261272', + }, }, } } diff --git a/admin/src/pages/CommunityStatistic.vue b/admin/src/pages/CommunityStatistic.vue index 3b4865ee3..e656fd2c8 100644 --- a/admin/src/pages/CommunityStatistic.vue +++ b/admin/src/pages/CommunityStatistic.vue @@ -31,7 +31,9 @@ export default { return communityStatistics }, update({ communityStatistics }) { - this.statistics = communityStatistics + const totals = { ...communityStatistics.dynamicStatisticsFields } + this.statistics = { ...communityStatistics, ...totals } + delete this.statistics.dynamicStatisticsFields }, error({ message }) { this.toastError(message) diff --git a/backend/src/graphql/model/CommunityStatistics.ts b/backend/src/graphql/model/CommunityStatistics.ts index 61354115c..87ef595af 100644 --- a/backend/src/graphql/model/CommunityStatistics.ts +++ b/backend/src/graphql/model/CommunityStatistics.ts @@ -2,13 +2,25 @@ import { ObjectType, Field } from 'type-graphql' import Decimal from 'decimal.js-light' @ObjectType() -export class CommunityStatistics { - @Field(() => Number) - totalUsers: number - +export class DynamicStatisticsFields { @Field(() => Number) activeUsers: number + @Field(() => Decimal) + totalGradidoAvailable: Decimal + + @Field(() => Decimal) + totalGradidoUnbookedDecayed: Decimal +} + +@ObjectType() +export class CommunityStatistics { + @Field(() => Number) + allUsers: number + + @Field(() => Number) + totalUsers: number + @Field(() => Number) deletedUsers: number @@ -18,9 +30,7 @@ export class CommunityStatistics { @Field(() => Decimal) totalGradidoDecayed: Decimal - @Field(() => Decimal) - totalGradidoAvailable: Decimal - - @Field(() => Decimal) - totalGradidoUnbookedDecayed: Decimal + // be carefull querying this, takes longer than 2 secs. + @Field(() => DynamicStatisticsFields) + dynamicStatisticsFields: DynamicStatisticsFields } diff --git a/backend/src/graphql/resolver/ContributionResolver.ts b/backend/src/graphql/resolver/ContributionResolver.ts index 7771c62ca..afa786f38 100644 --- a/backend/src/graphql/resolver/ContributionResolver.ts +++ b/backend/src/graphql/resolver/ContributionResolver.ts @@ -557,7 +557,6 @@ export class ContributionResolver { ): Promise { // acquire lock const releaseLock = await TRANSACTIONS_LOCK.acquire() - try { const clientTimezoneOffset = getClientTimezoneOffset(context) const contribution = await DbContribution.findOne(id) @@ -664,7 +663,6 @@ export class ContributionResolver { } finally { releaseLock() } - return true } diff --git a/backend/src/graphql/resolver/StatisticsResolver.ts b/backend/src/graphql/resolver/StatisticsResolver.ts index f6c2b9e22..e91840f10 100644 --- a/backend/src/graphql/resolver/StatisticsResolver.ts +++ b/backend/src/graphql/resolver/StatisticsResolver.ts @@ -1,81 +1,113 @@ import Decimal from 'decimal.js-light' -import { Resolver, Query, Authorized } from 'type-graphql' +import { Resolver, Query, Authorized, FieldResolver } from 'type-graphql' import { getConnection } from '@dbTools/typeorm' import { Transaction as DbTransaction } from '@entity/Transaction' import { User as DbUser } from '@entity/User' -import { CommunityStatistics } from '@model/CommunityStatistics' +import { CommunityStatistics, DynamicStatisticsFields } from '@model/CommunityStatistics' import { RIGHTS } from '@/auth/RIGHTS' import { calculateDecay } from '@/util/decay' -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ - -@Resolver() +/* eslint-disable-next-line @typescript-eslint/no-unused-vars */ +@Resolver((of) => CommunityStatistics) export class StatisticsResolver { @Authorized([RIGHTS.COMMUNITY_STATISTICS]) @Query(() => CommunityStatistics) async communityStatistics(): Promise { - const allUsers = await DbUser.count({ withDeleted: true }) - const totalUsers = await DbUser.count() - const deletedUsers = allUsers - totalUsers + return new CommunityStatistics() + } + @FieldResolver(() => Decimal) + async allUsers(): Promise { + return await DbUser.count({ withDeleted: true }) + } + + @FieldResolver() + async totalUsers(): Promise { + return await DbUser.count() + } + + @FieldResolver() + async deletedUsers(): Promise { + return (await this.allUsers()) - (await this.totalUsers()) + } + + @FieldResolver() + async totalGradidoCreated(): Promise { + const queryRunner = getConnection().createQueryRunner() + try { + await queryRunner.connect() + const { totalGradidoCreated } = await queryRunner.manager + .createQueryBuilder() + .select('SUM(transaction.amount) AS totalGradidoCreated') + .from(DbTransaction, 'transaction') + .where('transaction.typeId = 1') + .getRawOne() + return totalGradidoCreated + } finally { + await queryRunner.release() + } + } + + @FieldResolver() + async totalGradidoDecayed(): Promise { + const queryRunner = getConnection().createQueryRunner() + try { + await queryRunner.connect() + const { totalGradidoDecayed } = await queryRunner.manager + .createQueryBuilder() + .select('SUM(transaction.decay) AS totalGradidoDecayed') + .from(DbTransaction, 'transaction') + .where('transaction.decay IS NOT NULL') + .getRawOne() + return totalGradidoDecayed + } finally { + await queryRunner.release() + } + } + + @FieldResolver() + async dynamicStatisticsFields(): Promise { let totalGradidoAvailable: Decimal = new Decimal(0) let totalGradidoUnbookedDecayed: Decimal = new Decimal(0) const receivedCallDate = new Date() const queryRunner = getConnection().createQueryRunner() - await queryRunner.connect() + try { + await queryRunner.connect() - const lastUserTransactions = await queryRunner.manager - .createQueryBuilder(DbUser, 'user') - .select('transaction.balance', 'balance') - .addSelect('transaction.balance_date', 'balanceDate') - .innerJoin(DbTransaction, 'transaction', 'user.id = transaction.user_id') - .where( - `transaction.balance_date = (SELECT MAX(t.balance_date) FROM transactions AS t WHERE t.user_id = user.id)`, - ) - .orderBy('transaction.balance_date', 'DESC') - .addOrderBy('transaction.id', 'DESC') - .getRawMany() + const lastUserTransactions = await queryRunner.manager + .createQueryBuilder(DbUser, 'user') + .select('transaction.balance', 'balance') + .addSelect('transaction.balance_date', 'balanceDate') + .innerJoin(DbTransaction, 'transaction', 'user.id = transaction.user_id') + .where( + `transaction.balance_date = (SELECT MAX(t.balance_date) FROM transactions AS t WHERE t.user_id = user.id)`, + ) + .orderBy('transaction.balance_date', 'DESC') + .addOrderBy('transaction.id', 'DESC') + .getRawMany() - const activeUsers = lastUserTransactions.length + const activeUsers = lastUserTransactions.length - lastUserTransactions.forEach(({ balance, balanceDate }) => { - const decay = calculateDecay(new Decimal(balance), new Date(balanceDate), receivedCallDate) - if (decay) { - totalGradidoAvailable = totalGradidoAvailable.plus(decay.balance.toString()) - totalGradidoUnbookedDecayed = totalGradidoUnbookedDecayed.plus(decay.decay.toString()) + lastUserTransactions.forEach(({ balance, balanceDate }) => { + const decay = calculateDecay(new Decimal(balance), new Date(balanceDate), receivedCallDate) + if (decay) { + totalGradidoAvailable = totalGradidoAvailable.plus(decay.balance.toString()) + totalGradidoUnbookedDecayed = totalGradidoUnbookedDecayed.plus(decay.decay.toString()) + } + }) + + return { + activeUsers, + totalGradidoAvailable, + totalGradidoUnbookedDecayed, } - }) - - const { totalGradidoCreated } = await queryRunner.manager - .createQueryBuilder() - .select('SUM(transaction.amount) AS totalGradidoCreated') - .from(DbTransaction, 'transaction') - .where('transaction.typeId = 1') - .getRawOne() - - const { totalGradidoDecayed } = await queryRunner.manager - .createQueryBuilder() - .select('SUM(transaction.decay) AS totalGradidoDecayed') - .from(DbTransaction, 'transaction') - .where('transaction.decay IS NOT NULL') - .getRawOne() - - await queryRunner.release() - - return { - totalUsers, - activeUsers, - deletedUsers, - totalGradidoCreated, - totalGradidoDecayed, - totalGradidoAvailable, - totalGradidoUnbookedDecayed, + } finally { + await queryRunner.release() } } } diff --git a/backend/src/graphql/resolver/TransactionLinkResolver.test.ts b/backend/src/graphql/resolver/TransactionLinkResolver.test.ts index 9f7d30244..50f9b0414 100644 --- a/backend/src/graphql/resolver/TransactionLinkResolver.test.ts +++ b/backend/src/graphql/resolver/TransactionLinkResolver.test.ts @@ -4,7 +4,7 @@ import { transactionLinkCode } from './TransactionLinkResolver' import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg' import { peterLustig } from '@/seeds/users/peter-lustig' -import { cleanDB, testEnvironment, resetToken } from '@test/helpers' +import { cleanDB, testEnvironment, resetToken, resetEntity } from '@test/helpers' import { creationFactory } from '@/seeds/factory/creation' import { creations } from '@/seeds/creation/index' import { userFactory } from '@/seeds/factory/user' @@ -50,238 +50,340 @@ afterAll(async () => { }) describe('TransactionLinkResolver', () => { - // TODO: have this test separated into a transactionLink and a contributionLink part (if possible) - describe('redeem daily Contribution Link', () => { - const now = new Date() - let contributionLink: DbContributionLink | undefined - let contribution: UnconfirmedContribution | undefined - - beforeAll(async () => { - await mutate({ - mutation: login, - variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, - }) - await mutate({ - mutation: createContributionLink, - variables: { - amount: new Decimal(5), - name: 'Daily Contribution Link', - memo: 'Thank you for contribute daily to the community', - cycle: 'DAILY', - validFrom: new Date(now.getFullYear(), 0, 1).toISOString(), - validTo: new Date(now.getFullYear(), 11, 31, 23, 59, 59, 999).toISOString(), - maxAmountPerMonth: new Decimal(200), - maxPerCycle: 1, - }, - }) - }) - - it('has a daily contribution link in the database', async () => { - const cls = await DbContributionLink.find() - expect(cls).toHaveLength(1) - contributionLink = cls[0] - expect(contributionLink).toEqual( - expect.objectContaining({ - id: expect.any(Number), - name: 'Daily Contribution Link', - memo: 'Thank you for contribute daily to the community', - validFrom: new Date(now.getFullYear(), 0, 1), - validTo: new Date(now.getFullYear(), 11, 31, 23, 59, 59, 0), - cycle: 'DAILY', - maxPerCycle: 1, - totalMaxCountOfContribution: null, - maxAccountBalance: null, - minGapHours: null, - createdAt: expect.any(Date), - deletedAt: null, - code: expect.stringMatching(/^[0-9a-f]{24,24}$/), - linkEnabled: true, - amount: expect.decimalEqual(5), - maxAmountPerMonth: expect.decimalEqual(200), - }), - ) - }) - - describe('user has pending contribution of 1000 GDD', () => { - beforeAll(async () => { - await mutate({ - mutation: login, - variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, - }) - const result = await mutate({ - mutation: createContribution, - variables: { - amount: new Decimal(1000), - memo: 'I was brewing potions for the community the whole month', - creationDate: now.toISOString(), - }, - }) - contribution = result.data.createContribution - }) - - it('does not allow the user to redeem the contribution link', async () => { - await expect( - mutate({ - mutation: redeemTransactionLink, - variables: { - code: 'CL-' + (contributionLink ? contributionLink.code : ''), - }, - }), - ).resolves.toMatchObject({ - errors: [ - new GraphQLError( - 'Creation from contribution link was not successful. Error: The amount (5 GDD) to be created exceeds the amount (0 GDD) still available for this month.', - ), - ], - }) - }) - }) - - describe('user has no pending contributions that would not allow to redeem the link', () => { - beforeAll(async () => { - await mutate({ - mutation: login, - variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, - }) - await mutate({ - mutation: updateContribution, - variables: { - contributionId: contribution ? contribution.id : -1, - amount: new Decimal(800), - memo: 'I was brewing potions for the community the whole month', - creationDate: now.toISOString(), - }, - }) - }) - - it('allows the user to redeem the contribution link', async () => { - await expect( - mutate({ - mutation: redeemTransactionLink, - variables: { - code: 'CL-' + (contributionLink ? contributionLink.code : ''), - }, - }), - ).resolves.toMatchObject({ - data: { - redeemTransactionLink: true, - }, - errors: undefined, - }) - }) - - it('does not allow the user to redeem the contribution link a second time on the same day', async () => { - await expect( - mutate({ - mutation: redeemTransactionLink, - variables: { - code: 'CL-' + (contributionLink ? contributionLink.code : ''), - }, - }), - ).resolves.toMatchObject({ - errors: [ - new GraphQLError( - 'Creation from contribution link was not successful. Error: Contribution link already redeemed today', - ), - ], - }) - }) - - describe('after one day', () => { + describe('redeemTransactionLink', () => { + describe('contributionLink', () => { + describe('input not valid', () => { beforeAll(async () => { - jest.useFakeTimers() - setTimeout(jest.fn(), 1000 * 60 * 60 * 24) - jest.runAllTimers() await mutate({ mutation: login, - variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, }) }) - afterAll(() => { - jest.useRealTimers() - }) - - it('allows the user to redeem the contribution link again', async () => { + it('throws error when link does not exists', async () => { await expect( mutate({ mutation: redeemTransactionLink, variables: { - code: 'CL-' + (contributionLink ? contributionLink.code : ''), - }, - }), - ).resolves.toMatchObject({ - data: { - redeemTransactionLink: true, - }, - errors: undefined, - }) - }) - - it('does not allow the user to redeem the contribution link a second time on the same day', async () => { - await expect( - mutate({ - mutation: redeemTransactionLink, - variables: { - code: 'CL-' + (contributionLink ? contributionLink.code : ''), + code: 'CL-123456', }, }), ).resolves.toMatchObject({ errors: [ new GraphQLError( - 'Creation from contribution link was not successful. Error: Contribution link already redeemed today', + 'Creation from contribution link was not successful. Error: No contribution link found to given code: CL-123456', ), ], }) }) + + it('throws error when link is not valid yet', async () => { + const now = new Date() + const { + data: { createContributionLink: contributionLink }, + } = await mutate({ + mutation: createContributionLink, + variables: { + amount: new Decimal(5), + name: 'Daily Contribution Link', + memo: 'Thank you for contribute daily to the community', + cycle: 'DAILY', + validFrom: new Date(now.getFullYear() + 1, 0, 1).toISOString(), + validTo: new Date(now.getFullYear() + 1, 11, 31, 23, 59, 59, 999).toISOString(), + maxAmountPerMonth: new Decimal(200), + maxPerCycle: 1, + }, + }) + await expect( + mutate({ + mutation: redeemTransactionLink, + variables: { + code: 'CL-' + contributionLink.code, + }, + }), + ).resolves.toMatchObject({ + errors: [ + new GraphQLError( + 'Creation from contribution link was not successful. Error: Contribution link not valid yet', + ), + ], + }) + await resetEntity(DbContributionLink) + }) + + it('throws error when contributionLink cycle is invalid', async () => { + const now = new Date() + const { + data: { createContributionLink: contributionLink }, + } = await mutate({ + mutation: createContributionLink, + variables: { + amount: new Decimal(5), + name: 'Daily Contribution Link', + memo: 'Thank you for contribute daily to the community', + cycle: 'INVALID', + validFrom: new Date(now.getFullYear(), 0, 1).toISOString(), + validTo: new Date(now.getFullYear(), 11, 31, 23, 59, 59, 999).toISOString(), + maxAmountPerMonth: new Decimal(200), + maxPerCycle: 1, + }, + }) + await expect( + mutate({ + mutation: redeemTransactionLink, + variables: { + code: 'CL-' + contributionLink.code, + }, + }), + ).resolves.toMatchObject({ + errors: [ + new GraphQLError( + 'Creation from contribution link was not successful. Error: Contribution link has unknown cycle', + ), + ], + }) + await resetEntity(DbContributionLink) + }) + + it('throws error when link is no longer valid', async () => { + const now = new Date() + const { + data: { createContributionLink: contributionLink }, + } = await mutate({ + mutation: createContributionLink, + variables: { + amount: new Decimal(5), + name: 'Daily Contribution Link', + memo: 'Thank you for contribute daily to the community', + cycle: 'DAILY', + validFrom: new Date(now.getFullYear() - 1, 0, 1).toISOString(), + validTo: new Date(now.getFullYear() - 1, 11, 31, 23, 59, 59, 999).toISOString(), + maxAmountPerMonth: new Decimal(200), + maxPerCycle: 1, + }, + }) + await expect( + mutate({ + mutation: redeemTransactionLink, + variables: { + code: 'CL-' + contributionLink.code, + }, + }), + ).resolves.toMatchObject({ + errors: [ + new GraphQLError( + 'Creation from contribution link was not successful. Error: Contribution link is no longer valid', + ), + ], + }) + await resetEntity(DbContributionLink) + }) }) - }) - }) - describe('transaction links list', () => { - const variables = { - userId: 1, // dummy, may be replaced - filters: null, - currentPage: 1, - pageSize: 5, - } + // TODO: have this test separated into a transactionLink and a contributionLink part + describe('redeem daily Contribution Link', () => { + const now = new Date() + let contributionLink: DbContributionLink | undefined + let contribution: UnconfirmedContribution | undefined - // TODO: there is a test not cleaning up after itself! Fix it! - beforeAll(async () => { - await cleanDB() - resetToken() - }) - - describe('unauthenticated', () => { - it('returns an error', async () => { - await expect( - query({ - query: listTransactionLinksAdmin, - variables, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('authenticated', () => { - describe('without admin rights', () => { beforeAll(async () => { - user = await userFactory(testEnv, bibiBloxberg) await mutate({ mutation: login, - variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, + }) + await mutate({ + mutation: createContributionLink, + variables: { + amount: new Decimal(5), + name: 'Daily Contribution Link', + memo: 'Thank you for contribute daily to the community', + cycle: 'DAILY', + validFrom: new Date(now.getFullYear(), 0, 1).toISOString(), + validTo: new Date(now.getFullYear(), 11, 31, 23, 59, 59, 999).toISOString(), + maxAmountPerMonth: new Decimal(200), + maxPerCycle: 1, + }, }) }) - afterAll(async () => { - await cleanDB() - resetToken() + it('has a daily contribution link in the database', async () => { + const cls = await DbContributionLink.find() + expect(cls).toHaveLength(1) + contributionLink = cls[0] + expect(contributionLink).toEqual( + expect.objectContaining({ + id: expect.any(Number), + name: 'Daily Contribution Link', + memo: 'Thank you for contribute daily to the community', + validFrom: new Date(now.getFullYear(), 0, 1), + validTo: new Date(now.getFullYear(), 11, 31, 23, 59, 59, 0), + cycle: 'DAILY', + maxPerCycle: 1, + totalMaxCountOfContribution: null, + maxAccountBalance: null, + minGapHours: null, + createdAt: expect.any(Date), + deletedAt: null, + code: expect.stringMatching(/^[0-9a-f]{24,24}$/), + linkEnabled: true, + amount: expect.decimalEqual(5), + maxAmountPerMonth: expect.decimalEqual(200), + }), + ) }) + describe('user has pending contribution of 1000 GDD', () => { + beforeAll(async () => { + await mutate({ + mutation: login, + variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + }) + const result = await mutate({ + mutation: createContribution, + variables: { + amount: new Decimal(1000), + memo: 'I was brewing potions for the community the whole month', + creationDate: now.toISOString(), + }, + }) + contribution = result.data.createContribution + }) + + it('does not allow the user to redeem the contribution link', async () => { + await expect( + mutate({ + mutation: redeemTransactionLink, + variables: { + code: 'CL-' + (contributionLink ? contributionLink.code : ''), + }, + }), + ).resolves.toMatchObject({ + errors: [ + new GraphQLError( + 'Creation from contribution link was not successful. Error: The amount (5 GDD) to be created exceeds the amount (0 GDD) still available for this month.', + ), + ], + }) + }) + }) + + describe('user has no pending contributions that would not allow to redeem the link', () => { + beforeAll(async () => { + await mutate({ + mutation: login, + variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + }) + await mutate({ + mutation: updateContribution, + variables: { + contributionId: contribution ? contribution.id : -1, + amount: new Decimal(800), + memo: 'I was brewing potions for the community the whole month', + creationDate: now.toISOString(), + }, + }) + }) + + it('allows the user to redeem the contribution link', async () => { + await expect( + mutate({ + mutation: redeemTransactionLink, + variables: { + code: 'CL-' + (contributionLink ? contributionLink.code : ''), + }, + }), + ).resolves.toMatchObject({ + data: { + redeemTransactionLink: true, + }, + errors: undefined, + }) + }) + + it('does not allow the user to redeem the contribution link a second time on the same day', async () => { + await expect( + mutate({ + mutation: redeemTransactionLink, + variables: { + code: 'CL-' + (contributionLink ? contributionLink.code : ''), + }, + }), + ).resolves.toMatchObject({ + errors: [ + new GraphQLError( + 'Creation from contribution link was not successful. Error: Contribution link already redeemed today', + ), + ], + }) + }) + + describe('after one day', () => { + beforeAll(async () => { + jest.useFakeTimers() + setTimeout(jest.fn(), 1000 * 60 * 60 * 24) + jest.runAllTimers() + await mutate({ + mutation: login, + variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + }) + }) + + afterAll(() => { + jest.useRealTimers() + }) + + it('allows the user to redeem the contribution link again', async () => { + await expect( + mutate({ + mutation: redeemTransactionLink, + variables: { + code: 'CL-' + (contributionLink ? contributionLink.code : ''), + }, + }), + ).resolves.toMatchObject({ + data: { + redeemTransactionLink: true, + }, + errors: undefined, + }) + }) + + it('does not allow the user to redeem the contribution link a second time on the same day', async () => { + await expect( + mutate({ + mutation: redeemTransactionLink, + variables: { + code: 'CL-' + (contributionLink ? contributionLink.code : ''), + }, + }), + ).resolves.toMatchObject({ + errors: [ + new GraphQLError( + 'Creation from contribution link was not successful. Error: Contribution link already redeemed today', + ), + ], + }) + }) + }) + }) + }) + }) + + describe('transaction links list', () => { + const variables = { + userId: 1, // dummy, may be replaced + filters: null, + currentPage: 1, + pageSize: 5, + } + + // TODO: there is a test not cleaning up after itself! Fix it! + beforeAll(async () => { + await cleanDB() + resetToken() + }) + + describe('unauthenticated', () => { it('returns an error', async () => { await expect( query({ @@ -296,40 +398,22 @@ describe('TransactionLinkResolver', () => { }) }) - describe('with admin rights', () => { - beforeAll(async () => { - // admin 'peter@lustig.de' has to exists for 'creationFactory' - await userFactory(testEnv, peterLustig) - - user = await userFactory(testEnv, bibiBloxberg) - variables.userId = user.id - variables.pageSize = 25 - // bibi needs GDDs - const bibisCreation = creations.find((creation) => creation.email === 'bibi@bloxberg.de') - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await creationFactory(testEnv, bibisCreation!) - // bibis transaktion links - const bibisTransaktionLinks = transactionLinks.filter( - (transactionLink) => transactionLink.email === 'bibi@bloxberg.de', - ) - for (let i = 0; i < bibisTransaktionLinks.length; i++) { - await transactionLinkFactory(testEnv, bibisTransaktionLinks[i]) - } - - // admin: only now log in - await mutate({ - mutation: login, - variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, + describe('authenticated', () => { + describe('without admin rights', () => { + beforeAll(async () => { + user = await userFactory(testEnv, bibiBloxberg) + await mutate({ + mutation: login, + variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + }) }) - }) - afterAll(async () => { - await cleanDB() - resetToken() - }) + afterAll(async () => { + await cleanDB() + resetToken() + }) - describe('without any filters', () => { - it('finds 6 open transaction links and no deleted or redeemed', async () => { + it('returns an error', async () => { await expect( query({ query: listTransactionLinksAdmin, @@ -337,185 +421,235 @@ describe('TransactionLinkResolver', () => { }), ).resolves.toEqual( expect.objectContaining({ - data: { - listTransactionLinksAdmin: { - linkCount: 6, - linkList: expect.not.arrayContaining([ - expect.objectContaining({ - memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(', - createdAt: expect.any(String), - }), - expect.objectContaining({ - memo: 'Da habe ich mich wohl etwas übernommen.', - deletedAt: expect.any(String), - }), - ]), - }, - }, + errors: [new GraphQLError('401 Unauthorized')], }), ) }) }) - describe('all filters are null', () => { - it('finds 6 open transaction links and no deleted or redeemed', async () => { - await expect( - query({ - query: listTransactionLinksAdmin, - variables: { - ...variables, - filters: { - withDeleted: null, - withExpired: null, - withRedeemed: null, - }, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - listTransactionLinksAdmin: { - linkCount: 6, - linkList: expect.not.arrayContaining([ - expect.objectContaining({ - memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(', - createdAt: expect.any(String), - }), - expect.objectContaining({ - memo: 'Da habe ich mich wohl etwas übernommen.', - deletedAt: expect.any(String), - }), - ]), - }, - }, - }), - ) - }) - }) + describe('with admin rights', () => { + beforeAll(async () => { + // admin 'peter@lustig.de' has to exists for 'creationFactory' + await userFactory(testEnv, peterLustig) - describe('filter with deleted', () => { - it('finds 6 open transaction links, 1 deleted, and no redeemed', async () => { - await expect( - query({ - query: listTransactionLinksAdmin, - variables: { - ...variables, - filters: { - withDeleted: true, - }, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - listTransactionLinksAdmin: { - linkCount: 7, - linkList: expect.arrayContaining([ - expect.not.objectContaining({ - memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(', - createdAt: expect.any(String), - }), - expect.objectContaining({ - memo: 'Da habe ich mich wohl etwas übernommen.', - deletedAt: expect.any(String), - }), - ]), - }, - }, - }), + user = await userFactory(testEnv, bibiBloxberg) + variables.userId = user.id + variables.pageSize = 25 + // bibi needs GDDs + const bibisCreation = creations.find( + (creation) => creation.email === 'bibi@bloxberg.de', ) - }) - }) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + await creationFactory(testEnv, bibisCreation!) + // bibis transaktion links + const bibisTransaktionLinks = transactionLinks.filter( + (transactionLink) => transactionLink.email === 'bibi@bloxberg.de', + ) + for (let i = 0; i < bibisTransaktionLinks.length; i++) { + await transactionLinkFactory(testEnv, bibisTransaktionLinks[i]) + } - describe('filter by expired', () => { - it('finds 5 open transaction links, 1 expired, and no redeemed', async () => { - await expect( - query({ - query: listTransactionLinksAdmin, - variables: { - ...variables, - filters: { - withExpired: true, - }, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - listTransactionLinksAdmin: { - linkCount: 7, - linkList: expect.arrayContaining([ - expect.objectContaining({ - memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(', - createdAt: expect.any(String), - }), - expect.not.objectContaining({ - memo: 'Da habe ich mich wohl etwas übernommen.', - deletedAt: expect.any(String), - }), - ]), - }, - }, - }), - ) + // admin: only now log in + await mutate({ + mutation: login, + variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, + }) }) - }) - // TODO: works not as expected, because 'redeemedAt' and 'redeemedBy' have to be added to the transaktion link factory - describe.skip('filter by redeemed', () => { - it('finds 6 open transaction links, 1 deleted, and no redeemed', async () => { - await expect( - query({ - query: listTransactionLinksAdmin, - variables: { - ...variables, - filters: { - withDeleted: null, - withExpired: null, - withRedeemed: true, + afterAll(async () => { + await cleanDB() + resetToken() + }) + + describe('without any filters', () => { + it('finds 6 open transaction links and no deleted or redeemed', async () => { + await expect( + query({ + query: listTransactionLinksAdmin, + variables, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + listTransactionLinksAdmin: { + linkCount: 6, + linkList: expect.not.arrayContaining([ + expect.objectContaining({ + memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(', + createdAt: expect.any(String), + }), + expect.objectContaining({ + memo: 'Da habe ich mich wohl etwas übernommen.', + deletedAt: expect.any(String), + }), + ]), + }, }, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - listTransactionLinksAdmin: { - linkCount: 6, - linkList: expect.arrayContaining([ - expect.not.objectContaining({ - memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(', - createdAt: expect.any(String), - }), - expect.objectContaining({ - memo: 'Yeah, eingelöst!', - redeemedAt: expect.any(String), - redeemedBy: expect.any(Number), - }), - expect.not.objectContaining({ - memo: 'Da habe ich mich wohl etwas übernommen.', - deletedAt: expect.any(String), - }), - ]), + }), + ) + }) + }) + + describe('all filters are null', () => { + it('finds 6 open transaction links and no deleted or redeemed', async () => { + await expect( + query({ + query: listTransactionLinksAdmin, + variables: { + ...variables, + filters: { + withDeleted: null, + withExpired: null, + withRedeemed: null, + }, }, - }, - }), - ) + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + listTransactionLinksAdmin: { + linkCount: 6, + linkList: expect.not.arrayContaining([ + expect.objectContaining({ + memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(', + createdAt: expect.any(String), + }), + expect.objectContaining({ + memo: 'Da habe ich mich wohl etwas übernommen.', + deletedAt: expect.any(String), + }), + ]), + }, + }, + }), + ) + }) + }) + + describe('filter with deleted', () => { + it('finds 6 open transaction links, 1 deleted, and no redeemed', async () => { + await expect( + query({ + query: listTransactionLinksAdmin, + variables: { + ...variables, + filters: { + withDeleted: true, + }, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + listTransactionLinksAdmin: { + linkCount: 7, + linkList: expect.arrayContaining([ + expect.not.objectContaining({ + memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(', + createdAt: expect.any(String), + }), + expect.objectContaining({ + memo: 'Da habe ich mich wohl etwas übernommen.', + deletedAt: expect.any(String), + }), + ]), + }, + }, + }), + ) + }) + }) + + describe('filter by expired', () => { + it('finds 5 open transaction links, 1 expired, and no redeemed', async () => { + await expect( + query({ + query: listTransactionLinksAdmin, + variables: { + ...variables, + filters: { + withExpired: true, + }, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + listTransactionLinksAdmin: { + linkCount: 7, + linkList: expect.arrayContaining([ + expect.objectContaining({ + memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(', + createdAt: expect.any(String), + }), + expect.not.objectContaining({ + memo: 'Da habe ich mich wohl etwas übernommen.', + deletedAt: expect.any(String), + }), + ]), + }, + }, + }), + ) + }) + }) + + // TODO: works not as expected, because 'redeemedAt' and 'redeemedBy' have to be added to the transaktion link factory + describe.skip('filter by redeemed', () => { + it('finds 6 open transaction links, 1 deleted, and no redeemed', async () => { + await expect( + query({ + query: listTransactionLinksAdmin, + variables: { + ...variables, + filters: { + withDeleted: null, + withExpired: null, + withRedeemed: true, + }, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + listTransactionLinksAdmin: { + linkCount: 6, + linkList: expect.arrayContaining([ + expect.not.objectContaining({ + memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(', + createdAt: expect.any(String), + }), + expect.objectContaining({ + memo: 'Yeah, eingelöst!', + redeemedAt: expect.any(String), + redeemedBy: expect.any(Number), + }), + expect.not.objectContaining({ + memo: 'Da habe ich mich wohl etwas übernommen.', + deletedAt: expect.any(String), + }), + ]), + }, + }, + }), + ) + }) }) }) }) }) }) -}) -describe('transactionLinkCode', () => { - const date = new Date() + describe('transactionLinkCode', () => { + const date = new Date() - it('returns a string of length 24', () => { - expect(transactionLinkCode(date)).toHaveLength(24) - }) + it('returns a string of length 24', () => { + expect(transactionLinkCode(date)).toHaveLength(24) + }) - it('returns a string that ends with the hex value of date', () => { - const regexp = new RegExp(date.getTime().toString(16) + '$') - expect(transactionLinkCode(date)).toEqual(expect.stringMatching(regexp)) + it('returns a string that ends with the hex value of date', () => { + const regexp = new RegExp(date.getTime().toString(16) + '$') + expect(transactionLinkCode(date)).toEqual(expect.stringMatching(regexp)) + }) }) }) diff --git a/backend/src/graphql/resolver/TransactionLinkResolver.ts b/backend/src/graphql/resolver/TransactionLinkResolver.ts index 897cf9252..df70b4bc9 100644 --- a/backend/src/graphql/resolver/TransactionLinkResolver.ts +++ b/backend/src/graphql/resolver/TransactionLinkResolver.ts @@ -170,148 +170,154 @@ export class TransactionLinkResolver { if (code.match(/^CL-/)) { // acquire lock const releaseLock = await TRANSACTIONS_LOCK.acquire() - logger.info('redeem contribution link...') - const now = new Date() - const queryRunner = getConnection().createQueryRunner() - await queryRunner.connect() - await queryRunner.startTransaction('REPEATABLE READ') try { - const contributionLink = await queryRunner.manager - .createQueryBuilder() - .select('contributionLink') - .from(DbContributionLink, 'contributionLink') - .where('contributionLink.code = :code', { code: code.replace('CL-', '') }) - .getOne() - if (!contributionLink) { - logger.error('no contribution link found to given code:', code) - throw new Error('No contribution link found') - } - logger.info('...contribution link found with id', contributionLink.id) - if (new Date(contributionLink.validFrom).getTime() > now.getTime()) { - logger.error( - 'contribution link is not valid yet. Valid from: ', - contributionLink.validFrom, - ) - throw new Error('Contribution link not valid yet') - } - if (contributionLink.validTo) { - if (new Date(contributionLink.validTo).setHours(23, 59, 59) < now.getTime()) { - logger.error('contribution link is depricated. Valid to: ', contributionLink.validTo) - throw new Error('Contribution link is depricated') + logger.info('redeem contribution link...') + const now = new Date() + const queryRunner = getConnection().createQueryRunner() + await queryRunner.connect() + await queryRunner.startTransaction('REPEATABLE READ') + try { + const contributionLink = await queryRunner.manager + .createQueryBuilder() + .select('contributionLink') + .from(DbContributionLink, 'contributionLink') + .where('contributionLink.code = :code', { code: code.replace('CL-', '') }) + .getOne() + if (!contributionLink) { + logger.error('no contribution link found to given code:', code) + throw new Error(`No contribution link found to given code: ${code}`) } - } - let alreadyRedeemed: DbContribution | undefined - switch (contributionLink.cycle) { - case ContributionCycleType.ONCE: { - alreadyRedeemed = await queryRunner.manager - .createQueryBuilder() - .select('contribution') - .from(DbContribution, 'contribution') - .where('contribution.contributionLinkId = :linkId AND contribution.userId = :id', { - linkId: contributionLink.id, - id: user.id, - }) - .getOne() - if (alreadyRedeemed) { + logger.info('...contribution link found with id', contributionLink.id) + if (new Date(contributionLink.validFrom).getTime() > now.getTime()) { + logger.error( + 'contribution link is not valid yet. Valid from: ', + contributionLink.validFrom, + ) + throw new Error('Contribution link not valid yet') + } + if (contributionLink.validTo) { + if (new Date(contributionLink.validTo).setHours(23, 59, 59) < now.getTime()) { logger.error( - 'contribution link with rule ONCE already redeemed by user with id', - user.id, + 'contribution link is no longer valid. Valid to: ', + contributionLink.validTo, ) - throw new Error('Contribution link already redeemed') + throw new Error('Contribution link is no longer valid') } - break } - case ContributionCycleType.DAILY: { - const start = new Date() - start.setHours(0, 0, 0, 0) - const end = new Date() - end.setHours(23, 59, 59, 999) - alreadyRedeemed = await queryRunner.manager - .createQueryBuilder() - .select('contribution') - .from(DbContribution, 'contribution') - .where( - `contribution.contributionLinkId = :linkId AND contribution.userId = :id - AND Date(contribution.confirmedAt) BETWEEN :start AND :end`, - { + let alreadyRedeemed: DbContribution | undefined + switch (contributionLink.cycle) { + case ContributionCycleType.ONCE: { + alreadyRedeemed = await queryRunner.manager + .createQueryBuilder() + .select('contribution') + .from(DbContribution, 'contribution') + .where('contribution.contributionLinkId = :linkId AND contribution.userId = :id', { linkId: contributionLink.id, id: user.id, - start, - end, - }, - ) - .getOne() - if (alreadyRedeemed) { - logger.error( - 'contribution link with rule DAILY already redeemed by user with id', - user.id, - ) - throw new Error('Contribution link already redeemed today') + }) + .getOne() + if (alreadyRedeemed) { + logger.error( + 'contribution link with rule ONCE already redeemed by user with id', + user.id, + ) + throw new Error('Contribution link already redeemed') + } + break + } + case ContributionCycleType.DAILY: { + const start = new Date() + start.setHours(0, 0, 0, 0) + const end = new Date() + end.setHours(23, 59, 59, 999) + alreadyRedeemed = await queryRunner.manager + .createQueryBuilder() + .select('contribution') + .from(DbContribution, 'contribution') + .where( + `contribution.contributionLinkId = :linkId AND contribution.userId = :id + AND Date(contribution.confirmedAt) BETWEEN :start AND :end`, + { + linkId: contributionLink.id, + id: user.id, + start, + end, + }, + ) + .getOne() + if (alreadyRedeemed) { + logger.error( + 'contribution link with rule DAILY already redeemed by user with id', + user.id, + ) + throw new Error('Contribution link already redeemed today') + } + break + } + default: { + logger.error('contribution link has unknown cycle', contributionLink.cycle) + throw new Error('Contribution link has unknown cycle') } - break } - default: { - logger.error('contribution link has unknown cycle', contributionLink.cycle) - throw new Error('Contribution link has unknown cycle') + + const creations = await getUserCreation(user.id, clientTimezoneOffset) + logger.info('open creations', creations) + validateContribution(creations, contributionLink.amount, now, clientTimezoneOffset) + const contribution = new DbContribution() + contribution.userId = user.id + contribution.createdAt = now + contribution.contributionDate = now + contribution.memo = contributionLink.memo + contribution.amount = contributionLink.amount + contribution.contributionLinkId = contributionLink.id + contribution.contributionType = ContributionType.LINK + contribution.contributionStatus = ContributionStatus.CONFIRMED + + await queryRunner.manager.insert(DbContribution, contribution) + + const lastTransaction = await queryRunner.manager + .createQueryBuilder() + .select('transaction') + .from(DbTransaction, 'transaction') + .where('transaction.userId = :id', { id: user.id }) + .orderBy('transaction.id', 'DESC') + .getOne() + let newBalance = new Decimal(0) + + let decay: Decay | null = null + if (lastTransaction) { + decay = calculateDecay(lastTransaction.balance, lastTransaction.balanceDate, now) + newBalance = decay.balance } + newBalance = newBalance.add(contributionLink.amount.toString()) + + const transaction = new DbTransaction() + transaction.typeId = TransactionTypeId.CREATION + transaction.memo = contribution.memo + transaction.userId = contribution.userId + transaction.previous = lastTransaction ? lastTransaction.id : null + transaction.amount = contribution.amount + transaction.creationDate = contribution.contributionDate + transaction.balance = newBalance + transaction.balanceDate = now + transaction.decay = decay ? decay.decay : new Decimal(0) + transaction.decayStart = decay ? decay.start : null + await queryRunner.manager.insert(DbTransaction, transaction) + + contribution.confirmedAt = now + contribution.transactionId = transaction.id + await queryRunner.manager.update(DbContribution, { id: contribution.id }, contribution) + + await queryRunner.commitTransaction() + logger.info('creation from contribution link commited successfuly.') + } catch (e) { + await queryRunner.rollbackTransaction() + logger.error(`Creation from contribution link was not successful: ${e}`) + throw new Error(`Creation from contribution link was not successful. ${e}`) + } finally { + await queryRunner.release() } - - const creations = await getUserCreation(user.id, clientTimezoneOffset) - logger.info('open creations', creations) - validateContribution(creations, contributionLink.amount, now, clientTimezoneOffset) - const contribution = new DbContribution() - contribution.userId = user.id - contribution.createdAt = now - contribution.contributionDate = now - contribution.memo = contributionLink.memo - contribution.amount = contributionLink.amount - contribution.contributionLinkId = contributionLink.id - contribution.contributionType = ContributionType.LINK - contribution.contributionStatus = ContributionStatus.CONFIRMED - - await queryRunner.manager.insert(DbContribution, contribution) - - const lastTransaction = await queryRunner.manager - .createQueryBuilder() - .select('transaction') - .from(DbTransaction, 'transaction') - .where('transaction.userId = :id', { id: user.id }) - .orderBy('transaction.id', 'DESC') - .getOne() - let newBalance = new Decimal(0) - - let decay: Decay | null = null - if (lastTransaction) { - decay = calculateDecay(lastTransaction.balance, lastTransaction.balanceDate, now) - newBalance = decay.balance - } - newBalance = newBalance.add(contributionLink.amount.toString()) - - const transaction = new DbTransaction() - transaction.typeId = TransactionTypeId.CREATION - transaction.memo = contribution.memo - transaction.userId = contribution.userId - transaction.previous = lastTransaction ? lastTransaction.id : null - transaction.amount = contribution.amount - transaction.creationDate = contribution.contributionDate - transaction.balance = newBalance - transaction.balanceDate = now - transaction.decay = decay ? decay.decay : new Decimal(0) - transaction.decayStart = decay ? decay.start : null - await queryRunner.manager.insert(DbTransaction, transaction) - - contribution.confirmedAt = now - contribution.transactionId = transaction.id - await queryRunner.manager.update(DbContribution, { id: contribution.id }, contribution) - - await queryRunner.commitTransaction() - logger.info('creation from contribution link commited successfuly.') - } catch (e) { - await queryRunner.rollbackTransaction() - logger.error(`Creation from contribution link was not successful: ${e}`) - throw new Error(`Creation from contribution link was not successful. ${e}`) } finally { - await queryRunner.release() releaseLock() } return true diff --git a/backend/src/graphql/resolver/TransactionResolver.ts b/backend/src/graphql/resolver/TransactionResolver.ts index 33914583e..2f97596b2 100644 --- a/backend/src/graphql/resolver/TransactionResolver.ts +++ b/backend/src/graphql/resolver/TransactionResolver.ts @@ -45,29 +45,28 @@ export const executeTransaction = async ( recipient: dbUser, transactionLink?: dbTransactionLink | null, ): Promise => { - logger.info( - `executeTransaction(amount=${amount}, memo=${memo}, sender=${sender}, recipient=${recipient})...`, - ) - - if (sender.id === recipient.id) { - logger.error(`Sender and Recipient are the same.`) - throw new Error('Sender and Recipient are the same.') - } - - if (memo.length > MEMO_MAX_CHARS) { - logger.error(`memo text is too long: memo.length=${memo.length} > ${MEMO_MAX_CHARS}`) - throw new Error(`memo text is too long (${MEMO_MAX_CHARS} characters maximum)`) - } - - if (memo.length < MEMO_MIN_CHARS) { - logger.error(`memo text is too short: memo.length=${memo.length} < ${MEMO_MIN_CHARS}`) - throw new Error(`memo text is too short (${MEMO_MIN_CHARS} characters minimum)`) - } - // acquire lock const releaseLock = await TRANSACTIONS_LOCK.acquire() - try { + logger.info( + `executeTransaction(amount=${amount}, memo=${memo}, sender=${sender}, recipient=${recipient})...`, + ) + + if (sender.id === recipient.id) { + logger.error(`Sender and Recipient are the same.`) + throw new Error('Sender and Recipient are the same.') + } + + if (memo.length > MEMO_MAX_CHARS) { + logger.error(`memo text is too long: memo.length=${memo.length} > ${MEMO_MAX_CHARS}`) + throw new Error(`memo text is too long (${MEMO_MAX_CHARS} characters maximum)`) + } + + if (memo.length < MEMO_MIN_CHARS) { + logger.error(`memo text is too short: memo.length=${memo.length} < ${MEMO_MIN_CHARS}`) + throw new Error(`memo text is too short (${MEMO_MIN_CHARS} characters minimum)`) + } + // validate amount const receivedCallDate = new Date() const sendBalance = await calculateBalance( @@ -187,10 +186,10 @@ export const executeTransaction = async ( }) } logger.info(`finished executeTransaction successfully`) - return true } finally { releaseLock() } + return true } @Resolver() diff --git a/frontend/src/assets/scss/gradido-template.scss b/frontend/src/assets/scss/gradido-template.scss index 59b461dce..9edb72a4f 100644 --- a/frontend/src/assets/scss/gradido-template.scss +++ b/frontend/src/assets/scss/gradido-template.scss @@ -11,7 +11,7 @@ body { .bg-gradient { background: rgb(4 112 6); - background: linear-gradient(90deg, rgb(4 112 6 / 100%) 73%, rgb(197 141 56 / 100%) 100%); + background: linear-gradient(90deg, rgb(4 112 6 / 100%) 22%, rgb(197 141 56 / 100%) 98%); color: white; } diff --git a/frontend/src/components/Contributions/ContributionForm.spec.js b/frontend/src/components/Contributions/ContributionForm.spec.js index c417ca54c..020e3f552 100644 --- a/frontend/src/components/Contributions/ContributionForm.spec.js +++ b/frontend/src/components/Contributions/ContributionForm.spec.js @@ -12,6 +12,7 @@ describe('ContributionForm', () => { date: '', memo: '', amount: '', + hours: 0, }, isThisMonth: true, minimalDate: new Date(), @@ -22,6 +23,7 @@ describe('ContributionForm', () => { const mocks = { $t: jest.fn((t) => t), $d: jest.fn((d) => d), + $n: jest.fn((n) => n), $store: { state: { creation: ['1000', '1000', '1000'], @@ -375,6 +377,7 @@ describe('ContributionForm', () => { date: now, memo: 'Mein Beitrag zur Gemeinschaft für diesen Monat ...', amount: '200', + hours: 0, }, ]), ]), diff --git a/frontend/src/components/Contributions/ContributionForm.vue b/frontend/src/components/Contributions/ContributionForm.vue index 7ef5835c0..210841b05 100644 --- a/frontend/src/components/Contributions/ContributionForm.vue +++ b/frontend/src/components/Contributions/ContributionForm.vue @@ -3,13 +3,12 @@ -
- - - -
-
{{ $t('contribution.exhausted') }}
+ + + + + @@ -111,7 +109,7 @@ export default { this.form.id = null this.form.date = '' this.form.memo = '' - this.form.hours = 0.0 + this.form.hours = 0 this.form.amount = '' }, }, diff --git a/frontend/src/components/Contributions/ContributionListItem.spec.js b/frontend/src/components/Contributions/ContributionListItem.spec.js index 86b0efa31..3415ee5d3 100644 --- a/frontend/src/components/Contributions/ContributionListItem.spec.js +++ b/frontend/src/components/Contributions/ContributionListItem.spec.js @@ -48,7 +48,7 @@ describe('ContributionListItem', () => { it('is x-circle when deletedAt is present', async () => { await wrapper.setProps({ deletedAt: new Date().toISOString() }) - expect(wrapper.vm.icon).toBe('x-circle') + expect(wrapper.vm.icon).toBe('trash') }) it('is check when confirmedAt is present', async () => { diff --git a/frontend/src/components/Contributions/ContributionListItem.vue b/frontend/src/components/Contributions/ContributionListItem.vue index d46b79c52..1f35097ef 100644 --- a/frontend/src/components/Contributions/ContributionListItem.vue +++ b/frontend/src/components/Contributions/ContributionListItem.vue @@ -30,7 +30,10 @@
{{ $t('creation') }} {{ $t('(') }}{{ amount / 20 }} {{ $t('h') }}{{ $t(')') }}
-
{{ amount | GDD }}
+
+ {{ $t('contribution.deleted') }} +
+
{{ amount | GDD }}
@@ -168,7 +171,7 @@ export default { }, computed: { icon() { - if (this.deletedAt) return 'x-circle' + if (this.deletedAt) return 'trash' if (this.confirmedAt) return 'check' if (this.state === 'IN_PROGRESS') return 'question-circle' return 'bell-fill' diff --git a/frontend/src/components/GddSend/TransactionConfirmationLink.vue b/frontend/src/components/GddSend/TransactionConfirmationLink.vue index 91fea3486..1600a4966 100644 --- a/frontend/src/components/GddSend/TransactionConfirmationLink.vue +++ b/frontend/src/components/GddSend/TransactionConfirmationLink.vue @@ -35,12 +35,15 @@ {{ $t('form.new_balance') }} {{ (balance - amount) | GDD }} - - - {{ $t('back') }} + + + + {{ $t('back') }} + - + - + @@ -39,12 +39,15 @@ {{ $t('form.new_balance') }} {{ (balance - amount) | GDD }} - - - {{ $t('back') }} + + + + {{ $t('back') }} + - + {{ $t('form.no_gdd_available') }}
- - - + + + {{ $t('form.reset') }} - - + + {{ $t('form.check_now') }} diff --git a/frontend/src/components/GddTransactionList.vue b/frontend/src/components/GddTransactionList.vue index f3524c6fe..deed0dedb 100644 --- a/frontend/src/components/GddTransactionList.vue +++ b/frontend/src/components/GddTransactionList.vue @@ -26,45 +26,46 @@ -
{{ $t('lastMonth') }}
-
- - +
+
+ + - + - + - - + + +
{ const mocks = { $t: jest.fn((t) => t), + $n: jest.fn((n) => n), $i18n: { locale: jest.fn(() => 'en'), }, - $n: jest.fn((n) => String(n)), $route: { params: {}, }, @@ -46,13 +46,14 @@ describe('InputAmount', () => { describe('amount normalization', () => { describe('if invalid', () => { - beforeEach(() => { + beforeEach(async () => { + await wrapper.setProps({ value: '12m34' }) valid = false }) it('is not normalized', () => { - wrapper.vm.normalizeAmount(valid) - expect(wrapper.vm.amountValue).toBe(0.0) + wrapper.vm.normalizeAmount(false) + expect(wrapper.vm.currentValue).toBe('12m34') }) }) @@ -97,13 +98,14 @@ describe('InputAmount', () => { describe('amount normalization', () => { describe('if invalid', () => { - beforeEach(() => { + beforeEach(async () => { + await wrapper.setProps({ value: '12m34' }) valid = false }) it('is not normalized', () => { wrapper.vm.normalizeAmount(valid) - expect(wrapper.vm.amountValue).toBe(0.0) + expect(wrapper.vm.currentValue).toBe('12m34') }) }) diff --git a/frontend/src/components/Inputs/InputAmount.vue b/frontend/src/components/Inputs/InputAmount.vue index 7a72a3a77..3268a0ec1 100644 --- a/frontend/src/components/Inputs/InputAmount.vue +++ b/frontend/src/components/Inputs/InputAmount.vue @@ -20,7 +20,7 @@ trim v-focus="amountFocused" @focus="amountFocused = true" - @blur="normalizeAmount(true)" + @blur="normalizeAmount(valid)" :disabled="disabled" autocomplete="off" > @@ -90,5 +90,8 @@ export default { this.currentValue = this.$n(this.amountValue, 'ungroupedDecimal') }, }, + mounted() { + if (this.value !== '') this.normalizeAmount(true) + }, } diff --git a/frontend/src/components/Inputs/InputHour.spec.js b/frontend/src/components/Inputs/InputHour.spec.js index e47a7aa66..293c8a332 100644 --- a/frontend/src/components/Inputs/InputHour.spec.js +++ b/frontend/src/components/Inputs/InputHour.spec.js @@ -74,7 +74,7 @@ describe('InputHour', () => { it('emits input with new value', async () => { await wrapper.find('input').setValue('12') expect(wrapper.emitted('input')).toBeTruthy() - expect(wrapper.emitted('input')).toEqual([['12']]) + expect(wrapper.emitted('input')).toEqual([[12]]) }) }) diff --git a/frontend/src/components/Inputs/InputHour.vue b/frontend/src/components/Inputs/InputHour.vue index 10fc00363..89380e73f 100644 --- a/frontend/src/components/Inputs/InputHour.vue +++ b/frontend/src/components/Inputs/InputHour.vue @@ -32,11 +32,11 @@ export default { type: Object, default: () => {}, }, - name: { type: String, required: true, default: 'Time' }, - label: { type: String, required: true, default: 'Time' }, - placeholder: { type: String, required: true, default: 'Time' }, + name: { type: String, required: true }, + label: { type: String, required: true }, + placeholder: { type: String, required: true }, value: { type: Number, required: true, default: 0 }, - validMaxTime: { type: Number, required: true, default: 0 }, + validMaxTime: { type: Number, required: true }, }, data() { return { @@ -50,7 +50,7 @@ export default { }, watch: { currentValue() { - this.$emit('input', this.currentValue) + this.$emit('input', Number(this.currentValue)) }, value() { if (this.value !== this.currentValue) this.currentValue = this.value diff --git a/frontend/src/components/Menu/Navbar.vue b/frontend/src/components/Menu/Navbar.vue index 309e3ffd5..f939de71d 100644 --- a/frontend/src/components/Menu/Navbar.vue +++ b/frontend/src/components/Menu/Navbar.vue @@ -1,64 +1,49 @@ @@ -91,6 +76,10 @@ export default { diff --git a/frontend/src/components/Menu/Sidebar.vue b/frontend/src/components/Menu/Sidebar.vue index a373e9a74..9388b0e64 100644 --- a/frontend/src/components/Menu/Sidebar.vue +++ b/frontend/src/components/Menu/Sidebar.vue @@ -1,6 +1,11 @@