diff --git a/packages/server/modules/core/domain/streams/operations.ts b/packages/server/modules/core/domain/streams/operations.ts index b1e828175..f4e3fbc0a 100644 --- a/packages/server/modules/core/domain/streams/operations.ts +++ b/packages/server/modules/core/domain/streams/operations.ts @@ -34,7 +34,15 @@ export type LegacyGetStreams = (params: { workspaceIdWhitelist?: string[] | null | undefined offset?: MaybeNullOrUndefined publicOnly?: MaybeNullOrUndefined -}) => Promise<{ streams: Stream[]; totalCount: number; cursorDate: Nullable }> + /** + * For filling in stream.role for the specified user + */ + userId?: string +}) => Promise<{ + streams: StreamWithOptionalRole[] + totalCount: number + cursorDate: Nullable +}> export type GetStreams = ( streamIds: string[], @@ -76,6 +84,17 @@ export type GetStreamsCollaborators = (params: { streamIds: string[] }) => Promi [streamId: string]: Array }> +export type GetStreamsCollaboratorCounts = (params: { + streamIds: string[] + type?: StreamRoles +}) => Promise<{ + [streamId: string]: + | { + [role in StreamRoles]?: number + } + | undefined +}> + export type GetUserDeletableStreams = (userId: string) => Promise> export type StoreStream = ( @@ -234,10 +253,18 @@ export type GetFavoritedStreamsCount = ( streamIdWhitelist?: Optional ) => Promise -export type RevokeStreamPermissions = (params: { - streamId: string - userId: string -}) => Promise> +export type RevokeStreamPermissions = ( + params: { + streamId: string + userId: string + }, + options?: Partial<{ + /** + * Whether to mark project record as updated + */ + trackProjectUpdate: boolean + }> +) => Promise> export type GrantStreamPermissions = ( params: { @@ -311,6 +338,14 @@ export type AddOrUpdateStreamCollaborator = ( adderResourceAccessRules?: MaybeNullOrUndefined, options?: Partial<{ fromInvite: ServerInviteRecord + /** + * Whether to mark project record as updated + */ + trackProjectUpdate: boolean + /** + * Whether to skipp checking if setByUserId has access to the stream + */ + skipAuthorization: boolean }> ) => Promise @@ -318,7 +353,40 @@ export type RemoveStreamCollaborator = ( streamId: string, userId: string, removedById: string, - removerResourceAccessRules?: MaybeNullOrUndefined + removerResourceAccessRules?: MaybeNullOrUndefined, + options?: Partial<{ + /** + * Whether to mark project record as updated + */ + trackProjectUpdate: boolean + /** + * Whether to skipp checking if setByUserId has access to the stream + */ + skipAuthorization: boolean + }> +) => Promise + +export type SetStreamCollaborator = ( + params: { + streamId: string + userId: string + /** + * Null/undefined means - remove collaborator + */ + role: MaybeNullOrUndefined + setByUserId: string + setterResourceAccessRules?: MaybeNullOrUndefined + }, + options?: Partial<{ + /** + * Whether to mark project record as updated + */ + trackProjectUpdate: boolean + /** + * Whether to skipp checking if setByUserId has access to the stream + */ + skipAuthorization: boolean + }> ) => Promise export type CloneStream = (userId: string, sourceStreamId: string) => Promise diff --git a/packages/server/modules/core/repositories/streams.ts b/packages/server/modules/core/repositories/streams.ts index fa85da19f..8bedbd1b0 100644 --- a/packages/server/modules/core/repositories/streams.ts +++ b/packages/server/modules/core/repositories/streams.ts @@ -96,7 +96,8 @@ import { MarkCommitStreamUpdated, MarkOnboardingBaseStream, GetUserDeletableStreams, - GetStreamsCollaborators + GetStreamsCollaborators, + GetStreamsCollaboratorCounts } from '@/modules/core/domain/streams/operations' import { generateProjectName } from '@/modules/core/domain/projects/logic' export type { StreamWithOptionalRole, StreamWithCommitId } @@ -585,6 +586,33 @@ export const getDiscoverableStreamsPageFactory = return await q } +export const getStreamsCollaboratorCountsFactory = + (deps: { db: Knex }): GetStreamsCollaboratorCounts => + async ({ streamIds, type }) => { + if (!streamIds.length) return {} + + const q = tables + .streamAcl(deps.db) + .whereIn(StreamAcl.col.resourceId, streamIds) + .groupBy(StreamAcl.col.resourceId, StreamAcl.col.role) + .select>([ + StreamAcl.colAs('resourceId', 'streamId'), + StreamAcl.col.role, + knex.raw('COUNT(*) as count') + ]) + + if (type) { + q.andWhere(StreamAcl.col.role, type) + } + + const res = await q + return res.reduce((acc, { streamId, role, count }) => { + acc[streamId] = acc[streamId] || {} + acc[streamId][role] = parseInt(count) + return acc + }, {} as Awaited>) + } + /** * Get stream collaborators for multiple streams at a time */ @@ -1087,8 +1115,9 @@ export const deleteProjectRoleFactory = export const revokeStreamPermissionsFactory = (deps: { db: Knex }): RevokeStreamPermissions => - async (params: { streamId: string; userId: string }) => { + async (params, options) => { const { streamId, userId } = params + const { trackProjectUpdate = true } = options || {} const existingPermission = await tables .streamAcl(deps.db) @@ -1147,12 +1176,14 @@ export const revokeStreamPermissionsFactory = }) } - // update stream updated at - const [stream] = await tables - .streams(deps.db) - .where({ id: streamId }) - .update({ updatedAt: knex.fn.now() }, '*') + // update stream updated at, if enabled + const streamQ = tables.streams(deps.db).where({ id: streamId }) + if (trackProjectUpdate) { + streamQ.update({ updatedAt: knex.fn.now() }, '*') + } + + const [stream] = await streamQ return stream } @@ -1219,10 +1250,10 @@ export const legacyGetStreamsFactory = streamIdWhitelist, workspaceIdWhitelist, offset, - publicOnly + publicOnly, + userId }) => { const query = tables.streams(deps.db) - const countQuery = tables.streams(deps.db) if (searchQuery) { const whereFunc: Knex.QueryCallback = function () { @@ -1233,7 +1264,6 @@ export const legacyGetStreamsFactory = ) } query.where(whereFunc) - countQuery.where(whereFunc) } if (publicOnly) { @@ -1250,20 +1280,33 @@ export const legacyGetStreamsFactory = this.where({ isPublic }) } query.andWhere(publicFunc) - countQuery.andWhere(publicFunc) } if (streamIdWhitelist?.length) { query.whereIn('id', streamIdWhitelist) - countQuery.whereIn('id', streamIdWhitelist) } if (workspaceIdWhitelist?.length) { query.whereIn('workspaceId', workspaceIdWhitelist) - countQuery.whereIn('workspaceId', workspaceIdWhitelist) } - const [res] = await countQuery.count() + if (userId) { + query.select([ + ...Object.values(Streams.col), + // Getting first role from grouped results + knex.raw(`(array_agg("stream_acl"."role"))[1] as role`) + ]) + query.leftJoin(StreamAcl.name, function () { + this.on(StreamAcl.col.resourceId, Streams.col.id).andOnVal( + StreamAcl.col.userId, + userId + ) + }) + query.groupBy(Streams.col.id) + } + + const countQ = deps.db.from(query.clone().as('t1')).count() + const [res] = await countQ const count = parseInt(res.count + '') if (!count) return { streams: [], totalCount: 0, cursorDate: null } diff --git a/packages/server/modules/core/services/streams/access.ts b/packages/server/modules/core/services/streams/access.ts index 534d84b26..935458495 100644 --- a/packages/server/modules/core/services/streams/access.ts +++ b/packages/server/modules/core/services/streams/access.ts @@ -6,6 +6,7 @@ import { IsStreamCollaborator, RemoveStreamCollaborator, RevokeStreamPermissions, + SetStreamCollaborator, ValidateStreamAccess } from '@/modules/core/domain/streams/operations' import { GetUser } from '@/modules/core/domain/users/operations' @@ -17,6 +18,7 @@ import { StreamRecord } from '@/modules/core/helpers/types' import { ServerInvitesEvents } from '@/modules/serverinvites/domain/events' import { AuthorizeResolver } from '@/modules/shared/domain/operations' import { BadRequestError, ForbiddenError, LogicError } from '@/modules/shared/errors' +import { DependenciesOf } from '@/modules/shared/helpers/factory' import { EventBusEmit } from '@/modules/shared/services/eventBus' import { ensureError, Roles, StreamRoles } from '@speckle/shared' @@ -93,24 +95,26 @@ export const removeStreamCollaboratorFactory = revokeStreamPermissions: RevokeStreamPermissions emitEvent: EventBusEmit }): RemoveStreamCollaborator => - async (streamId, userId, removedById, removerResourceAccessRules) => { - if (userId !== removedById) { - // User must be a stream owner to remove others - await deps.validateStreamAccess( - removedById, - streamId, - Roles.Stream.Owner, - removerResourceAccessRules - ) - } else { - // User must have any kind of role to remove himself - const isCollaborator = await deps.isStreamCollaborator(userId, streamId) - if (!isCollaborator) { - throw new StreamAccessUpdateError('User is not a stream collaborator') + async (streamId, userId, removedById, removerResourceAccessRules, options) => { + if (!options?.skipAuthorization) { + if (userId !== removedById) { + // User must be a stream owner to remove others + await deps.validateStreamAccess( + removedById, + streamId, + Roles.Stream.Owner, + removerResourceAccessRules + ) + } else { + // User must have any kind of role to remove himself + const isCollaborator = await deps.isStreamCollaborator(userId, streamId) + if (!isCollaborator) { + throw new StreamAccessUpdateError('User is not a stream collaborator') + } } } - const stream = await deps.revokeStreamPermissions({ streamId, userId }) + const stream = await deps.revokeStreamPermissions({ streamId, userId }, options) if (!stream) { throw new LogicError('Stream not found') } @@ -150,26 +154,28 @@ export const addOrUpdateStreamCollaboratorFactory = role, addedById, adderResourceAccessRules, - { fromInvite } = {} + { fromInvite, trackProjectUpdate, skipAuthorization } = {} ) => { const validRoles = Object.values(Roles.Stream) as string[] if (!validRoles.includes(role)) { throw new LogicError('Unexpected stream role') } - if (userId === addedById) { - throw new StreamInvalidAccessError( - 'User cannot change their own stream access level' + if (!skipAuthorization) { + if (userId === addedById) { + throw new StreamInvalidAccessError( + 'User cannot change their own stream access level' + ) + } + + await deps.validateStreamAccess( + addedById, + streamId, + Roles.Stream.Owner, + adderResourceAccessRules ) } - await deps.validateStreamAccess( - addedById, - streamId, - Roles.Stream.Owner, - adderResourceAccessRules - ) - // make sure server guests cannot be stream owners if (role === Roles.Stream.Owner) { const user = await deps.getUser(userId, { withRole: true }) @@ -188,11 +194,14 @@ export const addOrUpdateStreamCollaboratorFactory = } }) - const stream = (await deps.grantStreamPermissions({ - streamId, - userId, - role: role as StreamRoles - })) as StreamRecord // validateStreamAccess already checked that it exists + const stream = (await deps.grantStreamPermissions( + { + streamId, + userId, + role: role as StreamRoles + }, + { trackProjectUpdate } + )) as StreamRecord // validateStreamAccess already checked that it exists if (fromInvite) { await deps.emitEvent({ @@ -217,3 +226,33 @@ export const addOrUpdateStreamCollaboratorFactory = return stream } + +export const setStreamCollaboratorFactory = + ( + deps: DependenciesOf & + DependenciesOf + ): SetStreamCollaborator => + async (params, options) => { + const addOrUpdateStreamCollaborator = addOrUpdateStreamCollaboratorFactory(deps) + const removeStreamCollaborator = removeStreamCollaboratorFactory(deps) + + const { streamId, userId, role, setterResourceAccessRules, setByUserId } = params + if (role) { + return await addOrUpdateStreamCollaborator( + streamId, + userId, + role, + setByUserId, + setterResourceAccessRules, + options + ) + } else { + return await removeStreamCollaborator( + streamId, + userId, + setByUserId, + setterResourceAccessRules, + options + ) + } + } diff --git a/packages/server/modules/gatekeeper/domain/billing.ts b/packages/server/modules/gatekeeper/domain/billing.ts index 1409ec973..9795b5822 100644 --- a/packages/server/modules/gatekeeper/domain/billing.ts +++ b/packages/server/modules/gatekeeper/domain/billing.ts @@ -6,7 +6,13 @@ import { WorkspacePlanProductPrices, WorkspacePricingProducts } from '@/modules/gatekeeperCore/domain/billing' -import { PaidWorkspacePlans, WorkspacePlanBillingIntervals } from '@speckle/shared' +import { Workspace, WorkspaceAcl } from '@/modules/workspacesCore/domain/types' +import { + Nullable, + Optional, + PaidWorkspacePlans, + WorkspacePlanBillingIntervals +} from '@speckle/shared' import { OverrideProperties } from 'type-fest' import { z } from 'zod' @@ -14,6 +20,10 @@ export type GetWorkspacePlan = (args: { workspaceId: string }) => Promise +export type GetWorkspaceWithPlan = (args: { + workspaceId: string +}) => Promise }>> + export type UpsertTrialWorkspacePlan = (args: { workspacePlan: TrialWorkspacePlan }) => Promise @@ -214,3 +224,11 @@ export type GetRecurringPrices = () => Promise< > export type GetWorkspacePlanProductPrices = () => Promise + +export type GetWorkspaceRolesAndSeats = (params: { workspaceId: string }) => Promise<{ + [userId: string]: { + role: WorkspaceAcl + seat: Nullable + userId: string + } +}> diff --git a/packages/server/modules/gatekeeper/graph/resolvers/index.ts b/packages/server/modules/gatekeeper/graph/resolvers/index.ts index c56daa475..cfa547613 100644 --- a/packages/server/modules/gatekeeper/graph/resolvers/index.ts +++ b/packages/server/modules/gatekeeper/graph/resolvers/index.ts @@ -201,7 +201,12 @@ export = FF_GATEKEEPER_MODULE_ENABLED getWorkspaceRoleForUser: getWorkspaceRoleForUserFactory({ db }), emit: getEventBus().emit }) - await assignSeat({ workspaceId, userId, type: seatType }) + await assignSeat({ + workspaceId, + userId, + type: seatType, + assignedByUserId: ctx.userId! + }) return ctx.loaders.workspaces!.getWorkspace.load(workspaceId) } diff --git a/packages/server/modules/gatekeeper/repositories/billing.ts b/packages/server/modules/gatekeeper/repositories/billing.ts index 1cc772846..23651121b 100644 --- a/packages/server/modules/gatekeeper/repositories/billing.ts +++ b/packages/server/modules/gatekeeper/repositories/billing.ts @@ -1,4 +1,4 @@ -import { Streams } from '@/modules/core/dbSchema' +import { buildTableHelper, Streams } from '@/modules/core/dbSchema' import { CheckoutSession, GetCheckoutSession, @@ -15,7 +15,8 @@ import { GetWorkspaceSubscriptionBySubscriptionId, GetWorkspaceSubscriptions, UpsertTrialWorkspacePlan, - UpsertUnpaidWorkspacePlan + UpsertUnpaidWorkspacePlan, + GetWorkspaceWithPlan } from '@/modules/gatekeeper/domain/billing' import { ChangeExpiredTrialWorkspacePlanStatuses, @@ -23,19 +24,52 @@ import { GetWorkspacePlanByProjectId } from '@/modules/gatekeeper/domain/operations' import { WorkspacePlan } from '@/modules/gatekeeperCore/domain/billing' +import { formatJsonArrayRecords } from '@/modules/shared/helpers/dbHelper' import { Workspace } from '@/modules/workspacesCore/domain/types' import { Workspaces } from '@/modules/workspacesCore/helpers/db' import { Knex } from 'knex' +import { omit } from 'lodash' + +const WorkspacePlans = buildTableHelper('workspace_plans', [ + 'workspaceId', + 'name', + 'status', + 'createdAt', + 'updatedAt' +]) const tables = { workspaces: (db: Knex) => db('workspaces'), - workspacePlans: (db: Knex) => db('workspace_plans'), + workspacePlans: (db: Knex) => db(WorkspacePlans.name), workspaceCheckoutSessions: (db: Knex) => db('workspace_checkout_sessions'), workspaceSubscriptions: (db: Knex) => db('workspace_subscriptions') } +export const getWorkspaceWithPlanFactory = + (deps: { db: Knex }): GetWorkspaceWithPlan => + async ({ workspaceId }) => { + const q = tables + .workspaces(deps.db) + .select([ + ...Workspaces.cols, + WorkspacePlans.groupArray('plans') + ]) + .leftJoin(WorkspacePlans.name, WorkspacePlans.col.workspaceId, Workspaces.col.id) + .where(Workspaces.col.id, workspaceId) + .groupBy(Workspaces.col.id) + .first() + + const workspace = await q + if (!workspace) return undefined + + return { + ...omit(workspace, 'plans'), + plan: formatJsonArrayRecords(workspace.plans || [])[0] || null + } + } + export const getWorkspacePlanFactory = ({ db }: { db: Knex }): GetWorkspacePlan => async ({ workspaceId }) => { diff --git a/packages/server/modules/gatekeeper/repositories/workspaceSeat.ts b/packages/server/modules/gatekeeper/repositories/workspaceSeat.ts index 4dfc9c38b..f5bd78ae3 100644 --- a/packages/server/modules/gatekeeper/repositories/workspaceSeat.ts +++ b/packages/server/modules/gatekeeper/repositories/workspaceSeat.ts @@ -1,5 +1,8 @@ import { buildTableHelper } from '@/modules/core/dbSchema' -import { WorkspaceSeat } from '@/modules/gatekeeper/domain/billing' +import { + GetWorkspaceRolesAndSeats, + WorkspaceSeat +} from '@/modules/gatekeeper/domain/billing' import { CountSeatsByTypeInWorkspace, CreateWorkspaceSeat, @@ -7,6 +10,9 @@ import { GetWorkspaceUserSeat, GetWorkspaceUserSeats } from '@/modules/gatekeeper/domain/operations' +import { formatJsonArrayRecords } from '@/modules/shared/helpers/dbHelper' +import { WorkspaceAcl as WorkspaceAclRecord } from '@/modules/workspacesCore/domain/types' +import { WorkspaceAcl } from '@/modules/workspacesCore/helpers/db' import { Knex } from 'knex' const WorkspaceSeats = buildTableHelper('workspace_seats', [ @@ -18,7 +24,8 @@ const WorkspaceSeats = buildTableHelper('workspace_seats', [ ]) const tables = { - workspaceSeats: (db: Knex) => db(WorkspaceSeats.name) + workspaceSeats: (db: Knex) => db(WorkspaceSeats.name), + workspaceAcl: (db: Knex) => db(WorkspaceAcl.name) } export const countSeatsByTypeInWorkspaceFactory = @@ -79,3 +86,37 @@ export const getWorkspaceUserSeatFactory = }) return seats[userId] } + +export const getWorkspaceRolesAndSeatsFactory = + (deps: { db: Knex }): GetWorkspaceRolesAndSeats => + async ({ workspaceId }) => { + const q = tables + .workspaceAcl(deps.db) + .select>([ + // There's only ever gonna be 1 role and seat per user, but this way we can avoid having to group + // by many columns and we can get everything in 1 query + WorkspaceAcl.groupArray('roles'), + WorkspaceSeats.groupArray('seats') + ]) + .leftJoin(WorkspaceSeats.name, (j1) => { + j1.on(WorkspaceSeats.col.userId, WorkspaceAcl.col.userId).andOnVal( + WorkspaceSeats.col.workspaceId, + workspaceId + ) + }) + .where(WorkspaceAcl.col.workspaceId, workspaceId) + .groupBy(WorkspaceAcl.col.userId) + + const res = await q + return res.reduce((acc, row) => { + const role = formatJsonArrayRecords(row.roles)[0] + if (!role) return acc + + acc[role.userId] = { + role, + seat: formatJsonArrayRecords(row.seats || [])[0] || null, + userId: role.userId + } + return acc + }, {} as Awaited>) + } diff --git a/packages/server/modules/shared/domain/rolesAndScopes/logic.ts b/packages/server/modules/shared/domain/rolesAndScopes/logic.ts index b69f3bfaf..f578a1d33 100644 --- a/packages/server/modules/shared/domain/rolesAndScopes/logic.ts +++ b/packages/server/modules/shared/domain/rolesAndScopes/logic.ts @@ -2,6 +2,9 @@ import { UserRoleData } from '@/modules/shared/domain/rolesAndScopes/types' import { AvailableRoles } from '@speckle/shared' import { isUndefined } from 'lodash' +/** + * Order roles by weight in descending order (meaning - highest permission roles come first) + */ export const orderByWeight = ( roles: T[], definitions: UserRoleData[] diff --git a/packages/server/modules/workspaces/domain/operations.ts b/packages/server/modules/workspaces/domain/operations.ts index 88b46a365..17b49c688 100644 --- a/packages/server/modules/workspaces/domain/operations.ts +++ b/packages/server/modules/workspaces/domain/operations.ts @@ -1,5 +1,4 @@ import { WorkspaceEvents } from '@/modules/workspacesCore/domain/events' -import { StreamRecord } from '@/modules/core/helpers/types' import { Workspace, WorkspaceAcl, @@ -20,12 +19,9 @@ import { StreamRoles, WorkspaceRoles } from '@speckle/shared' -import { - WorkspaceCreationState, - WorkspaceRoleToDefaultProjectRoleMapping -} from '@/modules/workspaces/domain/types' +import { WorkspaceCreationState } from '@/modules/workspaces/domain/types' import { WorkspaceTeam } from '@/modules/workspaces/domain/types' -import { Stream } from '@/modules/core/domain/streams/types' +import { Stream, StreamWithOptionalRole } from '@/modules/core/domain/streams/types' import { TokenResourceIdentifier } from '@/modules/core/domain/tokens/types' import { ServerRegion } from '@/modules/multiregion/domain/types' import { SetOptional } from 'type-fest' @@ -199,22 +195,37 @@ export type UpdateWorkspaceRole = ( * Only add or upgrade role, prevent downgrades */ preventRoleDowngrade?: boolean + + updatedByUserId: string } ) => Promise -export type GetWorkspaceRoleToDefaultProjectRoleMapping = (args: { +export type GetWorkspaceRolesAllowedProjectRolesFactory = (params: { workspaceId: string -}) => Promise +}) => Promise<{ + defaultProjectRole: (args: { + workspaceRole: WorkspaceRoles + seatType: MaybeNullOrUndefined + }) => StreamRoles | null + allowedProjectRoles: (args: { + workspaceRole: WorkspaceRoles + seatType: MaybeNullOrUndefined + }) => StreamRoles[] +}> /** Workspace Projects */ type QueryAllWorkspaceProjectsArgs = { workspaceId: string + /** + * Optionally get project roles for a specific user + */ + userId?: string } export type QueryAllWorkspaceProjects = ( args: QueryAllWorkspaceProjectsArgs -) => AsyncGenerator +) => AsyncGenerator /** Workspace Project Roles */ @@ -337,7 +348,9 @@ export type GetWorkspaceJoinRequest = ( ) => Promise export type ApproveWorkspaceJoinRequest = ( - params: Pick + params: Pick & { + approvedByUserId: string + } ) => Promise export type DenyWorkspaceJoinRequest = ( @@ -388,7 +401,10 @@ export type CopyProjectAutomations = (params: { }) => Promise> export type AssignWorkspaceSeat = ( - params: Pick & { type: WorkspaceSeatType } + params: Pick & { + type: WorkspaceSeatType + assignedByUserId: string + } ) => Promise export type EnsureValidWorkspaceRoleSeat = (params: { diff --git a/packages/server/modules/workspaces/domain/types.ts b/packages/server/modules/workspaces/domain/types.ts index 4889904e3..d82931ad1 100644 --- a/packages/server/modules/workspaces/domain/types.ts +++ b/packages/server/modules/workspaces/domain/types.ts @@ -1,7 +1,7 @@ export { WorkspaceInviteResourceTarget } from '@/modules/workspacesCore/domain/types' import { LimitedUserRecord, UserWithRole } from '@/modules/core/helpers/types' import { WorkspaceInviteResourceType } from '@/modules/workspacesCore/domain/constants' -import { StreamRoles, WorkspaceRoles } from '@speckle/shared' +import { WorkspaceRoles } from '@speckle/shared' declare module '@/modules/serverinvites/domain/types' { interface InviteResourceTargetTypeMap { @@ -22,10 +22,6 @@ export type WorkspaceTeamMember = UserWithRole & { export type WorkspaceTeam = WorkspaceTeamMember[] -export type WorkspaceRoleToDefaultProjectRoleMapping = { - [key in WorkspaceRoles]: StreamRoles | null -} - export type WorkspaceCreationState = { workspaceId: string completed: boolean diff --git a/packages/server/modules/workspaces/events/eventListener.ts b/packages/server/modules/workspaces/events/eventListener.ts index 11680b4a9..df58ab2f4 100644 --- a/packages/server/modules/workspaces/events/eventListener.ts +++ b/packages/server/modules/workspaces/events/eventListener.ts @@ -1,7 +1,10 @@ import { deleteProjectRoleFactory, getStreamFactory, + getStreamsCollaboratorCountsFactory, + grantStreamPermissionsFactory, legacyGetStreamsFactory, + revokeStreamPermissionsFactory, upsertProjectRoleFactory } from '@/modules/core/repositories/streams' import { @@ -9,8 +12,7 @@ import { GetDefaultRegion, GetWorkspace, GetWorkspaceRoleForUser, - GetWorkspaceRoles, - GetWorkspaceRoleToDefaultProjectRoleMapping, + GetWorkspaceRolesAllowedProjectRolesFactory, QueryAllWorkspaceProjects } from '@/modules/workspaces/domain/operations' import { @@ -25,12 +27,7 @@ import { logger, moduleLogger } from '@/observability/logging' import { updateWorkspaceRoleFactory } from '@/modules/workspaces/services/management' import { EventPayload, getEventBus } from '@/modules/shared/services/eventBus' import { WorkspaceInviteResourceType } from '@/modules/workspacesCore/domain/constants' -import { - PaidWorkspacePlansNew, - Roles, - throwUncoveredError, - WorkspaceRoles -} from '@speckle/shared' +import { Roles, throwUncoveredError, WorkspaceRoles } from '@speckle/shared' import { DeleteProjectRole, UpsertProjectRole @@ -47,14 +44,18 @@ import { } from '@/modules/workspaces/repositories/workspaces' import { queryAllWorkspaceProjectsFactory, - getWorkspaceRoleToDefaultProjectRoleMappingFactory + getWorkspaceRolesAllowedProjectRolesFactory } from '@/modules/workspaces/services/projects' import { withTransaction } from '@/modules/shared/helpers/dbHelper' import { findEmailsByUserIdFactory, findVerifiedEmailsByUserIdFactory } from '@/modules/core/repositories/userEmails' -import { GetStream } from '@/modules/core/domain/streams/operations' +import { + GetStream, + GetStreamsCollaboratorCounts, + SetStreamCollaborator +} from '@/modules/core/domain/streams/operations' import { GetUserSsoSession, GetWorkspaceSsoProviderRecord @@ -66,7 +67,6 @@ import { getWorkspaceSsoProviderRecordFactory } from '@/modules/workspaces/repositories/sso' import { - WorkspaceAdminError, WorkspaceInvalidRoleError, WorkspacesNotAuthorizedError } from '@/modules/workspaces/errors/workspace' @@ -80,6 +80,7 @@ import { getBaseTrackingProperties, getClient } from '@/modules/shared/utils/mix import { calculateSubscriptionSeats, GetWorkspacePlan, + GetWorkspaceRolesAndSeats, GetWorkspaceSubscription, WorkspaceSeatType } from '@/modules/gatekeeper/domain/billing' @@ -89,28 +90,37 @@ import { FindEmailsByUserId } from '@/modules/core/domain/userEmails/operations' import { getDefaultRegionFactory } from '@/modules/workspaces/repositories/regions' import { getWorkspacePlanFactory, - getWorkspaceSubscriptionFactory + getWorkspaceSubscriptionFactory, + getWorkspaceWithPlanFactory } from '@/modules/gatekeeper/repositories/billing' import { ensureValidWorkspaceRoleSeatFactory } from '@/modules/workspaces/services/workspaceSeat' import { createWorkspaceSeatFactory, deleteWorkspaceSeatFactory, + getWorkspaceRolesAndSeatsFactory, getWorkspaceUserSeatFactory } from '@/modules/gatekeeper/repositories/workspaceSeat' import { DeleteWorkspaceSeat, GetWorkspaceUserSeat } from '@/modules/gatekeeper/domain/operations' +import { + isStreamCollaboratorFactory, + setStreamCollaboratorFactory, + validateStreamAccessFactory +} from '@/modules/core/services/streams/access' +import { getUserFactory } from '@/modules/core/repositories/users' +import { authorizeResolver } from '@/modules/shared' export const onProjectCreatedFactory = ({ - getWorkspaceRoles, + getWorkspaceRolesAndSeats, upsertProjectRole, - getWorkspaceRoleToDefaultProjectRoleMapping + getWorkspaceRolesAllowedProjectRoles }: { - getWorkspaceRoles: GetWorkspaceRoles + getWorkspaceRolesAndSeats: GetWorkspaceRolesAndSeats upsertProjectRole: UpsertProjectRole - getWorkspaceRoleToDefaultProjectRoleMapping: GetWorkspaceRoleToDefaultProjectRoleMapping + getWorkspaceRolesAllowedProjectRoles: GetWorkspaceRolesAllowedProjectRolesFactory }) => async (payload: ProjectEventsPayloads[typeof ProjectEvents.Created]) => { const { id: projectId, workspaceId } = payload.project @@ -119,20 +129,21 @@ export const onProjectCreatedFactory = return } - const workspaceMembers = await getWorkspaceRoles({ workspaceId }) + const workspaceMembers = Object.values( + await getWorkspaceRolesAndSeats({ workspaceId }) + ) - const defaultRoleMapping = await getWorkspaceRoleToDefaultProjectRoleMapping({ + const { defaultProjectRole } = await getWorkspaceRolesAllowedProjectRoles({ workspaceId }) await Promise.all( - workspaceMembers.map(({ userId, role: workspaceRole }) => { - const projectRole = defaultRoleMapping[workspaceRole] + workspaceMembers.map(({ userId, role: { role: workspaceRole }, seat }) => { + const projectRole = defaultProjectRole({ workspaceRole, seatType: seat?.type }) + if (!projectRole) return // we do not need to assign new roles to the project owner if (userId === payload.ownerId) return - // Guests do not get roles on project create - if (!projectRole || workspaceRole === Roles.Workspace.Guest) return return upsertProjectRole({ projectId, @@ -182,7 +193,8 @@ export const onInviteFinalizedFactory = role: workspaceRole, userId: targetUserId, workspaceId: project.workspaceId, - skipProjectRoleUpdatesFor: [project.id] + skipProjectRoleUpdatesFor: [project.id], + updatedByUserId: invite.inviterId }) } @@ -244,56 +256,81 @@ export const onWorkspaceRoleDeletedFactory = export const onWorkspaceRoleUpdatedFactory = ({ - getWorkspaceRoleToDefaultProjectRoleMapping, + getWorkspaceRolesAllowedProjectRoles, queryAllWorkspaceProjects, - deleteProjectRole, - upsertProjectRole + setStreamCollaborator, + getStreamsCollaboratorCounts }: { - getWorkspaceRoleToDefaultProjectRoleMapping: GetWorkspaceRoleToDefaultProjectRoleMapping + getWorkspaceRolesAllowedProjectRoles: GetWorkspaceRolesAllowedProjectRolesFactory queryAllWorkspaceProjects: QueryAllWorkspaceProjects - deleteProjectRole: DeleteProjectRole - upsertProjectRole: UpsertProjectRole + setStreamCollaborator: SetStreamCollaborator + getStreamsCollaboratorCounts: GetStreamsCollaboratorCounts }) => async ({ acl, - flags + flags, + seatType, + updatedByUserId }: { acl: { userId: string; role: WorkspaceRoles; workspaceId: string } + seatType: WorkspaceSeatType flags?: { skipProjectRoleUpdatesFor: string[] } + updatedByUserId: string }) => { const { userId, role, workspaceId } = acl - const defaultProjectRoleMapping = await getWorkspaceRoleToDefaultProjectRoleMapping( - { - workspaceId - } - ) + const { defaultProjectRole } = await getWorkspaceRolesAllowedProjectRoles({ + workspaceId + }) - const nextProjectRole = defaultProjectRoleMapping[role] + const nextUserRole = defaultProjectRole({ workspaceRole: role, seatType }) + + // Keep user's project roles in sync with their workspace role & seat type + for await (const projectsPage of queryAllWorkspaceProjects({ + workspaceId, + userId + })) { + const projectsOldOwnerCounts = await getStreamsCollaboratorCounts({ + streamIds: projectsPage.map((p) => p.id), + type: Roles.Stream.Owner + }) - for await (const projectsPage of queryAllWorkspaceProjects({ workspaceId })) { await Promise.all( - projectsPage.map(async ({ id: projectId }) => { + projectsPage.map(async ({ id: projectId, role: originalProjectRole }) => { if (flags?.skipProjectRoleUpdatesFor.includes(projectId)) { // Skip assignment (used during invite flow) // TODO: Can we refactor this special case away? return } - if (!nextProjectRole) { - // User is being demoted to a workspace role without project access - await deleteProjectRole({ projectId, userId }) - return + // If downgraded from owner & last owner, transfer ownership to admin causing the role update (updatedByUserId) + const isNoLongerOwner = + originalProjectRole === Roles.Stream.Owner && + (!nextUserRole || nextUserRole !== Roles.Stream.Owner) + const wasLastOwner = + projectsOldOwnerCounts[projectId]?.[Roles.Stream.Owner] === 1 + if (isNoLongerOwner && wasLastOwner) { + await setStreamCollaborator( + { + streamId: projectId, + userId: updatedByUserId, + role: Roles.Stream.Owner, + setByUserId: updatedByUserId + }, + { trackProjectUpdate: false, skipAuthorization: true } + ) } - await upsertProjectRole( + // Finally change target role + await setStreamCollaborator( { - projectId, + streamId: projectId, userId, - role: nextProjectRole + role: nextUserRole, + setByUserId: updatedByUserId }, - { trackProjectUpdate: false } + { trackProjectUpdate: false, skipAuthorization: true } ) }) ) @@ -481,7 +518,7 @@ const blockInvalidWorkspaceProjectRoleUpdatesFactory = getStream: GetStream getWorkspaceRoleForUser: GetWorkspaceRoleForUser getWorkspaceUserSeat: GetWorkspaceUserSeat - getWorkspacePlan: GetWorkspacePlan + getWorkspaceRolesAllowedProjectRoles: GetWorkspaceRolesAllowedProjectRolesFactory }) => async ({ payload }: EventPayload) => { const project = await deps.getStream({ streamId: payload.projectId }) @@ -491,41 +528,23 @@ const blockInvalidWorkspaceProjectRoleUpdatesFactory = workspaceId: project.workspaceId, userId: payload.targetUserId } - const [currentWorkspaceRole, seat, plan] = await Promise.all([ + const [currentWorkspaceRole, seat, { allowedProjectRoles }] = await Promise.all([ deps.getWorkspaceRoleForUser(roleSeatParams), deps.getWorkspaceUserSeat(roleSeatParams), - deps.getWorkspacePlan({ workspaceId: project.workspaceId }) + deps.getWorkspaceRolesAllowedProjectRoles({ workspaceId: project.workspaceId }) ]) - // Workspace role checks - if (currentWorkspaceRole?.role === Roles.Workspace.Admin) { - // User is workspace admin and cannot have their project roles changed - throw new WorkspaceAdminError() - } + if (!currentWorkspaceRole) return - if ( - currentWorkspaceRole?.role === Roles.Workspace.Guest && - payload.role === Roles.Stream.Owner - ) { - // Workspace guests cannot be project owners - throw new WorkspaceInvalidRoleError('Workspace guests cannot be project owners.') - } + const allowedRoles = allowedProjectRoles({ + workspaceRole: currentWorkspaceRole.role, + seatType: seat?.type + }) - // Workspace seat checks - if ( - !plan || - !seat || - !(Object.values(PaidWorkspacePlansNew) as string[]).includes(plan.name) - ) { - return // Doesn't apply - } - - if ( - seat.type === WorkspaceSeatType.Viewer && - payload.role !== Roles.Stream.Reviewer - ) { + if (!allowedRoles.includes(payload.role)) { + // User's workspace role does not allow the requested project role throw new WorkspaceInvalidRoleError( - 'Workspace viewers can only be project reviewers.' + `User's workspace role '${currentWorkspaceRole.role}' and seat type '${seat?.type}' does not allow project role '${payload.role}'.` ) } } @@ -548,7 +567,10 @@ export const initializeEventListenersFactory = getStream, getWorkspaceRoleForUser, getWorkspaceUserSeat, - getWorkspacePlan + getWorkspaceRolesAllowedProjectRoles: + getWorkspaceRolesAllowedProjectRolesFactory({ + getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db }) + }) }) const createWorkspaceSeat = createWorkspaceSeatFactory({ db }) const ensureValidWorkspaceRoleSeat = ensureValidWorkspaceRoleSeatFactory({ @@ -559,12 +581,12 @@ export const initializeEventListenersFactory = const quitCbs = [ eventBus.listen(ProjectEvents.Created, async ({ payload }) => { const onProjectCreated = onProjectCreatedFactory({ - getWorkspaceRoleToDefaultProjectRoleMapping: - getWorkspaceRoleToDefaultProjectRoleMappingFactory({ - getWorkspace + getWorkspaceRolesAllowedProjectRoles: + getWorkspaceRolesAllowedProjectRolesFactory({ + getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db }) }), upsertProjectRole: upsertProjectRoleFactory({ db }), - getWorkspaceRoles: getWorkspaceRolesFactory({ db }) + getWorkspaceRolesAndSeats: getWorkspaceRolesAndSeatsFactory({ db }) }) await onProjectCreated(payload) }), @@ -624,13 +646,22 @@ export const initializeEventListenersFactory = eventBus.listen(WorkspaceEvents.RoleUpdated, async ({ payload }) => { const trx = await db.transaction() const onWorkspaceRoleUpdated = onWorkspaceRoleUpdatedFactory({ - getWorkspaceRoleToDefaultProjectRoleMapping: - getWorkspaceRoleToDefaultProjectRoleMappingFactory({ - getWorkspace + getWorkspaceRolesAllowedProjectRoles: + getWorkspaceRolesAllowedProjectRolesFactory({ + getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db }) }), queryAllWorkspaceProjects: queryAllWorkspaceProjectsFactory({ getStreams }), - deleteProjectRole: deleteProjectRoleFactory({ db: trx }), - upsertProjectRole: upsertProjectRoleFactory({ db: trx }) + setStreamCollaborator: setStreamCollaboratorFactory({ + getUser: getUserFactory({ db }), + validateStreamAccess: validateStreamAccessFactory({ authorizeResolver }), + emitEvent: eventBus.emit, + grantStreamPermissions: grantStreamPermissionsFactory({ db: trx }), + isStreamCollaborator: isStreamCollaboratorFactory({ + getStream: getStreamFactory({ db }) + }), + revokeStreamPermissions: revokeStreamPermissionsFactory({ db: trx }) + }), + getStreamsCollaboratorCounts: getStreamsCollaboratorCountsFactory({ db }) }) await withTransaction(onWorkspaceRoleUpdated(payload), trx) }), diff --git a/packages/server/modules/workspaces/graph/resolvers/workspaceJoinRequests.ts b/packages/server/modules/workspaces/graph/resolvers/workspaceJoinRequests.ts index 117f8737f..7bc8801ee 100644 --- a/packages/server/modules/workspaces/graph/resolvers/workspaceJoinRequests.ts +++ b/packages/server/modules/workspaces/graph/resolvers/workspaceJoinRequests.ts @@ -127,7 +127,7 @@ export default FF_WORKSPACES_MODULE_ENABLED workspaceJoinRequestMutations: () => ({}) }, WorkspaceJoinRequestMutations: { - approve: async (_parent, args) => { + approve: async (_parent, args, ctx) => { const approveWorkspaceJoinRequest = commandFactory({ db, @@ -163,7 +163,8 @@ export default FF_WORKSPACES_MODULE_ENABLED }) return await approveWorkspaceJoinRequest({ userId: args.input.userId, - workspaceId: args.input.workspaceId + workspaceId: args.input.workspaceId, + approvedByUserId: ctx.userId! }) }, deny: async (_parent, args) => { diff --git a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts index 412ba54ef..b1448c12e 100644 --- a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts +++ b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts @@ -98,7 +98,7 @@ import { import { createWorkspaceProjectFactory, getWorkspaceProjectsFactory, - getWorkspaceRoleToDefaultProjectRoleMappingFactory, + getWorkspaceRolesAllowedProjectRolesFactory, moveProjectToWorkspaceFactory, queryAllWorkspaceProjectsFactory } from '@/modules/workspaces/services/projects' @@ -177,6 +177,7 @@ import { getDefaultRegionFactory } from '@/modules/workspaces/repositories/regio import { convertFunctionToGraphQLReturn } from '@/modules/automate/services/functionManagement' import { getWorkspacePlanFactory, + getWorkspaceWithPlanFactory, upsertWorkspacePlanFactory } from '@/modules/gatekeeper/repositories/billing' import { Knex } from 'knex' @@ -206,6 +207,7 @@ import { import { ensureValidWorkspaceRoleSeatFactory } from '@/modules/workspaces/services/workspaceSeat' import { createWorkspaceSeatFactory, + getWorkspaceRolesAndSeatsFactory, getWorkspaceUserSeatFactory } from '@/modules/gatekeeper/repositories/workspaceSeat' @@ -606,7 +608,12 @@ export = FF_WORKSPACES_MODULE_ENABLED }) }) }) - await updateWorkspaceRole({ userId, workspaceId, role }) + await updateWorkspaceRole({ + userId, + workspaceId, + role, + updatedByUserId: context.userId! + }) } return await getWorkspaceFactory({ db })({ @@ -1004,10 +1011,10 @@ export = FF_WORKSPACES_MODULE_ENABLED updateProject: updateProjectFactory({ db }), upsertProjectRole: upsertProjectRoleFactory({ db }), getProjectCollaborators: getProjectCollaboratorsFactory({ db }), - getWorkspaceRoles: getWorkspaceRolesFactory({ db }), - getWorkspaceRoleToDefaultProjectRoleMapping: - getWorkspaceRoleToDefaultProjectRoleMappingFactory({ - getWorkspace: getWorkspaceFactory({ db }) + getWorkspaceRolesAndSeats: getWorkspaceRolesAndSeatsFactory({ db }), + getWorkspaceRolesAllowedProjectRoles: + getWorkspaceRolesAllowedProjectRolesFactory({ + getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db }) }), updateWorkspaceRole: updateWorkspaceRoleFactory({ getWorkspaceRoles: getWorkspaceRolesFactory({ db }), @@ -1025,7 +1032,11 @@ export = FF_WORKSPACES_MODULE_ENABLED }) }) - return await moveProjectToWorkspace({ projectId, workspaceId }) + return await moveProjectToWorkspace({ + projectId, + workspaceId, + movedByUserId: context.userId! + }) } }, Workspace: { diff --git a/packages/server/modules/workspaces/services/invites.ts b/packages/server/modules/workspaces/services/invites.ts index e6c5e7c3d..56a930433 100644 --- a/packages/server/modules/workspaces/services/invites.ts +++ b/packages/server/modules/workspaces/services/invites.ts @@ -575,7 +575,8 @@ export const processFinalizedWorkspaceInviteFactory = userId: finalizerUserId, workspaceId: workspace.id, role: invite.resource.role || Roles.Workspace.Member, - preventRoleDowngrade: true + preventRoleDowngrade: true, + updatedByUserId: invite.inviterId }) } } diff --git a/packages/server/modules/workspaces/services/join.ts b/packages/server/modules/workspaces/services/join.ts index c5eabd739..fa9e9f622 100644 --- a/packages/server/modules/workspaces/services/join.ts +++ b/packages/server/modules/workspaces/services/join.ts @@ -56,6 +56,10 @@ export const joinWorkspaceFactory = }) await emitWorkspaceEvent({ eventName: WorkspaceEvents.RoleUpdated, - payload: { acl: { userId, workspaceId, role }, seatType: type } + payload: { + acl: { userId, workspaceId, role }, + seatType: type, + updatedByUserId: userId + } }) } diff --git a/packages/server/modules/workspaces/services/management.ts b/packages/server/modules/workspaces/services/management.ts index b29b1074a..d4766f8c3 100644 --- a/packages/server/modules/workspaces/services/management.ts +++ b/packages/server/modules/workspaces/services/management.ts @@ -421,7 +421,8 @@ export const updateWorkspaceRoleFactory = userId, role: nextWorkspaceRole, skipProjectRoleUpdatesFor, - preventRoleDowngrade + preventRoleDowngrade, + updatedByUserId }): Promise => { const workspaceRoles = await getWorkspaceRoles({ workspaceId }) @@ -494,7 +495,8 @@ export const updateWorkspaceRoleFactory = seatType: type, flags: { skipProjectRoleUpdatesFor: skipProjectRoleUpdatesFor ?? [] - } + }, + updatedByUserId } }) } diff --git a/packages/server/modules/workspaces/services/projects.ts b/packages/server/modules/workspaces/services/projects.ts index 957bfee91..f069f3b7b 100644 --- a/packages/server/modules/workspaces/services/projects.ts +++ b/packages/server/modules/workspaces/services/projects.ts @@ -1,9 +1,7 @@ import { StreamRecord } from '@/modules/core/helpers/types' import { GetDefaultRegion, - GetWorkspace, - GetWorkspaceRoles, - GetWorkspaceRoleToDefaultProjectRoleMapping, + GetWorkspaceRolesAllowedProjectRolesFactory, QueryAllWorkspaceProjects, UpdateWorkspaceRole } from '@/modules/workspaces/domain/operations' @@ -18,8 +16,14 @@ import { UpdateProject, UpsertProjectRole } from '@/modules/core/domain/projects/operations' -import { chunk } from 'lodash' -import { Roles, StreamRoles } from '@speckle/shared' +import { chunk, intersection } from 'lodash' +import { + MaybeNullOrUndefined, + Roles, + StreamRoles, + throwUncoveredError, + WorkspaceRoles +} from '@speckle/shared' import { orderByWeight } from '@/modules/shared/domain/rolesAndScopes/logic' import coreUserRoles from '@/modules/core/roles' import { @@ -46,6 +50,13 @@ import { getWorkspaceFactory, upsertWorkspaceFactory } from '@/modules/workspaces/repositories/workspaces' +import { + GetWorkspaceRolesAndSeats, + GetWorkspaceWithPlan, + WorkspaceSeatType +} from '@/modules/gatekeeper/domain/billing' +import { isNewPaidPlanType } from '@/modules/gatekeeper/helpers/plans' +import { LogicError } from '@/modules/shared/errors' export const queryAllWorkspaceProjectsFactory = ({ getStreams @@ -53,7 +64,8 @@ export const queryAllWorkspaceProjectsFactory = ({ getStreams: LegacyGetStreams }): QueryAllWorkspaceProjects => async function* queryAllWorkspaceProjects({ - workspaceId + workspaceId, + userId }): AsyncGenerator { let cursor: Date | null = null let iterationCount = 0 @@ -64,11 +76,12 @@ export const queryAllWorkspaceProjectsFactory = ({ const { streams, cursorDate } = await getStreams({ cursor, orderBy: null, - limit: 1000, + limit: 100, visibility: null, searchQuery: null, streamIdWhitelist: null, - workspaceIdWhitelist: [workspaceId] + workspaceIdWhitelist: [workspaceId], + userId }) yield streams @@ -119,6 +132,7 @@ export const getWorkspaceProjectsFactory = type MoveProjectToWorkspaceArgs = { projectId: string workspaceId: string + movedByUserId: string } export const moveProjectToWorkspaceFactory = @@ -127,21 +141,22 @@ export const moveProjectToWorkspaceFactory = updateProject, upsertProjectRole, getProjectCollaborators, - getWorkspaceRoles, - getWorkspaceRoleToDefaultProjectRoleMapping, + getWorkspaceRolesAndSeats, + getWorkspaceRolesAllowedProjectRoles, updateWorkspaceRole }: { getProject: GetProject updateProject: UpdateProject upsertProjectRole: UpsertProjectRole getProjectCollaborators: GetProjectCollaborators - getWorkspaceRoles: GetWorkspaceRoles - getWorkspaceRoleToDefaultProjectRoleMapping: GetWorkspaceRoleToDefaultProjectRoleMapping + getWorkspaceRolesAndSeats: GetWorkspaceRolesAndSeats + getWorkspaceRolesAllowedProjectRoles: GetWorkspaceRolesAllowedProjectRolesFactory updateWorkspaceRole: UpdateWorkspaceRole }) => async ({ projectId, - workspaceId + workspaceId, + movedByUserId }: MoveProjectToWorkspaceArgs): Promise => { const project = await getProject({ projectId }) @@ -155,37 +170,56 @@ export const moveProjectToWorkspaceFactory = // Update roles for current project members const projectTeam = await getProjectCollaborators({ projectId }) - const workspaceTeam = await getWorkspaceRoles({ workspaceId }) - const defaultProjectRoleMapping = await getWorkspaceRoleToDefaultProjectRoleMapping( - { workspaceId } - ) + const workspaceTeam = await getWorkspaceRolesAndSeats({ workspaceId }) + const { + defaultProjectRole: getDefaultProjectRole, + allowedProjectRoles: getAllowedProjectRoles + } = await getWorkspaceRolesAllowedProjectRoles({ + workspaceId + }) for (const projectMembers of chunk(projectTeam, 5)) { await Promise.all( projectMembers.map( async ({ id: userId, role: serverRole, streamRole: currentProjectRole }) => { // Update workspace role. Prefer existing workspace role if there is one. - const currentWorkspaceRole = workspaceTeam.find( - (role) => role.userId === userId - ) - const nextWorkspaceRole = currentWorkspaceRole ?? { - userId, - workspaceId, - role: - serverRole === Roles.Server.Guest - ? Roles.Workspace.Guest - : Roles.Workspace.Member, - createdAt: new Date() - } - await updateWorkspaceRole(nextWorkspaceRole) + const currentWorkspaceRole = workspaceTeam[userId]?.role + const currentWorkspaceSeat = workspaceTeam[userId]?.seat + + const nextWorkspaceRole = currentWorkspaceRole + ? currentWorkspaceRole + : { + userId, + workspaceId, + role: + serverRole === Roles.Server.Guest + ? Roles.Workspace.Guest + : Roles.Workspace.Member, + createdAt: new Date() + } + + await updateWorkspaceRole({ + ...nextWorkspaceRole, + updatedByUserId: movedByUserId + }) // Update project role. Prefer default workspace project role if more permissive. const defaultProjectRole = - defaultProjectRoleMapping[nextWorkspaceRole.role] ?? Roles.Stream.Reviewer - const nextProjectRole = orderByWeight( + getDefaultProjectRole({ + workspaceRole: nextWorkspaceRole.role, + seatType: currentWorkspaceSeat?.type + }) ?? Roles.Stream.Reviewer + const allowedProjectRoles = getAllowedProjectRoles({ + workspaceRole: nextWorkspaceRole.role, + seatType: currentWorkspaceSeat?.type + }) + const rolePicks = intersection( [currentProjectRole, defaultProjectRole], - coreUserRoles - )[0] + allowedProjectRoles + ) + const nextProjectRole = orderByWeight(rolePicks, coreUserRoles)[0] + + // TODO: Shouldn't this be the service call that also fires events? await upsertProjectRole({ userId, projectId, @@ -200,23 +234,76 @@ export const moveProjectToWorkspaceFactory = return await updateProject({ projectUpdate: { id: projectId, workspaceId } }) } -export const getWorkspaceRoleToDefaultProjectRoleMappingFactory = - ({ - getWorkspace - }: { - getWorkspace: GetWorkspace - }): GetWorkspaceRoleToDefaultProjectRoleMapping => +export const getWorkspaceRolesAllowedProjectRolesFactory = + (deps: { + getWorkspaceWithPlan: GetWorkspaceWithPlan + }): GetWorkspaceRolesAllowedProjectRolesFactory => async ({ workspaceId }) => { - const workspace = await getWorkspace({ workspaceId }) + const workspace = await deps.getWorkspaceWithPlan({ workspaceId }) if (!workspace) { throw new WorkspaceNotFoundError() } + const isNewPlan = workspace.plan && isNewPaidPlanType(workspace.plan.name) + + const allowedProjectRoles = (args: { + workspaceRole: WorkspaceRoles + seatType: MaybeNullOrUndefined + }) => { + const { workspaceRole, seatType = WorkspaceSeatType.Viewer } = args + + switch (workspaceRole) { + case Roles.Workspace.Guest: + if (isNewPlan && seatType === WorkspaceSeatType.Viewer) { + return [Roles.Stream.Reviewer] + } else { + return [Roles.Stream.Reviewer, Roles.Stream.Contributor] + } + case Roles.Workspace.Member: + if (isNewPlan && seatType === WorkspaceSeatType.Viewer) { + return [Roles.Stream.Reviewer] + } else { + return [Roles.Stream.Reviewer, Roles.Stream.Contributor, Roles.Stream.Owner] + } + case Roles.Workspace.Admin: + return [Roles.Stream.Owner] + default: + throwUncoveredError(workspaceRole) + } + } + + const defaultProjectRole = (args: { + workspaceRole: WorkspaceRoles + seatType: MaybeNullOrUndefined + }) => { + const { workspaceRole, seatType = WorkspaceSeatType.Viewer } = args + const allowedRoles = allowedProjectRoles({ workspaceRole, seatType }) + + const role = (() => { + switch (workspaceRole) { + case Roles.Workspace.Guest: + return null // No default role + case Roles.Workspace.Member: + if (isNewPlan && seatType === WorkspaceSeatType.Viewer) + return Roles.Stream.Reviewer + return workspace.defaultProjectRole + case Roles.Workspace.Admin: + return Roles.Stream.Owner + default: + throwUncoveredError(workspaceRole) + } + })() + if (role && !allowedRoles.includes(role)) { + throw new LogicError('Invalid default project role') + } + + return role + } + return { - [Roles.Workspace.Guest]: null, - [Roles.Workspace.Member]: workspace.defaultProjectRole, - [Roles.Workspace.Admin]: Roles.Stream.Owner + defaultProjectRole, + allowedProjectRoles } } diff --git a/packages/server/modules/workspaces/services/workspaceJoinRequests.ts b/packages/server/modules/workspaces/services/workspaceJoinRequests.ts index 7cf9d1281..3ae5aab6f 100644 --- a/packages/server/modules/workspaces/services/workspaceJoinRequests.ts +++ b/packages/server/modules/workspaces/services/workspaceJoinRequests.ts @@ -7,6 +7,7 @@ import { import { GetUser } from '@/modules/core/domain/users/operations' import { NotFoundError } from '@/modules/shared/errors' import { + ApproveWorkspaceJoinRequest, CreateWorkspaceJoinRequest, DenyWorkspaceJoinRequest, EnsureValidWorkspaceRoleSeat, @@ -119,8 +120,8 @@ export const approveWorkspaceJoinRequestFactory = upsertWorkspaceRole: UpsertWorkspaceRole emit: EventBus['emit'] ensureValidWorkspaceRoleSeat: EnsureValidWorkspaceRoleSeat - }) => - async ({ userId, workspaceId }: { userId: string; workspaceId: string }) => { + }): ApproveWorkspaceJoinRequest => + async ({ userId, workspaceId, approvedByUserId }) => { const requester = await getUserById(userId) if (!requester) { throw new NotFoundError('User not found') @@ -153,7 +154,11 @@ export const approveWorkspaceJoinRequestFactory = await emit({ eventName: WorkspaceEvents.Updated, payload: { workspace } }) await emit({ eventName: WorkspaceEvents.RoleUpdated, - payload: { acl: { workspaceId, userId, role }, seatType: type } + payload: { + acl: { workspaceId, userId, role }, + seatType: type, + updatedByUserId: approvedByUserId + } }) await sendWorkspaceJoinRequestApprovedEmail({ diff --git a/packages/server/modules/workspaces/services/workspaceSeat.ts b/packages/server/modules/workspaces/services/workspaceSeat.ts index 1f48b6352..f378083ce 100644 --- a/packages/server/modules/workspaces/services/workspaceSeat.ts +++ b/packages/server/modules/workspaces/services/workspaceSeat.ts @@ -96,7 +96,7 @@ export const assignWorkspaceSeatFactory = getWorkspaceRoleForUser: GetWorkspaceRoleForUser emit: EventBusEmit }): AssignWorkspaceSeat => - async ({ workspaceId, userId, type }) => { + async ({ workspaceId, userId, type, assignedByUserId }) => { const workspaceAcl = await getWorkspaceRoleForUser({ workspaceId, userId }) if (!workspaceAcl) { throw new NotFoundError('User does not have a role in the workspace') @@ -127,7 +127,11 @@ export const assignWorkspaceSeatFactory = await emit({ eventName: WorkspaceEvents.RoleUpdated, - payload: { acl: workspaceAcl, seatType: seat.type } + payload: { + acl: workspaceAcl, + seatType: seat.type, + updatedByUserId: assignedByUserId + } }) return seat diff --git a/packages/server/modules/workspaces/tests/helpers/creation.ts b/packages/server/modules/workspaces/tests/helpers/creation.ts index 0778a558f..c71de513b 100644 --- a/packages/server/modules/workspaces/tests/helpers/creation.ts +++ b/packages/server/modules/workspaces/tests/helpers/creation.ts @@ -308,14 +308,16 @@ export const assignToWorkspace = async ( await updateWorkspaceRole({ userId: user.id, workspaceId: workspace.id, - role + role, + updatedByUserId: workspace.ownerId }) if (seatType) { await assignWorkspaceSeat({ userId: user.id, workspaceId: workspace.id, - type: seatType + type: seatType, + assignedByUserId: workspace.ownerId }) } } diff --git a/packages/server/modules/workspaces/tests/integration/projects.graph.spec.ts b/packages/server/modules/workspaces/tests/integration/projects.graph.spec.ts index 8673ffca6..6e77398e9 100644 --- a/packages/server/modules/workspaces/tests/integration/projects.graph.spec.ts +++ b/packages/server/modules/workspaces/tests/integration/projects.graph.spec.ts @@ -12,6 +12,7 @@ import { createWebhookConfigFactory, createWebhookEventFactory } from '@/modules/webhooks/repositories/webhooks' +import { WorkspaceInvalidRoleError } from '@/modules/workspaces/errors/workspace' import { assignToWorkspace, BasicTestWorkspace, @@ -281,9 +282,7 @@ describe('Workspace project GQL CRUD', () => { }) const newRole = await getUserStreamRole(workspaceGuest.id, roleProject.id) - expect(res).to.haveGraphQLErrors( - 'Workspace guests cannot be project owners' - ) + expect(res).to.haveGraphQLErrors({ code: WorkspaceInvalidRoleError.code }) expect(newRole).to.eq(Roles.Stream.Reviewer) }) @@ -310,12 +309,12 @@ describe('Workspace project GQL CRUD', () => { expect(resB).to.not.haveGraphQLErrors() expect(newRole).to.eq(Roles.Stream.Owner) } else { - expect(resA).to.haveGraphQLErrors( - 'Workspace viewers can only be project reviewers.' - ) - expect(resB).to.haveGraphQLErrors( - 'Workspace viewers can only be project reviewers.' - ) + expect(resA).to.haveGraphQLErrors({ + code: WorkspaceInvalidRoleError.code + }) + expect(resB).to.haveGraphQLErrors({ + code: WorkspaceInvalidRoleError.code + }) expect(newRole).to.eq(Roles.Stream.Reviewer) } }) diff --git a/packages/server/modules/workspaces/tests/integration/workspaceJoinRequests.spec.ts b/packages/server/modules/workspaces/tests/integration/workspaceJoinRequests.spec.ts index 0215fe2dc..034e67301 100644 --- a/packages/server/modules/workspaces/tests/integration/workspaceJoinRequests.spec.ts +++ b/packages/server/modules/workspaces/tests/integration/workspaceJoinRequests.spec.ts @@ -250,7 +250,11 @@ const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags() ensureValidWorkspaceRoleSeat: async () => { throw new Error('Should not happen') } - })({ workspaceId: createRandomString(), userId: createRandomString() }) + })({ + workspaceId: createRandomString(), + userId: createRandomString(), + approvedByUserId: createRandomString() + }) ) expect(err.message).to.equal('User not found') @@ -270,7 +274,11 @@ const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags() ensureValidWorkspaceRoleSeat: async () => { throw new Error('Should not happen') } - })({ workspaceId: createRandomString(), userId: createRandomString() }) + })({ + workspaceId: createRandomString(), + userId: createRandomString(), + approvedByUserId: createRandomString() + }) ) expect(err.message).to.equal(WorkspaceNotFoundError.defaultMessage) @@ -298,7 +306,11 @@ const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags() ensureValidWorkspaceRoleSeat: async () => { throw new Error('Should not happen') } - })({ workspaceId: createRandomString(), userId: createRandomString() }) + })({ + workspaceId: createRandomString(), + userId: createRandomString(), + approvedByUserId: createRandomString() + }) ) expect(err.message).to.equal('Workspace join request not found') @@ -364,7 +376,7 @@ const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags() createdAt: new Date(), updatedAt: new Date() }) - })({ workspaceId: workspace.id, userId: user.id }) + })({ workspaceId: workspace.id, userId: user.id, approvedByUserId: user.id }) ).to.equal(true) expect( diff --git a/packages/server/modules/workspaces/tests/integration/workspaceSeat.graph.spec.ts b/packages/server/modules/workspaces/tests/integration/workspaceSeat.graph.spec.ts index 1432f71cf..c1a1f535e 100644 --- a/packages/server/modules/workspaces/tests/integration/workspaceSeat.graph.spec.ts +++ b/packages/server/modules/workspaces/tests/integration/workspaceSeat.graph.spec.ts @@ -3,6 +3,7 @@ import { createRandomEmail, createRandomString } from '@/modules/core/helpers/testHelpers' +import { deleteProjectRoleFactory } from '@/modules/core/repositories/streams' import { WorkspaceSeatType } from '@/modules/gatekeeper/domain/billing' import { getWorkspaceUserSeatFactory } from '@/modules/gatekeeper/repositories/workspaceSeat' import { @@ -12,12 +13,14 @@ import { } from '@/modules/workspaces/tests/helpers/creation' import { BasicTestUser, createTestUser } from '@/test/authHelper' import { + GetProjectCollaboratorsDocument, UpdateWorkspaceSeatTypeDocument, WorkspaceUpdateSeatTypeInput } from '@/test/graphql/generated/graphql' import { testApolloServer, TestApolloServer } from '@/test/graphqlHelper' import { beforeEachContext } from '@/test/hooks' import { StripeClientMock } from '@/test/mocks/global' +import { BasicTestStream, createTestStream } from '@/test/speckle-helpers/streamHelper' import { Roles } from '@speckle/shared' import { expect } from 'chai' @@ -112,7 +115,7 @@ describe('Workspace Seats @graphql', () => { expect(res.data?.workspaceMutations.updateSeatType).to.not.be.ok }) - it('should assign a workspace seat with the provided type and reconcile subscription', async () => { + it('should upgrade a workspace seat and reconcile subscription', async () => { const user: BasicTestUser = { id: createRandomString(), name: createRandomString(), @@ -151,5 +154,107 @@ describe('Workspace Seats @graphql', () => { expect(reconcileArgs.prorationBehavior).to.eq('always_invoice') // new plan expect(reconcileArgs.subscriptionData.products.length).to.be.ok }) + + it('should downgrade a workspace seat', async () => { + const user: BasicTestUser = { + id: createRandomString(), + name: createRandomString(), + email: createRandomEmail(), + role: Roles.Server.User, + verified: true + } + await createTestUser(user) + await assignToWorkspace( + testWorkspace1, + user, + Roles.Workspace.Member, + WorkspaceSeatType.Editor + ) + + const res = await updateSeatType({ + workspaceId: testWorkspace1.id, + userId: user.id, + seatType: WorkspaceSeatType.Viewer + }) + + expect(res).to.not.haveGraphQLErrors() + expect( + res.data?.workspaceMutations.updateSeatType.team.items.find( + (i) => i.id === user.id + )?.seatType + ).to.eq(WorkspaceSeatType.Viewer) + }) + + it('should assign away project ownership on downgrade to viewer seat', async () => { + const testWorkspace2: BasicTestWorkspace = { + id: '', + slug: '', + ownerId: '', + name: 'Test Workspace 2' + } + await createTestWorkspace(testWorkspace2, workspaceAdmin, { + addPlan: { name: 'pro', status: 'valid' } + }) + + const user: BasicTestUser = { + id: createRandomString(), + name: createRandomString(), + email: createRandomEmail(), + role: Roles.Server.User, + verified: true + } + await createTestUser(user) + await assignToWorkspace( + testWorkspace2, + user, + Roles.Workspace.Member, + WorkspaceSeatType.Editor + ) + + const userOwnedProject: BasicTestStream = { + name: 'User Owned Project', + isPublic: false, + id: '', + ownerId: '', + workspaceId: testWorkspace2.id + } + await createTestStream(userOwnedProject, user) + + // Manually remove admin stream role, to test that it's being added + await deleteProjectRoleFactory({ db })({ + projectId: userOwnedProject.id, + userId: workspaceAdmin.id + }) + + const res1 = await updateSeatType({ + workspaceId: testWorkspace2.id, + userId: user.id, + seatType: WorkspaceSeatType.Viewer + }) + + expect(res1).to.not.haveGraphQLErrors() + expect( + res1.data?.workspaceMutations.updateSeatType.team.items.find( + (i) => i.id === user.id + )?.seatType + ).to.eq(WorkspaceSeatType.Viewer) + + // Check project ownership + const res2 = await apollo.execute( + GetProjectCollaboratorsDocument, + { + projectId: userOwnedProject.id + }, + { assertNoErrors: true } + ) + + expect(res2.data?.project.id).to.eq(userOwnedProject.id) + expect(res2.data?.project.team.length).to.greaterThanOrEqual(2) + + const adminRes = res2.data?.project.team.find((t) => t.id === workspaceAdmin.id) + const userRes = res2.data?.project.team.find((t) => t.id === user.id) + expect(adminRes?.role).to.eq(Roles.Stream.Owner) + expect(userRes?.role).to.eq(Roles.Stream.Reviewer) + }) }) }) diff --git a/packages/server/modules/workspaces/tests/unit/events/eventListener.spec.ts b/packages/server/modules/workspaces/tests/unit/events/eventListener.spec.ts index e703b6d3d..a333b61c0 100644 --- a/packages/server/modules/workspaces/tests/unit/events/eventListener.spec.ts +++ b/packages/server/modules/workspaces/tests/unit/events/eventListener.spec.ts @@ -8,6 +8,10 @@ import { } from '@/modules/workspaces/events/eventListener' import { expect } from 'chai' import { chunk } from 'lodash' +import { + GetWorkspaceRolesAndSeats, + WorkspaceSeatType +} from '@/modules/gatekeeper/domain/billing' describe('Event handlers', () => { describe('onProjectCreatedFactory creates a function, that', () => { @@ -38,13 +42,28 @@ describe('Event handlers', () => { const projectRoles: StreamAclRecord[] = [] + // TODO: New plan support const onProjectCreated = onProjectCreatedFactory({ - getWorkspaceRoles: async () => workspaceRoles, - getWorkspaceRoleToDefaultProjectRoleMapping: async () => ({ - [Roles.Workspace.Admin]: Roles.Stream.Owner, - [Roles.Workspace.Member]: Roles.Stream.Contributor, - [Roles.Workspace.Guest]: null - }), + getWorkspaceRolesAndSeats: async () => + workspaceRoles.reduce((acc, role) => { + acc[role.userId] = { role, seat: null, userId: role.userId } + return acc + }, {} as Awaited>), + getWorkspaceRolesAllowedProjectRoles: async () => { + const mapping = { + [Roles.Workspace.Admin]: Roles.Stream.Owner, + [Roles.Workspace.Member]: Roles.Stream.Contributor, + [Roles.Workspace.Guest]: null + } + return { + defaultProjectRole: ({ workspaceRole }) => { + return mapping[workspaceRole] + }, + allowedProjectRoles: ({ workspaceRole }) => { + return [mapping[workspaceRole] || Roles.Stream.Reviewer] + } + } + }, upsertProjectRole: async ({ projectId, userId, role }) => { projectRoles.push({ resourceId: projectId, @@ -68,29 +87,47 @@ describe('Event handlers', () => { describe('onWorkspaceRoleUpdatedFactory creates a function, that', () => { it('assigns no project roles if the role mapping returns null', async () => { let isDeleteCalled = false + const fakeProject = { id: 'test' } as StreamRecord await onWorkspaceRoleUpdatedFactory({ - getWorkspaceRoleToDefaultProjectRoleMapping: async () => ({ - [Roles.Workspace.Admin]: Roles.Stream.Owner, - [Roles.Workspace.Member]: Roles.Stream.Contributor, - [Roles.Workspace.Guest]: null - }), + getWorkspaceRolesAllowedProjectRoles: async () => { + const mapping = { + [Roles.Workspace.Admin]: Roles.Stream.Owner, + [Roles.Workspace.Member]: Roles.Stream.Contributor, + [Roles.Workspace.Guest]: null + } + return { + defaultProjectRole: ({ workspaceRole }) => { + return mapping[workspaceRole] + }, + allowedProjectRoles: ({ workspaceRole }) => { + return [mapping[workspaceRole] || Roles.Stream.Reviewer] + } + } + }, async *queryAllWorkspaceProjects() { - yield [{ id: 'test' } as StreamRecord] + yield [fakeProject as StreamRecord] }, - deleteProjectRole: async () => { - isDeleteCalled = true - return undefined + getStreamsCollaboratorCounts: async () => { + return {} }, - upsertProjectRole: async () => { - expect.fail() + setStreamCollaborator: async ({ role }) => { + if (!role) { + isDeleteCalled = true + } else { + expect.fail() + } + + return fakeProject } })({ acl: { role: Roles.Workspace.Guest, userId: cryptoRandomString({ length: 10 }), workspaceId: cryptoRandomString({ length: 10 }) - } + }, + seatType: WorkspaceSeatType.Editor, + updatedByUserId: cryptoRandomString({ length: 10 }) }) expect(isDeleteCalled).to.be.true @@ -108,30 +145,50 @@ describe('Event handlers', () => { const storedRoles: { userId: string; role: StreamRoles; projectId: string }[] = [] let trackProjectUpdate: boolean | undefined = false await onWorkspaceRoleUpdatedFactory({ - getWorkspaceRoleToDefaultProjectRoleMapping: async () => ({ - [Roles.Workspace.Admin]: Roles.Stream.Owner, - [Roles.Workspace.Member]: projectRole, - [Roles.Workspace.Guest]: null - }), + getWorkspaceRolesAllowedProjectRoles: async () => { + const mapping = { + [Roles.Workspace.Admin]: Roles.Stream.Owner, + [Roles.Workspace.Member]: projectRole, + [Roles.Workspace.Guest]: null + } + return { + defaultProjectRole: ({ workspaceRole }) => { + return mapping[workspaceRole] + }, + allowedProjectRoles: ({ workspaceRole }) => { + return [mapping[workspaceRole] || Roles.Stream.Reviewer] + } + } + }, async *queryAllWorkspaceProjects() { for (const projIds of chunk(projectIds, 3)) { yield projIds.map((projId) => ({ id: projId } as unknown as StreamRecord)) } }, - deleteProjectRole: async () => { - expect.fail() + getStreamsCollaboratorCounts: async () => { + return {} }, - upsertProjectRole: async (args, options) => { - storedRoles.push(args) - trackProjectUpdate = trackProjectUpdate || options?.trackProjectUpdate - return {} as StreamRecord + setStreamCollaborator: async (params, options) => { + if (!params.role) { + return expect.fail() + } else { + storedRoles.push({ + userId: params.userId, + role: params.role, + projectId: params.streamId + }) + trackProjectUpdate = trackProjectUpdate || options?.trackProjectUpdate + return {} as StreamRecord + } } })({ acl: { role: Roles.Workspace.Member, userId, workspaceId: cryptoRandomString({ length: 10 }) - } + }, + seatType: WorkspaceSeatType.Editor, + updatedByUserId: cryptoRandomString({ length: 10 }) }) expect(storedRoles).deep.equals( projectIds.map((projectId) => ({ projectId, role: projectRole, userId })) diff --git a/packages/server/modules/workspaces/tests/unit/services/management.spec.ts b/packages/server/modules/workspaces/tests/unit/services/management.spec.ts index 2a32414c9..82d6f797c 100644 --- a/packages/server/modules/workspaces/tests/unit/services/management.spec.ts +++ b/packages/server/modules/workspaces/tests/unit/services/management.spec.ts @@ -770,10 +770,12 @@ describe('Workspace role services', () => { it('sets the workspace role', async () => { const userId = cryptoRandomString({ length: 10 }) const workspaceId = cryptoRandomString({ length: 10 }) + const workspaceOwnerId = cryptoRandomString({ length: 10 }) const role = { userId, workspaceId, - role: Roles.Workspace.Member + role: Roles.Workspace.Member, + updatedByUserId: workspaceOwnerId } const { updateWorkspaceRole, context } = buildUpdateWorkspaceRoleAndTestContext({ @@ -791,6 +793,7 @@ describe('Workspace role services', () => { it('emits a role-updated event', async () => { const userId = cryptoRandomString({ length: 10 }) const workspaceId = cryptoRandomString({ length: 10 }) + const workspaceOwnerId = cryptoRandomString({ length: 10 }) const role: Pick = { userId, workspaceId, @@ -801,7 +804,7 @@ describe('Workspace role services', () => { workspaceId }) - await updateWorkspaceRole(role) + await updateWorkspaceRole({ ...role, updatedByUserId: workspaceOwnerId }) const payload = { ...(context.eventData @@ -811,11 +814,16 @@ describe('Workspace role services', () => { expect(context.eventData.isCalled).to.be.true expect(context.eventData.eventName).to.equal(WorkspaceEvents.RoleUpdated) - expect(payload).to.deep.equal({ acl: role, seatType: WorkspaceSeatType.Editor }) + expect(payload).to.deep.equal({ + acl: role, + seatType: WorkspaceSeatType.Editor, + updatedByUserId: workspaceOwnerId + }) }) it('throws if attempting to remove the last admin in a workspace', async () => { const userId = cryptoRandomString({ length: 10 }) const workspaceId = cryptoRandomString({ length: 10 }) + const workspaceOwnerId = cryptoRandomString({ length: 10 }) const role: WorkspaceAcl = { userId, workspaceId, @@ -829,7 +837,11 @@ describe('Workspace role services', () => { }) await expectToThrow(() => - updateWorkspaceRole({ ...role, role: Roles.Workspace.Member }) + updateWorkspaceRole({ + ...role, + role: Roles.Workspace.Member, + updatedByUserId: workspaceOwnerId + }) ) }) it('throws if attempting to set user role to more than GUEST and workspace domain protection is enabled and user has not an email matching a workspace domain', async () => { @@ -880,7 +892,8 @@ describe('Workspace role services', () => { updateWorkspaceRole({ workspaceId, userId: guestId, - role: Roles.Workspace.Member + role: Roles.Workspace.Member, + updatedByUserId: adminId }) ) expect(err.message).to.eq(new WorkspaceProtectedError().message) @@ -888,6 +901,7 @@ describe('Workspace role services', () => { it('sets roles on workspace projects when user added to workspace as admin', async () => { const userId = cryptoRandomString({ length: 10 }) const workspaceId = cryptoRandomString({ length: 10 }) + const workspaceOwnerId = cryptoRandomString({ length: 10 }) const projectId = cryptoRandomString({ length: 10 }) const workspaceRole: WorkspaceAcl = { @@ -902,7 +916,7 @@ describe('Workspace role services', () => { workspaceProjects: [{ id: projectId } as StreamRecord] }) - await updateWorkspaceRole(workspaceRole) + await updateWorkspaceRole({ ...workspaceRole, updatedByUserId: workspaceOwnerId }) expect(context.workspaceProjectRoles.length).to.equal(1) expect(context.workspaceProjectRoles[0].userId).to.equal(userId) @@ -912,6 +926,7 @@ describe('Workspace role services', () => { const userId = cryptoRandomString({ length: 10 }) const workspaceId = cryptoRandomString({ length: 10 }) const projectId = cryptoRandomString({ length: 10 }) + const workspaceOwnerId = cryptoRandomString({ length: 10 }) const workspaceRole: WorkspaceAcl = { userId, @@ -925,7 +940,7 @@ describe('Workspace role services', () => { workspaceProjects: [{ id: projectId } as StreamRecord] }) - await updateWorkspaceRole(workspaceRole) + await updateWorkspaceRole({ ...workspaceRole, updatedByUserId: workspaceOwnerId }) expect(context.workspaceProjectRoles.length).to.equal(1) expect(context.workspaceProjectRoles[0].userId).to.equal(userId) @@ -935,6 +950,7 @@ describe('Workspace role services', () => { const userId = cryptoRandomString({ length: 10 }) const workspaceId = cryptoRandomString({ length: 10 }) const projectId = cryptoRandomString({ length: 10 }) + const workspaceOwnerId = cryptoRandomString({ length: 10 }) const workspaceRole: WorkspaceAcl = { userId, @@ -948,7 +964,7 @@ describe('Workspace role services', () => { workspaceProjects: [{ id: projectId } as StreamRecord] }) - await updateWorkspaceRole(workspaceRole) + await updateWorkspaceRole({ ...workspaceRole, updatedByUserId: workspaceOwnerId }) expect(context.workspaceProjectRoles.find((role) => role.userId === userId)).to .not.exist diff --git a/packages/server/modules/workspaces/tests/unit/services/projects.spec.ts b/packages/server/modules/workspaces/tests/unit/services/projects.spec.ts index 48fbc942b..48f93df44 100644 --- a/packages/server/modules/workspaces/tests/unit/services/projects.spec.ts +++ b/packages/server/modules/workspaces/tests/unit/services/projects.spec.ts @@ -1,6 +1,7 @@ import { ProjectTeamMember } from '@/modules/core/domain/projects/types' import { ProjectNotFoundError } from '@/modules/core/errors/projects' import { StreamAclRecord, StreamRecord } from '@/modules/core/helpers/types' +import { GetWorkspaceRolesAllowedProjectRolesFactory } from '@/modules/workspaces/domain/operations' import { WorkspaceInvalidProjectError } from '@/modules/workspaces/errors/workspace' import { moveProjectToWorkspaceFactory, @@ -12,11 +13,22 @@ import { Roles } from '@speckle/shared' import { expect } from 'chai' import cryptoRandomString from 'crypto-random-string' -const getWorkspaceRoleToDefaultProjectRoleMapping = async () => ({ - 'workspace:admin': Roles.Stream.Owner, - 'workspace:guest': null, - 'workspace:member': Roles.Stream.Contributor -}) +const getWorkspaceRolesAllowedProjectRoles: GetWorkspaceRolesAllowedProjectRolesFactory = + async () => { + const mapping = { + [Roles.Workspace.Admin]: Roles.Stream.Owner, + [Roles.Workspace.Member]: Roles.Stream.Contributor, + [Roles.Workspace.Guest]: null + } + return { + defaultProjectRole: ({ workspaceRole }) => { + return mapping[workspaceRole] + }, + allowedProjectRoles: () => { + return Object.values(Roles.Stream) + } + } + } describe('Project retrieval services', () => { describe('queryAllWorkspaceProjectFactory returns a generator, that', () => { @@ -105,10 +117,10 @@ describe('Project management services', () => { getProjectCollaborators: async () => { expect.fail() }, - getWorkspaceRoles: async () => { + getWorkspaceRolesAndSeats: async () => { expect.fail() }, - getWorkspaceRoleToDefaultProjectRoleMapping: async () => { + getWorkspaceRolesAllowedProjectRoles: async () => { expect.fail() }, updateWorkspaceRole: async () => { @@ -119,7 +131,8 @@ describe('Project management services', () => { const err = await expectToThrow(() => moveProjectToWorkspace({ projectId: cryptoRandomString({ length: 6 }), - workspaceId: cryptoRandomString({ length: 6 }) + workspaceId: cryptoRandomString({ length: 6 }), + movedByUserId: cryptoRandomString({ length: 10 }) }) ) expect(err.message).to.equal(new ProjectNotFoundError().message) @@ -140,10 +153,10 @@ describe('Project management services', () => { getProjectCollaborators: async () => { expect.fail() }, - getWorkspaceRoles: async () => { + getWorkspaceRolesAndSeats: async () => { expect.fail() }, - getWorkspaceRoleToDefaultProjectRoleMapping: async () => { + getWorkspaceRolesAllowedProjectRoles: async () => { expect.fail() }, updateWorkspaceRole: async () => { @@ -154,7 +167,8 @@ describe('Project management services', () => { const err = await expectToThrow(() => moveProjectToWorkspace({ projectId: cryptoRandomString({ length: 6 }), - workspaceId: cryptoRandomString({ length: 6 }) + workspaceId: cryptoRandomString({ length: 6 }), + movedByUserId: cryptoRandomString({ length: 10 }) }) ) expect(err instanceof WorkspaceInvalidProjectError).to.be.true @@ -185,27 +199,27 @@ describe('Project management services', () => { } as unknown as ProjectTeamMember ] }, - getWorkspaceRoles: async () => { - return [ - { - userId, - role: Roles.Workspace.Admin, - workspaceId, - createdAt: new Date() + getWorkspaceRolesAndSeats: async () => { + return { + [userId]: { + role: { + userId, + role: Roles.Workspace.Admin, + workspaceId, + createdAt: new Date() + }, + seat: null, + userId } - ] + } }, - getWorkspaceRoleToDefaultProjectRoleMapping: async () => ({ - 'workspace:admin': Roles.Stream.Owner, - 'workspace:guest': null, - 'workspace:member': Roles.Stream.Contributor - }), + getWorkspaceRolesAllowedProjectRoles, updateWorkspaceRole: async (role) => { updatedRoles.push(role) } }) - await moveProjectToWorkspace({ projectId, workspaceId }) + await moveProjectToWorkspace({ projectId, workspaceId, movedByUserId: userId }) expect(updatedRoles.length).to.equal(1) expect(updatedRoles[0].role).to.equal(Roles.Workspace.Admin) @@ -236,16 +250,16 @@ describe('Project management services', () => { } as unknown as ProjectTeamMember ] }, - getWorkspaceRoles: async () => { - return [] + getWorkspaceRolesAndSeats: async () => { + return {} }, - getWorkspaceRoleToDefaultProjectRoleMapping, + getWorkspaceRolesAllowedProjectRoles, updateWorkspaceRole: async (role) => { updatedRoles.push(role) } }) - await moveProjectToWorkspace({ projectId, workspaceId }) + await moveProjectToWorkspace({ projectId, workspaceId, movedByUserId: userId }) expect(updatedRoles.length).to.equal(1) expect(updatedRoles[0].role).to.equal(Roles.Workspace.Member) @@ -277,16 +291,16 @@ describe('Project management services', () => { } as unknown as ProjectTeamMember ] }, - getWorkspaceRoles: async () => { - return [] + getWorkspaceRolesAndSeats: async () => { + return {} }, - getWorkspaceRoleToDefaultProjectRoleMapping, + getWorkspaceRolesAllowedProjectRoles, updateWorkspaceRole: async (role) => { updatedRoles.push(role) } }) - await moveProjectToWorkspace({ projectId, workspaceId }) + await moveProjectToWorkspace({ projectId, workspaceId, movedByUserId: userId }) expect(updatedRoles.length).to.equal(1) expect(updatedRoles[0].role).to.equal(Roles.Workspace.Guest) @@ -319,14 +333,14 @@ describe('Project management services', () => { } as unknown as ProjectTeamMember ] }, - getWorkspaceRoles: async () => { - return [] + getWorkspaceRolesAndSeats: async () => { + return {} }, - getWorkspaceRoleToDefaultProjectRoleMapping, + getWorkspaceRolesAllowedProjectRoles, updateWorkspaceRole: async () => {} }) - await moveProjectToWorkspace({ projectId, workspaceId }) + await moveProjectToWorkspace({ projectId, workspaceId, movedByUserId: userId }) expect(updatedRoles.length).to.equal(1) expect(updatedRoles[0].role).to.equal(Roles.Stream.Owner) @@ -359,18 +373,14 @@ describe('Project management services', () => { } as unknown as ProjectTeamMember ] }, - getWorkspaceRoles: async () => { - return [] + getWorkspaceRolesAndSeats: async () => { + return {} }, - getWorkspaceRoleToDefaultProjectRoleMapping: async () => ({ - [Roles.Workspace.Guest]: null, - [Roles.Workspace.Member]: Roles.Stream.Contributor, - [Roles.Workspace.Admin]: Roles.Stream.Owner - }), + getWorkspaceRolesAllowedProjectRoles, updateWorkspaceRole: async () => {} }) - await moveProjectToWorkspace({ projectId, workspaceId }) + await moveProjectToWorkspace({ projectId, workspaceId, movedByUserId: userId }) expect(updatedRoles.length).to.equal(1) expect(updatedRoles[0].role).to.equal(Roles.Stream.Contributor) @@ -403,25 +413,25 @@ describe('Project management services', () => { } as unknown as ProjectTeamMember ] }, - getWorkspaceRoles: async () => { - return [ - { - userId, - workspaceId, - role: Roles.Workspace.Admin, - createdAt: new Date() + getWorkspaceRolesAndSeats: async () => { + return { + [userId]: { + role: { + userId, + workspaceId, + role: Roles.Workspace.Admin, + createdAt: new Date() + }, + seat: null, + userId } - ] + } }, - getWorkspaceRoleToDefaultProjectRoleMapping: async () => ({ - [Roles.Workspace.Guest]: null, - [Roles.Workspace.Member]: Roles.Stream.Contributor, - [Roles.Workspace.Admin]: Roles.Stream.Owner - }), + getWorkspaceRolesAllowedProjectRoles, updateWorkspaceRole: async () => {} }) - await moveProjectToWorkspace({ projectId, workspaceId }) + await moveProjectToWorkspace({ projectId, workspaceId, movedByUserId: userId }) expect(updatedRoles.length).to.equal(1) expect(updatedRoles[0].role).to.equal(Roles.Stream.Owner) diff --git a/packages/server/modules/workspacesCore/domain/events.ts b/packages/server/modules/workspacesCore/domain/events.ts index 1630705f4..d9aa5ce41 100644 --- a/packages/server/modules/workspacesCore/domain/events.ts +++ b/packages/server/modules/workspacesCore/domain/events.ts @@ -34,6 +34,7 @@ type WorkspaceRoleUpdatedPayload = { acl: Pick seatType: WorkspaceSeatType flags?: { skipProjectRoleUpdatesFor: string[] } + updatedByUserId: string } type WorkspaceJoinedFromDiscoveryPayload = { userId: string diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts index 3902ec2af..4615a5d0d 100644 --- a/packages/server/test/graphql/generated/graphql.ts +++ b/packages/server/test/graphql/generated/graphql.ts @@ -5577,6 +5577,13 @@ export type UpdateProjectRoleMutationVariables = Exact<{ export type UpdateProjectRoleMutation = { __typename?: 'Mutation', projectMutations: { __typename?: 'ProjectMutations', updateRole: { __typename?: 'Project', id: string, name: string, description?: string | null, visibility: SimpleProjectVisibility, allowPublicComments: boolean, role?: string | null, createdAt: string, updatedAt: string } } }; +export type GetProjectCollaboratorsQueryVariables = Exact<{ + projectId: Scalars['String']['input']; +}>; + + +export type GetProjectCollaboratorsQuery = { __typename?: 'Query', project: { __typename?: 'Project', id: string, team: Array<{ __typename?: 'ProjectCollaborator', id: string, role: string }> } }; + export type CreateServerInviteMutationVariables = Exact<{ input: ServerInviteCreateInput; }>; @@ -6058,6 +6065,7 @@ export const GetProjectDocument = {"kind":"Document","definitions":[{"kind":"Ope export const CreateProjectDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateProject"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ProjectCreateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projectMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"create"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicProjectFields"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicProjectFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Project"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"allowPublicComments"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; export const BatchDeleteProjectsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"BatchDeleteProjects"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projectMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"batchDelete"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}}]}]}}]}}]} as unknown as DocumentNode; export const UpdateProjectRoleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateProjectRole"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ProjectUpdateRoleInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projectMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateRole"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicProjectFields"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicProjectFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Project"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"allowPublicComments"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; +export const GetProjectCollaboratorsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetProjectCollaborators"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]}}]}}]} as unknown as DocumentNode; export const CreateServerInviteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateServerInvite"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ServerInviteCreateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serverInviteCreate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode; export const CreateStreamInviteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateStreamInvite"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"StreamInviteCreateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"streamInviteCreate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode; export const ResendInviteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ResendInvite"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"inviteId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"inviteResend"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"inviteId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"inviteId"}}}]}]}}]} as unknown as DocumentNode; diff --git a/packages/server/test/graphql/projects.ts b/packages/server/test/graphql/projects.ts index 5ddd0321e..8b534f66c 100644 --- a/packages/server/test/graphql/projects.ts +++ b/packages/server/test/graphql/projects.ts @@ -93,3 +93,15 @@ export const updateProjectRoleMutation = gql` ${basicProjectFieldsFragment} ` + +export const getProjectCollaboratorsQuery = gql` + query GetProjectCollaborators($projectId: String!) { + project(id: $projectId) { + id + team { + id + role + } + } + } +` diff --git a/packages/server/test/hooks.ts b/packages/server/test/hooks.ts index bfa1324c4..d5d494063 100644 --- a/packages/server/test/hooks.ts +++ b/packages/server/test/hooks.ts @@ -23,6 +23,7 @@ import { MaybeNullOrUndefined, Nullable, Optional, + retry, wait } from '@speckle/shared' import * as mocha from 'mocha' @@ -233,8 +234,11 @@ const truncateTablesFactory = (deps: { db: Knex }) => async (tableNames?: string if (!tableNames.length) return // Nothing to truncate // We're deleting everything, so lets turn off triggers to avoid deadlocks/slowdowns - await deps.db.transaction(async (trx) => { - await trx.raw(` + // This still seems to randomly cause deadlocks, so adding a retry + await retry( + async () => + await deps.db.transaction(async (trx) => { + await trx.raw(` -- Disable triggers and foreign key constraints for this session SET session_replication_role = replica; @@ -243,7 +247,10 @@ const truncateTablesFactory = (deps: { db: Knex }) => async (tableNames?: string -- Re-enable triggers and foreign key constraints SET session_replication_role = DEFAULT; `) - }) + }), + 3, + 200 + ) } else { await deps.db.raw(`truncate table ${tableNames.join(',')} cascade`) }