From eaf3b3a47966eae6873fb2db878196a5e63b27d0 Mon Sep 17 00:00:00 2001 From: Alessandro Magionami Date: Tue, 25 Mar 2025 09:47:13 +0100 Subject: [PATCH 1/6] feat(gatekeeper): seats counts --- .../typedefs/gatekeeper.graphql | 12 +++- packages/server/codegen.yml | 1 + .../modules/core/graph/generated/graphql.ts | 14 +++- .../graph/generated/graphql.ts | 6 ++ .../gatekeeper/graph/resolvers/index.ts | 33 +++++++++ .../modules/gatekeeper/helpers/graphTypes.ts | 2 + .../intergration/workspace.graph.spec.ts | 69 ++++++++++++++++++- .../workspaces/tests/helpers/graphql.ts | 21 ++++++ .../server/test/graphql/generated/graphql.ts | 14 ++++ 9 files changed, 165 insertions(+), 7 deletions(-) diff --git a/packages/server/assets/gatekeeperCore/typedefs/gatekeeper.graphql b/packages/server/assets/gatekeeperCore/typedefs/gatekeeper.graphql index 4b779ca52..f2f535e2d 100644 --- a/packages/server/assets/gatekeeperCore/typedefs/gatekeeper.graphql +++ b/packages/server/assets/gatekeeperCore/typedefs/gatekeeper.graphql @@ -92,8 +92,16 @@ type WorkspacePlan { } type WorkspaceSubscriptionSeats { - plan: Int! - guest: Int! + plan: Int! @deprecated + guest: Int! @deprecated + """ + Total number of seats purchased and available in the current subscription cycle + """ + totalCount: Int! + """ + Number assigned seats in the current billing cycle + """ + assigned: Int! } type WorkspaceSubscription { diff --git a/packages/server/codegen.yml b/packages/server/codegen.yml index aa4f4b8bd..bee2e7612 100644 --- a/packages/server/codegen.yml +++ b/packages/server/codegen.yml @@ -84,6 +84,7 @@ generates: ServerRegionMutations: '@/modules/core/helpers/graphTypes#MutationsObjectGraphQLReturn' ServerRegionItem: '@/modules/multiregion/helpers/graphTypes#ServerRegionItemGraphQLReturn' Price: '@/modules/gatekeeperCore/helpers/graphTypes#PriceGraphQLReturn' + WorkspaceSubscription: '@/modules/gatekeeper/helpers/graphTypes#WorkspaceSubscriptionGraphQLReturn' modules/cross-server-sync/graph/generated/graphql.ts: plugins: - 'typescript' diff --git a/packages/server/modules/core/graph/generated/graphql.ts b/packages/server/modules/core/graph/generated/graphql.ts index 7d69f1c19..d58684e83 100644 --- a/packages/server/modules/core/graph/generated/graphql.ts +++ b/packages/server/modules/core/graph/generated/graphql.ts @@ -6,7 +6,7 @@ import { PendingStreamCollaboratorGraphQLReturn } from '@/modules/serverinvites/ import { FileUploadGraphQLReturn } from '@/modules/fileuploads/helpers/types'; import { AutomateFunctionGraphQLReturn, AutomateFunctionReleaseGraphQLReturn, AutomationGraphQLReturn, AutomationRevisionGraphQLReturn, AutomationRevisionFunctionGraphQLReturn, AutomateRunGraphQLReturn, AutomationRunTriggerGraphQLReturn, AutomationRevisionTriggerDefinitionGraphQLReturn, AutomateFunctionRunGraphQLReturn, TriggeredAutomationsStatusGraphQLReturn, ProjectAutomationMutationsGraphQLReturn, ProjectTriggeredAutomationsStatusUpdatedMessageGraphQLReturn, ProjectAutomationsUpdatedMessageGraphQLReturn, UserAutomateInfoGraphQLReturn } from '@/modules/automate/helpers/graphTypes'; import { WorkspaceGraphQLReturn, WorkspaceSsoGraphQLReturn, WorkspaceMutationsGraphQLReturn, WorkspaceJoinRequestMutationsGraphQLReturn, WorkspaceInviteMutationsGraphQLReturn, WorkspaceProjectMutationsGraphQLReturn, PendingWorkspaceCollaboratorGraphQLReturn, WorkspaceCollaboratorGraphQLReturn, WorkspaceJoinRequestGraphQLReturn, LimitedWorkspaceJoinRequestGraphQLReturn, ProjectRoleGraphQLReturn } from '@/modules/workspacesCore/helpers/graphTypes'; -import { WorkspaceBillingMutationsGraphQLReturn } from '@/modules/gatekeeper/helpers/graphTypes'; +import { WorkspaceBillingMutationsGraphQLReturn, WorkspaceSubscriptionGraphQLReturn } from '@/modules/gatekeeper/helpers/graphTypes'; import { WebhookGraphQLReturn } from '@/modules/webhooks/helpers/graphTypes'; import { SmartTextEditorValueGraphQLReturn } from '@/modules/core/services/richTextEditorService'; import { BlobStorageItem } from '@/modules/blobstorage/domain/types'; @@ -4878,8 +4878,14 @@ export type WorkspaceSubscription = { export type WorkspaceSubscriptionSeats = { __typename?: 'WorkspaceSubscriptionSeats'; + /** Number assigned seats in the current billing cycle */ + assigned: Scalars['Int']['output']; + /** @deprecated Field no longer supported */ guest: Scalars['Int']['output']; + /** @deprecated Field no longer supported */ plan: Scalars['Int']['output']; + /** Total number of seats purchased and available in the current subscription cycle */ + totalCount: Scalars['Int']['output']; }; export type WorkspaceTeamFilter = { @@ -5297,7 +5303,7 @@ export type ResolversTypes = { WorkspaceSso: ResolverTypeWrapper; WorkspaceSsoProvider: ResolverTypeWrapper; WorkspaceSsoSession: ResolverTypeWrapper; - WorkspaceSubscription: ResolverTypeWrapper; + WorkspaceSubscription: ResolverTypeWrapper; WorkspaceSubscriptionSeats: ResolverTypeWrapper; WorkspaceTeamFilter: WorkspaceTeamFilter; WorkspaceUpdateInput: WorkspaceUpdateInput; @@ -5583,7 +5589,7 @@ export type ResolversParentTypes = { WorkspaceSso: WorkspaceSsoGraphQLReturn; WorkspaceSsoProvider: WorkspaceSsoProvider; WorkspaceSsoSession: WorkspaceSsoSession; - WorkspaceSubscription: WorkspaceSubscription; + WorkspaceSubscription: WorkspaceSubscriptionGraphQLReturn; WorkspaceSubscriptionSeats: WorkspaceSubscriptionSeats; WorkspaceTeamFilter: WorkspaceTeamFilter; WorkspaceUpdateInput: WorkspaceUpdateInput; @@ -7241,8 +7247,10 @@ export type WorkspaceSubscriptionResolvers = { + assigned?: Resolver; guest?: Resolver; plan?: Resolver; + totalCount?: 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 222bb3687..70316cf24 100644 --- a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts +++ b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts @@ -4858,8 +4858,14 @@ export type WorkspaceSubscription = { export type WorkspaceSubscriptionSeats = { __typename?: 'WorkspaceSubscriptionSeats'; + /** Number assigned seats in the current billing cycle */ + assigned: Scalars['Int']['output']; + /** @deprecated Field no longer supported */ guest: Scalars['Int']['output']; + /** @deprecated Field no longer supported */ plan: Scalars['Int']['output']; + /** Total number of seats purchased and available in the current subscription cycle */ + totalCount: Scalars['Int']['output']; }; export type WorkspaceTeamFilter = { diff --git a/packages/server/modules/gatekeeper/graph/resolvers/index.ts b/packages/server/modules/gatekeeper/graph/resolvers/index.ts index 694037a67..fc2c9575e 100644 --- a/packages/server/modules/gatekeeper/graph/resolvers/index.ts +++ b/packages/server/modules/gatekeeper/graph/resolvers/index.ts @@ -158,6 +158,39 @@ export = FF_GATEKEEPER_MODULE_ENABLED }) } }, + WorkspaceSubscription: { + seats: async (parent) => { + const workspacePlan = await getWorkspacePlanFactory({ db })({ + workspaceId: parent.workspaceId + }) + if (!workspacePlan || !isNewPlanType(workspacePlan.name)) { + return { + ...calculateSubscriptionSeats({ + subscriptionData: parent.subscriptionData, + guestSeatProductId: getWorkspacePlanProductId({ + workspacePlan: 'guest' + }) + }), + // These values have no reference in the old plans FF_WORKSPACES_NEW_PLANS_ENABLED + totalCount: 0, + assigned: 0 + } + } + // Only editor seats are considered + const totalSeatsCount = parent.subscriptionData.products[0].quantity + const assignedSeatsCount = await countSeatsByTypeInWorkspaceFactory({ db })({ + workspaceId: parent.workspaceId, + type: 'editor' + }) + return { + assigned: assignedSeatsCount, + totalCount: totalSeatsCount, + // These values have no reference in the new plans + guest: 0, + plan: 0 + } + } + }, WorkspaceCollaborator: { seatType: async (parent, _args, context) => { const seat = await context.loaders diff --git a/packages/server/modules/gatekeeper/helpers/graphTypes.ts b/packages/server/modules/gatekeeper/helpers/graphTypes.ts index 90b501e55..c8197a05f 100644 --- a/packages/server/modules/gatekeeper/helpers/graphTypes.ts +++ b/packages/server/modules/gatekeeper/helpers/graphTypes.ts @@ -1,3 +1,5 @@ import { MutationsObjectGraphQLReturn } from '@/modules/core/helpers/graphTypes' +import { WorkspaceSubscription } from '@/modules/gatekeeper/domain/billing' export type WorkspaceBillingMutationsGraphQLReturn = MutationsObjectGraphQLReturn +export type WorkspaceSubscriptionGraphQLReturn = WorkspaceSubscription diff --git a/packages/server/modules/gatekeeper/tests/intergration/workspace.graph.spec.ts b/packages/server/modules/gatekeeper/tests/intergration/workspace.graph.spec.ts index 685e44b37..d074d882e 100644 --- a/packages/server/modules/gatekeeper/tests/intergration/workspace.graph.spec.ts +++ b/packages/server/modules/gatekeeper/tests/intergration/workspace.graph.spec.ts @@ -1,12 +1,23 @@ +import { db } from '@/db/knex' import { AllScopes } from '@/modules/core/helpers/mainConstants' +import { + createRandomEmail, + createRandomString +} from '@/modules/core/helpers/testHelpers' +import { upsertWorkspaceSubscriptionFactory } from '@/modules/gatekeeper/repositories/billing' import { getFeatureFlags } from '@/modules/shared/helpers/envHelper' import { createTestWorkspace } from '@/modules/workspaces/tests/helpers/creation' import { BasicTestUser, createAuthTokenForUser, - createTestUsers + createTestUser, + createTestUsers, + login } from '@/test/authHelper' -import { GetWorkspaceDocument } from '@/test/graphql/generated/graphql' +import { + GetWorkspaceDocument, + GetWorkspaceWithSubscriptionDocument +} from '@/test/graphql/generated/graphql' import { createTestContext, testApolloServer, @@ -16,6 +27,7 @@ import { beforeEachContext } from '@/test/hooks' import { Roles } from '@speckle/shared' import { expect } from 'chai' import cryptoRandomString from 'crypto-random-string' +import dayjs from 'dayjs' const { FF_BILLING_INTEGRATION_ENABLED } = getFeatureFlags() @@ -103,4 +115,57 @@ describe('Workspaces Billing', () => { }) } ) + describe('workspace.subscription', () => { + describe('subscription.seats', () => { + it('should return the number of total seats', async () => { + const user = await createTestUser({ + name: createRandomString(), + email: createRandomEmail(), + role: Roles.Server.Admin, + verified: true + }) + const workspace = { + id: createRandomString(), + name: createRandomString(), + slug: cryptoRandomString({ length: 10 }), + ownerId: user.id + } + await createTestWorkspace(workspace, user, { + addPlan: { name: 'pro', status: 'valid' } + }) + await upsertWorkspaceSubscriptionFactory({ db })({ + workspaceSubscription: { + workspaceId: workspace.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: [ + { + priceId: createRandomString(), + quantity: 12, + productId: createRandomString(), + subscriptionItemId: createRandomString() + } + ] + } + } + }) + const session = await login(user) + + const res = await session.execute(GetWorkspaceWithSubscriptionDocument, { + workspaceId: workspace.id + }) + + expect(res).to.not.haveGraphQLErrors() + const seats = res.data?.workspace.subscription?.seats + expect(seats).to.deep.eq({ guest: 0, plan: 0, assigned: 1, totalCount: 12 }) + }) + }) + }) }) diff --git a/packages/server/modules/workspaces/tests/helpers/graphql.ts b/packages/server/modules/workspaces/tests/helpers/graphql.ts index 061758a17..76fea3785 100644 --- a/packages/server/modules/workspaces/tests/helpers/graphql.ts +++ b/packages/server/modules/workspaces/tests/helpers/graphql.ts @@ -363,6 +363,27 @@ export const getWorkspaceWithJoinRequestsQuery = gql` ${basicWorkspaceFragment} ` +export const getWorkspaceWithSubscriptionQuery = gql` + query GetWorkspaceWithSubscription($workspaceId: String!) { + workspace(id: $workspaceId) { + ...BasicWorkspace + subscription { + createdAt + updatedAt + currentBillingCycleEnd + billingInterval + seats { + guest + plan + assigned + totalCount + } + } + } + } + ${basicWorkspaceFragment} +` + export const updateWorkspaceProjectRoleMutation = gql` mutation UpdateWorkspaceProjectRole($input: ProjectUpdateRoleInput!) { workspaceMutations { diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts index f93621555..7cedbd143 100644 --- a/packages/server/test/graphql/generated/graphql.ts +++ b/packages/server/test/graphql/generated/graphql.ts @@ -4859,8 +4859,14 @@ export type WorkspaceSubscription = { export type WorkspaceSubscriptionSeats = { __typename?: 'WorkspaceSubscriptionSeats'; + /** Number assigned seats in the current billing cycle */ + assigned: Scalars['Int']['output']; + /** @deprecated Field no longer supported */ guest: Scalars['Int']['output']; + /** @deprecated Field no longer supported */ plan: Scalars['Int']['output']; + /** Total number of seats purchased and available in the current subscription cycle */ + totalCount: Scalars['Int']['output']; }; export type WorkspaceTeamFilter = { @@ -5180,6 +5186,13 @@ export type GetWorkspaceWithJoinRequestsQueryVariables = Exact<{ export type GetWorkspaceWithJoinRequestsQuery = { __typename?: 'Query', workspace: { __typename?: 'Workspace', id: string, name: string, slug: string, updatedAt: string, createdAt: string, role?: string | null, readOnly: boolean, adminWorkspacesJoinRequests?: { __typename?: 'WorkspaceJoinRequestCollection', cursor?: string | null, totalCount: number, items: Array<{ __typename?: 'WorkspaceJoinRequest', status: WorkspaceJoinRequestStatus, createdAt: string, user: { __typename?: 'LimitedUser', id: string, name: string }, workspace: { __typename?: 'Workspace', id: string, name: string } }> } | null } }; +export type GetWorkspaceWithSubscriptionQueryVariables = Exact<{ + workspaceId: Scalars['String']['input']; +}>; + + +export type GetWorkspaceWithSubscriptionQuery = { __typename?: 'Query', workspace: { __typename?: 'Workspace', id: string, name: string, slug: string, updatedAt: string, createdAt: string, role?: string | null, readOnly: boolean, subscription?: { __typename?: 'WorkspaceSubscription', createdAt: string, updatedAt: string, currentBillingCycleEnd: string, billingInterval: BillingInterval, seats: { __typename?: 'WorkspaceSubscriptionSeats', guest: number, plan: number, assigned: number, totalCount: number } } | null } }; + export type UpdateWorkspaceProjectRoleMutationVariables = Exact<{ input: ProjectUpdateRoleInput; }>; @@ -6020,6 +6033,7 @@ export const OnWorkspaceUpdatedDocument = {"kind":"Document","definitions":[{"ki export const DismissWorkspaceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"dismissWorkspace"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceDismissInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"dismiss"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]}}]} as unknown as DocumentNode; 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 GetWorkspaceWithSubscriptionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetWorkspaceWithSubscription"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"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":"subscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"currentBillingCycleEnd"}},{"kind":"Field","name":{"kind":"Name","value":"billingInterval"}},{"kind":"Field","name":{"kind":"Name","value":"seats"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"guest"}},{"kind":"Field","name":{"kind":"Name","value":"plan"}},{"kind":"Field","name":{"kind":"Name","value":"assigned"}},{"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; From ba2152f2d3e1b4f20801b2468582505a2a164985 Mon Sep 17 00:00:00 2001 From: Alessandro Magionami Date: Tue, 25 Mar 2025 10:27:08 +0100 Subject: [PATCH 2/6] chore(workspaces): disable test if FF workspaces is off --- .../intergration/workspace.graph.spec.ts | 101 +++++++++--------- 1 file changed, 52 insertions(+), 49 deletions(-) diff --git a/packages/server/modules/gatekeeper/tests/intergration/workspace.graph.spec.ts b/packages/server/modules/gatekeeper/tests/intergration/workspace.graph.spec.ts index d074d882e..6fc1fdc63 100644 --- a/packages/server/modules/gatekeeper/tests/intergration/workspace.graph.spec.ts +++ b/packages/server/modules/gatekeeper/tests/intergration/workspace.graph.spec.ts @@ -115,57 +115,60 @@ describe('Workspaces Billing', () => { }) } ) - describe('workspace.subscription', () => { - describe('subscription.seats', () => { - it('should return the number of total seats', async () => { - const user = await createTestUser({ - name: createRandomString(), - email: createRandomEmail(), - role: Roles.Server.Admin, - verified: true - }) - const workspace = { - id: createRandomString(), - name: createRandomString(), - slug: cryptoRandomString({ length: 10 }), - ownerId: user.id - } - await createTestWorkspace(workspace, user, { - addPlan: { name: 'pro', status: 'valid' } - }) - await upsertWorkspaceSubscriptionFactory({ db })({ - workspaceSubscription: { - workspaceId: workspace.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: [ - { - priceId: createRandomString(), - quantity: 12, - productId: createRandomString(), - subscriptionItemId: createRandomString() - } - ] - } + ;(FF_BILLING_INTEGRATION_ENABLED ? describe : describe.skip)( + 'workspace.subscription', + () => { + describe('subscription.seats', () => { + it('should return the number of total seats', async () => { + const user = await createTestUser({ + name: createRandomString(), + email: createRandomEmail(), + role: Roles.Server.Admin, + verified: true + }) + const workspace = { + id: createRandomString(), + name: createRandomString(), + slug: cryptoRandomString({ length: 10 }), + ownerId: user.id } - }) - const session = await login(user) + await createTestWorkspace(workspace, user, { + addPlan: { name: 'pro', status: 'valid' } + }) + await upsertWorkspaceSubscriptionFactory({ db })({ + workspaceSubscription: { + workspaceId: workspace.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: [ + { + priceId: createRandomString(), + quantity: 12, + productId: createRandomString(), + subscriptionItemId: createRandomString() + } + ] + } + } + }) + const session = await login(user) - const res = await session.execute(GetWorkspaceWithSubscriptionDocument, { - workspaceId: workspace.id - }) + const res = await session.execute(GetWorkspaceWithSubscriptionDocument, { + workspaceId: workspace.id + }) - expect(res).to.not.haveGraphQLErrors() - const seats = res.data?.workspace.subscription?.seats - expect(seats).to.deep.eq({ guest: 0, plan: 0, assigned: 1, totalCount: 12 }) + expect(res).to.not.haveGraphQLErrors() + const seats = res.data?.workspace.subscription?.seats + expect(seats).to.deep.eq({ guest: 0, plan: 0, assigned: 1, totalCount: 12 }) + }) }) - }) - }) + } + ) }) From ddae24eedf992a3fde26e9e4b8cc91e53fc8c329 Mon Sep 17 00:00:00 2001 From: Alessandro Magionami Date: Tue, 25 Mar 2025 17:40:09 +0100 Subject: [PATCH 3/6] chore(workspaces): add test and make product selection more robust --- .../gatekeeper/graph/resolvers/index.ts | 7 +++- .../gatekeeper/services/subscriptions.ts | 26 +++++++++++++ .../intergration/workspace.graph.spec.ts | 4 +- .../tests/unit/subscriptions.spec.ts | 37 +++++++++++++++++++ 4 files changed, 70 insertions(+), 4 deletions(-) diff --git a/packages/server/modules/gatekeeper/graph/resolvers/index.ts b/packages/server/modules/gatekeeper/graph/resolvers/index.ts index fc2c9575e..e424d97d4 100644 --- a/packages/server/modules/gatekeeper/graph/resolvers/index.ts +++ b/packages/server/modules/gatekeeper/graph/resolvers/index.ts @@ -67,6 +67,7 @@ import { } from '@/modules/gatekeeper/repositories/workspaceSeat' import { assignWorkspaceSeatFactory } from '@/modules/workspaces/services/workspaceSeat' import { getEventBus } from '@/modules/shared/services/eventBus' +import { getTotalSeatsCountByPlanFactory } from '@/modules/gatekeeper/services/subscriptions' const { FF_GATEKEEPER_MODULE_ENABLED, FF_BILLING_INTEGRATION_ENABLED } = getFeatureFlags() @@ -177,14 +178,16 @@ export = FF_GATEKEEPER_MODULE_ENABLED } } // Only editor seats are considered - const totalSeatsCount = parent.subscriptionData.products[0].quantity const assignedSeatsCount = await countSeatsByTypeInWorkspaceFactory({ db })({ workspaceId: parent.workspaceId, type: 'editor' }) return { assigned: assignedSeatsCount, - totalCount: totalSeatsCount, + totalCount: getTotalSeatsCountByPlanFactory({ getWorkspacePlanProductId })({ + workspacePlan, + subscriptionData: parent.subscriptionData + }), // These values have no reference in the new plans guest: 0, plan: 0 diff --git a/packages/server/modules/gatekeeper/services/subscriptions.ts b/packages/server/modules/gatekeeper/services/subscriptions.ts index a069545fd..6ea8989c1 100644 --- a/packages/server/modules/gatekeeper/services/subscriptions.ts +++ b/packages/server/modules/gatekeeper/services/subscriptions.ts @@ -31,6 +31,7 @@ import { cloneDeep, isEqual, sum } from 'lodash' import { mutateSubscriptionDataWithNewValidSeatNumbers } from '@/modules/gatekeeper/services/subscriptions/mutateSubscriptionDataWithNewValidSeatNumbers' import { calculateNewBillingCycleEnd } from '@/modules/gatekeeper/services/subscriptions/calculateNewBillingCycleEnd' import { CountSeatsByTypeInWorkspace } from '@/modules/gatekeeper/domain/operations' +import { WorkspacePlan } from '@/modules/gatekeeperCore/domain/billing' export const handleSubscriptionUpdateFactory = ({ @@ -411,3 +412,28 @@ export const manageSubscriptionDownscaleFactory = log.info({ updatedWorkspaceSubscription }, 'Updated workspace billing cycle end') } } + +export const getTotalSeatsCountByPlanFactory = + ({ + getWorkspacePlanProductId + }: { + getWorkspacePlanProductId: GetWorkspacePlanProductId + }) => + ({ + workspacePlan, + subscriptionData + }: { + workspacePlan: Pick + subscriptionData: Pick + }) => { + if (workspacePlan.name === 'free') { + return 3 // Max editors seats in the free plan + } + const productId = getWorkspacePlanProductId({ + workspacePlan: workspacePlan.name as 'pro' | 'team' + }) + const product = subscriptionData.products.find( + (product) => product.productId === productId + ) + return product?.quantity ?? 0 + } diff --git a/packages/server/modules/gatekeeper/tests/intergration/workspace.graph.spec.ts b/packages/server/modules/gatekeeper/tests/intergration/workspace.graph.spec.ts index 6fc1fdc63..fe6edd795 100644 --- a/packages/server/modules/gatekeeper/tests/intergration/workspace.graph.spec.ts +++ b/packages/server/modules/gatekeeper/tests/intergration/workspace.graph.spec.ts @@ -119,7 +119,7 @@ describe('Workspaces Billing', () => { 'workspace.subscription', () => { describe('subscription.seats', () => { - it('should return the number of total seats', async () => { + it('should return the number of total and assigned seats', async () => { const user = await createTestUser({ name: createRandomString(), email: createRandomEmail(), @@ -166,7 +166,7 @@ describe('Workspaces Billing', () => { expect(res).to.not.haveGraphQLErrors() const seats = res.data?.workspace.subscription?.seats - expect(seats).to.deep.eq({ guest: 0, plan: 0, assigned: 1, totalCount: 12 }) + expect(seats?.assigned).to.eq(1) }) }) } diff --git a/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts b/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts index e3f08d820..903c320cf 100644 --- a/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts +++ b/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts @@ -16,6 +16,7 @@ import { addWorkspaceSubscriptionSeatIfNeededFactoryNew, addWorkspaceSubscriptionSeatIfNeededFactoryOld, downscaleWorkspaceSubscriptionFactory, + getTotalSeatsCountByPlanFactory, handleSubscriptionUpdateFactory, manageSubscriptionDownscaleFactory } from '@/modules/gatekeeper/services/subscriptions' @@ -2178,4 +2179,40 @@ describe('subscriptions @gatekeeper', () => { expect(newProduct!.priceId).to.equal('newPlanPrice') }) }) + describe('getTotalSeatsCountByPlanFactory returns a function that, ', () => { + it('should return the fixed value for the free plan', () => { + const getWorkspacePlanProductId = () => expect.fail() + expect( + getTotalSeatsCountByPlanFactory({ getWorkspacePlanProductId })({ + workspacePlan: { name: 'free' }, + subscriptionData: { products: [] } + }) + ).to.eq(3) + }) + it('should return 0 if subscription data has no product', () => { + const getWorkspacePlanProductId = () => 'any' + expect( + getTotalSeatsCountByPlanFactory({ getWorkspacePlanProductId })({ + workspacePlan: { name: 'pro' }, + subscriptionData: { products: [] } + }) + ).to.eq(0) + }) + it('should return the number of purchased seats in the current billing period for the subscription', () => { + const getWorkspacePlanProductId = () => 'productId' + expect( + getTotalSeatsCountByPlanFactory({ getWorkspacePlanProductId })({ + workspacePlan: { name: 'pro' }, + subscriptionData: { + products: [ + { + productId: 'productId', + quantity: 4 + } as SubscriptionData['products'][number] + ] + } + }) + ).to.eq(4) + }) + }) }) From f433e3016cdc0c07cd7166b01aed3a5a09a85475 Mon Sep 17 00:00:00 2001 From: Alessandro Magionami Date: Wed, 26 Mar 2025 09:36:42 +0100 Subject: [PATCH 4/6] chore(workspaces): fix build --- .../gatekeeper/services/subscriptions.ts | 114 ------------------ .../intergration/workspace.graph.spec.ts | 1 + 2 files changed, 1 insertion(+), 114 deletions(-) diff --git a/packages/server/modules/gatekeeper/services/subscriptions.ts b/packages/server/modules/gatekeeper/services/subscriptions.ts index 8a59f177a..fecd34783 100644 --- a/packages/server/modules/gatekeeper/services/subscriptions.ts +++ b/packages/server/modules/gatekeeper/services/subscriptions.ts @@ -294,120 +294,6 @@ export const addWorkspaceSubscriptionSeatIfNeededFactoryOld = }) } -type DownscaleWorkspaceSubscription = (args: { - workspaceSubscription: WorkspaceSubscription -}) => Promise - -export const downscaleWorkspaceSubscriptionFactory = - ({ - getWorkspacePlan, - countWorkspaceRole, - getWorkspacePlanProductId, - reconcileSubscriptionData - }: { - getWorkspacePlan: GetWorkspacePlan - countWorkspaceRole: CountWorkspaceRoleWithOptionalProjectRole - getWorkspacePlanProductId: GetWorkspacePlanProductId - reconcileSubscriptionData: ReconcileSubscriptionData - }): DownscaleWorkspaceSubscription => - async ({ workspaceSubscription }) => { - const workspaceId = workspaceSubscription.workspaceId - - const workspacePlan = await getWorkspacePlan({ workspaceId }) - if (!workspacePlan) throw new WorkspacePlanNotFoundError() - - switch (workspacePlan.name) { - case 'team': - case 'pro': - // Cause seat types matter, a future issue - throw new NotImplementedError() - case 'starter': - case 'plus': - case 'business': - break - case 'unlimited': - case 'academia': - case 'starterInvoiced': - case 'plusInvoiced': - case 'businessInvoiced': - case 'free': - throw new WorkspacePlanMismatchError() - default: - throwUncoveredError(workspacePlan) - } - - if (workspacePlan.status === 'canceled') return false - - // TODO: Guests will be able to have a paid seat - const [guestCount, memberCount, adminCount] = await Promise.all([ - countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:guest' }), - countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:member' }), - countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:admin' }) - ]) - - const subscriptionData = cloneDeep(workspaceSubscription.subscriptionData) - - mutateSubscriptionDataWithNewValidSeatNumbers({ - seatCount: guestCount, - workspacePlan: 'guest', - getWorkspacePlanProductId, - subscriptionData - }) - mutateSubscriptionDataWithNewValidSeatNumbers({ - seatCount: memberCount + adminCount, - workspacePlan: workspacePlan.name, - getWorkspacePlanProductId, - subscriptionData - }) - - if (!isEqual(subscriptionData, workspaceSubscription.subscriptionData)) { - await reconcileSubscriptionData({ subscriptionData, prorationBehavior: 'none' }) - return true - } - return false - } - -export const manageSubscriptionDownscaleFactory = - ({ - getWorkspaceSubscriptions, - downscaleWorkspaceSubscription, - updateWorkspaceSubscription - }: { - getWorkspaceSubscriptions: GetWorkspaceSubscriptions - downscaleWorkspaceSubscription: DownscaleWorkspaceSubscription - updateWorkspaceSubscription: UpsertWorkspaceSubscription - }) => - async (context: { logger: Logger }) => { - const { logger } = context - const subscriptions = await getWorkspaceSubscriptions() - for (const workspaceSubscription of subscriptions) { - const log = logger.child({ workspaceId: workspaceSubscription.workspaceId }) - try { - const subDownscaled = await downscaleWorkspaceSubscription({ - workspaceSubscription - }) - if (subDownscaled) { - log.info( - 'Downscaled workspace subscription to match the current workspace team' - ) - } else { - log.info('Did not need to downscale the workspace subscription') - } - } catch (err) { - log.error({ err }, 'Failed to downscale workspace subscription') - } - const newBillingCycleEnd = calculateNewBillingCycleEnd({ workspaceSubscription }) - const updatedWorkspaceSubscription = { - ...workspaceSubscription, - currentBillingCycleEnd: newBillingCycleEnd - } - await updateWorkspaceSubscription({ - workspaceSubscription: updatedWorkspaceSubscription - }) - log.info({ updatedWorkspaceSubscription }, 'Updated workspace billing cycle end') - } - } - export const getTotalSeatsCountByPlanFactory = ({ getWorkspacePlanProductId diff --git a/packages/server/modules/gatekeeper/tests/intergration/workspace.graph.spec.ts b/packages/server/modules/gatekeeper/tests/intergration/workspace.graph.spec.ts index fe6edd795..786e16f5c 100644 --- a/packages/server/modules/gatekeeper/tests/intergration/workspace.graph.spec.ts +++ b/packages/server/modules/gatekeeper/tests/intergration/workspace.graph.spec.ts @@ -147,6 +147,7 @@ describe('Workspaces Billing', () => { customerId: cryptoRandomString({ length: 10 }), cancelAt: null, status: 'active', + currentPeriodEnd: new Date(), products: [ { priceId: createRandomString(), From a38bbd40664e499db677ada62333abbb8c0f1351 Mon Sep 17 00:00:00 2001 From: Alessandro Magionami Date: Wed, 26 Mar 2025 10:24:14 +0100 Subject: [PATCH 5/6] feat(workspaces): add viewers count --- .../typedefs/gatekeeper.graphql | 4 + .../modules/core/graph/generated/graphql.ts | 3 + .../graph/generated/graphql.ts | 2 + .../gatekeeper/graph/resolvers/index.ts | 4 + .../modules/gatekeeper/helpers/graphTypes.ts | 5 +- .../intergration/workspace.graph.spec.ts | 83 ++++++++++++++++++- .../workspaces/tests/helpers/graphql.ts | 1 + .../server/test/graphql/generated/graphql.ts | 6 +- 8 files changed, 103 insertions(+), 5 deletions(-) diff --git a/packages/server/assets/gatekeeperCore/typedefs/gatekeeper.graphql b/packages/server/assets/gatekeeperCore/typedefs/gatekeeper.graphql index f2f535e2d..a0cc1ec5f 100644 --- a/packages/server/assets/gatekeeperCore/typedefs/gatekeeper.graphql +++ b/packages/server/assets/gatekeeperCore/typedefs/gatekeeper.graphql @@ -102,6 +102,10 @@ type WorkspaceSubscriptionSeats { Number assigned seats in the current billing cycle """ assigned: Int! + """ + Number of viewer seats currently assigned in the workspace + """ + viewersCount: Int! } type WorkspaceSubscription { diff --git a/packages/server/modules/core/graph/generated/graphql.ts b/packages/server/modules/core/graph/generated/graphql.ts index d58684e83..f1fc11a9e 100644 --- a/packages/server/modules/core/graph/generated/graphql.ts +++ b/packages/server/modules/core/graph/generated/graphql.ts @@ -4886,6 +4886,8 @@ export type WorkspaceSubscriptionSeats = { plan: Scalars['Int']['output']; /** Total number of seats purchased and available in the current subscription cycle */ totalCount: Scalars['Int']['output']; + /** Number of viewer seats currently assigned in the workspace */ + viewersCount: Scalars['Int']['output']; }; export type WorkspaceTeamFilter = { @@ -7251,6 +7253,7 @@ export type WorkspaceSubscriptionSeatsResolvers; plan?: Resolver; totalCount?: Resolver; + viewersCount?: 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 70316cf24..16c042570 100644 --- a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts +++ b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts @@ -4866,6 +4866,8 @@ export type WorkspaceSubscriptionSeats = { plan: Scalars['Int']['output']; /** Total number of seats purchased and available in the current subscription cycle */ totalCount: Scalars['Int']['output']; + /** Number of viewer seats currently assigned in the workspace */ + viewersCount: Scalars['Int']['output']; }; export type WorkspaceTeamFilter = { diff --git a/packages/server/modules/gatekeeper/graph/resolvers/index.ts b/packages/server/modules/gatekeeper/graph/resolvers/index.ts index e424d97d4..b906d5e9d 100644 --- a/packages/server/modules/gatekeeper/graph/resolvers/index.ts +++ b/packages/server/modules/gatekeeper/graph/resolvers/index.ts @@ -188,6 +188,10 @@ export = FF_GATEKEEPER_MODULE_ENABLED workspacePlan, subscriptionData: parent.subscriptionData }), + viewersCount: await countSeatsByTypeInWorkspaceFactory({ db })({ + workspaceId: parent.workspaceId, + type: 'viewer' + }), // These values have no reference in the new plans guest: 0, plan: 0 diff --git a/packages/server/modules/gatekeeper/helpers/graphTypes.ts b/packages/server/modules/gatekeeper/helpers/graphTypes.ts index c8197a05f..96e3e23c6 100644 --- a/packages/server/modules/gatekeeper/helpers/graphTypes.ts +++ b/packages/server/modules/gatekeeper/helpers/graphTypes.ts @@ -1,5 +1,8 @@ import { MutationsObjectGraphQLReturn } from '@/modules/core/helpers/graphTypes' import { WorkspaceSubscription } from '@/modules/gatekeeper/domain/billing' +import { Workspace } from '@/modules/workspacesCore/domain/types' export type WorkspaceBillingMutationsGraphQLReturn = MutationsObjectGraphQLReturn -export type WorkspaceSubscriptionGraphQLReturn = WorkspaceSubscription +export type WorkspaceSubscriptionGraphQLReturn = WorkspaceSubscription & { + parent: Workspace +} diff --git a/packages/server/modules/gatekeeper/tests/intergration/workspace.graph.spec.ts b/packages/server/modules/gatekeeper/tests/intergration/workspace.graph.spec.ts index 786e16f5c..d874c76aa 100644 --- a/packages/server/modules/gatekeeper/tests/intergration/workspace.graph.spec.ts +++ b/packages/server/modules/gatekeeper/tests/intergration/workspace.graph.spec.ts @@ -4,9 +4,13 @@ import { createRandomEmail, createRandomString } from '@/modules/core/helpers/testHelpers' +import { WorkspaceSeatType } from '@/modules/gatekeeper/domain/billing' import { upsertWorkspaceSubscriptionFactory } from '@/modules/gatekeeper/repositories/billing' import { getFeatureFlags } from '@/modules/shared/helpers/envHelper' -import { createTestWorkspace } from '@/modules/workspaces/tests/helpers/creation' +import { + assignToWorkspace, + createTestWorkspace +} from '@/modules/workspaces/tests/helpers/creation' import { BasicTestUser, createAuthTokenForUser, @@ -119,7 +123,7 @@ describe('Workspaces Billing', () => { 'workspace.subscription', () => { describe('subscription.seats', () => { - it('should return the number of total and assigned seats', async () => { + it('should return the number of assigned seats', async () => { const user = await createTestUser({ name: createRandomString(), email: createRandomEmail(), @@ -169,6 +173,81 @@ describe('Workspaces Billing', () => { const seats = res.data?.workspace.subscription?.seats expect(seats?.assigned).to.eq(1) }) + it('should return the number of viewers', async () => { + const user = await createTestUser({ + name: createRandomString(), + email: createRandomEmail(), + role: Roles.Server.Admin, + verified: true + }) + const workspace = { + id: createRandomString(), + name: createRandomString(), + slug: cryptoRandomString({ length: 10 }), + ownerId: user.id + } + await createTestWorkspace(workspace, user, { + addPlan: { name: 'pro', status: 'valid' } + }) + await upsertWorkspaceSubscriptionFactory({ db })({ + workspaceSubscription: { + workspaceId: workspace.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', + currentPeriodEnd: new Date(), + products: [ + { + priceId: createRandomString(), + quantity: 12, + productId: createRandomString(), + subscriptionItemId: createRandomString() + } + ] + } + } + }) + const viewer1 = await createTestUser({ + name: createRandomString(), + email: createRandomEmail(), + role: Roles.Server.User, + verified: true + }) + await assignToWorkspace( + workspace, + viewer1, + Roles.Workspace.Member, + WorkspaceSeatType.Viewer + ) + const viewer2 = await createTestUser({ + name: createRandomString(), + email: createRandomEmail(), + role: Roles.Server.User, + verified: true + }) + await assignToWorkspace( + workspace, + viewer2, + Roles.Workspace.Member, + WorkspaceSeatType.Viewer + ) + + const session = await login(user) + + const res = await session.execute(GetWorkspaceWithSubscriptionDocument, { + workspaceId: workspace.id + }) + + expect(res).to.not.haveGraphQLErrors() + const seats = res.data?.workspace.subscription?.seats + expect(seats?.viewersCount).to.eq(2) + }) }) } ) diff --git a/packages/server/modules/workspaces/tests/helpers/graphql.ts b/packages/server/modules/workspaces/tests/helpers/graphql.ts index 76fea3785..67ec90ef5 100644 --- a/packages/server/modules/workspaces/tests/helpers/graphql.ts +++ b/packages/server/modules/workspaces/tests/helpers/graphql.ts @@ -377,6 +377,7 @@ export const getWorkspaceWithSubscriptionQuery = gql` plan assigned totalCount + viewersCount } } } diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts index 7cedbd143..008b43a08 100644 --- a/packages/server/test/graphql/generated/graphql.ts +++ b/packages/server/test/graphql/generated/graphql.ts @@ -4867,6 +4867,8 @@ export type WorkspaceSubscriptionSeats = { plan: Scalars['Int']['output']; /** Total number of seats purchased and available in the current subscription cycle */ totalCount: Scalars['Int']['output']; + /** Number of viewer seats currently assigned in the workspace */ + viewersCount: Scalars['Int']['output']; }; export type WorkspaceTeamFilter = { @@ -5191,7 +5193,7 @@ export type GetWorkspaceWithSubscriptionQueryVariables = Exact<{ }>; -export type GetWorkspaceWithSubscriptionQuery = { __typename?: 'Query', workspace: { __typename?: 'Workspace', id: string, name: string, slug: string, updatedAt: string, createdAt: string, role?: string | null, readOnly: boolean, subscription?: { __typename?: 'WorkspaceSubscription', createdAt: string, updatedAt: string, currentBillingCycleEnd: string, billingInterval: BillingInterval, seats: { __typename?: 'WorkspaceSubscriptionSeats', guest: number, plan: number, assigned: number, totalCount: number } } | null } }; +export type GetWorkspaceWithSubscriptionQuery = { __typename?: 'Query', workspace: { __typename?: 'Workspace', id: string, name: string, slug: string, updatedAt: string, createdAt: string, role?: string | null, readOnly: boolean, subscription?: { __typename?: 'WorkspaceSubscription', createdAt: string, updatedAt: string, currentBillingCycleEnd: string, billingInterval: BillingInterval, seats: { __typename?: 'WorkspaceSubscriptionSeats', guest: number, plan: number, assigned: number, totalCount: number, viewersCount: number } } | null } }; export type UpdateWorkspaceProjectRoleMutationVariables = Exact<{ input: ProjectUpdateRoleInput; @@ -6033,7 +6035,7 @@ export const OnWorkspaceUpdatedDocument = {"kind":"Document","definitions":[{"ki export const DismissWorkspaceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"dismissWorkspace"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceDismissInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"dismiss"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]}}]} as unknown as DocumentNode; 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 GetWorkspaceWithSubscriptionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetWorkspaceWithSubscription"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"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":"subscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"currentBillingCycleEnd"}},{"kind":"Field","name":{"kind":"Name","value":"billingInterval"}},{"kind":"Field","name":{"kind":"Name","value":"seats"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"guest"}},{"kind":"Field","name":{"kind":"Name","value":"plan"}},{"kind":"Field","name":{"kind":"Name","value":"assigned"}},{"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 GetWorkspaceWithSubscriptionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetWorkspaceWithSubscription"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"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":"subscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"currentBillingCycleEnd"}},{"kind":"Field","name":{"kind":"Name","value":"billingInterval"}},{"kind":"Field","name":{"kind":"Name","value":"seats"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"guest"}},{"kind":"Field","name":{"kind":"Name","value":"plan"}},{"kind":"Field","name":{"kind":"Name","value":"assigned"}},{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"viewersCount"}}]}}]}}]}}]}},{"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; From 5348c32cca17534372dbab60c6368c2a9feabe34 Mon Sep 17 00:00:00 2001 From: Alexandru Popovici Date: Wed, 26 Mar 2025 12:07:46 +0200 Subject: [PATCH 6/6] feat(viewer-lib): Hidden objects are now ignored by default when oribiting around cursor (#4265) --- packages/viewer-sandbox/src/main.ts | 4 +++- .../extensions/controls/SmoothOrbitControls.ts | 13 +++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/viewer-sandbox/src/main.ts b/packages/viewer-sandbox/src/main.ts index 2f775cc4b..4728913e6 100644 --- a/packages/viewer-sandbox/src/main.ts +++ b/packages/viewer-sandbox/src/main.ts @@ -492,7 +492,9 @@ const getStream = () => { // REGIONS // https://app.speckle.systems/projects/16ce7b208c/models/1c14e37363@0614bb2957 - // SUPER slow tree build time + // 'https://app.speckle.systems/projects/7591c56179/models/82b94108a3' + + // SUPER slow tree build time (LARGE N-GONS TRIANGULATION) // 'https://app.speckle.systems/projects/0edb6ef628/models/ff3d8480bc@cd83d90a2c' ) } diff --git a/packages/viewer/src/modules/extensions/controls/SmoothOrbitControls.ts b/packages/viewer/src/modules/extensions/controls/SmoothOrbitControls.ts index 0504b3940..c15667629 100644 --- a/packages/viewer/src/modules/extensions/controls/SmoothOrbitControls.ts +++ b/packages/viewer/src/modules/extensions/controls/SmoothOrbitControls.ts @@ -38,6 +38,7 @@ import { computeOrthographicSize } from '../CameraController.js' import { ObjectLayers } from '../../../IViewer.js' import SpeckleBasicMaterial from '../../materials/SpeckleBasicMaterial.js' import SpeckleRenderer from '../../SpeckleRenderer.js' +import { ExtendedMeshIntersection } from '../../objects/SpeckleRaycaster.js' /** * @param {Number} value @@ -1076,6 +1077,12 @@ export class SmoothOrbitControls extends SpeckleControls { this.orbitSphere.visible = false } + /** By default hidden objects are ignored when picking for orbit around cursor */ + protected filterOrbitToCursorHits(hit: ExtendedMeshIntersection) { + const material = this.renderer.getMaterial(hit.batchObject.renderView) + return material?.visible ?? false + } + protected onPointerDown = (event: PointerEvent) => { if (this._options.orbitAroundCursor) { /** Hope this is not slow */ @@ -1083,7 +1090,7 @@ export class SmoothOrbitControls extends SpeckleControls { const x = ((event.clientX - rect.left) / rect.width) * 2 - 1 const y = ((event.clientY - rect.top) / rect.height) * -2 + 1 - const res = this.renderer.intersections.intersect( + let res = this.renderer.intersections.intersect( this.renderer.scene, this._targetCamera as PerspectiveCamera, new Vector2(x, y), @@ -1091,7 +1098,9 @@ export class SmoothOrbitControls extends SpeckleControls { true, this.renderer.clippingVolume ) - if (res && res.length) { + res = res?.filter(this.filterOrbitToCursorHits.bind(this)) ?? [] + + if (res.length) { this.pivotPoint.copy(res[0].point) this.usePivotal = true this.orbitSphere.visible = this._options.showOrbitPoint