refactor contribution resolver for pre-mysql-query optimization based on requested fields

This commit is contained in:
einhornimmond 2025-03-27 15:31:58 +01:00
parent f6c78c59ec
commit 3d20019c02
17 changed files with 530 additions and 148 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -33,4 +33,5 @@ export const USER_RIGHTS = [
RIGHTS.HUMHUB_AUTO_LOGIN,
RIGHTS.PROJECT_BRANDING_VIEW,
RIGHTS.LIST_HUMHUB_SPACES,
RIGHTS.VIEW_USER_CONTACT,
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<ContributionListResult> {
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<ContributionListResult> {
// 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<User> {
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)
}
}

View File

@ -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<string> => {
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<UserContact> {
// 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<DbUser> {

View File

@ -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<T extends ObjectLiteral>(
info: GraphQLResolveInfo,
queryBuilder: SelectQueryBuilder<T>,
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}`))
}
}
}

View File

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

View File

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

View File

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