mirror of
https://github.com/twentyhq/twenty
synced 2026-04-21 13:37:22 +00:00
feat: add npm and tarball app distribution with upgrade mechanism (#18358)
## Summary - **npm + tarball app distribution**: Apps can be installed from the npm registry (public or private) or uploaded as `.tar.gz` tarballs, with `AppRegistrationSourceType` tracking the origin - **Upgrade mechanism**: `AppUpgradeService` checks for newer versions, supports rollback for npm-sourced apps, and a cron job runs every 6 hours to update `latestAvailableVersion` on registrations - **Security hardening**: Tarball extraction uses path traversal protection, and `enableScripts: false` in `.yarnrc.yml` disables all lifecycle scripts during `yarn install` to prevent RCE - **Frontend**: "Install from npm" and "Upload tarball" modals, upgrade button on app detail page, blue "Update" badge on installed apps table when a newer version is available - **Marketplace catalog sync**: Hourly cron job syncs a hardcoded catalog index into `ApplicationRegistration` entities - **Integration tests**: Coverage for install, upgrade, tarball upload, and catalog sync flows ## Backend changes | Area | Files | |------|-------| | Entity & migration | `ApplicationRegistrationEntity` (sourceType, sourcePackage, latestAvailableVersion), `ApplicationEntity` (applicationRegistrationId), migration | | Services | `AppPackageResolverService`, `ApplicationInstallService`, `AppUpgradeService`, `MarketplaceCatalogSyncService` | | Cron jobs | `MarketplaceCatalogSyncCronJob` (hourly), `AppVersionCheckCronJob` (every 6h) | | REST endpoint | `AppRegistrationUploadController` — tarball upload with secure extraction | | Resolver | `MarketplaceResolver` — simplified `installMarketplaceApp` (removed redundant `sourcePackage` arg) | | Security | `.yarnrc.yml` — `enableScripts: false` to block postinstall RCE | ## Frontend changes | Area | Files | |------|-------| | Modals | `SettingsInstallNpmAppModal`, `SettingsUploadTarballModal`, `SettingsAppModalLayout` | | Hooks | `useUploadAppTarball`, `useInstallMarketplaceApp` (cleaned up) | | Upgrade UI | `SettingsApplicationVersionContainer`, `SettingsApplicationDetailAboutTab` | | Badge | `SettingsApplicationTableRow` — blue "Update" tag, `SettingsApplicationsInstalledTab` — fetches registrations for version comparison | | Styling | Migrated to Linaria (matching main) | ## Test plan - [ ] Install an app from npm via the "Install from npm" modal - [ ] Upload a `.tar.gz` tarball via the "Upload tarball" modal - [ ] Verify upgrade badge appears when `latestAvailableVersion > version` - [ ] Verify upgrade flow from app detail page - [ ] Run integration tests: `app-distribution.integration-spec.ts`, `marketplace-catalog-sync.integration-spec.ts` - [ ] Verify `enableScripts: false` blocks postinstall scripts during yarn install Made with [Cursor](https://cursor.com)
This commit is contained in:
parent
bfa50f566e
commit
0e89c96170
205 changed files with 4741 additions and 1014 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "hello-world",
|
||||
"version": "0.1.0",
|
||||
"name": "@twentyhq/hello-world",
|
||||
"version": "0.2.2",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
|
|
|
|||
|
|
@ -1591,6 +1591,22 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@twentyhq/hello-world@workspace:.":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@twentyhq/hello-world@workspace:."
|
||||
dependencies:
|
||||
"@types/node": "npm:^24.7.2"
|
||||
"@types/react": "npm:^18.2.0"
|
||||
eslint: "npm:^9.32.0"
|
||||
react: "npm:^18.2.0"
|
||||
twenty-sdk: "npm:0.6.3"
|
||||
typescript: "npm:^5.9.3"
|
||||
typescript-eslint: "npm:^8.50.0"
|
||||
vite-tsconfig-paths: "npm:^4.2.1"
|
||||
vitest: "npm:^3.1.1"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@types/chai@npm:^5.2.2":
|
||||
version: 5.2.3
|
||||
resolution: "@types/chai@npm:5.2.3"
|
||||
|
|
@ -1604,7 +1620,7 @@ __metadata:
|
|||
"@types/deep-eql@npm:*":
|
||||
version: 4.0.2
|
||||
resolution: "@types/deep-eql@npm:4.0.2"
|
||||
checksum: 10c0/bf3f811843117900d7084b9d0c852da9a044d12eb40e6de73b552598a6843c21291a8a381b0532644574beecd5e3491c5ff3a0365ab86b15d59862c025384844
|
||||
checksum: 10c0-bf3f811843117900d7084b9d0c852da9a044d12eb40e6de73b552598a6843c21291a8a381b0532644574beecd5e3491c5ff3a0365ab86b15d59862c025384844
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
|
@ -4676,22 +4692,6 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"hello-world@workspace:.":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "hello-world@workspace:."
|
||||
dependencies:
|
||||
"@types/node": "npm:^24.7.2"
|
||||
"@types/react": "npm:^18.2.0"
|
||||
eslint: "npm:^9.32.0"
|
||||
react: "npm:^18.2.0"
|
||||
twenty-sdk: "npm:0.6.3"
|
||||
typescript: "npm:^5.9.3"
|
||||
typescript-eslint: "npm:^8.50.0"
|
||||
vite-tsconfig-paths: "npm:^4.2.1"
|
||||
vitest: "npm:^3.1.1"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"hoist-non-react-statics@npm:^3.3.1":
|
||||
version: 3.3.2
|
||||
resolution: "hoist-non-react-statics@npm:3.3.2"
|
||||
|
|
|
|||
|
|
@ -316,6 +316,12 @@ export type ApiKeyToken = {
|
|||
token: Scalars['String'];
|
||||
};
|
||||
|
||||
export enum AppRegistrationSourceType {
|
||||
LOCAL = 'LOCAL',
|
||||
NPM = 'NPM',
|
||||
TARBALL = 'TARBALL'
|
||||
}
|
||||
|
||||
export type AppToken = {
|
||||
__typename?: 'AppToken';
|
||||
createdAt: Scalars['DateTime'];
|
||||
|
|
@ -328,12 +334,14 @@ export type AppToken = {
|
|||
export type Application = {
|
||||
__typename?: 'Application';
|
||||
agents: Array<Agent>;
|
||||
applicationRegistration?: Maybe<ApplicationRegistrationSummary>;
|
||||
applicationRegistrationId?: Maybe<Scalars['UUID']>;
|
||||
applicationVariables: Array<ApplicationVariable>;
|
||||
availablePackages: Scalars['JSON'];
|
||||
canBeUninstalled: Scalars['Boolean'];
|
||||
defaultLogicFunctionRole?: Maybe<Role>;
|
||||
defaultRoleId?: Maybe<Scalars['String']>;
|
||||
description: Scalars['String'];
|
||||
description?: Maybe<Scalars['String']>;
|
||||
id: Scalars['UUID'];
|
||||
logicFunctions: Array<LogicFunction>;
|
||||
name: Scalars['String'];
|
||||
|
|
@ -342,7 +350,7 @@ export type Application = {
|
|||
packageJsonFileId?: Maybe<Scalars['UUID']>;
|
||||
settingsCustomTabFrontComponentId?: Maybe<Scalars['UUID']>;
|
||||
universalIdentifier: Scalars['String'];
|
||||
version: Scalars['String'];
|
||||
version?: Maybe<Scalars['String']>;
|
||||
yarnLockChecksum?: Maybe<Scalars['String']>;
|
||||
yarnLockFileId?: Maybe<Scalars['UUID']>;
|
||||
};
|
||||
|
|
@ -353,11 +361,15 @@ export type ApplicationRegistration = {
|
|||
createdAt: Scalars['DateTime'];
|
||||
description?: Maybe<Scalars['String']>;
|
||||
id: Scalars['UUID'];
|
||||
isFeatured: Scalars['Boolean'];
|
||||
latestAvailableVersion?: Maybe<Scalars['String']>;
|
||||
logoUrl?: Maybe<Scalars['String']>;
|
||||
name: Scalars['String'];
|
||||
oAuthClientId: Scalars['String'];
|
||||
oAuthRedirectUris: Array<Scalars['String']>;
|
||||
oAuthScopes: Array<Scalars['String']>;
|
||||
sourcePackage?: Maybe<Scalars['String']>;
|
||||
sourceType: AppRegistrationSourceType;
|
||||
termsUrl?: Maybe<Scalars['String']>;
|
||||
universalIdentifier: Scalars['String'];
|
||||
updatedAt: Scalars['DateTime'];
|
||||
|
|
@ -371,6 +383,13 @@ export type ApplicationRegistrationStats = {
|
|||
versionDistribution: Array<VersionDistributionEntry>;
|
||||
};
|
||||
|
||||
export type ApplicationRegistrationSummary = {
|
||||
__typename?: 'ApplicationRegistrationSummary';
|
||||
id: Scalars['UUID'];
|
||||
latestAvailableVersion?: Maybe<Scalars['String']>;
|
||||
sourceType: AppRegistrationSourceType;
|
||||
};
|
||||
|
||||
export type ApplicationRegistrationVariable = {
|
||||
__typename?: 'ApplicationRegistrationVariable';
|
||||
createdAt: Scalars['DateTime'];
|
||||
|
|
@ -1772,6 +1791,7 @@ export type File = {
|
|||
|
||||
export enum FileFolder {
|
||||
AgentChat = 'AgentChat',
|
||||
AppTarball = 'AppTarball',
|
||||
Attachment = 'Attachment',
|
||||
BuiltFrontComponent = 'BuiltFrontComponent',
|
||||
BuiltLogicFunction = 'BuiltLogicFunction',
|
||||
|
|
@ -2218,6 +2238,7 @@ export type MarketplaceApp = {
|
|||
objects: Array<MarketplaceAppObject>;
|
||||
providers: Array<Scalars['String']>;
|
||||
screenshots: Array<Scalars['String']>;
|
||||
sourcePackage?: Maybe<Scalars['String']>;
|
||||
termsUrl?: Maybe<Scalars['String']>;
|
||||
version: Scalars['String'];
|
||||
websiteUrl?: Maybe<Scalars['String']>;
|
||||
|
|
@ -2432,6 +2453,7 @@ export type Mutation = {
|
|||
initiateOTPProvisioningForAuthenticatedUser: InitiateTwoFactorAuthenticationProvisioning;
|
||||
installApplication: Scalars['Boolean'];
|
||||
installMarketplaceApp: Scalars['Boolean'];
|
||||
installNpmApp: Scalars['Boolean'];
|
||||
removeQueryFromEventStream: Scalars['Boolean'];
|
||||
removeRoleFromAgent: Scalars['Boolean'];
|
||||
renewApplicationToken: ApplicationTokenPair;
|
||||
|
|
@ -2490,7 +2512,9 @@ export type Mutation = {
|
|||
updateWorkspace: Workspace;
|
||||
updateWorkspaceFeatureFlag: Scalars['Boolean'];
|
||||
updateWorkspaceMemberRole: WorkspaceMember;
|
||||
upgradeApplication: Scalars['Boolean'];
|
||||
uploadAIChatFile: FileWithSignedUrl;
|
||||
uploadAppTarball: ApplicationRegistration;
|
||||
uploadApplicationFile: File;
|
||||
uploadFilesFieldFile: FileWithSignedUrl;
|
||||
uploadFilesFieldFileByUniversalIdentifier: FileWithSignedUrl;
|
||||
|
|
@ -3019,6 +3043,18 @@ export type MutationInstallApplicationArgs = {
|
|||
};
|
||||
|
||||
|
||||
export type MutationInstallMarketplaceAppArgs = {
|
||||
universalIdentifier: Scalars['String'];
|
||||
version?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
|
||||
export type MutationInstallNpmAppArgs = {
|
||||
packageName: Scalars['String'];
|
||||
version?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
|
||||
export type MutationRemoveQueryFromEventStreamArgs = {
|
||||
input: RemoveQueryFromEventStreamInput;
|
||||
};
|
||||
|
|
@ -3324,11 +3360,23 @@ export type MutationUpdateWorkspaceMemberRoleArgs = {
|
|||
};
|
||||
|
||||
|
||||
export type MutationUpgradeApplicationArgs = {
|
||||
appRegistrationId: Scalars['String'];
|
||||
targetVersion: Scalars['String'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationUploadAiChatFileArgs = {
|
||||
file: Scalars['Upload'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationUploadAppTarballArgs = {
|
||||
file: Scalars['Upload'];
|
||||
universalIdentifier?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
|
||||
export type MutationUploadApplicationFileArgs = {
|
||||
applicationUniversalIdentifier: Scalars['String'];
|
||||
file: Scalars['Upload'];
|
||||
|
|
@ -5765,19 +5813,19 @@ export type UpdateOneApplicationVariableMutationVariables = Exact<{
|
|||
|
||||
export type UpdateOneApplicationVariableMutation = { __typename?: 'Mutation', updateOneApplicationVariable: boolean };
|
||||
|
||||
export type ApplicationFieldsFragment = { __typename?: 'Application', id: string, name: string, description: string, version: string, universalIdentifier: string, 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, canBeUninstalled: boolean, defaultRoleId?: string | null, settingsCustomTabFrontComponentId?: string | null, availablePackages: any, applicationRegistration?: { __typename?: 'ApplicationRegistrationSummary', id: string, latestAvailableVersion?: string | null, sourceType: AppRegistrationSourceType } | null, 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, version: string }> };
|
||||
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, sourceType: AppRegistrationSourceType } | null }> };
|
||||
|
||||
export type FindOneApplicationQueryVariables = Exact<{
|
||||
id: Scalars['UUID'];
|
||||
}>;
|
||||
|
||||
|
||||
export type FindOneApplicationQuery = { __typename?: 'Query', findOneApplication: { __typename?: 'Application', id: string, name: string, description: string, version: string, universalIdentifier: string, 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, canBeUninstalled: boolean, defaultRoleId?: string | null, settingsCustomTabFrontComponentId?: string | null, availablePackages: any, applicationRegistration?: { __typename?: 'ApplicationRegistrationSummary', id: string, latestAvailableVersion?: string | null, sourceType: AppRegistrationSourceType } | null, 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 AuthTokenFragmentFragment = { __typename?: 'AuthToken', token: string, expiresAt: string };
|
||||
|
||||
|
|
@ -6172,12 +6220,44 @@ export type GetLogicFunctionSourceCodeQueryVariables = Exact<{
|
|||
|
||||
export type GetLogicFunctionSourceCodeQuery = { __typename?: 'Query', getLogicFunctionSourceCode?: string | null };
|
||||
|
||||
export type MarketplaceAppFieldsFragment = { __typename?: 'MarketplaceApp', id: string, name: string, description: string, icon: string, version: string, author: string, category: string, logo?: string | null, screenshots: Array<string>, aboutDescription: string, providers: Array<string>, websiteUrl?: string | null, termsUrl?: string | null, objects: Array<{ __typename?: 'MarketplaceAppObject', universalIdentifier: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, fields: Array<{ __typename?: 'MarketplaceAppField', universalIdentifier?: string | null, name: string, type: string, label: string, description?: string | null, icon?: string | null }> }>, fields: Array<{ __typename?: 'MarketplaceAppField', name: string, type: string, label: string, description?: string | null, icon?: string | null, objectUniversalIdentifier?: string | null }>, logicFunctions: Array<{ __typename?: 'MarketplaceAppLogicFunction', name: string, description?: string | null, timeoutSeconds?: number | null }>, frontComponents: Array<{ __typename?: 'MarketplaceAppFrontComponent', name: string, description?: string | null }>, defaultRole?: { __typename?: 'MarketplaceAppDefaultRole', id: string, label: string, description?: string | null, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canUpdateAllSettings: boolean, canAccessAllTools: boolean, permissionFlags: Array<string>, objectPermissions: Array<{ __typename?: 'MarketplaceAppRoleObjectPermission', objectUniversalIdentifier: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null }>, fieldPermissions: Array<{ __typename?: 'MarketplaceAppRoleFieldPermission', objectUniversalIdentifier: string, fieldUniversalIdentifier: string, canReadFieldValue?: boolean | null, canUpdateFieldValue?: boolean | null }> } | null };
|
||||
export type MarketplaceAppFieldsFragment = { __typename?: 'MarketplaceApp', id: string, name: string, description: string, icon: string, version: string, author: string, category: string, logo?: string | null, screenshots: Array<string>, aboutDescription: string, providers: Array<string>, websiteUrl?: string | null, termsUrl?: string | null, sourcePackage?: string | null, objects: Array<{ __typename?: 'MarketplaceAppObject', universalIdentifier: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, fields: Array<{ __typename?: 'MarketplaceAppField', universalIdentifier?: string | null, name: string, type: string, label: string, description?: string | null, icon?: string | null }> }>, fields: Array<{ __typename?: 'MarketplaceAppField', name: string, type: string, label: string, description?: string | null, icon?: string | null, objectUniversalIdentifier?: string | null }>, logicFunctions: Array<{ __typename?: 'MarketplaceAppLogicFunction', name: string, description?: string | null, timeoutSeconds?: number | null }>, frontComponents: Array<{ __typename?: 'MarketplaceAppFrontComponent', name: string, description?: string | null }>, defaultRole?: { __typename?: 'MarketplaceAppDefaultRole', id: string, label: string, description?: string | null, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canUpdateAllSettings: boolean, canAccessAllTools: boolean, permissionFlags: Array<string>, objectPermissions: Array<{ __typename?: 'MarketplaceAppRoleObjectPermission', objectUniversalIdentifier: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null }>, fieldPermissions: Array<{ __typename?: 'MarketplaceAppRoleFieldPermission', objectUniversalIdentifier: string, fieldUniversalIdentifier: string, canReadFieldValue?: boolean | null, canUpdateFieldValue?: boolean | null }> } | null };
|
||||
|
||||
export type InstallMarketplaceAppMutationVariables = Exact<{
|
||||
universalIdentifier: Scalars['String'];
|
||||
version?: InputMaybe<Scalars['String']>;
|
||||
}>;
|
||||
|
||||
|
||||
export type InstallMarketplaceAppMutation = { __typename?: 'Mutation', installMarketplaceApp: boolean };
|
||||
|
||||
export type InstallNpmAppMutationVariables = Exact<{
|
||||
packageName: Scalars['String'];
|
||||
version?: InputMaybe<Scalars['String']>;
|
||||
}>;
|
||||
|
||||
|
||||
export type InstallNpmAppMutation = { __typename?: 'Mutation', installNpmApp: boolean };
|
||||
|
||||
export type UpgradeApplicationMutationVariables = Exact<{
|
||||
appRegistrationId: Scalars['String'];
|
||||
targetVersion: Scalars['String'];
|
||||
}>;
|
||||
|
||||
|
||||
export type UpgradeApplicationMutation = { __typename?: 'Mutation', upgradeApplication: boolean };
|
||||
|
||||
export type UploadAppTarballMutationVariables = Exact<{
|
||||
file: Scalars['Upload'];
|
||||
universalIdentifier?: InputMaybe<Scalars['String']>;
|
||||
}>;
|
||||
|
||||
|
||||
export type UploadAppTarballMutation = { __typename?: 'Mutation', uploadAppTarball: { __typename?: 'ApplicationRegistration', id: string, universalIdentifier: string, name: string } };
|
||||
|
||||
export type FindManyMarketplaceAppsQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type FindManyMarketplaceAppsQuery = { __typename?: 'Query', findManyMarketplaceApps: Array<{ __typename?: 'MarketplaceApp', id: string, name: string, description: string, icon: string, version: string, author: string, category: string, logo?: string | null, screenshots: Array<string>, aboutDescription: string, providers: Array<string>, websiteUrl?: string | null, termsUrl?: string | null, objects: Array<{ __typename?: 'MarketplaceAppObject', universalIdentifier: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, fields: Array<{ __typename?: 'MarketplaceAppField', universalIdentifier?: string | null, name: string, type: string, label: string, description?: string | null, icon?: string | null }> }>, fields: Array<{ __typename?: 'MarketplaceAppField', name: string, type: string, label: string, description?: string | null, icon?: string | null, objectUniversalIdentifier?: string | null }>, logicFunctions: Array<{ __typename?: 'MarketplaceAppLogicFunction', name: string, description?: string | null, timeoutSeconds?: number | null }>, frontComponents: Array<{ __typename?: 'MarketplaceAppFrontComponent', name: string, description?: string | null }>, defaultRole?: { __typename?: 'MarketplaceAppDefaultRole', id: string, label: string, description?: string | null, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canUpdateAllSettings: boolean, canAccessAllTools: boolean, permissionFlags: Array<string>, objectPermissions: Array<{ __typename?: 'MarketplaceAppRoleObjectPermission', objectUniversalIdentifier: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null }>, fieldPermissions: Array<{ __typename?: 'MarketplaceAppRoleFieldPermission', objectUniversalIdentifier: string, fieldUniversalIdentifier: string, canReadFieldValue?: boolean | null, canUpdateFieldValue?: boolean | null }> } | null }> };
|
||||
export type FindManyMarketplaceAppsQuery = { __typename?: 'Query', findManyMarketplaceApps: Array<{ __typename?: 'MarketplaceApp', id: string, name: string, description: string, icon: string, version: string, author: string, category: string, logo?: string | null, screenshots: Array<string>, aboutDescription: string, providers: Array<string>, websiteUrl?: string | null, termsUrl?: string | null, sourcePackage?: string | null, objects: Array<{ __typename?: 'MarketplaceAppObject', universalIdentifier: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, fields: Array<{ __typename?: 'MarketplaceAppField', universalIdentifier?: string | null, name: string, type: string, label: string, description?: string | null, icon?: string | null }> }>, fields: Array<{ __typename?: 'MarketplaceAppField', name: string, type: string, label: string, description?: string | null, icon?: string | null, objectUniversalIdentifier?: string | null }>, logicFunctions: Array<{ __typename?: 'MarketplaceAppLogicFunction', name: string, description?: string | null, timeoutSeconds?: number | null }>, frontComponents: Array<{ __typename?: 'MarketplaceAppFrontComponent', name: string, description?: string | null }>, defaultRole?: { __typename?: 'MarketplaceAppDefaultRole', id: string, label: string, description?: string | null, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canUpdateAllSettings: boolean, canAccessAllTools: boolean, permissionFlags: Array<string>, objectPermissions: Array<{ __typename?: 'MarketplaceAppRoleObjectPermission', objectUniversalIdentifier: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null }>, fieldPermissions: Array<{ __typename?: 'MarketplaceAppRoleFieldPermission', objectUniversalIdentifier: string, fieldUniversalIdentifier: string, canReadFieldValue?: boolean | null, canUpdateFieldValue?: boolean | null }> } | null }> };
|
||||
|
||||
export type NavigationMenuItemFieldsFragment = { __typename?: 'NavigationMenuItem', id: string, userWorkspaceId?: string | null, targetRecordId?: string | null, targetObjectMetadataId?: string | null, viewId?: string | null, folderId?: string | null, name?: string | null, link?: string | null, icon?: string | null, color?: string | null, position: number, applicationId?: string | null, createdAt: string, updatedAt: string };
|
||||
|
||||
|
|
@ -6464,7 +6544,7 @@ export type GetSystemHealthStatusQueryVariables = Exact<{ [key: string]: never;
|
|||
|
||||
export type GetSystemHealthStatusQuery = { __typename?: 'Query', getSystemHealthStatus: { __typename?: 'SystemHealth', services: Array<{ __typename?: 'SystemHealthService', id: HealthIndicatorId, label: string, status: AdminPanelHealthServiceStatus }> } };
|
||||
|
||||
export type ApplicationRegistrationFragmentFragment = { __typename?: 'ApplicationRegistration', id: string, universalIdentifier: string, name: string, description?: string | null, logoUrl?: string | null, author?: string | null, oAuthClientId: string, oAuthRedirectUris: Array<string>, oAuthScopes: Array<string>, websiteUrl?: string | null, termsUrl?: string | null, createdAt: string, updatedAt: string };
|
||||
export type ApplicationRegistrationFragmentFragment = { __typename?: 'ApplicationRegistration', id: string, universalIdentifier: string, name: string, description?: string | null, logoUrl?: string | null, author?: string | null, oAuthClientId: string, oAuthRedirectUris: Array<string>, oAuthScopes: Array<string>, sourceType: AppRegistrationSourceType, sourcePackage?: string | null, latestAvailableVersion?: string | null, websiteUrl?: string | null, termsUrl?: string | null, createdAt: string, updatedAt: string };
|
||||
|
||||
export type DeleteApplicationRegistrationMutationVariables = Exact<{
|
||||
id: Scalars['String'];
|
||||
|
|
@ -6485,7 +6565,7 @@ export type UpdateApplicationRegistrationMutationVariables = Exact<{
|
|||
}>;
|
||||
|
||||
|
||||
export type UpdateApplicationRegistrationMutation = { __typename?: 'Mutation', updateApplicationRegistration: { __typename?: 'ApplicationRegistration', id: string, universalIdentifier: string, name: string, description?: string | null, logoUrl?: string | null, author?: string | null, oAuthClientId: string, oAuthRedirectUris: Array<string>, oAuthScopes: Array<string>, websiteUrl?: string | null, termsUrl?: string | null, createdAt: string, updatedAt: string } };
|
||||
export type UpdateApplicationRegistrationMutation = { __typename?: 'Mutation', updateApplicationRegistration: { __typename?: 'ApplicationRegistration', id: string, universalIdentifier: string, name: string, description?: string | null, logoUrl?: string | null, author?: string | null, oAuthClientId: string, oAuthRedirectUris: Array<string>, oAuthScopes: Array<string>, sourceType: AppRegistrationSourceType, sourcePackage?: string | null, latestAvailableVersion?: string | null, websiteUrl?: string | null, termsUrl?: string | null, createdAt: string, updatedAt: string } };
|
||||
|
||||
export type UpdateApplicationRegistrationVariableMutationVariables = Exact<{
|
||||
input: UpdateApplicationRegistrationVariableInput;
|
||||
|
|
@ -6518,14 +6598,14 @@ export type FindApplicationRegistrationVariablesQuery = { __typename?: 'Query',
|
|||
export type FindManyApplicationRegistrationsQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type FindManyApplicationRegistrationsQuery = { __typename?: 'Query', findManyApplicationRegistrations: Array<{ __typename?: 'ApplicationRegistration', id: string, universalIdentifier: string, name: string, description?: string | null, logoUrl?: string | null, author?: string | null, oAuthClientId: string, oAuthRedirectUris: Array<string>, oAuthScopes: Array<string>, websiteUrl?: string | null, termsUrl?: string | null, createdAt: string, updatedAt: string }> };
|
||||
export type FindManyApplicationRegistrationsQuery = { __typename?: 'Query', findManyApplicationRegistrations: Array<{ __typename?: 'ApplicationRegistration', id: string, universalIdentifier: string, name: string, description?: string | null, logoUrl?: string | null, author?: string | null, oAuthClientId: string, oAuthRedirectUris: Array<string>, oAuthScopes: Array<string>, sourceType: AppRegistrationSourceType, sourcePackage?: string | null, latestAvailableVersion?: string | null, websiteUrl?: string | null, termsUrl?: string | null, createdAt: string, updatedAt: string }> };
|
||||
|
||||
export type FindOneApplicationRegistrationQueryVariables = Exact<{
|
||||
id: Scalars['String'];
|
||||
}>;
|
||||
|
||||
|
||||
export type FindOneApplicationRegistrationQuery = { __typename?: 'Query', findOneApplicationRegistration: { __typename?: 'ApplicationRegistration', id: string, universalIdentifier: string, name: string, description?: string | null, logoUrl?: string | null, author?: string | null, oAuthClientId: string, oAuthRedirectUris: Array<string>, oAuthScopes: Array<string>, websiteUrl?: string | null, termsUrl?: string | null, createdAt: string, updatedAt: string } };
|
||||
export type FindOneApplicationRegistrationQuery = { __typename?: 'Query', findOneApplicationRegistration: { __typename?: 'ApplicationRegistration', id: string, universalIdentifier: string, name: string, description?: string | null, logoUrl?: string | null, author?: string | null, oAuthClientId: string, oAuthRedirectUris: Array<string>, oAuthScopes: Array<string>, sourceType: AppRegistrationSourceType, sourcePackage?: string | null, latestAvailableVersion?: string | null, websiteUrl?: string | null, termsUrl?: string | null, createdAt: string, updatedAt: string } };
|
||||
|
||||
export type UninstallApplicationMutationVariables = Exact<{
|
||||
universalIdentifier: Scalars['String'];
|
||||
|
|
@ -7422,6 +7502,12 @@ export const ApplicationFieldsFragmentDoc = gql`
|
|||
description
|
||||
version
|
||||
universalIdentifier
|
||||
applicationRegistrationId
|
||||
applicationRegistration {
|
||||
id
|
||||
latestAvailableVersion
|
||||
sourceType
|
||||
}
|
||||
canBeUninstalled
|
||||
defaultRoleId
|
||||
settingsCustomTabFrontComponentId
|
||||
|
|
@ -7790,6 +7876,7 @@ export const MarketplaceAppFieldsFragmentDoc = gql`
|
|||
name
|
||||
description
|
||||
}
|
||||
sourcePackage
|
||||
defaultRole {
|
||||
id
|
||||
label
|
||||
|
|
@ -7856,6 +7943,9 @@ export const ApplicationRegistrationFragmentFragmentDoc = gql`
|
|||
oAuthClientId
|
||||
oAuthRedirectUris
|
||||
oAuthScopes
|
||||
sourceType
|
||||
sourcePackage
|
||||
latestAvailableVersion
|
||||
websiteUrl
|
||||
termsUrl
|
||||
createdAt
|
||||
|
|
@ -9283,6 +9373,12 @@ export const FindManyApplicationsDocument = gql`
|
|||
name
|
||||
description
|
||||
version
|
||||
applicationRegistrationId
|
||||
applicationRegistration {
|
||||
id
|
||||
latestAvailableVersion
|
||||
sourceType
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
|
@ -11329,6 +11425,144 @@ export function useGetLogicFunctionSourceCodeLazyQuery(baseOptions?: Apollo.Lazy
|
|||
export type GetLogicFunctionSourceCodeQueryHookResult = ReturnType<typeof useGetLogicFunctionSourceCodeQuery>;
|
||||
export type GetLogicFunctionSourceCodeLazyQueryHookResult = ReturnType<typeof useGetLogicFunctionSourceCodeLazyQuery>;
|
||||
export type GetLogicFunctionSourceCodeQueryResult = Apollo.QueryResult<GetLogicFunctionSourceCodeQuery, GetLogicFunctionSourceCodeQueryVariables>;
|
||||
export const InstallMarketplaceAppDocument = gql`
|
||||
mutation InstallMarketplaceApp($universalIdentifier: String!, $version: String) {
|
||||
installMarketplaceApp(
|
||||
universalIdentifier: $universalIdentifier
|
||||
version: $version
|
||||
)
|
||||
}
|
||||
`;
|
||||
export type InstallMarketplaceAppMutationFn = Apollo.MutationFunction<InstallMarketplaceAppMutation, InstallMarketplaceAppMutationVariables>;
|
||||
|
||||
/**
|
||||
* __useInstallMarketplaceAppMutation__
|
||||
*
|
||||
* To run a mutation, you first call `useInstallMarketplaceAppMutation` within a React component and pass it any options that fit your needs.
|
||||
* When your component renders, `useInstallMarketplaceAppMutation` returns a tuple that includes:
|
||||
* - A mutate function that you can call at any time to execute the mutation
|
||||
* - An object with fields that represent the current status of the mutation's execution
|
||||
*
|
||||
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||
*
|
||||
* @example
|
||||
* const [installMarketplaceAppMutation, { data, loading, error }] = useInstallMarketplaceAppMutation({
|
||||
* variables: {
|
||||
* universalIdentifier: // value for 'universalIdentifier'
|
||||
* version: // value for 'version'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useInstallMarketplaceAppMutation(baseOptions?: Apollo.MutationHookOptions<InstallMarketplaceAppMutation, InstallMarketplaceAppMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<InstallMarketplaceAppMutation, InstallMarketplaceAppMutationVariables>(InstallMarketplaceAppDocument, options);
|
||||
}
|
||||
export type InstallMarketplaceAppMutationHookResult = ReturnType<typeof useInstallMarketplaceAppMutation>;
|
||||
export type InstallMarketplaceAppMutationResult = Apollo.MutationResult<InstallMarketplaceAppMutation>;
|
||||
export type InstallMarketplaceAppMutationOptions = Apollo.BaseMutationOptions<InstallMarketplaceAppMutation, InstallMarketplaceAppMutationVariables>;
|
||||
export const InstallNpmAppDocument = gql`
|
||||
mutation InstallNpmApp($packageName: String!, $version: String) {
|
||||
installNpmApp(packageName: $packageName, version: $version)
|
||||
}
|
||||
`;
|
||||
export type InstallNpmAppMutationFn = Apollo.MutationFunction<InstallNpmAppMutation, InstallNpmAppMutationVariables>;
|
||||
|
||||
/**
|
||||
* __useInstallNpmAppMutation__
|
||||
*
|
||||
* To run a mutation, you first call `useInstallNpmAppMutation` within a React component and pass it any options that fit your needs.
|
||||
* When your component renders, `useInstallNpmAppMutation` returns a tuple that includes:
|
||||
* - A mutate function that you can call at any time to execute the mutation
|
||||
* - An object with fields that represent the current status of the mutation's execution
|
||||
*
|
||||
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||
*
|
||||
* @example
|
||||
* const [installNpmAppMutation, { data, loading, error }] = useInstallNpmAppMutation({
|
||||
* variables: {
|
||||
* packageName: // value for 'packageName'
|
||||
* version: // value for 'version'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useInstallNpmAppMutation(baseOptions?: Apollo.MutationHookOptions<InstallNpmAppMutation, InstallNpmAppMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<InstallNpmAppMutation, InstallNpmAppMutationVariables>(InstallNpmAppDocument, options);
|
||||
}
|
||||
export type InstallNpmAppMutationHookResult = ReturnType<typeof useInstallNpmAppMutation>;
|
||||
export type InstallNpmAppMutationResult = Apollo.MutationResult<InstallNpmAppMutation>;
|
||||
export type InstallNpmAppMutationOptions = Apollo.BaseMutationOptions<InstallNpmAppMutation, InstallNpmAppMutationVariables>;
|
||||
export const UpgradeApplicationDocument = gql`
|
||||
mutation UpgradeApplication($appRegistrationId: String!, $targetVersion: String!) {
|
||||
upgradeApplication(
|
||||
appRegistrationId: $appRegistrationId
|
||||
targetVersion: $targetVersion
|
||||
)
|
||||
}
|
||||
`;
|
||||
export type UpgradeApplicationMutationFn = Apollo.MutationFunction<UpgradeApplicationMutation, UpgradeApplicationMutationVariables>;
|
||||
|
||||
/**
|
||||
* __useUpgradeApplicationMutation__
|
||||
*
|
||||
* To run a mutation, you first call `useUpgradeApplicationMutation` within a React component and pass it any options that fit your needs.
|
||||
* When your component renders, `useUpgradeApplicationMutation` returns a tuple that includes:
|
||||
* - A mutate function that you can call at any time to execute the mutation
|
||||
* - An object with fields that represent the current status of the mutation's execution
|
||||
*
|
||||
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||
*
|
||||
* @example
|
||||
* const [upgradeApplicationMutation, { data, loading, error }] = useUpgradeApplicationMutation({
|
||||
* variables: {
|
||||
* appRegistrationId: // value for 'appRegistrationId'
|
||||
* targetVersion: // value for 'targetVersion'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useUpgradeApplicationMutation(baseOptions?: Apollo.MutationHookOptions<UpgradeApplicationMutation, UpgradeApplicationMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<UpgradeApplicationMutation, UpgradeApplicationMutationVariables>(UpgradeApplicationDocument, options);
|
||||
}
|
||||
export type UpgradeApplicationMutationHookResult = ReturnType<typeof useUpgradeApplicationMutation>;
|
||||
export type UpgradeApplicationMutationResult = Apollo.MutationResult<UpgradeApplicationMutation>;
|
||||
export type UpgradeApplicationMutationOptions = Apollo.BaseMutationOptions<UpgradeApplicationMutation, UpgradeApplicationMutationVariables>;
|
||||
export const UploadAppTarballDocument = gql`
|
||||
mutation UploadAppTarball($file: Upload!, $universalIdentifier: String) {
|
||||
uploadAppTarball(file: $file, universalIdentifier: $universalIdentifier) {
|
||||
id
|
||||
universalIdentifier
|
||||
name
|
||||
}
|
||||
}
|
||||
`;
|
||||
export type UploadAppTarballMutationFn = Apollo.MutationFunction<UploadAppTarballMutation, UploadAppTarballMutationVariables>;
|
||||
|
||||
/**
|
||||
* __useUploadAppTarballMutation__
|
||||
*
|
||||
* To run a mutation, you first call `useUploadAppTarballMutation` within a React component and pass it any options that fit your needs.
|
||||
* When your component renders, `useUploadAppTarballMutation` returns a tuple that includes:
|
||||
* - A mutate function that you can call at any time to execute the mutation
|
||||
* - An object with fields that represent the current status of the mutation's execution
|
||||
*
|
||||
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||
*
|
||||
* @example
|
||||
* const [uploadAppTarballMutation, { data, loading, error }] = useUploadAppTarballMutation({
|
||||
* variables: {
|
||||
* file: // value for 'file'
|
||||
* universalIdentifier: // value for 'universalIdentifier'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useUploadAppTarballMutation(baseOptions?: Apollo.MutationHookOptions<UploadAppTarballMutation, UploadAppTarballMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<UploadAppTarballMutation, UploadAppTarballMutationVariables>(UploadAppTarballDocument, options);
|
||||
}
|
||||
export type UploadAppTarballMutationHookResult = ReturnType<typeof useUploadAppTarballMutation>;
|
||||
export type UploadAppTarballMutationResult = Apollo.MutationResult<UploadAppTarballMutation>;
|
||||
export type UploadAppTarballMutationOptions = Apollo.BaseMutationOptions<UploadAppTarballMutation, UploadAppTarballMutationVariables>;
|
||||
export const FindManyMarketplaceAppsDocument = gql`
|
||||
query FindManyMarketplaceApps {
|
||||
findManyMarketplaceApps {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,12 @@ export const APPLICATION_FRAGMENT = gql`
|
|||
description
|
||||
version
|
||||
universalIdentifier
|
||||
applicationRegistrationId
|
||||
applicationRegistration {
|
||||
id
|
||||
latestAvailableVersion
|
||||
sourceType
|
||||
}
|
||||
canBeUninstalled
|
||||
defaultRoleId
|
||||
settingsCustomTabFrontComponentId
|
||||
|
|
|
|||
|
|
@ -7,6 +7,12 @@ export const FIND_MANY_APPLICATIONS = gql`
|
|||
name
|
||||
description
|
||||
version
|
||||
applicationRegistrationId
|
||||
applicationRegistration {
|
||||
id
|
||||
latestAvailableVersion
|
||||
sourceType
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export const MARKETPLACE_APP_FRAGMENT = gql`
|
|||
name
|
||||
description
|
||||
}
|
||||
sourcePackage
|
||||
defaultRole {
|
||||
id
|
||||
label
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
import gql from 'graphql-tag';
|
||||
|
||||
export const INSTALL_MARKETPLACE_APP = gql`
|
||||
mutation InstallMarketplaceApp() {
|
||||
installMarketplaceApp()
|
||||
mutation InstallMarketplaceApp(
|
||||
$universalIdentifier: String!
|
||||
$version: String
|
||||
) {
|
||||
installMarketplaceApp(
|
||||
universalIdentifier: $universalIdentifier
|
||||
version: $version
|
||||
)
|
||||
}
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
import gql from 'graphql-tag';
|
||||
|
||||
export const INSTALL_NPM_APP = gql`
|
||||
mutation InstallNpmApp($packageName: String!, $version: String) {
|
||||
installNpmApp(packageName: $packageName, version: $version)
|
||||
}
|
||||
`;
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import gql from 'graphql-tag';
|
||||
|
||||
export const UPGRADE_APPLICATION = gql`
|
||||
mutation UpgradeApplication(
|
||||
$appRegistrationId: String!
|
||||
$targetVersion: String!
|
||||
) {
|
||||
upgradeApplication(
|
||||
appRegistrationId: $appRegistrationId
|
||||
targetVersion: $targetVersion
|
||||
)
|
||||
}
|
||||
`;
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { gql } from '@apollo/client';
|
||||
|
||||
export const UPLOAD_APP_TARBALL = gql`
|
||||
mutation UploadAppTarball($file: Upload!, $universalIdentifier: String) {
|
||||
uploadAppTarball(file: $file, universalIdentifier: $universalIdentifier) {
|
||||
id
|
||||
universalIdentifier
|
||||
name
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
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';
|
||||
|
||||
export const useInstallApp = <TVariables extends Record<string, unknown>>(
|
||||
mutationFn: (options: {
|
||||
variables: TVariables;
|
||||
}) => Promise<{ data?: Record<string, unknown> | null }>,
|
||||
) => {
|
||||
const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar();
|
||||
const [isInstalling, setIsInstalling] = useState(false);
|
||||
|
||||
const install = async (variables: TVariables) => {
|
||||
setIsInstalling(true);
|
||||
|
||||
try {
|
||||
const result = await mutationFn({ variables });
|
||||
|
||||
if (isDefined(result.data)) {
|
||||
enqueueSuccessSnackBar({
|
||||
message: t`Application installed successfully.`,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch {
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Failed to install the application.`,
|
||||
});
|
||||
|
||||
return false;
|
||||
} finally {
|
||||
setIsInstalling(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { install, isInstalling };
|
||||
};
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
export const useInstallMarketplaceApp = () => {
|
||||
const install = async () => {
|
||||
return true;
|
||||
};
|
||||
import { useInstallApp } from '~/modules/marketplace/hooks/useInstallApp';
|
||||
import { useInstallMarketplaceAppMutation } from '~/generated-metadata/graphql';
|
||||
|
||||
return {
|
||||
install,
|
||||
};
|
||||
export const useInstallMarketplaceApp = () => {
|
||||
const [installMarketplaceAppMutation] = useInstallMarketplaceAppMutation();
|
||||
|
||||
return useInstallApp<{
|
||||
universalIdentifier: string;
|
||||
version?: string;
|
||||
}>(installMarketplaceAppMutation);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
import { useMutation } from '@apollo/client';
|
||||
|
||||
import { useInstallApp } from '~/modules/marketplace/hooks/useInstallApp';
|
||||
import { INSTALL_NPM_APP } from '~/modules/marketplace/graphql/mutations/installNpmApp';
|
||||
|
||||
export const useInstallNpmApp = () => {
|
||||
const [installNpmAppMutation] = useMutation(INSTALL_NPM_APP);
|
||||
|
||||
return useInstallApp<{
|
||||
packageName: string;
|
||||
version?: string;
|
||||
}>(installNpmAppMutation);
|
||||
};
|
||||
|
|
@ -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 };
|
||||
};
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { useMutation } from '@apollo/client';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useState } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { UPLOAD_APP_TARBALL } from '~/modules/marketplace/graphql/mutations/uploadAppTarball';
|
||||
|
||||
type UploadResult =
|
||||
| {
|
||||
success: true;
|
||||
universalIdentifier: string;
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
};
|
||||
|
||||
export const useUploadAppTarball = () => {
|
||||
const [uploadAppTarball] = useMutation(UPLOAD_APP_TARBALL);
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const upload = async (file: File): Promise<UploadResult> => {
|
||||
setIsUploading(true);
|
||||
|
||||
try {
|
||||
const result = await uploadAppTarball({
|
||||
variables: { file },
|
||||
});
|
||||
|
||||
const registration = result.data?.uploadAppTarball;
|
||||
|
||||
if (!isDefined(registration?.universalIdentifier)) {
|
||||
enqueueErrorSnackBar({ message: t`Upload failed.` });
|
||||
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
universalIdentifier: registration.universalIdentifier,
|
||||
};
|
||||
} catch {
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Failed to upload tarball.`,
|
||||
});
|
||||
|
||||
return { success: false };
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { upload, isUploading };
|
||||
};
|
||||
|
|
@ -11,6 +11,9 @@ export const APPLICATION_REGISTRATION_FRAGMENT = gql`
|
|||
oAuthClientId
|
||||
oAuthRedirectUris
|
||||
oAuthScopes
|
||||
sourceType
|
||||
sourcePackage
|
||||
latestAvailableVersion
|
||||
websiteUrl
|
||||
termsUrl
|
||||
createdAt
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import { Button } from 'twenty-ui/input';
|
|||
import { Section } from 'twenty-ui/layout';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { PermissionFlagType } from '~/generated-metadata/graphql';
|
||||
import { useMarketplaceApps } from '~/pages/settings/applications/hooks/useMarketplaceApps';
|
||||
import { useMarketplaceApps } from '~/modules/marketplace/hooks/useMarketplaceApps';
|
||||
import { SettingsApplicationPermissionsTab } from '~/pages/settings/applications/tabs/SettingsApplicationPermissionsTab';
|
||||
import { SettingsAvailableApplicationDetailContentTab } from '~/pages/settings/applications/tabs/SettingsAvailableApplicationDetailContentTab';
|
||||
|
||||
|
|
@ -248,7 +248,7 @@ export const SettingsAvailableApplicationDetails = () => {
|
|||
const [selectedScreenshotIndex, setSelectedScreenshotIndex] = useState(0);
|
||||
|
||||
const { data: marketplaceApps } = useMarketplaceApps();
|
||||
const { install } = useInstallMarketplaceApp();
|
||||
const { install, isInstalling } = useInstallMarketplaceApp();
|
||||
const canInstallMarketplaceApps = useHasPermissionFlag(
|
||||
PermissionFlagType.MARKETPLACE_APPS,
|
||||
);
|
||||
|
|
@ -259,7 +259,9 @@ export const SettingsAvailableApplicationDetails = () => {
|
|||
|
||||
const handleInstall = async () => {
|
||||
if (isDefined(application)) {
|
||||
await install();
|
||||
await install({
|
||||
universalIdentifier: application.id,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -460,10 +462,11 @@ export const SettingsAvailableApplicationDetails = () => {
|
|||
{canInstallMarketplaceApps && (
|
||||
<Button
|
||||
Icon={IconDownload}
|
||||
title={t`Install`}
|
||||
title={isInstalling ? t`Installing...` : t`Install`}
|
||||
variant="primary"
|
||||
accent="blue"
|
||||
onClick={handleInstall}
|
||||
disabled={isInstalling}
|
||||
/>
|
||||
)}
|
||||
</StyledHeader>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
import { styled } from '@linaria/react';
|
||||
|
||||
import { ModalStatefulWrapper } from '@/ui/layout/modal/components/ModalStatefulWrapper';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import {
|
||||
type ModalOverlay,
|
||||
type ModalPadding,
|
||||
type ModalSize,
|
||||
Section,
|
||||
} from 'twenty-ui/layout';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
type StyledAppModalBaseProps = React.PropsWithChildren<{
|
||||
modalId: string;
|
||||
size?: ModalSize;
|
||||
padding?: ModalPadding;
|
||||
overlay?: ModalOverlay;
|
||||
dataGloballyPreventClickOutside?: boolean;
|
||||
}>;
|
||||
|
||||
type StyledAppModalProps = StyledAppModalBaseProps &
|
||||
(
|
||||
| { isClosable: true; onClose?: () => void }
|
||||
| { isClosable?: false; onClose?: never }
|
||||
);
|
||||
|
||||
const ModalWrapper = ({
|
||||
modalId,
|
||||
children,
|
||||
size,
|
||||
padding,
|
||||
overlay,
|
||||
dataGloballyPreventClickOutside,
|
||||
isClosable,
|
||||
onClose,
|
||||
}: StyledAppModalBaseProps & { isClosable: boolean; onClose?: () => void }) =>
|
||||
isClosable ? (
|
||||
<ModalStatefulWrapper
|
||||
modalInstanceId={modalId}
|
||||
isClosable={true}
|
||||
onClose={onClose}
|
||||
size={size}
|
||||
padding={padding}
|
||||
overlay={overlay}
|
||||
smallBorderRadius
|
||||
narrowWidth
|
||||
autoHeight
|
||||
dataGloballyPreventClickOutside={dataGloballyPreventClickOutside}
|
||||
>
|
||||
{children}
|
||||
</ModalStatefulWrapper>
|
||||
) : (
|
||||
<ModalStatefulWrapper
|
||||
modalInstanceId={modalId}
|
||||
isClosable={false}
|
||||
size={size}
|
||||
padding={padding}
|
||||
overlay={overlay}
|
||||
smallBorderRadius
|
||||
narrowWidth
|
||||
autoHeight
|
||||
dataGloballyPreventClickOutside={dataGloballyPreventClickOutside}
|
||||
>
|
||||
{children}
|
||||
</ModalStatefulWrapper>
|
||||
);
|
||||
|
||||
export const StyledAppModal = (props: StyledAppModalProps) => (
|
||||
<ModalWrapper
|
||||
modalId={props.modalId}
|
||||
size={props.size}
|
||||
padding={props.padding}
|
||||
overlay={props.overlay}
|
||||
dataGloballyPreventClickOutside={props.dataGloballyPreventClickOutside}
|
||||
isClosable={props.isClosable ?? false}
|
||||
onClose={props.isClosable ? props.onClose : undefined}
|
||||
>
|
||||
{props.children}
|
||||
</ModalWrapper>
|
||||
);
|
||||
|
||||
export const StyledAppModalButton = styled(Button)`
|
||||
box-sizing: border-box;
|
||||
justify-content: center;
|
||||
margin-top: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
export const StyledAppModalTitle = styled.div`
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
export const StyledAppModalSection = styled(Section)`
|
||||
margin-bottom: ${themeCssVariables.spacing[6]};
|
||||
`;
|
||||
|
|
@ -1,15 +1,18 @@
|
|||
import { styled } from '@linaria/react';
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { TableCell } from '@/ui/layout/table/components/TableCell';
|
||||
import { TableRow } from '@/ui/layout/table/components/TableRow';
|
||||
import { OverflowingTextWithTooltip } from 'twenty-ui/display';
|
||||
import { Tag } from 'twenty-ui/components';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { type ApplicationWithoutRelation } from '~/pages/settings/applications/types/applicationWithoutRelation';
|
||||
|
||||
export type SettingsApplicationTableRowProps = {
|
||||
action: ReactNode;
|
||||
application: ApplicationWithoutRelation;
|
||||
hasUpdate?: boolean;
|
||||
link?: string;
|
||||
};
|
||||
|
||||
|
|
@ -29,9 +32,17 @@ const StyledActionTableCell = styled(TableCell)`
|
|||
padding-right: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
const StyledDescriptionCell = styled(TableCell)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
export const SettingsApplicationTableRow = ({
|
||||
action,
|
||||
application,
|
||||
hasUpdate,
|
||||
link,
|
||||
}: SettingsApplicationTableRowProps) => {
|
||||
return (
|
||||
|
|
@ -39,9 +50,12 @@ export const SettingsApplicationTableRow = ({
|
|||
<StyledNameTableCell>
|
||||
<OverflowingTextWithTooltip text={application.name} />
|
||||
</StyledNameTableCell>
|
||||
<TableCell>
|
||||
<StyledDescriptionCell>
|
||||
<OverflowingTextWithTooltip text={application.description} />
|
||||
</TableCell>
|
||||
{hasUpdate === true && (
|
||||
<Tag color="blue" text={t`Update`} weight="medium" />
|
||||
)}
|
||||
</StyledDescriptionCell>
|
||||
<StyledActionTableCell>{action}</StyledActionTableCell>
|
||||
</StyledApplicationTableRow>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,24 +1,55 @@
|
|||
import { SettingsAdminTableCard } from '@/settings/admin-panel/components/SettingsAdminTableCard';
|
||||
import { SettingsAdminVersionDisplay } from '@/settings/admin-panel/components/SettingsAdminVersionDisplay';
|
||||
import { useUpgradeApplication } from '@/marketplace/hooks/useUpgradeApplication';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IconCircleDot, IconStatusChange } from 'twenty-ui/display';
|
||||
import type { Application } from '~/generated-metadata/graphql';
|
||||
import { IconCircleDot, IconStatusChange, IconUpload } from 'twenty-ui/display';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import {
|
||||
AppRegistrationSourceType,
|
||||
type Application,
|
||||
} from '~/generated-metadata/graphql';
|
||||
import { isNewerSemver } from '~/pages/settings/applications/utils/isNewerSemver';
|
||||
|
||||
export const SettingsApplicationVersionContainer = ({
|
||||
application,
|
||||
latestAvailableVersion,
|
||||
appRegistrationId,
|
||||
}: {
|
||||
application?: Omit<Application, 'objects' | 'universalIdentifier'> & {
|
||||
objects: { id: string }[];
|
||||
};
|
||||
latestAvailableVersion?: string | null;
|
||||
appRegistrationId?: string | null;
|
||||
}) => {
|
||||
const loading = !isDefined(application);
|
||||
|
||||
const currentVersion = application?.version;
|
||||
|
||||
// TODO fetch latestVersion of the application
|
||||
// if published on twenty public application registry
|
||||
const latestVersion = currentVersion;
|
||||
const sourceType = application?.applicationRegistration?.sourceType;
|
||||
const isNpmApp = sourceType === AppRegistrationSourceType.NPM;
|
||||
|
||||
const latestVersion = isNpmApp
|
||||
? (latestAvailableVersion ?? currentVersion)
|
||||
: currentVersion;
|
||||
|
||||
const hasUpdate =
|
||||
isNpmApp &&
|
||||
isDefined(latestAvailableVersion) &&
|
||||
isDefined(currentVersion) &&
|
||||
isNewerSemver(latestAvailableVersion, currentVersion);
|
||||
|
||||
const { upgrade, isUpgrading } = useUpgradeApplication();
|
||||
|
||||
const handleUpgrade = async () => {
|
||||
if (!isDefined(appRegistrationId) || !isDefined(latestAvailableVersion)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await upgrade({
|
||||
appRegistrationId,
|
||||
targetVersion: latestAvailableVersion,
|
||||
});
|
||||
};
|
||||
|
||||
const versionItems = [
|
||||
{
|
||||
|
|
@ -32,24 +63,44 @@ export const SettingsApplicationVersionContainer = ({
|
|||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
Icon: IconStatusChange,
|
||||
label: t`Latest version`,
|
||||
value: (
|
||||
<SettingsAdminVersionDisplay
|
||||
version={latestVersion}
|
||||
loading={loading}
|
||||
noVersionMessage={t`No latest version found`}
|
||||
/>
|
||||
),
|
||||
},
|
||||
...(isNpmApp
|
||||
? [
|
||||
{
|
||||
Icon: IconStatusChange,
|
||||
label: t`Latest version`,
|
||||
value: (
|
||||
<SettingsAdminVersionDisplay
|
||||
version={latestVersion}
|
||||
loading={loading}
|
||||
noVersionMessage={t`No latest version found`}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<SettingsAdminTableCard
|
||||
rounded
|
||||
items={versionItems}
|
||||
gridAutoColumns="3fr 8fr"
|
||||
/>
|
||||
<>
|
||||
<SettingsAdminTableCard
|
||||
rounded
|
||||
items={versionItems}
|
||||
gridAutoColumns="3fr 8fr"
|
||||
/>
|
||||
{hasUpdate && isDefined(appRegistrationId) && (
|
||||
<Button
|
||||
Icon={IconUpload}
|
||||
title={
|
||||
isUpgrading
|
||||
? t`Upgrading...`
|
||||
: t`Upgrade to ${latestAvailableVersion}`
|
||||
}
|
||||
variant="secondary"
|
||||
accent="blue"
|
||||
onClick={handleUpgrade}
|
||||
disabled={isUpgrading}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { H2Title, IconChevronRight, IconSearch } from 'twenty-ui/display';
|
||||
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
|
||||
import { getSettingsPath } from 'twenty-shared/utils';
|
||||
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Table } from '@/ui/layout/table/components/Table';
|
||||
|
|
@ -11,10 +11,12 @@ import {
|
|||
} from '~/pages/settings/applications/components/SettingsApplicationTableRow';
|
||||
import { useContext, useMemo, useState } from 'react';
|
||||
import { type ApplicationWithoutRelation } from '~/pages/settings/applications/types/applicationWithoutRelation';
|
||||
import { isNewerSemver } from '~/pages/settings/applications/utils/isNewerSemver';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
|
||||
import { ThemeContext } from 'twenty-ui/theme';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { AppRegistrationSourceType } from '~/generated-metadata/graphql';
|
||||
|
||||
const StyledTable = styled(Table)`
|
||||
margin-top: ${themeCssVariables.spacing[3]};
|
||||
|
|
@ -44,7 +46,7 @@ export const SettingsApplicationsTable = ({
|
|||
return applications.filter(
|
||||
(application) =>
|
||||
application.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
application.description
|
||||
(application.description ?? '')
|
||||
.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
|
@ -70,21 +72,37 @@ export const SettingsApplicationsTable = ({
|
|||
<TableHeader> {''}</TableHeader>
|
||||
<TableHeader />
|
||||
</StyledTableHeaderRow>
|
||||
{filteredApplications.map((application) => (
|
||||
<SettingsApplicationTableRow
|
||||
key={application.id}
|
||||
application={application}
|
||||
action={
|
||||
<IconChevronRight
|
||||
size={theme.icon.size.md}
|
||||
stroke={theme.icon.stroke.sm}
|
||||
/>
|
||||
}
|
||||
link={getSettingsPath(SettingsPath.ApplicationDetail, {
|
||||
applicationId: application.id,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
{filteredApplications.map((application) => {
|
||||
const isNpmApp =
|
||||
application.applicationRegistration?.sourceType ===
|
||||
AppRegistrationSourceType.NPM;
|
||||
|
||||
const latestVersion =
|
||||
application.applicationRegistration?.latestAvailableVersion;
|
||||
|
||||
const hasUpdate =
|
||||
isNpmApp &&
|
||||
isDefined(latestVersion) &&
|
||||
isDefined(application.version) &&
|
||||
isNewerSemver(latestVersion, application.version);
|
||||
|
||||
return (
|
||||
<SettingsApplicationTableRow
|
||||
key={application.id}
|
||||
application={application}
|
||||
hasUpdate={hasUpdate}
|
||||
action={
|
||||
<IconChevronRight
|
||||
size={theme.icon.size.md}
|
||||
stroke={theme.icon.stroke.sm}
|
||||
/>
|
||||
}
|
||||
link={getSettingsPath(SettingsPath.ApplicationDetail, {
|
||||
applicationId: application.id,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</StyledTable>
|
||||
</Section>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
import { styled } from '@linaria/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
|
||||
import { useModal } from '@/ui/layout/modal/hooks/useModal';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { H1Title, H1TitleFontColor } from 'twenty-ui/display';
|
||||
import { Section, SectionAlignment, SectionFontColor } from 'twenty-ui/layout';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { useInstallNpmApp } from '~/modules/marketplace/hooks/useInstallNpmApp';
|
||||
import { useFindManyApplicationsQuery } from '~/generated-metadata/graphql';
|
||||
import {
|
||||
StyledAppModal,
|
||||
StyledAppModalButton,
|
||||
StyledAppModalSection,
|
||||
StyledAppModalTitle,
|
||||
} from '~/pages/settings/applications/components/SettingsAppModalLayout';
|
||||
|
||||
export const INSTALL_NPM_APP_MODAL_ID = 'install-npm-app-modal';
|
||||
|
||||
const StyledInputGroup = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[3]};
|
||||
`;
|
||||
|
||||
export const SettingsInstallNpmAppModal = () => {
|
||||
const { t } = useLingui();
|
||||
const { closeModal } = useModal();
|
||||
const { install, isInstalling } = useInstallNpmApp();
|
||||
const { refetch } = useFindManyApplicationsQuery();
|
||||
|
||||
const [packageName, setPackageName] = useState('');
|
||||
const [version, setVersion] = useState('');
|
||||
|
||||
const isValid = packageName.trim().length > 0;
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await install({
|
||||
packageName: packageName.trim(),
|
||||
version: version.trim() || undefined,
|
||||
});
|
||||
|
||||
if (success) {
|
||||
await refetch();
|
||||
closeModal(INSTALL_NPM_APP_MODAL_ID);
|
||||
setPackageName('');
|
||||
setVersion('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
closeModal(INSTALL_NPM_APP_MODAL_ID);
|
||||
setPackageName('');
|
||||
setVersion('');
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledAppModal
|
||||
modalId={INSTALL_NPM_APP_MODAL_ID}
|
||||
isClosable={true}
|
||||
padding="large"
|
||||
dataGloballyPreventClickOutside
|
||||
>
|
||||
<StyledAppModalTitle>
|
||||
<H1Title
|
||||
title={t`Install from npm`}
|
||||
fontColor={H1TitleFontColor.Primary}
|
||||
/>
|
||||
</StyledAppModalTitle>
|
||||
<StyledAppModalSection
|
||||
alignment={SectionAlignment.Center}
|
||||
fontColor={SectionFontColor.Primary}
|
||||
>
|
||||
{t`Enter the npm package name of the application you want to install.`}
|
||||
</StyledAppModalSection>
|
||||
|
||||
<Section>
|
||||
<StyledInputGroup>
|
||||
<SettingsTextInput
|
||||
instanceId="npm-package-name-input"
|
||||
value={packageName}
|
||||
onChange={setPackageName}
|
||||
placeholder={t`e.g. @twentyhq/hello-world`}
|
||||
fullWidth
|
||||
disableHotkeys
|
||||
label={t`Package name`}
|
||||
autoFocusOnMount
|
||||
/>
|
||||
<SettingsTextInput
|
||||
instanceId="npm-version-input"
|
||||
value={version}
|
||||
onChange={setVersion}
|
||||
placeholder={t`latest`}
|
||||
fullWidth
|
||||
disableHotkeys
|
||||
label={t`Version (optional)`}
|
||||
/>
|
||||
</StyledInputGroup>
|
||||
</Section>
|
||||
|
||||
<StyledAppModalButton
|
||||
onClick={handleCancel}
|
||||
variant="secondary"
|
||||
title={t`Cancel`}
|
||||
fullWidth
|
||||
/>
|
||||
<StyledAppModalButton
|
||||
onClick={handleInstall}
|
||||
variant="secondary"
|
||||
accent="blue"
|
||||
title={t`Install`}
|
||||
disabled={!isValid || isInstalling}
|
||||
fullWidth
|
||||
/>
|
||||
</StyledAppModal>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
import { styled } from '@linaria/react';
|
||||
import { useRef } from 'react';
|
||||
|
||||
import { useModal } from '@/ui/layout/modal/hooks/useModal';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { H1Title, H1TitleFontColor } from 'twenty-ui/display';
|
||||
import { SectionAlignment, SectionFontColor } from 'twenty-ui/layout';
|
||||
import { useUploadAppTarball } from '~/modules/marketplace/hooks/useUploadAppTarball';
|
||||
import { useInstallMarketplaceApp } from '~/modules/marketplace/hooks/useInstallMarketplaceApp';
|
||||
import { useFindManyApplicationsQuery } from '~/generated-metadata/graphql';
|
||||
import {
|
||||
StyledAppModal,
|
||||
StyledAppModalButton,
|
||||
StyledAppModalSection,
|
||||
StyledAppModalTitle,
|
||||
} from '~/pages/settings/applications/components/SettingsAppModalLayout';
|
||||
|
||||
export const UPLOAD_TARBALL_MODAL_ID = 'upload-tarball-modal';
|
||||
|
||||
const StyledFileInput = styled.input`
|
||||
display: none;
|
||||
`;
|
||||
|
||||
export const SettingsUploadTarballModal = () => {
|
||||
const { t } = useLingui();
|
||||
const { closeModal } = useModal();
|
||||
const { upload, isUploading } = useUploadAppTarball();
|
||||
const { install } = useInstallMarketplaceApp();
|
||||
const { refetch } = useFindManyApplicationsQuery();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleSelectFile = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const file = event.target.files?.[0];
|
||||
|
||||
if (!isDefined(file)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const uploadResult = await upload(file);
|
||||
|
||||
if (uploadResult.success) {
|
||||
const installResult = await install({
|
||||
universalIdentifier: uploadResult.universalIdentifier,
|
||||
});
|
||||
|
||||
if (installResult) {
|
||||
await refetch();
|
||||
closeModal(UPLOAD_TARBALL_MODAL_ID);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (isDefined(fileInputRef.current)) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
closeModal(UPLOAD_TARBALL_MODAL_ID);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledAppModal
|
||||
modalId={UPLOAD_TARBALL_MODAL_ID}
|
||||
isClosable={true}
|
||||
padding="large"
|
||||
dataGloballyPreventClickOutside
|
||||
>
|
||||
<StyledAppModalTitle>
|
||||
<H1Title
|
||||
title={t`Upload tarball`}
|
||||
fontColor={H1TitleFontColor.Primary}
|
||||
/>
|
||||
</StyledAppModalTitle>
|
||||
<StyledAppModalSection
|
||||
alignment={SectionAlignment.Center}
|
||||
fontColor={SectionFontColor.Primary}
|
||||
>
|
||||
{t`Select a .tar.gz application package to upload and install.`}
|
||||
</StyledAppModalSection>
|
||||
|
||||
<StyledFileInput
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".tar.gz,.tgz"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
<StyledAppModalButton
|
||||
onClick={handleCancel}
|
||||
variant="secondary"
|
||||
title={t`Cancel`}
|
||||
fullWidth
|
||||
/>
|
||||
<StyledAppModalButton
|
||||
onClick={handleSelectFile}
|
||||
variant="secondary"
|
||||
accent="blue"
|
||||
title={t`Choose file`}
|
||||
disabled={isUploading}
|
||||
fullWidth
|
||||
/>
|
||||
</StyledAppModal>
|
||||
);
|
||||
};
|
||||
|
|
@ -36,6 +36,11 @@ export const SettingsApplicationDetailAboutTab = ({
|
|||
|
||||
const navigate = useNavigateSettings();
|
||||
|
||||
const registrationId = application?.applicationRegistrationId;
|
||||
|
||||
const latestAvailableVersion =
|
||||
application?.applicationRegistration?.latestAvailableVersion ?? null;
|
||||
|
||||
if (!isDefined(application)) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -80,7 +85,7 @@ export const SettingsApplicationDetailAboutTab = ({
|
|||
/>
|
||||
<SettingsTextInput
|
||||
instanceId={`application-description-${id}`}
|
||||
value={description}
|
||||
value={description ?? undefined}
|
||||
disabled
|
||||
fullWidth
|
||||
/>
|
||||
|
|
@ -90,7 +95,11 @@ export const SettingsApplicationDetailAboutTab = ({
|
|||
title={t`Version`}
|
||||
description={t`Version of the application`}
|
||||
/>
|
||||
<SettingsApplicationVersionContainer application={application} />
|
||||
<SettingsApplicationVersionContainer
|
||||
application={application}
|
||||
latestAvailableVersion={latestAvailableVersion}
|
||||
appRegistrationId={registrationId}
|
||||
/>
|
||||
</Section>
|
||||
{application.canBeUninstalled && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { SearchInput } from 'twenty-ui/input';
|
|||
import { Section } from 'twenty-ui/layout';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { SettingsAvailableApplicationCard } from '~/pages/settings/applications/components/SettingsAvailableApplicationCard';
|
||||
import { useMarketplaceApps } from '~/pages/settings/applications/hooks/useMarketplaceApps';
|
||||
import { useMarketplaceApps } from '~/modules/marketplace/hooks/useMarketplaceApps';
|
||||
|
||||
const StyledSearchInputContainer = styled.div`
|
||||
padding-bottom: ${themeCssVariables.spacing[2]};
|
||||
|
|
|
|||
|
|
@ -1,14 +1,99 @@
|
|||
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
|
||||
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
|
||||
import { useModal } from '@/ui/layout/modal/hooks/useModal';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { IconChevronDown, IconDownload, IconUpload } from 'twenty-ui/display';
|
||||
import { Button, ButtonGroup, IconButton } from 'twenty-ui/input';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { MenuItem } from 'twenty-ui/navigation';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { useFindManyApplicationsQuery } from '~/generated-metadata/graphql';
|
||||
import { SettingsApplicationsTable } from '~/pages/settings/applications/components/SettingsApplicationsTable';
|
||||
import {
|
||||
INSTALL_NPM_APP_MODAL_ID,
|
||||
SettingsInstallNpmAppModal,
|
||||
} from '~/pages/settings/applications/components/SettingsInstallNpmAppModal';
|
||||
import {
|
||||
SettingsUploadTarballModal,
|
||||
UPLOAD_TARBALL_MODAL_ID,
|
||||
} from '~/pages/settings/applications/components/SettingsUploadTarballModal';
|
||||
|
||||
const INSTALL_APP_DROPDOWN_ID = 'install-app-dropdown';
|
||||
|
||||
const StyledButtonGroupContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: ${themeCssVariables.spacing[4]};
|
||||
`;
|
||||
|
||||
export const SettingsApplicationsInstalledTab = () => {
|
||||
const { t } = useLingui();
|
||||
const { data } = useFindManyApplicationsQuery();
|
||||
const { openModal } = useModal();
|
||||
const { closeDropdown } = useCloseDropdown();
|
||||
|
||||
const applications = data?.findManyApplications ?? [];
|
||||
|
||||
if (applications.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const handleInstallFromNpm = () => {
|
||||
closeDropdown(INSTALL_APP_DROPDOWN_ID);
|
||||
openModal(INSTALL_NPM_APP_MODAL_ID);
|
||||
};
|
||||
|
||||
return <SettingsApplicationsTable applications={applications} />;
|
||||
const handleUploadTarball = () => {
|
||||
closeDropdown(INSTALL_APP_DROPDOWN_ID);
|
||||
openModal(UPLOAD_TARBALL_MODAL_ID);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{applications.length > 0 && (
|
||||
<SettingsApplicationsTable applications={applications} />
|
||||
)}
|
||||
|
||||
<Section>
|
||||
<StyledButtonGroupContainer>
|
||||
<ButtonGroup size="small" variant="secondary">
|
||||
<Button
|
||||
Icon={IconDownload}
|
||||
title={t`Install app`}
|
||||
onClick={handleInstallFromNpm}
|
||||
/>
|
||||
<Dropdown
|
||||
dropdownId={INSTALL_APP_DROPDOWN_ID}
|
||||
clickableComponent={
|
||||
<IconButton
|
||||
size="small"
|
||||
variant="secondary"
|
||||
Icon={IconChevronDown}
|
||||
position="right"
|
||||
/>
|
||||
}
|
||||
dropdownComponents={
|
||||
<DropdownContent>
|
||||
<DropdownMenuItemsContainer>
|
||||
<MenuItem
|
||||
LeftIcon={IconDownload}
|
||||
text={t`Install from npm`}
|
||||
onClick={handleInstallFromNpm}
|
||||
/>
|
||||
<MenuItem
|
||||
LeftIcon={IconUpload}
|
||||
text={t`Upload tarball`}
|
||||
onClick={handleUploadTarball}
|
||||
/>
|
||||
</DropdownMenuItemsContainer>
|
||||
</DropdownContent>
|
||||
}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
</StyledButtonGroupContainer>
|
||||
</Section>
|
||||
|
||||
<SettingsInstallNpmAppModal />
|
||||
<SettingsUploadTarballModal />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,5 +2,10 @@ import { type Application } from '~/generated-metadata/graphql';
|
|||
|
||||
export type ApplicationWithoutRelation = Pick<
|
||||
Application,
|
||||
'id' | 'name' | 'description'
|
||||
| 'id'
|
||||
| 'name'
|
||||
| 'description'
|
||||
| 'version'
|
||||
| 'applicationRegistrationId'
|
||||
| 'applicationRegistration'
|
||||
>;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
// Returns true if versionA is strictly greater than versionB (major.minor.patch)
|
||||
export const isNewerSemver = (versionA: string, versionB: string): boolean => {
|
||||
const partsA = versionA.split('.').map(Number);
|
||||
const partsB = versionB.split('.').map(Number);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const a = partsA[i] ?? 0;
|
||||
const b = partsB[i] ?? 0;
|
||||
|
||||
if (a > b) return true;
|
||||
if (a < b) return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
|
@ -79,6 +79,7 @@
|
|||
"dotenv": "^16.4.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"fast-glob": "^3.3.0",
|
||||
"form-data": "^4.0.5",
|
||||
"fs-extra": "^11.2.0",
|
||||
"graphql": "^16.8.1",
|
||||
"graphql-sse": "^2.5.4",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import chalk from 'chalk';
|
|||
import type { Command } from 'commander';
|
||||
import { AppBuildCommand } from './app/app-build';
|
||||
import { AppDevCommand } from './app/app-dev';
|
||||
import { AppPackCommand } from './app/app-pack';
|
||||
import { AppPushCommand } from './app/app-push';
|
||||
import { AppTypecheckCommand } from './app/app-typecheck';
|
||||
import { AppUninstallCommand } from './app/app-uninstall';
|
||||
import { AuthListCommand } from './auth/auth-list';
|
||||
|
|
@ -63,6 +65,8 @@ export const registerCommands = (program: Command): void => {
|
|||
// App commands
|
||||
const buildCommand = new AppBuildCommand();
|
||||
const devCommand = new AppDevCommand();
|
||||
const packCommand = new AppPackCommand();
|
||||
const pushCommand = new AppPushCommand();
|
||||
const typecheckCommand = new AppTypecheckCommand();
|
||||
const uninstallCommand = new AppUninstallCommand();
|
||||
const addCommand = new EntityAddCommand();
|
||||
|
|
@ -112,6 +116,28 @@ export const registerCommands = (program: Command): void => {
|
|||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command('app:pack [appPath]')
|
||||
.description('Build and pack the application into a .tgz tarball')
|
||||
.action(async (appPath) => {
|
||||
await packCommand.execute({ appPath: formatPath(appPath) });
|
||||
});
|
||||
|
||||
program
|
||||
.command('app:push [appPath]')
|
||||
.description(
|
||||
'Build, upload, and install a local application on a Twenty server (for air-gapped/dev deployments)',
|
||||
)
|
||||
.option('--server <url>', 'Twenty server URL')
|
||||
.option('--token <token>', 'Auth token for the server')
|
||||
.action(async (appPath, options) => {
|
||||
await pushCommand.execute({
|
||||
appPath: formatPath(appPath),
|
||||
server: options.server,
|
||||
token: options.token,
|
||||
});
|
||||
});
|
||||
|
||||
program
|
||||
.command('entity:add [entityType]')
|
||||
.option('--path <path>', 'Path in which the entity should be created.')
|
||||
|
|
|
|||
30
packages/twenty-sdk/src/cli/commands/app/app-pack.ts
Normal file
30
packages/twenty-sdk/src/cli/commands/app/app-pack.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { appPack } from '@/cli/public-operations/app-pack';
|
||||
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
|
||||
import chalk from 'chalk';
|
||||
|
||||
export type AppPackCommandOptions = {
|
||||
appPath?: string;
|
||||
};
|
||||
|
||||
export class AppPackCommand {
|
||||
async execute(options: AppPackCommandOptions): Promise<void> {
|
||||
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
|
||||
|
||||
console.log(chalk.blue('Building and packing application...'));
|
||||
console.log(chalk.gray(`App path: ${appPath}`));
|
||||
console.log('');
|
||||
|
||||
const result = await appPack({
|
||||
appPath,
|
||||
onProgress: (message) => console.log(chalk.gray(message)),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.error(chalk.red(result.error.message));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(chalk.green('✓ Application packed successfully'));
|
||||
console.log(chalk.gray(`Tarball: ${result.data.tarballPath}`));
|
||||
}
|
||||
}
|
||||
62
packages/twenty-sdk/src/cli/commands/app/app-push.ts
Normal file
62
packages/twenty-sdk/src/cli/commands/app/app-push.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { appPack } from '@/cli/public-operations/app-pack';
|
||||
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
|
||||
import { ApiService } from '@/cli/utilities/api/api-service';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs';
|
||||
|
||||
export type AppPushCommandOptions = {
|
||||
appPath?: string;
|
||||
server?: string;
|
||||
token?: string;
|
||||
};
|
||||
|
||||
export class AppPushCommand {
|
||||
async execute(options: AppPushCommandOptions): Promise<void> {
|
||||
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
|
||||
|
||||
console.log(chalk.blue('Building, packing, and pushing application...'));
|
||||
console.log(chalk.gray(`App path: ${appPath}`));
|
||||
console.log('');
|
||||
|
||||
const packResult = await appPack({
|
||||
appPath,
|
||||
onProgress: (message) => console.log(chalk.gray(message)),
|
||||
});
|
||||
|
||||
if (!packResult.success) {
|
||||
console.error(chalk.red(packResult.error.message));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { tarballPath } = packResult.data;
|
||||
|
||||
console.log(chalk.gray(`Uploading ${tarballPath}...`));
|
||||
|
||||
const tarballBuffer = fs.readFileSync(tarballPath);
|
||||
|
||||
const apiService = new ApiService({
|
||||
serverUrl: options.server,
|
||||
token: options.token,
|
||||
});
|
||||
|
||||
const uploadResult = await apiService.uploadAppTarball({ tarballBuffer });
|
||||
|
||||
if (!uploadResult.success) {
|
||||
console.error(chalk.red(`Upload failed: ${uploadResult.error}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(chalk.gray('Installing application...'));
|
||||
|
||||
const installResult = await apiService.installTarballApp({
|
||||
universalIdentifier: uploadResult.data.universalIdentifier,
|
||||
});
|
||||
|
||||
if (!installResult.success) {
|
||||
console.error(chalk.red(`Install failed: ${installResult.error}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(chalk.green('✓ Application pushed and installed successfully'));
|
||||
}
|
||||
}
|
||||
39
packages/twenty-sdk/src/cli/public-operations/app-pack.ts
Normal file
39
packages/twenty-sdk/src/cli/public-operations/app-pack.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { execSync } from 'child_process';
|
||||
import path from 'path';
|
||||
|
||||
import { appBuild, type AppBuildOptions } from './app-build';
|
||||
import { runSafe } from '@/cli/utilities/run-safe';
|
||||
import { APP_ERROR_CODES, type CommandResult } from './types';
|
||||
|
||||
export type AppPackResult = {
|
||||
tarballPath: string;
|
||||
};
|
||||
|
||||
const innerAppPack = async (
|
||||
options: AppBuildOptions,
|
||||
): Promise<CommandResult<AppPackResult>> => {
|
||||
const buildResult = await appBuild(options);
|
||||
|
||||
if (!buildResult.success) {
|
||||
return buildResult;
|
||||
}
|
||||
|
||||
options.onProgress?.('Packing tarball...');
|
||||
|
||||
const outputDir = path.join(options.appPath, '.twenty', 'output');
|
||||
|
||||
const packOutput = execSync('npm pack --pack-destination .', {
|
||||
cwd: outputDir,
|
||||
encoding: 'utf-8',
|
||||
}).trim();
|
||||
|
||||
const tarballName = packOutput.split('\n').pop()!;
|
||||
const tarballPath = path.join(outputDir, tarballName);
|
||||
|
||||
return { success: true, data: { tarballPath } };
|
||||
};
|
||||
|
||||
export const appPack = (
|
||||
options: AppBuildOptions,
|
||||
): Promise<CommandResult<AppPackResult>> =>
|
||||
runSafe(() => innerAppPack(options), APP_ERROR_CODES.SYNC_FAILED);
|
||||
|
|
@ -7,6 +7,8 @@ export type { AuthLogoutOptions } from './auth-logout';
|
|||
// App
|
||||
export { appBuild } from './app-build';
|
||||
export type { AppBuildOptions, AppBuildResult } from './app-build';
|
||||
export { appPack } from './app-pack';
|
||||
export type { AppPackResult } from './app-pack';
|
||||
export { appUninstall } from './app-uninstall';
|
||||
export type { AppUninstallOptions } from './app-uninstall';
|
||||
|
||||
|
|
|
|||
|
|
@ -14,18 +14,24 @@ export class ApiService {
|
|||
private client: AxiosInstance;
|
||||
private configService: ConfigService;
|
||||
|
||||
constructor(options?: { disableInterceptors: boolean }) {
|
||||
const { disableInterceptors = false } = options || {};
|
||||
constructor(options?: {
|
||||
disableInterceptors?: boolean;
|
||||
serverUrl?: string;
|
||||
token?: string;
|
||||
}) {
|
||||
const { disableInterceptors = false, serverUrl, token } = options || {};
|
||||
this.configService = new ConfigService();
|
||||
this.client = axios.create();
|
||||
|
||||
this.client.interceptors.request.use(async (config) => {
|
||||
const twentyConfig = await this.configService.getConfig();
|
||||
|
||||
config.baseURL = twentyConfig.apiUrl;
|
||||
config.baseURL = serverUrl ?? twentyConfig.apiUrl;
|
||||
|
||||
if (!config.headers.Authorization && twentyConfig.apiKey) {
|
||||
config.headers.Authorization = `Bearer ${twentyConfig.apiKey}`;
|
||||
const authToken = token ?? twentyConfig.apiKey;
|
||||
|
||||
if (!config.headers.Authorization && authToken) {
|
||||
config.headers.Authorization = `Bearer ${authToken}`;
|
||||
}
|
||||
|
||||
return config;
|
||||
|
|
@ -794,6 +800,140 @@ export class ApiService {
|
|||
);
|
||||
}
|
||||
|
||||
// TODO: Migrate to MetadataClient once available
|
||||
// (see https://github.com/twentyhq/core-team-issues/issues/2289)
|
||||
async uploadAppTarball({
|
||||
tarballBuffer,
|
||||
universalIdentifier,
|
||||
}: {
|
||||
tarballBuffer: Buffer;
|
||||
universalIdentifier?: string;
|
||||
}): Promise<
|
||||
ApiResponse<{
|
||||
id: string;
|
||||
universalIdentifier: string;
|
||||
name: string;
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
const mutation = `
|
||||
mutation UploadAppTarball($file: Upload!, $universalIdentifier: String) {
|
||||
uploadAppTarball(file: $file, universalIdentifier: $universalIdentifier) {
|
||||
id
|
||||
universalIdentifier
|
||||
name
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const operations = JSON.stringify({
|
||||
query: mutation,
|
||||
variables: {
|
||||
file: null,
|
||||
universalIdentifier: universalIdentifier ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
const map = JSON.stringify({
|
||||
'0': ['variables.file'],
|
||||
});
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('operations', operations);
|
||||
formData.append('map', map);
|
||||
formData.append(
|
||||
'0',
|
||||
new Blob([new Uint8Array(tarballBuffer)], {
|
||||
type: 'application/gzip',
|
||||
}),
|
||||
'app.tar.gz',
|
||||
);
|
||||
|
||||
const response: AxiosResponse = await this.client.post(
|
||||
'/metadata',
|
||||
formData,
|
||||
);
|
||||
|
||||
if (response.data.errors) {
|
||||
return {
|
||||
success: false,
|
||||
error: response.data.errors[0]?.message || 'Failed to upload tarball',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response.data.data.uploadAppTarball,
|
||||
};
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response.data?.errors?.[0]?.message || error.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async installTarballApp({
|
||||
universalIdentifier,
|
||||
}: {
|
||||
universalIdentifier: string;
|
||||
}): Promise<ApiResponse<boolean>> {
|
||||
try {
|
||||
const mutation = `
|
||||
mutation InstallMarketplaceApp($universalIdentifier: String!) {
|
||||
installMarketplaceApp(universalIdentifier: $universalIdentifier)
|
||||
}
|
||||
`;
|
||||
|
||||
const response: AxiosResponse = await this.client.post(
|
||||
'/metadata',
|
||||
{
|
||||
query: mutation,
|
||||
variables: { universalIdentifier },
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: '*/*',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (response.data.errors) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
response.data.errors[0]?.message || 'Failed to install application',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response.data.data.installMarketplaceApp,
|
||||
};
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response.data?.errors?.[0]?.message || error.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async uploadFile({
|
||||
filePath,
|
||||
builtHandlerPath,
|
||||
|
|
|
|||
|
|
@ -37,15 +37,16 @@ export default defineConfig(() => {
|
|||
}
|
||||
|
||||
const builtins = [
|
||||
'path',
|
||||
'child_process',
|
||||
'crypto',
|
||||
'fs',
|
||||
'fs/promises',
|
||||
'url',
|
||||
'crypto',
|
||||
'stream',
|
||||
'util',
|
||||
'os',
|
||||
'module',
|
||||
'os',
|
||||
'path',
|
||||
'stream',
|
||||
'url',
|
||||
'util',
|
||||
];
|
||||
|
||||
if (builtins.includes(id)) {
|
||||
|
|
|
|||
|
|
@ -172,6 +172,7 @@
|
|||
"semver": "7.6.3",
|
||||
"sharp": "0.32.6",
|
||||
"stripe": "19.3.1",
|
||||
"tar": "^7.5.9",
|
||||
"temporal-polyfill": "^0.3.0",
|
||||
"transliteration": "2.3.5",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
|
|
@ -221,6 +222,7 @@
|
|||
"@types/pluralize": "^0.0.33",
|
||||
"@types/psl": "^1.1.3",
|
||||
"@types/react": "^18.2.39",
|
||||
"@types/tar": "^7.0.87",
|
||||
"@types/unzipper": "^0",
|
||||
"@yarnpkg/types": "^4.0.0",
|
||||
"rimraf": "^5.0.5",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { Logger } from '@nestjs/common';
|
|||
|
||||
import { Command, CommandRunner } from 'nest-commander';
|
||||
|
||||
import { AppVersionCheckCronCommand } from 'src/engine/core-modules/application/application-version-check/crons/commands/app-version-check.cron.command';
|
||||
import { MarketplaceCatalogSyncCronCommand } from 'src/engine/core-modules/application/application-marketplace/crons/commands/marketplace-catalog-sync.cron.command';
|
||||
import { EventLogCleanupCronCommand } from 'src/engine/core-modules/event-logs/cleanup/commands/event-log-cleanup.cron.command';
|
||||
import { CheckPublicDomainsValidRecordsCronCommand } from 'src/engine/core-modules/public-domain/crons/commands/check-public-domains-valid-records.cron.command';
|
||||
import { CheckCustomDomainValidRecordsCronCommand } from 'src/engine/core-modules/workspace/crons/commands/check-custom-domain-valid-records.cron.command';
|
||||
|
|
@ -52,6 +54,8 @@ export class CronRegisterAllCommand extends CommandRunner {
|
|||
private readonly cleanOnboardingWorkspacesCronCommand: CleanOnboardingWorkspacesCronCommand,
|
||||
private readonly trashCleanupCronCommand: TrashCleanupCronCommand,
|
||||
private readonly eventLogCleanupCronCommand: EventLogCleanupCronCommand,
|
||||
private readonly marketplaceCatalogSyncCronCommand: MarketplaceCatalogSyncCronCommand,
|
||||
private readonly appVersionCheckCronCommand: AppVersionCheckCronCommand,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
|
@ -136,6 +140,14 @@ export class CronRegisterAllCommand extends CommandRunner {
|
|||
name: 'EventLogCleanup',
|
||||
command: this.eventLogCleanupCronCommand,
|
||||
},
|
||||
{
|
||||
name: 'MarketplaceCatalogSync',
|
||||
command: this.marketplaceCatalogSyncCronCommand,
|
||||
},
|
||||
{
|
||||
name: 'AppVersionCheck',
|
||||
command: this.appVersionCheckCronCommand,
|
||||
},
|
||||
];
|
||||
|
||||
let successCount = 0;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import { UpgradeVersionCommandModule } from 'src/database/commands/upgrade-versi
|
|||
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
|
||||
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
|
||||
import { GenerateApiKeyCommand } from 'src/engine/core-modules/api-key/commands/generate-api-key.command';
|
||||
import { AppVersionCheckModule } from 'src/engine/core-modules/application/application-version-check/application-version-check.module';
|
||||
import { MarketplaceModule } from 'src/engine/core-modules/application/application-marketplace/marketplace.module';
|
||||
import { EventLogCleanupModule } from 'src/engine/core-modules/event-logs/cleanup/event-log-cleanup.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { FileModule } from 'src/engine/core-modules/file/file.module';
|
||||
|
|
@ -55,6 +57,8 @@ import { AutomatedTriggerModule } from 'src/modules/workflow/workflow-trigger/au
|
|||
TrashCleanupModule,
|
||||
PublicDomainModule,
|
||||
EventLogCleanupModule,
|
||||
MarketplaceModule,
|
||||
AppVersionCheckModule,
|
||||
],
|
||||
providers: [
|
||||
DataSeedWorkspaceCommand,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { DataSource, Repository } from 'typeorm';
|
|||
|
||||
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
|
||||
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
|
||||
import { parseAvailablePackagesFromPackageJsonAndYarnLock } from 'src/engine/core-modules/application/utils/parse-available-packages-from-package-json-and-yarn-lock.util';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { v4 } from 'uuid';
|
|||
|
||||
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
|
||||
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
|
||||
import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util';
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { Repository } from 'typeorm';
|
|||
|
||||
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
|
||||
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
|
||||
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service';
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { v4 } from 'uuid';
|
|||
|
||||
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
|
||||
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { v4 } from 'uuid';
|
|||
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
|
||||
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
|
||||
import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { v4 as uuidv4 } from 'uuid';
|
|||
|
||||
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
|
||||
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { v4 } from 'uuid';
|
|||
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
|
||||
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
|
||||
import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { DataSource, In, Repository } from 'typeorm';
|
|||
|
||||
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
|
||||
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { v4 } from 'uuid';
|
|||
|
||||
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
|
||||
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { Repository } from 'typeorm';
|
|||
|
||||
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
|
||||
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
|
||||
import { findFlatEntityByUniversalIdentifierOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier-or-throw.util';
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { Repository } from 'typeorm';
|
|||
|
||||
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
|
||||
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
import { type MigrationInterface, type QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddAppRegistrationSourceFields1772267875870
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddAppRegistrationSourceFields1772267875870';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."applicationRegistration" ADD "sourceType" text NOT NULL DEFAULT 'local'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."applicationRegistration" ADD "sourcePackage" text`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."applicationRegistration" ADD "tarballFileId" uuid`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."applicationRegistration" ADD "latestAvailableVersion" text`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."applicationRegistration" ADD CONSTRAINT "REL_36715821de396df9536fd4afc8" UNIQUE ("tarballFileId")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."applicationRegistration" ADD CONSTRAINT "FK_36715821de396df9536fd4afc81" FOREIGN KEY ("tarballFileId") REFERENCES "core"."file"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."applicationRegistration" ADD "isFeatured" boolean NOT NULL DEFAULT false`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."applicationRegistration" ADD "marketplaceDisplayData" jsonb`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."applicationRegistration"
|
||||
ADD CONSTRAINT "CHK_NPM_HAS_SOURCE_PACKAGE"
|
||||
CHECK ("sourceType" <> 'npm' OR "sourcePackage" IS NOT NULL)`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."applicationRegistration" DROP CONSTRAINT IF EXISTS "CHK_NPM_HAS_SOURCE_PACKAGE"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."applicationRegistration" DROP COLUMN "marketplaceDisplayData"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."applicationRegistration" DROP COLUMN "isFeatured"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."applicationRegistration" DROP CONSTRAINT "FK_36715821de396df9536fd4afc81"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."applicationRegistration" DROP CONSTRAINT "REL_36715821de396df9536fd4afc8"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."applicationRegistration" DROP COLUMN "latestAvailableVersion"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."applicationRegistration" DROP COLUMN "tarballFileId"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."applicationRegistration" DROP COLUMN "sourcePackage"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."applicationRegistration" DROP COLUMN "sourceType"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module';
|
||||
import { ApplicationInstallModule } from 'src/engine/core-modules/application/application-install/application-install.module';
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { ApplicationDevelopmentResolver } from 'src/engine/core-modules/application/application-development/application-development.resolver';
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ApplicationInstallModule,
|
||||
ApplicationRegistrationModule,
|
||||
ApplicationModule,
|
||||
FeatureFlagModule,
|
||||
TokenModule,
|
||||
FileStorageModule,
|
||||
PermissionsModule,
|
||||
],
|
||||
providers: [
|
||||
ApplicationDevelopmentResolver,
|
||||
WorkspaceMigrationGraphqlApiExceptionInterceptor,
|
||||
],
|
||||
})
|
||||
export class ApplicationDevelopmentModule {}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import {
|
||||
Logger,
|
||||
UseFilters,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
|
|
@ -12,6 +13,9 @@ import { FileFolder, FeatureFlagKey } from 'twenty-shared/types';
|
|||
|
||||
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
|
||||
|
||||
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration/application-registration-variable.service';
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
|
||||
import { AppRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/app-registration-source-type.enum';
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { ApplicationExceptionFilter } from 'src/engine/core-modules/application/application-exception-filter';
|
||||
import {
|
||||
|
|
@ -24,8 +28,8 @@ import { CreateApplicationInput } from 'src/engine/core-modules/application/dtos
|
|||
import { GenerateApplicationTokenInput } from 'src/engine/core-modules/application/dtos/generate-application-token.input';
|
||||
import { UploadApplicationFileInput } from 'src/engine/core-modules/application/dtos/uploadApplicationFileInput';
|
||||
import { WorkspaceMigrationDTO } from 'src/engine/core-modules/application/dtos/workspace-migration.dto';
|
||||
import { ApplicationSyncService } from 'src/engine/core-modules/application/services/application-sync.service';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
|
||||
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-install/application-sync.service';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { ApplicationTokenPairDTO } from 'src/engine/core-modules/application/dtos/application-token-pair.dto';
|
||||
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
|
|
@ -34,7 +38,10 @@ import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/re
|
|||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { DevelopmentGuard } from 'src/engine/guards/development.guard';
|
||||
import { RequireFeatureFlag } from 'src/engine/guards/feature-flag.guard';
|
||||
import {
|
||||
FeatureFlagGuard,
|
||||
RequireFeatureFlag,
|
||||
} from 'src/engine/guards/feature-flag.guard';
|
||||
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
|
||||
|
|
@ -46,14 +53,19 @@ import { streamToBuffer } from 'src/utils/stream-to-buffer';
|
|||
@UseFilters(ApplicationExceptionFilter)
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
DevelopmentGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.APPLICATIONS),
|
||||
)
|
||||
export class ApplicationDevelopmentResolver {
|
||||
private readonly logger = new Logger(ApplicationDevelopmentResolver.name);
|
||||
|
||||
constructor(
|
||||
private readonly applicationTokenService: ApplicationTokenService,
|
||||
private readonly applicationSyncService: ApplicationSyncService,
|
||||
private readonly applicationService: ApplicationService,
|
||||
private readonly applicationRegistrationService: ApplicationRegistrationService,
|
||||
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
|
||||
private readonly fileStorageService: FileStorageService,
|
||||
) {}
|
||||
|
||||
|
|
@ -75,12 +87,33 @@ export class ApplicationDevelopmentResolver {
|
|||
@Args() { manifest }: ApplicationInput,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
): Promise<WorkspaceMigrationDTO> {
|
||||
const applicationRegistrationId =
|
||||
await this.resolveApplicationRegistrationId(
|
||||
manifest.application.universalIdentifier,
|
||||
{
|
||||
name: manifest.application.displayName,
|
||||
description: manifest.application.description,
|
||||
logoUrl: manifest.application.logoUrl,
|
||||
author: manifest.application.author,
|
||||
websiteUrl: manifest.application.websiteUrl,
|
||||
termsUrl: manifest.application.termsUrl,
|
||||
},
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const workspaceMigration =
|
||||
await this.applicationSyncService.synchronizeFromManifest({
|
||||
workspaceId,
|
||||
manifest,
|
||||
applicationRegistrationId,
|
||||
});
|
||||
|
||||
await this.syncRegistrationMetadata(
|
||||
applicationRegistrationId,
|
||||
manifest,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
return {
|
||||
applicationUniversalIdentifier:
|
||||
workspaceMigration.applicationUniversalIdentifier,
|
||||
|
|
@ -96,7 +129,7 @@ export class ApplicationDevelopmentResolver {
|
|||
) {
|
||||
return await this.applicationService.create({
|
||||
...input,
|
||||
sourceType: 'local',
|
||||
sourceType: AppRegistrationSourceType.LOCAL,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
|
@ -142,4 +175,88 @@ export class ApplicationDevelopmentResolver {
|
|||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
});
|
||||
}
|
||||
|
||||
private async resolveApplicationRegistrationId(
|
||||
universalIdentifier: string,
|
||||
metadata: {
|
||||
name: string;
|
||||
description?: string;
|
||||
logoUrl?: string;
|
||||
author?: string;
|
||||
websiteUrl?: string;
|
||||
termsUrl?: string;
|
||||
},
|
||||
workspaceId: string,
|
||||
): Promise<string> {
|
||||
const existingRegistration =
|
||||
await this.applicationRegistrationService.findOneByUniversalIdentifier(
|
||||
universalIdentifier,
|
||||
);
|
||||
|
||||
if (existingRegistration) {
|
||||
const isOwner =
|
||||
await this.applicationRegistrationService.isOwnedByWorkspace(
|
||||
existingRegistration.id,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (!isOwner) {
|
||||
throw new ApplicationException(
|
||||
'Cannot sync application: registration is owned by another workspace',
|
||||
ApplicationExceptionCode.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
|
||||
return existingRegistration.id;
|
||||
}
|
||||
|
||||
const { applicationRegistration: newRegistration } =
|
||||
await this.applicationRegistrationService.create(
|
||||
{ ...metadata, universalIdentifier },
|
||||
workspaceId,
|
||||
null,
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Created app registration for ${metadata.name} (${universalIdentifier})`,
|
||||
);
|
||||
|
||||
return newRegistration.id;
|
||||
}
|
||||
|
||||
private async syncRegistrationMetadata(
|
||||
applicationRegistrationId: string,
|
||||
manifest: { application: ApplicationInput['manifest']['application'] },
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const isOwner =
|
||||
await this.applicationRegistrationService.isOwnedByWorkspace(
|
||||
applicationRegistrationId,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (isOwner) {
|
||||
await this.applicationRegistrationService.update(
|
||||
{
|
||||
id: applicationRegistrationId,
|
||||
update: {
|
||||
name: manifest.application.displayName,
|
||||
description: manifest.application.description,
|
||||
logoUrl: manifest.application.logoUrl,
|
||||
author: manifest.application.author,
|
||||
websiteUrl: manifest.application.websiteUrl,
|
||||
termsUrl: manifest.application.termsUrl,
|
||||
},
|
||||
},
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (manifest.application.serverVariables) {
|
||||
await this.applicationRegistrationVariableService.syncVariableSchemas(
|
||||
applicationRegistrationId,
|
||||
manifest.application.serverVariables,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,8 @@ import {
|
|||
ApplicationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application.exception';
|
||||
import {
|
||||
ForbiddenError,
|
||||
InternalServerError,
|
||||
NotFoundError,
|
||||
UserInputError,
|
||||
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
|
|
@ -23,8 +25,14 @@ export class ApplicationExceptionFilter implements ExceptionFilter {
|
|||
case ApplicationExceptionCode.FRONT_COMPONENT_NOT_FOUND:
|
||||
throw new NotFoundError(exception);
|
||||
case ApplicationExceptionCode.FORBIDDEN:
|
||||
throw new ForbiddenError(exception);
|
||||
case ApplicationExceptionCode.INVALID_INPUT:
|
||||
case ApplicationExceptionCode.SOURCE_CHANNEL_MISMATCH:
|
||||
throw new UserInputError(exception);
|
||||
case ApplicationExceptionCode.PACKAGE_RESOLUTION_FAILED:
|
||||
case ApplicationExceptionCode.TARBALL_EXTRACTION_FAILED:
|
||||
case ApplicationExceptionCode.UPGRADE_FAILED:
|
||||
throw new InternalServerError(exception);
|
||||
default: {
|
||||
assertUnreachable(exception.code);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,285 @@
|
|||
import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
|
||||
|
||||
import { execFile } from 'child_process';
|
||||
import { promises as fs } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { promisify } from 'util';
|
||||
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
import { type PackageJson } from 'type-fest';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import { AppRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/app-registration-source-type.enum';
|
||||
import {
|
||||
ApplicationException,
|
||||
ApplicationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application.exception';
|
||||
import { YARN_ENGINE_DIRNAME } from 'src/engine/core-modules/application/constants/yarn-engine-dirname';
|
||||
import { assertValidNpmPackageName } from 'src/engine/core-modules/application/utils/assert-valid-npm-package-name.util';
|
||||
import { extractTarballSecurely } from 'src/engine/core-modules/application/utils/extract-tarball-securely.util';
|
||||
import { readJsonFileOrThrow } from 'src/engine/core-modules/application/utils/read-json-file.util';
|
||||
import { resolvePackageContentDir } from 'src/engine/core-modules/application/utils/tarball-utils';
|
||||
import { FileStorageDriverFactory } from 'src/engine/core-modules/file-storage/file-storage-driver.factory';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { streamToBuffer } from 'src/utils/stream-to-buffer';
|
||||
|
||||
const execFilePromise = promisify(execFile);
|
||||
|
||||
const APP_FETCHER_TMPDIR = join(tmpdir(), 'twenty-app-fetcher');
|
||||
const RESOLUTION_TIMEOUT_MS = 30_000;
|
||||
|
||||
export type ResolvedPackage = {
|
||||
extractedDir: string;
|
||||
cleanupDir: string;
|
||||
manifest: Manifest;
|
||||
packageJson: PackageJson;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AppPackageFetcherService implements OnModuleInit {
|
||||
private readonly logger = new Logger(AppPackageFetcherService.name);
|
||||
|
||||
constructor(
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly fileStorageDriverFactory: FileStorageDriverFactory,
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
try {
|
||||
await fs.rm(APP_FETCHER_TMPDIR, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best-effort cleanup of stale temp files from previous runs
|
||||
}
|
||||
}
|
||||
|
||||
async resolvePackage(
|
||||
appRegistration: ApplicationRegistrationEntity,
|
||||
options?: { targetVersion?: string },
|
||||
): Promise<ResolvedPackage | null> {
|
||||
switch (appRegistration.sourceType) {
|
||||
case AppRegistrationSourceType.NPM:
|
||||
return this.resolveFromNpm(appRegistration, options?.targetVersion);
|
||||
case AppRegistrationSourceType.TARBALL:
|
||||
return this.resolveFromTarball(appRegistration);
|
||||
case AppRegistrationSourceType.LOCAL:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async cleanupExtractedDir(extractedDir: string): Promise<void> {
|
||||
try {
|
||||
await fs.rm(extractedDir, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to clean up ${extractedDir}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveFromNpm(
|
||||
appRegistration: ApplicationRegistrationEntity,
|
||||
targetVersion?: string,
|
||||
): Promise<ResolvedPackage> {
|
||||
const workDir = join(APP_FETCHER_TMPDIR, v4());
|
||||
|
||||
await fs.mkdir(workDir, { recursive: true });
|
||||
|
||||
try {
|
||||
const registryUrl = this.twentyConfigService.get('APP_REGISTRY_URL');
|
||||
|
||||
const authToken = this.twentyConfigService.get('APP_REGISTRY_TOKEN');
|
||||
|
||||
if (!appRegistration.sourcePackage) {
|
||||
throw new ApplicationException(
|
||||
`App registration ${appRegistration.id} has sourceType=npm but no sourcePackage`,
|
||||
ApplicationExceptionCode.PACKAGE_RESOLUTION_FAILED,
|
||||
);
|
||||
}
|
||||
|
||||
const sourcePackage = appRegistration.sourcePackage;
|
||||
|
||||
assertValidNpmPackageName(sourcePackage);
|
||||
|
||||
const versionSpec = targetVersion ?? 'latest';
|
||||
|
||||
await this.writeNpmrc({
|
||||
workDir,
|
||||
packageName: sourcePackage,
|
||||
registryUrl,
|
||||
authToken,
|
||||
});
|
||||
await this.setupYarnEngine(workDir);
|
||||
await this.writeMinimalPackageJson(workDir, sourcePackage, versionSpec);
|
||||
await this.runYarnInstall(workDir);
|
||||
|
||||
const packageDir = join(workDir, 'node_modules', sourcePackage);
|
||||
const manifest = await readJsonFileOrThrow<Manifest>(
|
||||
packageDir,
|
||||
'manifest.json',
|
||||
);
|
||||
const packageJson = await readJsonFileOrThrow<PackageJson>(
|
||||
packageDir,
|
||||
'package.json',
|
||||
);
|
||||
|
||||
return {
|
||||
extractedDir: packageDir,
|
||||
cleanupDir: workDir,
|
||||
manifest,
|
||||
packageJson,
|
||||
};
|
||||
} catch (error) {
|
||||
await this.cleanupExtractedDir(workDir);
|
||||
throw new ApplicationException(
|
||||
`Failed to resolve npm package ${appRegistration.sourcePackage}: ${error}`,
|
||||
ApplicationExceptionCode.PACKAGE_RESOLUTION_FAILED,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveFromTarball(
|
||||
appRegistration: ApplicationRegistrationEntity,
|
||||
): Promise<ResolvedPackage> {
|
||||
const workDir = join(APP_FETCHER_TMPDIR, v4());
|
||||
|
||||
await fs.mkdir(workDir, { recursive: true });
|
||||
|
||||
try {
|
||||
const storagePath = join('app-tarball', appRegistration.id, 'app.tar.gz');
|
||||
const driver = this.fileStorageDriverFactory.getCurrentDriver();
|
||||
const tarballStream = await driver.readFile({
|
||||
filePath: storagePath,
|
||||
});
|
||||
const tarballBuffer = await streamToBuffer(tarballStream);
|
||||
const tarballPath = join(workDir, 'app.tar.gz');
|
||||
|
||||
await fs.writeFile(tarballPath, tarballBuffer);
|
||||
await extractTarballSecurely(tarballPath, workDir);
|
||||
await fs.rm(tarballPath);
|
||||
|
||||
const contentDir = await resolvePackageContentDir(workDir);
|
||||
const manifest = await readJsonFileOrThrow<Manifest>(
|
||||
contentDir,
|
||||
'manifest.json',
|
||||
);
|
||||
const packageJson = await readJsonFileOrThrow<PackageJson>(
|
||||
contentDir,
|
||||
'package.json',
|
||||
);
|
||||
|
||||
return {
|
||||
extractedDir: contentDir,
|
||||
cleanupDir: workDir,
|
||||
manifest,
|
||||
packageJson,
|
||||
};
|
||||
} catch (error) {
|
||||
await this.cleanupExtractedDir(workDir);
|
||||
|
||||
if (error instanceof ApplicationException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new ApplicationException(
|
||||
`Failed to resolve tarball for app ${appRegistration.universalIdentifier}: ${error}`,
|
||||
ApplicationExceptionCode.TARBALL_EXTRACTION_FAILED,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Note: .npmrc settings take precedence over publishConfig.registry in
|
||||
// individual packages. This is correct for our use case since we want
|
||||
// to control the registry at the resolver level.
|
||||
private async writeNpmrc(config: {
|
||||
workDir: string;
|
||||
packageName: string;
|
||||
registryUrl: string;
|
||||
authToken?: string;
|
||||
}): Promise<void> {
|
||||
const lines: string[] = [];
|
||||
const registryHost = new URL(config.registryUrl).host;
|
||||
|
||||
if (config.packageName.startsWith('@')) {
|
||||
const scope = config.packageName.split('/')[0];
|
||||
|
||||
lines.push(`${scope}:registry=${config.registryUrl}`);
|
||||
} else if (config.registryUrl !== 'https://registry.npmjs.org') {
|
||||
lines.push(`registry=${config.registryUrl}`);
|
||||
}
|
||||
|
||||
if (config.authToken) {
|
||||
lines.push(`//${registryHost}/:_authToken=${config.authToken}`);
|
||||
}
|
||||
|
||||
if (lines.length > 0) {
|
||||
await fs.writeFile(
|
||||
join(config.workDir, '.npmrc'),
|
||||
lines.join('\n') + '\n',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async setupYarnEngine(workDir: string): Promise<void> {
|
||||
await fs.cp(YARN_ENGINE_DIRNAME, workDir, { recursive: true });
|
||||
}
|
||||
|
||||
private async writeMinimalPackageJson(
|
||||
workDir: string,
|
||||
packageName: string,
|
||||
versionSpec: string,
|
||||
): Promise<void> {
|
||||
const packageJson = {
|
||||
name: 'twenty-app-resolver-workspace',
|
||||
private: true,
|
||||
dependencies: {
|
||||
[packageName]: versionSpec,
|
||||
},
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
join(workDir, 'package.json'),
|
||||
JSON.stringify(packageJson, null, 2),
|
||||
);
|
||||
}
|
||||
|
||||
private async resolveLocalYarnPath(workDir: string): Promise<string> {
|
||||
const yarnrcPath = join(workDir, '.yarnrc.yml');
|
||||
const yarnrcContent = await fs.readFile(yarnrcPath, 'utf-8');
|
||||
const match = yarnrcContent.match(/^yarnPath:\s*(.+)$/m);
|
||||
|
||||
if (!match) {
|
||||
throw new ApplicationException(
|
||||
'yarnPath not found in .yarnrc.yml',
|
||||
ApplicationExceptionCode.PACKAGE_RESOLUTION_FAILED,
|
||||
);
|
||||
}
|
||||
|
||||
return join(workDir, match[1].trim());
|
||||
}
|
||||
|
||||
private async runYarnInstall(workDir: string): Promise<void> {
|
||||
const localYarnPath = await this.resolveLocalYarnPath(workDir);
|
||||
|
||||
const { NODE_OPTIONS: _nodeOptions, ...cleanEnv } = process.env;
|
||||
|
||||
try {
|
||||
await execFilePromise(
|
||||
process.execPath,
|
||||
[localYarnPath, 'install', '--no-immutable'],
|
||||
{
|
||||
cwd: workDir,
|
||||
env: cleanEnv,
|
||||
timeout: RESOLUTION_TIMEOUT_MS,
|
||||
},
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
throw new ApplicationException(
|
||||
`yarn install failed: ${errorMessage}`,
|
||||
ApplicationExceptionCode.PACKAGE_RESOLUTION_FAILED,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import axios from 'axios';
|
||||
import { Repository } from 'typeorm';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import { AppRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/app-registration-source-type.enum';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import {
|
||||
ApplicationException,
|
||||
ApplicationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application.exception';
|
||||
import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
const npmPackageMetadataSchema = z.object({
|
||||
version: z.string(),
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
export class AppUpgradeService {
|
||||
private readonly logger = new Logger(AppUpgradeService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ApplicationRegistrationEntity)
|
||||
private readonly appRegistrationRepository: Repository<ApplicationRegistrationEntity>,
|
||||
@InjectRepository(ApplicationEntity)
|
||||
private readonly applicationRepository: Repository<ApplicationEntity>,
|
||||
private readonly applicationInstallService: ApplicationInstallService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
) {}
|
||||
|
||||
async checkForUpdates(
|
||||
appRegistration: ApplicationRegistrationEntity,
|
||||
): Promise<string | null> {
|
||||
if (appRegistration.sourceType !== AppRegistrationSourceType.NPM) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const registryUrl = this.twentyConfigService.get('APP_REGISTRY_URL');
|
||||
|
||||
if (!appRegistration.sourcePackage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const encodedPackage = encodeURIComponent(appRegistration.sourcePackage);
|
||||
|
||||
const { data } = await axios.get(
|
||||
`${registryUrl}/${encodedPackage}/latest`,
|
||||
{
|
||||
headers: { 'User-Agent': 'Twenty-AppUpgrade' },
|
||||
timeout: 10_000,
|
||||
},
|
||||
);
|
||||
|
||||
const parsed = npmPackageMetadataSchema.safeParse(data);
|
||||
|
||||
if (!parsed.success) {
|
||||
this.logger.warn(
|
||||
`Unexpected response shape from registry for ${appRegistration.sourcePackage}`,
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
await this.appRegistrationRepository.update(appRegistration.id, {
|
||||
latestAvailableVersion: parsed.data.version,
|
||||
});
|
||||
|
||||
return parsed.data.version;
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to check updates for ${appRegistration.sourcePackage}: ${error}`,
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async checkAllForUpdates(): Promise<void> {
|
||||
const npmRegistrations = await this.appRegistrationRepository.find({
|
||||
where: { sourceType: AppRegistrationSourceType.NPM },
|
||||
});
|
||||
|
||||
for (const registration of npmRegistrations) {
|
||||
await this.checkForUpdates(registration);
|
||||
}
|
||||
}
|
||||
|
||||
async upgradeApplication(params: {
|
||||
appRegistrationId: string;
|
||||
targetVersion: string;
|
||||
workspaceId: string;
|
||||
}): Promise<boolean> {
|
||||
const appRegistration = await this.appRegistrationRepository.findOneOrFail({
|
||||
where: { id: params.appRegistrationId },
|
||||
});
|
||||
|
||||
if (
|
||||
appRegistration.sourceType === AppRegistrationSourceType.LOCAL ||
|
||||
appRegistration.sourceType === AppRegistrationSourceType.TARBALL
|
||||
) {
|
||||
throw new ApplicationException(
|
||||
'Cannot upgrade an app installed from a tarball or local source',
|
||||
ApplicationExceptionCode.UPGRADE_FAILED,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.applicationInstallService.installApplication({
|
||||
appRegistrationId: params.appRegistrationId,
|
||||
version: params.targetVersion,
|
||||
workspaceId: params.workspaceId,
|
||||
});
|
||||
} catch (error) {
|
||||
const appName =
|
||||
appRegistration.sourcePackage ?? appRegistration.universalIdentifier;
|
||||
|
||||
this.logger.error(`Upgrade failed for ${appName}`, error);
|
||||
|
||||
if (error instanceof ApplicationException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new ApplicationException(
|
||||
`Upgrade failed for ${appName}`,
|
||||
ApplicationExceptionCode.UPGRADE_FAILED,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +1,25 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application-registration/application-registration.module';
|
||||
import { CacheLockModule } from 'src/engine/core-modules/cache-lock/cache-lock.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { ApplicationDevelopmentResolver } from 'src/engine/core-modules/application/resolvers/application-development.resolver';
|
||||
import { ApplicationResolver } from 'src/engine/core-modules/application/resolvers/application.resolver';
|
||||
import { MarketplaceResolver } from 'src/engine/core-modules/application/resolvers/marketplace.resolver';
|
||||
import { ApplicationManifestMigrationService } from 'src/engine/core-modules/application/services/application-manifest-migration.service';
|
||||
import { ApplicationSyncService } from 'src/engine/core-modules/application/services/application-sync.service';
|
||||
import { ApplicationVariableEntityModule } from 'src/engine/core-modules/applicationVariable/application-variable.module';
|
||||
import { ApplicationInstallResolver } from 'src/engine/core-modules/application/application-install/application-install.resolver';
|
||||
import { AppPackageFetcherService } from 'src/engine/core-modules/application/application-install/app-package-fetcher.service';
|
||||
import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service';
|
||||
import { ApplicationManifestMigrationService } from 'src/engine/core-modules/application/application-install/application-manifest-migration.service';
|
||||
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-install/application-sync.service';
|
||||
import { AppUpgradeService } from 'src/engine/core-modules/application/application-install/app-upgrade.service';
|
||||
import { ApplicationVariableEntityModule } from 'src/engine/core-modules/application/application-variable/application-variable.module';
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { ObjectPermissionModule } from 'src/engine/metadata-modules/object-permission/object-permission.module';
|
||||
import { PermissionFlagModule } from 'src/engine/metadata-modules/permission-flag/permission-flag.module';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
|
||||
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
|
||||
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
|
||||
import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/workspace-migration-runner.module';
|
||||
|
|
@ -24,9 +29,14 @@ import { CodeStepBuildModule } from 'src/modules/workflow/workflow-builder/workf
|
|||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([FileEntity]),
|
||||
ApplicationRegistrationModule,
|
||||
TypeOrmModule.forFeature([
|
||||
FileEntity,
|
||||
ApplicationRegistrationEntity,
|
||||
ApplicationEntity,
|
||||
]),
|
||||
ApplicationModule,
|
||||
CacheLockModule,
|
||||
FeatureFlagModule,
|
||||
ApplicationVariableEntityModule,
|
||||
TokenModule,
|
||||
WorkspaceMigrationModule,
|
||||
|
|
@ -38,15 +48,22 @@ import { CodeStepBuildModule } from 'src/modules/workflow/workflow-builder/workf
|
|||
FileStorageModule,
|
||||
WorkspaceCacheModule,
|
||||
WorkspaceMigrationRunnerModule,
|
||||
TwentyConfigModule,
|
||||
],
|
||||
providers: [
|
||||
ApplicationResolver,
|
||||
ApplicationDevelopmentResolver,
|
||||
MarketplaceResolver,
|
||||
ApplicationInstallResolver,
|
||||
ApplicationManifestMigrationService,
|
||||
ApplicationSyncService,
|
||||
AppPackageFetcherService,
|
||||
ApplicationInstallService,
|
||||
AppUpgradeService,
|
||||
WorkspaceMigrationGraphqlApiExceptionInterceptor,
|
||||
],
|
||||
exports: [ApplicationSyncService],
|
||||
exports: [
|
||||
ApplicationSyncService,
|
||||
AppPackageFetcherService,
|
||||
ApplicationInstallService,
|
||||
AppUpgradeService,
|
||||
],
|
||||
})
|
||||
export class ApplicationSyncModule {}
|
||||
export class ApplicationInstallModule {}
|
||||
|
|
@ -12,22 +12,25 @@ import { FeatureFlagKey } from 'twenty-shared/types';
|
|||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { ApplicationExceptionFilter } from 'src/engine/core-modules/application/application-exception-filter';
|
||||
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
|
||||
import {
|
||||
ApplicationException,
|
||||
ApplicationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application.exception';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-install/application-sync.service';
|
||||
import { ApplicationTokenPairDTO } from 'src/engine/core-modules/application/dtos/application-token-pair.dto';
|
||||
import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/application.dto';
|
||||
import { InstallApplicationInput } from 'src/engine/core-modules/application/dtos/install-application.input';
|
||||
import { UninstallApplicationInput } from 'src/engine/core-modules/application/dtos/uninstallApplicationInput';
|
||||
import { ApplicationSyncService } from 'src/engine/core-modules/application/services/application-sync.service';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
|
||||
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
|
||||
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
|
||||
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { RequireFeatureFlag } from 'src/engine/guards/feature-flag.guard';
|
||||
import {
|
||||
FeatureFlagGuard,
|
||||
RequireFeatureFlag,
|
||||
} from 'src/engine/guards/feature-flag.guard';
|
||||
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
|
|
@ -39,19 +42,18 @@ import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/wo
|
|||
@MetadataResolver()
|
||||
@UseInterceptors(WorkspaceMigrationGraphqlApiExceptionInterceptor)
|
||||
@UseFilters(ApplicationExceptionFilter, AuthGraphqlApiExceptionFilter)
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
export class ApplicationResolver {
|
||||
@UseGuards(WorkspaceAuthGuard, FeatureFlagGuard)
|
||||
export class ApplicationInstallResolver {
|
||||
constructor(
|
||||
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
|
||||
private readonly applicationSyncService: ApplicationSyncService,
|
||||
private readonly applicationService: ApplicationService,
|
||||
private readonly applicationTokenService: ApplicationTokenService,
|
||||
private readonly applicationSyncService: ApplicationSyncService,
|
||||
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
) {}
|
||||
|
||||
@Query(() => [ApplicationDTO])
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS))
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
async findManyApplications(
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
) {
|
||||
|
|
@ -60,11 +62,13 @@ export class ApplicationResolver {
|
|||
|
||||
@Query(() => ApplicationDTO)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS))
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
async findOneApplication(
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
@Args('id', { type: () => UUIDScalarType, nullable: true }) id?: string,
|
||||
@Args('universalIdentifier', { type: () => UUIDScalarType, nullable: true })
|
||||
@Args('universalIdentifier', {
|
||||
type: () => UUIDScalarType,
|
||||
nullable: true,
|
||||
})
|
||||
universalIdentifier?: string,
|
||||
) {
|
||||
return await this.applicationService.findOneApplicationOrThrow({
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import { join, relative } from 'path';
|
||||
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import { AppRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/app-registration-source-type.enum';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import {
|
||||
AppPackageFetcherService,
|
||||
type ResolvedPackage,
|
||||
} from 'src/engine/core-modules/application/application-install/app-package-fetcher.service';
|
||||
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-install/application-sync.service';
|
||||
import { CacheLockService } from 'src/engine/core-modules/cache-lock/cache-lock.service';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
|
||||
const FILE_FOLDER_MAPPING: Record<string, FileFolder> = {
|
||||
'package.json': FileFolder.Dependencies,
|
||||
'yarn.lock': FileFolder.Dependencies,
|
||||
};
|
||||
|
||||
const FILE_FOLDER_PATTERN_MAPPING: Array<{
|
||||
pattern: RegExp;
|
||||
folder: FileFolder;
|
||||
}> = [
|
||||
{ pattern: /\.function\.mjs$/, folder: FileFolder.BuiltLogicFunction },
|
||||
{
|
||||
pattern: /\.front-component\.mjs$/,
|
||||
folder: FileFolder.BuiltFrontComponent,
|
||||
},
|
||||
{ pattern: /^public\//, folder: FileFolder.PublicAsset },
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class ApplicationInstallService {
|
||||
private readonly logger = new Logger(ApplicationInstallService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ApplicationRegistrationEntity)
|
||||
private readonly appRegistrationRepository: Repository<ApplicationRegistrationEntity>,
|
||||
@InjectRepository(ApplicationEntity)
|
||||
private readonly applicationRepository: Repository<ApplicationEntity>,
|
||||
private readonly appPackageFetcherService: AppPackageFetcherService,
|
||||
private readonly applicationSyncService: ApplicationSyncService,
|
||||
private readonly fileStorageService: FileStorageService,
|
||||
private readonly cacheLockService: CacheLockService,
|
||||
) {}
|
||||
|
||||
async installApplication(params: {
|
||||
appRegistrationId: string;
|
||||
version?: string;
|
||||
workspaceId: string;
|
||||
}): Promise<boolean> {
|
||||
const appRegistration = await this.appRegistrationRepository.findOneOrFail({
|
||||
where: { id: params.appRegistrationId },
|
||||
});
|
||||
|
||||
if (appRegistration.sourceType === AppRegistrationSourceType.LOCAL) {
|
||||
this.logger.log(
|
||||
`Skipping install for LOCAL app ${appRegistration.universalIdentifier} (files synced by CLI watcher in dev mode)`,
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const lockKey = `app-install:${params.workspaceId}:${appRegistration.universalIdentifier}`;
|
||||
|
||||
return this.cacheLockService.withLock(
|
||||
() =>
|
||||
this.doInstallApplication(appRegistration, {
|
||||
version: params.version,
|
||||
workspaceId: params.workspaceId,
|
||||
}),
|
||||
lockKey,
|
||||
{ ttl: 60_000, ms: 500, maxRetries: 120 },
|
||||
);
|
||||
}
|
||||
|
||||
private async doInstallApplication(
|
||||
appRegistration: ApplicationRegistrationEntity,
|
||||
params: { version?: string; workspaceId: string },
|
||||
): Promise<boolean> {
|
||||
let resolvedPackage: ResolvedPackage | null = null;
|
||||
|
||||
try {
|
||||
resolvedPackage = await this.appPackageFetcherService.resolvePackage(
|
||||
appRegistration,
|
||||
{ targetVersion: params.version },
|
||||
);
|
||||
|
||||
if (!resolvedPackage) {
|
||||
return true;
|
||||
}
|
||||
|
||||
await this.writeFilesToStorage(
|
||||
resolvedPackage.extractedDir,
|
||||
appRegistration.universalIdentifier,
|
||||
params.workspaceId,
|
||||
);
|
||||
|
||||
await this.applicationSyncService.synchronizeFromManifest({
|
||||
workspaceId: params.workspaceId,
|
||||
manifest: resolvedPackage.manifest,
|
||||
applicationRegistrationId: appRegistration.id,
|
||||
});
|
||||
|
||||
await this.updateApplicationSourceType(
|
||||
appRegistration.universalIdentifier,
|
||||
params.workspaceId,
|
||||
appRegistration.sourceType,
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Successfully installed app ${appRegistration.universalIdentifier} v${resolvedPackage.packageJson.version ?? 'unknown'}`,
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to install app ${appRegistration.universalIdentifier}: ${error}`,
|
||||
);
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
if (resolvedPackage) {
|
||||
await this.appPackageFetcherService.cleanupExtractedDir(
|
||||
resolvedPackage.cleanupDir,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async writeFilesToStorage(
|
||||
extractedDir: string,
|
||||
applicationUniversalIdentifier: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const files = await this.collectFiles(extractedDir);
|
||||
|
||||
for (const filePath of files) {
|
||||
const relativePath = relative(extractedDir, filePath);
|
||||
const fileFolder = this.resolveFileFolder(relativePath);
|
||||
const content = await fs.readFile(filePath);
|
||||
|
||||
await this.fileStorageService.writeFile({
|
||||
sourceFile: content,
|
||||
mimeType: undefined,
|
||||
fileFolder,
|
||||
applicationUniversalIdentifier,
|
||||
workspaceId,
|
||||
resourcePath: relativePath,
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private resolveFileFolder(relativePath: string): FileFolder {
|
||||
const exact = FILE_FOLDER_MAPPING[relativePath];
|
||||
|
||||
if (isDefined(exact)) {
|
||||
return exact;
|
||||
}
|
||||
|
||||
for (const { pattern, folder } of FILE_FOLDER_PATTERN_MAPPING) {
|
||||
if (pattern.test(relativePath)) {
|
||||
return folder;
|
||||
}
|
||||
}
|
||||
|
||||
return FileFolder.Source;
|
||||
}
|
||||
|
||||
private async collectFiles(dir: string): Promise<string[]> {
|
||||
const result: string[] = [];
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
|
||||
if (entry.name === 'node_modules' || entry.name === '.yarn') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const subFiles = await this.collectFiles(fullPath);
|
||||
|
||||
result.push(...subFiles);
|
||||
} else {
|
||||
result.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async updateApplicationSourceType(
|
||||
universalIdentifier: string,
|
||||
workspaceId: string,
|
||||
sourceType: AppRegistrationSourceType,
|
||||
): Promise<void> {
|
||||
await this.applicationRepository.update(
|
||||
{ universalIdentifier, workspaceId },
|
||||
{ sourceType },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ import {
|
|||
ApplicationException,
|
||||
ApplicationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application.exception';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
|
||||
import { buildFromToAllUniversalFlatEntityMaps } from 'src/engine/core-modules/application/utils/build-from-to-all-universal-flat-entity-maps.util';
|
||||
import { computeApplicationManifestAllUniversalFlatEntityMaps } from 'src/engine/core-modules/application/utils/compute-application-manifest-all-universal-flat-entity-maps.util';
|
||||
|
|
@ -1,25 +1,23 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
import { ALL_METADATA_NAME } from 'twenty-shared/metadata';
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { PackageJson } from 'type-fest';
|
||||
|
||||
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application-registration/application-registration-variable.service';
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application-registration/application-registration.service';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import {
|
||||
ApplicationException,
|
||||
ApplicationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application.exception';
|
||||
import { ApplicationInput } from 'src/engine/core-modules/application/dtos/application.input';
|
||||
import { ApplicationManifestMigrationService } from 'src/engine/core-modules/application/services/application-manifest-migration.service';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
|
||||
import { ApplicationManifestMigrationService } from 'src/engine/core-modules/application/application-install/application-manifest-migration.service';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
|
||||
import { buildFromToAllUniversalFlatEntityMaps } from 'src/engine/core-modules/application/utils/build-from-to-all-universal-flat-entity-maps.util';
|
||||
import { getApplicationSubAllFlatEntityMaps } from 'src/engine/core-modules/application/utils/get-application-sub-all-flat-entity-maps.util';
|
||||
import { getDefaultApplicationPackageFields } from 'src/engine/core-modules/application/utils/get-default-application-package-fields.util';
|
||||
import { ApplicationVariableEntityService } from 'src/engine/core-modules/applicationVariable/application-variable.service';
|
||||
import { ApplicationVariableEntityService } from 'src/engine/core-modules/application/application-variable/application-variable.service';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { createEmptyAllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-all-flat-entity-maps.constant';
|
||||
import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util';
|
||||
|
|
@ -40,19 +38,21 @@ export class ApplicationSyncService {
|
|||
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
private readonly fileStorageService: FileStorageService,
|
||||
private readonly applicationRegistrationService: ApplicationRegistrationService,
|
||||
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
|
||||
) {}
|
||||
|
||||
public async synchronizeFromManifest({
|
||||
workspaceId,
|
||||
manifest,
|
||||
}: ApplicationInput & {
|
||||
applicationRegistrationId,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
manifest: Manifest;
|
||||
applicationRegistrationId?: string;
|
||||
}): Promise<WorkspaceMigration> {
|
||||
const application = await this.syncApplication({
|
||||
workspaceId,
|
||||
manifest,
|
||||
applicationRegistrationId,
|
||||
});
|
||||
|
||||
const ownerFlatApplication: FlatApplication = application;
|
||||
|
|
@ -64,7 +64,7 @@ export class ApplicationSyncService {
|
|||
ownerFlatApplication,
|
||||
});
|
||||
|
||||
this.logger.log('✅ Application sync from manifest completed');
|
||||
this.logger.log('Application sync from manifest completed');
|
||||
|
||||
return workspaceMigration;
|
||||
}
|
||||
|
|
@ -72,8 +72,11 @@ export class ApplicationSyncService {
|
|||
private async syncApplication({
|
||||
workspaceId,
|
||||
manifest,
|
||||
}: ApplicationInput & {
|
||||
applicationRegistrationId,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
manifest: Manifest;
|
||||
applicationRegistrationId?: string;
|
||||
}): Promise<ApplicationEntity> {
|
||||
const name = manifest.application.displayName;
|
||||
const packageJson = JSON.parse(
|
||||
|
|
@ -103,7 +106,7 @@ export class ApplicationSyncService {
|
|||
name,
|
||||
description: manifest.application.description,
|
||||
version: packageJson.version,
|
||||
sourcePath: 'cli-sync', // Placeholder for CLI-synced apps
|
||||
sourcePath: 'cli-sync',
|
||||
defaultRoleId: null,
|
||||
workspaceId,
|
||||
packageJsonChecksum: defaultPackageFields.packageJsonChecksum,
|
||||
|
|
@ -122,47 +125,8 @@ export class ApplicationSyncService {
|
|||
},
|
||||
);
|
||||
|
||||
const applicationRegistrationMetadata = {
|
||||
name,
|
||||
description: manifest.application.description,
|
||||
logoUrl: manifest.application.logoUrl,
|
||||
author: manifest.application.author,
|
||||
websiteUrl: manifest.application.websiteUrl,
|
||||
termsUrl: manifest.application.termsUrl,
|
||||
};
|
||||
|
||||
const applicationRegistrationId =
|
||||
await this.resolveApplicationRegistrationId(
|
||||
application.applicationRegistrationId,
|
||||
manifest.application.universalIdentifier,
|
||||
applicationRegistrationMetadata,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
// Only update registration metadata if this workspace owns it.
|
||||
// Other workspaces that install the same app attach to the existing
|
||||
// registration but must not be able to modify its metadata.
|
||||
if (
|
||||
await this.applicationRegistrationService.isOwnedByWorkspace(
|
||||
applicationRegistrationId,
|
||||
workspaceId,
|
||||
)
|
||||
) {
|
||||
await this.applicationRegistrationService.update(
|
||||
{
|
||||
id: applicationRegistrationId,
|
||||
update: applicationRegistrationMetadata,
|
||||
},
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
if (manifest.application.serverVariables) {
|
||||
await this.applicationRegistrationVariableService.syncVariableSchemas(
|
||||
applicationRegistrationId,
|
||||
manifest.application.serverVariables,
|
||||
);
|
||||
}
|
||||
const resolvedRegistrationId =
|
||||
applicationRegistrationId ?? application.applicationRegistrationId;
|
||||
|
||||
return await this.applicationService.update(application.id, {
|
||||
name,
|
||||
|
|
@ -170,7 +134,8 @@ export class ApplicationSyncService {
|
|||
version: packageJson.version,
|
||||
packageJsonChecksum: manifest.application.packageJsonChecksum,
|
||||
yarnLockChecksum: manifest.application.yarnLockChecksum,
|
||||
applicationRegistrationId,
|
||||
applicationRegistrationId: resolvedRegistrationId,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -250,44 +215,4 @@ export class ApplicationSyncService {
|
|||
|
||||
return validateAndBuildResult.workspaceMigration;
|
||||
}
|
||||
|
||||
private async resolveApplicationRegistrationId(
|
||||
existingId: string | null,
|
||||
universalIdentifier: string,
|
||||
metadata: {
|
||||
name: string;
|
||||
description?: string;
|
||||
logoUrl?: string;
|
||||
author?: string;
|
||||
websiteUrl?: string;
|
||||
termsUrl?: string;
|
||||
},
|
||||
workspaceId: string,
|
||||
): Promise<string> {
|
||||
if (existingId) {
|
||||
return existingId;
|
||||
}
|
||||
|
||||
const existingRegistration =
|
||||
await this.applicationRegistrationService.findOneByUniversalIdentifier(
|
||||
universalIdentifier,
|
||||
);
|
||||
|
||||
if (existingRegistration) {
|
||||
return existingRegistration.id;
|
||||
}
|
||||
|
||||
const { applicationRegistration: newRegistration } =
|
||||
await this.applicationRegistrationService.create(
|
||||
{ ...metadata, universalIdentifier },
|
||||
workspaceId,
|
||||
null,
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Created app registration for ${metadata.name} (${universalIdentifier})`,
|
||||
);
|
||||
|
||||
return newRegistration.id;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
import { type MarketplaceDisplayData } from 'src/engine/core-modules/application/application-marketplace/types/marketplace-display-data.type';
|
||||
|
||||
export type CuratedAppEntry = {
|
||||
universalIdentifier: string;
|
||||
sourcePackage: string;
|
||||
isFeatured: boolean;
|
||||
|
||||
name: string;
|
||||
description: string;
|
||||
author: string;
|
||||
logoUrl?: string;
|
||||
websiteUrl?: string;
|
||||
termsUrl?: string;
|
||||
|
||||
richDisplayData: MarketplaceDisplayData;
|
||||
};
|
||||
|
||||
const MOCK_ENRICHMENT_APP_ID = 'a1b2c3d4-0000-0000-0000-000000000001';
|
||||
const MOCK_ENRICHMENT_JOB_ID = 'a1b2c3d4-0000-0000-0000-000000000100';
|
||||
|
||||
const COMPANY_UNIVERSAL_ID = '20202020-b374-4779-a561-80086cb2e17f';
|
||||
const PERSON_UNIVERSAL_ID = '20202020-e674-48e5-a542-72570eee7213';
|
||||
|
||||
const MOCK_LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="#1a2744"><ellipse cx="38" cy="20" rx="28" ry="10"/><rect x="10" y="20" width="56" height="50"/><ellipse cx="38" cy="70" rx="28" ry="10"/><ellipse cx="38" cy="35" rx="28" ry="10" fill="none" stroke="#fff" stroke-width="3"/><ellipse cx="38" cy="52" rx="28" ry="10" fill="none" stroke="#fff" stroke-width="3"/><circle cx="72" cy="62" r="22" fill="#1a2744"/><circle cx="72" cy="62" r="18" fill="#fff"/><path d="M72 50 L72 74 M62 58 L72 48 L82 58" stroke="#1a2744" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>`;
|
||||
const ENCODED_MOCK_LOGO = `data:image/svg+xml,${encodeURIComponent(MOCK_LOGO_SVG)}`;
|
||||
|
||||
export const MARKETPLACE_CATALOG_INDEX: CuratedAppEntry[] = [
|
||||
{
|
||||
universalIdentifier: MOCK_ENRICHMENT_APP_ID,
|
||||
sourcePackage: '@twentyhq/app-data-enrichment',
|
||||
isFeatured: true,
|
||||
name: 'Data Enrichment',
|
||||
description: 'Enrich your data easily. Choose your provider.',
|
||||
author: 'Twenty',
|
||||
logoUrl: ENCODED_MOCK_LOGO,
|
||||
websiteUrl: 'https://twenty.com',
|
||||
richDisplayData: {
|
||||
icon: 'IconSparkles',
|
||||
version: '1.0.0',
|
||||
category: 'Data',
|
||||
logo: ENCODED_MOCK_LOGO,
|
||||
screenshots: [
|
||||
'https://placehold.co/800x400/f5f5f5/666?text=Screenshot+1',
|
||||
'https://placehold.co/800x400/f5f5f5/666?text=Screenshot+2',
|
||||
'https://placehold.co/800x400/f5f5f5/666?text=Screenshot+3',
|
||||
],
|
||||
aboutDescription:
|
||||
'Enhance your workspace with automated data intelligence. This app monitors your new records and automatically populates missing details such as job titles, company size, social profiles, and industry insights.',
|
||||
providers: ['Clearbit', 'Apollo', 'Hunter.io'],
|
||||
objects: [
|
||||
{
|
||||
universalIdentifier: MOCK_ENRICHMENT_JOB_ID,
|
||||
nameSingular: 'enrichmentJob',
|
||||
namePlural: 'enrichmentJobs',
|
||||
labelSingular: 'Enrichment Job',
|
||||
labelPlural: 'Enrichment Jobs',
|
||||
description: 'Tracks data enrichment requests and their status',
|
||||
icon: 'IconSparkles',
|
||||
fields: [
|
||||
{
|
||||
name: 'status',
|
||||
type: 'SELECT',
|
||||
label: 'Status',
|
||||
description: 'Current status of the enrichment job',
|
||||
icon: 'IconProgressCheck',
|
||||
universalIdentifier: 'a1b2c3d4-0000-0000-0000-000000000101',
|
||||
objectUniversalIdentifier: MOCK_ENRICHMENT_JOB_ID,
|
||||
},
|
||||
{
|
||||
name: 'provider',
|
||||
type: 'TEXT',
|
||||
label: 'Provider',
|
||||
description: 'Enrichment provider used',
|
||||
icon: 'IconCloud',
|
||||
universalIdentifier: 'a1b2c3d4-0000-0000-0000-000000000102',
|
||||
objectUniversalIdentifier: MOCK_ENRICHMENT_JOB_ID,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
name: 'industry',
|
||||
type: 'TEXT',
|
||||
label: 'Industry',
|
||||
description: 'Company industry from enrichment',
|
||||
icon: 'IconBuildingFactory2',
|
||||
objectUniversalIdentifier: COMPANY_UNIVERSAL_ID,
|
||||
universalIdentifier: 'a1b2c3d4-0000-0000-0000-000000000201',
|
||||
},
|
||||
{
|
||||
name: 'linkedInUrl',
|
||||
type: 'LINKS',
|
||||
label: 'LinkedIn URL',
|
||||
description: 'LinkedIn profile URL from enrichment',
|
||||
icon: 'IconBrandLinkedin',
|
||||
objectUniversalIdentifier: PERSON_UNIVERSAL_ID,
|
||||
universalIdentifier: 'a1b2c3d4-0000-0000-0000-000000000203',
|
||||
},
|
||||
],
|
||||
logicFunctions: [
|
||||
{
|
||||
name: 'enrich-on-create',
|
||||
description:
|
||||
'Automatically enriches new records when they are created',
|
||||
timeoutSeconds: 30,
|
||||
},
|
||||
],
|
||||
frontComponents: [],
|
||||
defaultRole: {
|
||||
id: 'a1b2c3d4-0000-0000-0000-000000000010',
|
||||
label: 'Data Enrichment default role',
|
||||
description: 'Default permissions for the Data Enrichment app',
|
||||
canReadAllObjectRecords: true,
|
||||
canUpdateAllObjectRecords: true,
|
||||
canSoftDeleteAllObjectRecords: true,
|
||||
canDestroyAllObjectRecords: true,
|
||||
canUpdateAllSettings: false,
|
||||
canAccessAllTools: false,
|
||||
objectPermissions: [
|
||||
{
|
||||
objectUniversalIdentifier: COMPANY_UNIVERSAL_ID,
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: true,
|
||||
canSoftDeleteObjectRecords: false,
|
||||
canDestroyObjectRecords: false,
|
||||
},
|
||||
{
|
||||
objectUniversalIdentifier: PERSON_UNIVERSAL_ID,
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: true,
|
||||
canSoftDeleteObjectRecords: true,
|
||||
canDestroyObjectRecords: false,
|
||||
},
|
||||
],
|
||||
fieldPermissions: [],
|
||||
permissionFlags: ['DATA_MODEL', 'API_KEYS_AND_WEBHOOKS'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
|
||||
sourcePackage: '@twentyhq/hello-world',
|
||||
isFeatured: false,
|
||||
name: 'Hello World',
|
||||
description: 'A simple hello world app to get started with Twenty apps.',
|
||||
author: 'Twenty',
|
||||
websiteUrl: 'https://twenty.com',
|
||||
richDisplayData: {
|
||||
icon: 'IconWorld',
|
||||
version: '0.2.2',
|
||||
category: 'Getting Started',
|
||||
screenshots: [],
|
||||
aboutDescription:
|
||||
'A minimal example app that demonstrates the Twenty app framework. Creates a PostCard object and a logic function to generate new postcards. Great starting point for building your own apps.',
|
||||
providers: [],
|
||||
objects: [
|
||||
{
|
||||
universalIdentifier: 'e2c3d4f5-0000-0000-0000-000000000001',
|
||||
nameSingular: 'postCard',
|
||||
namePlural: 'postCards',
|
||||
labelSingular: 'Post Card',
|
||||
labelPlural: 'Post Cards',
|
||||
description: 'A simple postcard object',
|
||||
icon: 'IconMail',
|
||||
fields: [],
|
||||
},
|
||||
],
|
||||
fields: [],
|
||||
logicFunctions: [
|
||||
{
|
||||
name: 'create-new-post-card',
|
||||
description: 'Creates a new postcard record',
|
||||
},
|
||||
],
|
||||
frontComponents: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { Command, CommandRunner } from 'nest-commander';
|
||||
|
||||
import { MARKETPLACE_CATALOG_SYNC_CRON_PATTERN } from 'src/engine/core-modules/application/application-marketplace/crons/constants/marketplace-catalog-sync-cron-pattern.constant';
|
||||
import { MarketplaceCatalogSyncCronJob } from 'src/engine/core-modules/application/application-marketplace/crons/marketplace-catalog-sync.cron.job';
|
||||
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';
|
||||
|
||||
@Command({
|
||||
name: 'cron:marketplace-catalog-sync',
|
||||
description:
|
||||
'Starts a cron job to sync the marketplace catalog into ApplicationRegistration',
|
||||
})
|
||||
export class MarketplaceCatalogSyncCronCommand extends CommandRunner {
|
||||
constructor(
|
||||
@InjectMessageQueue(MessageQueue.cronQueue)
|
||||
private readonly messageQueueService: MessageQueueService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
await this.messageQueueService.add(MarketplaceCatalogSyncCronJob.name, {});
|
||||
|
||||
await this.messageQueueService.addCron<undefined>({
|
||||
jobName: MarketplaceCatalogSyncCronJob.name,
|
||||
data: undefined,
|
||||
options: {
|
||||
repeat: {
|
||||
pattern: MARKETPLACE_CATALOG_SYNC_CRON_PATTERN,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export const MARKETPLACE_CATALOG_SYNC_CRON_PATTERN = '0 * * * *';
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
|
||||
import { MARKETPLACE_CATALOG_SYNC_CRON_PATTERN } from 'src/engine/core-modules/application/application-marketplace/crons/constants/marketplace-catalog-sync-cron-pattern.constant';
|
||||
import { MarketplaceCatalogSyncService } from 'src/engine/core-modules/application/application-marketplace/services/marketplace-catalog-sync.service';
|
||||
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
|
||||
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
|
||||
@Injectable()
|
||||
@Processor(MessageQueue.cronQueue)
|
||||
export class MarketplaceCatalogSyncCronJob {
|
||||
private readonly logger = new Logger(MarketplaceCatalogSyncCronJob.name);
|
||||
|
||||
constructor(
|
||||
private readonly marketplaceCatalogSyncService: MarketplaceCatalogSyncService,
|
||||
) {}
|
||||
|
||||
@Process(MarketplaceCatalogSyncCronJob.name)
|
||||
@SentryCronMonitor(
|
||||
MarketplaceCatalogSyncCronJob.name,
|
||||
MARKETPLACE_CATALOG_SYNC_CRON_PATTERN,
|
||||
)
|
||||
async handle(): Promise<void> {
|
||||
this.logger.log('Starting marketplace catalog sync...');
|
||||
|
||||
try {
|
||||
await this.marketplaceCatalogSyncService.syncCatalog();
|
||||
this.logger.log('Marketplace catalog sync completed successfully');
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Marketplace catalog sync failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,9 @@ import {
|
|||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
@ObjectType('MarketplaceAppField')
|
||||
export class MarketplaceAppFieldDTO {
|
||||
|
|
@ -274,6 +276,13 @@ export class MarketplaceAppDTO {
|
|||
frontComponents: MarketplaceAppFrontComponentDTO[];
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => MarketplaceAppDefaultRoleDTO)
|
||||
@Field(() => MarketplaceAppDefaultRoleDTO, { nullable: true })
|
||||
defaultRole?: MarketplaceAppDefaultRoleDTO;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Field({ nullable: true })
|
||||
sourcePackage?: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import { ApplicationInstallModule } from 'src/engine/core-modules/application/application-install/application-install.module';
|
||||
import { MarketplaceCatalogSyncCronCommand } from 'src/engine/core-modules/application/application-marketplace/crons/commands/marketplace-catalog-sync.cron.command';
|
||||
import { MarketplaceCatalogSyncCronJob } from 'src/engine/core-modules/application/application-marketplace/crons/marketplace-catalog-sync.cron.job';
|
||||
import { MarketplaceCatalogSyncService } from 'src/engine/core-modules/application/application-marketplace/services/marketplace-catalog-sync.service';
|
||||
import { MarketplaceQueryService } from 'src/engine/core-modules/application/application-marketplace/services/marketplace-query.service';
|
||||
import { MarketplaceResolver } from 'src/engine/core-modules/application/application-marketplace/resolvers/marketplace.resolver';
|
||||
import { MarketplaceService } from 'src/engine/core-modules/application/application-marketplace/services/marketplace.service';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([ApplicationRegistrationEntity]),
|
||||
ApplicationInstallModule,
|
||||
FeatureFlagModule,
|
||||
PermissionsModule,
|
||||
TwentyConfigModule,
|
||||
],
|
||||
providers: [
|
||||
MarketplaceService,
|
||||
MarketplaceCatalogSyncService,
|
||||
MarketplaceQueryService,
|
||||
MarketplaceCatalogSyncCronJob,
|
||||
MarketplaceCatalogSyncCronCommand,
|
||||
MarketplaceResolver,
|
||||
],
|
||||
exports: [
|
||||
MarketplaceCatalogSyncService,
|
||||
MarketplaceQueryService,
|
||||
MarketplaceCatalogSyncCronCommand,
|
||||
],
|
||||
})
|
||||
export class MarketplaceModule {}
|
||||
|
Before Width: | Height: | Size: 743 B After Width: | Height: | Size: 743 B |
|
|
@ -0,0 +1,114 @@
|
|||
import { UseFilters, UseGuards } from '@nestjs/common';
|
||||
import { Args, Mutation, Query } from '@nestjs/graphql';
|
||||
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { ApplicationRegistrationExceptionFilter } from 'src/engine/core-modules/application/application-registration/application-registration-exception-filter';
|
||||
import {
|
||||
ApplicationRegistrationException,
|
||||
ApplicationRegistrationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application-registration/application-registration.exception';
|
||||
import { AppRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/app-registration-source-type.enum';
|
||||
import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service';
|
||||
import { AppUpgradeService } from 'src/engine/core-modules/application/application-install/app-upgrade.service';
|
||||
import { MarketplaceAppDTO } from 'src/engine/core-modules/application/application-marketplace/dtos/marketplace-app.dto';
|
||||
import { MarketplaceQueryService } from 'src/engine/core-modules/application/application-marketplace/services/marketplace-query.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import {
|
||||
FeatureFlagGuard,
|
||||
RequireFeatureFlag,
|
||||
} from 'src/engine/guards/feature-flag.guard';
|
||||
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
|
||||
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
|
||||
@MetadataResolver()
|
||||
@UseFilters(ApplicationRegistrationExceptionFilter)
|
||||
@UseGuards(
|
||||
UserAuthGuard,
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
NoPermissionGuard,
|
||||
)
|
||||
export class MarketplaceResolver {
|
||||
constructor(
|
||||
private readonly marketplaceQueryService: MarketplaceQueryService,
|
||||
private readonly applicationInstallService: ApplicationInstallService,
|
||||
private readonly appUpgradeService: AppUpgradeService,
|
||||
) {}
|
||||
|
||||
@Query(() => [MarketplaceAppDTO])
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
async findManyMarketplaceApps(): Promise<MarketplaceAppDTO[]> {
|
||||
return this.marketplaceQueryService.findManyMarketplaceApps();
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.MARKETPLACE_APPS))
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
async installMarketplaceApp(
|
||||
@Args('universalIdentifier') universalIdentifier: string,
|
||||
@Args('version', { type: () => String, nullable: true })
|
||||
version: string | undefined,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<boolean> {
|
||||
const registration =
|
||||
await this.marketplaceQueryService.findRegistrationByUniversalIdentifier(
|
||||
universalIdentifier,
|
||||
);
|
||||
|
||||
if (registration.sourceType !== AppRegistrationSourceType.NPM) {
|
||||
throw new ApplicationRegistrationException(
|
||||
`Only NPM apps can be installed via the marketplace`,
|
||||
ApplicationRegistrationExceptionCode.SOURCE_CHANNEL_MISMATCH,
|
||||
);
|
||||
}
|
||||
|
||||
return this.applicationInstallService.installApplication({
|
||||
appRegistrationId: registration.id,
|
||||
version,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.MARKETPLACE_APPS))
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
async installNpmApp(
|
||||
@Args('packageName') packageName: string,
|
||||
@Args('version', { type: () => String, nullable: true })
|
||||
version: string | undefined,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<boolean> {
|
||||
const registration =
|
||||
await this.marketplaceQueryService.findOrCreateNpmRegistration({
|
||||
packageName,
|
||||
ownerWorkspaceId: workspace.id,
|
||||
});
|
||||
|
||||
return this.applicationInstallService.installApplication({
|
||||
appRegistrationId: registration.id,
|
||||
version,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.MARKETPLACE_APPS))
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
async upgradeApplication(
|
||||
@Args('appRegistrationId') appRegistrationId: string,
|
||||
@Args('targetVersion') targetVersion: string,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<boolean> {
|
||||
return this.appUpgradeService.upgradeApplication({
|
||||
appRegistrationId,
|
||||
targetVersion,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
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/application-registration/application-registration.entity';
|
||||
import { AppRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/app-registration-source-type.enum';
|
||||
import { MARKETPLACE_CATALOG_INDEX } from 'src/engine/core-modules/application/application-marketplace/constants/marketplace-catalog-index.constant';
|
||||
import { MarketplaceService } from 'src/engine/core-modules/application/application-marketplace/services/marketplace.service';
|
||||
import { getAdminWorkspaceId } from 'src/engine/core-modules/application/application-marketplace/utils/get-admin-workspace-id.util';
|
||||
|
||||
@Injectable()
|
||||
export class MarketplaceCatalogSyncService {
|
||||
private readonly logger = new Logger(MarketplaceCatalogSyncService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ApplicationRegistrationEntity)
|
||||
private readonly appRegistrationRepository: Repository<ApplicationRegistrationEntity>,
|
||||
private readonly marketplaceService: MarketplaceService,
|
||||
) {}
|
||||
|
||||
async syncCatalog(): Promise<void> {
|
||||
const dataSource = this.appRegistrationRepository.manager.connection;
|
||||
const adminWorkspaceId = await getAdminWorkspaceId(dataSource);
|
||||
|
||||
if (!isDefined(adminWorkspaceId)) {
|
||||
this.logger.warn(
|
||||
'No admin workspace found. Skipping marketplace catalog sync.',
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await this.syncCuratedApps(adminWorkspaceId);
|
||||
await this.syncNpmApps(adminWorkspaceId);
|
||||
|
||||
this.logger.log('Marketplace catalog sync completed');
|
||||
}
|
||||
|
||||
private async syncCuratedApps(ownerWorkspaceId: string): Promise<void> {
|
||||
for (const entry of MARKETPLACE_CATALOG_INDEX) {
|
||||
try {
|
||||
await this.upsertRegistration({
|
||||
universalIdentifier: entry.universalIdentifier,
|
||||
name: entry.name,
|
||||
description:
|
||||
entry.richDisplayData.aboutDescription ?? entry.description,
|
||||
author: entry.author,
|
||||
sourceType: AppRegistrationSourceType.NPM,
|
||||
sourcePackage: entry.sourcePackage,
|
||||
logoUrl: entry.logoUrl ?? null,
|
||||
websiteUrl: entry.websiteUrl ?? null,
|
||||
termsUrl: entry.termsUrl ?? null,
|
||||
latestAvailableVersion: entry.richDisplayData.version ?? null,
|
||||
isFeatured: entry.isFeatured,
|
||||
marketplaceDisplayData: entry.richDisplayData,
|
||||
ownerWorkspaceId,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to sync curated app "${entry.name}": ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async syncNpmApps(ownerWorkspaceId: string): Promise<void> {
|
||||
const npmApps = await this.marketplaceService.fetchAppsFromNpmRegistry();
|
||||
|
||||
const curatedIdentifiers = new Set(
|
||||
MARKETPLACE_CATALOG_INDEX.map((entry) => entry.universalIdentifier),
|
||||
);
|
||||
|
||||
for (const app of npmApps) {
|
||||
if (curatedIdentifiers.has(app.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.upsertRegistration({
|
||||
universalIdentifier: app.id,
|
||||
name: app.name,
|
||||
description: app.description,
|
||||
author: app.author,
|
||||
sourceType: AppRegistrationSourceType.NPM,
|
||||
sourcePackage: app.sourcePackage ?? app.name,
|
||||
logoUrl: null,
|
||||
websiteUrl: app.websiteUrl ?? null,
|
||||
termsUrl: null,
|
||||
latestAvailableVersion: app.version ?? null,
|
||||
isFeatured: false,
|
||||
marketplaceDisplayData: null,
|
||||
ownerWorkspaceId,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to sync npm app "${app.name}": ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lookup by universalIdentifier only (matches the unique constraint).
|
||||
// ownerWorkspaceId is only set on insert.
|
||||
private async upsertRegistration(
|
||||
params: Pick<
|
||||
ApplicationRegistrationEntity,
|
||||
| 'universalIdentifier'
|
||||
| 'name'
|
||||
| 'description'
|
||||
| 'author'
|
||||
| 'sourceType'
|
||||
| 'sourcePackage'
|
||||
| 'logoUrl'
|
||||
| 'websiteUrl'
|
||||
| 'termsUrl'
|
||||
| 'latestAvailableVersion'
|
||||
| 'isFeatured'
|
||||
| 'marketplaceDisplayData'
|
||||
| 'ownerWorkspaceId'
|
||||
>,
|
||||
): Promise<void> {
|
||||
const existing = await this.appRegistrationRepository.findOne({
|
||||
where: {
|
||||
universalIdentifier: params.universalIdentifier,
|
||||
},
|
||||
});
|
||||
|
||||
if (isDefined(existing)) {
|
||||
await this.appRegistrationRepository.save({
|
||||
...existing,
|
||||
name: params.name,
|
||||
description: params.description,
|
||||
author: params.author,
|
||||
sourceType: params.sourceType,
|
||||
sourcePackage: params.sourcePackage,
|
||||
logoUrl: params.logoUrl,
|
||||
websiteUrl: params.websiteUrl,
|
||||
termsUrl: params.termsUrl,
|
||||
latestAvailableVersion: params.latestAvailableVersion,
|
||||
isFeatured: params.isFeatured,
|
||||
marketplaceDisplayData: params.marketplaceDisplayData,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const registration = this.appRegistrationRepository.create({
|
||||
universalIdentifier: params.universalIdentifier,
|
||||
name: params.name,
|
||||
description: params.description,
|
||||
author: params.author,
|
||||
sourceType: params.sourceType,
|
||||
sourcePackage: params.sourcePackage,
|
||||
logoUrl: params.logoUrl,
|
||||
websiteUrl: params.websiteUrl,
|
||||
termsUrl: params.termsUrl,
|
||||
latestAvailableVersion: params.latestAvailableVersion,
|
||||
isFeatured: params.isFeatured,
|
||||
marketplaceDisplayData: params.marketplaceDisplayData,
|
||||
oAuthClientId: v4(),
|
||||
oAuthRedirectUris: [],
|
||||
oAuthScopes: [],
|
||||
ownerWorkspaceId: params.ownerWorkspaceId,
|
||||
});
|
||||
|
||||
await this.appRegistrationRepository.save(registration);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
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/application-registration/application-registration.entity';
|
||||
import {
|
||||
ApplicationRegistrationException,
|
||||
ApplicationRegistrationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application-registration/application-registration.exception';
|
||||
import { assertValidNpmPackageName } from 'src/engine/core-modules/application/utils/assert-valid-npm-package-name.util';
|
||||
import { AppRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/app-registration-source-type.enum';
|
||||
import { MarketplaceCatalogSyncCronJob } from 'src/engine/core-modules/application/application-marketplace/crons/marketplace-catalog-sync.cron.job';
|
||||
import { MarketplaceAppDTO } from 'src/engine/core-modules/application/application-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';
|
||||
|
||||
const MARKETPLACE_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
@Injectable()
|
||||
export class MarketplaceQueryService {
|
||||
private readonly logger = new Logger(MarketplaceQueryService.name);
|
||||
private cachedApps: MarketplaceAppDTO[] | null = null;
|
||||
private cacheExpiresAt = 0;
|
||||
private hasSyncBeenEnqueued = false;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ApplicationRegistrationEntity)
|
||||
private readonly appRegistrationRepository: Repository<ApplicationRegistrationEntity>,
|
||||
@InjectMessageQueue(MessageQueue.cronQueue)
|
||||
private readonly messageQueueService: MessageQueueService,
|
||||
) {}
|
||||
|
||||
async findManyMarketplaceApps(): Promise<MarketplaceAppDTO[]> {
|
||||
if (this.cachedApps !== null && Date.now() < this.cacheExpiresAt) {
|
||||
return this.cachedApps;
|
||||
}
|
||||
|
||||
const registrations = await this.appRegistrationRepository.find({
|
||||
where: { sourceType: AppRegistrationSourceType.NPM },
|
||||
});
|
||||
|
||||
if (registrations.length === 0) {
|
||||
if (!this.hasSyncBeenEnqueued) {
|
||||
this.hasSyncBeenEnqueued = true;
|
||||
this.logger.log(
|
||||
'No marketplace registrations found, enqueuing one-time sync job',
|
||||
);
|
||||
await this.messageQueueService.add(
|
||||
MarketplaceCatalogSyncCronJob.name,
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
this.cachedApps = registrations.map((registration) =>
|
||||
this.toMarketplaceAppDTO(registration),
|
||||
);
|
||||
this.cacheExpiresAt = Date.now() + MARKETPLACE_CACHE_TTL_MS;
|
||||
|
||||
return this.cachedApps;
|
||||
}
|
||||
|
||||
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> {
|
||||
assertValidNpmPackageName(params.packageName);
|
||||
|
||||
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}"`,
|
||||
);
|
||||
|
||||
try {
|
||||
const registration = this.appRegistrationRepository.create({
|
||||
universalIdentifier: v4(),
|
||||
name: params.packageName,
|
||||
sourceType: AppRegistrationSourceType.NPM,
|
||||
sourcePackage: params.packageName,
|
||||
oAuthClientId: v4(),
|
||||
oAuthRedirectUris: [],
|
||||
oAuthScopes: [],
|
||||
ownerWorkspaceId: params.ownerWorkspaceId,
|
||||
});
|
||||
|
||||
return await this.appRegistrationRepository.save(registration);
|
||||
} catch {
|
||||
const concurrentlyCreated = await this.appRegistrationRepository.findOne({
|
||||
where: { sourcePackage: params.packageName },
|
||||
});
|
||||
|
||||
if (isDefined(concurrentlyCreated)) {
|
||||
return concurrentlyCreated;
|
||||
}
|
||||
|
||||
throw new ApplicationRegistrationException(
|
||||
`Failed to create registration for package "${params.packageName}"`,
|
||||
ApplicationRegistrationExceptionCode.APPLICATION_REGISTRATION_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import axios from 'axios';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { MarketplaceAppDTO } from 'src/engine/core-modules/application/application-marketplace/dtos/marketplace-app.dto';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
const npmSearchResultSchema = z.object({
|
||||
objects: z.array(
|
||||
z.object({
|
||||
package: z.object({
|
||||
name: z.string(),
|
||||
version: z.string(),
|
||||
description: z.string().optional(),
|
||||
keywords: z.array(z.string()).optional(),
|
||||
author: z.object({ name: z.string().optional() }).optional(),
|
||||
links: z.object({ homepage: z.string().optional() }).optional(),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
export class MarketplaceService {
|
||||
private readonly logger = new Logger(MarketplaceService.name);
|
||||
|
||||
constructor(private readonly twentyConfigService: TwentyConfigService) {}
|
||||
|
||||
async fetchAppsFromNpmRegistry(): Promise<MarketplaceAppDTO[]> {
|
||||
const registryUrl = this.twentyConfigService.get('APP_REGISTRY_URL');
|
||||
|
||||
try {
|
||||
const { data } = await axios.get(
|
||||
`${registryUrl}/-/v1/search?text=keywords:twenty-app&size=250`,
|
||||
{
|
||||
headers: { 'User-Agent': 'Twenty-Marketplace' },
|
||||
timeout: 10_000,
|
||||
},
|
||||
);
|
||||
|
||||
const parsed = npmSearchResultSchema.safeParse(data);
|
||||
|
||||
if (!parsed.success) {
|
||||
this.logger.warn(
|
||||
`Unexpected npm search response shape: ${parsed.error.message}`,
|
||||
);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
return parsed.data.objects
|
||||
.map((result) => {
|
||||
const { name, version, description, author, links } = result.package;
|
||||
const twentyKeyword = (result.package.keywords ?? []).find(
|
||||
(keyword) => keyword.startsWith('twenty-uid:'),
|
||||
);
|
||||
|
||||
if (!isDefined(twentyKeyword)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const universalIdentifier = twentyKeyword.replace('twenty-uid:', '');
|
||||
|
||||
return {
|
||||
id: universalIdentifier,
|
||||
name,
|
||||
description: description ?? '',
|
||||
icon: 'IconApps',
|
||||
version,
|
||||
author: author?.name ?? 'Unknown',
|
||||
category: '',
|
||||
screenshots: [],
|
||||
aboutDescription: description ?? '',
|
||||
providers: [],
|
||||
websiteUrl: links?.homepage,
|
||||
objects: [],
|
||||
fields: [],
|
||||
logicFunctions: [],
|
||||
frontComponents: [],
|
||||
sourcePackage: name,
|
||||
};
|
||||
})
|
||||
.filter(isDefined);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to fetch apps from npm registry: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
// Rich display data stored alongside ApplicationRegistration for marketplace
|
||||
// rendering. This is denormalized from the catalog source so it can be displayed
|
||||
// pre-install without resolving the package.
|
||||
export type MarketplaceDisplayData = {
|
||||
icon?: string;
|
||||
version?: string;
|
||||
category?: string;
|
||||
logo?: string;
|
||||
screenshots?: string[];
|
||||
aboutDescription?: string;
|
||||
providers?: string[];
|
||||
objects?: MarketplaceDisplayObject[];
|
||||
fields?: MarketplaceDisplayField[];
|
||||
logicFunctions?: MarketplaceDisplayLogicFunction[];
|
||||
frontComponents?: MarketplaceDisplayFrontComponent[];
|
||||
defaultRole?: MarketplaceDisplayDefaultRole;
|
||||
};
|
||||
|
||||
type MarketplaceDisplayObject = {
|
||||
universalIdentifier: string;
|
||||
nameSingular: string;
|
||||
namePlural: string;
|
||||
labelSingular: string;
|
||||
labelPlural: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
fields: MarketplaceDisplayField[];
|
||||
};
|
||||
|
||||
type MarketplaceDisplayField = {
|
||||
name: string;
|
||||
type: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
objectUniversalIdentifier: string;
|
||||
universalIdentifier: string;
|
||||
};
|
||||
|
||||
type MarketplaceDisplayLogicFunction = {
|
||||
name: string;
|
||||
description?: string;
|
||||
timeoutSeconds?: number;
|
||||
};
|
||||
|
||||
type MarketplaceDisplayFrontComponent = {
|
||||
name: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
type MarketplaceDisplayDefaultRole = {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
canReadAllObjectRecords: boolean;
|
||||
canUpdateAllObjectRecords: boolean;
|
||||
canSoftDeleteAllObjectRecords: boolean;
|
||||
canDestroyAllObjectRecords: boolean;
|
||||
canUpdateAllSettings: boolean;
|
||||
canAccessAllTools: boolean;
|
||||
objectPermissions: Array<{
|
||||
objectUniversalIdentifier: string;
|
||||
canReadObjectRecords?: boolean;
|
||||
canUpdateObjectRecords?: boolean;
|
||||
canSoftDeleteObjectRecords?: boolean;
|
||||
canDestroyObjectRecords?: boolean;
|
||||
}>;
|
||||
fieldPermissions: Array<{
|
||||
objectUniversalIdentifier: string;
|
||||
fieldUniversalIdentifier: string;
|
||||
canReadFieldValue?: boolean;
|
||||
canUpdateFieldValue?: boolean;
|
||||
}>;
|
||||
permissionFlags: string[];
|
||||
};
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
import { type DataSource } from 'typeorm';
|
||||
|
||||
// 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> => {
|
||||
const result = await dataSource.query<Array<{ workspaceId: string }>>(
|
||||
`SELECT uw."workspaceId"
|
||||
FROM core."userWorkspace" uw
|
||||
JOIN core."user" u ON u.id = uw."userId" AND u."deletedAt" IS NULL
|
||||
JOIN core."workspace" w ON w.id = uw."workspaceId" AND w."deletedAt" IS NULL
|
||||
WHERE (u."canAccessFullAdminPanel" = true OR u."canImpersonate" = true)
|
||||
AND w."activationStatus" = $1
|
||||
AND uw."deletedAt" IS NULL
|
||||
ORDER BY w."createdAt" ASC
|
||||
LIMIT 1`,
|
||||
[WorkspaceActivationStatus.ACTIVE],
|
||||
);
|
||||
|
||||
if (result.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result[0].workspaceId;
|
||||
};
|
||||
|
|
@ -2,7 +2,7 @@ import {
|
|||
ALL_OAUTH_SCOPES,
|
||||
OAUTH_SCOPE_DESCRIPTIONS,
|
||||
OAUTH_SCOPES,
|
||||
} from 'src/engine/core-modules/application-registration/constants/oauth-scopes';
|
||||
} from 'src/engine/core-modules/application/application-registration/constants/oauth-scopes';
|
||||
|
||||
describe('OAuth Scopes', () => {
|
||||
it('should have all scopes defined', () => {
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { Catch, ExceptionFilter } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
ApplicationRegistrationException,
|
||||
ApplicationRegistrationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ import {
|
|||
} from 'typeorm';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application-registration/application-registration.entity';
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
|
||||
@Entity({ name: 'applicationRegistrationVariable', schema: 'core' })
|
||||
@ObjectType('ApplicationRegistrationVariable')
|
||||
|
|
@ -5,14 +5,14 @@ import { type ServerVariables } from 'twenty-shared/application';
|
|||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { In, Not, type Repository } from 'typeorm';
|
||||
|
||||
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application-registration/application-registration-variable.entity';
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application-registration/application-registration.entity';
|
||||
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration/application-registration-variable.entity';
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import {
|
||||
ApplicationRegistrationException,
|
||||
ApplicationRegistrationExceptionCode,
|
||||
} from 'src/engine/core-modules/application-registration/application-registration.exception';
|
||||
import { type CreateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application-registration/dtos/create-application-registration-variable.input';
|
||||
import { type UpdateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application-registration/dtos/update-application-registration-variable.input';
|
||||
} from 'src/engine/core-modules/application/application-registration/application-registration.exception';
|
||||
import { type CreateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application/application-registration/dtos/create-application-registration-variable.input';
|
||||
import { type UpdateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application/application-registration/dtos/update-application-registration-variable.input';
|
||||
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -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) {
|
||||
|
|
@ -2,6 +2,7 @@ import { Field, ObjectType } from '@nestjs/graphql';
|
|||
|
||||
import { IDField } from '@ptc-org/nestjs-query-graphql';
|
||||
import {
|
||||
Check,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
|
|
@ -10,13 +11,17 @@ import {
|
|||
JoinColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
OneToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
type Relation,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application-registration/application-registration-variable.entity';
|
||||
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration/application-registration-variable.entity';
|
||||
import { AppRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/app-registration-source-type.enum';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { type MarketplaceDisplayData } from 'src/engine/core-modules/application/application-marketplace/types/marketplace-display-data.type';
|
||||
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
|
|
@ -39,7 +44,11 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent
|
|||
},
|
||||
)
|
||||
@Index('IDX_APPLICATION_REGISTRATION_CREATED_BY_USER_ID', ['createdByUserId'])
|
||||
@Index('IDX_APPLICATION_REGISTRATION_WORKSPACE_ID', ['workspaceId'])
|
||||
@Index('IDX_APPLICATION_REGISTRATION_WORKSPACE_ID', ['ownerWorkspaceId'])
|
||||
@Check(
|
||||
'CHK_NPM_HAS_SOURCE_PACKAGE',
|
||||
`"sourceType" <> 'npm' OR "sourcePackage" IS NOT NULL`,
|
||||
)
|
||||
export class ApplicationRegistrationEntity {
|
||||
@IDField(() => UUIDScalarType)
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
|
|
@ -87,13 +96,38 @@ export class ApplicationRegistrationEntity {
|
|||
@JoinColumn({ name: 'createdByUserId' })
|
||||
createdByUser: Relation<UserEntity> | null;
|
||||
|
||||
@Column({ nullable: false, type: 'uuid' })
|
||||
workspaceId: string;
|
||||
// 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({ name: 'workspaceId', nullable: false, type: 'uuid' })
|
||||
ownerWorkspaceId: string;
|
||||
|
||||
@ManyToOne(() => WorkspaceEntity, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'workspaceId' })
|
||||
workspace: Relation<WorkspaceEntity>;
|
||||
|
||||
@Field(() => AppRegistrationSourceType)
|
||||
@Column({
|
||||
type: 'text',
|
||||
default: AppRegistrationSourceType.LOCAL,
|
||||
})
|
||||
sourceType: AppRegistrationSourceType;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@Column({ nullable: true, type: 'text' })
|
||||
sourcePackage: string | null;
|
||||
|
||||
@Column({ nullable: true, type: 'uuid' })
|
||||
tarballFileId: string | null;
|
||||
|
||||
@OneToOne(() => FileEntity, { onDelete: 'SET NULL', nullable: true })
|
||||
@JoinColumn({ name: 'tarballFileId' })
|
||||
tarballFile: Relation<FileEntity> | null;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@Column({ nullable: true, type: 'text' })
|
||||
latestAvailableVersion: string | null;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@Column({ nullable: true, type: 'text' })
|
||||
websiteUrl: string | null;
|
||||
|
|
@ -102,6 +136,13 @@ export class ApplicationRegistrationEntity {
|
|||
@Column({ nullable: true, type: 'text' })
|
||||
termsUrl: string | null;
|
||||
|
||||
@Field(() => Boolean)
|
||||
@Column({ name: 'isFeatured', type: 'boolean', default: false })
|
||||
isFeatured: boolean;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
marketplaceDisplayData: MarketplaceDisplayData | null;
|
||||
|
||||
@OneToMany(
|
||||
() => ApplicationRegistrationVariableEntity,
|
||||
(variable) => variable.applicationRegistration,
|
||||
|
|
@ -9,6 +9,8 @@ export enum ApplicationRegistrationExceptionCode {
|
|||
UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED = 'UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED',
|
||||
INVALID_SCOPE = 'INVALID_SCOPE',
|
||||
INVALID_REDIRECT_URI = 'INVALID_REDIRECT_URI',
|
||||
INVALID_INPUT = 'INVALID_INPUT',
|
||||
SOURCE_CHANNEL_MISMATCH = 'SOURCE_CHANNEL_MISMATCH',
|
||||
VARIABLE_NOT_FOUND = 'VARIABLE_NOT_FOUND',
|
||||
}
|
||||
|
||||
|
|
@ -24,6 +26,10 @@ const getExceptionUserFriendlyMessage = (
|
|||
return msg`One or more requested scopes are invalid.`;
|
||||
case ApplicationRegistrationExceptionCode.INVALID_REDIRECT_URI:
|
||||
return msg`One or more redirect URIs are invalid.`;
|
||||
case ApplicationRegistrationExceptionCode.INVALID_INPUT:
|
||||
return msg`Invalid input for application registration.`;
|
||||
case ApplicationRegistrationExceptionCode.SOURCE_CHANNEL_MISMATCH:
|
||||
return msg`The app source channel does not match the expected type.`;
|
||||
case ApplicationRegistrationExceptionCode.VARIABLE_NOT_FOUND:
|
||||
return msg`Application registration variable not found.`;
|
||||
default:
|
||||
|
|
@ -1,22 +1,27 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
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';
|
||||
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 { 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';
|
||||
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration/application-registration-variable.entity';
|
||||
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration/application-registration-variable.service';
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import { ApplicationRegistrationResolver } from 'src/engine/core-modules/application/application-registration/application-registration.resolver';
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
|
||||
import { OAuthDiscoveryController } from 'src/engine/core-modules/application/application-registration/controllers/oauth-discovery.controller';
|
||||
import { OAuthTokenController } from 'src/engine/core-modules/application/application-registration/controllers/oauth-token.controller';
|
||||
import { OAuthService } from 'src/engine/core-modules/application/application-registration/oauth.service';
|
||||
import { AppTarballUploadService } from 'src/engine/core-modules/application/application-registration/services/app-tarball-upload.service';
|
||||
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { ApplicationInstallModule } from 'src/engine/core-modules/application/application-install/application-install.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
|
||||
import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryption/secret-encryption.module';
|
||||
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -28,16 +33,21 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
|
|||
UserWorkspaceEntity,
|
||||
]),
|
||||
SecretEncryptionModule,
|
||||
FeatureFlagModule,
|
||||
PermissionsModule,
|
||||
ThrottlerModule,
|
||||
TokenModule,
|
||||
ApplicationModule,
|
||||
ApplicationInstallModule,
|
||||
FileStorageModule,
|
||||
WorkspaceCacheStorageModule,
|
||||
],
|
||||
controllers: [OAuthTokenController, OAuthDiscoveryController],
|
||||
providers: [
|
||||
ApplicationRegistrationService,
|
||||
ApplicationRegistrationVariableService,
|
||||
ApplicationRegistrationResolver,
|
||||
AppTarballUploadService,
|
||||
OAuthService,
|
||||
],
|
||||
exports: [
|
||||
|
|
@ -1,20 +1,33 @@
|
|||
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
|
||||
import { Args, Mutation, Query } from '@nestjs/graphql';
|
||||
|
||||
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
|
||||
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';
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application-registration/application-registration.service';
|
||||
import { ApplicationRegistrationStatsDTO } from 'src/engine/core-modules/application-registration/dtos/application-registration-stats.dto';
|
||||
import { CreateApplicationRegistrationInput } from 'src/engine/core-modules/application-registration/dtos/create-application-registration.input';
|
||||
import { CreateApplicationRegistrationDTO } from 'src/engine/core-modules/application-registration/dtos/create-application-registration.dto';
|
||||
import { PublicApplicationRegistrationDTO } from 'src/engine/core-modules/application-registration/dtos/public-application-registration.dto';
|
||||
import { CreateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application-registration/dtos/create-application-registration-variable.input';
|
||||
import { RotateClientSecretDTO } from 'src/engine/core-modules/application-registration/dtos/rotate-client-secret.dto';
|
||||
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 type { FileUpload } from 'graphql-upload/processRequest.mjs';
|
||||
|
||||
import { ApplicationRegistrationExceptionFilter } from 'src/engine/core-modules/application/application-registration/application-registration-exception-filter';
|
||||
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration/application-registration-variable.entity';
|
||||
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration/application-registration-variable.service';
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
|
||||
import {
|
||||
AppTarballUploadService,
|
||||
MAX_TARBALL_UPLOAD_SIZE_BYTES,
|
||||
} from 'src/engine/core-modules/application/application-registration/services/app-tarball-upload.service';
|
||||
import { ApplicationRegistrationStatsDTO } from 'src/engine/core-modules/application/application-registration/dtos/application-registration-stats.dto';
|
||||
import { CreateApplicationRegistrationInput } from 'src/engine/core-modules/application/application-registration/dtos/create-application-registration.input';
|
||||
import { CreateApplicationRegistrationDTO } from 'src/engine/core-modules/application/application-registration/dtos/create-application-registration.dto';
|
||||
import { PublicApplicationRegistrationDTO } from 'src/engine/core-modules/application/application-registration/dtos/public-application-registration.dto';
|
||||
import { CreateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application/application-registration/dtos/create-application-registration-variable.input';
|
||||
import { RotateClientSecretDTO } from 'src/engine/core-modules/application/application-registration/dtos/rotate-client-secret.dto';
|
||||
import { UpdateApplicationRegistrationInput } from 'src/engine/core-modules/application/application-registration/dtos/update-application-registration.input';
|
||||
import { UpdateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application/application-registration/dtos/update-application-registration-variable.input';
|
||||
import {
|
||||
ApplicationRegistrationException,
|
||||
ApplicationRegistrationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/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';
|
||||
|
|
@ -23,14 +36,20 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent
|
|||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import {
|
||||
FeatureFlagGuard,
|
||||
RequireFeatureFlag,
|
||||
} from 'src/engine/guards/feature-flag.guard';
|
||||
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
|
||||
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { streamToBuffer } from 'src/utils/stream-to-buffer';
|
||||
|
||||
@UsePipes(ResolverValidationPipe)
|
||||
@MetadataResolver()
|
||||
@UseFilters(
|
||||
ApplicationRegistrationExceptionFilter,
|
||||
AuthGraphqlApiExceptionFilter,
|
||||
PreventNestToAutoLogGraphqlErrorsFilter,
|
||||
)
|
||||
|
|
@ -38,6 +57,7 @@ export class ApplicationRegistrationResolver {
|
|||
constructor(
|
||||
private readonly applicationRegistrationService: ApplicationRegistrationService,
|
||||
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
|
||||
private readonly appTarballUploadService: AppTarballUploadService,
|
||||
) {}
|
||||
|
||||
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
|
||||
|
|
@ -48,7 +68,8 @@ export class ApplicationRegistrationResolver {
|
|||
return this.applicationRegistrationService.findPublicByClientId(clientId);
|
||||
}
|
||||
|
||||
@UseGuards(WorkspaceAuthGuard, NoPermissionGuard)
|
||||
@UseGuards(WorkspaceAuthGuard, FeatureFlagGuard, NoPermissionGuard)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@Query(() => ApplicationRegistrationEntity, { nullable: true })
|
||||
async findApplicationRegistrationByUniversalIdentifier(
|
||||
@Args('universalIdentifier') universalIdentifier: string,
|
||||
|
|
@ -60,8 +81,10 @@ export class ApplicationRegistrationResolver {
|
|||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
|
||||
)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@Query(() => [ApplicationRegistrationEntity])
|
||||
async findManyApplicationRegistrations(
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
|
|
@ -71,8 +94,10 @@ export class ApplicationRegistrationResolver {
|
|||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
|
||||
)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@Query(() => ApplicationRegistrationEntity)
|
||||
async findOneApplicationRegistration(
|
||||
@Args('id') id: string,
|
||||
|
|
@ -83,8 +108,10 @@ export class ApplicationRegistrationResolver {
|
|||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
|
||||
)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@Query(() => ApplicationRegistrationStatsDTO)
|
||||
async findApplicationRegistrationStats(
|
||||
@Args('id') id: string,
|
||||
|
|
@ -95,8 +122,10 @@ export class ApplicationRegistrationResolver {
|
|||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
|
||||
)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@Mutation(() => CreateApplicationRegistrationDTO)
|
||||
async createApplicationRegistration(
|
||||
@Args('input') input: CreateApplicationRegistrationInput,
|
||||
|
|
@ -112,8 +141,10 @@ export class ApplicationRegistrationResolver {
|
|||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
|
||||
)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@Mutation(() => ApplicationRegistrationEntity)
|
||||
async updateApplicationRegistration(
|
||||
@Args('input') input: UpdateApplicationRegistrationInput,
|
||||
|
|
@ -124,8 +155,10 @@ export class ApplicationRegistrationResolver {
|
|||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
|
||||
)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@Mutation(() => Boolean)
|
||||
async deleteApplicationRegistration(
|
||||
@Args('id') id: string,
|
||||
|
|
@ -136,8 +169,10 @@ export class ApplicationRegistrationResolver {
|
|||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
|
||||
)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@Mutation(() => RotateClientSecretDTO)
|
||||
async rotateApplicationRegistrationClientSecret(
|
||||
@Args('id') id: string,
|
||||
|
|
@ -154,8 +189,10 @@ export class ApplicationRegistrationResolver {
|
|||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
|
||||
)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@Query(() => [ApplicationRegistrationVariableEntity])
|
||||
async findApplicationRegistrationVariables(
|
||||
@Args('applicationRegistrationId') applicationRegistrationId: string,
|
||||
|
|
@ -169,8 +206,10 @@ export class ApplicationRegistrationResolver {
|
|||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
|
||||
)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@Mutation(() => ApplicationRegistrationVariableEntity)
|
||||
async createApplicationRegistrationVariable(
|
||||
@Args('input') input: CreateApplicationRegistrationVariableInput,
|
||||
|
|
@ -184,8 +223,10 @@ export class ApplicationRegistrationResolver {
|
|||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
|
||||
)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@Mutation(() => ApplicationRegistrationVariableEntity)
|
||||
async updateApplicationRegistrationVariable(
|
||||
@Args('input') input: UpdateApplicationRegistrationVariableInput,
|
||||
|
|
@ -199,8 +240,10 @@ export class ApplicationRegistrationResolver {
|
|||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
|
||||
)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@Mutation(() => Boolean)
|
||||
async deleteApplicationRegistrationVariable(
|
||||
@Args('id') id: string,
|
||||
|
|
@ -211,4 +254,35 @@ export class ApplicationRegistrationResolver {
|
|||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.MARKETPLACE_APPS),
|
||||
)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@Mutation(() => ApplicationRegistrationEntity)
|
||||
async uploadAppTarball(
|
||||
@Args({ name: 'file', type: () => GraphQLUpload })
|
||||
{ createReadStream }: FileUpload,
|
||||
@Args('universalIdentifier', { type: () => String, nullable: true })
|
||||
universalIdentifier: string | undefined,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
): Promise<ApplicationRegistrationEntity> {
|
||||
const stream = createReadStream();
|
||||
const tarballBuffer = await streamToBuffer(stream);
|
||||
|
||||
if (tarballBuffer.length > MAX_TARBALL_UPLOAD_SIZE_BYTES) {
|
||||
throw new ApplicationRegistrationException(
|
||||
`Tarball exceeds maximum size of ${MAX_TARBALL_UPLOAD_SIZE_BYTES} bytes`,
|
||||
ApplicationRegistrationExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
return this.appTarballUploadService.uploadTarball({
|
||||
tarballBuffer,
|
||||
universalIdentifier,
|
||||
ownerWorkspaceId: workspaceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -8,16 +8,16 @@ import { isDefined } from 'twenty-shared/utils';
|
|||
import { type Repository } from 'typeorm';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application-registration/application-registration.entity';
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import {
|
||||
ApplicationRegistrationException,
|
||||
ApplicationRegistrationExceptionCode,
|
||||
} from 'src/engine/core-modules/application-registration/application-registration.exception';
|
||||
import { ALL_OAUTH_SCOPES } from 'src/engine/core-modules/application-registration/constants/oauth-scopes';
|
||||
import { type ApplicationRegistrationStatsDTO } from 'src/engine/core-modules/application-registration/dtos/application-registration-stats.dto';
|
||||
import { type CreateApplicationRegistrationInput } from 'src/engine/core-modules/application-registration/dtos/create-application-registration.input';
|
||||
import { type PublicApplicationRegistrationDTO } from 'src/engine/core-modules/application-registration/dtos/public-application-registration.dto';
|
||||
import { type UpdateApplicationRegistrationInput } from 'src/engine/core-modules/application-registration/dtos/update-application-registration.input';
|
||||
} from 'src/engine/core-modules/application/application-registration/application-registration.exception';
|
||||
import { ALL_OAUTH_SCOPES } from 'src/engine/core-modules/application/application-registration/constants/oauth-scopes';
|
||||
import { type ApplicationRegistrationStatsDTO } from 'src/engine/core-modules/application/application-registration/dtos/application-registration-stats.dto';
|
||||
import { type CreateApplicationRegistrationInput } from 'src/engine/core-modules/application/application-registration/dtos/create-application-registration.input';
|
||||
import { type PublicApplicationRegistrationDTO } from 'src/engine/core-modules/application/application-registration/dtos/public-application-registration.dto';
|
||||
import { type UpdateApplicationRegistrationInput } from 'src/engine/core-modules/application/application-registration/dtos/update-application-registration.input';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import { validateRedirectUri } from 'src/engine/core-modules/auth/utils/validate-redirect-uri.util';
|
||||
|
||||
|
|
@ -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,6 +1,6 @@
|
|||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
|
||||
import { ALL_OAUTH_SCOPES } from 'src/engine/core-modules/application-registration/constants/oauth-scopes';
|
||||
import { ALL_OAUTH_SCOPES } from 'src/engine/core-modules/application/application-registration/constants/oauth-scopes';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
|
||||
|
|
@ -13,12 +13,12 @@ import {
|
|||
|
||||
import { type Request, type Response } from 'express';
|
||||
|
||||
import { OAuthIntrospectInput } from 'src/engine/core-modules/application-registration/dtos/oauth-introspect.input';
|
||||
import { OAuthRevokeInput } from 'src/engine/core-modules/application-registration/dtos/oauth-revoke.input';
|
||||
import { OAuthTokenInput } from 'src/engine/core-modules/application-registration/dtos/oauth-token.input';
|
||||
import { OAuthService } from 'src/engine/core-modules/application-registration/oauth.service';
|
||||
import { OAuthErrorResponse } from 'src/engine/core-modules/application-registration/types/oauth-error-response.type';
|
||||
import { OAuthTokenResponse } from 'src/engine/core-modules/application-registration/types/oauth-token-response.type';
|
||||
import { OAuthIntrospectInput } from 'src/engine/core-modules/application/application-registration/dtos/oauth-introspect.input';
|
||||
import { OAuthRevokeInput } from 'src/engine/core-modules/application/application-registration/dtos/oauth-revoke.input';
|
||||
import { OAuthTokenInput } from 'src/engine/core-modules/application/application-registration/dtos/oauth-token.input';
|
||||
import { OAuthService } from 'src/engine/core-modules/application/application-registration/oauth.service';
|
||||
import { OAuthErrorResponse } from 'src/engine/core-modules/application/application-registration/types/oauth-error-response.type';
|
||||
import { OAuthTokenResponse } from 'src/engine/core-modules/application/application-registration/types/oauth-token-response.type';
|
||||
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
|
||||
import { ThrottlerException } from 'src/engine/core-modules/throttler/throttler.exception';
|
||||
import { ThrottlerService } from 'src/engine/core-modules/throttler/throttler.service';
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application-registration/application-registration.entity';
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
|
||||
@ObjectType('CreateApplicationRegistration')
|
||||
export class CreateApplicationRegistrationDTO {
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
export enum AppRegistrationSourceType {
|
||||
NPM = 'npm',
|
||||
TARBALL = 'tarball',
|
||||
LOCAL = 'local',
|
||||
}
|
||||
|
||||
registerEnumType(AppRegistrationSourceType, {
|
||||
name: 'AppRegistrationSourceType',
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue