This commit is contained in:
Félix Malfait
2026-03-04 14:43:24 +01:00
parent 9e34c42dc2
commit be7bc82a2e
28 changed files with 337 additions and 426 deletions
@@ -298,6 +298,7 @@ export type AppToken = {
export type Application = {
__typename?: 'Application';
agents: Array<Agent>;
applicationRegistration?: Maybe<ApplicationRegistrationSummary>;
applicationRegistrationId?: Maybe<Scalars['UUID']>;
applicationVariables: Array<ApplicationVariable>;
availablePackages: Scalars['JSON'];
@@ -339,6 +340,12 @@ export type ApplicationRegistration = {
websiteUrl?: Maybe<Scalars['String']>;
};
export type ApplicationRegistrationSummary = {
__typename?: 'ApplicationRegistrationSummary';
id: Scalars['UUID'];
latestAvailableVersion?: Maybe<Scalars['String']>;
};
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<string>, 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<Array<string>> | 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<string>, 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<Array<string>> | 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<string>, 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<Array<string>> | 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<string>, 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<Array<string>> | 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
}
}
}
`;
@@ -14,6 +14,10 @@ export const APPLICATION_FRAGMENT = gql`
version
universalIdentifier
applicationRegistrationId
applicationRegistration {
id
latestAvailableVersion
}
canBeUninstalled
defaultRoleId
settingsCustomTabFrontComponentId
@@ -8,6 +8,10 @@ export const FIND_MANY_APPLICATIONS = gql`
description
version
applicationRegistrationId
applicationRegistration {
id
latestAvailableVersion
}
}
}
`;
@@ -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 };
};
@@ -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 = [
@@ -32,10 +32,8 @@ const StyledSearchInput = styled(SettingsTextInput)`
export const SettingsApplicationsTable = ({
applications,
registrationVersionMap,
}: {
applications: ApplicationWithoutRelation[];
registrationVersionMap?: Map<string, string>;
}) => {
const { t } = useLingui();
@@ -74,9 +72,8 @@ export const SettingsApplicationsTable = ({
<TableHeader />
</StyledTableHeaderRow>
{filteredApplications.map((application) => {
const latestVersion = application.applicationRegistrationId
? registrationVersionMap?.get(application.applicationRegistrationId)
: undefined;
const latestVersion =
application.applicationRegistration?.latestAvailableVersion;
const hasUpdate =
isDefined(latestVersion) &&
@@ -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;
@@ -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<string, string>();
const registrations =
registrationsData?.findManyApplicationRegistrations ?? [];
for (const registration of registrations) {
if (isDefined(registration.latestAvailableVersion)) {
map.set(registration.id, registration.latestAvailableVersion);
}
}
return map;
}, [registrationsData]);
return (
<>
<Section>
@@ -72,10 +51,7 @@ export const SettingsApplicationsInstalledTab = () => {
</Section>
{applications.length > 0 && (
<SettingsApplicationsTable
applications={applications}
registrationVersionMap={registrationVersionMap}
/>
<SettingsApplicationsTable applications={applications} />
)}
<SettingsInstallNpmAppModal />
@@ -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'
>;
@@ -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);
}
}
}
@@ -176,7 +176,7 @@ export class ApplicationRegistrationVariableService {
workspaceId: string,
): Promise<void> {
const registration = await this.applicationRegistrationRepository.findOne({
where: { id: registrationId, workspaceId },
where: { id: registrationId, ownerWorkspaceId: workspaceId },
});
if (!registration) {
@@ -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' })
@@ -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,
@@ -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,
});
}
}
@@ -33,20 +33,20 @@ export class ApplicationRegistrationService {
) {}
async findMany(
workspaceId: string,
ownerWorkspaceId: string,
): Promise<ApplicationRegistrationEntity[]> {
return this.applicationRegistrationRepository.find({
where: { workspaceId },
where: { ownerWorkspaceId },
order: { createdAt: 'DESC' },
});
}
async findOneById(
id: string,
workspaceId: string,
ownerWorkspaceId: string,
): Promise<ApplicationRegistrationEntity> {
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<boolean> {
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<ApplicationRegistrationEntity> {
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<boolean> {
await this.findOneById(id, workspaceId);
async delete(id: string, ownerWorkspaceId: string): Promise<boolean> {
await this.findOneById(id, ownerWorkspaceId);
await this.applicationRegistrationRepository.softDelete(id);
return true;
}
async rotateClientSecret(id: string, workspaceId: string): Promise<string> {
await this.findOneById(id, workspaceId);
async rotateClientSecret(
id: string,
ownerWorkspaceId: string,
): Promise<string> {
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<ApplicationRegistrationStatsDTO> {
await this.findOneById(applicationRegistrationId, workspaceId);
await this.findOneById(applicationRegistrationId, ownerWorkspaceId);
const versionDistribution: { version: string; count: number }[] =
await this.applicationRepository
@@ -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<ApplicationRegistrationEntity> {
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,
});
}
}
@@ -35,7 +35,7 @@ export class AppTarballUploadService {
async uploadTarball(params: {
tarballBuffer: Buffer;
universalIdentifier?: string;
workspaceId: string;
ownerWorkspaceId: string;
}): Promise<ApplicationRegistrationEntity> {
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 =
@@ -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);
@@ -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<Response>();
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,
);
}
}
}
@@ -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:
@@ -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;
}
@@ -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;
}
@@ -126,6 +126,7 @@ export class ApplicationService {
'applicationVariables',
'packageJsonFile',
'yarnLockFile',
'applicationRegistration',
],
});
}
@@ -160,6 +161,7 @@ export class ApplicationService {
'applicationVariables',
'packageJsonFile',
'yarnLockFile',
'applicationRegistration',
],
});
}
@@ -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 {}
@@ -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<MarketplaceAppDTO[]> {
return this.marketplaceCatalogSyncService.findManyMarketplaceApps();
return this.marketplaceQueryService.findManyMarketplaceApps();
}
@Mutation(() => Boolean)
@@ -38,7 +40,7 @@ export class MarketplaceResolver {
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<boolean> {
const registration =
await this.marketplaceCatalogSyncService.findRegistrationByUniversalIdentifier(
await this.marketplaceQueryService.findRegistrationByUniversalIdentifier(
universalIdentifier,
);
@@ -58,9 +60,9 @@ export class MarketplaceResolver {
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<boolean> {
const registration =
await this.marketplaceCatalogSyncService.findOrCreateNpmRegistration({
await this.marketplaceQueryService.findOrCreateNpmRegistration({
packageName,
workspaceId: workspace.id,
ownerWorkspaceId: workspace.id,
});
return this.applicationInstallService.installApplication({
@@ -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<ApplicationRegistrationEntity>,
private readonly marketplaceService: MarketplaceService,
@InjectMessageQueue(MessageQueue.cronQueue)
private readonly messageQueueService: MessageQueueService,
) {}
async syncCatalog(): Promise<void> {
@@ -51,106 +40,7 @@ export class MarketplaceCatalogSyncService {
this.logger.log('Marketplace catalog sync completed');
}
async findManyMarketplaceApps(): Promise<MarketplaceAppDTO[]> {
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<ApplicationRegistrationEntity> {
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<ApplicationRegistrationEntity> {
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<void> {
private async syncCuratedApps(ownerWorkspaceId: string): Promise<void> {
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<void> {
private async syncNpmApps(ownerWorkspaceId: string): Promise<void> {
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<void> {
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);
@@ -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<ApplicationRegistrationEntity>,
@InjectMessageQueue(MessageQueue.cronQueue)
private readonly messageQueueService: MessageQueueService,
) {}
async findManyMarketplaceApps(): Promise<MarketplaceAppDTO[]> {
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<ApplicationRegistrationEntity> {
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<ApplicationRegistrationEntity> {
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,
};
}
}
@@ -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<string | null> => {