20215 convert application variable to a syncable entity (#20269)
## Summary - Converts applicationVariable from a bespoke sync path to a proper SyncableEntity, unifying it with the workspace migration pipeline used by all other manifest-managed entities (agent, skill, frontComponent, webhook, etc.) - Removes the upsertManyApplicationVariableEntities method and its direct-DB-mutation approach in favor of the standard validate → build → run action handler pipeline - Adds universalIdentifier, deletedAt columns and makes applicationId NOT NULL via an instance command migration ## Motivation Before this change, applicationVariable was the only manifest-managed entity that bypassed ApplicationManifestMigrationService.syncMetadataFromManifest(). It used a bespoke service method called directly from syncApplication(), creating two mental models, two validation styles, and two cache invalidation patterns. Now there's one unified pipeline for all manifest entities. ## What changed ### Entity refactor: - ApplicationVariableEntity now extends SyncableEntity (gains universalIdentifier, non-nullable applicationId with CASCADE, soft-delete via deletedAt) ### New flat entity layer (flat-application-variable/): - Type, maps type, editable properties constant, entity-to-flat converter, cache service, module ### New migration pipeline wiring: - Manifest converter (fromApplicationVariableManifestToUniversalFlatApplicationVariable) - Validator service (FlatApplicationVariableValidatorService) - Builder service (WorkspaceMigrationApplicationVariableActionsBuilderService) - Create/Update/Delete action handlers with secret encryption hooks - Registered in orchestrator, builder module, runner module, and all type registries ### Removed bespoke path: - Deleted upsertManyApplicationVariableEntities from ApplicationVariableEntityService - Removed its call from ApplicationSyncService.syncApplication() - Kept update() (operator-set value at runtime) and getDisplayValue() (runtime display) ### Database migration: - Instance command to add columns, backfill universalIdentifier, enforce NOT NULL constraints, and update indexes ## Test plan - npx nx typecheck twenty-server passes (0 errors) - Unit tests pass (application-variable.service.spec.ts, build-env-var.spec.ts) - Install an app with applicationVariables in its manifest → variables appear with correct universalIdentifier - Update app manifest (add/remove/modify a variable) → migration pipeline handles diff correctly - Operator-set value via update endpoint persists correctly with encryption - Uninstall app → variables cascade-deleted - app dev --once on example app syncs without errors
This commit is contained in:
@@ -11,10 +11,6 @@ import { CreateApiKeyInput } from 'src/engine/core-modules/api-key/dtos/create-a
|
||||
import { GetApiKeyInput } from 'src/engine/core-modules/api-key/dtos/get-api-key.input';
|
||||
import { RevokeApiKeyInput } from 'src/engine/core-modules/api-key/dtos/revoke-api-key.input';
|
||||
import { UpdateApiKeyInput } from 'src/engine/core-modules/api-key/dtos/update-api-key.input';
|
||||
import {
|
||||
ApiKeyException,
|
||||
ApiKeyExceptionCode,
|
||||
} from 'src/engine/core-modules/api-key/exceptions/api-key.exception';
|
||||
import { apiKeyGraphqlApiExceptionHandler } from 'src/engine/core-modules/api-key/utils/api-key-graphql-api-exception-handler.util';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
|
||||
-10
@@ -16,7 +16,6 @@ import { ApplicationService } from 'src/engine/core-modules/application/applicat
|
||||
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
|
||||
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';
|
||||
import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util';
|
||||
@@ -32,7 +31,6 @@ export class ApplicationSyncService {
|
||||
|
||||
constructor(
|
||||
private readonly applicationService: ApplicationService,
|
||||
private readonly applicationVariableService: ApplicationVariableEntityService,
|
||||
private readonly applicationManifestMigrationService: ApplicationManifestMigrationService,
|
||||
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
@@ -145,14 +143,6 @@ export class ApplicationSyncService {
|
||||
);
|
||||
}
|
||||
|
||||
await this.applicationVariableService.upsertManyApplicationVariableEntities(
|
||||
{
|
||||
applicationVariables: manifest.application.applicationVariables,
|
||||
applicationId: application.id,
|
||||
workspaceId,
|
||||
},
|
||||
);
|
||||
|
||||
const resolvedRegistrationId =
|
||||
applicationRegistrationId ?? application.applicationRegistrationId;
|
||||
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { type UniversalFlatApplicationVariable } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-application-variable.type';
|
||||
|
||||
export const fromApplicationVariableManifestToUniversalFlatApplicationVariable =
|
||||
({
|
||||
key,
|
||||
universalIdentifier,
|
||||
description,
|
||||
value,
|
||||
isSecret,
|
||||
applicationUniversalIdentifier,
|
||||
now,
|
||||
}: {
|
||||
key: string;
|
||||
universalIdentifier: string;
|
||||
description?: string;
|
||||
value?: string;
|
||||
isSecret?: boolean;
|
||||
applicationUniversalIdentifier: string;
|
||||
now: string;
|
||||
}): UniversalFlatApplicationVariable => {
|
||||
return {
|
||||
universalIdentifier,
|
||||
applicationUniversalIdentifier,
|
||||
key,
|
||||
value: isSecret ? '' : (value ?? ''), // We protect secret variable by not syncing its value at all
|
||||
description: description ?? '',
|
||||
isSecret: isSecret ?? false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
};
|
||||
+23
@@ -4,6 +4,7 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { generateIndexForFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/generate-index-for-flat-field-metadata.util';
|
||||
|
||||
import { fromApplicationVariableManifestToUniversalFlatApplicationVariable } from 'src/engine/core-modules/application/application-manifest/converters/from-application-variable-manifest-to-universal-flat-application-variable.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 { fromConnectionProviderManifestToUniversalFlatConnectionProvider } from 'src/engine/core-modules/application/application-manifest/converters/from-connection-provider-manifest-to-universal-flat-connection-provider.util';
|
||||
import { fromFieldManifestToUniversalFlatFieldMetadata } from 'src/engine/core-modules/application/application-manifest/converters/from-field-manifest-to-universal-flat-field-metadata.util';
|
||||
@@ -431,6 +432,28 @@ export const computeApplicationManifestAllUniversalFlatEntityMaps = ({
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, applicationVariableManifest] of Object.entries(
|
||||
manifest.application.applicationVariables ?? {},
|
||||
)) {
|
||||
addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({
|
||||
universalFlatEntity:
|
||||
fromApplicationVariableManifestToUniversalFlatApplicationVariable({
|
||||
key,
|
||||
universalIdentifier: applicationVariableManifest.universalIdentifier,
|
||||
value:
|
||||
'value' in applicationVariableManifest
|
||||
? applicationVariableManifest.value
|
||||
: undefined,
|
||||
description: applicationVariableManifest.description,
|
||||
isSecret: applicationVariableManifest.isSecret,
|
||||
applicationUniversalIdentifier,
|
||||
now,
|
||||
}),
|
||||
universalFlatEntityMapsToMutate:
|
||||
allUniversalFlatEntityMaps.flatApplicationVariableMaps,
|
||||
});
|
||||
}
|
||||
|
||||
for (const commandMenuItemManifest of manifest.commandMenuItems ?? []) {
|
||||
if (!isDefined(commandMenuItemManifest.frontComponentUniversalIdentifier)) {
|
||||
throw new Error(
|
||||
|
||||
-1
@@ -32,7 +32,6 @@ import { RotateClientSecretDTO } from 'src/engine/core-modules/application/appli
|
||||
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 { DomainServerConfigService } from 'src/engine/core-modules/domain/domain-server-config/services/domain-server-config.service';
|
||||
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';
|
||||
|
||||
-214
@@ -159,220 +159,6 @@ describe('ApplicationVariableEntityService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsertManyApplicationVariableEntities', () => {
|
||||
it('should encrypt secret values when creating new variables', async () => {
|
||||
repository.find.mockResolvedValue([]);
|
||||
repository.save.mockResolvedValue({} as any);
|
||||
repository.delete.mockResolvedValue({ affected: 0 } as any);
|
||||
|
||||
await service.upsertManyApplicationVariableEntities({
|
||||
applicationVariables: {
|
||||
SECRET_KEY: {
|
||||
universalIdentifier: 'secret-key-123',
|
||||
value: 'my-secret',
|
||||
description: 'A secret key',
|
||||
isSecret: true,
|
||||
},
|
||||
},
|
||||
applicationId: mockApplicationId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
});
|
||||
|
||||
expect(secretEncryptionService.encrypt).toHaveBeenCalledWith('my-secret');
|
||||
expect(repository.save).toHaveBeenCalledWith([
|
||||
{
|
||||
key: 'SECRET_KEY',
|
||||
value: 'encrypted_my-secret',
|
||||
description: 'A secret key',
|
||||
isSecret: true,
|
||||
applicationId: mockApplicationId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not encrypt non-secret values when creating new variables', async () => {
|
||||
repository.find.mockResolvedValue([]);
|
||||
repository.save.mockResolvedValue({} as any);
|
||||
repository.delete.mockResolvedValue({ affected: 0 } as any);
|
||||
|
||||
await service.upsertManyApplicationVariableEntities({
|
||||
applicationVariables: {
|
||||
PUBLIC_URL: {
|
||||
universalIdentifier: 'public-url-123',
|
||||
value: 'https://example.com',
|
||||
description: 'Public URL',
|
||||
isSecret: false,
|
||||
},
|
||||
},
|
||||
applicationId: mockApplicationId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
});
|
||||
|
||||
expect(secretEncryptionService.encrypt).not.toHaveBeenCalled();
|
||||
expect(repository.save).toHaveBeenCalledWith([
|
||||
{
|
||||
key: 'PUBLIC_URL',
|
||||
value: 'https://example.com',
|
||||
description: 'Public URL',
|
||||
isSecret: false,
|
||||
applicationId: mockApplicationId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle undefined isSecret as false', async () => {
|
||||
repository.find.mockResolvedValue([]);
|
||||
repository.save.mockResolvedValue({} as any);
|
||||
repository.delete.mockResolvedValue({ affected: 0 } as any);
|
||||
|
||||
await service.upsertManyApplicationVariableEntities({
|
||||
applicationVariables: {
|
||||
SOME_VAR: {
|
||||
universalIdentifier: 'some-var-123',
|
||||
value: 'some-value',
|
||||
description: 'Some variable',
|
||||
},
|
||||
},
|
||||
applicationId: mockApplicationId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
});
|
||||
|
||||
expect(secretEncryptionService.encrypt).not.toHaveBeenCalled();
|
||||
expect(repository.save).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
isSecret: false,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should update existing variables without changing values', async () => {
|
||||
const existingVariable = {
|
||||
id: '1',
|
||||
key: 'EXISTING_VAR',
|
||||
value: 'existing-encrypted-value',
|
||||
isSecret: true,
|
||||
applicationId: mockApplicationId,
|
||||
} as ApplicationVariableEntity;
|
||||
|
||||
repository.find.mockResolvedValue([existingVariable]);
|
||||
repository.save.mockResolvedValue({} as any);
|
||||
repository.delete.mockResolvedValue({ affected: 0 } as any);
|
||||
|
||||
await service.upsertManyApplicationVariableEntities({
|
||||
applicationVariables: {
|
||||
EXISTING_VAR: {
|
||||
universalIdentifier: 'existing-var-123',
|
||||
value: 'new-value',
|
||||
description: 'Updated description',
|
||||
isSecret: true,
|
||||
},
|
||||
},
|
||||
applicationId: mockApplicationId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
});
|
||||
|
||||
expect(repository.save).toHaveBeenCalledWith([
|
||||
{
|
||||
id: '1',
|
||||
description: 'Updated description',
|
||||
isSecret: true,
|
||||
},
|
||||
]);
|
||||
expect(repository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update existing value when shouldUpdateValue is true', async () => {
|
||||
const existingVariable = {
|
||||
id: '1',
|
||||
key: 'EXISTING_VAR',
|
||||
value: 'existing-encrypted-value',
|
||||
isSecret: true,
|
||||
applicationId: mockApplicationId,
|
||||
} as ApplicationVariableEntity;
|
||||
|
||||
repository.find.mockResolvedValue([existingVariable]);
|
||||
repository.save.mockResolvedValue({} as any);
|
||||
repository.delete.mockResolvedValue({ affected: 0 } as any);
|
||||
|
||||
await service.upsertManyApplicationVariableEntities({
|
||||
applicationVariables: {
|
||||
EXISTING_VAR: {
|
||||
universalIdentifier: 'existing-var-123',
|
||||
value: 'new-value',
|
||||
description: 'Updated description',
|
||||
isSecret: true,
|
||||
},
|
||||
},
|
||||
applicationId: mockApplicationId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
shouldUpdateValue: true,
|
||||
});
|
||||
|
||||
expect(repository.save).toHaveBeenCalledWith([
|
||||
{
|
||||
id: '1',
|
||||
description: 'Updated description',
|
||||
value: 'encrypted_new-value',
|
||||
isSecret: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should update existing value if isSecret changes', async () => {
|
||||
const existingVariable = {
|
||||
id: '1',
|
||||
key: 'EXISTING_VAR',
|
||||
value: 'existing-encrypted-value',
|
||||
isSecret: true,
|
||||
applicationId: mockApplicationId,
|
||||
} as ApplicationVariableEntity;
|
||||
|
||||
repository.find.mockResolvedValue([existingVariable]);
|
||||
repository.save.mockResolvedValue({} as any);
|
||||
repository.delete.mockResolvedValue({ affected: 0 } as any);
|
||||
|
||||
await service.upsertManyApplicationVariableEntities({
|
||||
applicationVariables: {
|
||||
EXISTING_VAR: {
|
||||
universalIdentifier: 'existing-var-123',
|
||||
value: 'new-value',
|
||||
description: 'Updated description',
|
||||
isSecret: false,
|
||||
},
|
||||
},
|
||||
applicationId: mockApplicationId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
});
|
||||
|
||||
expect(repository.save).toHaveBeenCalledWith([
|
||||
{
|
||||
id: '1',
|
||||
description: 'Updated description',
|
||||
value: 'new-value',
|
||||
isSecret: false,
|
||||
},
|
||||
]);
|
||||
expect(repository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle undefined applicationVariables', async () => {
|
||||
await service.upsertManyApplicationVariableEntities({
|
||||
applicationVariables: undefined,
|
||||
applicationId: mockApplicationId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
});
|
||||
|
||||
expect(repository.find).not.toHaveBeenCalled();
|
||||
expect(repository.save).not.toHaveBeenCalled();
|
||||
expect(repository.update).not.toHaveBeenCalled();
|
||||
expect(
|
||||
workspaceCacheService.invalidateAndRecompute,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDisplayValue', () => {
|
||||
it('should return plain value for non-secret variables', () => {
|
||||
const variable = {
|
||||
|
||||
+6
-1
@@ -6,7 +6,10 @@ import {
|
||||
ApplicationVariableEntityException,
|
||||
ApplicationVariableEntityExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application-variable/application-variable.exception';
|
||||
import { NotFoundError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
import {
|
||||
NotFoundError,
|
||||
UserInputError,
|
||||
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
|
||||
@Catch(ApplicationVariableEntityException)
|
||||
export class ApplicationVariableEntityExceptionFilter
|
||||
@@ -16,6 +19,8 @@ export class ApplicationVariableEntityExceptionFilter
|
||||
switch (exception.code) {
|
||||
case ApplicationVariableEntityExceptionCode.APPLICATION_VARIABLE_NOT_FOUND:
|
||||
throw new NotFoundError(exception);
|
||||
case ApplicationVariableEntityExceptionCode.INVALID_APPLICATION_VARIABLE_INPUT:
|
||||
throw new UserInputError(exception);
|
||||
default:
|
||||
assertUnreachable(exception.code);
|
||||
}
|
||||
|
||||
+2
-34
@@ -5,41 +5,23 @@ import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
Unique,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import type { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { EntityRelation } from 'src/engine/workspace-manager/workspace-migration/types/entity-relation.interface';
|
||||
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
|
||||
|
||||
@Entity({
|
||||
name: 'applicationVariable',
|
||||
schema: 'core',
|
||||
})
|
||||
@ObjectType('ApplicationVariable')
|
||||
@Unique('IDX_APPLICATION_VARIABLE_KEY_APPLICATION_ID_UNIQUE', [
|
||||
'key',
|
||||
'applicationId',
|
||||
])
|
||||
export class ApplicationVariableEntity {
|
||||
export class ApplicationVariableEntity extends SyncableEntity {
|
||||
@IDField(() => UUIDScalarType)
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ nullable: false, type: 'uuid' })
|
||||
@Index()
|
||||
workspaceId: string;
|
||||
|
||||
@ManyToOne('WorkspaceEntity', { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'workspaceId' })
|
||||
workspace: EntityRelation<WorkspaceEntity>;
|
||||
|
||||
@Column({ nullable: false, type: 'text' })
|
||||
key: string;
|
||||
|
||||
@@ -52,20 +34,6 @@ export class ApplicationVariableEntity {
|
||||
@Column({ nullable: false, type: 'boolean', default: false })
|
||||
isSecret: boolean;
|
||||
|
||||
@Column({ nullable: true, type: 'uuid' })
|
||||
applicationId?: string;
|
||||
|
||||
@ManyToOne(
|
||||
() => ApplicationEntity,
|
||||
(application) => application.applicationVariables,
|
||||
{
|
||||
onDelete: 'CASCADE',
|
||||
nullable: true,
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: 'applicationId' })
|
||||
application: EntityRelation<ApplicationEntity> | null;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
|
||||
+3
@@ -6,6 +6,7 @@ import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export enum ApplicationVariableEntityExceptionCode {
|
||||
APPLICATION_VARIABLE_NOT_FOUND = 'APPLICATION_VARIABLE_NOT_FOUND',
|
||||
INVALID_APPLICATION_VARIABLE_INPUT = 'INVALID_APPLICATION_VARIABLE_INPUT',
|
||||
}
|
||||
|
||||
const getApplicationVariableEntityExceptionUserFriendlyMessage = (
|
||||
@@ -14,6 +15,8 @@ const getApplicationVariableEntityExceptionUserFriendlyMessage = (
|
||||
switch (code) {
|
||||
case ApplicationVariableEntityExceptionCode.APPLICATION_VARIABLE_NOT_FOUND:
|
||||
return msg`Application variable not found.`;
|
||||
case ApplicationVariableEntityExceptionCode.INVALID_APPLICATION_VARIABLE_INPUT:
|
||||
return msg`Invalid application variable input.`;
|
||||
default:
|
||||
assertUnreachable(code);
|
||||
}
|
||||
|
||||
+4
-1
@@ -3,21 +3,24 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
|
||||
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import { ApplicationVariableEntity } from 'src/engine/core-modules/application/application-variable/application-variable.entity';
|
||||
import { ApplicationVariableEntityResolver } from 'src/engine/core-modules/application/application-variable/application-variable.resolver';
|
||||
import { ApplicationVariableEntityService } from 'src/engine/core-modules/application/application-variable/application-variable.service';
|
||||
import { WorkspaceApplicationVariableMapCacheService } from 'src/engine/core-modules/application/application-variable/workspace-application-variable-map-cache.service';
|
||||
import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryption/secret-encryption.module';
|
||||
import { FlatApplicationVariableModule } from 'src/engine/metadata-modules/flat-application-variable/flat-application-variable.module';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
NestjsQueryTypeOrmModule.forFeature([ApplicationVariableEntity]),
|
||||
TypeOrmModule.forFeature([ApplicationVariableEntity]),
|
||||
TypeOrmModule.forFeature([ApplicationVariableEntity, ApplicationEntity]),
|
||||
PermissionsModule,
|
||||
WorkspaceCacheModule,
|
||||
SecretEncryptionModule,
|
||||
FlatApplicationVariableModule,
|
||||
],
|
||||
providers: [
|
||||
ApplicationVariableEntityService,
|
||||
|
||||
+4
-131
@@ -1,9 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { ApplicationVariables } from 'twenty-shared/application';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { In, Not, Repository } from 'typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { ApplicationVariableEntity } from 'src/engine/core-modules/application/application-variable/application-variable.entity';
|
||||
import {
|
||||
@@ -23,14 +22,6 @@ export class ApplicationVariableEntityService {
|
||||
private readonly secretEncryptionService: SecretEncryptionService,
|
||||
) {}
|
||||
|
||||
private encryptSecretValue(value: string, isSecret: boolean): string {
|
||||
if (!isSecret) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return this.secretEncryptionService.encrypt(value);
|
||||
}
|
||||
|
||||
getDisplayValue(applicationVariable: ApplicationVariableEntity): string {
|
||||
if (!applicationVariable.isSecret) {
|
||||
return applicationVariable.value;
|
||||
@@ -42,48 +33,6 @@ export class ApplicationVariableEntityService {
|
||||
});
|
||||
}
|
||||
|
||||
// Decrypted plaintext value. Server-side only — never expose via GraphQL.
|
||||
// Used by trusted server flows that need the raw secret (e.g. exchanging an
|
||||
// OAuth client secret with a third-party provider).
|
||||
getRawValue(applicationVariable: ApplicationVariableEntity): string {
|
||||
if (!applicationVariable.isSecret) {
|
||||
return applicationVariable.value;
|
||||
}
|
||||
|
||||
return this.secretEncryptionService.decrypt(applicationVariable.value);
|
||||
}
|
||||
|
||||
async findOneByKey({
|
||||
applicationId,
|
||||
key,
|
||||
}: {
|
||||
applicationId: string;
|
||||
key: string;
|
||||
}): Promise<ApplicationVariableEntity | null> {
|
||||
return this.applicationVariableRepository.findOne({
|
||||
where: { applicationId, key },
|
||||
});
|
||||
}
|
||||
|
||||
async getRawValueByKeyOrThrow({
|
||||
applicationId,
|
||||
key,
|
||||
}: {
|
||||
applicationId: string;
|
||||
key: string;
|
||||
}): Promise<string> {
|
||||
const variable = await this.findOneByKey({ applicationId, key });
|
||||
|
||||
if (!isDefined(variable)) {
|
||||
throw new ApplicationVariableEntityException(
|
||||
`Application variable "${key}" not found for application ${applicationId}`,
|
||||
ApplicationVariableEntityExceptionCode.APPLICATION_VARIABLE_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return this.getRawValue(variable);
|
||||
}
|
||||
|
||||
async update({
|
||||
key,
|
||||
plainTextValue,
|
||||
@@ -105,10 +54,9 @@ export class ApplicationVariableEntityService {
|
||||
);
|
||||
}
|
||||
|
||||
const encryptedValue = this.encryptSecretValue(
|
||||
plainTextValue,
|
||||
existingVariable.isSecret,
|
||||
);
|
||||
const encryptedValue = existingVariable.isSecret
|
||||
? this.secretEncryptionService.encrypt(plainTextValue)
|
||||
: plainTextValue;
|
||||
|
||||
await this.applicationVariableRepository.update(
|
||||
{ key, applicationId },
|
||||
@@ -121,79 +69,4 @@ export class ApplicationVariableEntityService {
|
||||
'applicationVariableMaps',
|
||||
]);
|
||||
}
|
||||
|
||||
async upsertManyApplicationVariableEntities({
|
||||
applicationVariables,
|
||||
applicationId,
|
||||
workspaceId,
|
||||
shouldUpdateValue = false,
|
||||
}: {
|
||||
applicationVariables?: ApplicationVariables;
|
||||
applicationId: string;
|
||||
workspaceId: string;
|
||||
shouldUpdateValue?: boolean;
|
||||
}) {
|
||||
if (!isDefined(applicationVariables)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keys = Object.keys(applicationVariables);
|
||||
|
||||
const existingVariables = await this.applicationVariableRepository.find({
|
||||
where: {
|
||||
applicationId,
|
||||
key: In(keys),
|
||||
},
|
||||
});
|
||||
|
||||
const existingVariablesByKey = new Map(
|
||||
existingVariables.map((variable) => [variable.key, variable]),
|
||||
);
|
||||
|
||||
const entitiesToSave: Partial<ApplicationVariableEntity>[] = [];
|
||||
|
||||
for (const [key, { value, description, isSecret }] of Object.entries(
|
||||
applicationVariables,
|
||||
)) {
|
||||
const existingVariable = existingVariablesByKey.get(key);
|
||||
const isSecretValue = isSecret ?? false;
|
||||
const encryptedValue = this.encryptSecretValue(
|
||||
value ?? '',
|
||||
isSecretValue,
|
||||
);
|
||||
|
||||
if (isDefined(existingVariable)) {
|
||||
entitiesToSave.push({
|
||||
id: existingVariable.id,
|
||||
description: description ?? '',
|
||||
isSecret: isSecretValue,
|
||||
...(shouldUpdateValue || existingVariable.isSecret !== isSecretValue
|
||||
? { value: encryptedValue }
|
||||
: {}),
|
||||
});
|
||||
} else {
|
||||
entitiesToSave.push({
|
||||
key,
|
||||
value: encryptedValue,
|
||||
description: description ?? '',
|
||||
isSecret: isSecretValue,
|
||||
applicationId,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (entitiesToSave.length > 0) {
|
||||
await this.applicationVariableRepository.save(entitiesToSave);
|
||||
}
|
||||
|
||||
await this.applicationVariableRepository.delete({
|
||||
applicationId,
|
||||
key: Not(In(keys)),
|
||||
});
|
||||
|
||||
await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [
|
||||
'applicationVariableMaps',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
+4
-5
@@ -1,6 +1,5 @@
|
||||
import { type FlatApplicationVariable } from 'src/engine/core-modules/application/application-variable/types/flat-application-variable.type';
|
||||
import { type FlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable.type';
|
||||
import type { FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
|
||||
export type ApplicationVariableCacheMaps = {
|
||||
byId: Partial<Record<string, FlatApplicationVariable>>;
|
||||
byApplicationId: Partial<Record<string, FlatApplicationVariable[]>>;
|
||||
};
|
||||
export type ApplicationVariableCacheMaps =
|
||||
FlatEntityMaps<FlatApplicationVariable>;
|
||||
|
||||
-4
@@ -1,4 +0,0 @@
|
||||
import { type ApplicationVariableEntity } from 'src/engine/core-modules/application/application-variable/application-variable.entity';
|
||||
import { type FlatEntityFrom } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-from.type';
|
||||
|
||||
export type FlatApplicationVariable = FlatEntityFrom<ApplicationVariableEntity>;
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
import { type ApplicationVariableEntity } from 'src/engine/core-modules/application/application-variable/application-variable.entity';
|
||||
import { type FlatApplicationVariable } from 'src/engine/core-modules/application/application-variable/types/flat-application-variable.type';
|
||||
|
||||
export const fromApplicationVariableEntityToFlatApplicationVariable = (
|
||||
entity: ApplicationVariableEntity,
|
||||
): FlatApplicationVariable => ({
|
||||
id: entity.id,
|
||||
key: entity.key,
|
||||
value: entity.value,
|
||||
description: entity.description,
|
||||
isSecret: entity.isSecret,
|
||||
applicationId: entity.applicationId,
|
||||
workspaceId: entity.workspaceId,
|
||||
createdAt: entity.createdAt.toISOString(),
|
||||
updatedAt: entity.updatedAt.toISOString(),
|
||||
});
|
||||
+28
-34
@@ -2,14 +2,17 @@ import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service';
|
||||
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import { ApplicationVariableEntity } from 'src/engine/core-modules/application/application-variable/application-variable.entity';
|
||||
import { type ApplicationVariableCacheMaps } from 'src/engine/core-modules/application/application-variable/types/application-variable-cache-maps.type';
|
||||
import { fromApplicationVariableEntityToFlatApplicationVariable } from 'src/engine/core-modules/application/application-variable/utils/from-application-variable-entity-to-flat-application-variable.util';
|
||||
import { fromApplicationVariableEntityToFlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/utils/from-application-variable-entity-to-flat-application-variable.util';
|
||||
import { WorkspaceCache } from 'src/engine/workspace-cache/decorators/workspace-cache.decorator';
|
||||
import { createIdToUniversalIdentifierMap } from 'src/engine/workspace-cache/utils/create-id-to-universal-identifier-map.util';
|
||||
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
|
||||
import { addFlatEntityToFlatEntityMapsThroughMutationOrThrow } from 'src/engine/workspace-manager/workspace-migration/utils/add-flat-entity-to-flat-entity-maps-through-mutation-or-throw.util';
|
||||
|
||||
@Injectable()
|
||||
@WorkspaceCache('applicationVariableMaps')
|
||||
@@ -17,6 +20,8 @@ export class WorkspaceApplicationVariableMapCacheService extends WorkspaceCacheP
|
||||
constructor(
|
||||
@InjectRepository(ApplicationVariableEntity)
|
||||
private readonly applicationVariableRepository: Repository<ApplicationVariableEntity>,
|
||||
@InjectRepository(ApplicationEntity)
|
||||
private readonly applicationRepository: Repository<ApplicationEntity>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -24,43 +29,32 @@ export class WorkspaceApplicationVariableMapCacheService extends WorkspaceCacheP
|
||||
async computeForCache(
|
||||
workspaceId: string,
|
||||
): Promise<ApplicationVariableCacheMaps> {
|
||||
const applicationVariableEntities = await this.applicationVariableRepository
|
||||
.createQueryBuilder('applicationVariable')
|
||||
.innerJoin('applicationVariable.application', 'application')
|
||||
.where('application.workspaceId = :workspaceId', { workspaceId })
|
||||
.getMany();
|
||||
const [applicationVariableEntities, applications] = await Promise.all([
|
||||
this.applicationVariableRepository.find({
|
||||
where: { workspaceId },
|
||||
}),
|
||||
this.applicationRepository.find({
|
||||
where: { workspaceId },
|
||||
select: ['id', 'universalIdentifier'],
|
||||
}),
|
||||
]);
|
||||
|
||||
const applicationVariableMaps: ApplicationVariableCacheMaps = {
|
||||
byId: {},
|
||||
byApplicationId: {},
|
||||
};
|
||||
const applicationIdToUniversalIdentifierMap =
|
||||
createIdToUniversalIdentifierMap(applications);
|
||||
|
||||
const applicationVariableMaps = createEmptyFlatEntityMaps();
|
||||
|
||||
for (const entity of applicationVariableEntities) {
|
||||
const flatApplicationVariable =
|
||||
fromApplicationVariableEntityToFlatApplicationVariable(entity);
|
||||
fromApplicationVariableEntityToFlatApplicationVariable({
|
||||
entity,
|
||||
applicationIdToUniversalIdentifierMap,
|
||||
});
|
||||
|
||||
applicationVariableMaps.byId[flatApplicationVariable.id] =
|
||||
flatApplicationVariable;
|
||||
|
||||
if (!isDefined(flatApplicationVariable.applicationId)) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!isDefined(
|
||||
applicationVariableMaps.byApplicationId[
|
||||
flatApplicationVariable.applicationId
|
||||
],
|
||||
)
|
||||
) {
|
||||
applicationVariableMaps.byApplicationId[
|
||||
flatApplicationVariable.applicationId
|
||||
] = [flatApplicationVariable];
|
||||
continue;
|
||||
}
|
||||
|
||||
applicationVariableMaps.byApplicationId[
|
||||
flatApplicationVariable.applicationId
|
||||
]?.push(flatApplicationVariable);
|
||||
addFlatEntityToFlatEntityMapsThroughMutationOrThrow({
|
||||
flatEntity: flatApplicationVariable,
|
||||
flatEntityMapsToMutate: applicationVariableMaps,
|
||||
});
|
||||
}
|
||||
|
||||
return applicationVariableMaps;
|
||||
|
||||
+12
-3
@@ -19,7 +19,7 @@ import {
|
||||
import { ApplicationLogsService } from 'src/engine/core-modules/application-logs/application-logs.service';
|
||||
import { parseApplicationLogLines } from 'src/engine/core-modules/application-logs/utils/parse-application-log-lines';
|
||||
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity';
|
||||
import type { FlatApplicationVariable } from 'src/engine/core-modules/application/application-variable/types/flat-application-variable.type';
|
||||
import type { FlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable.type';
|
||||
import { FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
|
||||
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
|
||||
import { LOGIC_FUNCTION_EXECUTED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/logic-function/logic-function-executed';
|
||||
@@ -205,8 +205,17 @@ export class LogicFunctionExecutorService {
|
||||
);
|
||||
}
|
||||
|
||||
const flatApplicationVariables =
|
||||
applicationVariableMaps.byApplicationId[flatApplication.id] ?? [];
|
||||
const flatApplicationVariableUniversalIdentifiers =
|
||||
applicationVariableMaps.universalIdentifiersByApplicationId[
|
||||
flatApplication.id
|
||||
] ?? [];
|
||||
|
||||
const flatApplicationVariables = flatApplicationVariableUniversalIdentifiers
|
||||
.map(
|
||||
(universalIdentifier) =>
|
||||
applicationVariableMaps.byUniversalIdentifier[universalIdentifier],
|
||||
)
|
||||
.filter(isDefined);
|
||||
|
||||
return { flatApplication, flatLogicFunction, flatApplicationVariables };
|
||||
}
|
||||
|
||||
+13
-1
@@ -1,4 +1,4 @@
|
||||
import { type FlatApplicationVariable } from 'src/engine/core-modules/application/application-variable/types/flat-application-variable.type';
|
||||
import { type FlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable.type';
|
||||
import { type SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
|
||||
import { buildEnvVar } from 'src/engine/core-modules/logic-function/logic-function-executor/utils/build-env-var';
|
||||
|
||||
@@ -28,6 +28,8 @@ describe('buildEnvVar', () => {
|
||||
isSecret: false,
|
||||
applicationId: 'app-1',
|
||||
workspaceId: '00000000-0000-0000-0000-000000000000',
|
||||
universalIdentifier: '00000000-0000-0000-0000-000000000000',
|
||||
applicationUniversalIdentifier: '00000000-0000-0000-0000-000000000000',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
@@ -39,6 +41,8 @@ describe('buildEnvVar', () => {
|
||||
isSecret: true,
|
||||
applicationId: 'app-1',
|
||||
workspaceId: '00000000-0000-0000-0000-000000000000',
|
||||
universalIdentifier: '00000000-0000-0000-0000-000000000000',
|
||||
applicationUniversalIdentifier: '00000000-0000-0000-0000-000000000000',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
@@ -50,6 +54,8 @@ describe('buildEnvVar', () => {
|
||||
isSecret: false,
|
||||
applicationId: 'app-1',
|
||||
workspaceId: '00000000-0000-0000-0000-000000000000',
|
||||
universalIdentifier: '00000000-0000-0000-0000-000000000000',
|
||||
applicationUniversalIdentifier: '00000000-0000-0000-0000-000000000000',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
@@ -78,6 +84,8 @@ describe('buildEnvVar', () => {
|
||||
isSecret: false,
|
||||
applicationId: 'app-1',
|
||||
workspaceId: '00000000-0000-0000-0000-000000000000',
|
||||
universalIdentifier: '00000000-0000-0000-0000-000000000000',
|
||||
applicationUniversalIdentifier: '00000000-0000-0000-0000-000000000000',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
@@ -89,6 +97,8 @@ describe('buildEnvVar', () => {
|
||||
isSecret: false,
|
||||
applicationId: 'app-1',
|
||||
workspaceId: '00000000-0000-0000-0000-000000000000',
|
||||
universalIdentifier: '00000000-0000-0000-0000-000000000000',
|
||||
applicationUniversalIdentifier: '00000000-0000-0000-0000-000000000000',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
@@ -112,6 +122,8 @@ describe('buildEnvVar', () => {
|
||||
isSecret: false,
|
||||
applicationId: 'app-1',
|
||||
workspaceId: '00000000-0000-0000-0000-000000000000',
|
||||
universalIdentifier: '00000000-0000-0000-0000-000000000000',
|
||||
applicationUniversalIdentifier: '00000000-0000-0000-0000-000000000000',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { type FlatApplicationVariable } from 'src/engine/core-modules/application/application-variable/types/flat-application-variable.type';
|
||||
import { type FlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable.type';
|
||||
import { type SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
|
||||
|
||||
export const buildEnvVar = (
|
||||
|
||||
+1
@@ -102,6 +102,7 @@ export class UpgradeStatusCommand extends CommandRunner {
|
||||
...this.formatSummary(instanceStatus, groupedWorkspaceUpgradeStatuses),
|
||||
);
|
||||
|
||||
// oxlint-disable-next-line no-console
|
||||
console.log(lines.join('\n'));
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
|
||||
Reference in New Issue
Block a user