diff --git a/admin/src/graphql/communityStatistics.js b/admin/src/graphql/communityStatistics.js new file mode 100644 index 000000000..868bfd02a --- /dev/null +++ b/admin/src/graphql/communityStatistics.js @@ -0,0 +1,15 @@ +import gql from 'graphql-tag' + +export const communityStatistics = gql` + query { + communityStatistics { + totalUsers + activeUsers + deletedUsers + totalGradidoCreated + totalGradidoDecayed + totalGradidoAvailable + totalGradidoUnbookedDecayed + } + } +` diff --git a/backend/src/auth/RIGHTS.ts b/backend/src/auth/RIGHTS.ts index 1e38eab7f..0d8252402 100644 --- a/backend/src/auth/RIGHTS.ts +++ b/backend/src/auth/RIGHTS.ts @@ -31,6 +31,8 @@ export enum RIGHTS { LIST_ALL_CONTRIBUTIONS = 'LIST_ALL_CONTRIBUTIONS', UPDATE_CONTRIBUTION = 'UPDATE_CONTRIBUTION', LIST_CONTRIBUTION_LINKS = 'LIST_CONTRIBUTION_LINKS', + COMMUNITY_STATISTICS = 'COMMUNITY_STATISTICS', + SEARCH_ADMIN_USERS = 'SEARCH_ADMIN_USERS', // Admin SEARCH_USERS = 'SEARCH_USERS', SET_USER_ROLE = 'SET_USER_ROLE', diff --git a/backend/src/auth/ROLES.ts b/backend/src/auth/ROLES.ts index 500c8bec4..f14e77b17 100644 --- a/backend/src/auth/ROLES.ts +++ b/backend/src/auth/ROLES.ts @@ -28,7 +28,9 @@ export const ROLE_USER = new Role('user', [ RIGHTS.LIST_CONTRIBUTIONS, RIGHTS.LIST_ALL_CONTRIBUTIONS, RIGHTS.UPDATE_CONTRIBUTION, + RIGHTS.SEARCH_ADMIN_USERS, RIGHTS.LIST_CONTRIBUTION_LINKS, + RIGHTS.COMMUNITY_STATISTICS, ]) export const ROLE_ADMIN = new Role('admin', Object.values(RIGHTS)) // all rights diff --git a/backend/src/graphql/model/AdminUser.ts b/backend/src/graphql/model/AdminUser.ts new file mode 100644 index 000000000..92a22b7f1 --- /dev/null +++ b/backend/src/graphql/model/AdminUser.ts @@ -0,0 +1,25 @@ +import { User } from '@entity/User' +import { Field, Int, ObjectType } from 'type-graphql' + +@ObjectType() +export class AdminUser { + constructor(user: User) { + this.firstName = user.firstName + this.lastName = user.lastName + } + + @Field(() => String) + firstName: string + + @Field(() => String) + lastName: string +} + +@ObjectType() +export class SearchAdminUsersResult { + @Field(() => Int) + userCount: number + + @Field(() => [AdminUser]) + userList: AdminUser[] +} diff --git a/backend/src/graphql/model/CommunityStatistics.ts b/backend/src/graphql/model/CommunityStatistics.ts new file mode 100644 index 000000000..61354115c --- /dev/null +++ b/backend/src/graphql/model/CommunityStatistics.ts @@ -0,0 +1,26 @@ +import { ObjectType, Field } from 'type-graphql' +import Decimal from 'decimal.js-light' + +@ObjectType() +export class CommunityStatistics { + @Field(() => Number) + totalUsers: number + + @Field(() => Number) + activeUsers: number + + @Field(() => Number) + deletedUsers: number + + @Field(() => Decimal) + totalGradidoCreated: Decimal + + @Field(() => Decimal) + totalGradidoDecayed: Decimal + + @Field(() => Decimal) + totalGradidoAvailable: Decimal + + @Field(() => Decimal) + totalGradidoUnbookedDecayed: Decimal +} diff --git a/backend/src/graphql/resolver/StatisticsResolver.ts b/backend/src/graphql/resolver/StatisticsResolver.ts new file mode 100644 index 000000000..4c1500839 --- /dev/null +++ b/backend/src/graphql/resolver/StatisticsResolver.ts @@ -0,0 +1,77 @@ +import { Resolver, Query, Authorized } from 'type-graphql' +import { RIGHTS } from '@/auth/RIGHTS' +import { CommunityStatistics } from '@model/CommunityStatistics' +import { User as DbUser } from '@entity/User' +import { Transaction as DbTransaction } from '@entity/Transaction' +import { getConnection } from '@dbTools/typeorm' +import Decimal from 'decimal.js-light' +import { calculateDecay } from '@/util/decay' + +@Resolver() +export class StatisticsResolver { + @Authorized([RIGHTS.COMMUNITY_STATISTICS]) + @Query(() => CommunityStatistics) + async communityStatistics(): Promise { + const allUsers = await DbUser.find({ withDeleted: true }) + + let totalUsers = 0 + let activeUsers = 0 + let deletedUsers = 0 + + let totalGradidoAvailable: Decimal = new Decimal(0) + let totalGradidoUnbookedDecayed: Decimal = new Decimal(0) + + const receivedCallDate = new Date() + + for (let i = 0; i < allUsers.length; i++) { + if (allUsers[i].deletedAt) { + deletedUsers++ + } else { + totalUsers++ + const lastTransaction = await DbTransaction.findOne({ + where: { userId: allUsers[i].id }, + order: { balanceDate: 'DESC' }, + }) + if (lastTransaction) { + activeUsers++ + const decay = calculateDecay( + lastTransaction.balance, + lastTransaction.balanceDate, + receivedCallDate, + ) + if (decay) { + totalGradidoAvailable = totalGradidoAvailable.plus(decay.balance.toString()) + totalGradidoUnbookedDecayed = totalGradidoUnbookedDecayed.plus(decay.decay.toString()) + } + } + } + } + + const queryRunner = getConnection().createQueryRunner() + await queryRunner.connect() + + 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() + + return { + totalUsers, + activeUsers, + deletedUsers, + totalGradidoCreated, + totalGradidoDecayed, + totalGradidoAvailable, + totalGradidoUnbookedDecayed, + } + } +} diff --git a/backend/src/graphql/resolver/UserResolver.test.ts b/backend/src/graphql/resolver/UserResolver.test.ts index 35a569c4b..c7727ca5f 100644 --- a/backend/src/graphql/resolver/UserResolver.test.ts +++ b/backend/src/graphql/resolver/UserResolver.test.ts @@ -5,7 +5,7 @@ import { testEnvironment, headerPushMock, resetToken, cleanDB, resetEntity } fro import { userFactory } from '@/seeds/factory/user' import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg' import { createUser, setPassword, forgotPassword, updateUserInfos } from '@/seeds/graphql/mutations' -import { login, logout, verifyLogin, queryOptIn } from '@/seeds/graphql/queries' +import { login, logout, verifyLogin, queryOptIn, searchAdminUsers } from '@/seeds/graphql/queries' import { GraphQLError } from 'graphql' import { LoginEmailOptIn } from '@entity/LoginEmailOptIn' import { User } from '@entity/User' @@ -21,6 +21,7 @@ import { ContributionLink } from '@model/ContributionLink' import { logger } from '@test/testSetup' import { validate as validateUUID, version as versionUUID } from 'uuid' +import { peterLustig } from '@/seeds/users/peter-lustig' // import { klicktippSignIn } from '@/apis/KlicktippController' @@ -878,6 +879,51 @@ bei Gradidio sei dabei!`, }) }) }) + + describe('searchAdminUsers', () => { + describe('unauthenticated', () => { + it('throws an error', async () => { + resetToken() + await expect(mutate({ mutation: searchAdminUsers })).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('authenticated', () => { + beforeAll(async () => { + await userFactory(testEnv, bibiBloxberg) + await userFactory(testEnv, peterLustig) + await query({ + query: login, + variables: { + email: 'bibi@bloxberg.de', + password: 'Aa12345_', + }, + }) + }) + + it('finds peter@lustig.de', async () => { + await expect(mutate({ mutation: searchAdminUsers })).resolves.toEqual( + expect.objectContaining({ + data: { + searchAdminUsers: { + userCount: 1, + userList: expect.arrayContaining([ + expect.objectContaining({ + firstName: 'Peter', + lastName: 'Lustig', + }), + ]), + }, + }, + }), + ) + }) + }) + }) }) describe('printTimeDuration', () => { diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index aa32197b8..ff11c841e 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -3,7 +3,7 @@ import { backendLogger as logger } from '@/server/logger' import { Context, getUser } from '@/server/context' import { Resolver, Query, Args, Arg, Authorized, Ctx, UseMiddleware, Mutation } from 'type-graphql' -import { getConnection } from '@dbTools/typeorm' +import { getConnection, getCustomRepository, IsNull, Not } from '@dbTools/typeorm' import CONFIG from '@/config' import { User } from '@model/User' import { User as DbUser } from '@entity/User' @@ -32,6 +32,10 @@ import { EventSendConfirmationEmail, } from '@/event/Event' import { getUserCreation } from './util/creations' +import { UserRepository } from '@/typeorm/repository/User' +import { SearchAdminUsersResult } from '@model/AdminUser' +import Paginated from '@arg/Paginated' +import { Order } from '@enum/Order' import { v4 as uuidv4 } from 'uuid' // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -752,6 +756,36 @@ export class UserResolver { logger.debug(`has ElopageBuys = ${elopageBuys}`) return elopageBuys } + + @Authorized([RIGHTS.SEARCH_ADMIN_USERS]) + @Query(() => SearchAdminUsersResult) + async searchAdminUsers( + @Args() + { currentPage = 1, pageSize = 25, order = Order.DESC }: Paginated, + ): Promise { + const userRepository = getCustomRepository(UserRepository) + + const [users, count] = await userRepository.findAndCount({ + where: { + isAdmin: Not(IsNull()), + }, + order: { + createdAt: order, + }, + skip: (currentPage - 1) * pageSize, + take: pageSize, + }) + + return { + userCount: count, + userList: users.map((user) => { + return { + firstName: user.firstName, + lastName: user.lastName, + } + }), + } + } } const isTimeExpired = (optIn: LoginEmailOptIn, duration: number): boolean => { diff --git a/backend/src/seeds/graphql/queries.ts b/backend/src/seeds/graphql/queries.ts index 9f7a02e70..3bd042ac2 100644 --- a/backend/src/seeds/graphql/queries.ts +++ b/backend/src/seeds/graphql/queries.ts @@ -280,3 +280,15 @@ export const listContributionLinks = gql` } } ` + +export const searchAdminUsers = gql` + query { + searchAdminUsers { + userCount + userList { + firstName + lastName + } + } + } +`