From 8e4123e772abbceb23f9739c9c2b971050aa4101 Mon Sep 17 00:00:00 2001 From: martmull Date: Thu, 21 Mar 2024 10:47:25 +0100 Subject: [PATCH] 48 add yearly monthly sub switch (#4577) --- .../effect-components/PageChangeEffect.tsx | 9 +- .../twenty-front/src/generated/graphql.tsx | 48 +++++- .../graphql/updateBillingSubscription.ts | 9 ++ .../src/modules/ui/display/icon/index.ts | 1 + .../modal/components/ConfirmationModal.tsx | 6 +- .../users/graphql/queries/getCurrentUser.ts | 1 + .../src/pages/settings/SettingsBilling.tsx | 151 +++++++++++++++--- ...613773-addIntervalToBillingSubscription.ts | 19 +++ .../core-modules/billing/billing.resolver.ts | 9 ++ .../core-modules/billing/billing.service.ts | 27 ++++ .../billing/dto/update-billing.entity.ts | 9 ++ .../entities/billing-subscription.entity.ts | 4 + .../billing/stripe/stripe.service.ts | 14 ++ .../engine/core-modules/user/user.resolver.ts | 2 - 14 files changed, 284 insertions(+), 25 deletions(-) create mode 100644 packages/twenty-front/src/modules/billing/graphql/updateBillingSubscription.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/1710926613773-addIntervalToBillingSubscription.ts create mode 100644 packages/twenty-server/src/engine/core-modules/billing/dto/update-billing.entity.ts diff --git a/packages/twenty-front/src/effect-components/PageChangeEffect.tsx b/packages/twenty-front/src/effect-components/PageChangeEffect.tsx index 2cf692c3e..820ce959c 100644 --- a/packages/twenty-front/src/effect-components/PageChangeEffect.tsx +++ b/packages/twenty-front/src/effect-components/PageChangeEffect.tsx @@ -113,11 +113,16 @@ export const PageChangeEffect = () => { ) { navigate(AppPath.CreateProfile); } else if ( - (onboardingStatus === OnboardingStatus.Completed || - onboardingStatus === OnboardingStatus.CompletedWithoutSubscription) && + onboardingStatus === OnboardingStatus.Completed && isMatchingOnboardingRoute ) { navigate(AppPath.Index); + } else if ( + onboardingStatus === OnboardingStatus.CompletedWithoutSubscription && + isMatchingOnboardingRoute && + !isMatchingLocation(AppPath.PlanRequired) + ) { + navigate(AppPath.Index); } else if (isMatchingLocation(AppPath.Invite)) { const inviteHash = matchPath({ path: '/invite/:workspaceInviteHash' }, location.pathname) diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 1c89af101..73da02df1 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -68,6 +68,7 @@ export type Billing = { export type BillingSubscription = { __typename?: 'BillingSubscription'; id: Scalars['ID']; + interval?: Maybe; status: Scalars['String']; }; @@ -258,6 +259,7 @@ export type Mutation = { renewToken: AuthTokens; signUp: LoginToken; track: Analytics; + updateBillingSubscription: UpdateBillingEntity; updateOneObject: Object; updatePasswordViaResetToken: InvalidatePassword; updateWorkspace: Workspace; @@ -660,6 +662,12 @@ export type TransientToken = { transientToken: AuthToken; }; +export type UpdateBillingEntity = { + __typename?: 'UpdateBillingEntity'; + /** Boolean that confirms query was successful */ + success: Scalars['Boolean']; +}; + export type UpdateWorkspaceInput = { allowImpersonation?: InputMaybe; displayName?: InputMaybe; @@ -1045,6 +1053,11 @@ export type GetProductPricesQueryVariables = Exact<{ export type GetProductPricesQuery = { __typename?: 'Query', getProductPrices: { __typename?: 'ProductPricesEntity', productPrices: Array<{ __typename?: 'ProductPriceEntity', created: number, recurringInterval: string, stripePriceId: string, unitAmount: number }> } }; +export type UpdateBillingSubscriptionMutationVariables = Exact<{ [key: string]: never; }>; + + +export type UpdateBillingSubscriptionMutation = { __typename?: 'Mutation', updateBillingSubscription: { __typename?: 'UpdateBillingEntity', success: boolean } }; + export type GetClientConfigQueryVariables = Exact<{ [key: string]: never; }>; @@ -1083,7 +1096,7 @@ export type UploadProfilePictureMutation = { __typename?: 'Mutation', uploadProf export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>; -export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale: string, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, defaultWorkspace: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, subscriptionStatus: string, activationStatus: string, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: string, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', status: string } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, domainName?: string | null } | null }> } }; +export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale: string, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, defaultWorkspace: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, subscriptionStatus: string, activationStatus: string, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: string, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', status: string, interval?: string | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, domainName?: string | null } | null }> } }; export type RemoveWorkspaceMemberMutationVariables = Exact<{ memberId: Scalars['String']; @@ -2006,6 +2019,38 @@ export function useGetProductPricesLazyQuery(baseOptions?: Apollo.LazyQueryHookO export type GetProductPricesQueryHookResult = ReturnType; export type GetProductPricesLazyQueryHookResult = ReturnType; export type GetProductPricesQueryResult = Apollo.QueryResult; +export const UpdateBillingSubscriptionDocument = gql` + mutation UpdateBillingSubscription { + updateBillingSubscription { + success + } +} + `; +export type UpdateBillingSubscriptionMutationFn = Apollo.MutationFunction; + +/** + * __useUpdateBillingSubscriptionMutation__ + * + * To run a mutation, you first call `useUpdateBillingSubscriptionMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useUpdateBillingSubscriptionMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [updateBillingSubscriptionMutation, { data, loading, error }] = useUpdateBillingSubscriptionMutation({ + * variables: { + * }, + * }); + */ +export function useUpdateBillingSubscriptionMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(UpdateBillingSubscriptionDocument, options); + } +export type UpdateBillingSubscriptionMutationHookResult = ReturnType; +export type UpdateBillingSubscriptionMutationResult = Apollo.MutationResult; +export type UpdateBillingSubscriptionMutationOptions = Apollo.BaseMutationOptions; export const GetClientConfigDocument = gql` query GetClientConfig { clientConfig { @@ -2225,6 +2270,7 @@ export const GetCurrentUserDocument = gql` } currentBillingSubscription { status + interval } } workspaces { diff --git a/packages/twenty-front/src/modules/billing/graphql/updateBillingSubscription.ts b/packages/twenty-front/src/modules/billing/graphql/updateBillingSubscription.ts new file mode 100644 index 000000000..2f75c7610 --- /dev/null +++ b/packages/twenty-front/src/modules/billing/graphql/updateBillingSubscription.ts @@ -0,0 +1,9 @@ +import { gql } from '@apollo/client'; + +export const UPDATE_BILLING_SUBSCRIPTION = gql` + mutation UpdateBillingSubscription { + updateBillingSubscription { + success + } + } +`; diff --git a/packages/twenty-front/src/modules/ui/display/icon/index.ts b/packages/twenty-front/src/modules/ui/display/icon/index.ts index 74f81ccb9..7bfc09d63 100644 --- a/packages/twenty-front/src/modules/ui/display/icon/index.ts +++ b/packages/twenty-front/src/modules/ui/display/icon/index.ts @@ -37,6 +37,7 @@ export { IconChevronUp, IconCircleDot, IconCircleOff, + IconCircleX, IconClick, IconCode, IconCoins, diff --git a/packages/twenty-front/src/modules/ui/layout/modal/components/ConfirmationModal.tsx b/packages/twenty-front/src/modules/ui/layout/modal/components/ConfirmationModal.tsx index e71955d62..515a27bfe 100644 --- a/packages/twenty-front/src/modules/ui/layout/modal/components/ConfirmationModal.tsx +++ b/packages/twenty-front/src/modules/ui/layout/modal/components/ConfirmationModal.tsx @@ -7,7 +7,7 @@ import { H1Title, H1TitleFontColor, } from '@/ui/display/typography/components/H1Title'; -import { Button } from '@/ui/input/button/components/Button'; +import { Button, ButtonAccent } from '@/ui/input/button/components/Button'; import { TextInput } from '@/ui/input/components/TextInput'; import { Modal } from '@/ui/layout/modal/components/Modal'; import { @@ -25,6 +25,7 @@ export type ConfirmationModalProps = { deleteButtonText?: string; confirmationPlaceholder?: string; confirmationValue?: string; + confirmButtonAccent?: ButtonAccent; }; const StyledConfirmationModal = styled(Modal)` @@ -66,6 +67,7 @@ export const ConfirmationModal = ({ deleteButtonText = 'Delete', confirmationValue, confirmationPlaceholder, + confirmButtonAccent = 'danger', }: ConfirmationModalProps) => { const [inputConfirmationValue, setInputConfirmationValue] = useState(''); @@ -127,7 +129,7 @@ export const ConfirmationModal = ({ setIsOpen(false); }} variant="secondary" - accent="danger" + accent={confirmButtonAccent} title={deleteButtonText} disabled={!isValidValue} fullWidth diff --git a/packages/twenty-front/src/modules/users/graphql/queries/getCurrentUser.ts b/packages/twenty-front/src/modules/users/graphql/queries/getCurrentUser.ts index 5568ae49c..03a6a06a4 100644 --- a/packages/twenty-front/src/modules/users/graphql/queries/getCurrentUser.ts +++ b/packages/twenty-front/src/modules/users/graphql/queries/getCurrentUser.ts @@ -37,6 +37,7 @@ export const GET_CURRENT_USER = gql` } currentBillingSubscription { status + interval } } workspaces { diff --git a/packages/twenty-front/src/pages/settings/SettingsBilling.tsx b/packages/twenty-front/src/pages/settings/SettingsBilling.tsx index 13de2e854..e0f9f92ee 100644 --- a/packages/twenty-front/src/pages/settings/SettingsBilling.tsx +++ b/packages/twenty-front/src/pages/settings/SettingsBilling.tsx @@ -1,21 +1,29 @@ -import React from 'react'; +import React, { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import styled from '@emotion/styled'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; import { useOnboardingStatus } from '@/auth/hooks/useOnboardingStatus.ts'; +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState.ts'; import { OnboardingStatus } from '@/auth/utils/getOnboardingStatus.ts'; import { SettingsBillingCoverImage } from '@/billing/components/SettingsBillingCoverImage.tsx'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SupportChat } from '@/support/components/SupportChat.tsx'; import { AppPath } from '@/types/AppPath.ts'; +import { IconCalendarEvent, IconCircleX } from '@/ui/display/icon'; import { IconCreditCard, IconCurrencyDollar } from '@/ui/display/icon'; import { Info } from '@/ui/display/info/components/Info.tsx'; import { H1Title } from '@/ui/display/typography/components/H1Title.tsx'; import { H2Title } from '@/ui/display/typography/components/H2Title.tsx'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar.tsx'; import { Button } from '@/ui/input/button/components/Button.tsx'; +import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal.tsx'; import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer'; import { Section } from '@/ui/layout/section/components/Section.tsx'; -import { useBillingPortalSessionQuery } from '~/generated/graphql.tsx'; +import { + useBillingPortalSessionQuery, + useUpdateBillingSubscriptionMutation, +} from '~/generated/graphql.tsx'; import { isDefined } from '~/utils/isDefined'; const StyledH1Title = styled(H1Title)` @@ -26,9 +34,45 @@ const StyledInvisibleChat = styled.div` display: none; `; +type SwitchInfo = { + newInterval: string; + to: string; + from: string; + impact: string; +}; + +const MONTHLY_SWITCH_INFO: SwitchInfo = { + newInterval: 'year', + to: 'to yearly', + from: 'from monthly to yearly', + impact: 'You will be charged immediately for the full year.', +}; + +const YEARLY_SWITCH_INFO: SwitchInfo = { + newInterval: 'month', + to: 'to monthly', + from: 'from yearly to monthly', + impact: 'Your credit balance will be used to pay the monthly bills.', +}; + +const SWITCH_INFOS = { + year: YEARLY_SWITCH_INFO, + month: MONTHLY_SWITCH_INFO, +}; + export const SettingsBilling = () => { const navigate = useNavigate(); + const { enqueueSnackBar } = useSnackBar(); const onboardingStatus = useOnboardingStatus(); + const currentWorkspace = useRecoilValue(currentWorkspaceState); + const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState); + const switchingInfo = + currentWorkspace?.currentBillingSubscription?.interval === 'year' + ? SWITCH_INFOS.year + : SWITCH_INFOS.month; + const [isSwitchingIntervalModalOpen, setIsSwitchingIntervalModalOpen] = + useState(false); + const [updateBillingSubscription] = useUpdateBillingSubscriptionMutation(); const { data, loading } = useBillingPortalSessionQuery({ variables: { returnUrlPath: '/settings/billing', @@ -54,6 +98,36 @@ export const SettingsBilling = () => { } }; + const openSwitchingIntervalModal = () => { + setIsSwitchingIntervalModalOpen(true); + }; + + const switchInterval = async () => { + try { + await updateBillingSubscription(); + if (isDefined(currentWorkspace?.currentBillingSubscription)) { + const newCurrentWorkspace = { + ...currentWorkspace, + currentBillingSubscription: { + ...currentWorkspace?.currentBillingSubscription, + interval: switchingInfo.newInterval, + }, + }; + setCurrentWorkspace(newCurrentWorkspace); + } + enqueueSnackBar(`Subscription has been switched ${switchingInfo.to}`, { + variant: 'success', + }); + } catch (error: any) { + enqueueSnackBar( + `Error while switching subscription ${switchingInfo.to}.`, + { + variant: 'error', + }, + ); + } + }; + const redirectToSubscribePage = () => { navigate(AppPath.PlanRequired); }; @@ -79,33 +153,74 @@ export const SettingsBilling = () => { onClick={redirectToSubscribePage} /> )} - {displaySubscribeInfo && ( + {displaySubscribeInfo ? ( - )} - {!displaySubscribeInfo && ( -
- -
+ ) : ( + <> +
+ +
+
+ +
+
+ +
+ )} + + {`Are you sure that you want to change your billing interval? + ${switchingInfo.impact}`} + + } + onConfirmClick={switchInterval} + deleteButtonText={`Change ${switchingInfo.to}`} + confirmButtonAccent={'blue'} + /> ); }; diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/1710926613773-addIntervalToBillingSubscription.ts b/packages/twenty-server/src/database/typeorm/core/migrations/1710926613773-addIntervalToBillingSubscription.ts new file mode 100644 index 000000000..441cf54d0 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/1710926613773-addIntervalToBillingSubscription.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddIntervalToBillingSubscription1710926613773 + implements MigrationInterface +{ + name = 'AddIntervalToBillingSubscription1710926613773'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."billingSubscription" ADD "interval" character varying`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."billingSubscription" DROP COLUMN "interval"`, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts index 0050a6576..8766c4adc 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts @@ -14,6 +14,7 @@ import { User } from 'src/engine/core-modules/user/user.entity'; import { CheckoutSessionInput } from 'src/engine/core-modules/billing/dto/checkout-session.input'; import { SessionEntity } from 'src/engine/core-modules/billing/dto/session.entity'; import { BillingSessionInput } from 'src/engine/core-modules/billing/dto/billing-session.input'; +import { UpdateBillingEntity } from 'src/engine/core-modules/billing/dto/update-billing.entity'; @Resolver() export class BillingResolver { @@ -88,4 +89,12 @@ export class BillingResolver { ), }; } + + @Mutation(() => UpdateBillingEntity) + @UseGuards(JwtAuthGuard) + async updateBillingSubscription(@AuthUser() user: User) { + await this.billingService.updateBillingSubscription(user); + + return { success: true }; + } } diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.service.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.service.ts index f95580085..722c9f8c2 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/billing.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/billing.service.ts @@ -154,6 +154,32 @@ export class BillingService { return session.url; } + async updateBillingSubscription(user: User) { + const billingSubscription = await this.getCurrentBillingSubscription({ + workspaceId: user.defaultWorkspaceId, + }); + const newInterval = + billingSubscription?.interval === 'year' ? 'month' : 'year'; + const billingSubscriptionItem = await this.getBillingSubscriptionItem( + user.defaultWorkspaceId, + ); + const stripeProductId = this.getProductStripeId(AvailableProduct.BasePlan); + + if (!stripeProductId) { + throw new Error('Stripe product id not found for basePlan'); + } + const productPrices = await this.getProductPrices(stripeProductId); + + const stripePriceId = productPrices.filter( + (price) => price.recurringInterval === newInterval, + )?.[0]?.stripePriceId; + + await this.stripeService.updateBillingSubscriptionItem( + billingSubscriptionItem, + stripePriceId, + ); + } + async computeCheckoutSessionURL( user: User, priceId: string, @@ -230,6 +256,7 @@ export class BillingService { stripeCustomerId: data.object.customer as string, stripeSubscriptionId: data.object.id, status: data.object.status, + interval: data.object.items.data[0].plan.interval, }, { conflictPaths: ['stripeSubscriptionId'], diff --git a/packages/twenty-server/src/engine/core-modules/billing/dto/update-billing.entity.ts b/packages/twenty-server/src/engine/core-modules/billing/dto/update-billing.entity.ts new file mode 100644 index 000000000..ae8f8660d --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/dto/update-billing.entity.ts @@ -0,0 +1,9 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +@ObjectType() +export class UpdateBillingEntity { + @Field(() => Boolean, { + description: 'Boolean that confirms query was successful', + }) + success: boolean; +} diff --git a/packages/twenty-server/src/engine/core-modules/billing/entities/billing-subscription.entity.ts b/packages/twenty-server/src/engine/core-modules/billing/entities/billing-subscription.entity.ts index 4a6a7c046..9198e9a8c 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/entities/billing-subscription.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/entities/billing-subscription.entity.ts @@ -51,6 +51,10 @@ export class BillingSubscription { @Column({ nullable: false }) status: Stripe.Subscription.Status; + @Field({ nullable: true }) + @Column({ nullable: true }) + interval: Stripe.Price.Recurring.Interval; + @OneToMany( () => BillingSubscriptionItem, (billingSubscriptionItem) => billingSubscriptionItem.billingSubscription, diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.service.ts index be72dda1a..1a4e728fe 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.service.ts @@ -4,6 +4,7 @@ import Stripe from 'stripe'; import { EnvironmentService } from 'src/engine/integrations/environment/environment.service'; import { User } from 'src/engine/core-modules/user/user.entity'; +import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity'; @Injectable() export class StripeService { @@ -105,4 +106,17 @@ export class StripeService { } await this.stripe.invoices.pay(latestInvoice.id); } + + async updateBillingSubscriptionItem( + stripeSubscriptionItem: BillingSubscriptionItem, + stripePriceId: string, + ) { + await this.stripe.subscriptionItems.update( + stripeSubscriptionItem.stripeSubscriptionItemId, + { + price: stripePriceId, + quantity: stripeSubscriptionItem.quantity, + }, + ); + } } diff --git a/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts b/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts index fc505ae2b..2ec203b10 100644 --- a/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts @@ -26,7 +26,6 @@ import { DemoEnvGuard } from 'src/engine/guards/demo.env.guard'; import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard'; import { User } from 'src/engine/core-modules/user/user.entity'; import { WorkspaceMember } from 'src/engine/core-modules/user/dtos/workspace-member.dto'; -import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; import { UserService } from './services/user.service'; @@ -45,7 +44,6 @@ export class UserResolver { @InjectRepository(User, 'core') private readonly userRepository: Repository, private readonly userService: UserService, - private readonly userWorkspaceService: UserWorkspaceService, private readonly environmentService: EnvironmentService, private readonly fileUploadService: FileUploadService, ) {}