From 445b76fa26d507f2ba4178754b2a1c30ccc4aa9f Mon Sep 17 00:00:00 2001 From: martmull Date: Fri, 21 Nov 2025 15:45:07 +0100 Subject: [PATCH] Add uninstall button to application setting (#15988) As title image --- packages/twenty-cli/package.json | 2 +- ...tions-install-delete-reinstall.e2e-spec.ts | 4 +- ...te.command.ts => app-uninstall.command.ts} | 16 +-- .../twenty-cli/src/commands/app.command.ts | 26 ++++- .../twenty-cli/src/services/api.service.ts | 12 ++- .../src/generated-metadata/graphql.ts | 57 ++++++++-- .../twenty-front/src/generated/graphql.ts | 13 +-- .../graphql/fragments/applicationFragment.ts | 2 + .../graphql/mutations/uninstallApplication.ts | 7 ++ .../SettingsApplicationDetailSettingsTab.tsx | 101 ++++++++++++++++-- ...d-application-not-uninstallable.command.ts | 70 ++++++++++++ .../1-12-upgrade-version-command.module.ts | 19 ++++ .../upgrade-version-command.module.ts | 2 + .../upgrade.command.ts | 12 +++ ...-addCanBeUninstalledColumnToApplication.ts | 19 ++++ .../application-exception-filter.ts | 7 +- .../application/application-sync.service.ts | 9 +- .../application/application.entity.ts | 3 + .../application/application.exception.ts | 1 + .../application/application.resolver.ts | 8 +- .../application/dtos/application.dto.ts | 14 ++- ....input.ts => uninstallApplicationInput.ts} | 2 +- .../constants/twenty-standard-applications.ts | 3 +- ...rkspace-custom-create-application-input.ts | 1 + .../utils/find-many-applications.util.ts | 1 + 25 files changed, 352 insertions(+), 59 deletions(-) rename packages/twenty-cli/src/commands/{app-delete.command.ts => app-uninstall.command.ts} (72%) create mode 100644 packages/twenty-front/src/modules/settings/applications/graphql/mutations/uninstallApplication.ts create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/1-12/1-12-set-standard-application-not-uninstallable.command.ts create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/1-12/1-12-upgrade-version-command.module.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1763731277403-addCanBeUninstalledColumnToApplication.ts rename packages/twenty-server/src/engine/core-modules/application/dtos/{deleteApplication.input.ts => uninstallApplicationInput.ts} (74%) diff --git a/packages/twenty-cli/package.json b/packages/twenty-cli/package.json index 68fcc6ea74..63e457db7f 100644 --- a/packages/twenty-cli/package.json +++ b/packages/twenty-cli/package.json @@ -1,6 +1,6 @@ { "name": "twenty-cli", - "version": "0.2.2", + "version": "0.2.3", "description": "Command-line interface for Twenty application development", "main": "dist/cli.js", "bin": { diff --git a/packages/twenty-cli/src/__tests__/e2e/applications-install-delete-reinstall.e2e-spec.ts b/packages/twenty-cli/src/__tests__/e2e/applications-install-delete-reinstall.e2e-spec.ts index 5718818fbf..e7f8ce4b40 100644 --- a/packages/twenty-cli/src/__tests__/e2e/applications-install-delete-reinstall.e2e-spec.ts +++ b/packages/twenty-cli/src/__tests__/e2e/applications-install-delete-reinstall.e2e-spec.ts @@ -1,5 +1,5 @@ import { existsSync } from 'fs'; -import { AppDeleteCommand } from '../../commands/app-delete.command'; +import { AppUninstallCommand } from 'src/commands/app-uninstall.command'; import { AppSyncCommand } from '../../commands/app-sync.command'; import { COVERED_APPLICATION_FOLDERS } from './constants/covered-applications-folder.constant'; import { getTestedApplicationPath } from './utils/get-tested-application-path.util'; @@ -8,7 +8,7 @@ describe.each(COVERED_APPLICATION_FOLDERS)( 'Application: "%s" install delete and reinstall test suite', (applicationName) => { const syncCommand = new AppSyncCommand(); - const deleteCommand = new AppDeleteCommand(); + const deleteCommand = new AppUninstallCommand(); const appPath = getTestedApplicationPath(applicationName); beforeAll(async () => { diff --git a/packages/twenty-cli/src/commands/app-delete.command.ts b/packages/twenty-cli/src/commands/app-uninstall.command.ts similarity index 72% rename from packages/twenty-cli/src/commands/app-delete.command.ts rename to packages/twenty-cli/src/commands/app-uninstall.command.ts index 8baaa73a07..9cd95411d6 100644 --- a/packages/twenty-cli/src/commands/app-delete.command.ts +++ b/packages/twenty-cli/src/commands/app-uninstall.command.ts @@ -5,7 +5,7 @@ import { ApiService } from '../services/api.service'; import { ApiResponse } from '../types/config.types'; import { loadManifest } from '../utils/load-manifest'; -export class AppDeleteCommand { +export class AppUninstallCommand { private apiService = new ApiService(); async execute({ @@ -16,31 +16,31 @@ export class AppDeleteCommand { askForConfirmation: boolean; }): Promise> { try { - console.log(chalk.blue('🚀 Deleting Twenty Application')); + console.log(chalk.blue('🚀 Uninstall Twenty Application')); console.log(chalk.gray(`📁 App Path: ${appPath}`)); console.log(''); if (askForConfirmation && !(await this.confirmationPrompt())) { - console.error(chalk.red('⛔️ Aborting deletion')); + console.error(chalk.red('⛔️ Aborting uninstall')); process.exit(1); } const { manifest } = await loadManifest(appPath); - const result = await this.apiService.deleteApplication( + const result = await this.apiService.uninstallApplication( manifest.application.universalIdentifier, ); if (!result.success) { - console.error(chalk.red('❌ Deletion failed:'), result.error); + console.error(chalk.red('❌ Uninstall failed:'), result.error); } else { - console.log(chalk.green('✅ Application deleted successfully')); + console.log(chalk.green('✅ Application uninstalled successfully')); } return result; } catch (error) { console.error( - chalk.red('Deletion failed:'), + chalk.red('Uninstall failed:'), error instanceof Error ? error.message : error, ); throw error; @@ -52,7 +52,7 @@ export class AppDeleteCommand { { type: 'confirm', name: 'confirmation', - message: 'Are you sure you want to delete this application?', + message: 'Are you sure you want to uninstall this application?', default: false, }, ]); diff --git a/packages/twenty-cli/src/commands/app.command.ts b/packages/twenty-cli/src/commands/app.command.ts index b09cdea176..3c1030fb87 100644 --- a/packages/twenty-cli/src/commands/app.command.ts +++ b/packages/twenty-cli/src/commands/app.command.ts @@ -5,7 +5,7 @@ import { isSyncableEntity, SyncableEntity, } from './app-add.command'; -import { AppDeleteCommand } from './app-delete.command'; +import { AppUninstallCommand } from './app-uninstall.command'; import { AppDevCommand } from './app-dev.command'; import { AppInitCommand } from './app-init.command'; import { AppSyncCommand } from './app-sync.command'; @@ -15,7 +15,7 @@ import { AppGenerateCommand } from './app-generate.command'; export class AppCommand { private devCommand = new AppDevCommand(); private syncCommand = new AppSyncCommand(); - private deleteCommand = new AppDeleteCommand(); + private uninstallCommand = new AppUninstallCommand(); private initCommand = new AppInitCommand(); private addCommand = new AppAddCommand(); private generateCommand = new AppGenerateCommand(); @@ -50,11 +50,29 @@ export class AppCommand { }); appCommand - .command('delete [appPath]') + .command('uninstall [appPath]') + .description('Uninstall application from Twenty') + .action(async (appPath?: string) => { + try { + const result = await this.uninstallCommand.execute({ + appPath: formatPath(appPath), + askForConfirmation: true, + }); + if (!result.success) { + process.exit(1); + } + } catch { + process.exit(1); + } + }); + + // Keeping to avoid breaking changes + appCommand + .command('delete [appPath]', { hidden: true }) .description('Delete application from Twenty') .action(async (appPath?: string) => { try { - const result = await this.deleteCommand.execute({ + const result = await this.uninstallCommand.execute({ appPath: formatPath(appPath), askForConfirmation: true, }); diff --git a/packages/twenty-cli/src/services/api.service.ts b/packages/twenty-cli/src/services/api.service.ts index d26e389841..f2b53aa845 100644 --- a/packages/twenty-cli/src/services/api.service.ts +++ b/packages/twenty-cli/src/services/api.service.ts @@ -146,11 +146,13 @@ export class ApiService { } } - async deleteApplication(universalIdentifier: string): Promise { + async uninstallApplication( + universalIdentifier: string, + ): Promise { try { const mutation = ` - mutation DeleteApplication($universalIdentifier: String!) { - deleteApplication(universalIdentifier: $universalIdentifier) + mutation UninstallApplication($universalIdentifier: String!) { + uninstallApplication(universalIdentifier: $universalIdentifier) } `; @@ -180,8 +182,8 @@ export class ApiService { return { success: true, - data: response.data.data.deleteApplication, - message: 'Successfully deleted application', + data: response.data.data.uninstallApplication, + message: 'Successfully uninstalled application', }; } catch (error) { if (axios.isAxiosError(error) && error.response) { diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index b18ec53912..1d171ff460 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -217,6 +217,7 @@ export type Application = { __typename?: 'Application'; agents: Array; applicationVariables: Array; + canBeUninstalled: Scalars['Boolean']; description: Scalars['String']; id: Scalars['UUID']; name: Scalars['String']; @@ -1790,7 +1791,6 @@ export type Mutation = { createWorkflowVersionEdge: WorkflowVersionStepChanges; createWorkflowVersionStep: WorkflowVersionStepChanges; deactivateWorkflowVersion: Scalars['Boolean']; - deleteApplication: Scalars['Boolean']; deleteApprovedAccessDomain: Scalars['Boolean']; deleteCoreView: Scalars['Boolean']; deleteCoreViewField: CoreViewField; @@ -1881,6 +1881,7 @@ export type Mutation = { syncRemoteTableSchemaChanges: RemoteTable; testHttpRequest: TestHttpRequestOutput; trackAnalytics: Analytics; + uninstallApplication: Scalars['Boolean']; unsyncRemoteTable: RemoteTable; updateApiKey?: Maybe; updateCoreView: CoreView; @@ -2168,11 +2169,6 @@ export type MutationDeactivateWorkflowVersionArgs = { }; -export type MutationDeleteApplicationArgs = { - universalIdentifier: Scalars['String']; -}; - - export type MutationDeleteApprovedAccessDomainArgs = { input: DeleteApprovedAccessDomainInput; }; @@ -2602,6 +2598,11 @@ export type MutationTrackAnalyticsArgs = { }; +export type MutationUninstallApplicationArgs = { + universalIdentifier: Scalars['String']; +}; + + export type MutationUnsyncRemoteTableArgs = { input: RemoteTableInput; }; @@ -5045,7 +5046,7 @@ export type UpdateOneApplicationVariableMutationVariables = Exact<{ export type UpdateOneApplicationVariableMutation = { __typename?: 'Mutation', updateOneApplicationVariable: boolean }; -export type ApplicationFieldsFragment = { __typename?: 'Application', id: string, name: string, description: string, version: string, 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, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: 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 | null, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, 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, 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 }> }>, serverlessFunctions: Array<{ __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array, handlerPath: string, handlerName: string, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null }> }; +export type ApplicationFieldsFragment = { __typename?: 'Application', id: string, name: string, description: string, version: string, universalIdentifier: string, canBeUninstalled: boolean, 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, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: 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 | null, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, 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, 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 }> }>, serverlessFunctions: Array<{ __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array, handlerPath: string, handlerName: string, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null }> }; export type FindManyApplicationsQueryVariables = Exact<{ [key: string]: never; }>; @@ -5057,7 +5058,7 @@ export type FindOneApplicationQueryVariables = Exact<{ }>; -export type FindOneApplicationQuery = { __typename?: 'Query', findOneApplication: { __typename?: 'Application', id: string, name: string, description: string, version: string, 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, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: 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 | null, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, 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, 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 }> }>, serverlessFunctions: Array<{ __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array, handlerPath: string, handlerName: string, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null }> } }; +export type FindOneApplicationQuery = { __typename?: 'Query', findOneApplication: { __typename?: 'Application', id: string, name: string, description: string, version: string, universalIdentifier: string, canBeUninstalled: boolean, 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, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: 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 | null, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, 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, 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 }> }>, serverlessFunctions: Array<{ __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array, handlerPath: string, handlerName: string, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null }> } }; export type UploadFileMutationVariables = Exact<{ file: Scalars['Upload']; @@ -5621,6 +5622,13 @@ 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 UninstallApplicationMutationVariables = Exact<{ + universalIdentifier: Scalars['String']; +}>; + + +export type UninstallApplicationMutation = { __typename?: 'Mutation', uninstallApplication: boolean }; + export type ApiKeyFragmentFragment = { __typename?: 'ApiKey', id: string, name: string, expiresAt: string, revokedAt?: string | null, role: { __typename?: 'Role', id: string, label: string, icon?: string | null } }; export type WebhookFragmentFragment = { __typename?: 'Webhook', id: string, targetUrl: string, operations: Array, description?: string | null, secret: string }; @@ -6633,6 +6641,8 @@ export const ApplicationFieldsFragmentDoc = gql` name description version + universalIdentifier + canBeUninstalled applicationVariables { id key @@ -10766,6 +10776,37 @@ export function useGetSystemHealthStatusLazyQuery(baseOptions?: Apollo.LazyQuery export type GetSystemHealthStatusQueryHookResult = ReturnType; export type GetSystemHealthStatusLazyQueryHookResult = ReturnType; export type GetSystemHealthStatusQueryResult = Apollo.QueryResult; +export const UninstallApplicationDocument = gql` + mutation UninstallApplication($universalIdentifier: String!) { + uninstallApplication(universalIdentifier: $universalIdentifier) +} + `; +export type UninstallApplicationMutationFn = Apollo.MutationFunction; + +/** + * __useUninstallApplicationMutation__ + * + * To run a mutation, you first call `useUninstallApplicationMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useUninstallApplicationMutation` 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 [uninstallApplicationMutation, { data, loading, error }] = useUninstallApplicationMutation({ + * variables: { + * universalIdentifier: // value for 'universalIdentifier' + * }, + * }); + */ +export function useUninstallApplicationMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(UninstallApplicationDocument, options); + } +export type UninstallApplicationMutationHookResult = ReturnType; +export type UninstallApplicationMutationResult = Apollo.MutationResult; +export type UninstallApplicationMutationOptions = Apollo.BaseMutationOptions; export const AssignRoleToApiKeyDocument = gql` mutation AssignRoleToApiKey($apiKeyId: UUID!, $roleId: UUID!) { assignRoleToApiKey(apiKeyId: $apiKeyId, roleId: $roleId) diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 15f5ac894c..8be536d39b 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -217,6 +217,7 @@ export type Application = { __typename?: 'Application'; agents: Array; applicationVariables: Array; + canBeUninstalled: Scalars['Boolean']; description: Scalars['String']; id: Scalars['UUID']; name: Scalars['String']; @@ -1765,7 +1766,6 @@ export type Mutation = { createWorkflowVersionEdge: WorkflowVersionStepChanges; createWorkflowVersionStep: WorkflowVersionStepChanges; deactivateWorkflowVersion: Scalars['Boolean']; - deleteApplication: Scalars['Boolean']; deleteApprovedAccessDomain: Scalars['Boolean']; deleteCoreView: Scalars['Boolean']; deleteCoreViewField: CoreViewField; @@ -1853,6 +1853,7 @@ export type Mutation = { syncApplication: Scalars['Boolean']; testHttpRequest: TestHttpRequestOutput; trackAnalytics: Analytics; + uninstallApplication: Scalars['Boolean']; updateApiKey?: Maybe; updateCoreView: CoreView; updateCoreViewField: CoreViewField; @@ -2128,11 +2129,6 @@ export type MutationDeactivateWorkflowVersionArgs = { }; -export type MutationDeleteApplicationArgs = { - universalIdentifier: Scalars['String']; -}; - - export type MutationDeleteApprovedAccessDomainArgs = { input: DeleteApprovedAccessDomainInput; }; @@ -2547,6 +2543,11 @@ export type MutationTrackAnalyticsArgs = { }; +export type MutationUninstallApplicationArgs = { + universalIdentifier: Scalars['String']; +}; + + export type MutationUpdateApiKeyArgs = { input: UpdateApiKeyInput; }; diff --git a/packages/twenty-front/src/modules/applications/graphql/fragments/applicationFragment.ts b/packages/twenty-front/src/modules/applications/graphql/fragments/applicationFragment.ts index 4fb0317dbf..df316bfce2 100644 --- a/packages/twenty-front/src/modules/applications/graphql/fragments/applicationFragment.ts +++ b/packages/twenty-front/src/modules/applications/graphql/fragments/applicationFragment.ts @@ -12,6 +12,8 @@ export const APPLICATION_FRAGMENT = gql` name description version + universalIdentifier + canBeUninstalled applicationVariables { id key diff --git a/packages/twenty-front/src/modules/settings/applications/graphql/mutations/uninstallApplication.ts b/packages/twenty-front/src/modules/settings/applications/graphql/mutations/uninstallApplication.ts new file mode 100644 index 0000000000..ee9f043496 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/applications/graphql/mutations/uninstallApplication.ts @@ -0,0 +1,7 @@ +import gql from 'graphql-tag'; + +export const UNINSTALL_APPLICATION = gql` + mutation UninstallApplication($universalIdentifier: String!) { + uninstallApplication(universalIdentifier: $universalIdentifier) + } +`; diff --git a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailSettingsTab.tsx b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailSettingsTab.tsx index f775323831..1f2a91daa0 100644 --- a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailSettingsTab.tsx +++ b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailSettingsTab.tsx @@ -2,16 +2,41 @@ import { isDefined } from 'twenty-shared/utils'; import type { Application } from '~/generated/graphql'; import { useUpdateOneApplicationVariable } from '~/pages/settings/applications/hooks/useUpdateOneApplicationVariable'; import { SettingsApplicationDetailEnvironmentVariablesTable } from '~/pages/settings/applications/tabs/SettingsApplicationDetailEnvironmentVariablesTable'; +import { H2Title, IconTrash } from 'twenty-ui/display'; +import { Button } from 'twenty-ui/input'; +import { Section } from 'twenty-ui/layout'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { useModal } from '@/ui/layout/modal/hooks/useModal'; +import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal'; +import { useState } from 'react'; +import { SettingsPath } from 'twenty-shared/types'; +import { useNavigateSettings } from '~/hooks/useNavigateSettings'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { useUninstallApplicationMutation } from '~/generated-metadata/graphql'; + +const UNINSTALL_APPLICATION_MODAL_ID = 'uninstall-application-modal'; export const SettingsApplicationDetailSettingsTab = ({ application, }: { - application?: Omit & { + application?: Omit & { objects: { id: string }[]; }; }) => { + const { t } = useLingui(); + + const { openModal } = useModal(); + + const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar(); + + const navigate = useNavigateSettings(); + + const [isLoading, setIsLoading] = useState(false); + const { updateOneApplicationVariable } = useUpdateOneApplicationVariable(); + const [uninstallApplication] = useUninstallApplicationMutation(); + if (!isDefined(application)) { return null; } @@ -20,16 +45,70 @@ export const SettingsApplicationDetailSettingsTab = ({ (a, b) => a.key.localeCompare(b.key), ); + const handleUninstallApplication = async () => { + setIsLoading(true); + try { + await uninstallApplication({ + variables: { universalIdentifier: application.universalIdentifier }, + }); + + enqueueSuccessSnackBar({ + message: t`Application successfully uninstalled.`, + }); + navigate(SettingsPath.Applications); + } catch { + enqueueErrorSnackBar({ message: t`Error uninstalling application.` }); + } finally { + setIsLoading(false); + } + }; + + const confirmationValue = t`yes`; + return ( - - updateOneApplicationVariable({ - key, - value, - applicationId: application.id, - }) - } - /> + <> + + updateOneApplicationVariable({ + key, + value, + applicationId: application.id, + }) + } + /> + {application.canBeUninstalled && ( + <> +
+ +
+ + Please type {`"${confirmationValue}"`} to confirm you want to + uninstall this application. + + } + onConfirmClick={handleUninstallApplication} + confirmButtonText={t`Uninstall`} + loading={isLoading} + /> + + )} + ); }; diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-12/1-12-set-standard-application-not-uninstallable.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-12/1-12-set-standard-application-not-uninstallable.command.ts new file mode 100644 index 0000000000..993e3614cb --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-12/1-12-set-standard-application-not-uninstallable.command.ts @@ -0,0 +1,70 @@ +import { InjectRepository } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; +import { Command } from 'nest-commander'; + +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; +import { ApplicationService } from 'src/engine/core-modules/application/application.service'; +import { + ActiveOrSuspendedWorkspacesMigrationCommandRunner, + RunOnWorkspaceArgs, +} from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner'; +import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/twenty-standard-applications'; + +@Command({ + name: 'upgrade:1-12:set-standard-application-not-uninstallable', + description: 'Set canBeUninstalled flag to false for standard applications', +}) +export class SetStandardApplicationNotUninstallableCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner { + constructor( + @InjectRepository(WorkspaceEntity) + protected readonly workspaceRepository: Repository, + protected readonly twentyORMGlobalManager: TwentyORMGlobalManager, + @InjectRepository(ApplicationEntity) + private readonly applicationRepository: Repository, + private readonly applicationService: ApplicationService, + ) { + super(workspaceRepository, twentyORMGlobalManager); + } + + override async runOnWorkspace({ + workspaceId, + options, + }: RunOnWorkspaceArgs): Promise { + try { + this.logger.log( + `Checking standard applications for workspace ${workspaceId}`, + ); + + const existingApplications = await this.applicationRepository.find({ + where: [ + { + workspaceId, + universalIdentifier: + TWENTY_STANDARD_APPLICATION.universalIdentifier, + }, + { + workspaceId, + description: 'Workspace custom application', + sourcePath: 'workspace-custom', + }, + ], + }); + + if (options.dryRun) { + return; + } + + for (const existingApplication of existingApplications) { + await this.applicationService.update(existingApplication.id, { + canBeUninstalled: false, + }); + } + this.logger.log(`Successfully updated standard applications`); + } catch (e) { + this.logger.error(`Failed to update standard applications`, e); + } + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-12/1-12-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-12/1-12-upgrade-version-command.module.ts new file mode 100644 index 0000000000..c10e9614c0 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-12/1-12-upgrade-version-command.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; +import { WorkspaceSchemaManagerModule } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.module'; +import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; +import { SetStandardApplicationNotUninstallableCommand } from 'src/database/commands/upgrade-version-command/1-12/1-12-set-standard-application-not-uninstallable.command'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([WorkspaceEntity, ApplicationEntity]), + WorkspaceSchemaManagerModule, + ApplicationModule, + ], + providers: [SetStandardApplicationNotUninstallableCommand], + exports: [SetStandardApplicationNotUninstallableCommand], +}) +export class V1_12_UpgradeVersionCommandModule {} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade-version-command.module.ts index 22ec8dedf3..25eb635cf9 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade-version-command.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade-version-command.module.ts @@ -6,6 +6,7 @@ import { V1_11_UpgradeVersionCommandModule } from 'src/database/commands/upgrade import { V1_6_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-6/1-6-upgrade-version-command.module'; import { V1_7_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-7/1-7-upgrade-version-command.module'; import { V1_8_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-8/1-8-upgrade-version-command.module'; +import { V1_12_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-12/1-12-upgrade-version-command.module'; import { UpgradeCommand } from 'src/database/commands/upgrade-version-command/upgrade.command'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { WorkspaceSyncMetadataModule } from 'src/engine/workspace-manager/workspace-sync-metadata/workspace-sync-metadata.module'; @@ -18,6 +19,7 @@ import { WorkspaceSyncMetadataModule } from 'src/engine/workspace-manager/worksp V1_8_UpgradeVersionCommandModule, V1_10_UpgradeVersionCommandModule, V1_11_UpgradeVersionCommandModule, + V1_12_UpgradeVersionCommandModule, WorkspaceSyncMetadataModule, ], providers: [UpgradeCommand], diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts index 562ac94735..50985f8493 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts @@ -33,6 +33,7 @@ import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twent import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { SyncWorkspaceMetadataCommand } from 'src/engine/workspace-manager/workspace-sync-metadata/commands/sync-workspace-metadata.command'; +import { SetStandardApplicationNotUninstallableCommand } from 'src/database/commands/upgrade-version-command/1-12/1-12-set-standard-application-not-uninstallable.command'; @Command({ name: 'upgrade', @@ -77,6 +78,9 @@ export class UpgradeCommand extends UpgradeCommandRunner { protected readonly cleanOrphanedUserWorkspacesCommand: CleanOrphanedUserWorkspacesCommand, protected readonly cleanOrphanedRoleTargetsCommand: CleanOrphanedRoleTargetsCommand, protected readonly seedStandardApplicationsCommand: CreateTwentyStandardApplicationCommand, + + // 1.12 Commands + protected readonlysetStandardApplicationNotUninstallableCommand: SetStandardApplicationNotUninstallableCommand, ) { super( workspaceRepository, @@ -133,12 +137,20 @@ export class UpgradeCommand extends UpgradeCommandRunner { ], }; + const commands_1120: VersionCommands = { + beforeSyncMetadata: [], + afterSyncMetadata: [ + this.readonlysetStandardApplicationNotUninstallableCommand, + ], + }; + this.allCommands = { '1.6.0': commands_160, '1.7.0': commands_170, '1.8.0': commands_180, '1.10.0': commands_1100, '1.11.0': commands_1110, + '1.12.0': commands_1120, }; } diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1763731277403-addCanBeUninstalledColumnToApplication.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1763731277403-addCanBeUninstalledColumnToApplication.ts new file mode 100644 index 0000000000..eb648eda5f --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1763731277403-addCanBeUninstalledColumnToApplication.ts @@ -0,0 +1,19 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +export class AddCanBeUninstalledColumnToApplication1763731277403 + implements MigrationInterface +{ + name = 'AddCanBeUninstalledColumnToApplication1763731277403'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."application" ADD "canBeUninstalled" boolean NOT NULL DEFAULT true`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."application" DROP COLUMN "canBeUninstalled"`, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/application/application-exception-filter.ts b/packages/twenty-server/src/engine/core-modules/application/application-exception-filter.ts index a6e95b68b3..9c5c9fa8b7 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-exception-filter.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-exception-filter.ts @@ -6,7 +6,10 @@ import { ApplicationException, ApplicationExceptionCode, } from 'src/engine/core-modules/application/application.exception'; -import { NotFoundError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { + NotFoundError, + UserInputError, +} from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; @Catch(ApplicationException) export class ApplicationExceptionFilter implements ExceptionFilter { @@ -18,6 +21,8 @@ export class ApplicationExceptionFilter implements ExceptionFilter { case ApplicationExceptionCode.APPLICATION_NOT_FOUND: case ApplicationExceptionCode.SERVERLESS_FUNCTION_NOT_FOUND: throw new NotFoundError(exception); + case ApplicationExceptionCode.FORBIDDEN: + throw new UserInputError(exception); default: { assertUnreachable(exception.code); } diff --git a/packages/twenty-server/src/engine/core-modules/application/application-sync.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-sync.service.ts index 394ffa469a..901108131a 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-sync.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-sync.service.ts @@ -926,7 +926,7 @@ export class ApplicationSyncService { } } - public async deleteApplication({ + public async uninstallApplication({ workspaceId, applicationUniversalIdentifier, }: { @@ -959,6 +959,13 @@ export class ApplicationSyncService { ); } + if (!application.canBeUninstalled) { + throw new ApplicationException( + 'This application cannot be uninstalled.', + ApplicationExceptionCode.FORBIDDEN, + ); + } + const flatObjectMetadataMapsByApplicationId = getFlatEntitiesByApplicationId( existingFlatObjectMetadataMaps, diff --git a/packages/twenty-server/src/engine/core-modules/application/application.entity.ts b/packages/twenty-server/src/engine/core-modules/application/application.entity.ts index 21de01cad3..a6f88f162e 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application.entity.ts @@ -59,6 +59,9 @@ export class ApplicationEntity { @Column({ nullable: true, type: 'uuid' }) serverlessFunctionLayerId: string | null; + @Column({ nullable: false, type: 'boolean', default: true }) + canBeUninstalled: boolean; + @ManyToOne(() => WorkspaceEntity, { onDelete: 'CASCADE', }) diff --git a/packages/twenty-server/src/engine/core-modules/application/application.exception.ts b/packages/twenty-server/src/engine/core-modules/application/application.exception.ts index e65020760e..63e35df844 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application.exception.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application.exception.ts @@ -8,4 +8,5 @@ export enum ApplicationExceptionCode { SERVERLESS_FUNCTION_NOT_FOUND = 'SERVERLESS_FUNCTION_NOT_FOUND', ENTITY_NOT_FOUND = 'ENTITY_NOT_FOUND', APPLICATION_NOT_FOUND = 'APPLICATION_NOT_FOUND', + FORBIDDEN = 'FORBIDDEN', } diff --git a/packages/twenty-server/src/engine/core-modules/application/application.resolver.ts b/packages/twenty-server/src/engine/core-modules/application/application.resolver.ts index 534c513dc0..d03964d366 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application.resolver.ts @@ -7,7 +7,7 @@ import { ApplicationSyncService } from 'src/engine/core-modules/application/appl import { ApplicationService } from 'src/engine/core-modules/application/application.service'; import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/application.dto'; import { ApplicationInput } from 'src/engine/core-modules/application/dtos/application.input'; -import { DeleteApplicationInput } from 'src/engine/core-modules/application/dtos/deleteApplication.input'; +import { UninstallApplicationInput } from 'src/engine/core-modules/application/dtos/uninstallApplicationInput'; import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; @@ -61,11 +61,11 @@ export class ApplicationResolver { } @Mutation(() => Boolean) - async deleteApplication( - @Args() { universalIdentifier }: DeleteApplicationInput, + async uninstallApplication( + @Args() { universalIdentifier }: UninstallApplicationInput, @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, ) { - await this.applicationSyncService.deleteApplication({ + await this.applicationSyncService.uninstallApplication({ applicationUniversalIdentifier: universalIdentifier, workspaceId, }); diff --git a/packages/twenty-server/src/engine/core-modules/application/dtos/application.dto.ts b/packages/twenty-server/src/engine/core-modules/application/dtos/application.dto.ts index dec35dd123..d2ebb15276 100644 --- a/packages/twenty-server/src/engine/core-modules/application/dtos/application.dto.ts +++ b/packages/twenty-server/src/engine/core-modules/application/dtos/application.dto.ts @@ -1,6 +1,6 @@ import { Field, ObjectType } from '@nestjs/graphql'; -import { IsNotEmpty, IsString, IsUUID } from 'class-validator'; +import { IsBoolean, IsNotEmpty, IsString, IsUUID } from 'class-validator'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; import { ApplicationVariableEntityDTO } from 'src/engine/core-modules/applicationVariable/dtos/application-variable.dto'; @@ -27,6 +27,14 @@ export class ApplicationDTO { @Field() version: string; + @IsString() + @Field() + universalIdentifier: string; + + @Field(() => Boolean) + @IsBoolean() + canBeUninstalled: boolean; + @Field(() => [AgentDTO]) agents: AgentDTO[]; @@ -38,8 +46,4 @@ export class ApplicationDTO { @Field(() => [ApplicationVariableEntityDTO]) applicationVariables: ApplicationVariableEntityDTO[]; - - @IsString() - @Field() - universalIdentifier: string; } diff --git a/packages/twenty-server/src/engine/core-modules/application/dtos/deleteApplication.input.ts b/packages/twenty-server/src/engine/core-modules/application/dtos/uninstallApplicationInput.ts similarity index 74% rename from packages/twenty-server/src/engine/core-modules/application/dtos/deleteApplication.input.ts rename to packages/twenty-server/src/engine/core-modules/application/dtos/uninstallApplicationInput.ts index 75a274fc4a..f6108ed111 100644 --- a/packages/twenty-server/src/engine/core-modules/application/dtos/deleteApplication.input.ts +++ b/packages/twenty-server/src/engine/core-modules/application/dtos/uninstallApplicationInput.ts @@ -1,7 +1,7 @@ import { ArgsType, Field } from '@nestjs/graphql'; @ArgsType() -export class DeleteApplicationInput { +export class UninstallApplicationInput { @Field(() => String) universalIdentifier: string; } diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/twenty-standard-applications.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/twenty-standard-applications.ts index d88237cb0a..e5a3829023 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/twenty-standard-applications.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/twenty-standard-applications.ts @@ -8,6 +8,7 @@ export const TWENTY_STANDARD_APPLICATION = { version: '1.0.0', sourcePath: 'cli-sync', sourceType: 'local', + canBeUninstalled: false, } as const satisfies CreateApplicationInput; export type CreateApplicationInput = Omit< @@ -24,5 +25,3 @@ export type CreateApplicationInput = Omit< | 'objects' | 'serverlessFunctions' >; -export type TwentyStandardApplicationUniversalIdentifiers = - (typeof TWENTY_STANDARD_APPLICATION)['universalIdentifier']; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/utils/compute-workspace-custom-create-application-input.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/utils/compute-workspace-custom-create-application-input.ts index 8bb10dc30e..f56ad58995 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/utils/compute-workspace-custom-create-application-input.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/utils/compute-workspace-custom-create-application-input.ts @@ -20,6 +20,7 @@ export const computeWorkspaceCustomCreateApplicationInput = ({ universalIdentifier: applicationId, workspaceId: workspace.id, id: applicationId, + canBeUninstalled: false, }) as const satisfies CreateApplicationInput & { workspaceId: string; id: string; diff --git a/packages/twenty-server/test/integration/graphql/utils/find-many-applications.util.ts b/packages/twenty-server/test/integration/graphql/utils/find-many-applications.util.ts index 7d5097af75..eddb2c55c4 100644 --- a/packages/twenty-server/test/integration/graphql/utils/find-many-applications.util.ts +++ b/packages/twenty-server/test/integration/graphql/utils/find-many-applications.util.ts @@ -12,6 +12,7 @@ export const APPLICATION_GQL_FIELDS = ` description version universalIdentifier + canBeUninstalled `; export const findManyApplications = async ({