feat(server admin): add FE2 admin page backend

This commit is contained in:
Gergő Jedlicska
2023-07-25 14:29:18 +02:00
parent 37a0fa4094
commit 1ca6c73d18
16 changed files with 452 additions and 28 deletions
+5 -5
View File
@@ -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"
+1 -1
View File
@@ -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=''
@@ -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)
}
@@ -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
@@ -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
@@ -1,5 +1,5 @@
extend type Query {
serverStats: ServerStats!
serverStats: ServerStats! @deprecated(reason: "use admin.serverStatistics instead")
}
type ServerStats {
@@ -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`
)
@@ -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<Scalars['String']>;
limit?: Scalars['Int'];
orderBy?: InputMaybe<Scalars['String']>;
query?: InputMaybe<Scalars['String']>;
visibility?: InputMaybe<Scalars['String']>;
};
export type AdminQueriesUserListArgs = {
cursor?: InputMaybe<Scalars['String']>;
limit?: Scalars['Int'];
query?: InputMaybe<Scalars['String']>;
role?: InputMaybe<ServerRole>;
};
export type AdminUserList = {
__typename?: 'AdminUserList';
cursor?: Maybe<Scalars['String']>;
items: Array<BaseUser>;
totalCount: Scalars['Int'];
};
export type AdminUsersListCollection = {
__typename?: 'AdminUsersListCollection';
items: Array<AdminUsersListItem>;
@@ -132,6 +163,26 @@ export type AuthStrategy = {
url: Scalars['String'];
};
export type BaseUser = {
__typename?: 'BaseUser';
avatar?: Maybe<Scalars['String']>;
bio?: Maybe<Scalars['String']>;
company?: Maybe<Scalars['String']>;
createdAt?: Maybe<Scalars['DateTime']>;
/**
* 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<Scalars['String']>;
id: Scalars['ID'];
/** Whether post-sign up onboarding has been finished or skipped entirely */
isOnboardingFinished?: Maybe<Scalars['Boolean']>;
name: Scalars['String'];
profiles?: Maybe<Scalars['JSONObject']>;
role?: Maybe<Scalars['String']>;
verified?: Maybe<Scalars['Boolean']>;
};
export type BlobMetadata = {
__typename?: 'BlobMetadata';
createdAt: Scalars['DateTime'];
@@ -1593,6 +1644,7 @@ export type Query = {
_?: Maybe<Scalars['String']>;
/** Gets the profile of the authenticated user or null if not authenticated */
activeUser?: Maybe<User>;
admin: AdminQueries;
/** All the streams of the server. Available to admins only. */
adminStreams?: Maybe<StreamCollection>;
/**
@@ -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<MutationsObjectGraphQLReturn>;
Activity: ResolverTypeWrapper<Activity>;
ActivityCollection: ResolverTypeWrapper<ActivityCollection>;
AdminQueries: ResolverTypeWrapper<Omit<AdminQueries, 'projectList'> & { projectList: ResolversTypes['ProjectCollection'] }>;
AdminUserList: ResolverTypeWrapper<AdminUserList>;
AdminUsersListCollection: ResolverTypeWrapper<Omit<AdminUsersListCollection, 'items'> & { items: Array<ResolversTypes['AdminUsersListItem']> }>;
AdminUsersListItem: ResolverTypeWrapper<Omit<AdminUsersListItem, 'invitedUser' | 'registeredUser'> & { invitedUser?: Maybe<ResolversTypes['ServerInvite']>, registeredUser?: Maybe<ResolversTypes['User']> }>;
ApiToken: ResolverTypeWrapper<ApiToken>;
@@ -2720,6 +2781,7 @@ export type ResolversTypes = {
AppCreateInput: AppCreateInput;
AppUpdateInput: AppUpdateInput;
AuthStrategy: ResolverTypeWrapper<AuthStrategy>;
BaseUser: ResolverTypeWrapper<BaseUser>;
BigInt: ResolverTypeWrapper<Scalars['BigInt']>;
BlobMetadata: ResolverTypeWrapper<BlobMetadata>;
BlobMetadataCollection: ResolverTypeWrapper<BlobMetadataCollection>;
@@ -2821,6 +2883,7 @@ export type ResolversTypes = {
ServerInvite: ResolverTypeWrapper<Omit<ServerInvite, 'invitedBy'> & { invitedBy: ResolversTypes['LimitedUser'] }>;
ServerInviteCreateInput: ServerInviteCreateInput;
ServerRole: ServerRole;
ServerStatistics: ResolverTypeWrapper<ServerStatistics>;
ServerStats: ResolverTypeWrapper<ServerStats>;
SmartTextEditorValue: ResolverTypeWrapper<SmartTextEditorValue>;
SortDirection: SortDirection;
@@ -2870,6 +2933,8 @@ export type ResolversParentTypes = {
ActiveUserMutations: MutationsObjectGraphQLReturn;
Activity: Activity;
ActivityCollection: ActivityCollection;
AdminQueries: Omit<AdminQueries, 'projectList'> & { projectList: ResolversParentTypes['ProjectCollection'] };
AdminUserList: AdminUserList;
AdminUsersListCollection: Omit<AdminUsersListCollection, 'items'> & { items: Array<ResolversParentTypes['AdminUsersListItem']> };
AdminUsersListItem: Omit<AdminUsersListItem, 'invitedUser' | 'registeredUser'> & { invitedUser?: Maybe<ResolversParentTypes['ServerInvite']>, registeredUser?: Maybe<ResolversParentTypes['User']> };
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<ServerInvite, 'invitedBy'> & { invitedBy: ResolversParentTypes['LimitedUser'] };
ServerInviteCreateInput: ServerInviteCreateInput;
ServerStatistics: ServerStatistics;
ServerStats: ServerStats;
SmartTextEditorValue: SmartTextEditorValue;
Stream: StreamGraphQLReturn;
@@ -3069,6 +3136,20 @@ export type ActivityCollectionResolvers<ContextType = GraphQLContext, ParentType
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type AdminQueriesResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['AdminQueries'] = ResolversParentTypes['AdminQueries']> = {
projectList?: Resolver<ResolversTypes['ProjectCollection'], ParentType, ContextType, RequireFields<AdminQueriesProjectListArgs, 'cursor' | 'limit'>>;
serverStatistics?: Resolver<ResolversTypes['ServerStatistics'], ParentType, ContextType>;
userList?: Resolver<ResolversTypes['AdminUserList'], ParentType, ContextType, RequireFields<AdminQueriesUserListArgs, 'cursor' | 'limit' | 'query' | 'role'>>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type AdminUserListResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['AdminUserList'] = ResolversParentTypes['AdminUserList']> = {
cursor?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
items?: Resolver<Array<ResolversTypes['BaseUser']>, ParentType, ContextType>;
totalCount?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type AdminUsersListCollectionResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['AdminUsersListCollection'] = ResolversParentTypes['AdminUsersListCollection']> = {
items?: Resolver<Array<ResolversTypes['AdminUsersListItem']>, ParentType, ContextType>;
totalCount?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
@@ -3109,6 +3190,21 @@ export type AuthStrategyResolvers<ContextType = GraphQLContext, ParentType exten
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type BaseUserResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['BaseUser'] = ResolversParentTypes['BaseUser']> = {
avatar?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
bio?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
company?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
createdAt?: Resolver<Maybe<ResolversTypes['DateTime']>, ParentType, ContextType>;
email?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
isOnboardingFinished?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
profiles?: Resolver<Maybe<ResolversTypes['JSONObject']>, ParentType, ContextType>;
role?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
verified?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export interface BigIntScalarConfig extends GraphQLScalarTypeConfig<ResolversTypes['BigInt'], any> {
name: 'BigInt';
}
@@ -3579,6 +3675,7 @@ export type ProjectVersionsUpdatedMessageResolvers<ContextType = GraphQLContext,
export type QueryResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['Query'] = ResolversParentTypes['Query']> = {
_?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
activeUser?: Resolver<Maybe<ResolversTypes['User']>, ParentType, ContextType>;
admin?: Resolver<ResolversTypes['AdminQueries'], ParentType, ContextType>;
adminStreams?: Resolver<Maybe<ResolversTypes['StreamCollection']>, ParentType, ContextType, RequireFields<QueryAdminStreamsArgs, 'limit' | 'offset'>>;
adminUsers?: Resolver<Maybe<ResolversTypes['AdminUsersListCollection']>, ParentType, ContextType, RequireFields<QueryAdminUsersArgs, 'limit' | 'offset' | 'query'>>;
app?: Resolver<Maybe<ResolversTypes['ServerApp']>, ParentType, ContextType, RequireFields<QueryAppArgs, 'id'>>;
@@ -3673,6 +3770,13 @@ export type ServerInviteResolvers<ContextType = GraphQLContext, ParentType exten
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type ServerStatisticsResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['ServerStatistics'] = ResolversParentTypes['ServerStatistics']> = {
totalPendingInvites?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
totalProjectCount?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
totalUserCount?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type ServerStatsResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['ServerStats'] = ResolversParentTypes['ServerStats']> = {
commitHistory?: Resolver<Maybe<Array<Maybe<ResolversTypes['JSONObject']>>>, ParentType, ContextType>;
objectHistory?: Resolver<Maybe<Array<Maybe<ResolversTypes['JSONObject']>>>, ParentType, ContextType>;
@@ -3911,11 +4015,14 @@ export type Resolvers<ContextType = GraphQLContext> = {
ActiveUserMutations?: ActiveUserMutationsResolvers<ContextType>;
Activity?: ActivityResolvers<ContextType>;
ActivityCollection?: ActivityCollectionResolvers<ContextType>;
AdminQueries?: AdminQueriesResolvers<ContextType>;
AdminUserList?: AdminUserListResolvers<ContextType>;
AdminUsersListCollection?: AdminUsersListCollectionResolvers<ContextType>;
AdminUsersListItem?: AdminUsersListItemResolvers<ContextType>;
ApiToken?: ApiTokenResolvers<ContextType>;
AppAuthor?: AppAuthorResolvers<ContextType>;
AuthStrategy?: AuthStrategyResolvers<ContextType>;
BaseUser?: BaseUserResolvers<ContextType>;
BigInt?: GraphQLScalarType;
BlobMetadata?: BlobMetadataResolvers<ContextType>;
BlobMetadataCollection?: BlobMetadataCollectionResolvers<ContextType>;
@@ -3968,6 +4075,7 @@ export type Resolvers<ContextType = GraphQLContext> = {
ServerAppListItem?: ServerAppListItemResolvers<ContextType>;
ServerInfo?: ServerInfoResolvers<ContextType>;
ServerInvite?: ServerInviteResolvers<ContextType>;
ServerStatistics?: ServerStatisticsResolvers<ContextType>;
ServerStats?: ServerStatsResolvers<ContextType>;
SmartTextEditorValue?: SmartTextEditorValueResolvers<ContextType>;
Stream?: StreamResolvers<ContextType>;
@@ -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
@@ -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 extends LimitedUserRecord = UserRecord> = 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 extends LimitedUserRecord = UserRecord> = 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<UserWithRole[]> {
const sanitizedLimit = clamp(limit, 1, 200)
const q = Users.knex<UserWithRole[]>()
.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<number> {
// 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
*/
@@ -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<T> = 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<Collection<UserRecord>> => {
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<Collection<StreamRecord>> => {
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
}
}
@@ -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 }) {
@@ -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 } } }'
})
@@ -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')
@@ -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)
@@ -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<Scalars['String']>;
limit?: Scalars['Int'];
orderBy?: InputMaybe<Scalars['String']>;
query?: InputMaybe<Scalars['String']>;
visibility?: InputMaybe<Scalars['String']>;
};
export type AdminQueriesUserListArgs = {
cursor?: InputMaybe<Scalars['String']>;
limit?: Scalars['Int'];
query?: InputMaybe<Scalars['String']>;
role?: InputMaybe<ServerRole>;
};
export type AdminUserList = {
__typename?: 'AdminUserList';
cursor?: Maybe<Scalars['String']>;
items: Array<BaseUser>;
totalCount: Scalars['Int'];
};
export type AdminUsersListCollection = {
__typename?: 'AdminUsersListCollection';
items: Array<AdminUsersListItem>;
@@ -123,6 +154,26 @@ export type AuthStrategy = {
url: Scalars['String'];
};
export type BaseUser = {
__typename?: 'BaseUser';
avatar?: Maybe<Scalars['String']>;
bio?: Maybe<Scalars['String']>;
company?: Maybe<Scalars['String']>;
createdAt?: Maybe<Scalars['DateTime']>;
/**
* 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<Scalars['String']>;
id: Scalars['ID'];
/** Whether post-sign up onboarding has been finished or skipped entirely */
isOnboardingFinished?: Maybe<Scalars['Boolean']>;
name: Scalars['String'];
profiles?: Maybe<Scalars['JSONObject']>;
role?: Maybe<Scalars['String']>;
verified?: Maybe<Scalars['Boolean']>;
};
export type BlobMetadata = {
__typename?: 'BlobMetadata';
createdAt: Scalars['DateTime'];
@@ -1584,6 +1635,7 @@ export type Query = {
_?: Maybe<Scalars['String']>;
/** Gets the profile of the authenticated user or null if not authenticated */
activeUser?: Maybe<User>;
admin: AdminQueries;
/** All the streams of the server. Available to admins only. */
adminStreams?: Maybe<StreamCollection>;
/**
@@ -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 }. */