From 8299488f21eaaaafbd8320c9e01370eaa27012ee Mon Sep 17 00:00:00 2001 From: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Date: Mon, 24 Nov 2025 14:39:04 +0100 Subject: [PATCH] Fix front data model edition + non nullable workspaceCustom application migration (#16016) # Introduction Two things: - Enforcing non nullable workspace custom application Id for any workspace - Fixing front non editable data models following https://github.com/twentyhq/twenty/pull/15911 that associate any custom entities to an applicationId. The front was putting everything as readonly when under an app ( we will have to handle the twenty standard application in the future too ) ## Fallback ### Migration The non nullable migration will fail when released, that's why it's being swallowed and re-run in an upgrade command post workspace custom application creation for those that miss one. Allowing the migration to pass in the end The typeorm migration still need to exists for any new workspaces ### GetCurrentUser In order to dynamically display isReadOnly in data model settings we're fetching the workspaceCustomApplicationId through the `getCurrentUser` If not fallback this endpoint would throw until we're handling existing workspaces that do not have a custom workspace application The fallback should be removed post release --- .../src/generated-metadata/graphql.ts | 11 +-- .../twenty-front/src/generated/graphql.ts | 3 +- .../services/__tests__/apollo.factory.test.ts | 3 + .../auth/states/currentWorkspaceState.ts | 7 +- ...mWorkspaceApplicationMock.test.constant.ts | 14 ++++ ...olumnDefinitionsFromObjectMetadata.test.ts | 4 ++ .../isObjectMetadataSettingsReadOnly.test.ts | 15 ++++ .../utils/isObjectMetadataSettingsReadOnly.ts | 12 +++- .../components/SettingsItemTypeTag.tsx | 11 ++- .../SettingsObjectFieldItemTableRow.tsx | 6 +- ...SettingsUpdateDataModelObjectAboutForm.tsx | 10 ++- .../components/tabs/ObjectFields.tsx | 5 ++ .../components/tabs/ObjectSettings.tsx | 11 ++- ...SettingsDataModelObjectIdentifiersForm.tsx | 9 ++- .../data-model/utils/getItemTagInfo.ts | 19 +++-- .../components/UserAndViewsProviderEffect.tsx | 3 + .../graphql/fragments/userQueryFragment.ts | 4 +- .../modules/users/hooks/useLoadCurrentUser.ts | 8 ++- .../data-model/SettingsObjectDetailPage.tsx | 8 ++- .../data-model/SettingsObjectFieldEdit.tsx | 12 +++- .../data-model/SettingsObjectTable.tsx | 15 +++- .../src/testing/mock-data/users.ts | 7 +- .../1-12-upgrade-version-command.module.ts | 3 + ...ation-id-non-nullable-migration.command.ts | 72 +++++++++++++++++++ .../upgrade.command.ts | 11 +-- ...pace-custom-application-id-non-nullable.ts | 39 ++++++++++ .../application/application.entity.ts | 1 + .../application/application.service.ts | 24 ++++--- .../application/dtos/application.dto.ts | 22 ++++-- ...lat-application-to-application-dto.util.ts | 23 ++++++ .../workspace/workspace.entity.ts | 7 +- .../workspace/workspace.module.ts | 2 + .../workspace/workspace.resolver.ts | 25 +++++++ 33 files changed, 368 insertions(+), 58 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-metadata/hooks/__tests__/constants/CustomWorkspaceApplicationMock.test.constant.ts create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/1-12/1-12-workspace-custom-application-id-non-nullable-migration.command.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1763977334519-workspace-custom-application-id-non-nullable.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/utils/from-flat-application-to-application-dto.util.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 1d171ff460..b972a5ed51 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -4828,7 +4828,8 @@ export type Workspace = { viewGroups?: Maybe>; viewSorts?: Maybe>; views?: Maybe>; - workspaceCustomApplicationId?: Maybe; + workspaceCustomApplication?: Maybe; + workspaceCustomApplicationId: Scalars['String']; workspaceMembersCount?: Maybe; workspaceUrls: WorkspaceUrls; }; @@ -5973,7 +5974,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, hasPassword: boolean, 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', id: string, 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, isGoogleAuthBypassEnabled: boolean, isMicrosoftAuthBypassEnabled: boolean, isPasswordAuthBypassEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, routerModel: string, isTwoFactorAuthenticationEnforced: boolean, trashRetentionDays: number, editableProfileFields?: Array | null, 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 } | 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, hasPassword: boolean, 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', id: string, 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, isGoogleAuthBypassEnabled: boolean, isMicrosoftAuthBypassEnabled: boolean, isPasswordAuthBypassEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, routerModel: string, isTwoFactorAuthenticationEnforced: boolean, trashRetentionDays: number, editableProfileFields?: Array | null, workspaceCustomApplication?: { __typename?: 'Application', id: string } | null, 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 } | 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 }; @@ -5999,7 +6000,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, hasPassword: boolean, 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', id: string, 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, isGoogleAuthBypassEnabled: boolean, isMicrosoftAuthBypassEnabled: boolean, isPasswordAuthBypassEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, routerModel: string, isTwoFactorAuthenticationEnforced: boolean, trashRetentionDays: number, editableProfileFields?: Array | null, 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 } | 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, hasPassword: boolean, 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', id: string, 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, isGoogleAuthBypassEnabled: boolean, isMicrosoftAuthBypassEnabled: boolean, isPasswordAuthBypassEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, routerModel: string, isTwoFactorAuthenticationEnforced: boolean, trashRetentionDays: number, editableProfileFields?: Array | null, workspaceCustomApplication?: { __typename?: 'Application', id: string } | null, 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 } | 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 }; @@ -6988,7 +6989,9 @@ export const UserQueryFragmentFragmentDoc = gql` isPasswordAuthBypassEnabled subdomain hasValidEnterpriseKey - customDomain + workspaceCustomApplication { + id + } isCustomDomainEnabled workspaceUrls { ...WorkspaceUrlsFragment diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 8be536d39b..4e1694ad96 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -4680,7 +4680,8 @@ export type Workspace = { viewGroups?: Maybe>; viewSorts?: Maybe>; views?: Maybe>; - workspaceCustomApplicationId?: Maybe; + workspaceCustomApplication?: Maybe; + workspaceCustomApplicationId: Scalars['String']; workspaceMembersCount?: Maybe; workspaceUrls: WorkspaceUrls; }; 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 2ff2143ee1..16d24d2903 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 @@ -1,6 +1,7 @@ import { ApolloError, gql, InMemoryCache } from '@apollo/client'; import fetchMock, { enableFetchMocks } from 'jest-fetch-mock'; +import { CUSTOM_WORKSPACE_APPLICATION_MOCK } from '@/object-metadata/hooks/__tests__/constants/CustomWorkspaceApplicationMock.test.constant'; import { WorkspaceActivationStatus } from '~/generated/graphql'; import { ApolloFactory, type Options } from '../apollo.factory'; @@ -62,6 +63,8 @@ const mockWorkspace = { isTwoFactorAuthenticationEnforced: false, trashRetentionDays: 14, routerModel: 'auto', + workspaceCustomApplication: CUSTOM_WORKSPACE_APPLICATION_MOCK, + workspaceCustomApplicationId: CUSTOM_WORKSPACE_APPLICATION_MOCK.id, }; 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 e8d15079c5..92e9f34910 100644 --- a/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts +++ b/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts @@ -1,5 +1,9 @@ import { createState } from 'twenty-ui/utilities'; -import { type Role, type Workspace } from '~/generated/graphql'; +import { + type Application, + type Role, + type Workspace, +} from '~/generated/graphql'; export type CurrentWorkspace = Pick< Workspace, @@ -32,6 +36,7 @@ export type CurrentWorkspace = Pick< | 'editableProfileFields' > & { defaultRole?: Omit | null; + workspaceCustomApplication: Pick | null; }; export const currentWorkspaceState = createState({ diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/constants/CustomWorkspaceApplicationMock.test.constant.ts b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/constants/CustomWorkspaceApplicationMock.test.constant.ts new file mode 100644 index 0000000000..a8ac1d44ff --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/constants/CustomWorkspaceApplicationMock.test.constant.ts @@ -0,0 +1,14 @@ +import { type Application } from '~/generated/graphql'; + +export const CUSTOM_WORKSPACE_APPLICATION_MOCK = { + id: 'dc75f982-35a2-4c1b-a63d-bd1131215377', + agents: [], + applicationVariables: [], + canBeUninstalled: false, + description: 'workpace custom application', + name: 'custom', + objects: [], + serverlessFunctions: [], + universalIdentifier: '66a698b6-f6c1-4d35-a6e7-20aeadc3cd95', + version: '1.0.0', +} as const satisfies Application; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromObjectMetadata.test.ts b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromObjectMetadata.test.ts index 9e5e9870e6..6a4b87b173 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromObjectMetadata.test.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromObjectMetadata.test.ts @@ -1,6 +1,7 @@ import { renderHook } from '@testing-library/react'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { CUSTOM_WORKSPACE_APPLICATION_MOCK } from '@/object-metadata/hooks/__tests__/constants/CustomWorkspaceApplicationMock.test.constant'; import { useColumnDefinitionsFromObjectMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromObjectMetadata'; import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { @@ -17,6 +18,9 @@ const Wrapper = getJestMetadataAndApolloMocksAndActionMenuWrapper({ contextStoreCurrentObjectMetadataNameSingular: 'company', onInitializeRecoilSnapshot: ({ set }) => { set(currentWorkspaceState, { + workspaceCustomApplication: { + id: CUSTOM_WORKSPACE_APPLICATION_MOCK.id, + }, id: '1', featureFlags: [], allowImpersonation: false, diff --git a/packages/twenty-front/src/modules/object-record/read-only/utils/__tests__/isObjectMetadataSettingsReadOnly.test.ts b/packages/twenty-front/src/modules/object-record/read-only/utils/__tests__/isObjectMetadataSettingsReadOnly.test.ts index 5ba8306e0c..57a5fe227c 100644 --- a/packages/twenty-front/src/modules/object-record/read-only/utils/__tests__/isObjectMetadataSettingsReadOnly.test.ts +++ b/packages/twenty-front/src/modules/object-record/read-only/utils/__tests__/isObjectMetadataSettingsReadOnly.test.ts @@ -12,6 +12,7 @@ describe('isObjectMetadataSettingsReadOnly', () => { isUIReadOnly: false, isRemote: false, }, + workspaceCustomApplicationId: 'workspaceApplicationId', }); expect(result).toBe(false); @@ -29,8 +30,22 @@ describe('isObjectMetadataSettingsReadOnly', () => { isUIReadOnly: false, isRemote: false, }, + workspaceCustomApplicationId: null, }); expect(result).toBe(true); }); + + it('should return false if object is owned by workspace custom application', () => { + const result = isObjectMetadataSettingsReadOnly({ + objectMetadataItem: { + isUIReadOnly: false, + isRemote: false, + applicationId: 'workspaceApplicationId', + }, + workspaceCustomApplicationId: 'workspaceApplicationId', + }); + + expect(result).toBe(false); + }); }); diff --git a/packages/twenty-front/src/modules/object-record/read-only/utils/isObjectMetadataSettingsReadOnly.ts b/packages/twenty-front/src/modules/object-record/read-only/utils/isObjectMetadataSettingsReadOnly.ts index 7b8877271f..d3f339e71d 100644 --- a/packages/twenty-front/src/modules/object-record/read-only/utils/isObjectMetadataSettingsReadOnly.ts +++ b/packages/twenty-front/src/modules/object-record/read-only/utils/isObjectMetadataSettingsReadOnly.ts @@ -1,7 +1,7 @@ import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { type ObjectPermission } from '~/generated/graphql'; -import { isDefined } from 'twenty-shared/utils'; import { isObjectMetadataReadOnly } from '@/object-record/read-only/utils/isObjectMetadataReadOnly'; +import { isDefined } from 'twenty-shared/utils'; +import { type ObjectPermission } from '~/generated/graphql'; type IsObjectMetadataReadOnlyParams = { objectPermissions?: ObjectPermission; @@ -9,14 +9,20 @@ type IsObjectMetadataReadOnlyParams = { ObjectMetadataItem, 'isUIReadOnly' | 'isRemote' | 'applicationId' >; + workspaceCustomApplicationId: string | null; }; export const isObjectMetadataSettingsReadOnly = ({ objectPermissions, objectMetadataItem, + workspaceCustomApplicationId, }: IsObjectMetadataReadOnlyParams) => { return ( isObjectMetadataReadOnly({ objectPermissions, objectMetadataItem }) || - isDefined(objectMetadataItem?.applicationId) + (isDefined(objectMetadataItem?.applicationId) + ? isDefined(workspaceCustomApplicationId) + ? objectMetadataItem.applicationId !== workspaceCustomApplicationId + : true + : false) ); }; diff --git a/packages/twenty-front/src/modules/settings/components/SettingsItemTypeTag.tsx b/packages/twenty-front/src/modules/settings/components/SettingsItemTypeTag.tsx index 7a823af584..086249851f 100644 --- a/packages/twenty-front/src/modules/settings/components/SettingsItemTypeTag.tsx +++ b/packages/twenty-front/src/modules/settings/components/SettingsItemTypeTag.tsx @@ -1,5 +1,7 @@ -import { Tag } from 'twenty-ui/components'; +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { getItemTagInfo } from '@/settings/data-model/utils/getItemTagInfo'; +import { useRecoilValue } from 'recoil'; +import { Tag } from 'twenty-ui/components'; type SettingsItemTypeTagProps = { item: { @@ -14,7 +16,12 @@ export const SettingsItemTypeTag = ({ className, item: { isCustom, isRemote, applicationId }, }: SettingsItemTypeTagProps) => { - const itemTagInfo = getItemTagInfo({ isCustom, isRemote, applicationId }); + const currentWorkspace = useRecoilValue(currentWorkspaceState); + const itemTagInfo = getItemTagInfo({ + objectMetadataItem: { isCustom, isRemote, applicationId }, + workspaceCustomApplicationId: + currentWorkspace?.workspaceCustomApplication?.id, + }); return ( { - const readonly = isObjectMetadataSettingsReadOnly({ objectMetadataItem }); + const currentWorkspace = useRecoilValue(currentWorkspaceState); + const readonly = isObjectMetadataSettingsReadOnly({ + objectMetadataItem, + workspaceCustomApplicationId: + currentWorkspace?.workspaceCustomApplication?.id, + }); const navigate = useNavigateSettings(); const setUpdatedObjectNamePlural = useSetRecoilState( updatedObjectNamePluralState, diff --git a/packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/ObjectFields.tsx b/packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/ObjectFields.tsx index 2c0a160c08..6e0240a8e6 100644 --- a/packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/ObjectFields.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/ObjectFields.tsx @@ -1,3 +1,4 @@ +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { SettingsPath } from 'twenty-shared/types'; import { getSettingsPath } from 'twenty-shared/utils'; @@ -10,6 +11,7 @@ import { Button } from 'twenty-ui/input'; import { Section } from 'twenty-ui/layout'; import { UndecoratedLink } from 'twenty-ui/navigation'; import { isObjectMetadataSettingsReadOnly } from '@/object-record/read-only/utils/isObjectMetadataSettingsReadOnly'; +import { useRecoilValue } from 'recoil'; const StyledDiv = styled.div` display: flex; @@ -22,8 +24,11 @@ type ObjectFieldsProps = { }; export const ObjectFields = ({ objectMetadataItem }: ObjectFieldsProps) => { + const currentWorkspace = useRecoilValue(currentWorkspaceState); const readonly = isObjectMetadataSettingsReadOnly({ objectMetadataItem, + workspaceCustomApplicationId: + currentWorkspace?.workspaceCustomApplication?.id, }); const { t } = useLingui(); diff --git a/packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/ObjectSettings.tsx b/packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/ObjectSettings.tsx index 4ce80f5450..44b2338aaa 100644 --- a/packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/ObjectSettings.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/ObjectSettings.tsx @@ -1,16 +1,18 @@ import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem'; +import { isObjectMetadataSettingsReadOnly } from '@/object-record/read-only/utils/isObjectMetadataSettingsReadOnly'; import { SettingsUpdateDataModelObjectAboutForm } from '@/settings/data-model/object-details/components/SettingsUpdateDataModelObjectAboutForm'; import { SettingsDataModelObjectSettingsFormCard } from '@/settings/data-model/objects/forms/components/SettingsDataModelObjectSettingsFormCard'; import styled from '@emotion/styled'; import { useLingui } from '@lingui/react/macro'; +import { useRecoilValue } from 'recoil'; import { SettingsPath } from 'twenty-shared/types'; import { H2Title, IconArchive } from 'twenty-ui/display'; import { Button } from 'twenty-ui/input'; import { Section } from 'twenty-ui/layout'; import { useNavigateSettings } from '~/hooks/useNavigateSettings'; -import { isObjectMetadataSettingsReadOnly } from '@/object-record/read-only/utils/isObjectMetadataSettingsReadOnly'; type ObjectSettingsProps = { objectMetadataItem: ObjectMetadataItem; @@ -28,7 +30,12 @@ const StyledFormSection = styled(Section)` export const ObjectSettings = ({ objectMetadataItem }: ObjectSettingsProps) => { const { t } = useLingui(); - const readonly = isObjectMetadataSettingsReadOnly({ objectMetadataItem }); + const currentWorkspace = useRecoilValue(currentWorkspaceState); + const readonly = isObjectMetadataSettingsReadOnly({ + objectMetadataItem, + workspaceCustomApplicationId: + currentWorkspace?.workspaceCustomApplication?.id, + }); const navigate = useNavigateSettings(); const { updateOneObjectMetadataItem } = useUpdateOneObjectMetadataItem(); diff --git a/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectIdentifiersForm.tsx b/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectIdentifiersForm.tsx index e115aa7aae..47d722c030 100644 --- a/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectIdentifiersForm.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectIdentifiersForm.tsx @@ -1,3 +1,4 @@ +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem'; import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { getActiveFieldMetadataItems } from '@/object-metadata/utils/getActiveFieldMetadataItems'; @@ -9,6 +10,7 @@ import { t } from '@lingui/core/macro'; import { useMemo } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { useNavigate } from 'react-router-dom'; +import { useRecoilValue } from 'recoil'; import { isLabelIdentifierFieldMetadataTypes } from 'twenty-shared/utils'; import { IconCircleOff, IconPlus, useIcons } from 'twenty-ui/display'; import { type SelectOption } from 'twenty-ui/input'; @@ -42,7 +44,12 @@ const StyledContainer = styled.div` export const SettingsDataModelObjectIdentifiersForm = ({ objectMetadataItem, }: SettingsDataModelObjectIdentifiersFormProps) => { - const readonly = isObjectMetadataSettingsReadOnly({ objectMetadataItem }); + const currentWorkspace = useRecoilValue(currentWorkspaceState); + const readonly = isObjectMetadataSettingsReadOnly({ + objectMetadataItem, + workspaceCustomApplicationId: + currentWorkspace?.workspaceCustomApplication?.id, + }); const formConfig = useForm({ mode: 'onTouched', resolver: zodResolver(settingsDataModelObjectIdentifiersFormSchema), diff --git a/packages/twenty-front/src/modules/settings/data-model/utils/getItemTagInfo.ts b/packages/twenty-front/src/modules/settings/data-model/utils/getItemTagInfo.ts index 36c6cb6ce4..a07208003c 100644 --- a/packages/twenty-front/src/modules/settings/data-model/utils/getItemTagInfo.ts +++ b/packages/twenty-front/src/modules/settings/data-model/utils/getItemTagInfo.ts @@ -27,15 +27,20 @@ type ManagedItemTagInfo = { }; export const getItemTagInfo = ({ - isCustom, - isRemote, - applicationId, + objectMetadataItem: { isCustom, isRemote, applicationId }, + workspaceCustomApplicationId, }: { - isCustom?: boolean; - isRemote?: boolean; - applicationId?: string | null; + objectMetadataItem: { + isCustom?: boolean; + isRemote?: boolean; + applicationId?: string | null; + }; + workspaceCustomApplicationId?: string; }): ItemTagInfo => { - if (isDefined(applicationId)) { + if ( + isDefined(applicationId) && + applicationId !== workspaceCustomApplicationId + ) { return { labelText: 'Managed', labelColor: 'sky' }; } diff --git a/packages/twenty-front/src/modules/users/components/UserAndViewsProviderEffect.tsx b/packages/twenty-front/src/modules/users/components/UserAndViewsProviderEffect.tsx index be4dc159b4..e4158c6e00 100644 --- a/packages/twenty-front/src/modules/users/components/UserAndViewsProviderEffect.tsx +++ b/packages/twenty-front/src/modules/users/components/UserAndViewsProviderEffect.tsx @@ -118,6 +118,9 @@ export const UserAndViewsProviderEffect = () => { ...userQueryData.currentUser.currentWorkspace, defaultRole: userQueryData.currentUser.currentWorkspace.defaultRole ?? null, + workspaceCustomApplication: + userQueryData.currentUser.currentWorkspace + .workspaceCustomApplication ?? null, }); } 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 f7c12e8e29..5ef403ab6b 100644 --- a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts +++ b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts @@ -60,7 +60,9 @@ export const USER_QUERY_FRAGMENT = gql` isPasswordAuthBypassEnabled subdomain hasValidEnterpriseKey - customDomain + workspaceCustomApplication { + id + } isCustomDomainEnabled workspaceUrls { ...WorkspaceUrlsFragment diff --git a/packages/twenty-front/src/modules/users/hooks/useLoadCurrentUser.ts b/packages/twenty-front/src/modules/users/hooks/useLoadCurrentUser.ts index b201b96899..3fd0288e9f 100644 --- a/packages/twenty-front/src/modules/users/hooks/useLoadCurrentUser.ts +++ b/packages/twenty-front/src/modules/users/hooks/useLoadCurrentUser.ts @@ -107,7 +107,13 @@ export const useLoadCurrentUser = () => { ); } - const workspace = user.currentWorkspace ?? null; + const workspace = isDefined(user.currentWorkspace) + ? { + ...user.currentWorkspace, + workspaceCustomApplication: + user.currentWorkspace.workspaceCustomApplication ?? null, + } + : null; setCurrentWorkspace(workspace); diff --git a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectDetailPage.tsx b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectDetailPage.tsx index bd686c4bc0..7ebbabc062 100644 --- a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectDetailPage.tsx +++ b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectDetailPage.tsx @@ -14,6 +14,7 @@ import styled from '@emotion/styled'; import { useRecoilState, useRecoilValue } from 'recoil'; import { AppPath, SettingsPath } from 'twenty-shared/types'; +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { isObjectMetadataSettingsReadOnly } from '@/object-record/read-only/utils/isObjectMetadataSettingsReadOnly'; import { SettingsItemTypeTag } from '@/settings/components/SettingsItemTypeTag'; import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState'; @@ -67,7 +68,12 @@ export const SettingsObjectDetailPage = () => { findObjectMetadataItemByNamePlural(objectNamePlural) ?? findObjectMetadataItemByNamePlural(updatedObjectNamePlural); - const readonly = isObjectMetadataSettingsReadOnly({ objectMetadataItem }); + const currentWorkspace = useRecoilValue(currentWorkspaceState); + const readonly = isObjectMetadataSettingsReadOnly({ + objectMetadataItem, + workspaceCustomApplicationId: + currentWorkspace?.workspaceCustomApplication?.id, + }); const activeTabId = useRecoilComponentValue( activeTabIdComponentState, diff --git a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx index 2b067240af..4be7821506 100644 --- a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx +++ b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx @@ -5,6 +5,7 @@ import { FormProvider, useForm } from 'react-hook-form'; import { useNavigate, useParams } from 'react-router-dom'; import { type z } from 'zod'; +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem'; import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems'; import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata'; @@ -12,6 +13,7 @@ import { useUpdateOneFieldMetadataItem } from '@/object-metadata/hooks/useUpdate import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural'; import { formatFieldMetadataItemInput } from '@/object-metadata/utils/formatFieldMetadataItemInput'; import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField'; +import { isObjectMetadataSettingsReadOnly } from '@/object-record/read-only/utils/isObjectMetadataSettingsReadOnly'; import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { FIELD_NAME_MAXIMUM_LENGTH } from '@/settings/data-model/constants/FieldNameMaximumLength'; @@ -24,7 +26,7 @@ import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBa import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState'; import { shouldNavigateBackToMemorizedUrlOnSaveState } from '@/ui/navigation/states/shouldNavigateBackToMemorizedUrlOnSaveState'; import { useLingui } from '@lingui/react/macro'; -import { useRecoilState } from 'recoil'; +import { useRecoilState, useRecoilValue } from 'recoil'; import { AppPath, SettingsPath } from 'twenty-shared/types'; import { getSettingsPath, isDefined } from 'twenty-shared/utils'; import { H2Title, IconArchive, IconArchiveOff } from 'twenty-ui/display'; @@ -33,7 +35,6 @@ import { Section } from 'twenty-ui/layout'; import { FieldMetadataType } from '~/generated-metadata/graphql'; import { useNavigateApp } from '~/hooks/useNavigateApp'; import { useNavigateSettings } from '~/hooks/useNavigateSettings'; -import { isObjectMetadataSettingsReadOnly } from '@/object-record/read-only/utils/isObjectMetadataSettingsReadOnly'; //TODO: fix this type export type SettingsDataModelFieldEditFormValues = z.infer< @@ -64,7 +65,12 @@ export const SettingsObjectFieldEdit = () => { const objectMetadataItem = findObjectMetadataItemByNamePlural(objectNamePlural); - const readonly = isObjectMetadataSettingsReadOnly({ objectMetadataItem }); + const currentWorkspace = useRecoilValue(currentWorkspaceState); + const readonly = isObjectMetadataSettingsReadOnly({ + objectMetadataItem, + workspaceCustomApplicationId: + currentWorkspace?.workspaceCustomApplication?.id, + }); const { deactivateMetadataField, activateMetadataField } = useFieldMetadataItem(); diff --git a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectTable.tsx b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectTable.tsx index 5b103097ef..7320a2ef0e 100644 --- a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectTable.tsx +++ b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectTable.tsx @@ -1,3 +1,4 @@ +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { useDeleteOneObjectMetadataItem } from '@/object-metadata/hooks/useDeleteOneObjectMetadataItem'; import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem'; import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; @@ -80,6 +81,8 @@ export const SettingsObjectTable = ({ }, ); + const currentWorkspace = useRecoilValue(currentWorkspaceState); + const allObjectSettingsArray = useMemo( () => objectMetadataItems.map( @@ -87,7 +90,11 @@ export const SettingsObjectTable = ({ ({ objectMetadataItem, labelPlural: objectMetadataItem.labelPlural, - objectTypeLabel: getItemTagInfo(objectMetadataItem).labelText, + objectTypeLabel: getItemTagInfo({ + objectMetadataItem, + workspaceCustomApplicationId: + currentWorkspace?.workspaceCustomApplication?.id, + }).labelText, fieldsCount: objectMetadataItem.fields.filter( (field) => !field.isSystem, ).length, @@ -97,7 +104,11 @@ export const SettingsObjectTable = ({ ] ?? 0, }) satisfies SettingsObjectTableItem, ), - [objectMetadataItems, totalCountByObjectMetadataItemNamePlural], + [ + objectMetadataItems, + totalCountByObjectMetadataItemNamePlural, + currentWorkspace, + ], ); const sortedObjectSettingsItems = useSortedArray( diff --git a/packages/twenty-front/src/testing/mock-data/users.ts b/packages/twenty-front/src/testing/mock-data/users.ts index 43c4c84159..88012e285e 100644 --- a/packages/twenty-front/src/testing/mock-data/users.ts +++ b/packages/twenty-front/src/testing/mock-data/users.ts @@ -1,4 +1,5 @@ import { type CurrentUserWorkspace } from '@/auth/states/currentUserWorkspaceState'; +import { CUSTOM_WORKSPACE_APPLICATION_MOCK } from '@/object-metadata/hooks/__tests__/constants/CustomWorkspaceApplicationMock.test.constant'; import { type WorkspaceMember } from '@/workspace-member/types/WorkspaceMember'; import { FeatureFlagKey, @@ -54,7 +55,9 @@ const PRO_METERED_MONTHLY_PRICE = PRO_METERED_PRODUCT?.prices?.find( (pr) => pr.recurringInterval === 'Month', )!; -export const mockCurrentWorkspace: Workspace = { +export const mockCurrentWorkspace = { + workspaceCustomApplication: CUSTOM_WORKSPACE_APPLICATION_MOCK, + workspaceCustomApplicationId: CUSTOM_WORKSPACE_APPLICATION_MOCK.id, subdomain: 'acme.twenty.com', id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6w', displayName: 'Twenty', @@ -158,7 +161,7 @@ export const mockCurrentWorkspace: Workspace = { databaseUrl: '', isTwoFactorAuthenticationEnforced: false, __typename: 'Workspace', -}; +} as const satisfies Workspace; export const mockedWorkspaceMemberData: WorkspaceMember = { __typename: 'WorkspaceMember', diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-12/1-12-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-12/1-12-upgrade-version-command.module.ts index 84059fb5ed..03ec7b862c 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/1-12/1-12-upgrade-version-command.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-12/1-12-upgrade-version-command.module.ts @@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { CreateWorkspaceCustomApplicationCommand } from 'src/database/commands/upgrade-version-command/1-12/1-12-create-workspace-custom-application.command'; import { SetStandardApplicationNotUninstallableCommand } from 'src/database/commands/upgrade-version-command/1-12/1-12-set-standard-application-not-uninstallable.command'; +import { WorkspaceCustomApplicationIdNonNullableCommand } from 'src/database/commands/upgrade-version-command/1-12/1-12-workspace-custom-application-id-non-nullable-migration.command'; import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { WorkspaceSchemaManagerModule } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.module'; @@ -16,10 +17,12 @@ import { WorkspaceSchemaManagerModule } from 'src/engine/twenty-orm/workspace-sc providers: [ CreateWorkspaceCustomApplicationCommand, SetStandardApplicationNotUninstallableCommand, + WorkspaceCustomApplicationIdNonNullableCommand, ], exports: [ CreateWorkspaceCustomApplicationCommand, SetStandardApplicationNotUninstallableCommand, + WorkspaceCustomApplicationIdNonNullableCommand, ], }) export class V1_12_UpgradeVersionCommandModule {} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-12/1-12-workspace-custom-application-id-non-nullable-migration.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-12/1-12-workspace-custom-application-id-non-nullable-migration.command.ts new file mode 100644 index 0000000000..226f112a36 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-12/1-12-workspace-custom-application-id-non-nullable-migration.command.ts @@ -0,0 +1,72 @@ +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; + +import { Command } from 'nest-commander'; +import { DataSource, Repository } from 'typeorm'; + +import { + ActiveOrSuspendedWorkspacesMigrationCommandRunner, + type RunOnWorkspaceArgs, +} from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; + +@Command({ + name: '1-12-workspace-custom-application-id-non-nullable-migration', + description: 'Create FK_3b1acb13a5dac9956d1a4b32755 foreign key', +}) +export class WorkspaceCustomApplicationIdNonNullableCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner { + private hasRunOnce = false; + + constructor( + @InjectRepository(WorkspaceEntity) + protected readonly workspaceRepository: Repository, + protected readonly twentyORMGlobalManager: TwentyORMGlobalManager, + @InjectDataSource() + private readonly coreDataSource: DataSource, + ) { + super(workspaceRepository, twentyORMGlobalManager); + } + + override async runOnWorkspace({ + options, + }: RunOnWorkspaceArgs): Promise { + if (this.hasRunOnce) { + this.logger.log( + 'Skipping has already been run once WorkspaceCustomApplicationIdNonNullableCommand', + ); + + return; + } + + const queryRunner = this.coreDataSource.createQueryRunner(); + + await queryRunner.connect(); + await queryRunner.startTransaction(); + if (!options.dryRun) { + try { + await queryRunner.query( + `ALTER TABLE "core"."workspace" DROP CONSTRAINT "FK_3b1acb13a5dac9956d1a4b32755"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."workspace" ALTER COLUMN "workspaceCustomApplicationId" SET NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "core"."workspace" ADD CONSTRAINT "FK_3b1acb13a5dac9956d1a4b32755" FOREIGN KEY ("workspaceCustomApplicationId") REFERENCES "core"."application"("id") ON DELETE RESTRICT ON UPDATE NO ACTION`, + ); + + await queryRunner.commitTransaction(); + this.logger.log( + 'Successfully run WorkspaceCustomApplicationIdNonNullableCommand', + ); + this.hasRunOnce = true; + } catch (error) { + await queryRunner.rollbackTransaction(); + this.logger.log( + `Rollbacking WorkspaceCustomApplicationIdNonNullableCommand: ${error.message}`, + ); + } finally { + await queryRunner.release(); + } + } + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts index 0d930992a6..08d5aedac2 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts @@ -23,6 +23,7 @@ import { CleanOrphanedUserWorkspacesCommand } from 'src/database/commands/upgrad import { CreateTwentyStandardApplicationCommand } from 'src/database/commands/upgrade-version-command/1-11/1-11-create-twenty-standard-application.command'; import { CreateWorkspaceCustomApplicationCommand } from 'src/database/commands/upgrade-version-command/1-12/1-12-create-workspace-custom-application.command'; import { SetStandardApplicationNotUninstallableCommand } from 'src/database/commands/upgrade-version-command/1-12/1-12-set-standard-application-not-uninstallable.command'; +import { WorkspaceCustomApplicationIdNonNullableCommand } from 'src/database/commands/upgrade-version-command/1-12/1-12-workspace-custom-application-id-non-nullable-migration.command'; import { FixLabelIdentifierPositionAndVisibilityCommand } from 'src/database/commands/upgrade-version-command/1-6/1-6-fix-label-identifier-position-and-visibility.command'; import { BackfillWorkflowManualTriggerAvailabilityCommand } from 'src/database/commands/upgrade-version-command/1-7/1-7-backfill-workflow-manual-trigger-availability.command'; import { DeduplicateUniqueFieldsCommand } from 'src/database/commands/upgrade-version-command/1-8/1-8-deduplicate-unique-fields.command'; @@ -79,9 +80,10 @@ export class UpgradeCommand extends UpgradeCommandRunner { protected readonly seedStandardApplicationsCommand: CreateTwentyStandardApplicationCommand, // 1.12 Commands - protected readonlysetStandardApplicationNotUninstallableCommand: SetStandardApplicationNotUninstallableCommand, + protected readonly setStandardApplicationNotUninstallableCommand: SetStandardApplicationNotUninstallableCommand, protected readonly createTwentyStandardApplicationCommand: CreateTwentyStandardApplicationCommand, protected readonly createWorkspaceCustomApplicationCommand: CreateWorkspaceCustomApplicationCommand, + protected readonly workspaceCustomApplicationIdNonNullableCommand: WorkspaceCustomApplicationIdNonNullableCommand, ) { super( workspaceRepository, @@ -138,10 +140,11 @@ export class UpgradeCommand extends UpgradeCommandRunner { }; const commands_1120: VersionCommands = { - beforeSyncMetadata: [this.createWorkspaceCustomApplicationCommand], - afterSyncMetadata: [ - this.readonlysetStandardApplicationNotUninstallableCommand, + beforeSyncMetadata: [ + this.createWorkspaceCustomApplicationCommand, + this.workspaceCustomApplicationIdNonNullableCommand, ], + afterSyncMetadata: [this.setStandardApplicationNotUninstallableCommand], }; this.allCommands = { diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1763977334519-workspace-custom-application-id-non-nullable.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1763977334519-workspace-custom-application-id-non-nullable.ts new file mode 100644 index 0000000000..0090fa851e --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1763977334519-workspace-custom-application-id-non-nullable.ts @@ -0,0 +1,39 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +export class WorkspaceCustomApplicationIdNonNullable1763977334519 + implements MigrationInterface +{ + name = 'WorkspaceCustomApplicationIdNonNullable1763977334519'; + + public async up(queryRunner: QueryRunner): Promise { + try { + await queryRunner.query( + `ALTER TABLE "core"."workspace" DROP CONSTRAINT "FK_3b1acb13a5dac9956d1a4b32755"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."workspace" ALTER COLUMN "workspaceCustomApplicationId" SET NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "core"."workspace" ADD CONSTRAINT "FK_3b1acb13a5dac9956d1a4b32755" FOREIGN KEY ("workspaceCustomApplicationId") REFERENCES "core"."application"("id") ON DELETE RESTRICT ON UPDATE NO ACTION`, + ); + } catch (e) { + // eslint-disable-next-line no-console + console.error( + 'Swallowing WorkspaceCustomApplicationIdNonNullable1763977334519 error', + e, + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."workspace" DROP CONSTRAINT "FK_3b1acb13a5dac9956d1a4b32755"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."workspace" ALTER COLUMN "workspaceCustomApplicationId" DROP NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "core"."workspace" ADD CONSTRAINT "FK_3b1acb13a5dac9956d1a4b32755" FOREIGN KEY ("workspaceCustomApplicationId") REFERENCES "core"."application"("id") ON DELETE RESTRICT ON UPDATE NO ACTION`, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/application/application.entity.ts b/packages/twenty-server/src/engine/core-modules/application/application.entity.ts index a6f88f162e..c3a1f99418 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application.entity.ts @@ -44,6 +44,7 @@ export class ApplicationEntity { @Column({ nullable: true, type: 'text' }) description: string | null; + // TODO should not be nullable @Column({ nullable: true, type: 'text' }) version: string | null; diff --git a/packages/twenty-server/src/engine/core-modules/application/application.service.ts b/packages/twenty-server/src/engine/core-modules/application/application.service.ts index 5e32260b1e..5cd6a57cd8 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application.service.ts @@ -27,15 +27,21 @@ export class ApplicationService { ) {} async findWorkspaceTwentyStandardAndCustomApplicationOrThrow({ + workspace: workspaceInput, workspaceId, - }: { - workspaceId: string; - }) { - const workspace = await this.workspaceRepository.findOne({ - where: { - id: workspaceId, - }, - }); + }: + | { + workspaceId: string; + workspace?: never; + } + | { workspace: WorkspaceEntity; workspaceId?: never }) { + const workspace = isDefined(workspaceInput) + ? workspaceInput + : await this.workspaceRepository.findOne({ + where: { + id: workspaceId, + }, + }); if (!isDefined(workspace)) { throw new ApplicationException( @@ -47,7 +53,7 @@ export class ApplicationService { const flatApplicationMaps = await this.workspaceFlatApplicationMapCacheService.getExistingOrRecomputeFlatMaps( { - workspaceId, + workspaceId: workspace.id, }, ); const twentyStandardApplicationId = diff --git a/packages/twenty-server/src/engine/core-modules/application/dtos/application.dto.ts b/packages/twenty-server/src/engine/core-modules/application/dtos/application.dto.ts index d2ebb15276..c531ea8a2e 100644 --- a/packages/twenty-server/src/engine/core-modules/application/dtos/application.dto.ts +++ b/packages/twenty-server/src/engine/core-modules/application/dtos/application.dto.ts @@ -1,6 +1,12 @@ import { Field, ObjectType } from '@nestjs/graphql'; -import { IsBoolean, IsNotEmpty, IsString, IsUUID } from 'class-validator'; +import { + IsBoolean, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, +} from 'class-validator'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; import { ApplicationVariableEntityDTO } from 'src/engine/core-modules/applicationVariable/dtos/application-variable.dto'; @@ -19,13 +25,15 @@ export class ApplicationDTO { @Field() name: string; + @IsOptional() @IsString() @Field() - description: string; + description?: string; + @IsOptional() @IsString() @Field() - version: string; + version?: string; @IsString() @Field() @@ -36,14 +44,14 @@ export class ApplicationDTO { canBeUninstalled: boolean; @Field(() => [AgentDTO]) - agents: AgentDTO[]; + agents?: AgentDTO[]; @Field(() => [ServerlessFunctionDTO]) - serverlessFunctions: ServerlessFunctionDTO[]; + serverlessFunctions?: ServerlessFunctionDTO[]; @Field(() => [ObjectMetadataDTO]) - objects: ObjectMetadataDTO[]; + objects?: ObjectMetadataDTO[]; @Field(() => [ApplicationVariableEntityDTO]) - applicationVariables: ApplicationVariableEntityDTO[]; + applicationVariables?: ApplicationVariableEntityDTO[]; } diff --git a/packages/twenty-server/src/engine/core-modules/application/utils/from-flat-application-to-application-dto.util.ts b/packages/twenty-server/src/engine/core-modules/application/utils/from-flat-application-to-application-dto.util.ts new file mode 100644 index 0000000000..664a93d6bc --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/utils/from-flat-application-to-application-dto.util.ts @@ -0,0 +1,23 @@ +import { type ApplicationDTO } from 'src/engine/core-modules/application/dtos/application.dto'; +import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type'; + +export const fromFlatApplicationToApplicationDto = ({ + canBeUninstalled, + + description, + id, + name, + + universalIdentifier, + version, +}: FlatApplication): ApplicationDTO => { + return { + canBeUninstalled, + description: description ?? undefined, + id, + name, + objects: [], + universalIdentifier, + version: version ?? undefined, + }; +}; 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 b06800e950..50e914624b 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 @@ -23,6 +23,7 @@ import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const'; import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity'; import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity'; import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; +import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/application.dto'; import { ApprovedAccessDomainEntity } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity'; import { EmailingDomainEntity } from 'src/engine/core-modules/emailing-domain/emailing-domain.entity'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; @@ -286,12 +287,10 @@ export class WorkspaceEntity { @Column({ type: 'varchar', nullable: false, default: 'auto' }) routerModel: ModelId; - // TODO prastoin - // Temporarily setting as nullable for retro compatibility, not udpating TypeScript types - @Field(() => String, { nullable: true }) - @Column({ nullable: true, type: 'uuid' }) + @Column({ nullable: false, type: 'uuid' }) workspaceCustomApplicationId: string; + @Field(() => ApplicationDTO, { nullable: true }) @ManyToOne(() => ApplicationEntity, { onDelete: 'RESTRICT', nullable: false, diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts index 090f0a537e..2139ec9761 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts @@ -28,6 +28,7 @@ import { WorkspaceWorkspaceMemberListener } from 'src/engine/core-modules/worksp import { workspaceAutoResolverOpts } from 'src/engine/core-modules/workspace/workspace.auto-resolver-opts'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { WorkspaceResolver } from 'src/engine/core-modules/workspace/workspace.resolver'; +import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; import { AgentModule } from 'src/engine/metadata-modules/agent/agent.module'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; @@ -72,6 +73,7 @@ import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-m CustomDomainManagerModule, ViewModule, WorkspaceManyOrAllFlatEntityMapsCacheModule, + ApplicationModule, ], services: [WorkspaceService], resolvers: workspaceAutoResolverOpts, 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 80cac26cf5..65a5f37a4c 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 @@ -23,6 +23,9 @@ import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder. import type { FileUpload } from 'graphql-upload/processRequest.mjs'; +import { ApplicationService } from 'src/engine/core-modules/application/application.service'; +import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/application.dto'; +import { fromFlatApplicationToApplicationDto } from 'src/engine/core-modules/application/utils/from-flat-application-to-application-dto.util'; import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; import { DomainValidRecords } from 'src/engine/core-modules/dns-manager/dtos/domain-valid-records'; @@ -103,6 +106,7 @@ export class WorkspaceResolver { private readonly viewService: ViewService, private readonly dnsManagerService: DnsManagerService, private readonly customDomainManagerService: CustomDomainManagerService, + private readonly applicationService: ApplicationService, ) {} @Query(() => WorkspaceEntity) @@ -240,6 +244,27 @@ export class WorkspaceResolver { return workspace.routerModel; } + @ResolveField(() => ApplicationDTO, { nullable: true }) + async workspaceCustomApplication( + @Parent() workspace: WorkspaceEntity, + ): Promise { + try { + const { workspaceCustomFlatApplication } = + await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( + { + workspace, + }, + ); + + return fromFlatApplicationToApplicationDto( + workspaceCustomFlatApplication, + ); + } catch { + // Temporary should be removed after CreateWorkspaceCustomApplicationCommand is run + return null; + } + } + @ResolveField(() => BillingSubscriptionEntity, { nullable: true }) async currentBillingSubscription( @Parent() workspace: WorkspaceEntity,