diff --git a/backend/src/graphql/resolver/ContributionResolver.test.ts b/backend/src/graphql/resolver/ContributionResolver.test.ts index de049129a..9abd318a3 100644 --- a/backend/src/graphql/resolver/ContributionResolver.test.ts +++ b/backend/src/graphql/resolver/ContributionResolver.test.ts @@ -22,7 +22,7 @@ import { import { listAllContributions, listContributions, - listUnconfirmedContributions, + adminListAllContributions, } from '@/seeds/graphql/queries' import { sendContributionConfirmedEmail } from '@/emails/sendEmailVariants' import { @@ -45,9 +45,10 @@ import { EventProtocolType } from '@/event/EventProtocolType' import { logger, i18n as localization } from '@test/testSetup' import { UserInputError } from 'apollo-server-express' import { raeuberHotzenplotz } from '@/seeds/users/raeuber-hotzenplotz' -import { UnconfirmedContribution } from '../model/UnconfirmedContribution' -import { ContributionListResult } from '../model/Contribution' -import { ContributionStatus } from '../enum/ContributionStatus' +import { UnconfirmedContribution } from '@model/UnconfirmedContribution' +import { ContributionListResult } from '@model/Contribution' +import { ContributionStatus } from '@enum/ContributionStatus' +import { Order } from '@enum/Order' // mock account activation email to avoid console spam jest.mock('@/emails/sendEmailVariants', () => { @@ -876,6 +877,7 @@ describe('ContributionResolver', () => { describe('other user sends a deleteContribution', () => { beforeAll(async () => { + jest.clearAllMocks() await mutate({ mutation: login, variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, @@ -887,7 +889,6 @@ describe('ContributionResolver', () => { }) it('returns an error', async () => { - jest.clearAllMocks() const { errors: errorObjects }: { errors: [GraphQLError] } = await mutate({ mutation: deleteContribution, variables: { @@ -910,6 +911,7 @@ describe('ContributionResolver', () => { describe('User deletes own contribution', () => { beforeAll(async () => { + jest.clearAllMocks() await mutate({ mutation: login, variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, @@ -1673,20 +1675,6 @@ describe('ContributionResolver', () => { }) }) - describe('listUnconfirmedContributions', () => { - it('returns an error', async () => { - await expect( - query({ - query: listUnconfirmedContributions, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - describe('adminDeleteContribution', () => { it('returns an error', async () => { await expect( @@ -1766,20 +1754,6 @@ describe('ContributionResolver', () => { }) }) - describe('listUnconfirmedContributions', () => { - it('returns an error', async () => { - await expect( - query({ - query: listUnconfirmedContributions, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - describe('adminDeleteContribution', () => { it('returns an error', async () => { await expect( @@ -2328,72 +2302,6 @@ describe('ContributionResolver', () => { }) }) - describe('listUnconfirmedContributions', () => { - it('returns four pending creations', async () => { - await expect( - query({ - query: listUnconfirmedContributions, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - listUnconfirmedContributions: expect.arrayContaining([ - expect.objectContaining({ - id: expect.any(Number), - firstName: 'Peter', - lastName: 'Lustig', - email: 'peter@lustig.de', - date: expect.any(String), - memo: 'Das war leider zu Viel!', - amount: '200', - moderator: admin.id, - creation: ['1000', '800', '1000'], - }), - expect.not.objectContaining({ - email: 'bibi@bloxberg.de', - memo: 'Test contribution to delete', - amount: '100', - }), - expect.objectContaining({ - id: expect.any(Number), - firstName: 'Bibi', - lastName: 'Bloxberg', - email: 'bibi@bloxberg.de', - date: expect.any(String), - memo: 'Test PENDING contribution update', - amount: '10', - moderator: null, - creation: ['1000', '1000', '590'], - }), - expect.objectContaining({ - id: expect.any(Number), - firstName: 'Bibi', - lastName: 'Bloxberg', - email: 'bibi@bloxberg.de', - date: expect.any(String), - memo: 'Test IN_PROGRESS contribution', - amount: '100', - moderator: null, - creation: ['1000', '1000', '590'], - }), - expect.objectContaining({ - id: expect.any(Number), - firstName: 'Bibi', - lastName: 'Bloxberg', - email: 'bibi@bloxberg.de', - date: expect.any(String), - memo: 'Aktives Grundeinkommen', - amount: '200', - moderator: admin.id, - creation: ['1000', '1000', '590'], - }), - ]), - }, - }), - ) - }) - }) - describe('adminDeleteContribution', () => { describe('creation id does not exist', () => { it('throws an error', async () => { @@ -2734,4 +2642,320 @@ describe('ContributionResolver', () => { }) }) }) + + describe('adminListAllContribution', () => { + describe('unauthenticated', () => { + it('returns an error', async () => { + await expect( + query({ + query: adminListAllContributions, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('authenticated as user', () => { + beforeAll(async () => { + await mutate({ + mutation: login, + variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + }) + }) + + afterAll(() => { + resetToken() + }) + + it('returns an error', async () => { + await expect( + query({ + query: adminListAllContributions, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('authenticated as admin', () => { + beforeAll(async () => { + await mutate({ + mutation: login, + variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, + }) + }) + + afterAll(() => { + resetToken() + }) + + it('returns 19 creations in total', async () => { + const { + data: { adminListAllContributions: contributionListObject }, + }: { data: { adminListAllContributions: ContributionListResult } } = await query({ + query: adminListAllContributions, + }) + expect(contributionListObject.contributionList).toHaveLength(19) + expect(contributionListObject).toMatchObject({ + contributionCount: 19, + contributionList: expect.arrayContaining([ + expect.objectContaining({ + amount: expect.decimalEqual(50), + firstName: 'Bibi', + id: expect.any(Number), + lastName: 'Bloxberg', + memo: 'Herzlich Willkommen bei Gradido liebe Bibi!', + messagesCount: 0, + state: 'CONFIRMED', + }), + expect.objectContaining({ + amount: expect.decimalEqual(50), + firstName: 'Bibi', + id: expect.any(Number), + lastName: 'Bloxberg', + memo: 'Herzlich Willkommen bei Gradido liebe Bibi!', + messagesCount: 0, + state: 'CONFIRMED', + }), + expect.objectContaining({ + amount: expect.decimalEqual(450), + firstName: 'Bibi', + id: expect.any(Number), + lastName: 'Bloxberg', + memo: 'Herzlich Willkommen bei Gradido liebe Bibi!', + messagesCount: 0, + state: 'CONFIRMED', + }), + expect.objectContaining({ + amount: expect.decimalEqual(100), + firstName: 'Bob', + id: expect.any(Number), + lastName: 'der Baumeister', + memo: 'Confirmed Contribution', + messagesCount: 0, + state: 'CONFIRMED', + }), + expect.objectContaining({ + amount: expect.decimalEqual(400), + firstName: 'Peter', + id: expect.any(Number), + lastName: 'Lustig', + memo: 'Herzlich Willkommen bei Gradido!', + messagesCount: 0, + state: 'PENDING', + }), + expect.objectContaining({ + amount: expect.decimalEqual(100), + firstName: 'Peter', + id: expect.any(Number), + lastName: 'Lustig', + memo: 'Test env contribution', + messagesCount: 0, + state: 'PENDING', + }), + expect.objectContaining({ + amount: expect.decimalEqual(200), + firstName: 'Bibi', + id: expect.any(Number), + lastName: 'Bloxberg', + memo: 'Aktives Grundeinkommen', + messagesCount: 0, + state: 'PENDING', + }), + expect.objectContaining({ + amount: expect.decimalEqual(500), + firstName: 'Bibi', + id: expect.any(Number), + lastName: 'Bloxberg', + memo: 'Grundeinkommen', + messagesCount: 0, + state: 'PENDING', + }), + expect.objectContaining({ + amount: expect.decimalEqual(500), + firstName: 'Peter', + id: expect.any(Number), + lastName: 'Lustig', + memo: 'Grundeinkommen', + messagesCount: 0, + state: 'PENDING', + }), + expect.objectContaining({ + amount: expect.decimalEqual(10), + firstName: 'Bibi', + id: expect.any(Number), + lastName: 'Bloxberg', + memo: 'Test PENDING contribution update', + messagesCount: 0, + state: 'PENDING', + }), + expect.objectContaining({ + amount: expect.decimalEqual(200), + firstName: 'Peter', + id: expect.any(Number), + lastName: 'Lustig', + memo: 'Das war leider zu Viel!', + messagesCount: 0, + state: 'DELETED', + }), + expect.objectContaining({ + amount: expect.decimalEqual(166), + firstName: 'Räuber', + id: expect.any(Number), + lastName: 'Hotzenplotz', + memo: 'Whatever contribution', + messagesCount: 0, + state: 'DELETED', + }), + expect.objectContaining({ + amount: expect.decimalEqual(166), + firstName: 'Räuber', + id: expect.any(Number), + lastName: 'Hotzenplotz', + memo: 'Whatever contribution', + messagesCount: 0, + state: 'DENIED', + }), + expect.objectContaining({ + amount: expect.decimalEqual(166), + firstName: 'Räuber', + id: expect.any(Number), + lastName: 'Hotzenplotz', + memo: 'Whatever contribution', + messagesCount: 0, + state: 'CONFIRMED', + }), + expect.objectContaining({ + amount: expect.decimalEqual(100), + firstName: 'Bibi', + id: expect.any(Number), + lastName: 'Bloxberg', + memo: 'Test IN_PROGRESS contribution', + messagesCount: 0, + state: 'IN_PROGRESS', + }), + expect.objectContaining({ + amount: expect.decimalEqual(100), + firstName: 'Bibi', + id: expect.any(Number), + lastName: 'Bloxberg', + memo: 'Test contribution to confirm', + messagesCount: 0, + state: 'CONFIRMED', + }), + expect.objectContaining({ + amount: expect.decimalEqual(100), + firstName: 'Bibi', + id: expect.any(Number), + lastName: 'Bloxberg', + memo: 'Test contribution to deny', + messagesCount: 0, + state: 'DENIED', + }), + expect.objectContaining({ + amount: expect.decimalEqual(100), + firstName: 'Bibi', + id: expect.any(Number), + lastName: 'Bloxberg', + memo: 'Test contribution to delete', + messagesCount: 0, + state: 'DELETED', + }), + expect.objectContaining({ + amount: expect.decimalEqual(1000), + firstName: 'Bibi', + id: expect.any(Number), + lastName: 'Bloxberg', + memo: 'Herzlich Willkommen bei Gradido!', + messagesCount: 0, + state: 'CONFIRMED', + }), + ]), + }) + }) + + it('returns five pending creations with page size set to 5', async () => { + const { + data: { adminListAllContributions: contributionListObject }, + }: { data: { adminListAllContributions: ContributionListResult } } = await query({ + query: adminListAllContributions, + variables: { + currentPage: 1, + pageSize: 5, + order: Order.DESC, + statusFilter: ['PENDING'], + }, + }) + expect(contributionListObject.contributionList).toHaveLength(5) + expect(contributionListObject).toMatchObject({ + contributionCount: 6, + contributionList: expect.arrayContaining([ + expect.objectContaining({ + amount: '400', + firstName: 'Peter', + id: expect.any(Number), + lastName: 'Lustig', + memo: 'Herzlich Willkommen bei Gradido!', + messagesCount: 0, + state: 'PENDING', + }), + expect.objectContaining({ + amount: '200', + firstName: 'Bibi', + id: expect.any(Number), + lastName: 'Bloxberg', + memo: 'Aktives Grundeinkommen', + messagesCount: 0, + state: 'PENDING', + }), + expect.objectContaining({ + amount: '500', + firstName: 'Bibi', + id: expect.any(Number), + lastName: 'Bloxberg', + memo: 'Grundeinkommen', + messagesCount: 0, + state: 'PENDING', + }), + expect.objectContaining({ + amount: '500', + firstName: 'Peter', + id: expect.any(Number), + lastName: 'Lustig', + memo: 'Grundeinkommen', + messagesCount: 0, + state: 'PENDING', + }), + expect.objectContaining({ + amount: '100', + firstName: 'Peter', + id: expect.any(Number), + lastName: 'Lustig', + memo: 'Test env contribution', + messagesCount: 0, + state: 'PENDING', + }), + expect.not.objectContaining({ + state: 'DENIED', + }), + expect.not.objectContaining({ + state: 'DELETED', + }), + expect.not.objectContaining({ + state: 'CONFIRMED', + }), + expect.not.objectContaining({ + state: 'IN_PROGRESS', + }), + ]), + }) + }) + }) + }) }) diff --git a/backend/src/graphql/resolver/ContributionResolver.ts b/backend/src/graphql/resolver/ContributionResolver.ts index 2e3367e1f..9cb81ce4c 100644 --- a/backend/src/graphql/resolver/ContributionResolver.ts +++ b/backend/src/graphql/resolver/ContributionResolver.ts @@ -1,6 +1,6 @@ import Decimal from 'decimal.js-light' import { Arg, Args, Authorized, Ctx, Int, Mutation, Query, Resolver } from 'type-graphql' -import { FindOperator, IsNull, In, getConnection } from '@dbTools/typeorm' +import { FindOperator, IsNull, getConnection } from '@dbTools/typeorm' import { Contribution as DbContribution } from '@entity/Contribution' import { ContributionMessage } from '@entity/ContributionMessage' @@ -29,12 +29,11 @@ import { backendLogger as logger } from '@/server/logger' import { getCreationDates, getUserCreation, - getUserCreations, validateContribution, updateCreations, isValidDateString, } from './util/creations' -import { MEMO_MAX_CHARS, MEMO_MIN_CHARS, FULL_CREATION_AVAILABLE } from './const/const' +import { MEMO_MAX_CHARS, MEMO_MIN_CHARS } from './const/const' import { EVENT_CONTRIBUTION_CREATE, EVENT_CONTRIBUTION_DELETE, @@ -55,6 +54,7 @@ import { TRANSACTIONS_LOCK } from '@/util/TRANSACTIONS_LOCK' import LogError from '@/server/LogError' import { getLastTransaction } from './util/getLastTransaction' +import { findContributions } from './util/findContributions' @Resolver() export class ContributionResolver { @@ -167,25 +167,14 @@ export class ContributionResolver { @Arg('statusFilter', () => [ContributionStatus], { nullable: true }) statusFilter?: ContributionStatus[], ): Promise { - const where: { - contributionStatus?: FindOperator | null - } = {} + const [dbContributions, count] = await findContributions( + order, + currentPage, + pageSize, + false, + statusFilter, + ) - if (statusFilter && statusFilter.length) { - where.contributionStatus = In(statusFilter) - } - - const [dbContributions, count] = await getConnection() - .createQueryBuilder() - .select('c') - .from(DbContribution, 'c') - .innerJoinAndSelect('c.user', 'u') - .leftJoinAndSelect('c.messages', 'm') - .where(where) - .orderBy('c.createdAt', order) - .limit(pageSize) - .offset((currentPage - 1) * pageSize) - .getManyAndCount() return new ContributionListResult( count, dbContributions.map((contribution) => new Contribution(contribution, contribution.user)), @@ -397,40 +386,25 @@ export class ContributionResolver { } @Authorized([RIGHTS.LIST_UNCONFIRMED_CONTRIBUTIONS]) - @Query(() => [UnconfirmedContribution]) - async listUnconfirmedContributions(@Ctx() context: Context): Promise { - const clientTimezoneOffset = getClientTimezoneOffset(context) - const contributions = await getConnection() - .createQueryBuilder() - .select('c') - .from(DbContribution, 'c') - .leftJoinAndSelect('c.messages', 'm') - .where({ confirmedAt: IsNull() }) - .andWhere({ deniedAt: IsNull() }) - .getMany() + @Query(() => ContributionListResult) // [UnconfirmedContribution] + async adminListAllContributions( + @Args() + { currentPage = 1, pageSize = 3, order = Order.DESC }: Paginated, + @Arg('statusFilter', () => [ContributionStatus], { nullable: true }) + statusFilter?: ContributionStatus[], + ): Promise { + const [dbContributions, count] = await findContributions( + order, + currentPage, + pageSize, + true, + statusFilter, + ) - if (contributions.length === 0) { - return [] - } - - const userIds = contributions.map((p) => p.userId) - const userCreations = await getUserCreations(userIds, clientTimezoneOffset) - const users = await DbUser.find({ - where: { id: In(userIds) }, - withDeleted: true, - relations: ['emailContact'], - }) - - return contributions.map((contribution) => { - const user = users.find((u) => u.id === contribution.userId) - const creation = userCreations.find((c) => c.id === contribution.userId) - - return new UnconfirmedContribution( - contribution, - user, - creation ? creation.creations : FULL_CREATION_AVAILABLE, - ) - }) + return new ContributionListResult( + count, + dbContributions.map((contribution) => new Contribution(contribution, contribution.user)), + ) } @Authorized([RIGHTS.ADMIN_DELETE_CONTRIBUTION]) diff --git a/backend/src/graphql/resolver/util/findContributions.ts b/backend/src/graphql/resolver/util/findContributions.ts new file mode 100644 index 000000000..0dc70cf30 --- /dev/null +++ b/backend/src/graphql/resolver/util/findContributions.ts @@ -0,0 +1,24 @@ +import { ContributionStatus } from '@enum/ContributionStatus' +import { Order } from '@enum/Order' +import { Contribution as DbContribution } from '@entity/Contribution' +import { In } from '@dbTools/typeorm' + +export const findContributions = async ( + order: Order, + currentPage: number, + pageSize: number, + withDeleted: boolean, + statusFilter?: ContributionStatus[], +): Promise<[DbContribution[], number]> => + DbContribution.findAndCount({ + where: { + ...(statusFilter && statusFilter.length && { contributionStatus: In(statusFilter) }), + }, + withDeleted: withDeleted, + order: { + createdAt: order, + }, + relations: ['user'], + skip: (currentPage - 1) * pageSize, + take: pageSize, + }) diff --git a/backend/src/seeds/graphql/queries.ts b/backend/src/seeds/graphql/queries.ts index 3469c200d..71d305dbb 100644 --- a/backend/src/seeds/graphql/queries.ts +++ b/backend/src/seeds/graphql/queries.ts @@ -186,6 +186,40 @@ query ($currentPage: Int = 1, $pageSize: Int = 5, $order: Order = DESC, $statusF contributionCount contributionList { id + firstName + lastName + amount + memo + createdAt + confirmedAt + confirmedBy + contributionDate + state + messagesCount + deniedAt + deniedBy + } + } +} +` +// from admin interface + +export const adminListAllContributions = gql` + query ( + $currentPage: Int = 1 + $pageSize: Int = 25 + $order: Order = DESC + $statusFilter: [ContributionStatus!] + ) { + adminListAllContributions( + currentPage: $currentPage + pageSize: $pageSize + order: $order + statusFilter: $statusFilter + ) { + contributionCount + contributionList { + id firstName lastName amount @@ -198,24 +232,7 @@ query ($currentPage: Int = 1, $pageSize: Int = 5, $order: Order = DESC, $statusF messagesCount deniedAt deniedBy - } - } -} -` -// from admin interface - -export const listUnconfirmedContributions = gql` - query { - listUnconfirmedContributions { - id - firstName - lastName - email - amount - memo - date - moderator - creation + } } } `