Refactor application module architecture for clarity and explicitness (#18432)
## Summary - **Module reorganization**: Moved `ApplicationUpgradeService` and cron jobs to `application-upgrade/`, `ApplicationSyncService` to `application-manifest/`, and `runWorkspaceMigration`/`uninstallApplication` mutations to the manifest resolver — each module now has a single clear responsibility. - **Explicit install flow**: Removed implicit `ApplicationEntity` creation from `ApplicationSyncService`. The install service and dev resolver now explicitly create the `ApplicationEntity` before syncing. npm packages are resolved at registration time to extract manifest metadata (universalIdentifier, name, description, etc.), eliminating the `reconcileUniversalIdentifier` hack. - **Better error handling**: Frontend hooks now surface actual server error messages in snackbars instead of swallowing them. Replaced the ugly `ConfirmationModal` for transfer ownership with a proper form modal. Fixed `SettingsAdminTableCard` row height overflow and corrected the `yarn-engine` asset path. ## Test plan - [ ] Register an npm package — verify manifest metadata (name, description, universalIdentifier) is extracted correctly - [ ] Install a registered npm app on a workspace — verify ApplicationEntity is created and sync succeeds - [ ] Test `app:dev` CLI flow — verify local app registration and sync work - [ ] Upload a tarball — verify registration and install flow - [ ] Transfer ownership — verify the new modal UX works - [ ] Verify error messages appear correctly in snackbars when operations fail Made with [Cursor](https://cursor.com)
This commit is contained in:
@@ -6,6 +6,7 @@ import { AdminPanelHealthService } from 'src/engine/core-modules/admin-panel/adm
|
||||
import { AdminPanelQueueService } from 'src/engine/core-modules/admin-panel/admin-panel-queue.service';
|
||||
import { AdminPanelResolver } from 'src/engine/core-modules/admin-panel/admin-panel.resolver';
|
||||
import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-panel.service';
|
||||
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module';
|
||||
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
|
||||
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
|
||||
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
|
||||
@@ -40,6 +41,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
|
||||
ImpersonationModule,
|
||||
PermissionsModule,
|
||||
SecureHttpClientModule,
|
||||
ApplicationRegistrationModule,
|
||||
],
|
||||
providers: [
|
||||
AdminPanelResolver,
|
||||
|
||||
@@ -7,6 +7,8 @@ import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
import { AdminPanelHealthService } from 'src/engine/core-modules/admin-panel/admin-panel-health.service';
|
||||
import { AdminPanelQueueService } from 'src/engine/core-modules/admin-panel/admin-panel-queue.service';
|
||||
import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-panel.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 { AdminAIModelsDTO } from 'src/engine/core-modules/client-config/client-config.entity';
|
||||
import { ConfigVariableDTO } from 'src/engine/core-modules/admin-panel/dtos/config-variable.dto';
|
||||
import { ConfigVariablesDTO } from 'src/engine/core-modules/admin-panel/dtos/config-variables.dto';
|
||||
@@ -56,8 +58,9 @@ import { QueueMetricsDataDTO } from './dtos/queue-metrics-data.dto';
|
||||
)
|
||||
export class AdminPanelResolver {
|
||||
constructor(
|
||||
private adminService: AdminPanelService,
|
||||
private adminPanelHealthService: AdminPanelHealthService,
|
||||
private readonly adminService: AdminPanelService,
|
||||
private readonly adminPanelHealthService: AdminPanelHealthService,
|
||||
private readonly applicationRegistrationService: ApplicationRegistrationService,
|
||||
private adminPanelQueueService: AdminPanelQueueService,
|
||||
private featureFlagService: FeatureFlagService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
@@ -267,4 +270,12 @@ export class AdminPanelResolver {
|
||||
jobIds,
|
||||
);
|
||||
}
|
||||
|
||||
@UseGuards(AdminPanelGuard)
|
||||
@Query(() => [ApplicationRegistrationEntity])
|
||||
async findAllApplicationRegistrations(): Promise<
|
||||
ApplicationRegistrationEntity[]
|
||||
> {
|
||||
return this.applicationRegistrationService.findAll();
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
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 { ApplicationManifestModule } from 'src/engine/core-modules/application/application-manifest/application-manifest.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';
|
||||
@@ -12,9 +12,9 @@ import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/wor
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ApplicationInstallModule,
|
||||
ApplicationRegistrationModule,
|
||||
ApplicationModule,
|
||||
ApplicationManifestModule,
|
||||
ApplicationRegistrationModule,
|
||||
FeatureFlagModule,
|
||||
TokenModule,
|
||||
FileStorageModule,
|
||||
|
||||
+61
-72
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
Logger,
|
||||
UseFilters,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
@@ -13,24 +12,22 @@ 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 { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/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 {
|
||||
ApplicationException,
|
||||
ApplicationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application.exception';
|
||||
import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/application.dto';
|
||||
import { ApplicationInput } from 'src/engine/core-modules/application/dtos/application.input';
|
||||
import { CreateApplicationInput } from 'src/engine/core-modules/application/dtos/create-application.input';
|
||||
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/application-install/application-sync.service';
|
||||
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
|
||||
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/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 { ApplicationInput } from 'src/engine/core-modules/application/application-development/dtos/application.input';
|
||||
import { GenerateApplicationTokenInput } from 'src/engine/core-modules/application/application-development/dtos/generate-application-token.input';
|
||||
import { UploadApplicationFileInput } from 'src/engine/core-modules/application/application-development/dtos/upload-application-file.input';
|
||||
import { WorkspaceMigrationDTO } from 'src/engine/core-modules/application/application-development/dtos/workspace-migration.dto';
|
||||
import { ApplicationTokenPairDTO } from 'src/engine/core-modules/application/application-oauth/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';
|
||||
import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
|
||||
@@ -58,12 +55,10 @@ import { streamToBuffer } from 'src/utils/stream-to-buffer';
|
||||
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 applicationSyncService: ApplicationSyncService,
|
||||
private readonly applicationRegistrationService: ApplicationRegistrationService,
|
||||
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
|
||||
private readonly fileStorageService: FileStorageService,
|
||||
@@ -87,19 +82,17 @@ 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 applicationRegistrationId = await this.findApplicationRegistrationId(
|
||||
manifest.application.universalIdentifier,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
await this.ensureApplicationExists({
|
||||
universalIdentifier: manifest.application.universalIdentifier,
|
||||
name: manifest.application.displayName,
|
||||
workspaceId,
|
||||
applicationRegistrationId,
|
||||
});
|
||||
|
||||
const workspaceMigration =
|
||||
await this.applicationSyncService.synchronizeFromManifest({
|
||||
@@ -121,19 +114,6 @@ export class ApplicationDevelopmentResolver {
|
||||
};
|
||||
}
|
||||
|
||||
@Mutation(() => ApplicationDTO)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
async createOneApplication(
|
||||
@Args('input') input: CreateApplicationInput,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
) {
|
||||
return await this.applicationService.create({
|
||||
...input,
|
||||
sourceType: AppRegistrationSourceType.LOCAL,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => FileDTO)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE))
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@@ -176,16 +156,8 @@ export class ApplicationDevelopmentResolver {
|
||||
});
|
||||
}
|
||||
|
||||
private async resolveApplicationRegistrationId(
|
||||
private async findApplicationRegistrationId(
|
||||
universalIdentifier: string,
|
||||
metadata: {
|
||||
name: string;
|
||||
description?: string;
|
||||
logoUrl?: string;
|
||||
author?: string;
|
||||
websiteUrl?: string;
|
||||
termsUrl?: string;
|
||||
},
|
||||
workspaceId: string,
|
||||
): Promise<string> {
|
||||
const existingRegistration =
|
||||
@@ -193,35 +165,27 @@ export class ApplicationDevelopmentResolver {
|
||||
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;
|
||||
if (!existingRegistration) {
|
||||
throw new ApplicationException(
|
||||
`No registration found for "${universalIdentifier}". Create one first with createApplicationRegistration.`,
|
||||
ApplicationExceptionCode.APPLICATION_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const { applicationRegistration: newRegistration } =
|
||||
await this.applicationRegistrationService.create(
|
||||
{ ...metadata, universalIdentifier },
|
||||
const isOwner =
|
||||
await this.applicationRegistrationService.isOwnedByWorkspace(
|
||||
existingRegistration.id,
|
||||
workspaceId,
|
||||
null,
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Created app registration for ${metadata.name} (${universalIdentifier})`,
|
||||
);
|
||||
if (!isOwner) {
|
||||
throw new ApplicationException(
|
||||
'Cannot sync application: registration is owned by another workspace',
|
||||
ApplicationExceptionCode.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
|
||||
return newRegistration.id;
|
||||
return existingRegistration.id;
|
||||
}
|
||||
|
||||
private async syncRegistrationMetadata(
|
||||
@@ -259,4 +223,29 @@ export class ApplicationDevelopmentResolver {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureApplicationExists(params: {
|
||||
universalIdentifier: string;
|
||||
name: string;
|
||||
workspaceId: string;
|
||||
applicationRegistrationId: string;
|
||||
}): Promise<void> {
|
||||
const existing = await this.applicationService.findByUniversalIdentifier({
|
||||
universalIdentifier: params.universalIdentifier,
|
||||
workspaceId: params.workspaceId,
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.applicationService.create({
|
||||
universalIdentifier: params.universalIdentifier,
|
||||
name: params.name,
|
||||
sourcePath: params.universalIdentifier,
|
||||
sourceType: ApplicationRegistrationSourceType.LOCAL,
|
||||
applicationRegistrationId: params.applicationRegistrationId,
|
||||
workspaceId: params.workspaceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -21,6 +21,7 @@ export class ApplicationExceptionFilter implements ExceptionFilter {
|
||||
case ApplicationExceptionCode.FIELD_NOT_FOUND:
|
||||
case ApplicationExceptionCode.ENTITY_NOT_FOUND:
|
||||
case ApplicationExceptionCode.APPLICATION_NOT_FOUND:
|
||||
case ApplicationExceptionCode.APP_NOT_INSTALLED:
|
||||
case ApplicationExceptionCode.LOGIC_FUNCTION_NOT_FOUND:
|
||||
case ApplicationExceptionCode.FRONT_COMPONENT_NOT_FOUND:
|
||||
throw new NotFoundError(exception);
|
||||
|
||||
+7
-47
@@ -4,66 +4,26 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
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 { ApplicationManifestModule } from 'src/engine/core-modules/application/application-manifest/application-manifest.module';
|
||||
import { ApplicationPackageModule } from 'src/engine/core-modules/application/application-package/application-package.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';
|
||||
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
|
||||
import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module';
|
||||
import { CodeStepBuildModule } from 'src/modules/workflow/workflow-builder/workflow-version-step/code-step/code-step-build.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
FileEntity,
|
||||
ApplicationRegistrationEntity,
|
||||
ApplicationEntity,
|
||||
]),
|
||||
TypeOrmModule.forFeature([ApplicationRegistrationEntity]),
|
||||
ApplicationModule,
|
||||
ApplicationManifestModule,
|
||||
ApplicationPackageModule,
|
||||
CacheLockModule,
|
||||
FeatureFlagModule,
|
||||
ApplicationVariableEntityModule,
|
||||
TokenModule,
|
||||
WorkspaceMigrationModule,
|
||||
PermissionsModule,
|
||||
ObjectPermissionModule,
|
||||
PermissionFlagModule,
|
||||
WorkflowCommonModule,
|
||||
CodeStepBuildModule,
|
||||
FileStorageModule,
|
||||
WorkspaceCacheModule,
|
||||
WorkspaceMigrationRunnerModule,
|
||||
TwentyConfigModule,
|
||||
],
|
||||
providers: [
|
||||
ApplicationInstallResolver,
|
||||
ApplicationManifestMigrationService,
|
||||
ApplicationSyncService,
|
||||
AppPackageFetcherService,
|
||||
ApplicationInstallService,
|
||||
AppUpgradeService,
|
||||
WorkspaceMigrationGraphqlApiExceptionInterceptor,
|
||||
],
|
||||
exports: [
|
||||
ApplicationSyncService,
|
||||
AppPackageFetcherService,
|
||||
ApplicationInstallService,
|
||||
AppUpgradeService,
|
||||
],
|
||||
providers: [ApplicationInstallResolver, ApplicationInstallService],
|
||||
exports: [ApplicationInstallService],
|
||||
})
|
||||
export class ApplicationInstallModule {}
|
||||
|
||||
+10
-96
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
UseFilters,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
UsePipes,
|
||||
} from '@nestjs/common';
|
||||
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
|
||||
import { Args, Mutation, Query } from '@nestjs/graphql';
|
||||
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
@@ -12,18 +7,10 @@ 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 {
|
||||
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 { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service';
|
||||
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 { 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';
|
||||
@@ -31,25 +18,17 @@ 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';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
|
||||
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service';
|
||||
|
||||
@UsePipes(ResolverValidationPipe)
|
||||
@MetadataResolver()
|
||||
@UseInterceptors(WorkspaceMigrationGraphqlApiExceptionInterceptor)
|
||||
@UseFilters(ApplicationExceptionFilter, AuthGraphqlApiExceptionFilter)
|
||||
@UseGuards(WorkspaceAuthGuard, FeatureFlagGuard)
|
||||
export class ApplicationInstallResolver {
|
||||
constructor(
|
||||
private readonly applicationService: ApplicationService,
|
||||
private readonly applicationTokenService: ApplicationTokenService,
|
||||
private readonly applicationSyncService: ApplicationSyncService,
|
||||
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
private readonly applicationInstallService: ApplicationInstallService,
|
||||
) {}
|
||||
|
||||
@Query(() => [ApplicationDTO])
|
||||
@@ -78,84 +57,19 @@ export class ApplicationInstallResolver {
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => ApplicationTokenPairDTO)
|
||||
@UseGuards(NoPermissionGuard)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
async renewApplicationToken(
|
||||
@Args('applicationRefreshToken') applicationRefreshToken: string,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
): Promise<ApplicationTokenPairDTO> {
|
||||
const applicationRefreshTokenPayload =
|
||||
this.applicationTokenService.validateApplicationRefreshToken(
|
||||
applicationRefreshToken,
|
||||
);
|
||||
|
||||
if (applicationRefreshTokenPayload.workspaceId !== workspaceId) {
|
||||
throw new ApplicationException(
|
||||
'Refresh token workspace does not match authenticated workspace',
|
||||
ApplicationExceptionCode.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
|
||||
return this.applicationTokenService.renewApplicationTokens(
|
||||
applicationRefreshTokenPayload,
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS))
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
async installApplication(
|
||||
@Args() { workspaceMigration: { actions } }: InstallApplicationInput,
|
||||
@Args('appRegistrationId') appRegistrationId: string,
|
||||
@Args('version', { type: () => String, nullable: true })
|
||||
version: string | undefined,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
) {
|
||||
const { featureFlagsMap } = await this.workspaceCacheService.getOrRecompute(
|
||||
workspaceId,
|
||||
['featureFlagsMap'],
|
||||
);
|
||||
|
||||
if (
|
||||
featureFlagsMap[
|
||||
FeatureFlagKey.IS_APPLICATION_INSTALLATION_FROM_TARBALL_ENABLED
|
||||
] !== true
|
||||
) {
|
||||
throw new ApplicationException(
|
||||
'Application installation from tarball is not enabled',
|
||||
ApplicationExceptionCode.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
|
||||
const { workspaceCustomFlatApplication } =
|
||||
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
|
||||
{
|
||||
workspaceId,
|
||||
},
|
||||
);
|
||||
|
||||
await this.workspaceMigrationRunnerService.run({
|
||||
workspaceMigration: {
|
||||
actions,
|
||||
applicationUniversalIdentifier:
|
||||
workspaceCustomFlatApplication.universalIdentifier,
|
||||
},
|
||||
): Promise<boolean> {
|
||||
return this.applicationInstallService.installApplication({
|
||||
appRegistrationId,
|
||||
version,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS))
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
async uninstallApplication(
|
||||
@Args() { universalIdentifier }: UninstallApplicationInput,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
) {
|
||||
await this.applicationSyncService.uninstallApplication({
|
||||
applicationUniversalIdentifier: universalIdentifier,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
+66
-33
@@ -8,14 +8,19 @@ 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,
|
||||
ApplicationException,
|
||||
ApplicationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application.exception';
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import {
|
||||
ApplicationPackageFetcherService,
|
||||
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';
|
||||
} from 'src/engine/core-modules/application/application-package/application-package-fetcher.service';
|
||||
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/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';
|
||||
|
||||
@@ -43,9 +48,8 @@ export class ApplicationInstallService {
|
||||
constructor(
|
||||
@InjectRepository(ApplicationRegistrationEntity)
|
||||
private readonly appRegistrationRepository: Repository<ApplicationRegistrationEntity>,
|
||||
@InjectRepository(ApplicationEntity)
|
||||
private readonly applicationRepository: Repository<ApplicationEntity>,
|
||||
private readonly appPackageFetcherService: AppPackageFetcherService,
|
||||
private readonly applicationService: ApplicationService,
|
||||
private readonly applicationPackageFetcherService: ApplicationPackageFetcherService,
|
||||
private readonly applicationSyncService: ApplicationSyncService,
|
||||
private readonly fileStorageService: FileStorageService,
|
||||
private readonly cacheLockService: CacheLockService,
|
||||
@@ -56,11 +60,20 @@ export class ApplicationInstallService {
|
||||
version?: string;
|
||||
workspaceId: string;
|
||||
}): Promise<boolean> {
|
||||
const appRegistration = await this.appRegistrationRepository.findOneOrFail({
|
||||
const appRegistration = await this.appRegistrationRepository.findOne({
|
||||
where: { id: params.appRegistrationId },
|
||||
});
|
||||
|
||||
if (appRegistration.sourceType === AppRegistrationSourceType.LOCAL) {
|
||||
if (!appRegistration) {
|
||||
throw new ApplicationException(
|
||||
`Application registration with id ${params.appRegistrationId} not found`,
|
||||
ApplicationExceptionCode.APPLICATION_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
appRegistration.sourceType === ApplicationRegistrationSourceType.LOCAL
|
||||
) {
|
||||
this.logger.log(
|
||||
`Skipping install for LOCAL app ${appRegistration.universalIdentifier} (files synced by CLI watcher in dev mode)`,
|
||||
);
|
||||
@@ -88,18 +101,29 @@ export class ApplicationInstallService {
|
||||
let resolvedPackage: ResolvedPackage | null = null;
|
||||
|
||||
try {
|
||||
resolvedPackage = await this.appPackageFetcherService.resolvePackage(
|
||||
appRegistration,
|
||||
{ targetVersion: params.version },
|
||||
);
|
||||
resolvedPackage =
|
||||
await this.applicationPackageFetcherService.resolvePackage(
|
||||
appRegistration,
|
||||
{ targetVersion: params.version },
|
||||
);
|
||||
|
||||
if (!resolvedPackage) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const universalIdentifier = appRegistration.universalIdentifier;
|
||||
|
||||
await this.ensureApplicationExists({
|
||||
universalIdentifier,
|
||||
name: resolvedPackage.manifest.application.displayName,
|
||||
workspaceId: params.workspaceId,
|
||||
applicationRegistrationId: appRegistration.id,
|
||||
sourceType: appRegistration.sourceType,
|
||||
});
|
||||
|
||||
await this.writeFilesToStorage(
|
||||
resolvedPackage.extractedDir,
|
||||
appRegistration.universalIdentifier,
|
||||
universalIdentifier,
|
||||
params.workspaceId,
|
||||
);
|
||||
|
||||
@@ -109,14 +133,8 @@ export class ApplicationInstallService {
|
||||
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'}`,
|
||||
`Successfully installed app ${universalIdentifier} v${resolvedPackage.packageJson.version ?? 'unknown'}`,
|
||||
);
|
||||
|
||||
return true;
|
||||
@@ -128,7 +146,7 @@ export class ApplicationInstallService {
|
||||
throw error;
|
||||
} finally {
|
||||
if (resolvedPackage) {
|
||||
await this.appPackageFetcherService.cleanupExtractedDir(
|
||||
await this.applicationPackageFetcherService.cleanupExtractedDir(
|
||||
resolvedPackage.cleanupDir,
|
||||
);
|
||||
}
|
||||
@@ -202,14 +220,29 @@ export class ApplicationInstallService {
|
||||
return result;
|
||||
}
|
||||
|
||||
private async updateApplicationSourceType(
|
||||
universalIdentifier: string,
|
||||
workspaceId: string,
|
||||
sourceType: AppRegistrationSourceType,
|
||||
): Promise<void> {
|
||||
await this.applicationRepository.update(
|
||||
{ universalIdentifier, workspaceId },
|
||||
{ sourceType },
|
||||
);
|
||||
private async ensureApplicationExists(params: {
|
||||
universalIdentifier: string;
|
||||
name: string;
|
||||
workspaceId: string;
|
||||
applicationRegistrationId: string;
|
||||
sourceType: ApplicationRegistrationSourceType;
|
||||
}): Promise<ApplicationEntity> {
|
||||
const existing = await this.applicationService.findByUniversalIdentifier({
|
||||
universalIdentifier: params.universalIdentifier,
|
||||
workspaceId: params.workspaceId,
|
||||
});
|
||||
|
||||
if (isDefined(existing)) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
return this.applicationService.create({
|
||||
universalIdentifier: params.universalIdentifier,
|
||||
name: params.name,
|
||||
sourcePath: params.universalIdentifier,
|
||||
sourceType: params.sourceType,
|
||||
applicationRegistrationId: params.applicationRegistrationId,
|
||||
workspaceId: params.workspaceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+8
-8
@@ -5,14 +5,14 @@ import axios from 'axios';
|
||||
import { Repository } from 'typeorm';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service';
|
||||
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 { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-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({
|
||||
@@ -20,8 +20,8 @@ const npmPackageMetadataSchema = z.object({
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
export class AppUpgradeService {
|
||||
private readonly logger = new Logger(AppUpgradeService.name);
|
||||
export class ApplicationUpgradeService {
|
||||
private readonly logger = new Logger(ApplicationUpgradeService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ApplicationRegistrationEntity)
|
||||
@@ -35,7 +35,7 @@ export class AppUpgradeService {
|
||||
async checkForUpdates(
|
||||
appRegistration: ApplicationRegistrationEntity,
|
||||
): Promise<string | null> {
|
||||
if (appRegistration.sourceType !== AppRegistrationSourceType.NPM) {
|
||||
if (appRegistration.sourceType !== ApplicationRegistrationSourceType.NPM) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ export class AppUpgradeService {
|
||||
|
||||
async checkAllForUpdates(): Promise<void> {
|
||||
const npmRegistrations = await this.appRegistrationRepository.find({
|
||||
where: { sourceType: AppRegistrationSourceType.NPM },
|
||||
where: { sourceType: ApplicationRegistrationSourceType.NPM },
|
||||
});
|
||||
|
||||
for (const registration of npmRegistrations) {
|
||||
@@ -100,8 +100,8 @@ export class AppUpgradeService {
|
||||
});
|
||||
|
||||
if (
|
||||
appRegistration.sourceType === AppRegistrationSourceType.LOCAL ||
|
||||
appRegistration.sourceType === AppRegistrationSourceType.TARBALL
|
||||
appRegistration.sourceType === ApplicationRegistrationSourceType.LOCAL ||
|
||||
appRegistration.sourceType === ApplicationRegistrationSourceType.TARBALL
|
||||
) {
|
||||
throw new ApplicationException(
|
||||
'Cannot upgrade an app installed from a tarball or local source',
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
|
||||
import { APPLICATION_VERSION_CHECK_CRON_PATTERN } from 'src/engine/core-modules/application/application-upgrade/crons/constants/application-version-check-cron-pattern.constant';
|
||||
import { ApplicationUpgradeService } from 'src/engine/core-modules/application/application-upgrade/application-upgrade.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 ApplicationVersionCheckCronJob {
|
||||
private readonly logger = new Logger(ApplicationVersionCheckCronJob.name);
|
||||
|
||||
constructor(
|
||||
private readonly applicationUpgradeService: ApplicationUpgradeService,
|
||||
) {}
|
||||
|
||||
@Process(ApplicationVersionCheckCronJob.name)
|
||||
@SentryCronMonitor(
|
||||
ApplicationVersionCheckCronJob.name,
|
||||
APPLICATION_VERSION_CHECK_CRON_PATTERN,
|
||||
)
|
||||
async handle(): Promise<void> {
|
||||
this.logger.log('Starting application version check...');
|
||||
|
||||
try {
|
||||
await this.applicationUpgradeService.checkAllForUpdates();
|
||||
this.logger.log('Application version check completed successfully');
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Application version check failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -1,7 +1,7 @@
|
||||
import { Command, CommandRunner } from 'nest-commander';
|
||||
|
||||
import { APP_VERSION_CHECK_CRON_PATTERN } from 'src/engine/core-modules/application/application-version-check/crons/constants/app-version-check-cron-pattern.constant';
|
||||
import { AppVersionCheckCronJob } from 'src/engine/core-modules/application/application-version-check/crons/app-version-check.cron.job';
|
||||
import { APPLICATION_VERSION_CHECK_CRON_PATTERN } from 'src/engine/core-modules/application/application-upgrade/crons/constants/application-version-check-cron-pattern.constant';
|
||||
import { ApplicationVersionCheckCronJob } from 'src/engine/core-modules/application/application-upgrade/crons/application-version-check.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';
|
||||
@@ -11,7 +11,7 @@ import { MessageQueueService } from 'src/engine/core-modules/message-queue/servi
|
||||
description:
|
||||
'Starts a cron job to check for app version updates on npm registries',
|
||||
})
|
||||
export class AppVersionCheckCronCommand extends CommandRunner {
|
||||
export class ApplicationVersionCheckCronCommand extends CommandRunner {
|
||||
constructor(
|
||||
@InjectMessageQueue(MessageQueue.cronQueue)
|
||||
private readonly messageQueueService: MessageQueueService,
|
||||
@@ -21,11 +21,11 @@ export class AppVersionCheckCronCommand extends CommandRunner {
|
||||
|
||||
async run(): Promise<void> {
|
||||
await this.messageQueueService.addCron<undefined>({
|
||||
jobName: AppVersionCheckCronJob.name,
|
||||
jobName: ApplicationVersionCheckCronJob.name,
|
||||
data: undefined,
|
||||
options: {
|
||||
repeat: {
|
||||
pattern: APP_VERSION_CHECK_CRON_PATTERN,
|
||||
pattern: APPLICATION_VERSION_CHECK_CRON_PATTERN,
|
||||
},
|
||||
},
|
||||
});
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
// Every 6 hours
|
||||
export const APPLICATION_VERSION_CHECK_CRON_PATTERN = '0 */6 * * *';
|
||||
+3
-3
@@ -10,9 +10,9 @@ import {
|
||||
} from 'src/engine/core-modules/application/application.exception';
|
||||
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';
|
||||
import { getApplicationSubAllFlatEntityMaps } from 'src/engine/core-modules/application/utils/get-application-sub-all-flat-entity-maps.util';
|
||||
import { buildFromToAllUniversalFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/build-from-to-all-universal-flat-entity-maps.util';
|
||||
import { computeApplicationManifestAllUniversalFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/compute-application-manifest-all-universal-flat-entity-maps.util';
|
||||
import { getApplicationSubAllFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/get-application-sub-all-flat-entity-maps.util';
|
||||
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
|
||||
import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util';
|
||||
import { FieldPermissionService } from 'src/engine/metadata-modules/object-permission/field-permission/field-permission.service';
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { ApplicationManifestMigrationService } from 'src/engine/core-modules/application/application-manifest/application-manifest-migration.service';
|
||||
import { ApplicationManifestResolver } from 'src/engine/core-modules/application/application-manifest/application-manifest.resolver';
|
||||
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/application-sync.service';
|
||||
import { ApplicationVariableEntityModule } from 'src/engine/core-modules/application/application-variable/application-variable.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 { 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 { 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';
|
||||
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ApplicationModule,
|
||||
ApplicationVariableEntityModule,
|
||||
FeatureFlagModule,
|
||||
FileStorageModule,
|
||||
ObjectPermissionModule,
|
||||
PermissionFlagModule,
|
||||
PermissionsModule,
|
||||
WorkspaceCacheModule,
|
||||
WorkspaceMigrationModule,
|
||||
WorkspaceMigrationRunnerModule,
|
||||
],
|
||||
providers: [
|
||||
ApplicationManifestMigrationService,
|
||||
ApplicationManifestResolver,
|
||||
ApplicationSyncService,
|
||||
WorkspaceMigrationGraphqlApiExceptionInterceptor,
|
||||
],
|
||||
exports: [ApplicationManifestMigrationService, ApplicationSyncService],
|
||||
})
|
||||
export class ApplicationManifestModule {}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
UseFilters,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
UsePipes,
|
||||
} from '@nestjs/common';
|
||||
import { Args, Mutation } 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 { ApplicationExceptionFilter } from 'src/engine/core-modules/application/application-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-manifest/application-sync.service';
|
||||
import { RunWorkspaceMigrationInput } from 'src/engine/core-modules/application/application-manifest/dtos/run-workspace-migration.input';
|
||||
import { UninstallApplicationInput } from 'src/engine/core-modules/application/application-manifest/dtos/uninstall-application.input';
|
||||
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
|
||||
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 {
|
||||
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 { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
|
||||
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service';
|
||||
|
||||
@UsePipes(ResolverValidationPipe)
|
||||
@MetadataResolver()
|
||||
@UseInterceptors(WorkspaceMigrationGraphqlApiExceptionInterceptor)
|
||||
@UseFilters(ApplicationExceptionFilter, AuthGraphqlApiExceptionFilter)
|
||||
@UseGuards(WorkspaceAuthGuard, FeatureFlagGuard)
|
||||
export class ApplicationManifestResolver {
|
||||
constructor(
|
||||
private readonly applicationService: ApplicationService,
|
||||
private readonly applicationSyncService: ApplicationSyncService,
|
||||
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
) {}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS))
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
async runWorkspaceMigration(
|
||||
@Args() { workspaceMigration: { actions } }: RunWorkspaceMigrationInput,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
) {
|
||||
const { featureFlagsMap } = await this.workspaceCacheService.getOrRecompute(
|
||||
workspaceId,
|
||||
['featureFlagsMap'],
|
||||
);
|
||||
|
||||
if (
|
||||
featureFlagsMap[
|
||||
FeatureFlagKey.IS_APPLICATION_INSTALLATION_FROM_TARBALL_ENABLED
|
||||
] !== true
|
||||
) {
|
||||
throw new ApplicationException(
|
||||
'Application installation from tarball is not enabled',
|
||||
ApplicationExceptionCode.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
|
||||
const { workspaceCustomFlatApplication } =
|
||||
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
|
||||
{
|
||||
workspaceId,
|
||||
},
|
||||
);
|
||||
|
||||
await this.workspaceMigrationRunnerService.run({
|
||||
workspaceMigration: {
|
||||
actions,
|
||||
applicationUniversalIdentifier:
|
||||
workspaceCustomFlatApplication.universalIdentifier,
|
||||
},
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS))
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
async uninstallApplication(
|
||||
@Args() { universalIdentifier }: UninstallApplicationInput,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
) {
|
||||
await this.applicationSyncService.uninstallApplication({
|
||||
applicationUniversalIdentifier: universalIdentifier,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+13
-24
@@ -11,12 +11,11 @@ import {
|
||||
ApplicationException,
|
||||
ApplicationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application.exception';
|
||||
import { ApplicationManifestMigrationService } from 'src/engine/core-modules/application/application-install/application-manifest-migration.service';
|
||||
import { ApplicationManifestMigrationService } from 'src/engine/core-modules/application/application-manifest/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 { buildFromToAllUniversalFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/build-from-to-all-universal-flat-entity-maps.util';
|
||||
import { getApplicationSubAllFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/get-application-sub-all-flat-entity-maps.util';
|
||||
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';
|
||||
@@ -93,28 +92,18 @@ export class ApplicationSyncService {
|
||||
).toString('utf-8'),
|
||||
) as PackageJson;
|
||||
|
||||
const defaultPackageFields = await getDefaultApplicationPackageFields();
|
||||
|
||||
let application = await this.applicationService.findByUniversalIdentifier({
|
||||
universalIdentifier: manifest.application.universalIdentifier,
|
||||
workspaceId,
|
||||
});
|
||||
const application = await this.applicationService.findByUniversalIdentifier(
|
||||
{
|
||||
universalIdentifier: manifest.application.universalIdentifier,
|
||||
workspaceId,
|
||||
},
|
||||
);
|
||||
|
||||
if (!application) {
|
||||
application = await this.applicationService.create({
|
||||
universalIdentifier: manifest.application.universalIdentifier,
|
||||
name,
|
||||
description: manifest.application.description,
|
||||
version: packageJson.version,
|
||||
sourcePath: 'cli-sync',
|
||||
defaultRoleId: null,
|
||||
workspaceId,
|
||||
packageJsonChecksum: defaultPackageFields.packageJsonChecksum,
|
||||
packageJsonFileId: null,
|
||||
yarnLockChecksum: defaultPackageFields.yarnLockChecksum,
|
||||
yarnLockFileId: null,
|
||||
availablePackages: defaultPackageFields.availablePackages,
|
||||
});
|
||||
throw new ApplicationException(
|
||||
`Application "${manifest.application.universalIdentifier}" is not installed in workspace "${workspaceId}". Install it first.`,
|
||||
ApplicationExceptionCode.APP_NOT_INSTALLED,
|
||||
);
|
||||
}
|
||||
|
||||
await this.applicationVariableService.upsertManyApplicationVariableEntities(
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { fromNavigationMenuItemManifestToUniversalFlatNavigationMenuItem } from 'src/engine/core-modules/application/utils/from-navigation-menu-item-manifest-to-universal-flat-navigation-menu-item.util';
|
||||
import { fromNavigationMenuItemManifestToUniversalFlatNavigationMenuItem } from 'src/engine/core-modules/application/application-manifest/converters/from-navigation-menu-item-manifest-to-universal-flat-navigation-menu-item.util';
|
||||
|
||||
describe('fromNavigationMenuItemManifestToUniversalFlatNavigationMenuItem', () => {
|
||||
const now = '2026-01-01T00:00:00.000Z';
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { PageLayoutType } from 'src/engine/metadata-modules/page-layout/enums/page-layout-type.enum';
|
||||
import { fromPageLayoutManifestToUniversalFlatPageLayout } from 'src/engine/core-modules/application/utils/from-page-layout-manifest-to-universal-flat-page-layout.util';
|
||||
import { fromPageLayoutManifestToUniversalFlatPageLayout } from 'src/engine/core-modules/application/application-manifest/converters/from-page-layout-manifest-to-universal-flat-page-layout.util';
|
||||
|
||||
describe('fromPageLayoutManifestToUniversalFlatPageLayout', () => {
|
||||
const now = '2026-01-01T00:00:00.000Z';
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { PageLayoutTabLayoutMode } from 'twenty-shared/types';
|
||||
|
||||
import { fromPageLayoutTabManifestToUniversalFlatPageLayoutTab } from 'src/engine/core-modules/application/utils/from-page-layout-tab-manifest-to-universal-flat-page-layout-tab.util';
|
||||
import { fromPageLayoutTabManifestToUniversalFlatPageLayoutTab } from 'src/engine/core-modules/application/application-manifest/converters/from-page-layout-tab-manifest-to-universal-flat-page-layout-tab.util';
|
||||
|
||||
describe('fromPageLayoutTabManifestToUniversalFlatPageLayoutTab', () => {
|
||||
const now = '2026-01-01T00:00:00.000Z';
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { WidgetType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-type.enum';
|
||||
import { fromPageLayoutWidgetManifestToUniversalFlatPageLayoutWidget } from 'src/engine/core-modules/application/utils/from-page-layout-widget-manifest-to-universal-flat-page-layout-widget.util';
|
||||
import { fromPageLayoutWidgetManifestToUniversalFlatPageLayoutWidget } from 'src/engine/core-modules/application/application-manifest/converters/from-page-layout-widget-manifest-to-universal-flat-page-layout-widget.util';
|
||||
|
||||
describe('fromPageLayoutWidgetManifestToUniversalFlatPageLayoutWidget', () => {
|
||||
const now = '2026-01-01T00:00:00.000Z';
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { AggregateOperations } from 'twenty-shared/types';
|
||||
|
||||
import { fromViewFieldManifestToUniversalFlatViewField } from 'src/engine/core-modules/application/utils/from-view-field-manifest-to-universal-flat-view-field.util';
|
||||
import { fromViewFieldManifestToUniversalFlatViewField } from 'src/engine/core-modules/application/application-manifest/converters/from-view-field-manifest-to-universal-flat-view-field.util';
|
||||
|
||||
describe('fromViewFieldManifestToUniversalFlatViewField', () => {
|
||||
const now = '2026-01-01T00:00:00.000Z';
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { ViewFilterOperand } from 'twenty-shared/types';
|
||||
|
||||
import { fromViewFilterManifestToUniversalFlatViewFilter } from 'src/engine/core-modules/application/utils/from-view-filter-manifest-to-universal-flat-view-filter.util';
|
||||
import { fromViewFilterManifestToUniversalFlatViewFilter } from 'src/engine/core-modules/application/application-manifest/converters/from-view-filter-manifest-to-universal-flat-view-filter.util';
|
||||
|
||||
describe('fromViewFilterManifestToUniversalFlatViewFilter', () => {
|
||||
const now = '2026-01-01T00:00:00.000Z';
|
||||
+1
-1
@@ -4,7 +4,7 @@ import {
|
||||
ViewVisibility,
|
||||
} from 'twenty-shared/types';
|
||||
|
||||
import { fromViewManifestToUniversalFlatView } from 'src/engine/core-modules/application/utils/from-view-manifest-to-universal-flat-view.util';
|
||||
import { fromViewManifestToUniversalFlatView } from 'src/engine/core-modules/application/application-manifest/converters/from-view-manifest-to-universal-flat-view.util';
|
||||
|
||||
describe('fromViewManifestToUniversalFlatView', () => {
|
||||
const now = '2026-01-01T00:00:00.000Z';
|
||||
+1
-1
@@ -46,7 +46,7 @@ export class WorkspaceMigrationInput {
|
||||
}
|
||||
|
||||
@ArgsType()
|
||||
export class InstallApplicationInput {
|
||||
export class RunWorkspaceMigrationInput {
|
||||
@Field(() => WorkspaceMigrationInput)
|
||||
@ValidateNested()
|
||||
@Type(() => WorkspaceMigrationInput)
|
||||
+17
-17
@@ -1,23 +1,23 @@
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
|
||||
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
|
||||
import { fromCommandMenuItemManifestToUniversalFlatCommandMenuItem } from 'src/engine/core-modules/application/utils/from-command-menu-item-manifest-to-universal-flat-command-menu-item.util';
|
||||
import { fromFieldManifestToUniversalFlatFieldMetadata } from 'src/engine/core-modules/application/utils/from-field-manifest-to-universal-flat-field-metadata.util';
|
||||
import { fromFrontComponentManifestToUniversalFlatFrontComponent } from 'src/engine/core-modules/application/utils/from-front-component-manifest-to-universal-flat-front-component.util';
|
||||
import { fromLogicFunctionManifestToUniversalFlatLogicFunction } from 'src/engine/core-modules/application/utils/from-logic-function-manifest-to-universal-flat-logic-function.util';
|
||||
import { fromNavigationMenuItemManifestToUniversalFlatNavigationMenuItem } from 'src/engine/core-modules/application/utils/from-navigation-menu-item-manifest-to-universal-flat-navigation-menu-item.util';
|
||||
import { fromObjectManifestToUniversalFlatObjectMetadata } from 'src/engine/core-modules/application/utils/from-object-manifest-to-universal-flat-object-metadata.util';
|
||||
import { fromPageLayoutManifestToUniversalFlatPageLayout } from 'src/engine/core-modules/application/utils/from-page-layout-manifest-to-universal-flat-page-layout.util';
|
||||
import { fromPageLayoutTabManifestToUniversalFlatPageLayoutTab } from 'src/engine/core-modules/application/utils/from-page-layout-tab-manifest-to-universal-flat-page-layout-tab.util';
|
||||
import { fromPageLayoutWidgetManifestToUniversalFlatPageLayoutWidget } from 'src/engine/core-modules/application/utils/from-page-layout-widget-manifest-to-universal-flat-page-layout-widget.util';
|
||||
import { fromRoleManifestToUniversalFlatRole } from 'src/engine/core-modules/application/utils/from-role-manifest-to-universal-flat-role.util';
|
||||
import { fromSkillManifestToUniversalFlatSkill } from 'src/engine/core-modules/application/utils/from-skill-manifest-to-universal-flat-skill.util';
|
||||
import { fromViewFieldGroupManifestToUniversalFlatViewFieldGroup } from 'src/engine/core-modules/application/utils/from-view-field-group-manifest-to-universal-flat-view-field-group.util';
|
||||
import { fromViewFieldManifestToUniversalFlatViewField } from 'src/engine/core-modules/application/utils/from-view-field-manifest-to-universal-flat-view-field.util';
|
||||
import { fromViewFilterGroupManifestToUniversalFlatViewFilterGroup } from 'src/engine/core-modules/application/utils/from-view-filter-group-manifest-to-universal-flat-view-filter-group.util';
|
||||
import { fromViewFilterManifestToUniversalFlatViewFilter } from 'src/engine/core-modules/application/utils/from-view-filter-manifest-to-universal-flat-view-filter.util';
|
||||
import { fromViewGroupManifestToUniversalFlatViewGroup } from 'src/engine/core-modules/application/utils/from-view-group-manifest-to-universal-flat-view-group.util';
|
||||
import { fromViewManifestToUniversalFlatView } from 'src/engine/core-modules/application/utils/from-view-manifest-to-universal-flat-view.util';
|
||||
import { fromCommandMenuItemManifestToUniversalFlatCommandMenuItem } from 'src/engine/core-modules/application/application-manifest/converters/from-command-menu-item-manifest-to-universal-flat-command-menu-item.util';
|
||||
import { fromFieldManifestToUniversalFlatFieldMetadata } from 'src/engine/core-modules/application/application-manifest/converters/from-field-manifest-to-universal-flat-field-metadata.util';
|
||||
import { fromFrontComponentManifestToUniversalFlatFrontComponent } from 'src/engine/core-modules/application/application-manifest/converters/from-front-component-manifest-to-universal-flat-front-component.util';
|
||||
import { fromLogicFunctionManifestToUniversalFlatLogicFunction } from 'src/engine/core-modules/application/application-manifest/converters/from-logic-function-manifest-to-universal-flat-logic-function.util';
|
||||
import { fromNavigationMenuItemManifestToUniversalFlatNavigationMenuItem } from 'src/engine/core-modules/application/application-manifest/converters/from-navigation-menu-item-manifest-to-universal-flat-navigation-menu-item.util';
|
||||
import { fromObjectManifestToUniversalFlatObjectMetadata } from 'src/engine/core-modules/application/application-manifest/converters/from-object-manifest-to-universal-flat-object-metadata.util';
|
||||
import { fromPageLayoutManifestToUniversalFlatPageLayout } from 'src/engine/core-modules/application/application-manifest/converters/from-page-layout-manifest-to-universal-flat-page-layout.util';
|
||||
import { fromPageLayoutTabManifestToUniversalFlatPageLayoutTab } from 'src/engine/core-modules/application/application-manifest/converters/from-page-layout-tab-manifest-to-universal-flat-page-layout-tab.util';
|
||||
import { fromPageLayoutWidgetManifestToUniversalFlatPageLayoutWidget } from 'src/engine/core-modules/application/application-manifest/converters/from-page-layout-widget-manifest-to-universal-flat-page-layout-widget.util';
|
||||
import { fromRoleManifestToUniversalFlatRole } from 'src/engine/core-modules/application/application-manifest/converters/from-role-manifest-to-universal-flat-role.util';
|
||||
import { fromSkillManifestToUniversalFlatSkill } from 'src/engine/core-modules/application/application-manifest/converters/from-skill-manifest-to-universal-flat-skill.util';
|
||||
import { fromViewFieldGroupManifestToUniversalFlatViewFieldGroup } from 'src/engine/core-modules/application/application-manifest/converters/from-view-field-group-manifest-to-universal-flat-view-field-group.util';
|
||||
import { fromViewFieldManifestToUniversalFlatViewField } from 'src/engine/core-modules/application/application-manifest/converters/from-view-field-manifest-to-universal-flat-view-field.util';
|
||||
import { fromViewFilterGroupManifestToUniversalFlatViewFilterGroup } from 'src/engine/core-modules/application/application-manifest/converters/from-view-filter-group-manifest-to-universal-flat-view-filter-group.util';
|
||||
import { fromViewFilterManifestToUniversalFlatViewFilter } from 'src/engine/core-modules/application/application-manifest/converters/from-view-filter-manifest-to-universal-flat-view-filter.util';
|
||||
import { fromViewGroupManifestToUniversalFlatViewGroup } from 'src/engine/core-modules/application/application-manifest/converters/from-view-group-manifest-to-universal-flat-view-group.util';
|
||||
import { fromViewManifestToUniversalFlatView } from 'src/engine/core-modules/application/application-manifest/converters/from-view-manifest-to-universal-flat-view.util';
|
||||
import { createEmptyAllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-all-flat-entity-maps.constant';
|
||||
import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
|
||||
import { addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/utils/add-universal-flat-entity-to-universal-flat-entity-maps-through-mutation-or-throw.util';
|
||||
+1
-1
@@ -2,7 +2,7 @@ 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 { MarketplaceCatalogSyncService } from 'src/engine/core-modules/application/application-marketplace/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';
|
||||
|
||||
+5
@@ -2,6 +2,7 @@ import { Field, Int, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
@@ -285,4 +286,8 @@ export class MarketplaceAppDTO {
|
||||
@IsString()
|
||||
@Field({ nullable: true })
|
||||
sourcePackage?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@Field(() => Boolean)
|
||||
isFeatured: boolean;
|
||||
}
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
|
||||
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-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/marketplace.service';
|
||||
|
||||
@Injectable()
|
||||
export class MarketplaceCatalogSyncService {
|
||||
private readonly logger = new Logger(MarketplaceCatalogSyncService.name);
|
||||
|
||||
constructor(
|
||||
private readonly applicationRegistrationService: ApplicationRegistrationService,
|
||||
private readonly marketplaceService: MarketplaceService,
|
||||
) {}
|
||||
|
||||
async syncCatalog(): Promise<void> {
|
||||
await this.syncCuratedApps();
|
||||
await this.syncNpmApps();
|
||||
|
||||
this.logger.log('Marketplace catalog sync completed');
|
||||
}
|
||||
|
||||
private async syncCuratedApps(): Promise<void> {
|
||||
for (const entry of MARKETPLACE_CATALOG_INDEX) {
|
||||
try {
|
||||
await this.applicationRegistrationService.upsertFromCatalog({
|
||||
universalIdentifier: entry.universalIdentifier,
|
||||
name: entry.name,
|
||||
description:
|
||||
entry.richDisplayData.aboutDescription ?? entry.description,
|
||||
author: entry.author,
|
||||
sourceType: ApplicationRegistrationSourceType.NPM,
|
||||
sourcePackage: entry.sourcePackage,
|
||||
logoUrl: entry.logoUrl ?? null,
|
||||
websiteUrl: entry.websiteUrl ?? null,
|
||||
termsUrl: entry.termsUrl ?? null,
|
||||
latestAvailableVersion: entry.richDisplayData.version ?? null,
|
||||
isListed: true,
|
||||
isFeatured: entry.isFeatured,
|
||||
marketplaceDisplayData: entry.richDisplayData,
|
||||
ownerWorkspaceId: null,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to sync curated app "${entry.name}": ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async syncNpmApps(): 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.applicationRegistrationService.upsertFromCatalog({
|
||||
universalIdentifier: app.id,
|
||||
name: app.name,
|
||||
description: app.description,
|
||||
author: app.author,
|
||||
sourceType: ApplicationRegistrationSourceType.NPM,
|
||||
sourcePackage: app.sourcePackage ?? app.name,
|
||||
logoUrl: null,
|
||||
websiteUrl: app.websiteUrl ?? null,
|
||||
termsUrl: null,
|
||||
latestAvailableVersion: app.version ?? null,
|
||||
isListed: true,
|
||||
isFeatured: false,
|
||||
marketplaceDisplayData: null,
|
||||
ownerWorkspaceId: null,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to sync npm app "${app.name}": ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
-61
@@ -1,17 +1,13 @@
|
||||
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 { type 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 { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
|
||||
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';
|
||||
@@ -28,8 +24,7 @@ export class MarketplaceQueryService {
|
||||
private hasSyncBeenEnqueued = false;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ApplicationRegistrationEntity)
|
||||
private readonly appRegistrationRepository: Repository<ApplicationRegistrationEntity>,
|
||||
private readonly applicationRegistrationService: ApplicationRegistrationService,
|
||||
@InjectMessageQueue(MessageQueue.cronQueue)
|
||||
private readonly messageQueueService: MessageQueueService,
|
||||
) {}
|
||||
@@ -39,9 +34,8 @@ export class MarketplaceQueryService {
|
||||
return this.cachedApps;
|
||||
}
|
||||
|
||||
const registrations = await this.appRegistrationRepository.find({
|
||||
where: { sourceType: AppRegistrationSourceType.NPM },
|
||||
});
|
||||
const registrations =
|
||||
await this.applicationRegistrationService.findManyListed();
|
||||
|
||||
if (registrations.length === 0) {
|
||||
if (!this.hasSyncBeenEnqueued) {
|
||||
@@ -66,12 +60,22 @@ export class MarketplaceQueryService {
|
||||
return this.cachedApps;
|
||||
}
|
||||
|
||||
async findOneMarketplaceApp(
|
||||
universalIdentifier: string,
|
||||
): Promise<MarketplaceAppDTO> {
|
||||
const registration =
|
||||
await this.findRegistrationByUniversalIdentifier(universalIdentifier);
|
||||
|
||||
return this.toMarketplaceAppDTO(registration);
|
||||
}
|
||||
|
||||
async findRegistrationByUniversalIdentifier(
|
||||
universalIdentifier: string,
|
||||
): Promise<ApplicationRegistrationEntity> {
|
||||
const registration = await this.appRegistrationRepository.findOne({
|
||||
where: { universalIdentifier },
|
||||
});
|
||||
const registration =
|
||||
await this.applicationRegistrationService.findOneByUniversalIdentifier(
|
||||
universalIdentifier,
|
||||
);
|
||||
|
||||
if (!isDefined(registration)) {
|
||||
throw new ApplicationRegistrationException(
|
||||
@@ -83,53 +87,6 @@ export class MarketplaceQueryService {
|
||||
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 {
|
||||
@@ -157,6 +114,7 @@ export class MarketplaceQueryService {
|
||||
frontComponents: displayData?.frontComponents ?? [],
|
||||
sourcePackage: registration.sourcePackage ?? undefined,
|
||||
defaultRole: displayData?.defaultRole,
|
||||
isFeatured: registration.isFeatured,
|
||||
};
|
||||
}
|
||||
}
|
||||
+6
-7
@@ -1,21 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
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 { 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 { MarketplaceCatalogSyncService } from 'src/engine/core-modules/application/application-marketplace/marketplace-catalog-sync.service';
|
||||
import { MarketplaceQueryService } from 'src/engine/core-modules/application/application-marketplace/marketplace-query.service';
|
||||
import { MarketplaceResolver } from 'src/engine/core-modules/application/application-marketplace/marketplace.resolver';
|
||||
import { MarketplaceService } from 'src/engine/core-modules/application/application-marketplace/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]),
|
||||
ApplicationRegistrationModule,
|
||||
ApplicationInstallModule,
|
||||
FeatureFlagModule,
|
||||
PermissionsModule,
|
||||
|
||||
+11
-52
@@ -6,15 +6,9 @@ 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 { MarketplaceQueryService } from 'src/engine/core-modules/application/application-marketplace/marketplace-query.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import {
|
||||
@@ -38,7 +32,6 @@ export class MarketplaceResolver {
|
||||
constructor(
|
||||
private readonly marketplaceQueryService: MarketplaceQueryService,
|
||||
private readonly applicationInstallService: ApplicationInstallService,
|
||||
private readonly appUpgradeService: AppUpgradeService,
|
||||
) {}
|
||||
|
||||
@Query(() => [MarketplaceAppDTO])
|
||||
@@ -47,6 +40,16 @@ export class MarketplaceResolver {
|
||||
return this.marketplaceQueryService.findManyMarketplaceApps();
|
||||
}
|
||||
|
||||
@Query(() => MarketplaceAppDTO)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
async findOneMarketplaceApp(
|
||||
@Args('universalIdentifier') universalIdentifier: string,
|
||||
): Promise<MarketplaceAppDTO> {
|
||||
return this.marketplaceQueryService.findOneMarketplaceApp(
|
||||
universalIdentifier,
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.MARKETPLACE_APPS))
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@@ -61,54 +64,10 @@ export class MarketplaceResolver {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+1
@@ -80,6 +80,7 @@ export class MarketplaceService {
|
||||
logicFunctions: [],
|
||||
frontComponents: [],
|
||||
sourcePackage: name,
|
||||
isFeatured: false,
|
||||
};
|
||||
})
|
||||
.filter(isDefined);
|
||||
-171
@@ -1,171 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
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;
|
||||
};
|
||||
+1
-1
@@ -2,7 +2,7 @@ import {
|
||||
ALL_OAUTH_SCOPES,
|
||||
OAUTH_SCOPE_DESCRIPTIONS,
|
||||
OAUTH_SCOPES,
|
||||
} from 'src/engine/core-modules/application/application-registration/constants/oauth-scopes';
|
||||
} from 'src/engine/core-modules/application/application-oauth/constants/oauth-scopes';
|
||||
|
||||
describe('OAuth Scopes', () => {
|
||||
it('should have all scopes defined', () => {
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { ApplicationInstallModule } from 'src/engine/core-modules/application/application-install/application-install.module';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import { ApplicationModule as ApplicationCoreModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { ApplicationOAuthResolver } from 'src/engine/core-modules/application/application-oauth/application-oauth.resolver';
|
||||
import { OAuthDiscoveryController } from 'src/engine/core-modules/application/application-oauth/controllers/oauth-discovery.controller';
|
||||
import { OAuthTokenController } from 'src/engine/core-modules/application/application-oauth/controllers/oauth-token.controller';
|
||||
import { OAuthService } from 'src/engine/core-modules/application/application-oauth/oauth.service';
|
||||
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module';
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
|
||||
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.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: [
|
||||
TypeOrmModule.forFeature([
|
||||
AppTokenEntity,
|
||||
ApplicationEntity,
|
||||
UserWorkspaceEntity,
|
||||
]),
|
||||
ApplicationRegistrationModule,
|
||||
ApplicationCoreModule,
|
||||
ApplicationInstallModule,
|
||||
TokenModule,
|
||||
FeatureFlagModule,
|
||||
PermissionsModule,
|
||||
ThrottlerModule,
|
||||
TwentyConfigModule,
|
||||
WorkspaceCacheStorageModule,
|
||||
],
|
||||
controllers: [OAuthTokenController, OAuthDiscoveryController],
|
||||
providers: [OAuthService, ApplicationOAuthResolver],
|
||||
exports: [OAuthService],
|
||||
})
|
||||
export class ApplicationOAuthModule {}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
|
||||
import { Args, Mutation } from '@nestjs/graphql';
|
||||
|
||||
import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import {
|
||||
ApplicationException,
|
||||
ApplicationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application.exception';
|
||||
import { ApplicationTokenPairDTO } from 'src/engine/core-modules/application/application-oauth/dtos/application-token-pair.dto';
|
||||
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 {
|
||||
FeatureFlagGuard,
|
||||
RequireFeatureFlag,
|
||||
} from 'src/engine/guards/feature-flag.guard';
|
||||
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
|
||||
@UsePipes(ResolverValidationPipe)
|
||||
@MetadataResolver()
|
||||
@UseFilters(AuthGraphqlApiExceptionFilter)
|
||||
@UseGuards(WorkspaceAuthGuard, FeatureFlagGuard)
|
||||
export class ApplicationOAuthResolver {
|
||||
constructor(
|
||||
private readonly applicationTokenService: ApplicationTokenService,
|
||||
) {}
|
||||
|
||||
@Mutation(() => ApplicationTokenPairDTO)
|
||||
@UseGuards(NoPermissionGuard)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
async renewApplicationToken(
|
||||
@Args('applicationRefreshToken') applicationRefreshToken: string,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
): Promise<ApplicationTokenPairDTO> {
|
||||
const applicationRefreshTokenPayload =
|
||||
this.applicationTokenService.validateApplicationRefreshToken(
|
||||
applicationRefreshToken,
|
||||
);
|
||||
|
||||
if (applicationRefreshTokenPayload.workspaceId !== workspaceId) {
|
||||
throw new ApplicationException(
|
||||
'Refresh token workspace does not match authenticated workspace',
|
||||
ApplicationExceptionCode.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
|
||||
return this.applicationTokenService.renewApplicationTokens(
|
||||
applicationRefreshTokenPayload,
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
|
||||
import { ALL_OAUTH_SCOPES } from 'src/engine/core-modules/application/application-registration/constants/oauth-scopes';
|
||||
import { ALL_OAUTH_SCOPES } from 'src/engine/core-modules/application/application-oauth/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';
|
||||
+6
-6
@@ -13,12 +13,12 @@ import {
|
||||
|
||||
import { type Request, type Response } from 'express';
|
||||
|
||||
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 { OAuthIntrospectInput } from 'src/engine/core-modules/application/application-oauth/dtos/oauth-introspect.input';
|
||||
import { OAuthRevokeInput } from 'src/engine/core-modules/application/application-oauth/dtos/oauth-revoke.input';
|
||||
import { OAuthTokenInput } from 'src/engine/core-modules/application/application-oauth/dtos/oauth-token.input';
|
||||
import { OAuthService } from 'src/engine/core-modules/application/application-oauth/oauth.service';
|
||||
import { OAuthErrorResponse } from 'src/engine/core-modules/application/application-oauth/types/oauth-error-response.type';
|
||||
import { OAuthTokenResponse } from 'src/engine/core-modules/application/application-oauth/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';
|
||||
+9
-12
@@ -11,14 +11,13 @@ import {
|
||||
AppTokenEntity,
|
||||
AppTokenType,
|
||||
} from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.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 { 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 { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.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 { OAuthErrorResponse } from 'src/engine/core-modules/application/application-oauth/types/oauth-error-response.type';
|
||||
import { OAuthTokenResponse } from 'src/engine/core-modules/application/application-oauth/types/oauth-token-response.type';
|
||||
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
@@ -557,14 +556,7 @@ export class OAuthService {
|
||||
return existingApplication;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Auto-installing application "${applicationRegistration.name}" in workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
if (
|
||||
applicationRegistration.sourceType === AppRegistrationSourceType.NPM ||
|
||||
applicationRegistration.sourceType === AppRegistrationSourceType.TARBALL
|
||||
) {
|
||||
try {
|
||||
await this.applicationInstallService.installApplication({
|
||||
appRegistrationId: applicationRegistration.id,
|
||||
workspaceId,
|
||||
@@ -584,6 +576,11 @@ export class OAuthService {
|
||||
this.logger.warn(
|
||||
`Install succeeded but application not found in workspace, falling back to bare creation`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Auto-install failed for ${applicationRegistration.name}, falling back to bare creation`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
return this.applicationService.create({
|
||||
+80
-36
@@ -1,4 +1,5 @@
|
||||
import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { execFile } from 'child_process';
|
||||
import { promises as fs } from 'fs';
|
||||
@@ -7,21 +8,27 @@ import { join } from 'path';
|
||||
import { promisify } from 'util';
|
||||
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type PackageJson } from 'type-fest';
|
||||
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 { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-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 { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import { YARN_ENGINE_DIRNAME } from 'src/engine/core-modules/application/application-package/constants/yarn-engine-dirname';
|
||||
import { assertValidNpmPackageName } from 'src/engine/core-modules/application/application-package/utils/assert-valid-npm-package-name.util';
|
||||
import { extractTarballSecurely } from 'src/engine/core-modules/application/application-package/utils/extract-tarball-securely.util';
|
||||
import { readJsonFileOrThrow } from 'src/engine/core-modules/application/application-package/utils/read-json-file.util';
|
||||
import { resolvePackageContentDir } from 'src/engine/core-modules/application/application-package/utils/tarball-utils';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { removeFileFolderFromFileEntityPath } from 'src/engine/core-modules/file/utils/remove-file-folder-from-file-entity-path.utils';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { streamToBuffer } from 'src/utils/stream-to-buffer';
|
||||
|
||||
@@ -38,12 +45,16 @@ export type ResolvedPackage = {
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AppPackageFetcherService implements OnModuleInit {
|
||||
private readonly logger = new Logger(AppPackageFetcherService.name);
|
||||
export class ApplicationPackageFetcherService implements OnModuleInit {
|
||||
private readonly logger = new Logger(ApplicationPackageFetcherService.name);
|
||||
|
||||
constructor(
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly fileStorageDriverFactory: FileStorageDriverFactory,
|
||||
private readonly fileStorageService: FileStorageService,
|
||||
@InjectRepository(FileEntity)
|
||||
private readonly fileRepository: Repository<FileEntity>,
|
||||
@InjectRepository(ApplicationEntity)
|
||||
private readonly applicationRepository: Repository<ApplicationEntity>,
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
@@ -54,16 +65,33 @@ export class AppPackageFetcherService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
async resolveNpmPackage(
|
||||
packageName: string,
|
||||
targetVersion?: string,
|
||||
): Promise<ResolvedPackage> {
|
||||
return this.resolveFromNpm(packageName, targetVersion);
|
||||
}
|
||||
|
||||
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:
|
||||
case ApplicationRegistrationSourceType.NPM:
|
||||
if (!appRegistration.sourcePackage) {
|
||||
throw new ApplicationException(
|
||||
`App registration ${appRegistration.id} has sourceType=npm but no sourcePackage`,
|
||||
ApplicationExceptionCode.PACKAGE_RESOLUTION_FAILED,
|
||||
);
|
||||
}
|
||||
|
||||
return this.resolveFromNpm(
|
||||
appRegistration.sourcePackage,
|
||||
options?.targetVersion,
|
||||
);
|
||||
case ApplicationRegistrationSourceType.TARBALL:
|
||||
return this.resolveFromTarball(appRegistration);
|
||||
case AppRegistrationSourceType.LOCAL:
|
||||
case ApplicationRegistrationSourceType.LOCAL:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -77,7 +105,7 @@ export class AppPackageFetcherService implements OnModuleInit {
|
||||
}
|
||||
|
||||
private async resolveFromNpm(
|
||||
appRegistration: ApplicationRegistrationEntity,
|
||||
packageName: string,
|
||||
targetVersion?: string,
|
||||
): Promise<ResolvedPackage> {
|
||||
const workDir = join(APP_FETCHER_TMPDIR, v4());
|
||||
@@ -89,30 +117,21 @@ export class AppPackageFetcherService implements OnModuleInit {
|
||||
|
||||
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);
|
||||
assertValidNpmPackageName(packageName);
|
||||
|
||||
const versionSpec = targetVersion ?? 'latest';
|
||||
|
||||
await this.writeNpmrc({
|
||||
workDir,
|
||||
packageName: sourcePackage,
|
||||
packageName,
|
||||
registryUrl,
|
||||
authToken,
|
||||
});
|
||||
await this.setupYarnEngine(workDir);
|
||||
await this.writeMinimalPackageJson(workDir, sourcePackage, versionSpec);
|
||||
await this.writeMinimalPackageJson(workDir, packageName, versionSpec);
|
||||
await this.runYarnInstall(workDir);
|
||||
|
||||
const packageDir = join(workDir, 'node_modules', sourcePackage);
|
||||
const packageDir = join(workDir, 'node_modules', packageName);
|
||||
const manifest = await readJsonFileOrThrow<Manifest>(
|
||||
packageDir,
|
||||
'manifest.json',
|
||||
@@ -131,7 +150,7 @@ export class AppPackageFetcherService implements OnModuleInit {
|
||||
} catch (error) {
|
||||
await this.cleanupExtractedDir(workDir);
|
||||
throw new ApplicationException(
|
||||
`Failed to resolve npm package ${appRegistration.sourcePackage}: ${error}`,
|
||||
`Failed to resolve npm package ${packageName}: ${error}`,
|
||||
ApplicationExceptionCode.PACKAGE_RESOLUTION_FAILED,
|
||||
);
|
||||
}
|
||||
@@ -140,16 +159,33 @@ export class AppPackageFetcherService implements OnModuleInit {
|
||||
private async resolveFromTarball(
|
||||
appRegistration: ApplicationRegistrationEntity,
|
||||
): Promise<ResolvedPackage> {
|
||||
if (!isDefined(appRegistration.tarballFileId)) {
|
||||
throw new ApplicationException(
|
||||
`App registration ${appRegistration.id} has sourceType=tarball but no tarball file`,
|
||||
ApplicationExceptionCode.TARBALL_EXTRACTION_FAILED,
|
||||
);
|
||||
}
|
||||
|
||||
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 file = await this.fileRepository.findOneOrFail({
|
||||
where: { id: appRegistration.tarballFileId },
|
||||
});
|
||||
|
||||
const application = await this.applicationRepository.findOneOrFail({
|
||||
where: { id: file.applicationId },
|
||||
});
|
||||
|
||||
const tarballStream = await this.fileStorageService.readFile({
|
||||
workspaceId: file.workspaceId,
|
||||
applicationUniversalIdentifier: application.universalIdentifier,
|
||||
fileFolder: FileFolder.AppTarball,
|
||||
resourcePath: removeFileFolderFromFileEntityPath(file.path),
|
||||
});
|
||||
|
||||
const tarballBuffer = await streamToBuffer(tarballStream);
|
||||
const tarballPath = join(workDir, 'app.tar.gz');
|
||||
|
||||
@@ -273,11 +309,19 @@ export class AppPackageFetcherService implements OnModuleInit {
|
||||
},
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
const stderr =
|
||||
isDefined(error) &&
|
||||
typeof error === 'object' &&
|
||||
'stderr' in error &&
|
||||
typeof (error as { stderr: unknown }).stderr === 'string'
|
||||
? (error as { stderr: string }).stderr
|
||||
: undefined;
|
||||
|
||||
const message =
|
||||
stderr ?? (error instanceof Error ? error.message : String(error));
|
||||
|
||||
throw new ApplicationException(
|
||||
`yarn install failed: ${errorMessage}`,
|
||||
`yarn install failed: ${message}`,
|
||||
ApplicationExceptionCode.PACKAGE_RESOLUTION_FAILED,
|
||||
);
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ApplicationPackageFetcherService } from 'src/engine/core-modules/application/application-package/application-package-fetcher.service';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
FileStorageModule,
|
||||
TwentyConfigModule,
|
||||
TypeOrmModule.forFeature([FileEntity, ApplicationEntity]),
|
||||
],
|
||||
providers: [ApplicationPackageFetcherService],
|
||||
exports: [ApplicationPackageFetcherService],
|
||||
})
|
||||
export class ApplicationPackageModule {}
|
||||
+1
-1
@@ -6,6 +6,6 @@ export const SEED_DEPENDENCIES_DIRNAME = path.resolve(
|
||||
__dirname,
|
||||
path.join(
|
||||
ASSET_PATH,
|
||||
'engine/core-modules/application/constants/seed-dependencies',
|
||||
'engine/core-modules/application/application-package/constants/seed-dependencies',
|
||||
),
|
||||
);
|
||||
+1
-1
@@ -6,6 +6,6 @@ export const YARN_ENGINE_DIRNAME = path.resolve(
|
||||
__dirname,
|
||||
path.join(
|
||||
ASSET_PATH,
|
||||
'engine/core-modules/application/constants/yarn-engine',
|
||||
'engine/core-modules/application/application-package/constants/yarn-engine',
|
||||
),
|
||||
);
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { promises as fs, statSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { promisify } from 'util';
|
||||
|
||||
import { YARN_ENGINE_DIRNAME } from 'src/engine/core-modules/application/constants/yarn-engine-dirname';
|
||||
import { YARN_ENGINE_DIRNAME } from 'src/engine/core-modules/application/application-package/constants/yarn-engine-dirname';
|
||||
|
||||
const execFilePromise = promisify(execFile);
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
import { readFile } from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
import { parseAvailablePackagesFromPackageJsonAndYarnLock } from 'src/engine/core-modules/application/utils/parse-available-packages-from-package-json-and-yarn-lock.util';
|
||||
import { SEED_DEPENDENCIES_DIRNAME } from 'src/engine/core-modules/application/constants/seed-dependencies-dirname';
|
||||
import { parseAvailablePackagesFromPackageJsonAndYarnLock } from 'src/engine/core-modules/application/application-package/utils/parse-available-packages-from-package-json-and-yarn-lock.util';
|
||||
import { SEED_DEPENDENCIES_DIRNAME } from 'src/engine/core-modules/application/application-package/constants/seed-dependencies-dirname';
|
||||
|
||||
// To regenerate: use logicFunctionCreateHash from logic-function-create-hash.utils.
|
||||
// package.json: hash(JSON.stringify(JSON.parse(content))). yarn.lock: hash(content).
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity';
|
||||
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service';
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryption/secret-encryption.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
ApplicationRegistrationVariableEntity,
|
||||
ApplicationRegistrationEntity,
|
||||
]),
|
||||
SecretEncryptionModule,
|
||||
],
|
||||
providers: [ApplicationRegistrationVariableService],
|
||||
exports: [ApplicationRegistrationVariableService],
|
||||
})
|
||||
export class ApplicationRegistrationVariableModule {}
|
||||
+3
-3
@@ -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/application-registration/application-registration-variable.entity';
|
||||
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/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/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 { type CreateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application/application-registration-variable/dtos/create-application-registration-variable.input';
|
||||
import { type UpdateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application/application-registration-variable/dtos/update-application-registration-variable.input';
|
||||
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
|
||||
|
||||
@Injectable()
|
||||
+17
-13
@@ -18,10 +18,10 @@ import {
|
||||
} from 'typeorm';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
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 { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity';
|
||||
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
@@ -96,22 +96,22 @@ export class ApplicationRegistrationEntity {
|
||||
@JoinColumn({ name: 'createdByUserId' })
|
||||
createdByUser: Relation<UserEntity> | null;
|
||||
|
||||
// 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;
|
||||
// Ownership (who can edit). Null for catalog-synced marketplace apps that
|
||||
// have no explicit owner — these are managed via the Admin Panel.
|
||||
@Field(() => UUIDScalarType, { nullable: true })
|
||||
@Column({ name: 'workspaceId', nullable: true, type: 'uuid' })
|
||||
ownerWorkspaceId: string | null;
|
||||
|
||||
@ManyToOne(() => WorkspaceEntity, { onDelete: 'CASCADE' })
|
||||
@ManyToOne(() => WorkspaceEntity, { onDelete: 'SET NULL', nullable: true })
|
||||
@JoinColumn({ name: 'workspaceId' })
|
||||
workspace: Relation<WorkspaceEntity>;
|
||||
workspace: Relation<WorkspaceEntity> | null;
|
||||
|
||||
@Field(() => AppRegistrationSourceType)
|
||||
@Field(() => ApplicationRegistrationSourceType)
|
||||
@Column({
|
||||
type: 'text',
|
||||
default: AppRegistrationSourceType.LOCAL,
|
||||
default: ApplicationRegistrationSourceType.LOCAL,
|
||||
})
|
||||
sourceType: AppRegistrationSourceType;
|
||||
sourceType: ApplicationRegistrationSourceType;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@Column({ nullable: true, type: 'text' })
|
||||
@@ -136,6 +136,10 @@ export class ApplicationRegistrationEntity {
|
||||
@Column({ nullable: true, type: 'text' })
|
||||
termsUrl: string | null;
|
||||
|
||||
@Field(() => Boolean)
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isListed: boolean;
|
||||
|
||||
@Field(() => Boolean)
|
||||
@Column({ name: 'isFeatured', type: 'boolean', default: false })
|
||||
isFeatured: boolean;
|
||||
|
||||
+12
-25
@@ -1,25 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
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 { ApplicationRegistrationVariableModule } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.module';
|
||||
import { ApplicationTarballService } from 'src/engine/core-modules/application/application-registration/application-tarball.service';
|
||||
import { ApplicationPackageModule } from 'src/engine/core-modules/application/application-package/application-package.module';
|
||||
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 { FileUrlModule } from 'src/engine/core-modules/file/file-url/file-url.module';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
|
||||
|
||||
@@ -27,32 +20,26 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
ApplicationRegistrationEntity,
|
||||
ApplicationRegistrationVariableEntity,
|
||||
ApplicationEntity,
|
||||
AppTokenEntity,
|
||||
UserWorkspaceEntity,
|
||||
WorkspaceEntity,
|
||||
]),
|
||||
SecretEncryptionModule,
|
||||
ApplicationRegistrationVariableModule,
|
||||
ApplicationPackageModule,
|
||||
ApplicationModule,
|
||||
FeatureFlagModule,
|
||||
PermissionsModule,
|
||||
ThrottlerModule,
|
||||
TokenModule,
|
||||
ApplicationModule,
|
||||
ApplicationInstallModule,
|
||||
FileStorageModule,
|
||||
FileUrlModule,
|
||||
WorkspaceCacheStorageModule,
|
||||
],
|
||||
controllers: [OAuthTokenController, OAuthDiscoveryController],
|
||||
providers: [
|
||||
ApplicationRegistrationService,
|
||||
ApplicationRegistrationVariableService,
|
||||
ApplicationRegistrationResolver,
|
||||
AppTarballUploadService,
|
||||
OAuthService,
|
||||
ApplicationTarballService,
|
||||
],
|
||||
exports: [
|
||||
ApplicationRegistrationService,
|
||||
ApplicationRegistrationVariableService,
|
||||
ApplicationRegistrationVariableModule,
|
||||
],
|
||||
})
|
||||
export class ApplicationRegistrationModule {}
|
||||
|
||||
+116
-19
@@ -3,37 +3,42 @@ 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 { FeatureFlagKey, FileFolder } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
|
||||
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity';
|
||||
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service';
|
||||
import { CreateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application/application-registration-variable/dtos/create-application-registration-variable.input';
|
||||
import { UpdateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application/application-registration-variable/dtos/update-application-registration-variable.input';
|
||||
import { ApplicationPackageFetcherService } from 'src/engine/core-modules/application/application-package/application-package-fetcher.service';
|
||||
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 { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
|
||||
import {
|
||||
ApplicationTarballService,
|
||||
MAX_TARBALL_UPLOAD_SIZE_BYTES,
|
||||
} from 'src/engine/core-modules/application/application-registration/application-tarball.service';
|
||||
import { ApplicationRegistrationStatsDTO } from 'src/engine/core-modules/application/application-registration/dtos/application-registration-stats.dto';
|
||||
import { CreateApplicationRegistrationDTO } from 'src/engine/core-modules/application/application-registration/dtos/create-application-registration.dto';
|
||||
import { CreateApplicationRegistrationInput } from 'src/engine/core-modules/application/application-registration/dtos/create-application-registration.input';
|
||||
import { PublicApplicationRegistrationDTO } from 'src/engine/core-modules/application/application-registration/dtos/public-application-registration.dto';
|
||||
import { RotateClientSecretDTO } from 'src/engine/core-modules/application/application-registration/dtos/rotate-client-secret.dto';
|
||||
import { TransferApplicationRegistrationOwnershipInput } from 'src/engine/core-modules/application/application-registration/dtos/transfer-application-registration-ownership.input';
|
||||
import { UpdateApplicationRegistrationInput } from 'src/engine/core-modules/application/application-registration/dtos/update-application-registration.input';
|
||||
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
|
||||
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
|
||||
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
|
||||
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';
|
||||
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
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 {
|
||||
@@ -57,7 +62,9 @@ export class ApplicationRegistrationResolver {
|
||||
constructor(
|
||||
private readonly applicationRegistrationService: ApplicationRegistrationService,
|
||||
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
|
||||
private readonly appTarballUploadService: AppTarballUploadService,
|
||||
private readonly applicationTarballService: ApplicationTarballService,
|
||||
private readonly applicationPackageFetcherService: ApplicationPackageFetcherService,
|
||||
private readonly fileUrlService: FileUrlService,
|
||||
) {}
|
||||
|
||||
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
|
||||
@@ -255,6 +262,44 @@ export class ApplicationRegistrationResolver {
|
||||
);
|
||||
}
|
||||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.MARKETPLACE_APPS),
|
||||
)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@Mutation(() => ApplicationRegistrationEntity)
|
||||
async registerNpmPackage(
|
||||
@Args('packageName') packageName: string,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
): Promise<ApplicationRegistrationEntity> {
|
||||
const resolvedPackage =
|
||||
await this.applicationPackageFetcherService.resolveNpmPackage(
|
||||
packageName,
|
||||
);
|
||||
|
||||
try {
|
||||
const manifest = resolvedPackage.manifest.application;
|
||||
|
||||
return await this.applicationRegistrationService.createFromNpmPackage({
|
||||
packageName,
|
||||
universalIdentifier: manifest.universalIdentifier,
|
||||
name: manifest.displayName,
|
||||
description: manifest.description,
|
||||
author: manifest.author,
|
||||
logoUrl: manifest.logoUrl,
|
||||
websiteUrl: manifest.websiteUrl,
|
||||
termsUrl: manifest.termsUrl,
|
||||
version: resolvedPackage.packageJson.version as string | undefined,
|
||||
ownerWorkspaceId: workspaceId,
|
||||
});
|
||||
} finally {
|
||||
await this.applicationPackageFetcherService.cleanupExtractedDir(
|
||||
resolvedPackage.cleanupDir,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
@@ -279,10 +324,62 @@ export class ApplicationRegistrationResolver {
|
||||
);
|
||||
}
|
||||
|
||||
return this.appTarballUploadService.uploadTarball({
|
||||
return this.applicationTarballService.uploadTarball({
|
||||
tarballBuffer,
|
||||
universalIdentifier,
|
||||
ownerWorkspaceId: workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
|
||||
)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@Query(() => String, { nullable: true })
|
||||
async applicationRegistrationTarballUrl(
|
||||
@Args('id') id: string,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
): Promise<string | null> {
|
||||
const registration = await this.applicationRegistrationService.findOneById(
|
||||
id,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (
|
||||
registration.sourceType !== ApplicationRegistrationSourceType.TARBALL ||
|
||||
!isDefined(registration.tarballFileId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.fileUrlService.signFileByIdUrl({
|
||||
fileId: registration.tarballFileId,
|
||||
workspaceId,
|
||||
fileFolder: FileFolder.AppTarball,
|
||||
});
|
||||
}
|
||||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.APPLICATIONS),
|
||||
)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@Mutation(() => ApplicationRegistrationEntity)
|
||||
async transferApplicationRegistrationOwnership(
|
||||
@Args()
|
||||
{
|
||||
applicationRegistrationId,
|
||||
targetWorkspaceSubdomain,
|
||||
}: TransferApplicationRegistrationOwnershipInput,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
): Promise<ApplicationRegistrationEntity> {
|
||||
return this.applicationRegistrationService.transferOwnership({
|
||||
applicationRegistrationId,
|
||||
targetWorkspaceSubdomain,
|
||||
currentOwnerWorkspaceId: workspaceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+203
-4
@@ -5,22 +5,23 @@ import crypto from 'crypto';
|
||||
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type Repository } from 'typeorm';
|
||||
import { IsNull, type Repository } from 'typeorm';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { ALL_OAUTH_SCOPES } from 'src/engine/core-modules/application/application-oauth/constants/oauth-scopes';
|
||||
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 { 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 { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import { validateRedirectUri } from 'src/engine/core-modules/auth/utils/validate-redirect-uri.util';
|
||||
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
const BCRYPT_SALT_ROUNDS = 10;
|
||||
|
||||
@Injectable()
|
||||
@@ -30,6 +31,8 @@ export class ApplicationRegistrationService {
|
||||
private readonly applicationRegistrationRepository: Repository<ApplicationRegistrationEntity>,
|
||||
@InjectRepository(ApplicationEntity)
|
||||
private readonly applicationRepository: Repository<ApplicationEntity>,
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
private readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
) {}
|
||||
|
||||
async findMany(
|
||||
@@ -41,12 +44,36 @@ export class ApplicationRegistrationService {
|
||||
});
|
||||
}
|
||||
|
||||
async findAll(): Promise<ApplicationRegistrationEntity[]> {
|
||||
return this.applicationRegistrationRepository.find({
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOneById(
|
||||
id: string,
|
||||
ownerWorkspaceId: string,
|
||||
): Promise<ApplicationRegistrationEntity> {
|
||||
const registration = await this.applicationRegistrationRepository.findOne({
|
||||
where: { id, ownerWorkspaceId },
|
||||
where: [
|
||||
{ id, ownerWorkspaceId },
|
||||
{ id, ownerWorkspaceId: IsNull() },
|
||||
],
|
||||
});
|
||||
|
||||
if (!registration) {
|
||||
throw new ApplicationRegistrationException(
|
||||
`Application registration with id ${id} not found`,
|
||||
ApplicationRegistrationExceptionCode.APPLICATION_REGISTRATION_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return registration;
|
||||
}
|
||||
|
||||
async findOneByIdGlobal(id: string): Promise<ApplicationRegistrationEntity> {
|
||||
const registration = await this.applicationRegistrationRepository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!registration) {
|
||||
@@ -193,6 +220,7 @@ export class ApplicationRegistrationService {
|
||||
updateData.oAuthScopes = update.oAuthScopes;
|
||||
if (isDefined(update.websiteUrl)) updateData.websiteUrl = update.websiteUrl;
|
||||
if (isDefined(update.termsUrl)) updateData.termsUrl = update.termsUrl;
|
||||
if (isDefined(update.isListed)) updateData.isListed = update.isListed;
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await this.applicationRegistrationRepository.update(id, updateData);
|
||||
@@ -235,6 +263,140 @@ export class ApplicationRegistrationService {
|
||||
return bcrypt.compare(clientSecret, registration.oAuthClientSecretHash);
|
||||
}
|
||||
|
||||
async upsertFromCatalog(
|
||||
params: Pick<
|
||||
ApplicationRegistrationEntity,
|
||||
| 'universalIdentifier'
|
||||
| 'name'
|
||||
| 'description'
|
||||
| 'author'
|
||||
| 'sourceType'
|
||||
| 'sourcePackage'
|
||||
| 'logoUrl'
|
||||
| 'websiteUrl'
|
||||
| 'termsUrl'
|
||||
| 'latestAvailableVersion'
|
||||
| 'isListed'
|
||||
| 'isFeatured'
|
||||
| 'marketplaceDisplayData'
|
||||
| 'ownerWorkspaceId'
|
||||
>,
|
||||
): Promise<void> {
|
||||
const existing = await this.findOneByUniversalIdentifier(
|
||||
params.universalIdentifier,
|
||||
);
|
||||
|
||||
if (isDefined(existing)) {
|
||||
await this.applicationRegistrationRepository.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,
|
||||
marketplaceDisplayData: params.marketplaceDisplayData,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const registration = this.applicationRegistrationRepository.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,
|
||||
isListed: params.isListed,
|
||||
isFeatured: params.isFeatured,
|
||||
marketplaceDisplayData: params.marketplaceDisplayData,
|
||||
oAuthClientId: v4(),
|
||||
oAuthRedirectUris: [],
|
||||
oAuthScopes: [],
|
||||
ownerWorkspaceId: params.ownerWorkspaceId,
|
||||
});
|
||||
|
||||
await this.applicationRegistrationRepository.save(registration);
|
||||
}
|
||||
|
||||
async createFromNpmPackage(params: {
|
||||
packageName: string;
|
||||
universalIdentifier: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
logoUrl?: string;
|
||||
websiteUrl?: string;
|
||||
termsUrl?: string;
|
||||
version?: string;
|
||||
ownerWorkspaceId: string;
|
||||
}): Promise<ApplicationRegistrationEntity> {
|
||||
const existingByUid = await this.findOneByUniversalIdentifier(
|
||||
params.universalIdentifier,
|
||||
);
|
||||
|
||||
if (isDefined(existingByUid)) {
|
||||
throw new ApplicationRegistrationException(
|
||||
`An app with universalIdentifier "${params.universalIdentifier}" is already registered`,
|
||||
ApplicationRegistrationExceptionCode.UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED,
|
||||
);
|
||||
}
|
||||
|
||||
const existingByPackage =
|
||||
await this.applicationRegistrationRepository.findOne({
|
||||
where: { sourcePackage: params.packageName },
|
||||
});
|
||||
|
||||
if (isDefined(existingByPackage)) {
|
||||
throw new ApplicationRegistrationException(
|
||||
`Package "${params.packageName}" is already registered`,
|
||||
ApplicationRegistrationExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
const registration = this.applicationRegistrationRepository.create({
|
||||
universalIdentifier: params.universalIdentifier,
|
||||
name: params.name,
|
||||
description: params.description ?? null,
|
||||
author: params.author ?? null,
|
||||
logoUrl: params.logoUrl ?? null,
|
||||
websiteUrl: params.websiteUrl ?? null,
|
||||
termsUrl: params.termsUrl ?? null,
|
||||
latestAvailableVersion: params.version ?? null,
|
||||
sourceType: ApplicationRegistrationSourceType.NPM,
|
||||
sourcePackage: params.packageName,
|
||||
isListed: true,
|
||||
oAuthClientId: v4(),
|
||||
oAuthRedirectUris: [],
|
||||
oAuthScopes: [],
|
||||
ownerWorkspaceId: params.ownerWorkspaceId,
|
||||
});
|
||||
|
||||
return this.applicationRegistrationRepository.save(registration);
|
||||
}
|
||||
|
||||
async findManyBySourceType(
|
||||
sourceType: ApplicationRegistrationSourceType,
|
||||
): Promise<ApplicationRegistrationEntity[]> {
|
||||
return this.applicationRegistrationRepository.find({
|
||||
where: { sourceType },
|
||||
});
|
||||
}
|
||||
|
||||
async findManyListed(): Promise<ApplicationRegistrationEntity[]> {
|
||||
return this.applicationRegistrationRepository.find({
|
||||
where: { isListed: true },
|
||||
});
|
||||
}
|
||||
|
||||
async getStats(
|
||||
applicationRegistrationId: string,
|
||||
ownerWorkspaceId: string,
|
||||
@@ -269,6 +431,43 @@ export class ApplicationRegistrationService {
|
||||
};
|
||||
}
|
||||
|
||||
async transferOwnership(params: {
|
||||
applicationRegistrationId: string;
|
||||
targetWorkspaceSubdomain: string;
|
||||
currentOwnerWorkspaceId: string;
|
||||
}): Promise<ApplicationRegistrationEntity> {
|
||||
const registration = await this.findOneById(
|
||||
params.applicationRegistrationId,
|
||||
params.currentOwnerWorkspaceId,
|
||||
);
|
||||
|
||||
const targetWorkspace = await this.workspaceRepository.findOne({
|
||||
where: { subdomain: params.targetWorkspaceSubdomain },
|
||||
});
|
||||
|
||||
if (!isDefined(targetWorkspace)) {
|
||||
throw new ApplicationRegistrationException(
|
||||
`No workspace found with subdomain "${params.targetWorkspaceSubdomain}"`,
|
||||
ApplicationRegistrationExceptionCode.APPLICATION_REGISTRATION_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
if (targetWorkspace.id === params.currentOwnerWorkspaceId) {
|
||||
throw new ApplicationRegistrationException(
|
||||
'Cannot transfer ownership to the same workspace',
|
||||
ApplicationRegistrationExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
await this.applicationRegistrationRepository.update(registration.id, {
|
||||
ownerWorkspaceId: targetWorkspace.id,
|
||||
});
|
||||
|
||||
return this.applicationRegistrationRepository.findOneOrFail({
|
||||
where: { id: registration.id },
|
||||
});
|
||||
}
|
||||
|
||||
private async generateClientSecret(): Promise<{
|
||||
clientSecret: string;
|
||||
clientSecretHash: string;
|
||||
|
||||
+33
-17
@@ -5,31 +5,34 @@ import { promises as fs } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
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 {
|
||||
ApplicationRegistrationException,
|
||||
ApplicationRegistrationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application-registration/application-registration.exception';
|
||||
import { extractTarballSecurely } from 'src/engine/core-modules/application/utils/extract-tarball-securely.util';
|
||||
import { resolvePackageContentDir } from 'src/engine/core-modules/application/utils/tarball-utils';
|
||||
import { readJsonFile } from 'src/engine/core-modules/application/utils/read-json-file.util';
|
||||
import { FileStorageDriverFactory } from 'src/engine/core-modules/file-storage/file-storage-driver.factory';
|
||||
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
|
||||
import { extractTarballSecurely } from 'src/engine/core-modules/application/application-package/utils/extract-tarball-securely.util';
|
||||
import { readJsonFile } from 'src/engine/core-modules/application/application-package/utils/read-json-file.util';
|
||||
import { resolvePackageContentDir } from 'src/engine/core-modules/application/application-package/utils/tarball-utils';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
|
||||
export const MAX_TARBALL_UPLOAD_SIZE_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
@Injectable()
|
||||
export class AppTarballUploadService {
|
||||
private readonly logger = new Logger(AppTarballUploadService.name);
|
||||
export class ApplicationTarballService {
|
||||
private readonly logger = new Logger(ApplicationTarballService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ApplicationRegistrationEntity)
|
||||
private readonly appRegistrationRepository: Repository<ApplicationRegistrationEntity>,
|
||||
private readonly fileStorageDriverFactory: FileStorageDriverFactory,
|
||||
private readonly fileStorageService: FileStorageService,
|
||||
private readonly applicationService: ApplicationService,
|
||||
) {}
|
||||
|
||||
async uploadTarball(params: {
|
||||
@@ -86,8 +89,10 @@ export class AppTarballUploadService {
|
||||
|
||||
if (isDefined(appRegistration)) {
|
||||
if (
|
||||
appRegistration.sourceType !== AppRegistrationSourceType.LOCAL &&
|
||||
appRegistration.sourceType !== AppRegistrationSourceType.TARBALL
|
||||
appRegistration.sourceType !==
|
||||
ApplicationRegistrationSourceType.LOCAL &&
|
||||
appRegistration.sourceType !==
|
||||
ApplicationRegistrationSourceType.TARBALL
|
||||
) {
|
||||
throw new ApplicationRegistrationException(
|
||||
`This app is registered as ${appRegistration.sourceType}. Cannot upload tarball.`,
|
||||
@@ -98,7 +103,7 @@ export class AppTarballUploadService {
|
||||
appRegistration = this.appRegistrationRepository.create({
|
||||
universalIdentifier,
|
||||
name: manifest.application?.displayName ?? 'Unknown App',
|
||||
sourceType: AppRegistrationSourceType.TARBALL,
|
||||
sourceType: ApplicationRegistrationSourceType.TARBALL,
|
||||
oAuthClientId: v4(),
|
||||
oAuthRedirectUris: [],
|
||||
oAuthScopes: [],
|
||||
@@ -109,18 +114,29 @@ export class AppTarballUploadService {
|
||||
await this.appRegistrationRepository.save(appRegistration);
|
||||
}
|
||||
|
||||
const storagePath = join('app-tarball', appRegistration.id);
|
||||
const { workspaceCustomFlatApplication } =
|
||||
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
|
||||
{ workspaceId: params.ownerWorkspaceId },
|
||||
);
|
||||
|
||||
const driver = this.fileStorageDriverFactory.getCurrentDriver();
|
||||
|
||||
await driver.writeFile({
|
||||
filePath: join(storagePath, 'app.tar.gz'),
|
||||
const savedFile = await this.fileStorageService.writeFile({
|
||||
sourceFile: params.tarballBuffer,
|
||||
resourcePath: `${appRegistration.id}/app.tar.gz`,
|
||||
mimeType: 'application/gzip',
|
||||
fileFolder: FileFolder.AppTarball,
|
||||
applicationUniversalIdentifier:
|
||||
workspaceCustomFlatApplication.universalIdentifier,
|
||||
workspaceId: params.ownerWorkspaceId,
|
||||
fileId: appRegistration.tarballFileId ?? v4(),
|
||||
settings: {
|
||||
isTemporaryFile: false,
|
||||
toDelete: false,
|
||||
},
|
||||
});
|
||||
|
||||
await this.appRegistrationRepository.update(appRegistration.id, {
|
||||
sourceType: AppRegistrationSourceType.TARBALL,
|
||||
sourceType: ApplicationRegistrationSourceType.TARBALL,
|
||||
tarballFileId: savedFile.id,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
+3
-3
@@ -3,7 +3,7 @@ import { Field, ObjectType } from '@nestjs/graphql';
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { AppRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/app-registration-source-type.enum';
|
||||
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
|
||||
|
||||
@ObjectType('ApplicationRegistrationSummary')
|
||||
export class ApplicationRegistrationSummaryDTO {
|
||||
@@ -15,6 +15,6 @@ export class ApplicationRegistrationSummaryDTO {
|
||||
@Field({ nullable: true })
|
||||
latestAvailableVersion?: string;
|
||||
|
||||
@Field(() => AppRegistrationSourceType)
|
||||
sourceType: AppRegistrationSourceType;
|
||||
@Field(() => ApplicationRegistrationSourceType)
|
||||
sourceType: ApplicationRegistrationSourceType;
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { ArgsType, Field } from '@nestjs/graphql';
|
||||
|
||||
import { IsNotEmpty, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
@ArgsType()
|
||||
export class TransferApplicationRegistrationOwnershipInput {
|
||||
@Field(() => String)
|
||||
@IsUUID()
|
||||
applicationRegistrationId: string;
|
||||
|
||||
@Field(() => String)
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
targetWorkspaceSubdomain: string;
|
||||
}
|
||||
+6
@@ -4,6 +4,7 @@ import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
@@ -65,6 +66,11 @@ export class UpdateApplicationRegistrationPayload {
|
||||
@MaxLength(2048)
|
||||
@IsOptional()
|
||||
termsUrl?: string;
|
||||
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isListed?: boolean;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user