From 3d20019c022dd530575a501e2245bffec5fe15c6 Mon Sep 17 00:00:00 2001 From: einhornimmond Date: Thu, 27 Mar 2025 15:31:58 +0100 Subject: [PATCH] refactor contribution resolver for pre-mysql-query optimization based on requested fields --- .../graphql/adminListContributions.graphql | 11 +- backend/package.json | 1 + backend/src/auth/MODERATOR_RIGHTS.ts | 1 + backend/src/auth/RIGHTS.ts | 2 + backend/src/auth/USER_RIGHTS.ts | 1 + backend/src/graphql/arg/Paginated.ts | 21 +- .../arg/SearchContributionsFilterArgs.ts | 3 +- backend/src/graphql/model/Contribution.ts | 35 +- backend/src/graphql/model/User.ts | 14 + backend/src/graphql/model/UserContact.ts | 42 +++ .../resolver/ContributionResolver.test.ts | 325 ++++++++++++++---- .../graphql/resolver/ContributionResolver.ts | 74 +++- backend/src/graphql/resolver/UserResolver.ts | 43 ++- .../resolver/util/extractGraphQLFields.ts | 48 +++ .../resolver/util/findContributions.ts | 7 +- backend/src/seeds/graphql/queries.ts | 42 +-- backend/yarn.lock | 8 + 17 files changed, 530 insertions(+), 148 deletions(-) create mode 100644 backend/src/graphql/model/UserContact.ts create mode 100644 backend/src/graphql/resolver/util/extractGraphQLFields.ts diff --git a/admin/src/graphql/adminListContributions.graphql b/admin/src/graphql/adminListContributions.graphql index 625ab713f..cb696a51b 100644 --- a/admin/src/graphql/adminListContributions.graphql +++ b/admin/src/graphql/adminListContributions.graphql @@ -1,13 +1,10 @@ #import './fragments.graphql' query adminListContributions( - $filter: SearchContributionsFilterArgs! + $filter: SearchContributionsFilterArgs $paginated: Paginated ) { - adminListContributions( - paginated: $paginated, - filter: $filter - ) { + adminListContributions(paginated: $paginated, filter: $filter) { contributionCount contributionList { id @@ -41,7 +38,7 @@ query adminListContributions( } query adminListContributionsShort( - $filter: SearchContributionsFilterArgs! + $filter: SearchContributionsFilterArgs $paginated: Paginated ) { adminListContributions( @@ -63,7 +60,7 @@ query adminListContributionsShort( query adminListContributionsCount( - $filter: SearchContributionsFilterArgs! + $filter: SearchContributionsFilterArgs ) { adminListContributions( filter: $filter diff --git a/backend/package.json b/backend/package.json index c00fa9028..2d4756afb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -36,6 +36,7 @@ "gradido-config": "file:../config", "gradido-database": "file:../database", "graphql": "^15.5.1", + "graphql-parse-resolve-info": "^4.13.0", "graphql-request": "5.0.0", "graphql-type-json": "0.3.2", "helmet": "^5.1.1", diff --git a/backend/src/auth/MODERATOR_RIGHTS.ts b/backend/src/auth/MODERATOR_RIGHTS.ts index 61edad466..f62cc98df 100644 --- a/backend/src/auth/MODERATOR_RIGHTS.ts +++ b/backend/src/auth/MODERATOR_RIGHTS.ts @@ -17,4 +17,5 @@ export const MODERATOR_RIGHTS = [ RIGHTS.ADMIN_LIST_ALL_CONTRIBUTION_MESSAGES, RIGHTS.DENY_CONTRIBUTION, RIGHTS.ADMIN_OPEN_CREATIONS, + RIGHTS.VIEW_USER_CONTACT, ] diff --git a/backend/src/auth/RIGHTS.ts b/backend/src/auth/RIGHTS.ts index a63134baf..0ccb9695f 100644 --- a/backend/src/auth/RIGHTS.ts +++ b/backend/src/auth/RIGHTS.ts @@ -42,6 +42,7 @@ export enum RIGHTS { HUMHUB_AUTO_LOGIN = 'HUMHUB_AUTO_LOGIN', PROJECT_BRANDING_VIEW = 'PROJECT_BRANDING_VIEW', LIST_HUMHUB_SPACES = 'LIST_HUMHUB_SPACES', + VIEW_OWN_USER_CONTACT = 'VIEW_OWN_USER_CONTACT', // Moderator SEARCH_USERS = 'SEARCH_USERS', ADMIN_CREATE_CONTRIBUTION = 'ADMIN_CREATE_CONTRIBUTION', @@ -59,6 +60,7 @@ export enum RIGHTS { DENY_CONTRIBUTION = 'DENY_CONTRIBUTION', ADMIN_OPEN_CREATIONS = 'ADMIN_OPEN_CREATIONS', ADMIN_LIST_ALL_CONTRIBUTION_MESSAGES = 'ADMIN_LIST_ALL_CONTRIBUTION_MESSAGES', + VIEW_USER_CONTACT = 'VIEW_USER_CONTACT', // Moderator AI AI_SEND_MESSAGE = 'AI_SEND_MESSAGE', // Admin diff --git a/backend/src/auth/USER_RIGHTS.ts b/backend/src/auth/USER_RIGHTS.ts index 445bc2093..2d0a4d980 100644 --- a/backend/src/auth/USER_RIGHTS.ts +++ b/backend/src/auth/USER_RIGHTS.ts @@ -33,4 +33,5 @@ export const USER_RIGHTS = [ RIGHTS.HUMHUB_AUTO_LOGIN, RIGHTS.PROJECT_BRANDING_VIEW, RIGHTS.LIST_HUMHUB_SPACES, + RIGHTS.VIEW_USER_CONTACT, ] diff --git a/backend/src/graphql/arg/Paginated.ts b/backend/src/graphql/arg/Paginated.ts index c63416e7e..f66c71c21 100644 --- a/backend/src/graphql/arg/Paginated.ts +++ b/backend/src/graphql/arg/Paginated.ts @@ -1,20 +1,27 @@ /* eslint-disable type-graphql/invalid-nullable-input-type */ import { IsPositive, IsEnum } from 'class-validator' -import { ArgsType, Field, Int } from 'type-graphql' +import { ArgsType, Field, Int, InputType } from 'type-graphql' import { Order } from '@enum/Order' @ArgsType() +@InputType() export class Paginated { - @Field(() => Int, { nullable: true }) + @Field(() => Int) @IsPositive() - currentPage?: number + currentPage: number - @Field(() => Int, { nullable: true }) + @Field(() => Int) @IsPositive() - pageSize?: number + pageSize: number - @Field(() => Order, { nullable: true }) + @Field(() => Order) @IsEnum(Order) - order?: Order + order: Order + + public constructor(pageSize?: number, currentPage?: number, order?: Order) { + this.pageSize = pageSize ?? 3 + this.currentPage = currentPage ?? 1 + this.order = order ?? Order.DESC + } } diff --git a/backend/src/graphql/arg/SearchContributionsFilterArgs.ts b/backend/src/graphql/arg/SearchContributionsFilterArgs.ts index 0d50457ca..7896ad67a 100644 --- a/backend/src/graphql/arg/SearchContributionsFilterArgs.ts +++ b/backend/src/graphql/arg/SearchContributionsFilterArgs.ts @@ -1,11 +1,12 @@ import { IsBoolean, IsPositive, IsString } from 'class-validator' -import { Field, ArgsType, Int } from 'type-graphql' +import { Field, ArgsType, Int, InputType } from 'type-graphql' import { ContributionStatus } from '@enum/ContributionStatus' import { isContributionStatusArray } from '@/graphql/validator/ContributionStatusArray' @ArgsType() +@InputType() export class SearchContributionsFilterArgs { @Field(() => [ContributionStatus], { nullable: true, defaultValue: null }) @isContributionStatusArray() diff --git a/backend/src/graphql/model/Contribution.ts b/backend/src/graphql/model/Contribution.ts index 31b486265..dc4d65e63 100644 --- a/backend/src/graphql/model/Contribution.ts +++ b/backend/src/graphql/model/Contribution.ts @@ -1,24 +1,16 @@ import { Contribution as dbContribution } from '@entity/Contribution' -import { User } from '@entity/User' +import { User as DbUser } from '@entity/User' import { Decimal } from 'decimal.js-light' import { ObjectType, Field, Int } from 'type-graphql' -import { PublishNameType } from '@enum/PublishNameType' - -import { PublishNameLogic } from '@/data/PublishName.logic' +import { User } from './User' @ObjectType() export class Contribution { - constructor(contribution: dbContribution, user?: User | null) { + constructor(contribution: dbContribution, user?: DbUser | null) { this.id = contribution.id this.firstName = user?.firstName ?? null this.lastName = user?.lastName ?? null - this.email = user?.emailContact?.email ?? null - this.username = user?.alias ?? null - if (user) { - const publishNameLogic = new PublishNameLogic(user) - this.humhubUsername = publishNameLogic.getUsername(user.humhubPublishName as PublishNameType) - } this.amount = contribution.amount this.memo = contribution.memo this.createdAt = contribution.createdAt @@ -36,26 +28,26 @@ export class Contribution { this.moderatorId = contribution.moderatorId this.userId = contribution.userId this.resubmissionAt = contribution.resubmissionAt + if (user) { + this.user = new User(user) + } } @Field(() => Int) id: number + @Field(() => Int, { nullable: true }) + userId: number | null + + @Field(() => User, { nullable: true }) + user: User | null + @Field(() => String, { nullable: true }) firstName: string | null @Field(() => String, { nullable: true }) lastName: string | null - @Field(() => String, { nullable: true }) - email: string | null - - @Field(() => String, { nullable: true }) - username: string | null - - @Field(() => String, { nullable: true }) - humhubUsername: string | null - @Field(() => Decimal) amount: Decimal @@ -101,9 +93,6 @@ export class Contribution { @Field(() => Int, { nullable: true }) moderatorId: number | null - @Field(() => Int, { nullable: true }) - userId: number | null - @Field(() => Date, { nullable: true }) resubmissionAt: Date | null } diff --git a/backend/src/graphql/model/User.ts b/backend/src/graphql/model/User.ts index 328bec61b..abcc10f8d 100644 --- a/backend/src/graphql/model/User.ts +++ b/backend/src/graphql/model/User.ts @@ -4,7 +4,10 @@ import { ObjectType, Field, Int } from 'type-graphql' import { GmsPublishLocationType } from '@enum/GmsPublishLocationType' import { PublishNameType } from '@enum/PublishNameType' +import { PublishNameLogic } from '@/data/PublishName.logic' + import { KlickTipp } from './KlickTipp' +import { UserContact } from './UserContact' @ObjectType() export class User { @@ -18,8 +21,13 @@ export class User { } this.gradidoID = user.gradidoID this.alias = user.alias + + const publishNameLogic = new PublishNameLogic(user) + this.humhubUsername = publishNameLogic.getUsername(user.humhubPublishName as PublishNameType) + if (user.emailContact) { this.emailChecked = user.emailContact.emailChecked + this.emailContact = new UserContact(user.emailContact) } this.firstName = user.firstName this.lastName = user.lastName @@ -58,6 +66,9 @@ export class User { @Field(() => String, { nullable: true }) alias: string | null + @Field(() => String, { nullable: true }) + humhubUsername: string | null + @Field(() => String, { nullable: true }) firstName: string | null @@ -109,4 +120,7 @@ export class User { @Field(() => [String]) roles: string[] + + @Field(() => UserContact, { nullable: true }) + emailContact: UserContact | null } diff --git a/backend/src/graphql/model/UserContact.ts b/backend/src/graphql/model/UserContact.ts new file mode 100644 index 000000000..3c32229f2 --- /dev/null +++ b/backend/src/graphql/model/UserContact.ts @@ -0,0 +1,42 @@ +import { UserContact as DbUserContact } from '@entity/UserContact' +import { ObjectType, Field, Int } from 'type-graphql' + +@ObjectType() +export class UserContact { + constructor(userContact: DbUserContact) { + Object.assign(this, userContact) + } + + @Field(() => Int) + id: number + + @Field(() => Int) + userId: number + + @Field(() => String) + email: string + + @Field(() => Boolean) + gmsPublishEmail: boolean + + @Field(() => Boolean) + emailChecked: boolean + + @Field(() => String, { nullable: true }) + countryCode: string | null + + @Field(() => String, { nullable: true }) + phone: string | null + + @Field(() => Int) + gmsPublishPhone: number + + @Field(() => Date) + createdAt: Date + + @Field(() => Date, { nullable: true }) + updatedAt: Date | null + + @Field(() => Date, { nullable: true }) + deletedAt: Date | null +} diff --git a/backend/src/graphql/resolver/ContributionResolver.test.ts b/backend/src/graphql/resolver/ContributionResolver.test.ts index 5c7a3e77d..134b378dc 100644 --- a/backend/src/graphql/resolver/ContributionResolver.test.ts +++ b/backend/src/graphql/resolver/ContributionResolver.test.ts @@ -2782,13 +2782,15 @@ describe('ContributionResolver', () => { const { errors: errorObjects } = await query({ query: adminListContributions, variables: { - statusFilter: ['INVALID_STATUS'], + filter: { + statusFilter: ['INVALID_STATUS'], + }, }, }) expect(errorObjects).toMatchObject([ { message: - 'Variable "$statusFilter" got invalid value "INVALID_STATUS" at "statusFilter[0]"; Value "INVALID_STATUS" does not exist in "ContributionStatus" enum.', + 'Variable "$filter" got invalid value "INVALID_STATUS" at "filter.statusFilter[0]"; Value "INVALID_STATUS" does not exist in "ContributionStatus" enum.', extensions: { code: 'BAD_USER_INPUT', }, @@ -2801,6 +2803,7 @@ describe('ContributionResolver', () => { data: { adminListContributions: contributionListObject }, } = await query({ query: adminListContributions, + variables: { paginated: { pageSize: 20 } }, }) expect(contributionListObject.contributionList).toHaveLength(18) @@ -2809,165 +2812,255 @@ describe('ContributionResolver', () => { contributionList: expect.arrayContaining([ expect.objectContaining({ amount: expect.decimalEqual(100), - firstName: 'Peter', id: expect.any(Number), - lastName: 'Lustig', memo: '#firefighters', messagesCount: 0, status: 'PENDING', + user: expect.objectContaining({ + firstName: 'Peter', + lastName: 'Lustig', + emailContact: expect.objectContaining({ + email: 'peter@lustig.de', + }), + }), }), expect.objectContaining({ amount: expect.decimalEqual(50), - firstName: 'Bibi', id: expect.any(Number), - lastName: 'Bloxberg', memo: 'Herzlich Willkommen bei Gradido liebe Bibi!', messagesCount: 0, status: 'CONFIRMED', + user: expect.objectContaining({ + firstName: 'Bibi', + lastName: 'Bloxberg', + emailContact: expect.objectContaining({ + email: 'bibi@bloxberg.de', + }), + }), }), expect.objectContaining({ amount: expect.decimalEqual(50), - firstName: 'Bibi', id: expect.any(Number), - lastName: 'Bloxberg', memo: 'Herzlich Willkommen bei Gradido liebe Bibi!', messagesCount: 0, status: 'CONFIRMED', + user: expect.objectContaining({ + firstName: 'Bibi', + lastName: 'Bloxberg', + emailContact: expect.objectContaining({ + email: 'bibi@bloxberg.de', + }), + }), }), expect.objectContaining({ amount: expect.decimalEqual(450), - firstName: 'Bibi', id: expect.any(Number), - lastName: 'Bloxberg', memo: 'Herzlich Willkommen bei Gradido liebe Bibi!', messagesCount: 0, status: 'CONFIRMED', + user: expect.objectContaining({ + firstName: 'Bibi', + lastName: 'Bloxberg', + emailContact: expect.objectContaining({ + email: 'bibi@bloxberg.de', + }), + }), }), expect.objectContaining({ amount: expect.decimalEqual(400), - firstName: 'Peter', id: expect.any(Number), - lastName: 'Lustig', memo: 'Herzlich Willkommen bei Gradido!', messagesCount: 0, status: 'PENDING', + user: expect.objectContaining({ + firstName: 'Peter', + lastName: 'Lustig', + emailContact: expect.objectContaining({ + email: 'peter@lustig.de', + }), + }), }), expect.objectContaining({ amount: expect.decimalEqual(100), - firstName: 'Bob', id: expect.any(Number), - lastName: 'der Baumeister', memo: 'Confirmed Contribution', messagesCount: 0, status: 'CONFIRMED', + user: expect.objectContaining({ + firstName: 'Bob', + lastName: 'der Baumeister', + emailContact: expect.objectContaining({ + email: 'bob@baumeister.de', + }), + }), }), expect.objectContaining({ amount: expect.decimalEqual(100), - firstName: 'Peter', id: expect.any(Number), - lastName: 'Lustig', memo: 'Test env contribution', messagesCount: 0, status: 'PENDING', + user: expect.objectContaining({ + firstName: 'Peter', + lastName: 'Lustig', + emailContact: expect.objectContaining({ + email: 'peter@lustig.de', + }), + }), }), expect.objectContaining({ amount: expect.decimalEqual(200), - firstName: 'Bibi', id: expect.any(Number), - lastName: 'Bloxberg', memo: 'Aktives Grundeinkommen', messagesCount: 0, status: 'PENDING', + user: expect.objectContaining({ + firstName: 'Bibi', + lastName: 'Bloxberg', + emailContact: expect.objectContaining({ + email: 'bibi@bloxberg.de', + }), + }), }), expect.objectContaining({ amount: expect.decimalEqual(200), - firstName: 'Peter', id: expect.any(Number), - lastName: 'Lustig', memo: 'Das war leider zu Viel!', messagesCount: 1, status: 'DELETED', + user: expect.objectContaining({ + firstName: 'Peter', + lastName: 'Lustig', + emailContact: expect.objectContaining({ + email: 'peter@lustig.de', + }), + }), }), expect.objectContaining({ amount: expect.decimalEqual(166), - firstName: 'Räuber', id: expect.any(Number), - lastName: 'Hotzenplotz', memo: 'Whatever contribution', messagesCount: 0, status: 'DENIED', + user: expect.objectContaining({ + firstName: 'Räuber', + lastName: 'Hotzenplotz', + emailContact: expect.objectContaining({ + email: 'raeuber@hotzenplotz.de', + }), + }), }), expect.objectContaining({ amount: expect.decimalEqual(166), - firstName: 'Räuber', id: expect.any(Number), - lastName: 'Hotzenplotz', memo: 'Whatever contribution', messagesCount: 0, status: 'DELETED', + user: expect.objectContaining({ + firstName: 'Räuber', + lastName: 'Hotzenplotz', + emailContact: expect.objectContaining({ + email: 'raeuber@hotzenplotz.de', + }), + }), }), expect.objectContaining({ amount: expect.decimalEqual(166), - firstName: 'Räuber', id: expect.any(Number), - lastName: 'Hotzenplotz', memo: 'Whatever contribution', messagesCount: 0, status: 'CONFIRMED', + user: expect.objectContaining({ + firstName: 'Räuber', + lastName: 'Hotzenplotz', + emailContact: expect.objectContaining({ + email: 'raeuber@hotzenplotz.de', + }), + }), }), expect.objectContaining({ amount: expect.decimalEqual(100), - firstName: 'Bibi', id: expect.any(Number), - lastName: 'Bloxberg', memo: 'Test contribution to delete', messagesCount: 0, status: 'DELETED', + user: expect.objectContaining({ + firstName: 'Bibi', + lastName: 'Bloxberg', + emailContact: expect.objectContaining({ + email: 'bibi@bloxberg.de', + }), + }), }), expect.objectContaining({ amount: expect.decimalEqual(100), - firstName: 'Bibi', id: expect.any(Number), - lastName: 'Bloxberg', memo: 'Test contribution to deny', messagesCount: 0, status: 'DENIED', + user: expect.objectContaining({ + firstName: 'Bibi', + lastName: 'Bloxberg', + emailContact: expect.objectContaining({ + email: 'bibi@bloxberg.de', + }), + }), }), expect.objectContaining({ amount: expect.decimalEqual(100), - firstName: 'Bibi', id: expect.any(Number), - lastName: 'Bloxberg', memo: 'Test contribution to confirm', messagesCount: 0, status: 'CONFIRMED', + user: expect.objectContaining({ + firstName: 'Bibi', + lastName: 'Bloxberg', + emailContact: expect.objectContaining({ + email: 'bibi@bloxberg.de', + }), + }), }), expect.objectContaining({ amount: expect.decimalEqual(100), - firstName: 'Bibi', id: expect.any(Number), - lastName: 'Bloxberg', memo: 'Test IN_PROGRESS contribution', messagesCount: 1, status: 'IN_PROGRESS', + user: expect.objectContaining({ + firstName: 'Bibi', + lastName: 'Bloxberg', + emailContact: expect.objectContaining({ + email: 'bibi@bloxberg.de', + }), + }), }), expect.objectContaining({ amount: expect.decimalEqual(10), - firstName: 'Bibi', id: expect.any(Number), - lastName: 'Bloxberg', memo: 'Test PENDING contribution update', messagesCount: 2, status: 'PENDING', + user: expect.objectContaining({ + firstName: 'Bibi', + lastName: 'Bloxberg', + emailContact: expect.objectContaining({ + email: 'bibi@bloxberg.de', + }), + }), }), expect.objectContaining({ amount: expect.decimalEqual(1000), - firstName: 'Bibi', id: expect.any(Number), - lastName: 'Bloxberg', memo: 'Herzlich Willkommen bei Gradido!', messagesCount: 0, status: 'CONFIRMED', + user: expect.objectContaining({ + firstName: 'Bibi', + lastName: 'Bloxberg', + emailContact: expect.objectContaining({ + email: 'bibi@bloxberg.de', + }), + }), }), ]), }) @@ -2979,10 +3072,14 @@ describe('ContributionResolver', () => { } = await query({ query: adminListContributions, variables: { - currentPage: 1, - pageSize: 2, - order: Order.DESC, - statusFilter: ['PENDING'], + paginated: { + currentPage: 1, + pageSize: 2, + order: Order.DESC, + }, + filter: { + statusFilter: ['PENDING'], + }, }, }) expect(contributionListObject.contributionList).toHaveLength(2) @@ -2991,21 +3088,31 @@ describe('ContributionResolver', () => { contributionList: expect.arrayContaining([ expect.objectContaining({ amount: '100', - firstName: 'Peter', id: expect.any(Number), - lastName: 'Lustig', memo: '#firefighters', messagesCount: 0, status: 'PENDING', + user: expect.objectContaining({ + firstName: 'Peter', + lastName: 'Lustig', + emailContact: expect.objectContaining({ + email: 'peter@lustig.de', + }), + }), }), expect.objectContaining({ amount: '400', - firstName: 'Peter', id: expect.any(Number), - lastName: 'Lustig', memo: 'Herzlich Willkommen bei Gradido!', messagesCount: 0, status: 'PENDING', + user: expect.objectContaining({ + firstName: 'Peter', + lastName: 'Lustig', + emailContact: expect.objectContaining({ + email: 'peter@lustig.de', + }), + }), }), expect.not.objectContaining({ status: 'DENIED', @@ -3030,7 +3137,10 @@ describe('ContributionResolver', () => { } = await query({ query: adminListContributions, variables: { - query: 'Peter', + filter: { + query: 'Peter', + }, + paginated: { pageSize: 20 }, }, }) expect(contributionListObject.contributionList).toHaveLength(4) @@ -3039,39 +3149,59 @@ describe('ContributionResolver', () => { contributionList: expect.arrayContaining([ expect.objectContaining({ amount: expect.decimalEqual(100), - firstName: 'Peter', id: expect.any(Number), - lastName: 'Lustig', memo: '#firefighters', messagesCount: 0, status: 'PENDING', + user: expect.objectContaining({ + firstName: 'Peter', + lastName: 'Lustig', + emailContact: expect.objectContaining({ + email: 'peter@lustig.de', + }), + }), }), expect.objectContaining({ amount: expect.decimalEqual(400), - firstName: 'Peter', id: expect.any(Number), - lastName: 'Lustig', memo: 'Herzlich Willkommen bei Gradido!', messagesCount: 0, status: 'PENDING', + user: expect.objectContaining({ + firstName: 'Peter', + lastName: 'Lustig', + emailContact: expect.objectContaining({ + email: 'peter@lustig.de', + }), + }), }), expect.objectContaining({ amount: expect.decimalEqual(100), - firstName: 'Peter', id: expect.any(Number), - lastName: 'Lustig', memo: 'Test env contribution', messagesCount: 0, status: 'PENDING', + user: expect.objectContaining({ + firstName: 'Peter', + lastName: 'Lustig', + emailContact: expect.objectContaining({ + email: 'peter@lustig.de', + }), + }), }), expect.objectContaining({ amount: expect.decimalEqual(200), - firstName: 'Peter', id: expect.any(Number), - lastName: 'Lustig', memo: 'Das war leider zu Viel!', messagesCount: 1, status: 'DELETED', + user: expect.objectContaining({ + firstName: 'Peter', + lastName: 'Lustig', + emailContact: expect.objectContaining({ + email: 'peter@lustig.de', + }), + }), }), ]), }) @@ -3083,8 +3213,11 @@ describe('ContributionResolver', () => { } = await query({ query: adminListContributions, variables: { - query: 'Peter', - noHashtag: true, + filter: { + query: 'Peter', + noHashtag: true, + }, + paginated: { pageSize: 20 }, }, }) expect(contributionListObject.contributionList).toHaveLength(3) @@ -3093,30 +3226,45 @@ describe('ContributionResolver', () => { contributionList: expect.arrayContaining([ expect.objectContaining({ amount: expect.decimalEqual(400), - firstName: 'Peter', id: expect.any(Number), - lastName: 'Lustig', memo: 'Herzlich Willkommen bei Gradido!', messagesCount: 0, status: 'PENDING', + user: expect.objectContaining({ + firstName: 'Peter', + lastName: 'Lustig', + emailContact: expect.objectContaining({ + email: 'peter@lustig.de', + }), + }), }), expect.objectContaining({ amount: expect.decimalEqual(100), - firstName: 'Peter', id: expect.any(Number), - lastName: 'Lustig', memo: 'Test env contribution', messagesCount: 0, status: 'PENDING', + user: expect.objectContaining({ + firstName: 'Peter', + lastName: 'Lustig', + emailContact: expect.objectContaining({ + email: 'peter@lustig.de', + }), + }), }), expect.objectContaining({ amount: expect.decimalEqual(200), - firstName: 'Peter', id: expect.any(Number), - lastName: 'Lustig', memo: 'Das war leider zu Viel!', messagesCount: 1, status: 'DELETED', + user: expect.objectContaining({ + firstName: 'Peter', + lastName: 'Lustig', + emailContact: expect.objectContaining({ + email: 'peter@lustig.de', + }), + }), }), ]), }) @@ -3128,7 +3276,9 @@ describe('ContributionResolver', () => { } = await query({ query: adminListContributions, variables: { - query: '#firefighter', + filter: { + query: '#firefighter', + }, }, }) expect(contributionListObject.contributionList).toHaveLength(1) @@ -3137,12 +3287,17 @@ describe('ContributionResolver', () => { contributionList: expect.arrayContaining([ expect.objectContaining({ amount: expect.decimalEqual(100), - firstName: 'Peter', id: expect.any(Number), - lastName: 'Lustig', memo: '#firefighters', messagesCount: 0, status: 'PENDING', + user: expect.objectContaining({ + firstName: 'Peter', + lastName: 'Lustig', + emailContact: expect.objectContaining({ + email: 'peter@lustig.de', + }), + }), }), ]), }) @@ -3154,8 +3309,10 @@ describe('ContributionResolver', () => { } = await query({ query: adminListContributions, variables: { - query: '#firefighter', - noHashtag: true, + filter: { + query: '#firefighter', + noHashtag: true, + }, }, }) expect(contributionListObject.contributionList).toHaveLength(0) @@ -3171,7 +3328,10 @@ describe('ContributionResolver', () => { } = await query({ query: adminListContributions, variables: { - query: 'RAEUBER', // only found in lowercase in the email + filter: { + query: 'RAEUBER', // only found in lowercase in the email + }, + paginated: { pageSize: 20 }, }, }) expect(contributionListObject.contributionList).toHaveLength(3) @@ -3180,30 +3340,45 @@ describe('ContributionResolver', () => { contributionList: expect.arrayContaining([ expect.objectContaining({ amount: expect.decimalEqual(166), - firstName: 'Räuber', id: expect.any(Number), - lastName: 'Hotzenplotz', memo: 'Whatever contribution', messagesCount: 0, status: 'DENIED', + user: expect.objectContaining({ + firstName: 'Räuber', + lastName: 'Hotzenplotz', + emailContact: expect.objectContaining({ + email: 'raeuber@hotzenplotz.de', + }), + }), }), expect.objectContaining({ amount: expect.decimalEqual(166), - firstName: 'Räuber', id: expect.any(Number), - lastName: 'Hotzenplotz', memo: 'Whatever contribution', messagesCount: 0, status: 'DELETED', + user: expect.objectContaining({ + firstName: 'Räuber', + lastName: 'Hotzenplotz', + emailContact: expect.objectContaining({ + email: 'raeuber@hotzenplotz.de', + }), + }), }), expect.objectContaining({ amount: expect.decimalEqual(166), - firstName: 'Räuber', id: expect.any(Number), - lastName: 'Hotzenplotz', memo: 'Whatever contribution', messagesCount: 0, status: 'CONFIRMED', + user: expect.objectContaining({ + firstName: 'Räuber', + lastName: 'Hotzenplotz', + emailContact: expect.objectContaining({ + email: 'raeuber@hotzenplotz.de', + }), + }), }), ]), }) diff --git a/backend/src/graphql/resolver/ContributionResolver.ts b/backend/src/graphql/resolver/ContributionResolver.ts index 2f67dc6a5..4c1dbe488 100644 --- a/backend/src/graphql/resolver/ContributionResolver.ts +++ b/backend/src/graphql/resolver/ContributionResolver.ts @@ -4,7 +4,20 @@ import { Transaction as DbTransaction } from '@entity/Transaction' import { User as DbUser } from '@entity/User' import { UserContact } from '@entity/UserContact' import { Decimal } from 'decimal.js-light' -import { Arg, Args, Authorized, Ctx, Int, Mutation, Query, Resolver } from 'type-graphql' +import { GraphQLResolveInfo } from 'graphql' +import { + Arg, + Args, + Authorized, + Ctx, + FieldResolver, + Info, + Int, + Mutation, + Query, + Resolver, + Root, +} from 'type-graphql' import { AdminCreateContributionArgs } from '@arg/AdminCreateContributionArgs' import { AdminUpdateContributionArgs } from '@arg/AdminUpdateContributionArgs' @@ -20,6 +33,7 @@ import { Contribution, ContributionListResult } from '@model/Contribution' import { Decay } from '@model/Decay' import { OpenCreation } from '@model/OpenCreation' import { UnconfirmedContribution } from '@model/UnconfirmedContribution' +import { User } from '@model/User' import { RIGHTS } from '@/auth/RIGHTS' import { @@ -48,11 +62,12 @@ import { fullName } from '@/util/utilities' import { findContribution } from './util/contributions' import { getUserCreation, validateContribution, getOpenCreations } from './util/creations' +import { extractGraphQLFields, extractGraphQLFieldsForSelect } from './util/extractGraphQLFields' import { findContributions } from './util/findContributions' import { getLastTransaction } from './util/getLastTransaction' import { sendTransactionsToDltConnector } from './util/sendTransactionsToDltConnector' -@Resolver() +@Resolver(() => Contribution) export class ContributionResolver { @Authorized([RIGHTS.ADMIN_LIST_CONTRIBUTIONS]) @Query(() => Contribution) @@ -321,15 +336,37 @@ export class ContributionResolver { @Authorized([RIGHTS.ADMIN_LIST_CONTRIBUTIONS]) @Query(() => ContributionListResult) async adminListContributions( - @Args() paginated: Paginated, - @Args() filter: SearchContributionsFilterArgs, - ): Promise { - const [dbContributions, count] = await findContributions(paginated, filter, true, { - user: { - emailContact: true, - }, - messages: true, + @Arg('filter', () => SearchContributionsFilterArgs, { + defaultValue: new SearchContributionsFilterArgs(), }) + filter: SearchContributionsFilterArgs, + @Arg('paginated', () => Paginated, { defaultValue: new Paginated() }) paginated: Paginated, + @Info() info: GraphQLResolveInfo, + ): Promise { + // Check if only count was requested (without contributionList) + const fields = Object.keys(extractGraphQLFields(info)) + const countOnly: boolean = fields.includes('contributionCount') && fields.length === 1 + // check if related user was requested + const userRequested = + fields.includes('user') || filter.userId !== undefined || filter.query !== undefined + // check if related emailContact was requested + const emailContactRequested = fields.includes('user.emailContact') || filter.query !== undefined + // check if related messages were requested + const messagesRequested = ['messagesCount', 'messages'].some((field) => fields.includes(field)) + const [dbContributions, count] = await findContributions( + paginated, + filter, + true, + { + user: userRequested + ? { + emailContact: emailContactRequested, + } + : false, + messages: messagesRequested, + }, + countOnly, + ) return new ContributionListResult( count, @@ -573,4 +610,21 @@ export class ContributionResolver { return !!res } + + // Field resolvers + @Authorized([RIGHTS.USER]) + @FieldResolver(() => User) + async user( + @Root() contribution: DbContribution, + @Info() info: GraphQLResolveInfo, + ): Promise { + let user = contribution.user + if (!user) { + const queryBuilder = DbUser.createQueryBuilder('user') + queryBuilder.where('user.id = :userId', { userId: contribution.userId }) + extractGraphQLFieldsForSelect(info, queryBuilder, 'user') + user = await queryBuilder.getOneOrFail() + } + return new User(user) + } } diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index b6edab784..5e8843650 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -8,8 +8,21 @@ import { ProjectBranding } from '@entity/ProjectBranding' import { TransactionLink as DbTransactionLink } from '@entity/TransactionLink' import { User as DbUser } from '@entity/User' import { UserContact as DbUserContact } from '@entity/UserContact' +import { GraphQLResolveInfo } from 'graphql' import i18n from 'i18n' -import { Resolver, Query, Args, Arg, Authorized, Ctx, Mutation, Int } from 'type-graphql' +import { + Resolver, + Query, + Args, + Arg, + Authorized, + Ctx, + Mutation, + Int, + Root, + FieldResolver, + Info, +} from 'type-graphql' import { IRestResponse } from 'typed-rest-client' import { v4 as uuidv4 } from 'uuid' @@ -29,6 +42,7 @@ import { SearchAdminUsersResult } from '@model/AdminUser' import { GmsUserAuthenticationResult } from '@model/GmsUserAuthenticationResult' import { User } from '@model/User' import { UserAdmin, SearchUsersResult } from '@model/UserAdmin' +import { UserContact } from '@model/UserContact' import { UserLocationResult } from '@model/UserLocationResult' import { HumHubClient } from '@/apis/humhub/HumHubClient' @@ -79,6 +93,7 @@ import { authenticateGmsUserPlayground } from './util/authenticateGmsUserPlaygro import { getHomeCommunity } from './util/communities' import { compareGmsRelevantUserSettings } from './util/compareGmsRelevantUserSettings' import { getUserCreations } from './util/creations' +import { extractGraphQLFieldsForSelect } from './util/extractGraphQLFields' import { findUserByIdentifier } from './util/findUserByIdentifier' import { findUsers } from './util/findUsers' import { getKlicktippState } from './util/getKlicktippState' @@ -126,7 +141,7 @@ const newGradidoID = async (): Promise => { return gradidoId } -@Resolver() +@Resolver(() => User) export class UserResolver { @Authorized([RIGHTS.VERIFY_LOGIN]) @Query(() => User) @@ -1035,6 +1050,30 @@ export class UserResolver { const modelUser = new User(foundDbUser) return modelUser } + + // FIELD RESOLVERS + @FieldResolver(() => UserContact) + async emailContact( + @Root() user: DbUser, + @Ctx() context: Context, + @Info() info: GraphQLResolveInfo, + ): Promise { + // Check if user has the necessary permissions to view user contact + // Either they need VIEW_USER_CONTACT right, or they need VIEW_OWN_USER_CONTACT and must be viewing their own contact + if (!context.role?.hasRight(RIGHTS.VIEW_USER_CONTACT)) { + if (!context.role?.hasRight(RIGHTS.VIEW_OWN_USER_CONTACT) || context.user?.id !== user.id) { + throw new LogError('User does not have permission to view this user contact', user.id) + } + } + let userContact = user.emailContact + if (!userContact) { + const queryBuilder = DbUserContact.createQueryBuilder('userContact') + queryBuilder.where('userContact.userId = :userId', { userId: user.id }) + extractGraphQLFieldsForSelect(info, queryBuilder, 'userContact') + userContact = await queryBuilder.getOneOrFail() + } + return new UserContact(userContact) + } } export async function findUserByEmail(email: string): Promise { diff --git a/backend/src/graphql/resolver/util/extractGraphQLFields.ts b/backend/src/graphql/resolver/util/extractGraphQLFields.ts new file mode 100644 index 000000000..082028ff1 --- /dev/null +++ b/backend/src/graphql/resolver/util/extractGraphQLFields.ts @@ -0,0 +1,48 @@ +import { ObjectLiteral, SelectQueryBuilder } from '@dbTools/typeorm' +import { GraphQLResolveInfo } from 'graphql' +import { + parseResolveInfo, + ResolveTree, + simplifyParsedResolveInfoFragmentWithType, +} from 'graphql-parse-resolve-info' + +/** + * Extracts the requested fields from GraphQL + * @param info GraphQLResolveInfo + */ +export function extractGraphQLFields(info: GraphQLResolveInfo): object { + const parsedInfo = parseResolveInfo(info) + if (!parsedInfo) { + throw new Error('Could not parse resolve info') + } + + return simplifyParsedResolveInfoFragmentWithType(parsedInfo as ResolveTree, info.returnType) + .fields +} + +/** + * Extracts the requested fields from GraphQL and applies them to a TypeORM query. + * @param info GraphQLResolveInfo + * @param queryBuilder TypeORM QueryBuilder + * @param alias the table alias for select + */ +export function extractGraphQLFieldsForSelect( + info: GraphQLResolveInfo, + queryBuilder: SelectQueryBuilder, + alias: string, +) { + const requestedFields = Object.keys(extractGraphQLFields(info)) + + if (requestedFields.length > 0) { + // Filter out fields that don't exist in the entity type T + const entityName = queryBuilder.alias.charAt(0).toUpperCase() + queryBuilder.alias.slice(1) + const metadata = queryBuilder.connection.getMetadata(entityName) + const validFields = requestedFields.filter( + (field) => metadata.findColumnWithPropertyName(field) !== undefined, + ) + + if (requestedFields.length > 0) { + queryBuilder.select(validFields.map((field) => `${alias}.${field}`)) + } + } +} diff --git a/backend/src/graphql/resolver/util/findContributions.ts b/backend/src/graphql/resolver/util/findContributions.ts index e929cdc10..07cc5dc14 100644 --- a/backend/src/graphql/resolver/util/findContributions.ts +++ b/backend/src/graphql/resolver/util/findContributions.ts @@ -14,7 +14,6 @@ import { Paginated } from '@arg/Paginated' import { SearchContributionsFilterArgs } from '@arg/SearchContributionsFilterArgs' import { Connection } from '@typeorm/connection' -import { Order } from '@/graphql/enum/Order' import { LogError } from '@/server/LogError' interface Relations { @@ -36,10 +35,11 @@ function joinRelationsRecursive( } export const findContributions = async ( - { pageSize = 3, currentPage = 1, order = Order.DESC }: Paginated, + { pageSize, currentPage, order }: Paginated, filter: SearchContributionsFilterArgs, withDeleted = false, relations: Relations | undefined = undefined, + countOnly = false, ): Promise<[DbContribution[], number]> => { const connection = await Connection.getInstance() if (!connection) { @@ -76,6 +76,9 @@ export const findContributions = async ( }), ) } + if (countOnly) { + return [[], await queryBuilder.getCount()] + } return queryBuilder .orderBy('Contribution.createdAt', order) .addOrderBy('Contribution.id', order) diff --git a/backend/src/seeds/graphql/queries.ts b/backend/src/seeds/graphql/queries.ts index 0365f07a4..72a1a029d 100644 --- a/backend/src/seeds/graphql/queries.ts +++ b/backend/src/seeds/graphql/queries.ts @@ -300,39 +300,39 @@ query ($currentPage: Int = 1, $pageSize: Int = 5, $order: Order = DESC, $statusF // from admin interface export const adminListContributions = gql` - query ( - $currentPage: Int = 1 - $pageSize: Int = 25 - $order: Order = DESC - $statusFilter: [ContributionStatus!] - $userId: Int - $query: String - $noHashtag: Boolean - ) { - adminListContributions( - currentPage: $currentPage - pageSize: $pageSize - order: $order - statusFilter: $statusFilter - userId: $userId - query: $query - noHashtag: $noHashtag - ) { + query ($filter: SearchContributionsFilterArgs, $paginated: Paginated) { + adminListContributions(filter: $filter, paginated: $paginated) { contributionCount contributionList { id - firstName - lastName + user { + emailContact { + email + } + id + firstName + lastName + alias + humhubUsername + createdAt + } amount memo createdAt + contributionDate confirmedAt confirmedBy - contributionDate + updatedAt + updatedBy status messagesCount deniedAt deniedBy + deletedAt + deletedBy + moderatorId + userId + resubmissionAt } } } diff --git a/backend/yarn.lock b/backend/yarn.lock index b1a3ee3cf..f7a2a27b1 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -3852,6 +3852,14 @@ graphql-extensions@^0.16.0: apollo-server-env "^3.2.0" apollo-server-types "^0.10.0" +graphql-parse-resolve-info@^4.13.0: + version "4.13.0" + resolved "https://registry.yarnpkg.com/graphql-parse-resolve-info/-/graphql-parse-resolve-info-4.13.0.tgz#03627032e25917bd6f9ed89e768568c61200e6ff" + integrity sha512-VVJ1DdHYcR7hwOGQKNH+QTzuNgsLA8l/y436HtP9YHoX6nmwXRWq3xWthU3autMysXdm0fQUbhTZCx0W9ICozw== + dependencies: + debug "^4.1.1" + tslib "^2.0.1" + graphql-query-complexity@^0.7.0: version "0.7.2" resolved "https://registry.yarnpkg.com/graphql-query-complexity/-/graphql-query-complexity-0.7.2.tgz#7fc6bb20930ab1b666ecf3bbfb24b65b6f08ecc4"