Add workspace migration orchestrator + refactor builders + view/viewField action builders (#14383)

This commit is contained in:
Weiko
2025-09-10 16:55:30 +02:00
committed by GitHub
parent fcf3dc4429
commit 61f1a883a0
78 changed files with 2557 additions and 468 deletions
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ViewCacheService } from 'src/engine/core-modules/view/cache/services/view-cache.service';
import { ViewEntity } from 'src/engine/core-modules/view/entities/view.entity';
@Module({
imports: [TypeOrmModule.forFeature([ViewEntity])],
providers: [ViewCacheService],
exports: [ViewCacheService],
})
export class ViewCacheModule {}
@@ -0,0 +1,47 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ViewEntity } from 'src/engine/core-modules/view/entities/view.entity';
import { FlatViewMaps } from 'src/engine/core-modules/view/flat-view/types/flat-view-maps.type';
import { generateFlatViewMaps } from 'src/engine/core-modules/view/flat-view/utils/generate-flat-view-maps.util';
type GetExistingOrRecomputeFlatViewMapsResult = {
flatViewMaps: FlatViewMaps;
};
@Injectable()
export class ViewCacheService {
logger = new Logger(ViewCacheService.name);
constructor(
@InjectRepository(ViewEntity)
private readonly viewRepository: Repository<ViewEntity>,
) {}
async getExistingOrRecomputeFlatViewMaps({
workspaceId,
}: {
workspaceId: string;
}): Promise<GetExistingOrRecomputeFlatViewMapsResult> {
// TODO: get from cache later
const existingViews = await this.viewRepository.find({
where: {
workspaceId,
},
relations: ['viewFields'],
select: {
viewFields: {
id: true,
},
},
});
const existingFlatViewMaps = generateFlatViewMaps(existingViews);
return {
flatViewMaps: existingFlatViewMaps,
};
}
}
@@ -31,7 +31,10 @@ import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/
where: '"deletedAt" IS NULL',
},
)
export class ViewFieldEntity extends SyncableEntity {
export class ViewFieldEntity
extends SyncableEntity
implements Required<ViewFieldEntity>
{
@PrimaryGeneratedColumn('uuid')
id: string;
@@ -59,7 +62,7 @@ export class ViewFieldEntity extends SyncableEntity {
nullable: true,
default: null,
})
aggregateOperation?: AggregateOperations | null;
aggregateOperation: AggregateOperations | null;
@Column({ nullable: false, type: 'uuid' })
viewId: string;
@@ -74,7 +77,7 @@ export class ViewFieldEntity extends SyncableEntity {
updatedAt: Date;
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt?: Date | null;
deletedAt: Date | null;
@ManyToOne(() => Workspace, {
onDelete: 'CASCADE',
@@ -31,7 +31,7 @@ import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadat
'workspaceId',
'objectMetadataId',
])
export class ViewEntity extends SyncableEntity {
export class ViewEntity extends SyncableEntity implements Required<ViewEntity> {
@PrimaryGeneratedColumn('uuid')
id: string;
@@ -89,10 +89,10 @@ export class ViewEntity extends SyncableEntity {
nullable: true,
default: null,
})
kanbanAggregateOperation?: AggregateOperations | null;
kanbanAggregateOperation: AggregateOperations | null;
@Column({ nullable: true, type: 'uuid' })
kanbanAggregateOperationFieldMetadataId?: string | null;
kanbanAggregateOperationFieldMetadataId: string | null;
@Column({ nullable: false, type: 'uuid' })
workspaceId: string;
@@ -104,10 +104,10 @@ export class ViewEntity extends SyncableEntity {
updatedAt: Date;
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt?: Date | null;
deletedAt: Date | null;
@Column({ nullable: true, type: 'text', default: null })
anyFieldFilterValue?: string | null;
anyFieldFilterValue: string | null;
@ManyToOne(() => Workspace, {
onDelete: 'CASCADE',
@@ -0,0 +1,13 @@
import {
appendCommonExceptionCode,
CustomException,
} from 'src/utils/custom-exception';
export class FlatViewException extends CustomException<
keyof typeof FlatViewExceptionCode
> {}
export const FlatViewExceptionCode = appendCommonExceptionCode({
VIEW_NOT_FOUND: 'VIEW_NOT_FOUND',
VIEW_ALREADY_EXISTS: 'VIEW_ALREADY_EXISTS',
} as const);
@@ -0,0 +1,8 @@
import { type FlatViewField } from 'src/engine/core-modules/view/flat-view/types/flat-view-field.type';
export const FLAT_VIEW_FIELD_PROPERTIES_TO_COMPARE = [
'isVisible',
'size',
'position',
'aggregateOperation',
] as const satisfies (keyof FlatViewField)[];
@@ -0,0 +1,14 @@
import { type FlatView } from 'src/engine/core-modules/view/flat-view/types/flat-view.type';
export const FLAT_VIEW_PROPERTIES_TO_COMPARE = [
'name',
'type',
'key',
'isCompact',
'openRecordIn',
'kanbanAggregateOperation',
'kanbanAggregateOperationFieldMetadataId',
'position',
'anyFieldFilterValue',
'icon',
] as const satisfies (keyof FlatView)[];
@@ -0,0 +1,11 @@
import { type ViewEntityRelationProperties } from 'src/engine/core-modules/view/flat-view/types/flat-view.type';
export const VIEW_ENTITY_RELATION_PROPERTIES = [
'objectMetadata',
'viewFields',
'viewFilters',
'viewFilterGroups',
'viewGroups',
'viewSorts',
'workspace',
] as const satisfies ViewEntityRelationProperties[];
@@ -0,0 +1,7 @@
import { type ViewFieldEntityRelationProperties } from 'src/engine/core-modules/view/flat-view/types/flat-view-field.type';
export const VIEW_FIELD_ENTITY_RELATION_PROPERTIES = [
'fieldMetadata',
'view',
'workspace',
] as const satisfies ViewFieldEntityRelationProperties[];
@@ -0,0 +1,5 @@
import { type FlatViewField } from 'src/engine/core-modules/view/flat-view/types/flat-view-field.type';
export type FailedFlatViewFieldValidationMinimalInformation = Partial<
Pick<FlatViewField, 'id' | 'fieldMetadataId' | 'viewId'>
>;
@@ -0,0 +1,9 @@
import { type FailedFlatViewFieldValidationMinimalInformation } from 'src/engine/core-modules/view/flat-view/types/failed-flat-view-field-validation-minimal-information.type';
import { type FlatViewFieldValidationError } from 'src/engine/core-modules/view/flat-view/types/flat-view-field-validation-error.type';
import { type WorkspaceMigrationViewFieldActionTypeV2 } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/workspace-migration-view-field-action-v2.type';
export type FailedFlatViewFieldValidation = {
type: WorkspaceMigrationViewFieldActionTypeV2;
viewFieldLevelErrors: FlatViewFieldValidationError[];
failedViewFieldValidationMinimalInformation: Partial<FailedFlatViewFieldValidationMinimalInformation>;
};
@@ -0,0 +1,5 @@
import { type FlatView } from 'src/engine/core-modules/view/flat-view/types/flat-view.type';
export type FailedFlatViewValidationMinimalInformation = Partial<
Pick<FlatView, 'id' | 'name' | 'objectMetadataId'>
>;
@@ -0,0 +1,9 @@
import { type FailedFlatViewValidationMinimalInformation } from 'src/engine/core-modules/view/flat-view/types/failed-flat-view-validation-minimal-information.type';
import { type FlatViewValidationError } from 'src/engine/core-modules/view/flat-view/types/flat-view-validation-error.type';
import { type WorkspaceMigrationViewActionTypeV2 } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/workspace-migration-view-action-v2.type';
export type FailedFlatViewValidation = {
type: WorkspaceMigrationViewActionTypeV2;
viewLevelErrors: FlatViewValidationError[];
failedViewValidationMinimalInformation: Partial<FailedFlatViewValidationMinimalInformation>;
};
@@ -0,0 +1,4 @@
import { type FlatEntityMaps } from 'src/engine/core-modules/common/types/flat-entity-maps.type';
import { type FlatViewField } from 'src/engine/core-modules/view/flat-view/types/flat-view-field.type';
export type FlatViewFieldMaps = FlatEntityMaps<FlatViewField>;
@@ -0,0 +1,4 @@
import { type FLAT_VIEW_FIELD_PROPERTIES_TO_COMPARE } from 'src/engine/core-modules/view/flat-view/constants/flat-view-field-properties-to-compare.constant';
export type FlatViewFieldPropertiesToCompare =
(typeof FLAT_VIEW_FIELD_PROPERTIES_TO_COMPARE)[number];
@@ -0,0 +1,4 @@
export type FlatViewFieldValidationError = {
message: string;
code: string;
};
@@ -0,0 +1,18 @@
import { type ViewFieldEntity } from 'src/engine/core-modules/view/entities/view-field.entity';
import { type ViewEntity } from 'src/engine/core-modules/view/entities/view.entity';
import { type Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { type ExtractRecordTypeOrmRelationProperties } from 'src/engine/workspace-manager/workspace-migration-v2/types/extract-record-typeorm-relation-properties.type';
export type ViewFieldEntityRelationProperties =
ExtractRecordTypeOrmRelationProperties<
ViewFieldEntity,
FieldMetadataEntity | ViewEntity | Workspace
>;
export type FlatViewField = Omit<
ViewFieldEntity,
ViewFieldEntityRelationProperties
> & {
universalIdentifier: string;
};
@@ -0,0 +1,4 @@
import { type FlatEntityMaps } from 'src/engine/core-modules/common/types/flat-entity-maps.type';
import { type FlatView } from 'src/engine/core-modules/view/flat-view/types/flat-view.type';
export type FlatViewMaps = FlatEntityMaps<FlatView>;
@@ -0,0 +1,4 @@
import { type FLAT_VIEW_PROPERTIES_TO_COMPARE } from 'src/engine/core-modules/view/flat-view/constants/flat-view-properies-to-compare.constant';
export type FlatViewPropertiesToCompare =
(typeof FLAT_VIEW_PROPERTIES_TO_COMPARE)[number];
@@ -0,0 +1,8 @@
import { type ViewExceptionCode } from 'src/engine/core-modules/view/exceptions/view.exception';
export type FlatViewValidationError = {
code: ViewExceptionCode;
message: string;
userFriendlyMessage?: string;
value?: unknown;
};
@@ -0,0 +1,26 @@
import { type ViewFieldEntity } from 'src/engine/core-modules/view/entities/view-field.entity';
import { type ViewFilterGroupEntity } from 'src/engine/core-modules/view/entities/view-filter-group.entity';
import { type ViewFilterEntity } from 'src/engine/core-modules/view/entities/view-filter.entity';
import { type ViewGroupEntity } from 'src/engine/core-modules/view/entities/view-group.entity';
import { type ViewSortEntity } from 'src/engine/core-modules/view/entities/view-sort.entity';
import { type ViewEntity } from 'src/engine/core-modules/view/entities/view.entity';
import { type Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { type ExtractRecordTypeOrmRelationProperties } from 'src/engine/workspace-manager/workspace-migration-v2/types/extract-record-typeorm-relation-properties.type';
export type ViewEntityRelationProperties =
ExtractRecordTypeOrmRelationProperties<
ViewEntity,
| ObjectMetadataEntity
| ViewFieldEntity
| ViewSortEntity
| ViewFilterEntity
| ViewGroupEntity
| ViewFilterGroupEntity
| Workspace
>;
export type FlatView = Omit<ViewEntity, ViewEntityRelationProperties> & {
universalIdentifier: string;
viewFieldIds: string[];
};
@@ -0,0 +1,59 @@
import diff from 'microdiff';
import { type FromTo } from 'twenty-shared/types';
import { FLAT_VIEW_FIELD_PROPERTIES_TO_COMPARE } from 'src/engine/core-modules/view/flat-view/constants/flat-view-field-properties-to-compare.constant';
import { type FlatViewFieldPropertiesToCompare } from 'src/engine/core-modules/view/flat-view/types/flat-view-field-properties-to-compare.type';
import { type FlatViewField } from 'src/engine/core-modules/view/flat-view/types/flat-view-field.type';
import { type UpdateViewFieldAction } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/workspace-migration-view-field-action-v2.type';
import { transformMetadataForComparison } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/utils/transform-metadata-for-comparison.util';
type GetWorkspaceMigrationUpdateViewFieldActionArgs = FromTo<
FlatViewField,
'FlatViewField'
>;
export const compareTwoFlatViewField = ({
fromFlatViewField,
toFlatViewField,
}: GetWorkspaceMigrationUpdateViewFieldActionArgs) => {
const transformMetadataForComparisonParameters = {
shouldIgnoreProperty: (property: string) =>
!FLAT_VIEW_FIELD_PROPERTIES_TO_COMPARE.includes(
property as FlatViewFieldPropertiesToCompare,
),
propertiesToStringify: [],
};
const fromCompare = transformMetadataForComparison(
fromFlatViewField,
transformMetadataForComparisonParameters,
);
const toCompare = transformMetadataForComparison(
toFlatViewField,
transformMetadataForComparisonParameters,
);
const flatViewFieldDifferences = diff(fromCompare, toCompare);
return flatViewFieldDifferences.flatMap<
UpdateViewFieldAction['updates'][number]
>((difference) => {
switch (difference.type) {
case 'CHANGE': {
const { oldValue, path, value } = difference;
const property = path[0] as FlatViewFieldPropertiesToCompare;
return {
from: oldValue,
to: value,
property,
};
}
case 'CREATE':
case 'REMOVE':
default: {
// Should never occurs, we should only provide null never undefined and so on
return [];
}
}
});
};
@@ -0,0 +1,56 @@
import diff from 'microdiff';
import { type FromTo } from 'twenty-shared/types';
import { FLAT_VIEW_PROPERTIES_TO_COMPARE } from 'src/engine/core-modules/view/flat-view/constants/flat-view-properies-to-compare.constant';
import { type FlatViewPropertiesToCompare } from 'src/engine/core-modules/view/flat-view/types/flat-view-properties-to-compare.type';
import { type FlatView } from 'src/engine/core-modules/view/flat-view/types/flat-view.type';
import { type UpdateViewAction } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/workspace-migration-view-action-v2.type';
import { transformMetadataForComparison } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/utils/transform-metadata-for-comparison.util';
type GetWorkspaceMigrationUpdateViewActionArgs = FromTo<FlatView, 'FlatView'>;
export const compareTwoFlatView = ({
fromFlatView,
toFlatView,
}: GetWorkspaceMigrationUpdateViewActionArgs) => {
const transformMetadataForComparisonParameters = {
shouldIgnoreProperty: (property: string) =>
!FLAT_VIEW_PROPERTIES_TO_COMPARE.includes(
property as FlatViewPropertiesToCompare,
),
propertiesToStringify: [],
};
const fromCompare = transformMetadataForComparison(
fromFlatView,
transformMetadataForComparisonParameters,
);
const toCompare = transformMetadataForComparison(
toFlatView,
transformMetadataForComparisonParameters,
);
const flatViewDifferences = diff(fromCompare, toCompare);
return flatViewDifferences.flatMap<UpdateViewAction['updates'][number]>(
(difference) => {
switch (difference.type) {
case 'CHANGE': {
const { oldValue, path, value } = difference;
const property = path[0] as FlatViewPropertiesToCompare;
return {
from: oldValue,
to: value,
property,
};
}
case 'CREATE':
case 'REMOVE':
default: {
// Should never occurs, we should only provide null never undefined and so on
return [];
}
}
},
);
};
@@ -0,0 +1,27 @@
import { v4 } from 'uuid';
import { type FlatViewField } from 'src/engine/core-modules/view/flat-view/types/flat-view-field.type';
export const fromPartialFlatViewFieldToFlatViewFieldWithDefault = (
partialFlatViewField: Partial<FlatViewField>,
): FlatViewField => {
const createdAt = new Date();
const viewFieldId = partialFlatViewField.id ?? v4();
return {
...partialFlatViewField,
id: viewFieldId,
fieldMetadataId: partialFlatViewField.fieldMetadataId ?? '',
isVisible: partialFlatViewField.isVisible ?? true,
size: partialFlatViewField.size ?? 0,
position: partialFlatViewField.position ?? 0,
aggregateOperation: partialFlatViewField.aggregateOperation ?? null,
viewId: partialFlatViewField.viewId ?? '',
workspaceId: partialFlatViewField.workspaceId ?? '',
createdAt: createdAt,
updatedAt: createdAt,
deletedAt: null,
universalIdentifier:
partialFlatViewField.universalIdentifier ?? viewFieldId,
};
};
@@ -0,0 +1,36 @@
import { v4 } from 'uuid';
import { ViewOpenRecordIn } from 'src/engine/core-modules/view/enums/view-open-record-in';
import { ViewType } from 'src/engine/core-modules/view/enums/view-type.enum';
import { type FlatView } from 'src/engine/core-modules/view/flat-view/types/flat-view.type';
export const fromPartialFlatViewToFlatViewWithDefault = (
partialFlatView: Partial<FlatView>,
): FlatView => {
const createdAt = new Date();
const viewId = partialFlatView.id ?? v4();
return {
...partialFlatView,
id: viewId,
name: partialFlatView.name ?? '',
objectMetadataId: partialFlatView.objectMetadataId ?? '',
type: partialFlatView.type ?? ViewType.TABLE,
key: partialFlatView.key ?? null,
icon: partialFlatView.icon ?? '',
position: partialFlatView.position ?? 0,
isCompact: partialFlatView.isCompact ?? false,
isCustom: partialFlatView.isCustom ?? false,
openRecordIn: partialFlatView.openRecordIn ?? ViewOpenRecordIn.SIDE_PANEL,
kanbanAggregateOperation: partialFlatView.kanbanAggregateOperation ?? null,
kanbanAggregateOperationFieldMetadataId:
partialFlatView.kanbanAggregateOperationFieldMetadataId ?? null,
workspaceId: partialFlatView.workspaceId ?? '',
createdAt: createdAt,
updatedAt: createdAt,
deletedAt: null,
anyFieldFilterValue: partialFlatView.anyFieldFilterValue ?? null,
universalIdentifier: partialFlatView.universalIdentifier ?? viewId,
viewFieldIds: partialFlatView.viewFieldIds ?? [],
};
};
@@ -0,0 +1,20 @@
import { removePropertiesFromRecord } from 'twenty-shared/utils';
import { type ViewEntity } from 'src/engine/core-modules/view/entities/view.entity';
import { VIEW_ENTITY_RELATION_PROPERTIES } from 'src/engine/core-modules/view/flat-view/constants/view-entity-relation-properties.constant';
import { type FlatView } from 'src/engine/core-modules/view/flat-view/types/flat-view.type';
export const fromViewEntityToFlatView = (viewEntity: ViewEntity): FlatView => {
const viewEntityWithoutRelations = removePropertiesFromRecord(
viewEntity,
VIEW_ENTITY_RELATION_PROPERTIES,
);
return {
...viewEntityWithoutRelations,
universalIdentifier:
viewEntityWithoutRelations.universalIdentifier ??
viewEntityWithoutRelations.id,
viewFieldIds: viewEntity.viewFields?.map((viewField) => viewField.id) ?? [],
};
};
@@ -0,0 +1,21 @@
import { removePropertiesFromRecord } from 'twenty-shared/utils';
import { type ViewFieldEntity } from 'src/engine/core-modules/view/entities/view-field.entity';
import { VIEW_FIELD_ENTITY_RELATION_PROPERTIES } from 'src/engine/core-modules/view/flat-view/constants/view-field-entity-relation-properties.constant';
import { type FlatViewField } from 'src/engine/core-modules/view/flat-view/types/flat-view-field.type';
export const fromViewFieldEntityToFlatViewField = (
viewFieldEntity: ViewFieldEntity,
): FlatViewField => {
const viewFieldEntityWithoutRelations = removePropertiesFromRecord(
viewFieldEntity,
VIEW_FIELD_ENTITY_RELATION_PROPERTIES,
);
return {
...viewFieldEntityWithoutRelations,
universalIdentifier:
viewFieldEntityWithoutRelations.universalIdentifier ??
viewFieldEntityWithoutRelations.id,
};
};
@@ -0,0 +1,27 @@
import { type ViewEntity } from 'src/engine/core-modules/view/entities/view.entity';
import { type FlatViewMaps } from 'src/engine/core-modules/view/flat-view/types/flat-view-maps.type';
import { type FlatView } from 'src/engine/core-modules/view/flat-view/types/flat-view.type';
export const generateFlatViewMaps = (
viewCollection: ViewEntity[],
): FlatViewMaps => {
const flatViewMaps: FlatViewMaps = {
byId: {},
idByUniversalIdentifier: {},
};
for (const view of viewCollection) {
const processedFlatView: FlatView = {
...view,
viewFieldIds: view.viewFields?.map((viewField) => viewField.id) ?? [],
universalIdentifier: view.universalIdentifier ?? view.id, // TODO: should not fallback to id, to remove once we have universalIdentifier required
};
flatViewMaps.byId[view.id] = processedFlatView;
flatViewMaps.idByUniversalIdentifier[
processedFlatView.universalIdentifier
] = view.id;
}
return flatViewMaps;
};
@@ -0,0 +1,244 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined, removePropertiesFromRecord } from 'twenty-shared/utils';
import { Equal, Repository } from 'typeorm';
import { v4 } from 'uuid';
import { addFlatEntityToFlatEntityMapsOrThrow } from 'src/engine/core-modules/common/utils/add-flat-entity-to-flat-entity-maps-or-throw.util';
import { deleteFlatEntityFromFlatEntityMapsOrThrow } from 'src/engine/core-modules/common/utils/delete-flat-entity-from-flat-entity-maps-or-throw.util';
import { replaceFlatEntityInFlatEntityMapsOrThrow } from 'src/engine/core-modules/common/utils/replace-flat-entity-in-flat-entity-maps-or-throw.util';
import { ViewFieldEntity } from 'src/engine/core-modules/view/entities/view-field.entity';
import {
ViewFieldException,
ViewFieldExceptionCode,
ViewFieldExceptionMessageKey,
generateViewFieldExceptionMessage,
generateViewFieldUserFriendlyExceptionMessage,
} from 'src/engine/core-modules/view/exceptions/view-field.exception';
import { VIEW_FIELD_ENTITY_RELATION_PROPERTIES } from 'src/engine/core-modules/view/flat-view/constants/view-field-entity-relation-properties.constant';
import { FlatViewFieldMaps } from 'src/engine/core-modules/view/flat-view/types/flat-view-field-maps.type';
import { fromPartialFlatViewFieldToFlatViewFieldWithDefault } from 'src/engine/core-modules/view/flat-view/utils/from-partial-flat-view-field-to-flat-view-field-with-default.util';
import { fromViewFieldEntityToFlatViewField } from 'src/engine/core-modules/view/flat-view/utils/from-view-field-entity-to-flat-view-field.util';
import { WorkspaceMigrationOrchestratorException } from 'src/engine/workspace-manager/workspace-migration-v2/exceptions/workspace-migration-orchestrator-exception';
import { WorkspaceMigrationBuildOrchestratorService } from 'src/engine/workspace-manager/workspace-migration-v2/services/workspace-migration-build-orchestrator.service';
@Injectable()
export class ViewFieldV2Service {
constructor(
@InjectRepository(ViewFieldEntity)
private readonly viewFieldRepository: Repository<ViewFieldEntity>,
private readonly workspaceMigrationOrchestratorService: WorkspaceMigrationBuildOrchestratorService,
) {}
// TODO: move to cache service
private async getExistingFlatViewFieldMaps(
workspaceId: string,
): Promise<FlatViewFieldMaps> {
const existingViewFields = await this.viewFieldRepository.find({
where: { workspaceId },
});
const flatViewFieldMaps: FlatViewFieldMaps = {
byId: {},
idByUniversalIdentifier: {},
};
for (const viewFieldEntity of existingViewFields) {
const flatViewField = fromViewFieldEntityToFlatViewField(viewFieldEntity);
flatViewFieldMaps.byId[flatViewField.id] = flatViewField;
flatViewFieldMaps.idByUniversalIdentifier[
flatViewField.universalIdentifier
] = flatViewField.id;
}
return flatViewFieldMaps;
}
async createOne(
viewFieldData: Partial<ViewFieldEntity>,
): Promise<ViewFieldEntity> {
if (!isDefined(viewFieldData.workspaceId)) {
throw new ViewFieldException(
generateViewFieldExceptionMessage(
ViewFieldExceptionMessageKey.WORKSPACE_ID_REQUIRED,
),
ViewFieldExceptionCode.INVALID_VIEW_FIELD_DATA,
{
userFriendlyMessage: generateViewFieldUserFriendlyExceptionMessage(
ViewFieldExceptionMessageKey.WORKSPACE_ID_REQUIRED,
),
},
);
}
const existingFlatViewFieldMaps = await this.getExistingFlatViewFieldMaps(
viewFieldData.workspaceId,
);
const flatViewFieldFromCreateInput =
fromPartialFlatViewFieldToFlatViewFieldWithDefault({
...viewFieldData,
universalIdentifier: viewFieldData.universalIdentifier ?? v4(),
});
const toFlatViewFieldMaps: FlatViewFieldMaps =
addFlatEntityToFlatEntityMapsOrThrow({
flatEntity: flatViewFieldFromCreateInput,
flatEntityMaps: existingFlatViewFieldMaps,
});
const validateAndBuildResult =
await this.workspaceMigrationOrchestratorService.buildWorkspaceMigrations(
{
entityMaps: {
viewField: {
fromFlatViewFieldMaps: existingFlatViewFieldMaps,
toFlatViewFieldMaps,
},
},
buildOptions: {
isSystemBuild: false,
inferDeletionFromMissingEntities: false,
},
workspaceId: viewFieldData.workspaceId,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationOrchestratorException(
'Multiple validation errors occurred while creating view field',
);
}
const [createdViewField] = await this.viewFieldRepository.find({
where: {
id: flatViewFieldFromCreateInput.id,
},
});
return createdViewField;
}
async updateOne(
id: string,
workspaceId: string,
updateData: Partial<ViewFieldEntity>,
): Promise<ViewFieldEntity> {
const existingFlatViewFieldMaps =
await this.getExistingFlatViewFieldMaps(workspaceId);
const existingViewField = existingFlatViewFieldMaps.byId[id];
if (!isDefined(existingViewField)) {
throw new ViewFieldException(
generateViewFieldExceptionMessage(
ViewFieldExceptionMessageKey.VIEW_FIELD_NOT_FOUND,
id,
),
ViewFieldExceptionCode.VIEW_FIELD_NOT_FOUND,
);
}
const existingViewFieldToUpdate = removePropertiesFromRecord(
{
...existingViewField,
...updateData,
},
VIEW_FIELD_ENTITY_RELATION_PROPERTIES,
);
const flatViewFieldFromUpdateInput =
fromPartialFlatViewFieldToFlatViewFieldWithDefault({
...existingViewFieldToUpdate,
universalIdentifier:
existingViewFieldToUpdate.universalIdentifier ?? '',
});
const toFlatViewFieldMaps: FlatViewFieldMaps =
replaceFlatEntityInFlatEntityMapsOrThrow({
flatEntity: flatViewFieldFromUpdateInput,
flatEntityMaps: existingFlatViewFieldMaps,
});
const validateAndBuildResult =
await this.workspaceMigrationOrchestratorService.buildWorkspaceMigrations(
{
entityMaps: {
viewField: {
fromFlatViewFieldMaps: existingFlatViewFieldMaps,
toFlatViewFieldMaps,
},
},
buildOptions: {
isSystemBuild: false,
inferDeletionFromMissingEntities: false,
},
workspaceId,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationOrchestratorException(
'Multiple validation errors occurred while updating view field',
);
}
const [updatedViewField] = await this.viewFieldRepository.find({
where: {
id: Equal(id),
},
});
return updatedViewField;
}
async deleteOne(id: string, workspaceId: string): Promise<boolean> {
const existingFlatViewFieldMaps =
await this.getExistingFlatViewFieldMaps(workspaceId);
const existingViewFieldToDelete = existingFlatViewFieldMaps.byId[id];
if (!isDefined(existingViewFieldToDelete)) {
throw new ViewFieldException(
generateViewFieldExceptionMessage(
ViewFieldExceptionMessageKey.VIEW_FIELD_NOT_FOUND,
id,
),
ViewFieldExceptionCode.VIEW_FIELD_NOT_FOUND,
);
}
const toFlatViewFieldMaps: FlatViewFieldMaps =
deleteFlatEntityFromFlatEntityMapsOrThrow({
flatEntityMaps: existingFlatViewFieldMaps,
entityToDeleteId: existingViewFieldToDelete.id,
});
const validateAndBuildResult =
await this.workspaceMigrationOrchestratorService.buildWorkspaceMigrations(
{
entityMaps: {
viewField: {
fromFlatViewFieldMaps: existingFlatViewFieldMaps,
toFlatViewFieldMaps,
},
},
buildOptions: {
isSystemBuild: false,
inferDeletionFromMissingEntities: true,
},
workspaceId,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationOrchestratorException(
'Multiple validation errors occurred while deleting view field',
);
}
return true;
}
}
@@ -0,0 +1,219 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined, removePropertiesFromRecord } from 'twenty-shared/utils';
import { Equal, Repository } from 'typeorm';
import { v4 } from 'uuid';
import { addFlatEntityToFlatEntityMapsOrThrow } from 'src/engine/core-modules/common/utils/add-flat-entity-to-flat-entity-maps-or-throw.util';
import { deleteFlatEntityFromFlatEntityMapsOrThrow } from 'src/engine/core-modules/common/utils/delete-flat-entity-from-flat-entity-maps-or-throw.util';
import { replaceFlatEntityInFlatEntityMapsOrThrow } from 'src/engine/core-modules/common/utils/replace-flat-entity-in-flat-entity-maps-or-throw.util';
import { ViewCacheService } from 'src/engine/core-modules/view/cache/services/view-cache.service';
import { ViewEntity } from 'src/engine/core-modules/view/entities/view.entity';
import {
ViewException,
ViewExceptionCode,
ViewExceptionMessageKey,
generateViewExceptionMessage,
generateViewUserFriendlyExceptionMessage,
} from 'src/engine/core-modules/view/exceptions/view.exception';
import { VIEW_ENTITY_RELATION_PROPERTIES } from 'src/engine/core-modules/view/flat-view/constants/view-entity-relation-properties.constant';
import { FlatViewMaps } from 'src/engine/core-modules/view/flat-view/types/flat-view-maps.type';
import { fromPartialFlatViewToFlatViewWithDefault } from 'src/engine/core-modules/view/flat-view/utils/from-partial-flat-view-to-flat-view-to-with-default.util';
import { WorkspaceMigrationOrchestratorException } from 'src/engine/workspace-manager/workspace-migration-v2/exceptions/workspace-migration-orchestrator-exception';
import { WorkspaceMigrationBuildOrchestratorService } from 'src/engine/workspace-manager/workspace-migration-v2/services/workspace-migration-build-orchestrator.service';
@Injectable()
export class ViewV2Service {
constructor(
@InjectRepository(ViewEntity)
private readonly viewRepository: Repository<ViewEntity>,
private readonly workspaceMigrationOrchestratorService: WorkspaceMigrationBuildOrchestratorService,
private readonly viewCacheService: ViewCacheService,
) {}
async createOne(viewData: Partial<ViewEntity>): Promise<ViewEntity> {
if (!isDefined(viewData.workspaceId)) {
throw new ViewException(
generateViewExceptionMessage(
ViewExceptionMessageKey.WORKSPACE_ID_REQUIRED,
),
ViewExceptionCode.INVALID_VIEW_DATA,
{
userFriendlyMessage: generateViewUserFriendlyExceptionMessage(
ViewExceptionMessageKey.WORKSPACE_ID_REQUIRED,
),
},
);
}
const { flatViewMaps: existingFlatViewMaps } =
await this.viewCacheService.getExistingOrRecomputeFlatViewMaps({
workspaceId: viewData.workspaceId,
});
const flatViewFromCreateInput = fromPartialFlatViewToFlatViewWithDefault({
...viewData,
universalIdentifier: viewData.universalIdentifier ?? v4(),
});
const toFlatViewMaps: FlatViewMaps = addFlatEntityToFlatEntityMapsOrThrow({
flatEntity: flatViewFromCreateInput,
flatEntityMaps: existingFlatViewMaps,
});
const validateAndBuildResult =
await this.workspaceMigrationOrchestratorService.buildWorkspaceMigrations(
{
entityMaps: {
view: {
fromFlatViewMaps: existingFlatViewMaps,
toFlatViewMaps,
},
},
buildOptions: {
isSystemBuild: false,
inferDeletionFromMissingEntities: false,
},
workspaceId: viewData.workspaceId,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationOrchestratorException(
'Multiple validation errors occurred while creating view',
);
}
const [createdView] = await this.viewRepository.find({
where: {
id: flatViewFromCreateInput.id,
},
});
return createdView;
}
async updateOne(
id: string,
workspaceId: string,
updateData: Partial<ViewEntity>,
): Promise<ViewEntity> {
const { flatViewMaps: existingFlatViewMaps } =
await this.viewCacheService.getExistingOrRecomputeFlatViewMaps({
workspaceId,
});
const existingView = existingFlatViewMaps.byId[id];
if (!isDefined(existingView)) {
throw new ViewException(
generateViewExceptionMessage(
ViewExceptionMessageKey.VIEW_NOT_FOUND,
id,
),
ViewExceptionCode.VIEW_NOT_FOUND,
);
}
const existingViewToUpdate = removePropertiesFromRecord(
{
...existingView,
...updateData,
},
VIEW_ENTITY_RELATION_PROPERTIES,
);
const flatViewFromUpdateInput = fromPartialFlatViewToFlatViewWithDefault({
...existingViewToUpdate,
universalIdentifier: existingViewToUpdate.universalIdentifier ?? '',
});
const toFlatViewMaps: FlatViewMaps =
replaceFlatEntityInFlatEntityMapsOrThrow({
flatEntity: flatViewFromUpdateInput,
flatEntityMaps: existingFlatViewMaps,
});
const validateAndBuildResult =
await this.workspaceMigrationOrchestratorService.buildWorkspaceMigrations(
{
entityMaps: {
view: {
fromFlatViewMaps: existingFlatViewMaps,
toFlatViewMaps,
},
},
buildOptions: {
isSystemBuild: false,
inferDeletionFromMissingEntities: false,
},
workspaceId,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationOrchestratorException(
'Multiple validation errors occurred while updating view',
);
}
const [updatedView] = await this.viewRepository.find({
where: {
id: Equal(id),
},
});
return updatedView;
}
async deleteOne(id: string, workspaceId: string): Promise<boolean> {
const { flatViewMaps: existingFlatViewMaps } =
await this.viewCacheService.getExistingOrRecomputeFlatViewMaps({
workspaceId,
});
const existingViewToDelete = existingFlatViewMaps.byId[id];
if (!isDefined(existingViewToDelete)) {
throw new ViewException(
generateViewExceptionMessage(
ViewExceptionMessageKey.VIEW_NOT_FOUND,
id,
),
ViewExceptionCode.VIEW_NOT_FOUND,
);
}
const toFlatViewMaps: FlatViewMaps =
deleteFlatEntityFromFlatEntityMapsOrThrow({
flatEntityMaps: existingFlatViewMaps,
entityToDeleteId: existingViewToDelete.id,
});
const validateAndBuildResult =
await this.workspaceMigrationOrchestratorService.buildWorkspaceMigrations(
{
entityMaps: {
view: {
fromFlatViewMaps: existingFlatViewMaps,
toFlatViewMaps,
},
},
buildOptions: {
isSystemBuild: false,
inferDeletionFromMissingEntities: true,
},
workspaceId,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationOrchestratorException(
'Multiple validation errors occurred while deleting view',
);
}
return true;
}
}
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { I18nModule } from 'src/engine/core-modules/i18n/i18n.module';
import { ViewCacheModule } from 'src/engine/core-modules/view/cache/services/view-cache.module';
import { ViewFieldController } from 'src/engine/core-modules/view/controllers/view-field.controller';
import { ViewFilterGroupController } from 'src/engine/core-modules/view/controllers/view-filter-group.controller';
import { ViewFilterController } from 'src/engine/core-modules/view/controllers/view-filter.controller';
@@ -20,14 +21,17 @@ import { ViewFilterResolver } from 'src/engine/core-modules/view/resolvers/view-
import { ViewGroupResolver } from 'src/engine/core-modules/view/resolvers/view-group.resolver';
import { ViewSortResolver } from 'src/engine/core-modules/view/resolvers/view-sort.resolver';
import { ViewResolver } from 'src/engine/core-modules/view/resolvers/view.resolver';
import { ViewFieldV2Service } from 'src/engine/core-modules/view/services/view-field-v2.service';
import { ViewFieldService } from 'src/engine/core-modules/view/services/view-field.service';
import { ViewFilterGroupService } from 'src/engine/core-modules/view/services/view-filter-group.service';
import { ViewFilterService } from 'src/engine/core-modules/view/services/view-filter.service';
import { ViewGroupService } from 'src/engine/core-modules/view/services/view-group.service';
import { ViewSortService } from 'src/engine/core-modules/view/services/view-sort.service';
import { ViewV2Service } from 'src/engine/core-modules/view/services/view-v2.service';
import { ViewService } from 'src/engine/core-modules/view/services/view.service';
import { WorkspaceMetadataCacheModule } from 'src/engine/metadata-modules/workspace-metadata-cache/workspace-metadata-cache.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-v2.module';
@Module({
imports: [
@@ -42,6 +46,8 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
I18nModule,
WorkspaceCacheStorageModule,
WorkspaceMetadataCacheModule,
WorkspaceMigrationV2Module,
ViewCacheModule,
],
controllers: [
ViewController,
@@ -64,6 +70,8 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
ViewFilterGroupResolver,
ViewGroupResolver,
ViewSortResolver,
ViewV2Service,
ViewFieldV2Service,
],
exports: [
ViewService,
@@ -72,6 +80,8 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
ViewFilterGroupService,
ViewGroupService,
ViewSortService,
ViewV2Service,
ViewFieldV2Service,
],
})
export class CoreViewModule {}