[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:
nitin
2026-06-05 18:32:50 +05:30
committed by GitHub
parent a78b319877
commit e485b679ea
41 changed files with 3999 additions and 899 deletions
@@ -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 {}
@@ -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}`,
);
}
}
@@ -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);
});
});
@@ -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']);
});
});
@@ -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');
});
});
@@ -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 };
};
@@ -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,
};
});
};
@@ -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;
};