diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index ed6d628bbd19e..a9284046a68a1 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -4239,6 +4239,7 @@ export type UpdateWorkspaceInput = { isTwoFactorAuthenticationEnforced?: InputMaybe; logo?: InputMaybe; subdomain?: InputMaybe; + trashRetentionDays?: InputMaybe; }; export type UpsertFieldPermissionsInput = { @@ -4534,6 +4535,7 @@ export type Workspace = { logo?: Maybe; metadataVersion: Scalars['Float']; subdomain: Scalars['String']; + trashRetentionDays: Scalars['Float']; updatedAt: Scalars['DateTime']; version?: Maybe; viewFields?: Maybe>; @@ -5624,7 +5626,7 @@ export type BillingSubscriptionFragmentFragment = { __typename?: 'BillingSubscri export type CurrentBillingSubscriptionFragmentFragment = { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItemDTO', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null }; -export type UserQueryFragmentFragment = { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, numberFormat?: WorkspaceMemberNumberFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', permissionFlags?: Array | null, objectsPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodDTO', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, isTwoFactorAuthenticationEnforced: boolean, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItemDTO', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean } | null, defaultAgent?: { __typename?: 'Agent', id: string } | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } }; +export type UserQueryFragmentFragment = { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, numberFormat?: WorkspaceMemberNumberFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', permissionFlags?: Array | null, objectsPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodDTO', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, isTwoFactorAuthenticationEnforced: boolean, trashRetentionDays: number, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItemDTO', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean } | null, defaultAgent?: { __typename?: 'Agent', id: string } | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } }; export type WorkspaceUrlsFragmentFragment = { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }; @@ -5643,7 +5645,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, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, numberFormat?: WorkspaceMemberNumberFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', permissionFlags?: Array | null, objectsPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodDTO', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, isTwoFactorAuthenticationEnforced: boolean, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItemDTO', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean } | null, defaultAgent?: { __typename?: 'Agent', id: string } | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } } }; +export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, numberFormat?: WorkspaceMemberNumberFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', permissionFlags?: Array | null, objectsPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodDTO', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, isTwoFactorAuthenticationEnforced: boolean, trashRetentionDays: number, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItemDTO', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean } | null, defaultAgent?: { __typename?: 'Agent', id: string } | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } } }; export type ViewFieldFragmentFragment = { __typename?: 'CoreViewField', id: string, fieldMetadataId: string, viewId: string, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }; @@ -6461,6 +6463,7 @@ export const UserQueryFragmentFragmentDoc = gql` id } isTwoFactorAuthenticationEnforced + trashRetentionDays } availableWorkspaces { ...AvailableWorkspacesFragment diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index c5e5c683aa825..365f8be3256da 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -4077,6 +4077,7 @@ export type UpdateWorkspaceInput = { isTwoFactorAuthenticationEnforced?: InputMaybe; logo?: InputMaybe; subdomain?: InputMaybe; + trashRetentionDays?: InputMaybe; }; export type UpsertFieldPermissionsInput = { @@ -4362,6 +4363,7 @@ export type Workspace = { logo?: Maybe; metadataVersion: Scalars['Float']; subdomain: Scalars['String']; + trashRetentionDays: Scalars['Float']; updatedAt: Scalars['DateTime']; version?: Maybe; viewFields?: Maybe>; diff --git a/packages/twenty-front/src/modules/apollo/services/__tests__/apollo.factory.test.ts b/packages/twenty-front/src/modules/apollo/services/__tests__/apollo.factory.test.ts index a134773cbc57a..4d0bd6dda50bc 100644 --- a/packages/twenty-front/src/modules/apollo/services/__tests__/apollo.factory.test.ts +++ b/packages/twenty-front/src/modules/apollo/services/__tests__/apollo.factory.test.ts @@ -57,6 +57,7 @@ const mockWorkspace = { customUrl: 'test.com', }, isTwoFactorAuthenticationEnforced: false, + trashRetentionDays: 14, }; const createMockOptions = (): Options => ({ diff --git a/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts b/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts index 138503087f277..de03d7fb375cb 100644 --- a/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts +++ b/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts @@ -24,6 +24,7 @@ export type CurrentWorkspace = Pick< | 'workspaceUrls' | 'metadataVersion' | 'isTwoFactorAuthenticationEnforced' + | 'trashRetentionDays' > & { defaultRole?: Omit | null; defaultAgent?: { id: string } | null; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromFieldMetadata.test.ts b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromFieldMetadata.test.ts index 9e7ffc96ffa16..d57a9164b37d9 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromFieldMetadata.test.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromFieldMetadata.test.ts @@ -50,6 +50,7 @@ const Wrapper = getJestMetadataAndApolloMocksAndActionMenuWrapper({ }, ], isTwoFactorAuthenticationEnforced: false, + trashRetentionDays: 14, }); }, }); diff --git a/packages/twenty-front/src/modules/settings/components/SettingsCounter.tsx b/packages/twenty-front/src/modules/settings/components/SettingsCounter.tsx index 970ab58ac8610..f265a00fecf50 100644 --- a/packages/twenty-front/src/modules/settings/components/SettingsCounter.tsx +++ b/packages/twenty-front/src/modules/settings/components/SettingsCounter.tsx @@ -10,14 +10,16 @@ type SettingsCounterProps = { minValue?: number; maxValue?: number; disabled?: boolean; + showButtons?: boolean; }; -const StyledCounterContainer = styled.div` +const StyledCounterContainer = styled.div<{ showButtons: boolean }>` align-items: center; display: flex; gap: ${({ theme }) => theme.spacing(1)}; margin-left: auto; - width: ${({ theme }) => theme.spacing(30)}; + width: ${({ theme, showButtons }) => + showButtons ? theme.spacing(30) : theme.spacing(16)}; `; const StyledTextInput = styled(SettingsTextInput)` @@ -34,11 +36,12 @@ export const SettingsCounter = ({ value, onChange, minValue = 0, - maxValue = 100, + maxValue, disabled = false, + showButtons = true, }: SettingsCounterProps) => { const handleIncrementCounter = () => { - if (value < maxValue) { + if (maxValue === undefined || value < maxValue) { onChange(value + 1); } }; @@ -60,7 +63,7 @@ export const SettingsCounter = ({ return; } - if (castedNumber > maxValue) { + if (maxValue !== undefined && castedNumber > maxValue) { onChange(maxValue); return; } @@ -68,14 +71,16 @@ export const SettingsCounter = ({ }; return ( - - + + {showButtons && ( + + )} - + {showButtons && ( + + )} ); }; diff --git a/packages/twenty-front/src/modules/settings/components/SettingsOptions/SettingsOptionCardContentCounter.tsx b/packages/twenty-front/src/modules/settings/components/SettingsOptions/SettingsOptionCardContentCounter.tsx index ce4b22e327d31..fbd5b32528d60 100644 --- a/packages/twenty-front/src/modules/settings/components/SettingsOptions/SettingsOptionCardContentCounter.tsx +++ b/packages/twenty-front/src/modules/settings/components/SettingsOptions/SettingsOptionCardContentCounter.tsx @@ -17,6 +17,7 @@ type SettingsOptionCardContentCounterProps = { onChange: (value: number) => void; minValue?: number; maxValue?: number; + showButtons?: boolean; }; export const SettingsOptionCardContentCounter = ({ @@ -28,6 +29,7 @@ export const SettingsOptionCardContentCounter = ({ onChange, minValue, maxValue, + showButtons = true, }: SettingsOptionCardContentCounterProps) => { return ( @@ -50,6 +52,7 @@ export const SettingsOptionCardContentCounter = ({ minValue={minValue} maxValue={maxValue} disabled={disabled} + showButtons={showButtons} /> ); diff --git a/packages/twenty-front/src/modules/settings/components/SettingsOptions/__stories__/SettingsOptionCardContentCounter.stories.tsx b/packages/twenty-front/src/modules/settings/components/SettingsOptions/__stories__/SettingsOptionCardContentCounter.stories.tsx index f683e2516a3cb..f3250e4704385 100644 --- a/packages/twenty-front/src/modules/settings/components/SettingsOptions/__stories__/SettingsOptionCardContentCounter.stories.tsx +++ b/packages/twenty-front/src/modules/settings/components/SettingsOptions/__stories__/SettingsOptionCardContentCounter.stories.tsx @@ -25,6 +25,7 @@ const SettingsOptionCardContentCounterWrapper = ( disabled={args.disabled} minValue={args.minValue} maxValue={args.maxValue} + showButtons={args.showButtons} /> ); @@ -50,6 +51,7 @@ export const Default: Story = { value: 5, minValue: 1, maxValue: 10, + showButtons: true, }, argTypes: { Icon: { control: false }, @@ -64,6 +66,7 @@ export const WithoutIcon: Story = { value: 20, minValue: 10, maxValue: 50, + showButtons: true, }, }; @@ -76,5 +79,17 @@ export const Disabled: Story = { disabled: true, minValue: 1, maxValue: 10, + showButtons: true, + }, +}; + +export const WithoutButtons: Story = { + args: { + Icon: IconUsers, + title: 'Trash Retention', + description: 'Adjust the number of days before deletion', + value: 14, + minValue: 0, + showButtons: false, }, }; diff --git a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts index 40ee486a43b05..df6099ce62cfd 100644 --- a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts +++ b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts @@ -79,6 +79,7 @@ export const USER_QUERY_FRAGMENT = gql` id } isTwoFactorAuthenticationEnforced + trashRetentionDays } availableWorkspaces { ...AvailableWorkspacesFragment diff --git a/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx b/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx index 11b929cadacc7..b3069435b9c53 100644 --- a/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx +++ b/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx @@ -1,19 +1,24 @@ import styled from '@emotion/styled'; import { Trans, useLingui } from '@lingui/react/macro'; +import { useDebouncedCallback } from 'use-debounce'; +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; +import { SettingsOptionCardContentCounter } from '@/settings/components/SettingsOptions/SettingsOptionCardContentCounter'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsSSOIdentitiesProvidersListCard } from '@/settings/security/components/SSO/SettingsSSOIdentitiesProvidersListCard'; import { SettingsSecurityAuthProvidersOptionsList } from '@/settings/security/components/SettingsSecurityAuthProvidersOptionsList'; - import { ToggleImpersonate } from '@/settings/workspace/components/ToggleImpersonate'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; -import { useRecoilValue } from 'recoil'; +import { ApolloError } from '@apollo/client'; +import { useRecoilState, useRecoilValue } from 'recoil'; import { SettingsPath } from 'twenty-shared/types'; import { getSettingsPath } from 'twenty-shared/utils'; import { Tag } from 'twenty-ui/components'; -import { H2Title, IconLock } from 'twenty-ui/display'; -import { Section } from 'twenty-ui/layout'; +import { H2Title, IconLock, IconTrash } from 'twenty-ui/display'; +import { Card, Section } from 'twenty-ui/layout'; +import { useUpdateWorkspaceMutation } from '~/generated-metadata/graphql'; const StyledContainer = styled.div` width: 100%; @@ -32,8 +37,50 @@ const StyledSection = styled(Section)` export const SettingsSecurity = () => { const { t } = useLingui(); + const { enqueueErrorSnackBar } = useSnackBar(); const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); + const [currentWorkspace, setCurrentWorkspace] = useRecoilState( + currentWorkspaceState, + ); + const [updateWorkspace] = useUpdateWorkspaceMutation(); + + const saveWorkspace = useDebouncedCallback(async (value: number) => { + try { + if (!currentWorkspace?.id) { + throw new Error('User is not logged in'); + } + + await updateWorkspace({ + variables: { + input: { + trashRetentionDays: value, + }, + }, + }); + } catch (err) { + enqueueErrorSnackBar({ + apolloError: err instanceof ApolloError ? err : undefined, + }); + } + }, 500); + + const handleTrashRetentionDaysChange = (value: number) => { + if (!currentWorkspace) { + return; + } + + if (value === currentWorkspace.trashRetentionDays) { + return; + } + + setCurrentWorkspace({ + ...currentWorkspace, + trashRetentionDays: value, + }); + + saveWorkspace(value); + }; return ( { )} +
+ + + + +
diff --git a/packages/twenty-front/src/testing/mock-data/users.ts b/packages/twenty-front/src/testing/mock-data/users.ts index 6d70084b0f822..776b06b2de6c8 100644 --- a/packages/twenty-front/src/testing/mock-data/users.ts +++ b/packages/twenty-front/src/testing/mock-data/users.ts @@ -84,6 +84,7 @@ export const mockCurrentWorkspace: Workspace = { createdAt: '2023-04-26T10:23:42.33625+00:00', updatedAt: '2023-04-26T10:23:42.33625+00:00', metadataVersion: 1, + trashRetentionDays: 14, currentBillingSubscription: { __typename: 'BillingSubscription', id: '7efbc3f7-6e5e-4128-957e-8d86808cdf6a', @@ -151,6 +152,7 @@ export const mockCurrentWorkspace: Workspace = { databaseSchema: '', databaseUrl: '', isTwoFactorAuthenticationEnforced: false, + __typename: 'Workspace', }; export const mockedWorkspaceMemberData: WorkspaceMember = { diff --git a/packages/twenty-server/.env.example b/packages/twenty-server/.env.example index ff27505ef6d6d..59f019fdf3892 100644 --- a/packages/twenty-server/.env.example +++ b/packages/twenty-server/.env.example @@ -78,4 +78,4 @@ FRONTEND_URL=http://localhost:3001 # CLOUDFLARE_WEBHOOK_SECRET= # IS_CONFIG_VARIABLES_IN_DB_ENABLED=false # ANALYTICS_ENABLED= -# CLICKHOUSE_URL=http://default:clickhousePassword@localhost:8123/twenty +# CLICKHOUSE_URL=http://default:clickhousePassword@localhost:8123/twenty \ No newline at end of file diff --git a/packages/twenty-server/src/database/commands/cron-register-all.command.ts b/packages/twenty-server/src/database/commands/cron-register-all.command.ts index 36b7fc206dc0e..17e9e547648c2 100644 --- a/packages/twenty-server/src/database/commands/cron-register-all.command.ts +++ b/packages/twenty-server/src/database/commands/cron-register-all.command.ts @@ -7,6 +7,7 @@ import { CheckCustomDomainValidRecordsCronCommand } from 'src/engine/core-module import { CronTriggerCronCommand } from 'src/engine/metadata-modules/cron-trigger/crons/commands/cron-trigger.cron.command'; import { CleanOnboardingWorkspacesCronCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/clean-onboarding-workspaces.cron.command'; import { CleanSuspendedWorkspacesCronCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/clean-suspended-workspaces.cron.command'; +import { TrashCleanupCronCommand } from 'src/engine/trash-cleanup/commands/trash-cleanup.cron.command'; import { CalendarEventListFetchCronCommand } from 'src/modules/calendar/calendar-event-import-manager/crons/commands/calendar-event-list-fetch.cron.command'; import { CalendarEventsImportCronCommand } from 'src/modules/calendar/calendar-event-import-manager/crons/commands/calendar-import.cron.command'; import { CalendarOngoingStaleCronCommand } from 'src/modules/calendar/calendar-event-import-manager/crons/commands/calendar-ongoing-stale.cron.command'; @@ -42,6 +43,7 @@ export class CronRegisterAllCommand extends CommandRunner { private readonly cronTriggerCronCommand: CronTriggerCronCommand, private readonly cleanSuspendedWorkspacesCronCommand: CleanSuspendedWorkspacesCronCommand, private readonly cleanOnboardingWorkspacesCronCommand: CleanOnboardingWorkspacesCronCommand, + private readonly trashCleanupCronCommand: TrashCleanupCronCommand, ) { super(); } @@ -110,6 +112,10 @@ export class CronRegisterAllCommand extends CommandRunner { name: 'CleanOnboardingWorkspaces', command: this.cleanOnboardingWorkspacesCronCommand, }, + { + name: 'TrashCleanup', + command: this.trashCleanupCronCommand, + }, ]; let successCount = 0; diff --git a/packages/twenty-server/src/database/commands/database-command.module.ts b/packages/twenty-server/src/database/commands/database-command.module.ts index b260376571395..9207f9fab88ed 100644 --- a/packages/twenty-server/src/database/commands/database-command.module.ts +++ b/packages/twenty-server/src/database/commands/database-command.module.ts @@ -20,6 +20,7 @@ import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadat import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; import { DevSeederModule } from 'src/engine/workspace-manager/dev-seeder/dev-seeder.module'; import { WorkspaceCleanerModule } from 'src/engine/workspace-manager/workspace-cleaner/workspace-cleaner.module'; +import { TrashCleanupModule } from 'src/engine/trash-cleanup/trash-cleanup.module'; import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module'; import { CalendarEventImportManagerModule } from 'src/modules/calendar/calendar-event-import-manager/calendar-event-import-manager.module'; import { MessagingImportManagerModule } from 'src/modules/messaging/message-import-manager/messaging-import-manager.module'; @@ -50,6 +51,7 @@ import { AutomatedTriggerModule } from 'src/modules/workflow/workflow-trigger/au CronTriggerModule, DatabaseEventTriggerModule, WorkspaceCleanerModule, + TrashCleanupModule, PublicDomainModule, ], providers: [ diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1760356369619-add-workspace-trash-retention.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1760356369619-add-workspace-trash-retention.ts new file mode 100644 index 0000000000000..2e4704e19ae5f --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1760356369619-add-workspace-trash-retention.ts @@ -0,0 +1,19 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +export class AddWorkspaceTrashRetention1760356369619 + implements MigrationInterface +{ + name = 'AddWorkspaceTrashRetention1760356369619'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."workspace" ADD "trashRetentionDays" integer NOT NULL DEFAULT '14'`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."workspace" DROP COLUMN "trashRetentionDays"`, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts index 0ac48a4d3a530..56d1c1558f504 100644 --- a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts +++ b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts @@ -56,6 +56,7 @@ import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-inv import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module'; import { RoleModule } from 'src/engine/metadata-modules/role/role.module'; import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module'; +import { TrashCleanupModule } from 'src/engine/trash-cleanup/trash-cleanup.module'; import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module'; import { ChannelSyncModule } from 'src/modules/connected-account/channel-sync/channel-sync.module'; @@ -134,6 +135,7 @@ import { FileModule } from './file/file.module'; WebhookModule, PageLayoutModule, ImpersonationModule, + TrashCleanupModule, ], exports: [ AuditModule, diff --git a/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts b/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts index d46d5778f2118..26a3ef8cf4c5d 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts @@ -2,11 +2,13 @@ import { Field, InputType } from '@nestjs/graphql'; import { IsBoolean, + IsInt, IsNotIn, IsOptional, IsString, IsUUID, Matches, + Min, } from 'class-validator'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; @@ -197,4 +199,10 @@ export class UpdateWorkspaceInput { @IsBoolean() @IsOptional() isTwoFactorAuthenticationEnforced?: boolean; + + @Field({ nullable: true }) + @IsInt() + @Min(0) + @IsOptional() + trashRetentionDays?: number; } 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 158f86d41db3d..2e3a311a54510 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 @@ -476,7 +476,8 @@ export class WorkspaceService extends TypeOrmQueryService { 'displayName' in payload || 'subdomain' in payload || 'customDomain' in payload || - 'logo' in payload + 'logo' in payload || + 'trashRetentionDays' in payload ) { if (!userWorkspaceId) { throw new Error('Missing userWorkspaceId in authContext'); diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts index c6b31334008b6..a94f002121180 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts @@ -92,6 +92,10 @@ export class Workspace { @Column({ default: true }) isPublicInviteLinkEnabled: boolean; + @Field() + @Column({ type: 'integer', default: 14 }) + trashRetentionDays: number; + // Relations @OneToMany(() => AppToken, (appToken) => appToken.workspace, { cascade: true, diff --git a/packages/twenty-server/src/engine/trash-cleanup/commands/trash-cleanup.cron.command.ts b/packages/twenty-server/src/engine/trash-cleanup/commands/trash-cleanup.cron.command.ts new file mode 100644 index 0000000000000..ced36075ef443 --- /dev/null +++ b/packages/twenty-server/src/engine/trash-cleanup/commands/trash-cleanup.cron.command.ts @@ -0,0 +1,32 @@ +import { Command, CommandRunner } from 'nest-commander'; + +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 { TRASH_CLEANUP_CRON_PATTERN } from 'src/engine/trash-cleanup/constants/trash-cleanup.constants'; +import { TrashCleanupCronJob } from 'src/engine/trash-cleanup/crons/trash-cleanup.cron.job'; + +@Command({ + name: 'cron:trash-cleanup', + description: 'Starts a cron job to clean up soft-deleted records', +}) +export class TrashCleanupCronCommand extends CommandRunner { + constructor( + @InjectMessageQueue(MessageQueue.cronQueue) + private readonly messageQueueService: MessageQueueService, + ) { + super(); + } + + async run(): Promise { + await this.messageQueueService.addCron({ + jobName: TrashCleanupCronJob.name, + data: undefined, + options: { + repeat: { + pattern: TRASH_CLEANUP_CRON_PATTERN, + }, + }, + }); + } +} diff --git a/packages/twenty-server/src/engine/trash-cleanup/constants/trash-cleanup.constants.ts b/packages/twenty-server/src/engine/trash-cleanup/constants/trash-cleanup.constants.ts new file mode 100644 index 0000000000000..ba74a0839dbb7 --- /dev/null +++ b/packages/twenty-server/src/engine/trash-cleanup/constants/trash-cleanup.constants.ts @@ -0,0 +1,5 @@ +// Daily at 00:10 UTC +export const TRASH_CLEANUP_CRON_PATTERN = '10 0 * * *'; + +export const TRASH_CLEANUP_MAX_RECORDS_PER_WORKSPACE = 1_000_000; +export const TRASH_CLEANUP_BATCH_SIZE = 1_000; diff --git a/packages/twenty-server/src/engine/trash-cleanup/crons/trash-cleanup.cron.job.ts b/packages/twenty-server/src/engine/trash-cleanup/crons/trash-cleanup.cron.job.ts new file mode 100644 index 0000000000000..41c67bd7f2488 --- /dev/null +++ b/packages/twenty-server/src/engine/trash-cleanup/crons/trash-cleanup.cron.job.ts @@ -0,0 +1,92 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; +import { Repository } from 'typeorm'; + +import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator'; +import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service'; +import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator'; +import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator'; +import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.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 { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { TRASH_CLEANUP_CRON_PATTERN } from 'src/engine/trash-cleanup/constants/trash-cleanup.constants'; +import { + TrashCleanupJob, + type TrashCleanupJobData, +} from 'src/engine/trash-cleanup/jobs/trash-cleanup.job'; + +@Injectable() +@Processor(MessageQueue.cronQueue) +export class TrashCleanupCronJob { + private readonly logger = new Logger(TrashCleanupCronJob.name); + + constructor( + @InjectRepository(Workspace) + private readonly workspaceRepository: Repository, + @InjectMessageQueue(MessageQueue.workspaceQueue) + private readonly messageQueueService: MessageQueueService, + private readonly exceptionHandlerService: ExceptionHandlerService, + ) {} + + @Process(TrashCleanupCronJob.name) + @SentryCronMonitor(TrashCleanupCronJob.name, TRASH_CLEANUP_CRON_PATTERN) + async handle(): Promise { + const workspaces = await this.getActiveWorkspaces(); + + if (workspaces.length === 0) { + this.logger.log('No active workspaces found for trash cleanup'); + + return; + } + + this.logger.log( + `Enqueuing trash cleanup jobs for ${workspaces.length} workspace(s)`, + ); + + for (const workspace of workspaces) { + try { + await this.messageQueueService.add( + TrashCleanupJob.name, + { + workspaceId: workspace.id, + trashRetentionDays: workspace.trashRetentionDays, + }, + ); + } catch (error) { + this.exceptionHandlerService.captureExceptions([error], { + workspace: { + id: workspace.id, + }, + }); + } + } + + this.logger.log( + `Successfully enqueued ${workspaces.length} trash cleanup job(s)`, + ); + } + + private async getActiveWorkspaces(): Promise< + Array<{ id: string; trashRetentionDays: number }> + > { + const workspaces = await this.workspaceRepository.find({ + where: { + activationStatus: WorkspaceActivationStatus.ACTIVE, + }, + select: ['id', 'trashRetentionDays'], + order: { id: 'ASC' }, + }); + + if (workspaces.length === 0) { + return []; + } + + return workspaces.map((workspace) => ({ + id: workspace.id, + trashRetentionDays: workspace.trashRetentionDays, + })); + } +} diff --git a/packages/twenty-server/src/engine/trash-cleanup/jobs/trash-cleanup.job.ts b/packages/twenty-server/src/engine/trash-cleanup/jobs/trash-cleanup.job.ts new file mode 100644 index 0000000000000..b5b7200632c5e --- /dev/null +++ b/packages/twenty-server/src/engine/trash-cleanup/jobs/trash-cleanup.job.ts @@ -0,0 +1,37 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator'; +import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator'; +import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; +import { TrashCleanupService } from 'src/engine/trash-cleanup/services/trash-cleanup.service'; + +export type TrashCleanupJobData = { + workspaceId: string; + trashRetentionDays: number; +}; + +@Injectable() +@Processor(MessageQueue.workspaceQueue) +export class TrashCleanupJob { + private readonly logger = new Logger(TrashCleanupJob.name); + + constructor(private readonly trashCleanupService: TrashCleanupService) {} + + @Process(TrashCleanupJob.name) + async handle(data: TrashCleanupJobData): Promise { + const { workspaceId, trashRetentionDays } = data; + + try { + await this.trashCleanupService.cleanupWorkspaceTrash({ + workspaceId, + trashRetentionDays, + }); + } catch (error) { + this.logger.error( + `Trash cleanup failed for workspace ${workspaceId}`, + error instanceof Error ? error.stack : String(error), + ); + throw error; + } + } +} diff --git a/packages/twenty-server/src/engine/trash-cleanup/services/__tests__/trash-cleanup.service.spec.ts b/packages/twenty-server/src/engine/trash-cleanup/services/__tests__/trash-cleanup.service.spec.ts new file mode 100644 index 0000000000000..b597dc8d78f79 --- /dev/null +++ b/packages/twenty-server/src/engine/trash-cleanup/services/__tests__/trash-cleanup.service.spec.ts @@ -0,0 +1,208 @@ +import { Test, type TestingModule } from '@nestjs/testing'; + +import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; +import { TrashCleanupService } from 'src/engine/trash-cleanup/services/trash-cleanup.service'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; + +describe('TrashCleanupService', () => { + let service: TrashCleanupService; + let mockFlatEntityMapsCacheService: any; + let mockTwentyORMGlobalManager: any; + + beforeEach(async () => { + mockFlatEntityMapsCacheService = { + getOrRecomputeManyOrAllFlatEntityMaps: jest.fn(), + }; + + mockTwentyORMGlobalManager = { + getRepositoryForWorkspace: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TrashCleanupService, + { + provide: WorkspaceManyOrAllFlatEntityMapsCacheService, + useValue: mockFlatEntityMapsCacheService, + }, + { + provide: TwentyORMGlobalManager, + useValue: mockTwentyORMGlobalManager, + }, + ], + }).compile(); + + service = module.get(TrashCleanupService); + + // Suppress logger output in tests + jest.spyOn(service['logger'], 'log').mockImplementation(); + jest.spyOn(service['logger'], 'error').mockImplementation(); + + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('cleanupWorkspaceTrash', () => { + const createRepositoryMock = (name: string, initialCount: number) => { + let remaining = initialCount; + let counter = 0; + + return { + find: jest.fn().mockImplementation(({ take }) => { + const amount = Math.min(take ?? remaining, remaining); + const records = Array.from({ length: amount }, () => ({ + id: `${name}-${counter++}`, + })); + + remaining -= amount; + + return Promise.resolve(records); + }), + delete: jest.fn().mockResolvedValue(undefined), + }; + }; + + const setObjectMetadataCache = ( + entries: Array<{ id: string; nameSingular: string }>, + ) => { + const byId = entries.reduce>( + (acc, { id, nameSingular }) => { + acc[id] = { + id, + nameSingular, + }; + + return acc; + }, + {}, + ); + + mockFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps.mockResolvedValue( + { + flatObjectMetadataMaps: { + byId, + idByUniversalIdentifier: {}, + }, + }, + ); + }; + + it('should return deleted count when cleanup succeeds', async () => { + setObjectMetadataCache([ + { id: 'obj-company', nameSingular: 'company' }, + { id: 'obj-person', nameSingular: 'person' }, + ]); + + const companyRepository = createRepositoryMock('company', 2); + const personRepository = createRepositoryMock('person', 1); + + mockTwentyORMGlobalManager.getRepositoryForWorkspace + .mockResolvedValueOnce(companyRepository) + .mockResolvedValueOnce(personRepository); + + const result = await service.cleanupWorkspaceTrash({ + workspaceId: 'workspace-id', + trashRetentionDays: 14, + }); + + expect(result).toEqual(3); + expect(companyRepository.find).toHaveBeenCalled(); + expect(personRepository.find).toHaveBeenCalled(); + expect(companyRepository.delete).toHaveBeenCalledTimes(1); + expect(personRepository.delete).toHaveBeenCalledTimes(1); + + const findArgs = companyRepository.find.mock.calls[0][0]; + + expect(findArgs.withDeleted).toBe(true); + expect(findArgs.order).toEqual({ deletedAt: 'ASC' }); + }); + + it('should return zero when no objects are found', async () => { + setObjectMetadataCache([]); + + const result = await service.cleanupWorkspaceTrash({ + workspaceId: 'workspace-id', + trashRetentionDays: 14, + }); + + expect(result).toEqual(0); + expect( + mockTwentyORMGlobalManager.getRepositoryForWorkspace, + ).not.toHaveBeenCalled(); + }); + + it('should respect max records limit across objects', async () => { + (service as any).maxRecordsPerWorkspace = 3; + (service as any).batchSize = 3; + setObjectMetadataCache([ + { id: 'obj-company', nameSingular: 'company' }, + { id: 'obj-person', nameSingular: 'person' }, + ]); + + const companyRepository = createRepositoryMock('company', 2); + const personRepository = createRepositoryMock('person', 5); + + mockTwentyORMGlobalManager.getRepositoryForWorkspace + .mockResolvedValueOnce(companyRepository) + .mockResolvedValueOnce(personRepository); + + const result = await service.cleanupWorkspaceTrash({ + workspaceId: 'workspace-id', + trashRetentionDays: 14, + }); + + expect(result).toEqual(3); + expect(companyRepository.delete).toHaveBeenCalledTimes(1); + expect(personRepository.delete).toHaveBeenCalledTimes(1); + const personDeleteArgs = personRepository.delete.mock.calls[0][0]; + const deletedIds = + personDeleteArgs.id._value ?? personDeleteArgs.id.value; + + expect(deletedIds).toHaveLength(1); + expect(personRepository.find).toHaveBeenCalledTimes(1); + }); + + it('should ignore objects without soft deleted records', async () => { + setObjectMetadataCache([{ id: 'obj-company', nameSingular: 'company' }]); + + const companyRepository = createRepositoryMock('company', 0); + + mockTwentyORMGlobalManager.getRepositoryForWorkspace.mockResolvedValueOnce( + companyRepository, + ); + + const result = await service.cleanupWorkspaceTrash({ + workspaceId: 'workspace-id', + trashRetentionDays: 14, + }); + + expect(result).toEqual(0); + expect(companyRepository.delete).not.toHaveBeenCalled(); + }); + + it('should delete records across multiple batches', async () => { + setObjectMetadataCache([{ id: 'obj-company', nameSingular: 'company' }]); + + const companyRepository = createRepositoryMock('company', 5); + + mockTwentyORMGlobalManager.getRepositoryForWorkspace.mockResolvedValueOnce( + companyRepository, + ); + + (service as any).batchSize = 2; + (service as any).maxRecordsPerWorkspace = 10; + + const result = await service.cleanupWorkspaceTrash({ + workspaceId: 'workspace-id', + trashRetentionDays: 14, + }); + + expect(result).toEqual(5); + expect(companyRepository.find).toHaveBeenCalledTimes(4); + expect(companyRepository.delete).toHaveBeenCalledTimes(3); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/trash-cleanup/services/trash-cleanup.service.ts b/packages/twenty-server/src/engine/trash-cleanup/services/trash-cleanup.service.ts new file mode 100644 index 0000000000000..1d044f75f7bb1 --- /dev/null +++ b/packages/twenty-server/src/engine/trash-cleanup/services/trash-cleanup.service.ts @@ -0,0 +1,146 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { isDefined } from 'twenty-shared/utils'; +import { In, LessThan } from 'typeorm'; + +import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; +import { + TRASH_CLEANUP_BATCH_SIZE, + TRASH_CLEANUP_MAX_RECORDS_PER_WORKSPACE, +} from 'src/engine/trash-cleanup/constants/trash-cleanup.constants'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; + +export type TrashCleanupInput = { + workspaceId: string; + trashRetentionDays: number; +}; + +@Injectable() +export class TrashCleanupService { + private readonly logger = new Logger(TrashCleanupService.name); + private readonly maxRecordsPerWorkspace = + TRASH_CLEANUP_MAX_RECORDS_PER_WORKSPACE; + private readonly batchSize = TRASH_CLEANUP_BATCH_SIZE; + + constructor( + private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, + private readonly twentyORMGlobalManager: TwentyORMGlobalManager, + ) {} + + async cleanupWorkspaceTrash(input: TrashCleanupInput): Promise { + const { workspaceId, trashRetentionDays } = input; + + const { flatObjectMetadataMaps } = + await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatObjectMetadataMaps'], + }, + ); + + const objectNames = Object.values(flatObjectMetadataMaps.byId ?? {}) + .map((metadata) => metadata?.nameSingular) + .filter(isDefined); + + if (objectNames.length === 0) { + this.logger.log(`No objects found in workspace ${workspaceId}`); + + return 0; + } + + const cutoffDate = this.calculateCutoffDate(trashRetentionDays); + let deletedCount = 0; + + for (const objectName of objectNames) { + if (deletedCount >= this.maxRecordsPerWorkspace) { + this.logger.log( + `Reached deletion limit (${this.maxRecordsPerWorkspace}) for workspace ${workspaceId}`, + ); + break; + } + + const remainingQuota = this.maxRecordsPerWorkspace - deletedCount; + const deletedForObject = await this.deleteSoftDeletedRecords({ + workspaceId, + objectName, + cutoffDate, + remainingQuota, + }); + + if (deletedForObject > 0) { + this.logger.log( + `Deleted ${deletedForObject} record(s) from ${objectName} in workspace ${workspaceId}`, + ); + } + + deletedCount += deletedForObject; + } + + this.logger.log( + `Deleted ${deletedCount} record(s) from workspace ${workspaceId}`, + ); + + return deletedCount; + } + + private async deleteSoftDeletedRecords({ + workspaceId, + objectName, + cutoffDate, + remainingQuota, + }: { + workspaceId: string; + objectName: string; + cutoffDate: Date; + remainingQuota: number; + }): Promise { + if (remainingQuota <= 0) { + return 0; + } + + const repository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, + objectName, + { shouldBypassPermissionChecks: true }, + ); + + let deleted = 0; + + while (deleted < remainingQuota) { + const take = Math.min(this.batchSize, remainingQuota - deleted); + + const recordsToDelete = await repository.find({ + withDeleted: true, + select: ['id'], + where: { + deletedAt: LessThan(cutoffDate), + }, + order: { deletedAt: 'ASC' }, + take, + loadEagerRelations: false, + }); + + if (recordsToDelete.length === 0) { + break; + } + + await repository.delete({ + id: In(recordsToDelete.map((record) => record.id)), + }); + + deleted += recordsToDelete.length; + } + + return deleted; + } + + private calculateCutoffDate(trashRetentionDays: number): Date { + const cutoffDate = new Date(); + + cutoffDate.setUTCHours(0, 0, 0, 0); + cutoffDate.setDate(cutoffDate.getDate() - trashRetentionDays + 1); + + return cutoffDate; + } +} diff --git a/packages/twenty-server/src/engine/trash-cleanup/trash-cleanup.module.ts b/packages/twenty-server/src/engine/trash-cleanup/trash-cleanup.module.ts new file mode 100644 index 0000000000000..477cdd228b0dd --- /dev/null +++ b/packages/twenty-server/src/engine/trash-cleanup/trash-cleanup.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; +import { TrashCleanupCronCommand } from 'src/engine/trash-cleanup/commands/trash-cleanup.cron.command'; +import { TrashCleanupCronJob } from 'src/engine/trash-cleanup/crons/trash-cleanup.cron.job'; +import { TrashCleanupJob } from 'src/engine/trash-cleanup/jobs/trash-cleanup.job'; +import { TrashCleanupService } from 'src/engine/trash-cleanup/services/trash-cleanup.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Workspace]), + WorkspaceManyOrAllFlatEntityMapsCacheModule, + ], + providers: [ + TrashCleanupService, + TrashCleanupJob, + TrashCleanupCronJob, + TrashCleanupCronCommand, + ], + exports: [TrashCleanupCronCommand], +}) +export class TrashCleanupModule {}