[Call Recording] Add standard object (#21158)
Adds **Call Recording** as a first-class standard object (Twenty's
flat-metadata
standard-object system), with a hidden junction to calendar events and a
backfill
command for existing workspaces. Everything is gated behind the
`IS_CALL_RECORDING_ENABLED` feature flag.
### What's included
- **`CallRecording`**: audio/video files, transcript, status, recording
policy,
timing, external bot/recording ids. Label identifier is
`meetingOccurrenceKey`.
- **`CallRecordingCalendarEventAssociation`**: hidden junction linking a
recording
to a calendar event (dedupes one bot to many subscribers of the same
meeting).
- Full metadata graph via the flat-metadata builders: fields, indexes,
views,
view fields/groups, record page layout, and navigation items.
- **Metadata-only reverse relation** on `CalendarEvent`: present in
standard
metadata, omitted from the TS entity class to avoid expanding recursive
nested-insert types.
- **Upgrade command (2.9.0)** backfilling active/suspended workspaces:
- Creates the full graph; idempotent (skips when it already exists).
- Moves a colliding custom `callRecording` object aside to
`callRecordingOld`
(numeric suffix if that name is also taken).
- Navigation items (commands) are flag-gated by `universalIdentifier`,
so a custom object
reusing the name is never gated.
### QA
Run locally against existing workspaces (with and without a name
collision) and a
freshly created workspace:
- [x] Backfill, collision: custom `callRecording` renamed to
`callRecordingOld`;
standard graph created.
- [x] Backfill, no collision: standard graph created; unrelated custom
object untouched.
- [x] Idempotent: re-run is a no-op, with no duplicate metadata and
counts unchanged.
- [x] New workspace via `init()` produces an identical graph to the
backfill
(`universalIdentifier` set-diff = 0).
- [x] Label identifier (`meetingOccurrenceKey`) holds position 0 in
non-widget views.
- [x] Nav items gated behind the feature flag; collision-renamed
object's nav
expression re-pointed to its new name.
- [x] Unit tests cover collision name resolution and nav-gating logic.
This commit is contained in:
+2
@@ -5,6 +5,7 @@ import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/w
|
||||
import { AddInactiveGenericStandardFieldsCommand } from 'src/database/commands/upgrade-version-command/2-10/2-10-workspace-command-1799000050000-add-inactive-generic-standard-fields.command';
|
||||
import { MoveDemotedStandardFieldsToCustomApplicationCommand } from 'src/database/commands/upgrade-version-command/2-10/2-10-workspace-command-1799000040000-move-demoted-standard-fields-to-custom-application.command';
|
||||
import { RenameConflictingCustomFieldsCommand } from 'src/database/commands/upgrade-version-command/2-10/2-10-workspace-command-1799000045000-rename-conflicting-custom-fields.command';
|
||||
import { SyncCallRecordingStandardObjectsCommand } from 'src/database/commands/upgrade-version-command/2-10/2-10-workspace-command-1799000055000-sync-call-recording-standard-objects.command';
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module';
|
||||
@@ -24,6 +25,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
|
||||
MoveDemotedStandardFieldsToCustomApplicationCommand,
|
||||
RenameConflictingCustomFieldsCommand,
|
||||
AddInactiveGenericStandardFieldsCommand,
|
||||
SyncCallRecordingStandardObjectsCommand,
|
||||
],
|
||||
})
|
||||
export class V2_10_UpgradeVersionCommandModule {}
|
||||
|
||||
+425
@@ -0,0 +1,425 @@
|
||||
import { Command } from 'nest-commander';
|
||||
import {
|
||||
STANDARD_OBJECTS,
|
||||
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'twenty-shared/metadata';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
|
||||
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
|
||||
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
|
||||
import { buildNavigationCommandMenuItemOperationsOrThrow } from 'src/database/commands/upgrade-version-command/2-10/utils/build-navigation-command-menu-item-operations-or-throw.util';
|
||||
import {
|
||||
buildCalendarEventFieldRenameUpdates,
|
||||
buildCallRecordingObjectRenameUpdates,
|
||||
} from 'src/database/commands/upgrade-version-command/2-10/utils/call-recording-name-collision.util';
|
||||
import {
|
||||
getExistingOrStandardFlatEntityOrThrow,
|
||||
getStandardFlatEntitiesToCreateOrThrow,
|
||||
} from 'src/database/commands/upgrade-version-command/2-10/utils/get-standard-flat-entities-to-create-or-throw.util';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
import { type FlatPageLayoutTab } from 'src/engine/metadata-modules/flat-page-layout-tab/types/flat-page-layout-tab.type';
|
||||
import { type FlatPageLayoutWidget } from 'src/engine/metadata-modules/flat-page-layout-widget/types/flat-page-layout-widget.type';
|
||||
import { type FlatPageLayout } from 'src/engine/metadata-modules/flat-page-layout/types/flat-page-layout.type';
|
||||
import { type FlatViewFieldGroup } from 'src/engine/metadata-modules/flat-view-field-group/types/flat-view-field-group.type';
|
||||
import { type FlatViewField } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field.type';
|
||||
import { type FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
import { computeTwentyStandardApplicationAllFlatEntityMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant';
|
||||
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
|
||||
|
||||
const getUniversalIdentifiers = (
|
||||
entitiesByName: Record<string, { universalIdentifier: string }>,
|
||||
): string[] =>
|
||||
Object.values(entitiesByName).map((entity) => entity.universalIdentifier);
|
||||
|
||||
const CALL_RECORDING_OBJECT_METADATA_UNIVERSAL_IDENTIFIERS = [
|
||||
STANDARD_OBJECTS.callRecording.universalIdentifier,
|
||||
];
|
||||
|
||||
const CALL_RECORDING_FIELD_METADATA_UNIVERSAL_IDENTIFIERS = [
|
||||
...getUniversalIdentifiers(STANDARD_OBJECTS.callRecording.fields),
|
||||
STANDARD_OBJECTS.calendarEvent.fields.recordingPreference.universalIdentifier,
|
||||
STANDARD_OBJECTS.calendarEvent.fields.callRecordings.universalIdentifier,
|
||||
];
|
||||
|
||||
const CALL_RECORDING_INDEX_UNIVERSAL_IDENTIFIERS = getUniversalIdentifiers(
|
||||
STANDARD_OBJECTS.callRecording.indexes,
|
||||
);
|
||||
|
||||
const CALL_RECORDING_VIEW_UNIVERSAL_IDENTIFIERS = [
|
||||
STANDARD_OBJECTS.callRecording.views.allCallRecordings.universalIdentifier,
|
||||
STANDARD_OBJECTS.callRecording.views.callRecordingRecordPageFields
|
||||
.universalIdentifier,
|
||||
];
|
||||
|
||||
const CALL_RECORDING_VIEW_FIELD_GROUP_UNIVERSAL_IDENTIFIERS =
|
||||
getUniversalIdentifiers(
|
||||
STANDARD_OBJECTS.callRecording.views.callRecordingRecordPageFields
|
||||
.viewFieldGroups,
|
||||
);
|
||||
|
||||
const CALL_RECORDING_VIEW_FIELD_UNIVERSAL_IDENTIFIERS = [
|
||||
...getUniversalIdentifiers(
|
||||
STANDARD_OBJECTS.callRecording.views.allCallRecordings.viewFields,
|
||||
),
|
||||
...getUniversalIdentifiers(
|
||||
STANDARD_OBJECTS.callRecording.views.callRecordingRecordPageFields
|
||||
.viewFields,
|
||||
),
|
||||
];
|
||||
|
||||
const CALL_RECORDING_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS = [
|
||||
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.callRecordingRecordPage
|
||||
.universalIdentifier,
|
||||
];
|
||||
|
||||
const CALL_RECORDING_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIERS = [
|
||||
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.callRecordingRecordPage.tabs.home
|
||||
.universalIdentifier,
|
||||
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.callRecordingRecordPage.tabs
|
||||
.timeline.universalIdentifier,
|
||||
];
|
||||
|
||||
const CALL_RECORDING_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIERS = [
|
||||
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.callRecordingRecordPage.tabs.home
|
||||
.widgets.fields.universalIdentifier,
|
||||
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.callRecordingRecordPage.tabs
|
||||
.timeline.widgets.timeline.universalIdentifier,
|
||||
];
|
||||
|
||||
@RegisteredWorkspaceCommand('2.10.0', 1799000055000)
|
||||
@Command({
|
||||
name: 'upgrade:2-10:sync-call-recording-standard-objects',
|
||||
description:
|
||||
'Create the CallRecording standard metadata in existing workspaces',
|
||||
})
|
||||
export class SyncCallRecordingStandardObjectsCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
|
||||
constructor(
|
||||
protected readonly workspaceIteratorService: WorkspaceIteratorService,
|
||||
private readonly applicationService: ApplicationService,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
|
||||
) {
|
||||
super(workspaceIteratorService);
|
||||
}
|
||||
|
||||
override async runOnWorkspace({
|
||||
workspaceId,
|
||||
options,
|
||||
}: RunOnWorkspaceArgs): Promise<void> {
|
||||
const isDryRun = options.dryRun ?? false;
|
||||
|
||||
const {
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
flatIndexMaps,
|
||||
flatViewMaps,
|
||||
flatViewFieldMaps,
|
||||
flatViewFieldGroupMaps,
|
||||
flatPageLayoutMaps,
|
||||
flatPageLayoutTabMaps,
|
||||
flatPageLayoutWidgetMaps,
|
||||
flatCommandMenuItemMaps,
|
||||
} = await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
||||
'flatObjectMetadataMaps',
|
||||
'flatFieldMetadataMaps',
|
||||
'flatIndexMaps',
|
||||
'flatViewMaps',
|
||||
'flatViewFieldMaps',
|
||||
'flatViewFieldGroupMaps',
|
||||
'flatPageLayoutMaps',
|
||||
'flatPageLayoutTabMaps',
|
||||
'flatPageLayoutWidgetMaps',
|
||||
'flatCommandMenuItemMaps',
|
||||
]);
|
||||
|
||||
const calendarEventObjectMetadata =
|
||||
flatObjectMetadataMaps.byUniversalIdentifier[
|
||||
STANDARD_OBJECTS.calendarEvent.universalIdentifier
|
||||
];
|
||||
|
||||
if (!isDefined(calendarEventObjectMetadata)) {
|
||||
throw new Error(
|
||||
`calendarEvent object not found for workspace ${workspaceId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const { twentyStandardFlatApplication } =
|
||||
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
|
||||
{ workspaceId },
|
||||
);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const { allFlatEntityMaps: standardAllFlatEntityMaps } =
|
||||
computeTwentyStandardApplicationAllFlatEntityMaps({
|
||||
now,
|
||||
workspaceId,
|
||||
twentyStandardApplicationId: twentyStandardFlatApplication.id,
|
||||
});
|
||||
|
||||
const objectMetadataRenameUpdates = buildCallRecordingObjectRenameUpdates({
|
||||
flatObjectMetadataMaps,
|
||||
now,
|
||||
});
|
||||
const fieldMetadataRenameUpdates = buildCalendarEventFieldRenameUpdates({
|
||||
flatFieldMetadataMaps,
|
||||
now,
|
||||
});
|
||||
const renamedCollisionObjectMetadatas = objectMetadataRenameUpdates.map(
|
||||
(objectMetadata) => ({
|
||||
universalIdentifier: objectMetadata.universalIdentifier,
|
||||
nameSingular: objectMetadata.nameSingular,
|
||||
}),
|
||||
);
|
||||
|
||||
const callRecordingObjectMetadataForNavigation =
|
||||
getExistingOrStandardFlatEntityOrThrow<FlatObjectMetadata>({
|
||||
standardFlatEntityMaps:
|
||||
standardAllFlatEntityMaps.flatObjectMetadataMaps,
|
||||
existingFlatEntityMaps: flatObjectMetadataMaps,
|
||||
universalIdentifier: STANDARD_OBJECTS.callRecording.universalIdentifier,
|
||||
});
|
||||
|
||||
const navigationCommandMenuItemOperations =
|
||||
buildNavigationCommandMenuItemOperationsOrThrow({
|
||||
existingFlatCommandMenuItemMaps: flatCommandMenuItemMaps,
|
||||
objectMetadatasForNavigation: [
|
||||
callRecordingObjectMetadataForNavigation,
|
||||
],
|
||||
applicationId: twentyStandardFlatApplication.id,
|
||||
workspaceId,
|
||||
now,
|
||||
renamedCollisionObjectMetadatas,
|
||||
});
|
||||
|
||||
const allFlatEntityOperationByMetadataName = {
|
||||
objectMetadata: {
|
||||
flatEntityToCreate:
|
||||
getStandardFlatEntitiesToCreateOrThrow<FlatObjectMetadata>({
|
||||
standardFlatEntityMaps:
|
||||
standardAllFlatEntityMaps.flatObjectMetadataMaps,
|
||||
existingFlatEntityMaps: flatObjectMetadataMaps,
|
||||
universalIdentifiers:
|
||||
CALL_RECORDING_OBJECT_METADATA_UNIVERSAL_IDENTIFIERS,
|
||||
}),
|
||||
flatEntityToDelete: [],
|
||||
flatEntityToUpdate: [],
|
||||
},
|
||||
fieldMetadata: {
|
||||
flatEntityToCreate:
|
||||
getStandardFlatEntitiesToCreateOrThrow<FlatFieldMetadata>({
|
||||
standardFlatEntityMaps:
|
||||
standardAllFlatEntityMaps.flatFieldMetadataMaps,
|
||||
existingFlatEntityMaps: flatFieldMetadataMaps,
|
||||
universalIdentifiers:
|
||||
CALL_RECORDING_FIELD_METADATA_UNIVERSAL_IDENTIFIERS,
|
||||
}),
|
||||
flatEntityToDelete: [],
|
||||
flatEntityToUpdate: [],
|
||||
},
|
||||
index: {
|
||||
flatEntityToCreate:
|
||||
getStandardFlatEntitiesToCreateOrThrow<FlatIndexMetadata>({
|
||||
standardFlatEntityMaps: standardAllFlatEntityMaps.flatIndexMaps,
|
||||
existingFlatEntityMaps: flatIndexMaps,
|
||||
universalIdentifiers: CALL_RECORDING_INDEX_UNIVERSAL_IDENTIFIERS,
|
||||
}),
|
||||
flatEntityToDelete: [],
|
||||
flatEntityToUpdate: [],
|
||||
},
|
||||
view: {
|
||||
flatEntityToCreate: getStandardFlatEntitiesToCreateOrThrow<FlatView>({
|
||||
standardFlatEntityMaps: standardAllFlatEntityMaps.flatViewMaps,
|
||||
existingFlatEntityMaps: flatViewMaps,
|
||||
universalIdentifiers: CALL_RECORDING_VIEW_UNIVERSAL_IDENTIFIERS,
|
||||
}),
|
||||
flatEntityToDelete: [],
|
||||
flatEntityToUpdate: [],
|
||||
},
|
||||
viewFieldGroup: {
|
||||
flatEntityToCreate:
|
||||
getStandardFlatEntitiesToCreateOrThrow<FlatViewFieldGroup>({
|
||||
standardFlatEntityMaps:
|
||||
standardAllFlatEntityMaps.flatViewFieldGroupMaps,
|
||||
existingFlatEntityMaps: flatViewFieldGroupMaps,
|
||||
universalIdentifiers:
|
||||
CALL_RECORDING_VIEW_FIELD_GROUP_UNIVERSAL_IDENTIFIERS,
|
||||
}),
|
||||
flatEntityToDelete: [],
|
||||
flatEntityToUpdate: [],
|
||||
},
|
||||
viewField: {
|
||||
flatEntityToCreate:
|
||||
getStandardFlatEntitiesToCreateOrThrow<FlatViewField>({
|
||||
standardFlatEntityMaps: standardAllFlatEntityMaps.flatViewFieldMaps,
|
||||
existingFlatEntityMaps: flatViewFieldMaps,
|
||||
universalIdentifiers:
|
||||
CALL_RECORDING_VIEW_FIELD_UNIVERSAL_IDENTIFIERS,
|
||||
}),
|
||||
flatEntityToDelete: [],
|
||||
flatEntityToUpdate: [],
|
||||
},
|
||||
pageLayout: {
|
||||
flatEntityToCreate:
|
||||
getStandardFlatEntitiesToCreateOrThrow<FlatPageLayout>({
|
||||
standardFlatEntityMaps:
|
||||
standardAllFlatEntityMaps.flatPageLayoutMaps,
|
||||
existingFlatEntityMaps: flatPageLayoutMaps,
|
||||
universalIdentifiers:
|
||||
CALL_RECORDING_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS,
|
||||
}),
|
||||
flatEntityToDelete: [],
|
||||
flatEntityToUpdate: [],
|
||||
},
|
||||
pageLayoutTab: {
|
||||
flatEntityToCreate:
|
||||
getStandardFlatEntitiesToCreateOrThrow<FlatPageLayoutTab>({
|
||||
standardFlatEntityMaps:
|
||||
standardAllFlatEntityMaps.flatPageLayoutTabMaps,
|
||||
existingFlatEntityMaps: flatPageLayoutTabMaps,
|
||||
universalIdentifiers:
|
||||
CALL_RECORDING_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIERS,
|
||||
}),
|
||||
flatEntityToDelete: [],
|
||||
flatEntityToUpdate: [],
|
||||
},
|
||||
pageLayoutWidget: {
|
||||
flatEntityToCreate:
|
||||
getStandardFlatEntitiesToCreateOrThrow<FlatPageLayoutWidget>({
|
||||
standardFlatEntityMaps:
|
||||
standardAllFlatEntityMaps.flatPageLayoutWidgetMaps,
|
||||
existingFlatEntityMaps: flatPageLayoutWidgetMaps,
|
||||
universalIdentifiers:
|
||||
CALL_RECORDING_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIERS,
|
||||
}),
|
||||
flatEntityToDelete: [],
|
||||
flatEntityToUpdate: [],
|
||||
},
|
||||
commandMenuItem: navigationCommandMenuItemOperations,
|
||||
};
|
||||
|
||||
const totalOperationCount =
|
||||
objectMetadataRenameUpdates.length +
|
||||
fieldMetadataRenameUpdates.length +
|
||||
Object.values(allFlatEntityOperationByMetadataName).reduce(
|
||||
(total, operations) =>
|
||||
total +
|
||||
operations.flatEntityToCreate.length +
|
||||
operations.flatEntityToUpdate.length,
|
||||
0,
|
||||
);
|
||||
|
||||
if (totalOperationCount === 0) {
|
||||
this.logger.log(
|
||||
`CallRecording standard metadata already exists for workspace ${workspaceId}, skipping`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDryRun) {
|
||||
if (objectMetadataRenameUpdates.length > 0) {
|
||||
this.logger.log(
|
||||
`[DRY RUN] Would rename ${objectMetadataRenameUpdates.length} CallRecording object name collision(s) for workspace ${workspaceId}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (fieldMetadataRenameUpdates.length > 0) {
|
||||
this.logger.log(
|
||||
`[DRY RUN] Would rename ${fieldMetadataRenameUpdates.length} calendarEvent field name collision(s) for workspace ${workspaceId}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`[DRY RUN] Would apply ${totalOperationCount} CallRecording standard metadata operations for workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Renames must commit before the create: a combined create + rename
|
||||
// migration trips the namePlural unique index.
|
||||
const collisionRenameMigrations = [
|
||||
...objectMetadataRenameUpdates.map((flatObjectMetadata) => ({
|
||||
applicationUniversalIdentifier:
|
||||
flatObjectMetadata.applicationUniversalIdentifier,
|
||||
allFlatEntityOperationByMetadataName: {
|
||||
objectMetadata: {
|
||||
flatEntityToCreate: [],
|
||||
flatEntityToDelete: [],
|
||||
flatEntityToUpdate: [flatObjectMetadata],
|
||||
},
|
||||
},
|
||||
})),
|
||||
...fieldMetadataRenameUpdates.map((flatFieldMetadata) => ({
|
||||
applicationUniversalIdentifier:
|
||||
flatFieldMetadata.applicationUniversalIdentifier,
|
||||
allFlatEntityOperationByMetadataName: {
|
||||
fieldMetadata: {
|
||||
flatEntityToCreate: [],
|
||||
flatEntityToDelete: [],
|
||||
flatEntityToUpdate: [flatFieldMetadata],
|
||||
},
|
||||
},
|
||||
})),
|
||||
];
|
||||
|
||||
// Collisions can belong to different applications, so each rename runs as
|
||||
// its own migration scoped to the colliding entity's application.
|
||||
for (const {
|
||||
applicationUniversalIdentifier,
|
||||
allFlatEntityOperationByMetadataName,
|
||||
} of collisionRenameMigrations) {
|
||||
const renameResult =
|
||||
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
|
||||
{
|
||||
isSystemBuild: true,
|
||||
applicationUniversalIdentifier,
|
||||
workspaceId,
|
||||
allFlatEntityOperationByMetadataName,
|
||||
},
|
||||
);
|
||||
|
||||
if (renameResult.status === 'fail') {
|
||||
throw new Error(
|
||||
`Failed to rename CallRecording name collision for workspace ${workspaceId}: ${JSON.stringify(
|
||||
renameResult,
|
||||
null,
|
||||
2,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const validateAndBuildResult =
|
||||
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
|
||||
{
|
||||
isSystemBuild: true,
|
||||
applicationUniversalIdentifier:
|
||||
twentyStandardFlatApplication.universalIdentifier,
|
||||
workspaceId,
|
||||
allFlatEntityOperationByMetadataName,
|
||||
},
|
||||
);
|
||||
|
||||
if (validateAndBuildResult.status === 'fail') {
|
||||
throw new Error(
|
||||
`Failed to create CallRecording standard objects for workspace ${workspaceId}: ${JSON.stringify(
|
||||
validateAndBuildResult,
|
||||
null,
|
||||
2,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Applied ${totalOperationCount} CallRecording standard metadata operations for workspace ${workspaceId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
import { v5 } from 'uuid';
|
||||
|
||||
import { buildNavigationCommandMenuItemOperationsOrThrow } from 'src/database/commands/upgrade-version-command/2-10/utils/build-navigation-command-menu-item-operations-or-throw.util';
|
||||
import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type';
|
||||
import {
|
||||
buildNavigationFlatCommandMenuItem,
|
||||
NAVIGATION_COMMAND_UUID_NAMESPACE,
|
||||
} from 'src/engine/metadata-modules/flat-command-menu-item/utils/build-navigation-flat-command-menu-item.util';
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { getFlatObjectMetadataMock } from 'src/engine/metadata-modules/flat-object-metadata/__mocks__/get-flat-object-metadata.mock';
|
||||
|
||||
const APPLICATION_ID = 'application-id';
|
||||
const WORKSPACE_ID = 'workspace-id';
|
||||
const NOW = '2026-06-04T00:00:00.000Z';
|
||||
|
||||
const buildFlatCommandMenuItemMaps = (
|
||||
flatCommandMenuItems: FlatCommandMenuItem[],
|
||||
): FlatEntityMaps<FlatCommandMenuItem> => ({
|
||||
byUniversalIdentifier: Object.fromEntries(
|
||||
flatCommandMenuItems.map((flatCommandMenuItem) => [
|
||||
flatCommandMenuItem.universalIdentifier,
|
||||
flatCommandMenuItem,
|
||||
]),
|
||||
),
|
||||
universalIdentifierById: Object.fromEntries(
|
||||
flatCommandMenuItems.map((flatCommandMenuItem) => [
|
||||
flatCommandMenuItem.id,
|
||||
flatCommandMenuItem.universalIdentifier,
|
||||
]),
|
||||
),
|
||||
universalIdentifiersByApplicationId: {},
|
||||
});
|
||||
|
||||
const buildExistingNavigationItem = ({
|
||||
objectId,
|
||||
objectUniversalIdentifier,
|
||||
nameSingular,
|
||||
position,
|
||||
}: {
|
||||
objectId: string;
|
||||
objectUniversalIdentifier: string;
|
||||
nameSingular: string;
|
||||
position: number;
|
||||
}): FlatCommandMenuItem =>
|
||||
buildNavigationFlatCommandMenuItem({
|
||||
objectMetadata: {
|
||||
id: objectId,
|
||||
universalIdentifier: objectUniversalIdentifier,
|
||||
nameSingular,
|
||||
shortcut: null,
|
||||
},
|
||||
commandMenuItemId: `command-menu-item-${objectUniversalIdentifier}`,
|
||||
applicationId: APPLICATION_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
position,
|
||||
now: NOW,
|
||||
});
|
||||
|
||||
describe('buildNavigationCommandMenuItemOperationsOrThrow', () => {
|
||||
it('creates a navigation item for an active object that has no existing navigation', () => {
|
||||
const objectMetadata = getFlatObjectMetadataMock({
|
||||
universalIdentifier: 'call-recording',
|
||||
nameSingular: 'callRecording',
|
||||
namePlural: 'callRecordings',
|
||||
});
|
||||
|
||||
const result = buildNavigationCommandMenuItemOperationsOrThrow({
|
||||
existingFlatCommandMenuItemMaps: buildFlatCommandMenuItemMaps([]),
|
||||
objectMetadatasForNavigation: [objectMetadata],
|
||||
applicationId: APPLICATION_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
now: NOW,
|
||||
renamedCollisionObjectMetadatas: [],
|
||||
});
|
||||
|
||||
expect(result.flatEntityToCreate).toHaveLength(1);
|
||||
expect(result.flatEntityToCreate[0].universalIdentifier).toBe(
|
||||
v5('call-recording', NAVIGATION_COMMAND_UUID_NAMESPACE),
|
||||
);
|
||||
expect(result.flatEntityToCreate[0].position).toBe(0);
|
||||
expect(result.flatEntityToCreate[0].payload).toEqual({
|
||||
objectMetadataItemId: objectMetadata.id,
|
||||
});
|
||||
expect(result.flatEntityToUpdate).toHaveLength(0);
|
||||
expect(result.flatEntityToDelete).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('uses the provided object id in the navigation payload', () => {
|
||||
const objectMetadata = getFlatObjectMetadataMock({
|
||||
id: 'existing-call-recording-object-id',
|
||||
universalIdentifier: 'call-recording',
|
||||
nameSingular: 'callRecording',
|
||||
namePlural: 'callRecordings',
|
||||
});
|
||||
|
||||
const result = buildNavigationCommandMenuItemOperationsOrThrow({
|
||||
existingFlatCommandMenuItemMaps: buildFlatCommandMenuItemMaps([]),
|
||||
objectMetadatasForNavigation: [objectMetadata],
|
||||
applicationId: APPLICATION_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
now: NOW,
|
||||
renamedCollisionObjectMetadatas: [],
|
||||
});
|
||||
|
||||
expect(result.flatEntityToCreate[0].payload).toEqual({
|
||||
objectMetadataItemId: 'existing-call-recording-object-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not create a navigation item for an inactive object', () => {
|
||||
const objectMetadata = getFlatObjectMetadataMock({
|
||||
universalIdentifier: 'call-recording',
|
||||
nameSingular: 'callRecording',
|
||||
namePlural: 'callRecordings',
|
||||
isActive: false,
|
||||
});
|
||||
|
||||
const result = buildNavigationCommandMenuItemOperationsOrThrow({
|
||||
existingFlatCommandMenuItemMaps: buildFlatCommandMenuItemMaps([]),
|
||||
objectMetadatasForNavigation: [objectMetadata],
|
||||
applicationId: APPLICATION_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
now: NOW,
|
||||
renamedCollisionObjectMetadatas: [],
|
||||
});
|
||||
|
||||
expect(result.flatEntityToCreate).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not recreate a navigation item that already exists (idempotent)', () => {
|
||||
const objectMetadata = getFlatObjectMetadataMock({
|
||||
universalIdentifier: 'call-recording',
|
||||
nameSingular: 'callRecording',
|
||||
namePlural: 'callRecordings',
|
||||
});
|
||||
const existingNavigationItem = buildExistingNavigationItem({
|
||||
objectId: objectMetadata.id,
|
||||
objectUniversalIdentifier: 'call-recording',
|
||||
nameSingular: 'callRecording',
|
||||
position: 0,
|
||||
});
|
||||
|
||||
const result = buildNavigationCommandMenuItemOperationsOrThrow({
|
||||
existingFlatCommandMenuItemMaps: buildFlatCommandMenuItemMaps([
|
||||
existingNavigationItem,
|
||||
]),
|
||||
objectMetadatasForNavigation: [objectMetadata],
|
||||
applicationId: APPLICATION_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
now: NOW,
|
||||
renamedCollisionObjectMetadatas: [],
|
||||
});
|
||||
|
||||
expect(result.flatEntityToCreate).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('positions the new navigation item after the highest existing position', () => {
|
||||
const objectMetadata = getFlatObjectMetadataMock({
|
||||
universalIdentifier: 'call-recording',
|
||||
nameSingular: 'callRecording',
|
||||
namePlural: 'callRecordings',
|
||||
});
|
||||
const existingNavigationItems = [
|
||||
buildExistingNavigationItem({
|
||||
objectId: 'other-object-1-id',
|
||||
objectUniversalIdentifier: 'other-object-1',
|
||||
nameSingular: 'otherObjectOne',
|
||||
position: 0,
|
||||
}),
|
||||
buildExistingNavigationItem({
|
||||
objectId: 'other-object-2-id',
|
||||
objectUniversalIdentifier: 'other-object-2',
|
||||
nameSingular: 'otherObjectTwo',
|
||||
position: 5,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = buildNavigationCommandMenuItemOperationsOrThrow({
|
||||
existingFlatCommandMenuItemMaps: buildFlatCommandMenuItemMaps(
|
||||
existingNavigationItems,
|
||||
),
|
||||
objectMetadatasForNavigation: [objectMetadata],
|
||||
applicationId: APPLICATION_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
now: NOW,
|
||||
renamedCollisionObjectMetadatas: [],
|
||||
});
|
||||
|
||||
expect(result.flatEntityToCreate[0].position).toBe(6);
|
||||
});
|
||||
|
||||
it('rewrites the stale availability expression of a renamed object navigation item', () => {
|
||||
const existingNavigationItem = buildExistingNavigationItem({
|
||||
objectId: 'colliding-object-id',
|
||||
objectUniversalIdentifier: 'colliding-object',
|
||||
nameSingular: 'callRecording',
|
||||
position: 0,
|
||||
});
|
||||
|
||||
const result = buildNavigationCommandMenuItemOperationsOrThrow({
|
||||
existingFlatCommandMenuItemMaps: buildFlatCommandMenuItemMaps([
|
||||
existingNavigationItem,
|
||||
]),
|
||||
objectMetadatasForNavigation: [],
|
||||
applicationId: APPLICATION_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
now: NOW,
|
||||
renamedCollisionObjectMetadatas: [
|
||||
{
|
||||
universalIdentifier: 'colliding-object',
|
||||
nameSingular: 'callRecordingOld',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.flatEntityToUpdate).toHaveLength(1);
|
||||
expect(result.flatEntityToUpdate[0].conditionalAvailabilityExpression).toBe(
|
||||
'targetObjectReadPermissions.callRecordingOld',
|
||||
);
|
||||
expect(result.flatEntityToUpdate[0].updatedAt).toBe(NOW);
|
||||
expect(result.flatEntityToCreate).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not produce an update when a renamed object has no existing navigation item', () => {
|
||||
const result = buildNavigationCommandMenuItemOperationsOrThrow({
|
||||
existingFlatCommandMenuItemMaps: buildFlatCommandMenuItemMaps([]),
|
||||
objectMetadatasForNavigation: [],
|
||||
applicationId: APPLICATION_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
now: NOW,
|
||||
renamedCollisionObjectMetadatas: [
|
||||
{
|
||||
universalIdentifier: 'colliding-object',
|
||||
nameSingular: 'callRecordingOld',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.flatEntityToUpdate).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
+467
@@ -0,0 +1,467 @@
|
||||
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
import {
|
||||
buildCalendarEventFieldRenameUpdates,
|
||||
buildCallRecordingObjectRenameUpdates,
|
||||
findCalendarEventFieldNameCollisionsForCallRecording,
|
||||
findCallRecordingObjectNameCollisions,
|
||||
resolveAvailableOldCalendarEventFieldName,
|
||||
resolveAvailableOldCallRecordingObjectNames,
|
||||
} from 'src/database/commands/upgrade-version-command/2-10/utils/call-recording-name-collision.util';
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { getFlatFieldMetadataMock } from 'src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { getFlatObjectMetadataMock } from 'src/engine/metadata-modules/flat-object-metadata/__mocks__/get-flat-object-metadata.mock';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
|
||||
const NOW = '2026-06-04T00:00:00.000Z';
|
||||
const CALENDAR_EVENT_OBJECT_METADATA_ID = 'calendar-event-object-metadata-id';
|
||||
|
||||
const buildFlatObjectMetadataMaps = (
|
||||
flatObjectMetadatas: FlatObjectMetadata[],
|
||||
): FlatEntityMaps<FlatObjectMetadata> => ({
|
||||
byUniversalIdentifier: Object.fromEntries(
|
||||
flatObjectMetadatas.map((flatObjectMetadata) => [
|
||||
flatObjectMetadata.universalIdentifier,
|
||||
flatObjectMetadata,
|
||||
]),
|
||||
),
|
||||
universalIdentifierById: Object.fromEntries(
|
||||
flatObjectMetadatas.map((flatObjectMetadata) => [
|
||||
flatObjectMetadata.id,
|
||||
flatObjectMetadata.universalIdentifier,
|
||||
]),
|
||||
),
|
||||
universalIdentifiersByApplicationId: {},
|
||||
});
|
||||
|
||||
const buildFlatFieldMetadataMaps = (
|
||||
flatFieldMetadatas: FlatFieldMetadata[],
|
||||
): FlatEntityMaps<FlatFieldMetadata> => ({
|
||||
byUniversalIdentifier: Object.fromEntries(
|
||||
flatFieldMetadatas.map((flatFieldMetadata) => [
|
||||
flatFieldMetadata.universalIdentifier,
|
||||
flatFieldMetadata,
|
||||
]),
|
||||
),
|
||||
universalIdentifierById: Object.fromEntries(
|
||||
flatFieldMetadatas.map((flatFieldMetadata) => [
|
||||
flatFieldMetadata.id,
|
||||
flatFieldMetadata.universalIdentifier,
|
||||
]),
|
||||
),
|
||||
universalIdentifiersByApplicationId: {},
|
||||
});
|
||||
|
||||
const getCalendarEventFieldMetadataMock = (
|
||||
overrides: Pick<FlatFieldMetadata, 'name' | 'universalIdentifier'> &
|
||||
Partial<FlatFieldMetadata>,
|
||||
): FlatFieldMetadata =>
|
||||
getFlatFieldMetadataMock({
|
||||
objectMetadataId: CALENDAR_EVENT_OBJECT_METADATA_ID,
|
||||
objectMetadataUniversalIdentifier:
|
||||
STANDARD_OBJECTS.calendarEvent.universalIdentifier,
|
||||
type: FieldMetadataType.TEXT,
|
||||
label: overrides.name,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('findCallRecordingObjectNameCollisions', () => {
|
||||
it('returns an empty array when no object uses the callRecording name', () => {
|
||||
const maps = buildFlatObjectMetadataMaps([
|
||||
getFlatObjectMetadataMock({
|
||||
universalIdentifier: 'unrelated-object',
|
||||
nameSingular: 'invoice',
|
||||
namePlural: 'invoices',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(findCallRecordingObjectNameCollisions(maps)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns the custom object whose singular name collides', () => {
|
||||
const maps = buildFlatObjectMetadataMaps([
|
||||
getFlatObjectMetadataMock({
|
||||
universalIdentifier: 'colliding-singular',
|
||||
nameSingular: 'callRecording',
|
||||
namePlural: 'myRecordings',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(
|
||||
findCallRecordingObjectNameCollisions(maps).map(
|
||||
(object) => object.universalIdentifier,
|
||||
),
|
||||
).toEqual(['colliding-singular']);
|
||||
});
|
||||
|
||||
it('returns the custom object whose plural name collides', () => {
|
||||
const maps = buildFlatObjectMetadataMaps([
|
||||
getFlatObjectMetadataMock({
|
||||
universalIdentifier: 'colliding-plural',
|
||||
nameSingular: 'myRecording',
|
||||
namePlural: 'callRecordings',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(
|
||||
findCallRecordingObjectNameCollisions(maps).map(
|
||||
(object) => object.universalIdentifier,
|
||||
),
|
||||
).toEqual(['colliding-plural']);
|
||||
});
|
||||
|
||||
it('returns every colliding object when singular and plural collide on different objects', () => {
|
||||
const maps = buildFlatObjectMetadataMaps([
|
||||
getFlatObjectMetadataMock({
|
||||
universalIdentifier: 'colliding-singular',
|
||||
nameSingular: 'callRecording',
|
||||
namePlural: 'myRecordings',
|
||||
}),
|
||||
getFlatObjectMetadataMock({
|
||||
universalIdentifier: 'colliding-plural',
|
||||
nameSingular: 'myRecording',
|
||||
namePlural: 'callRecordings',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(
|
||||
findCallRecordingObjectNameCollisions(maps)
|
||||
.map((object) => object.universalIdentifier)
|
||||
.sort(),
|
||||
).toEqual(['colliding-plural', 'colliding-singular']);
|
||||
});
|
||||
|
||||
it('excludes the standard callRecording object itself', () => {
|
||||
const maps = buildFlatObjectMetadataMaps([
|
||||
getFlatObjectMetadataMock({
|
||||
universalIdentifier: STANDARD_OBJECTS.callRecording.universalIdentifier,
|
||||
nameSingular: 'callRecording',
|
||||
namePlural: 'callRecordings',
|
||||
isCustom: false,
|
||||
isSystem: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(findCallRecordingObjectNameCollisions(maps)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveAvailableOldCallRecordingObjectNames', () => {
|
||||
it('returns the unsuffixed Old names when none are taken', () => {
|
||||
const maps = buildFlatObjectMetadataMaps([
|
||||
getFlatObjectMetadataMock({
|
||||
universalIdentifier: 'colliding',
|
||||
nameSingular: 'callRecording',
|
||||
namePlural: 'callRecordings',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(resolveAvailableOldCallRecordingObjectNames(maps)).toEqual({
|
||||
nameSingular: 'callRecordingOld',
|
||||
namePlural: 'callRecordingsOld',
|
||||
labelSingular: 'Call Recording (Old)',
|
||||
labelPlural: 'Call Recordings (Old)',
|
||||
});
|
||||
});
|
||||
|
||||
it('advances to the next discriminator when the Old name is already taken', () => {
|
||||
const maps = buildFlatObjectMetadataMaps([
|
||||
getFlatObjectMetadataMock({
|
||||
universalIdentifier: 'colliding',
|
||||
nameSingular: 'callRecording',
|
||||
namePlural: 'callRecordings',
|
||||
}),
|
||||
getFlatObjectMetadataMock({
|
||||
universalIdentifier: 'old-already-taken',
|
||||
nameSingular: 'callRecordingOld',
|
||||
namePlural: 'callRecordingsOld',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(resolveAvailableOldCallRecordingObjectNames(maps)).toEqual({
|
||||
nameSingular: 'callRecordingOld2',
|
||||
namePlural: 'callRecordingsOld2',
|
||||
labelSingular: 'Call Recording (Old) 2',
|
||||
labelPlural: 'Call Recordings (Old) 2',
|
||||
});
|
||||
});
|
||||
|
||||
it('skips Old names provided as already reserved', () => {
|
||||
const maps = buildFlatObjectMetadataMaps([
|
||||
getFlatObjectMetadataMock({
|
||||
universalIdentifier: 'colliding',
|
||||
nameSingular: 'callRecording',
|
||||
namePlural: 'callRecordings',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(
|
||||
resolveAvailableOldCallRecordingObjectNames(
|
||||
maps,
|
||||
new Set(['callRecordingOld', 'callRecordingsOld']),
|
||||
),
|
||||
).toEqual({
|
||||
nameSingular: 'callRecordingOld2',
|
||||
namePlural: 'callRecordingsOld2',
|
||||
labelSingular: 'Call Recording (Old) 2',
|
||||
labelPlural: 'Call Recordings (Old) 2',
|
||||
});
|
||||
});
|
||||
|
||||
it('skips a discriminator when only the plural Old name is taken', () => {
|
||||
const maps = buildFlatObjectMetadataMaps([
|
||||
getFlatObjectMetadataMock({
|
||||
universalIdentifier: 'plural-old-taken',
|
||||
nameSingular: 'unrelatedSingular',
|
||||
namePlural: 'callRecordingsOld',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(resolveAvailableOldCallRecordingObjectNames(maps)).toEqual({
|
||||
nameSingular: 'callRecordingOld2',
|
||||
namePlural: 'callRecordingsOld2',
|
||||
labelSingular: 'Call Recording (Old) 2',
|
||||
labelPlural: 'Call Recordings (Old) 2',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when every candidate Old name is taken', () => {
|
||||
const blockedSingularNames = Array.from({ length: 100 }, (_, attempt) => {
|
||||
const discriminator = attempt === 0 ? '' : `${attempt + 1}`;
|
||||
|
||||
return `callRecordingOld${discriminator}`;
|
||||
});
|
||||
|
||||
const maps = buildFlatObjectMetadataMaps(
|
||||
blockedSingularNames.map((nameSingular, index) =>
|
||||
getFlatObjectMetadataMock({
|
||||
universalIdentifier: `taken-${index}`,
|
||||
nameSingular,
|
||||
namePlural: `${nameSingular}Filler`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(() => resolveAvailableOldCallRecordingObjectNames(maps)).toThrow(
|
||||
'Could not find an available callRecordingOld name after 100 attempts',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCallRecordingObjectRenameUpdates', () => {
|
||||
it('builds a migration update for a colliding object name', () => {
|
||||
const maps = buildFlatObjectMetadataMaps([
|
||||
getFlatObjectMetadataMock({
|
||||
universalIdentifier: 'colliding',
|
||||
nameSingular: 'callRecording',
|
||||
namePlural: 'callRecordings',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(
|
||||
buildCallRecordingObjectRenameUpdates({
|
||||
flatObjectMetadataMaps: maps,
|
||||
now: NOW,
|
||||
}),
|
||||
).toMatchObject([
|
||||
{
|
||||
universalIdentifier: 'colliding',
|
||||
nameSingular: 'callRecordingOld',
|
||||
namePlural: 'callRecordingsOld',
|
||||
labelSingular: 'Call Recording (Old)',
|
||||
labelPlural: 'Call Recordings (Old)',
|
||||
isLabelSyncedWithName: false,
|
||||
updatedAt: NOW,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('reserves generated names across multiple object updates', () => {
|
||||
const maps = buildFlatObjectMetadataMaps([
|
||||
getFlatObjectMetadataMock({
|
||||
universalIdentifier: 'colliding-singular',
|
||||
nameSingular: 'callRecording',
|
||||
namePlural: 'myRecordings',
|
||||
}),
|
||||
getFlatObjectMetadataMock({
|
||||
universalIdentifier: 'colliding-plural',
|
||||
nameSingular: 'myRecording',
|
||||
namePlural: 'callRecordings',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(
|
||||
buildCallRecordingObjectRenameUpdates({
|
||||
flatObjectMetadataMaps: maps,
|
||||
now: NOW,
|
||||
}).map((objectMetadata) => objectMetadata.nameSingular),
|
||||
).toEqual(['callRecordingOld', 'callRecordingOld2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findCalendarEventFieldNameCollisionsForCallRecording', () => {
|
||||
it('finds calendarEvent fields that use a name reserved by CallRecording', () => {
|
||||
const maps = buildFlatFieldMetadataMaps([
|
||||
getCalendarEventFieldMetadataMock({
|
||||
universalIdentifier: 'colliding-recording-preference',
|
||||
name: 'recordingPreference',
|
||||
label: 'Recording Preference',
|
||||
}),
|
||||
getCalendarEventFieldMetadataMock({
|
||||
universalIdentifier: 'colliding-call-recordings',
|
||||
name: 'callRecordings',
|
||||
label: 'Call Recordings',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(
|
||||
findCalendarEventFieldNameCollisionsForCallRecording(maps).map(
|
||||
(fieldMetadata) => fieldMetadata.universalIdentifier,
|
||||
),
|
||||
).toEqual(['colliding-recording-preference', 'colliding-call-recordings']);
|
||||
});
|
||||
|
||||
it('excludes the CallRecording standard calendarEvent fields', () => {
|
||||
const maps = buildFlatFieldMetadataMaps([
|
||||
getCalendarEventFieldMetadataMock({
|
||||
universalIdentifier:
|
||||
STANDARD_OBJECTS.calendarEvent.fields.recordingPreference
|
||||
.universalIdentifier,
|
||||
name: 'recordingPreference',
|
||||
}),
|
||||
getCalendarEventFieldMetadataMock({
|
||||
universalIdentifier:
|
||||
STANDARD_OBJECTS.calendarEvent.fields.callRecordings
|
||||
.universalIdentifier,
|
||||
name: 'callRecordings',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(findCalendarEventFieldNameCollisionsForCallRecording(maps)).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('excludes fields on objects other than calendarEvent', () => {
|
||||
const maps = buildFlatFieldMetadataMaps([
|
||||
getFlatFieldMetadataMock({
|
||||
universalIdentifier: 'other-object-field',
|
||||
objectMetadataId: 'other-object-id',
|
||||
objectMetadataUniversalIdentifier: 'other-object',
|
||||
type: FieldMetadataType.TEXT,
|
||||
name: 'recordingPreference',
|
||||
label: 'Recording Preference',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(findCalendarEventFieldNameCollisionsForCallRecording(maps)).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveAvailableOldCalendarEventFieldName', () => {
|
||||
it('returns the unsuffixed Old field name when it is available', () => {
|
||||
const maps = buildFlatFieldMetadataMaps([
|
||||
getCalendarEventFieldMetadataMock({
|
||||
universalIdentifier: 'colliding-field',
|
||||
name: 'recordingPreference',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(
|
||||
resolveAvailableOldCalendarEventFieldName({
|
||||
flatFieldMetadataMaps: maps,
|
||||
originalFieldName: 'recordingPreference',
|
||||
}),
|
||||
).toBe('recordingPreferenceOld');
|
||||
});
|
||||
|
||||
it('advances to the next discriminator when the Old field name is taken', () => {
|
||||
const maps = buildFlatFieldMetadataMaps([
|
||||
getCalendarEventFieldMetadataMock({
|
||||
universalIdentifier: 'colliding-field',
|
||||
name: 'recordingPreference',
|
||||
}),
|
||||
getCalendarEventFieldMetadataMock({
|
||||
universalIdentifier: 'old-field-name-taken',
|
||||
name: 'recordingPreferenceOld',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(
|
||||
resolveAvailableOldCalendarEventFieldName({
|
||||
flatFieldMetadataMaps: maps,
|
||||
originalFieldName: 'recordingPreference',
|
||||
}),
|
||||
).toBe('recordingPreferenceOld2');
|
||||
});
|
||||
|
||||
it('skips Old field names provided as already reserved', () => {
|
||||
const maps = buildFlatFieldMetadataMaps([
|
||||
getCalendarEventFieldMetadataMock({
|
||||
universalIdentifier: 'colliding-field',
|
||||
name: 'callRecordings',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(
|
||||
resolveAvailableOldCalendarEventFieldName({
|
||||
flatFieldMetadataMaps: maps,
|
||||
originalFieldName: 'callRecordings',
|
||||
additionalTakenNames: new Set(['callRecordingsOld']),
|
||||
}),
|
||||
).toBe('callRecordingsOld2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCalendarEventFieldRenameUpdates', () => {
|
||||
it('builds a migration update for a colliding calendarEvent field name', () => {
|
||||
const maps = buildFlatFieldMetadataMaps([
|
||||
getCalendarEventFieldMetadataMock({
|
||||
universalIdentifier: 'colliding-field',
|
||||
name: 'recordingPreference',
|
||||
label: 'Recording Preference',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(
|
||||
buildCalendarEventFieldRenameUpdates({
|
||||
flatFieldMetadataMaps: maps,
|
||||
now: NOW,
|
||||
}),
|
||||
).toMatchObject([
|
||||
{
|
||||
universalIdentifier: 'colliding-field',
|
||||
name: 'recordingPreferenceOld',
|
||||
label: 'Recording Preference (Old)',
|
||||
isLabelSyncedWithName: false,
|
||||
updatedAt: NOW,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('reserves generated names across multiple field updates', () => {
|
||||
const maps = buildFlatFieldMetadataMaps([
|
||||
getCalendarEventFieldMetadataMock({
|
||||
universalIdentifier: 'colliding-field-one',
|
||||
name: 'recordingPreference',
|
||||
label: 'Recording Preference',
|
||||
}),
|
||||
getCalendarEventFieldMetadataMock({
|
||||
universalIdentifier: 'colliding-field-two',
|
||||
name: 'recordingPreference',
|
||||
label: 'Recording Preference',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(
|
||||
buildCalendarEventFieldRenameUpdates({
|
||||
flatFieldMetadataMaps: maps,
|
||||
now: NOW,
|
||||
}).map((fieldMetadata) => fieldMetadata.name),
|
||||
).toEqual(['recordingPreferenceOld', 'recordingPreferenceOld2']);
|
||||
});
|
||||
});
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
import {
|
||||
getExistingOrStandardFlatEntityOrThrow,
|
||||
getStandardFlatEntitiesToCreateOrThrow,
|
||||
} from 'src/database/commands/upgrade-version-command/2-10/utils/get-standard-flat-entities-to-create-or-throw.util';
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { getFlatObjectMetadataMock } from 'src/engine/metadata-modules/flat-object-metadata/__mocks__/get-flat-object-metadata.mock';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
|
||||
const buildFlatObjectMetadataMaps = (
|
||||
flatObjectMetadatas: FlatObjectMetadata[],
|
||||
): FlatEntityMaps<FlatObjectMetadata> => ({
|
||||
byUniversalIdentifier: Object.fromEntries(
|
||||
flatObjectMetadatas.map((flatObjectMetadata) => [
|
||||
flatObjectMetadata.universalIdentifier,
|
||||
flatObjectMetadata,
|
||||
]),
|
||||
),
|
||||
universalIdentifierById: Object.fromEntries(
|
||||
flatObjectMetadatas.map((flatObjectMetadata) => [
|
||||
flatObjectMetadata.id,
|
||||
flatObjectMetadata.universalIdentifier,
|
||||
]),
|
||||
),
|
||||
universalIdentifiersByApplicationId: {},
|
||||
});
|
||||
|
||||
describe('getStandardFlatEntitiesToCreateOrThrow', () => {
|
||||
it('returns the standard entity when it is absent from the existing maps', () => {
|
||||
const standardEntity = getFlatObjectMetadataMock({
|
||||
universalIdentifier: 'call-recording',
|
||||
nameSingular: 'callRecording',
|
||||
namePlural: 'callRecordings',
|
||||
});
|
||||
const standardFlatEntityMaps = buildFlatObjectMetadataMaps([
|
||||
standardEntity,
|
||||
]);
|
||||
const existingFlatEntityMaps = buildFlatObjectMetadataMaps([]);
|
||||
|
||||
expect(
|
||||
getStandardFlatEntitiesToCreateOrThrow<FlatObjectMetadata>({
|
||||
standardFlatEntityMaps,
|
||||
existingFlatEntityMaps,
|
||||
universalIdentifiers: ['call-recording'],
|
||||
}),
|
||||
).toEqual([standardEntity]);
|
||||
});
|
||||
|
||||
it('skips an entity that already exists in the existing maps (idempotent re-run)', () => {
|
||||
const standardEntity = getFlatObjectMetadataMock({
|
||||
universalIdentifier: 'call-recording',
|
||||
nameSingular: 'callRecording',
|
||||
namePlural: 'callRecordings',
|
||||
});
|
||||
const standardFlatEntityMaps = buildFlatObjectMetadataMaps([
|
||||
standardEntity,
|
||||
]);
|
||||
const existingFlatEntityMaps = buildFlatObjectMetadataMaps([
|
||||
standardEntity,
|
||||
]);
|
||||
|
||||
expect(
|
||||
getStandardFlatEntitiesToCreateOrThrow<FlatObjectMetadata>({
|
||||
standardFlatEntityMaps,
|
||||
existingFlatEntityMaps,
|
||||
universalIdentifiers: ['call-recording'],
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns only the entities missing from the existing maps', () => {
|
||||
const existingEntity = getFlatObjectMetadataMock({
|
||||
universalIdentifier: 'already-synced',
|
||||
nameSingular: 'alreadySynced',
|
||||
namePlural: 'alreadySynceds',
|
||||
});
|
||||
const missingEntity = getFlatObjectMetadataMock({
|
||||
universalIdentifier: 'not-yet-synced',
|
||||
nameSingular: 'notYetSynced',
|
||||
namePlural: 'notYetSynceds',
|
||||
});
|
||||
const standardFlatEntityMaps = buildFlatObjectMetadataMaps([
|
||||
existingEntity,
|
||||
missingEntity,
|
||||
]);
|
||||
const existingFlatEntityMaps = buildFlatObjectMetadataMaps([
|
||||
existingEntity,
|
||||
]);
|
||||
|
||||
expect(
|
||||
getStandardFlatEntitiesToCreateOrThrow<FlatObjectMetadata>({
|
||||
standardFlatEntityMaps,
|
||||
existingFlatEntityMaps,
|
||||
universalIdentifiers: ['already-synced', 'not-yet-synced'],
|
||||
}),
|
||||
).toEqual([missingEntity]);
|
||||
});
|
||||
|
||||
it('returns an empty array when no universal identifiers are requested', () => {
|
||||
const standardFlatEntityMaps = buildFlatObjectMetadataMaps([
|
||||
getFlatObjectMetadataMock({
|
||||
universalIdentifier: 'call-recording',
|
||||
nameSingular: 'callRecording',
|
||||
namePlural: 'callRecordings',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(
|
||||
getStandardFlatEntitiesToCreateOrThrow<FlatObjectMetadata>({
|
||||
standardFlatEntityMaps,
|
||||
existingFlatEntityMaps: buildFlatObjectMetadataMaps([]),
|
||||
universalIdentifiers: [],
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('throws when a requested identifier is missing from the standard maps', () => {
|
||||
expect(() =>
|
||||
getStandardFlatEntitiesToCreateOrThrow<FlatObjectMetadata>({
|
||||
standardFlatEntityMaps: buildFlatObjectMetadataMaps([]),
|
||||
existingFlatEntityMaps: buildFlatObjectMetadataMaps([]),
|
||||
universalIdentifiers: ['missing-from-standard'],
|
||||
}),
|
||||
).toThrow('Could not find standard entity missing-from-standard');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExistingOrStandardFlatEntityOrThrow', () => {
|
||||
it('returns the existing entity when one already exists', () => {
|
||||
const standardEntity = getFlatObjectMetadataMock({
|
||||
id: 'standard-id',
|
||||
universalIdentifier: 'call-recording',
|
||||
nameSingular: 'callRecording',
|
||||
namePlural: 'callRecordings',
|
||||
});
|
||||
const existingEntity = getFlatObjectMetadataMock({
|
||||
id: 'existing-id',
|
||||
universalIdentifier: 'call-recording',
|
||||
nameSingular: 'callRecording',
|
||||
namePlural: 'callRecordings',
|
||||
});
|
||||
|
||||
expect(
|
||||
getExistingOrStandardFlatEntityOrThrow<FlatObjectMetadata>({
|
||||
standardFlatEntityMaps: buildFlatObjectMetadataMaps([standardEntity]),
|
||||
existingFlatEntityMaps: buildFlatObjectMetadataMaps([existingEntity]),
|
||||
universalIdentifier: 'call-recording',
|
||||
}),
|
||||
).toBe(existingEntity);
|
||||
});
|
||||
|
||||
it('returns the standard entity when the entity is missing from existing maps', () => {
|
||||
const standardEntity = getFlatObjectMetadataMock({
|
||||
id: 'standard-id',
|
||||
universalIdentifier: 'call-recording',
|
||||
nameSingular: 'callRecording',
|
||||
namePlural: 'callRecordings',
|
||||
});
|
||||
|
||||
expect(
|
||||
getExistingOrStandardFlatEntityOrThrow<FlatObjectMetadata>({
|
||||
standardFlatEntityMaps: buildFlatObjectMetadataMaps([standardEntity]),
|
||||
existingFlatEntityMaps: buildFlatObjectMetadataMaps([]),
|
||||
universalIdentifier: 'call-recording',
|
||||
}),
|
||||
).toBe(standardEntity);
|
||||
});
|
||||
|
||||
it('throws when the entity is absent from both maps', () => {
|
||||
expect(() =>
|
||||
getExistingOrStandardFlatEntityOrThrow<FlatObjectMetadata>({
|
||||
standardFlatEntityMaps: buildFlatObjectMetadataMaps([]),
|
||||
existingFlatEntityMaps: buildFlatObjectMetadataMaps([]),
|
||||
universalIdentifier: 'missing-from-standard',
|
||||
}),
|
||||
).toThrow('Could not find standard entity missing-from-standard');
|
||||
});
|
||||
});
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { v4, v5 } from 'uuid';
|
||||
|
||||
import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type';
|
||||
import {
|
||||
buildNavigationConditionalAvailabilityExpression,
|
||||
buildNavigationFlatCommandMenuItem,
|
||||
NAVIGATION_COMMAND_UUID_NAMESPACE,
|
||||
} from 'src/engine/metadata-modules/flat-command-menu-item/utils/build-navigation-flat-command-menu-item.util';
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { type FlatEntityToCreateDeleteUpdate } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-to-create-delete-update.type';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
|
||||
export const buildNavigationCommandMenuItemOperationsOrThrow = ({
|
||||
existingFlatCommandMenuItemMaps,
|
||||
objectMetadatasForNavigation,
|
||||
applicationId,
|
||||
workspaceId,
|
||||
now,
|
||||
renamedCollisionObjectMetadatas,
|
||||
}: {
|
||||
existingFlatCommandMenuItemMaps: FlatEntityMaps<FlatCommandMenuItem>;
|
||||
objectMetadatasForNavigation: FlatObjectMetadata[];
|
||||
applicationId: string;
|
||||
workspaceId: string;
|
||||
now: string;
|
||||
renamedCollisionObjectMetadatas: {
|
||||
universalIdentifier: string;
|
||||
nameSingular: string;
|
||||
}[];
|
||||
}): FlatEntityToCreateDeleteUpdate<'commandMenuItem'> => {
|
||||
const flatEntityToCreate: FlatCommandMenuItem[] = [];
|
||||
const flatEntityToUpdate: FlatCommandMenuItem[] = [];
|
||||
|
||||
let nextPosition =
|
||||
Object.values(existingFlatCommandMenuItemMaps.byUniversalIdentifier)
|
||||
.filter(isDefined)
|
||||
.reduce(
|
||||
(maxPosition, commandMenuItem) =>
|
||||
Math.max(maxPosition, commandMenuItem.position),
|
||||
-1,
|
||||
) + 1;
|
||||
|
||||
for (const objectMetadata of objectMetadatasForNavigation) {
|
||||
const commandMenuItemUniversalIdentifier = v5(
|
||||
objectMetadata.universalIdentifier,
|
||||
NAVIGATION_COMMAND_UUID_NAMESPACE,
|
||||
);
|
||||
|
||||
if (
|
||||
!objectMetadata.isActive ||
|
||||
isDefined(
|
||||
existingFlatCommandMenuItemMaps.byUniversalIdentifier[
|
||||
commandMenuItemUniversalIdentifier
|
||||
],
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
flatEntityToCreate.push(
|
||||
buildNavigationFlatCommandMenuItem({
|
||||
objectMetadata,
|
||||
commandMenuItemId: v4(),
|
||||
applicationId,
|
||||
workspaceId,
|
||||
position: nextPosition++,
|
||||
now,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (const renamedCollisionObjectMetadata of renamedCollisionObjectMetadatas) {
|
||||
const renamedNavigationCommandMenuItemUniversalIdentifier = v5(
|
||||
renamedCollisionObjectMetadata.universalIdentifier,
|
||||
NAVIGATION_COMMAND_UUID_NAMESPACE,
|
||||
);
|
||||
const staleNavigationCommandMenuItem =
|
||||
existingFlatCommandMenuItemMaps.byUniversalIdentifier[
|
||||
renamedNavigationCommandMenuItemUniversalIdentifier
|
||||
];
|
||||
|
||||
if (isDefined(staleNavigationCommandMenuItem)) {
|
||||
flatEntityToUpdate.push({
|
||||
...staleNavigationCommandMenuItem,
|
||||
conditionalAvailabilityExpression:
|
||||
buildNavigationConditionalAvailabilityExpression({
|
||||
universalIdentifier:
|
||||
renamedCollisionObjectMetadata.universalIdentifier,
|
||||
nameSingular: renamedCollisionObjectMetadata.nameSingular,
|
||||
}),
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { flatEntityToCreate, flatEntityToDelete: [], flatEntityToUpdate };
|
||||
};
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
|
||||
const CALL_RECORDING_NAME_SINGULAR = 'callRecording';
|
||||
const CALL_RECORDING_NAME_PLURAL = 'callRecordings';
|
||||
const CALL_RECORDING_OLD_NAME_SINGULAR = 'callRecordingOld';
|
||||
const CALL_RECORDING_OLD_NAME_PLURAL = 'callRecordingsOld';
|
||||
const CALENDAR_EVENT_RECORDING_PREFERENCE_FIELD_NAME = 'recordingPreference';
|
||||
const CALENDAR_EVENT_CALL_RECORDINGS_FIELD_NAME = 'callRecordings';
|
||||
const FIELD_OLD_NAME_SUFFIX = 'Old';
|
||||
const FIELD_OLD_LABEL_SUFFIX = ' (Old)';
|
||||
const MAX_OLD_NAME_ATTEMPTS = 100;
|
||||
|
||||
const CALL_RECORDING_CALENDAR_EVENT_FIELD_NAMES = [
|
||||
CALENDAR_EVENT_RECORDING_PREFERENCE_FIELD_NAME,
|
||||
CALENDAR_EVENT_CALL_RECORDINGS_FIELD_NAME,
|
||||
];
|
||||
|
||||
const CALL_RECORDING_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIERS =
|
||||
new Set<string>([
|
||||
STANDARD_OBJECTS.calendarEvent.fields.recordingPreference
|
||||
.universalIdentifier,
|
||||
STANDARD_OBJECTS.calendarEvent.fields.callRecordings.universalIdentifier,
|
||||
]);
|
||||
|
||||
export const findCallRecordingObjectNameCollisions = (
|
||||
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>,
|
||||
): FlatObjectMetadata[] =>
|
||||
Object.values(flatObjectMetadataMaps.byUniversalIdentifier).filter(
|
||||
(flatObjectMetadata): flatObjectMetadata is FlatObjectMetadata =>
|
||||
isDefined(flatObjectMetadata) &&
|
||||
flatObjectMetadata.universalIdentifier !==
|
||||
STANDARD_OBJECTS.callRecording.universalIdentifier &&
|
||||
[flatObjectMetadata.nameSingular, flatObjectMetadata.namePlural].some(
|
||||
(name) =>
|
||||
name === CALL_RECORDING_NAME_SINGULAR ||
|
||||
name === CALL_RECORDING_NAME_PLURAL,
|
||||
),
|
||||
);
|
||||
|
||||
export const resolveAvailableOldCallRecordingObjectNames = (
|
||||
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>,
|
||||
additionalTakenNames: ReadonlySet<string> = new Set(),
|
||||
): {
|
||||
nameSingular: string;
|
||||
namePlural: string;
|
||||
labelSingular: string;
|
||||
labelPlural: string;
|
||||
} => {
|
||||
const takenNames = new Set([
|
||||
...Object.values(flatObjectMetadataMaps.byUniversalIdentifier)
|
||||
.filter(isDefined)
|
||||
.flatMap((flatObjectMetadata) => [
|
||||
flatObjectMetadata.nameSingular,
|
||||
flatObjectMetadata.namePlural,
|
||||
]),
|
||||
...additionalTakenNames,
|
||||
]);
|
||||
|
||||
for (let attempt = 0; attempt < MAX_OLD_NAME_ATTEMPTS; attempt++) {
|
||||
const discriminator = attempt === 0 ? '' : `${attempt + 1}`;
|
||||
const nameSingular = `${CALL_RECORDING_OLD_NAME_SINGULAR}${discriminator}`;
|
||||
const namePlural = `${CALL_RECORDING_OLD_NAME_PLURAL}${discriminator}`;
|
||||
|
||||
if (!takenNames.has(nameSingular) && !takenNames.has(namePlural)) {
|
||||
const labelSuffix = discriminator === '' ? '' : ` ${discriminator}`;
|
||||
|
||||
return {
|
||||
nameSingular,
|
||||
namePlural,
|
||||
labelSingular: `Call Recording (Old)${labelSuffix}`,
|
||||
labelPlural: `Call Recordings (Old)${labelSuffix}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Could not find an available callRecordingOld name after ${MAX_OLD_NAME_ATTEMPTS} attempts`,
|
||||
);
|
||||
};
|
||||
|
||||
export const buildCallRecordingObjectRenameUpdates = ({
|
||||
flatObjectMetadataMaps,
|
||||
now,
|
||||
}: {
|
||||
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
|
||||
now: string;
|
||||
}): FlatObjectMetadata[] => {
|
||||
const reservedOldNames = new Set<string>();
|
||||
|
||||
return findCallRecordingObjectNameCollisions(flatObjectMetadataMaps).map(
|
||||
(collidingObjectMetadata) => {
|
||||
const { nameSingular, namePlural, labelSingular, labelPlural } =
|
||||
resolveAvailableOldCallRecordingObjectNames(
|
||||
flatObjectMetadataMaps,
|
||||
reservedOldNames,
|
||||
);
|
||||
|
||||
reservedOldNames.add(nameSingular);
|
||||
reservedOldNames.add(namePlural);
|
||||
|
||||
return {
|
||||
...collidingObjectMetadata,
|
||||
nameSingular,
|
||||
namePlural,
|
||||
labelSingular,
|
||||
labelPlural,
|
||||
isLabelSyncedWithName: false,
|
||||
updatedAt: now,
|
||||
};
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const findCalendarEventFieldNameCollisionsForCallRecording = (
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
|
||||
): FlatFieldMetadata[] =>
|
||||
Object.values(flatFieldMetadataMaps.byUniversalIdentifier).filter(
|
||||
(flatFieldMetadata): flatFieldMetadata is FlatFieldMetadata =>
|
||||
isDefined(flatFieldMetadata) &&
|
||||
flatFieldMetadata.objectMetadataUniversalIdentifier ===
|
||||
STANDARD_OBJECTS.calendarEvent.universalIdentifier &&
|
||||
!CALL_RECORDING_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIERS.has(
|
||||
flatFieldMetadata.universalIdentifier,
|
||||
) &&
|
||||
CALL_RECORDING_CALENDAR_EVENT_FIELD_NAMES.includes(
|
||||
flatFieldMetadata.name,
|
||||
),
|
||||
);
|
||||
|
||||
export const resolveAvailableOldCalendarEventFieldName = ({
|
||||
flatFieldMetadataMaps,
|
||||
originalFieldName,
|
||||
additionalTakenNames = new Set(),
|
||||
}: {
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
originalFieldName: string;
|
||||
additionalTakenNames?: ReadonlySet<string>;
|
||||
}): string => {
|
||||
const takenFieldNames = new Set([
|
||||
...Object.values(flatFieldMetadataMaps.byUniversalIdentifier)
|
||||
.filter(isDefined)
|
||||
.filter(
|
||||
(flatFieldMetadata) =>
|
||||
flatFieldMetadata.objectMetadataUniversalIdentifier ===
|
||||
STANDARD_OBJECTS.calendarEvent.universalIdentifier,
|
||||
)
|
||||
.map((flatFieldMetadata) => flatFieldMetadata.name),
|
||||
...additionalTakenNames,
|
||||
]);
|
||||
|
||||
for (let attempt = 0; attempt < MAX_OLD_NAME_ATTEMPTS; attempt++) {
|
||||
const discriminator = attempt === 0 ? '' : `${attempt + 1}`;
|
||||
const candidateName = `${originalFieldName}${FIELD_OLD_NAME_SUFFIX}${discriminator}`;
|
||||
|
||||
if (!takenFieldNames.has(candidateName)) {
|
||||
return candidateName;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Could not find an available ${originalFieldName}Old name after ${MAX_OLD_NAME_ATTEMPTS} attempts`,
|
||||
);
|
||||
};
|
||||
|
||||
export const buildCalendarEventFieldRenameUpdates = ({
|
||||
flatFieldMetadataMaps,
|
||||
now,
|
||||
}: {
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
now: string;
|
||||
}): FlatFieldMetadata[] => {
|
||||
const reservedOldFieldNames = new Set<string>();
|
||||
|
||||
return findCalendarEventFieldNameCollisionsForCallRecording(
|
||||
flatFieldMetadataMaps,
|
||||
).map((collidingFieldMetadata) => {
|
||||
const name = resolveAvailableOldCalendarEventFieldName({
|
||||
flatFieldMetadataMaps,
|
||||
originalFieldName: collidingFieldMetadata.name,
|
||||
additionalTakenNames: reservedOldFieldNames,
|
||||
});
|
||||
const oldNamePrefix = `${collidingFieldMetadata.name}${FIELD_OLD_NAME_SUFFIX}`;
|
||||
const discriminator = name.slice(oldNamePrefix.length);
|
||||
const labelSuffix =
|
||||
discriminator === ''
|
||||
? FIELD_OLD_LABEL_SUFFIX
|
||||
: `${FIELD_OLD_LABEL_SUFFIX} ${discriminator}`;
|
||||
|
||||
reservedOldFieldNames.add(name);
|
||||
|
||||
return {
|
||||
...collidingFieldMetadata,
|
||||
name,
|
||||
label: `${collidingFieldMetadata.label}${labelSuffix}`,
|
||||
isLabelSyncedWithName: false,
|
||||
updatedAt: now,
|
||||
};
|
||||
});
|
||||
};
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type SyncableFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-from.type';
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
|
||||
export const getStandardFlatEntitiesToCreateOrThrow = <
|
||||
T extends SyncableFlatEntity,
|
||||
>({
|
||||
standardFlatEntityMaps,
|
||||
existingFlatEntityMaps,
|
||||
universalIdentifiers,
|
||||
}: {
|
||||
standardFlatEntityMaps: FlatEntityMaps<T>;
|
||||
existingFlatEntityMaps: FlatEntityMaps<T>;
|
||||
universalIdentifiers: string[];
|
||||
}): T[] =>
|
||||
universalIdentifiers.flatMap((universalIdentifier) => {
|
||||
const standardFlatEntity =
|
||||
standardFlatEntityMaps.byUniversalIdentifier[universalIdentifier];
|
||||
|
||||
if (!isDefined(standardFlatEntity)) {
|
||||
throw new Error(`Could not find standard entity ${universalIdentifier}`);
|
||||
}
|
||||
|
||||
if (
|
||||
isDefined(
|
||||
existingFlatEntityMaps.byUniversalIdentifier[universalIdentifier],
|
||||
)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [standardFlatEntity];
|
||||
});
|
||||
|
||||
export const getExistingOrStandardFlatEntityOrThrow = <
|
||||
T extends SyncableFlatEntity,
|
||||
>({
|
||||
standardFlatEntityMaps,
|
||||
existingFlatEntityMaps,
|
||||
universalIdentifier,
|
||||
}: {
|
||||
standardFlatEntityMaps: FlatEntityMaps<T>;
|
||||
existingFlatEntityMaps: FlatEntityMaps<T>;
|
||||
universalIdentifier: string;
|
||||
}): T => {
|
||||
const existingFlatEntity =
|
||||
existingFlatEntityMaps.byUniversalIdentifier[universalIdentifier];
|
||||
|
||||
if (isDefined(existingFlatEntity)) {
|
||||
return existingFlatEntity;
|
||||
}
|
||||
|
||||
const standardFlatEntity =
|
||||
standardFlatEntityMaps.byUniversalIdentifier[universalIdentifier];
|
||||
|
||||
if (!isDefined(standardFlatEntity)) {
|
||||
throw new Error(`Could not find standard entity ${universalIdentifier}`);
|
||||
}
|
||||
|
||||
return standardFlatEntity;
|
||||
};
|
||||
Reference in New Issue
Block a user