diff --git a/.circleci/config.yml b/.circleci/config.yml index 6a7806467..916f8df54 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1105,7 +1105,8 @@ jobs: command: yarn workspaces foreach -tvW version $IMAGE_VERSION_TAG - run: name: publish to npm - command: 'yarn workspaces foreach -pvW --no-private npm publish --access public' + # TODO: publish dry run while we figure out build/bundle issue + command: 'yarn workspaces foreach -pvW --no-private npm publish --access public --dry-run' publish-helm-chart: docker: diff --git a/.zed/debug.json b/.zed/debug.json new file mode 100644 index 000000000..e6fcae251 --- /dev/null +++ b/.zed/debug.json @@ -0,0 +1,16 @@ +[ + { + "adapter": "JavaScript", + "label": "yarn (JavaScript)", + "request": "launch", + "console": "integratedTerminal", + "program": "dev", + "runtimeExecutable": "yarn", + "args": [], + "type": "pwa-node", + "cwd": "$ZED_WORKTREE_ROOT/packages/server", + "skipFiles": ["/**"], + "env": {}, + "stop_on_entry": false + } +] diff --git a/codecov.yml b/codecov.yml index 24abb6c9a..02fc5dde0 100644 --- a/codecov.yml +++ b/codecov.yml @@ -20,7 +20,7 @@ coverage: default: target: 90% #overall project/ repo coverage server: - target: 90% + target: 70% #TODO This is low and should be increased flags: - server shared: diff --git a/packages/frontend-2/components/settings/workspaces/General/DeleteDialog.vue b/packages/frontend-2/components/settings/workspaces/General/DeleteDialog.vue index 563d3b44e..7f6397b56 100644 --- a/packages/frontend-2/components/settings/workspaces/General/DeleteDialog.vue +++ b/packages/frontend-2/components/settings/workspaces/General/DeleteDialog.vue @@ -54,7 +54,6 @@ import { import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast' import { useActiveUser } from '~~/lib/auth/composables/activeUser' import { isUndefined } from 'lodash-es' -import { useMixpanel } from '~/lib/core/composables/mp' import { homeRoute, defaultZapierWebhookUrl } from '~/lib/common/helpers/route' import { useZapier } from '~/lib/core/composables/zapier' import { useForm } from 'vee-validate' @@ -79,7 +78,6 @@ const { triggerNotification } = useGlobalToast() const { activeUser } = useActiveUser() const router = useRouter() const apollo = useApolloClient().client -const mixpanel = useMixpanel() const { sendWebhook } = useZapier() const { resetForm } = useForm<{ feedback: string }>() const { mutateActiveWorkspaceSlug } = useNavigation() @@ -123,12 +121,6 @@ const onDelete = async () => { ) } - mixpanel.track('Workspace Deleted', { - // eslint-disable-next-line camelcase - workspace_id: workspaceId, - feedback: feedback.value - }) - if (feedback.value) { await sendWebhook(defaultZapierWebhookUrl, { feedback: [ diff --git a/packages/frontend-2/lib/navigation/composables/navigation.ts b/packages/frontend-2/lib/navigation/composables/navigation.ts index 11d06428e..81f0d904c 100644 --- a/packages/frontend-2/lib/navigation/composables/navigation.ts +++ b/packages/frontend-2/lib/navigation/composables/navigation.ts @@ -14,6 +14,7 @@ export const useNavigation = () => { const state = useNavigationState() const { mutate } = useMutation(setActiveWorkspaceMutation) const { $intercom } = useNuxtApp() + const isWorkspacesEnabled = useIsWorkspacesEnabled() const activeWorkspaceSlug = computed({ get: () => state.value.activeWorkspaceSlug, @@ -28,6 +29,8 @@ export const useNavigation = () => { const mutateActiveWorkspaceSlug = async (newVal: string | null) => { state.value.activeWorkspaceSlug = newVal state.value.isProjectsActive = false + if (!isWorkspacesEnabled.value) return + await mutate({ slug: newVal, isProjectsActive: false }) $intercom.updateCompany() } @@ -35,6 +38,8 @@ export const useNavigation = () => { const mutateIsProjectsActive = async (isActive: boolean) => { state.value.isProjectsActive = isActive state.value.activeWorkspaceSlug = null + if (!isWorkspacesEnabled.value) return + await mutate({ isProjectsActive: state.value.isProjectsActive, slug: null }) } diff --git a/packages/server/assets/workspacesCore/typedefs/workspaceJoinRequests.graphql b/packages/server/assets/workspacesCore/typedefs/workspaceJoinRequests.graphql index 53e8393d2..eeddd9049 100644 --- a/packages/server/assets/workspacesCore/typedefs/workspaceJoinRequests.graphql +++ b/packages/server/assets/workspacesCore/typedefs/workspaceJoinRequests.graphql @@ -52,3 +52,42 @@ extend type User { @hasScope(scope: "workspace:read") @isOwner } + +extend type Workspace { + """ + Get all join requests for all the workspaces the user is an admin of + """ + adminWorkspacesJoinRequests( + filter: AdminWorkspaceJoinRequestFilter + cursor: String + limit: Int! = 25 + ): WorkspaceJoinRequestCollection + @hasServerRole(role: SERVER_USER) + @hasScope(scope: "workspace:read") + @hasWorkspaceRole(role: ADMIN) +} + +type WorkspaceJoinRequestCollection { + totalCount: Int! + cursor: String + items: [WorkspaceJoinRequest!]! +} + +type WorkspaceJoinRequest { + id: String! + workspace: Workspace! + user: LimitedUser! + email: String + status: WorkspaceJoinRequestStatus! + createdAt: DateTime! +} + +enum WorkspaceJoinRequestStatus { + pending + approved + denied +} + +input AdminWorkspaceJoinRequestFilter { + status: WorkspaceJoinRequestStatus +} diff --git a/packages/server/assets/workspacesCore/typedefs/workspaces.graphql b/packages/server/assets/workspacesCore/typedefs/workspaces.graphql index 008777701..02df12834 100644 --- a/packages/server/assets/workspacesCore/typedefs/workspaces.graphql +++ b/packages/server/assets/workspacesCore/typedefs/workspaces.graphql @@ -318,17 +318,6 @@ type Workspace { Workspace-level configuration for models in embedded viewer """ embedOptions: WorkspaceEmbedOptions! - """ - Get all join requests for all the workspaces the user is an admin of - """ - adminWorkspacesJoinRequests( - filter: AdminWorkspaceJoinRequestFilter - cursor: String - limit: Int! = 25 - ): WorkspaceJoinRequestCollection - @hasServerRole(role: SERVER_USER) - @hasScope(scope: "workspace:read") - @hasWorkspaceRole(role: ADMIN) } type WorkspaceEmbedOptions { @@ -465,6 +454,7 @@ type WorkspaceCollaborator { id: ID! role: String! user: LimitedUser! + email: String projectRoles: [ProjectRole!]! """ Date that the user joined the workspace. @@ -510,30 +500,6 @@ type WorkspaceCollection { items: [Workspace!]! } -type WorkspaceJoinRequestCollection { - totalCount: Int! - cursor: String - items: [WorkspaceJoinRequest!]! -} - -type WorkspaceJoinRequest { - id: String! - workspace: Workspace! - user: LimitedUser! - status: WorkspaceJoinRequestStatus! - createdAt: DateTime! -} - -enum WorkspaceJoinRequestStatus { - pending - approved - denied -} - -input AdminWorkspaceJoinRequestFilter { - status: WorkspaceJoinRequestStatus -} - extend type User { """ Get discoverable workspaces with verified domains that match the active user's @@ -625,7 +591,7 @@ extend type Project { workspaceId: String } -# case of using userSearch, and we alway expose this +# case of using userSearch, and we always expose this extend type LimitedUser { workspaceDomainPolicyCompliant(workspaceSlug: String): Boolean workspaceRole(workspaceId: String): String diff --git a/packages/server/modules/automate/services/automationManagement.ts b/packages/server/modules/automate/services/automationManagement.ts index 23f7cd223..4b8d761e9 100644 --- a/packages/server/modules/automate/services/automationManagement.ts +++ b/packages/server/modules/automate/services/automationManagement.ts @@ -10,7 +10,12 @@ import { getFunctionReleaseFactory, getFunctionReleasesFactory } from '@/modules/automate/clients/executionEngine' -import { Automate, Roles, removeNullOrUndefinedKeys } from '@speckle/shared' +import { + Automate, + Roles, + ensureError, + removeNullOrUndefinedKeys +} from '@speckle/shared' import { AuthCodePayloadAction } from '@/modules/automate/services/authCode' import { ProjectAutomationCreateInput, @@ -52,6 +57,7 @@ import { GetBranchesByIds } from '@/modules/core/domain/branches/operations' import { ValidateStreamAccess } from '@/modules/core/domain/streams/operations' import { EventBusEmit } from '@/modules/shared/services/eventBus' import { AutomationEvents } from '@/modules/automate/domain/events' +import { UnformattableTriggerDefinitionSchemaError } from '@speckle/shared/dist/commonjs/automate/index.js' export type CreateAutomationDeps = { createAuthCode: CreateStoredAuthCode @@ -395,9 +401,20 @@ export const createAutomationRevisionFactory = userResourceAccessRules ) - const triggers = Automate.AutomateTypes.formatTriggerDefinitionSchema( - input.triggerDefinitions - ) + let triggers: Automate.AutomateTypes.TriggerDefinitionsSchema + try { + triggers = Automate.AutomateTypes.formatTriggerDefinitionSchema( + input.triggerDefinitions + ) + } catch (e) { + if (e instanceof UnformattableTriggerDefinitionSchemaError) { + throw new AutomationRevisionCreationError( + 'One or more trigger definitions are not valid', + { cause: ensureError(e, 'Unknown error when formatting trigger definition') } + ) + } + throw e + } const triggerDefinitions = triggers.definitions.map((d) => { if (Automate.AutomateTypes.isVersionCreatedTriggerDefinition(d)) { const triggerDef: InsertableAutomationRevisionTrigger = { diff --git a/packages/server/modules/automate/tests/automations.spec.ts b/packages/server/modules/automate/tests/automations.spec.ts index 6122aaaab..34537636a 100644 --- a/packages/server/modules/automate/tests/automations.spec.ts +++ b/packages/server/modules/automate/tests/automations.spec.ts @@ -1,5 +1,6 @@ import { AutomationCreationError, + AutomationRevisionCreationError, AutomationUpdateError } from '@/modules/automate/errors/management' import { @@ -406,10 +407,7 @@ const buildAutomationUpdate = () => { }) ) - expect( - e instanceof Automate.UnformattableTriggerDefinitionSchemaError, - e.toString() - ).to.be.true + expect(e instanceof AutomationRevisionCreationError, e.toString()).to.be.true }) }) diff --git a/packages/server/modules/core/graph/generated/graphql.ts b/packages/server/modules/core/graph/generated/graphql.ts index 252e49211..e0153761a 100644 --- a/packages/server/modules/core/graph/generated/graphql.ts +++ b/packages/server/modules/core/graph/generated/graphql.ts @@ -4612,6 +4612,7 @@ export type WorkspaceBillingMutationsUpgradePlanArgs = { /** Overridden by `WorkspaceCollaboratorGraphQLReturn` */ export type WorkspaceCollaborator = { __typename?: 'WorkspaceCollaborator'; + email?: Maybe; id: Scalars['ID']['output']; /** Date that the user joined the workspace. */ joinDate: Scalars['DateTime']['output']; @@ -4756,6 +4757,7 @@ export type WorkspaceInviteUseInput = { export type WorkspaceJoinRequest = { __typename?: 'WorkspaceJoinRequest'; createdAt: Scalars['DateTime']['output']; + email?: Maybe; id: Scalars['String']['output']; status: WorkspaceJoinRequestStatus; user: LimitedUser; @@ -7515,6 +7517,7 @@ export type WorkspaceBillingMutationsResolvers = { + email?: Resolver, ParentType, ContextType>; id?: Resolver; joinDate?: Resolver; projectRoles?: Resolver, ParentType, ContextType>; @@ -7566,6 +7569,7 @@ export type WorkspaceInviteMutationsResolvers = { createdAt?: Resolver; + email?: Resolver, ParentType, ContextType>; id?: Resolver; status?: Resolver; user?: Resolver; diff --git a/packages/server/modules/core/graph/resolvers/projects.ts b/packages/server/modules/core/graph/resolvers/projects.ts index 5f85e98d1..36c41e0e7 100644 --- a/packages/server/modules/core/graph/resolvers/projects.ts +++ b/packages/server/modules/core/graph/resolvers/projects.ts @@ -114,6 +114,7 @@ import { renderEmail } from '@/modules/emails/services/emailRendering' import { sendEmail } from '@/modules/emails/services/sending' import { ProjectRecordVisibility } from '@/modules/core/helpers/types' import { mapDbToGqlProjectVisibility } from '@/modules/core/helpers/project' +import { StreamNotFoundError } from '@/modules/core/errors/stream' const getServerInfo = getServerInfoFactory({ db }) const getUsers = getUsersFactory({ db }) @@ -249,7 +250,7 @@ const throwIfRateLimited = throwIfRateLimitedFactory({ rateLimiterEnabled: isRateLimiterEnabled() }) -export = { +const resolvers: Resolvers = { Query: { async project(_parent, args, context) { throwIfResourceAccessNotAllowed({ @@ -265,6 +266,29 @@ export = { throwIfAuthNotOk(canQuery) const project = await getStream({ streamId: args.id }) + if (!project) { + // This should not be happening, because canQuery should've thrown + // And yet it does...so extra logging is necessary + + // Test canQuery again - is it a cache issue? + context.clearCache() + const canQueryAgain = await context.authPolicies.project.canRead({ + projectId: args.id, + userId: context.userId + }) + + context.log.error( + { + projectId: args.id, + userId: context.userId, + project, + canQuery, + canQueryAgain + }, + 'Unexpected project not found' + ) + throw new StreamNotFoundError('Project not found') + } if (project?.visibility !== ProjectRecordVisibility.Public) { await validateScopes(context.scopes, Scopes.Streams.Read) @@ -494,7 +518,7 @@ export = { // Reset loader cache ctx.clearCache() - return ret + return ret! }, async leave(_parent, args, context) { const projectId = args.id @@ -546,7 +570,8 @@ export = { return { totalCount: await ctx.loaders.users.getOwnStreamCount.load(ctx.userId!), items: [], - cursor: null + cursor: null, + numberOfHidden: 0 } } @@ -599,7 +624,7 @@ export = { Project: { async role(parent, _args, ctx) { // If role already resolved, return that - if (has(parent, 'role')) return parent.role + if (has(parent, 'role')) return parent.role || null return await ctx.loaders.streams.getRole.load(parent.id) }, @@ -676,4 +701,6 @@ export = { ) } } -} as Resolvers +} + +export default resolvers diff --git a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts index 35eeeda6c..a5029abf4 100644 --- a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts +++ b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts @@ -4592,6 +4592,7 @@ export type WorkspaceBillingMutationsUpgradePlanArgs = { /** Overridden by `WorkspaceCollaboratorGraphQLReturn` */ export type WorkspaceCollaborator = { __typename?: 'WorkspaceCollaborator'; + email?: Maybe; id: Scalars['ID']['output']; /** Date that the user joined the workspace. */ joinDate: Scalars['DateTime']['output']; @@ -4736,6 +4737,7 @@ export type WorkspaceInviteUseInput = { export type WorkspaceJoinRequest = { __typename?: 'WorkspaceJoinRequest'; createdAt: Scalars['DateTime']['output']; + email?: Maybe; id: Scalars['String']['output']; status: WorkspaceJoinRequestStatus; user: LimitedUser; diff --git a/packages/server/modules/gatekeeper/rest/billing.ts b/packages/server/modules/gatekeeper/rest/billing.ts index 244d1df6a..289238c62 100644 --- a/packages/server/modules/gatekeeper/rest/billing.ts +++ b/packages/server/modules/gatekeeper/rest/billing.ts @@ -29,6 +29,7 @@ import { } from '@/observability/domain/fields' import { withOperationLogging } from '@/observability/domain/businessLogging' import { asOperation } from '@/modules/shared/command' +import { getEventBus } from '@/modules/shared/services/eventBus' export const getBillingRouter = (): Router => { const router = Router() @@ -200,7 +201,8 @@ export const getBillingRouter = (): Router => { upsertPaidWorkspacePlan: upsertPaidWorkspacePlanFactory({ db }), getWorkspaceSubscriptionBySubscriptionId: getWorkspaceSubscriptionBySubscriptionIdFactory({ db }), - upsertWorkspaceSubscription: upsertWorkspaceSubscriptionFactory({ db }) + upsertWorkspaceSubscription: upsertWorkspaceSubscriptionFactory({ db }), + emitEvent: getEventBus().emit })({ subscriptionData: parseSubscriptionData(event.data.object) }), { logger, @@ -222,7 +224,8 @@ export const getBillingRouter = (): Router => { upsertPaidWorkspacePlan: upsertPaidWorkspacePlanFactory({ db }), getWorkspaceSubscriptionBySubscriptionId: getWorkspaceSubscriptionBySubscriptionIdFactory({ db }), - upsertWorkspaceSubscription: upsertWorkspaceSubscriptionFactory({ db }) + upsertWorkspaceSubscription: upsertWorkspaceSubscriptionFactory({ db }), + emitEvent: getEventBus().emit })({ subscriptionData }), { logger, diff --git a/packages/server/modules/gatekeeper/services/checkout.ts b/packages/server/modules/gatekeeper/services/checkout.ts index 7e8715109..648d5ec0a 100644 --- a/packages/server/modules/gatekeeper/services/checkout.ts +++ b/packages/server/modules/gatekeeper/services/checkout.ts @@ -94,9 +94,11 @@ export const completeCheckoutSessionFactory = workspacePlan: { workspaceId: checkoutSession.workspaceId, status: workspacePlan.status, - name: workspacePlan.name, - previousPlanName: previousPlan?.name - } + name: workspacePlan.name + }, + ...(previousPlan && { + previousPlan: { name: previousPlan.name } + }) } }) } diff --git a/packages/server/modules/gatekeeper/services/subscriptions.ts b/packages/server/modules/gatekeeper/services/subscriptions.ts index 67e1e5a1f..9647979a7 100644 --- a/packages/server/modules/gatekeeper/services/subscriptions.ts +++ b/packages/server/modules/gatekeeper/services/subscriptions.ts @@ -23,18 +23,21 @@ import { } from '@speckle/shared' import { cloneDeep } from 'lodash' import { CountSeatsByTypeInWorkspace } from '@/modules/gatekeeper/domain/operations' +import { EventBusEmit } from '@/modules/shared/services/eventBus' export const handleSubscriptionUpdateFactory = ({ upsertPaidWorkspacePlan, getWorkspacePlan, getWorkspaceSubscriptionBySubscriptionId, - upsertWorkspaceSubscription + upsertWorkspaceSubscription, + emitEvent }: { getWorkspacePlan: GetWorkspacePlan upsertPaidWorkspacePlan: UpsertPaidWorkspacePlan getWorkspaceSubscriptionBySubscriptionId: GetWorkspaceSubscriptionBySubscriptionId upsertWorkspaceSubscription: UpsertWorkspaceSubscription + emitEvent: EventBusEmit }) => async ({ subscriptionData }: { subscriptionData: SubscriptionData }) => { // we're only handling marking the sub scheduled for cancelation right now @@ -101,6 +104,14 @@ export const handleSubscriptionUpdateFactory = subscriptionData } }) + + await emitEvent({ + eventName: 'gatekeeper.workspace-subscription-updated', + payload: { + workspaceId: subscription.workspaceId, + status + } + }) } } diff --git a/packages/server/modules/gatekeeper/services/subscriptions/upgradeWorkspaceSubscription.ts b/packages/server/modules/gatekeeper/services/subscriptions/upgradeWorkspaceSubscription.ts index 2ade6f23c..5eaade669 100644 --- a/packages/server/modules/gatekeeper/services/subscriptions/upgradeWorkspaceSubscription.ts +++ b/packages/server/modules/gatekeeper/services/subscriptions/upgradeWorkspaceSubscription.ts @@ -200,9 +200,11 @@ export const upgradeWorkspaceSubscriptionFactory = workspacePlan: { workspaceId, status: workspacePlan.status, - name: targetPlan, - previousPlanName: workspacePlan.name - } + name: targetPlan + }, + ...(workspacePlan && { + previousPlan: { name: workspacePlan.name } + }) } }) } diff --git a/packages/server/modules/gatekeeper/services/workspacePlans.ts b/packages/server/modules/gatekeeper/services/workspacePlans.ts index cc12fb7d5..117f2c88a 100644 --- a/packages/server/modules/gatekeeper/services/workspacePlans.ts +++ b/packages/server/modules/gatekeeper/services/workspacePlans.ts @@ -75,16 +75,17 @@ export const updateWorkspacePlanFactory = default: throwUncoveredError(name) } - await emitEvent({ eventName: 'gatekeeper.workspace-plan-updated', payload: { workspacePlan: { name, status, - workspaceId, - previousPlanName: previousPlan?.name - } + workspaceId + }, + ...(previousPlan && { + previousPlan: { name: previousPlan.name } + }) } }) } diff --git a/packages/server/modules/gatekeeper/tests/helpers/workspacePlan.ts b/packages/server/modules/gatekeeper/tests/helpers/workspacePlan.ts index 6ba6ff7cf..5e64da10e 100644 --- a/packages/server/modules/gatekeeper/tests/helpers/workspacePlan.ts +++ b/packages/server/modules/gatekeeper/tests/helpers/workspacePlan.ts @@ -1,6 +1,10 @@ import { WorkspacePlan } from '@speckle/shared' import cryptoRandomString from 'crypto-random-string' import { assign } from 'lodash' +import { + SubscriptionData, + WorkspaceSubscription +} from '@/modules/gatekeeper/domain/billing' export const buildTestWorkspacePlan = ( overrides?: Partial @@ -15,3 +19,34 @@ export const buildTestWorkspacePlan = ( }, overrides ) + +export const buildTestWorkspaceSubscription = ( + overrides?: Partial +): WorkspaceSubscription => + assign( + { + workspaceId: cryptoRandomString({ length: 10 }), + createdAt: new Date(), + updatedAt: new Date(), + currentBillingCycleEnd: new Date(), + billingInterval: 'monthly', + currency: 'usd', + subscriptionData: buildTestSubscriptionData() + }, + overrides + ) + +export const buildTestSubscriptionData = ( + overrides?: Partial +): SubscriptionData => + assign( + { + subscriptionId: cryptoRandomString({ length: 10 }), + customerId: cryptoRandomString({ length: 10 }), + cancelAt: new Date(), + status: 'active', + products: [], + currentPeriodEnd: new Date() + }, + overrides + ) diff --git a/packages/server/modules/gatekeeper/tests/unit/checkout.spec.ts b/packages/server/modules/gatekeeper/tests/unit/checkout.spec.ts index 1874de460..c21f6c0c1 100644 --- a/packages/server/modules/gatekeeper/tests/unit/checkout.spec.ts +++ b/packages/server/modules/gatekeeper/tests/unit/checkout.spec.ts @@ -162,8 +162,10 @@ describe('checkout @gatekeeper', () => { workspacePlan: { workspaceId, name: storedCheckoutSession.workspacePlan, - status: 'valid', - previousPlanName: 'free' + status: 'valid' + }, + previousPlan: { + name: 'free' } }) expect(storedWorkspaceSubscriptionData!.billingInterval).to.equal( diff --git a/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts b/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts index 7a89e450e..f4136146a 100644 --- a/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts +++ b/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts @@ -27,6 +27,7 @@ import { expect } from 'chai' import cryptoRandomString from 'crypto-random-string' import { omit } from 'lodash' import { upgradeWorkspaceSubscriptionFactory } from '@/modules/gatekeeper/services/subscriptions/upgradeWorkspaceSubscription' +import { EventBusEmit } from '@/modules/shared/services/eventBus' describe('subscriptions @gatekeeper', () => { describe('handleSubscriptionUpdateFactory creates a function, that', () => { @@ -43,6 +44,9 @@ describe('subscriptions @gatekeeper', () => { }, upsertPaidWorkspacePlan: async () => { expect.fail() + }, + emitEvent: async () => { + expect.fail() } })({ subscriptionData }) }) @@ -61,6 +65,9 @@ describe('subscriptions @gatekeeper', () => { }, upsertPaidWorkspacePlan: async () => { expect.fail() + }, + emitEvent: async () => { + expect.fail() } })({ subscriptionData }) }) @@ -76,6 +83,9 @@ describe('subscriptions @gatekeeper', () => { }, upsertPaidWorkspacePlan: async () => { expect.fail() + }, + emitEvent: async () => { + expect.fail() } })({ subscriptionData }) }) @@ -104,6 +114,9 @@ describe('subscriptions @gatekeeper', () => { }, upsertPaidWorkspacePlan: async () => { expect.fail() + }, + emitEvent: async () => { + expect.fail() } })({ subscriptionData }) }) @@ -123,6 +136,12 @@ describe('subscriptions @gatekeeper', () => { let updatedSubscription: WorkspaceSubscription | undefined = undefined let updatedPlan: WorkspacePlan | undefined = undefined + let emittedEventName: string | undefined = undefined + let emittedEventPayload: unknown = undefined + const emitEvent: EventBusEmit = async ({ eventName, payload }) => { + emittedEventName = eventName + emittedEventPayload = payload + } await handleSubscriptionUpdateFactory({ getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription, @@ -138,7 +157,8 @@ describe('subscriptions @gatekeeper', () => { }, upsertPaidWorkspacePlan: async ({ workspacePlan }) => { updatedPlan = workspacePlan - } + }, + emitEvent })({ subscriptionData }) expect(updatedPlan!.status).to.be.equal('cancelationScheduled') expect(updatedSubscription!.updatedAt).to.be.greaterThanOrEqual( @@ -147,6 +167,11 @@ describe('subscriptions @gatekeeper', () => { expect(omit(updatedSubscription!, 'updatedAt')).deep.equal( omit(workspaceSubscription, 'updatedAt') ) + expect(emittedEventName).to.eq('gatekeeper.workspace-subscription-updated') + expect(emittedEventPayload).to.deep.eq({ + workspaceId, + status: 'cancelationScheduled' + }) }) it('sets the status to valid', async () => { const subscriptionData = createTestSubscriptionData({ @@ -166,6 +191,12 @@ describe('subscriptions @gatekeeper', () => { let updatedSubscription: WorkspaceSubscription | undefined = undefined let updatedPlan: WorkspacePlan | undefined = undefined + let emittedEventName: string | undefined = undefined + let emittedEventPayload: unknown = undefined + const emitEvent: EventBusEmit = async ({ eventName, payload }) => { + emittedEventName = eventName + emittedEventPayload = payload + } await handleSubscriptionUpdateFactory({ getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription, @@ -181,7 +212,8 @@ describe('subscriptions @gatekeeper', () => { }, upsertPaidWorkspacePlan: async ({ workspacePlan }) => { updatedPlan = workspacePlan - } + }, + emitEvent })({ subscriptionData }) expect(updatedPlan!.status).to.be.equal('valid') expect(updatedSubscription!.updatedAt).to.be.greaterThanOrEqual( @@ -190,6 +222,11 @@ describe('subscriptions @gatekeeper', () => { expect(omit(updatedSubscription!, 'updatedAt')).deep.equal( omit(workspaceSubscription, 'updatedAt') ) + expect(emittedEventName).to.eq('gatekeeper.workspace-subscription-updated') + expect(emittedEventPayload).to.deep.eq({ + workspaceId, + status: 'valid' + }) }) it('sets the state to paymentFailed', async () => { const subscriptionData = createTestSubscriptionData({ @@ -204,6 +241,12 @@ describe('subscriptions @gatekeeper', () => { let updatedSubscription: WorkspaceSubscription | undefined = undefined let updatedPlan: WorkspacePlan | undefined = undefined + let emittedEventName: string | undefined = undefined + let emittedEventPayload: unknown = undefined + const emitEvent: EventBusEmit = async ({ eventName, payload }) => { + emittedEventName = eventName + emittedEventPayload = payload + } await handleSubscriptionUpdateFactory({ getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription, @@ -219,7 +262,8 @@ describe('subscriptions @gatekeeper', () => { }, upsertPaidWorkspacePlan: async ({ workspacePlan }) => { updatedPlan = workspacePlan - } + }, + emitEvent })({ subscriptionData }) expect(updatedPlan!.status).to.be.equal('paymentFailed') expect(updatedSubscription!.updatedAt).to.be.greaterThanOrEqual( @@ -228,6 +272,11 @@ describe('subscriptions @gatekeeper', () => { expect(omit(updatedSubscription!, 'updatedAt')).deep.equal( omit(workspaceSubscription, 'updatedAt') ) + expect(emittedEventName).to.eq('gatekeeper.workspace-subscription-updated') + expect(emittedEventPayload).to.deep.eq({ + workspaceId, + status: 'paymentFailed' + }) }) it('sets the state to canceled', async () => { const subscriptionData = createTestSubscriptionData({ @@ -246,6 +295,12 @@ describe('subscriptions @gatekeeper', () => { let updatedSubscription: WorkspaceSubscription | undefined = undefined let updatedPlan: WorkspacePlan | undefined = undefined + let emittedEventName: string | undefined = undefined + let emittedEventPayload: unknown = undefined + const emitEvent: EventBusEmit = async ({ eventName, payload }) => { + emittedEventName = eventName + emittedEventPayload = payload + } await handleSubscriptionUpdateFactory({ getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription, @@ -261,7 +316,8 @@ describe('subscriptions @gatekeeper', () => { }, upsertPaidWorkspacePlan: async ({ workspacePlan }) => { updatedPlan = workspacePlan - } + }, + emitEvent })({ subscriptionData }) expect(updatedPlan!.status).to.be.equal('canceled') expect(updatedSubscription!.updatedAt).to.be.greaterThanOrEqual( @@ -270,6 +326,11 @@ describe('subscriptions @gatekeeper', () => { expect(omit(updatedSubscription!, 'updatedAt')).deep.equal( omit(workspaceSubscription, 'updatedAt') ) + expect(emittedEventName).to.eq('gatekeeper.workspace-subscription-updated') + expect(emittedEventPayload).to.deep.eq({ + workspaceId, + status: 'canceled' + }) }) ;( ['incomplete', 'incomplete_expired', 'trialing', 'unpaid', 'paused'] as const @@ -299,6 +360,9 @@ describe('subscriptions @gatekeeper', () => { }, upsertPaidWorkspacePlan: async () => { expect.fail() + }, + emitEvent: async () => { + expect.fail() } })({ subscriptionData }) }) @@ -1431,8 +1495,10 @@ describe('subscriptions @gatekeeper', () => { workspacePlan: { workspaceId, status: 'valid', - name: 'pro', - previousPlanName: 'team' + name: 'pro' + }, + previousPlan: { + name: 'team' } }) }) diff --git a/packages/server/modules/gatekeeper/tests/unit/workspacePlans.spec.ts b/packages/server/modules/gatekeeper/tests/unit/workspacePlans.spec.ts index 71d8f6e11..b8956a879 100644 --- a/packages/server/modules/gatekeeper/tests/unit/workspacePlans.spec.ts +++ b/packages/server/modules/gatekeeper/tests/unit/workspacePlans.spec.ts @@ -204,8 +204,7 @@ describe('workspacePlan services @gatekeeper', () => { expect(emittedEventName).to.equal('gatekeeper.workspace-plan-updated') expect(eventPayload).to.deep.equal({ workspacePlan: { - ...expectedPlan, - ...{ previousPlanName: undefined } + ...expectedPlan } }) } @@ -246,8 +245,10 @@ describe('workspacePlan services @gatekeeper', () => { workspacePlan: { workspaceId, status: WorkspacePlanStatuses.Valid, - name: PaidWorkspacePlans.ProUnlimited, - previousPlanName: PaidWorkspacePlans.Team + name: PaidWorkspacePlans.ProUnlimited + }, + previousPlan: { + name: PaidWorkspacePlans.Team } }) }) diff --git a/packages/server/modules/gatekeeperCore/domain/events.ts b/packages/server/modules/gatekeeperCore/domain/events.ts index b6b03c671..c2d61a537 100644 --- a/packages/server/modules/gatekeeperCore/domain/events.ts +++ b/packages/server/modules/gatekeeperCore/domain/events.ts @@ -7,14 +7,18 @@ const eventPrefix = `${gatekeeperEventNamespace}.` as const export const GatekeeperEvents = { WorkspaceTrialExpired: `${eventPrefix}workspace-trial-expired`, WorkspacePlanUpdated: `${eventPrefix}workspace-plan-updated`, + WorkspaceSubscriptionUpdated: `${eventPrefix}workspace-subscription-updated`, WorkspaceSeatUpdated: `${eventPrefix}workspace-seat-updated` } as const export type GatekeeperEventPayloads = { [GatekeeperEvents.WorkspaceTrialExpired]: { workspaceId: string } [GatekeeperEvents.WorkspacePlanUpdated]: { - workspacePlan: Pick & { - previousPlanName: WorkspacePlan['name'] | undefined - } + workspacePlan: Pick + previousPlan?: Pick } + [GatekeeperEvents.WorkspaceSubscriptionUpdated]: Pick< + WorkspacePlan, + 'workspaceId' | 'status' + > } diff --git a/packages/server/modules/shared/test/helpers/mixpanel.ts b/packages/server/modules/shared/test/helpers/mixpanel.ts index 9eb8d2f98..d0af9f419 100644 --- a/packages/server/modules/shared/test/helpers/mixpanel.ts +++ b/packages/server/modules/shared/test/helpers/mixpanel.ts @@ -1,12 +1,16 @@ /* eslint-disable camelcase */ import { Mixpanel } from 'mixpanel' +export type MixpanelFakeEventRecord = Array<{ event: string; payload: unknown }> + type MixpanelFakeStorage = { + events?: MixpanelFakeEventRecord people?: Record groups?: Record } export const buildMixpanelFake = ({ + events, people, groups }: MixpanelFakeStorage = {}): Mixpanel => { @@ -16,7 +20,11 @@ export const buildMixpanelFake = ({ return { init: notImplemented, - track: notImplemented, + track: (event, payload) => { + if (events) { + events.push({ event, payload }) + } + }, track_batch: notImplemented, import: notImplemented, import_batch: notImplemented, diff --git a/packages/server/modules/shared/utils/mixpanel.ts b/packages/server/modules/shared/utils/mixpanel.ts index 6c15ab22e..3331bea5d 100644 --- a/packages/server/modules/shared/utils/mixpanel.ts +++ b/packages/server/modules/shared/utils/mixpanel.ts @@ -35,7 +35,11 @@ export function initialize() { export const MixpanelEvents = { WorkspaceUpgraded: 'Workspace Upgraded', - WorkspaceCreated: 'Workspace Created' + WorkspaceCreated: 'Workspace Created', + WorkspaceDeleted: 'Workspace Deleted', + WorkspaceSubscriptionCanceled: 'Workspace Subscription Canceled', + WorkspaceSubscriptionCancelationScheduled: + 'Workspace Subscription Cancelation Scheduled' } as const /** diff --git a/packages/server/modules/workspaces/domain/operations.ts b/packages/server/modules/workspaces/domain/operations.ts index ee8631754..60d6b5447 100644 --- a/packages/server/modules/workspaces/domain/operations.ts +++ b/packages/server/modules/workspaces/domain/operations.ts @@ -143,6 +143,7 @@ export type GetWorkspaceCollaboratorsArgs = { */ excludeUserIds?: string[] } + hasAccessToEmail?: boolean } export type GetWorkspaceCollaborators = ( @@ -439,7 +440,7 @@ export type UpdateWorkspaceJoinRequestStatus = (params: { }) => Promise export type CreateWorkspaceJoinRequest = (params: { - workspaceJoinRequest: Omit + workspaceJoinRequest: Omit }) => Promise export type SendWorkspaceJoinRequestReceivedEmail = (params: { diff --git a/packages/server/modules/workspaces/domain/types.ts b/packages/server/modules/workspaces/domain/types.ts index 675fb0c83..9780d0936 100644 --- a/packages/server/modules/workspaces/domain/types.ts +++ b/packages/server/modules/workspaces/domain/types.ts @@ -3,6 +3,7 @@ import { LimitedUserRecord, UserWithRole } from '@/modules/core/helpers/types' import { WorkspaceRoles } from '@speckle/shared' export type WorkspaceTeamMember = UserWithRole & { + email: string | null workspaceRole: WorkspaceRoles workspaceRoleCreatedAt: Date workspaceId: string diff --git a/packages/server/modules/workspaces/events/eventListener.ts b/packages/server/modules/workspaces/events/eventListener.ts index 4b8baf7cb..6501ea0f7 100644 --- a/packages/server/modules/workspaces/events/eventListener.ts +++ b/packages/server/modules/workspaces/events/eventListener.ts @@ -134,6 +134,9 @@ import { WORKSPACE_TRACKING_ID_KEY } from '@/modules/workspaces/services/tracking' import { assign } from 'lodash' +import { WorkspacePlanStatuses } from '@/modules/cross-server-sync/graph/generated/graphql' +import { Mixpanel } from 'mixpanel' +import { GatekeeperEvents } from '@/modules/gatekeeperCore/domain/events' const { FF_BILLING_INTEGRATION_ENABLED } = getFeatureFlags() @@ -510,7 +513,9 @@ export const workspaceTrackingFactory = getUserEmails, getWorkspaceModelCount, getWorkspacesProjectCount, - getWorkspaceSeatCount + getWorkspaceSeatCount, + mixpanel = getClient(), + getServerTrackingProperties = getBaseTrackingProperties }: { getWorkspace: GetWorkspace countWorkspaceRole: CountWorkspaceRoleWithOptionalProjectRole @@ -522,14 +527,15 @@ export const workspaceTrackingFactory = getWorkspaceModelCount: GetWorkspaceModelCount getWorkspacesProjectCount: GetWorkspacesProjectsCounts getWorkspaceSeatCount: GetWorkspaceSeatCount + mixpanel?: Mixpanel + getServerTrackingProperties?: typeof getBaseTrackingProperties }) => async (params: EventPayload<'workspace.*'> | EventPayload<'gatekeeper.*'>) => { // temp ignoring tracking for this, if billing is not enabled // this should be sorted with a better separation between workspaces and the gatekeeper module if (!FF_BILLING_INTEGRATION_ENABLED) return - const { eventName, payload } = params - const mixpanel = getClient() if (!mixpanel) return + const { eventName, payload } = params const buildWorkspaceTrackingProperties = buildWorkspaceTrackingPropertiesFactory({ countWorkspaceRole, @@ -569,7 +575,7 @@ export const workspaceTrackingFactory = } switch (eventName) { - case 'gatekeeper.workspace-plan-updated': + case GatekeeperEvents.WorkspacePlanUpdated: const updatedPlanWorkspace = await getWorkspace({ workspaceId: payload.workspacePlan.workspaceId }) @@ -589,17 +595,38 @@ export const workspaceTrackingFactory = [WORKSPACE_TRACKING_ID_KEY]: payload.workspacePlan.workspaceId, plan: payload.workspacePlan.name, cycle: subscription?.billingInterval, - previousPlan: payload.workspacePlan.previousPlanName + previousPlan: payload.previousPlan?.name }, - getBaseTrackingProperties() + getServerTrackingProperties() ) ) break - case 'gatekeeper.workspace-trial-expired': + case GatekeeperEvents.WorkspaceSubscriptionUpdated: + if (payload.status === WorkspacePlanStatuses.Canceled) { + mixpanel.track( + MixpanelEvents.WorkspaceSubscriptionCanceled, + assign( + { [WORKSPACE_TRACKING_ID_KEY]: payload.workspaceId }, + getServerTrackingProperties() + ) + ) + } + + if (payload.status === WorkspacePlanStatuses.CancelationScheduled) { + mixpanel.track( + MixpanelEvents.WorkspaceSubscriptionCancelationScheduled, + assign( + { [WORKSPACE_TRACKING_ID_KEY]: payload.workspaceId }, + getServerTrackingProperties() + ) + ) + } + break + case GatekeeperEvents.WorkspaceTrialExpired: break case WorkspaceEvents.Authorizing: break - case 'workspace.created': + case WorkspaceEvents.Created: // we're setting workspace props and attributing to speckle users mixpanel.groups.set( WORKSPACE_TRACKING_ID_KEY, @@ -614,11 +641,11 @@ export const workspaceTrackingFactory = assign( { [WORKSPACE_TRACKING_ID_KEY]: payload.workspace.id }, await getUserTrackingProperties({ userId: payload.createdByUserId }), - getBaseTrackingProperties() + getServerTrackingProperties() ) ) break - case 'workspace.updated': + case WorkspaceEvents.Updated: // just updating workspace props mixpanel.groups.set( WORKSPACE_TRACKING_ID_KEY, @@ -626,16 +653,23 @@ export const workspaceTrackingFactory = await buildWorkspaceTrackingProperties(payload.workspace) ) break - case 'workspace.deleted': + case WorkspaceEvents.Deleted: // just marking workspace deleted mixpanel.groups.set( WORKSPACE_TRACKING_ID_KEY, payload.workspaceId, - assign({ isDeleted: true }, getBaseTrackingProperties()) + assign({ isDeleted: true }, getServerTrackingProperties()) + ) + mixpanel.track( + MixpanelEvents.WorkspaceDeleted, + assign( + { [WORKSPACE_TRACKING_ID_KEY]: payload.workspaceId }, + getServerTrackingProperties() + ) ) break - case 'workspace.role-deleted': - case 'workspace.role-updated': + case WorkspaceEvents.RoleDeleted: + case WorkspaceEvents.RoleUpdated: case WorkspaceEvents.SeatUpdated: const entity = 'acl' in payload ? payload.acl : payload.seat const workspace = await getWorkspace({ workspaceId: entity.workspaceId }) diff --git a/packages/server/modules/workspaces/graph/resolvers/workspaceJoinRequests.ts b/packages/server/modules/workspaces/graph/resolvers/workspaceJoinRequests.ts index 065489a1f..7c27065bf 100644 --- a/packages/server/modules/workspaces/graph/resolvers/workspaceJoinRequests.ts +++ b/packages/server/modules/workspaces/graph/resolvers/workspaceJoinRequests.ts @@ -88,6 +88,14 @@ export default FF_WORKSPACES_MODULE_ENABLED user: async (parent, _args, ctx) => { return await ctx.loaders.users.getUser.load(parent.userId) }, + email: async (parent, _args, ctx) => { + const hasAccessToEmail = await ctx.authPolicies.workspace.canReadMemberEmail({ + workspaceId: parent.workspaceId, + userId: ctx.userId + }) + if (!hasAccessToEmail.isOk) return null + return parent.email + }, workspace: async (parent, _args, ctx) => { return await ctx.loaders.workspaces!.getWorkspace.load(parent.workspaceId) } diff --git a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts index 9e19ab98d..5acd2a6e4 100644 --- a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts +++ b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts @@ -1565,7 +1565,7 @@ export = FF_WORKSPACES_MODULE_ENABLED }) return acl?.role || null }, - team: async (parent, args) => { + team: async (parent, args, ctx) => { const roles = args.filter?.roles?.map((r) => { const role = r as WorkspaceRoles if (!Object.values(Roles.Workspace).includes(role)) { @@ -1575,6 +1575,10 @@ export = FF_WORKSPACES_MODULE_ENABLED } return role }) + const hasAccessToEmail = await ctx.authPolicies.workspace.canReadMemberEmail({ + workspaceId: parent.id, + userId: ctx.userId + }) const filter = removeNullOrUndefinedKeys({ ...args?.filter, roles @@ -1586,7 +1590,8 @@ export = FF_WORKSPACES_MODULE_ENABLED workspaceId: parent.id, filter, limit: args.limit, - cursor: args.cursor ?? undefined + cursor: args.cursor ?? undefined, + hasAccessToEmail: hasAccessToEmail.isOk }) return team }, @@ -1809,13 +1814,19 @@ export = FF_WORKSPACES_MODULE_ENABLED const token = parent.token const authedUserId = ctx.userId + const canReadMemberEmail = + await ctx.authPolicies.workspace.canReadMemberEmail({ + workspaceId: parent.workspaceId, + userId: ctx.userId + }) const targetUserId = parent.user?.id - // Only returning it for the user that is the pending stream collaborator // OR if the token was specified + // OR if the policy allows if ( (!authedUserId || !targetUserId || authedUserId !== targetUserId) && - !token + !token && + !canReadMemberEmail.isOk ) { return null } diff --git a/packages/server/modules/workspaces/repositories/workspaceJoinRequests.ts b/packages/server/modules/workspaces/repositories/workspaceJoinRequests.ts index c0c1fcf8d..02f4b048f 100644 --- a/packages/server/modules/workspaces/repositories/workspaceJoinRequests.ts +++ b/packages/server/modules/workspaces/repositories/workspaceJoinRequests.ts @@ -1,3 +1,4 @@ +import { UserEmails } from '@/modules/core/dbSchema' import { CreateWorkspaceJoinRequest, GetWorkspaceJoinRequest, @@ -70,6 +71,12 @@ const adminWorkspaceJoinRequestsBaseQueryFactory = WorkspaceAcl.col.workspaceId, WorkspaceJoinRequests.col.workspaceId ) + .join(UserEmails.name, UserEmails.col.userId, WorkspaceJoinRequests.col.userId) + // returning the primary here as a shortcut + // should be doing an intersection with the workspace domains, the users emails + // and be distincted to a single user + // but for now, multi email usage is low enough to not warrant that here + .where(UserEmails.col.primary, '=', true) .where(WorkspaceAcl.col.role, Roles.Workspace.Admin) .where(WorkspaceAcl.col.userId, filter.userId) .where(WorkspaceJoinRequests.col.workspaceId, filter.workspaceId) @@ -95,7 +102,10 @@ export const getAdminWorkspaceJoinRequestsFactory = query.andWhere(WorkspaceJoinRequests.col.createdAt, '<', cursor) } return await query - .select(WorkspaceJoinRequests.cols) + .select([ + ...WorkspaceJoinRequests.cols, + UserEmails.col.email + ]) .orderBy(WorkspaceJoinRequests.col.createdAt, 'desc') .limit(limit) } diff --git a/packages/server/modules/workspaces/repositories/workspaces.ts b/packages/server/modules/workspaces/repositories/workspaces.ts index 3a68d1cd1..9ead8cd90 100644 --- a/packages/server/modules/workspaces/repositories/workspaces.ts +++ b/packages/server/modules/workspaces/repositories/workspaces.ts @@ -58,7 +58,14 @@ import { Workspaces, WorkspaceSeats } from '@/modules/workspaces/helpers/db' -import { knex, ServerAcl, StreamAcl, Streams, Users } from '@/modules/core/dbSchema' +import { + knex, + ServerAcl, + StreamAcl, + Streams, + UserEmails, + Users +} from '@/modules/core/dbSchema' import { removePrivateFields } from '@/modules/core/helpers/userHelper' import { clamp, has, isObjectLike } from 'lodash' @@ -408,7 +415,7 @@ export const getWorkspaceCollaboratorsTotalCountFactory = export const getWorkspaceCollaboratorsFactory = ({ db }: { db: Knex }): GetWorkspaceCollaborators => - async ({ workspaceId, filter = {}, cursor, limit = 25 }) => { + async ({ workspaceId, filter = {}, cursor, limit = 25, hasAccessToEmail }) => { const query = db .from(Users.name) .select>( @@ -419,8 +426,14 @@ export const getWorkspaceCollaboratorsFactory = DbWorkspaceAcl.colAs('createdAt', 'workspaceRoleCreatedAt') ) .join(DbWorkspaceAcl.name, DbWorkspaceAcl.col.userId, Users.col.id) + .join(UserEmails.name, Users.col.id, UserEmails.col.userId) .join(ServerAcl.name, ServerAcl.col.userId, Users.col.id) .where(DbWorkspaceAcl.col.workspaceId, workspaceId) + // this will only get the primary email of a user + // if the user has a secondary email matching the workspace's domain + // it will not be surfaced by this query + // + .andWhere(UserEmails.col.primary, true) .orderBy('workspaceRoleCreatedAt', 'desc') const { search, roles, seatType, excludeUserIds } = filter || {} @@ -462,6 +475,7 @@ export const getWorkspaceCollaboratorsFactory = const items = (await query).map((i) => ({ ...removePrivateFields(i), + email: hasAccessToEmail ? i.email : null, workspaceRole: i.workspaceRole, workspaceRoleCreatedAt: i.workspaceRoleCreatedAt, workspaceId: i.workspaceId, diff --git a/packages/server/modules/workspaces/services/invites.ts b/packages/server/modules/workspaces/services/invites.ts index b48de35c5..844947edb 100644 --- a/packages/server/modules/workspaces/services/invites.ts +++ b/packages/server/modules/workspaces/services/invites.ts @@ -518,7 +518,6 @@ export const getPendingWorkspaceCollaboratorsFactory = results.push(buildPendingWorkspaceCollaboratorModel(invite, user)) } - return results } diff --git a/packages/server/modules/workspaces/services/tracking.ts b/packages/server/modules/workspaces/services/tracking.ts index 28d1ec096..16c9a1916 100644 --- a/packages/server/modules/workspaces/services/tracking.ts +++ b/packages/server/modules/workspaces/services/tracking.ts @@ -137,6 +137,7 @@ export const buildWorkspaceTrackingPropertiesFactory = createdAt: workspace.createdAt, projectCount: workspacesProjectCount[workspace.id] || 0, modelCount, + lastSyncAt: new Date(), ...getBaseTrackingProperties() } } diff --git a/packages/server/modules/workspaces/tests/helpers/creation.ts b/packages/server/modules/workspaces/tests/helpers/creation.ts index ec231f378..2e947f4c0 100644 --- a/packages/server/modules/workspaces/tests/helpers/creation.ts +++ b/packages/server/modules/workspaces/tests/helpers/creation.ts @@ -128,8 +128,9 @@ import { validateStreamAccessFactory } from '@/modules/core/services/streams/access' import { authorizeResolver } from '@/modules/shared' -import { createRandomString } from '@/modules/core/helpers/testHelpers' import { WorkspaceCreationState } from '@/modules/workspaces/domain/types' +import { WorkspaceWithOptionalRole } from '@/modules/workspacesCore/domain/types' +import { WorkspaceRole } from '@/modules/cross-server-sync/graph/generated/graphql' const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags() @@ -353,14 +354,35 @@ export const buildBasicTestWorkspace = ( ): BasicTestWorkspace => assign( { - id: createRandomString(), - name: createRandomString(), - slug: createRandomString(), + id: cryptoRandomString({ length: 10 }), + name: cryptoRandomString({ length: 10 }), + slug: cryptoRandomString({ length: 10 }), ownerId: '' }, overrides ) +export const buildTestWorkspaceWithOptionalRole = ( + overrides?: Partial +): WorkspaceWithOptionalRole => + assign( + { + id: cryptoRandomString({ length: 10 }), + name: cryptoRandomString({ length: 10 }), + slug: cryptoRandomString({ length: 10 }), + description: cryptoRandomString({ length: 10 }), + createdAt: new Date(), + updatedAt: new Date(), + logo: cryptoRandomString({ length: 10 }), + domainBasedMembershipProtectionEnabled: false, + discoverabilityEnabled: true, + discoverabilityAutoJoinEnabled: true, + isEmbedSpeckleBrandingHidden: true, + role: WorkspaceRole.Member + }, + overrides + ) + export const assignToWorkspace = async ( workspace: BasicTestWorkspace, user: BasicTestUser, diff --git a/packages/server/modules/workspaces/tests/unit/events/eventListener.spec.ts b/packages/server/modules/workspaces/tests/unit/events/eventListener.spec.ts new file mode 100644 index 000000000..d579f3103 --- /dev/null +++ b/packages/server/modules/workspaces/tests/unit/events/eventListener.spec.ts @@ -0,0 +1,238 @@ +import { workspaceTrackingFactory } from '@/modules/workspaces/events/eventListener' +import { buildBasicTestUser } from '@/test/authHelper' +import { buildTestWorkspaceWithOptionalRole } from '@/modules/workspaces/tests/helpers/creation' +import { + CountWorkspaceRoleWithOptionalProjectRole, + GetDefaultRegion, + GetWorkspace, + GetWorkspaceModelCount, + GetWorkspaceSeatCount, + GetWorkspacesProjectsCounts +} from '@/modules/workspaces/domain/operations' +import { + buildTestWorkspacePlan, + buildTestWorkspaceSubscription +} from '@/modules/gatekeeper/tests/helpers/workspacePlan' +import { + GetWorkspacePlan, + GetWorkspaceSubscription +} from '@/modules/gatekeeper/domain/billing' +import { + FindEmailsByUserId, + FindPrimaryEmailForUser +} from '@/modules/core/domain/userEmails/operations' +import { + buildMixpanelFake, + MixpanelFakeEventRecord +} from '@/modules/shared/test/helpers/mixpanel' +import { getFeatureFlags } from '@speckle/shared/environment' +import { GatekeeperEvents } from '@/modules/gatekeeperCore/domain/events' +import { MixpanelEvents } from '@/modules/shared/utils/mixpanel' +import { expect } from 'chai' +import { WORKSPACE_TRACKING_ID_KEY } from '@/modules/workspaces/services/tracking' +import { WorkspacePlanStatuses } from '@speckle/shared' +import { WorkspaceEvents } from '@/modules/workspacesCore/domain/events' + +const { FF_BILLING_INTEGRATION_ENABLED } = getFeatureFlags() + +;(FF_BILLING_INTEGRATION_ENABLED ? describe : describe.skip)( + 'workspaceTrackingFactory creates a function, that @workspaceEventListener', + () => { + const workspace = buildTestWorkspaceWithOptionalRole() + const user = buildBasicTestUser() + const email = { + id: user.id, + email: user.email, + primary: true, + verified: true, + userId: user.id, + createdAt: new Date(), + updatedAt: new Date() + } + const region = { + key: 'reg-1', + name: 'Region', + description: '', + createdAt: new Date(), + updatedAt: new Date() + } + const baseTrackingProperties = { + // eslint-disable-next-line camelcase + server_id: 'tracking_server_id', + speckleVersion: 'test', + hostApp: 'serverside' + } + + const workspacePlan = buildTestWorkspacePlan({ workspaceId: workspace.id }) + const workspaceSubscribtion = buildTestWorkspaceSubscription({ + workspaceId: workspace.id + }) + + const getWorkspace: GetWorkspace = async () => workspace + const countWorkspaceRole: CountWorkspaceRoleWithOptionalProjectRole = async () => 0 + const getDefaultRegion: GetDefaultRegion = async () => region + const getWorkspacePlan: GetWorkspacePlan = async () => workspacePlan + const getWorkspaceSubscription: GetWorkspaceSubscription = async () => + workspaceSubscribtion + const findPrimaryEmailForUser: FindPrimaryEmailForUser = async () => email + const getUserEmails: FindEmailsByUserId = async () => [email] + const getWorkspaceModelCount: GetWorkspaceModelCount = async () => 20 + const getWorkspacesProjectCount: GetWorkspacesProjectsCounts = async () => ({ + [workspace.id]: 10 + }) + const getWorkspaceSeatCount: GetWorkspaceSeatCount = async () => 5 + + const defaults = { + getWorkspace, + countWorkspaceRole, + getDefaultRegion, + getWorkspacePlan, + getWorkspaceSubscription, + findPrimaryEmailForUser, + getUserEmails, + getWorkspaceModelCount, + getWorkspacesProjectCount, + getWorkspaceSeatCount, + getServerTrackingProperties: () => baseTrackingProperties + } + + it('pushes a Mixpanel Upgrade event when workspace plan was upgraded', async () => { + const events: MixpanelFakeEventRecord = [] + const workspaceTracking = workspaceTrackingFactory({ + ...defaults, + mixpanel: buildMixpanelFake({ events }) + }) + + await workspaceTracking({ + eventName: GatekeeperEvents.WorkspacePlanUpdated, + payload: { + workspacePlan: { + workspaceId: workspacePlan.workspaceId, + name: workspacePlan.name, + status: workspacePlan.status + }, + previousPlan: { + name: 'free' + } + } + }) + + const event = events[0] + expect(events).to.have.lengthOf(1) + expect(event.event).to.be.eq(MixpanelEvents.WorkspaceUpgraded) + expect(event.payload).to.be.deep.eq({ + [WORKSPACE_TRACKING_ID_KEY]: workspace.id, + plan: workspacePlan.name, + cycle: workspaceSubscribtion.billingInterval, + previousPlan: 'free', + hostApp: 'serverside', + speckleVersion: 'test', + // eslint-disable-next-line camelcase + server_id: 'tracking_server_id' + }) + }), + [WorkspacePlanStatuses.PaymentFailed, WorkspacePlanStatuses.Valid].forEach( + (status) => { + it(`does not send anything to mixpanel on subscription update regarding the status ${status}`, async () => { + const events: MixpanelFakeEventRecord = [] + const workspaceTracking = workspaceTrackingFactory({ + ...defaults, + mixpanel: buildMixpanelFake({ events }) + }) + + await workspaceTracking({ + eventName: GatekeeperEvents.WorkspaceSubscriptionUpdated, + payload: { + workspaceId: workspace.id, + status + } + }) + + expect(events).to.have.lengthOf(0) + }) + } + ) + + it(`sends a canceled event to mixpanel on subscription cancelation`, async () => { + const events: MixpanelFakeEventRecord = [] + const workspaceTracking = workspaceTrackingFactory({ + ...defaults, + mixpanel: buildMixpanelFake({ events }) + }) + + await workspaceTracking({ + eventName: GatekeeperEvents.WorkspaceSubscriptionUpdated, + payload: { + workspaceId: workspace.id, + status: WorkspacePlanStatuses.Canceled + } + }) + + const event = events[0] + expect(events).to.have.lengthOf(1) + expect(event.event).to.be.eq(MixpanelEvents.WorkspaceSubscriptionCanceled) + expect(event.payload).to.be.deep.eq({ + [WORKSPACE_TRACKING_ID_KEY]: workspace.id, + hostApp: 'serverside', + speckleVersion: 'test', + // eslint-disable-next-line camelcase + server_id: 'tracking_server_id' + }) + }) + + it(`sends a CancelSchedule event to mixpanel when a subscription is scheduled to be canceled`, async () => { + const events: MixpanelFakeEventRecord = [] + const workspaceTracking = workspaceTrackingFactory({ + ...defaults, + mixpanel: buildMixpanelFake({ events }) + }) + + await workspaceTracking({ + eventName: GatekeeperEvents.WorkspaceSubscriptionUpdated, + payload: { + workspaceId: workspace.id, + status: WorkspacePlanStatuses.CancelationScheduled + } + }) + + const event = events[0] + expect(events).to.have.lengthOf(1) + expect(event.event).to.be.eq( + MixpanelEvents.WorkspaceSubscriptionCancelationScheduled + ) + expect(event.payload).to.be.deep.eq({ + [WORKSPACE_TRACKING_ID_KEY]: workspace.id, + hostApp: 'serverside', + speckleVersion: 'test', + // eslint-disable-next-line camelcase + server_id: 'tracking_server_id' + }) + }) + + it('sends a custom delete mixpanel event on Workspace Delete', async () => { + const events: MixpanelFakeEventRecord = [] + const workspaceTracking = workspaceTrackingFactory({ + ...defaults, + mixpanel: buildMixpanelFake({ events }) + }) + + await workspaceTracking({ + eventName: WorkspaceEvents.Deleted, + payload: { + workspaceId: workspace.id + } + }) + + const event = events[0] + expect(events).to.have.lengthOf(1) + expect(event.event).to.be.eq(MixpanelEvents.WorkspaceDeleted) + expect(event.payload).to.be.deep.eq({ + [WORKSPACE_TRACKING_ID_KEY]: workspace.id, + hostApp: 'serverside', + speckleVersion: 'test', + // eslint-disable-next-line camelcase + server_id: 'tracking_server_id' + }) + }) + } +) diff --git a/packages/server/modules/workspacesCore/domain/types.ts b/packages/server/modules/workspacesCore/domain/types.ts index bf011ad76..4bd5f584b 100644 --- a/packages/server/modules/workspacesCore/domain/types.ts +++ b/packages/server/modules/workspacesCore/domain/types.ts @@ -71,6 +71,7 @@ export type WorkspaceJoinRequestStatus = 'pending' | 'approved' | 'denied' | 'di export type WorkspaceJoinRequest = { workspaceId: string userId: string + email: string status: WorkspaceJoinRequestStatus createdAt: Date updatedAt: Date diff --git a/packages/server/modules/workspacesCore/graph/resolvers/workspacesCore.ts b/packages/server/modules/workspacesCore/graph/resolvers/workspacesCore.ts index cfa32a8ef..ed5959e62 100644 --- a/packages/server/modules/workspacesCore/graph/resolvers/workspacesCore.ts +++ b/packages/server/modules/workspacesCore/graph/resolvers/workspacesCore.ts @@ -24,6 +24,11 @@ export = !FF_WORKSPACES_MODULE_ENABLED Mutation: { workspaceMutations: () => ({}) }, + ActiveUserMutations: { + setActiveWorkspace: async () => { + throw new WorkspacesModuleDisabledError() + } + }, WorkspaceMutations: { create: async () => { throw new WorkspacesModuleDisabledError() diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts index cbb2e63bf..fd06a024a 100644 --- a/packages/server/test/graphql/generated/graphql.ts +++ b/packages/server/test/graphql/generated/graphql.ts @@ -4593,6 +4593,7 @@ export type WorkspaceBillingMutationsUpgradePlanArgs = { /** Overridden by `WorkspaceCollaboratorGraphQLReturn` */ export type WorkspaceCollaborator = { __typename?: 'WorkspaceCollaborator'; + email?: Maybe; id: Scalars['ID']['output']; /** Date that the user joined the workspace. */ joinDate: Scalars['DateTime']['output']; @@ -4737,6 +4738,7 @@ export type WorkspaceInviteUseInput = { export type WorkspaceJoinRequest = { __typename?: 'WorkspaceJoinRequest'; createdAt: Scalars['DateTime']['output']; + email?: Maybe; id: Scalars['String']['output']; status: WorkspaceJoinRequestStatus; user: LimitedUser; diff --git a/packages/shared/src/authz/policies/index.ts b/packages/shared/src/authz/policies/index.ts index a61fb18a2..c50e00857 100644 --- a/packages/shared/src/authz/policies/index.ts +++ b/packages/shared/src/authz/policies/index.ts @@ -30,6 +30,7 @@ import { canDeleteAutomationPolicy } from './project/automation/canDelete.js' import { canPublishPolicy } from './project/canPublish.js' import { canLoadPolicy } from './project/canLoad.js' import { canUpdateEmbedOptionsPolicy } from './workspace/canUpdateEmbedOptions.js' +import { canReadMemberEmailPolicy } from './workspace/canReadMemberEmail.js' export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({ project: { @@ -74,7 +75,8 @@ export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({ canInvite: canInviteToWorkspacePolicy(loaders), canReceiveProjectsUpdatedMessage: canReceiveWorkspaceProjectsUpdatedMessagePolicy(loaders), - canUpdateEmbedOptions: canUpdateEmbedOptionsPolicy(loaders) + canUpdateEmbedOptions: canUpdateEmbedOptionsPolicy(loaders), + canReadMemberEmail: canReadMemberEmailPolicy(loaders) } }) diff --git a/packages/shared/src/authz/policies/workspace/canReadMemberEmail.spec.ts b/packages/shared/src/authz/policies/workspace/canReadMemberEmail.spec.ts new file mode 100644 index 000000000..8fb7d58f7 --- /dev/null +++ b/packages/shared/src/authz/policies/workspace/canReadMemberEmail.spec.ts @@ -0,0 +1,137 @@ +import cryptoRandomString from 'crypto-random-string' +import { Roles } from '../../../core/constants.js' +import { parseFeatureFlags } from '../../../environment/index.js' +import { Workspace } from '../../domain/workspaces/types.js' +import { WorkspacePlan } from '../../../workspaces/index.js' +import { describe, expect, it } from 'vitest' +import { + ServerNoAccessError, + ServerNoSessionError, + ServerNotEnoughPermissionsError, + WorkspaceNoAccessError, + WorkspaceNotEnoughPermissionsError, + WorkspacesNotEnabledError +} from '../../domain/authErrors.js' +import { canReadMemberEmailPolicy } from './canReadMemberEmail.js' + +const buildCanReadMemberEmailPolicy = ( + overrides?: Partial[0]> +) => { + const workspaceId = cryptoRandomString({ length: 9 }) + + return canReadMemberEmailPolicy({ + getEnv: async () => + parseFeatureFlags({ + FF_WORKSPACES_MODULE_ENABLED: 'true' + }), + getServerRole: async () => Roles.Server.Admin, + getWorkspace: async () => { + return { + id: workspaceId, + slug: cryptoRandomString({ length: 9 }) + } as Workspace + }, + getWorkspaceRole: async () => Roles.Workspace.Admin, + getWorkspaceSsoProvider: async () => null, + getWorkspaceSsoSession: async () => null, + getWorkspacePlan: async () => { + return { + workspaceId, + name: 'unlimited', + status: 'valid', + createdAt: new Date() + } as WorkspacePlan + }, + ...overrides + }) +} + +const getPolicyArgs = () => ({ + userId: cryptoRandomString({ length: 9 }), + workspaceId: cryptoRandomString({ length: 9 }) +}) + +describe('canReadMemberEmailPolicy', () => { + it('returns error if workspaces is not enabled', async () => { + const policy = buildCanReadMemberEmailPolicy({ + getEnv: async () => parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'false' }) + }) + + const result = await policy({ + ...getPolicyArgs(), + userId: undefined + }) + + expect(result).toBeAuthErrorResult({ + code: WorkspacesNotEnabledError.code + }) + }) + it('returns error if user is not logged in', async () => { + const policy = buildCanReadMemberEmailPolicy() + + const result = await policy({ + ...getPolicyArgs(), + userId: undefined + }) + + expect(result).toBeAuthErrorResult({ + code: ServerNoSessionError.code + }) + }) + + it('returns error if user is not found', async () => { + const policy = buildCanReadMemberEmailPolicy({ + getServerRole: async () => null + }) + + const result = await policy(getPolicyArgs()) + + expect(result).toBeAuthErrorResult({ + code: ServerNoAccessError.code + }) + }) + + it('returns error if user is a server guest', async () => { + const policy = buildCanReadMemberEmailPolicy({ + getServerRole: async () => Roles.Server.Guest + }) + + const result = await policy(getPolicyArgs()) + + expect(result).toBeAuthErrorResult({ + code: ServerNotEnoughPermissionsError.code + }) + }) + + it('returns error if workspace does not exist', async () => { + const policy = buildCanReadMemberEmailPolicy({ + getWorkspace: async () => null + }) + + const result = await policy(getPolicyArgs()) + + expect(result).toBeAuthErrorResult({ + code: WorkspaceNoAccessError.code + }) + }) + + it('returns error if user is not workspace admin', async () => { + const policy = buildCanReadMemberEmailPolicy({ + getWorkspaceRole: async () => Roles.Workspace.Member + }) + + const result = await policy(getPolicyArgs()) + + expect(result).toBeAuthErrorResult({ + code: WorkspaceNotEnoughPermissionsError.code + }) + }) + + it('returns ok if user is workspace admin', async () => { + const policy = buildCanReadMemberEmailPolicy() + + const result = await policy(getPolicyArgs()) + + expect(result).toBeAuthOKResult() + }) +}) diff --git a/packages/shared/src/authz/policies/workspace/canReadMemberEmail.ts b/packages/shared/src/authz/policies/workspace/canReadMemberEmail.ts new file mode 100644 index 000000000..4186f96d6 --- /dev/null +++ b/packages/shared/src/authz/policies/workspace/canReadMemberEmail.ts @@ -0,0 +1,67 @@ +import { err, ok } from 'true-myth/result' +import { MaybeUserContext, WorkspaceContext } from '../../domain/context.js' +import { AuthCheckContextLoaderKeys } from '../../domain/loaders.js' +import { AuthPolicy } from '../../domain/policies.js' +import { + ensureWorkspaceRoleAndSessionFragment, + ensureWorkspacesEnabledFragment +} from '../../fragments/workspaces.js' +import { + ServerNoAccessError, + ServerNoSessionError, + ServerNotEnoughPermissionsError, + WorkspaceNoAccessError, + WorkspaceNotEnoughPermissionsError, + WorkspacesNotEnabledError, + WorkspaceSsoSessionNoAccessError +} from '../../domain/authErrors.js' +import { ensureMinimumServerRoleFragment } from '../../fragments/server.js' +import { Roles } from '../../../core/constants.js' + +type PolicyArgs = MaybeUserContext & WorkspaceContext + +type PolicyLoaderKeys = + | typeof AuthCheckContextLoaderKeys.getEnv + | typeof AuthCheckContextLoaderKeys.getServerRole + | typeof AuthCheckContextLoaderKeys.getWorkspace + | typeof AuthCheckContextLoaderKeys.getWorkspaceRole + | typeof AuthCheckContextLoaderKeys.getWorkspaceSsoProvider + | typeof AuthCheckContextLoaderKeys.getWorkspaceSsoSession + | typeof AuthCheckContextLoaderKeys.getWorkspacePlan + +type PolicyErrors = + | InstanceType + | InstanceType + | InstanceType + | InstanceType + | InstanceType + | InstanceType + | InstanceType + +export const canReadMemberEmailPolicy: AuthPolicy< + PolicyLoaderKeys, + PolicyArgs, + PolicyErrors +> = + (loaders) => + async ({ userId, workspaceId }) => { + const ensuredWorkspacesEnabled = await ensureWorkspacesEnabledFragment(loaders)({}) + if (ensuredWorkspacesEnabled.isErr) return err(ensuredWorkspacesEnabled.error) + + const ensuredServerRole = await ensureMinimumServerRoleFragment(loaders)({ + userId, + role: Roles.Server.User + }) + + if (ensuredServerRole.isErr) return err(ensuredServerRole.error) + + const ensuredWorkspaceAccess = await ensureWorkspaceRoleAndSessionFragment(loaders)( + { + userId: userId!, + workspaceId, + role: Roles.Workspace.Admin + } + ) + if (ensuredWorkspaceAccess.isErr) return err(ensuredWorkspaceAccess.error) + return ok() + } diff --git a/packages/viewer/src/modules/extensions/measurements/MeasurementPointGizmo.ts b/packages/viewer/src/modules/extensions/measurements/MeasurementPointGizmo.ts index 291a85d1e..406401eb4 100644 --- a/packages/viewer/src/modules/extensions/measurements/MeasurementPointGizmo.ts +++ b/packages/viewer/src/modules/extensions/measurements/MeasurementPointGizmo.ts @@ -92,14 +92,17 @@ export class MeasurementPointGizmo extends Group { } private getNormalIndicatorMaterial() { - const material = new SpeckleLineMaterial({ - color: 0x047efb, - linewidth: 1, - worldUnits: false, - vertexColors: false, - alphaToCoverage: false, - resolution: new Vector2(1, 1) - }) + const material = new SpeckleLineMaterial( + { + color: 0x047efb, + linewidth: 1, + worldUnits: false, + vertexColors: false, + alphaToCoverage: false, + resolution: new Vector2(1, 1) + }, + ['USE_RTE'] + ) material.color = new Color(this._style.normalIndicatorColor) material.color.convertSRGBToLinear() material.toneMapped = false diff --git a/utils/docker-compose-ingress/nginx/default.conf b/utils/docker-compose-ingress/nginx/default.conf index 17b498bf4..773ab9bb9 100644 --- a/utils/docker-compose-ingress/nginx/default.conf +++ b/utils/docker-compose-ingress/nginx/default.conf @@ -11,6 +11,20 @@ server { proxy_set_header Connection "upgrade"; } location ~* ^/(graphql|explorer|(auth/.*)|(objects/.*)|(preview/.*)|(api/.*)|(static/.*)) { + resolver 127.0.0.11 valid=30s; + set $upstream_speckle_server speckle-server; + client_max_body_size 10m; + proxy_pass http://127.0.0.1:3000; + + proxy_buffering off; + proxy_request_buffering off; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + location ~* ^/api/file/.* { resolver 127.0.0.11 valid=30s; set $upstream_speckle_server speckle-server; client_max_body_size 300m; diff --git a/utils/helm/speckle-server/templates/file.minion.ingress.yml b/utils/helm/speckle-server/templates/file.minion.ingress.yml new file mode 100644 index 000000000..aa1d598db --- /dev/null +++ b/utils/helm/speckle-server/templates/file.minion.ingress.yml @@ -0,0 +1,28 @@ +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: speckle-server-minion-api-file + namespace: {{ .Values.namespace }} + labels: +{{ include "speckle.labels" . | indent 4 }} + annotations: + nginx.org/mergeable-ingress-type: "minion" + {{- if .Values.cert_manager_issuer }} + cert-manager.io/cluster-issuer: {{ .Values.cert_manager_issuer }} + {{- end }} + nginx.ingress.kubernetes.io/proxy-body-size: {{ (printf "%dm" (int .Values.file_size_limit_mb)) | quote }} +spec: + ingressClassName: nginx + rules: + - host: {{ .Values.domain }} + http: + paths: + - pathType: Prefix + path: "/api/file/" + backend: + service: + name: speckle-objects + port: + name: web +{{- end }} diff --git a/utils/helm/speckle-server/templates/master.ingress.yml b/utils/helm/speckle-server/templates/master.ingress.yml index 03f913e19..de4c33525 100644 --- a/utils/helm/speckle-server/templates/master.ingress.yml +++ b/utils/helm/speckle-server/templates/master.ingress.yml @@ -11,8 +11,6 @@ metadata: {{- if .Values.cert_manager_issuer }} cert-manager.io/cluster-issuer: {{ .Values.cert_manager_issuer }} {{- end }} - nginx.ingress.kubernetes.io/proxy-body-size: {{ (printf "%dm" (int .Values.file_size_limit_mb)) | quote }} - nginx.org/client-max-body-size: {{ (printf "%dm" (int .Values.file_size_limit_mb)) | quote }} spec: ingressClassName: nginx {{- if .Values.cert_manager_issuer }} diff --git a/utils/helm/speckle-server/templates/minion.ingress.yml b/utils/helm/speckle-server/templates/minion.ingress.yml index a06f8164d..9f5cfae01 100644 --- a/utils/helm/speckle-server/templates/minion.ingress.yml +++ b/utils/helm/speckle-server/templates/minion.ingress.yml @@ -11,7 +11,7 @@ metadata: {{- if .Values.cert_manager_issuer }} cert-manager.io/cluster-issuer: {{ .Values.cert_manager_issuer }} {{- end }} - nginx.ingress.kubernetes.io/proxy-body-size: {{ (printf "%dm" (int .Values.file_size_limit_mb)) | quote }} + nginx.ingress.kubernetes.io/proxy-body-size: {{ (printf "%dm" (int .Values.ingress.client_max_body_size_mb)) | quote }} spec: ingressClassName: nginx rules: @@ -61,6 +61,7 @@ spec: port: name: web - pathType: Prefix + # for /api/file/ see file.minion.ingress.yml path: "/api/" backend: service: diff --git a/utils/helm/speckle-server/values.schema.json b/utils/helm/speckle-server/values.schema.json index 6d16cc4af..ac2fd757d 100644 --- a/utils/helm/speckle-server/values.schema.json +++ b/utils/helm/speckle-server/values.schema.json @@ -154,6 +154,11 @@ "type": "string", "description": "The name of the Kubernetes pod in which the ingress controller is deployed.", "default": "ingress-nginx" + }, + "client_max_body_size_mb": { + "type": "number", + "description": "This maximum size of the body of any request (except file uploads) that can be sent to Speckle. For file uploads, the maximum size is defined by the `.file_size_limit_mb` parameter.", + "default": 10 } } }, diff --git a/utils/helm/speckle-server/values.yaml b/utils/helm/speckle-server/values.yaml index 72dd70729..dfbc5c328 100644 --- a/utils/helm/speckle-server/values.yaml +++ b/utils/helm/speckle-server/values.yaml @@ -94,9 +94,14 @@ ingress: ## enabled: true ## @param ingress.namespace The namespace in which the ingress controller is deployed. + ## namespace: ingress-nginx ## @param ingress.controllerName The name of the Kubernetes pod in which the ingress controller is deployed. + ## controllerName: ingress-nginx + ## @param ingress.client_max_body_size_mb This maximum size of the body of any request (except file uploads) that can be sent to Speckle. For file uploads, the maximum size is defined by the `.file_size_limit_mb` parameter. + ## + client_max_body_size_mb: 10 ## @section Common parameters ##