diff --git a/packages/server/.env-example b/packages/server/.env-example index 191a08a76..8130a3a26 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" # URL of a project on any FE2 speckle server that will be pulled in and used as the onboarding stream ONBOARDING_STREAM_URL=https://latest.speckle.systems/projects/843d07eb10 @@ -64,8 +64,8 @@ S3_CREATE_BUCKET="true" # Emails ############################################################ EMAIL=true +EMAIL_HOST="127.0.0.1" EMAIL_FROM="no-reply@example.org" -EMAIL_HOST="localhost" EMAIL_PORT="1025" # EMAIL_HOST="-> FILL IN <-" @@ -126,4 +126,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..6755f270c --- /dev/null +++ b/packages/server/assets/core/typedefs/admin.graphql @@ -0,0 +1,34 @@ +type AdminUserList { + items: [LimitedUser!]! + 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") +} + +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..370c8ba5b 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") 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/codegen.yml b/packages/server/codegen.yml index 48554bd8c..4646f293d 100644 --- a/packages/server/codegen.yml +++ b/packages/server/codegen.yml @@ -24,6 +24,8 @@ generates: ModelMutations: '@/modules/core/helpers/graphTypes#MutationsObjectGraphQLReturn' VersionMutations: '@/modules/core/helpers/graphTypes#MutationsObjectGraphQLReturn' CommentMutations: '@/modules/core/helpers/graphTypes#MutationsObjectGraphQLReturn' + AdminQueries: '@/modules/core/helpers/graphTypes#GraphQLEmptyReturn' + ServerStatistics: '@/modules/core/helpers/graphTypes#GraphQLEmptyReturn' CommentReplyAuthorCollection: '@/modules/comments/helpers/graphTypes#CommentReplyAuthorCollectionGraphQLReturn' Comment: '@/modules/comments/helpers/graphTypes#CommentGraphQLReturn' PendingStreamCollaborator: '@/modules/serverinvites/helpers/graphTypes#PendingStreamCollaboratorGraphQLReturn' 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..db9dbe95e 100644 --- a/packages/server/modules/core/graph/generated/graphql.ts +++ b/packages/server/modules/core/graph/generated/graphql.ts @@ -1,5 +1,5 @@ import { GraphQLResolveInfo, GraphQLScalarType, GraphQLScalarTypeConfig } from 'graphql'; -import { StreamGraphQLReturn, CommitGraphQLReturn, ProjectGraphQLReturn, VersionGraphQLReturn, ModelGraphQLReturn, ModelsTreeItemGraphQLReturn, LimitedUserGraphQLReturn, MutationsObjectGraphQLReturn } from '@/modules/core/helpers/graphTypes'; +import { StreamGraphQLReturn, CommitGraphQLReturn, ProjectGraphQLReturn, VersionGraphQLReturn, ModelGraphQLReturn, ModelsTreeItemGraphQLReturn, LimitedUserGraphQLReturn, MutationsObjectGraphQLReturn, GraphQLEmptyReturn } from '@/modules/core/helpers/graphTypes'; import { StreamAccessRequestGraphQLReturn } from '@/modules/accessrequests/helpers/graphTypes'; import { CommentReplyAuthorCollectionGraphQLReturn, CommentGraphQLReturn } from '@/modules/comments/helpers/graphTypes'; import { PendingStreamCollaboratorGraphQLReturn } from '@/modules/serverinvites/helpers/graphTypes'; @@ -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; @@ -1593,11 +1624,16 @@ export type Query = { _?: Maybe; /** Gets the profile of the authenticated user or null if not authenticated */ activeUser?: Maybe; - /** All the streams of the server. Available to admins only. */ + admin: AdminQueries; + /** + * All the streams of the server. Available to admins only. + * @deprecated use admin.projectList instead + */ adminStreams?: Maybe; /** * Get all (or search for specific) users, registered or invited, from the server in a paginated view. * The query looks for matches in name, company and email. + * @deprecated use admin.UserList instead */ adminUsers?: Maybe; /** Gets a specific app from the server. */ @@ -1627,6 +1663,7 @@ export type Query = { */ projectInvite?: Maybe; serverInfo: ServerInfo; + /** @deprecated use admin.serverStatistics instead */ serverStats: ServerStats; /** * Returns a specific stream. Will throw an authorization error if active user isn't authorized @@ -1881,6 +1918,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 +2756,8 @@ export type ResolversTypes = { ActiveUserMutations: ResolverTypeWrapper; Activity: ResolverTypeWrapper; ActivityCollection: ResolverTypeWrapper; + AdminQueries: ResolverTypeWrapper; + AdminUserList: ResolverTypeWrapper & { items: Array }>; AdminUsersListCollection: ResolverTypeWrapper & { items: Array }>; AdminUsersListItem: ResolverTypeWrapper & { invitedUser?: Maybe, registeredUser?: Maybe }>; ApiToken: ResolverTypeWrapper; @@ -2821,6 +2867,7 @@ export type ResolversTypes = { ServerInvite: ResolverTypeWrapper & { invitedBy: ResolversTypes['LimitedUser'] }>; ServerInviteCreateInput: ServerInviteCreateInput; ServerRole: ServerRole; + ServerStatistics: ResolverTypeWrapper; ServerStats: ResolverTypeWrapper; SmartTextEditorValue: ResolverTypeWrapper; SortDirection: SortDirection; @@ -2870,6 +2917,8 @@ export type ResolversParentTypes = { ActiveUserMutations: MutationsObjectGraphQLReturn; Activity: Activity; ActivityCollection: ActivityCollection; + AdminQueries: GraphQLEmptyReturn; + AdminUserList: Omit & { items: Array }; AdminUsersListCollection: Omit & { items: Array }; AdminUsersListItem: Omit & { invitedUser?: Maybe, registeredUser?: Maybe }; ApiToken: ApiToken; @@ -2969,6 +3018,7 @@ export type ResolversParentTypes = { ServerInfoUpdateInput: ServerInfoUpdateInput; ServerInvite: Omit & { invitedBy: ResolversParentTypes['LimitedUser'] }; ServerInviteCreateInput: ServerInviteCreateInput; + ServerStatistics: GraphQLEmptyReturn; ServerStats: ServerStats; SmartTextEditorValue: SmartTextEditorValue; Stream: StreamGraphQLReturn; @@ -3069,6 +3119,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; @@ -3579,6 +3643,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 +3738,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,6 +3983,8 @@ export type Resolvers = { ActiveUserMutations?: ActiveUserMutationsResolvers; Activity?: ActivityResolvers; ActivityCollection?: ActivityCollectionResolvers; + AdminQueries?: AdminQueriesResolvers; + AdminUserList?: AdminUserListResolvers; AdminUsersListCollection?: AdminUsersListCollectionResolvers; AdminUsersListItem?: AdminUsersListItemResolvers; ApiToken?: ApiTokenResolvers; @@ -3968,6 +4042,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..09d214c0c --- /dev/null +++ b/packages/server/modules/core/graph/resolvers/admin.ts @@ -0,0 +1,42 @@ +import { Resolvers } 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' + +export = { + Query: { + admin: () => ({}) + }, + AdminQueries: { + async userList(_parent, { limit, cursor, query, role }) { + return await adminUserList({ + limit, + cursor, + query, + role: role ? mapServerRoleToValue(role) : null + }) + }, + async projectList(_parent, args) { + return await adminProjectList({ + query: args.query ?? null, + orderBy: args.orderBy ?? null, + visibility: args.visibility ?? null, + limit: args.limit, + cursor: args.cursor + }) + }, + 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/helpers/graphTypes.ts b/packages/server/modules/core/helpers/graphTypes.ts index a3276569a..ea03396ff 100644 --- a/packages/server/modules/core/helpers/graphTypes.ts +++ b/packages/server/modules/core/helpers/graphTypes.ts @@ -96,6 +96,12 @@ export type ModelsTreeItemGraphQLReturn = Omit +/** + * Use this to override the generated graphql type, in cases like graphql resolver + * collection objects + */ +export type GraphQLEmptyReturn = Record + /** * Map GQL StreamRole enum to the value types we use in the backend */ diff --git a/packages/server/modules/core/repositories/users.ts b/packages/server/modules/core/repositories/users.ts index 37427a3f9..12591900e 100644 --- a/packages/server/modules/core/repositories/users.ts +++ b/packages/server/modules/core/repositories/users.ts @@ -1,17 +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 { Roles } from '@speckle/shared' +import { Knex } from 'knex' +import { Roles, ServerRoles } from '@speckle/shared' 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<{ @@ -56,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..a84069a36 --- /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 ?? null + }), + 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 | null + visibility: string | null + 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/cross-server-sync/graph/generated/graphql.ts b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts index fd40ead31..98e3ebbf6 100644 --- a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts +++ b/packages/server/modules/cross-server-sync/graph/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; @@ -1584,11 +1615,16 @@ export type Query = { _?: Maybe; /** Gets the profile of the authenticated user or null if not authenticated */ activeUser?: Maybe; - /** All the streams of the server. Available to admins only. */ + admin: AdminQueries; + /** + * All the streams of the server. Available to admins only. + * @deprecated use admin.projectList instead + */ adminStreams?: Maybe; /** * Get all (or search for specific) users, registered or invited, from the server in a paginated view. * The query looks for matches in name, company and email. + * @deprecated use admin.UserList instead */ adminUsers?: Maybe; /** Gets a specific app from the server. */ @@ -1618,6 +1654,7 @@ export type Query = { */ projectInvite?: Maybe; serverInfo: ServerInfo; + /** @deprecated use admin.serverStatistics instead */ serverStats: ServerStats; /** * Returns a specific stream. Will throw an authorization error if active user isn't authorized @@ -1872,6 +1909,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 }. */ 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..be4def872 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; @@ -1584,11 +1615,16 @@ export type Query = { _?: Maybe; /** Gets the profile of the authenticated user or null if not authenticated */ activeUser?: Maybe; - /** All the streams of the server. Available to admins only. */ + admin: AdminQueries; + /** + * All the streams of the server. Available to admins only. + * @deprecated use admin.projectList instead + */ adminStreams?: Maybe; /** * Get all (or search for specific) users, registered or invited, from the server in a paginated view. * The query looks for matches in name, company and email. + * @deprecated use admin.UserList instead */ adminUsers?: Maybe; /** Gets a specific app from the server. */ @@ -1618,6 +1654,7 @@ export type Query = { */ projectInvite?: Maybe; serverInfo: ServerInfo; + /** @deprecated use admin.serverStatistics instead */ serverStats: ServerStats; /** * Returns a specific stream. Will throw an authorization error if active user isn't authorized @@ -1872,6 +1909,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 }. */