496 lines
18 KiB
TypeScript
496 lines
18 KiB
TypeScript
import { getFeatureFlags, getFrontendOrigin } from '@/modules/shared/helpers/envHelper'
|
|
import type { Resolvers } from '@/modules/core/graph/generated/graphql'
|
|
import { authorizeResolver } from '@/modules/shared'
|
|
import {
|
|
Roles,
|
|
throwUncoveredError,
|
|
WorkspacePlanFeatures,
|
|
WorkspacePlans
|
|
} from '@speckle/shared'
|
|
import {
|
|
getWorkspaceFactory,
|
|
getWorkspaceRoleForUserFactory,
|
|
getWorkspacesProjectsCountsFactory
|
|
} from '@/modules/workspaces/repositories/workspaces'
|
|
import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace'
|
|
import { db } from '@/db/knex'
|
|
import {
|
|
createCustomerPortalUrlFactory,
|
|
getRecurringPricesFactory,
|
|
getStripeClient,
|
|
getStripeSubscriptionDataFactory,
|
|
reconcileWorkspaceSubscriptionFactory
|
|
} from '@/modules/gatekeeper/clients/stripe'
|
|
import {
|
|
getWorkspacePlanPriceId,
|
|
getWorkspacePlanProductId,
|
|
getWorkspacePlanProductAndPriceIds
|
|
} from '@/modules/gatekeeper/helpers/prices'
|
|
import {
|
|
deleteCheckoutSessionFactory,
|
|
getWorkspaceCheckoutSessionFactory,
|
|
getWorkspacePlanFactory,
|
|
getWorkspaceSubscriptionFactory,
|
|
saveCheckoutSessionFactory,
|
|
upsertWorkspaceSubscriptionFactory
|
|
} from '@/modules/gatekeeper/repositories/billing'
|
|
import { canWorkspaceAccessFeatureFactory } from '@/modules/gatekeeper/services/featureAuthorization'
|
|
import { isWorkspaceReadOnlyFactory } from '@/modules/gatekeeper/services/readOnly'
|
|
import {
|
|
CreateCheckoutSession,
|
|
WorkspaceSeatType
|
|
} from '@/modules/gatekeeper/domain/billing'
|
|
import { WorkspacePaymentMethod } from '@/modules/core/graph/generated/graphql'
|
|
import { LogicError, UnauthorizedError } from '@/modules/shared/errors'
|
|
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 { upgradeWorkspaceSubscriptionFactory } from '@/modules/gatekeeper/services/subscriptions/upgradeWorkspaceSubscription'
|
|
import {
|
|
countSeatsByTypeInWorkspaceFactory,
|
|
createWorkspaceSeatFactory,
|
|
getWorkspaceUserSeatFactory
|
|
} from '@/modules/gatekeeper/repositories/workspaceSeat'
|
|
import { assignWorkspaceSeatFactory } from '@/modules/workspaces/services/workspaceSeat'
|
|
import { getEventBus } from '@/modules/shared/services/eventBus'
|
|
import { getTotalSeatsCountByPlanFactory } from '@/modules/gatekeeper/services/subscriptions'
|
|
import { legacyGetStreamsFactory } from '@/modules/core/repositories/streams'
|
|
import { getWorkspaceModelCountFactory } from '@/modules/workspaces/services/workspaceLimits'
|
|
import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector'
|
|
import { getPaginatedProjectModelsTotalCountFactory } from '@/modules/core/repositories/branches'
|
|
import { withOperationLogging } from '@/observability/domain/businessLogging'
|
|
import { queryAllProjectsFactory } from '@/modules/core/services/projects'
|
|
|
|
const { FF_GATEKEEPER_MODULE_ENABLED, FF_BILLING_INTEGRATION_ENABLED } =
|
|
getFeatureFlags()
|
|
|
|
const getWorkspacePlan = getWorkspacePlanFactory({ db })
|
|
|
|
export default FF_GATEKEEPER_MODULE_ENABLED
|
|
? ({
|
|
Workspace: {
|
|
plan: async (parent) => {
|
|
const workspacePlan = await getWorkspacePlanFactory({ db })({
|
|
workspaceId: parent.id
|
|
})
|
|
if (!workspacePlan) return null
|
|
let paymentMethod: WorkspacePaymentMethod
|
|
switch (workspacePlan.name) {
|
|
case WorkspacePlans.Team:
|
|
case WorkspacePlans.TeamUnlimited:
|
|
case WorkspacePlans.Pro:
|
|
case WorkspacePlans.ProUnlimited:
|
|
paymentMethod = WorkspacePaymentMethod.Billing
|
|
break
|
|
case WorkspacePlans.Unlimited:
|
|
case WorkspacePlans.Academia:
|
|
case WorkspacePlans.Free:
|
|
paymentMethod = WorkspacePaymentMethod.Unpaid
|
|
break
|
|
case WorkspacePlans.ProUnlimitedInvoiced:
|
|
case WorkspacePlans.TeamUnlimitedInvoiced:
|
|
case WorkspacePlans.Enterprise:
|
|
paymentMethod = WorkspacePaymentMethod.Invoice
|
|
break
|
|
default:
|
|
throwUncoveredError(workspacePlan)
|
|
}
|
|
return { ...workspacePlan, paymentMethod }
|
|
},
|
|
subscription: async (parent) => {
|
|
const workspaceId = parent.id
|
|
const subscription = await getWorkspaceSubscriptionFactory({ db })({
|
|
workspaceId
|
|
})
|
|
return subscription
|
|
},
|
|
customerPortalUrl: async (parent) => {
|
|
const workspaceId = parent.id
|
|
const workspaceSubscription = await getWorkspaceSubscriptionFactory({ db })({
|
|
workspaceId
|
|
})
|
|
if (!workspaceSubscription) return null
|
|
const workspace = await getWorkspaceFactory({ db })({ workspaceId })
|
|
if (!workspace)
|
|
throw new LogicError(
|
|
'This cannot be, if there is a sub, there is a workspace'
|
|
)
|
|
return await createCustomerPortalUrlFactory({
|
|
getStripeClient,
|
|
frontendOrigin: getFrontendOrigin()
|
|
})({
|
|
workspaceId: workspaceSubscription.workspaceId,
|
|
workspaceSlug: workspace.slug,
|
|
customerId: workspaceSubscription.subscriptionData.customerId
|
|
})
|
|
},
|
|
hasAccessToFeature: async (parent, args) => {
|
|
const hasAccess = await canWorkspaceAccessFeatureFactory({
|
|
getWorkspacePlan: getWorkspacePlanFactory({ db })
|
|
})({
|
|
workspaceId: parent.id,
|
|
workspaceFeature: args.featureName
|
|
})
|
|
return hasAccess
|
|
},
|
|
readOnly: async (parent) => {
|
|
if (!FF_BILLING_INTEGRATION_ENABLED) return false
|
|
return await isWorkspaceReadOnlyFactory({ getWorkspacePlan })({
|
|
workspaceId: parent.id
|
|
})
|
|
},
|
|
planPrices: async (parent) => {
|
|
const getWorkspacePlanPrices = getWorkspacePlanProductPricesFactory({
|
|
getRecurringPrices: getRecurringPricesFactory({
|
|
getStripeClient
|
|
}),
|
|
getWorkspacePlanProductAndPriceIds
|
|
})
|
|
const prices = await getWorkspacePlanPrices()
|
|
const workspaceSubscription = await getWorkspaceSubscriptionFactory({ db })({
|
|
workspaceId: parent.id
|
|
})
|
|
return prices[workspaceSubscription?.currency ?? 'usd']
|
|
},
|
|
seatType: async (parent, _args, context) => {
|
|
if (!context.userId) return null
|
|
|
|
const seat = await context.loaders.gatekeeper!.getUserWorkspaceSeat.load({
|
|
workspaceId: parent.id,
|
|
userId: context.userId
|
|
})
|
|
|
|
// Defaults to Editor for old plans that don't have seat types
|
|
return seat?.type || WorkspaceSeatType.Viewer
|
|
},
|
|
seats: async (parent) => {
|
|
return { workspaceId: parent.id }
|
|
}
|
|
},
|
|
Project: {
|
|
hasAccessToFeature: async (parent, args) => {
|
|
if (!parent.workspaceId) {
|
|
return false
|
|
}
|
|
|
|
switch (args.featureName) {
|
|
case WorkspacePlanFeatures.HideSpeckleBranding: {
|
|
return await canWorkspaceAccessFeatureFactory({
|
|
getWorkspacePlan: getWorkspacePlanFactory({ db })
|
|
})({
|
|
workspaceId: parent.workspaceId,
|
|
workspaceFeature: args.featureName
|
|
})
|
|
}
|
|
default: {
|
|
// Only publicly validate embed-related features at the project level
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
},
|
|
WorkspacePlan: {
|
|
usage: async (parent) => {
|
|
return { workspaceId: parent.workspaceId }
|
|
}
|
|
},
|
|
WorkspacePlanUsage: {
|
|
projectCount: async (parent) => {
|
|
const { workspaceId } = parent
|
|
const countsByWorkspaceId = await getWorkspacesProjectsCountsFactory({ db })({
|
|
workspaceIds: [workspaceId]
|
|
})
|
|
return countsByWorkspaceId[workspaceId] ?? 0
|
|
},
|
|
modelCount: async (parent) => {
|
|
const { workspaceId } = parent
|
|
|
|
return await getWorkspaceModelCountFactory({
|
|
queryAllProjects: queryAllProjectsFactory({
|
|
getStreams: legacyGetStreamsFactory({ db })
|
|
}),
|
|
getPaginatedProjectModelsTotalCount: async (projectId, params) => {
|
|
const regionDb = await getProjectDbClient({ projectId })
|
|
return await getPaginatedProjectModelsTotalCountFactory({ db: regionDb })(
|
|
projectId,
|
|
params
|
|
)
|
|
}
|
|
})({ workspaceId })
|
|
}
|
|
},
|
|
WorkspaceSubscription: {
|
|
seats: async (parent) => {
|
|
return parent
|
|
}
|
|
},
|
|
WorkspaceSubscriptionSeats: {
|
|
editors: async (parent) => {
|
|
const { workspaceId } = parent
|
|
|
|
const [workspacePlan, subscription] = await Promise.all([
|
|
getWorkspacePlanFactory({ db })({
|
|
workspaceId
|
|
}),
|
|
getWorkspaceSubscriptionFactory({ db })({
|
|
workspaceId
|
|
})
|
|
])
|
|
|
|
if (!workspacePlan) {
|
|
return {
|
|
assigned: 0,
|
|
available: 0
|
|
}
|
|
}
|
|
|
|
const assigned = await countSeatsByTypeInWorkspaceFactory({ db })({
|
|
workspaceId,
|
|
type: WorkspaceSeatType.Editor
|
|
})
|
|
let available = 0
|
|
|
|
// If we have a stripe sub, use that to resolve available
|
|
if (subscription) {
|
|
let purchased = 0
|
|
switch (workspacePlan.name) {
|
|
case WorkspacePlans.Unlimited:
|
|
case WorkspacePlans.Academia:
|
|
case WorkspacePlans.Free:
|
|
case WorkspacePlans.ProUnlimitedInvoiced:
|
|
case WorkspacePlans.TeamUnlimitedInvoiced:
|
|
case WorkspacePlans.Enterprise:
|
|
// not stripe paid plans and old plans do not have seats available
|
|
break
|
|
case WorkspacePlans.Team:
|
|
case WorkspacePlans.TeamUnlimited:
|
|
case WorkspacePlans.Pro:
|
|
case WorkspacePlans.ProUnlimited:
|
|
purchased = getTotalSeatsCountByPlanFactory({
|
|
getWorkspacePlanProductId
|
|
})({
|
|
workspacePlan: workspacePlan.name,
|
|
subscriptionData: subscription.subscriptionData
|
|
})
|
|
break
|
|
default:
|
|
throwUncoveredError(workspacePlan)
|
|
}
|
|
|
|
available = purchased - assigned > 0 ? purchased - assigned : 0
|
|
}
|
|
|
|
return {
|
|
assigned,
|
|
available
|
|
}
|
|
},
|
|
|
|
viewers: async ({ workspaceId }) => {
|
|
return {
|
|
assigned: await countSeatsByTypeInWorkspaceFactory({ db })({
|
|
workspaceId,
|
|
type: WorkspaceSeatType.Viewer
|
|
}),
|
|
available: 0
|
|
}
|
|
}
|
|
},
|
|
WorkspaceCollaborator: {
|
|
seatType: async (parent, _args, context) => {
|
|
const seat = await context.loaders.gatekeeper!.getUserWorkspaceSeat.load({
|
|
workspaceId: parent.workspaceId,
|
|
userId: parent.id
|
|
})
|
|
|
|
return seat?.type || WorkspaceSeatType.Viewer
|
|
}
|
|
},
|
|
ServerWorkspacesInfo: {
|
|
planPrices: async () => {
|
|
const getWorkspacePlanPrices = getWorkspacePlanProductPricesFactory({
|
|
getRecurringPrices: getRecurringPricesFactory({
|
|
getStripeClient
|
|
}),
|
|
getWorkspacePlanProductAndPriceIds
|
|
})
|
|
const prices = await getWorkspacePlanPrices()
|
|
return prices
|
|
}
|
|
},
|
|
ProjectCollaborator: {
|
|
seatType: async (parent, _args, context) => {
|
|
const seat = await context.loaders.gatekeeper!.getUserProjectSeat.load({
|
|
projectId: parent.projectId,
|
|
userId: parent.id
|
|
})
|
|
|
|
// Defaults to Editor for old plans that don't have seat types
|
|
return seat?.type || WorkspaceSeatType.Editor
|
|
}
|
|
},
|
|
WorkspaceMutations: {
|
|
billing: () => ({}),
|
|
updateSeatType: async (_parent, args, ctx) => {
|
|
const { workspaceId, userId, seatType } = args.input
|
|
|
|
await authorizeResolver(
|
|
ctx.userId,
|
|
workspaceId,
|
|
Roles.Workspace.Admin,
|
|
ctx.resourceAccessRules
|
|
)
|
|
|
|
const assignSeat = assignWorkspaceSeatFactory({
|
|
createWorkspaceSeat: createWorkspaceSeatFactory({ db }),
|
|
getWorkspaceRoleForUser: getWorkspaceRoleForUserFactory({ db }),
|
|
getWorkspaceUserSeat: getWorkspaceUserSeatFactory({ db }),
|
|
eventEmit: getEventBus().emit
|
|
})
|
|
await withOperationLogging(
|
|
async () =>
|
|
await assignSeat({
|
|
workspaceId,
|
|
userId,
|
|
type: seatType,
|
|
assignedByUserId: ctx.userId!
|
|
}),
|
|
{
|
|
logger: ctx.log,
|
|
operationName: 'updateWorkspaceSeatType',
|
|
operationDescription: 'Updating seat type'
|
|
}
|
|
)
|
|
|
|
return ctx.loaders.workspaces!.getWorkspace.load(workspaceId)
|
|
}
|
|
},
|
|
WorkspaceBillingMutations: {
|
|
cancelCheckoutSession: async (_parent, args, ctx) => {
|
|
const { workspaceId, sessionId } = args.input
|
|
|
|
await authorizeResolver(
|
|
ctx.userId,
|
|
workspaceId,
|
|
Roles.Workspace.Admin,
|
|
ctx.resourceAccessRules
|
|
)
|
|
await withOperationLogging(
|
|
async () =>
|
|
await deleteCheckoutSessionFactory({ db })({
|
|
checkoutSessionId: sessionId
|
|
}),
|
|
{
|
|
logger: ctx.log,
|
|
operationName: 'cancelCheckoutSession',
|
|
operationDescription:
|
|
'Checkout session cancelled; so checkout session is being deleted'
|
|
}
|
|
)
|
|
return true
|
|
},
|
|
createCheckoutSession: async (_parent, args, ctx) => {
|
|
let logger = extendLoggerComponent(ctx.log, 'gatekeeper', 'resolvers')
|
|
const { workspaceId, workspacePlan, billingInterval, isCreateFlow } =
|
|
args.input
|
|
logger = logger.child({ workspaceId, workspacePlan })
|
|
const userId = ctx.userId
|
|
if (!userId) throw new UnauthorizedError()
|
|
|
|
const workspace = await getWorkspaceFactory({ db })({ workspaceId })
|
|
|
|
if (!workspace) throw new WorkspaceNotFoundError()
|
|
|
|
await authorizeResolver(
|
|
userId,
|
|
workspaceId,
|
|
Roles.Workspace.Admin,
|
|
ctx.resourceAccessRules
|
|
)
|
|
|
|
const createCheckoutSession = createCheckoutSessionFactory({
|
|
getStripeClient,
|
|
frontendOrigin: getFrontendOrigin(),
|
|
getWorkspacePlanPrice: getWorkspacePlanPriceId
|
|
})
|
|
const startCheckoutSession = startCheckoutSessionFactory({
|
|
getWorkspaceCheckoutSession: getWorkspaceCheckoutSessionFactory({ db }),
|
|
getWorkspacePlan: getWorkspacePlanFactory({ db }),
|
|
countSeatsByTypeInWorkspace: countSeatsByTypeInWorkspaceFactory({ db }),
|
|
createCheckoutSession: createCheckoutSession as CreateCheckoutSession,
|
|
saveCheckoutSession: saveCheckoutSessionFactory({ db }),
|
|
deleteCheckoutSession: deleteCheckoutSessionFactory({ db })
|
|
})
|
|
|
|
return await withOperationLogging(
|
|
async () =>
|
|
await startCheckoutSession({
|
|
workspacePlan,
|
|
workspaceId,
|
|
userId,
|
|
workspaceSlug: workspace.slug,
|
|
isCreateFlow: isCreateFlow || false,
|
|
billingInterval,
|
|
currency: args.input.currency ?? 'usd'
|
|
}),
|
|
{
|
|
logger,
|
|
operationName: 'startCheckoutSession',
|
|
operationDescription: 'Starting checkout session'
|
|
}
|
|
)
|
|
},
|
|
upgradePlan: async (_parent, args, ctx) => {
|
|
let logger = extendLoggerComponent(ctx.log, 'gatekeeper', 'resolvers')
|
|
const { workspaceId, workspacePlan, billingInterval } = args.input
|
|
logger = logger.child({ workspaceId, workspacePlan })
|
|
|
|
const userId = ctx.userId
|
|
if (!userId) throw new UnauthorizedError()
|
|
await authorizeResolver(
|
|
userId,
|
|
workspaceId,
|
|
Roles.Workspace.Admin,
|
|
ctx.resourceAccessRules
|
|
)
|
|
|
|
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
|
|
getWorkspacePlan: getWorkspacePlanFactory({ db }),
|
|
reconcileSubscriptionData: reconcileWorkspaceSubscriptionFactory({
|
|
getStripeClient,
|
|
getStripeSubscriptionData: getStripeSubscriptionDataFactory({
|
|
getStripeClient
|
|
})
|
|
}),
|
|
countSeatsByTypeInWorkspace: countSeatsByTypeInWorkspaceFactory({
|
|
db
|
|
}),
|
|
getWorkspaceSubscription: getWorkspaceSubscriptionFactory({ db }),
|
|
getWorkspacePlanPriceId,
|
|
getWorkspacePlanProductId,
|
|
updateWorkspaceSubscription: upsertWorkspaceSubscriptionFactory({
|
|
db
|
|
})
|
|
})
|
|
await withOperationLogging(
|
|
async () =>
|
|
await upgradeWorkspaceSubscription({
|
|
userId,
|
|
workspaceId,
|
|
targetPlan: workspacePlan, // This should not be casted and the cast will be removed once we will not support old plans anymore
|
|
billingInterval
|
|
}),
|
|
{
|
|
logger,
|
|
operationName: 'upgradeWorkspaceSubscription',
|
|
operationDescription: 'Upgrading workspace subscription'
|
|
}
|
|
)
|
|
|
|
return true
|
|
}
|
|
}
|
|
} as Resolvers)
|
|
: {}
|