Files
speckle-server/packages/server/modules/gatekeeper/graph/resolvers/index.ts
T
2025-07-14 11:31:12 +03:00

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)
: {}