diff --git a/packages/twenty-docs/user-guide/permissions-access/capabilities/permissions.mdx b/packages/twenty-docs/user-guide/permissions-access/capabilities/permissions.mdx index 3cb8840592..6390b0b2cc 100644 --- a/packages/twenty-docs/user-guide/permissions-access/capabilities/permissions.mdx +++ b/packages/twenty-docs/user-guide/permissions-access/capabilities/permissions.mdx @@ -95,6 +95,21 @@ Click **+ Add rule** and select an object to create an exception. | Opportunities → disable "See Records" | Intern cannot see the Opportunities object at all | | People → enable "Edit Records" | Intern can edit People records (but not other objects) | +### Row-Level Permissions + + +Row-level permissions are a **Premium feature** available on the **Organization** plan (Cloud and Self-Hosted). + + +Row-level permissions let you restrict which individual records a role can see or edit, based on dynamic criteria. Unlike object permissions (which apply to an entire object type), row-level permissions evaluate each record independently. + +**Example use cases:** +- Sales reps can only see their own opportunities +- Managers can see all records in their region +- Support agents can only view tickets assigned to them + +To configure row-level permissions, open a role, navigate to the **Objects** tab, and use the **Row-Level** section to define filter conditions for a specific object. + ### Field Permissions Within each object-level rule, you can go further and configure **field-level permissions** to control access to specific fields. diff --git a/packages/twenty-docs/user-guide/permissions-access/how-tos/permissions-faq.mdx b/packages/twenty-docs/user-guide/permissions-access/how-tos/permissions-faq.mdx index 0d5510508a..18ed1856df 100644 --- a/packages/twenty-docs/user-guide/permissions-access/how-tos/permissions-faq.mdx +++ b/packages/twenty-docs/user-guide/permissions-access/how-tos/permissions-faq.mdx @@ -60,7 +60,7 @@ For fields: -Row-level permissions will be available on the **Organization** plan by Q1 2026. This allows you to restrict access to specific records based on criteria (e.g., only see your own opportunities). +Row-level permissions are available on the **Organization** plan. This allows you to restrict access to specific records based on criteria (e.g., only see your own opportunities). diff --git a/packages/twenty-server/src/engine/core-modules/billing-webhook/billing-webhook.controller.ts b/packages/twenty-server/src/engine/core-modules/billing-webhook/billing-webhook.controller.ts index 2a0dfdcaf4..64a5ac0499 100644 --- a/packages/twenty-server/src/engine/core-modules/billing-webhook/billing-webhook.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/billing-webhook/billing-webhook.controller.ts @@ -15,7 +15,6 @@ import { import { type Response } from 'express'; import Stripe from 'stripe'; -import { BillingWebhookAlertService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-alert.service'; import { BillingWebhookCustomerService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-customer.service'; import { BillingWebhookEntitlementService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-entitlement.service'; import { BillingWebhookInvoiceService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-invoice.service'; @@ -46,7 +45,6 @@ export class BillingWebhookController { private readonly billingSubscriptionService: BillingSubscriptionService, private readonly billingWebhookProductService: BillingWebhookProductService, private readonly billingWebhookPriceService: BillingWebhookPriceService, - private readonly billingWebhookAlertService: BillingWebhookAlertService, private readonly billingWebhookInvoiceService: BillingWebhookInvoiceService, private readonly billingWebhookCustomerService: BillingWebhookCustomerService, private readonly billingWebhookSubscriptionScheduleService: BillingWebhookSubscriptionScheduleService, @@ -118,11 +116,6 @@ export class BillingWebhookController { event.data, ); - case BillingWebhookEvent.ALERT_TRIGGERED: - return await this.billingWebhookAlertService.processStripeEvent( - event.data, - ); - case BillingWebhookEvent.INVOICE_FINALIZED: case BillingWebhookEvent.INVOICE_PAID: return await this.billingWebhookInvoiceService.processStripeEvent( diff --git a/packages/twenty-server/src/engine/core-modules/billing-webhook/billing-webhook.module.ts b/packages/twenty-server/src/engine/core-modules/billing-webhook/billing-webhook.module.ts index f5f25f7141..40189a59ea 100644 --- a/packages/twenty-server/src/engine/core-modules/billing-webhook/billing-webhook.module.ts +++ b/packages/twenty-server/src/engine/core-modules/billing-webhook/billing-webhook.module.ts @@ -3,7 +3,6 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { AuditModule } from 'src/engine/core-modules/audit/audit.module'; import { BillingWebhookController } from 'src/engine/core-modules/billing-webhook/billing-webhook.controller'; -import { BillingWebhookAlertService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-alert.service'; import { BillingWebhookCustomerService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-customer.service'; import { BillingWebhookEntitlementService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-entitlement.service'; import { BillingWebhookInvoiceService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-invoice.service'; @@ -58,7 +57,6 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache providers: [ BillingWebhookProductService, BillingWebhookPriceService, - BillingWebhookAlertService, BillingWebhookInvoiceService, BillingWebhookCustomerService, BillingWebhookSubscriptionService, diff --git a/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-alert.service.ts b/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-alert.service.ts deleted file mode 100644 index 4b9ff2099a..0000000000 --- a/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-alert.service.ts +++ /dev/null @@ -1,68 +0,0 @@ -/* @license Enterprise */ - -import { Injectable, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; - -import { Repository } from 'typeorm'; -import { assertIsDefinedOrThrow } from 'twenty-shared/utils'; - -import type Stripe from 'stripe'; - -import { - BillingException, - BillingExceptionCode, -} from 'src/engine/core-modules/billing/billing.exception'; -import { BillingProductEntity } from 'src/engine/core-modules/billing/entities/billing-product.entity'; -import { BillingSubscriptionItemEntity } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity'; -import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; - -@Injectable() -export class BillingWebhookAlertService { - protected readonly logger = new Logger(BillingWebhookAlertService.name); - constructor( - private readonly billingSubscriptionService: BillingSubscriptionService, - @InjectRepository(BillingProductEntity) - private readonly billingProductRepository: Repository, - @InjectRepository(BillingSubscriptionItemEntity) - private readonly billingSubscriptionItemRepository: Repository, - ) {} - - async processStripeEvent(data: Stripe.BillingAlertTriggeredEvent.Data) { - const { customer: stripeCustomerId, alert } = data.object; - - const stripeMeterId = alert.usage_threshold?.meter; - - assertIsDefinedOrThrow(stripeMeterId); - - const subscription = - await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow( - { stripeCustomerId }, - ); - - const product = await this.billingProductRepository.findOne({ - where: { - billingPrices: { - stripeMeterId: - typeof stripeMeterId === 'string' - ? stripeMeterId - : stripeMeterId.id, - }, - }, - }); - - if (!product) { - throw new BillingException( - `Product associated to meter ${stripeMeterId} not found`, - BillingExceptionCode.BILLING_PRODUCT_NOT_FOUND, - ); - } - - await this.billingSubscriptionItemRepository.update( - { - billingSubscriptionId: subscription.id, - stripeProductId: product.stripeProductId, - }, - { hasReachedCurrentPeriodCap: true }, - ); - } -} diff --git a/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-subscription.service.ts b/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-subscription.service.ts index af3748056c..bd2eddc445 100644 --- a/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-subscription.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-subscription.service.ts @@ -23,9 +23,7 @@ import { BillingSubscriptionItemEntity } from 'src/engine/core-modules/billing/e import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum'; import { BillingWebhookEvent } from 'src/engine/core-modules/billing/enums/billing-webhook-events.enum'; -import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service'; -import { StripeBillingAlertService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-alert.service'; import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/services/stripe-customer.service'; import { StripeSubscriptionScheduleService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service'; import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator'; @@ -57,10 +55,8 @@ export class BillingWebhookSubscriptionService { private readonly workspaceRepository: Repository, @InjectRepository(BillingCustomerEntity) private readonly billingCustomerRepository: Repository, - private readonly billingSubscriptionService: BillingSubscriptionService, private readonly workspaceService: WorkspaceService, private readonly stripeSubscriptionScheduleService: StripeSubscriptionScheduleService, - private readonly stripeBillingAlertService: StripeBillingAlertService, private readonly billingUsageService: BillingUsageService, private readonly workspaceCacheService: WorkspaceCacheService, ) {} @@ -153,10 +149,7 @@ export class BillingWebhookSubscriptionService { if (shouldSuspend) { if (workspace.activationStatus === WorkspaceActivationStatus.ACTIVE) { - await this.workspaceRepository.update(workspaceId, { - activationStatus: WorkspaceActivationStatus.SUSPENDED, - suspendedAt: new Date(), - }); + await this.workspaceService.suspendWorkspace(workspaceId); } else if ( workspace.activationStatus === WorkspaceActivationStatus.PENDING_CREATION diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts index d08c217c91..c170c35be4 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts @@ -32,6 +32,7 @@ export enum BillingExceptionCode { BILLING_SUBSCRIPTION_PHASE_NOT_FOUND = 'BILLING_SUBSCRIPTION_PHASE_NOT_FOUND', BILLING_TOO_MUCH_SUBSCRIPTIONS_FOUND = 'BILLING_TOO_MUCH_SUBSCRIPTIONS_FOUND', BILLING_CREDITS_EXHAUSTED = 'BILLING_CREDITS_EXHAUSTED', + BILLING_SUBSCRIPTION_NOT_CANCELED = 'BILLING_SUBSCRIPTION_NOT_CANCELED', } const getBillingExceptionUserFriendlyMessage = (code: BillingExceptionCode) => { @@ -86,6 +87,8 @@ const getBillingExceptionUserFriendlyMessage = (code: BillingExceptionCode) => { return msg`Multiple subscriptions found where one was expected.`; case BillingExceptionCode.BILLING_CREDITS_EXHAUSTED: return msg`You have exhausted your credits. Please upgrade your plan to continue.`; + case BillingExceptionCode.BILLING_SUBSCRIPTION_NOT_CANCELED: + return msg`Workspace cannot be deleted: subscription is not yet canceled.`; default: assertUnreachable(code); } diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts index 04bbdb150e..c1a88b42a0 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts @@ -141,17 +141,29 @@ export class BillingSubscriptionService { return billingSubscriptionItem; } - async deleteSubscriptions(workspaceId: string) { - const subscriptionToCancel = await this.getCurrentBillingSubscription({ + async cancelSubscription(workspaceId: string): Promise { + const subscription = await this.getCurrentBillingSubscription({ workspaceId, }); - if (isDefined(subscriptionToCancel)) { + if (isDefined(subscription)) { await this.stripeSubscriptionService.cancelSubscription( - subscriptionToCancel.stripeSubscriptionId, + subscription.stripeSubscriptionId, + ); + } + } + + async assertSubscriptionCanceledOrNone(workspaceId: string): Promise { + const activeSubscription = await this.getCurrentBillingSubscription({ + workspaceId, + }); + + if (isDefined(activeSubscription)) { + throw new BillingException( + `Subscription for workspace ${workspaceId} is not canceled`, + BillingExceptionCode.BILLING_SUBSCRIPTION_NOT_CANCELED, ); } - await this.billingSubscriptionRepository.delete({ workspaceId }); } async handleUnpaidInvoices(data: Stripe.SetupIntentSucceededEvent.Data) { diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-billing-alert.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-billing-alert.service.ts deleted file mode 100644 index 38c1f95754..0000000000 --- a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-billing-alert.service.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { assertIsDefinedOrThrow } from 'twenty-shared/utils'; - -import type Stripe from 'stripe'; - -import { STRIPE_BILLING_METER_EVENT_NAME } from 'src/engine/core-modules/billing/stripe/constants/stripe-billing-meter-event-name.constant'; -import { StripeBillingMeterEventService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter-event.service'; -import { StripeBillingMeterService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter.service'; -import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service'; -import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; - -@Injectable() -export class StripeBillingAlertService { - private readonly stripe: Stripe; - - constructor( - private readonly twentyConfigService: TwentyConfigService, - private readonly stripeSDKService: StripeSDKService, - private readonly stripeBillingMeterService: StripeBillingMeterService, - private readonly stripeBillingMeterEventService: StripeBillingMeterEventService, - ) { - if (!this.twentyConfigService.get('IS_BILLING_ENABLED')) { - return; - } - this.stripe = this.stripeSDKService.getStripe( - this.twentyConfigService.get('BILLING_STRIPE_API_KEY'), - ); - } - - async createUsageThresholdAlertForCustomerMeter( - customerId: string, - tierCap: number, - creditBalance: number = 0, - periodStart?: Date, - ): Promise { - const meter = (await this.stripeBillingMeterService.getAllMeters()).find( - (meterItem) => { - return meterItem.event_name === STRIPE_BILLING_METER_EVENT_NAME; - }, - ); - - assertIsDefinedOrThrow(meter); - - await this.archiveAlertsForCustomer(customerId, meter.id); - - // Use cumulative usage at period start to ensure consistent threshold - // regardless of when the alert is created/recreated during the period - const usageAtPeriodStart = periodStart - ? await this.stripeBillingMeterEventService.getCumulativeUsageAtTime( - meter.id, - customerId, - periodStart, - ) - : await this.stripeBillingMeterEventService.getTotalCumulativeUsage( - meter.id, - customerId, - ); - - // Threshold = usage at period start + allowance for this period - const dynamicThreshold = usageAtPeriodStart + tierCap + creditBalance; - - await this.stripe.billing.alerts.create({ - alert_type: 'usage_threshold', - title: `Usage cap for customer ${customerId}`, - usage_threshold: { - gte: dynamicThreshold, - meter: meter.id, - recurrence: 'one_time', - filters: [ - { - type: 'customer', - customer: customerId, - }, - ], - }, - }); - } - - private async archiveAlertsForCustomer( - customerId: string, - meterId: string, - ): Promise { - const alerts = await this.stripe.billing.alerts.list({ - meter: meterId, - }); - - const customerAlerts = alerts.data.filter( - (alert) => - alert.status === 'active' && - alert.usage_threshold?.filters?.some( - (filter) => - filter.type === 'customer' && filter.customer === customerId, - ), - ); - - for (const alert of customerAlerts) { - await this.stripe.billing.alerts.archive(alert.id); - } - } -} diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.module.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.module.ts index 098874e640..adbbf09ba5 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.module.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.module.ts @@ -3,7 +3,6 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { StripeBillingAlertService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-alert.service'; import { StripeBillingMeterEventService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter-event.service'; import { StripeBillingMeterService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter.service'; import { StripeBillingPortalService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-portal.service'; @@ -39,7 +38,6 @@ import { DomainServerConfigModule } from 'src/engine/core-modules/domain/domain- StripePriceService, StripeProductService, StripeBillingMeterEventService, - StripeBillingAlertService, StripeCreditGrantService, StripeInvoiceService, ], @@ -55,7 +53,6 @@ import { DomainServerConfigModule } from 'src/engine/core-modules/domain/domain- StripeProductService, StripeBillingMeterEventService, StripeSubscriptionScheduleService, - StripeBillingAlertService, StripeCreditGrantService, StripeInvoiceService, ], diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/get-billing-exception-status-code.util.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/get-billing-exception-status-code.util.ts index c91dc80571..718dcba78f 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/utils/get-billing-exception-status-code.util.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/utils/get-billing-exception-status-code.util.ts @@ -39,6 +39,7 @@ export const getBillingExceptionStatusCode = ( case BillingExceptionCode.BILLING_PRICE_INVALID: case BillingExceptionCode.BILLING_SUBSCRIPTION_PHASE_NOT_FOUND: case BillingExceptionCode.BILLING_TOO_MUCH_SUBSCRIPTIONS_FOUND: + case BillingExceptionCode.BILLING_SUBSCRIPTION_NOT_CANCELED: return 500; default: { return assertUnreachable(exception.code); diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/__tests__/workspace.service.spec.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/__tests__/workspace.service.spec.ts index 82e9f4cf4c..d29d97fd4c 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/services/__tests__/workspace.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/services/__tests__/workspace.service.spec.ts @@ -3,27 +3,21 @@ import { getDataSourceToken, getRepositoryToken } from '@nestjs/typeorm'; import { type Repository } from 'typeorm'; -import { ApprovedAccessDomainEntity } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity'; -import { AuditService } from 'src/engine/core-modules/audit/services/audit.service'; import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; import { BillingService } from 'src/engine/core-modules/billing/services/billing.service'; import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service'; import { CustomDomainManagerService } from 'src/engine/core-modules/domain/custom-domain-manager/services/custom-domain-manager.service'; import { SubdomainManagerService } from 'src/engine/core-modules/domain/subdomain-manager/services/subdomain-manager.service'; -import { EmailService } from 'src/engine/core-modules/email/email.service'; import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service'; import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { FileCorePictureService } from 'src/engine/core-modules/file/file-core-picture/services/file-core-picture.service'; import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; import { type MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service'; import { getQueueToken } from 'src/engine/core-modules/message-queue/utils/get-queue-token.util'; -import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; -import { UserService } from 'src/engine/core-modules/user/services/user.service'; import { UserEntity } from 'src/engine/core-modules/user/user.entity'; -import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { createEmptyAllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-all-flat-entity-maps.constant'; @@ -31,7 +25,6 @@ import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadat import { UpgradeMigrationService } from 'src/engine/core-modules/upgrade/services/upgrade-migration.service'; import { UpgradeSequenceReaderService } from 'src/engine/core-modules/upgrade/services/upgrade-sequence-reader.service'; import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service'; -import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service'; import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service'; import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; @@ -66,18 +59,6 @@ describe('WorkspaceService', () => { delete: jest.fn(), }, }, - { - provide: getRepositoryToken(ApprovedAccessDomainEntity), - useValue: { - findOneBy: jest.fn(), - }, - }, - { - provide: ObjectMetadataService, - useValue: { - deleteWorkspaceAllObjectMetadata: jest.fn(), - }, - }, { provide: getRepositoryToken(UserWorkspaceEntity), useValue: { @@ -101,30 +82,19 @@ describe('WorkspaceService', () => { { provide: BillingSubscriptionService, useValue: { - deleteSubscriptions: jest.fn(), - }, - }, - { - provide: AuditService, - useValue: { - createContext: jest.fn(), + cancelSubscription: jest.fn(), + assertSubscriptionCanceledOrNone: jest.fn(), }, }, ...[ WorkspaceManagerService, - UserWorkspaceService, - UserService, DnsManagerService, CustomDomainManagerService, SubdomainManagerService, TwentyConfigService, - EmailService, - OnboardingService, - WorkspaceInvitationService, - PermissionsService, - FeatureFlagService, ExceptionHandlerService, PermissionsService, + FeatureFlagService, FileCorePictureService, AiModelRegistryService, ApplicationService, @@ -251,6 +221,7 @@ describe('WorkspaceService', () => { expect(userWorkspaceRepository.delete).not.toHaveBeenCalled(); expect(userRepository.softDelete).toHaveBeenCalledWith('user-id'); }); + it('should destroy the user workspace record', async () => { jest.spyOn(userWorkspaceRepository, 'find').mockResolvedValue([ { @@ -308,7 +279,7 @@ describe('WorkspaceService', () => { }); describe('deleteWorkspace', () => { - it('should delete the workspace', async () => { + it('should hard delete the workspace', async () => { const mockWorkspace = { id: 'workspace-id', metadataVersion: 0, @@ -321,12 +292,16 @@ describe('WorkspaceService', () => { await service.deleteWorkspace(mockWorkspace.id, false); - expect(workspaceRepository.softDelete).not.toHaveBeenCalled(); + expect( + billingSubscriptionService.assertSubscriptionCanceledOrNone, + ).toHaveBeenCalledWith(mockWorkspace.id); expect(workspaceCacheStorageService.flush).toHaveBeenCalledWith( mockWorkspace.id, mockWorkspace.metadataVersion, ); expect(messageQueueService.add).toHaveBeenCalled(); + expect(workspaceRepository.delete).toHaveBeenCalledWith(mockWorkspace.id); + expect(workspaceRepository.softDelete).not.toHaveBeenCalled(); }); it('should soft delete the workspace', async () => { @@ -342,8 +317,9 @@ describe('WorkspaceService', () => { await service.deleteWorkspace(mockWorkspace.id, true); - expect(billingSubscriptionService.deleteSubscriptions).toHaveBeenCalled(); - + expect( + billingSubscriptionService.cancelSubscription, + ).toHaveBeenCalledWith(mockWorkspace.id); expect(workspaceRepository.softDelete).toHaveBeenCalledWith({ id: mockWorkspace.id, }); diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts index a7f5c094d8..0e9eb8d408 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts @@ -30,6 +30,7 @@ import { import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator'; import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service'; +import { SdkClientGenerationService } from 'src/engine/core-modules/sdk-client/sdk-client-generation.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { UpgradeMigrationService } from 'src/engine/core-modules/upgrade/services/upgrade-migration.service'; import { UpgradeSequenceReaderService } from 'src/engine/core-modules/upgrade/services/upgrade-sequence-reader.service'; @@ -58,7 +59,6 @@ import { PermissionsService } from 'src/engine/metadata-modules/permissions/perm import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util'; import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service'; -import { SdkClientGenerationService } from 'src/engine/core-modules/sdk-client/sdk-client-generation.service'; import { PrefillLogicFunctionService } from 'src/engine/workspace-manager/standard-objects-prefill-data/services/prefill-logic-function.service'; import { prefillCompanies } from 'src/engine/workspace-manager/standard-objects-prefill-data/utils/prefill-companies.util'; import { prefillDashboards } from 'src/engine/workspace-manager/standard-objects-prefill-data/utils/prefill-dashboards.util'; @@ -439,6 +439,13 @@ export class WorkspaceService extends TypeOrmQueryService { } } + async suspendWorkspace(id: string) { + await this.workspaceRepository.update(id, { + activationStatus: WorkspaceActivationStatus.SUSPENDED, + suspendedAt: new Date(), + }); + } + async deleteWorkspace(id: string, softDelete = false) { const workspace = await this.workspaceRepository.findOne({ where: { id }, @@ -465,11 +472,11 @@ export class WorkspaceService extends TypeOrmQueryService { this.logger.log(`workspace ${id} cache flushed`); - if (this.billingService.isBillingEnabled()) { - await this.billingSubscriptionService.deleteSubscriptions(workspace.id); - } - if (softDelete) { + if (this.billingService.isBillingEnabled()) { + await this.billingSubscriptionService.cancelSubscription(workspace.id); + } + await this.workspaceRepository.softDelete({ id }); await this.coreEntityCacheService.invalidate('workspaceEntity', id); @@ -478,6 +485,12 @@ export class WorkspaceService extends TypeOrmQueryService { return workspace; } + if (this.billingService.isBillingEnabled()) { + await this.billingSubscriptionService.assertSubscriptionCanceledOrNone( + workspace.id, + ); + } + await this.deleteWorkspaceSyncableMetadataEntities(workspace); await this.workspaceDataSourceService.deleteWorkspaceDBSchema(workspace.id); diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts index 434ccb7abe..1a1575f79e 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts @@ -14,6 +14,7 @@ import { FeatureFlagKey, FileFolder } from 'twenty-shared/types'; import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils'; import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/application.dto'; @@ -30,7 +31,6 @@ import { EnterprisePlanService } from 'src/engine/core-modules/enterprise/servic import { FeatureFlagDTO } from 'src/engine/core-modules/feature-flag/dtos/feature-flag.dto'; import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service'; -import { FileService } from 'src/engine/core-modules/file/services/file.service'; import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter'; import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; @@ -70,7 +70,6 @@ import { fromRoleEntityToRoleDto } from 'src/engine/metadata-modules/role/utils/ import { ViewDTO } from 'src/engine/metadata-modules/view/dtos/view.dto'; import { ViewService } from 'src/engine/metadata-modules/view/services/view.service'; import { getRequest } from 'src/utils/extract-request'; -import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; const OriginHeader = createParamDecorator( (_: unknown, ctx: ExecutionContext) => { const request = getRequest(ctx); @@ -91,7 +90,6 @@ export class WorkspaceResolver { private readonly workspaceDomainsService: WorkspaceDomainsService, private readonly userWorkspaceService: UserWorkspaceService, private readonly twentyConfigService: TwentyConfigService, - private readonly fileService: FileService, private readonly fileUrlService: FileUrlService, private readonly billingSubscriptionService: BillingSubscriptionService, private readonly featureFlagService: FeatureFlagService, @@ -171,7 +169,8 @@ export class WorkspaceResolver { SettingsPermissionGuard(PermissionFlagType.WORKSPACE), ) async deleteCurrentWorkspace(@AuthWorkspace() { id }: WorkspaceEntity) { - return this.workspaceService.deleteWorkspace(id); + await this.workspaceService.suspendWorkspace(id); + return this.workspaceService.deleteWorkspace(id, true); } @ResolveField(() => [BillingSubscriptionEntity]) diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/services/cleaner.workspace-service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/services/cleaner.workspace-service.ts index 52fb5c319c..c242ef6f33 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/services/cleaner.workspace-service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/services/cleaner.workspace-service.ts @@ -291,27 +291,49 @@ export class CleanerWorkspaceService { }); if (workspaces.length !== 0) { - if (!dryRun) { - for (const workspace of workspaces) { - const userWorkspaces = await this.userWorkspaceRepository.find({ - where: { - workspaceId: workspace.id, - }, - withDeleted: true, - }); + for (const workspace of workspaces) { + if (!isDefined(workspace.deletedAt)) { + this.logger.log( + `${dryRun ? 'DRY RUN - ' : ''}Soft deleting onboarding workspace ${workspace.id}`, + ); - for (const userWorkspace of userWorkspaces) { - await this.workspaceService.handleRemoveWorkspaceMember( - workspace.id, - userWorkspace.userId, - ); + if (!dryRun) { + const userWorkspaces = await this.userWorkspaceRepository.find({ + where: { + workspaceId: workspace.id, + }, + withDeleted: true, + }); + + for (const userWorkspace of userWorkspaces) { + await this.workspaceService.handleRemoveWorkspaceMember( + workspace.id, + userWorkspace.userId, + ); + } + + if (this.twentyConfigService.get('IS_BILLING_ENABLED')) { + await this.billingSubscriptionService.cancelSubscription( + workspace.id, + ); + } + + await this.workspaceService.deleteWorkspace(workspace.id, true); } + } else { if (this.twentyConfigService.get('IS_BILLING_ENABLED')) { - await this.billingSubscriptionService.deleteSubscriptions( + await this.billingSubscriptionService.assertSubscriptionCanceledOrNone( workspace.id, ); } - await this.workspaceRepository.delete(workspace.id); + + this.logger.log( + `${dryRun ? 'DRY RUN - ' : ''}Hard deleting onboarding workspace ${workspace.id}`, + ); + + if (!dryRun) { + await this.workspaceService.deleteWorkspace(workspace.id); + } } }