diff --git a/packages/frontend-2/components/settings/workspaces/members/MembersTable.vue b/packages/frontend-2/components/settings/workspaces/members/MembersTable.vue index e3bcc4c41..181d34c43 100644 --- a/packages/frontend-2/components/settings/workspaces/members/MembersTable.vue +++ b/packages/frontend-2/components/settings/workspaces/members/MembersTable.vue @@ -31,7 +31,7 @@ } ]" :items="members" - :loading="loading" + :loading="isVeryFirstLoading" :empty-message=" search.length || seatTypeFilter || roleFilter ? 'No results' @@ -79,7 +79,7 @@ @@ -156,26 +156,29 @@ const targetUser = ref ({ - query: search.value?.length ? search.value : null, limit: 10, slug: props.workspaceSlug, filter: { search: search.value, - roles: roleFilter.value - ? [roleFilter.value] - : [Roles.Workspace.Admin, Roles.Workspace.Member], + roles: roleFilter.value ? [roleFilter.value] : defaultRoles.value, seatType: seatTypeFilter.value }, cursor: null as Nullable })), - resolveKey: (vars) => [vars.query || ''], + resolveKey: (vars) => ({ + slug: vars.slug, + filter: vars.filter + }), resolveCurrentResult: (res) => res?.workspaceBySlug.team, resolveNextPageVariables: (baseVars, cursor) => ({ ...baseVars, @@ -185,8 +188,5 @@ const { }) const workspace = computed(() => result.value?.workspaceBySlug) -const canReadMemberEmail = computed( - () => workspace.value?.permissions.canReadMemberEmail.authorized -) const members = computed(() => membersResult.value?.workspaceBySlug.team.items) diff --git a/packages/frontend-2/lib/common/composables/graphql.ts b/packages/frontend-2/lib/common/composables/graphql.ts index e06ed12ea..4a705181e 100644 --- a/packages/frontend-2/lib/common/composables/graphql.ts +++ b/packages/frontend-2/lib/common/composables/graphql.ts @@ -68,6 +68,9 @@ type SerializableObject = { | SerializableObject | SerializableValue[] | SerializableObject[] + | Array + | undefined + | null } type BasicCursorContainer = { @@ -106,6 +109,8 @@ export const usePaginatedQuery = < | SerializableObject | SerializableValue[] | SerializableObject[] + | Array + /** * Predicate for resolving the current paginated result from the query result. Return undefined * if query hasn't finished loading yet. diff --git a/packages/frontend-2/lib/core/configs/apollo.ts b/packages/frontend-2/lib/core/configs/apollo.ts index 735acd4bd..92bd16d38 100644 --- a/packages/frontend-2/lib/core/configs/apollo.ts +++ b/packages/frontend-2/lib/core/configs/apollo.ts @@ -323,7 +323,7 @@ function createCache(): InMemoryCache { merge: (_existing, incoming) => incoming }, team: { - keyArgs: ['filter', 'limit'], + keyArgs: ['limit', 'filter', ['roles', 'search', 'seatType']], merge: buildAbstractCollectionMergeFunction( 'WorkspaceCollaboratorCollection' ) diff --git a/packages/frontend-2/nuxt.config.ts b/packages/frontend-2/nuxt.config.ts index 7e4655510..bc0a91e17 100644 --- a/packages/frontend-2/nuxt.config.ts +++ b/packages/frontend-2/nuxt.config.ts @@ -24,7 +24,6 @@ const buildSourceMaps = ['1', 'true', true, 1].includes(BUILD_SOURCEMAPS) // https://v3.nuxtjs.org/api/configuration/nuxt.config export default defineNuxtConfig({ - // ssr: false, // for debugging set to false (prod should always be true) ...(buildSourceMaps ? { sourcemap: true } : {}), modulesDir: ['./node_modules'], typescript: { @@ -56,18 +55,18 @@ export default defineNuxtConfig({ redisUrl: '', public: { ...featureFlags, - apiOrigin: 'UNDEFINED', + apiOrigin: '', backendApiOrigin: '', baseUrl: '', - mixpanelApiHost: 'UNDEFINED', - mixpanelTokenId: 'UNDEFINED', + mixpanelApiHost: '', + mixpanelTokenId: '', logLevel: NUXT_PUBLIC_LOG_LEVEL, logPretty: isLogPretty, logCsrEmitProps: false, logClientApiToken: '', logClientApiEndpoint: '', speckleServerVersion: SPECKLE_SERVER_VERSION || 'unknown', - serverName: 'UNDEFINED', + serverName: 'unknown', viewerDebug: false, debugCoreWebVitals: false, datadogAppId: '', diff --git a/packages/server/modules/fileuploads/repositories/fileUploads.ts b/packages/server/modules/fileuploads/repositories/fileUploads.ts index a1615158f..515a8fa95 100644 --- a/packages/server/modules/fileuploads/repositories/fileUploads.ts +++ b/packages/server/modules/fileuploads/repositories/fileUploads.ts @@ -276,13 +276,11 @@ export const getModelUploadsItemsFactory = (deps: { db: Knex }): GetModelUploadsItems => async (params) => { const limit = clamp(params.limit || 0, 0, 100) - const { filterByCursor, resolveNewCursor } = getCursorTools() + const { applyCursorSortAndFilter, resolveNewCursor } = getCursorTools() - const q = getModelUploadsBaseQueryFactory(deps)(params) - .orderBy(FileUploads.col.convertedLastUpdate, 'desc') - .limit(limit) + const q = getModelUploadsBaseQueryFactory(deps)(params).limit(limit) - filterByCursor({ + applyCursorSortAndFilter({ query: q, cursor: params.cursor }) diff --git a/packages/server/modules/shared/helpers/graphqlHelper.ts b/packages/server/modules/shared/helpers/graphqlHelper.ts index c58bbac78..a8d3a6a51 100644 --- a/packages/server/modules/shared/helpers/graphqlHelper.ts +++ b/packages/server/modules/shared/helpers/graphqlHelper.ts @@ -76,14 +76,22 @@ export const decodeCompositeCursor = ( return null } +// This is to allow custom column/alias support for compositeCursorTools() - we don't want +// to force the user to pass in the entire schema config, just the data we need +type LimitedSchemaConfig = Pick, 'col'> + /** * Simplifies working with composite cursors in SQL queries. Composite cursors are better because they * allow duplicate values (e.g. updatedAt date) in different rows */ export const compositeCursorTools = < - Config extends SchemaConfig, + Config extends LimitedSchemaConfig, SelectedCols extends Array >(args: { + /** + * Db table schema config OR in case of aliased columns - manual column mapping between final aliases + * as keys and table-prefixed column names as values + */ schema: Config /** * Order of columns matters - put the primary ordering column first (e.g. updatedAt), then the secondary @@ -107,9 +115,10 @@ export const compositeCursorTools = < ) /** - * Invoke this on the knex querybuilder to filter the query by the cursor + * Invoke this on the knex querybuilder to filter the query by the cursor and apply + * appropriate ordering */ - const filterByCursor = (params: { + const applyCursorSortAndFilter = (params: { query: Query /** * If falsy, filter will be skipped @@ -121,9 +130,16 @@ export const compositeCursorTools = < sort?: 'desc' | 'asc' }) => { const { query, sort = 'desc' } = params + + // Apply orderBy for each cursor column w/ proper sort direction + args.cols.forEach((col) => { + query.orderBy(args.schema.col[col], sort) + }) + const cursor = isString(params.cursor) ? decode(params.cursor) : params.cursor if (!cursor) return query + // Apply cursor filter const colCount = args.cols.length const sql = `(${times(colCount, () => '??').join(', ')}) ${ @@ -161,7 +177,7 @@ export const compositeCursorTools = < return { encode, decode, - filterByCursor, + applyCursorSortAndFilter, resolveNewCursor } } diff --git a/packages/server/modules/shared/services/paginatedItems.ts b/packages/server/modules/shared/services/paginatedItems.ts index 99493b75e..2800693de 100644 --- a/packages/server/modules/shared/services/paginatedItems.ts +++ b/packages/server/modules/shared/services/paginatedItems.ts @@ -1,42 +1,24 @@ -import { - decodeIsoDateCursor, - encodeIsoDateCursor, - Collection -} from '@/modules/shared/helpers/graphqlHelper' +import { Collection } from '@/modules/shared/helpers/graphqlHelper' +import { MaybeNullOrUndefined } from '@speckle/shared' type GetPaginatedItemsArgs = { limit: number - cursor?: string + cursor?: MaybeNullOrUndefined } export const getPaginatedItemsFactory = - ({ + ({ getItems, getTotalCount }: { - getItems: (args: TArgs) => Promise + getItems: (args: TArgs) => Promise<{ items: T[]; cursor: string | null }> getTotalCount: (args: Omit) => Promise }) => async (args: TArgs): Promise> => { - const totalCount = await getTotalCount(args) - if (args.limit === 0) { - return { - cursor: null, - items: [], - totalCount - } - } - const maybeDecodedCursor = args.cursor ? decodeIsoDateCursor(args.cursor) : null - const items = await getItems({ - ...args, - cursor: maybeDecodedCursor ?? undefined - }) - - let cursor = null - if (items.length === args.limit) { - const lastItem = items.at(-1) - cursor = lastItem ? encodeIsoDateCursor(lastItem.createdAt) : null - } + const [totalCount, { items, cursor }] = await Promise.all([ + getTotalCount(args), + args.limit === 0 ? { cursor: null, items: [] } : getItems(args) + ]) return { items, diff --git a/packages/server/modules/shared/test/unit/paginatedItems.spec.ts b/packages/server/modules/shared/test/unit/paginatedItems.spec.ts deleted file mode 100644 index b87d559fe..000000000 --- a/packages/server/modules/shared/test/unit/paginatedItems.spec.ts +++ /dev/null @@ -1,9 +0,0 @@ -describe('paginatedItems @shared', () => { - describe('getPaginatedItemsFactory creates a function, that', () => { - it('converts undefined cursors to null') - it( - 'calculates a new cursor from createdAt if there could be more items to be queried' - ) - it('returns items and count from dependencies') - }) -}) diff --git a/packages/server/modules/workspaces/domain/operations.ts b/packages/server/modules/workspaces/domain/operations.ts index c2a7e7eba..6cdc5234d 100644 --- a/packages/server/modules/workspaces/domain/operations.ts +++ b/packages/server/modules/workspaces/domain/operations.ts @@ -128,7 +128,9 @@ export type QueryWorkspacesArgs = CountWorkspacesArgs & { limit: number cursor?: string } -export type QueryWorkspaces = (args: QueryWorkspacesArgs) => Promise +export type QueryWorkspaces = ( + args: QueryWorkspacesArgs +) => Promise<{ items: Workspace[]; cursor: string | null }> export type CountWorkspaces = (args: CountWorkspacesArgs) => Promise export type GetProjectWorkspace = (args: { projectId: string @@ -136,10 +138,8 @@ export type GetProjectWorkspace = (args: { /** Workspace Roles */ -export type GetWorkspaceCollaboratorsArgs = { +export type GetWorkspaceCollaboratorsBaseArgs = { workspaceId: string - limit: number - cursor?: string filter?: { /** * Optionally filter by workspace role(s) @@ -158,16 +158,17 @@ export type GetWorkspaceCollaboratorsArgs = { hasAccessToEmail?: boolean } -export type GetWorkspaceCollaborators = ( - args: GetWorkspaceCollaboratorsArgs -) => Promise - -type GetWorkspaceCollaboratorsTotalCountArgs = { - workspaceId: string +export type GetWorkspaceCollaboratorsArgs = GetWorkspaceCollaboratorsBaseArgs & { + limit: number + cursor?: string } +export type GetWorkspaceCollaborators = ( + args: GetWorkspaceCollaboratorsArgs +) => Promise<{ items: WorkspaceTeam; cursor: string | null }> + export type GetWorkspaceCollaboratorsTotalCount = ( - args: GetWorkspaceCollaboratorsTotalCountArgs + args: GetWorkspaceCollaboratorsBaseArgs ) => Promise type DeleteWorkspaceRoleArgs = { diff --git a/packages/server/modules/workspaces/events/eventListener.ts b/packages/server/modules/workspaces/events/eventListener.ts index ce5606b3d..954ba75b8 100644 --- a/packages/server/modules/workspaces/events/eventListener.ts +++ b/packages/server/modules/workspaces/events/eventListener.ts @@ -250,7 +250,9 @@ export const onWorkspaceRoleDeletedFactory = updatedByUserId: string }) => { // Resolve a fallback admin - const [admin] = await deps.getWorkspaceCollaborators({ + const { + items: [admin] + } = await deps.getWorkspaceCollaborators({ workspaceId, limit: 1, filter: { @@ -330,7 +332,9 @@ export const onWorkspaceSeatUpdatedFactory = }) // Resolve a fallback admin - const [admin] = await deps.getWorkspaceCollaborators({ + const { + items: [admin] + } = await deps.getWorkspaceCollaborators({ workspaceId, limit: 1, filter: { @@ -415,7 +419,9 @@ export const onWorkspaceRoleUpdatedFactory = if (!seatType) return // Resolve a fallback admin - const [admin] = await deps.getWorkspaceCollaborators({ + const { + items: [admin] + } = await deps.getWorkspaceCollaborators({ workspaceId, limit: 1, filter: { diff --git a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts index c37854b18..8fd712891 100644 --- a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts +++ b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts @@ -197,8 +197,6 @@ import { getProjectFactory } from '@/modules/core/repositories/projects' import { getProjectRegionKey } from '@/modules/multiregion/utils/regionSelector' import { scheduleJob } from '@/modules/multiregion/services/queue' import { updateWorkspacePlanFactory } from '@/modules/gatekeeper/services/workspacePlans' -import { GetWorkspaceCollaboratorsArgs } from '@/modules/workspaces/domain/operations' -import { WorkspaceTeamMember } from '@/modules/workspaces/domain/types' import { UsersMeta } from '@/modules/core/dbSchema' import { setUserActiveWorkspaceFactory } from '@/modules/workspaces/repositories/users' import { getGenericRedis } from '@/modules/shared/redis/redis' @@ -2086,10 +2084,7 @@ export = FF_WORKSPACES_MODULE_ENABLED }, LimitedWorkspace: { team: async (parent, args) => { - const team = await getPaginatedItemsFactory< - Pick, - WorkspaceTeamMember - >({ + const team = await getPaginatedItemsFactory({ getItems: getWorkspaceCollaboratorsFactory({ db }), getTotalCount: getWorkspaceCollaboratorsTotalCountFactory({ db }) })({ @@ -2107,7 +2102,7 @@ export = FF_WORKSPACES_MODULE_ENABLED roles: [Roles.Workspace.Admin] } }) - return team + return team.items } }, ActiveUserMutations: { diff --git a/packages/server/modules/workspaces/repositories/users.ts b/packages/server/modules/workspaces/repositories/users.ts index c115f25e8..74c3111d1 100644 --- a/packages/server/modules/workspaces/repositories/users.ts +++ b/packages/server/modules/workspaces/repositories/users.ts @@ -10,6 +10,7 @@ import { metaHelpers } from '@/modules/core/helpers/meta' import { StreamAclRecord, UserRecord } from '@/modules/core/helpers/types' import { removePrivateFields } from '@/modules/core/helpers/userHelper' import { formatJsonArrayRecords } from '@/modules/shared/helpers/dbHelper' +import { compositeCursorTools } from '@/modules/shared/helpers/graphqlHelper' import { SetUserActiveWorkspace } from '@/modules/workspaces/domain/operations' import { WorkspaceTeamMember } from '@/modules/workspaces/domain/types' import { WorkspaceAcl as WorkspaceAclRecord } from '@/modules/workspacesCore/domain/types' @@ -45,6 +46,12 @@ const buildInvitableCollaboratorsByProjectIdQueryFactory = }) => { const query = tables .users(db) + .select([ + ...Users.cols, + WorkspaceAcl.groupArray('workspaceAcl'), + ServerAcl.groupArray('serverAcl'), + UserEmails.groupArray('emails') + ]) .join(WorkspaceAcl.name, WorkspaceAcl.col.userId, Users.col.id) .join(Streams.name, Streams.col.workspaceId, WorkspaceAcl.col.workspaceId) .join(ServerAcl.name, ServerAcl.col.userId, Users.col.id) @@ -81,25 +88,27 @@ export const getInvitableCollaboratorsByProjectIdFactory = } cursor?: string limit: number - }): Promise => { + }): Promise<{ items: WorkspaceTeamMember[]; cursor: string | null }> => { const { workspaceId, projectId, search } = filter const query = buildInvitableCollaboratorsByProjectIdQueryFactory({ db })({ workspaceId, projectId, search }) - if (cursor) { - query.andWhere(Users.col.createdAt, '<', cursor) - } + const { applyCursorSortAndFilter, resolveNewCursor } = compositeCursorTools({ + schema: Users, + cols: ['createdAt', 'id'] + }) + + applyCursorSortAndFilter({ + query, + cursor + }) + + query.limit(limit) + const res = await query - .orderBy(Users.col.createdAt, 'desc') - .limit(limit) - .select([ - ...Users.cols, - WorkspaceAcl.groupArray('workspaceAcl'), - ServerAcl.groupArray('serverAcl'), - UserEmails.groupArray('emails') - ]) + const nextCursor = resolveNewCursor(res) const formattedRes = res.map((row) => { const workspaceAcl = formatJsonArrayRecords( @@ -119,7 +128,7 @@ export const getInvitableCollaboratorsByProjectIdFactory = } }) - return formattedRes + return { items: formattedRes, cursor: nextCursor } } export const countInvitableCollaboratorsByProjectIdFactory = @@ -134,11 +143,16 @@ export const countInvitableCollaboratorsByProjectIdFactory = } }) => { const { workspaceId, projectId, search } = filter - const query = buildInvitableCollaboratorsByProjectIdQueryFactory({ db })({ - workspaceId, - projectId, - search - }) - const [res] = await query.count() + const query = db + .from( + buildInvitableCollaboratorsByProjectIdQueryFactory({ db })({ + workspaceId, + projectId, + search + }).as('sq1') + ) + .count() + + const [res] = await query return parseInt(res?.count?.toString() ?? '0') } diff --git a/packages/server/modules/workspaces/repositories/workspaceJoinRequests.ts b/packages/server/modules/workspaces/repositories/workspaceJoinRequests.ts index 02f4b048f..17ef4819d 100644 --- a/packages/server/modules/workspaces/repositories/workspaceJoinRequests.ts +++ b/packages/server/modules/workspaces/repositories/workspaceJoinRequests.ts @@ -1,4 +1,5 @@ import { UserEmails } from '@/modules/core/dbSchema' +import { compositeCursorTools } from '@/modules/shared/helpers/graphqlHelper' import { CreateWorkspaceJoinRequest, GetWorkspaceJoinRequest, @@ -96,18 +97,30 @@ export const getAdminWorkspaceJoinRequestsFactory = cursor?: string limit: number }) => { + const { applyCursorSortAndFilter, resolveNewCursor } = compositeCursorTools({ + schema: WorkspaceJoinRequests, + cols: ['createdAt', 'userId'] + }) const query = adminWorkspaceJoinRequestsBaseQueryFactory(db)(filter) + applyCursorSortAndFilter({ + query, + cursor + }) - if (cursor) { - query.andWhere(WorkspaceJoinRequests.col.createdAt, '<', cursor) - } - return await query + query .select([ ...WorkspaceJoinRequests.cols, UserEmails.col.email ]) - .orderBy(WorkspaceJoinRequests.col.createdAt, 'desc') .limit(limit) + + const items = await query + const newCursor = resolveNewCursor(items) + + return { + items, + cursor: newCursor + } } export const countAdminWorkspaceJoinRequestsFactory = @@ -138,7 +151,7 @@ const workspaceJoinRequestsBaseQueryFactory = export const getWorkspaceJoinRequestsFactory = ({ db }: { db: Knex }) => - ({ + async ({ filter, cursor, limit @@ -148,14 +161,24 @@ export const getWorkspaceJoinRequestsFactory = limit: number }) => { const query = workspaceJoinRequestsBaseQueryFactory(db)(filter) + const { applyCursorSortAndFilter, resolveNewCursor } = compositeCursorTools({ + schema: WorkspaceJoinRequests, + cols: ['createdAt', 'userId'] + }) + applyCursorSortAndFilter({ + query, + cursor + }) - if (cursor) { - query.andWhere(WorkspaceJoinRequests.col.createdAt, '<', cursor) + query.select(WorkspaceJoinRequests.cols).limit(limit) + + const items = await query + const newCursor = resolveNewCursor(items) + + return { + items, + cursor: newCursor } - return query - .select(WorkspaceJoinRequests.cols) - .orderBy(WorkspaceJoinRequests.col.createdAt, 'desc') - .limit(limit) } export const countWorkspaceJoinRequestsFactory = diff --git a/packages/server/modules/workspaces/repositories/workspaces.ts b/packages/server/modules/workspaces/repositories/workspaces.ts index 3edf1ee94..89a98d153 100644 --- a/packages/server/modules/workspaces/repositories/workspaces.ts +++ b/packages/server/modules/workspaces/repositories/workspaces.ts @@ -26,6 +26,7 @@ import { GetWorkspaceBySlug, GetWorkspaceBySlugOrId, GetWorkspaceCollaborators, + GetWorkspaceCollaboratorsBaseArgs, GetWorkspaceCollaboratorsTotalCount, GetWorkspaceCreationState, GetWorkspaceDomains, @@ -70,7 +71,7 @@ import { UserEmails, Users } from '@/modules/core/dbSchema' -import { removePrivateFields } from '@/modules/core/helpers/userHelper' +import { removePrivateFields, UserRecord } from '@/modules/core/helpers/userHelper' import { clamp, has, isObjectLike } from 'lodash' import { @@ -78,6 +79,7 @@ import { WorkspaceTeamMember } from '@/modules/workspaces/domain/types' import { + compositeCursorTools, decodeCompositeCursor, decodeCursor, encodeCompositeCursor, @@ -86,6 +88,7 @@ import { import { adminOverrideEnabled } from '@/modules/shared/helpers/envHelper' const tables = { + users: (db: Knex) => db(Users.name), branches: (db: Knex) => db('branches'), streams: (db: Knex) => db('streams'), streamAcl: (db: Knex) => db('stream_acl'), @@ -311,15 +314,24 @@ const buildWorkspacesQuery = ({ db, search }: { db: Knex; search?: string }) => export const queryWorkspacesFactory = ({ db }: { db: Knex }): QueryWorkspaces => async ({ limit, cursor, filter }) => { + const { applyCursorSortAndFilter, resolveNewCursor } = compositeCursorTools({ + schema: Workspaces, + cols: ['createdAt', 'id'] + }) + const query = buildWorkspacesQuery({ db, search: filter?.search }) .select() - .orderBy('createdAt', 'desc') .limit(limit) - if (cursor) { - query.andWhere('createdAt', '<', cursor) - } - return await query + applyCursorSortAndFilter({ + query, + cursor + }) + + const res = await query + const newCursor = resolveNewCursor(res) + + return { items: res, cursor: newCursor } } export const countWorkspacesFactory = @@ -445,19 +457,12 @@ export const upsertWorkspaceRoleFactory = .merge(['role']) } -export const getWorkspaceCollaboratorsTotalCountFactory = - ({ db }: { db: Knex }): GetWorkspaceCollaboratorsTotalCount => - async ({ workspaceId }) => { - const [res] = await DbWorkspaceAcl.knex(db).where({ workspaceId }).count() - const count = parseInt(res.count) - return count || 0 - } +const getWorkspaceCollaboratorsBaseQuery = + (deps: { db: Knex }) => (params: GetWorkspaceCollaboratorsBaseArgs) => { + const { workspaceId, filter = {} } = params -export const getWorkspaceCollaboratorsFactory = - ({ db }: { db: Knex }): GetWorkspaceCollaborators => - async ({ workspaceId, filter = {}, cursor, limit = 25, hasAccessToEmail }) => { - const query = db - .from(Users.name) + const query = tables + .users(deps.db) .select>( ...Users.cols, ServerAcl.col.role, @@ -474,7 +479,6 @@ export const getWorkspaceCollaboratorsFactory = // it will not be surfaced by this query // .andWhere(UserEmails.col.primary, true) - .orderBy('workspaceRoleCreatedAt', 'desc') const { search, roles, seatType, excludeUserIds } = filter || {} @@ -505,9 +509,40 @@ export const getWorkspaceCollaboratorsFactory = }) } - if (cursor) { - query.andWhere(DbWorkspaceAcl.col.createdAt, '<', cursor) - } + return query + } + +export const getWorkspaceCollaboratorsTotalCountFactory = + ({ db }: { db: Knex }): GetWorkspaceCollaboratorsTotalCount => + async (params) => { + const q = db + .from(getWorkspaceCollaboratorsBaseQuery({ db })(params).as('t1')) + .count() + + const [res] = await q + const count = parseInt(res.count + '') + return count || 0 + } + +export const getWorkspaceCollaboratorsFactory = + ({ db }: { db: Knex }): GetWorkspaceCollaborators => + async (params) => { + const { limit = 25, hasAccessToEmail } = params + const query = getWorkspaceCollaboratorsBaseQuery({ db })(params) + const { applyCursorSortAndFilter, resolveNewCursor } = compositeCursorTools({ + schema: { + col: { + workspaceRoleCreatedAt: DbWorkspaceAcl.col.createdAt, + id: Users.col.id + } + }, + cols: ['workspaceRoleCreatedAt', 'id'] + }) + + applyCursorSortAndFilter({ + query, + cursor: params.cursor + }) if (limit) { query.limit(clamp(limit, 0, 100)) @@ -521,8 +556,9 @@ export const getWorkspaceCollaboratorsFactory = workspaceId: i.workspaceId, role: i.role })) + const newCursor = resolveNewCursor(items) - return items + return { items, cursor: newCursor } } export const storeWorkspaceDomainFactory = diff --git a/packages/server/modules/workspaces/services/workspaceJoinRequestEmails/received.ts b/packages/server/modules/workspaces/services/workspaceJoinRequestEmails/received.ts index 95366b533..052b1e86e 100644 --- a/packages/server/modules/workspaces/services/workspaceJoinRequestEmails/received.ts +++ b/packages/server/modules/workspaces/services/workspaceJoinRequestEmails/received.ts @@ -77,7 +77,7 @@ export const sendWorkspaceJoinRequestReceivedEmailFactory = }): SendWorkspaceJoinRequestReceivedEmail => async (args) => { const { requester, workspace } = args - const [serverInfo, workspaceAdmins] = await Promise.all([ + const [serverInfo, { items: workspaceAdmins }] = await Promise.all([ getServerInfo(), getWorkspaceCollaborators({ workspaceId: workspace.id, diff --git a/packages/server/modules/workspaces/services/workspaceJoinRequests.ts b/packages/server/modules/workspaces/services/workspaceJoinRequests.ts index 1571cc33e..df91bd676 100644 --- a/packages/server/modules/workspaces/services/workspaceJoinRequests.ts +++ b/packages/server/modules/workspaces/services/workspaceJoinRequests.ts @@ -90,7 +90,9 @@ export const requestToJoinWorkspaceFactory = } if (workspace.discoverabilityAutoJoinEnabled) { - const [workspaceAdmin] = await getWorkspaceTeam({ + const { + items: [workspaceAdmin] + } = await getWorkspaceTeam({ workspaceId, limit: 1, filter: { diff --git a/packages/server/modules/workspaces/tests/integration/repositories.spec.ts b/packages/server/modules/workspaces/tests/integration/repositories.spec.ts index f1fbe2e27..d4dc47353 100644 --- a/packages/server/modules/workspaces/tests/integration/repositories.spec.ts +++ b/packages/server/modules/workspaces/tests/integration/repositories.spec.ts @@ -250,7 +250,7 @@ describe('Workspace repositories', () => { }) it('returns all workspace members', async () => { - const team = await getWorkspaceCollaborators({ + const { items: team } = await getWorkspaceCollaborators({ workspaceId: testWorkspace.id, limit: 50 }) @@ -298,7 +298,7 @@ describe('Workspace repositories', () => { }) it('limits search results to specified workspace', async () => { - const result = await getWorkspaceCollaborators({ + const { items: result } = await getWorkspaceCollaborators({ workspaceId: testWorkspaces[2].id, limit: 50, filter: { search: 'John' } diff --git a/packages/server/modules/workspaces/tests/integration/repositories/users.spec.ts b/packages/server/modules/workspaces/tests/integration/repositories/users.spec.ts index cc1451fc1..33cd0c079 100644 --- a/packages/server/modules/workspaces/tests/integration/repositories/users.spec.ts +++ b/packages/server/modules/workspaces/tests/integration/repositories/users.spec.ts @@ -86,7 +86,7 @@ describe('Workspace repositories', () => { }) it('should return all workspace collaborators not members of the project', async () => { - const invitable = await getInvitableCollaboratorsByProjectId({ + const { items: invitable } = await getInvitableCollaboratorsByProjectId({ filter: { workspaceId: testWorkspace.id, projectId: testProject.id @@ -100,7 +100,7 @@ describe('Workspace repositories', () => { ]) }) it('should should filter by user name', async () => { - const invitable = await getInvitableCollaboratorsByProjectId({ + const { items: invitable } = await getInvitableCollaboratorsByProjectId({ filter: { workspaceId: testWorkspace.id, projectId: testProject.id, @@ -114,7 +114,7 @@ describe('Workspace repositories', () => { ]) }) it('should should filter by user email', async () => { - const invitable = await getInvitableCollaboratorsByProjectId({ + const { items: invitable } = await getInvitableCollaboratorsByProjectId({ filter: { workspaceId: testWorkspace.id, projectId: testProject.id, @@ -128,7 +128,7 @@ describe('Workspace repositories', () => { ]) }) it('should should filter by user name and email', async () => { - const invitable = await getInvitableCollaboratorsByProjectId({ + const { items: invitable } = await getInvitableCollaboratorsByProjectId({ filter: { workspaceId: testWorkspace.id, projectId: testProject.id, diff --git a/packages/server/modules/workspaces/tests/integration/workspaceJoinRequests.spec.ts b/packages/server/modules/workspaces/tests/integration/workspaceJoinRequests.spec.ts index 2f905a4f8..8ca750645 100644 --- a/packages/server/modules/workspaces/tests/integration/workspaceJoinRequests.spec.ts +++ b/packages/server/modules/workspaces/tests/integration/workspaceJoinRequests.spec.ts @@ -133,7 +133,7 @@ const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags() getWorkspaceWithDomains: async () => null, getUserEmails: async () => [], addOrUpdateWorkspaceRole: async () => {}, - getWorkspaceTeam: async () => [] + getWorkspaceTeam: async () => ({ items: [], cursor: null }) })({ workspaceId: createRandomString(), userId: createRandomString() }) ) @@ -150,7 +150,7 @@ const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags() getWorkspaceWithDomains: async () => null, getUserEmails: async () => [], addOrUpdateWorkspaceRole: async () => {}, - getWorkspaceTeam: async () => [] + getWorkspaceTeam: async () => ({ items: [], cursor: null }) })({ workspaceId: createRandomString(), userId: createRandomString() }) ) @@ -185,7 +185,7 @@ const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags() workspace as unknown as WorkspaceWithDomains, getUserEmails: async () => [], addOrUpdateWorkspaceRole: async () => {}, - getWorkspaceTeam: async () => [] + getWorkspaceTeam: async () => ({ items: [], cursor: null }) })({ workspaceId: createRandomString(), userId: createRandomString() }) ) @@ -243,7 +243,7 @@ const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags() getUserEmails: async () => [{ email: user.email, verified: true }] as unknown as UserEmail[], addOrUpdateWorkspaceRole: async () => {}, - getWorkspaceTeam: async () => [] + getWorkspaceTeam: async () => ({ items: [], cursor: null }) })({ workspaceId: workspace.id, userId: user.id }) ).to.equal(true) @@ -314,7 +314,7 @@ const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags() getUserEmails: async () => [{ email: user.email, verified: true }] as unknown as UserEmail[], addOrUpdateWorkspaceRole: async () => {}, - getWorkspaceTeam: async () => [] + getWorkspaceTeam: async () => ({ items: [], cursor: null }) }) expect( diff --git a/packages/server/modules/workspaces/tests/integration/workspaces.graph.spec.ts b/packages/server/modules/workspaces/tests/integration/workspaces.graph.spec.ts index a77347f2c..6a64a13c7 100644 --- a/packages/server/modules/workspaces/tests/integration/workspaces.graph.spec.ts +++ b/packages/server/modules/workspaces/tests/integration/workspaces.graph.spec.ts @@ -463,6 +463,11 @@ describe('Workspaces GQL CRUD', () => { limit: 10, cursor: resA.data?.workspace.team.cursor }) + const resC = await largeWorkspaceApollo.execute(GetWorkspaceTeamDocument, { + workspaceId: largeWorkspace.id, + limit: 10, + cursor: resB.data?.workspace.team.cursor + }) expect(resA).to.not.haveGraphQLErrors() expect(resA.data?.workspace.team.items.length).to.equal(2) @@ -475,7 +480,11 @@ describe('Workspaces GQL CRUD', () => { expect(resB).to.not.haveGraphQLErrors() expect(resB.data?.workspace.team.items.length).to.equal(4) - expect(resB.data?.workspace.team.cursor).to.be.null + expect(resB.data?.workspace.team.cursor).to.be.not.null + + expect(resC).to.not.haveGraphQLErrors() + expect(resC.data?.workspace.team.items.length).to.equal(0) + expect(resC.data?.workspace.team.cursor).to.be.null }) it('should return correct total count', async () => {