diff --git a/packages/server/.env-example b/packages/server/.env-example index ef5713dec..b98042555 100644 --- a/packages/server/.env-example +++ b/packages/server/.env-example @@ -7,11 +7,11 @@ # BIND_ADDRESS="127.0.0.1" PORT=3000 -CANONICAL_URL="http://localhost:3000" +CANONICAL_URL="http://127.0.0.1:3000" SESSION_SECRET="-> FILL IN <-" # Redis connection: default for local development environment -REDIS_URL="redis://localhost:6379" +REDIS_URL="redis://127.0.0.1:6379" ############################################################ # Frontend 2.0 settings @@ -20,7 +20,7 @@ REDIS_URL="redis://localhost:6379" # Whether server is meant to be used with Frontend 2.0 USE_FRONTEND_2=false -FRONTEND_ORIGIN="http://localhost:8081" +FRONTEND_ORIGIN="http://127.0.0.1:8081" # Stream to be used as the demo/tutorial stream in onboarding flows # (if not set/valid, stream will be a blank one) @@ -63,7 +63,7 @@ S3_CREATE_BUCKET="true" ############################################################ EMAIL=true EMAIL_FROM="speckle@speckle.local" -EMAIL_HOST="localhost" +EMAIL_HOST="127.0.0.1" EMAIL_PORT="1025" # EMAIL_HOST="-> FILL IN <-" @@ -124,4 +124,4 @@ STRATEGY_LOCAL=true # FRONTEND_HOST=localhost # FRONTEND_PORT=8081 -SPECKLE_AUTOMATE_URL="http://localhost:3030" +SPECKLE_AUTOMATE_URL="http://127.0.0.1:3030" diff --git a/packages/server/.env.test-example b/packages/server/.env.test-example index 26d3383a7..0662ea0b7 100644 --- a/packages/server/.env.test-example +++ b/packages/server/.env.test-example @@ -3,5 +3,5 @@ ############################################### PORT=0 -POSTGRES_URL=postgres://speckle:speckle@localhost/speckle2_test +POSTGRES_URL=postgres://speckle:speckle@127.0.0.1/speckle2_test POSTGRES_USER='' \ No newline at end of file diff --git a/packages/server/assets/core/typedefs/admin.graphql b/packages/server/assets/core/typedefs/admin.graphql new file mode 100644 index 000000000..6ec051c86 --- /dev/null +++ b/packages/server/assets/core/typedefs/admin.graphql @@ -0,0 +1,39 @@ +type AdminUserList { + items: [BaseUser!]! + cursor: String + totalCount: Int! +} + +type ServerStatistics { + totalProjectCount: Int! + totalUserCount: Int! + totalPendingInvites: Int! +} + +type AdminQueries { + userList( + limit: Int! = 25 + cursor: String = null + query: String = null + role: ServerRole = null + ): AdminUserList! @hasScope(scope: "users:read") + + projectList( + query: String + orderBy: String + visibility: String + limit: Int! = 25 + cursor: String = null + ): ProjectCollection! + + serverStatistics: ServerStatistics! @hasScope(scope: "server:stats") + + # inviteList( + # limit: Int! = 25 + # offset: Int! = 0 + # ) +} + +extend type Query { + admin: AdminQueries! @hasServerRole(role: SERVER_ADMIN) +} diff --git a/packages/server/assets/core/typedefs/streams.graphql b/packages/server/assets/core/typedefs/streams.graphql index ad0bdd264..77cbd7b63 100644 --- a/packages/server/assets/core/typedefs/streams.graphql +++ b/packages/server/assets/core/typedefs/streams.graphql @@ -22,7 +22,9 @@ extend type Query { orderBy: String visibility: String limit: Int = 25 - ): StreamCollection @hasRole(role: "server:admin") + ): StreamCollection + @hasRole(role: "server:admin") + @deprecated(reason: "use admin.projectList instead") """ All of the discoverable streams of the server diff --git a/packages/server/assets/core/typedefs/user.graphql b/packages/server/assets/core/typedefs/user.graphql index 9a5a9e256..de3882485 100644 --- a/packages/server/assets/core/typedefs/user.graphql +++ b/packages/server/assets/core/typedefs/user.graphql @@ -28,6 +28,7 @@ extend type Query { offset: Int! = 0 query: String = null ): AdminUsersListCollection + @deprecated(reason: "use admin.UserList instead") @hasRole(role: "server:admin") @hasScope(scope: "users:read") @@ -71,6 +72,28 @@ type PasswordStrengthCheckFeedback { suggestions: [String!]! } +# TODO: this is a blatant duplication, should we have interfaces for this? +type BaseUser { + id: ID! + """ + E-mail can be null, if it's requested for a user other than the authenticated one + and the user isn't an admin + """ + email: String + name: String! + bio: String + company: String + avatar: String + verified: Boolean + profiles: JSONObject + role: String + """ + Whether post-sign up onboarding has been finished or skipped entirely + """ + isOnboardingFinished: Boolean @isOwner + createdAt: DateTime @isOwner +} + """ Full user type, should only be used in the context of admin operations or when a user is reading/writing info about himself diff --git a/packages/server/assets/stats/typedefs/stats.gql b/packages/server/assets/stats/typedefs/stats.gql index 17d940748..98c29eb0b 100644 --- a/packages/server/assets/stats/typedefs/stats.gql +++ b/packages/server/assets/stats/typedefs/stats.gql @@ -1,5 +1,5 @@ extend type Query { - serverStats: ServerStats! + serverStats: ServerStats! @deprecated(reason: "use admin.serverStatistics instead") } type ServerStats { diff --git a/packages/server/modules/core/graph/directives/isOwner.ts b/packages/server/modules/core/graph/directives/isOwner.ts index 1436c088d..fcbdd3e63 100644 --- a/packages/server/modules/core/graph/directives/isOwner.ts +++ b/packages/server/modules/core/graph/directives/isOwner.ts @@ -3,6 +3,7 @@ import { ForbiddenError } from '@/modules/shared/errors' import { getDirective } from '@graphql-tools/utils' import { mapSchema } from '@graphql-tools/utils' import { MapperKind } from '@graphql-tools/utils' +import { Roles } from '@speckle/shared' import { defaultFieldResolver } from 'graphql' /** @@ -50,7 +51,8 @@ export const isOwner: GraphqlDirectiveBuilder = () => { const authUserId = context.userId if (info.parentType?.name === 'User') { - if (parentId !== authUserId) { + // allow admins to query private user fields + if (parentId !== authUserId && context.role !== Roles.Server.Admin) { throw new ForbiddenError( `You must be authenticated as the user whose '${fieldName}' value you wish to retrieve` ) diff --git a/packages/server/modules/core/graph/generated/graphql.ts b/packages/server/modules/core/graph/generated/graphql.ts index 6ce7d5be4..0d4b3f0ef 100644 --- a/packages/server/modules/core/graph/generated/graphql.ts +++ b/packages/server/modules/core/graph/generated/graphql.ts @@ -61,6 +61,37 @@ export type ActivityCollection = { totalCount: Scalars['Int']; }; +export type AdminQueries = { + __typename?: 'AdminQueries'; + projectList: ProjectCollection; + serverStatistics: ServerStatistics; + userList: AdminUserList; +}; + + +export type AdminQueriesProjectListArgs = { + cursor?: InputMaybe; + limit?: Scalars['Int']; + orderBy?: InputMaybe; + query?: InputMaybe; + visibility?: InputMaybe; +}; + + +export type AdminQueriesUserListArgs = { + cursor?: InputMaybe; + limit?: Scalars['Int']; + query?: InputMaybe; + role?: InputMaybe; +}; + +export type AdminUserList = { + __typename?: 'AdminUserList'; + cursor?: Maybe; + items: Array; + totalCount: Scalars['Int']; +}; + export type AdminUsersListCollection = { __typename?: 'AdminUsersListCollection'; items: Array; @@ -132,6 +163,26 @@ export type AuthStrategy = { url: Scalars['String']; }; +export type BaseUser = { + __typename?: 'BaseUser'; + avatar?: Maybe; + bio?: Maybe; + company?: Maybe; + createdAt?: Maybe; + /** + * E-mail can be null, if it's requested for a user other than the authenticated one + * and the user isn't an admin + */ + email?: Maybe; + id: Scalars['ID']; + /** Whether post-sign up onboarding has been finished or skipped entirely */ + isOnboardingFinished?: Maybe; + name: Scalars['String']; + profiles?: Maybe; + role?: Maybe; + verified?: Maybe; +}; + export type BlobMetadata = { __typename?: 'BlobMetadata'; createdAt: Scalars['DateTime']; @@ -1593,6 +1644,7 @@ export type Query = { _?: Maybe; /** Gets the profile of the authenticated user or null if not authenticated */ activeUser?: Maybe; + admin: AdminQueries; /** All the streams of the server. Available to admins only. */ adminStreams?: Maybe; /** @@ -1881,6 +1933,13 @@ export enum ServerRole { ServerUser = 'SERVER_USER' } +export type ServerStatistics = { + __typename?: 'ServerStatistics'; + totalPendingInvites: Scalars['Int']; + totalProjectCount: Scalars['Int']; + totalUserCount: Scalars['Int']; +}; + export type ServerStats = { __typename?: 'ServerStats'; /** An array of objects currently structured as { created_month: Date, count: int }. */ @@ -2712,6 +2771,8 @@ export type ResolversTypes = { ActiveUserMutations: ResolverTypeWrapper; Activity: ResolverTypeWrapper; ActivityCollection: ResolverTypeWrapper; + AdminQueries: ResolverTypeWrapper & { projectList: ResolversTypes['ProjectCollection'] }>; + AdminUserList: ResolverTypeWrapper; AdminUsersListCollection: ResolverTypeWrapper & { items: Array }>; AdminUsersListItem: ResolverTypeWrapper & { invitedUser?: Maybe, registeredUser?: Maybe }>; ApiToken: ResolverTypeWrapper; @@ -2720,6 +2781,7 @@ export type ResolversTypes = { AppCreateInput: AppCreateInput; AppUpdateInput: AppUpdateInput; AuthStrategy: ResolverTypeWrapper; + BaseUser: ResolverTypeWrapper; BigInt: ResolverTypeWrapper; BlobMetadata: ResolverTypeWrapper; BlobMetadataCollection: ResolverTypeWrapper; @@ -2821,6 +2883,7 @@ export type ResolversTypes = { ServerInvite: ResolverTypeWrapper & { invitedBy: ResolversTypes['LimitedUser'] }>; ServerInviteCreateInput: ServerInviteCreateInput; ServerRole: ServerRole; + ServerStatistics: ResolverTypeWrapper; ServerStats: ResolverTypeWrapper; SmartTextEditorValue: ResolverTypeWrapper; SortDirection: SortDirection; @@ -2870,6 +2933,8 @@ export type ResolversParentTypes = { ActiveUserMutations: MutationsObjectGraphQLReturn; Activity: Activity; ActivityCollection: ActivityCollection; + AdminQueries: Omit & { projectList: ResolversParentTypes['ProjectCollection'] }; + AdminUserList: AdminUserList; AdminUsersListCollection: Omit & { items: Array }; AdminUsersListItem: Omit & { invitedUser?: Maybe, registeredUser?: Maybe }; ApiToken: ApiToken; @@ -2878,6 +2943,7 @@ export type ResolversParentTypes = { AppCreateInput: AppCreateInput; AppUpdateInput: AppUpdateInput; AuthStrategy: AuthStrategy; + BaseUser: BaseUser; BigInt: Scalars['BigInt']; BlobMetadata: BlobMetadata; BlobMetadataCollection: BlobMetadataCollection; @@ -2969,6 +3035,7 @@ export type ResolversParentTypes = { ServerInfoUpdateInput: ServerInfoUpdateInput; ServerInvite: Omit & { invitedBy: ResolversParentTypes['LimitedUser'] }; ServerInviteCreateInput: ServerInviteCreateInput; + ServerStatistics: ServerStatistics; ServerStats: ServerStats; SmartTextEditorValue: SmartTextEditorValue; Stream: StreamGraphQLReturn; @@ -3069,6 +3136,20 @@ export type ActivityCollectionResolvers; }; +export type AdminQueriesResolvers = { + projectList?: Resolver>; + serverStatistics?: Resolver; + userList?: Resolver>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type AdminUserListResolvers = { + cursor?: Resolver, ParentType, ContextType>; + items?: Resolver, ParentType, ContextType>; + totalCount?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type AdminUsersListCollectionResolvers = { items?: Resolver, ParentType, ContextType>; totalCount?: Resolver; @@ -3109,6 +3190,21 @@ export type AuthStrategyResolvers; }; +export type BaseUserResolvers = { + avatar?: Resolver, ParentType, ContextType>; + bio?: Resolver, ParentType, ContextType>; + company?: Resolver, ParentType, ContextType>; + createdAt?: Resolver, ParentType, ContextType>; + email?: Resolver, ParentType, ContextType>; + id?: Resolver; + isOnboardingFinished?: Resolver, ParentType, ContextType>; + name?: Resolver; + profiles?: Resolver, ParentType, ContextType>; + role?: Resolver, ParentType, ContextType>; + verified?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + export interface BigIntScalarConfig extends GraphQLScalarTypeConfig { name: 'BigInt'; } @@ -3579,6 +3675,7 @@ export type ProjectVersionsUpdatedMessageResolvers = { _?: Resolver, ParentType, ContextType>; activeUser?: Resolver, ParentType, ContextType>; + admin?: Resolver; adminStreams?: Resolver, ParentType, ContextType, RequireFields>; adminUsers?: Resolver, ParentType, ContextType, RequireFields>; app?: Resolver, ParentType, ContextType, RequireFields>; @@ -3673,6 +3770,13 @@ export type ServerInviteResolvers; }; +export type ServerStatisticsResolvers = { + totalPendingInvites?: Resolver; + totalProjectCount?: Resolver; + totalUserCount?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type ServerStatsResolvers = { commitHistory?: Resolver>>, ParentType, ContextType>; objectHistory?: Resolver>>, ParentType, ContextType>; @@ -3911,11 +4015,14 @@ export type Resolvers = { ActiveUserMutations?: ActiveUserMutationsResolvers; Activity?: ActivityResolvers; ActivityCollection?: ActivityCollectionResolvers; + AdminQueries?: AdminQueriesResolvers; + AdminUserList?: AdminUserListResolvers; AdminUsersListCollection?: AdminUsersListCollectionResolvers; AdminUsersListItem?: AdminUsersListItemResolvers; ApiToken?: ApiTokenResolvers; AppAuthor?: AppAuthorResolvers; AuthStrategy?: AuthStrategyResolvers; + BaseUser?: BaseUserResolvers; BigInt?: GraphQLScalarType; BlobMetadata?: BlobMetadataResolvers; BlobMetadataCollection?: BlobMetadataCollectionResolvers; @@ -3968,6 +4075,7 @@ export type Resolvers = { ServerAppListItem?: ServerAppListItemResolvers; ServerInfo?: ServerInfoResolvers; ServerInvite?: ServerInviteResolvers; + ServerStatistics?: ServerStatisticsResolvers; ServerStats?: ServerStatsResolvers; SmartTextEditorValue?: SmartTextEditorValueResolvers; Stream?: StreamResolvers; diff --git a/packages/server/modules/core/graph/resolvers/admin.ts b/packages/server/modules/core/graph/resolvers/admin.ts new file mode 100644 index 000000000..b5fbd2393 --- /dev/null +++ b/packages/server/modules/core/graph/resolvers/admin.ts @@ -0,0 +1,59 @@ +import { Resolvers, ServerRole } from '@/modules/core/graph/generated/graphql' +import { mapServerRoleToValue } from '@/modules/core/helpers/graphTypes' +import { adminProjectList, adminUserList } from '@/modules/core/services/admin' +import { getTotalStreamCount, getTotalUserCount } from '@/modules/stats/services' + +type CursorAndLimit = { + cursor: string | null + limit: number +} + +type Query = { + query: string | null +} + +type UserListArgs = CursorAndLimit & + Query & { + role: ServerRole | null + } + +type ProjectListArgs = CursorAndLimit & + Query & { + orderBy: string + visibility: string + } + +export = { + Query: { + admin: () => ({}) + }, + AdminQueries: { + async userList(_parent, { limit, cursor, query, role }: UserListArgs) { + return await adminUserList({ + limit, + cursor, + query, + role: mapServerRoleToValue(role) + }) + }, + // async inviteList() { + + // } + async projectList(_parent, args: ProjectListArgs) { + return await adminProjectList(args) + }, + serverStatistics: () => ({}) + }, + ServerStatistics: { + async totalProjectCount() { + return await getTotalStreamCount() + }, + + async totalUserCount() { + return await getTotalUserCount() + }, + async totalPendingInvites() { + return 0 + } + } +} as Resolvers diff --git a/packages/server/modules/core/repositories/users.ts b/packages/server/modules/core/repositories/users.ts index baf98de80..f653dbfc2 100644 --- a/packages/server/modules/core/repositories/users.ts +++ b/packages/server/modules/core/repositories/users.ts @@ -1,16 +1,22 @@ import { ServerAcl, Users, knex } from '@/modules/core/dbSchema' import { LimitedUserRecord, UserRecord } from '@/modules/core/helpers/types' import { Nullable } from '@/modules/shared/helpers/typeHelper' -import { isArray } from 'lodash' +import { clamp, isArray } from 'lodash' import { metaHelpers } from '@/modules/core/helpers/meta' import { UserValidationError } from '@/modules/core/errors/user' +import { ServerRoles } from '@speckle/shared' +import { Knex } from 'knex' export type UserWithOptionalRole = User & { /** * Available, if query joined this data from server_acl * (this can be the server role or stream role depending on how and where this was retrieved) */ - role?: string + role?: ServerRoles +} + +export type UserWithRole = User & { + role: ServerRoles } export type GetUserParams = Partial<{ @@ -55,6 +61,62 @@ export async function getUsers( return (await q).map((u) => (skipClean ? u : sanitizeUserRecord(u))) } +type UserQuery = { + query: string | null + role: ServerRoles | null +} + +const getUsersBaseQuery = (q: Knex.QueryBuilder, { query, role }: UserQuery) => { + if (query) { + q.where((queryBuilder) => { + queryBuilder + .where('email', 'ILIKE', `%${query}%`) + .orWhere('name', 'ILIKE', `%${query}%`) + .orWhere('company', 'ILIKE', `%${query}%`) + }) + } + if (role) q.where({ role }) + return q +} +/** + * List users + */ +export async function listUsers({ + limit, + cursor, + query, + role +}: { + limit: number + cursor: Date | null +} & UserQuery): Promise { + const sanitizedLimit = clamp(limit, 1, 200) + const q = Users.knex() + .orderBy(Users.col.createdAt, 'desc') + .limit(sanitizedLimit) + .columns([ + ...Object.values(Users.col), + // Getting first role from grouped results + knex.raw(`(array_agg("server_acl"."role"))[1] as role`) + ]) + .leftJoin(ServerAcl.name, ServerAcl.col.userId, Users.col.id) + .groupBy(Users.col.id) + if (cursor) q.where(Users.col.createdAt, '<', cursor) + const users: UserWithRole[] = await getUsersBaseQuery(q, { query, role }) + return users.map((u) => sanitizeUserRecord(u)) +} + +export async function countUsers(args: UserQuery): Promise { + // const result = await getUsersBaseQuery(Users.knex(), args).countDistinct(Users.col.id) + const q = Users.knex() + .leftJoin(ServerAcl.name, ServerAcl.col.userId, Users.col.id) + .countDistinct(Users.col.id) + const result = await getUsersBaseQuery(q, args) + // .groupBy(Users.col.id) + // const result = await q + return parseInt(result[0]['count']) +} + /** * Get user by ID */ diff --git a/packages/server/modules/core/services/admin.ts b/packages/server/modules/core/services/admin.ts new file mode 100644 index 000000000..1589e7ce9 --- /dev/null +++ b/packages/server/modules/core/services/admin.ts @@ -0,0 +1,75 @@ +import { StreamRecord, UserRecord } from '@/modules/core/helpers/types' +import { listUsers, countUsers } from '@/modules/core/repositories/users' +import { getStreams } from '@/modules/core/services/streams' +import { BaseError } from '@/modules/shared/errors/base' +import { ServerRoles } from '@speckle/shared' + +type HasCursor = { + cursor: string | null +} + +type Collection = HasCursor & { + items: T[] + totalCount: number +} + +type AdminUserListArgs = HasCursor & { + limit: number + query: string | null + role: ServerRoles | null +} + +export class CursorParsingError extends BaseError { + static defaultMessage = 'Invalid cursor provided' + static code = 'INVALID_CURSOR_VALUE' +} + +export const parseCursorToDate = (cursor: string): Date => { + const timestamp = Date.parse(Buffer.from(cursor, 'base64').toString('utf-8')) + if (isNaN(timestamp)) throw new CursorParsingError() + return new Date(timestamp) +} + +export const convertDateToCursor = (date: Date): string => + Buffer.from(date.toISOString()).toString('base64') + +export const adminUserList = async ( + args: AdminUserListArgs +): Promise> => { + const parsedCursor = args.cursor ? parseCursorToDate(args.cursor) : null + const [items, totalCount] = await Promise.all([ + listUsers({ + role: args.role, + cursor: parsedCursor, + limit: args.limit, + query: args.query + }), + countUsers(args) + ]) + const cursor = items.length ? convertDateToCursor(items.slice(-1)[0].createdAt) : null + return { totalCount, items, cursor } +} + +type AdminProjectListArgs = HasCursor & { + query: string | null + orderBy: string + visibility: string + limit: number +} + +export const adminProjectList = async ( + args: AdminProjectListArgs +): Promise> => { + const parsedCursor = args.cursor ? parseCursorToDate(args.cursor) : null + const { streams, totalCount, cursorDate } = await getStreams({ + ...args, + searchQuery: args.query, + cursor: parsedCursor + }) + const cursor = cursorDate ? convertDateToCursor(cursorDate) : null + return { + cursor, + items: streams, + totalCount + } +} diff --git a/packages/server/modules/core/services/streams.js b/packages/server/modules/core/services/streams.js index 1a7b8c2e9..210b037e1 100644 --- a/packages/server/modules/core/services/streams.js +++ b/packages/server/modules/core/services/streams.js @@ -73,16 +73,8 @@ module.exports = { return await deleteStreamFromDb(streamId) }, - async getStreams({ offset, limit, orderBy, visibility, searchQuery }) { - const query = knex - .column( - 'streams.*', - knex.raw('coalesce(sum(pg_column_size(objects.data)),0) as size') - ) - .select() - .from('streams') - .leftJoin('objects', 'streams.id', 'objects.streamId') - .groupBy('streams.id') + async getStreams({ cursor, limit, orderBy, visibility, searchQuery }) { + const query = knex.select().from('streams') const countQuery = Streams.knex() @@ -116,9 +108,12 @@ module.exports = { const [columnName, order] = orderBy.split(',') - const rows = await query.orderBy(`${columnName}`, order).offset(offset).limit(limit) + if (cursor) query.where(columnName, order === 'desc' ? '<' : '>', cursor) - return { streams: rows, totalCount: count } + const rows = await query.orderBy(`${columnName}`, order).limit(limit) + + const cursorDate = rows.length ? rows.slice(-1)[0][columnName] : null + return { streams: rows, totalCount: count, cursorDate } }, async getStreamUsers({ streamId }) { diff --git a/packages/server/modules/core/tests/graph.spec.js b/packages/server/modules/core/tests/graph.spec.js index ecbcd1528..1d65f6e45 100644 --- a/packages/server/modules/core/tests/graph.spec.js +++ b/packages/server/modules/core/tests/graph.spec.js @@ -607,11 +607,6 @@ describe('GraphQL API Core @core-api', () => { expect(streamResults.body.data.adminStreams.totalCount).to.equal(10) expect(streamResults.body.data.adminStreams.items.length).to.equal(2) - streamResults = await sendRequest(userA.token, { - query: '{ adminStreams(offset: 5) { totalCount items { id name } } }' - }) - expect(streamResults.body.data.adminStreams.items.length).to.equal(5) - streamResults = await sendRequest(userA.token, { query: '{ adminStreams( query: "Admin" ) { totalCount items { id name } } }' }) diff --git a/packages/server/modules/stats/graph/resolvers/stats.js b/packages/server/modules/stats/graph/resolvers/stats.js index 5cf49e8ab..d45e87bf3 100644 --- a/packages/server/modules/stats/graph/resolvers/stats.js +++ b/packages/server/modules/stats/graph/resolvers/stats.js @@ -13,6 +13,9 @@ const { module.exports = { Query: { + /** + * @deprecated('Use admin.serverStatistics') + */ async serverStats(parent, args, context) { await validateServerRole(context, 'server:admin') await validateScopes(context.scopes, 'server:stats') diff --git a/packages/server/modules/stats/services/index.js b/packages/server/modules/stats/services/index.js index e988f5093..38a2ec9e0 100644 --- a/packages/server/modules/stats/services/index.js +++ b/packages/server/modules/stats/services/index.js @@ -21,6 +21,8 @@ module.exports = { }, async getTotalUserCount() { + // returns -1 for small tables, no good + // const fastQuery = "SELECT reltuples::bigint AS estimate FROM pg_catalog.pg_class WHERE relname = 'users'" const query = 'SELECT COUNT(*) FROM users' const result = await knex.raw(query) return parseInt(result.rows[0].count) diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts index 93ffb6d54..5ac1f7118 100644 --- a/packages/server/test/graphql/generated/graphql.ts +++ b/packages/server/test/graphql/generated/graphql.ts @@ -52,6 +52,37 @@ export type ActivityCollection = { totalCount: Scalars['Int']; }; +export type AdminQueries = { + __typename?: 'AdminQueries'; + projectList: ProjectCollection; + serverStatistics: ServerStatistics; + userList: AdminUserList; +}; + + +export type AdminQueriesProjectListArgs = { + cursor?: InputMaybe; + limit?: Scalars['Int']; + orderBy?: InputMaybe; + query?: InputMaybe; + visibility?: InputMaybe; +}; + + +export type AdminQueriesUserListArgs = { + cursor?: InputMaybe; + limit?: Scalars['Int']; + query?: InputMaybe; + role?: InputMaybe; +}; + +export type AdminUserList = { + __typename?: 'AdminUserList'; + cursor?: Maybe; + items: Array; + totalCount: Scalars['Int']; +}; + export type AdminUsersListCollection = { __typename?: 'AdminUsersListCollection'; items: Array; @@ -123,6 +154,26 @@ export type AuthStrategy = { url: Scalars['String']; }; +export type BaseUser = { + __typename?: 'BaseUser'; + avatar?: Maybe; + bio?: Maybe; + company?: Maybe; + createdAt?: Maybe; + /** + * E-mail can be null, if it's requested for a user other than the authenticated one + * and the user isn't an admin + */ + email?: Maybe; + id: Scalars['ID']; + /** Whether post-sign up onboarding has been finished or skipped entirely */ + isOnboardingFinished?: Maybe; + name: Scalars['String']; + profiles?: Maybe; + role?: Maybe; + verified?: Maybe; +}; + export type BlobMetadata = { __typename?: 'BlobMetadata'; createdAt: Scalars['DateTime']; @@ -1584,6 +1635,7 @@ export type Query = { _?: Maybe; /** Gets the profile of the authenticated user or null if not authenticated */ activeUser?: Maybe; + admin: AdminQueries; /** All the streams of the server. Available to admins only. */ adminStreams?: Maybe; /** @@ -1872,6 +1924,13 @@ export enum ServerRole { ServerUser = 'SERVER_USER' } +export type ServerStatistics = { + __typename?: 'ServerStatistics'; + totalPendingInvites: Scalars['Int']; + totalProjectCount: Scalars['Int']; + totalUserCount: Scalars['Int']; +}; + export type ServerStats = { __typename?: 'ServerStats'; /** An array of objects currently structured as { created_month: Date, count: int }. */