feat(server): convert view to overridable entity (#21436)

## Context

Every entity created as a side effect of object creation must support
the overridable pattern (`isActive` + `overrides` + override routing)
before we can re-own side effects to their true application. Starting
with View.

viewField, viewFieldGroup, pageLayoutTab and pageLayoutWidget already
extend `OverridableEntity`. This PR brings `view` to the same pattern.

## What this does

- `ViewEntity` now extends `OverridableEntity<ViewOverrides>` (adds
`isActive` boolean + `overrides` jsonb). All editable view properties
are overridable; the 3 fieldMetadata foreign keys are converted to/from
universal identifiers like viewField's `viewFieldGroupId`.
- **Update**: mutations on a view not owned by the caller (e.g. standard
views like "All Companies") are written into `overrides` instead of
mutating the row. Reads merge overrides in
the DTO.
- **Delete/destroy**: views not owned by the caller are deactivated
(`isActive = false`) instead of deleted.
~~- **INDEX invariant**: `key = INDEX` views can only be created via
object-creation side effect. The API now rejects creating, deleting or
destroying INDEX views (object-deletion cascade is unaffected). This was
not really needed for this migration but was flagged during
implementation.~~
- **Front**: views with `isActive = false` are filtered out of the views
selector.
- Fast instance command adds the two columns
(`2-12-instance-command-fast-...-view-overridable-entity.ts`).

## Notes

- Custom (caller-owned) views behave exactly as before: direct updates,
soft delete.
- View-group side effects (kanban groups) are computed on the
override-merged view so overridden `mainGroupByFieldMetadataId` works.
This commit is contained in:
Weiko
2026-06-11 18:00:52 +02:00
committed by GitHub
parent 947a4d5253
commit cfb9772179
35 changed files with 497 additions and 52 deletions
@@ -86,6 +86,8 @@ export const fromCreateViewInputToFlatViewToCreate = ({
universalIdentifier: createViewInput.universalIdentifier ?? v4(),
visibility: createViewInput.visibility ?? ViewVisibility.WORKSPACE,
createdByUserWorkspaceId: createdByUserWorkspaceId ?? null,
isActive: true,
universalOverrides: null,
viewFieldUniversalIdentifiers: [],
viewFilterUniversalIdentifiers: [],
viewGroupUniversalIdentifiers: [],
@@ -6,6 +6,7 @@ import {
import { type FlatViewMaps } from 'src/engine/metadata-modules/flat-view/types/flat-view-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { isCallerOverridingEntity } from 'src/engine/metadata-modules/utils/is-caller-overriding-entity.util';
import { type DeleteViewInput } from 'src/engine/metadata-modules/view/dtos/inputs/delete-view.input';
import {
ViewException,
@@ -16,9 +17,13 @@ import { type UniversalFlatView } from 'src/engine/workspace-manager/workspace-m
export const fromDeleteViewInputToFlatViewOrThrow = ({
deleteViewInput: rawDeleteViewInput,
flatViewMaps,
callerApplicationUniversalIdentifier,
workspaceCustomApplicationUniversalIdentifier,
}: {
deleteViewInput: DeleteViewInput;
flatViewMaps: FlatViewMaps;
callerApplicationUniversalIdentifier: string;
workspaceCustomApplicationUniversalIdentifier: string;
}): UniversalFlatView => {
const { id: viewId } = extractAndSanitizeObjectStringFields(
rawDeleteViewInput,
@@ -37,8 +42,25 @@ export const fromDeleteViewInputToFlatViewOrThrow = ({
);
}
const now = new Date().toISOString();
const shouldDeactivate = isCallerOverridingEntity({
callerApplicationUniversalIdentifier,
entityApplicationUniversalIdentifier:
existingFlatViewToDelete.applicationUniversalIdentifier,
workspaceCustomApplicationUniversalIdentifier,
});
if (shouldDeactivate) {
return {
...existingFlatViewToDelete,
isActive: false,
updatedAt: now,
};
}
return {
...existingFlatViewToDelete,
deletedAt: new Date().toISOString(),
deletedAt: now,
};
};
@@ -12,8 +12,12 @@ import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-m
import { type FlatViewGroupMaps } from 'src/engine/metadata-modules/flat-view-group/types/flat-view-group-maps.type';
import { FLAT_VIEW_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-view/constants/flat-view-editable-properties.constant';
import { type FlatViewMaps } from 'src/engine/metadata-modules/flat-view/types/flat-view-maps.type';
import { fromViewOverridesToUniversalOverrides } from 'src/engine/metadata-modules/flat-view/utils/from-view-overrides-to-universal-overrides.util';
import { handleFlatViewUpdateSideEffect } from 'src/engine/metadata-modules/flat-view/utils/handle-flat-view-update-side-effect.util';
import { isCallerOverridingEntity } from 'src/engine/metadata-modules/utils/is-caller-overriding-entity.util';
import { sanitizeOverridableEntityInput } from 'src/engine/metadata-modules/utils/sanitize-overridable-entity-input.util';
import { type UpdateViewInput } from 'src/engine/metadata-modules/view/dtos/inputs/update-view.input';
import { type ViewOverrides } from 'src/engine/metadata-modules/view/entities/view.entity';
import {
ViewException,
ViewExceptionCode,
@@ -28,12 +32,16 @@ export const fromUpdateViewInputToFlatViewToUpdateOrThrow = ({
flatViewGroupMaps,
flatFieldMetadataMaps,
userWorkspaceId,
callerApplicationUniversalIdentifier,
workspaceCustomApplicationUniversalIdentifier,
}: {
updateViewInput: UpdateViewInput;
flatViewMaps: FlatViewMaps;
flatViewGroupMaps: FlatViewGroupMaps;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
userWorkspaceId?: string;
callerApplicationUniversalIdentifier: string;
workspaceCustomApplicationUniversalIdentifier: string;
}): {
flatViewToUpdate: UniversalFlatView;
flatViewGroupsToDelete: UniversalFlatViewGroup[];
@@ -57,19 +65,39 @@ export const fromUpdateViewInputToFlatViewToUpdateOrThrow = ({
);
}
const updatedEditableFieldProperties = extractAndSanitizeObjectStringFields(
const editableProperties = extractAndSanitizeObjectStringFields(
rawUpdateViewInput,
FLAT_VIEW_EDITABLE_PROPERTIES,
);
const flatViewToUpdate = mergeUpdateInExistingRecord({
existing: existingFlatViewToUpdate,
properties: FLAT_VIEW_EDITABLE_PROPERTIES,
update: updatedEditableFieldProperties,
const shouldOverride = isCallerOverridingEntity({
callerApplicationUniversalIdentifier,
entityApplicationUniversalIdentifier:
existingFlatViewToUpdate.applicationUniversalIdentifier,
workspaceCustomApplicationUniversalIdentifier,
});
const { overrides, updatedEditableProperties } =
sanitizeOverridableEntityInput({
metadataName: 'view',
existingFlatEntity: existingFlatViewToUpdate,
updatedEditableProperties: editableProperties,
shouldOverride,
});
const mergedRecord = mergeUpdateInExistingRecord({
existing: existingFlatViewToUpdate,
properties: [...FLAT_VIEW_EDITABLE_PROPERTIES],
update: updatedEditableProperties,
});
const flatViewToUpdate = {
...mergedRecord,
overrides,
} as UniversalFlatView;
if (
updatedEditableFieldProperties.kanbanAggregateOperationFieldMetadataId !==
updatedEditableProperties.kanbanAggregateOperationFieldMetadataId !==
undefined
) {
const { kanbanAggregateOperationFieldMetadataUniversalIdentifier } =
@@ -77,7 +105,7 @@ export const fromUpdateViewInputToFlatViewToUpdateOrThrow = ({
metadataName: 'view',
foreignKeyValues: {
kanbanAggregateOperationFieldMetadataId:
flatViewToUpdate.kanbanAggregateOperationFieldMetadataId,
mergedRecord.kanbanAggregateOperationFieldMetadataId,
},
flatEntityMaps: { flatFieldMetadataMaps },
});
@@ -86,12 +114,12 @@ export const fromUpdateViewInputToFlatViewToUpdateOrThrow = ({
kanbanAggregateOperationFieldMetadataUniversalIdentifier;
}
if (updatedEditableFieldProperties.calendarFieldMetadataId !== undefined) {
if (updatedEditableProperties.calendarFieldMetadataId !== undefined) {
const { calendarFieldMetadataUniversalIdentifier } =
resolveEntityRelationUniversalIdentifiers({
metadataName: 'view',
foreignKeyValues: {
calendarFieldMetadataId: flatViewToUpdate.calendarFieldMetadataId,
calendarFieldMetadataId: mergedRecord.calendarFieldMetadataId,
},
flatEntityMaps: { flatFieldMetadataMaps },
});
@@ -100,13 +128,12 @@ export const fromUpdateViewInputToFlatViewToUpdateOrThrow = ({
calendarFieldMetadataUniversalIdentifier;
}
if (updatedEditableFieldProperties.mainGroupByFieldMetadataId !== undefined) {
if (updatedEditableProperties.mainGroupByFieldMetadataId !== undefined) {
const { mainGroupByFieldMetadataUniversalIdentifier } =
resolveEntityRelationUniversalIdentifiers({
metadataName: 'view',
foreignKeyValues: {
mainGroupByFieldMetadataId:
flatViewToUpdate.mainGroupByFieldMetadataId,
mainGroupByFieldMetadataId: mergedRecord.mainGroupByFieldMetadataId,
},
flatEntityMaps: { flatFieldMetadataMaps },
});
@@ -115,6 +142,18 @@ export const fromUpdateViewInputToFlatViewToUpdateOrThrow = ({
mainGroupByFieldMetadataUniversalIdentifier;
}
if (isDefined(overrides)) {
flatViewToUpdate.universalOverrides = fromViewOverridesToUniversalOverrides(
{
overrides: overrides as ViewOverrides,
fieldMetadataUniversalIdentifierById:
flatFieldMetadataMaps.universalIdentifierById,
},
);
} else {
flatViewToUpdate.universalOverrides = null;
}
// If changing visibility from WORKSPACE to UNLISTED, ensure createdByUserWorkspaceId is set
// This prevents the view from disappearing for the user making the change
if (
@@ -127,10 +166,15 @@ export const fromUpdateViewInputToFlatViewToUpdateOrThrow = ({
flatViewToUpdate.createdByUserWorkspaceId = userWorkspaceId;
}
const effectiveFlatViewToUpdate = {
...mergedRecord,
...((overrides as ViewOverrides | null) ?? {}),
};
const { flatViewGroupsToDelete, flatViewGroupsToCreate } =
handleFlatViewUpdateSideEffect({
fromFlatView: existingFlatViewToUpdate,
toFlatView: flatViewToUpdate,
toFlatView: effectiveFlatViewToUpdate,
flatViewGroupMaps: flatViewGroupMaps,
flatFieldMetadataMaps: flatFieldMetadataMaps,
});
@@ -6,6 +6,7 @@ import {
} from 'src/engine/metadata-modules/flat-entity/exceptions/flat-entity-maps.exception';
import { getMetadataEntityRelationProperties } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-entity-relation-properties.util';
import { type FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type';
import { fromViewOverridesToUniversalOverrides } from 'src/engine/metadata-modules/flat-view/utils/from-view-overrides-to-universal-overrides.util';
import { type FromEntityToFlatEntityArgs } from 'src/engine/workspace-cache/types/from-entity-to-flat-entity-args.type';
export const fromViewEntityToFlatView = ({
@@ -88,12 +89,23 @@ export const fromViewEntityToFlatView = ({
}
}
const universalOverrides = isDefined(viewEntity.overrides)
? fromViewOverridesToUniversalOverrides({
overrides: viewEntity.overrides,
fieldMetadataUniversalIdentifierById: Object.fromEntries(
fieldMetadataIdToUniversalIdentifierMap.entries(),
),
shouldThrowOnMissingIdentifier: false,
})
: null;
return {
...viewEntityWithoutRelations,
createdAt: viewEntity.createdAt.toISOString(),
updatedAt: viewEntity.updatedAt.toISOString(),
deletedAt: viewEntity.deletedAt?.toISOString() ?? null,
universalIdentifier: viewEntityWithoutRelations.universalIdentifier,
universalOverrides,
viewFieldIds: viewEntity.viewFields.map(({ id }) => id),
viewFieldGroupIds: viewEntity.viewFieldGroups?.map(({ id }) => id) ?? [],
viewFilterIds: viewEntity.viewFilters.map(({ id }) => id),
@@ -0,0 +1,79 @@
import { type FormatRecordSerializedRelationProperties } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
FlatEntityMapsException,
FlatEntityMapsExceptionCode,
} from 'src/engine/metadata-modules/flat-entity/exceptions/flat-entity-maps.exception';
import { type ViewOverrides } from 'src/engine/metadata-modules/view/entities/view.entity';
type UniversalViewOverrides =
FormatRecordSerializedRelationProperties<ViewOverrides>;
const VIEW_OVERRIDES_FIELD_METADATA_FOREIGN_KEYS = [
'kanbanAggregateOperationFieldMetadataId',
'calendarFieldMetadataId',
'mainGroupByFieldMetadataId',
] as const;
type ViewOverridesFieldMetadataForeignKey =
(typeof VIEW_OVERRIDES_FIELD_METADATA_FOREIGN_KEYS)[number];
const toUniversalIdentifierProperty = (
foreignKey: ViewOverridesFieldMetadataForeignKey,
) =>
foreignKey.replace(
/Id$/,
'UniversalIdentifier',
) as keyof UniversalViewOverrides;
export const fromViewOverridesToUniversalOverrides = ({
overrides,
fieldMetadataUniversalIdentifierById,
shouldThrowOnMissingIdentifier = true,
}: {
overrides: ViewOverrides;
fieldMetadataUniversalIdentifierById: Partial<Record<string, string>>;
shouldThrowOnMissingIdentifier?: boolean;
}): UniversalViewOverrides => {
const {
kanbanAggregateOperationFieldMetadataId: _kanban,
calendarFieldMetadataId: _calendar,
mainGroupByFieldMetadataId: _mainGroupBy,
...scalarOverrides
} = overrides;
return VIEW_OVERRIDES_FIELD_METADATA_FOREIGN_KEYS.reduce<UniversalViewOverrides>(
(acc, foreignKey) => {
const foreignKeyValue = overrides[foreignKey];
if (foreignKeyValue === undefined) {
return acc;
}
const universalIdentifierProperty =
toUniversalIdentifierProperty(foreignKey);
if (foreignKeyValue === null) {
return { ...acc, [universalIdentifierProperty]: null };
}
const universalIdentifier =
fieldMetadataUniversalIdentifierById[foreignKeyValue];
if (!isDefined(universalIdentifier)) {
if (shouldThrowOnMissingIdentifier) {
throw new FlatEntityMapsException(
`FieldMetadata universal identifier not found for id: ${foreignKeyValue}`,
FlatEntityMapsExceptionCode.RELATION_UNIVERSAL_IDENTIFIER_NOT_FOUND,
);
}
return { ...acc, [universalIdentifierProperty]: null };
}
return { ...acc, [universalIdentifierProperty]: universalIdentifier };
},
scalarOverrides,
);
};