diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 0ddc163a96c..1cd90ae74fb 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -298,6 +298,7 @@ export type AppToken = { export type Application = { __typename?: 'Application'; agents: Array; + applicationRegistration?: Maybe; applicationRegistrationId?: Maybe; applicationVariables: Array; availablePackages: Scalars['JSON']; @@ -339,6 +340,12 @@ export type ApplicationRegistration = { websiteUrl?: Maybe; }; +export type ApplicationRegistrationSummary = { + __typename?: 'ApplicationRegistrationSummary'; + id: Scalars['UUID']; + latestAvailableVersion?: Maybe; +}; + export type ApplicationRegistrationStats = { __typename?: 'ApplicationRegistrationStats'; activeInstalls: Scalars['Int']; @@ -5750,19 +5757,19 @@ export type UpdateOneApplicationVariableMutationVariables = Exact<{ export type UpdateOneApplicationVariableMutation = { __typename?: 'Mutation', updateOneApplicationVariable: boolean }; -export type ApplicationFieldsFragment = { __typename?: 'Application', id: string, name: string, description?: string | null, version?: string | null, universalIdentifier: string, applicationRegistrationId?: string | null, canBeUninstalled: boolean, defaultRoleId?: string | null, settingsCustomTabFrontComponentId?: string | null, availablePackages: any, applicationVariables: Array<{ __typename?: 'ApplicationVariable', id: string, key: string, value: string, description: string, isSecret: boolean }>, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, evaluationInputs: Array, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, universalIdentifier: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId: string, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, universalIdentifier: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, morphId?: string | null, applicationId: string, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, logicFunctions: Array<{ __typename?: 'LogicFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, sourceHandlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, cronTriggerSettings?: any | null, databaseEventTriggerSettings?: any | null, httpRouteTriggerSettings?: any | null, applicationId?: string | null, createdAt: string, updatedAt: string }> }; +export type ApplicationFieldsFragment = { __typename?: 'Application', id: string, name: string, description?: string | null, version?: string | null, universalIdentifier: string, applicationRegistrationId?: string | null, applicationRegistration?: { __typename?: 'ApplicationRegistrationSummary', id: string, latestAvailableVersion?: string | null } | null, canBeUninstalled: boolean, defaultRoleId?: string | null, settingsCustomTabFrontComponentId?: string | null, availablePackages: any, applicationVariables: Array<{ __typename?: 'ApplicationVariable', id: string, key: string, value: string, description: string, isSecret: boolean }>, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, evaluationInputs: Array, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, universalIdentifier: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId: string, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, universalIdentifier: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, morphId?: string | null, applicationId: string, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, logicFunctions: Array<{ __typename?: 'LogicFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, sourceHandlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, cronTriggerSettings?: any | null, databaseEventTriggerSettings?: any | null, httpRouteTriggerSettings?: any | null, applicationId?: string | null, createdAt: string, updatedAt: string }> }; export type FindManyApplicationsQueryVariables = Exact<{ [key: string]: never; }>; -export type FindManyApplicationsQuery = { __typename?: 'Query', findManyApplications: Array<{ __typename?: 'Application', id: string, name: string, description?: string | null, version?: string | null, applicationRegistrationId?: string | null }> }; +export type FindManyApplicationsQuery = { __typename?: 'Query', findManyApplications: Array<{ __typename?: 'Application', id: string, name: string, description?: string | null, version?: string | null, applicationRegistrationId?: string | null, applicationRegistration?: { __typename?: 'ApplicationRegistrationSummary', id: string, latestAvailableVersion?: string | null } | null }> }; export type FindOneApplicationQueryVariables = Exact<{ id: Scalars['UUID']; }>; -export type FindOneApplicationQuery = { __typename?: 'Query', findOneApplication: { __typename?: 'Application', id: string, name: string, description?: string | null, version?: string | null, universalIdentifier: string, applicationRegistrationId?: string | null, canBeUninstalled: boolean, defaultRoleId?: string | null, settingsCustomTabFrontComponentId?: string | null, availablePackages: any, applicationVariables: Array<{ __typename?: 'ApplicationVariable', id: string, key: string, value: string, description: string, isSecret: boolean }>, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, evaluationInputs: Array, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, universalIdentifier: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId: string, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, universalIdentifier: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, morphId?: string | null, applicationId: string, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, logicFunctions: Array<{ __typename?: 'LogicFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, sourceHandlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, cronTriggerSettings?: any | null, databaseEventTriggerSettings?: any | null, httpRouteTriggerSettings?: any | null, applicationId?: string | null, createdAt: string, updatedAt: string }> } }; +export type FindOneApplicationQuery = { __typename?: 'Query', findOneApplication: { __typename?: 'Application', id: string, name: string, description?: string | null, version?: string | null, universalIdentifier: string, applicationRegistrationId?: string | null, applicationRegistration?: { __typename?: 'ApplicationRegistrationSummary', id: string, latestAvailableVersion?: string | null } | null, canBeUninstalled: boolean, defaultRoleId?: string | null, settingsCustomTabFrontComponentId?: string | null, availablePackages: any, applicationVariables: Array<{ __typename?: 'ApplicationVariable', id: string, key: string, value: string, description: string, isSecret: boolean }>, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, evaluationInputs: Array, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, universalIdentifier: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId: string, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, universalIdentifier: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, morphId?: string | null, applicationId: string, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, logicFunctions: Array<{ __typename?: 'LogicFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, sourceHandlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, cronTriggerSettings?: any | null, databaseEventTriggerSettings?: any | null, httpRouteTriggerSettings?: any | null, applicationId?: string | null, createdAt: string, updatedAt: string }> } }; export type UploadFileMutationVariables = Exact<{ file: Scalars['Upload']; @@ -7485,6 +7492,10 @@ export const ApplicationFieldsFragmentDoc = gql` version universalIdentifier applicationRegistrationId + applicationRegistration { + id + latestAvailableVersion + } canBeUninstalled defaultRoleId settingsCustomTabFrontComponentId @@ -9299,6 +9310,10 @@ export const FindManyApplicationsDocument = gql` description version applicationRegistrationId + applicationRegistration { + id + latestAvailableVersion + } } } `; diff --git a/packages/twenty-front/src/modules/applications/graphql/fragments/applicationFragment.ts b/packages/twenty-front/src/modules/applications/graphql/fragments/applicationFragment.ts index 5aa2312c0fe..f22de7b9ee1 100644 --- a/packages/twenty-front/src/modules/applications/graphql/fragments/applicationFragment.ts +++ b/packages/twenty-front/src/modules/applications/graphql/fragments/applicationFragment.ts @@ -14,6 +14,10 @@ export const APPLICATION_FRAGMENT = gql` version universalIdentifier applicationRegistrationId + applicationRegistration { + id + latestAvailableVersion + } canBeUninstalled defaultRoleId settingsCustomTabFrontComponentId diff --git a/packages/twenty-front/src/modules/applications/graphql/queries/findManyApplications.ts b/packages/twenty-front/src/modules/applications/graphql/queries/findManyApplications.ts index 52b32162378..4e957e21ab2 100644 --- a/packages/twenty-front/src/modules/applications/graphql/queries/findManyApplications.ts +++ b/packages/twenty-front/src/modules/applications/graphql/queries/findManyApplications.ts @@ -8,6 +8,10 @@ export const FIND_MANY_APPLICATIONS = gql` description version applicationRegistrationId + applicationRegistration { + id + latestAvailableVersion + } } } `; diff --git a/packages/twenty-front/src/modules/marketplace/hooks/useUpgradeApplication.ts b/packages/twenty-front/src/modules/marketplace/hooks/useUpgradeApplication.ts new file mode 100644 index 00000000000..af1ff1e9027 --- /dev/null +++ b/packages/twenty-front/src/modules/marketplace/hooks/useUpgradeApplication.ts @@ -0,0 +1,44 @@ +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { t } from '@lingui/core/macro'; +import { useState } from 'react'; +import { isDefined } from 'twenty-shared/utils'; +import { useUpgradeApplicationMutation } from '~/generated-metadata/graphql'; + +export const useUpgradeApplication = () => { + const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar(); + const [upgradeApplicationMutation] = useUpgradeApplicationMutation(); + const [isUpgrading, setIsUpgrading] = useState(false); + + const upgrade = async (params: { + appRegistrationId: string; + targetVersion: string; + }) => { + setIsUpgrading(true); + + try { + const result = await upgradeApplicationMutation({ + variables: params, + }); + + if (isDefined(result.data)) { + enqueueSuccessSnackBar({ + message: t`Application upgraded successfully.`, + }); + + return true; + } + + return false; + } catch { + enqueueErrorSnackBar({ + message: t`Failed to upgrade the application.`, + }); + + return false; + } finally { + setIsUpgrading(false); + } + }; + + return { upgrade, isUpgrading }; +}; diff --git a/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationVersionContainer.tsx b/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationVersionContainer.tsx index c6dc60f2ecb..cb7bbab5c4c 100644 --- a/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationVersionContainer.tsx +++ b/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationVersionContainer.tsx @@ -1,15 +1,11 @@ import { SettingsAdminTableCard } from '@/settings/admin-panel/components/SettingsAdminTableCard'; import { SettingsAdminVersionDisplay } from '@/settings/admin-panel/components/SettingsAdminVersionDisplay'; -import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { useUpgradeApplication } from '@/marketplace/hooks/useUpgradeApplication'; import { t } from '@lingui/core/macro'; -import { useState } from 'react'; import { isDefined } from 'twenty-shared/utils'; import { IconCircleDot, IconStatusChange, IconUpload } from 'twenty-ui/display'; import { Button } from 'twenty-ui/input'; -import { - type Application, - useUpgradeApplicationMutation, -} from '~/generated-metadata/graphql'; +import { type Application } from '~/generated-metadata/graphql'; import { isNewerSemver } from '~/pages/settings/applications/utils/isNewerSemver'; export const SettingsApplicationVersionContainer = ({ @@ -32,35 +28,17 @@ export const SettingsApplicationVersionContainer = ({ isDefined(currentVersion) && isNewerSemver(latestAvailableVersion, currentVersion); - const [upgradeApplication] = useUpgradeApplicationMutation(); - const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar(); - const [isUpgrading, setIsUpgrading] = useState(false); + const { upgrade, isUpgrading } = useUpgradeApplication(); const handleUpgrade = async () => { if (!isDefined(appRegistrationId) || !isDefined(latestAvailableVersion)) { return; } - setIsUpgrading(true); - - try { - await upgradeApplication({ - variables: { - appRegistrationId, - targetVersion: latestAvailableVersion, - }, - }); - - enqueueSuccessSnackBar({ - message: t`Application upgraded successfully.`, - }); - } catch { - enqueueErrorSnackBar({ - message: t`Failed to upgrade the application.`, - }); - } finally { - setIsUpgrading(false); - } + await upgrade({ + appRegistrationId, + targetVersion: latestAvailableVersion, + }); }; const versionItems = [ diff --git a/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationsTable.tsx b/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationsTable.tsx index cb125b8f01a..56ac2e9de95 100644 --- a/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationsTable.tsx +++ b/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationsTable.tsx @@ -32,10 +32,8 @@ const StyledSearchInput = styled(SettingsTextInput)` export const SettingsApplicationsTable = ({ applications, - registrationVersionMap, }: { applications: ApplicationWithoutRelation[]; - registrationVersionMap?: Map; }) => { const { t } = useLingui(); @@ -74,9 +72,8 @@ export const SettingsApplicationsTable = ({ {filteredApplications.map((application) => { - const latestVersion = application.applicationRegistrationId - ? registrationVersionMap?.get(application.applicationRegistrationId) - : undefined; + const latestVersion = + application.applicationRegistration?.latestAvailableVersion; const hasUpdate = isDefined(latestVersion) && diff --git a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailAboutTab.tsx b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailAboutTab.tsx index 540ceb526e9..b92defbfb98 100644 --- a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailAboutTab.tsx +++ b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailAboutTab.tsx @@ -12,7 +12,6 @@ import { useModal } from '@/ui/layout/modal/hooks/useModal'; import { useState } from 'react'; import { type Application, - useFindOneApplicationRegistrationQuery, useUninstallApplicationMutation, } from '~/generated-metadata/graphql'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; @@ -39,14 +38,8 @@ export const SettingsApplicationDetailAboutTab = ({ const registrationId = application?.applicationRegistrationId; - const { data: registrationData } = useFindOneApplicationRegistrationQuery({ - variables: { id: registrationId ?? '' }, - skip: !isDefined(registrationId), - }); - const latestAvailableVersion = - registrationData?.findOneApplicationRegistration?.latestAvailableVersion ?? - null; + application?.applicationRegistration?.latestAvailableVersion ?? null; if (!isDefined(application)) { return null; diff --git a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationsInstalledTab.tsx b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationsInstalledTab.tsx index 51b48a4811e..28954d3d54c 100644 --- a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationsInstalledTab.tsx +++ b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationsInstalledTab.tsx @@ -1,16 +1,11 @@ import { styled } from '@linaria/react'; -import { useMemo } from 'react'; import { useLingui } from '@lingui/react/macro'; import { IconDownload, IconUpload } from 'twenty-ui/display'; import { Button } from 'twenty-ui/input'; import { Section } from 'twenty-ui/layout'; import { themeCssVariables } from 'twenty-ui/theme-constants'; import { useModal } from '@/ui/layout/modal/hooks/useModal'; -import { isDefined } from 'twenty-shared/utils'; -import { - useFindManyApplicationRegistrationsQuery, - useFindManyApplicationsQuery, -} from '~/generated-metadata/graphql'; +import { useFindManyApplicationsQuery } from '~/generated-metadata/graphql'; import { SettingsApplicationsTable } from '~/pages/settings/applications/components/SettingsApplicationsTable'; import { SettingsInstallNpmAppModal, @@ -30,26 +25,10 @@ const StyledButtonGroup = styled.div` export const SettingsApplicationsInstalledTab = () => { const { t } = useLingui(); const { data } = useFindManyApplicationsQuery(); - const { data: registrationsData } = - useFindManyApplicationRegistrationsQuery(); const { openModal } = useModal(); const applications = data?.findManyApplications ?? []; - const registrationVersionMap = useMemo(() => { - const map = new Map(); - const registrations = - registrationsData?.findManyApplicationRegistrations ?? []; - - for (const registration of registrations) { - if (isDefined(registration.latestAvailableVersion)) { - map.set(registration.id, registration.latestAvailableVersion); - } - } - - return map; - }, [registrationsData]); - return ( <>
@@ -72,10 +51,7 @@ export const SettingsApplicationsInstalledTab = () => {
{applications.length > 0 && ( - + )} diff --git a/packages/twenty-front/src/pages/settings/applications/types/applicationWithoutRelation.ts b/packages/twenty-front/src/pages/settings/applications/types/applicationWithoutRelation.ts index fce61e8bcca..4864de1fc68 100644 --- a/packages/twenty-front/src/pages/settings/applications/types/applicationWithoutRelation.ts +++ b/packages/twenty-front/src/pages/settings/applications/types/applicationWithoutRelation.ts @@ -2,5 +2,10 @@ import { type Application } from '~/generated-metadata/graphql'; export type ApplicationWithoutRelation = Pick< Application, - 'id' | 'name' | 'description' | 'version' | 'applicationRegistrationId' + | 'id' + | 'name' + | 'description' + | 'version' + | 'applicationRegistrationId' + | 'applicationRegistration' >; diff --git a/packages/twenty-server/src/engine/core-modules/application-registration/application-registration-exception-filter.ts b/packages/twenty-server/src/engine/core-modules/application-registration/application-registration-exception-filter.ts new file mode 100644 index 00000000000..f55a4705566 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application-registration/application-registration-exception-filter.ts @@ -0,0 +1,32 @@ +import { Catch, ExceptionFilter } from '@nestjs/common'; + +import { + ApplicationRegistrationException, + ApplicationRegistrationExceptionCode, +} from 'src/engine/core-modules/application-registration/application-registration.exception'; +import { + InternalServerError, + NotFoundError, + UserInputError, +} from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; + +@Catch(ApplicationRegistrationException) +export class ApplicationRegistrationExceptionFilter + implements ExceptionFilter +{ + catch(exception: ApplicationRegistrationException) { + switch (exception.code) { + case ApplicationRegistrationExceptionCode.APPLICATION_REGISTRATION_NOT_FOUND: + case ApplicationRegistrationExceptionCode.VARIABLE_NOT_FOUND: + throw new NotFoundError(exception); + case ApplicationRegistrationExceptionCode.INVALID_INPUT: + case ApplicationRegistrationExceptionCode.INVALID_SCOPE: + case ApplicationRegistrationExceptionCode.INVALID_REDIRECT_URI: + case ApplicationRegistrationExceptionCode.SOURCE_CHANNEL_MISMATCH: + case ApplicationRegistrationExceptionCode.UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED: + throw new UserInputError(exception); + default: + throw new InternalServerError(exception); + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/application-registration/application-registration-variable.service.ts b/packages/twenty-server/src/engine/core-modules/application-registration/application-registration-variable.service.ts index ef57ca26624..df84cb25746 100644 --- a/packages/twenty-server/src/engine/core-modules/application-registration/application-registration-variable.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application-registration/application-registration-variable.service.ts @@ -176,7 +176,7 @@ export class ApplicationRegistrationVariableService { workspaceId: string, ): Promise { const registration = await this.applicationRegistrationRepository.findOne({ - where: { id: registrationId, workspaceId }, + where: { id: registrationId, ownerWorkspaceId: workspaceId }, }); if (!registration) { diff --git a/packages/twenty-server/src/engine/core-modules/application-registration/application-registration.entity.ts b/packages/twenty-server/src/engine/core-modules/application-registration/application-registration.entity.ts index eae0960aae4..4afe6f1fbe0 100644 --- a/packages/twenty-server/src/engine/core-modules/application-registration/application-registration.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/application-registration/application-registration.entity.ts @@ -96,8 +96,8 @@ export class ApplicationRegistrationEntity { // Represents ownership (who can edit), not visibility scoping. // Marketplace registrations are readable by all workspaces but owned by the // admin workspace when no developer has explicitly claimed them. - @Column({ nullable: false, type: 'uuid' }) - workspaceId: string; + @Column({ name: 'workspaceId', nullable: false, type: 'uuid' }) + ownerWorkspaceId: string; @ManyToOne(() => WorkspaceEntity, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'workspaceId' }) diff --git a/packages/twenty-server/src/engine/core-modules/application-registration/application-registration.module.ts b/packages/twenty-server/src/engine/core-modules/application-registration/application-registration.module.ts index cf93aaea0a8..864ab1c7fb1 100644 --- a/packages/twenty-server/src/engine/core-modules/application-registration/application-registration.module.ts +++ b/packages/twenty-server/src/engine/core-modules/application-registration/application-registration.module.ts @@ -6,7 +6,6 @@ import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/ import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application-registration/application-registration.entity'; import { ApplicationRegistrationResolver } from 'src/engine/core-modules/application-registration/application-registration.resolver'; import { ApplicationRegistrationService } from 'src/engine/core-modules/application-registration/application-registration.service'; -import { AppRegistrationUploadController } from 'src/engine/core-modules/application-registration/controllers/app-registration-upload.controller'; import { OAuthDiscoveryController } from 'src/engine/core-modules/application-registration/controllers/oauth-discovery.controller'; import { OAuthTokenController } from 'src/engine/core-modules/application-registration/controllers/oauth-token.controller'; import { OAuthService } from 'src/engine/core-modules/application-registration/oauth.service'; @@ -39,11 +38,7 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/ FileStorageModule, WorkspaceCacheStorageModule, ], - controllers: [ - OAuthTokenController, - OAuthDiscoveryController, - AppRegistrationUploadController, - ], + controllers: [OAuthTokenController, OAuthDiscoveryController], providers: [ ApplicationRegistrationService, ApplicationRegistrationVariableService, diff --git a/packages/twenty-server/src/engine/core-modules/application-registration/application-registration.resolver.ts b/packages/twenty-server/src/engine/core-modules/application-registration/application-registration.resolver.ts index 784864ae78b..dc27c497e23 100644 --- a/packages/twenty-server/src/engine/core-modules/application-registration/application-registration.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/application-registration/application-registration.resolver.ts @@ -6,6 +6,7 @@ import { PermissionFlagType } from 'twenty-shared/constants'; import type { FileUpload } from 'graphql-upload/processRequest.mjs'; +import { ApplicationRegistrationExceptionFilter } from 'src/engine/core-modules/application-registration/application-registration-exception-filter'; import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application-registration/application-registration-variable.entity'; import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application-registration/application-registration-variable.service'; import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application-registration/application-registration.entity'; @@ -23,9 +24,9 @@ import { RotateClientSecretDTO } from 'src/engine/core-modules/application-regis import { UpdateApplicationRegistrationInput } from 'src/engine/core-modules/application-registration/dtos/update-application-registration.input'; import { UpdateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application-registration/dtos/update-application-registration-variable.input'; import { - ApplicationException, - ApplicationExceptionCode, -} from 'src/engine/core-modules/application/application.exception'; + ApplicationRegistrationException, + ApplicationRegistrationExceptionCode, +} from 'src/engine/core-modules/application-registration/application-registration.exception'; import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter'; import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter'; import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; @@ -43,6 +44,7 @@ import { streamToBuffer } from 'src/utils/stream-to-buffer'; @UsePipes(ResolverValidationPipe) @MetadataResolver() @UseFilters( + ApplicationRegistrationExceptionFilter, AuthGraphqlApiExceptionFilter, PreventNestToAutoLogGraphqlErrorsFilter, ) @@ -241,16 +243,16 @@ export class ApplicationRegistrationResolver { const tarballBuffer = await streamToBuffer(stream); if (tarballBuffer.length > MAX_TARBALL_UPLOAD_SIZE_BYTES) { - throw new ApplicationException( + throw new ApplicationRegistrationException( `Tarball exceeds maximum size of ${MAX_TARBALL_UPLOAD_SIZE_BYTES} bytes`, - ApplicationExceptionCode.INVALID_INPUT, + ApplicationRegistrationExceptionCode.INVALID_INPUT, ); } return this.appTarballUploadService.uploadTarball({ tarballBuffer, universalIdentifier, - workspaceId, + ownerWorkspaceId: workspaceId, }); } } diff --git a/packages/twenty-server/src/engine/core-modules/application-registration/application-registration.service.ts b/packages/twenty-server/src/engine/core-modules/application-registration/application-registration.service.ts index 4fed82ea76f..a93a7c8911f 100644 --- a/packages/twenty-server/src/engine/core-modules/application-registration/application-registration.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application-registration/application-registration.service.ts @@ -33,20 +33,20 @@ export class ApplicationRegistrationService { ) {} async findMany( - workspaceId: string, + ownerWorkspaceId: string, ): Promise { return this.applicationRegistrationRepository.find({ - where: { workspaceId }, + where: { ownerWorkspaceId }, order: { createdAt: 'DESC' }, }); } async findOneById( id: string, - workspaceId: string, + ownerWorkspaceId: string, ): Promise { const registration = await this.applicationRegistrationRepository.findOne({ - where: { id, workspaceId }, + where: { id, ownerWorkspaceId }, }); if (!registration) { @@ -93,10 +93,10 @@ export class ApplicationRegistrationService { async isOwnedByWorkspace(id: string, workspaceId: string): Promise { const registration = await this.applicationRegistrationRepository.findOne({ where: { id }, - select: ['id', 'workspaceId'], + select: ['id', 'ownerWorkspaceId'], }); - return registration?.workspaceId === workspaceId; + return registration?.ownerWorkspaceId === workspaceId; } // Global lookup — used by app sync to find existing registrations @@ -110,7 +110,7 @@ export class ApplicationRegistrationService { async create( input: CreateApplicationRegistrationInput, - workspaceId: string, + ownerWorkspaceId: string, createdByUserId: string | null, ): Promise<{ applicationRegistration: ApplicationRegistrationEntity; @@ -152,7 +152,7 @@ export class ApplicationRegistrationService { oAuthRedirectUris: input.oAuthRedirectUris ?? [], oAuthScopes: input.oAuthScopes ?? [], createdByUserId, - workspaceId, + ownerWorkspaceId, websiteUrl: input.websiteUrl ?? null, termsUrl: input.termsUrl ?? null, }); @@ -166,11 +166,11 @@ export class ApplicationRegistrationService { async update( input: UpdateApplicationRegistrationInput, - workspaceId: string, + ownerWorkspaceId: string, ): Promise { const { id, update } = input; - await this.findOneById(id, workspaceId); + await this.findOneById(id, ownerWorkspaceId); if (isDefined(update.oAuthRedirectUris)) { this.validateRedirectUris(update.oAuthRedirectUris); @@ -198,18 +198,21 @@ export class ApplicationRegistrationService { await this.applicationRegistrationRepository.update(id, updateData); } - return this.findOneById(id, workspaceId); + return this.findOneById(id, ownerWorkspaceId); } - async delete(id: string, workspaceId: string): Promise { - await this.findOneById(id, workspaceId); + async delete(id: string, ownerWorkspaceId: string): Promise { + await this.findOneById(id, ownerWorkspaceId); await this.applicationRegistrationRepository.softDelete(id); return true; } - async rotateClientSecret(id: string, workspaceId: string): Promise { - await this.findOneById(id, workspaceId); + async rotateClientSecret( + id: string, + ownerWorkspaceId: string, + ): Promise { + await this.findOneById(id, ownerWorkspaceId); const { clientSecret, clientSecretHash } = await this.generateClientSecret(); @@ -234,9 +237,9 @@ export class ApplicationRegistrationService { async getStats( applicationRegistrationId: string, - workspaceId: string, + ownerWorkspaceId: string, ): Promise { - await this.findOneById(applicationRegistrationId, workspaceId); + await this.findOneById(applicationRegistrationId, ownerWorkspaceId); const versionDistribution: { version: string; count: number }[] = await this.applicationRepository diff --git a/packages/twenty-server/src/engine/core-modules/application-registration/controllers/app-registration-upload.controller.ts b/packages/twenty-server/src/engine/core-modules/application-registration/controllers/app-registration-upload.controller.ts deleted file mode 100644 index 84583ee4f7a..00000000000 --- a/packages/twenty-server/src/engine/core-modules/application-registration/controllers/app-registration-upload.controller.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { - Body, - Controller, - HttpCode, - Post, - UseFilters, - UseGuards, -} from '@nestjs/common'; - -import { PermissionFlagType } from 'twenty-shared/constants'; -import { isDefined } from 'twenty-shared/utils'; - -import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application-registration/application-registration.entity'; -import { - ApplicationException, - ApplicationExceptionCode, -} from 'src/engine/core-modules/application/application.exception'; -import { ApplicationRestApiExceptionFilter } from 'src/engine/core-modules/application/application-rest-api-exception-filter'; -import { - AppTarballUploadService, - MAX_TARBALL_UPLOAD_SIZE_BYTES, -} from 'src/engine/core-modules/application-registration/services/app-tarball-upload.service'; -import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; -import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; -import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard'; -import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard'; -import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; - -type TarballUploadBody = { - tarball: string; - universalIdentifier?: string; -}; - -@Controller('api/app-registrations') -@UseGuards(JwtAuthGuard, WorkspaceAuthGuard) -@UseFilters(ApplicationRestApiExceptionFilter) -export class AppRegistrationUploadController { - constructor( - private readonly appTarballUploadService: AppTarballUploadService, - ) {} - - @Post('upload-tarball') - @HttpCode(200) - @UseGuards(SettingsPermissionGuard(PermissionFlagType.MARKETPLACE_APPS)) - async uploadTarball( - @Body() body: TarballUploadBody, - @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, - ): Promise { - const tarballBase64 = body.tarball; - - if (!isDefined(tarballBase64)) { - throw new ApplicationException( - 'Tarball data is required', - ApplicationExceptionCode.INVALID_INPUT, - ); - } - - const tarballBuffer = Buffer.from(tarballBase64, 'base64'); - - if (tarballBuffer.length > MAX_TARBALL_UPLOAD_SIZE_BYTES) { - throw new ApplicationException( - `Tarball exceeds maximum size of ${MAX_TARBALL_UPLOAD_SIZE_BYTES} bytes`, - ApplicationExceptionCode.INVALID_INPUT, - ); - } - - return this.appTarballUploadService.uploadTarball({ - tarballBuffer, - universalIdentifier: body.universalIdentifier, - workspaceId, - }); - } -} diff --git a/packages/twenty-server/src/engine/core-modules/application-registration/services/app-tarball-upload.service.ts b/packages/twenty-server/src/engine/core-modules/application-registration/services/app-tarball-upload.service.ts index f05f73e1f72..cc8a4a60b16 100644 --- a/packages/twenty-server/src/engine/core-modules/application-registration/services/app-tarball-upload.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application-registration/services/app-tarball-upload.service.ts @@ -35,7 +35,7 @@ export class AppTarballUploadService { async uploadTarball(params: { tarballBuffer: Buffer; universalIdentifier?: string; - workspaceId: string; + ownerWorkspaceId: string; }): Promise { const tempDir = join(tmpdir(), 'twenty-tarball-upload', v4()); @@ -82,7 +82,7 @@ export class AppTarballUploadService { let appRegistration = await this.appRegistrationRepository.findOne({ where: { universalIdentifier, - workspaceId: params.workspaceId, + ownerWorkspaceId: params.ownerWorkspaceId, }, }); @@ -104,7 +104,7 @@ export class AppTarballUploadService { oAuthClientId: v4(), oAuthRedirectUris: [], oAuthScopes: [], - workspaceId: params.workspaceId, + ownerWorkspaceId: params.ownerWorkspaceId, }); appRegistration = diff --git a/packages/twenty-server/src/engine/core-modules/application/application-exception-filter.ts b/packages/twenty-server/src/engine/core-modules/application/application-exception-filter.ts index 9276e1724c3..a70b239ff5a 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-exception-filter.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-exception-filter.ts @@ -30,8 +30,6 @@ export class ApplicationExceptionFilter implements ExceptionFilter { case ApplicationExceptionCode.SOURCE_CHANNEL_MISMATCH: throw new UserInputError(exception); case ApplicationExceptionCode.PACKAGE_RESOLUTION_FAILED: - case ApplicationExceptionCode.INSTALL_LOCK_TIMEOUT: - case ApplicationExceptionCode.INSTALL_HOOK_EXECUTION_FAILED: case ApplicationExceptionCode.TARBALL_EXTRACTION_FAILED: case ApplicationExceptionCode.UPGRADE_FAILED: throw new InternalServerError(exception); diff --git a/packages/twenty-server/src/engine/core-modules/application/application-rest-api-exception-filter.ts b/packages/twenty-server/src/engine/core-modules/application/application-rest-api-exception-filter.ts deleted file mode 100644 index 71bfed8a5cb..00000000000 --- a/packages/twenty-server/src/engine/core-modules/application/application-rest-api-exception-filter.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { - type ArgumentsHost, - Catch, - type ExceptionFilter, -} from '@nestjs/common'; - -import { type Response } from 'express'; - -import { - ApplicationRegistrationException, - ApplicationRegistrationExceptionCode, -} from 'src/engine/core-modules/application-registration/application-registration.exception'; -import { - ApplicationException, - ApplicationExceptionCode, -} from 'src/engine/core-modules/application/application.exception'; -import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service'; - -@Catch(ApplicationException, ApplicationRegistrationException) -export class ApplicationRestApiExceptionFilter implements ExceptionFilter { - constructor( - private readonly httpExceptionHandlerService: HttpExceptionHandlerService, - ) {} - - catch( - exception: ApplicationException | ApplicationRegistrationException, - host: ArgumentsHost, - ) { - const ctx = host.switchToHttp(); - const response = ctx.getResponse(); - - if (exception instanceof ApplicationRegistrationException) { - return this.handleRegistrationException(exception, response); - } - - return this.handleApplicationException(exception, response); - } - - private handleApplicationException( - exception: ApplicationException, - response: Response, - ) { - switch (exception.code) { - case ApplicationExceptionCode.OBJECT_NOT_FOUND: - case ApplicationExceptionCode.FIELD_NOT_FOUND: - case ApplicationExceptionCode.ENTITY_NOT_FOUND: - case ApplicationExceptionCode.APPLICATION_NOT_FOUND: - case ApplicationExceptionCode.LOGIC_FUNCTION_NOT_FOUND: - case ApplicationExceptionCode.FRONT_COMPONENT_NOT_FOUND: - return this.httpExceptionHandlerService.handleError( - exception, - response, - 404, - ); - case ApplicationExceptionCode.FORBIDDEN: - return this.httpExceptionHandlerService.handleError( - exception, - response, - 403, - ); - case ApplicationExceptionCode.INVALID_INPUT: - case ApplicationExceptionCode.SOURCE_CHANNEL_MISMATCH: - return this.httpExceptionHandlerService.handleError( - exception, - response, - 400, - ); - case ApplicationExceptionCode.PACKAGE_RESOLUTION_FAILED: - case ApplicationExceptionCode.INSTALL_LOCK_TIMEOUT: - case ApplicationExceptionCode.INSTALL_HOOK_EXECUTION_FAILED: - case ApplicationExceptionCode.TARBALL_EXTRACTION_FAILED: - case ApplicationExceptionCode.UPGRADE_FAILED: - default: - return this.httpExceptionHandlerService.handleError( - exception, - response, - 500, - ); - } - } - - private handleRegistrationException( - exception: ApplicationRegistrationException, - response: Response, - ) { - switch (exception.code) { - case ApplicationRegistrationExceptionCode.APPLICATION_REGISTRATION_NOT_FOUND: - case ApplicationRegistrationExceptionCode.VARIABLE_NOT_FOUND: - return this.httpExceptionHandlerService.handleError( - exception, - response, - 404, - ); - case ApplicationRegistrationExceptionCode.INVALID_INPUT: - case ApplicationRegistrationExceptionCode.INVALID_SCOPE: - case ApplicationRegistrationExceptionCode.INVALID_REDIRECT_URI: - case ApplicationRegistrationExceptionCode.SOURCE_CHANNEL_MISMATCH: - case ApplicationRegistrationExceptionCode.UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED: - return this.httpExceptionHandlerService.handleError( - exception, - response, - 400, - ); - default: - return this.httpExceptionHandlerService.handleError( - exception, - response, - 500, - ); - } - } -} diff --git a/packages/twenty-server/src/engine/core-modules/application/application.exception.ts b/packages/twenty-server/src/engine/core-modules/application/application.exception.ts index ece727b6feb..ed88cbd77d9 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application.exception.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application.exception.ts @@ -15,8 +15,6 @@ export enum ApplicationExceptionCode { INVALID_INPUT = 'INVALID_INPUT', SOURCE_CHANNEL_MISMATCH = 'SOURCE_CHANNEL_MISMATCH', PACKAGE_RESOLUTION_FAILED = 'PACKAGE_RESOLUTION_FAILED', - INSTALL_LOCK_TIMEOUT = 'INSTALL_LOCK_TIMEOUT', - INSTALL_HOOK_EXECUTION_FAILED = 'INSTALL_HOOK_EXECUTION_FAILED', TARBALL_EXTRACTION_FAILED = 'TARBALL_EXTRACTION_FAILED', UPGRADE_FAILED = 'UPGRADE_FAILED', } @@ -45,10 +43,6 @@ const getApplicationExceptionUserFriendlyMessage = ( return msg`Source channel mismatch.`; case ApplicationExceptionCode.PACKAGE_RESOLUTION_FAILED: return msg`Unable to retrieve the application package.`; - case ApplicationExceptionCode.INSTALL_LOCK_TIMEOUT: - return msg`Another installation is in progress.`; - case ApplicationExceptionCode.INSTALL_HOOK_EXECUTION_FAILED: - return msg`Install hook execution failed.`; case ApplicationExceptionCode.TARBALL_EXTRACTION_FAILED: return msg`Failed to extract tarball.`; case ApplicationExceptionCode.UPGRADE_FAILED: diff --git a/packages/twenty-server/src/engine/core-modules/application/dtos/application-registration-summary.dto.ts b/packages/twenty-server/src/engine/core-modules/application/dtos/application-registration-summary.dto.ts new file mode 100644 index 00000000000..4dfd1c2c63a --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/dtos/application-registration-summary.dto.ts @@ -0,0 +1,16 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +import { IsOptional, IsString } from 'class-validator'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; + +@ObjectType('ApplicationRegistrationSummary') +export class ApplicationRegistrationSummaryDTO { + @Field(() => UUIDScalarType) + id: string; + + @IsOptional() + @IsString() + @Field({ nullable: true }) + latestAvailableVersion?: string; +} 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 7b80d0d1271..d86b0f57a29 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 @@ -10,6 +10,7 @@ import { import GraphQLJSON from 'graphql-type-json'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { ApplicationRegistrationSummaryDTO } from 'src/engine/core-modules/application/dtos/application-registration-summary.dto'; import { ApplicationVariableEntityDTO } from 'src/engine/core-modules/applicationVariable/dtos/application-variable.dto'; import { AgentDTO } from 'src/engine/metadata-modules/ai/ai-agent/dtos/agent.dto'; import { LogicFunctionDTO } from 'src/engine/metadata-modules/logic-function/dtos/logic-function.dto'; @@ -98,4 +99,8 @@ export class ApplicationDTO { @Field(() => [ApplicationVariableEntityDTO]) applicationVariables?: ApplicationVariableEntityDTO[]; + + @IsOptional() + @Field(() => ApplicationRegistrationSummaryDTO, { nullable: true }) + applicationRegistration?: ApplicationRegistrationSummaryDTO; } diff --git a/packages/twenty-server/src/engine/core-modules/application/services/application.service.ts b/packages/twenty-server/src/engine/core-modules/application/services/application.service.ts index 8e040398a75..8c265e945c8 100644 --- a/packages/twenty-server/src/engine/core-modules/application/services/application.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/services/application.service.ts @@ -126,6 +126,7 @@ export class ApplicationService { 'applicationVariables', 'packageJsonFile', 'yarnLockFile', + 'applicationRegistration', ], }); } @@ -160,6 +161,7 @@ export class ApplicationService { 'applicationVariables', 'packageJsonFile', 'yarnLockFile', + 'applicationRegistration', ], }); } diff --git a/packages/twenty-server/src/engine/core-modules/marketplace/marketplace.module.ts b/packages/twenty-server/src/engine/core-modules/marketplace/marketplace.module.ts index 72db89750e8..09754c9a858 100644 --- a/packages/twenty-server/src/engine/core-modules/marketplace/marketplace.module.ts +++ b/packages/twenty-server/src/engine/core-modules/marketplace/marketplace.module.ts @@ -6,6 +6,7 @@ import { ApplicationSyncModule } from 'src/engine/core-modules/application/appli import { MarketplaceCatalogSyncCronCommand } from 'src/engine/core-modules/marketplace/crons/commands/marketplace-catalog-sync.cron.command'; import { MarketplaceCatalogSyncCronJob } from 'src/engine/core-modules/marketplace/crons/marketplace-catalog-sync.cron.job'; import { MarketplaceCatalogSyncService } from 'src/engine/core-modules/marketplace/services/marketplace-catalog-sync.service'; +import { MarketplaceQueryService } from 'src/engine/core-modules/marketplace/services/marketplace-query.service'; import { MarketplaceResolver } from 'src/engine/core-modules/marketplace/resolvers/marketplace.resolver'; import { MarketplaceService } from 'src/engine/core-modules/marketplace/services/marketplace.service'; import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module'; @@ -21,10 +22,15 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi providers: [ MarketplaceService, MarketplaceCatalogSyncService, + MarketplaceQueryService, MarketplaceCatalogSyncCronJob, MarketplaceCatalogSyncCronCommand, MarketplaceResolver, ], - exports: [MarketplaceCatalogSyncService, MarketplaceCatalogSyncCronCommand], + exports: [ + MarketplaceCatalogSyncService, + MarketplaceQueryService, + MarketplaceCatalogSyncCronCommand, + ], }) export class MarketplaceModule {} diff --git a/packages/twenty-server/src/engine/core-modules/marketplace/resolvers/marketplace.resolver.ts b/packages/twenty-server/src/engine/core-modules/marketplace/resolvers/marketplace.resolver.ts index 590a6f169c6..9f985b5ead6 100644 --- a/packages/twenty-server/src/engine/core-modules/marketplace/resolvers/marketplace.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/marketplace/resolvers/marketplace.resolver.ts @@ -1,12 +1,13 @@ -import { UseGuards } from '@nestjs/common'; +import { UseFilters, UseGuards } from '@nestjs/common'; import { Args, Mutation, Query } from '@nestjs/graphql'; import { PermissionFlagType } from 'twenty-shared/constants'; +import { ApplicationRegistrationExceptionFilter } from 'src/engine/core-modules/application-registration/application-registration-exception-filter'; import { ApplicationInstallService } from 'src/engine/core-modules/application/services/application-install.service'; import { AppUpgradeService } from 'src/engine/core-modules/application/services/app-upgrade.service'; import { MarketplaceAppDTO } from 'src/engine/core-modules/marketplace/dtos/marketplace-app.dto'; -import { MarketplaceCatalogSyncService } from 'src/engine/core-modules/marketplace/services/marketplace-catalog-sync.service'; +import { MarketplaceQueryService } from 'src/engine/core-modules/marketplace/services/marketplace-query.service'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; @@ -16,17 +17,18 @@ import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; @MetadataResolver() +@UseFilters(ApplicationRegistrationExceptionFilter) @UseGuards(UserAuthGuard, WorkspaceAuthGuard, NoPermissionGuard) export class MarketplaceResolver { constructor( - private readonly marketplaceCatalogSyncService: MarketplaceCatalogSyncService, + private readonly marketplaceQueryService: MarketplaceQueryService, private readonly applicationInstallService: ApplicationInstallService, private readonly appUpgradeService: AppUpgradeService, ) {} @Query(() => [MarketplaceAppDTO]) async findManyMarketplaceApps(): Promise { - return this.marketplaceCatalogSyncService.findManyMarketplaceApps(); + return this.marketplaceQueryService.findManyMarketplaceApps(); } @Mutation(() => Boolean) @@ -38,7 +40,7 @@ export class MarketplaceResolver { @AuthWorkspace() workspace: WorkspaceEntity, ): Promise { const registration = - await this.marketplaceCatalogSyncService.findRegistrationByUniversalIdentifier( + await this.marketplaceQueryService.findRegistrationByUniversalIdentifier( universalIdentifier, ); @@ -58,9 +60,9 @@ export class MarketplaceResolver { @AuthWorkspace() workspace: WorkspaceEntity, ): Promise { const registration = - await this.marketplaceCatalogSyncService.findOrCreateNpmRegistration({ + await this.marketplaceQueryService.findOrCreateNpmRegistration({ packageName, - workspaceId: workspace.id, + ownerWorkspaceId: workspace.id, }); return this.applicationInstallService.installApplication({ diff --git a/packages/twenty-server/src/engine/core-modules/marketplace/services/marketplace-catalog-sync.service.ts b/packages/twenty-server/src/engine/core-modules/marketplace/services/marketplace-catalog-sync.service.ts index 0f752f28cc9..d9b25cb94d6 100644 --- a/packages/twenty-server/src/engine/core-modules/marketplace/services/marketplace-catalog-sync.service.ts +++ b/packages/twenty-server/src/engine/core-modules/marketplace/services/marketplace-catalog-sync.service.ts @@ -8,18 +8,9 @@ import { v4 } from 'uuid'; import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application-registration/application-registration.entity'; import { AppRegistrationSourceType } from 'src/engine/core-modules/application-registration/enums/app-registration-source-type.enum'; import { MARKETPLACE_CATALOG_INDEX } from 'src/engine/core-modules/marketplace/constants/marketplace-catalog-index.constant'; -import { MarketplaceCatalogSyncCronJob } from 'src/engine/core-modules/marketplace/crons/marketplace-catalog-sync.cron.job'; -import { MarketplaceAppDTO } from 'src/engine/core-modules/marketplace/dtos/marketplace-app.dto'; import { MarketplaceService } from 'src/engine/core-modules/marketplace/services/marketplace.service'; import { type MarketplaceDisplayData } from 'src/engine/core-modules/marketplace/types/marketplace-display-data.type'; import { getAdminWorkspaceId } from 'src/engine/core-modules/marketplace/utils/get-admin-workspace-id.util'; -import { - ApplicationException, - ApplicationExceptionCode, -} from 'src/engine/core-modules/application/application.exception'; -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'; @Injectable() export class MarketplaceCatalogSyncService { @@ -29,8 +20,6 @@ export class MarketplaceCatalogSyncService { @InjectRepository(ApplicationRegistrationEntity) private readonly appRegistrationRepository: Repository, private readonly marketplaceService: MarketplaceService, - @InjectMessageQueue(MessageQueue.cronQueue) - private readonly messageQueueService: MessageQueueService, ) {} async syncCatalog(): Promise { @@ -51,106 +40,7 @@ export class MarketplaceCatalogSyncService { this.logger.log('Marketplace catalog sync completed'); } - async findManyMarketplaceApps(): Promise { - const registrations = await this.appRegistrationRepository.find({ - where: { sourceType: AppRegistrationSourceType.NPM }, - }); - - if (registrations.length === 0) { - this.logger.log( - 'No marketplace registrations found, enqueuing one-time sync job', - ); - await this.messageQueueService.add( - MarketplaceCatalogSyncCronJob.name, - {}, - ); - - return []; - } - - return registrations.map((registration) => - this.toMarketplaceAppDTO(registration), - ); - } - - async findRegistrationByUniversalIdentifier( - universalIdentifier: string, - ): Promise { - const registration = await this.appRegistrationRepository.findOne({ - where: { universalIdentifier }, - }); - - if (!isDefined(registration)) { - throw new ApplicationException( - `No application registration found for identifier "${universalIdentifier}"`, - ApplicationExceptionCode.APPLICATION_NOT_FOUND, - ); - } - - return registration; - } - - async findOrCreateNpmRegistration(params: { - packageName: string; - workspaceId: string; - }): Promise { - const existing = await this.appRegistrationRepository.findOne({ - where: { sourcePackage: params.packageName }, - }); - - if (isDefined(existing)) { - return existing; - } - - this.logger.log( - `Creating new registration for npm package "${params.packageName}"`, - ); - - const registration = this.appRegistrationRepository.create({ - universalIdentifier: v4(), - name: params.packageName, - sourceType: AppRegistrationSourceType.NPM, - sourcePackage: params.packageName, - oAuthClientId: v4(), - oAuthRedirectUris: [], - oAuthScopes: [], - workspaceId: params.workspaceId, - }); - - return this.appRegistrationRepository.save(registration); - } - - toMarketplaceAppDTO( - registration: ApplicationRegistrationEntity, - ): MarketplaceAppDTO { - const displayData = registration.marketplaceDisplayData; - - return { - id: registration.universalIdentifier, - name: registration.name, - description: registration.description ?? '', - icon: displayData?.icon ?? 'IconApps', - version: - displayData?.version ?? registration.latestAvailableVersion ?? '0.0.0', - author: registration.author ?? 'Unknown', - category: displayData?.category ?? '', - logo: displayData?.logo, - screenshots: displayData?.screenshots ?? [], - aboutDescription: - displayData?.aboutDescription ?? registration.description ?? '', - providers: displayData?.providers ?? [], - websiteUrl: registration.websiteUrl ?? undefined, - termsUrl: registration.termsUrl ?? undefined, - objects: displayData?.objects ?? [], - fields: displayData?.fields ?? [], - logicFunctions: displayData?.logicFunctions ?? [], - frontComponents: displayData?.frontComponents ?? [], - sourcePackage: registration.sourcePackage ?? undefined, - defaultRole: displayData?.defaultRole, - }; - } - - private async syncCuratedApps(workspaceId: string): Promise { + private async syncCuratedApps(ownerWorkspaceId: string): Promise { for (const entry of MARKETPLACE_CATALOG_INDEX) { try { await this.upsertRegistration({ @@ -167,7 +57,7 @@ export class MarketplaceCatalogSyncService { latestAvailableVersion: entry.richDisplayData.version ?? null, isFeatured: entry.isFeatured, marketplaceDisplayData: entry.richDisplayData, - workspaceId, + ownerWorkspaceId, }); } catch (error) { this.logger.error( @@ -177,7 +67,7 @@ export class MarketplaceCatalogSyncService { } } - private async syncNpmApps(workspaceId: string): Promise { + private async syncNpmApps(ownerWorkspaceId: string): Promise { const npmApps = await this.marketplaceService.fetchAppsFromNpmRegistry(); const curatedIdentifiers = new Set( @@ -203,7 +93,7 @@ export class MarketplaceCatalogSyncService { latestAvailableVersion: app.version ?? null, isFeatured: false, marketplaceDisplayData: null, - workspaceId, + ownerWorkspaceId, }); } catch (error) { this.logger.error( @@ -213,6 +103,8 @@ export class MarketplaceCatalogSyncService { } } + // Lookup by universalIdentifier only (matches the unique constraint). + // ownerWorkspaceId is only set on insert. private async upsertRegistration(params: { universalIdentifier: string; name: string; @@ -226,12 +118,11 @@ export class MarketplaceCatalogSyncService { latestAvailableVersion: string | null; isFeatured: boolean; marketplaceDisplayData: MarketplaceDisplayData | null; - workspaceId: string; + ownerWorkspaceId: string; }): Promise { const existing = await this.appRegistrationRepository.findOne({ where: { universalIdentifier: params.universalIdentifier, - workspaceId: params.workspaceId, }, }); @@ -269,7 +160,7 @@ export class MarketplaceCatalogSyncService { oAuthClientId: v4(), oAuthRedirectUris: [], oAuthScopes: [], - workspaceId: params.workspaceId, + ownerWorkspaceId: params.ownerWorkspaceId, }); await this.appRegistrationRepository.save(registration); diff --git a/packages/twenty-server/src/engine/core-modules/marketplace/services/marketplace-query.service.ts b/packages/twenty-server/src/engine/core-modules/marketplace/services/marketplace-query.service.ts new file mode 100644 index 00000000000..87e050aec17 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/marketplace/services/marketplace-query.service.ts @@ -0,0 +1,129 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { isDefined } from 'twenty-shared/utils'; +import { Repository } from 'typeorm'; +import { v4 } from 'uuid'; + +import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application-registration/application-registration.entity'; +import { + ApplicationRegistrationException, + ApplicationRegistrationExceptionCode, +} from 'src/engine/core-modules/application-registration/application-registration.exception'; +import { AppRegistrationSourceType } from 'src/engine/core-modules/application-registration/enums/app-registration-source-type.enum'; +import { MarketplaceCatalogSyncCronJob } from 'src/engine/core-modules/marketplace/crons/marketplace-catalog-sync.cron.job'; +import { MarketplaceAppDTO } from 'src/engine/core-modules/marketplace/dtos/marketplace-app.dto'; +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'; + +@Injectable() +export class MarketplaceQueryService { + private readonly logger = new Logger(MarketplaceQueryService.name); + + constructor( + @InjectRepository(ApplicationRegistrationEntity) + private readonly appRegistrationRepository: Repository, + @InjectMessageQueue(MessageQueue.cronQueue) + private readonly messageQueueService: MessageQueueService, + ) {} + + async findManyMarketplaceApps(): Promise { + const registrations = await this.appRegistrationRepository.find({ + where: { sourceType: AppRegistrationSourceType.NPM }, + }); + + if (registrations.length === 0) { + this.logger.log( + 'No marketplace registrations found, enqueuing one-time sync job', + ); + await this.messageQueueService.add( + MarketplaceCatalogSyncCronJob.name, + {}, + ); + + return []; + } + + return registrations.map((registration) => + this.toMarketplaceAppDTO(registration), + ); + } + + async findRegistrationByUniversalIdentifier( + universalIdentifier: string, + ): Promise { + const registration = await this.appRegistrationRepository.findOne({ + where: { universalIdentifier }, + }); + + if (!isDefined(registration)) { + throw new ApplicationRegistrationException( + `No application registration found for identifier "${universalIdentifier}"`, + ApplicationRegistrationExceptionCode.APPLICATION_REGISTRATION_NOT_FOUND, + ); + } + + return registration; + } + + async findOrCreateNpmRegistration(params: { + packageName: string; + ownerWorkspaceId: string; + }): Promise { + const existing = await this.appRegistrationRepository.findOne({ + where: { sourcePackage: params.packageName }, + }); + + if (isDefined(existing)) { + return existing; + } + + this.logger.log( + `Creating new registration for npm package "${params.packageName}"`, + ); + + const registration = this.appRegistrationRepository.create({ + universalIdentifier: v4(), + name: params.packageName, + sourceType: AppRegistrationSourceType.NPM, + sourcePackage: params.packageName, + oAuthClientId: v4(), + oAuthRedirectUris: [], + oAuthScopes: [], + ownerWorkspaceId: params.ownerWorkspaceId, + }); + + return this.appRegistrationRepository.save(registration); + } + + toMarketplaceAppDTO( + registration: ApplicationRegistrationEntity, + ): MarketplaceAppDTO { + const displayData = registration.marketplaceDisplayData; + + return { + id: registration.universalIdentifier, + name: registration.name, + description: registration.description ?? '', + icon: displayData?.icon ?? 'IconApps', + version: + displayData?.version ?? registration.latestAvailableVersion ?? '0.0.0', + author: registration.author ?? 'Unknown', + category: displayData?.category ?? '', + logo: displayData?.logo, + screenshots: displayData?.screenshots ?? [], + aboutDescription: + displayData?.aboutDescription ?? registration.description ?? '', + providers: displayData?.providers ?? [], + websiteUrl: registration.websiteUrl ?? undefined, + termsUrl: registration.termsUrl ?? undefined, + objects: displayData?.objects ?? [], + fields: displayData?.fields ?? [], + logicFunctions: displayData?.logicFunctions ?? [], + frontComponents: displayData?.frontComponents ?? [], + sourcePackage: registration.sourcePackage ?? undefined, + defaultRole: displayData?.defaultRole, + }; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/marketplace/utils/get-admin-workspace-id.util.ts b/packages/twenty-server/src/engine/core-modules/marketplace/utils/get-admin-workspace-id.util.ts index 841d35f5e18..ae6eabe7882 100644 --- a/packages/twenty-server/src/engine/core-modules/marketplace/utils/get-admin-workspace-id.util.ts +++ b/packages/twenty-server/src/engine/core-modules/marketplace/utils/get-admin-workspace-id.util.ts @@ -1,12 +1,17 @@ import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; import { type DataSource } from 'typeorm'; -// Every ApplicationRegistration must be owned by a workspace (workspaceId +// Every ApplicationRegistration must be owned by a workspace (ownerWorkspaceId // represents ownership / write-access, not visibility scoping — marketplace // registrations are readable by all workspaces). When the catalog sync creates // registrations for marketplace apps that no developer has explicitly claimed, // we assign them to the "admin" workspace: the oldest active workspace whose // owner has admin privileges. +// +// TODO: This heuristic is fragile — on fresh instances with no admin users the +// catalog sync is silently skipped, and if the admin workspace is later deleted +// all marketplace registrations become orphaned. Consider introducing a +// dedicated "platform" workspace or making ownerWorkspaceId nullable instead. export const getAdminWorkspaceId = async ( dataSource: DataSource, ): Promise => {