chore: get rid of all old workspace plan code (#4624)

* first batch of changes

* tests fix

* FE fixed

* renaming constants

* test fixes

* moar test fixes

* another test fix

* reenable app rover check

---------

Co-authored-by: Gergő Jedlicska <gergo@jedlicska.com>
This commit is contained in:
Kristaps Fabians Geikins
2025-04-30 20:18:32 +03:00
committed by GitHub
parent 76b84e2068
commit 211922b6a6
76 changed files with 814 additions and 2901 deletions
+4 -4
View File
@@ -541,10 +541,10 @@ jobs:
command: 'IGNORE_MISSING_MIRATIONS=true yarn cli graphql introspect'
working_directory: 'packages/server'
# - run:
# name: Checking for GQL schema breakages against app.speckle.systems
# command: 'yarn rover graph check Speckle-Server@app-speckle-systems --schema ./introspected-schema.graphql'
# working_directory: 'packages/server'
- run:
name: Checking for GQL schema breakages against app.speckle.systems
command: 'yarn rover graph check Speckle-Server@app-speckle-systems --schema ./introspected-schema.graphql'
working_directory: 'packages/server'
- run:
name: Checking for GQL schema breakages against latest.speckle.systems
@@ -1913,11 +1913,8 @@ export type OnboardingCompletionInput = {
};
export enum PaidWorkspacePlans {
Business = 'business',
Plus = 'plus',
Pro = 'pro',
ProUnlimited = 'proUnlimited',
Starter = 'starter',
Team = 'team',
TeamUnlimited = 'teamUnlimited'
}
@@ -4848,9 +4845,7 @@ export type WorkspacePlanPrice = {
export enum WorkspacePlanStatuses {
CancelationScheduled = 'cancelationScheduled',
Canceled = 'canceled',
Expired = 'expired',
PaymentFailed = 'paymentFailed',
Trial = 'trial',
Valid = 'valid'
}
@@ -4862,16 +4857,10 @@ export type WorkspacePlanUsage = {
export enum WorkspacePlans {
Academia = 'academia',
Business = 'business',
BusinessInvoiced = 'businessInvoiced',
Free = 'free',
Plus = 'plus',
PlusInvoiced = 'plusInvoiced',
Pro = 'pro',
ProUnlimited = 'proUnlimited',
ProUnlimitedInvoiced = 'proUnlimitedInvoiced',
Starter = 'starter',
StarterInvoiced = 'starterInvoiced',
Team = 'team',
TeamUnlimited = 'teamUnlimited',
TeamUnlimitedInvoiced = 'teamUnlimitedInvoiced',
@@ -11,7 +11,7 @@
:workspace-id="props.workspaceId"
:has-subscription="!!subscription"
:currency="props.currency"
@on-upgrade-click="toggleUpgradeDialog(plan as PaidWorkspacePlansNew)"
@on-upgrade-click="toggleUpgradeDialog(plan as PaidWorkspacePlans)"
/>
<SettingsWorkspacesBillingUpgradeDialog
@@ -32,7 +32,7 @@
import { BillingInterval, type Currency } from '~/lib/common/generated/gql/graphql'
import {
WorkspacePlans,
type PaidWorkspacePlansNew,
type PaidWorkspacePlans,
type MaybeNullOrUndefined,
type WorkspaceRoles,
Roles
@@ -59,7 +59,7 @@ const {
const mixpanel = useMixpanel()
const isUpgradeDialogOpen = ref(false)
const planToUpgrade = ref<PaidWorkspacePlansNew | null>(null)
const planToUpgrade = ref<PaidWorkspacePlans | null>(null)
const plans = computed(() => [
WorkspacePlans.Free,
@@ -69,7 +69,7 @@ const plans = computed(() => [
const isAdmin = computed(() => props.role === Roles.Workspace.Admin)
const toggleUpgradeDialog = (plan: PaidWorkspacePlansNew) => {
const toggleUpgradeDialog = (plan: PaidWorkspacePlans) => {
planToUpgrade.value = plan
isUpgradeDialogOpen.value = !isUpgradeDialogOpen.value
@@ -40,7 +40,7 @@
import { useWorkspacePlan } from '~~/lib/workspaces/composables/plan'
import { useWorkspaceAddonPrices } from '~/lib/billing/composables/prices'
import { formatPrice } from '~/lib/billing/helpers/plan'
import { PaidWorkspacePlansNew, type MaybeNullOrUndefined } from '@speckle/shared'
import { PaidWorkspacePlans, type MaybeNullOrUndefined } from '@speckle/shared'
import { BillingInterval, Currency } from '~/lib/common/generated/gql/graphql'
import { useActiveWorkspace } from '~/lib/workspaces/composables/activeWorkspace'
import { useMixpanel } from '~~/lib/core/composables/mp'
@@ -101,15 +101,15 @@ const unlimitedAddOnButton = computed(() => ({
}))
const planToUpgrade = computed(() => {
return plan.value?.name === PaidWorkspacePlansNew.Team
? PaidWorkspacePlansNew.TeamUnlimited
: PaidWorkspacePlansNew.ProUnlimited
return plan.value?.name === PaidWorkspacePlans.Team
? PaidWorkspacePlans.TeamUnlimited
: PaidWorkspacePlans.ProUnlimited
})
const addonPrice = computed(() => {
if (!plan.value) return null
const addonPrice =
addonPrices.value?.[currency.value]?.[plan.value.name as PaidWorkspacePlansNew]
addonPrices.value?.[currency.value]?.[plan.value.name as PaidWorkspacePlans]
if (!addonPrice) return null
return formatPrice({
@@ -6,7 +6,7 @@
<script lang="ts" setup>
import { useWorkspacePlan } from '~/lib/workspaces/composables/plan'
import type { PaidWorkspacePlansNew } from '@speckle/shared'
import type { PaidWorkspacePlans } from '@speckle/shared'
import { BillingInterval } from '~/lib/common/generated/gql/graphql'
import { formatPrice } from '~/lib/billing/helpers/plan'
import { useWorkspaceAddonPrices } from '~/lib/billing/composables/prices'
@@ -16,7 +16,7 @@ type AddonIncludedSelect = 'yes' | 'no'
const props = defineProps<{
slug: string
plan: PaidWorkspacePlansNew
plan: PaidWorkspacePlans
billingInterval: BillingInterval
enableNoOption: boolean
}>()
@@ -134,13 +134,13 @@ import {
WorkspacePlans,
isPaidPlan,
doesPlanIncludeUnlimitedProjectsAddon,
type PaidWorkspacePlansNew
type PaidWorkspacePlans
} from '@speckle/shared'
import { BillingInterval } from '~/lib/common/generated/gql/graphql'
const props = defineProps<{
slug: string
plan: PaidWorkspacePlansNew
plan: PaidWorkspacePlans
billingInterval: BillingInterval
editorSeatCount: number
}>()
@@ -176,8 +176,7 @@ const currentEditorPrice = computed(() => {
})
}
const planPrice =
activeWorkspacePrices.value?.[plan.value.name as PaidWorkspacePlansNew]
const planPrice = activeWorkspacePrices.value?.[plan.value.name as PaidWorkspacePlans]
if (!planPrice) return null
return formatPrice({
@@ -222,7 +221,7 @@ const newEditorPrice = computed(() => {
const totalPrice = computed(() => {
const planPrice =
activeWorkspacePrices.value?.[plan.value?.name as PaidWorkspacePlansNew]
activeWorkspacePrices.value?.[plan.value?.name as PaidWorkspacePlans]
if (!planPrice) return null
return formatPrice({
@@ -253,7 +252,7 @@ const newTotalPriceFormatted = computed(() => {
const currentAddonPrice = computed(() => {
if (!plan.value?.name) return null
const addonPrice =
addonPrices.value?.[currency.value]?.[plan.value.name as PaidWorkspacePlansNew]
addonPrices.value?.[currency.value]?.[plan.value.name as PaidWorkspacePlans]
if (!addonPrice) return null
return intervalIsYearly.value
@@ -27,7 +27,7 @@
import type { LayoutDialogButton } from '@speckle/ui-components'
import { useBillingActions } from '~/lib/billing/composables/actions'
import {
PaidWorkspacePlansNew,
PaidWorkspacePlans,
WorkspacePlanConfigs,
type MaybeNullOrUndefined,
doesPlanIncludeUnlimitedProjectsAddon
@@ -40,7 +40,7 @@ import { useMixpanel } from '~/lib/core/composables/mp'
type AddonIncludedSelect = 'yes' | 'no'
const props = defineProps<{
plan: PaidWorkspacePlansNew
plan: PaidWorkspacePlans
billingInterval: BillingInterval
workspaceId: MaybeNullOrUndefined<string>
slug: string
@@ -100,9 +100,9 @@ const isSamePlanWithAddon = computed(
// If the user has selected to include the add-on, return the new plan with the add-on
const finalNewPlan = computed(() => {
if (includeUnlimitedAddon.value === 'yes') {
return props.plan === PaidWorkspacePlansNew.Team
? PaidWorkspacePlansNew.TeamUnlimited
: PaidWorkspacePlansNew.ProUnlimited
return props.plan === PaidWorkspacePlans.Team
? PaidWorkspacePlans.TeamUnlimited
: PaidWorkspacePlans.ProUnlimited
}
return props.plan
@@ -30,7 +30,7 @@
<script setup lang="ts">
import { useWorkspacesWizard } from '~/lib/workspaces/composables/wizard'
import { useMixpanel } from '~/lib/core/composables/mp'
import { type PaidWorkspacePlansNew, WorkspacePlans } from '@speckle/shared'
import { type PaidWorkspacePlans, WorkspacePlans } from '@speckle/shared'
import { useWorkspaceAddonPrices } from '~/lib/billing/composables/prices'
import { Currency, BillingInterval } from '~/lib/common/generated/gql/graphql'
import { formatPrice } from '~/lib/billing/helpers/plan'
@@ -46,7 +46,7 @@ const includeUnlimitedAddon = ref<AddonIncludedSelect | undefined>(undefined)
const addOnPrice = computed(() => {
if (!state.value.plan) return null
const price =
addonPrices.value?.[Currency.Usd]?.[state.value.plan as PaidWorkspacePlansNew]
addonPrices.value?.[Currency.Usd]?.[state.value.plan as PaidWorkspacePlans]
if (!price) return null
return formatPrice({
@@ -20,12 +20,6 @@ export const formatName = (plan?: MaybeNullOrUndefined<WorkspacePlans>) => {
const formattedPlanNames: Record<WorkspacePlans, string> = {
[WorkspacePlans.Unlimited]: 'Unlimited',
[WorkspacePlans.Academia]: 'Academia',
[WorkspacePlans.StarterInvoiced]: 'Starter (invoiced)',
[WorkspacePlans.PlusInvoiced]: 'Plus (Invoiced)',
[WorkspacePlans.BusinessInvoiced]: 'Business (Invoiced)',
[WorkspacePlans.Starter]: 'Starter',
[WorkspacePlans.Plus]: 'Plus',
[WorkspacePlans.Business]: 'Business',
[WorkspacePlans.Free]: 'Free',
[WorkspacePlans.Team]: 'Starter',
[WorkspacePlans.TeamUnlimited]: 'Starter',
@@ -1919,11 +1919,8 @@ export type OnboardingCompletionInput = {
};
export const PaidWorkspacePlans = {
Business: 'business',
Plus: 'plus',
Pro: 'pro',
ProUnlimited: 'proUnlimited',
Starter: 'starter',
Team: 'team',
TeamUnlimited: 'teamUnlimited'
} as const;
@@ -4878,9 +4875,7 @@ export type WorkspacePlanPrice = {
export const WorkspacePlanStatuses = {
CancelationScheduled: 'cancelationScheduled',
Canceled: 'canceled',
Expired: 'expired',
PaymentFailed: 'paymentFailed',
Trial: 'trial',
Valid: 'valid'
} as const;
@@ -4893,16 +4888,10 @@ export type WorkspacePlanUsage = {
export const WorkspacePlans = {
Academia: 'academia',
Business: 'business',
BusinessInvoiced: 'businessInvoiced',
Free: 'free',
Plus: 'plus',
PlusInvoiced: 'plusInvoiced',
Pro: 'pro',
ProUnlimited: 'proUnlimited',
ProUnlimitedInvoiced: 'proUnlimitedInvoiced',
Starter: 'starter',
StarterInvoiced: 'starterInvoiced',
Team: 'team',
TeamUnlimited: 'teamUnlimited',
TeamUnlimitedInvoiced: 'teamUnlimitedInvoiced',
@@ -154,9 +154,7 @@ export const useSettingsMembersActions = (params: {
workspaceSlug: params.workspaceSlug.value || ''
})
const { statusIsExpired, statusIsCanceled } = useWorkspacePlan(
params.workspaceSlug.value || ''
)
const { statusIsCanceled } = useWorkspacePlan(params.workspaceSlug.value || '')
const targetUserRole = computed(() => {
return params.targetUser.value.role
@@ -247,8 +245,8 @@ export const useSettingsMembersActions = (params: {
headerItems.push({
title: 'Upgrade to editor...',
id: WorkspaceUserActionTypes.UpgradeEditor,
disabled: statusIsExpired.value || statusIsCanceled.value,
disabledTooltip: 'This workspace has an expired or canceled plan'
disabled: statusIsCanceled.value,
disabledTooltip: 'This workspace has a canceled plan'
})
}
if (showDowngradeEditor.value) {
@@ -256,13 +254,10 @@ export const useSettingsMembersActions = (params: {
title: 'Downgrade to viewer...',
id: WorkspaceUserActionTypes.DowngradeEditor,
disabled:
targetUserRole.value === Roles.Workspace.Admin ||
statusIsExpired.value ||
statusIsCanceled.value,
disabledTooltip:
statusIsExpired.value || statusIsCanceled.value
? 'This workspace has an expired or canceled plan'
: 'Admins must be on an Editor seat'
targetUserRole.value === Roles.Workspace.Admin || statusIsCanceled.value,
disabledTooltip: statusIsCanceled.value
? 'This workspace has a canceled plan'
: 'Admins must be on an Editor seat'
})
}
// This will return post new workspace plan launch
@@ -2,7 +2,7 @@ import { graphql } from '~~/lib/common/generated/gql'
import { workspacePlanQuery } from '~~/lib/workspaces/graphql/queries'
import { useQuery } from '@vue/apollo-composable'
import {
PaidWorkspacePlansNew,
PaidWorkspacePlans,
UnpaidWorkspacePlans,
WorkspacePlanBillingIntervals,
isPaidPlan as isPaidPlanShared,
@@ -69,8 +69,8 @@ export const useWorkspacePlan = (slug: string) => {
const isFreePlan = computed(() => plan.value?.name === UnpaidWorkspacePlans.Free)
const isBusinessPlan = computed(
() =>
plan.value?.name === PaidWorkspacePlansNew.Pro ||
plan.value?.name === PaidWorkspacePlansNew.ProUnlimited
plan.value?.name === PaidWorkspacePlans.Pro ||
plan.value?.name === PaidWorkspacePlans.ProUnlimited
)
const isUnlimitedPlan = computed(
() => plan.value?.name === UnpaidWorkspacePlans.Unlimited
@@ -88,9 +88,6 @@ export const useWorkspacePlan = (slug: string) => {
})
// Plan status information
const statusIsExpired = computed(
() => plan.value?.status === WorkspacePlanStatuses.Expired
)
const statusIsCanceled = computed(
() => plan.value?.status === WorkspacePlanStatuses.Canceled
)
@@ -118,7 +115,7 @@ export const useWorkspacePlan = (slug: string) => {
const editorSeatPriceFormatted = computed(() => {
if (plan.value?.name && isPaidPlanShared(plan.value?.name)) {
return formatPrice(
prices.value?.[plan.value?.name as PaidWorkspacePlansNew]?.[
prices.value?.[plan.value?.name as PaidWorkspacePlans]?.[
intervalIsYearly.value
? WorkspacePlanBillingIntervals.Yearly
: WorkspacePlanBillingIntervals.Monthly
@@ -134,7 +131,6 @@ export const useWorkspacePlan = (slug: string) => {
return {
plan,
statusIsExpired,
statusIsCanceled,
isFreePlan,
billingInterval,
-16
View File
@@ -161,22 +161,6 @@ MULTI_REGION_CONFIG_PATH="multiregion.json"
# STRIPE_API_KEY=sk_test_
# STRIPE_ENDPOINT_SIGNING_KEY=whsec_
#
# WORKSPACE_STARTER_SEAT_STRIPE_PRODUCT_ID=prod_
# WORKSPACE_MONTHLY_STARTER_SEAT_STRIPE_PRICE_ID=price_
# WORKSPACE_YEARLY_STARTER_SEAT_STRIPE_PRICE_ID=price_
#
# WORKSPACE_GUEST_SEAT_STRIPE_PRODUCT_ID=prod_
# WORKSPACE_MONTHLY_GUEST_SEAT_STRIPE_PRICE_ID=price_
# WORKSPACE_YEARLY_GUEST_SEAT_STRIPE_PRICE_ID=price_
#
# WORKSPACE_PLUS_SEAT_STRIPE_PRODUCT_ID=prod_
# WORKSPACE_MONTHLY_PLUS_SEAT_STRIPE_PRICE_ID=price_
# WORKSPACE_YEARLY_PLUS_SEAT_STRIPE_PRICE_ID=price_
#
# WORKSPACE_BUSINESS_SEAT_STRIPE_PRODUCT_ID=prod_
# WORKSPACE_MONTHLY_BUSINESS_SEAT_STRIPE_PRICE_ID=price_
# WORKSPACE_YEARLY_BUSINESS_SEAT_STRIPE_PRICE_ID=price_
#
# WORKSPACE_TEAM_SEAT_STRIPE_PRODUCT_ID=
# WORKSPACE_MONTHLY_TEAM_SEAT_STRIPE_PRICE_ID=
# WORKSPACE_YEARLY_TEAM_SEAT_STRIPE_PRICE_ID=
-17
View File
@@ -13,23 +13,6 @@ RATELIMITER_ENABLED='false'
#### Stripe product ids ####
WORKSPACE_GUEST_SEAT_STRIPE_PRODUCT_ID='prod_RsMHWCX85KBTJd'
WORKSPACE_MONTHLY_GUEST_SEAT_STRIPE_PRICE_ID='price_1QybYa7yKEDpA6qK7p7U3BTE'
WORKSPACE_YEARLY_GUEST_SEAT_STRIPE_PRICE_ID='price_1QybZ77yKEDpA6qKqpBSV0OS'
WORKSPACE_STARTER_SEAT_STRIPE_PRODUCT_ID='prod_RsMItzuLAEKy50'
WORKSPACE_MONTHLY_STARTER_SEAT_STRIPE_PRICE_ID='price_1QybZY7yKEDpA6qKl9TfgArD'
WORKSPACE_YEARLY_STARTER_SEAT_STRIPE_PRICE_ID='price_1Qyba07yKEDpA6qKvH6iqDdU'
WORKSPACE_PLUS_SEAT_STRIPE_PRODUCT_ID='prod_RsMJxH2rfHbJRt'
WORKSPACE_MONTHLY_PLUS_SEAT_STRIPE_PRICE_ID='price_1Qybaf7yKEDpA6qKHbS0lVXA'
WORKSPACE_YEARLY_PLUS_SEAT_STRIPE_PRICE_ID='price_1Qybb97yKEDpA6qKEKXI0F2V'
WORKSPACE_BUSINESS_SEAT_STRIPE_PRODUCT_ID='prod_RsMKVRwEVBPl60'
WORKSPACE_MONTHLY_BUSINESS_SEAT_STRIPE_PRICE_ID='price_1Qybbh7yKEDpA6qKqlN7fKUA'
WORKSPACE_YEARLY_BUSINESS_SEAT_STRIPE_PRICE_ID='price_1Qybc67yKEDpA6qKtJ3DvClM'
#### NEW ####
WORKSPACE_TEAM_SEAT_STRIPE_PRODUCT_ID='prod_RsMMdAkZIkAQRb'
WORKSPACE_MONTHLY_TEAM_SEAT_GBP_STRIPE_PRICE_ID='price_1Qybdi7yKEDpA6qKlSaz9cFT'
WORKSPACE_YEARLY_TEAM_SEAT_GBP_STRIPE_PRICE_ID='price_1R9qjc7yKEDpA6qKablYeaCe'
@@ -3,10 +3,6 @@ extend type WorkspaceMutations {
}
enum PaidWorkspacePlans {
starter
plus
business
# New plans
team
teamUnlimited
pro
@@ -60,15 +56,8 @@ type WorkspaceBillingMutations {
enum WorkspacePlans {
free
starter
plus
business
unlimited
academia
starterInvoiced
plusInvoiced
businessInvoiced
# New plans
team
teamUnlimited
teamUnlimitedInvoiced
@@ -82,8 +71,6 @@ enum WorkspacePlanStatuses {
paymentFailed
cancelationScheduled
canceled
trial
expired
}
enum WorkspacePaymentMethod {
@@ -1,10 +1,15 @@
import { CommandModule } from 'yargs'
import { cliLogger as logger } from '@/observability/logging'
import { getWorkspaceBySlugOrIdFactory } from '@/modules/workspaces/repositories/workspaces'
import {
getWorkspaceBySlugOrIdFactory,
getWorkspaceFactory
} from '@/modules/workspaces/repositories/workspaces'
import { db } from '@/db/knex'
import { upsertPaidWorkspacePlanFactory } from '@/modules/gatekeeper/repositories/billing'
import { upsertWorkspacePlanFactory } from '@/modules/gatekeeper/repositories/billing'
import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace'
import { PaidWorkspacePlans, PaidWorkspacePlanStatuses } from '@speckle/shared'
import { getEventBus } from '@/modules/shared/services/eventBus'
import { updateWorkspacePlanFactory } from '@/modules/gatekeeper/services/workspacePlans'
const command: CommandModule<
unknown,
@@ -24,21 +29,14 @@ const command: CommandModule<
plan: {
describe: 'Plan to set the status for',
type: 'string',
default: 'business',
choices: ['business', 'starter', 'plus']
default: PaidWorkspacePlans.Team,
choices: [PaidWorkspacePlans.Team, PaidWorkspacePlans.Pro]
},
status: {
describe: 'Status to set for the workspace plan',
type: 'string',
default: 'valid',
choices: [
'valid',
'trial',
'expired',
'paymentFailed',
'cancelationScheduled',
'canceled'
]
choices: ['valid', 'paymentFailed', 'cancelationScheduled', 'canceled']
}
},
handler: async (args) => {
@@ -52,14 +50,17 @@ const command: CommandModule<
)
}
await upsertPaidWorkspacePlanFactory({ db })({
workspacePlan: {
createdAt: new Date(),
workspaceId: workspace.id,
name: args.plan,
status: args.status
}
const updateWorkspacePlan = updateWorkspacePlanFactory({
getWorkspace: getWorkspaceFactory({ db }),
upsertWorkspacePlan: upsertWorkspacePlanFactory({ db }),
emitEvent: getEventBus().emit
})
await updateWorkspacePlan({
workspaceId: workspace.id,
name: args.plan,
status: args.status
})
logger.info(`Plan set!`)
}
}
@@ -1942,11 +1942,8 @@ export type OnboardingCompletionInput = {
};
export const PaidWorkspacePlans = {
Business: 'business',
Plus: 'plus',
Pro: 'pro',
ProUnlimited: 'proUnlimited',
Starter: 'starter',
Team: 'team',
TeamUnlimited: 'teamUnlimited'
} as const;
@@ -4901,9 +4898,7 @@ export type WorkspacePlanPrice = {
export const WorkspacePlanStatuses = {
CancelationScheduled: 'cancelationScheduled',
Canceled: 'canceled',
Expired: 'expired',
PaymentFailed: 'paymentFailed',
Trial: 'trial',
Valid: 'valid'
} as const;
@@ -4916,16 +4911,10 @@ export type WorkspacePlanUsage = {
export const WorkspacePlans = {
Academia: 'academia',
Business: 'business',
BusinessInvoiced: 'businessInvoiced',
Free: 'free',
Plus: 'plus',
PlusInvoiced: 'plusInvoiced',
Pro: 'pro',
ProUnlimited: 'proUnlimited',
ProUnlimitedInvoiced: 'proUnlimitedInvoiced',
Starter: 'starter',
StarterInvoiced: 'starterInvoiced',
Team: 'team',
TeamUnlimited: 'teamUnlimited',
TeamUnlimitedInvoiced: 'teamUnlimitedInvoiced',
@@ -1,5 +1,18 @@
import { ServerAcl, StreamAcl, UserEmails, Users, knex } from '@/modules/core/dbSchema'
import { ServerAclRecord, UserRecord, UserWithRole } from '@/modules/core/helpers/types'
import {
ServerAcl,
StreamAcl,
Streams,
UserEmails,
Users,
knex
} from '@/modules/core/dbSchema'
import {
ServerAclRecord,
StreamAclRecord,
StreamRecord,
UserRecord,
UserWithRole
} from '@/modules/core/helpers/types'
import { Nullable } from '@/modules/shared/helpers/typeHelper'
import { clamp, isArray, omit } from 'lodash'
import { metaHelpers } from '@/modules/core/helpers/meta'
@@ -36,11 +49,14 @@ import {
UpdateUserServerRole
} from '@/modules/core/domain/users/operations'
import { removePrivateFields } from '@/modules/core/helpers/userHelper'
import { WorkspaceAcl } from '@/modules/workspacesCore/helpers/db'
export type { UserWithOptionalRole, GetUserParams }
const tables = {
users: (db: Knex) => db<UserRecord>(Users.name),
serverAcl: (db: Knex) => db<ServerAclRecord>(ServerAcl.name)
serverAcl: (db: Knex) => db<ServerAclRecord>(ServerAcl.name),
streamAcl: (db: Knex) => db<StreamAclRecord>(StreamAcl.name),
streams: (db: Knex) => db<StreamRecord>(Streams.name)
}
function sanitizeUserRecord<T extends Nullable<UserRecord>>(user: T): T {
@@ -508,9 +524,32 @@ export const lookupUsersFactory =
// limit to given project
if (projectId) {
// Workspace implicit roles logic:
// - User must have an explicit stream acl OR
// - User must have a project workspace acl w/ non-guest role
query
.innerJoin(StreamAcl.name, StreamAcl.col.userId, Users.col.id)
.andWhere(StreamAcl.col.resourceId, projectId)
.innerJoin(Streams.name, (j1) => {
j1.onVal(Streams.col.id, projectId)
})
.leftJoin(StreamAcl.name, (j1) => {
j1.on(StreamAcl.col.resourceId, Streams.col.id).andOn(
StreamAcl.col.userId,
Users.col.id
)
})
.leftJoin(WorkspaceAcl.name, (j1) => {
j1.on(WorkspaceAcl.col.workspaceId, Streams.col.workspaceId).andOn(
WorkspaceAcl.col.userId,
Users.col.id
)
})
.andWhere((w1) => {
w1.whereNotNull(StreamAcl.col.role).orWhere(
WorkspaceAcl.col.role,
'!=',
Roles.Workspace.Guest
)
})
}
const rows = (await query) as UserRecord[]
@@ -38,6 +38,7 @@ import { getServerInfoFactory } from '@/modules/core/repositories/server'
import { WorkspaceReadOnlyError } from '@/modules/gatekeeper/errors/billing'
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
import { getEventBus } from '@/modules/shared/services/eventBus'
import { PaidWorkspacePlanStatuses } from '@speckle/shared'
const getServerInfo = getServerInfoFactory({ db })
const getUser = legacyGetUserFactory({ db })
@@ -106,7 +107,7 @@ describe('Objects graphql @core', () => {
// Make the project read-only
await db('workspace_plans')
.update({ status: 'expired' })
.update({ status: PaidWorkspacePlanStatuses.Canceled })
.where({ workspaceId: workspace!.id })
const objectCreateRes = await apollo.execute(CreateObjectDocument, {
@@ -38,7 +38,7 @@ import {
storeTokenResourceAccessDefinitionsFactory,
storeTokenScopesFactory
} from '@/modules/core/repositories/tokens'
import { Scopes } from '@speckle/shared'
import { PaidWorkspacePlans, Scopes } from '@speckle/shared'
import { expect } from 'chai'
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
import { getEventBus } from '@/modules/shared/services/eventBus'
@@ -108,7 +108,7 @@ describe('Objects REST @core', () => {
slug: ''
}
await createTestWorkspace(workspace, user, {
addPlan: { name: 'business', status: 'expired' }
addPlan: { name: PaidWorkspacePlans.Team, status: 'canceled' }
})
const project = {
@@ -92,7 +92,7 @@ import {
} from '@/test/speckle-helpers/regions'
import { BasicTestStream, createTestStreams } from '@/test/speckle-helpers/streamHelper'
import { faker } from '@faker-js/faker'
import { Optional, Roles, Scopes, ServerScope } from '@speckle/shared'
import { Optional, Roles, Scopes, ServerScope, WorkspacePlans } from '@speckle/shared'
import { expect } from 'chai'
const validateStreamAccess = validateStreamAccessFactory({ authorizeResolver })
@@ -239,10 +239,12 @@ describe('Core GraphQL Subscriptions (New)', () => {
before(async () => {
await Promise.all([
createTestWorkspace(myMainWorkspace, me, {
regionKey: isMultiRegion ? getMainTestRegionKey() : undefined
regionKey: isMultiRegion ? getMainTestRegionKey() : undefined,
addPlan: WorkspacePlans.Pro
}),
createTestWorkspace(otherGuysWorkspace, otherGuy, {
regionKey: isMultiRegion ? getMainTestRegionKey() : undefined
regionKey: isMultiRegion ? getMainTestRegionKey() : undefined,
addPlan: WorkspacePlans.Pro
})
])
@@ -1922,11 +1922,8 @@ export type OnboardingCompletionInput = {
};
export const PaidWorkspacePlans = {
Business: 'business',
Plus: 'plus',
Pro: 'pro',
ProUnlimited: 'proUnlimited',
Starter: 'starter',
Team: 'team',
TeamUnlimited: 'teamUnlimited'
} as const;
@@ -4881,9 +4878,7 @@ export type WorkspacePlanPrice = {
export const WorkspacePlanStatuses = {
CancelationScheduled: 'cancelationScheduled',
Canceled: 'canceled',
Expired: 'expired',
PaymentFailed: 'paymentFailed',
Trial: 'trial',
Valid: 'valid'
} as const;
@@ -4896,16 +4891,10 @@ export type WorkspacePlanUsage = {
export const WorkspacePlans = {
Academia: 'academia',
Business: 'business',
BusinessInvoiced: 'businessInvoiced',
Free: 'free',
Plus: 'plus',
PlusInvoiced: 'plusInvoiced',
Pro: 'pro',
ProUnlimited: 'proUnlimited',
ProUnlimitedInvoiced: 'proUnlimitedInvoiced',
Starter: 'starter',
StarterInvoiced: 'starterInvoiced',
Team: 'team',
TeamUnlimited: 'teamUnlimited',
TeamUnlimitedInvoiced: 'teamUnlimitedInvoiced',
@@ -13,9 +13,6 @@ import {
Optional,
PaidWorkspacePlan,
PaidWorkspacePlans,
PaidWorkspacePlansNew,
PaidWorkspacePlansOld,
TrialWorkspacePlan,
UnpaidWorkspacePlan,
WorkspacePlan,
WorkspacePlanBillingIntervals
@@ -42,10 +39,6 @@ export type GetWorkspaceWithPlan = (args: {
workspaceId: string
}) => Promise<Optional<Workspace & { plan: Nullable<WorkspacePlan> }>>
export type UpsertTrialWorkspacePlan = (args: {
workspacePlan: TrialWorkspacePlan
}) => Promise<void>
export type UpsertPaidWorkspacePlan = (args: {
workspacePlan: PaidWorkspacePlan
}) => Promise<void>
@@ -131,7 +124,7 @@ export const SubscriptionData = z.object({
status: z.union([
z.literal('incomplete'),
z.literal('incomplete_expired'),
z.literal('trialing'),
z.literal('trialing'), // TODO: Should we get rid of trial related states?
z.literal('active'),
z.literal('past_due'),
z.literal('canceled'),
@@ -145,20 +138,12 @@ export const SubscriptionData = z.object({
export type SubscriptionData = z.infer<typeof SubscriptionData>
export const calculateSubscriptionSeats = ({
subscriptionData,
guestSeatProductId
subscriptionData
}: {
subscriptionData: SubscriptionData
guestSeatProductId: string
}): { plan: number; guest: number } => {
const guestProduct = subscriptionData.products.find(
(p) => p.productId === guestSeatProductId
)
const planProduct = subscriptionData.products.find(
(p) => p.productId !== guestSeatProductId
)
return { guest: guestProduct?.quantity || 0, plan: planProduct?.quantity || 0 }
}): number => {
const product = subscriptionData.products[0]
return product?.quantity || 0
}
export type UpsertWorkspaceSubscription = (args: {
@@ -189,16 +174,6 @@ export type GetWorkspacePlanProductId = (args: {
workspacePlan: WorkspacePricingProducts
}) => string
export type GbpOnlyPrice = { gbp: string }
type GbpOnlyProductPrice = {
monthly: GbpOnlyPrice
yearly: GbpOnlyPrice
}
type OldProductPriceIds = Record<
PaidWorkspacePlansOld | 'guest',
{ productId: string } & GbpOnlyProductPrice
>
export type MultiCurrencyPrice = {
usd: string
gbp: string
@@ -208,20 +183,11 @@ type MultiCurrencyProductPrice = {
yearly: MultiCurrencyPrice
}
export const isMultiCurrencyPrice = (
priceIds: GbpOnlyPrice | MultiCurrencyPrice
): priceIds is MultiCurrencyPrice =>
Object.values(Currency)
.map((c) => c in priceIds)
.every((p) => p === true)
type NewProductPriceIds = Record<
PaidWorkspacePlansNew,
export type WorkspacePlanProductAndPriceIds = Record<
PaidWorkspacePlans,
{ productId: string } & MultiCurrencyProductPrice
>
export type WorkspacePlanProductAndPriceIds = OldProductPriceIds & NewProductPriceIds
export type GetWorkspacePlanProductAndPriceIds = () => WorkspacePlanProductAndPriceIds
export type SubscriptionDataInput = OverrideProperties<
SubscriptionData,
@@ -17,10 +17,6 @@ export type WorkspaceFeatureAccessFunction = (args: {
workspaceId: string
}) => Promise<boolean>
export type ChangeExpiredTrialWorkspacePlanStatuses = (args: {
numberOfDays: number
}) => Promise<WorkspacePlan[]>
export type GetWorkspacesByPlanDaysTillExpiry = (args: {
daysTillExpiry: number
planValidFor: number
@@ -4,7 +4,7 @@ import {
getWorkspaceSubscriptionFactory
} from '@/modules/gatekeeper/repositories/billing'
import { countSeatsByTypeInWorkspaceFactory } from '@/modules/gatekeeper/repositories/workspaceSeat'
import { addWorkspaceSubscriptionSeatIfNeededFactoryNew } from '@/modules/gatekeeper/services/subscriptions'
import { addWorkspaceSubscriptionSeatIfNeededFactory } from '@/modules/gatekeeper/services/subscriptions'
import {
getWorkspacePlanPriceId,
getWorkspacePlanProductId
@@ -21,7 +21,7 @@ export const initializeEventListenersFactory =
const quitCbs = [
eventBus.listen(WorkspaceEvents.SeatUpdated, async ({ payload }) => {
const addWorkspaceSubscriptionSeatIfNeeded =
addWorkspaceSubscriptionSeatIfNeededFactoryNew({
addWorkspaceSubscriptionSeatIfNeededFactory({
getWorkspacePlan: getWorkspacePlanFactory({ db }),
getWorkspaceSubscription: getWorkspaceSubscriptionFactory({ db }),
getWorkspacePlanPriceId,
@@ -3,7 +3,6 @@ import type { Resolvers } from '@/modules/core/graph/generated/graphql'
import { authorizeResolver } from '@/modules/shared'
import { Roles, throwUncoveredError } from '@speckle/shared'
import {
countWorkspaceRoleWithOptionalProjectRoleFactory,
getWorkspaceFactory,
getWorkspaceRoleForUserFactory,
getWorkspacesProjectsCountsFactory
@@ -38,15 +37,11 @@ import {
} from '@/modules/gatekeeper/domain/billing'
import { WorkspacePaymentMethod } from '@/test/graphql/generated/graphql'
import { LogicError } from '@/modules/shared/errors'
import { isNewPlanType } from '@/modules/gatekeeper/helpers/plans'
import { getWorkspacePlanProductPricesFactory } from '@/modules/gatekeeper/services/prices'
import { extendLoggerComponent } from '@/observability/logging'
import { createCheckoutSessionFactory } from '@/modules/gatekeeper/clients/checkout/createCheckoutSession'
import { startCheckoutSessionFactory } from '@/modules/gatekeeper/services/checkout/startCheckoutSession'
import {
upgradeWorkspaceSubscriptionFactoryNew,
upgradeWorkspaceSubscriptionFactoryOld
} from '@/modules/gatekeeper/services/subscriptions/upgradeWorkspaceSubscription'
import { upgradeWorkspaceSubscriptionFactory } from '@/modules/gatekeeper/services/subscriptions/upgradeWorkspaceSubscription'
import {
countSeatsByTypeInWorkspaceFactory,
createWorkspaceSeatFactory
@@ -66,11 +61,6 @@ const { FF_GATEKEEPER_MODULE_ENABLED, FF_BILLING_INTEGRATION_ENABLED } =
const getWorkspacePlan = getWorkspacePlanFactory({ db })
async function shouldUseNewCheckoutFlow(workspaceId: string) {
const workspacePlan = await getWorkspacePlan({ workspaceId })
return workspacePlan && isNewPlanType(workspacePlan.name)
}
export = FF_GATEKEEPER_MODULE_ENABLED
? ({
Workspace: {
@@ -81,9 +71,6 @@ export = FF_GATEKEEPER_MODULE_ENABLED
if (!workspacePlan) return null
let paymentMethod: WorkspacePaymentMethod
switch (workspacePlan.name) {
case 'starter':
case 'plus':
case 'business':
case 'team':
case 'teamUnlimited':
case 'pro':
@@ -95,9 +82,6 @@ export = FF_GATEKEEPER_MODULE_ENABLED
case 'free':
paymentMethod = WorkspacePaymentMethod.Unpaid
break
case 'starterInvoiced':
case 'plusInvoiced':
case 'businessInvoiced':
case 'proUnlimitedInvoiced':
case 'teamUnlimitedInvoiced':
paymentMethod = WorkspacePaymentMethod.Invoice
@@ -244,13 +228,7 @@ export = FF_GATEKEEPER_MODULE_ENABLED
switch (workspacePlan.name) {
case 'unlimited':
case 'academia':
case 'business':
case 'businessInvoiced':
case 'free':
case 'plus':
case 'plusInvoiced':
case 'starter':
case 'starterInvoiced':
case 'proUnlimitedInvoiced':
case 'teamUnlimitedInvoiced':
// not stripe paid plans and old plans do not have seats available
@@ -397,9 +375,7 @@ export = FF_GATEKEEPER_MODULE_ENABLED
Roles.Workspace.Admin,
ctx.resourceAccessRules
)
const isNewFlow = await shouldUseNewCheckoutFlow(workspaceId)
if (!isNewFlow)
throw new Error('Checkout for old plans is not supported any more')
const createCheckoutSession = createCheckoutSessionFactory({
stripe: getStripeClient(),
frontendOrigin: getFrontendOrigin(),
@@ -444,41 +420,22 @@ export = FF_GATEKEEPER_MODULE_ENABLED
)
const stripe = getStripeClient()
const currentPlan = await getWorkspacePlan({ workspaceId })
const upgradeWorkspaceSubscription =
currentPlan && isNewPlanType(currentPlan.name)
? upgradeWorkspaceSubscriptionFactoryNew({
getWorkspacePlan: getWorkspacePlanFactory({ db }),
reconcileSubscriptionData: reconcileWorkspaceSubscriptionFactory({
stripe
}),
countSeatsByTypeInWorkspace: countSeatsByTypeInWorkspaceFactory({
db
}),
getWorkspaceSubscription: getWorkspaceSubscriptionFactory({ db }),
getWorkspacePlanPriceId,
getWorkspacePlanProductId,
upsertWorkspacePlan: upsertPaidWorkspacePlanFactory({ db }),
updateWorkspaceSubscription: upsertWorkspaceSubscriptionFactory({
db
})
})
: upgradeWorkspaceSubscriptionFactoryOld({
getWorkspacePlan: getWorkspacePlanFactory({ db }),
reconcileSubscriptionData: reconcileWorkspaceSubscriptionFactory({
stripe
}),
countWorkspaceRole: countWorkspaceRoleWithOptionalProjectRoleFactory({
db
}),
getWorkspaceSubscription: getWorkspaceSubscriptionFactory({ db }),
getWorkspacePlanPriceId,
getWorkspacePlanProductId,
upsertWorkspacePlan: upsertPaidWorkspacePlanFactory({ db }),
updateWorkspaceSubscription: upsertWorkspaceSubscriptionFactory({
db
})
})
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
getWorkspacePlan: getWorkspacePlanFactory({ db }),
reconcileSubscriptionData: reconcileWorkspaceSubscriptionFactory({
stripe
}),
countSeatsByTypeInWorkspace: countSeatsByTypeInWorkspaceFactory({
db
}),
getWorkspaceSubscription: getWorkspaceSubscriptionFactory({ db }),
getWorkspacePlanPriceId,
getWorkspacePlanProductId,
upsertWorkspacePlan: upsertPaidWorkspacePlanFactory({ db }),
updateWorkspaceSubscription: upsertWorkspaceSubscriptionFactory({
db
})
})
await withOperationLogging(
async () =>
await upgradeWorkspaceSubscription({
@@ -1,16 +1,5 @@
import {
isNewWorkspacePlan,
PaidWorkspacePlansNew,
PaidWorkspacePlansOld,
WorkspacePlans
} from '@speckle/shared'
import { PaidWorkspacePlans, WorkspacePlans } from '@speckle/shared'
export const isNewPaidPlanType = (plan: WorkspacePlans): boolean => {
return (Object.values(PaidWorkspacePlansNew) as string[]).includes(plan)
}
export const isNewPlanType = (plan: WorkspacePlans): boolean => isNewWorkspacePlan(plan)
export const isOldPaidPlanType = (plan: WorkspacePlans): boolean => {
return (Object.values(PaidWorkspacePlansOld) as string[]).includes(plan)
export const isPaidPlanType = (plan: WorkspacePlans): boolean => {
return (Object.values(PaidWorkspacePlans) as string[]).includes(plan)
}
+10 -133
View File
@@ -19,38 +19,23 @@ import {
releaseTaskLockFactory
} from '@/modules/core/repositories/scheduledTasks'
import {
changeExpiredTrialWorkspacePlanStatusesFactory,
getWorkspacePlanByProjectIdFactory,
getWorkspacePlanFactory,
getWorkspacesByPlanAgeFactory,
getWorkspaceSubscriptionsPastBillingCycleEndFactoryNewPlans,
getWorkspaceSubscriptionsPastBillingCycleEndFactoryOldPlans,
getWorkspaceSubscriptionsPastBillingCycleEndFactory,
upsertWorkspaceSubscriptionFactory
} from '@/modules/gatekeeper/repositories/billing'
import {
countWorkspaceRoleWithOptionalProjectRoleFactory,
getWorkspaceCollaboratorsFactory
} from '@/modules/workspaces/repositories/workspaces'
import {
getSubscriptionDataFactory,
reconcileWorkspaceSubscriptionFactory
} from '@/modules/gatekeeper/clients/stripe'
import { ScheduleExecution } from '@/modules/core/domain/scheduledTasks/operations'
import { EventBusEmit, getEventBus } from '@/modules/shared/services/eventBus'
import { sendWorkspaceTrialExpiresEmailFactory } from '@/modules/gatekeeper/services/trialEmails'
import { getServerInfoFactory } from '@/modules/core/repositories/server'
import { findEmailsByUserIdFactory } from '@/modules/core/repositories/userEmails'
import { sendEmail } from '@/modules/emails/services/sending'
import { renderEmail } from '@/modules/emails/services/emailRendering'
import coreModule from '@/modules/core/index'
import { isProjectReadOnlyFactory } from '@/modules/gatekeeper/services/readOnly'
import { WorkspaceReadOnlyError } from '@/modules/gatekeeper/errors/billing'
import { InvalidLicenseError } from '@/modules/gatekeeper/errors/license'
import {
downscaleWorkspaceSubscriptionFactoryNew,
downscaleWorkspaceSubscriptionFactoryOld,
manageSubscriptionDownscaleFactoryNew,
manageSubscriptionDownscaleFactoryOld
downscaleWorkspaceSubscriptionFactory,
manageSubscriptionDownscaleFactory
} from '@/modules/gatekeeper/services/subscriptions/manageSubscriptionDownscale'
import { countSeatsByTypeInWorkspaceFactory } from '@/modules/gatekeeper/repositories/workspaceSeat'
@@ -68,31 +53,16 @@ const scheduleWorkspaceSubscriptionDownscale = ({
scheduleExecution: ScheduleExecution
}) => {
const stripe = getStripeClient()
const manageSubscriptionDownscaleOld = manageSubscriptionDownscaleFactoryOld({
downscaleWorkspaceSubscription: downscaleWorkspaceSubscriptionFactoryOld({
countWorkspaceRole: countWorkspaceRoleWithOptionalProjectRoleFactory({ db }),
getWorkspacePlan: getWorkspacePlanFactory({ db }),
reconcileSubscriptionData: reconcileWorkspaceSubscriptionFactory({ stripe }),
getWorkspacePlanProductId
}),
getWorkspaceSubscriptions:
getWorkspaceSubscriptionsPastBillingCycleEndFactoryOldPlans({
db
}),
updateWorkspaceSubscription: upsertWorkspaceSubscriptionFactory({ db })
})
const manageSubscriptionDownscaleNew = manageSubscriptionDownscaleFactoryNew({
downscaleWorkspaceSubscription: downscaleWorkspaceSubscriptionFactoryNew({
const manageSubscriptionDownscale = manageSubscriptionDownscaleFactory({
downscaleWorkspaceSubscription: downscaleWorkspaceSubscriptionFactory({
countSeatsByTypeInWorkspace: countSeatsByTypeInWorkspaceFactory({ db }),
getWorkspacePlan: getWorkspacePlanFactory({ db }),
reconcileSubscriptionData: reconcileWorkspaceSubscriptionFactory({ stripe }),
getWorkspacePlanProductId
}),
getWorkspaceSubscriptions:
getWorkspaceSubscriptionsPastBillingCycleEndFactoryNewPlans({
db
}),
getWorkspaceSubscriptions: getWorkspaceSubscriptionsPastBillingCycleEndFactory({
db
}),
getSubscriptionData: getSubscriptionDataFactory({ stripe }),
updateWorkspaceSubscription: upsertWorkspaceSubscriptionFactory({ db })
})
@@ -103,99 +73,12 @@ const scheduleWorkspaceSubscriptionDownscale = ({
'WorkspaceSubscriptionDownscale',
async (_scheduledTime, { logger }) => {
await Promise.all([
manageSubscriptionDownscaleOld({ logger }), // Only takes old plans subscriptions
manageSubscriptionDownscaleNew({ logger }) // Only takes new plans subscriptions
manageSubscriptionDownscale({ logger }) // Only takes new plans subscriptions
])
}
)
}
const scheduleWorkspaceTrialEmails = ({
scheduleExecution
}: {
scheduleExecution: ScheduleExecution
}) => {
const sendWorkspaceTrialEmail = sendWorkspaceTrialExpiresEmailFactory({
getServerInfo: getServerInfoFactory({ db }),
getUserEmails: findEmailsByUserIdFactory({ db }),
getWorkspaceCollaborators: getWorkspaceCollaboratorsFactory({ db }),
sendEmail,
renderEmail
})
// TODO: make this a daily thing
// const cronExpression = '*/5 * * * * *'
// every day at noon
const cronExpression = '0 12 * * *'
return scheduleExecution(
cronExpression,
'WorkspaceTrialEmails',
async (_scheduledTime, { logger }) => {
logger.info('Sending workspace trial emails.')
const getWorkspacesByPlanAge = getWorkspacesByPlanAgeFactory({ db })
const trialValidForDays = 31
const trialWorkspacesExpireIn3Days = await getWorkspacesByPlanAge({
daysTillExpiry: 3,
planValidFor: trialValidForDays,
plan: 'starter',
status: 'trial'
})
if (trialWorkspacesExpireIn3Days.length) {
await Promise.all(
trialWorkspacesExpireIn3Days.map((workspace) =>
sendWorkspaceTrialEmail({ workspace, expiresInDays: 3 })
)
)
}
const trialWorkspacesExpireToday = await getWorkspacesByPlanAge({
daysTillExpiry: 0,
planValidFor: trialValidForDays,
plan: 'starter',
status: 'trial'
})
if (trialWorkspacesExpireToday.length) {
await Promise.all(
trialWorkspacesExpireToday.map((workspace) =>
sendWorkspaceTrialEmail({ workspace, expiresInDays: 0 })
)
)
}
}
)
}
const scheduleWorkspaceTrialExpiry = ({
scheduleExecution,
emit
}: {
scheduleExecution: ScheduleExecution
emit: EventBusEmit
}) => {
const changeExpiredStatuses = changeExpiredTrialWorkspacePlanStatusesFactory({ db })
const cronExpression = '*/5 * * * *'
return scheduleExecution(
cronExpression,
'WorkspaceTrialExpiry',
async (_scheduledTime, { logger }) => {
const expiredWorkspacePlans = await changeExpiredStatuses({ numberOfDays: 31 })
if (expiredWorkspacePlans.length) {
logger.info(
{ workspaceIds: expiredWorkspacePlans.map((p) => p.workspaceId) },
'Workspace trial expired for {workspaceIds}.'
)
await Promise.all(
expiredWorkspacePlans.map(async (plan) =>
emit({
eventName: 'gatekeeper.workspace-trial-expired',
payload: { workspaceId: plan.workspaceId }
})
)
)
}
}
)
}
let scheduledTasks: cron.ScheduledTask[] = []
let quitListeners: (() => void) | undefined = undefined
@@ -221,18 +104,12 @@ const gatekeeperModule: SpeckleModule = {
getWorkspacePlanProductAndPriceIds()
app.use(getBillingRouter())
const eventBus = getEventBus()
const scheduleExecution = scheduleExecutionFactory({
acquireTaskLock: acquireTaskLockFactory({ db }),
releaseTaskLock: releaseTaskLockFactory({ db })
})
scheduledTasks = [
scheduleWorkspaceSubscriptionDownscale({ scheduleExecution }),
scheduleWorkspaceTrialEmails({ scheduleExecution }),
scheduleWorkspaceTrialExpiry({ scheduleExecution, emit: eventBus.emit })
]
scheduledTasks = [scheduleWorkspaceSubscriptionDownscale({ scheduleExecution })]
quitListeners = initializeEventListenersFactory({
db,
@@ -14,24 +14,18 @@ import {
GetWorkspaceSubscription,
GetWorkspaceSubscriptionBySubscriptionId,
GetWorkspaceSubscriptions,
UpsertTrialWorkspacePlan,
UpsertUnpaidWorkspacePlan,
GetWorkspaceWithPlan,
GetWorkspacePlansByWorkspaceId
} from '@/modules/gatekeeper/domain/billing'
import {
ChangeExpiredTrialWorkspacePlanStatuses,
GetWorkspacesByPlanDaysTillExpiry,
GetWorkspacePlanByProjectId
} from '@/modules/gatekeeper/domain/operations'
import { formatJsonArrayRecords } from '@/modules/shared/helpers/dbHelper'
import { Workspace } from '@/modules/workspacesCore/domain/types'
import { Workspaces } from '@/modules/workspacesCore/helpers/db'
import {
PaidWorkspacePlansNew,
PaidWorkspacePlansOld,
WorkspacePlan
} from '@speckle/shared'
import { PaidWorkspacePlans, WorkspacePlan } from '@speckle/shared'
import { Knex } from 'knex'
import { omit } from 'lodash'
@@ -122,29 +116,12 @@ export const upsertPaidWorkspacePlanFactory = ({
db: Knex
}): UpsertPaidWorkspacePlan => upsertWorkspacePlanFactory({ db })
export const upsertTrialWorkspacePlanFactory = ({
db
}: {
db: Knex
}): UpsertTrialWorkspacePlan => upsertWorkspacePlanFactory({ db })
export const upsertUnpaidWorkspacePlanFactory = ({
db
}: {
db: Knex
}): UpsertUnpaidWorkspacePlan => upsertWorkspacePlanFactory({ db })
export const changeExpiredTrialWorkspacePlanStatusesFactory =
({ db }: { db: Knex }): ChangeExpiredTrialWorkspacePlanStatuses =>
async ({ numberOfDays }) => {
return await tables
.workspacePlans(db)
.where({ status: 'trial' })
.andWhereRaw(`"createdAt" + make_interval(days => ${numberOfDays}) < now()`)
.update({ status: 'expired' })
.returning('*')
}
export const getWorkspacesByPlanAgeFactory =
({ db }: { db: Knex }): GetWorkspacesByPlanDaysTillExpiry =>
async ({ daysTillExpiry, planValidFor, plan, status }) => {
@@ -235,10 +212,7 @@ export const getWorkspaceSubscriptionBySubscriptionIdFactory =
return subscription ?? null
}
const newPlans = Object.values(PaidWorkspacePlansNew)
const oldPlans = Object.values(PaidWorkspacePlansOld)
export const getWorkspaceSubscriptionsPastBillingCycleEndFactoryOldPlans =
export const getWorkspaceSubscriptionsPastBillingCycleEndFactory =
({ db }: { db: Knex }): GetWorkspaceSubscriptions =>
async () => {
const cycleEnd = new Date()
@@ -250,24 +224,7 @@ export const getWorkspaceSubscriptionsPastBillingCycleEndFactoryOldPlans =
WorkspacePlans.col.workspaceId,
'workspace_subscriptions.workspaceId'
)
.whereIn(WorkspacePlans.col.name, oldPlans)
.where('currentBillingCycleEnd', '<', cycleEnd)
.select(WorkspaceSubscriptions.cols)
}
export const getWorkspaceSubscriptionsPastBillingCycleEndFactoryNewPlans =
({ db }: { db: Knex }): GetWorkspaceSubscriptions =>
async () => {
const cycleEnd = new Date()
cycleEnd.setMinutes(cycleEnd.getMinutes() + 5)
return await tables
.workspaceSubscriptions(db)
.join(
WorkspacePlans.name,
WorkspacePlans.col.workspaceId,
'workspace_subscriptions.workspaceId'
)
.whereIn(WorkspacePlans.col.name, newPlans)
.whereIn(WorkspacePlans.col.name, Object.values(PaidWorkspacePlans))
.where('currentBillingCycleEnd', '<', cycleEnd)
.select(WorkspaceSubscriptions.cols)
}
@@ -97,13 +97,6 @@ export const startCheckoutSessionFactory =
checkoutSessionId: existingCheckoutSession?.id
})
break
// maybe, we can reactivate canceled plans via the sub in stripe, but this is fine too
// it will create a new customer and a new sub though, the reactivation would use the existing customer
case 'trial':
case 'expired':
// lets go ahead and pay
break
default:
throwUncoveredError(existingWorkspacePlan)
}
@@ -16,11 +16,9 @@ export const canWorkspaceAccessFeatureFactory =
if (!workspacePlan) return false
switch (workspacePlan.status) {
case 'valid':
case 'trial':
case 'paymentFailed':
case 'cancelationScheduled':
break
case 'expired':
case 'canceled':
return false
default:
@@ -5,37 +5,12 @@ import {
import { Currency } from '@/modules/gatekeeperCore/domain/billing'
import { expectToThrow } from '@/test/assertionHelper'
import { mockRedisCacheProviderFactory } from '@/test/redisHelper'
import {
PaidWorkspacePlans,
PaidWorkspacePlansNew,
WorkspaceGuestSeatType,
WorkspacePlanBillingIntervals
} from '@speckle/shared'
import { PaidWorkspacePlans, WorkspacePlanBillingIntervals } from '@speckle/shared'
import { expect } from 'chai'
import { flatten } from 'lodash'
import { WorkspacePlanProductAndPriceIds } from '@/modules/gatekeeper/domain/billing'
const testProductAndPriceIds: WorkspacePlanProductAndPriceIds = {
[WorkspaceGuestSeatType]: {
productId: 'prod_guest',
monthly: { gbp: 'price_guest_monthly_gbp' },
yearly: { gbp: 'price_guest_yearly_gbp' }
},
[PaidWorkspacePlans.Starter]: {
productId: 'prod_starter',
monthly: { gbp: 'price_starter_monthly_gbp' },
yearly: { gbp: 'price_starter_yearly_gbp' }
},
[PaidWorkspacePlans.Plus]: {
productId: 'prod_plus',
monthly: { gbp: 'price_plus_monthly_gbp' },
yearly: { gbp: 'price_plus_yearly_gbp' }
},
[PaidWorkspacePlans.Business]: {
productId: 'prod_business',
monthly: { gbp: 'price_business_monthly_gbp' },
yearly: { gbp: 'price_business_yearly_gbp' }
},
[PaidWorkspacePlans.Team]: {
productId: 'prod_team',
monthly: { gbp: 'price_team_monthly_gbp', usd: 'price_team_monthly_usd' },
@@ -71,7 +46,7 @@ const testProductAndPriceIds: WorkspacePlanProductAndPriceIds = {
}
const fakeGetRecurringPrices = async () => {
const pricePairs = Object.values(PaidWorkspacePlansNew).map((plan) => {
const pricePairs = Object.values(PaidWorkspacePlans).map((plan) => {
const { productId, monthly, yearly } = testProductAndPriceIds[plan]
return [
{
@@ -116,7 +91,7 @@ describe('prices @gatekeeper', () => {
expect(result).to.be.ok
for (const currency of Object.values(Currency)) {
const newPlans = result[currency]
for (const newPaidPlan of Object.values(PaidWorkspacePlansNew)) {
for (const newPaidPlan of Object.values(PaidWorkspacePlans)) {
const plan = newPlans[newPaidPlan]
for (const interval of Object.values(WorkspacePlanBillingIntervals)) {
const price = plan[interval]
@@ -13,7 +13,7 @@ import {
wrapFactoryWithCache
} from '@/modules/shared/utils/caching'
import {
PaidWorkspacePlansNew,
PaidWorkspacePlans,
TIME_MS,
WorkspacePlanBillingIntervals
} from '@speckle/shared'
@@ -29,7 +29,7 @@ export const getFreshWorkspacePlanProductPricesFactory =
const productAndPriceIds = deps.getWorkspacePlanProductAndPriceIds()
const productPrices = Object.values(Currency).reduce((acc, currency) => {
const currencyPrices = Object.values(PaidWorkspacePlansNew).reduce(
const currencyPrices = Object.values(PaidWorkspacePlans).reduce(
(acc, paidPlan) => {
const intervalPrices = Object.values(WorkspacePlanBillingIntervals).reduce(
(acc, interval) => {
@@ -16,9 +16,8 @@ import {
WorkspacePlanNotFoundError,
WorkspaceSubscriptionNotFoundError
} from '@/modules/gatekeeper/errors/billing'
import { isNewPlanType } from '@/modules/gatekeeper/helpers/plans'
import {
PaidWorkspacePlansNew,
PaidWorkspacePlans,
PaidWorkspacePlanStatuses,
throwUncoveredError
} from '@speckle/shared'
@@ -70,9 +69,6 @@ export const handleSubscriptionUpdateFactory =
if (status) {
switch (workspacePlan.name) {
case 'starter':
case 'plus':
case 'business':
case 'team':
case 'teamUnlimited':
case 'pro':
@@ -80,9 +76,6 @@ export const handleSubscriptionUpdateFactory =
break
case 'unlimited':
case 'academia':
case 'starterInvoiced':
case 'plusInvoiced':
case 'businessInvoiced':
case 'proUnlimitedInvoiced':
case 'teamUnlimitedInvoiced':
case 'free':
@@ -105,7 +98,7 @@ export const handleSubscriptionUpdateFactory =
}
}
export const addWorkspaceSubscriptionSeatIfNeededFactoryNew =
export const addWorkspaceSubscriptionSeatIfNeededFactory =
({
getWorkspacePlan,
getWorkspaceSubscription,
@@ -134,11 +127,6 @@ export const addWorkspaceSubscriptionSeatIfNeededFactoryNew =
const workspaceSubscription = await getWorkspaceSubscription({ workspaceId })
if (!workspaceSubscription) return
// if (!workspaceSubscription) throw new WorkspaceSubscriptionNotFoundError()
const isNewPlan = isNewPlanType(workspacePlan.name)
if (!isNewPlan) {
// old plans not supported
return
}
switch (workspacePlan.name) {
case 'team':
@@ -146,16 +134,13 @@ export const addWorkspaceSubscriptionSeatIfNeededFactoryNew =
case 'pro':
case 'proUnlimited':
// If viewer seat type, we don't need to do anything
if (seatType === WorkspaceSeatType.Viewer) return
case 'starter':
case 'plus':
case 'business':
break
if (seatType === WorkspaceSeatType.Viewer) {
return
} else {
break
}
case 'unlimited':
case 'academia':
case 'starterInvoiced':
case 'plusInvoiced':
case 'businessInvoiced':
case 'proUnlimitedInvoiced':
case 'teamUnlimitedInvoiced':
case 'free':
@@ -208,7 +193,7 @@ export const getTotalSeatsCountByPlanFactory =
workspacePlan,
subscriptionData
}: {
workspacePlan: PaidWorkspacePlansNew
workspacePlan: PaidWorkspacePlans
subscriptionData: Pick<SubscriptionData, 'products'>
}) => {
const productId = getWorkspacePlanProductId({
@@ -12,10 +12,7 @@ import {
WorkspacePlanMismatchError,
WorkspacePlanNotFoundError
} from '@/modules/gatekeeper/errors/billing'
import { calculateNewBillingCycleEnd } from '@/modules/gatekeeper/services/subscriptions/calculateNewBillingCycleEnd'
import { mutateSubscriptionDataWithNewValidSeatNumbers } from '@/modules/gatekeeper/services/subscriptions/mutateSubscriptionDataWithNewValidSeatNumbers'
import { NotImplementedError } from '@/modules/shared/errors'
import { CountWorkspaceRoleWithOptionalProjectRole } from '@/modules/workspaces/domain/operations'
import { Logger } from '@/observability/logging'
import { throwUncoveredError } from '@speckle/shared'
import { cloneDeep, isEqual } from 'lodash'
@@ -24,80 +21,7 @@ type DownscaleWorkspaceSubscription = (args: {
workspaceSubscription: WorkspaceSubscription
}) => Promise<boolean>
export const downscaleWorkspaceSubscriptionFactoryOld =
({
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 'teamUnlimited':
case 'pro':
case 'proUnlimited':
case 'proUnlimitedInvoiced':
case 'teamUnlimitedInvoiced':
// 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 downscaleWorkspaceSubscriptionFactoryNew =
export const downscaleWorkspaceSubscriptionFactory =
({
getWorkspacePlan,
countSeatsByTypeInWorkspace,
@@ -121,14 +45,8 @@ export const downscaleWorkspaceSubscriptionFactoryNew =
case 'pro':
case 'proUnlimited':
break
case 'starter':
case 'plus':
case 'business':
case 'unlimited':
case 'academia':
case 'starterInvoiced':
case 'plusInvoiced':
case 'businessInvoiced':
case 'proUnlimitedInvoiced':
case 'teamUnlimitedInvoiced':
case 'free':
@@ -160,48 +78,7 @@ export const downscaleWorkspaceSubscriptionFactoryNew =
return false
}
export const manageSubscriptionDownscaleFactoryOld =
({
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 manageSubscriptionDownscaleFactoryNew =
export const manageSubscriptionDownscaleFactory =
({
getWorkspaceSubscriptions,
downscaleWorkspaceSubscription,
@@ -219,7 +96,6 @@ export const manageSubscriptionDownscaleFactoryNew =
for (const workspaceSubscription of subscriptions) {
const log = logger.child({ workspaceId: workspaceSubscription.workspaceId })
try {
//TODO:
const subDownscaled = await downscaleWorkspaceSubscription({
workspaceSubscription
})
@@ -18,233 +18,18 @@ import {
WorkspacePlanUpgradeError,
WorkspaceSubscriptionNotFoundError
} from '@/modules/gatekeeper/errors/billing'
import {
isNewPaidPlanType,
isNewPlanType,
isOldPaidPlanType
} from '@/modules/gatekeeper/helpers/plans'
import { isPaidPlanType } from '@/modules/gatekeeper/helpers/plans'
import { calculateNewBillingCycleEnd } from '@/modules/gatekeeper/services/subscriptions/calculateNewBillingCycleEnd'
import { mutateSubscriptionDataWithNewValidSeatNumbers } from '@/modules/gatekeeper/services/subscriptions/mutateSubscriptionDataWithNewValidSeatNumbers'
import { isUpgradeWorkspacePlanValid } from '@/modules/gatekeeper/services/upgrades'
import { NotImplementedError } from '@/modules/shared/errors'
import { CountWorkspaceRoleWithOptionalProjectRole } from '@/modules/workspaces/domain/operations'
import {
PaidWorkspacePlans,
PaidWorkspacePlansNew,
throwUncoveredError,
WorkspacePlanBillingIntervals,
xor
WorkspacePlanBillingIntervals
} from '@speckle/shared'
import { cloneDeep } from 'lodash'
export const upgradeWorkspaceSubscriptionFactoryOld =
({
getWorkspacePlan,
getWorkspacePlanProductId,
getWorkspacePlanPriceId,
getWorkspaceSubscription,
reconcileSubscriptionData,
updateWorkspaceSubscription,
countWorkspaceRole,
upsertWorkspacePlan
}: {
getWorkspacePlan: GetWorkspacePlan
getWorkspacePlanProductId: GetWorkspacePlanProductId
getWorkspacePlanPriceId: GetWorkspacePlanPriceId
getWorkspaceSubscription: GetWorkspaceSubscription
reconcileSubscriptionData: ReconcileSubscriptionData
updateWorkspaceSubscription: UpsertWorkspaceSubscription
countWorkspaceRole: CountWorkspaceRoleWithOptionalProjectRole
upsertWorkspacePlan: UpsertPaidWorkspacePlan
}) =>
async ({
workspaceId,
targetPlan,
billingInterval
}: {
workspaceId: string
targetPlan: PaidWorkspacePlans
billingInterval: WorkspacePlanBillingIntervals
}) => {
const workspacePlan = await getWorkspacePlan({ workspaceId })
if (!workspacePlan) throw new WorkspacePlanNotFoundError()
switch (workspacePlan.name) {
case 'unlimited':
case 'academia':
case 'starterInvoiced':
case 'plusInvoiced':
case 'businessInvoiced':
case 'teamUnlimitedInvoiced':
case 'proUnlimitedInvoiced':
case 'free': // TODO: Don't we want to allow upgrades from free to paid?
throw new WorkspaceNotPaidPlanError()
case 'starter':
case 'plus':
case 'business':
break
case 'team':
case 'teamUnlimited':
case 'pro':
case 'proUnlimited':
throw new WorkspacePlanMismatchError()
default:
throwUncoveredError(workspacePlan)
}
switch (workspacePlan.status) {
case 'canceled':
case 'cancelationScheduled':
case 'paymentFailed':
case 'trial':
case 'expired':
throw new WorkspaceNotPaidPlanError()
case 'valid':
break
default:
throwUncoveredError(workspacePlan)
}
const workspaceSubscription = await getWorkspaceSubscription({ workspaceId })
if (!workspaceSubscription) throw new WorkspaceSubscriptionNotFoundError()
const planOrder: Record<PaidWorkspacePlans, number> = {
// old
business: 3,
plus: 2,
starter: 1,
// new
team: 1,
teamUnlimited: 2,
pro: 3,
proUnlimited: 4
}
if (isNewPlanType(workspacePlan.name) || isNewPlanType(targetPlan)) {
throw new NotImplementedError()
}
const planCheckers = [isNewPlanType, isOldPaidPlanType]
for (const isSpecificPlanType of planCheckers) {
const oldPlanFitsSchema = isSpecificPlanType(workspacePlan.name)
const newPlanFitsSchema = isSpecificPlanType(targetPlan)
if (xor(oldPlanFitsSchema, newPlanFitsSchema)) {
throw new WorkspacePlanUpgradeError(
'Attempting to switch between incompatible plan types'
)
}
}
if (isNewPlanType(targetPlan) || isNewPlanType(workspacePlan.name)) {
// Needs custom logic below for seats
throw new NotImplementedError()
}
if (
planOrder[workspacePlan.name] === planOrder[targetPlan] &&
workspaceSubscription.billingInterval === billingInterval
)
throw new WorkspacePlanUpgradeError("Can't upgrade to the same plan")
if (planOrder[workspacePlan.name] > planOrder[targetPlan])
throw new WorkspacePlanUpgradeError("Can't upgrade to a less expensive plan")
switch (billingInterval) {
case 'monthly':
if (workspaceSubscription.billingInterval === 'yearly')
throw new WorkspacePlanUpgradeError(
"Can't upgrade from yearly to monthly billing cycle"
)
case 'yearly':
break
default:
throwUncoveredError(billingInterval)
}
const subscriptionData: SubscriptionDataInput = cloneDeep(
workspaceSubscription.subscriptionData
)
const product = subscriptionData.products.find(
(p) =>
p.productId === getWorkspacePlanProductId({ workspacePlan: workspacePlan.name })
)
if (!product) throw new WorkspacePlanMismatchError()
const [guestCount, memberCount, adminCount] = await Promise.all([
countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:guest' }),
countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:member' }),
countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:admin' })
])
workspaceSubscription.updatedAt = new Date()
if (workspaceSubscription.billingInterval !== billingInterval) {
workspaceSubscription.billingInterval = billingInterval
workspaceSubscription.currentBillingCycleEnd = calculateNewBillingCycleEnd({
workspaceSubscription
})
const guestProduct = subscriptionData.products.find(
(p) => p.productId === getWorkspacePlanProductId({ workspacePlan: 'guest' })
)
if (guestProduct) {
mutateSubscriptionDataWithNewValidSeatNumbers({
seatCount: 0,
getWorkspacePlanProductId,
subscriptionData,
workspacePlan: 'guest'
})
subscriptionData.products.push({
quantity: guestCount,
productId: getWorkspacePlanProductId({ workspacePlan: 'guest' }),
priceId: getWorkspacePlanPriceId({
workspacePlan: 'guest',
billingInterval,
currency: workspaceSubscription.currency
}),
subscriptionItemId: undefined
})
}
}
// set current plan seat count to 0
mutateSubscriptionDataWithNewValidSeatNumbers({
seatCount: 0,
getWorkspacePlanProductId,
subscriptionData,
workspacePlan: workspacePlan.name
})
// set target plan seat count to current seat count
subscriptionData.products.push({
quantity: memberCount + adminCount,
productId: getWorkspacePlanProductId({ workspacePlan: targetPlan }),
priceId: getWorkspacePlanPriceId({
workspacePlan: targetPlan,
billingInterval,
currency: workspaceSubscription.currency
}),
subscriptionItemId: undefined
})
await reconcileSubscriptionData({
subscriptionData,
prorationBehavior: isNewPlanType(targetPlan)
? 'always_invoice'
: 'create_prorations'
})
await upsertWorkspacePlan({
workspacePlan: {
status: workspacePlan.status,
workspaceId,
name: targetPlan,
createdAt: new Date()
}
})
await updateWorkspaceSubscription({ workspaceSubscription })
}
export const upgradeWorkspaceSubscriptionFactoryNew =
export const upgradeWorkspaceSubscriptionFactory =
({
getWorkspacePlan,
getWorkspacePlanProductId,
@@ -282,16 +67,9 @@ export const upgradeWorkspaceSubscriptionFactoryNew =
case 'unlimited':
case 'academia':
case 'teamUnlimitedInvoiced':
case 'businessInvoiced':
case 'plusInvoiced':
case 'starterInvoiced':
case 'proUnlimitedInvoiced':
case 'free': // Upgrade from free is handled through startCheckout since it is from free to paid
throw new WorkspaceNotPaidPlanError()
case 'starter':
case 'plus':
case 'business':
throw new WorkspacePlanMismatchError()
case 'team':
case 'teamUnlimited':
case 'pro':
@@ -301,7 +79,7 @@ export const upgradeWorkspaceSubscriptionFactoryNew =
throwUncoveredError(workspacePlan)
}
if (!isNewPlanType(workspacePlan.name) || !isNewPaidPlanType(targetPlan)) {
if (!isPaidPlanType(targetPlan)) {
throw new UnsupportedWorkspacePlanError(null, {
info: { currentPlan: workspacePlan.name, targetPlan }
})
@@ -327,7 +105,7 @@ export const upgradeWorkspaceSubscriptionFactoryNew =
)
throw new WorkspacePlanUpgradeError("Can't upgrade to the same plan")
const planOrder: Record<PaidWorkspacePlansNew, number> = {
const planOrder: Record<PaidWorkspacePlans, number> = {
team: 1,
teamUnlimited: 2,
pro: 3,
@@ -336,9 +114,7 @@ export const upgradeWorkspaceSubscriptionFactoryNew =
if (
!isUpgradeWorkspacePlanValid({ current: workspacePlan.name, upgrade: targetPlan })
) {
if (
planOrder[workspacePlan.name] > planOrder[targetPlan as PaidWorkspacePlansNew]
) {
if (planOrder[workspacePlan.name] > planOrder[targetPlan]) {
throw new WorkspacePlanUpgradeError("Can't upgrade to a less expensive plan")
}
throw new InvalidWorkspacePlanUpgradeError(null, {
@@ -1,134 +0,0 @@
import { GetServerInfo } from '@/modules/core/domain/server/operations'
import { FindEmailsByUserId } from '@/modules/core/domain/userEmails/operations'
import {
EmailTemplateParams,
RenderEmail,
SendEmail,
SendEmailParams
} from '@/modules/emails/domain/operations'
import { getServerOrigin } from '@/modules/shared/helpers/envHelper'
import { mixpanel } from '@/modules/shared/utils/mixpanel'
import { GetWorkspaceCollaborators } from '@/modules/workspaces/domain/operations'
import { WorkspaceTeamMember } from '@/modules/workspaces/domain/types'
import { Workspace } from '@/modules/workspacesCore/domain/types'
import { Roles } from '@speckle/shared'
type TrialExpiresArgs = {
workspace: Workspace
expiresInDays: number
}
type TrialExpiresArgsWithAdmin = TrialExpiresArgs & {
workspaceAdmin: WorkspaceTeamMember
}
const buildMjmlBody = ({
workspace,
expiresInDays,
workspaceAdmin
}: TrialExpiresArgsWithAdmin) => {
const expireMessage =
expiresInDays === 0
? `<strong>today</strong>`
: `in <strong>${expiresInDays} days</strong>`
const bodyStart = `<mj-text align="center" line-height="2">
Hi ${workspaceAdmin.name}!
<br/>
<br/>
The trial for your workspace <span style="font-variant: small-caps; font-weight: bold;">${workspace.name}</span> expires ${expireMessage}.
<br/>
Upgrade to a paid plan before the trial expires to keep using your workspace. You can compare plans and get an overview of your estimated billing from your workspace's billing settings.
</mj-text>
`
const bodyEnd = `<mj-text align="center" padding-bottom="0px" line-height="2">
<span style="font-weight: bold;">Have questions or feedback?</span><br/>Please write us at <a href="mailto:hello@speckle.systems" target="_blank">hello@speckle.systems</a> and we'd be more than happy to talk.
</mj-text>`
return { bodyStart, bodyEnd }
}
const buildTextBody = ({
workspace,
expiresInDays,
workspaceAdmin
}: TrialExpiresArgsWithAdmin) => {
const expireMessage = expiresInDays === 0 ? `today` : `in ${expiresInDays} days`
const bodyStart = `
Hi ${workspaceAdmin.name}!
\r\n\r\n
The trial for your workspace ${workspace.name} expires ${expireMessage}.
\r\n\r\n
Upgrade to a paid plan before the trial expires to keep using your workspace. You can compare plans and get an overview of your estimated billing from your workspace's billing settings.
\r\n\r\n
`
const bodyEnd = `Have questions or feedback? Please write us at hello@speckle.systems and we'd be more than happy to talk.`
return { bodyStart, bodyEnd }
}
const buildEmailTemplateParams = (
args: TrialExpiresArgsWithAdmin
): EmailTemplateParams => {
const url = new URL(`workspaces/${args.workspace.slug}`, getServerOrigin()).toString()
return {
mjml: buildMjmlBody(args),
text: buildTextBody(args),
cta: {
title: 'Upgrade your workspace',
url
}
}
}
export const sendWorkspaceTrialExpiresEmailFactory =
({
renderEmail,
sendEmail,
getServerInfo,
getWorkspaceCollaborators,
getUserEmails
}: {
renderEmail: RenderEmail
sendEmail: SendEmail
getServerInfo: GetServerInfo
getWorkspaceCollaborators: GetWorkspaceCollaborators
getUserEmails: FindEmailsByUserId
}) =>
async (args: TrialExpiresArgs) => {
const mp = mixpanel({ userEmail: undefined, req: undefined })
const [serverInfo, workspaceAdmins] = await Promise.all([
getServerInfo(),
getWorkspaceCollaborators({
workspaceId: args.workspace.id,
limit: 100,
filter: { roles: [Roles.Workspace.Admin] }
})
])
const sendEmailParams = await Promise.all(
workspaceAdmins.map(async (admin) => {
const userEmails = await getUserEmails({ userId: admin.id })
const emailTemplateParams = buildEmailTemplateParams({
...args,
workspaceAdmin: admin
})
const { html, text } = await renderEmail(emailTemplateParams, serverInfo, null)
const subject =
args.expiresInDays === 0
? 'Your workspace trial expires today'
: `Your workspace trial expires in ${args.expiresInDays} days`
const sendEmailParams: SendEmailParams = {
html,
text,
subject,
to: userEmails.map((e) => e.email)
}
return sendEmailParams
})
)
await Promise.all(sendEmailParams.map((params) => sendEmail(params)))
await mp.track('Workspace Trial Expiration Email Sent', {
workspaceId: args.workspace.id,
// eslint-disable-next-line camelcase
workspace_id: args.workspace.id,
expiresInDays: args.expiresInDays
})
}
@@ -3,12 +3,6 @@ import { WorkspacePlans } from '@speckle/shared'
const WorkspacePlansUpgradeMapping: Record<WorkspacePlans, WorkspacePlans[]> = {
academia: [],
unlimited: [],
business: [],
businessInvoiced: [],
plus: [],
plusInvoiced: [],
starter: [],
starterInvoiced: [],
free: ['team', 'teamUnlimited', 'pro', 'proUnlimited'],
team: ['team', 'teamUnlimited', 'pro', 'proUnlimited'],
teamUnlimited: ['teamUnlimited', 'pro', 'proUnlimited'],
@@ -28,32 +28,11 @@ export const updateWorkspacePlanFactory =
if (!workspace) throw new WorkspaceNotFoundError()
const createdAt = new Date()
switch (name) {
case 'starter':
switch (status) {
case 'trial':
case 'expired':
case 'valid':
case 'cancelationScheduled':
case 'canceled':
case 'paymentFailed':
await upsertWorkspacePlan({
workspacePlan: { workspaceId, status, name, createdAt }
})
break
default:
throwUncoveredError(status)
}
break
case 'business':
case 'plus':
case 'team':
case 'teamUnlimited':
case 'pro':
case 'proUnlimited':
switch (status) {
case 'trial':
case 'expired':
throw new InvalidWorkspacePlanStatus()
case 'valid':
case 'cancelationScheduled':
case 'canceled':
@@ -70,9 +49,6 @@ export const updateWorkspacePlanFactory =
case 'free':
case 'academia':
case 'unlimited':
case 'starterInvoiced':
case 'plusInvoiced':
case 'businessInvoiced':
case 'teamUnlimitedInvoiced':
case 'proUnlimitedInvoiced':
switch (status) {
@@ -83,9 +59,7 @@ export const updateWorkspacePlanFactory =
break
case 'cancelationScheduled':
case 'canceled':
case 'expired':
case 'paymentFailed':
case 'trial':
throw new InvalidWorkspacePlanStatus()
default:
throwUncoveredError(status)
+1 -48
View File
@@ -1,12 +1,9 @@
import {
Currency,
GetWorkspacePlanPriceId,
GetWorkspacePlanProductAndPriceIds,
GetWorkspacePlanProductId,
isMultiCurrencyPrice
GetWorkspacePlanProductId
} from '@/modules/gatekeeper/domain/billing'
import { getStringFromEnv, getStripeApiKey } from '@/modules/shared/helpers/envHelper'
import { PriceLookupError } from '@/modules/gatekeeper/errors/billing'
import { Stripe } from 'stripe'
import { NotImplementedError } from '@/modules/shared/errors'
@@ -18,43 +15,6 @@ export const getStripeClient = () => {
}
const loadProductAndPriceIds: GetWorkspacePlanProductAndPriceIds = () => ({
// old
guest: {
productId: getStringFromEnv('WORKSPACE_GUEST_SEAT_STRIPE_PRODUCT_ID'),
monthly: {
gbp: getStringFromEnv('WORKSPACE_MONTHLY_GUEST_SEAT_STRIPE_PRICE_ID')
},
yearly: {
gbp: getStringFromEnv('WORKSPACE_YEARLY_GUEST_SEAT_STRIPE_PRICE_ID')
}
},
starter: {
productId: getStringFromEnv('WORKSPACE_STARTER_SEAT_STRIPE_PRODUCT_ID'),
monthly: {
gbp: getStringFromEnv('WORKSPACE_MONTHLY_STARTER_SEAT_STRIPE_PRICE_ID')
},
yearly: {
gbp: getStringFromEnv('WORKSPACE_YEARLY_STARTER_SEAT_STRIPE_PRICE_ID')
}
},
plus: {
productId: getStringFromEnv('WORKSPACE_PLUS_SEAT_STRIPE_PRODUCT_ID'),
monthly: {
gbp: getStringFromEnv('WORKSPACE_MONTHLY_PLUS_SEAT_STRIPE_PRICE_ID')
},
yearly: {
gbp: getStringFromEnv('WORKSPACE_YEARLY_PLUS_SEAT_STRIPE_PRICE_ID')
}
},
business: {
productId: getStringFromEnv('WORKSPACE_BUSINESS_SEAT_STRIPE_PRODUCT_ID'),
monthly: {
gbp: getStringFromEnv('WORKSPACE_MONTHLY_BUSINESS_SEAT_STRIPE_PRICE_ID')
},
yearly: {
gbp: getStringFromEnv('WORKSPACE_YEARLY_BUSINESS_SEAT_STRIPE_PRICE_ID')
}
},
team: {
productId: getStringFromEnv('WORKSPACE_TEAM_SEAT_STRIPE_PRODUCT_ID'),
monthly: {
@@ -118,13 +78,6 @@ export const getWorkspacePlanPriceId: GetWorkspacePlanPriceId = ({
}) => {
const plan = getWorkspacePlanProductAndPriceIds()[workspacePlan]
const priceIds = plan[billingInterval]
if (!isMultiCurrencyPrice(priceIds)) {
if (currency !== Currency.gbp)
throw new PriceLookupError(
`Plan '${workspacePlan}' does not have a ${billingInterval} price for currency ${currency}`
)
return priceIds[currency]
}
return priceIds[currency]
}
@@ -10,11 +10,9 @@ import {
upsertPaidWorkspacePlanFactory,
getWorkspaceSubscriptionFactory,
getWorkspaceSubscriptionBySubscriptionIdFactory,
changeExpiredTrialWorkspacePlanStatusesFactory,
upsertTrialWorkspacePlanFactory,
getWorkspacesByPlanAgeFactory,
getWorkspaceSubscriptionsPastBillingCycleEndFactoryOldPlans,
upsertWorkspacePlanFactory
upsertWorkspacePlanFactory,
getWorkspaceSubscriptionsPastBillingCycleEndFactory
} from '@/modules/gatekeeper/repositories/billing'
import {
createTestSubscriptionData,
@@ -23,6 +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 { expect } from 'chai'
import cryptoRandomString from 'crypto-random-string'
import { beforeEach } from 'mocha'
@@ -33,7 +32,6 @@ const createAndStoreTestWorkspace = createAndStoreTestWorkspaceFactory({
})
const getWorkspacePlan = getWorkspacePlanFactory({ db })
const upsertPaidWorkspacePlan = upsertPaidWorkspacePlanFactory({ db })
const upsertTrialWorkspacePlan = upsertTrialWorkspacePlanFactory({ db })
const saveCheckoutSession = saveCheckoutSessionFactory({ db })
const deleteCheckoutSession = deleteCheckoutSessionFactory({ db })
const getCheckoutSession = getCheckoutSessionFactory({ db })
@@ -43,14 +41,11 @@ const upsertWorkspaceSubscription = upsertWorkspaceSubscriptionFactory({ db })
const getWorkspaceSubscription = getWorkspaceSubscriptionFactory({ db })
const getWorkspaceSubscriptionBySubscriptionId =
getWorkspaceSubscriptionBySubscriptionIdFactory({ db })
const getSubscriptionsAboutToEndBillingCycleOld =
getWorkspaceSubscriptionsPastBillingCycleEndFactoryOldPlans({ db })
const changeExpiredTrialWorkspacePlanStatuses =
changeExpiredTrialWorkspacePlanStatusesFactory({ db })
const getWorkspacesByPlanAge = getWorkspacesByPlanAgeFactory({ db })
const getWorkspaceSubscriptionsPastBillingCycleEnd =
getWorkspaceSubscriptionsPastBillingCycleEndFactory({ db })
describe('billing repositories @gatekeeper', () => {
describe('workspacePlans', () => {
beforeEach(async () => {
@@ -63,7 +58,7 @@ describe('billing repositories @gatekeeper', () => {
let storedWorkspacePlan = await getWorkspacePlan({ workspaceId })
expect(storedWorkspacePlan).to.be.null
const workspacePlan = {
name: 'business',
name: PaidWorkspacePlans.Team,
status: 'paymentFailed',
workspaceId,
createdAt: new Date()
@@ -79,7 +74,7 @@ describe('billing repositories @gatekeeper', () => {
const workspace = await createAndStoreTestWorkspace()
const workspaceId = workspace.id
const workspacePlan = {
name: 'business',
name: PaidWorkspacePlans.Team,
status: 'paymentFailed',
createdAt: new Date(),
workspaceId
@@ -98,97 +93,14 @@ describe('billing repositories @gatekeeper', () => {
expect(storedWorkspacePlan).deep.equal(planUpdate)
})
})
describe('changeExpiredTrialWorkspacePlanStatusesFactory creates a function, that', () => {
it('ignores non trial plans', async () => {
const workspace = await createAndStoreTestWorkspace()
await upsertPaidWorkspacePlan({
workspacePlan: {
name: 'business',
status: 'cancelationScheduled',
workspaceId: workspace.id,
createdAt: new Date(2023, 0, 1)
}
})
const expiredPlans = await changeExpiredTrialWorkspacePlanStatuses({
numberOfDays: 1
})
expect(expiredPlans.map((p) => p.workspaceId).includes(workspace.id)).to.be
.false
})
it('ignores non expired trial plans', async () => {
const workspace = await createAndStoreTestWorkspace()
await upsertTrialWorkspacePlan({
workspacePlan: {
name: 'starter',
status: 'trial',
workspaceId: workspace.id,
createdAt: new Date()
}
})
const expiredPlans = await changeExpiredTrialWorkspacePlanStatuses({
numberOfDays: 1
})
expect(expiredPlans.map((p) => p.workspaceId).includes(workspace.id)).to.be
.false
})
it('changes status to expired for expired trial plans', async () => {
const workspace1 = await createAndStoreTestWorkspace()
await upsertTrialWorkspacePlan({
workspacePlan: {
name: 'starter',
status: 'trial',
workspaceId: workspace1.id,
createdAt: new Date(2023, 0, 1)
}
})
const workspace2 = await createAndStoreTestWorkspace()
await upsertTrialWorkspacePlan({
workspacePlan: {
name: 'starter',
status: 'trial',
workspaceId: workspace2.id,
createdAt: new Date(2023, 0, 1)
}
})
const workspace3 = await createAndStoreTestWorkspace()
const workspace3Plan = {
name: 'starter',
status: 'trial',
workspaceId: workspace3.id,
createdAt: new Date()
} as const
await upsertTrialWorkspacePlan({
workspacePlan: workspace3Plan
})
const expiredPlans = await changeExpiredTrialWorkspacePlanStatuses({
numberOfDays: 1
})
const expiredWorkspaceIds = expiredPlans.map((p) => p.workspaceId)
expect(expiredWorkspaceIds.includes(workspace1.id)).to.be.true
expect(expiredWorkspaceIds.includes(workspace2.id)).to.be.true
expect(expiredWorkspaceIds.includes(workspace3.id)).to.be.false
expiredPlans.forEach((expiredPlan) => {
expect(expiredPlan.status).to.equal('expired')
})
const storedWorkspacePlan = await getWorkspacePlan({
workspaceId: workspace3.id
})
expect(storedWorkspacePlan).deep.equal(workspace3Plan)
})
})
describe('getWorkspaceByPlanAgeFactory returns a function, that', () => {
it('gets workspace where days to expire matches expected', async () => {
const workspace1 = await createAndStoreTestWorkspace()
const createdAt1 = new Date()
createdAt1.setHours(createdAt1.getHours() - 22)
const workspace1Plan = {
name: 'business',
name: PaidWorkspacePlans.Team,
status: 'paymentFailed',
createdAt: createdAt1,
workspaceId: workspace1.id
@@ -200,7 +112,7 @@ describe('billing repositories @gatekeeper', () => {
const createdAt2 = new Date()
createdAt2.setHours(createdAt2.getHours() - 2)
const workspacePlan = {
name: 'business',
name: PaidWorkspacePlans.Team,
status: 'paymentFailed',
createdAt: createdAt2,
workspaceId: workspace2.id
@@ -225,7 +137,7 @@ describe('billing repositories @gatekeeper', () => {
const createdAt1 = new Date()
createdAt1.setHours(createdAt1.getHours() - 22)
const workspace1Plan = {
name: 'business',
name: PaidWorkspacePlans.Pro,
status: 'paymentFailed',
createdAt: createdAt1,
workspaceId: workspace1.id
@@ -237,7 +149,7 @@ describe('billing repositories @gatekeeper', () => {
const createdAt2 = new Date()
createdAt2.setHours(createdAt2.getHours() - 2)
const workspace2Plan = {
name: 'starter',
name: PaidWorkspacePlans.Team,
status: 'paymentFailed',
createdAt: createdAt2,
workspaceId: workspace2.id
@@ -259,7 +171,7 @@ describe('billing repositories @gatekeeper', () => {
const createdAt1 = new Date()
createdAt1.setHours(createdAt1.getHours() - 22)
const workspace1Plan = {
name: 'business',
name: PaidWorkspacePlans.Team,
status: 'paymentFailed',
createdAt: createdAt1,
workspaceId: workspace1.id
@@ -271,7 +183,7 @@ describe('billing repositories @gatekeeper', () => {
const createdAt2 = new Date()
createdAt2.setHours(createdAt2.getHours() - 2)
const workspace2Plan = {
name: 'business',
name: PaidWorkspacePlans.Team,
status: 'valid',
createdAt: createdAt2,
workspaceId: workspace2.id
@@ -293,7 +205,7 @@ describe('billing repositories @gatekeeper', () => {
const createdAt1 = new Date()
createdAt1.setHours(createdAt1.getHours() - 25)
const workspace1Plan = {
name: 'starter',
name: PaidWorkspacePlans.Team,
status: 'valid',
createdAt: createdAt1,
workspaceId: workspace1.id
@@ -305,7 +217,7 @@ describe('billing repositories @gatekeeper', () => {
const createdAt2 = new Date()
createdAt2.setHours(createdAt2.getHours() - 2)
const workspacePlan2 = {
name: 'starter',
name: PaidWorkspacePlans.Team,
status: 'valid',
createdAt: createdAt2,
workspaceId: workspace2.id
@@ -340,7 +252,7 @@ describe('billing repositories @gatekeeper', () => {
url: 'https://example.com',
workspaceId,
currency: 'usd',
workspacePlan: 'business'
workspacePlan: PaidWorkspacePlans.Team
} as const
await saveCheckoutSession({
@@ -364,7 +276,7 @@ describe('billing repositories @gatekeeper', () => {
url: 'https://example.com',
workspaceId,
currency: 'usd',
workspacePlan: 'business'
workspacePlan: PaidWorkspacePlans.Team
} as const
await saveCheckoutSession({
@@ -397,7 +309,7 @@ describe('billing repositories @gatekeeper', () => {
url: 'https://example.com',
workspaceId,
currency: 'usd',
workspacePlan: 'business'
workspacePlan: PaidWorkspacePlans.Team
} as const
await saveCheckoutSession({
@@ -439,7 +351,7 @@ describe('billing repositories @gatekeeper', () => {
url: 'https://example.com',
workspaceId,
currency: 'usd',
workspacePlan: 'business'
workspacePlan: PaidWorkspacePlans.Team
} as const
await saveCheckoutSession({
@@ -534,7 +446,7 @@ describe('billing repositories @gatekeeper', () => {
await upsertWorkspacePlanFactory({ db })({
workspacePlan: {
workspaceId: workspace2Subscription.workspaceId,
name: 'plus',
name: PaidWorkspacePlans.Team,
status: 'valid',
createdAt: new Date()
}
@@ -542,7 +454,7 @@ describe('billing repositories @gatekeeper', () => {
await upsertWorkspaceSubscription({
workspaceSubscription: workspace2Subscription
})
const subscriptions = await getSubscriptionsAboutToEndBillingCycleOld()
const subscriptions = await getWorkspaceSubscriptionsPastBillingCycleEnd()
expect(subscriptions).deep.equalInAnyOrder([workspace2Subscription])
})
})
@@ -1,7 +1,7 @@
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
import { GetWorkspacePlanPricesDocument } from '@/test/graphql/generated/graphql'
import { TestApolloServer, testApolloServer } from '@/test/graphqlHelper'
import { PaidWorkspacePlansNew } from '@speckle/shared'
import { PaidWorkspacePlans } from '@speckle/shared'
import { expect } from 'chai'
import { Currency } from '@/modules/gatekeeper/domain/billing'
@@ -21,7 +21,7 @@ const { FF_BILLING_INTEGRATION_ENABLED } = getFeatureFlags()
it('returns prices', async () => {
const res = await getPrices()
const expectedPlans = [...Object.values(PaidWorkspacePlansNew)]
const expectedPlans = [...Object.values(PaidWorkspacePlans)]
expect(res).to.not.haveGraphQLErrors()
@@ -35,7 +35,7 @@ import {
} from '@/test/speckle-helpers/branchHelper'
import { createTestCommit, createTestObject } from '@/test/speckle-helpers/commitHelper'
import { BasicTestStream, createTestStream } from '@/test/speckle-helpers/streamHelper'
import { Roles } from '@speckle/shared'
import { PaidWorkspacePlans, Roles } from '@speckle/shared'
import { expect } from 'chai'
import cryptoRandomString from 'crypto-random-string'
import dayjs from 'dayjs'
@@ -79,7 +79,7 @@ describe('Workspaces Billing', () => {
ownerId: ''
}
await createTestWorkspace(workspace, testAdminUser, {
addPlan: { name: 'business', status: 'valid' }
addPlan: { name: PaidWorkspacePlans.Team, status: 'valid' }
})
const res = await apollo.execute(GetWorkspaceDocument, {
@@ -89,7 +89,7 @@ describe('Workspaces Billing', () => {
expect(res).to.not.haveGraphQLErrors()
expect(res.data?.workspace?.readOnly).to.be.false
})
it('should return true for workspace plan status expired', async () => {
it('should return true for workspace plan status canceled', async () => {
const workspace = {
id: '',
name: 'test ws',
@@ -97,7 +97,7 @@ describe('Workspaces Billing', () => {
ownerId: ''
}
await createTestWorkspace(workspace, testAdminUser, {
addPlan: { name: 'business', status: 'expired' }
addPlan: { name: PaidWorkspacePlans.Team, status: 'canceled' }
})
const res = await apollo.execute(GetWorkspaceDocument, {
@@ -107,24 +107,6 @@ describe('Workspaces Billing', () => {
expect(res).to.not.haveGraphQLErrors()
expect(res.data?.workspace?.readOnly).to.be.true
})
it('should return false for workspace plan status trial', async () => {
const workspace = {
id: '',
name: 'test ws',
slug: cryptoRandomString({ length: 10 }),
ownerId: ''
}
await createTestWorkspace(workspace, testAdminUser, {
addPlan: { name: 'business', status: 'trial' }
})
const res = await apollo.execute(GetWorkspaceDocument, {
workspaceId: workspace.id
})
expect(res).to.not.haveGraphQLErrors()
expect(res.data?.workspace?.readOnly).to.be.false
})
}
)
;(FF_BILLING_INTEGRATION_ENABLED ? describe : describe.skip)(
@@ -1,6 +1,5 @@
import {
CheckoutSessionNotFoundError,
InvalidWorkspacePlanUpgradeError,
WorkspaceAlreadyPaidError,
WorkspaceCheckoutSessionInProgressError
} from '@/modules/gatekeeper/errors/billing'
@@ -62,7 +61,7 @@ describe('checkout @gatekeeper', () => {
paymentStatus: 'paid',
url: 'https://example.com',
workspaceId: cryptoRandomString({ length: 10 }),
workspacePlan: 'business',
workspacePlan: PaidWorkspacePlans.Team,
currency: 'usd',
createdAt: new Date(),
updatedAt: new Date()
@@ -98,7 +97,7 @@ describe('checkout @gatekeeper', () => {
paymentStatus: 'unpaid',
url: 'https://example.com',
workspaceId,
workspacePlan: 'business',
workspacePlan: PaidWorkspacePlans.Team,
currency: 'usd',
createdAt: new Date(),
updatedAt: new Date()
@@ -221,42 +220,6 @@ describe('checkout @gatekeeper', () => {
)
expect(err.name).to.be.equal(new NotFoundError().name)
})
it('does not allow checkout from old workspace plans', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const err = await expectToThrow(() =>
startCheckoutSessionFactory({
getWorkspacePlan: async () => ({
name: 'plus',
status: 'valid',
createdAt: new Date(),
workspaceId
}),
getWorkspaceCheckoutSession: () => {
expect.fail()
},
countSeatsByTypeInWorkspace: () => {
expect.fail()
},
createCheckoutSession: () => {
expect.fail()
},
saveCheckoutSession: () => {
expect.fail()
},
deleteCheckoutSession: () => {
expect.fail()
}
})({
workspaceId,
billingInterval: 'monthly',
workspacePlan: 'pro',
workspaceSlug: cryptoRandomString({ length: 10 }),
isCreateFlow: false,
currency: 'usd'
})
)
expect(err.name).to.be.equal(new InvalidWorkspacePlanUpgradeError().name)
})
it('does not allow checkout for paid workspace plans, that is in a valid state', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const err = await expectToThrow(() =>
@@ -346,7 +309,7 @@ describe('checkout @gatekeeper', () => {
paymentStatus: 'unpaid',
url: '',
workspaceId,
workspacePlan: 'business',
workspacePlan: PaidWorkspacePlans.Team,
currency: 'usd',
createdAt: new Date(),
updatedAt: new Date()
@@ -1,5 +1,5 @@
import { canWorkspaceAccessFeatureFactory } from '@/modules/gatekeeper/services/featureAuthorization'
import { WorkspacePlan } from '@speckle/shared'
import { PaidWorkspacePlans, WorkspacePlanFeatures } from '@speckle/shared'
import { expect } from 'chai'
import cryptoRandomString from 'crypto-random-string'
@@ -18,24 +18,28 @@ describe('featureAuthorization @gatekeeper', () => {
})
;(
[
['starter', 'expired', 'oidcSso', false],
['starter', 'valid', 'oidcSso', false],
['starter', 'valid', 'workspaceDataRegionSpecificity', false],
['plus', 'valid', 'workspaceDataRegionSpecificity', false],
['plus', 'canceled', 'oidcSso', false],
['plus', 'valid', 'oidcSso', true],
['business', 'valid', 'workspaceDataRegionSpecificity', true]
[PaidWorkspacePlans.Team, 'canceled', WorkspacePlanFeatures.SSO, false],
[PaidWorkspacePlans.Team, 'valid', WorkspacePlanFeatures.SSO, false],
[
PaidWorkspacePlans.Team,
'valid',
WorkspacePlanFeatures.CustomDataRegion,
false
],
[PaidWorkspacePlans.Pro, 'canceled', WorkspacePlanFeatures.SSO, false],
[PaidWorkspacePlans.Pro, 'valid', WorkspacePlanFeatures.SSO, true],
[PaidWorkspacePlans.Pro, 'valid', WorkspacePlanFeatures.CustomDataRegion, true]
] as const
).forEach(([plan, status, workspaceFeature, expectedResult]) => {
it(`returns ${expectedResult} for ${plan} @ ${status} for ${workspaceFeature}`, async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const canWorkspaceAccessFeature = canWorkspaceAccessFeatureFactory({
getWorkspacePlan: async () =>
({
name: plan,
status,
workspaceId
} as WorkspacePlan)
getWorkspacePlan: async () => ({
name: plan,
status,
workspaceId,
createdAt: new Date()
})
})
const result = await canWorkspaceAccessFeature({
workspaceId,
@@ -8,14 +8,6 @@ import { expect } from 'chai'
describe('@gatekeeper readOnly', () => {
describe('isWorkspaceReadOnlyFactory returns a function that', () => {
it('returns true if workspace plan status is expired', async () => {
const getWorkspacePlan: GetWorkspacePlan = () =>
({ status: 'expired' } as unknown as ReturnType<GetWorkspacePlan>)
const isWorkspaceReadOnly = isWorkspaceReadOnlyFactory({ getWorkspacePlan })
expect(await isWorkspaceReadOnly({ workspaceId: '' })).to.be.true
})
it('returns true if workspace plan status is paymentFailed', async () => {
const getWorkspacePlan: GetWorkspacePlan = () =>
({ status: 'paymentFailed' } as unknown as ReturnType<GetWorkspacePlan>)
@@ -32,14 +24,6 @@ describe('@gatekeeper readOnly', () => {
expect(await isWorkspaceReadOnly({ workspaceId: '' })).to.be.true
})
it('returns false if workspace plan status is trial', async () => {
const getWorkspacePlan: GetWorkspacePlan = () =>
({ status: 'trial' } as unknown as ReturnType<GetWorkspacePlan>)
const isWorkspaceReadOnly = isWorkspaceReadOnlyFactory({ getWorkspacePlan })
expect(await isWorkspaceReadOnly({ workspaceId: '' })).to.be.false
})
it('returns false if workspace plan status is valid', async () => {
const getWorkspacePlan: GetWorkspacePlan = () =>
({ status: 'valid' } as unknown as ReturnType<GetWorkspacePlan>)
@@ -68,18 +52,6 @@ describe('@gatekeeper readOnly', () => {
expect(await isProjectReadOnly({ projectId: '' })).to.be.false
})
it('returns true if workspace plan status is expired', async () => {
const getWorkspacePlanByProjectId: GetWorkspacePlanByProjectId = async () =>
({
status: 'expired'
} as unknown as ReturnType<GetWorkspacePlanByProjectId>)
const isProjectReadOnly = isProjectReadOnlyFactory({
getWorkspacePlanByProjectId
})
expect(await isProjectReadOnly({ projectId: '' })).to.be.true
})
it('returns true if workspace plan status is paymentFailed', async () => {
const getWorkspacePlanByProjectId: GetWorkspacePlanByProjectId = async () =>
({
@@ -104,18 +76,6 @@ describe('@gatekeeper readOnly', () => {
expect(await isProjectReadOnly({ projectId: '' })).to.be.true
})
it('returns false if workspace plan status is trial', async () => {
const getWorkspacePlanByProjectId: GetWorkspacePlanByProjectId = async () =>
({
status: 'trial'
} as unknown as ReturnType<GetWorkspacePlanByProjectId>)
const isProjectReadOnly = isProjectReadOnlyFactory({
getWorkspacePlanByProjectId
})
expect(await isProjectReadOnly({ projectId: '' })).to.be.false
})
it('returns false if workspace plan status is valid', async () => {
const getWorkspacePlanByProjectId: GetWorkspacePlanByProjectId = async () =>
({
File diff suppressed because it is too large Load Diff
@@ -4,7 +4,12 @@ import { EventBusEmit } from '@/modules/shared/services/eventBus'
import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace'
import { WorkspaceWithOptionalRole } from '@/modules/workspacesCore/domain/types'
import { expectToThrow } from '@/test/assertionHelper'
import { WorkspacePlan } from '@speckle/shared'
import {
PaidWorkspacePlans,
PaidWorkspacePlanStatuses,
UnpaidWorkspacePlans,
WorkspacePlan
} from '@speckle/shared'
import { expect } from 'chai'
import cryptoRandomString from 'crypto-random-string'
import { omit } from 'lodash'
@@ -24,8 +29,8 @@ describe('workspacePlan services @gatekeeper', () => {
const err = await expectToThrow(async () => {
await updateWorkspacePlan({
workspaceId: cryptoRandomString({ length: 10 }),
name: 'business',
status: 'expired'
name: PaidWorkspacePlans.Team,
status: PaidWorkspacePlanStatuses.Canceled
})
})
expect(err.message).to.equal(new WorkspaceNotFoundError().message)
@@ -34,100 +39,97 @@ describe('workspacePlan services @gatekeeper', () => {
const invalidPlanMessage = new InvalidWorkspacePlanStatus().message
;(
[
{ planName: 'foobar', cases: [['trial', uncoveredErrorMessage]] },
{
planName: 'starter',
planName: 'foobar',
cases: [[PaidWorkspacePlanStatuses.Canceled, uncoveredErrorMessage]]
},
{
planName: PaidWorkspacePlans.Team,
cases: [
['trial', null],
['expired', null],
['valid', null],
['cancelationScheduled', null],
['canceled', null],
['paymentFailed', null],
[PaidWorkspacePlanStatuses.Valid, null],
[PaidWorkspacePlanStatuses.CancelationScheduled, null],
[PaidWorkspacePlanStatuses.Canceled, null],
[PaidWorkspacePlanStatuses.PaymentFailed, null],
['foobar', uncoveredErrorMessage]
]
},
{
planName: 'business',
planName: PaidWorkspacePlans.Pro,
cases: [
['trial', invalidPlanMessage],
['expired', invalidPlanMessage],
['valid', null],
['cancelationScheduled', null],
['canceled', null],
['paymentFailed', null],
[PaidWorkspacePlanStatuses.Valid, null],
[PaidWorkspacePlanStatuses.CancelationScheduled, null],
[PaidWorkspacePlanStatuses.Canceled, null],
[PaidWorkspacePlanStatuses.PaymentFailed, null],
['foobar', uncoveredErrorMessage]
]
},
{
planName: 'plus',
planName: PaidWorkspacePlans.TeamUnlimited,
cases: [
['trial', invalidPlanMessage],
['expired', invalidPlanMessage],
['valid', null],
['cancelationScheduled', null],
['canceled', null],
['paymentFailed', null],
[PaidWorkspacePlanStatuses.Valid, null],
[PaidWorkspacePlanStatuses.CancelationScheduled, null],
[PaidWorkspacePlanStatuses.Canceled, null],
[PaidWorkspacePlanStatuses.PaymentFailed, null],
['foobar', uncoveredErrorMessage]
]
},
{
planName: 'academia',
planName: PaidWorkspacePlans.ProUnlimited,
cases: [
['valid', null],
['trial', invalidPlanMessage],
['expired', invalidPlanMessage],
['cancelationScheduled', invalidPlanMessage],
['canceled', invalidPlanMessage],
['paymentFailed', invalidPlanMessage],
[PaidWorkspacePlanStatuses.Valid, null],
[PaidWorkspacePlanStatuses.CancelationScheduled, null],
[PaidWorkspacePlanStatuses.Canceled, null],
[PaidWorkspacePlanStatuses.PaymentFailed, null],
['foobar', uncoveredErrorMessage]
]
},
{
planName: 'unlimited',
planName: UnpaidWorkspacePlans.Academia,
cases: [
['valid', null],
['trial', invalidPlanMessage],
['expired', invalidPlanMessage],
['cancelationScheduled', invalidPlanMessage],
['canceled', invalidPlanMessage],
['paymentFailed', invalidPlanMessage],
[PaidWorkspacePlanStatuses.Valid, null],
[PaidWorkspacePlanStatuses.CancelationScheduled, invalidPlanMessage],
[PaidWorkspacePlanStatuses.Canceled, invalidPlanMessage],
[PaidWorkspacePlanStatuses.PaymentFailed, invalidPlanMessage],
['foobar', uncoveredErrorMessage]
]
},
{
planName: 'starterInvoiced',
planName: UnpaidWorkspacePlans.Free,
cases: [
['valid', null],
['trial', invalidPlanMessage],
['expired', invalidPlanMessage],
['cancelationScheduled', invalidPlanMessage],
['canceled', invalidPlanMessage],
['paymentFailed', invalidPlanMessage],
[PaidWorkspacePlanStatuses.Valid, null],
[PaidWorkspacePlanStatuses.CancelationScheduled, invalidPlanMessage],
[PaidWorkspacePlanStatuses.Canceled, invalidPlanMessage],
[PaidWorkspacePlanStatuses.PaymentFailed, invalidPlanMessage],
['foobar', uncoveredErrorMessage]
]
},
{
planName: 'plusInvoiced',
planName: UnpaidWorkspacePlans.Unlimited,
cases: [
['valid', null],
['trial', invalidPlanMessage],
['expired', invalidPlanMessage],
['cancelationScheduled', invalidPlanMessage],
['canceled', invalidPlanMessage],
['paymentFailed', invalidPlanMessage],
[PaidWorkspacePlanStatuses.Valid, null],
[PaidWorkspacePlanStatuses.CancelationScheduled, invalidPlanMessage],
[PaidWorkspacePlanStatuses.Canceled, invalidPlanMessage],
[PaidWorkspacePlanStatuses.PaymentFailed, invalidPlanMessage],
['foobar', uncoveredErrorMessage]
]
},
{
planName: 'businessInvoiced',
planName: UnpaidWorkspacePlans.TeamUnlimitedInvoiced,
cases: [
['valid', null],
['trial', invalidPlanMessage],
['expired', invalidPlanMessage],
['cancelationScheduled', invalidPlanMessage],
['canceled', invalidPlanMessage],
['paymentFailed', invalidPlanMessage],
[PaidWorkspacePlanStatuses.Valid, null],
[PaidWorkspacePlanStatuses.CancelationScheduled, invalidPlanMessage],
[PaidWorkspacePlanStatuses.Canceled, invalidPlanMessage],
[PaidWorkspacePlanStatuses.PaymentFailed, invalidPlanMessage],
['foobar', uncoveredErrorMessage]
]
},
{
planName: UnpaidWorkspacePlans.ProUnlimitedInvoiced,
cases: [
[PaidWorkspacePlanStatuses.Valid, null],
[PaidWorkspacePlanStatuses.CancelationScheduled, invalidPlanMessage],
[PaidWorkspacePlanStatuses.Canceled, invalidPlanMessage],
[PaidWorkspacePlanStatuses.PaymentFailed, invalidPlanMessage],
['foobar', uncoveredErrorMessage]
]
}
@@ -1,23 +1,6 @@
import {
PaidWorkspacePlansOld,
PaidWorkspacePlansNew,
WorkspacePlanBillingIntervals
} from '@speckle/shared'
import { PaidWorkspacePlans, WorkspacePlanBillingIntervals } from '@speckle/shared'
/**
* This includes the pricing plans (Stripe products) a customer can sub to
// */
export type WorkspacePricingProducts =
| PaidWorkspacePlansNew
| PaidWorkspacePlansOld
| 'guest'
// type WorkspacePlanProductsMetadata<PriceData = string> = Record<
// WorkspacePricingProducts,
// Record<WorkspacePlanBillingIntervals, PriceData> & {
// productId: string
// }
// >
export type WorkspacePricingProducts = PaidWorkspacePlans
export const Currency = {
usd: 'usd',
@@ -31,13 +14,7 @@ type IntervalPrices = Record<
export type WorkspacePlanProductPrices = Record<
Currency,
Record<PaidWorkspacePlansNew, IntervalPrices>
Record<PaidWorkspacePlans, IntervalPrices>
>
export type Currency = (typeof Currency)[keyof typeof Currency]
// export type WorkspacePlanProductAndPriceIds = WorkspacePlanProductsMetadata<string>
// export type WorkspacePlanProductPrices = WorkspacePlanProductsMetadata<{
// amount: number
// currency: string
// }>
@@ -0,0 +1,37 @@
import { Knex } from 'knex'
/**
* The full stripe+db migration should've already executed, this is a fallback for dev/test envs to migrate broken plans
*/
const TABLE_NAME = 'workspace_plans'
const planMapping = {
starter: 'team',
starterInvoiced: 'teamUnlimitedInvoiced',
plus: 'pro',
plusInvoiced: 'proUnlimitedInvoiced',
business: 'pro',
businessInvoiced: 'proUnlimitedInvoiced'
}
const statusMapping = {
trial: 'canceled',
expired: 'canceled'
}
export async function up(knex: Knex): Promise<void> {
// Migrate plans names
for (const [oldPlan, newPlan] of Object.entries(planMapping)) {
await knex(TABLE_NAME).where('name', oldPlan).update({ name: newPlan })
}
// Migrate plans statuses
for (const [oldStatus, newStatus] of Object.entries(statusMapping)) {
await knex(TABLE_NAME).where('status', oldStatus).update({ status: newStatus })
}
}
export async function down(): Promise<void> {
// sorry, no going back
}
@@ -203,10 +203,6 @@ export type UpsertWorkspaceRole = (args: WorkspaceAcl) => Promise<void>
/** Service-level change with protection against invalid role changes */
export type UpdateWorkspaceRole = (
args: Pick<WorkspaceAcl, 'userId' | 'workspaceId' | 'role'> & {
/**
* If this gets triggered from a project role update, we don't want to override that project's role to the default one
*/
skipProjectRoleUpdatesFor?: string[]
/**
* Only add or upgrade role, prevent downgrades
*/
@@ -242,6 +238,14 @@ export type ValidateWorkspaceMemberProjectRole = (params: {
workspaceId: string
userId: string
projectRole: StreamRoles
/**
* Instead of resolving actual workspace role/seatType, use this one. Useful when checking
* if a planned workspace member will have valid access to a project
*/
workspaceAccess?: {
role: WorkspaceRoles
seatType: WorkspaceSeatType
}
}) => Promise<void>
/** Workspace Projects */
@@ -12,7 +12,6 @@ import {
GetWorkspace,
GetWorkspaceCollaborators,
GetWorkspaceRoleForUser,
GetWorkspaceRoleToDefaultProjectRoleMapping,
GetWorkspaceSeatTypeToProjectRoleMapping,
QueryAllWorkspaceProjects,
ValidateWorkspaceMemberProjectRole
@@ -76,19 +75,14 @@ import {
import { WorkspacesNotAuthorizedError } from '@/modules/workspaces/errors/workspace'
import { publish, WorkspaceSubscriptions } from '@/modules/shared/utils/subscriptions'
import { isWorkspaceResourceTarget } from '@/modules/workspaces/services/invites'
import {
ProjectEvents,
ProjectEventsPayloads
} from '@/modules/core/domain/projects/events'
import { ProjectEvents } from '@/modules/core/domain/projects/events'
import { getBaseTrackingProperties, getClient } from '@/modules/shared/utils/mixpanel'
import {
calculateSubscriptionSeats,
GetWorkspacePlan,
GetWorkspaceRolesAndSeats,
GetWorkspaceSubscription,
GetWorkspaceWithPlan
} from '@/modules/gatekeeper/domain/billing'
import { getWorkspacePlanProductId } from '@/modules/gatekeeper/stripe'
import { Workspace, WorkspaceSeatType } from '@/modules/workspacesCore/domain/types'
import { FindEmailsByUserId } from '@/modules/core/domain/userEmails/operations'
import { getDefaultRegionFactory } from '@/modules/workspaces/repositories/regions'
@@ -103,7 +97,6 @@ import {
createWorkspaceSeatFactory,
deleteWorkspaceSeatFactory,
getWorkspaceRoleAndSeatFactory,
getWorkspaceRolesAndSeatsFactory,
getWorkspaceUserSeatFactory
} from '@/modules/gatekeeper/repositories/workspaceSeat'
import {
@@ -117,56 +110,10 @@ import {
} from '@/modules/core/services/streams/access'
import { getUserFactory } from '@/modules/core/repositories/users'
import { authorizeResolver } from '@/modules/shared'
import { isNewPlanType } from '@/modules/gatekeeper/helpers/plans'
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
const { FF_BILLING_INTEGRATION_ENABLED } = getFeatureFlags()
export const onProjectCreatedFactory =
(deps: {
getWorkspaceRolesAndSeats: GetWorkspaceRolesAndSeats
upsertProjectRole: UpsertProjectRole
getWorkspaceRoleToDefaultProjectRoleMapping: GetWorkspaceRoleToDefaultProjectRoleMapping
getWorkspaceWithPlan: GetWorkspaceWithPlan
}) =>
async (payload: ProjectEventsPayloads[typeof ProjectEvents.Created]) => {
const { id: projectId, workspaceId } = payload.project
if (!workspaceId) {
return
}
// Automatic role assignment doesn't apply to new plans
const workspace = await deps.getWorkspaceWithPlan({ workspaceId })
if (workspace?.plan && isNewPlanType(workspace.plan.name)) return
const workspaceMembers = Object.values(
await deps.getWorkspaceRolesAndSeats({ workspaceId })
)
const { default: defaultProjectRoles } =
await deps.getWorkspaceRoleToDefaultProjectRoleMapping({
workspaceId
})
// On create assign project roles to all members
await Promise.all(
workspaceMembers.map(({ userId, role: { role: workspaceRole } }) => {
const projectRole = defaultProjectRoles[workspaceRole]
if (!projectRole) return
// we do not need to assign new roles to the project owner
if (userId === payload.ownerId) return
return deps.upsertProjectRole({
projectId,
userId,
role: projectRole
})
})
)
}
export const onInviteFinalizedFactory =
(deps: {
getStream: GetStream
@@ -209,8 +156,7 @@ export const onInviteFinalizedFactory =
userId: targetUserId,
workspaceId: project.workspaceId,
preventRoleDowngrade: true,
updatedByUserId: invite.inviterId,
skipProjectRoleUpdatesFor: [project.id]
updatedByUserId: invite.inviterId
})
// Automatically promote user to project owner if workspace admin
@@ -351,12 +297,6 @@ export const onWorkspaceSeatUpdatedFactory =
])
if (!workspace || !role) return
// Only new plans only rely on seat types
const isNewPlan = workspace.plan && isNewPlanType(workspace.plan.name)
if (!isNewPlan) {
return
}
const { allowed: allowedProjectRoles, default: defaultProjectRoles } =
await deps.getWorkspaceSeatTypeToProjectRoleMapping({
workspaceId
@@ -425,7 +365,6 @@ export const onWorkspaceSeatUpdatedFactory =
export const onWorkspaceRoleUpdatedFactory =
(deps: {
getWorkspaceRoleToDefaultProjectRoleMapping: GetWorkspaceRoleToDefaultProjectRoleMapping
queryAllWorkspaceProjects: QueryAllWorkspaceProjects
setStreamCollaborator: SetStreamCollaborator
getWorkspaceUserSeat: GetWorkspaceUserSeat
@@ -435,13 +374,9 @@ export const onWorkspaceRoleUpdatedFactory =
}) =>
async ({
acl,
updatedByUserId,
flags
updatedByUserId
}: {
acl: { userId: string; role: WorkspaceRoles; workspaceId: string }
flags?: {
skipProjectRoleUpdatesFor: string[]
}
updatedByUserId: string
}) => {
const { userId, role, workspaceId } = acl
@@ -449,13 +384,6 @@ export const onWorkspaceRoleUpdatedFactory =
const workspace = await deps.getWorkspaceWithPlan({ workspaceId })
if (!workspace) return
// Until we kill old plan code, we need to do full project role assignment for them
const isOldPlan = !workspace.plan || !isNewPlanType(workspace.plan.name)
const { default: defaultProjectRoles } =
await deps.getWorkspaceRoleToDefaultProjectRoleMapping({
workspaceId
})
const seatType = await deps.getWorkspaceUserSeat({ workspaceId, userId })
if (!seatType) return
@@ -480,13 +408,7 @@ export const onWorkspaceRoleUpdatedFactory =
})
await Promise.all(
projectsPage.map(async ({ id: projectId, role: originalProjectRole }) => {
if (isOldPlan && flags?.skipProjectRoleUpdatesFor.includes(projectId)) {
// Skip assignment (used during invite flow)
// TODO: Can we refactor this special case away?
return
}
if (!originalProjectRole && !isOldPlan) {
if (!originalProjectRole) {
return
}
@@ -495,34 +417,30 @@ export const onWorkspaceRoleUpdatedFactory =
* been written to DB. So we must ensure the updates we make here are valid
*/
let nextUserRole: StreamRoles | null
if (isOldPlan) {
nextUserRole = defaultProjectRoles[role]
} else {
switch (role) {
case Roles.Workspace.Admin: {
// Set workspace owner as project owner
nextUserRole = Roles.Stream.Owner
break
}
case Roles.Workspace.Guest: {
// If workspace guest is project owner
if (originalProjectRole !== Roles.Stream.Owner) {
return
}
// If workspace guest has an editor seat
if (seatType.type !== WorkspaceSeatType.Editor) {
return
}
// Demote to contributor
nextUserRole = Roles.Stream.Contributor
break
}
default:
return
let nextUserRole: StreamRoles
switch (role) {
case Roles.Workspace.Admin: {
// Set workspace owner as project owner
nextUserRole = Roles.Stream.Owner
break
}
case Roles.Workspace.Guest: {
// If workspace guest is project owner
if (originalProjectRole !== Roles.Stream.Owner) {
return
}
// If workspace guest has an editor seat
if (seatType.type !== WorkspaceSeatType.Editor) {
return
}
// Demote to contributor
nextUserRole = Roles.Stream.Contributor
break
}
default:
return
}
// If downgraded from owner & last owner, transfer ownership to a workspace admin
@@ -594,10 +512,9 @@ export const workspaceTrackingFactory =
])
const seats = subscription?.subscriptionData
? calculateSubscriptionSeats({
subscriptionData: subscription?.subscriptionData,
guestSeatProductId: getWorkspacePlanProductId({ workspacePlan: 'guest' })
subscriptionData: subscription?.subscriptionData
})
: { plan: 0, guest: 0 }
: 0
return {
name: workspace.name,
description: workspace.description,
@@ -614,8 +531,8 @@ export const workspaceTrackingFactory =
planCreatedAt: plan?.createdAt,
subscriptionBillingInterval: subscription?.billingInterval,
subscriptionCurrentBillingCycleEnd: subscription?.currentBillingCycleEnd,
seats: seats.plan,
seatsGuest: seats.guest,
seats,
seatsGuest: 0,
...getBaseTrackingProperties()
}
}
@@ -776,13 +693,9 @@ export const initializeEventListenersFactory =
validateWorkspaceMemberProjectRole: validateWorkspaceMemberProjectRoleFactory({
getWorkspaceRoleAndSeat: getWorkspaceRoleAndSeatFactory({ db }),
getWorkspaceRoleToDefaultProjectRoleMapping:
getWorkspaceRoleToDefaultProjectRoleMappingFactory({
getWorkspaceWithPlan
}),
getWorkspaceRoleToDefaultProjectRoleMappingFactory(),
getWorkspaceSeatTypeToProjectRoleMapping:
getWorkspaceSeatTypeToProjectRoleMappingFactory({
getWorkspaceWithPlan
}),
getWorkspaceSeatTypeToProjectRoleMappingFactory(),
getWorkspaceWithPlan
})
})
@@ -794,18 +707,6 @@ export const initializeEventListenersFactory =
})
const quitCbs = [
eventBus.listen(ProjectEvents.Created, async ({ payload }) => {
const onProjectCreated = onProjectCreatedFactory({
upsertProjectRole: upsertProjectRoleFactory({ db }),
getWorkspaceRolesAndSeats: getWorkspaceRolesAndSeatsFactory({ db }),
getWorkspaceRoleToDefaultProjectRoleMapping:
getWorkspaceRoleToDefaultProjectRoleMappingFactory({
getWorkspaceWithPlan
}),
getWorkspaceWithPlan
})
await onProjectCreated(payload)
}),
eventBus.listen(ServerInvitesEvents.Finalized, async ({ payload }) => {
const onInviteFinalized = onInviteFinalizedFactory({
getStream: getStreamFactory({ db }),
@@ -921,11 +822,7 @@ export const initializeEventListenersFactory =
getWorkspaceUserSeat: getWorkspaceUserSeatFactory({ db }),
getStreamsCollaboratorCounts: getStreamsCollaboratorCountsFactory({ db }),
getWorkspaceCollaborators: getWorkspaceCollaboratorsFactory({ db }),
getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db }),
getWorkspaceRoleToDefaultProjectRoleMapping:
getWorkspaceRoleToDefaultProjectRoleMappingFactory({
getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db })
})
getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db })
})
return await onWorkspaceRoleUpdated(payload)
},
@@ -954,9 +851,7 @@ export const initializeEventListenersFactory =
getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db }),
getWorkspaceRoleForUser: getWorkspaceRoleForUserFactory({ db }),
getWorkspaceSeatTypeToProjectRoleMapping:
getWorkspaceSeatTypeToProjectRoleMappingFactory({
getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db })
}),
getWorkspaceSeatTypeToProjectRoleMappingFactory(),
getStreamsCollaboratorCounts: getStreamsCollaboratorCountsFactory({ db }),
getWorkspaceCollaborators: getWorkspaceCollaboratorsFactory({ db })
})
@@ -249,13 +249,9 @@ const buildCollectAndValidateResourceTargets = () =>
getWorkspaceRoleAndSeat: getWorkspaceRoleAndSeatFactory({ db }),
getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db }),
getWorkspaceRoleToDefaultProjectRoleMapping:
getWorkspaceRoleToDefaultProjectRoleMappingFactory({
getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db })
}),
getWorkspaceRoleToDefaultProjectRoleMappingFactory(),
getWorkspaceSeatTypeToProjectRoleMapping:
getWorkspaceSeatTypeToProjectRoleMappingFactory({
getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db })
})
getWorkspaceSeatTypeToProjectRoleMappingFactory()
})
})
@@ -693,17 +689,12 @@ export = FF_WORKSPACES_MODULE_ENABLED
case 'teamUnlimited':
case 'pro':
case 'proUnlimited':
case 'starter':
case 'plus':
case 'business':
switch (workspacePlan.status) {
case 'cancelationScheduled':
case 'valid':
case 'paymentFailed':
throw new WorkspacePaidPlanActiveError()
case 'canceled':
case 'trial':
case 'expired':
break
default:
throwUncoveredError(workspacePlan)
@@ -711,9 +702,6 @@ export = FF_WORKSPACES_MODULE_ENABLED
case 'free':
case 'unlimited':
case 'academia':
case 'starterInvoiced':
case 'plusInvoiced':
case 'businessInvoiced':
case 'proUnlimitedInvoiced':
case 'teamUnlimitedInvoiced':
break
@@ -75,6 +75,7 @@ import {
import { GetStream } from '@/modules/core/domain/streams/operations'
import { GetUser } from '@/modules/core/domain/users/operations'
import { GetWorkspaceRoleAndSeat } from '@/modules/workspacesCore/domain/operations'
import { WorkspaceSeatType } from '@/modules/workspacesCore/domain/types'
export const isWorkspaceResourceTarget = (
target: InviteResourceTarget
@@ -154,19 +155,23 @@ export const collectAndValidateWorkspaceTargetsFactory =
? primaryResourceTarget
: null
const targetRole =
const targetWorkspaceRole =
primaryWorkspaceResourceTarget?.role ||
input.primaryResourceTarget.secondaryResourceRoles?.[
WorkspaceInviteResourceType
] ||
Roles.Workspace.Guest
const targetWorkspaceSeatType =
targetWorkspaceRole === Roles.Workspace.Admin
? WorkspaceSeatType.Editor
: WorkspaceSeatType.Viewer
// Role based checks
if (!Object.values(Roles.Workspace).includes(targetRole)) {
if (!Object.values(Roles.Workspace).includes(targetWorkspaceRole)) {
throw new InviteCreateValidationError('Unexpected workspace invite role')
}
if (targetRole === Roles.Workspace.Admin) {
if (targetWorkspaceRole === Roles.Workspace.Admin) {
const serverGuestInvite = baseTargets.find(
(target) =>
target.resourceType === ServerInviteResourceType &&
@@ -224,7 +229,16 @@ export const collectAndValidateWorkspaceTargetsFactory =
await deps.validateWorkspaceMemberProjectRoleFactory({
workspaceId,
userId: targetUser.id,
projectRole
projectRole,
workspaceAccess: workspaceRoleAndSeat
? {
role: workspaceRoleAndSeat.role.role,
seatType: workspaceRoleAndSeat.seat.type
}
: {
role: targetWorkspaceRole,
seatType: targetWorkspaceSeatType
}
})
// If project target is primary and user target is already a workspace member, mark invite as auto-acceptable
@@ -264,7 +278,7 @@ export const collectAndValidateWorkspaceTargetsFactory =
}
if (
targetRole !== Roles.Workspace.Guest &&
targetWorkspaceRole !== Roles.Workspace.Guest &&
workspace.domainBasedMembershipProtectionEnabled
) {
const workspaceDomains = await deps.getWorkspaceDomains({
@@ -308,7 +322,7 @@ export const collectAndValidateWorkspaceTargetsFactory =
: {
resourceId: workspaceId,
resourceType: WorkspaceInviteResourceType,
role: targetRole
role: targetWorkspaceRole
}
return [...baseTargets, finalWorkspaceResourceTarget]
@@ -423,8 +423,7 @@ export const updateWorkspaceRoleFactory =
userId,
role: nextWorkspaceRole,
preventRoleDowngrade,
updatedByUserId,
skipProjectRoleUpdatesFor
updatedByUserId
}): Promise<void> => {
const workspaceRoles = await getWorkspaceRoles({ workspaceId })
@@ -495,10 +494,7 @@ export const updateWorkspaceRoleFactory =
workspaceId,
role: nextWorkspaceRole
},
updatedByUserId,
flags: {
skipProjectRoleUpdatesFor: skipProjectRoleUpdatesFor ?? []
}
updatedByUserId
}
})
}
@@ -17,7 +17,7 @@ import {
} from '@/modules/workspaces/errors/workspace'
import { GetProject, UpdateProject } from '@/modules/core/domain/projects/operations'
import { chunk } from 'lodash'
import { Roles, StreamRoles } from '@speckle/shared'
import { Roles, WorkspaceRoles } from '@speckle/shared'
import {
GetStreamCollaborators,
LegacyGetStreams,
@@ -49,8 +49,6 @@ import {
GetWorkspaceWithPlan,
WorkspaceSeatType
} from '@/modules/gatekeeper/domain/billing'
import { isNewPlanType } from '@/modules/gatekeeper/helpers/plans'
import { NotImplementedError } from '@/modules/shared/errors'
import { FindEmailsByUserId } from '@/modules/core/domain/userEmails/operations'
import { userEmailsCompliantWithWorkspaceDomains } from '@/modules/workspaces/domain/logic'
import { CreateWorkspaceSeat } from '@/modules/gatekeeper/domain/operations'
@@ -217,19 +215,7 @@ export const moveProjectToWorkspaceFactory =
}
export const getWorkspaceRoleToDefaultProjectRoleMappingFactory =
({
getWorkspaceWithPlan
}: {
getWorkspaceWithPlan: GetWorkspaceWithPlan
}): GetWorkspaceRoleToDefaultProjectRoleMapping =>
async ({ workspaceId }) => {
const workspace = await getWorkspaceWithPlan({ workspaceId })
if (!workspace) {
throw new WorkspaceNotFoundError()
}
const isNewPlan = workspace.plan && isNewPlanType(workspace.plan.name)
(): GetWorkspaceRoleToDefaultProjectRoleMapping => async () => {
const allowed = {
[Roles.Workspace.Guest]: [Roles.Stream.Reviewer, Roles.Stream.Contributor],
[Roles.Workspace.Member]: [
@@ -244,45 +230,18 @@ export const getWorkspaceRoleToDefaultProjectRoleMappingFactory =
]
}
if (isNewPlan)
return {
default: {
[Roles.Workspace.Guest]: null,
[Roles.Workspace.Member]: null,
[Roles.Workspace.Admin]: null
},
allowed
}
return {
default: {
[Roles.Workspace.Guest]: null,
[Roles.Workspace.Member]: Roles.Stream.Reviewer,
[Roles.Workspace.Admin]: Roles.Stream.Owner
[Roles.Workspace.Member]: null,
[Roles.Workspace.Admin]: null
},
allowed
}
}
export const getWorkspaceSeatTypeToProjectRoleMappingFactory =
(deps: {
getWorkspaceWithPlan: GetWorkspaceWithPlan
}): GetWorkspaceSeatTypeToProjectRoleMapping =>
async (params: { workspaceId: string }) => {
const { workspaceId } = params
const workspace = await deps.getWorkspaceWithPlan({ workspaceId })
if (!workspace) {
throw new WorkspaceNotFoundError()
}
const isNewPlan = workspace.plan && isNewPlanType(workspace.plan.name)
if (!isNewPlan) {
throw new NotImplementedError(
'This function is not supported for this workspace plan'
)
}
(): GetWorkspaceSeatTypeToProjectRoleMapping => async () => {
return {
allowed: {
[WorkspaceSeatType.Viewer]: [Roles.Stream.Reviewer],
@@ -310,54 +269,50 @@ export const validateWorkspaceMemberProjectRoleFactory =
getWorkspaceSeatTypeToProjectRoleMapping: GetWorkspaceSeatTypeToProjectRoleMapping
}): ValidateWorkspaceMemberProjectRole =>
async (params) => {
const { workspaceId, userId, projectRole } = params
const { workspaceId, userId, projectRole, workspaceAccess } = params
const roleSeatParams = {
workspaceId,
userId
let workspaceRole: WorkspaceRoles
let seatType: WorkspaceSeatType
if (workspaceAccess) {
// Check planned workspace role/seat
workspaceRole = workspaceAccess.role
seatType = workspaceAccess.seatType
} else {
// Check real workspace role/seat
const roleSeatParams = {
workspaceId,
userId
}
const [currentWorkspaceRoleAndSeat, workspace] = await Promise.all([
deps.getWorkspaceRoleAndSeat(roleSeatParams),
deps.getWorkspaceWithPlan({ workspaceId })
])
if (!workspace || !currentWorkspaceRoleAndSeat?.role) return
workspaceRole = currentWorkspaceRoleAndSeat.role.role
seatType = currentWorkspaceRoleAndSeat.seat?.type || WorkspaceSeatType.Viewer
}
const [currentWorkspaceRoleAndSeat, workspace] = await Promise.all([
deps.getWorkspaceRoleAndSeat(roleSeatParams),
deps.getWorkspaceWithPlan({ workspaceId })
])
if (!workspace || !currentWorkspaceRoleAndSeat?.role) return
const {
role: { role: workspaceRole },
seat
} = currentWorkspaceRoleAndSeat
const seatType = seat?.type || WorkspaceSeatType.Viewer
let allowedRoles: StreamRoles[]
const isNewPlan = workspace.plan && isNewPlanType(workspace.plan.name)
if (isNewPlan) {
const workspaceAllowedRoles = (
await deps.getWorkspaceRoleToDefaultProjectRoleMapping({
workspaceId
})
).allowed[workspaceRole]
const seatAllowedRoles = (
await deps.getWorkspaceSeatTypeToProjectRoleMapping({
workspaceId
})
).allowed[seatType]
allowedRoles = Array.from(
new Set(workspaceAllowedRoles).intersection(new Set(seatAllowedRoles))
)
} else {
const roleMapping = await deps.getWorkspaceRoleToDefaultProjectRoleMapping({
const workspaceAllowedRoles = (
await deps.getWorkspaceRoleToDefaultProjectRoleMapping({
workspaceId
})
allowedRoles = roleMapping.allowed[workspaceRole]
}
).allowed[workspaceRole]
const seatAllowedRoles = (
await deps.getWorkspaceSeatTypeToProjectRoleMapping({
workspaceId
})
).allowed[seatType]
const allowedRoles = Array.from(
new Set(workspaceAllowedRoles).intersection(new Set(seatAllowedRoles))
)
if (!allowedRoles.includes(projectRole)) {
// User's workspace role does not allow the requested project role
throw new WorkspaceInvalidRoleError(
isNewPlan
? `User's workspace seat type '${seatType}' and workspace role '${workspaceRole}' does not allow project role '${projectRole}'.`
: `User's workspace role '${workspaceRole}' does not allow project role '${projectRole}'.`
`User's workspace seat type '${seatType}' and workspace role '${workspaceRole}' does not allow project role '${projectRole}'.`
)
}
}
@@ -50,8 +50,11 @@ import { CreateWorkspaceInviteMutationVariables } from '@/test/graphql/generated
import cryptoRandomString from 'crypto-random-string'
import {
MaybeNullOrUndefined,
PaidWorkspacePlans,
Roles,
WorkspacePlan,
WorkspacePlans,
WorkspacePlanStatuses,
WorkspaceRoles
} from '@speckle/shared'
import { getStreamFactory } from '@/modules/core/repositories/streams'
@@ -70,7 +73,7 @@ import { getDefaultSsoSessionExpirationDate } from '@/modules/workspaces/domain/
import {
getWorkspacePlanFactory,
getWorkspaceWithPlanFactory,
upsertPaidWorkspacePlanFactory,
upsertWorkspacePlanFactory,
upsertWorkspaceSubscriptionFactory
} from '@/modules/gatekeeper/repositories/billing'
import { SetOptional } from 'type-fest'
@@ -102,6 +105,7 @@ import {
getWorkspaceSeatTypeToProjectRoleMappingFactory,
validateWorkspaceMemberProjectRoleFactory
} from '@/modules/workspaces/services/projects'
import { isBoolean, isString } from 'lodash'
import { captureCreatedInvite } from '@/test/speckle-helpers/inviteHelper'
import {
finalizeInvitedServerRegistrationFactory,
@@ -141,7 +145,7 @@ export const createTestWorkspace = async (
owner: BasicTestUser,
options?: {
domain?: string
addPlan?: Pick<WorkspacePlan, 'name' | 'status'> | boolean
addPlan?: Partial<Pick<WorkspacePlan, 'name' | 'status'>> | boolean | WorkspacePlans
addSubscription?: boolean
regionKey?: string
}
@@ -158,7 +162,7 @@ export const createTestWorkspace = async (
return
}
const upsertWorkspacePlan = upsertPaidWorkspacePlanFactory({ db })
const upsertWorkspacePlan = upsertWorkspacePlanFactory({ db })
const createWorkspace = createWorkspaceFactory({
validateSlug: validateSlugFactory({
getWorkspaceBySlug: getWorkspaceBySlugFactory({ db })
@@ -207,21 +211,25 @@ export const createTestWorkspace = async (
}
if (addPlan || useRegion) {
let planName: WorkspacePlans
let planStatus: WorkspacePlanStatuses
if (isBoolean(addPlan)) {
planName = PaidWorkspacePlans.Team
planStatus = WorkspacePlanStatuses.Valid
} else {
planName = (isString(addPlan) ? addPlan : addPlan.name) || PaidWorkspacePlans.Team
planStatus =
(isString(addPlan) ? WorkspacePlanStatuses.Valid : addPlan.status) ||
WorkspacePlanStatuses.Valid
}
await upsertWorkspacePlan({
workspacePlan: {
createdAt: new Date(),
workspaceId: newWorkspace.id,
name:
typeof addPlan === 'object' && Object.hasOwn(addPlan, 'name')
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
(addPlan.name as any)
: 'business',
status:
typeof addPlan === 'object' && Object.hasOwn(addPlan, 'status')
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
(addPlan.status as any)
: 'valid'
}
name: planName,
status: planStatus
} as WorkspacePlan
})
}
@@ -411,13 +419,9 @@ export const createWorkspaceInviteDirectly = async (
getWorkspaceRoleAndSeat: getWorkspaceRoleAndSeatFactory({ db }),
getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db }),
getWorkspaceRoleToDefaultProjectRoleMapping:
getWorkspaceRoleToDefaultProjectRoleMappingFactory({
getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db })
}),
getWorkspaceRoleToDefaultProjectRoleMappingFactory(),
getWorkspaceSeatTypeToProjectRoleMapping:
getWorkspaceSeatTypeToProjectRoleMappingFactory({
getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db })
})
getWorkspaceSeatTypeToProjectRoleMappingFactory()
})
})
@@ -12,8 +12,12 @@ import {
CreateWorkspaceProjectInviteDocument,
CreateWorkspaceProjectInviteMutationVariables,
GetMyWorkspaceInvitesDocument,
GetProjectDocument,
GetProjectQueryVariables,
GetWorkspaceDocument,
GetWorkspaceInviteDocument,
GetWorkspaceInviteQueryVariables,
GetWorkspaceQueryVariables,
GetWorkspaceWithTeamDocument,
GetWorkspaceWithTeamQueryVariables,
ResendWorkspaceInviteDocument,
@@ -27,16 +31,20 @@ import { expect } from 'chai'
import { MaybeAsync, StreamRoles, WorkspaceRoles } from '@speckle/shared'
import { expectToThrow } from '@/test/assertionHelper'
import { getWorkspaceFactory } from '@/modules/workspaces/repositories/workspaces'
import { ForbiddenError } from '@/modules/shared/errors'
import { getStreamFactory } from '@/modules/core/repositories/streams'
import { db } from '@/db/knex'
export const buildInvitesGraphqlOperations = (deps: { apollo: TestApolloServer }) => {
const { apollo } = deps
const getStream = getStreamFactory({ db })
const getWorkspace = async (
args: GetWorkspaceQueryVariables,
options?: ExecuteOperationOptions
) => apollo.execute(GetWorkspaceDocument, args, options)
const getProject = async (
args: GetProjectQueryVariables,
options?: ExecuteOperationOptions
) => apollo.execute(GetProjectDocument, args, options)
const useInvite = async (
args: UseWorkspaceInviteMutationVariables,
@@ -91,7 +99,8 @@ export const buildInvitesGraphqlOperations = (deps: { apollo: TestApolloServer }
}
await wrapAccessCheck(async () => {
const workspace = await getWorkspaceFactory({ db })({ workspaceId, userId })
const res = await getWorkspace({ workspaceId }, { authUserId: userId })
const workspace = res.data?.workspace
if (!workspace?.role) {
throw new ForbiddenError('Missing workspace role')
}
@@ -108,14 +117,20 @@ export const buildInvitesGraphqlOperations = (deps: { apollo: TestApolloServer }
if (streamId?.length) {
await wrapAccessCheck(async () => {
const project = await getStream({ streamId, userId })
if (!project?.role) {
const res = await getProject({ id: streamId }, { authUserId: userId })
const project = res.data?.project
// No need to check for project role, since it can be implicit from workspace
if (!project?.id) {
throw new ForbiddenError('Missing project role')
}
if (params.expectedProjectRole && project.role !== params.expectedProjectRole) {
if (
params.expectedProjectRole &&
project?.role !== params.expectedProjectRole
) {
throw new ForbiddenError(
`Unexpected project role! Expected: ${params.expectedProjectRole}, real: ${project.role}`
`Unexpected project role! Expected: ${params.expectedProjectRole}, real: ${project?.role}`
)
}
})
@@ -66,6 +66,7 @@ import {
buildInvitesGraphqlOperations
} from '@/modules/workspaces/tests/helpers/invites'
import { getEventBus } from '@/modules/shared/services/eventBus'
import { WorkspaceSeatType } from '@/modules/workspacesCore/domain/types'
enum InviteByTarget {
Email = 'email',
@@ -528,7 +529,12 @@ describe('Workspaces Invites GQL', () => {
]
])
await assignToWorkspaces([
[myProjectInviteTargetWorkspace, myWorkspaceFriend, Roles.Workspace.Member],
[
myProjectInviteTargetWorkspace,
myWorkspaceFriend,
Roles.Workspace.Member,
WorkspaceSeatType.Editor
],
[
myProjectInviteTargetWorkspace,
workspaceMemberWithNoProjectAccess,
@@ -615,7 +621,7 @@ describe('Workspaces Invites GQL', () => {
inputs: [
{
userId: otherGuy.id,
role: Roles.Stream.Owner
role: Roles.Stream.Reviewer
}
]
},
@@ -671,7 +677,7 @@ describe('Workspaces Invites GQL', () => {
inputs: [
{
userId: workspaceMemberWithNoProjectAccess.id,
role: Roles.Stream.Owner
role: Roles.Stream.Reviewer
}
]
},
@@ -1026,7 +1032,7 @@ describe('Workspaces Invites GQL', () => {
inputs: [
{
userId: otherGuy.id,
role: Roles.Stream.Owner
role: Roles.Stream.Reviewer
}
]
},
@@ -1506,7 +1512,7 @@ describe('Workspaces Invites GQL', () => {
await validateResourceAccess({
shouldHaveAccess: true,
expectedWorkspaceRole: Roles.Workspace.Guest,
expectedProjectRole: Roles.Stream.Owner
expectedProjectRole: Roles.Stream.Reviewer
})
})
@@ -1525,7 +1531,7 @@ describe('Workspaces Invites GQL', () => {
inputs: [
{
userId: otherGuy.id,
role: Roles.Stream.Owner,
role: withRole ? Roles.Stream.Owner : Roles.Stream.Reviewer,
workspaceRole: withRole ? Roles.Workspace.Admin : undefined
}
]
@@ -1562,7 +1568,7 @@ describe('Workspaces Invites GQL', () => {
expectedWorkspaceRole: withRole
? Roles.Workspace.Admin
: Roles.Workspace.Guest,
expectedProjectRole: Roles.Stream.Owner
expectedProjectRole: withRole ? Roles.Stream.Owner : Roles.Stream.Reviewer
})
}
)
@@ -36,7 +36,13 @@ import {
createTestStream,
getUserStreamRole
} from '@/test/speckle-helpers/streamHelper'
import { isNonNullable, Nullable, Optional, Roles } from '@speckle/shared'
import {
isNonNullable,
Nullable,
Optional,
PaidWorkspacePlans,
Roles
} from '@speckle/shared'
import { expect } from 'chai'
import cryptoRandomString from 'crypto-random-string'
import dayjs from 'dayjs'
@@ -118,137 +124,120 @@ describe('Workspace project GQL CRUD', () => {
])
})
describeEach(
[{ oldPlan: true }, { oldPlan: false }],
({ oldPlan }) => `with ${oldPlan ? 'old (business)' : 'new (pro)'} plan`,
({ oldPlan }) => {
const roleProject: BasicTestStream = {
name: 'Role Project',
isPublic: false,
id: '',
ownerId: ''
}
describe(`with pro plan`, () => {
const roleProject: BasicTestStream = {
name: 'Role Project',
isPublic: false,
id: '',
ownerId: ''
}
const roleWorkspace: BasicTestWorkspace = {
id: '',
ownerId: '',
slug: cryptoRandomString({ length: 10 }),
name: 'Role Workspace'
}
const roleWorkspace: BasicTestWorkspace = {
id: '',
ownerId: '',
slug: cryptoRandomString({ length: 10 }),
name: 'Role Workspace'
}
before(async () => {
// TODO: Multiregion
await createTestWorkspace(roleWorkspace, serverAdminUser, {
addPlan: oldPlan
? { name: 'business', status: 'valid' }
: { name: 'pro', status: 'valid' }
})
roleProject.workspaceId = roleWorkspace.id
before(async () => {
// TODO: Multiregion
await createTestWorkspace(roleWorkspace, serverAdminUser, {
addPlan: { name: PaidWorkspacePlans.Pro, status: 'valid' }
})
roleProject.workspaceId = roleWorkspace.id
await Promise.all([
assignToWorkspace(roleWorkspace, workspaceGuest, Roles.Workspace.Guest),
assignToWorkspace(
roleWorkspace,
workspaceEditor,
Roles.Workspace.Member,
WorkspaceSeatType.Editor
),
assignToWorkspace(
roleWorkspace,
workspaceMemberViewer,
Roles.Workspace.Member,
WorkspaceSeatType.Viewer
)
])
await createTestStream(roleProject, serverAdminUser)
await Promise.all([
addToStream(roleProject, workspaceGuest, Roles.Stream.Reviewer),
addToStream(roleProject, workspaceEditor, Roles.Stream.Contributor),
addToStream(roleProject, workspaceMemberViewer, Roles.Stream.Reviewer)
])
// assert seat types
const seats = await getWorkspaceUserSeatsFactory({ db })({
workspaceId: roleWorkspace.id,
userIds: [workspaceGuest.id, workspaceEditor.id, workspaceMemberViewer.id]
})
expect(seats[workspaceGuest.id].type).to.equal(WorkspaceSeatType.Viewer)
expect(seats[workspaceEditor.id].type).to.equal(WorkspaceSeatType.Editor)
expect(seats[workspaceMemberViewer.id].type).to.equal(
await Promise.all([
assignToWorkspace(roleWorkspace, workspaceGuest, Roles.Workspace.Guest),
assignToWorkspace(
roleWorkspace,
workspaceEditor,
Roles.Workspace.Member,
WorkspaceSeatType.Editor
),
assignToWorkspace(
roleWorkspace,
workspaceMemberViewer,
Roles.Workspace.Member,
WorkspaceSeatType.Viewer
)
])
await createTestStream(roleProject, serverAdminUser)
await Promise.all([
addToStream(roleProject, workspaceGuest, Roles.Stream.Reviewer),
addToStream(roleProject, workspaceEditor, Roles.Stream.Contributor),
addToStream(roleProject, workspaceMemberViewer, Roles.Stream.Reviewer)
])
// assert seat types
const seats = await getWorkspaceUserSeatsFactory({ db })({
workspaceId: roleWorkspace.id,
userIds: [workspaceGuest.id, workspaceEditor.id, workspaceMemberViewer.id]
})
expect(seats[workspaceGuest.id].type).to.equal(WorkspaceSeatType.Viewer)
expect(seats[workspaceEditor.id].type).to.equal(WorkspaceSeatType.Editor)
expect(seats[workspaceMemberViewer.id].type).to.equal(WorkspaceSeatType.Viewer)
})
describeEach(
[{ oldResolver: true }, { oldResolver: false }],
({ oldResolver }) =>
`with ${oldResolver ? 'old' : 'new'} updateRole resolver`,
({ oldResolver }) => {
const updateRole = async (input: ProjectUpdateRoleInput) => {
if (oldResolver) {
const res = await apollo.execute(UpdateProjectRoleDocument, {
input
})
const project = res.data?.projectMutations?.updateRole
return { res, project }
} else {
const res = await apollo.execute(UpdateWorkspaceProjectRoleDocument, {
input
})
const project = res.data?.workspaceMutations?.projects?.updateRole
return { res, project }
}
describeEach(
[{ oldResolver: true }, { oldResolver: false }],
({ oldResolver }) => `with ${oldResolver ? 'old' : 'new'} updateRole resolver`,
({ oldResolver }) => {
const updateRole = async (input: ProjectUpdateRoleInput) => {
if (oldResolver) {
const res = await apollo.execute(UpdateProjectRoleDocument, {
input
})
const project = res.data?.projectMutations?.updateRole
return { res, project }
} else {
const res = await apollo.execute(UpdateWorkspaceProjectRoleDocument, {
input
})
const project = res.data?.workspaceMutations?.projects?.updateRole
return { res, project }
}
it("can't set a workspace guest as a project owner", async () => {
const { res } = await updateRole({
projectId: roleProject.id,
userId: workspaceGuest.id,
role: Roles.Stream.Owner
})
const newRole = await getUserStreamRole(workspaceGuest.id, roleProject.id)
expect(res).to.haveGraphQLErrors({ code: WorkspaceInvalidRoleError.code })
expect(newRole).to.eq(Roles.Stream.Reviewer)
})
it(`can${
oldPlan ? '' : 'not'
} set a workspace viewer as a project contributor or owner`, async () => {
const { res: resA } = await updateRole({
projectId: roleProject.id,
userId: workspaceMemberViewer.id,
role: Roles.Stream.Contributor
})
const { res: resB } = await updateRole({
projectId: roleProject.id,
userId: workspaceMemberViewer.id,
role: Roles.Stream.Owner
})
const newRole = await getUserStreamRole(
workspaceMemberViewer.id,
roleProject.id
)
if (oldPlan) {
expect(resA).to.not.haveGraphQLErrors()
expect(resB).to.not.haveGraphQLErrors()
expect(newRole).to.eq(Roles.Stream.Owner)
} else {
expect(resA).to.haveGraphQLErrors({
code: WorkspaceInvalidRoleError.code
})
expect(resB).to.haveGraphQLErrors({
code: WorkspaceInvalidRoleError.code
})
expect(newRole).to.eq(Roles.Stream.Reviewer)
}
})
}
)
}
)
it("can't set a workspace guest as a project owner", async () => {
const { res } = await updateRole({
projectId: roleProject.id,
userId: workspaceGuest.id,
role: Roles.Stream.Owner
})
const newRole = await getUserStreamRole(workspaceGuest.id, roleProject.id)
expect(res).to.haveGraphQLErrors({ code: WorkspaceInvalidRoleError.code })
expect(newRole).to.eq(Roles.Stream.Reviewer)
})
it(`can not set a workspace viewer as a project contributor or owner`, async () => {
const { res: resA } = await updateRole({
projectId: roleProject.id,
userId: workspaceMemberViewer.id,
role: Roles.Stream.Contributor
})
const { res: resB } = await updateRole({
projectId: roleProject.id,
userId: workspaceMemberViewer.id,
role: Roles.Stream.Owner
})
const newRole = await getUserStreamRole(
workspaceMemberViewer.id,
roleProject.id
)
expect(resA).to.haveGraphQLErrors({
code: WorkspaceInvalidRoleError.code
})
expect(resB).to.haveGraphQLErrors({
code: WorkspaceInvalidRoleError.code
})
expect(newRole).to.eq(Roles.Stream.Reviewer)
})
}
)
})
})
describe('when specifying a workspace id during project creation', () => {
@@ -17,7 +17,7 @@ import { testApolloServer, TestApolloServer } from '@/test/graphqlHelper'
import { beforeEachContext, getRegionKeys } from '@/test/hooks'
import { MultiRegionDbSelectorMock } from '@/test/mocks/global'
import { truncateRegionsSafely } from '@/test/speckle-helpers/regions'
import { Roles } from '@speckle/shared'
import { PaidWorkspacePlans, Roles } from '@speckle/shared'
import { expect } from 'chai'
const storeRegion = storeRegionFactory({ db })
@@ -51,7 +51,10 @@ isEnabled
await Promise.all([
// Create first test workspace
createTestWorkspace(myFirstWorkspace, me),
createTestWorkspace(myFirstWorkspace, me, {
// pro for custom regions
addPlan: { name: PaidWorkspacePlans.Pro }
}),
// Create a couple of test regions
storeRegion({
region: {
@@ -91,7 +94,28 @@ isEnabled
})
})
describe('when setting default region', () => {
it("can't set default region on invalid plan", async () => {
const workspace: BasicTestWorkspace = {
id: '',
ownerId: '',
slug: '',
name: 'My second workspace'
}
await createTestWorkspace(workspace, me, {
addPlan: { name: PaidWorkspacePlans.Team }
})
const res = await apollo.execute(
SetWorkspaceDefaultRegionDocument,
{ workspaceId: workspace.id, regionKey: region1Key },
{ authUserId: me.id }
)
expect(res).to.haveGraphQLErrors('Specified region not available for workspace')
expect(res.data?.workspaceMutations.setDefaultRegion).to.be.not.ok
})
describe('when setting default region on valid plan', () => {
const mySecondWorkspace: BasicTestWorkspace = {
id: '',
ownerId: '',
@@ -100,7 +124,10 @@ isEnabled
}
before(async () => {
await createTestWorkspace(mySecondWorkspace, me)
await createTestWorkspace(mySecondWorkspace, me, {
// pro for custom regions
addPlan: { name: PaidWorkspacePlans.Pro }
})
})
beforeEach(async () => {
@@ -145,7 +172,10 @@ isEnabled
}
before(async () => {
await createTestWorkspace(myThirdWorkspace, me)
await createTestWorkspace(myThirdWorkspace, me, {
// pro for custom regions
addPlan: { name: PaidWorkspacePlans.Pro }
})
await apollo.execute(
SetWorkspaceDefaultRegionDocument,
{
@@ -44,6 +44,7 @@ import {
waitForRegionUsers
} from '@/test/speckle-helpers/regions'
import { faker } from '@faker-js/faker'
import { WorkspacePlans } from '@speckle/shared'
import { expect } from 'chai'
enum WorkspaceIdentification {
@@ -104,7 +105,8 @@ describe('Workspace GQL Subscriptions', () => {
before(async () => {
await waitForRegionUsers([me, otherGuy])
await createTestWorkspace(myMainWorkspace, me, {
regionKey: isMultiRegion ? getMainTestRegionKey() : undefined
regionKey: isMultiRegion ? getMainTestRegionKey() : undefined,
addPlan: WorkspacePlans.Pro
})
})
@@ -194,7 +196,8 @@ describe('Workspace GQL Subscriptions', () => {
before(async () => {
await createTestWorkspace(myTeamWorkspace, me, {
regionKey: isMultiRegion ? getMainTestRegionKey() : undefined
regionKey: isMultiRegion ? getMainTestRegionKey() : undefined,
addPlan: WorkspacePlans.Pro
})
})
@@ -1,88 +0,0 @@
import cryptoRandomString from 'crypto-random-string'
import { Workspace, WorkspaceAcl } from '@/modules/workspacesCore/domain/types'
import { Roles } from '@speckle/shared'
import { StreamAclRecord, StreamRecord } from '@/modules/core/helpers/types'
import { onProjectCreatedFactory } from '@/modules/workspaces/events/eventListener'
import { expect } from 'chai'
import { GetWorkspaceRolesAndSeats } from '@/modules/gatekeeper/domain/billing'
describe('Event handlers', () => {
describe('onProjectCreatedFactory creates a function, that', () => {
it('grants project roles for all workspace members, except guests', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const projectId = cryptoRandomString({ length: 10 })
const workspaceRoles: WorkspaceAcl[] = [
{
workspaceId,
userId: cryptoRandomString({ length: 10 }),
role: Roles.Workspace.Admin,
createdAt: new Date()
},
{
workspaceId,
userId: cryptoRandomString({ length: 10 }),
role: Roles.Workspace.Member,
createdAt: new Date()
},
{
workspaceId,
userId: cryptoRandomString({ length: 10 }),
role: Roles.Workspace.Guest,
createdAt: new Date()
}
]
const projectRoles: StreamAclRecord[] = []
const onProjectCreated = onProjectCreatedFactory({
getWorkspaceRolesAndSeats: async () =>
workspaceRoles.reduce((acc, role) => {
acc[role.userId] = { role, seat: null, userId: role.userId }
return acc
}, {} as Awaited<ReturnType<GetWorkspaceRolesAndSeats>>),
getWorkspaceWithPlan: async () =>
({
id: workspaceId
} as Workspace & { plan: null }),
getWorkspaceRoleToDefaultProjectRoleMapping: async () => ({
default: {
[Roles.Workspace.Admin]: Roles.Stream.Owner,
[Roles.Workspace.Member]: Roles.Stream.Contributor,
[Roles.Workspace.Guest]: null
},
allowed: {
[Roles.Workspace.Admin]: [
Roles.Stream.Owner,
Roles.Stream.Contributor,
Roles.Stream.Reviewer
],
[Roles.Workspace.Member]: [
Roles.Stream.Owner,
Roles.Stream.Contributor,
Roles.Stream.Reviewer
],
[Roles.Workspace.Guest]: [Roles.Stream.Reviewer, Roles.Stream.Contributor]
}
}),
upsertProjectRole: async ({ projectId, userId, role }) => {
projectRoles.push({
resourceId: projectId,
userId,
role
})
return {} as StreamRecord
}
})
await onProjectCreated({
project: { workspaceId, id: projectId } as StreamRecord,
ownerId: cryptoRandomString({ length: 10 }),
input: { name: 'test' }
})
expect(projectRoles.length).to.equal(2)
})
})
})
@@ -834,10 +834,7 @@ describe('Workspace role services', () => {
expect(context.eventData.eventName).to.equal(WorkspaceEvents.RoleUpdated)
expect(payload).to.deep.equal({
acl: role,
updatedByUserId: workspaceOwnerId,
flags: {
skipProjectRoleUpdatesFor: []
}
updatedByUserId: workspaceOwnerId
})
})
it('throws if attempting to remove the last admin in a workspace', async () => {
@@ -93,14 +93,24 @@ describe('Project retrieval services', () => {
describe('Project management services', () => {
describe('moveProjectToWorkspaceFactory returns a function, that', () => {
const roleMapping: [
StreamRoles, // Current project role
WorkspaceRoles | null, // Current workspace role
WorkspaceSeatType | null, // Current workspace seat type
StreamRoles, // Final project role
WorkspaceRoles, // Final workspace role
WorkspaceSeatType // Final workspace seat type
][] = [
const roleMapping: Array<
| [
StreamRoles, // Current project role
null, // Current workspace role
null, // Current workspace seat type
StreamRoles, // Final project role
WorkspaceRoles, // Final workspace role
WorkspaceSeatType // Final workspace seat type
]
| [
StreamRoles, // Current project role
WorkspaceRoles, // Current workspace role
WorkspaceSeatType, // Current workspace seat type
StreamRoles, // Final project role
WorkspaceRoles, // Final workspace role
WorkspaceSeatType // Final workspace seat type
]
> = [
[
Roles.Stream.Owner,
Roles.Workspace.Admin,
@@ -343,7 +353,13 @@ describe('Project management services', () => {
workspaceId,
createdAt: new Date()
},
seat: null,
seat: {
workspaceId,
userId,
type: WorkspaceSeatType.Editor,
createdAt: new Date(),
updatedAt: new Date()
},
userId
}
}
@@ -471,7 +487,7 @@ describe('Project management services', () => {
]
},
getWorkspaceRolesAndSeats: async () => {
return workspaceRole
return workspaceRole && workspaceSeatType
? {
[userId]: {
role: {
@@ -480,15 +496,13 @@ describe('Project management services', () => {
workspaceId,
createdAt: new Date()
},
seat: workspaceSeatType
? {
workspaceId,
userId,
createdAt: new Date(),
updatedAt: new Date(),
type: workspaceSeatType
}
: null,
seat: {
workspaceId,
userId,
createdAt: new Date(),
updatedAt: new Date(),
type: workspaceSeatType
},
userId
}
}
@@ -1,5 +1,4 @@
import { WorkspaceAcl, WorkspaceSeat } from '@/modules/workspacesCore/domain/types'
import { Nullable } from '@speckle/shared'
export type GetWorkspaceRolesAndSeats = (params: {
workspaceId: string
@@ -7,7 +6,7 @@ export type GetWorkspaceRolesAndSeats = (params: {
}) => Promise<{
[userId: string]: {
role: WorkspaceAcl
seat: Nullable<WorkspaceSeat>
seat: WorkspaceSeat
userId: string
}
}>
@@ -18,7 +17,7 @@ export type GetWorkspaceRoleAndSeat = (params: {
}) => Promise<
| {
role: WorkspaceAcl
seat: Nullable<WorkspaceSeat>
seat: WorkspaceSeat
userId: string
}
| undefined
@@ -46,7 +46,7 @@ export const getWorkspaceRolesAndSeatsFactory =
acc[role.userId] = {
role,
seat: formatJsonArrayRecords(row.seats || [])[0] || null,
seat: formatJsonArrayRecords(row.seats || [])[0],
userId: role.userId
}
return acc
@@ -1923,11 +1923,8 @@ export type OnboardingCompletionInput = {
};
export const PaidWorkspacePlans = {
Business: 'business',
Plus: 'plus',
Pro: 'pro',
ProUnlimited: 'proUnlimited',
Starter: 'starter',
Team: 'team',
TeamUnlimited: 'teamUnlimited'
} as const;
@@ -4882,9 +4879,7 @@ export type WorkspacePlanPrice = {
export const WorkspacePlanStatuses = {
CancelationScheduled: 'cancelationScheduled',
Canceled: 'canceled',
Expired: 'expired',
PaymentFailed: 'paymentFailed',
Trial: 'trial',
Valid: 'valid'
} as const;
@@ -4897,16 +4892,10 @@ export type WorkspacePlanUsage = {
export const WorkspacePlans = {
Academia: 'academia',
Business: 'business',
BusinessInvoiced: 'businessInvoiced',
Free: 'free',
Plus: 'plus',
PlusInvoiced: 'plusInvoiced',
Pro: 'pro',
ProUnlimited: 'proUnlimited',
ProUnlimitedInvoiced: 'proUnlimitedInvoiced',
Starter: 'starter',
StarterInvoiced: 'starterInvoiced',
Team: 'team',
TeamUnlimited: 'teamUnlimited',
TeamUnlimitedInvoiced: 'teamUnlimitedInvoiced',
@@ -5888,7 +5877,7 @@ export type GetProjectQueryVariables = Exact<{
}>;
export type GetProjectQuery = { __typename?: 'Query', project: { __typename?: 'Project', id: string, name: string, workspaceId?: string | null } };
export type GetProjectQuery = { __typename?: 'Query', project: { __typename?: 'Project', id: string, name: string, workspaceId?: string | null, role?: string | null, description?: string | null, visibility: SimpleProjectVisibility, allowPublicComments: boolean, createdAt: string, updatedAt: string } };
export type CreateProjectMutationVariables = Exact<{
input: ProjectCreateInput;
@@ -6171,7 +6160,7 @@ export type MarkProjectVersionReceivedMutationVariables = Exact<{
export type MarkProjectVersionReceivedMutation = { __typename?: 'Mutation', versionMutations: { __typename?: 'VersionMutations', markReceived: boolean } };
export type TestWorkspaceFragment = { __typename?: 'Workspace', id: string, name: string, slug: string, description?: string | null, createdAt: string, updatedAt: string, logo?: string | null, readOnly: boolean, discoverabilityEnabled: boolean };
export type TestWorkspaceFragment = { __typename?: 'Workspace', id: string, name: string, slug: string, description?: string | null, createdAt: string, updatedAt: string, logo?: string | null, readOnly: boolean, discoverabilityEnabled: boolean, role?: string | null };
export type TestWorkspaceCollaboratorFragment = { __typename?: 'WorkspaceCollaborator', id: string, role: string, user: { __typename?: 'LimitedUser', name: string }, projectRoles: Array<{ __typename?: 'ProjectRole', role: string, project: { __typename?: 'Project', id: string, name: string } }> };
@@ -6182,7 +6171,7 @@ export type CreateWorkspaceMutationVariables = Exact<{
}>;
export type CreateWorkspaceMutation = { __typename?: 'Mutation', workspaceMutations: { __typename?: 'WorkspaceMutations', create: { __typename?: 'Workspace', id: string, name: string, slug: string, description?: string | null, createdAt: string, updatedAt: string, logo?: string | null, readOnly: boolean, discoverabilityEnabled: boolean } } };
export type CreateWorkspaceMutation = { __typename?: 'Mutation', workspaceMutations: { __typename?: 'WorkspaceMutations', create: { __typename?: 'Workspace', id: string, name: string, slug: string, description?: string | null, createdAt: string, updatedAt: string, logo?: string | null, readOnly: boolean, discoverabilityEnabled: boolean, role?: string | null } } };
export type DeleteWorkspaceMutationVariables = Exact<{
workspaceId: Scalars['String']['input'];
@@ -6196,14 +6185,14 @@ export type GetWorkspaceQueryVariables = Exact<{
}>;
export type GetWorkspaceQuery = { __typename?: 'Query', workspace: { __typename?: 'Workspace', id: string, name: string, slug: string, description?: string | null, createdAt: string, updatedAt: string, logo?: string | null, readOnly: boolean, discoverabilityEnabled: boolean, team: { __typename?: 'WorkspaceCollaboratorCollection', items: Array<{ __typename?: 'WorkspaceCollaborator', id: string, role: string, user: { __typename?: 'LimitedUser', name: string }, projectRoles: Array<{ __typename?: 'ProjectRole', role: string, project: { __typename?: 'Project', id: string, name: string } }> }> } } };
export type GetWorkspaceQuery = { __typename?: 'Query', workspace: { __typename?: 'Workspace', id: string, name: string, slug: string, description?: string | null, createdAt: string, updatedAt: string, logo?: string | null, readOnly: boolean, discoverabilityEnabled: boolean, role?: string | null, team: { __typename?: 'WorkspaceCollaboratorCollection', items: Array<{ __typename?: 'WorkspaceCollaborator', id: string, role: string, user: { __typename?: 'LimitedUser', name: string }, projectRoles: Array<{ __typename?: 'ProjectRole', role: string, project: { __typename?: 'Project', id: string, name: string } }> }> } } };
export type GetWorkspaceBySlugQueryVariables = Exact<{
workspaceSlug: Scalars['String']['input'];
}>;
export type GetWorkspaceBySlugQuery = { __typename?: 'Query', workspaceBySlug: { __typename?: 'Workspace', id: string, name: string, slug: string, description?: string | null, createdAt: string, updatedAt: string, logo?: string | null, readOnly: boolean, discoverabilityEnabled: boolean, team: { __typename?: 'WorkspaceCollaboratorCollection', items: Array<{ __typename?: 'WorkspaceCollaborator', id: string, role: string, user: { __typename?: 'LimitedUser', name: string }, projectRoles: Array<{ __typename?: 'ProjectRole', role: string, project: { __typename?: 'Project', id: string, name: string } }> }> } } };
export type GetWorkspaceBySlugQuery = { __typename?: 'Query', workspaceBySlug: { __typename?: 'Workspace', id: string, name: string, slug: string, description?: string | null, createdAt: string, updatedAt: string, logo?: string | null, readOnly: boolean, discoverabilityEnabled: boolean, role?: string | null, team: { __typename?: 'WorkspaceCollaboratorCollection', items: Array<{ __typename?: 'WorkspaceCollaborator', id: string, role: string, user: { __typename?: 'LimitedUser', name: string }, projectRoles: Array<{ __typename?: 'ProjectRole', role: string, project: { __typename?: 'Project', id: string, name: string } }> }> } } };
export type GetActiveUserDiscoverableWorkspacesQueryVariables = Exact<{ [key: string]: never; }>;
@@ -6215,12 +6204,12 @@ export type UpdateWorkspaceMutationVariables = Exact<{
}>;
export type UpdateWorkspaceMutation = { __typename?: 'Mutation', workspaceMutations: { __typename?: 'WorkspaceMutations', update: { __typename?: 'Workspace', id: string, name: string, slug: string, description?: string | null, createdAt: string, updatedAt: string, logo?: string | null, readOnly: boolean, discoverabilityEnabled: boolean } } };
export type UpdateWorkspaceMutation = { __typename?: 'Mutation', workspaceMutations: { __typename?: 'WorkspaceMutations', update: { __typename?: 'Workspace', id: string, name: string, slug: string, description?: string | null, createdAt: string, updatedAt: string, logo?: string | null, readOnly: boolean, discoverabilityEnabled: boolean, role?: string | null } } };
export type GetActiveUserWorkspacesQueryVariables = Exact<{ [key: string]: never; }>;
export type GetActiveUserWorkspacesQuery = { __typename?: 'Query', activeUser?: { __typename?: 'User', workspaces: { __typename?: 'WorkspaceCollection', items: Array<{ __typename?: 'Workspace', id: string, name: string, slug: string, description?: string | null, createdAt: string, updatedAt: string, logo?: string | null, readOnly: boolean, discoverabilityEnabled: boolean }> } } | null };
export type GetActiveUserWorkspacesQuery = { __typename?: 'Query', activeUser?: { __typename?: 'User', workspaces: { __typename?: 'WorkspaceCollection', items: Array<{ __typename?: 'Workspace', id: string, name: string, slug: string, description?: string | null, createdAt: string, updatedAt: string, logo?: string | null, readOnly: boolean, discoverabilityEnabled: boolean, role?: string | null }> } } | null };
export type UpdateWorkspaceRoleMutationVariables = Exact<{
input: WorkspaceRoleUpdateInput;
@@ -6311,7 +6300,7 @@ export const BasicStreamFieldsFragmentDoc = {"kind":"Document","definitions":[{"
export const UserWithEmailsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"UserWithEmails"},"typeCondition":{"kind":"NamedType","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":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"emails"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"verified"}},{"kind":"Field","name":{"kind":"Name","value":"primary"}}]}}]}}]} as unknown as DocumentNode<UserWithEmailsFragment, unknown>;
export const BaseUserFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BaseUserFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"User"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"bio"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"verified"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]} as unknown as DocumentNode<BaseUserFieldsFragment, unknown>;
export const BaseLimitedUserFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BaseLimitedUserFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"LimitedUser"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"bio"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"verified"}}]}}]} as unknown as DocumentNode<BaseLimitedUserFieldsFragment, unknown>;
export const TestWorkspaceFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TestWorkspace"},"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":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"readOnly"}},{"kind":"Field","name":{"kind":"Name","value":"discoverabilityEnabled"}}]}}]} as unknown as DocumentNode<TestWorkspaceFragment, unknown>;
export const TestWorkspaceFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TestWorkspace"},"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":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"readOnly"}},{"kind":"Field","name":{"kind":"Name","value":"discoverabilityEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]} as unknown as DocumentNode<TestWorkspaceFragment, unknown>;
export const TestWorkspaceCollaboratorFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TestWorkspaceCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"projectRoles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"project"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode<TestWorkspaceCollaboratorFragment, unknown>;
export const TestWorkspaceProjectFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TestWorkspaceProject"},"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":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]}}]} as unknown as DocumentNode<TestWorkspaceProjectFragment, unknown>;
export const CreateObjectDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateObject"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ObjectCreateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"objectCreate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"objectInput"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode<CreateObjectMutation, CreateObjectMutationVariables>;
@@ -6414,7 +6403,7 @@ export const CreateProjectCommentReplyDocument = {"kind":"Document","definitions
export const EditProjectCommentDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"EditProjectComment"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"EditCommentInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"commentMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edit"},"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":"BasicProjectComment"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicProjectComment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Comment"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"rawText"}},{"kind":"Field","name":{"kind":"Name","value":"text"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"doc"}}]}},{"kind":"Field","name":{"kind":"Name","value":"authorId"}}]}}]} as unknown as DocumentNode<EditProjectCommentMutation, EditProjectCommentMutationVariables>;
export const AdminProjectListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"AdminProjectList"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"query"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"orderBy"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"visibility"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},"defaultValue":{"kind":"IntValue","value":"25"}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}},"defaultValue":{"kind":"NullValue"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"admin"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projectList"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"query"},"value":{"kind":"Variable","name":{"kind":"Name","value":"query"}}},{"kind":"Argument","name":{"kind":"Name","value":"orderBy"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orderBy"}}},{"kind":"Argument","name":{"kind":"Name","value":"visibility"},"value":{"kind":"Variable","name":{"kind":"Name","value":"visibility"}}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}},{"kind":"Argument","name":{"kind":"Name","value":"cursor"},"value":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"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<AdminProjectListQuery, AdminProjectListQueryVariables>;
export const GetProjectObjectDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetProjectObject"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"objectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"object"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"objectId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]}}]} as unknown as DocumentNode<GetProjectObjectQuery, GetProjectObjectQueryVariables>;
export const GetProjectDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetProject"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceId"}}]}}]}}]} as unknown as DocumentNode<GetProjectQuery, GetProjectQueryVariables>;
export const GetProjectDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetProject"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"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<GetProjectQuery, GetProjectQueryVariables>;
export const CreateProjectDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateProject"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ProjectCreateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projectMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"create"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicProjectFields"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicProjectFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Project"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"allowPublicComments"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<CreateProjectMutation, CreateProjectMutationVariables>;
export const BatchDeleteProjectsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"BatchDeleteProjects"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projectMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"batchDelete"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}}]}]}}]}}]} as unknown as DocumentNode<BatchDeleteProjectsMutation, BatchDeleteProjectsMutationVariables>;
export const UpdateProjectRoleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateProjectRole"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ProjectUpdateRoleInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projectMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateRole"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicProjectFields"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicProjectFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Project"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"allowPublicComments"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<UpdateProjectRoleMutation, UpdateProjectRoleMutationVariables>;
@@ -6454,13 +6443,13 @@ export const UserActiveResourcesDocument = {"kind":"Document","definitions":[{"k
export const SetUserActiveWorkspaceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SetUserActiveWorkspace"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"slug"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"isProjectsActive"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUserMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setActiveWorkspace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"slug"},"value":{"kind":"Variable","name":{"kind":"Name","value":"slug"}}},{"kind":"Argument","name":{"kind":"Name","value":"isProjectsActive"},"value":{"kind":"Variable","name":{"kind":"Name","value":"isProjectsActive"}}}]}]}}]}}]} as unknown as DocumentNode<SetUserActiveWorkspaceMutation, SetUserActiveWorkspaceMutationVariables>;
export const CreateProjectVersionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateProjectVersion"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateVersionInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"versionMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"create"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"sourceApplication"}},{"kind":"Field","name":{"kind":"Name","value":"model"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"referencedObject"}}]}}]}}]}}]} as unknown as DocumentNode<CreateProjectVersionMutation, CreateProjectVersionMutationVariables>;
export const MarkProjectVersionReceivedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"MarkProjectVersionReceived"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"MarkReceivedVersionInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"versionMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"markReceived"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]}}]} as unknown as DocumentNode<MarkProjectVersionReceivedMutation, MarkProjectVersionReceivedMutationVariables>;
export const CreateWorkspaceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateWorkspace"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceCreateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"create"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"TestWorkspace"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TestWorkspace"},"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":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"readOnly"}},{"kind":"Field","name":{"kind":"Name","value":"discoverabilityEnabled"}}]}}]} as unknown as DocumentNode<CreateWorkspaceMutation, CreateWorkspaceMutationVariables>;
export const CreateWorkspaceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateWorkspace"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceCreateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"create"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"TestWorkspace"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TestWorkspace"},"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":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"readOnly"}},{"kind":"Field","name":{"kind":"Name","value":"discoverabilityEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]} as unknown as DocumentNode<CreateWorkspaceMutation, CreateWorkspaceMutationVariables>;
export const DeleteWorkspaceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteWorkspace"},"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":"workspaceMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"delete"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}]}]}}]}}]} as unknown as DocumentNode<DeleteWorkspaceMutation, DeleteWorkspaceMutationVariables>;
export const GetWorkspaceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetWorkspace"},"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":"TestWorkspace"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"TestWorkspaceCollaborator"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TestWorkspace"},"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":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"readOnly"}},{"kind":"Field","name":{"kind":"Name","value":"discoverabilityEnabled"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TestWorkspaceCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"projectRoles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"project"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode<GetWorkspaceQuery, GetWorkspaceQueryVariables>;
export const GetWorkspaceBySlugDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetWorkspaceBySlug"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceSlug"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceBySlug"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"slug"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceSlug"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"TestWorkspace"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"TestWorkspaceCollaborator"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TestWorkspace"},"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":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"readOnly"}},{"kind":"Field","name":{"kind":"Name","value":"discoverabilityEnabled"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TestWorkspaceCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"projectRoles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"project"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode<GetWorkspaceBySlugQuery, GetWorkspaceBySlugQueryVariables>;
export const GetWorkspaceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetWorkspace"},"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":"TestWorkspace"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"TestWorkspaceCollaborator"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TestWorkspace"},"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":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"readOnly"}},{"kind":"Field","name":{"kind":"Name","value":"discoverabilityEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TestWorkspaceCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"projectRoles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"project"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode<GetWorkspaceQuery, GetWorkspaceQueryVariables>;
export const GetWorkspaceBySlugDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetWorkspaceBySlug"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceSlug"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceBySlug"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"slug"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceSlug"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"TestWorkspace"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"TestWorkspaceCollaborator"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TestWorkspace"},"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":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"readOnly"}},{"kind":"Field","name":{"kind":"Name","value":"discoverabilityEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TestWorkspaceCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"projectRoles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"project"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode<GetWorkspaceBySlugQuery, GetWorkspaceBySlugQueryVariables>;
export const GetActiveUserDiscoverableWorkspacesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"getActiveUserDiscoverableWorkspaces"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"discoverableWorkspaces"},"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":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"avatar"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"cursor"}}]}}]}}]}}]}}]} as unknown as DocumentNode<GetActiveUserDiscoverableWorkspacesQuery, GetActiveUserDiscoverableWorkspacesQueryVariables>;
export const UpdateWorkspaceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateWorkspace"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceUpdateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"update"},"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":"TestWorkspace"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TestWorkspace"},"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":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"readOnly"}},{"kind":"Field","name":{"kind":"Name","value":"discoverabilityEnabled"}}]}}]} as unknown as DocumentNode<UpdateWorkspaceMutation, UpdateWorkspaceMutationVariables>;
export const GetActiveUserWorkspacesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetActiveUserWorkspaces"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaces"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"TestWorkspace"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TestWorkspace"},"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":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"readOnly"}},{"kind":"Field","name":{"kind":"Name","value":"discoverabilityEnabled"}}]}}]} as unknown as DocumentNode<GetActiveUserWorkspacesQuery, GetActiveUserWorkspacesQueryVariables>;
export const UpdateWorkspaceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateWorkspace"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceUpdateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"update"},"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":"TestWorkspace"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TestWorkspace"},"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":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"readOnly"}},{"kind":"Field","name":{"kind":"Name","value":"discoverabilityEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]} as unknown as DocumentNode<UpdateWorkspaceMutation, UpdateWorkspaceMutationVariables>;
export const GetActiveUserWorkspacesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetActiveUserWorkspaces"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaces"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"TestWorkspace"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TestWorkspace"},"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":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"readOnly"}},{"kind":"Field","name":{"kind":"Name","value":"discoverabilityEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]} as unknown as DocumentNode<GetActiveUserWorkspacesQuery, GetActiveUserWorkspacesQueryVariables>;
export const UpdateWorkspaceRoleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateWorkspaceRole"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceRoleUpdateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceMutations"},"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":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"TestWorkspaceCollaborator"}}]}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TestWorkspaceCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"projectRoles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"project"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode<UpdateWorkspaceRoleMutation, UpdateWorkspaceRoleMutationVariables>;
export const CreateWorkspaceProjectDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateWorkspaceProject"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceProjectCreateInput"}}}}],"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":"create"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"TestWorkspaceProject"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TestWorkspaceProject"},"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":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]}}]} as unknown as DocumentNode<CreateWorkspaceProjectMutation, CreateWorkspaceProjectMutationVariables>;
export const GetWorkspaceProjectsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetWorkspaceProjects"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","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"}}},{"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":"filter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceProjectsFilter"}}}],"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":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projects"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}},{"kind":"Argument","name":{"kind":"Name","value":"cursor"},"value":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"TestWorkspaceProject"}}]}},{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TestWorkspaceProject"},"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":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]}}]} as unknown as DocumentNode<GetWorkspaceProjectsQuery, GetWorkspaceProjectsQueryVariables>;
+4
View File
@@ -58,8 +58,12 @@ export const getProjectQuery = gql`
id
name
workspaceId
role
...BasicProjectFields
}
}
${basicProjectFieldsFragment}
`
export const createProjectMutation = gql`
@@ -11,6 +11,7 @@ export const workspaceFragment = gql`
logo
readOnly
discoverabilityEnabled
role
}
`
@@ -22,10 +22,7 @@ import {
ProjectContext,
WorkspaceContext
} from '../domain/context.js'
import {
isNewWorkspacePlan,
isWorkspacePlanStatusReadOnly
} from '../../workspaces/helpers/plans.js'
import { isWorkspacePlanStatusReadOnly } from '../../workspaces/helpers/plans.js'
import { hasEditorSeat } from '../checks/workspaceSeat.js'
/**
@@ -182,13 +179,11 @@ export const ensureWorkspaceProjectCanBeCreatedFragment: AuthPolicyEnsureFragmen
// Now check editor seat
if (userId) {
if (isNewWorkspacePlan(workspacePlan.name)) {
const isEditor = await hasEditorSeat(loaders)({
userId,
workspaceId
})
if (!isEditor) return err(new WorkspaceNoEditorSeatError())
}
const isEditor = await hasEditorSeat(loaders)({
userId,
workspaceId
})
if (!isEditor) return err(new WorkspaceNoEditorSeatError())
}
const workspaceLimits = await loaders.getWorkspaceLimits({ workspaceId })
@@ -391,7 +391,7 @@ describe('canCreateWorkspaceProjectPolicy creates a function, that handles', ()
},
getWorkspacePlan: async () => {
return {
status: 'expired'
status: 'canceled'
} as WorkspacePlan
},
getWorkspaceLimits: async () => {
@@ -428,7 +428,7 @@ describe('canCreateWorkspaceProjectPolicy creates a function, that handles', ()
return {} as Workspace
},
getWorkspaceSeat: async () => {
return 'viewer'
return 'editor'
},
getWorkspacePlan: async () => {
return {
@@ -466,7 +466,7 @@ describe('canCreateWorkspaceProjectPolicy creates a function, that handles', ()
return {} as Workspace
},
getWorkspaceSeat: async () => {
return 'viewer'
return 'editor'
},
getWorkspacePlan: async () => {
return {
@@ -507,7 +507,7 @@ describe('canCreateWorkspaceProjectPolicy creates a function, that handles', ()
return {} as Workspace
},
getWorkspaceSeat: async () => {
return 'viewer'
return 'editor'
},
getWorkspacePlan: async () => {
return {
@@ -550,7 +550,7 @@ describe('canCreateWorkspaceProjectPolicy creates a function, that handles', ()
return {} as Workspace
},
getWorkspaceSeat: async () => {
return 'viewer'
return 'editor'
},
getWorkspacePlan: async () => {
return {
@@ -591,7 +591,7 @@ describe('canCreateWorkspaceProjectPolicy creates a function, that handles', ()
return {} as Workspace
},
getWorkspaceSeat: async () => {
return 'viewer'
return 'editor'
},
getWorkspacePlan: async () => {
return {
@@ -86,26 +86,6 @@ const baseFeatures = [
export const WorkspacePaidPlanConfigs: {
[plan in PaidWorkspacePlans]: WorkspacePlanConfig<plan>
} = {
// Old
[PaidWorkspacePlans.Starter]: {
plan: PaidWorkspacePlans.Starter,
features: [...baseFeatures],
limits: unlimited
},
[PaidWorkspacePlans.Plus]: {
plan: PaidWorkspacePlans.Plus,
features: [...baseFeatures, WorkspacePlanFeatures.SSO],
limits: unlimited
},
[PaidWorkspacePlans.Business]: {
plan: PaidWorkspacePlans.Business,
features: [
...baseFeatures,
WorkspacePlanFeatures.SSO,
WorkspacePlanFeatures.CustomDataRegion
],
limits: unlimited
},
[PaidWorkspacePlans.Team]: {
plan: PaidWorkspacePlans.Team,
features: [...baseFeatures],
@@ -116,7 +96,6 @@ export const WorkspacePaidPlanConfigs: {
commentHistory: { value: 30, unit: 'day' }
}
},
// New
[PaidWorkspacePlans.TeamUnlimited]: {
plan: PaidWorkspacePlans.TeamUnlimited,
features: [...baseFeatures],
@@ -162,7 +141,6 @@ export const WorkspacePaidPlanConfigs: {
export const WorkspaceUnpaidPlanConfigs: {
[plan in UnpaidWorkspacePlans]: WorkspacePlanConfig<plan>
} = {
// Old
[UnpaidWorkspacePlans.Unlimited]: {
plan: UnpaidWorkspacePlans.Unlimited,
features: [
@@ -183,19 +161,6 @@ export const WorkspaceUnpaidPlanConfigs: {
],
limits: unlimited
},
[UnpaidWorkspacePlans.StarterInvoiced]: {
...WorkspacePaidPlanConfigs.starter,
plan: UnpaidWorkspacePlans.StarterInvoiced
},
[UnpaidWorkspacePlans.PlusInvoiced]: {
...WorkspacePaidPlanConfigs.plus,
plan: UnpaidWorkspacePlans.PlusInvoiced
},
[UnpaidWorkspacePlans.BusinessInvoiced]: {
...WorkspacePaidPlanConfigs.business,
plan: UnpaidWorkspacePlans.BusinessInvoiced
},
// New
[UnpaidWorkspacePlans.TeamUnlimitedInvoiced]: {
...WorkspacePaidPlanConfigs.teamUnlimited,
plan: UnpaidWorkspacePlans.TeamUnlimitedInvoiced
@@ -1,48 +1,15 @@
import { describe, expect, it } from 'vitest'
import {
doesPlanIncludeUnlimitedProjectsAddon,
isNewWorkspacePlan,
isSelfServeAvailablePlan,
WorkspacePlans
} from './plans.js'
describe('plan helpers', () => {
describe('isNewWorkspacePlan', () => {
const planCases: {
[P in WorkspacePlans]: boolean
} = <const>{
business: false,
businessInvoiced: false,
plus: false,
plusInvoiced: false,
starter: false,
starterInvoiced: false,
free: true,
academia: true,
unlimited: true,
pro: true,
proUnlimited: true,
proUnlimitedInvoiced: true,
team: true,
teamUnlimited: true,
teamUnlimitedInvoiced: true
}
it.each(Object.entries(planCases))('plan %s is new type -> %s', (plan, isNew) => {
const result = isNewWorkspacePlan(plan as WorkspacePlans)
expect(result).toStrictEqual(isNew)
})
})
describe('doesPlanIncludeUnlimitedProjectsAddon', () => {
const planCases: {
[P in WorkspacePlans]: boolean
} = <const>{
business: false,
businessInvoiced: false,
plus: false,
plusInvoiced: false,
starter: false,
starterInvoiced: false,
free: false,
academia: false,
unlimited: false,
@@ -66,12 +33,6 @@ describe('plan helpers', () => {
const planCases: {
[P in WorkspacePlans]: boolean
} = <const>{
business: false,
businessInvoiced: false,
plus: false,
plusInvoiced: false,
starter: false,
starterInvoiced: false,
free: true,
academia: false,
unlimited: false,
+5 -105
View File
@@ -1,50 +1,16 @@
import { throwUncoveredError } from '../../core/helpers/error.js'
import type { MaybeNullOrUndefined } from '../../core/helpers/utilityTypes.js'
/**
* PLANS
*/
export const TrialEnabledPaidWorkspacePlans = <const>{
Starter: 'starter'
}
export type TrialEnabledPaidWorkspacePlans =
(typeof TrialEnabledPaidWorkspacePlans)[keyof typeof TrialEnabledPaidWorkspacePlans]
export const PaidWorkspacePlansOld = <const>{
...TrialEnabledPaidWorkspacePlans,
Plus: 'plus',
Business: 'business'
}
export type PaidWorkspacePlansOld =
(typeof PaidWorkspacePlansOld)[keyof typeof PaidWorkspacePlansOld]
export const PaidWorkspacePlansNew = <const>{
Team: 'team',
TeamUnlimited: 'teamUnlimited',
Pro: 'pro',
ProUnlimited: 'proUnlimited'
}
export type PaidWorkspacePlansNew =
(typeof PaidWorkspacePlansNew)[keyof typeof PaidWorkspacePlansNew]
export const PaidWorkspacePlans = <const>{
...PaidWorkspacePlansOld,
...PaidWorkspacePlansNew
Team: 'team', // actually 'Starter'
TeamUnlimited: 'teamUnlimited',
Pro: 'pro', // actually 'Business'
ProUnlimited: 'proUnlimited'
}
export type PaidWorkspacePlans =
(typeof PaidWorkspacePlans)[keyof typeof PaidWorkspacePlans]
export const UnpaidWorkspacePlans = <const>{
// Old
StarterInvoiced: 'starterInvoiced',
PlusInvoiced: 'plusInvoiced',
BusinessInvoiced: 'businessInvoiced',
// New
TeamUnlimitedInvoiced: 'teamUnlimitedInvoiced',
ProUnlimitedInvoiced: 'proUnlimitedInvoiced',
Unlimited: 'unlimited',
@@ -62,38 +28,6 @@ export const WorkspacePlans = <const>{
export type WorkspacePlans = (typeof WorkspacePlans)[keyof typeof WorkspacePlans]
// TODO: Remove this post workspace migration
export const WorkspaceGuestSeatType = 'guest'
export type WorkspaceGuestSeatType = typeof WorkspaceGuestSeatType
// TODO: Remove this post workspace migration, only needed temporarily to differiante between old and new
export const isNewWorkspacePlan = (
plan: MaybeNullOrUndefined<WorkspacePlans>
): boolean => {
if (!plan) return false
switch (plan) {
case 'starter':
case 'starterInvoiced':
case 'plus':
case 'plusInvoiced':
case 'business':
case 'businessInvoiced':
return false
case 'team':
case 'teamUnlimited':
case 'teamUnlimitedInvoiced':
case 'pro':
case 'proUnlimited':
case 'proUnlimitedInvoiced':
case 'unlimited':
case 'academia':
case 'free':
return true
default:
throwUncoveredError(plan)
}
}
export const doesPlanIncludeUnlimitedProjectsAddon = (
plan: WorkspacePlans
): boolean => {
@@ -104,12 +38,6 @@ export const doesPlanIncludeUnlimitedProjectsAddon = (
case 'free':
case 'team':
case 'pro':
case 'starter':
case 'plus':
case 'business':
case 'starterInvoiced':
case 'plusInvoiced':
case 'businessInvoiced':
case 'teamUnlimitedInvoiced':
case 'proUnlimitedInvoiced':
case 'unlimited':
@@ -129,12 +57,6 @@ export const isSelfServeAvailablePlan = (plan: WorkspacePlans): boolean => {
case 'pro':
case 'proUnlimited':
return true
case 'starter':
case 'plus':
case 'business':
case 'starterInvoiced':
case 'plusInvoiced':
case 'businessInvoiced':
case 'teamUnlimitedInvoiced':
case 'proUnlimitedInvoiced':
case 'unlimited':
@@ -154,12 +76,6 @@ export const isPaidPlan = (plan: WorkspacePlans): boolean => {
case 'proUnlimited':
return true
case 'free':
case 'starter':
case 'plus':
case 'business':
case 'starterInvoiced':
case 'plusInvoiced':
case 'businessInvoiced':
case 'teamUnlimitedInvoiced':
case 'proUnlimitedInvoiced':
case 'unlimited':
@@ -204,17 +120,8 @@ export const PaidWorkspacePlanStatuses = <const>{
export type PaidWorkspacePlanStatuses =
(typeof PaidWorkspacePlanStatuses)[keyof typeof PaidWorkspacePlanStatuses]
export const TrialWorkspacePlanStatuses = <const>{
Trial: 'trial',
Expired: 'expired'
}
export type TrialWorkspacePlanStatuses =
(typeof TrialWorkspacePlanStatuses)[keyof typeof TrialWorkspacePlanStatuses]
export const WorkspacePlanStatuses = <const>{
...PaidWorkspacePlanStatuses,
...TrialWorkspacePlanStatuses,
...UnpaidWorkspacePlanStatuses
}
@@ -231,25 +138,18 @@ export type PaidWorkspacePlan = BaseWorkspacePlan & {
status: PaidWorkspacePlanStatuses
}
export type TrialWorkspacePlan = BaseWorkspacePlan & {
name: TrialEnabledPaidWorkspacePlans
status: TrialWorkspacePlanStatuses
}
export type UnpaidWorkspacePlan = BaseWorkspacePlan & {
name: UnpaidWorkspacePlans
status: UnpaidWorkspacePlanStatuses
}
export type WorkspacePlan = PaidWorkspacePlan | TrialWorkspacePlan | UnpaidWorkspacePlan
export type WorkspacePlan = PaidWorkspacePlan | UnpaidWorkspacePlan
export const isWorkspacePlanStatusReadOnly = (status: WorkspacePlan['status']) => {
switch (status) {
case 'cancelationScheduled':
case 'valid':
case 'trial':
case 'paymentFailed':
return false
case 'expired':
case 'canceled':
return true
default:
@@ -614,42 +614,6 @@ Generate the environment variables for Speckle server and Speckle objects deploy
name: "{{ default .Values.secretName .Values.billing.secretName }}"
key: {{ .Values.billing.stripeEndpointSigningKey.secretKey }}
- name: WORKSPACE_GUEST_SEAT_STRIPE_PRODUCT_ID
value: {{ .Values.billing.workspaceGuestSeatStripeProductId }}
- name: WORKSPACE_MONTHLY_GUEST_SEAT_STRIPE_PRICE_ID
value: {{ .Values.billing.workspaceMonthlyGuestSeatStripePriceId }}
- name: WORKSPACE_YEARLY_GUEST_SEAT_STRIPE_PRICE_ID
value: {{ .Values.billing.workspaceYearlyGuestSeatStripePriceId }}
- name: WORKSPACE_STARTER_SEAT_STRIPE_PRODUCT_ID
value: {{ .Values.billing.workspaceStarterSeatStripeProductId }}
- name: WORKSPACE_MONTHLY_STARTER_SEAT_STRIPE_PRICE_ID
value: {{ .Values.billing.workspaceMonthlyStarterSeatStripePriceId }}
- name: WORKSPACE_YEARLY_STARTER_SEAT_STRIPE_PRICE_ID
value: {{ .Values.billing.workspaceYearlyStarterSeatStripePriceId }}
- name: WORKSPACE_PLUS_SEAT_STRIPE_PRODUCT_ID
value: {{ .Values.billing.workspacePlusSeatStripeProductId }}
- name: WORKSPACE_MONTHLY_PLUS_SEAT_STRIPE_PRICE_ID
value: {{ .Values.billing.workspaceMonthlyPlusSeatStripePriceId }}
- name: WORKSPACE_YEARLY_PLUS_SEAT_STRIPE_PRICE_ID
value: {{ .Values.billing.workspaceYearlyPlusSeatStripePriceId }}
- name: WORKSPACE_BUSINESS_SEAT_STRIPE_PRODUCT_ID
value: {{ .Values.billing.workspaceBusinessSeatStripeProductId }}
- name: WORKSPACE_MONTHLY_BUSINESS_SEAT_STRIPE_PRICE_ID
value: {{ .Values.billing.workspaceMonthlyBusinessSeatStripePriceId }}
- name: WORKSPACE_YEARLY_BUSINESS_SEAT_STRIPE_PRICE_ID
value: {{ .Values.billing.workspaceYearlyBusinessSeatStripePriceId }}
- name: WORKSPACE_TEAM_SEAT_STRIPE_PRODUCT_ID
value: {{ .Values.billing.workspaceTeamSeatStripeProductId }}