diff --git a/.zed/debug.json b/.zed/debug.json index bfd9ee505..b538de8d9 100644 --- a/.zed/debug.json +++ b/.zed/debug.json @@ -34,7 +34,7 @@ "console": "integratedTerminal", "program": "test", "runtimeExecutable": "yarn", - "args": ["-t", "forbids creation for users eligible"], + "args": [], "type": "pwa-node", "cwd": "$ZED_WORKTREE_ROOT/packages/shared", "skipFiles": ["/**"], diff --git a/packages/frontend-2/lib/common/generated/gql/graphql.ts b/packages/frontend-2/lib/common/generated/gql/graphql.ts index feed52a0c..82dc2d52e 100644 --- a/packages/frontend-2/lib/common/generated/gql/graphql.ts +++ b/packages/frontend-2/lib/common/generated/gql/graphql.ts @@ -135,6 +135,11 @@ export type AddDomainToWorkspaceInput = { workspaceId: Scalars['ID']['input']; }; +export type AdminAccessToWorkspaceFeatureInput = { + featureFlagName: WorkspaceFeatureFlagName; + workspaceId: Scalars['ID']['input']; +}; + export type AdminInviteList = { __typename?: 'AdminInviteList'; cursor?: Maybe; @@ -144,10 +149,22 @@ export type AdminInviteList = { export type AdminMutations = { __typename?: 'AdminMutations'; + giveAccessToWorkspaceFeature: Scalars['Boolean']['output']; + removeAccessToWorkspaceFeature: Scalars['Boolean']['output']; updateWorkspacePlan: Scalars['Boolean']['output']; }; +export type AdminMutationsGiveAccessToWorkspaceFeatureArgs = { + input: AdminAccessToWorkspaceFeatureInput; +}; + + +export type AdminMutationsRemoveAccessToWorkspaceFeatureArgs = { + input: AdminAccessToWorkspaceFeatureInput; +}; + + export type AdminMutationsUpdateWorkspacePlanArgs = { input: AdminUpdateWorkspacePlanInput; }; @@ -5273,8 +5290,15 @@ export type WorkspaceEmbedOptions = { hideSpeckleBranding: Scalars['Boolean']['output']; }; +export const WorkspaceFeatureFlagName = { + AccIntegration: 'accIntegration', + Dashboards: 'dashboards' +} as const; + +export type WorkspaceFeatureFlagName = typeof WorkspaceFeatureFlagName[keyof typeof WorkspaceFeatureFlagName]; export const WorkspaceFeatureName = { AccIntegration: 'accIntegration', + Dashboards: 'dashboards', DomainBasedSecurityPolicies: 'domainBasedSecurityPolicies', ExclusiveMembership: 'exclusiveMembership', HideSpeckleBranding: 'hideSpeckleBranding', @@ -8872,6 +8896,8 @@ export type AdminInviteListFieldArgs = { totalCount: {}, } export type AdminMutationsFieldArgs = { + giveAccessToWorkspaceFeature: AdminMutationsGiveAccessToWorkspaceFeatureArgs, + removeAccessToWorkspaceFeature: AdminMutationsRemoveAccessToWorkspaceFeatureArgs, updateWorkspacePlan: AdminMutationsUpdateWorkspacePlanArgs, } export type AdminQueriesFieldArgs = { diff --git a/packages/server/assets/gatekeeperCore/typedefs/gatekeeper.graphql b/packages/server/assets/gatekeeperCore/typedefs/gatekeeper.graphql index ec9d4655b..e4f50c909 100644 --- a/packages/server/assets/gatekeeperCore/typedefs/gatekeeper.graphql +++ b/packages/server/assets/gatekeeperCore/typedefs/gatekeeper.graphql @@ -154,7 +154,8 @@ extend type Project { } enum WorkspaceFeatureName { - accIntegration + accIntegration # will be moved to a per workspace feature + dashboards domainBasedSecurityPolicies oidcSso hideSpeckleBranding diff --git a/packages/server/assets/workspacesCore/typedefs/workspaces.graphql b/packages/server/assets/workspacesCore/typedefs/workspaces.graphql index d729ed037..d3625dc40 100644 --- a/packages/server/assets/workspacesCore/typedefs/workspaces.graphql +++ b/packages/server/assets/workspacesCore/typedefs/workspaces.graphql @@ -684,8 +684,20 @@ input AdminUpdateWorkspacePlanInput { status: WorkspacePlanStatuses! } +enum WorkspaceFeatureFlagName { + dashboards + accIntegration +} + +input AdminAccessToWorkspaceFeatureInput { + workspaceId: ID! + featureFlagName: WorkspaceFeatureFlagName! +} + type AdminMutations { updateWorkspacePlan(input: AdminUpdateWorkspacePlanInput!): Boolean! + giveAccessToWorkspaceFeature(input: AdminAccessToWorkspaceFeatureInput!): Boolean! + removeAccessToWorkspaceFeature(input: AdminAccessToWorkspaceFeatureInput!): Boolean! } extend type ActiveUserMutations { diff --git a/packages/server/modules/core/graph/generated/graphql.ts b/packages/server/modules/core/graph/generated/graphql.ts index fdfd8700a..dce42cd62 100644 --- a/packages/server/modules/core/graph/generated/graphql.ts +++ b/packages/server/modules/core/graph/generated/graphql.ts @@ -156,6 +156,11 @@ export type AddDomainToWorkspaceInput = { workspaceId: Scalars['ID']['input']; }; +export type AdminAccessToWorkspaceFeatureInput = { + featureFlagName: WorkspaceFeatureFlagName; + workspaceId: Scalars['ID']['input']; +}; + export type AdminInviteList = { __typename?: 'AdminInviteList'; cursor?: Maybe; @@ -165,10 +170,22 @@ export type AdminInviteList = { export type AdminMutations = { __typename?: 'AdminMutations'; + giveAccessToWorkspaceFeature: Scalars['Boolean']['output']; + removeAccessToWorkspaceFeature: Scalars['Boolean']['output']; updateWorkspacePlan: Scalars['Boolean']['output']; }; +export type AdminMutationsGiveAccessToWorkspaceFeatureArgs = { + input: AdminAccessToWorkspaceFeatureInput; +}; + + +export type AdminMutationsRemoveAccessToWorkspaceFeatureArgs = { + input: AdminAccessToWorkspaceFeatureInput; +}; + + export type AdminMutationsUpdateWorkspacePlanArgs = { input: AdminUpdateWorkspacePlanInput; }; @@ -5299,8 +5316,15 @@ export type WorkspaceEmbedOptions = { hideSpeckleBranding: Scalars['Boolean']['output']; }; +export const WorkspaceFeatureFlagName = { + AccIntegration: 'accIntegration', + Dashboards: 'dashboards' +} as const; + +export type WorkspaceFeatureFlagName = typeof WorkspaceFeatureFlagName[keyof typeof WorkspaceFeatureFlagName]; export const WorkspaceFeatureName = { AccIntegration: 'accIntegration', + Dashboards: 'dashboards', DomainBasedSecurityPolicies: 'domainBasedSecurityPolicies', ExclusiveMembership: 'exclusiveMembership', HideSpeckleBranding: 'hideSpeckleBranding', @@ -5896,6 +5920,7 @@ export type ResolversTypes = { Activity: ResolverTypeWrapper; ActivityCollection: ResolverTypeWrapper; AddDomainToWorkspaceInput: AddDomainToWorkspaceInput; + AdminAccessToWorkspaceFeatureInput: AdminAccessToWorkspaceFeatureInput; AdminInviteList: ResolverTypeWrapper & { items: Array }>; AdminMutations: ResolverTypeWrapper; AdminQueries: ResolverTypeWrapper; @@ -6223,6 +6248,7 @@ export type ResolversTypes = { WorkspaceDomain: ResolverTypeWrapper; WorkspaceDomainDeleteInput: WorkspaceDomainDeleteInput; WorkspaceEmbedOptions: ResolverTypeWrapper; + WorkspaceFeatureFlagName: WorkspaceFeatureFlagName; WorkspaceFeatureName: WorkspaceFeatureName; WorkspaceInviteCreateInput: WorkspaceInviteCreateInput; WorkspaceInviteLookupOptions: WorkspaceInviteLookupOptions; @@ -6280,6 +6306,7 @@ export type ResolversParentTypes = { Activity: Activity; ActivityCollection: ActivityCollectionGraphQLReturn; AddDomainToWorkspaceInput: AddDomainToWorkspaceInput; + AdminAccessToWorkspaceFeatureInput: AdminAccessToWorkspaceFeatureInput; AdminInviteList: Omit & { items: Array }; AdminMutations: MutationsObjectGraphQLReturn; AdminQueries: GraphQLEmptyReturn; @@ -6726,6 +6753,8 @@ export type AdminInviteListResolvers = { + giveAccessToWorkspaceFeature?: Resolver>; + removeAccessToWorkspaceFeature?: Resolver>; updateWorkspacePlan?: Resolver>; __isTypeOf?: IsTypeOfResolverFn; }; diff --git a/packages/server/modules/gatekeeper/domain/operations.ts b/packages/server/modules/gatekeeper/domain/operations.ts index a5435b9cd..dfb5ef026 100644 --- a/packages/server/modules/gatekeeper/domain/operations.ts +++ b/packages/server/modules/gatekeeper/domain/operations.ts @@ -6,7 +6,7 @@ import type { import type { Optional, WorkspacePlan, - WorkspacePlanFeatures, + WorkspaceFeatures, WorkspacePlans, WorkspacePlanStatuses, WorkspaceRoles @@ -14,7 +14,7 @@ import type { export type CanWorkspaceAccessFeature = (args: { workspaceId: string - workspaceFeature: WorkspacePlanFeatures + workspaceFeature: WorkspaceFeatures }) => Promise export type WorkspaceFeatureAccessFunction = (args: { diff --git a/packages/server/modules/gatekeeper/graph/resolvers/index.ts b/packages/server/modules/gatekeeper/graph/resolvers/index.ts index 38ad1963f..61f4238ba 100644 --- a/packages/server/modules/gatekeeper/graph/resolvers/index.ts +++ b/packages/server/modules/gatekeeper/graph/resolvers/index.ts @@ -4,6 +4,7 @@ import { authorizeResolver } from '@/modules/shared' import { Roles, throwUncoveredError, + WorkspaceFeatureFlags, WorkspacePlanFeatures, WorkspacePlans } from '@speckle/shared' @@ -124,11 +125,28 @@ export default FF_GATEKEEPER_MODULE_ENABLED }) }, hasAccessToFeature: async (parent, args) => { + const workspaceFeature = (() => { + switch (args.featureName) { + case 'dashboards': + return WorkspaceFeatureFlags.dashboards + case 'accIntegration': + // TODO: move this to be a feature flag, once the feature flags have rolled out. + case WorkspacePlanFeatures.DomainSecurity: + case WorkspacePlanFeatures.ExclusiveMembership: + case WorkspacePlanFeatures.HideSpeckleBranding: + case WorkspacePlanFeatures.SSO: + case WorkspacePlanFeatures.CustomDataRegion: + case WorkspacePlanFeatures.SavedViews: + return args.featureName + default: + throwUncoveredError(args.featureName) + } + })() const hasAccess = await canWorkspaceAccessFeatureFactory({ getWorkspacePlan: getWorkspacePlanFactory({ db }) })({ workspaceId: parent.id, - workspaceFeature: args.featureName + workspaceFeature }) return hasAccess }, diff --git a/packages/server/modules/gatekeeper/repositories/billing.ts b/packages/server/modules/gatekeeper/repositories/billing.ts index 2cef6f5f7..9cceacd2c 100644 --- a/packages/server/modules/gatekeeper/repositories/billing.ts +++ b/packages/server/modules/gatekeeper/repositories/billing.ts @@ -35,7 +35,8 @@ const WorkspacePlans = buildTableHelper('workspace_plans', [ 'name', 'status', 'createdAt', - 'updatedAt' + 'updatedAt', + 'featureFlags' ]) const WorkspaceSubscriptions = buildTableHelper('workspace_subscriptions', [ 'workspaceId', @@ -107,7 +108,7 @@ export const upsertWorkspacePlanFactory = .workspacePlans(db) .insert(workspacePlan) .onConflict('workspaceId') - .merge(['name', 'status', 'updatedAt']) + .merge(['name', 'status', 'updatedAt', 'featureFlags']) } // this is a typed rebrand of the generic workspace plan upsert diff --git a/packages/server/modules/gatekeeper/services/checkout.ts b/packages/server/modules/gatekeeper/services/checkout.ts index 9ba96950a..a1ddf3ab4 100644 --- a/packages/server/modules/gatekeeper/services/checkout.ts +++ b/packages/server/modules/gatekeeper/services/checkout.ts @@ -79,7 +79,8 @@ export const completeCheckoutSessionFactory = updatedAt: new Date(), workspaceId: checkoutSession.workspaceId, name: checkoutSession.workspacePlan, - status: 'valid' + status: 'valid', + featureFlags: previousWorkspacePlan.featureFlags } as const await upsertPaidWorkspacePlan({ workspacePlan diff --git a/packages/server/modules/gatekeeper/services/featureAuthorization.ts b/packages/server/modules/gatekeeper/services/featureAuthorization.ts index b4e408b86..5b8201f3d 100644 --- a/packages/server/modules/gatekeeper/services/featureAuthorization.ts +++ b/packages/server/modules/gatekeeper/services/featureAuthorization.ts @@ -4,7 +4,12 @@ import type { WorkspaceFeatureAccessFunction } from '@/modules/gatekeeper/domain/operations' import { getFeatureFlags } from '@/modules/shared/helpers/envHelper' -import { throwUncoveredError, workspacePlanHasAccessToFeature } from '@speckle/shared' +import { + throwUncoveredError, + workspacePlanHasAccessToFeature, + isPlanFeature, + isWorkspaceFeatureFlagOn +} from '@speckle/shared' export const canWorkspaceAccessFeatureFactory = ({ @@ -25,12 +30,17 @@ export const canWorkspaceAccessFeatureFactory = default: throwUncoveredError(workspacePlan) } - - return workspacePlanHasAccessToFeature({ - plan: workspacePlan.name, - feature: workspaceFeature, - featureFlags: getFeatureFlags() + if (isPlanFeature(workspaceFeature)) + return workspacePlanHasAccessToFeature({ + plan: workspacePlan.name, + feature: workspaceFeature, + featureFlags: getFeatureFlags() + }) + const isFlagOn = isWorkspaceFeatureFlagOn({ + workspaceFeatureFlags: workspacePlan.featureFlags, + feature: workspaceFeature }) + return isFlagOn } export const canWorkspaceUseOidcSsoFactory = diff --git a/packages/server/modules/gatekeeper/services/workspacePlans.ts b/packages/server/modules/gatekeeper/services/workspacePlans.ts index d29eca33a..e64ccd1fc 100644 --- a/packages/server/modules/gatekeeper/services/workspacePlans.ts +++ b/packages/server/modules/gatekeeper/services/workspacePlans.ts @@ -59,7 +59,14 @@ export const updateWorkspacePlanFactory = case 'cancelationScheduled': case 'canceled': case 'paymentFailed': - workspacePlan = { workspaceId, status, name, createdAt, updatedAt } + workspacePlan = { + workspaceId, + status, + name, + createdAt, + updatedAt, + featureFlags: previousWorkspacePlan.featureFlags + } await upsertWorkspacePlan({ workspacePlan }) break default: @@ -77,7 +84,14 @@ export const updateWorkspacePlanFactory = case 'valid': if (workspaceSubscription) throw new InvalidWorkspacePlanStatus() - workspacePlan = { workspaceId, status, name, createdAt, updatedAt } + workspacePlan = { + workspaceId, + status, + name, + createdAt, + updatedAt, + featureFlags: previousWorkspacePlan.featureFlags + } await upsertWorkspacePlan({ workspacePlan }) break case 'cancelationScheduled': diff --git a/packages/server/modules/gatekeeper/tests/helpers/workspacePlan.ts b/packages/server/modules/gatekeeper/tests/helpers/workspacePlan.ts index 6ea2afee9..5179fad63 100644 --- a/packages/server/modules/gatekeeper/tests/helpers/workspacePlan.ts +++ b/packages/server/modules/gatekeeper/tests/helpers/workspacePlan.ts @@ -1,4 +1,4 @@ -import type { WorkspacePlan } from '@speckle/shared' +import { WorkspaceFeatureFlags, type WorkspacePlan } from '@speckle/shared' import cryptoRandomString from 'crypto-random-string' import { assign } from 'lodash-es' import type { @@ -16,7 +16,8 @@ export const buildTestWorkspacePlan = ( createdAt: new Date(), updatedAt: new Date(), name: 'free', - status: 'valid' + status: 'valid', + featureFlags: WorkspaceFeatureFlags.none }, overrides ) diff --git a/packages/server/modules/gatekeeper/tests/integration/billingRepositories.spec.ts b/packages/server/modules/gatekeeper/tests/integration/billingRepositories.spec.ts index e440b92bf..47b4094f7 100644 --- a/packages/server/modules/gatekeeper/tests/integration/billingRepositories.spec.ts +++ b/packages/server/modules/gatekeeper/tests/integration/billingRepositories.spec.ts @@ -21,7 +21,7 @@ import { import { upsertWorkspaceFactory } from '@/modules/workspaces/repositories/workspaces' import { truncateTables } from '@/test/hooks' import { createAndStoreTestWorkspaceFactory } from '@/test/speckle-helpers/workspaces' -import { PaidWorkspacePlans } from '@speckle/shared' +import { PaidWorkspacePlans, WorkspaceFeatureFlags } from '@speckle/shared' import { expect } from 'chai' import cryptoRandomString from 'crypto-random-string' @@ -61,7 +61,8 @@ describe('billing repositories @gatekeeper', () => { status: 'paymentFailed', workspaceId, createdAt: new Date(), - updatedAt: new Date() + updatedAt: new Date(), + featureFlags: WorkspaceFeatureFlags.none } as const await upsertPaidWorkspacePlan({ workspacePlan @@ -78,6 +79,7 @@ describe('billing repositories @gatekeeper', () => { status: 'paymentFailed', createdAt: new Date(), updatedAt: new Date(), + featureFlags: WorkspaceFeatureFlags.none, workspaceId } as const await upsertPaidWorkspacePlan({ @@ -105,6 +107,7 @@ describe('billing repositories @gatekeeper', () => { status: 'paymentFailed', createdAt: createdAt1, updatedAt: createdAt1, + featureFlags: WorkspaceFeatureFlags.none, workspaceId: workspace1.id } as const await upsertPaidWorkspacePlan({ @@ -118,6 +121,7 @@ describe('billing repositories @gatekeeper', () => { status: 'paymentFailed', createdAt: createdAt2, updatedAt: createdAt2, + featureFlags: WorkspaceFeatureFlags.none, workspaceId: workspace2.id } as const await upsertPaidWorkspacePlan({ @@ -144,6 +148,7 @@ describe('billing repositories @gatekeeper', () => { status: 'paymentFailed', createdAt: createdAt1, updatedAt: createdAt1, + featureFlags: WorkspaceFeatureFlags.none, workspaceId: workspace1.id } as const await upsertPaidWorkspacePlan({ @@ -157,6 +162,7 @@ describe('billing repositories @gatekeeper', () => { status: 'paymentFailed', createdAt: createdAt2, updatedAt: createdAt2, + featureFlags: WorkspaceFeatureFlags.none, workspaceId: workspace2.id } as const await upsertPaidWorkspacePlan({ @@ -180,6 +186,7 @@ describe('billing repositories @gatekeeper', () => { status: 'paymentFailed', createdAt: createdAt1, updatedAt: createdAt1, + featureFlags: WorkspaceFeatureFlags.none, workspaceId: workspace1.id } as const await upsertPaidWorkspacePlan({ @@ -193,6 +200,7 @@ describe('billing repositories @gatekeeper', () => { status: 'valid', createdAt: createdAt2, updatedAt: createdAt2, + featureFlags: WorkspaceFeatureFlags.none, workspaceId: workspace2.id } as const await upsertPaidWorkspacePlan({ @@ -216,6 +224,7 @@ describe('billing repositories @gatekeeper', () => { status: 'valid', createdAt: createdAt1, updatedAt: createdAt1, + featureFlags: WorkspaceFeatureFlags.none, workspaceId: workspace1.id } as const await upsertPaidWorkspacePlan({ @@ -229,6 +238,7 @@ describe('billing repositories @gatekeeper', () => { status: 'valid', createdAt: createdAt2, updatedAt: createdAt2, + featureFlags: WorkspaceFeatureFlags.none, workspaceId: workspace2.id } as const await upsertPaidWorkspacePlan({ @@ -462,7 +472,8 @@ describe('billing repositories @gatekeeper', () => { name: PaidWorkspacePlans.Team, status: 'valid', createdAt: new Date(), - updatedAt: new Date() + updatedAt: new Date(), + featureFlags: WorkspaceFeatureFlags.none } }) await upsertWorkspaceSubscription({ diff --git a/packages/server/modules/gatekeeper/tests/intergration/repositories/workspacePlan.spec.ts b/packages/server/modules/gatekeeper/tests/integration/repositories/workspacePlan.spec.ts similarity index 79% rename from packages/server/modules/gatekeeper/tests/intergration/repositories/workspacePlan.spec.ts rename to packages/server/modules/gatekeeper/tests/integration/repositories/workspacePlan.spec.ts index bc648cba7..f68f305b9 100644 --- a/packages/server/modules/gatekeeper/tests/intergration/repositories/workspacePlan.spec.ts +++ b/packages/server/modules/gatekeeper/tests/integration/repositories/workspacePlan.spec.ts @@ -14,6 +14,7 @@ import { createTestUser } from '@/test/authHelper' import type { WorkspacePlan } from '@speckle/shared' import { PaidWorkspacePlans, PaidWorkspacePlanStatuses } from '@speckle/shared' import { expect } from 'chai' +import { buildTestWorkspacePlan } from '@/modules/gatekeeper/tests/helpers/workspacePlan' const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags() @@ -25,7 +26,6 @@ describe('Module @gatekeeper', () => { 'Repositories WorkspacePlan', () => { let user: BasicTestUser - let now: Date let in5days: Date let workspace1: BasicTestWorkspace let workspace2: BasicTestWorkspace @@ -34,7 +34,6 @@ describe('Module @gatekeeper', () => { let plan2: WorkspacePlan before(async () => { - now = new Date() user = await createTestUser() workspace1 = buildBasicTestWorkspace({ ownerId: user.id }) workspace2 = buildBasicTestWorkspace({ ownerId: user.id }) @@ -43,21 +42,8 @@ describe('Module @gatekeeper', () => { await createTestWorkspace(workspace2, user) await createTestWorkspace(workspaceWithoutPlan, user) - plan1 = { - workspaceId: workspace1.id, - name: PaidWorkspacePlans.Team, - createdAt: now, - updatedAt: now, - status: PaidWorkspacePlanStatuses.Valid - } - - plan2 = { - workspaceId: workspace2.id, - name: PaidWorkspacePlans.Team, - createdAt: now, - updatedAt: now, - status: PaidWorkspacePlanStatuses.Valid - } + plan1 = buildTestWorkspacePlan({ workspaceId: workspace1.id }) + plan2 = buildTestWorkspacePlan({ workspaceId: workspace2.id }) await upsertWorkspacePlan({ workspacePlan: plan1 @@ -86,13 +72,10 @@ describe('Module @gatekeeper', () => { describe('upsertWorkspacePlan should return a function, that', () => { it('inserts a workspace plan if it does not exist', async () => { await upsertWorkspacePlan({ - workspacePlan: { + workspacePlan: buildTestWorkspacePlan({ workspaceId: workspaceWithoutPlan.id, - name: PaidWorkspacePlans.Team, - createdAt: now, - updatedAt: now, - status: PaidWorkspacePlanStatuses.Valid - } + name: PaidWorkspacePlans.Team + }) }) const result = ( @@ -109,17 +92,38 @@ describe('Module @gatekeeper', () => { in5days = new Date() in5days.setDate(in5days.getDate() + 7) + const now = new Date() + + const workspacePlan = buildTestWorkspacePlan({ + workspaceId: workspace2.id, + name: PaidWorkspacePlans.Pro, + updatedAt: now + }) + + await upsertWorkspacePlan({ + workspacePlan + }) + + let result = ( + await getWorkspacePlansByWorkspaceId({ + workspaceIds: [workspace2.id] + }) + )[workspace2.id] + + expect(result.workspaceId).to.equal(workspace2.id) + expect(result.name).to.equal(PaidWorkspacePlans.Pro) + expect(result.updatedAt).to.be.deep.eq(now) + await upsertWorkspacePlan({ workspacePlan: { - workspaceId: workspace2.id, + ...workspacePlan, name: PaidWorkspacePlans.ProUnlimited, - createdAt: in5days, updatedAt: in5days, status: PaidWorkspacePlanStatuses.Canceled } }) - const result = ( + result = ( await getWorkspacePlansByWorkspaceId({ workspaceIds: [workspace2.id] }) diff --git a/packages/server/modules/gatekeeper/tests/unit/checkout.spec.ts b/packages/server/modules/gatekeeper/tests/unit/checkout.spec.ts index a7c885d0a..1677e7075 100644 --- a/packages/server/modules/gatekeeper/tests/unit/checkout.spec.ts +++ b/packages/server/modules/gatekeeper/tests/unit/checkout.spec.ts @@ -158,7 +158,8 @@ describe('checkout @gatekeeper', () => { expect(omit(storedWorkspacePlan, 'createdAt', 'updatedAt')).to.deep.equal({ workspaceId, name: storedCheckoutSession.workspacePlan, - status: 'valid' + status: 'valid', + featureFlags: 0 }) expect(emittedEventName).to.equal('gatekeeper.workspace-subscription-updated') expect(emittedEventPayload).to.nested.include({ @@ -236,13 +237,12 @@ describe('checkout @gatekeeper', () => { const userId = cryptoRandomString({ length: 10 }) const err = await expectToThrow(() => startCheckoutSessionFactory({ - getWorkspacePlan: async () => ({ - name: 'team', - status: 'valid', - createdAt: new Date(), - updatedAt: new Date(), - workspaceId - }), + getWorkspacePlan: async () => + buildTestWorkspacePlan({ + name: 'team', + status: 'valid', + workspaceId + }), getWorkspaceCheckoutSession: () => { expect.fail() }, @@ -275,13 +275,12 @@ describe('checkout @gatekeeper', () => { const userId = cryptoRandomString({ length: 10 }) const err = await expectToThrow(() => startCheckoutSessionFactory({ - getWorkspacePlan: async () => ({ - name: 'team', - status: 'paymentFailed', - createdAt: new Date(), - updatedAt: new Date(), - workspaceId - }), + getWorkspacePlan: async () => + buildTestWorkspacePlan({ + name: 'team', + status: 'paymentFailed', + workspaceId + }), getWorkspaceCheckoutSession: () => { expect.fail() }, @@ -314,13 +313,12 @@ describe('checkout @gatekeeper', () => { const userId = cryptoRandomString({ length: 10 }) const err = await expectToThrow(() => startCheckoutSessionFactory({ - getWorkspacePlan: async () => ({ - name: 'free', - status: 'valid', - createdAt: new Date(), - updatedAt: new Date(), - workspaceId - }), + getWorkspacePlan: async () => + buildTestWorkspacePlan({ + name: 'free', + status: 'valid', + workspaceId + }), getWorkspaceCheckoutSession: async () => ({ billingInterval: 'monthly', id: cryptoRandomString({ length: 10 }), @@ -380,13 +378,12 @@ describe('checkout @gatekeeper', () => { } let storedCheckoutSession: CheckoutSession | undefined = undefined const createdCheckoutSession = await startCheckoutSessionFactory({ - getWorkspacePlan: async () => ({ - workspaceId, - name: 'free', - createdAt: new Date(), - updatedAt: new Date(), - status: 'valid' - }), + getWorkspacePlan: async () => + buildTestWorkspacePlan({ + workspaceId, + name: 'free', + status: 'valid' + }), getWorkspaceCheckoutSession: async () => null, countSeatsByTypeInWorkspace: async () => 1, deleteCheckoutSession: () => { @@ -440,13 +437,12 @@ describe('checkout @gatekeeper', () => { } let storedCheckoutSession: CheckoutSession | undefined = undefined const createdCheckoutSession = await startCheckoutSessionFactory({ - getWorkspacePlan: async () => ({ - workspaceId, - name: 'free', - status: 'valid', - createdAt: new Date(), - updatedAt: new Date() - }), + getWorkspacePlan: async () => + buildTestWorkspacePlan({ + workspaceId, + name: 'free', + status: 'valid' + }), getWorkspaceCheckoutSession: async () => existingCheckoutSession!, countSeatsByTypeInWorkspace: async () => 1, deleteCheckoutSession: async () => { @@ -489,13 +485,12 @@ describe('checkout @gatekeeper', () => { } const err = await expectToThrow(async () => { await startCheckoutSessionFactory({ - getWorkspacePlan: async () => ({ - workspaceId, - name: 'free', - createdAt: new Date(), - updatedAt: new Date(), - status: 'valid' - }), + getWorkspacePlan: async () => + buildTestWorkspacePlan({ + workspaceId, + name: 'free', + status: 'valid' + }), getWorkspaceCheckoutSession: async () => existingCheckoutSession!, countSeatsByTypeInWorkspace: async () => 1, deleteCheckoutSession: async () => { @@ -549,13 +544,12 @@ describe('checkout @gatekeeper', () => { } let storedCheckoutSession: CheckoutSession | undefined = undefined const createdCheckoutSession = await startCheckoutSessionFactory({ - getWorkspacePlan: async () => ({ - name: 'team', - workspaceId, - createdAt: new Date(), - updatedAt: new Date(), - status: 'canceled' - }), + getWorkspacePlan: async () => + buildTestWorkspacePlan({ + name: 'team', + workspaceId, + status: 'canceled' + }), getWorkspaceCheckoutSession: async () => existingCheckoutSession!, countSeatsByTypeInWorkspace: async () => 1, deleteCheckoutSession: async () => { diff --git a/packages/server/modules/gatekeeper/tests/unit/featureAuthorization.spec.ts b/packages/server/modules/gatekeeper/tests/unit/featureAuthorization.spec.ts index 89db440e8..0da071234 100644 --- a/packages/server/modules/gatekeeper/tests/unit/featureAuthorization.spec.ts +++ b/packages/server/modules/gatekeeper/tests/unit/featureAuthorization.spec.ts @@ -2,6 +2,7 @@ import { canWorkspaceAccessFeatureFactory } from '@/modules/gatekeeper/services/ import { PaidWorkspacePlans, WorkspacePlanFeatures } from '@speckle/shared' import { expect } from 'chai' import cryptoRandomString from 'crypto-random-string' +import { buildTestWorkspacePlan } from '@/modules/gatekeeper/tests/helpers/workspacePlan' describe('featureAuthorization @gatekeeper', () => { describe('canWorkspaceAccessFeatureFactory creates a function, that', () => { @@ -34,13 +35,12 @@ describe('featureAuthorization @gatekeeper', () => { it(`returns ${expectedResult} for ${plan} @ ${status} for ${workspaceFeature}`, async () => { const workspaceId = cryptoRandomString({ length: 10 }) const canWorkspaceAccessFeature = canWorkspaceAccessFeatureFactory({ - getWorkspacePlan: async () => ({ - name: plan, - status, - workspaceId, - createdAt: new Date(), - updatedAt: new Date() - }) + getWorkspacePlan: async () => + buildTestWorkspacePlan({ + name: plan, + status, + workspaceId + }) }) const result = await canWorkspaceAccessFeature({ workspaceId, diff --git a/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts b/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts index 89cce5771..5eca8c970 100644 --- a/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts +++ b/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts @@ -32,6 +32,7 @@ import { upgradeWorkspaceSubscriptionFactory } from '@/modules/gatekeeper/servic import type { EventBusEmit } from '@/modules/shared/services/eventBus' import { testLogger } from '@/observability/logging' import { mutateSubscriptionDataWithNewValidSeatNumbers } from '@/modules/gatekeeper/services/subscriptions/mutateSubscriptionDataWithNewValidSeatNumbers' +import { buildTestWorkspacePlan } from '@/modules/gatekeeper/tests/helpers/workspacePlan' describe('subscriptions @gatekeeper', () => { describe('handleSubscriptionUpdateFactory creates a function, that', () => { @@ -106,13 +107,12 @@ describe('subscriptions @gatekeeper', () => { subscriptionData, workspaceId }), - getWorkspacePlan: async () => ({ - name, - workspaceId, - createdAt: new Date(), - updatedAt: new Date(), - status: 'valid' - }), + getWorkspacePlan: async () => + buildTestWorkspacePlan({ + name, + workspaceId, + status: 'valid' + }), upsertWorkspaceSubscription: async () => { expect.fail() }, @@ -149,13 +149,12 @@ describe('subscriptions @gatekeeper', () => { await handleSubscriptionUpdateFactory({ getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription, - getWorkspacePlan: async () => ({ - name: PaidWorkspacePlans.Team, - workspaceId, - createdAt: new Date(), - updatedAt: new Date(), - status: 'valid' - }), + getWorkspacePlan: async () => + buildTestWorkspacePlan({ + name: PaidWorkspacePlans.Team, + workspaceId, + status: 'valid' + }), upsertWorkspaceSubscription: async ({ workspaceSubscription }) => { updatedSubscription = workspaceSubscription }, @@ -210,13 +209,12 @@ describe('subscriptions @gatekeeper', () => { await handleSubscriptionUpdateFactory({ getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription, - getWorkspacePlan: async () => ({ - name: PaidWorkspacePlans.Team, - workspaceId, - createdAt: new Date(), - updatedAt: new Date(), - status: 'paymentFailed' - }), + getWorkspacePlan: async () => + buildTestWorkspacePlan({ + name: PaidWorkspacePlans.Team, + workspaceId, + status: 'paymentFailed' + }), upsertWorkspaceSubscription: async ({ workspaceSubscription }) => { updatedSubscription = workspaceSubscription }, @@ -290,13 +288,12 @@ describe('subscriptions @gatekeeper', () => { await handleSubscriptionUpdateFactory({ getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription, - getWorkspacePlan: async () => ({ - name: PaidWorkspacePlans.Team, - workspaceId, - createdAt: new Date(), - updatedAt: new Date(), - status: 'paymentFailed' - }), + getWorkspacePlan: async () => + buildTestWorkspacePlan({ + name: PaidWorkspacePlans.Team, + workspaceId, + status: 'paymentFailed' + }), upsertWorkspaceSubscription: async ({ workspaceSubscription }) => { updatedSubscription = workspaceSubscription }, @@ -350,13 +347,12 @@ describe('subscriptions @gatekeeper', () => { await handleSubscriptionUpdateFactory({ getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription, - getWorkspacePlan: async () => ({ - name: PaidWorkspacePlans.Team, - workspaceId, - createdAt: new Date(), - updatedAt: new Date(), - status: 'valid' - }), + getWorkspacePlan: async () => + buildTestWorkspacePlan({ + name: PaidWorkspacePlans.Team, + workspaceId, + status: 'valid' + }), upsertWorkspaceSubscription: async ({ workspaceSubscription }) => { updatedSubscription = workspaceSubscription }, @@ -410,13 +406,12 @@ describe('subscriptions @gatekeeper', () => { await handleSubscriptionUpdateFactory({ getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription, - getWorkspacePlan: async () => ({ - name: PaidWorkspacePlans.Team, - workspaceId, - createdAt: new Date(), - updatedAt: new Date(), - status: 'valid' - }), + getWorkspacePlan: async () => + buildTestWorkspacePlan({ + name: PaidWorkspacePlans.Team, + workspaceId, + status: 'valid' + }), upsertWorkspaceSubscription: async ({ workspaceSubscription }) => { updatedSubscription = workspaceSubscription }, @@ -459,13 +454,12 @@ describe('subscriptions @gatekeeper', () => { await handleSubscriptionUpdateFactory({ getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription, - getWorkspacePlan: async () => ({ - name: PaidWorkspacePlans.Team, - workspaceId, - createdAt: new Date(), - updatedAt: new Date(), - status: 'valid' - }), + getWorkspacePlan: async () => + buildTestWorkspacePlan({ + name: PaidWorkspacePlans.Team, + workspaceId, + status: 'valid' + }), upsertWorkspaceSubscription: async () => { expect.fail() }, @@ -514,13 +508,12 @@ describe('subscriptions @gatekeeper', () => { const updatedByUserId = cryptoRandomString({ length: 10 }) const addWorkspaceSubscriptionSeatIfNeeded = addWorkspaceSubscriptionSeatIfNeededFactory({ - getWorkspacePlan: async () => ({ - name: 'free', - workspaceId, - createdAt: new Date(), - updatedAt: new Date(), - status: 'valid' - }), + getWorkspacePlan: async () => + buildTestWorkspacePlan({ + name: 'free', + workspaceId, + status: 'valid' + }), getWorkspaceSubscription: async () => null, getWorkspacePlanPriceId: () => { expect.fail() @@ -551,13 +544,12 @@ describe('subscriptions @gatekeeper', () => { }) const addWorkspaceSubscriptionSeatIfNeeded = addWorkspaceSubscriptionSeatIfNeededFactory({ - getWorkspacePlan: async () => ({ - name: 'free', - workspaceId, - createdAt: new Date(), - updatedAt: new Date(), - status: 'valid' - }), + getWorkspacePlan: async () => + buildTestWorkspacePlan({ + name: 'free', + workspaceId, + status: 'valid' + }), getWorkspaceSubscription: async () => workspaceSubscription, getWorkspacePlanPriceId: () => { expect.fail() @@ -590,13 +582,12 @@ describe('subscriptions @gatekeeper', () => { }) const addWorkspaceSubscriptionSeatIfNeeded = addWorkspaceSubscriptionSeatIfNeededFactory({ - getWorkspacePlan: async () => ({ - name: 'team', - workspaceId, - createdAt: new Date(), - updatedAt: new Date(), - status: 'canceled' - }), + getWorkspacePlan: async () => + buildTestWorkspacePlan({ + name: 'team', + workspaceId, + status: 'canceled' + }), getWorkspaceSubscription: async () => workspaceSubscription, getWorkspacePlanPriceId: () => { expect.fail() @@ -624,13 +615,11 @@ describe('subscriptions @gatekeeper', () => { workspaceId, subscriptionData }) - const workspacePlan: WorkspacePlan = { + const workspacePlan: WorkspacePlan = buildTestWorkspacePlan({ name: 'team', workspaceId, - createdAt: new Date(), - updatedAt: new Date(), status: 'valid' - } + }) const priceId = cryptoRandomString({ length: 10 }) const productId = cryptoRandomString({ length: 10 }) const roleCount = 10 @@ -699,13 +688,11 @@ describe('subscriptions @gatekeeper', () => { workspaceId, subscriptionData }) - const workspacePlan: WorkspacePlan = { + const workspacePlan: WorkspacePlan = buildTestWorkspacePlan({ name: 'team', workspaceId, - createdAt: new Date(), - updatedAt: new Date(), status: 'valid' - } + }) const roleCount = 10 let reconciledSubscriptionData: SubscriptionDataInput | undefined = undefined @@ -769,13 +756,11 @@ describe('subscriptions @gatekeeper', () => { workspaceId, subscriptionData }) - const workspacePlan: WorkspacePlan = { + const workspacePlan: WorkspacePlan = buildTestWorkspacePlan({ name: 'team', workspaceId, - createdAt: new Date(), - updatedAt: new Date(), status: 'valid' - } + }) const count = 1 const addWorkspaceSubscriptionSeatIfNeeded = @@ -931,13 +916,12 @@ describe('subscriptions @gatekeeper', () => { workspaceId }) const downscaleSubscription = downscaleWorkspaceSubscriptionFactory({ - getWorkspacePlan: async () => ({ - name: 'unlimited', - workspaceId, - createdAt: new Date(), - updatedAt: new Date(), - status: 'valid' - }), + getWorkspacePlan: async () => + buildTestWorkspacePlan({ + name: 'unlimited', + workspaceId, + status: 'valid' + }), countSeatsByTypeInWorkspace: async () => { expect.fail() }, @@ -961,13 +945,12 @@ describe('subscriptions @gatekeeper', () => { workspaceId }) const downscaleSubscription = downscaleWorkspaceSubscriptionFactory({ - getWorkspacePlan: async () => ({ - name: PaidWorkspacePlans.Team, - workspaceId, - createdAt: new Date(), - updatedAt: new Date(), - status: 'canceled' - }), + getWorkspacePlan: async () => + buildTestWorkspacePlan({ + name: PaidWorkspacePlans.Team, + workspaceId, + status: 'canceled' + }), countSeatsByTypeInWorkspace: async () => { expect.fail() }, @@ -998,13 +981,12 @@ describe('subscriptions @gatekeeper', () => { }) const workspacePlanName = PaidWorkspacePlans.Team const downscaleSubscription = downscaleWorkspaceSubscriptionFactory({ - getWorkspacePlan: async () => ({ - name: workspacePlanName, - workspaceId, - createdAt: new Date(), - updatedAt: new Date(), - status: 'valid' - }), + getWorkspacePlan: async () => + buildTestWorkspacePlan({ + name: workspacePlanName, + workspaceId, + status: 'valid' + }), countSeatsByTypeInWorkspace: async ({ type }) => { return type === WorkspaceSeatType.Viewer ? 0 : quantity }, @@ -1043,13 +1025,12 @@ describe('subscriptions @gatekeeper', () => { let reconciledSub: SubscriptionDataInput | undefined = undefined const downscaleSubscription = downscaleWorkspaceSubscriptionFactory({ - getWorkspacePlan: async () => ({ - name: workspacePlanName, - workspaceId, - createdAt: new Date(), - updatedAt: new Date(), - status: 'valid' - }), + getWorkspacePlan: async () => + buildTestWorkspacePlan({ + name: workspacePlanName, + workspaceId, + status: 'valid' + }), countSeatsByTypeInWorkspace: async ({ type }) => { return type === WorkspaceSeatType.Viewer ? 0 : editorQty / 2 }, @@ -1098,13 +1079,12 @@ describe('subscriptions @gatekeeper', () => { workspaceId }) const downscaleSubscription = downscaleWorkspaceSubscriptionFactory({ - getWorkspacePlan: async () => ({ - name: 'unlimited', - workspaceId, - createdAt: new Date(), - updatedAt: new Date(), - status: 'valid' - }), + getWorkspacePlan: async () => + buildTestWorkspacePlan({ + name: 'unlimited', + workspaceId, + status: 'valid' + }), countSeatsByTypeInWorkspace: async () => { expect.fail() }, @@ -1128,13 +1108,12 @@ describe('subscriptions @gatekeeper', () => { workspaceId }) const downscaleSubscription = downscaleWorkspaceSubscriptionFactory({ - getWorkspacePlan: async () => ({ - name: 'pro', - workspaceId, - createdAt: new Date(), - updatedAt: new Date(), - status: 'canceled' - }), + getWorkspacePlan: async () => + buildTestWorkspacePlan({ + name: 'pro', + workspaceId, + status: 'canceled' + }), countSeatsByTypeInWorkspace: async () => { expect.fail() }, @@ -1165,13 +1144,12 @@ describe('subscriptions @gatekeeper', () => { }) const workspacePlanName = 'pro' const downscaleSubscription = downscaleWorkspaceSubscriptionFactory({ - getWorkspacePlan: async () => ({ - name: workspacePlanName, - workspaceId, - createdAt: new Date(), - updatedAt: new Date(), - status: 'valid' - }), + getWorkspacePlan: async () => + buildTestWorkspacePlan({ + name: workspacePlanName, + workspaceId, + status: 'valid' + }), countSeatsByTypeInWorkspace: async () => { return 10 }, @@ -1211,13 +1189,12 @@ describe('subscriptions @gatekeeper', () => { let reconciledSub: SubscriptionDataInput | undefined = undefined const downscaleSubscription = downscaleWorkspaceSubscriptionFactory({ - getWorkspacePlan: async () => ({ - name: workspacePlanName, - workspaceId, - createdAt: new Date(), - updatedAt: new Date(), - status: 'valid' - }), + getWorkspacePlan: async () => + buildTestWorkspacePlan({ + name: workspacePlanName, + workspaceId, + status: 'valid' + }), countSeatsByTypeInWorkspace: async () => { return 5 }, @@ -1280,13 +1257,12 @@ describe('subscriptions @gatekeeper', () => { const workspaceId = cryptoRandomString({ length: 10 }) const userId = cryptoRandomString({ length: 10 }) const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({ - getWorkspacePlan: async () => ({ - createdAt: new Date(), - updatedAt: new Date(), - name: plan, - status: 'valid', - workspaceId - }), + getWorkspacePlan: async () => + buildTestWorkspacePlan({ + name: plan, + status: 'valid', + workspaceId + }), getWorkspacePlanProductId: () => { expect.fail() }, @@ -1325,13 +1301,12 @@ describe('subscriptions @gatekeeper', () => { const workspaceId = cryptoRandomString({ length: 10 }) const userId = cryptoRandomString({ length: 10 }) const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({ - getWorkspacePlan: async () => ({ - workspaceId, - createdAt: new Date(), - updatedAt: new Date(), - name: plan, - status - }), + getWorkspacePlan: async () => + buildTestWorkspacePlan({ + workspaceId, + name: plan, + status + }), getWorkspacePlanProductId: () => { expect.fail() }, @@ -1369,13 +1344,12 @@ describe('subscriptions @gatekeeper', () => { const workspaceId = cryptoRandomString({ length: 10 }) const userId = cryptoRandomString({ length: 10 }) const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({ - getWorkspacePlan: async () => ({ - workspaceId, - createdAt: new Date(), - updatedAt: new Date(), - name: 'team', - status: 'valid' - }), + getWorkspacePlan: async () => + buildTestWorkspacePlan({ + workspaceId, + name: 'team', + status: 'valid' + }), getWorkspacePlanProductId: () => { expect.fail() }, @@ -1412,13 +1386,12 @@ describe('subscriptions @gatekeeper', () => { const userId = cryptoRandomString({ length: 10 }) const workspaceSubscription = createTestWorkspaceSubscription() const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({ - getWorkspacePlan: async () => ({ - workspaceId, - createdAt: new Date(), - updatedAt: new Date(), - name: 'pro', - status: 'valid' - }), + getWorkspacePlan: async () => + buildTestWorkspacePlan({ + workspaceId, + name: 'pro', + status: 'valid' + }), getWorkspacePlanProductId: () => { expect.fail() }, @@ -1457,13 +1430,12 @@ describe('subscriptions @gatekeeper', () => { billingInterval: 'yearly' }) const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({ - getWorkspacePlan: async () => ({ - workspaceId, - createdAt: new Date(), - updatedAt: new Date(), - name: 'team', - status: 'valid' - }), + getWorkspacePlan: async () => + buildTestWorkspacePlan({ + workspaceId, + name: 'team', + status: 'valid' + }), getWorkspacePlanProductId: () => { expect.fail() }, @@ -1501,13 +1473,12 @@ describe('subscriptions @gatekeeper', () => { billingInterval: 'monthly' }) const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({ - getWorkspacePlan: async () => ({ - workspaceId, - createdAt: new Date(), - updatedAt: new Date(), - name: 'team', - status: 'valid' - }), + getWorkspacePlan: async () => + buildTestWorkspacePlan({ + workspaceId, + name: 'team', + status: 'valid' + }), getWorkspacePlanProductId: () => { expect.fail() }, @@ -1553,13 +1524,12 @@ describe('subscriptions @gatekeeper', () => { subscriptionData }) const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({ - getWorkspacePlan: async () => ({ - workspaceId, - createdAt: new Date(), - updatedAt: new Date(), - name: 'team', - status: 'valid' - }), + getWorkspacePlan: async () => + buildTestWorkspacePlan({ + workspaceId, + name: 'team', + status: 'valid' + }), getWorkspacePlanProductId: () => { return cryptoRandomString({ length: 10 }) }, @@ -1616,13 +1586,12 @@ describe('subscriptions @gatekeeper', () => { let reconciledSubscriptionData: SubscriptionDataInput | undefined = undefined let updatedWorkspaceSubscription: WorkspaceSubscription | undefined = undefined const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({ - getWorkspacePlan: async () => ({ - workspaceId, - createdAt: new Date(), - updatedAt: new Date(), - name: 'team', - status: 'valid' - }), + getWorkspacePlan: async () => + buildTestWorkspacePlan({ + workspaceId, + name: 'team', + status: 'valid' + }), getWorkspacePlanProductId: ({ workspacePlan }) => { switch (workspacePlan) { case 'team': diff --git a/packages/server/modules/gatekeeper/tests/unit/workspacePlans.spec.ts b/packages/server/modules/gatekeeper/tests/unit/workspacePlans.spec.ts index b727e2dff..e8d18445d 100644 --- a/packages/server/modules/gatekeeper/tests/unit/workspacePlans.spec.ts +++ b/packages/server/modules/gatekeeper/tests/unit/workspacePlans.spec.ts @@ -9,6 +9,7 @@ import { PaidWorkspacePlans, PaidWorkspacePlanStatuses, UnpaidWorkspacePlans, + WorkspaceFeatureFlags, WorkspacePlans } from '@speckle/shared' import { expect } from 'chai' @@ -215,7 +216,12 @@ describe('workspacePlan services @gatekeeper', () => { //@ts-expect-error we need to test the runtime error checks too status }) - const expectedPlan = { workspaceId, name: planName, status } + const expectedPlan = { + workspaceId, + name: planName, + status, + featureFlags: WorkspaceFeatureFlags.none + } expect(omit(storedWorkspacePlan, 'createdAt', 'updatedAt')).to.deep.equal( expectedPlan ) diff --git a/packages/server/modules/workspaces/events/eventListener.ts b/packages/server/modules/workspaces/events/eventListener.ts index 686fd3978..a0dc25066 100644 --- a/packages/server/modules/workspaces/events/eventListener.ts +++ b/packages/server/modules/workspaces/events/eventListener.ts @@ -33,7 +33,12 @@ import type { EventBusEmit, EventPayload } from '@/modules/shared/services/event import { getEventBus } from '@/modules/shared/services/eventBus' import { WorkspaceInviteResourceType } from '@/modules/workspacesCore/domain/constants' import type { MaybeNullOrUndefined, StreamRoles, WorkspaceRoles } from '@speckle/shared' -import { isPaidPlan, Roles, throwUncoveredError } from '@speckle/shared' +import { + isPaidPlan, + Roles, + throwUncoveredError, + WorkspaceFeatureFlags +} from '@speckle/shared' import type { QueryAllProjects, UpsertProjectRole @@ -965,7 +970,8 @@ export const initializeEventListenersFactory = status: WorkspacePlanStatuses.Valid, workspaceId: payload.workspace.id, createdAt: new Date(), - updatedAt: new Date() + updatedAt: new Date(), + featureFlags: WorkspaceFeatureFlags.none } await upsertUnpaidWorkspacePlanFactory({ db })({ workspacePlan }) await eventBus.emit({ diff --git a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts index 76aeff6ca..bb4d944bd 100644 --- a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts +++ b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts @@ -114,6 +114,7 @@ import { import type { WorkspaceRoles } from '@speckle/shared' import { Roles, + WorkspaceFeatureFlags, WorkspacePlanFeatures, WorkspacePlans, removeNullOrUndefinedKeys, @@ -229,6 +230,7 @@ import { import { WorkspaceInvitesLimit } from '@/modules/workspaces/domain/constants' import { copyWorkspaceFactory } from '@/modules/workspaces/repositories/projectRegions' import { queryAllProjectsFactory } from '@/modules/core/services/projects' +import { WorkspacePlanNotFoundError } from '@/modules/gatekeeper/errors/billing' const eventBus = getEventBus() const getServerInfo = getServerInfoFactory({ db }) @@ -607,6 +609,42 @@ export default FF_WORKSPACES_MODULE_ENABLED operationDescription: 'Update workspace plan' } ) + return true + }, + giveAccessToWorkspaceFeature: async (_parent, { input }, ctx) => { + const { workspaceId, featureFlagName } = input + const userId = ctx.userId + if (!userId) throw new UnauthorizedError() + + const featureFlag = WorkspaceFeatureFlags[featureFlagName] + + const workspacePlan = await getWorkspacePlanFactory({ db })({ workspaceId }) + if (!workspacePlan) throw new WorkspacePlanNotFoundError() + + workspacePlan.featureFlags |= featureFlag + // not updating updatedAt here deliberately. Feature flags are internal for now + await upsertWorkspacePlanFactory({ db })({ + workspacePlan + }) + + return true + }, + removeAccessToWorkspaceFeature: async (_parent, { input }, ctx) => { + const { workspaceId, featureFlagName } = input + const userId = ctx.userId + if (!userId) throw new UnauthorizedError() + + const featureFlag = WorkspaceFeatureFlags[featureFlagName] + + const workspacePlan = await getWorkspacePlanFactory({ db })({ workspaceId }) + if (!workspacePlan) throw new WorkspacePlanNotFoundError() + + workspacePlan.featureFlags ^= featureFlag + // not updating updatedAt here deliberately. Feature flags are internal for now + await upsertWorkspacePlanFactory({ db })({ + workspacePlan + }) + return true } }, diff --git a/packages/server/modules/workspacesCore/migrations/20250822161233_workspace_feature_flags.ts b/packages/server/modules/workspacesCore/migrations/20250822161233_workspace_feature_flags.ts new file mode 100644 index 000000000..dd051932f --- /dev/null +++ b/packages/server/modules/workspacesCore/migrations/20250822161233_workspace_feature_flags.ts @@ -0,0 +1,13 @@ +import { type Knex } from 'knex' + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable('workspace_plans', (table) => { + table.integer('featureFlags').notNullable().defaultTo(0) + }) +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable('workspace_plans', (table) => { + table.dropColumn('featureFlags') + }) +} diff --git a/packages/shared/src/authz/fragments/workspaces.spec.ts b/packages/shared/src/authz/fragments/workspaces.spec.ts index 3e5103e43..34b3eb796 100644 --- a/packages/shared/src/authz/fragments/workspaces.spec.ts +++ b/packages/shared/src/authz/fragments/workspaces.spec.ts @@ -233,15 +233,7 @@ describe('ensureUserIsWorkspaceAdminFragment', () => { getWorkspaceRole: async () => Roles.Workspace.Admin, getWorkspaceSsoProvider: async () => null, getWorkspaceSsoSession: async () => null, - getWorkspacePlan: async () => { - return { - workspaceId, - name: 'unlimited' as const, - status: 'valid' as const, - createdAt: new Date(), - updatedAt: new Date() - } - }, + getWorkspacePlan: getWorkspacePlanFake({ workspaceId }), ...overrides }) } diff --git a/packages/shared/src/authz/policies/project/canMoveToWorkspace.spec.ts b/packages/shared/src/authz/policies/project/canMoveToWorkspace.spec.ts index f1d9ad1e1..dee65c7ff 100644 --- a/packages/shared/src/authz/policies/project/canMoveToWorkspace.spec.ts +++ b/packages/shared/src/authz/policies/project/canMoveToWorkspace.spec.ts @@ -12,7 +12,11 @@ import { WorkspaceProjectMoveInvalidError, WorkspacesNotEnabledError } from '../../domain/authErrors.js' -import { getProjectFake, getWorkspaceFake } from '../../../tests/fakes.js' +import { + getProjectFake, + getWorkspaceFake, + getWorkspacePlanFake +} from '../../../tests/fakes.js' const buildCanMoveToWorkspace = ( overrides?: Partial[0]> @@ -42,15 +46,7 @@ const buildCanMoveToWorkspace = ( getWorkspaceSsoSession: async () => { assert.fail() }, - getWorkspacePlan: async () => { - return { - status: 'valid', - workspaceId: 'workspace-id', - createdAt: new Date(), - updatedAt: new Date(), - name: 'team' - } - }, + getWorkspacePlan: getWorkspacePlanFake({ workspaceId: 'workspace-id' }), getWorkspaceLimits: async () => { return { modelCount: 5, diff --git a/packages/shared/src/authz/policies/project/canReadAccIntegrationSettings.spec.ts b/packages/shared/src/authz/policies/project/canReadAccIntegrationSettings.spec.ts index 0a2f510ae..9d4c8aa62 100644 --- a/packages/shared/src/authz/policies/project/canReadAccIntegrationSettings.spec.ts +++ b/packages/shared/src/authz/policies/project/canReadAccIntegrationSettings.spec.ts @@ -3,13 +3,18 @@ import { Roles } from '../../../core/constants.js' import { parseFeatureFlags } from '../../../environment/index.js' import { OverridesOf } from '../../../tests/helpers/types.js' import { canReadAccIntegrationSettingsPolicy } from './canReadAccIntegrationSettings.js' -import { getProjectFake, getWorkspaceFake } from '../../../tests/fakes.js' +import { + getProjectFake, + getWorkspaceFake, + getWorkspacePlanFake +} from '../../../tests/fakes.js' import { assert, describe, expect, it } from 'vitest' import { AccIntegrationNotEnabledError, ProjectNoAccessError, WorkspacePlanNoFeatureAccessError } from '../../domain/authErrors.js' +import { WorkspaceFeatureFlags } from '../../../workspaces/index.js' const buildSUT = ( overrides?: OverridesOf @@ -43,15 +48,7 @@ const buildSUT = ( getWorkspaceSsoSession: async () => { assert.fail() }, - getWorkspacePlan: async () => { - return { - status: 'valid', - workspaceId, - name: 'enterprise', - createdAt: new Date(), - updatedAt: new Date() - } - }, + getWorkspacePlan: getWorkspacePlanFake({ workspaceId, name: 'enterprise' }), ...overrides }) } @@ -108,7 +105,8 @@ describe('canReadAccIntegrationSettings returns a function, that', () => { workspaceId: cryptoRandomString({ length: 9 }), name: 'free', createdAt: new Date(), - updatedAt: new Date() + updatedAt: new Date(), + featureFlags: WorkspaceFeatureFlags.none } } })(buildArgs()) diff --git a/packages/shared/src/authz/policies/project/canUpdateEmbedTokens.spec.ts b/packages/shared/src/authz/policies/project/canUpdateEmbedTokens.spec.ts index 1ffeb6e04..6205c2d69 100644 --- a/packages/shared/src/authz/policies/project/canUpdateEmbedTokens.spec.ts +++ b/packages/shared/src/authz/policies/project/canUpdateEmbedTokens.spec.ts @@ -1,7 +1,11 @@ import cryptoRandomString from 'crypto-random-string' import { Roles } from '../../../core/constants.js' import { parseFeatureFlags } from '../../../environment/index.js' -import { getProjectFake, getWorkspaceFake } from '../../../tests/fakes.js' +import { + getProjectFake, + getWorkspaceFake, + getWorkspacePlanFake +} from '../../../tests/fakes.js' import { canUpdateEmbedTokensPolicy } from './canUpdateEmbedTokens.js' import { assert, describe, expect, it } from 'vitest' import { @@ -10,6 +14,7 @@ import { WorkspacePlanNoFeatureAccessError } from '../../domain/authErrors.js' import { OverridesOf } from '../../../tests/helpers/types.js' +import { WorkspaceFeatureFlags } from '../../../workspaces/index.js' const buildCanUpdateEmbedTokens = ( overrides?: OverridesOf @@ -40,15 +45,7 @@ const buildCanUpdateEmbedTokens = ( getWorkspaceSsoSession: async () => { assert.fail() }, - getWorkspacePlan: async () => { - return { - status: 'valid', - workspaceId, - name: 'unlimited', - createdAt: new Date(), - updatedAt: new Date() - } - }, + getWorkspacePlan: getWorkspacePlanFake({ workspaceId }), ...overrides }) } @@ -118,7 +115,8 @@ describe('canUpdateEmbedTokensArgs returns a function, that', () => { workspaceId: 'foo', name: 'free', createdAt: new Date(), - updatedAt: new Date() + updatedAt: new Date(), + featureFlags: WorkspaceFeatureFlags.none } } })(canUpdateEmbedTokensArgs()) diff --git a/packages/shared/src/authz/policies/workspace/canReadMemberEmail.spec.ts b/packages/shared/src/authz/policies/workspace/canReadMemberEmail.spec.ts index c1ee79496..a27a89533 100644 --- a/packages/shared/src/authz/policies/workspace/canReadMemberEmail.spec.ts +++ b/packages/shared/src/authz/policies/workspace/canReadMemberEmail.spec.ts @@ -11,7 +11,7 @@ import { WorkspacesNotEnabledError } from '../../domain/authErrors.js' import { canReadMemberEmailPolicy } from './canReadMemberEmail.js' -import { getWorkspaceFake } from '../../../tests/fakes.js' +import { getWorkspaceFake, getWorkspacePlanFake } from '../../../tests/fakes.js' describe('canReadMemberEmailPolicy', () => { const workspaceId = cryptoRandomString({ length: 9 }) @@ -32,15 +32,7 @@ describe('canReadMemberEmailPolicy', () => { getWorkspaceRole: async () => Roles.Workspace.Admin, getWorkspaceSsoProvider: async () => null, getWorkspaceSsoSession: async () => null, - getWorkspacePlan: async () => { - return { - workspaceId, - name: 'unlimited' as const, - status: 'valid' as const, - createdAt: new Date(), - updatedAt: new Date() - } - }, + getWorkspacePlan: getWorkspacePlanFake({ workspaceId }), ...overrides }) } diff --git a/packages/shared/src/authz/policies/workspace/canUseWorkspacePlanFeature.spec.ts b/packages/shared/src/authz/policies/workspace/canUseWorkspacePlanFeature.spec.ts index bb8b7b47c..771a649ae 100644 --- a/packages/shared/src/authz/policies/workspace/canUseWorkspacePlanFeature.spec.ts +++ b/packages/shared/src/authz/policies/workspace/canUseWorkspacePlanFeature.spec.ts @@ -3,7 +3,10 @@ import { Roles } from '../../../core/constants.js' import { parseFeatureFlags } from '../../../environment/index.js' import { Workspace } from '../../domain/workspaces/types.js' import { canUseWorkspacePlanFeature } from './canUseWorkspacePlanFeature.js' -import { WorkspacePlanFeatures } from '../../../workspaces/index.js' +import { + WorkspaceFeatureFlags, + WorkspacePlanFeatures +} from '../../../workspaces/index.js' import { describe, expect, it } from 'vitest' import { ServerNoAccessError, @@ -14,6 +17,7 @@ import { WorkspaceNotEnoughPermissionsError, WorkspaceReadOnlyError } from '../../domain/authErrors.js' +import { getWorkspacePlanFake } from '../../../tests/fakes.js' const buildSUT = ( overrides?: Partial[0]> @@ -35,15 +39,7 @@ const buildSUT = ( getWorkspaceRole: async () => Roles.Workspace.Admin, getWorkspaceSsoProvider: async () => null, getWorkspaceSsoSession: async () => null, - getWorkspacePlan: async () => { - return { - workspaceId, - name: 'unlimited', - status: 'valid', - createdAt: new Date(), - updatedAt: new Date() - } - }, + getWorkspacePlan: getWorkspacePlanFake({ workspaceId, name: 'unlimited' }), ...overrides }) } @@ -123,7 +119,8 @@ describe('canUseFeature', () => { name: 'proUnlimited', status: 'canceled', createdAt: new Date(), - updatedAt: new Date() + updatedAt: new Date(), + featureFlags: WorkspaceFeatureFlags.none }) }) @@ -141,7 +138,8 @@ describe('canUseFeature', () => { name: 'free', status: 'valid', createdAt: new Date(), - updatedAt: new Date() + updatedAt: new Date(), + featureFlags: WorkspaceFeatureFlags.none }) }) diff --git a/packages/shared/src/tests/fakes.ts b/packages/shared/src/tests/fakes.ts index 9c0bf23ca..869e921d3 100644 --- a/packages/shared/src/tests/fakes.ts +++ b/packages/shared/src/tests/fakes.ts @@ -11,7 +11,7 @@ import { } from '../authz/domain/workspaces/types.js' import { parseFeatureFlags } from '../environment/index.js' import { mapValues } from 'lodash' -import { WorkspacePlan } from '../workspaces/index.js' +import { WorkspaceFeatureFlags, WorkspacePlan } from '../workspaces/index.js' import { TIME_MS } from '../core/index.js' import { SavedView, @@ -49,7 +49,8 @@ export const getWorkspacePlanFake = fakeGetFactory(() => ({ status: 'valid', workspaceId: nanoid(10), createdAt: new Date(Date.now() - TIME_MS.day), - updatedAt: new Date(Date.now() - TIME_MS.day) + updatedAt: new Date(Date.now() - TIME_MS.day), + featureFlags: WorkspaceFeatureFlags.none })) export const getWorkspaceSsoProviderFake = fakeGetFactory(() => ({ diff --git a/packages/shared/src/workspaces/helpers/features.spec.ts b/packages/shared/src/workspaces/helpers/features.spec.ts index 94e98b4aa..3ebd85347 100644 --- a/packages/shared/src/workspaces/helpers/features.spec.ts +++ b/packages/shared/src/workspaces/helpers/features.spec.ts @@ -2,31 +2,98 @@ import { describe, expect, it } from 'vitest' import { workspacePlanHasAccessToFeature, WorkspacePlanFeatures, - WorkspacePlanConfigs + WorkspacePlanConfigs, + isWorkspaceFeatureFlagOn, + WorkspaceFeatureFlags } from './features.js' import { WorkspacePlans } from './plans.js' -describe('workspacePlanHasAccessToFeature', () => { - describe('Comprehensive feature coverage', () => { - const allPlans = Object.values(WorkspacePlans) as WorkspacePlans[] - const allFeatures = Object.values(WorkspacePlanFeatures) as WorkspacePlanFeatures[] +describe('workspace features', () => { + describe('workspacePlanHasAccessToFeature', () => { + describe('Comprehensive feature coverage', () => { + const allPlans = Object.values(WorkspacePlans) as WorkspacePlans[] + const allFeatures = Object.values( + WorkspacePlanFeatures + ) as WorkspacePlanFeatures[] - describe.each(allPlans)('should work for %s plan', (plan) => { - it.each(allFeatures)('%s feature combination', (feature) => { - const expectedResult = WorkspacePlanConfigs({ featureFlags: undefined })[ - plan - ].features.includes(feature) - const actualResult = workspacePlanHasAccessToFeature({ - plan, - feature, - featureFlags: undefined + describe.each(allPlans)('should work for %s plan', (plan) => { + it.each(allFeatures)('%s feature combination', (feature) => { + const expectedResult = WorkspacePlanConfigs({ featureFlags: undefined })[ + plan + ].features.includes(feature) + const actualResult = workspacePlanHasAccessToFeature({ + plan, + feature, + featureFlags: undefined + }) + expect( + actualResult, + `Plan ${plan} feature ${feature} access should be ${expectedResult}` + ).toBe(expectedResult) }) - - expect( - actualResult, - `Plan ${plan} feature ${feature} access should be ${expectedResult}` - ).toBe(expectedResult) }) }) }) + describe('isWorkspaceFeatureFlagOn', () => { + it('returns false if no flags are on', () => { + const workspaceFeatureFlags = WorkspaceFeatureFlags.none + const feature = WorkspaceFeatureFlags.dashboards + const result = isWorkspaceFeatureFlagOn({ workspaceFeatureFlags, feature }) + expect(result).toBe(false) + }) + + it('returns false if the currently tested flag is off', () => { + const workspaceFeatureFlags = WorkspaceFeatureFlags.accIntegration + const feature = WorkspaceFeatureFlags.dashboards + const result = isWorkspaceFeatureFlagOn({ workspaceFeatureFlags, feature }) + expect(result).toBe(false) + }) + + it('returns true if the currently tested flag is on', () => { + const workspaceFeatureFlags = WorkspaceFeatureFlags.dashboards + const feature = WorkspaceFeatureFlags.dashboards + const result = isWorkspaceFeatureFlagOn({ workspaceFeatureFlags, feature }) + expect(result).toBe(true) + }) + + it('returns true if the currently tested flag is on in a combo flag', () => { + const workspaceFeatureFlags = + WorkspaceFeatureFlags.dashboards | WorkspaceFeatureFlags.accIntegration + let result = isWorkspaceFeatureFlagOn({ + workspaceFeatureFlags, + feature: WorkspaceFeatureFlags.dashboards + }) + expect(result).toBe(true) + + result = isWorkspaceFeatureFlagOn({ + workspaceFeatureFlags, + feature: WorkspaceFeatureFlags.accIntegration + }) + expect(result).toBe(true) + }) + it('feature flag can be turned on and off', () => { + let workspaceFeatureFlags = WorkspaceFeatureFlags.none + let result = isWorkspaceFeatureFlagOn({ + workspaceFeatureFlags, + feature: WorkspaceFeatureFlags.dashboards + }) + expect(result).toBe(false) + + workspaceFeatureFlags |= WorkspaceFeatureFlags.dashboards + + result = isWorkspaceFeatureFlagOn({ + workspaceFeatureFlags, + feature: WorkspaceFeatureFlags.dashboards + }) + expect(result).toBe(true) + + workspaceFeatureFlags ^= WorkspaceFeatureFlags.dashboards + + result = isWorkspaceFeatureFlagOn({ + workspaceFeatureFlags, + feature: WorkspaceFeatureFlags.dashboards + }) + expect(result).toBe(false) + }) + }) }) diff --git a/packages/shared/src/workspaces/helpers/features.ts b/packages/shared/src/workspaces/helpers/features.ts index a9031568d..5c3b9206d 100644 --- a/packages/shared/src/workspaces/helpers/features.ts +++ b/packages/shared/src/workspaces/helpers/features.ts @@ -24,13 +24,37 @@ export const WorkspacePlanFeatures = { HideSpeckleBranding: 'hideSpeckleBranding', ExclusiveMembership: 'exclusiveMembership', EmbedPrivateProjects: 'embedPrivateProjects', - AccIntegration: 'accIntegration', + AccIntegration: 'accIntegration', // TODO: this should be moved to a workspace addon SavedViews: 'savedViews' } export type WorkspacePlanFeatures = (typeof WorkspacePlanFeatures)[keyof typeof WorkspacePlanFeatures] +// this const will be used as a bitwise flag for a per workspace feature access controller +// IMPORTANT: always use powers of 2 as the value of the object +// read more https://www.hendrik-erz.de/post/bitwise-flags-are-beautiful-and-heres-why +// this will make its way to the pricing plan and info setup at some point +// but for now its an internal only control +export const WorkspaceFeatureFlags = { + none: 0, + dashboards: 1, + accIntegration: 2 +} + +export type WorkspaceFeatureFlags = + (typeof WorkspaceFeatureFlags)[keyof typeof WorkspaceFeatureFlags] + +export const isWorkspaceFeatureFlagOn = ({ + workspaceFeatureFlags, + feature +}: { + workspaceFeatureFlags: number + feature: WorkspaceFeatureFlags +}): boolean => (workspaceFeatureFlags & feature) === feature + +export type WorkspaceFeatures = WorkspacePlanFeatures | WorkspaceFeatureFlags + export const WorkspacePlanFeaturesMetadata = ({ [WorkspacePlanFeatures.AutomateBeta]: { displayName: 'Automate beta access', @@ -314,3 +338,12 @@ export const workspacePlanHasAccessToFeature = ({ const hasAccess = planConfig.features.includes(feature) return hasAccess } + +export const isPlanFeature = ( + feature: WorkspaceFeatures +): feature is WorkspacePlanFeatures => { + if (typeof feature === 'number') { + return false + } + return Object.values(WorkspacePlanFeatures).includes(feature) +} diff --git a/packages/shared/src/workspaces/helpers/plans.ts b/packages/shared/src/workspaces/helpers/plans.ts index 440e7b360..2ea0915b5 100644 --- a/packages/shared/src/workspaces/helpers/plans.ts +++ b/packages/shared/src/workspaces/helpers/plans.ts @@ -136,6 +136,7 @@ type BaseWorkspacePlan = { workspaceId: string createdAt: Date updatedAt: Date + featureFlags: number // this will be a bitwise flag number } export type PaidWorkspacePlan = BaseWorkspacePlan & {