From 28b49107b7fff4253822202ada2a2bfc4f10a567 Mon Sep 17 00:00:00 2001 From: Kristaps Fabians Geikins Date: Tue, 11 Mar 2025 12:41:18 +0200 Subject: [PATCH] feat(server): upgrade workspace seat type (#4158) * feat(server): upgrade workspace seat type * test/lint fixes --- .../lib/billing/composables/actions.ts | 2 +- .../lib/common/generated/gql/graphql.ts | 21 ++ .../typedefs/workspaceSeats.graphql | 20 ++ packages/server/bootstrap.js | 2 +- .../modules/core/graph/generated/graphql.ts | 24 ++ .../graph/generated/graphql.ts | 19 ++ .../clients/checkout/createCheckoutSession.ts | 2 +- .../modules/gatekeeper/clients/stripe.ts | 4 +- .../modules/gatekeeper/domain/billing.ts | 2 +- .../modules/gatekeeper/domain/operations.ts | 9 +- .../gatekeeper/events/eventListener.ts | 11 +- .../gatekeeper/graph/resolvers/index.ts | 44 ++- packages/server/modules/gatekeeper/index.ts | 2 +- .../gatekeeper/repositories/workspaceSeat.ts | 29 +- .../services/checkout/startCheckoutSession.ts | 3 + .../gatekeeper/services/subscriptions.ts | 111 ++++--- .../tests/unit/subscriptions.spec.ts | 67 +++-- .../gatekeeperCore/graph/dataloaders/index.ts | 47 +++ .../gatekeeperCore/graph/resolvers/index.ts | 8 + .../server/modules/shared/helpers/factory.ts | 8 + .../modules/workspaces/domain/operations.ts | 11 +- .../workspaces/events/eventListener.ts | 92 ++---- .../graph/resolvers/workspaceJoinRequests.ts | 11 +- .../workspaces/graph/resolvers/workspaces.ts | 35 ++- .../modules/workspaces/services/join.ts | 10 +- .../modules/workspaces/services/management.ts | 36 ++- .../services/workspaceJoinRequests.ts | 8 +- .../workspaces/services/workspaceSeat.ts | 61 +++- .../workspaces/tests/helpers/creation.ts | 54 +++- .../workspaces/tests/helpers/graphql.ts | 17 ++ .../integration/workspaceJoinRequests.spec.ts | 24 +- .../integration/workspaceSeat.graph.spec.ts | 155 ++++++++++ .../tests/integration/workspaceSeat.spec.ts | 283 +++++++----------- .../tests/unit/events/eventListener.spec.ts | 22 +- .../tests/unit/services/join.spec.ts | 18 +- .../tests/unit/services/management.spec.ts | 39 ++- .../modules/workspacesCore/domain/events.ts | 14 +- packages/server/package.json | 2 +- .../server/test/graphql/generated/graphql.ts | 27 ++ packages/server/test/mockHelper.ts | 59 ++++ packages/server/test/mocks/global.ts | 4 + 41 files changed, 1034 insertions(+), 383 deletions(-) create mode 100644 packages/server/assets/gatekeeperCore/typedefs/workspaceSeats.graphql create mode 100644 packages/server/modules/gatekeeperCore/graph/dataloaders/index.ts create mode 100644 packages/server/modules/workspaces/tests/integration/workspaceSeat.graph.spec.ts diff --git a/packages/frontend-2/lib/billing/composables/actions.ts b/packages/frontend-2/lib/billing/composables/actions.ts index 966bab3ce..1f63d02bf 100644 --- a/packages/frontend-2/lib/billing/composables/actions.ts +++ b/packages/frontend-2/lib/billing/composables/actions.ts @@ -65,7 +65,7 @@ export const useBillingActions = () => { }) if (result.data?.workspace.customerPortalUrl) { - window.location.href = result.data.workspace.customerPortalUrl + window.open(result.data.workspace.customerPortalUrl, '_blank') } } diff --git a/packages/frontend-2/lib/common/generated/gql/graphql.ts b/packages/frontend-2/lib/common/generated/gql/graphql.ts index f0a088920..254e32e06 100644 --- a/packages/frontend-2/lib/common/generated/gql/graphql.ts +++ b/packages/frontend-2/lib/common/generated/gql/graphql.ts @@ -4398,6 +4398,7 @@ export type WorkspaceCollaborator = { id: Scalars['ID']['output']; projectRoles: Array; role: Scalars['String']['output']; + seatType: WorkspaceSeatType; user: LimitedUser; }; @@ -4588,6 +4589,7 @@ export type WorkspaceMutations = { update: Workspace; updateCreationState: Scalars['Boolean']['output']; updateRole: Workspace; + updateSeatType: Workspace; }; @@ -4656,6 +4658,11 @@ export type WorkspaceMutationsUpdateRoleArgs = { input: WorkspaceRoleUpdateInput; }; + +export type WorkspaceMutationsUpdateSeatTypeArgs = { + input: WorkspaceUpdateSeatTypeInput; +}; + export const WorkspacePaymentMethod = { Billing: 'billing', Invoice: 'invoice', @@ -4805,6 +4812,12 @@ export type WorkspaceRoleUpdateInput = { workspaceId: Scalars['String']['input']; }; +export const WorkspaceSeatType = { + Editor: 'editor', + Viewer: 'viewer' +} as const; + +export type WorkspaceSeatType = typeof WorkspaceSeatType[keyof typeof WorkspaceSeatType]; export type WorkspaceSso = { __typename?: 'WorkspaceSso'; /** If null, the workspace does not have SSO configured */ @@ -4860,6 +4873,12 @@ export type WorkspaceUpdateInput = { slug?: InputMaybe; }; +export type WorkspaceUpdateSeatTypeInput = { + seatType: WorkspaceSeatType; + userId: Scalars['String']['input']; + workspaceId: Scalars['String']['input']; +}; + export type WorkspaceUpdatedMessage = { __typename?: 'WorkspaceUpdatedMessage'; /** Workspace ID */ @@ -8479,6 +8498,7 @@ export type WorkspaceCollaboratorFieldArgs = { id: {}, projectRoles: {}, role: {}, + seatType: {}, user: {}, } export type WorkspaceCollaboratorCollectionFieldArgs = { @@ -8539,6 +8559,7 @@ export type WorkspaceMutationsFieldArgs = { update: WorkspaceMutationsUpdateArgs, updateCreationState: WorkspaceMutationsUpdateCreationStateArgs, updateRole: WorkspaceMutationsUpdateRoleArgs, + updateSeatType: WorkspaceMutationsUpdateSeatTypeArgs, } export type WorkspacePlanFieldArgs = { createdAt: {}, diff --git a/packages/server/assets/gatekeeperCore/typedefs/workspaceSeats.graphql b/packages/server/assets/gatekeeperCore/typedefs/workspaceSeats.graphql new file mode 100644 index 000000000..a4e0931a9 --- /dev/null +++ b/packages/server/assets/gatekeeperCore/typedefs/workspaceSeats.graphql @@ -0,0 +1,20 @@ +enum WorkspaceSeatType { + editor + viewer +} + +extend type WorkspaceCollaborator { + seatType: WorkspaceSeatType! +} + +input WorkspaceUpdateSeatTypeInput { + userId: String! + workspaceId: String! + seatType: WorkspaceSeatType! +} + +extend type WorkspaceMutations { + updateSeatType(input: WorkspaceUpdateSeatTypeInput!): Workspace! + @hasScope(scope: "workspace:update") + @hasServerRole(role: SERVER_USER) +} diff --git a/packages/server/bootstrap.js b/packages/server/bootstrap.js index 4d83ef50f..75f9e020e 100644 --- a/packages/server/bootstrap.js +++ b/packages/server/bootstrap.js @@ -50,7 +50,7 @@ const startDebugger = process.env.START_DEBUGGER if ((isTestEnv() || isDevEnv()) && startDebugger) { const inspector = require('node:inspector') if (!inspector.url()) { - inspector.open(undefined, undefined, true) + inspector.open(0, undefined, true) } } diff --git a/packages/server/modules/core/graph/generated/graphql.ts b/packages/server/modules/core/graph/generated/graphql.ts index 2bdabacb8..ea6c23023 100644 --- a/packages/server/modules/core/graph/generated/graphql.ts +++ b/packages/server/modules/core/graph/generated/graphql.ts @@ -4421,6 +4421,7 @@ export type WorkspaceCollaborator = { id: Scalars['ID']['output']; projectRoles: Array; role: Scalars['String']['output']; + seatType: WorkspaceSeatType; user: LimitedUser; }; @@ -4611,6 +4612,7 @@ export type WorkspaceMutations = { update: Workspace; updateCreationState: Scalars['Boolean']['output']; updateRole: Workspace; + updateSeatType: Workspace; }; @@ -4679,6 +4681,11 @@ export type WorkspaceMutationsUpdateRoleArgs = { input: WorkspaceRoleUpdateInput; }; + +export type WorkspaceMutationsUpdateSeatTypeArgs = { + input: WorkspaceUpdateSeatTypeInput; +}; + export const WorkspacePaymentMethod = { Billing: 'billing', Invoice: 'invoice', @@ -4828,6 +4835,12 @@ export type WorkspaceRoleUpdateInput = { workspaceId: Scalars['String']['input']; }; +export const WorkspaceSeatType = { + Editor: 'editor', + Viewer: 'viewer' +} as const; + +export type WorkspaceSeatType = typeof WorkspaceSeatType[keyof typeof WorkspaceSeatType]; export type WorkspaceSso = { __typename?: 'WorkspaceSso'; /** If null, the workspace does not have SSO configured */ @@ -4883,6 +4896,12 @@ export type WorkspaceUpdateInput = { slug?: InputMaybe; }; +export type WorkspaceUpdateSeatTypeInput = { + seatType: WorkspaceSeatType; + userId: Scalars['String']['input']; + workspaceId: Scalars['String']['input']; +}; + export type WorkspaceUpdatedMessage = { __typename?: 'WorkspaceUpdatedMessage'; /** Workspace ID */ @@ -5267,6 +5286,7 @@ export type ResolversTypes = { WorkspaceRole: WorkspaceRole; WorkspaceRoleDeleteInput: WorkspaceRoleDeleteInput; WorkspaceRoleUpdateInput: WorkspaceRoleUpdateInput; + WorkspaceSeatType: WorkspaceSeatType; WorkspaceSso: ResolverTypeWrapper; WorkspaceSsoProvider: ResolverTypeWrapper; WorkspaceSsoSession: ResolverTypeWrapper; @@ -5274,6 +5294,7 @@ export type ResolversTypes = { WorkspaceSubscriptionSeats: ResolverTypeWrapper; WorkspaceTeamFilter: WorkspaceTeamFilter; WorkspaceUpdateInput: WorkspaceUpdateInput; + WorkspaceUpdateSeatTypeInput: WorkspaceUpdateSeatTypeInput; WorkspaceUpdatedMessage: ResolverTypeWrapper & { workspace: ResolversTypes['Workspace'] }>; }; @@ -5559,6 +5580,7 @@ export type ResolversParentTypes = { WorkspaceSubscriptionSeats: WorkspaceSubscriptionSeats; WorkspaceTeamFilter: WorkspaceTeamFilter; WorkspaceUpdateInput: WorkspaceUpdateInput; + WorkspaceUpdateSeatTypeInput: WorkspaceUpdateSeatTypeInput; WorkspaceUpdatedMessage: Omit & { workspace: ResolversParentTypes['Workspace'] }; }; @@ -7067,6 +7089,7 @@ export type WorkspaceCollaboratorResolvers; projectRoles?: Resolver, ParentType, ContextType>; role?: Resolver; + seatType?: Resolver; user?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -7145,6 +7168,7 @@ export type WorkspaceMutationsResolvers>; updateCreationState?: Resolver>; updateRole?: Resolver>; + updateSeatType?: Resolver>; __isTypeOf?: IsTypeOfResolverFn; }; diff --git a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts index bbee869e8..fc1ca0643 100644 --- a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts +++ b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts @@ -4401,6 +4401,7 @@ export type WorkspaceCollaborator = { id: Scalars['ID']['output']; projectRoles: Array; role: Scalars['String']['output']; + seatType: WorkspaceSeatType; user: LimitedUser; }; @@ -4591,6 +4592,7 @@ export type WorkspaceMutations = { update: Workspace; updateCreationState: Scalars['Boolean']['output']; updateRole: Workspace; + updateSeatType: Workspace; }; @@ -4659,6 +4661,11 @@ export type WorkspaceMutationsUpdateRoleArgs = { input: WorkspaceRoleUpdateInput; }; + +export type WorkspaceMutationsUpdateSeatTypeArgs = { + input: WorkspaceUpdateSeatTypeInput; +}; + export const WorkspacePaymentMethod = { Billing: 'billing', Invoice: 'invoice', @@ -4808,6 +4815,12 @@ export type WorkspaceRoleUpdateInput = { workspaceId: Scalars['String']['input']; }; +export const WorkspaceSeatType = { + Editor: 'editor', + Viewer: 'viewer' +} as const; + +export type WorkspaceSeatType = typeof WorkspaceSeatType[keyof typeof WorkspaceSeatType]; export type WorkspaceSso = { __typename?: 'WorkspaceSso'; /** If null, the workspace does not have SSO configured */ @@ -4863,6 +4876,12 @@ export type WorkspaceUpdateInput = { slug?: InputMaybe; }; +export type WorkspaceUpdateSeatTypeInput = { + seatType: WorkspaceSeatType; + userId: Scalars['String']['input']; + workspaceId: Scalars['String']['input']; +}; + export type WorkspaceUpdatedMessage = { __typename?: 'WorkspaceUpdatedMessage'; /** Workspace ID */ diff --git a/packages/server/modules/gatekeeper/clients/checkout/createCheckoutSession.ts b/packages/server/modules/gatekeeper/clients/checkout/createCheckoutSession.ts index cd32e8f2a..1cb780b4d 100644 --- a/packages/server/modules/gatekeeper/clients/checkout/createCheckoutSession.ts +++ b/packages/server/modules/gatekeeper/clients/checkout/createCheckoutSession.ts @@ -29,7 +29,7 @@ export const createCheckoutSessionFactoryOld = isCreateFlow }) => { if (isNewPlanType(workspacePlan)) { - // TODO: Supported in follow up task + // Use createCheckoutSessionFactoryNew instead throw new NotImplementedError() } diff --git a/packages/server/modules/gatekeeper/clients/stripe.ts b/packages/server/modules/gatekeeper/clients/stripe.ts index cff53714d..6f8b26d28 100644 --- a/packages/server/modules/gatekeeper/clients/stripe.ts +++ b/packages/server/modules/gatekeeper/clients/stripe.ts @@ -91,7 +91,7 @@ export const parseSubscriptionData = ( // on each change, we're reconciling that state to stripe export const reconcileWorkspaceSubscriptionFactory = ({ stripe }: { stripe: Stripe }): ReconcileSubscriptionData => - async ({ subscriptionData, applyProrotation }) => { + async ({ subscriptionData, prorationBehavior }) => { const existingSubscriptionState = await getSubscriptionDataFactory({ stripe })({ subscriptionId: subscriptionData.subscriptionId }) @@ -126,7 +126,7 @@ export const reconcileWorkspaceSubscriptionFactory = // const item = workspaceSubscription.subscriptionData.products.find(p => p.) await stripe.subscriptions.update(subscriptionData.subscriptionId, { items, - proration_behavior: applyProrotation ? 'create_prorations' : 'none' + proration_behavior: prorationBehavior }) } diff --git a/packages/server/modules/gatekeeper/domain/billing.ts b/packages/server/modules/gatekeeper/domain/billing.ts index b09febc80..1409ec973 100644 --- a/packages/server/modules/gatekeeper/domain/billing.ts +++ b/packages/server/modules/gatekeeper/domain/billing.ts @@ -186,7 +186,7 @@ export type SubscriptionDataInput = OverrideProperties< export type ReconcileSubscriptionData = (args: { subscriptionData: SubscriptionDataInput - applyProrotation: boolean + prorationBehavior: 'always_invoice' | 'create_prorations' | 'none' }) => Promise export const WorkspaceSeatType = { diff --git a/packages/server/modules/gatekeeper/domain/operations.ts b/packages/server/modules/gatekeeper/domain/operations.ts index 3f18d7e79..7cd1dacce 100644 --- a/packages/server/modules/gatekeeper/domain/operations.ts +++ b/packages/server/modules/gatekeeper/domain/operations.ts @@ -34,7 +34,14 @@ export type GetWorkspacePlanByProjectId = ({ }) => Promise export type CreateWorkspaceSeat = ( - args: Pick + args: Pick, + options?: Partial<{ + skipIfExists: boolean + }> +) => Promise + +export type DeleteWorkspaceSeat = ( + args: Pick ) => Promise export type CountSeatsByTypeInWorkspace = ( diff --git a/packages/server/modules/gatekeeper/events/eventListener.ts b/packages/server/modules/gatekeeper/events/eventListener.ts index 46bd1d6dd..90e777c51 100644 --- a/packages/server/modules/gatekeeper/events/eventListener.ts +++ b/packages/server/modules/gatekeeper/events/eventListener.ts @@ -5,6 +5,7 @@ import { upsertTrialWorkspacePlanFactory, upsertUnpaidWorkspacePlanFactory } from '@/modules/gatekeeper/repositories/billing' +import { countSeatsByTypeInWorkspaceFactory } from '@/modules/gatekeeper/repositories/workspaceSeat' import { addWorkspaceSubscriptionSeatIfNeededFactory } from '@/modules/gatekeeper/services/subscriptions' import { getWorkspacePlanPriceId, @@ -34,10 +35,16 @@ export const initializeEventListenersFactory = }), getWorkspacePlanPriceId, getWorkspacePlanProductId, - reconcileSubscriptionData: reconcileWorkspaceSubscriptionFactory({ stripe }) + reconcileSubscriptionData: reconcileWorkspaceSubscriptionFactory({ + stripe + }), + countSeatsByTypeInWorkspace: countSeatsByTypeInWorkspaceFactory({ db }) }) - await addWorkspaceSubscriptionSeatIfNeeded(payload) + await addWorkspaceSubscriptionSeatIfNeeded({ + ...payload.acl, + seatType: payload.seatType + }) }), eventBus.listen(WorkspaceEvents.Created, async ({ payload }) => { // TODO: based on a feature flag, we can force new workspaces into the free plan here diff --git a/packages/server/modules/gatekeeper/graph/resolvers/index.ts b/packages/server/modules/gatekeeper/graph/resolvers/index.ts index 1af7909b7..9f6f30580 100644 --- a/packages/server/modules/gatekeeper/graph/resolvers/index.ts +++ b/packages/server/modules/gatekeeper/graph/resolvers/index.ts @@ -4,7 +4,8 @@ import { authorizeResolver } from '@/modules/shared' import { ensureError, Roles, throwUncoveredError } from '@speckle/shared' import { countWorkspaceRoleWithOptionalProjectRoleFactory, - getWorkspaceFactory + getWorkspaceFactory, + getWorkspaceRoleForUserFactory } from '@/modules/workspaces/repositories/workspaces' import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace' import { db } from '@/db/knex' @@ -34,7 +35,8 @@ import { isWorkspaceReadOnlyFactory } from '@/modules/gatekeeper/services/readOn import { calculateSubscriptionSeats, CreateCheckoutSession, - CreateCheckoutSessionOld + CreateCheckoutSessionOld, + WorkspaceSeatType } from '@/modules/gatekeeper/domain/billing' import { WorkspacePaymentMethod } from '@/test/graphql/generated/graphql' import { LogicError, NotImplementedError } from '@/modules/shared/errors' @@ -51,7 +53,12 @@ import { startCheckoutSessionFactoryNew, startCheckoutSessionFactoryOld } from '@/modules/gatekeeper/services/checkout/startCheckoutSession' -import { countSeatsByTypeInWorkspaceFactory } from '@/modules/gatekeeper/repositories/workspaceSeat' +import { + countSeatsByTypeInWorkspaceFactory, + createWorkspaceSeatFactory +} from '@/modules/gatekeeper/repositories/workspaceSeat' +import { assignWorkspaceSeatFactory } from '@/modules/workspaces/services/workspaceSeat' +import { getEventBus } from '@/modules/shared/services/eventBus' const { FF_GATEKEEPER_MODULE_ENABLED, FF_BILLING_INTEGRATION_ENABLED } = getFeatureFlags() @@ -143,6 +150,16 @@ export = FF_GATEKEEPER_MODULE_ENABLED }) } }, + WorkspaceCollaborator: { + seatType: async (parent, _args, context) => { + const seat = await context.loaders + .gatekeeper!.getUserWorkspaceSeatType.forWorkspace(parent.workspaceId) + .load(parent.id) + + // Defaults to Editor for old plans that don't have seat types + return seat?.type || WorkspaceSeatType.Editor + } + }, ServerWorkspacesInfo: { planPrices: async () => { const getWorkspacePlanPrices = getWorkspacePlanProductPricesFactory({ @@ -160,7 +177,26 @@ export = FF_GATEKEEPER_MODULE_ENABLED } }, WorkspaceMutations: { - billing: () => ({}) + billing: () => ({}), + updateSeatType: async (_parent, args, ctx) => { + const { workspaceId, userId, seatType } = args.input + + await authorizeResolver( + ctx.userId, + workspaceId, + Roles.Workspace.Admin, + ctx.resourceAccessRules + ) + + const assignSeat = assignWorkspaceSeatFactory({ + createWorkspaceSeat: createWorkspaceSeatFactory({ db }), + getWorkspaceRoleForUser: getWorkspaceRoleForUserFactory({ db }), + emit: getEventBus().emit + }) + await assignSeat({ workspaceId, userId, type: seatType }) + + return ctx.loaders.workspaces!.getWorkspace.load(workspaceId) + } }, WorkspaceBillingMutations: { cancelCheckoutSession: async (_parent, args, ctx) => { diff --git a/packages/server/modules/gatekeeper/index.ts b/packages/server/modules/gatekeeper/index.ts index 72b631838..88b1d1fd6 100644 --- a/packages/server/modules/gatekeeper/index.ts +++ b/packages/server/modules/gatekeeper/index.ts @@ -71,7 +71,7 @@ const scheduleWorkspaceSubscriptionDownscale = ({ updateWorkspaceSubscription: upsertWorkspaceSubscriptionFactory({ db }) }) - const cronExpression = '*/5 * * * *' + const cronExpression = '*/5 * * * *' // every 5 minutes return scheduleExecution( cronExpression, 'WorkspaceSubscriptionDownscale', diff --git a/packages/server/modules/gatekeeper/repositories/workspaceSeat.ts b/packages/server/modules/gatekeeper/repositories/workspaceSeat.ts index 07733869f..4dfc9c38b 100644 --- a/packages/server/modules/gatekeeper/repositories/workspaceSeat.ts +++ b/packages/server/modules/gatekeeper/repositories/workspaceSeat.ts @@ -3,6 +3,7 @@ import { WorkspaceSeat } from '@/modules/gatekeeper/domain/billing' import { CountSeatsByTypeInWorkspace, CreateWorkspaceSeat, + DeleteWorkspaceSeat, GetWorkspaceUserSeat, GetWorkspaceUserSeats } from '@/modules/gatekeeper/domain/operations' @@ -32,16 +33,28 @@ export const countSeatsByTypeInWorkspaceFactory = export const createWorkspaceSeatFactory = ({ db }: { db: Knex }): CreateWorkspaceSeat => - async ({ userId, workspaceId, type }) => { - await tables + async ({ userId, workspaceId, type }, { skipIfExists } = {}) => { + const qBase = tables .workspaceSeats(db) - .insert({ - workspaceId, - userId, - type - }) + .insert( + { + workspaceId, + userId, + type + }, + '*' + ) .onConflict(['workspaceId', 'userId']) - .merge() + const q = skipIfExists ? qBase.ignore() : qBase.merge() + + const [seat] = await q + return seat + } + +export const deleteWorkspaceSeatFactory = + (deps: { db: Knex }): DeleteWorkspaceSeat => + async ({ userId, workspaceId }) => { + await tables.workspaceSeats(deps.db).where({ userId, workspaceId }).delete() } export const getWorkspaceUserSeatsFactory = diff --git a/packages/server/modules/gatekeeper/services/checkout/startCheckoutSession.ts b/packages/server/modules/gatekeeper/services/checkout/startCheckoutSession.ts index a728459b8..db94e2f38 100644 --- a/packages/server/modules/gatekeeper/services/checkout/startCheckoutSession.ts +++ b/packages/server/modules/gatekeeper/services/checkout/startCheckoutSession.ts @@ -259,6 +259,9 @@ export const startCheckoutSessionFactoryNew = workspaceId, type: 'editor' }) + if (!editorsCount) { + throw new InvalidWorkspacePlanUpgradeError('Workspace has no seats') + } const checkoutSession = await createCheckoutSession({ workspaceId, diff --git a/packages/server/modules/gatekeeper/services/subscriptions.ts b/packages/server/modules/gatekeeper/services/subscriptions.ts index dab441a68..8974010d2 100644 --- a/packages/server/modules/gatekeeper/services/subscriptions.ts +++ b/packages/server/modules/gatekeeper/services/subscriptions.ts @@ -11,6 +11,7 @@ import { SubscriptionDataInput, UpsertPaidWorkspacePlan, UpsertWorkspaceSubscription, + WorkspaceSeatType, WorkspaceSubscription } from '@/modules/gatekeeper/domain/billing' import { @@ -20,7 +21,11 @@ import { WorkspacePlanNotFoundError, WorkspaceSubscriptionNotFoundError } from '@/modules/gatekeeper/errors/billing' -import { isNewPlanType, isOldPaidPlanType } from '@/modules/gatekeeper/helpers/plans' +import { + isNewPaidPlanType, + isNewPlanType, + isOldPaidPlanType +} from '@/modules/gatekeeper/helpers/plans' import { WorkspacePricingProducts } from '@/modules/gatekeeperCore/domain/billing' import { LogicError, NotImplementedError } from '@/modules/shared/errors' import { getFeatureFlags } from '@/modules/shared/helpers/envHelper' @@ -34,6 +39,7 @@ import { xor } from '@speckle/shared' import { cloneDeep, isEqual, sum } from 'lodash' +import { CountSeatsByTypeInWorkspace } from '@/modules/gatekeeper/domain/operations' const { FF_WORKSPACES_NEW_PLANS_ENABLED } = getFeatureFlags() @@ -120,7 +126,8 @@ export const addWorkspaceSubscriptionSeatIfNeededFactory = countWorkspaceRole, getWorkspacePlanProductId, getWorkspacePlanPriceId, - reconcileSubscriptionData + reconcileSubscriptionData, + countSeatsByTypeInWorkspace }: { getWorkspacePlan: GetWorkspacePlan getWorkspaceSubscription: GetWorkspaceSubscription @@ -128,20 +135,30 @@ export const addWorkspaceSubscriptionSeatIfNeededFactory = getWorkspacePlanProductId: GetWorkspacePlanProductId getWorkspacePlanPriceId: GetWorkspacePlanPriceId reconcileSubscriptionData: ReconcileSubscriptionData + countSeatsByTypeInWorkspace: CountSeatsByTypeInWorkspace }) => - async ({ workspaceId, role }: { workspaceId: string; role: WorkspaceRoles }) => { + async ({ + workspaceId, + role, + seatType + }: { + workspaceId: string + role: WorkspaceRoles + seatType: WorkspaceSeatType + }) => { const workspacePlan = await getWorkspacePlan({ workspaceId }) // if (!workspacePlan) throw new WorkspacePlanNotFoundError() if (!workspacePlan) return const workspaceSubscription = await getWorkspaceSubscription({ workspaceId }) if (!workspaceSubscription) return // if (!workspaceSubscription) throw new WorkspaceSubscriptionNotFoundError() + const isNewPaidPlan = isNewPaidPlanType(workspacePlan.name) switch (workspacePlan.name) { case 'team': case 'pro': - // Cause seat types matter, a future issue. ProductId should change based on seat type - throw new NotImplementedError() + // If viewer seat type, we don't need to do anything + if (seatType === WorkspaceSeatType.Viewer) return case 'starter': case 'plus': case 'business': @@ -161,32 +178,44 @@ export const addWorkspaceSubscriptionSeatIfNeededFactory = let productId: string let priceId: string - let roleCount: number - switch (role) { - case 'workspace:guest': - roleCount = await countWorkspaceRole({ workspaceId, workspaceRole: role }) - productId = getWorkspacePlanProductId({ workspacePlan: 'guest' }) - priceId = getWorkspacePlanPriceId({ - workspacePlan: 'guest', - billingInterval: workspaceSubscription.billingInterval - }) - break - case 'workspace:admin': - case 'workspace:member': - roleCount = sum( - await Promise.all([ - countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:admin' }), - countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:member' }) - ]) - ) - productId = getWorkspacePlanProductId({ workspacePlan: workspacePlan.name }) - priceId = getWorkspacePlanPriceId({ - workspacePlan: workspacePlan.name, - billingInterval: workspaceSubscription.billingInterval - }) - break - default: - throwUncoveredError(role) + let productAmount: number + + if (isNewPaidPlan) { + // New logic, only based on seat types + productAmount = await countSeatsByTypeInWorkspace({ workspaceId, type: seatType }) + productId = getWorkspacePlanProductId({ workspacePlan: workspacePlan.name }) + priceId = getWorkspacePlanPriceId({ + workspacePlan: workspacePlan.name, + billingInterval: workspaceSubscription.billingInterval + }) + } else { + // Old logic for old plans - based on roles + switch (role) { + case 'workspace:guest': + productAmount = await countWorkspaceRole({ workspaceId, workspaceRole: role }) + productId = getWorkspacePlanProductId({ workspacePlan: 'guest' }) + priceId = getWorkspacePlanPriceId({ + workspacePlan: 'guest', + billingInterval: workspaceSubscription.billingInterval + }) + break + case 'workspace:admin': + case 'workspace:member': + productAmount = sum( + await Promise.all([ + countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:admin' }), + countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:member' }) + ]) + ) + productId = getWorkspacePlanProductId({ workspacePlan: workspacePlan.name }) + priceId = getWorkspacePlanPriceId({ + workspacePlan: workspacePlan.name, + billingInterval: workspaceSubscription.billingInterval + }) + break + default: + throwUncoveredError(role) + } } const subscriptionData: SubscriptionDataInput = cloneDeep( @@ -197,13 +226,16 @@ export const addWorkspaceSubscriptionSeatIfNeededFactory = (product) => product.productId === productId ) if (!currentPlanProduct) { - subscriptionData.products.push({ productId, priceId, quantity: roleCount }) + subscriptionData.products.push({ productId, priceId, quantity: productAmount }) } else { // if there is enough seats, we do not have to do anything - if (currentPlanProduct.quantity >= roleCount) return - currentPlanProduct.quantity = roleCount + if (currentPlanProduct.quantity >= productAmount) return + currentPlanProduct.quantity = productAmount } - await reconcileSubscriptionData({ subscriptionData, applyProrotation: true }) + await reconcileSubscriptionData({ + subscriptionData, + prorationBehavior: isNewPaidPlan ? 'always_invoice' : 'create_prorations' + }) } const mutateSubscriptionDataWithNewValidSeatNumbers = ({ @@ -320,7 +352,7 @@ export const downscaleWorkspaceSubscriptionFactory = }) if (!isEqual(subscriptionData, workspaceSubscription.subscriptionData)) { - await reconcileSubscriptionData({ subscriptionData, applyProrotation: false }) + await reconcileSubscriptionData({ subscriptionData, prorationBehavior: 'none' }) return true } return false @@ -551,7 +583,12 @@ export const upgradeWorkspaceSubscriptionFactory = subscriptionItemId: undefined }) - await reconcileSubscriptionData({ subscriptionData, applyProrotation: true }) + await reconcileSubscriptionData({ + subscriptionData, + prorationBehavior: isNewPlanType(targetPlan) + ? 'always_invoice' + : 'create_prorations' + }) await upsertWorkspacePlan({ workspacePlan: { status: workspacePlan.status, diff --git a/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts b/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts index f9eb9b3d1..e493b61df 100644 --- a/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts +++ b/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts @@ -2,6 +2,7 @@ import { testLogger as logger } from '@/observability/logging' import { SubscriptionData, SubscriptionDataInput, + WorkspaceSeatType, WorkspaceSubscription } from '@/modules/gatekeeper/domain/billing' import { @@ -304,11 +305,13 @@ describe('subscriptions @gatekeeper', () => { }, reconcileSubscriptionData: async () => { expect.fail() - } + }, + countSeatsByTypeInWorkspace: async () => 0 }) await addWorkspaceSubscriptionSeatIfNeeded({ workspaceId, - role: 'workspace:admin' + role: 'workspace:admin', + seatType: WorkspaceSeatType.Editor }) expect(true).to.be.true }) @@ -334,11 +337,13 @@ describe('subscriptions @gatekeeper', () => { }, reconcileSubscriptionData: async () => { expect.fail() - } + }, + countSeatsByTypeInWorkspace: async () => 0 }) await addWorkspaceSubscriptionSeatIfNeeded({ workspaceId, - role: 'workspace:admin' + role: 'workspace:admin', + seatType: WorkspaceSeatType.Editor }) }) it('throws if a non paid plan, has a subscription', async () => { @@ -368,12 +373,14 @@ describe('subscriptions @gatekeeper', () => { }, reconcileSubscriptionData: async () => { expect.fail() - } + }, + countSeatsByTypeInWorkspace: async () => 0 }) const err = await expectToThrow(async () => { await addWorkspaceSubscriptionSeatIfNeeded({ workspaceId, - role: 'workspace:admin' + role: 'workspace:admin', + seatType: WorkspaceSeatType.Editor }) }) expect(err.message).to.equal(new WorkspacePlanMismatchError().message) @@ -405,11 +412,13 @@ describe('subscriptions @gatekeeper', () => { }, reconcileSubscriptionData: async () => { expect.fail() - } + }, + countSeatsByTypeInWorkspace: async () => 0 }) await addWorkspaceSubscriptionSeatIfNeeded({ workspaceId, - role: 'workspace:admin' + role: 'workspace:admin', + seatType: WorkspaceSeatType.Editor }) }) it('uses the guest count, guest product and price id if the new role is workspace:guest', async () => { @@ -462,14 +471,19 @@ describe('subscriptions @gatekeeper', () => { if (args.workspacePlan !== 'guest') expect.fail() return productId }, - reconcileSubscriptionData: async ({ applyProrotation, subscriptionData }) => { - if (!applyProrotation) expect.fail() + reconcileSubscriptionData: async ({ + prorationBehavior, + subscriptionData + }) => { + if (prorationBehavior !== 'create_prorations') expect.fail() reconciledSubscriptionData = subscriptionData - } + }, + countSeatsByTypeInWorkspace: async () => 0 }) await addWorkspaceSubscriptionSeatIfNeeded({ workspaceId, - role: 'workspace:guest' + role: 'workspace:guest', + seatType: WorkspaceSeatType.Viewer }) expect(reconciledSubscriptionData!.products).deep.equalInAnyOrder([ { productId, priceId, quantity: roleCount } @@ -528,16 +542,18 @@ describe('subscriptions @gatekeeper', () => { return productId }, reconcileSubscriptionData: async ({ - applyProrotation, + prorationBehavior, subscriptionData }) => { - if (!applyProrotation) expect.fail() + if (prorationBehavior !== 'create_prorations') expect.fail() reconciledSubscriptionData = subscriptionData - } + }, + countSeatsByTypeInWorkspace: async () => 0 }) await addWorkspaceSubscriptionSeatIfNeeded({ workspaceId, - role + role, + seatType: WorkspaceSeatType.Editor }) expect(reconciledSubscriptionData!.products).deep.equalInAnyOrder([ { productId, priceId, quantity: 2 * roleCount } @@ -605,14 +621,19 @@ describe('subscriptions @gatekeeper', () => { if (args.workspacePlan !== workspacePlan.name) expect.fail() return productId }, - reconcileSubscriptionData: async ({ applyProrotation, subscriptionData }) => { - if (!applyProrotation) expect.fail() + reconcileSubscriptionData: async ({ + prorationBehavior, + subscriptionData + }) => { + if (prorationBehavior !== 'create_prorations') expect.fail() reconciledSubscriptionData = subscriptionData - } + }, + countSeatsByTypeInWorkspace: async () => 0 }) await addWorkspaceSubscriptionSeatIfNeeded({ workspaceId, - role: 'workspace:member' + role: 'workspace:member', + seatType: WorkspaceSeatType.Editor }) expect(reconciledSubscriptionData!.products).deep.equalInAnyOrder([ { productId, priceId, quantity: 2 * roleCount, subscriptionItemId } @@ -680,11 +701,13 @@ describe('subscriptions @gatekeeper', () => { }, reconcileSubscriptionData: async () => { expect.fail() - } + }, + countSeatsByTypeInWorkspace: async () => 0 }) await addWorkspaceSubscriptionSeatIfNeeded({ workspaceId, - role: 'workspace:member' + role: 'workspace:member', + seatType: WorkspaceSeatType.Editor }) }) }) diff --git a/packages/server/modules/gatekeeperCore/graph/dataloaders/index.ts b/packages/server/modules/gatekeeperCore/graph/dataloaders/index.ts new file mode 100644 index 000000000..19971debe --- /dev/null +++ b/packages/server/modules/gatekeeperCore/graph/dataloaders/index.ts @@ -0,0 +1,47 @@ +import { WorkspaceSeat } from '@/modules/gatekeeper/domain/billing' +import { getWorkspaceUserSeatsFactory } from '@/modules/gatekeeper/repositories/workspaceSeat' +import { getFeatureFlags } from '@/modules/shared/helpers/envHelper' +import { defineRequestDataloaders } from '@/modules/shared/helpers/graphqlHelper' +import DataLoader from 'dataloader' + +const { FF_GATEKEEPER_MODULE_ENABLED } = getFeatureFlags() + +declare module '@/modules/core/loaders' { + interface ModularizedDataLoaders + extends Partial> {} +} + +const dataLoadersDefinition = defineRequestDataloaders( + ({ createLoader, deps: { db } }) => { + const getUserSeats = getWorkspaceUserSeatsFactory({ db }) + + return { + gatekeeper: { + getUserWorkspaceSeatType: (() => { + type LoaderType = DataLoader + const workspaceLoaders = new Map() + return { + clearAll: () => workspaceLoaders.clear(), + forWorkspace(workspaceId: string): LoaderType { + let loader = workspaceLoaders.get(workspaceId) + if (!loader) { + loader = createLoader(async (ids) => { + const results = await getUserSeats({ + userIds: ids.slice(), + workspaceId + }) + return ids.map((id) => results[id] || null) + }) + workspaceLoaders.set(workspaceId, loader) + } + + return loader + } + } + })() + } + } + } +) + +export default FF_GATEKEEPER_MODULE_ENABLED ? dataLoadersDefinition : undefined diff --git a/packages/server/modules/gatekeeperCore/graph/resolvers/index.ts b/packages/server/modules/gatekeeperCore/graph/resolvers/index.ts index 7b3d69aa2..bfe3dc34a 100644 --- a/packages/server/modules/gatekeeperCore/graph/resolvers/index.ts +++ b/packages/server/modules/gatekeeperCore/graph/resolvers/index.ts @@ -10,6 +10,14 @@ const resolvers: Resolvers = FF_GATEKEEPER_MODULE_ENABLED WorkspaceMutations: { billing: () => { throw new GatekeeperModuleDisabledError() + }, + updateSeatType: () => { + throw new GatekeeperModuleDisabledError() + } + }, + WorkspaceCollaborator: { + seatType: () => { + throw new GatekeeperModuleDisabledError() } }, ServerWorkspacesInfo: { diff --git a/packages/server/modules/shared/helpers/factory.ts b/packages/server/modules/shared/helpers/factory.ts index 41cefc247..dcf3f0185 100644 --- a/packages/server/modules/shared/helpers/factory.ts +++ b/packages/server/modules/shared/helpers/factory.ts @@ -8,3 +8,11 @@ export type Factory< export type DependenciesOf = F extends Factory ? Deps : never + +export type FactoryResultOf = F extends Factory< + any, + infer Args, + infer ReturnType +> + ? (...args: Args) => ReturnType + : never diff --git a/packages/server/modules/workspaces/domain/operations.ts b/packages/server/modules/workspaces/domain/operations.ts index bf8cdae5e..88b46a365 100644 --- a/packages/server/modules/workspaces/domain/operations.ts +++ b/packages/server/modules/workspaces/domain/operations.ts @@ -388,8 +388,15 @@ export type CopyProjectAutomations = (params: { }) => Promise> export type AssignWorkspaceSeat = ( - params: Pick & { type?: WorkspaceSeatType } -) => Promise + params: Pick & { type: WorkspaceSeatType } +) => Promise + +export type EnsureValidWorkspaceRoleSeat = (params: { + workspaceId: string + userId: string + role: WorkspaceRoles +}) => Promise + export type CopyProjectComments = (params: { projectIds: string[] }) => Promise> diff --git a/packages/server/modules/workspaces/events/eventListener.ts b/packages/server/modules/workspaces/events/eventListener.ts index 506e5131e..11680b4a9 100644 --- a/packages/server/modules/workspaces/events/eventListener.ts +++ b/packages/server/modules/workspaces/events/eventListener.ts @@ -5,7 +5,6 @@ import { upsertProjectRoleFactory } from '@/modules/core/repositories/streams' import { - AssignWorkspaceSeat, CountWorkspaceRoleWithOptionalProjectRole, GetDefaultRegion, GetWorkspace, @@ -92,15 +91,16 @@ import { getWorkspacePlanFactory, getWorkspaceSubscriptionFactory } from '@/modules/gatekeeper/repositories/billing' -import { getFeatureFlags } from '@/modules/shared/helpers/envHelper' -import { assignWorkspaceSeatFactory } from '@/modules/workspaces/services/workspaceSeat' +import { ensureValidWorkspaceRoleSeatFactory } from '@/modules/workspaces/services/workspaceSeat' import { createWorkspaceSeatFactory, + deleteWorkspaceSeatFactory, getWorkspaceUserSeatFactory } from '@/modules/gatekeeper/repositories/workspaceSeat' -import { GetWorkspaceUserSeat } from '@/modules/gatekeeper/domain/operations' - -const { FF_WORKSPACES_NEW_PLANS_ENABLED } = getFeatureFlags() +import { + DeleteWorkspaceSeat, + GetWorkspaceUserSeat +} from '@/modules/gatekeeper/domain/operations' export const onProjectCreatedFactory = ({ @@ -219,10 +219,12 @@ export const onWorkspaceAuthorizedFactory = export const onWorkspaceRoleDeletedFactory = ({ queryAllWorkspaceProjects, - deleteProjectRole + deleteProjectRole, + deleteWorkspaceSeat }: { queryAllWorkspaceProjects: QueryAllWorkspaceProjects deleteProjectRole: DeleteProjectRole + deleteWorkspaceSeat: DeleteWorkspaceSeat }) => async ({ userId, workspaceId }: { userId: string; workspaceId: string }) => { // Delete roles for all workspace projects @@ -235,6 +237,9 @@ export const onWorkspaceRoleDeletedFactory = ) ) } + + // Delete seat + await deleteWorkspaceSeat({ userId, workspaceId }) } export const onWorkspaceRoleUpdatedFactory = @@ -242,30 +247,23 @@ export const onWorkspaceRoleUpdatedFactory = getWorkspaceRoleToDefaultProjectRoleMapping, queryAllWorkspaceProjects, deleteProjectRole, - upsertProjectRole, - assignWorkspaceSeat + upsertProjectRole }: { getWorkspaceRoleToDefaultProjectRoleMapping: GetWorkspaceRoleToDefaultProjectRoleMapping queryAllWorkspaceProjects: QueryAllWorkspaceProjects deleteProjectRole: DeleteProjectRole upsertProjectRole: UpsertProjectRole - assignWorkspaceSeat: AssignWorkspaceSeat }) => async ({ - userId, - role, - workspaceId, - seatType, + acl, flags }: { - userId: string - role: WorkspaceRoles - workspaceId: string - seatType?: WorkspaceSeatType + acl: { userId: string; role: WorkspaceRoles; workspaceId: string } flags?: { skipProjectRoleUpdatesFor: string[] } }) => { + const { userId, role, workspaceId } = acl const defaultProjectRoleMapping = await getWorkspaceRoleToDefaultProjectRoleMapping( { workspaceId @@ -300,10 +298,6 @@ export const onWorkspaceRoleUpdatedFactory = }) ) } - - if (FF_WORKSPACES_NEW_PLANS_ENABLED) { - await assignWorkspaceSeat({ userId, workspaceId, type: seatType }) - } } export const workspaceTrackingFactory = @@ -414,10 +408,12 @@ export const workspaceTrackingFactory = break case 'workspace.role-deleted': case 'workspace.role-updated': - const speckleMembers = await checkForSpeckleMembers({ userId: payload.userId }) - const workspace = await getWorkspace({ workspaceId: payload.workspaceId }) + const speckleMembers = await checkForSpeckleMembers({ + userId: payload.acl.userId + }) + const workspace = await getWorkspace({ workspaceId: payload.acl.workspaceId }) if (!workspace) break - mixpanel.groups.set('workspace_id', payload.workspaceId, { + mixpanel.groups.set('workspace_id', payload.acl.workspaceId, { ...(await calculateProperties(workspace)), // only marking has speckle members to true // calculating this for speckle member removal would require getting all users @@ -446,7 +442,7 @@ const emitWorkspaceGraphqlSubscriptionsFactory = break case WorkspaceEvents.RoleDeleted: case WorkspaceEvents.RoleUpdated: - const { workspaceId } = payload + const { workspaceId } = payload.acl const foundWorkspace = await deps.getWorkspace({ workspaceId }) if (foundWorkspace) { await publish(WorkspaceSubscriptions.WorkspaceUpdated, { @@ -480,21 +476,6 @@ const emitWorkspaceGraphqlSubscriptionsFactory = } } -const onWorkspaceCreatedFactory = - ({ assignWorkspaceSeat }: { assignWorkspaceSeat: AssignWorkspaceSeat }) => - async ({ - workspace, - createdByUserId - }: { - workspace: Workspace - createdByUserId: string - }) => { - if (!FF_WORKSPACES_NEW_PLANS_ENABLED) { - return - } - await assignWorkspaceSeat({ userId: createdByUserId, workspaceId: workspace.id }) - } - const blockInvalidWorkspaceProjectRoleUpdatesFactory = (deps: { getStream: GetStream @@ -569,6 +550,11 @@ export const initializeEventListenersFactory = getWorkspaceUserSeat, getWorkspacePlan }) + const createWorkspaceSeat = createWorkspaceSeatFactory({ db }) + const ensureValidWorkspaceRoleSeat = ensureValidWorkspaceRoleSeatFactory({ + createWorkspaceSeat, + getWorkspaceUserSeat + }) const quitCbs = [ eventBus.listen(ProjectEvents.Created, async ({ payload }) => { @@ -591,7 +577,8 @@ export const initializeEventListenersFactory = findVerifiedEmailsByUserId: findVerifiedEmailsByUserIdFactory({ db }), getWorkspaceRoles: getWorkspaceRolesFactory({ db }), upsertWorkspaceRole: upsertWorkspaceRoleFactory({ db }), - emitWorkspaceEvent: (...args) => getEventBus().emit(...args) + emitWorkspaceEvent: (...args) => getEventBus().emit(...args), + ensureValidWorkspaceRoleSeat }) }) await onInviteFinalized(payload) @@ -629,9 +616,10 @@ export const initializeEventListenersFactory = const trx = await db.transaction() const onWorkspaceRoleDeleted = onWorkspaceRoleDeletedFactory({ queryAllWorkspaceProjects: queryAllWorkspaceProjectsFactory({ getStreams }), - deleteProjectRole: deleteProjectRoleFactory({ db: trx }) + deleteProjectRole: deleteProjectRoleFactory({ db: trx }), + deleteWorkspaceSeat: deleteWorkspaceSeatFactory({ db: trx }) }) - await withTransaction(onWorkspaceRoleDeleted(payload), trx) + await withTransaction(onWorkspaceRoleDeleted(payload.acl), trx) }), eventBus.listen(WorkspaceEvents.RoleUpdated, async ({ payload }) => { const trx = await db.transaction() @@ -642,24 +630,10 @@ export const initializeEventListenersFactory = }), queryAllWorkspaceProjects: queryAllWorkspaceProjectsFactory({ getStreams }), deleteProjectRole: deleteProjectRoleFactory({ db: trx }), - upsertProjectRole: upsertProjectRoleFactory({ db: trx }), - assignWorkspaceSeat: assignWorkspaceSeatFactory({ - createWorkspaceSeat: createWorkspaceSeatFactory({ db: trx }), - getWorkspaceRoleForUser: getWorkspaceRoleForUserFactory({ db: trx }) - }) + upsertProjectRole: upsertProjectRoleFactory({ db: trx }) }) await withTransaction(onWorkspaceRoleUpdated(payload), trx) }), - eventBus.listen(WorkspaceEvents.Created, async ({ payload }) => { - const trx = await db.transaction() - const onWorkspaceCreated = onWorkspaceCreatedFactory({ - assignWorkspaceSeat: assignWorkspaceSeatFactory({ - createWorkspaceSeat: createWorkspaceSeatFactory({ db: trx }), - getWorkspaceRoleForUser: getWorkspaceRoleForUserFactory({ db: trx }) - }) - }) - await withTransaction(onWorkspaceCreated(payload), trx) - }), eventBus.listen('**', emitWorkspaceGraphqlSubscriptions), eventBus.listen( ProjectEvents.PermissionsBeingAdded, diff --git a/packages/server/modules/workspaces/graph/resolvers/workspaceJoinRequests.ts b/packages/server/modules/workspaces/graph/resolvers/workspaceJoinRequests.ts index 9d0406dc9..117f8737f 100644 --- a/packages/server/modules/workspaces/graph/resolvers/workspaceJoinRequests.ts +++ b/packages/server/modules/workspaces/graph/resolvers/workspaceJoinRequests.ts @@ -5,6 +5,10 @@ import { findEmailsByUserIdFactory } from '@/modules/core/repositories/userEmail import { getUserFactory } from '@/modules/core/repositories/users' import { renderEmail } from '@/modules/emails/services/emailRendering' import { sendEmail } from '@/modules/emails/services/sending' +import { + createWorkspaceSeatFactory, + getWorkspaceUserSeatFactory +} from '@/modules/gatekeeper/repositories/workspaceSeat' import { commandFactory } from '@/modules/shared/command' import { getFeatureFlags } from '@/modules/shared/helpers/envHelper' import { getEventBus } from '@/modules/shared/services/eventBus' @@ -31,6 +35,7 @@ import { approveWorkspaceJoinRequestFactory, denyWorkspaceJoinRequestFactory } from '@/modules/workspaces/services/workspaceJoinRequests' +import { ensureValidWorkspaceRoleSeatFactory } from '@/modules/workspaces/services/workspaceSeat' import { WorkspaceJoinRequestStatus } from '@/modules/workspacesCore/domain/types' import { WorkspaceJoinRequestGraphQLReturn } from '@/modules/workspacesCore/helpers/graphTypes' @@ -148,7 +153,11 @@ export default FF_WORKSPACES_MODULE_ENABLED db }), upsertWorkspaceRole: upsertWorkspaceRoleFactory({ db }), - emit + emit, + ensureValidWorkspaceRoleSeat: ensureValidWorkspaceRoleSeatFactory({ + createWorkspaceSeat: createWorkspaceSeatFactory({ db }), + getWorkspaceUserSeat: getWorkspaceUserSeatFactory({ db }) + }) }) } }) diff --git a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts index 62b8941c5..412ba54ef 100644 --- a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts +++ b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts @@ -203,6 +203,11 @@ import { AuthCodePayloadAction, createStoredAuthCodeFactory } from '@/modules/automate/services/authCode' +import { ensureValidWorkspaceRoleSeatFactory } from '@/modules/workspaces/services/workspaceSeat' +import { + createWorkspaceSeatFactory, + getWorkspaceUserSeatFactory +} from '@/modules/gatekeeper/repositories/workspaceSeat' const eventBus = getEventBus() const getServerInfo = getServerInfoFactory({ db }) @@ -441,7 +446,11 @@ export = FF_WORKSPACES_MODULE_ENABLED }), upsertWorkspace: upsertWorkspaceFactory({ db }), upsertWorkspaceRole: upsertWorkspaceRoleFactory({ db }), - emitWorkspaceEvent: getEventBus().emit + emitWorkspaceEvent: getEventBus().emit, + ensureValidWorkspaceRoleSeat: ensureValidWorkspaceRoleSeatFactory({ + createWorkspaceSeat: createWorkspaceSeatFactory({ db }), + getWorkspaceUserSeat: getWorkspaceUserSeatFactory({ db }) + }) }) const workspace = await createWorkspace({ @@ -590,7 +599,11 @@ export = FF_WORKSPACES_MODULE_ENABLED db }), getWorkspaceRoles: getWorkspaceRolesFactory({ db }), - emitWorkspaceEvent: emit + emitWorkspaceEvent: emit, + ensureValidWorkspaceRoleSeat: ensureValidWorkspaceRoleSeatFactory({ + createWorkspaceSeat: createWorkspaceSeatFactory({ db }), + getWorkspaceUserSeat: getWorkspaceUserSeatFactory({ db }) + }) }) }) await updateWorkspaceRole({ userId, workspaceId, role }) @@ -676,7 +689,11 @@ export = FF_WORKSPACES_MODULE_ENABLED getUserEmails: findEmailsByUserIdFactory({ db }), getWorkspaceWithDomains: getWorkspaceWithDomainsFactory({ db }), upsertWorkspaceRole: upsertWorkspaceRoleFactory({ db }), - emitWorkspaceEvent: getEventBus().emit + emitWorkspaceEvent: getEventBus().emit, + ensureValidWorkspaceRoleSeat: ensureValidWorkspaceRoleSeatFactory({ + createWorkspaceSeat: createWorkspaceSeatFactory({ db }), + getWorkspaceUserSeat: getWorkspaceUserSeatFactory({ db }) + }) })({ userId: context.userId, workspaceId: args.input.workspaceId }) return await getWorkspaceFactory({ db })({ @@ -855,7 +872,11 @@ export = FF_WORKSPACES_MODULE_ENABLED findVerifiedEmailsByUserId: findVerifiedEmailsByUserIdFactory({ db }), getWorkspaceRoles: getWorkspaceRolesFactory({ db }), upsertWorkspaceRole: upsertWorkspaceRoleFactory({ db }), - emitWorkspaceEvent: getEventBus().emit + emitWorkspaceEvent: getEventBus().emit, + ensureValidWorkspaceRoleSeat: ensureValidWorkspaceRoleSeatFactory({ + createWorkspaceSeat: createWorkspaceSeatFactory({ db }), + getWorkspaceUserSeat: getWorkspaceUserSeatFactory({ db }) + }) }) }), findEmail: findEmailFactory({ db }), @@ -995,7 +1016,11 @@ export = FF_WORKSPACES_MODULE_ENABLED db }), upsertWorkspaceRole: upsertWorkspaceRoleFactory({ db }), - emitWorkspaceEvent: emit + emitWorkspaceEvent: emit, + ensureValidWorkspaceRoleSeat: ensureValidWorkspaceRoleSeatFactory({ + createWorkspaceSeat: createWorkspaceSeatFactory({ db }), + getWorkspaceUserSeat: getWorkspaceUserSeatFactory({ db }) + }) }) }) }) diff --git a/packages/server/modules/workspaces/services/join.ts b/packages/server/modules/workspaces/services/join.ts index 30c756555..c5eabd739 100644 --- a/packages/server/modules/workspaces/services/join.ts +++ b/packages/server/modules/workspaces/services/join.ts @@ -1,6 +1,7 @@ import { FindEmailsByUserId } from '@/modules/core/domain/userEmails/operations' import { EventBus } from '@/modules/shared/services/eventBus' import { + EnsureValidWorkspaceRoleSeat, GetWorkspaceWithDomains, UpsertWorkspaceRole } from '@/modules/workspaces/domain/operations' @@ -17,12 +18,14 @@ export const joinWorkspaceFactory = getUserEmails, getWorkspaceWithDomains, upsertWorkspaceRole, - emitWorkspaceEvent + emitWorkspaceEvent, + ensureValidWorkspaceRoleSeat }: { getUserEmails: FindEmailsByUserId getWorkspaceWithDomains: GetWorkspaceWithDomains upsertWorkspaceRole: UpsertWorkspaceRole emitWorkspaceEvent: EventBus['emit'] + ensureValidWorkspaceRoleSeat: EnsureValidWorkspaceRoleSeat }) => async ({ userId, workspaceId }: { userId: string; workspaceId: string }) => { const userEmails = await getUserEmails({ userId }) @@ -43,13 +46,16 @@ export const joinWorkspaceFactory = if (!matchingEmail) throw new WorkspaceJoinNotAllowedError() const role = Roles.Workspace.Member + await upsertWorkspaceRole({ userId, workspaceId, role, createdAt: new Date() }) + const { type } = await ensureValidWorkspaceRoleSeat({ userId, workspaceId, role }) + await emitWorkspaceEvent({ eventName: WorkspaceEvents.JoinedFromDiscovery, payload: { userId, workspaceId, role } }) await emitWorkspaceEvent({ eventName: WorkspaceEvents.RoleUpdated, - payload: { userId, workspaceId, role } + payload: { acl: { userId, workspaceId, role }, seatType: type } }) } diff --git a/packages/server/modules/workspaces/services/management.ts b/packages/server/modules/workspaces/services/management.ts index 1aef7473e..b29b1074a 100644 --- a/packages/server/modules/workspaces/services/management.ts +++ b/packages/server/modules/workspaces/services/management.ts @@ -11,7 +11,8 @@ import { GetWorkspaceDomains, UpdateWorkspace, GetWorkspaceBySlug, - UpdateWorkspaceRole + UpdateWorkspaceRole, + EnsureValidWorkspaceRoleSeat } from '@/modules/workspaces/domain/operations' import { Workspace, @@ -126,13 +127,15 @@ export const createWorkspaceFactory = upsertWorkspaceRole, generateValidSlug, validateSlug, - emitWorkspaceEvent + emitWorkspaceEvent, + ensureValidWorkspaceRoleSeat }: { upsertWorkspace: UpsertWorkspace upsertWorkspaceRole: UpsertWorkspaceRole validateSlug: ValidateWorkspaceSlug generateValidSlug: GenerateValidSlug emitWorkspaceEvent: EventBus['emit'] + ensureValidWorkspaceRoleSeat: EnsureValidWorkspaceRoleSeat }) => async ({ userId, @@ -166,13 +169,20 @@ export const createWorkspaceFactory = discoverabilityEnabled: false } await upsertWorkspace({ workspace }) + // assign the creator as workspace administrator + const role = Roles.Workspace.Admin await upsertWorkspaceRole({ userId, - role: Roles.Workspace.Admin, + role, workspaceId: workspace.id, createdAt: new Date() }) + await ensureValidWorkspaceRoleSeat({ + userId, + workspaceId: workspace.id, + role + }) // emit a workspace created event await emitWorkspaceEvent({ @@ -370,7 +380,7 @@ export const deleteWorkspaceRoleFactory = // Emit deleted role await emitWorkspaceEvent({ eventName: WorkspaceEvents.RoleDeleted, - payload: deletedRole + payload: { acl: deletedRole } }) return deletedRole @@ -396,13 +406,15 @@ export const updateWorkspaceRoleFactory = getWorkspaceWithDomains, findVerifiedEmailsByUserId, upsertWorkspaceRole, - emitWorkspaceEvent + emitWorkspaceEvent, + ensureValidWorkspaceRoleSeat }: { getWorkspaceRoles: GetWorkspaceRoles getWorkspaceWithDomains: GetWorkspaceWithDomains findVerifiedEmailsByUserId: FindVerifiedEmailsByUserId upsertWorkspaceRole: UpsertWorkspaceRole emitWorkspaceEvent: EmitWorkspaceEvent + ensureValidWorkspaceRoleSeat: EnsureValidWorkspaceRoleSeat }): UpdateWorkspaceRole => async ({ workspaceId, @@ -465,13 +477,21 @@ export const updateWorkspaceRoleFactory = role: nextWorkspaceRole, createdAt: previousWorkspaceRole?.createdAt ?? new Date() }) + const { type } = await ensureValidWorkspaceRoleSeat({ + userId, + workspaceId, + role: nextWorkspaceRole + }) await emitWorkspaceEvent({ eventName: WorkspaceEvents.RoleUpdated, payload: { - userId, - workspaceId, - role: nextWorkspaceRole, + acl: { + userId, + workspaceId, + role: nextWorkspaceRole + }, + seatType: type, flags: { skipProjectRoleUpdatesFor: skipProjectRoleUpdatesFor ?? [] } diff --git a/packages/server/modules/workspaces/services/workspaceJoinRequests.ts b/packages/server/modules/workspaces/services/workspaceJoinRequests.ts index 7b792d108..7cf9d1281 100644 --- a/packages/server/modules/workspaces/services/workspaceJoinRequests.ts +++ b/packages/server/modules/workspaces/services/workspaceJoinRequests.ts @@ -9,6 +9,7 @@ import { NotFoundError } from '@/modules/shared/errors' import { CreateWorkspaceJoinRequest, DenyWorkspaceJoinRequest, + EnsureValidWorkspaceRoleSeat, GetWorkspace, GetWorkspaceJoinRequest, GetWorkspaceWithDomains, @@ -107,7 +108,8 @@ export const approveWorkspaceJoinRequestFactory = getWorkspace, getWorkspaceJoinRequest, upsertWorkspaceRole, - emit + emit, + ensureValidWorkspaceRoleSeat }: { updateWorkspaceJoinRequestStatus: UpdateWorkspaceJoinRequestStatus sendWorkspaceJoinRequestApprovedEmail: SendWorkspaceJoinRequestApprovedEmail @@ -116,6 +118,7 @@ export const approveWorkspaceJoinRequestFactory = getWorkspaceJoinRequest: GetWorkspaceJoinRequest upsertWorkspaceRole: UpsertWorkspaceRole emit: EventBus['emit'] + ensureValidWorkspaceRoleSeat: EnsureValidWorkspaceRoleSeat }) => async ({ userId, workspaceId }: { userId: string; workspaceId: string }) => { const requester = await getUserById(userId) @@ -145,11 +148,12 @@ export const approveWorkspaceJoinRequestFactory = const role = Roles.Workspace.Member await upsertWorkspaceRole({ userId, workspaceId, role, createdAt: new Date() }) + const { type } = await ensureValidWorkspaceRoleSeat({ userId, workspaceId, role }) await emit({ eventName: WorkspaceEvents.Updated, payload: { workspace } }) await emit({ eventName: WorkspaceEvents.RoleUpdated, - payload: { workspaceId, userId, role } + payload: { acl: { workspaceId, userId, role }, seatType: type } }) await sendWorkspaceJoinRequestApprovedEmail({ diff --git a/packages/server/modules/workspaces/services/workspaceSeat.ts b/packages/server/modules/workspaces/services/workspaceSeat.ts index 6d1605912..1f48b6352 100644 --- a/packages/server/modules/workspaces/services/workspaceSeat.ts +++ b/packages/server/modules/workspaces/services/workspaceSeat.ts @@ -1,11 +1,17 @@ import { WorkspaceSeatType } from '@/modules/gatekeeper/domain/billing' -import { CreateWorkspaceSeat } from '@/modules/gatekeeper/domain/operations' +import { + CreateWorkspaceSeat, + GetWorkspaceUserSeat +} from '@/modules/gatekeeper/domain/operations' import { NotFoundError } from '@/modules/shared/errors' +import { EventBusEmit } from '@/modules/shared/services/eventBus' import { AssignWorkspaceSeat, + EnsureValidWorkspaceRoleSeat, GetWorkspaceRoleForUser } from '@/modules/workspaces/domain/operations' import { InvalidWorkspaceSeatTypeError } from '@/modules/workspaces/errors/workspaceSeat' +import { WorkspaceEvents } from '@/modules/workspacesCore/domain/events' import { Roles, WorkspaceRoles } from '@speckle/shared' import { z } from 'zod' @@ -52,30 +58,50 @@ export const isWorkspaceRoleWorkspaceSeatTypeValid = ({ }).success } +export const ensureValidWorkspaceRoleSeatFactory = + (deps: { + createWorkspaceSeat: CreateWorkspaceSeat + getWorkspaceUserSeat: GetWorkspaceUserSeat + }): EnsureValidWorkspaceRoleSeat => + async (params) => { + const workspaceSeat = await deps.getWorkspaceUserSeat({ + workspaceId: params.workspaceId, + userId: params.userId + }) + if ( + workspaceSeat && + isWorkspaceRoleWorkspaceSeatTypeValid({ + workspaceRole: params.role, + workspaceSeatType: workspaceSeat.type + }) + ) { + return workspaceSeat + } + + // Upsert default seat type assignment + return await deps.createWorkspaceSeat({ + workspaceId: params.workspaceId, + userId: params.userId, + type: getDefaultWorkspaceSeatTypeByWorkspaceRole({ workspaceRole: params.role }) + }) + } + export const assignWorkspaceSeatFactory = ({ createWorkspaceSeat, - getWorkspaceRoleForUser + getWorkspaceRoleForUser, + emit }: { createWorkspaceSeat: CreateWorkspaceSeat getWorkspaceRoleForUser: GetWorkspaceRoleForUser + emit: EventBusEmit }): AssignWorkspaceSeat => async ({ workspaceId, userId, type }) => { const workspaceAcl = await getWorkspaceRoleForUser({ workspaceId, userId }) if (!workspaceAcl) { throw new NotFoundError('User does not have a role in the workspace') } - if (!type) { - return await createWorkspaceSeat({ - workspaceId, - userId, - type: type - ? type - : getDefaultWorkspaceSeatTypeByWorkspaceRole({ - workspaceRole: workspaceAcl.role - }) - }) - } + if ( !isWorkspaceRoleWorkspaceSeatTypeValid({ workspaceRole: workspaceAcl.role, @@ -93,9 +119,16 @@ export const assignWorkspaceSeatFactory = ) } - return await createWorkspaceSeat({ + const seat = await createWorkspaceSeat({ workspaceId, userId, type }) + + await emit({ + eventName: WorkspaceEvents.RoleUpdated, + payload: { acl: workspaceAcl, seatType: seat.type } + }) + + return seat } diff --git a/packages/server/modules/workspaces/tests/helpers/creation.ts b/packages/server/modules/workspaces/tests/helpers/creation.ts index 1d6a523f3..0778a558f 100644 --- a/packages/server/modules/workspaces/tests/helpers/creation.ts +++ b/packages/server/modules/workspaces/tests/helpers/creation.ts @@ -60,7 +60,8 @@ import { getFeatureFlags, getFrontendOrigin } from '@/modules/shared/helpers/env import { getDefaultSsoSessionExpirationDate } from '@/modules/workspaces/domain/sso/logic' import { getWorkspacePlanFactory, - upsertPaidWorkspacePlanFactory + upsertPaidWorkspacePlanFactory, + upsertWorkspaceSubscriptionFactory } from '@/modules/gatekeeper/repositories/billing' import { SetOptional } from 'type-fest' import { isMultiRegionTestMode } from '@/test/speckle-helpers/regions' @@ -77,8 +78,15 @@ import { import { getDb } from '@/modules/multiregion/utils/dbSelector' import { WorkspacePlan } from '@/modules/gatekeeperCore/domain/billing' import { WorkspaceSeatType } from '@/modules/gatekeeper/domain/billing' -import { assignWorkspaceSeatFactory } from '@/modules/workspaces/services/workspaceSeat' -import { createWorkspaceSeatFactory } from '@/modules/gatekeeper/repositories/workspaceSeat' +import { + assignWorkspaceSeatFactory, + ensureValidWorkspaceRoleSeatFactory +} from '@/modules/workspaces/services/workspaceSeat' +import { + createWorkspaceSeatFactory, + getWorkspaceUserSeatFactory +} from '@/modules/gatekeeper/repositories/workspaceSeat' +import dayjs from 'dayjs' const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags() @@ -110,10 +118,11 @@ export const createTestWorkspace = async ( options?: { domain?: string addPlan?: Pick | boolean + addSubscription?: boolean regionKey?: string } ) => { - const { domain, addPlan = true, regionKey } = options || {} + const { domain, addPlan = true, regionKey, addSubscription } = options || {} const useRegion = isMultiRegionTestMode() && regionKey if (!FF_WORKSPACES_MODULE_ENABLED) { @@ -135,8 +144,13 @@ export const createTestWorkspace = async ( }), upsertWorkspace: upsertWorkspaceFactory({ db }), upsertWorkspaceRole: upsertWorkspaceRoleFactory({ db }), - emitWorkspaceEvent: (...args) => getEventBus().emit(...args) + emitWorkspaceEvent: (...args) => getEventBus().emit(...args), + ensureValidWorkspaceRoleSeat: ensureValidWorkspaceRoleSeatFactory({ + createWorkspaceSeat: createWorkspaceSeatFactory({ db }), + getWorkspaceUserSeat: getWorkspaceUserSeatFactory({ db }) + }) }) + const upsertSubscription = upsertWorkspaceSubscriptionFactory({ db }) const newWorkspace = await createWorkspace({ userId: owner.id, @@ -186,6 +200,25 @@ export const createTestWorkspace = async ( }) } + if (addSubscription) { + await upsertSubscription({ + workspaceSubscription: { + workspaceId: newWorkspace.id, + createdAt: new Date(), + updatedAt: new Date(), + currentBillingCycleEnd: dayjs().add(1, 'month').toDate(), + billingInterval: 'monthly', + subscriptionData: { + subscriptionId: cryptoRandomString({ length: 10 }), + customerId: cryptoRandomString({ length: 10 }), + cancelAt: null, + status: 'active', + products: [] + } + } + }) + } + if (useRegion) { const regionDb = await getDb({ regionKey }) const assignRegion = assignWorkspaceRegionFactory({ @@ -251,16 +284,23 @@ export const assignToWorkspace = async ( role?: WorkspaceRoles, seatType?: WorkspaceSeatType ) => { + const getWorkspaceUserSeat = getWorkspaceUserSeatFactory({ db }) + const updateWorkspaceRole = updateWorkspaceRoleFactory({ getWorkspaceWithDomains: getWorkspaceWithDomainsFactory({ db }), findVerifiedEmailsByUserId: findVerifiedEmailsByUserIdFactory({ db }), getWorkspaceRoles: getWorkspaceRolesFactory({ db }), upsertWorkspaceRole: upsertWorkspaceRoleFactory({ db }), - emitWorkspaceEvent: (...args) => getEventBus().emit(...args) + emitWorkspaceEvent: (...args) => getEventBus().emit(...args), + ensureValidWorkspaceRoleSeat: ensureValidWorkspaceRoleSeatFactory({ + createWorkspaceSeat: createWorkspaceSeatFactory({ db }), + getWorkspaceUserSeat + }) }) const assignWorkspaceSeat = assignWorkspaceSeatFactory({ createWorkspaceSeat: createWorkspaceSeatFactory({ db }), - getWorkspaceRoleForUser: getWorkspaceRoleForUserFactory({ db }) + getWorkspaceRoleForUser: getWorkspaceRoleForUserFactory({ db }), + emit: getEventBus().emit }) role = role || Roles.Workspace.Member diff --git a/packages/server/modules/workspaces/tests/helpers/graphql.ts b/packages/server/modules/workspaces/tests/helpers/graphql.ts index b22806f80..061758a17 100644 --- a/packages/server/modules/workspaces/tests/helpers/graphql.ts +++ b/packages/server/modules/workspaces/tests/helpers/graphql.ts @@ -376,3 +376,20 @@ export const updateWorkspaceProjectRoleMutation = gql` ${basicProjectFieldsFragment} ` + +export const updateWorkspaceSeatTypeMutation = gql` + mutation UpdateWorkspaceSeatType($input: WorkspaceUpdateSeatTypeInput!) { + workspaceMutations { + updateSeatType(input: $input) { + id + team { + items { + id + role + seatType + } + } + } + } + } +` diff --git a/packages/server/modules/workspaces/tests/integration/workspaceJoinRequests.spec.ts b/packages/server/modules/workspaces/tests/integration/workspaceJoinRequests.spec.ts index ad9368f91..0215fe2dc 100644 --- a/packages/server/modules/workspaces/tests/integration/workspaceJoinRequests.spec.ts +++ b/packages/server/modules/workspaces/tests/integration/workspaceJoinRequests.spec.ts @@ -246,7 +246,10 @@ const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags() getWorkspace: async () => null, getWorkspaceJoinRequest: async () => undefined, upsertWorkspaceRole: async () => Promise.resolve(), - emit: async () => Promise.resolve() + emit: async () => Promise.resolve(), + ensureValidWorkspaceRoleSeat: async () => { + throw new Error('Should not happen') + } })({ workspaceId: createRandomString(), userId: createRandomString() }) ) @@ -263,7 +266,10 @@ const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags() getWorkspace: async () => null, getWorkspaceJoinRequest: async () => undefined, upsertWorkspaceRole: async () => Promise.resolve(), - emit: async () => Promise.resolve() + emit: async () => Promise.resolve(), + ensureValidWorkspaceRoleSeat: async () => { + throw new Error('Should not happen') + } })({ workspaceId: createRandomString(), userId: createRandomString() }) ) @@ -288,7 +294,10 @@ const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags() getWorkspace: async () => workspace as unknown as Workspace, getWorkspaceJoinRequest: async () => undefined, upsertWorkspaceRole: async () => Promise.resolve(), - emit: async () => Promise.resolve() + emit: async () => Promise.resolve(), + ensureValidWorkspaceRoleSeat: async () => { + throw new Error('Should not happen') + } })({ workspaceId: createRandomString(), userId: createRandomString() }) ) @@ -347,7 +356,14 @@ const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags() getWorkspace: async () => workspace as unknown as Workspace, getWorkspaceJoinRequest: async () => request, upsertWorkspaceRole, - emit: async () => Promise.resolve() + emit: async () => Promise.resolve(), + ensureValidWorkspaceRoleSeat: async () => ({ + type: 'editor', + workspaceId: workspace.id, + userId: user.id, + createdAt: new Date(), + updatedAt: new Date() + }) })({ workspaceId: workspace.id, userId: user.id }) ).to.equal(true) diff --git a/packages/server/modules/workspaces/tests/integration/workspaceSeat.graph.spec.ts b/packages/server/modules/workspaces/tests/integration/workspaceSeat.graph.spec.ts new file mode 100644 index 000000000..1432f71cf --- /dev/null +++ b/packages/server/modules/workspaces/tests/integration/workspaceSeat.graph.spec.ts @@ -0,0 +1,155 @@ +import { db } from '@/db/knex' +import { + createRandomEmail, + createRandomString +} from '@/modules/core/helpers/testHelpers' +import { WorkspaceSeatType } from '@/modules/gatekeeper/domain/billing' +import { getWorkspaceUserSeatFactory } from '@/modules/gatekeeper/repositories/workspaceSeat' +import { + assignToWorkspace, + BasicTestWorkspace, + createTestWorkspace +} from '@/modules/workspaces/tests/helpers/creation' +import { BasicTestUser, createTestUser } from '@/test/authHelper' +import { + 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 { Roles } from '@speckle/shared' +import { expect } from 'chai' + +const getWorkspaceUserSeat = getWorkspaceUserSeatFactory({ db }) + +describe('Workspace Seats @graphql', () => { + const workspaceAdmin: BasicTestUser = { + id: '', + name: 'Workspace Seats Admin Guy', + email: createRandomEmail(), + role: Roles.Server.Admin, + verified: true + } + + let apollo: TestApolloServer + + before(async () => { + await beforeEachContext() + await createTestUser(workspaceAdmin) + + apollo = await testApolloServer({ authUserId: workspaceAdmin.id }) + }) + + beforeEach(() => { + // cause we have a fake subscription + StripeClientMock.mockFunction( + 'reconcileWorkspaceSubscriptionFactory', + () => async () => {} + ) + }) + + after(async () => { + StripeClientMock.resetMockedFunctions() + }) + + describe('when being changed', () => { + const testWorkspace1: BasicTestWorkspace = { + id: '', + slug: '', + ownerId: '', + name: 'Test Workspace 1' + } + + before(async () => { + await createTestWorkspace(testWorkspace1, workspaceAdmin, { + addPlan: { name: 'pro', status: 'valid' }, + addSubscription: true + }) + }) + + const updateSeatType = (input: WorkspaceUpdateSeatTypeInput) => + apollo.execute(UpdateWorkspaceSeatTypeDocument, { input }) + + it('should throw an error if user is not a member of the workspace', async () => { + const user: BasicTestUser = { + id: createRandomString(), + name: createRandomString(), + email: createRandomEmail(), + role: Roles.Server.User, + verified: true + } + await createTestUser(user) + + const res = await updateSeatType({ + workspaceId: testWorkspace1.id, + userId: user.id, + seatType: WorkspaceSeatType.Editor + }) + + expect(res).to.haveGraphQLErrors('User does not have a role in the workspace') + expect(res.data?.workspaceMutations.updateSeatType).to.not.be.ok + }) + + it('should throw an error if seat type is not compatible with workspace role', 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.Admin) + + const res = await updateSeatType({ + workspaceId: testWorkspace1.id, + userId: user.id, + seatType: WorkspaceSeatType.Viewer + }) + + expect(res).to.haveGraphQLErrors('cannot have a seat of type') + expect(res.data?.workspaceMutations.updateSeatType).to.not.be.ok + }) + + it('should assign a workspace seat with the provided type and reconcile subscription', 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) + const oldSeat = await getWorkspaceUserSeat({ + workspaceId: testWorkspace1.id, + userId: user.id + }) + expect(oldSeat?.type).to.eq(WorkspaceSeatType.Viewer) + + const { args, length: reconciledTimes } = StripeClientMock.hijackFactoryFunction( + 'reconcileWorkspaceSubscriptionFactory', + async () => {} + ) + + const res = await updateSeatType({ + workspaceId: testWorkspace1.id, + userId: user.id, + seatType: WorkspaceSeatType.Editor + }) + + expect(res).to.not.haveGraphQLErrors() + expect( + res.data?.workspaceMutations.updateSeatType.team.items.find( + (i) => i.id === user.id + )?.seatType + ).to.eq(WorkspaceSeatType.Editor) + expect(reconciledTimes() > 0).to.be.true + + const reconcileArgs = args[0][0] + expect(reconcileArgs.prorationBehavior).to.eq('always_invoice') // new plan + expect(reconcileArgs.subscriptionData.products.length).to.be.ok + }) + }) +}) diff --git a/packages/server/modules/workspaces/tests/integration/workspaceSeat.spec.ts b/packages/server/modules/workspaces/tests/integration/workspaceSeat.spec.ts index 02afd5d51..d6eae6b25 100644 --- a/packages/server/modules/workspaces/tests/integration/workspaceSeat.spec.ts +++ b/packages/server/modules/workspaces/tests/integration/workspaceSeat.spec.ts @@ -3,192 +3,40 @@ import { createRandomEmail, createRandomString } from '@/modules/core/helpers/testHelpers' -import { createWorkspaceSeatFactory } from '@/modules/gatekeeper/repositories/workspaceSeat' -import { NotFoundError } from '@/modules/shared/errors' -import { InvalidWorkspaceSeatTypeError } from '@/modules/workspaces/errors/workspaceSeat' -import { getWorkspaceRoleForUserFactory } from '@/modules/workspaces/repositories/workspaces' -import { assignWorkspaceSeatFactory } from '@/modules/workspaces/services/workspaceSeat' +import { WorkspaceSeatType } from '@/modules/gatekeeper/domain/billing' +import { + createWorkspaceSeatFactory, + getWorkspaceUserSeatFactory +} from '@/modules/gatekeeper/repositories/workspaceSeat' +import { ensureValidWorkspaceRoleSeatFactory } from '@/modules/workspaces/services/workspaceSeat' import { assignToWorkspace, BasicTestWorkspace, - createTestWorkspace + createTestWorkspace, + unassignFromWorkspace } from '@/modules/workspaces/tests/helpers/creation' -import { expectToThrow } from '@/test/assertionHelper' -import { BasicTestUser, createTestUser } from '@/test/authHelper' +import { BasicTestUser, createTestUser, createTestUsers } from '@/test/authHelper' +import { beforeEachContext } from '@/test/hooks' import { Roles } from '@speckle/shared' import { expect } from 'chai' import cryptoRandomString from 'crypto-random-string' describe('Workspace workspaceSeat services', () => { describe('assignWorkspaceSeatFactory', () => { - it('should throw an error if user is not a member of the workspace', async () => { - const workspaceAdmin: BasicTestUser = { - id: createRandomString(), - name: createRandomString(), - email: createRandomEmail(), - role: Roles.Server.Admin, - verified: true - } + const workspaceAdmin: BasicTestUser = { + id: createRandomString(), + name: createRandomString(), + email: createRandomEmail(), + role: Roles.Server.Admin, + verified: true + } + + before(async () => { + await beforeEachContext() await createTestUser(workspaceAdmin) - - const workspace: BasicTestWorkspace = { - id: createRandomString(), - slug: createRandomString(), - ownerId: workspaceAdmin.id, - name: cryptoRandomString({ length: 6 }), - description: cryptoRandomString({ length: 12 }) - } - await createTestWorkspace(workspace, workspaceAdmin) - - const user: BasicTestUser = { - id: createRandomString(), - name: createRandomString(), - email: createRandomEmail(), - role: Roles.Server.User, - verified: true - } - await createTestUser(user) - - const err = await expectToThrow(() => - assignWorkspaceSeatFactory({ - createWorkspaceSeat: createWorkspaceSeatFactory({ db }), - getWorkspaceRoleForUser: getWorkspaceRoleForUserFactory({ db }) - })({ userId: user.id, workspaceId: workspace.id, type: 'editor' }) - ) - - expect(err.name).to.eq(NotFoundError.name) }) - it('should assign a workspace seat with the default type if none is provided', async () => { - const workspaceAdmin: BasicTestUser = { - id: createRandomString(), - name: createRandomString(), - email: createRandomEmail(), - role: Roles.Server.Admin, - verified: true - } - await createTestUser(workspaceAdmin) - const workspace: BasicTestWorkspace = { - id: createRandomString(), - slug: createRandomString(), - ownerId: workspaceAdmin.id, - name: cryptoRandomString({ length: 6 }), - description: cryptoRandomString({ length: 12 }) - } - await createTestWorkspace(workspace, workspaceAdmin) - - const user: BasicTestUser = { - id: createRandomString(), - name: createRandomString(), - email: createRandomEmail(), - role: Roles.Server.User, - verified: true - } - await createTestUser(user) - - await assignToWorkspace(workspace, user, Roles.Workspace.Member) - - await assignWorkspaceSeatFactory({ - createWorkspaceSeat: createWorkspaceSeatFactory({ db }), - getWorkspaceRoleForUser: getWorkspaceRoleForUserFactory({ db }) - })({ userId: user.id, workspaceId: workspace.id }) - - const workspaceSeat = await db('workspace_seats') - .where({ userId: user.id, workspaceId: workspace.id }) - .first() - - expect(workspaceSeat.type).to.eq('viewer') - }) - it('should assign a workspace seat with the provided type', async () => { - const workspaceAdmin: BasicTestUser = { - id: createRandomString(), - name: createRandomString(), - email: createRandomEmail(), - role: Roles.Server.Admin, - verified: true - } - await createTestUser(workspaceAdmin) - - const workspace: BasicTestWorkspace = { - id: createRandomString(), - slug: createRandomString(), - ownerId: workspaceAdmin.id, - name: cryptoRandomString({ length: 6 }), - description: cryptoRandomString({ length: 12 }) - } - await createTestWorkspace(workspace, workspaceAdmin) - - const user: BasicTestUser = { - id: createRandomString(), - name: createRandomString(), - email: createRandomEmail(), - role: Roles.Server.User, - verified: true - } - await createTestUser(user) - - await assignToWorkspace(workspace, user, Roles.Workspace.Member) - - await assignWorkspaceSeatFactory({ - createWorkspaceSeat: createWorkspaceSeatFactory({ db }), - getWorkspaceRoleForUser: getWorkspaceRoleForUserFactory({ db }) - })({ userId: user.id, workspaceId: workspace.id, type: 'editor' }) - - const workspaceSeat = await db('workspace_seats') - .where({ userId: user.id, workspaceId: workspace.id }) - .first() - - expect(workspaceSeat.type).to.eq('editor') - }) - it('should throw an error if seat type is not compatible with workspace role', async () => { - const workspaceAdmin: BasicTestUser = { - id: createRandomString(), - name: createRandomString(), - email: createRandomEmail(), - role: Roles.Server.Admin, - verified: true - } - await createTestUser(workspaceAdmin) - - const workspace: BasicTestWorkspace = { - id: createRandomString(), - slug: createRandomString(), - ownerId: workspaceAdmin.id, - name: cryptoRandomString({ length: 6 }), - description: cryptoRandomString({ length: 12 }) - } - await createTestWorkspace(workspace, workspaceAdmin) - - const user: BasicTestUser = { - id: createRandomString(), - name: createRandomString(), - email: createRandomEmail(), - role: Roles.Server.User, - verified: true - } - await createTestUser(user) - - await assignToWorkspace(workspace, user, Roles.Workspace.Admin) - - const err = await expectToThrow(() => - assignWorkspaceSeatFactory({ - createWorkspaceSeat: createWorkspaceSeatFactory({ db }), - getWorkspaceRoleForUser: getWorkspaceRoleForUserFactory({ db }) - })({ userId: user.id, workspaceId: workspace.id, type: 'viewer' }) - ) - - expect(err.name).to.eq(InvalidWorkspaceSeatTypeError.name) - }) it('should update seat type on role change', async () => { - const workspaceAdmin: BasicTestUser = { - id: createRandomString(), - name: createRandomString(), - email: createRandomEmail(), - role: Roles.Server.Admin, - verified: true - } - await createTestUser(workspaceAdmin) - const workspace: BasicTestWorkspace = { id: createRandomString(), slug: createRandomString(), @@ -224,4 +72,95 @@ describe('Workspace workspaceSeat services', () => { expect(workspaceSeatUpdated.type).to.eq('editor') }) }) + + describe('ensureValidWorkspaceRoleSeatFactory', () => { + const workspaceAdmin: BasicTestUser = { + id: '', + name: createRandomString(), + email: createRandomEmail(), + role: Roles.Server.Admin, + verified: true + } + const testUser: BasicTestUser = { + id: '', + name: createRandomString(), + email: createRandomEmail(), + role: Roles.Server.User, + verified: true + } + const workspace: BasicTestWorkspace = { + ownerId: '', + id: '', + slug: '', + name: cryptoRandomString({ length: 6 }), + description: cryptoRandomString({ length: 12 }) + } + + before(async () => { + await createTestUsers([workspaceAdmin, testUser]) + await createTestWorkspace(workspace, workspaceAdmin) + }) + + afterEach(async () => { + // remove testUsers from workspace + await unassignFromWorkspace(workspace, testUser) + }) + + const getWorkspaceUserSeat = getWorkspaceUserSeatFactory({ db }) + const createWorkspaceSeat = createWorkspaceSeatFactory({ db }) + const sut = ensureValidWorkspaceRoleSeatFactory({ + createWorkspaceSeat, + getWorkspaceUserSeat + }) + + it('should create a new seat if none exists', async () => { + const workspaceSeat = await sut({ + userId: testUser.id, + workspaceId: workspace.id, + role: Roles.Workspace.Member + }) + + expect(workspaceSeat).to.be.ok + expect(workspaceSeat?.type).to.eq(WorkspaceSeatType.Viewer) + }) + + it('should update seat type, if invalid one set', async () => { + await assignToWorkspace(workspace, testUser, Roles.Workspace.Member) + const oldSeat = await getWorkspaceUserSeat({ + userId: testUser.id, + workspaceId: workspace.id + }) + + const workspaceSeat = await sut({ + userId: testUser.id, + workspaceId: workspace.id, + role: Roles.Workspace.Admin + }) + + expect(oldSeat?.type).to.eq(WorkspaceSeatType.Viewer) + expect(workspaceSeat).to.be.ok + expect(workspaceSeat?.type).to.eq(WorkspaceSeatType.Editor) + }) + + it('should do nothing if valid seat type already exists', async () => { + await assignToWorkspace(workspace, testUser, Roles.Workspace.Admin) + const oldSeat = await getWorkspaceUserSeat({ + userId: testUser.id, + workspaceId: workspace.id + }) + + const workspaceSeat = await sut({ + userId: testUser.id, + workspaceId: workspace.id, + role: Roles.Workspace.Admin + }) + + expect(oldSeat?.type).to.eq(WorkspaceSeatType.Editor) + expect(workspaceSeat).to.be.ok + expect(workspaceSeat?.type).to.eq(WorkspaceSeatType.Editor) + expect(workspaceSeat?.updatedAt.toISOString()).to.eq( + oldSeat?.updatedAt.toISOString() + ) + }) + }) }) 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 e2b08c763..e703b6d3d 100644 --- a/packages/server/modules/workspaces/tests/unit/events/eventListener.spec.ts +++ b/packages/server/modules/workspaces/tests/unit/events/eventListener.spec.ts @@ -84,12 +84,13 @@ describe('Event handlers', () => { }, upsertProjectRole: async () => { expect.fail() - }, - assignWorkspaceSeat: async () => undefined + } })({ - role: Roles.Workspace.Guest, - userId: cryptoRandomString({ length: 10 }), - workspaceId: cryptoRandomString({ length: 10 }) + acl: { + role: Roles.Workspace.Guest, + userId: cryptoRandomString({ length: 10 }), + workspaceId: cryptoRandomString({ length: 10 }) + } }) expect(isDeleteCalled).to.be.true @@ -124,12 +125,13 @@ describe('Event handlers', () => { storedRoles.push(args) trackProjectUpdate = trackProjectUpdate || options?.trackProjectUpdate return {} as StreamRecord - }, - assignWorkspaceSeat: async () => undefined + } })({ - role: Roles.Workspace.Member, - userId, - workspaceId: cryptoRandomString({ length: 10 }) + acl: { + role: Roles.Workspace.Member, + userId, + workspaceId: cryptoRandomString({ length: 10 }) + } }) expect(storedRoles).deep.equals( projectIds.map((projectId) => ({ projectId, role: projectRole, userId })) diff --git a/packages/server/modules/workspaces/tests/unit/services/join.spec.ts b/packages/server/modules/workspaces/tests/unit/services/join.spec.ts index 6f649d735..5e5ee3fe4 100644 --- a/packages/server/modules/workspaces/tests/unit/services/join.spec.ts +++ b/packages/server/modules/workspaces/tests/unit/services/join.spec.ts @@ -53,6 +53,9 @@ describe('Workspace join services', () => { }, emitWorkspaceEvent: async () => { expect.fail() + }, + ensureValidWorkspaceRoleSeat: async () => { + throw new Error('Should not be called') } })({ userId, workspaceId }) }) @@ -75,6 +78,9 @@ describe('Workspace join services', () => { }, emitWorkspaceEvent: async () => { expect.fail() + }, + ensureValidWorkspaceRoleSeat: async () => { + throw new Error('Should not be called') } })({ userId, workspaceId }) }) @@ -98,6 +104,9 @@ describe('Workspace join services', () => { }, emitWorkspaceEvent: async () => { expect.fail() + }, + ensureValidWorkspaceRoleSeat: async () => { + throw new Error('Should not be called') } })({ userId, workspaceId }) }) @@ -122,7 +131,14 @@ describe('Workspace join services', () => { }, emitWorkspaceEvent: async ({ eventName }) => { firedEvents.push(eventName) - } + }, + ensureValidWorkspaceRoleSeat: async () => ({ + type: 'editor', + workspaceId, + userId, + createdAt: new Date(), + updatedAt: new Date() + }) })({ userId, workspaceId }) expect(storedWorkspaceRole!.userId).to.equal(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 7383a2b14..2a32414c9 100644 --- a/packages/server/modules/workspaces/tests/unit/services/management.spec.ts +++ b/packages/server/modules/workspaces/tests/unit/services/management.spec.ts @@ -40,6 +40,7 @@ import { UpsertWorkspaceArgs } from '@/modules/workspaces/domain/operations' import { FindVerifiedEmailsByUserId } from '@/modules/core/domain/userEmails/operations' +import { WorkspaceSeatType } from '@/modules/gatekeeper/domain/billing' type WorkspaceTestContext = { storedWorkspaces: UpsertWorkspaceArgs['workspace'][] @@ -78,6 +79,15 @@ const buildCreateWorkspaceWithTestContext = ( context.eventData.eventName = eventName context.eventData.payload = payload }, + ensureValidWorkspaceRoleSeat: async () => { + return { + type: 'editor', + workspaceId: 'test', + userId: 'test', + createdAt: new Date(), + updatedAt: new Date() + } + }, ...dependencyOverrides } @@ -563,8 +573,9 @@ const buildDeleteWorkspaceRoleAndTestContext = ( switch (eventName) { case WorkspaceEvents.RoleDeleted: { - const { userId } = - payload as WorkspaceEventsPayloads[typeof WorkspaceEvents.RoleDeleted] + const { + acl: { userId } + } = payload as WorkspaceEventsPayloads[typeof WorkspaceEvents.RoleDeleted] for (const project of context.workspaceProjects) { context.workspaceProjectRoles = context.workspaceProjectRoles.filter( (role) => role.resourceId !== project.id && role.userId !== userId @@ -609,8 +620,9 @@ const buildUpdateWorkspaceRoleAndTestContext = ( switch (eventName) { case WorkspaceEvents.RoleDeleted: { - const { userId } = - payload as WorkspaceEventsPayloads[typeof WorkspaceEvents.RoleDeleted] + const { + acl: { userId } + } = payload as WorkspaceEventsPayloads[typeof WorkspaceEvents.RoleDeleted] for (const project of context.workspaceProjects) { context.workspaceProjectRoles = context.workspaceProjectRoles.filter( (role) => role.resourceId !== project.id && role.userId !== userId @@ -629,20 +641,20 @@ const buildUpdateWorkspaceRoleAndTestContext = ( } for (const project of context.workspaceProjects) { - const projectRole = mapping[workspaceRole.role] + const projectRole = mapping[workspaceRole.acl.role] if (!projectRole) { continue } const streamAcl: StreamAclRecord = { - userId: workspaceRole.userId, + userId: workspaceRole.acl.userId, role: projectRole, resourceId: project.id } context.workspaceProjectRoles = context.workspaceProjectRoles.filter( - (acl) => acl.userId !== workspaceRole.userId + (acl) => acl.userId !== workspaceRole.acl.userId ) context.workspaceProjectRoles.push(streamAcl) } @@ -650,6 +662,15 @@ const buildUpdateWorkspaceRoleAndTestContext = ( } } }, + ensureValidWorkspaceRoleSeat: async () => { + return { + type: 'editor', + workspaceId: 'test', + userId: 'test', + createdAt: new Date(), + updatedAt: new Date() + } + }, ...dependencyOverrides } @@ -699,7 +720,7 @@ describe('Workspace role services', () => { expect(context.eventData.isCalled).to.be.true expect(context.eventData.eventName).to.equal(WorkspaceEvents.RoleDeleted) - expect(context.eventData.payload).to.deep.equal(role) + expect(context.eventData.payload).to.deep.equal({ acl: role }) }) it('throws if attempting to delete the last admin from a workspace', async () => { const userId = cryptoRandomString({ length: 10 }) @@ -790,7 +811,7 @@ describe('Workspace role services', () => { expect(context.eventData.isCalled).to.be.true expect(context.eventData.eventName).to.equal(WorkspaceEvents.RoleUpdated) - expect(payload).to.deep.equal(role) + expect(payload).to.deep.equal({ acl: role, seatType: WorkspaceSeatType.Editor }) }) it('throws if attempting to remove the last admin in a workspace', async () => { const userId = cryptoRandomString({ length: 10 }) diff --git a/packages/server/modules/workspacesCore/domain/events.ts b/packages/server/modules/workspacesCore/domain/events.ts index 38be7fbc0..1630705f4 100644 --- a/packages/server/modules/workspacesCore/domain/events.ts +++ b/packages/server/modules/workspacesCore/domain/events.ts @@ -1,3 +1,4 @@ +import { WorkspaceSeatType } from '@/modules/gatekeeper/domain/billing' import { Workspace, WorkspaceAcl } from '@/modules/workspacesCore/domain/types' import { WorkspaceRoles } from '@speckle/shared' @@ -26,11 +27,14 @@ type WorkspaceCreatedPayload = { createdByUserId: string } type WorkspaceUpdatedPayload = { workspace: Workspace } -type WorkspaceRoleDeletedPayload = Pick -type WorkspaceRoleUpdatedPayload = Pick< - WorkspaceAcl, - 'userId' | 'workspaceId' | 'role' -> & { flags?: { skipProjectRoleUpdatesFor: string[] } } +type WorkspaceRoleDeletedPayload = { + acl: Pick +} +type WorkspaceRoleUpdatedPayload = { + acl: Pick + seatType: WorkspaceSeatType + flags?: { skipProjectRoleUpdatesFor: string[] } +} type WorkspaceJoinedFromDiscoveryPayload = { userId: string workspaceId: string diff --git a/packages/server/package.json b/packages/server/package.json index 1b45e2bac..550701519 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -17,7 +17,7 @@ "scripts": { "build": "tsc -p ./tsconfig.build.json", "build:watch": "tsc -p ./tsconfig.build.json -w", - "run:watch": "cross-env NODE_ENV=development LOG_PRETTY=true nodemon --inspect ./bin/www --watch ./dist --watch ./assets --watch ./bin/www --watch .env --watch multiregion.json -e js,ts,graphql,env,gql", + "run:watch": "cross-env NODE_ENV=development LOG_PRETTY=true nodemon ./bin/www --watch ./dist --watch ./assets --watch ./bin/www --watch .env --watch multiregion.json -e js,ts,graphql,env,gql", "dev": "concurrently \"npm:build:watch\" \"npm:run:watch\" \"yarn gqlgen:watch\" -n tsc,server,gqlgen", "build:clean": "rimraf ./dist && yarn build", "dev:clean": "yarn build:clean && yarn dev", diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts index 40077b217..3902ec2af 100644 --- a/packages/server/test/graphql/generated/graphql.ts +++ b/packages/server/test/graphql/generated/graphql.ts @@ -4402,6 +4402,7 @@ export type WorkspaceCollaborator = { id: Scalars['ID']['output']; projectRoles: Array; role: Scalars['String']['output']; + seatType: WorkspaceSeatType; user: LimitedUser; }; @@ -4592,6 +4593,7 @@ export type WorkspaceMutations = { update: Workspace; updateCreationState: Scalars['Boolean']['output']; updateRole: Workspace; + updateSeatType: Workspace; }; @@ -4660,6 +4662,11 @@ export type WorkspaceMutationsUpdateRoleArgs = { input: WorkspaceRoleUpdateInput; }; + +export type WorkspaceMutationsUpdateSeatTypeArgs = { + input: WorkspaceUpdateSeatTypeInput; +}; + export const WorkspacePaymentMethod = { Billing: 'billing', Invoice: 'invoice', @@ -4809,6 +4816,12 @@ export type WorkspaceRoleUpdateInput = { workspaceId: Scalars['String']['input']; }; +export const WorkspaceSeatType = { + Editor: 'editor', + Viewer: 'viewer' +} as const; + +export type WorkspaceSeatType = typeof WorkspaceSeatType[keyof typeof WorkspaceSeatType]; export type WorkspaceSso = { __typename?: 'WorkspaceSso'; /** If null, the workspace does not have SSO configured */ @@ -4864,6 +4877,12 @@ export type WorkspaceUpdateInput = { slug?: InputMaybe; }; +export type WorkspaceUpdateSeatTypeInput = { + seatType: WorkspaceSeatType; + userId: Scalars['String']['input']; + workspaceId: Scalars['String']['input']; +}; + export type WorkspaceUpdatedMessage = { __typename?: 'WorkspaceUpdatedMessage'; /** Workspace ID */ @@ -5161,6 +5180,13 @@ export type UpdateWorkspaceProjectRoleMutationVariables = Exact<{ export type UpdateWorkspaceProjectRoleMutation = { __typename?: 'Mutation', workspaceMutations: { __typename?: 'WorkspaceMutations', projects: { __typename?: 'WorkspaceProjectMutations', updateRole: { __typename?: 'Project', id: string, name: string, description?: string | null, visibility: SimpleProjectVisibility, allowPublicComments: boolean, role?: string | null, createdAt: string, updatedAt: string } } } }; +export type UpdateWorkspaceSeatTypeMutationVariables = Exact<{ + input: WorkspaceUpdateSeatTypeInput; +}>; + + +export type UpdateWorkspaceSeatTypeMutation = { __typename?: 'Mutation', workspaceMutations: { __typename?: 'WorkspaceMutations', updateSeatType: { __typename?: 'Workspace', id: string, team: { __typename?: 'WorkspaceCollaboratorCollection', items: Array<{ __typename?: 'WorkspaceCollaborator', id: string, role: string, seatType: WorkspaceSeatType }> } } } }; + export type BasicStreamAccessRequestFieldsFragment = { __typename?: 'StreamAccessRequest', id: string, requesterId: string, streamId: string, createdAt: string, requester: { __typename?: 'LimitedUser', id: string, name: string } }; export type CreateStreamAccessRequestMutationVariables = Exact<{ @@ -5981,6 +6007,7 @@ export const DismissWorkspaceDocument = {"kind":"Document","definitions":[{"kind export const RequestToJoinWorkspaceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"requestToJoinWorkspace"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceRequestToJoinInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"requestToJoin"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]}}]} as unknown as DocumentNode; export const GetWorkspaceWithJoinRequestsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetWorkspaceWithJoinRequests"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"AdminWorkspaceJoinRequestFilter"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicWorkspace"}},{"kind":"Field","name":{"kind":"Name","value":"adminWorkspacesJoinRequests"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}},{"kind":"Argument","name":{"kind":"Name","value":"cursor"},"value":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"workspace"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicWorkspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"readOnly"}}]}}]} as unknown as DocumentNode; export const UpdateWorkspaceProjectRoleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateWorkspaceProjectRole"},"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":"workspaceMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projects"},"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 UpdateWorkspaceSeatTypeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateWorkspaceSeatType"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceUpdateSeatTypeInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateSeatType"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"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":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"seatType"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const CreateStreamAccessRequestDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateStreamAccessRequest"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"streamAccessRequestCreate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"streamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicStreamAccessRequestFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicStreamAccessRequestFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"StreamAccessRequest"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"requester"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"requesterId"}},{"kind":"Field","name":{"kind":"Name","value":"streamId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]} as unknown as DocumentNode; export const GetStreamAccessRequestDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetStreamAccessRequest"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"streamAccessRequest"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"streamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicStreamAccessRequestFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicStreamAccessRequestFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"StreamAccessRequest"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"requester"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"requesterId"}},{"kind":"Field","name":{"kind":"Name","value":"streamId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]} as unknown as DocumentNode; export const GetFullStreamAccessRequestDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetFullStreamAccessRequest"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"streamAccessRequest"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"streamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicStreamAccessRequestFields"}},{"kind":"Field","name":{"kind":"Name","value":"stream"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicStreamAccessRequestFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"StreamAccessRequest"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"requester"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"requesterId"}},{"kind":"Field","name":{"kind":"Name","value":"streamId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]} as unknown as DocumentNode; diff --git a/packages/server/test/mockHelper.ts b/packages/server/test/mockHelper.ts index 79c6bc9cd..a9a7df371 100644 --- a/packages/server/test/mockHelper.ts +++ b/packages/server/test/mockHelper.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-unsafe-return */ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { Factory, FactoryResultOf } from '@/modules/shared/helpers/factory' import { MaybeAsync } from '@/modules/shared/helpers/typeHelper' import { isArray, isFunction } from 'lodash' import mock from 'mock-require' @@ -27,6 +28,9 @@ export function mockRequireModule< type MockTypeFunctionsOnly = ConditionalPick type MockTypeFunctionProp = keyof MockTypeFunctionsOnly + type MockTypeFactoriesOnly = ConditionalPick + type MockTypeFactoryProp = keyof MockTypeFactoriesOnly + type MockedFunc = ( ...args: Parameters ) => ReturnType @@ -176,6 +180,61 @@ export function mockRequireModule< } ) + return { + /** + * Arguments that were used to call the mocked function. Each entry in this array is an array of arguments, so use the first array dimension to choose + * the invocation and the 2nd dimension to choose the specific argument. + */ + args: collectedArgs, + /** + * Return values that were returned from the mocked function. + */ + returns: collectedReturns, + /** + * Get the amount of invocations + */ + length: () => collectedArgs.length + } + }, + /** + * Simplification of hijackFunction for factories + */ + hijackFactoryFunction( + functionName: F, + implementation: FactoryResultOf, + params: { times: number } = { times: 1 } + ) { + const { times } = params + if (!isFunction(implementation)) + throw new Error('Implementation must be a function') + + const collectedReturns: Array< + ReturnType> + > = [] + const collectedArgs: Array< + Parameters> + > = [] + + core.enable() + core.mockFunction(functionName, (() => { + let localTimes = times + + return (...args: Parameters>) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + const returnVal = (implementation as Function).apply({}, args) + localTimes-- + + if (localTimes <= 0) { + core.resetMockedFunction(functionName) + } + + collectedArgs.push(args) + collectedReturns.push(returnVal) + + return returnVal + } + }) as ReturnType) + return { /** * Arguments that were used to call the mocked function. Each entry in this array is an array of arguments, so use the first array dimension to choose diff --git a/packages/server/test/mocks/global.ts b/packages/server/test/mocks/global.ts index f0b6ca3d4..634b94d5f 100644 --- a/packages/server/test/mocks/global.ts +++ b/packages/server/test/mocks/global.ts @@ -23,3 +23,7 @@ export const MultiRegionBlobStorageSelectorMock = mockRequireModule< export const MultiRegionConfigMock = mockRequireModule< typeof import('@/modules/multiregion/regionConfig') >(['@/modules/multiregion/regionConfig']) + +export const StripeClientMock = mockRequireModule< + typeof import('@/modules/gatekeeper/clients/stripe') +>(['@/modules/gatekeeper/clients/stripe'])