Add call recording schema and meeting bot scaffold (#21584)

## Summary
- add 2.13 upgrade commands for call recording request status and
dropping CalendarEvent recordingPreference
- remove the recording preference from the core CalendarEvent standard
object
- add a scaffold-generated twenty-meeting-bot app with logo and the
CalendarEvent meetingBotPreference field

## Tests
- yarn install
- yarn lint
- yarn twenty dev:typecheck
- git diff --check


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21584?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
nitin
2026-06-15 19:03:12 +05:30
committed by GitHub
parent d1ba63d4a4
commit 8a866dba54
47 changed files with 5716 additions and 1502 deletions
@@ -1,9 +1,12 @@
import { Command } from 'nest-commander';
import { TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER } from 'twenty-shared/application';
import {
STANDARD_OBJECTS,
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-shared/metadata';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { v4 as uuidv4 } from 'uuid';
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';
@@ -12,6 +15,7 @@ import { buildNavigationCommandMenuItemOperationsOrThrow } from 'src/database/co
import {
buildCalendarEventFieldRenameUpdates,
buildCallRecordingObjectRenameUpdates,
LEGACY_CALENDAR_EVENT_RECORDING_PREFERENCE_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/database/commands/upgrade-version-command/2-10/utils/call-recording-name-collision.util';
import {
getExistingOrStandardFlatEntityOrThrow,
@@ -43,7 +47,6 @@ const CALL_RECORDING_OBJECT_METADATA_UNIVERSAL_IDENTIFIERS = [
const CALL_RECORDING_FIELD_METADATA_UNIVERSAL_IDENTIFIERS = [
...getUniversalIdentifiers(STANDARD_OBJECTS.callRecording.fields),
STANDARD_OBJECTS.calendarEvent.fields.recordingPreference.universalIdentifier,
STANDARD_OBJECTS.calendarEvent.fields.callRecordings.universalIdentifier,
];
@@ -92,6 +95,91 @@ const CALL_RECORDING_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIERS = [
.timeline.widgets.timeline.universalIdentifier,
];
// Preserves the shipped 2.10 upgrade path after recordingPreference moved out of
// current standard metadata.
const buildLegacyCalendarEventRecordingPreferenceFieldMetadata = ({
calendarEventObjectMetadata,
now,
twentyStandardApplicationId,
workspaceId,
}: {
calendarEventObjectMetadata: FlatObjectMetadata;
now: string;
twentyStandardApplicationId: string;
workspaceId: string;
}): FlatFieldMetadata<FieldMetadataType.SELECT> => ({
id: uuidv4(),
universalIdentifier:
LEGACY_CALENDAR_EVENT_RECORDING_PREFERENCE_FIELD_UNIVERSAL_IDENTIFIER,
applicationId: twentyStandardApplicationId,
workspaceId,
objectMetadataId: calendarEventObjectMetadata.id,
type: FieldMetadataType.SELECT,
name: 'recordingPreference',
label: 'Recording Preference',
description:
'Whether to record this event, applied on top of the workspace policy',
icon: 'IconSettingsAutomation',
isActive: true,
isSystem: false,
isNullable: false,
isUnique: false,
isUIEditable: true,
isLabelSyncedWithName: false,
standardOverrides: null,
defaultValue: "'AUTO'",
settings: null,
options: [
{
id: '4c4761ce-ffbf-4176-be7f-5cf5257c8bff',
value: 'AUTO',
label: 'Auto',
position: 0,
color: 'blue',
},
{
id: '1ae19135-e1a1-4a96-b866-91643622e554',
value: 'ON',
label: 'On',
position: 1,
color: 'green',
},
{
id: '8c69a74f-2ab7-4c19-a813-eb0ea3533fd3',
value: 'OFF',
label: 'Off',
position: 2,
color: 'gray',
},
],
relationTargetFieldMetadataId: null,
relationTargetObjectMetadataId: null,
morphId: null,
viewFieldIds: [],
viewFilterIds: [],
fieldPermissionIds: [],
kanbanAggregateOperationViewIds: [],
calendarViewIds: [],
mainGroupByFieldMetadataViewIds: [],
createdAt: now,
updatedAt: now,
applicationUniversalIdentifier:
TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER,
objectMetadataUniversalIdentifier:
STANDARD_OBJECTS.calendarEvent.universalIdentifier,
relationTargetObjectMetadataUniversalIdentifier: null,
relationTargetFieldMetadataUniversalIdentifier: null,
viewFilterUniversalIdentifiers: [],
viewFieldUniversalIdentifiers: [],
fieldPermissionUniversalIdentifiers: [],
kanbanAggregateOperationViewUniversalIdentifiers: [],
calendarViewUniversalIdentifiers: [],
mainGroupByFieldMetadataViewUniversalIdentifiers: [],
viewSortIds: [],
viewSortUniversalIdentifiers: [],
universalSettings: null,
});
@RegisteredWorkspaceCommand('2.10.0', 1799000055000)
@Command({
name: 'upgrade:2-10:sync-call-recording-standard-objects',
@@ -200,6 +288,22 @@ export class SyncCallRecordingStandardObjectsCommand extends ActiveOrSuspendedWo
renamedCollisionObjectMetadatas,
});
const legacyCalendarEventRecordingPreferenceFieldMetadata =
flatFieldMetadataMaps.byUniversalIdentifier[
LEGACY_CALENDAR_EVENT_RECORDING_PREFERENCE_FIELD_UNIVERSAL_IDENTIFIER
];
const legacyCalendarEventRecordingPreferenceFieldMetadataToCreate =
isDefined(legacyCalendarEventRecordingPreferenceFieldMetadata)
? []
: [
buildLegacyCalendarEventRecordingPreferenceFieldMetadata({
calendarEventObjectMetadata,
now,
twentyStandardApplicationId: twentyStandardFlatApplication.id,
workspaceId,
}),
];
const allFlatEntityOperationByMetadataName = {
objectMetadata: {
flatEntityToCreate:
@@ -214,14 +318,16 @@ export class SyncCallRecordingStandardObjectsCommand extends ActiveOrSuspendedWo
flatEntityToUpdate: [],
},
fieldMetadata: {
flatEntityToCreate:
getStandardFlatEntitiesToCreateOrThrow<FlatFieldMetadata>({
flatEntityToCreate: [
...getStandardFlatEntitiesToCreateOrThrow<FlatFieldMetadata>({
standardFlatEntityMaps:
standardAllFlatEntityMaps.flatFieldMetadataMaps,
existingFlatEntityMaps: flatFieldMetadataMaps,
universalIdentifiers:
CALL_RECORDING_FIELD_METADATA_UNIVERSAL_IDENTIFIERS,
}),
...legacyCalendarEventRecordingPreferenceFieldMetadataToCreate,
],
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
@@ -6,6 +6,7 @@ import {
buildCallRecordingObjectRenameUpdates,
findCalendarEventFieldNameCollisionsForCallRecording,
findCallRecordingObjectNameCollisions,
LEGACY_CALENDAR_EVENT_RECORDING_PREFERENCE_FIELD_UNIVERSAL_IDENTIFIER,
resolveAvailableOldCalendarEventFieldName,
resolveAvailableOldCallRecordingObjectNames,
} from 'src/database/commands/upgrade-version-command/2-10/utils/call-recording-name-collision.util';
@@ -327,8 +328,7 @@ describe('findCalendarEventFieldNameCollisionsForCallRecording', () => {
const maps = buildFlatFieldMetadataMaps([
getCalendarEventFieldMetadataMock({
universalIdentifier:
STANDARD_OBJECTS.calendarEvent.fields.recordingPreference
.universalIdentifier,
LEGACY_CALENDAR_EVENT_RECORDING_PREFERENCE_FIELD_UNIVERSAL_IDENTIFIER,
name: 'recordingPreference',
}),
getCalendarEventFieldMetadataMock({
@@ -15,6 +15,9 @@ const FIELD_OLD_NAME_SUFFIX = 'Old';
const FIELD_OLD_LABEL_SUFFIX = ' (Old)';
const MAX_OLD_NAME_ATTEMPTS = 100;
export const LEGACY_CALENDAR_EVENT_RECORDING_PREFERENCE_FIELD_UNIVERSAL_IDENTIFIER =
'1d231e7e-9bbe-410b-8007-ea7678a83e58';
const CALL_RECORDING_CALENDAR_EVENT_FIELD_NAMES = [
CALENDAR_EVENT_RECORDING_PREFERENCE_FIELD_NAME,
CALENDAR_EVENT_CALL_RECORDINGS_FIELD_NAME,
@@ -22,8 +25,7 @@ const CALL_RECORDING_CALENDAR_EVENT_FIELD_NAMES = [
const CALL_RECORDING_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIERS =
new Set<string>([
STANDARD_OBJECTS.calendarEvent.fields.recordingPreference
.universalIdentifier,
LEGACY_CALENDAR_EVENT_RECORDING_PREFERENCE_FIELD_UNIVERSAL_IDENTIFIER,
STANDARD_OBJECTS.calendarEvent.fields.callRecordings.universalIdentifier,
]);
@@ -1,6 +1,8 @@
import { Module } from '@nestjs/common';
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
import { SyncCallRecordingRequestStatusCommand } from 'src/database/commands/upgrade-version-command/2-14/2-14-workspace-command-1799000065000-sync-call-recording-request-status.command';
import { DropCalendarEventRecordingPreferenceCommand } from 'src/database/commands/upgrade-version-command/2-14/2-14-workspace-command-1799000066000-drop-calendar-event-recording-preference.command';
import { FixStandardRelationFieldLabelsIconsCommand } from 'src/database/commands/upgrade-version-command/2-14/2-14-workspace-command-1799000040000-fix-standard-relation-field-labels-icons.command';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
@@ -13,6 +15,10 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
WorkspaceIteratorModule,
WorkspaceMigrationModule,
],
providers: [FixStandardRelationFieldLabelsIconsCommand],
providers: [
FixStandardRelationFieldLabelsIconsCommand,
SyncCallRecordingRequestStatusCommand,
DropCalendarEventRecordingPreferenceCommand,
],
})
export class V2_14_UpgradeVersionCommandModule {}
@@ -0,0 +1,193 @@
import { Command } from 'nest-commander';
import { STANDARD_OBJECTS } 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 { 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 FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { type FlatViewField } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field.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 CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER =
STANDARD_OBJECTS.callRecording.universalIdentifier;
const CALL_RECORDING_REQUEST_STATUS_FIELD_UNIVERSAL_IDENTIFIER =
STANDARD_OBJECTS.callRecording.fields.recordingRequestStatus
.universalIdentifier;
const CALL_RECORDING_REQUEST_STATUS_VIEW_FIELD_UNIVERSAL_IDENTIFIERS = [
STANDARD_OBJECTS.callRecording.views.allCallRecordings.viewFields
.recordingRequestStatus.universalIdentifier,
STANDARD_OBJECTS.callRecording.views.callRecordingRecordPageFields.viewFields
.recordingRequestStatus.universalIdentifier,
];
const CALL_RECORDING_REQUEST_STATUS_FIELD_NAME = 'recordingRequestStatus';
@RegisteredWorkspaceCommand('2.14.0', 1799000065000)
@Command({
name: 'upgrade:2-14:sync-call-recording-request-status',
description:
'Create the CallRecording recordingRequestStatus metadata in existing workspaces',
})
export class SyncCallRecordingRequestStatusCommand 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 { twentyStandardFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const { flatFieldMetadataMaps, flatObjectMetadataMaps, flatViewFieldMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatFieldMetadataMaps',
'flatObjectMetadataMaps',
'flatViewFieldMaps',
]);
const existingCallRecordingObjectMetadata =
flatObjectMetadataMaps.byUniversalIdentifier[
CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER
];
if (!isDefined(existingCallRecordingObjectMetadata)) {
this.logger.log(
`CallRecording object metadata does not exist for workspace ${workspaceId}, skipping`,
);
return;
}
const existingRecordingRequestStatusField =
flatFieldMetadataMaps.byUniversalIdentifier[
CALL_RECORDING_REQUEST_STATUS_FIELD_UNIVERSAL_IDENTIFIER
];
if (
!isDefined(existingRecordingRequestStatusField) &&
hasFieldNameConflict({
flatFieldMetadatas: Object.values(
flatFieldMetadataMaps.byUniversalIdentifier,
).filter(isDefined),
callRecordingObjectMetadata: existingCallRecordingObjectMetadata,
})
) {
this.logger.warn(
`Field name "${CALL_RECORDING_REQUEST_STATUS_FIELD_NAME}" is already taken on CallRecording for workspace ${workspaceId}; skipping`,
);
return;
}
const { allFlatEntityMaps: standardAllFlatEntityMaps } =
computeTwentyStandardApplicationAllFlatEntityMaps({
now: new Date().toISOString(),
workspaceId,
twentyStandardApplicationId: twentyStandardFlatApplication.id,
});
const recordingRequestStatusFieldsToCreate =
getStandardFlatEntitiesToCreateOrThrow<FlatFieldMetadata>({
standardFlatEntityMaps: standardAllFlatEntityMaps.flatFieldMetadataMaps,
existingFlatEntityMaps: flatFieldMetadataMaps,
universalIdentifiers: [
CALL_RECORDING_REQUEST_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
],
});
const recordingRequestStatusViewFieldsToCreate =
getStandardFlatEntitiesToCreateOrThrow<FlatViewField>({
standardFlatEntityMaps: standardAllFlatEntityMaps.flatViewFieldMaps,
existingFlatEntityMaps: flatViewFieldMaps,
universalIdentifiers:
CALL_RECORDING_REQUEST_STATUS_VIEW_FIELD_UNIVERSAL_IDENTIFIERS,
});
const totalOperationCount =
recordingRequestStatusFieldsToCreate.length +
recordingRequestStatusViewFieldsToCreate.length;
if (totalOperationCount === 0) {
this.logger.log(
`CallRecording recordingRequestStatus metadata already exists for workspace ${workspaceId}, skipping`,
);
return;
}
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Creating ${totalOperationCount} CallRecording recordingRequestStatus metadata item(s) for workspace ${workspaceId}`,
);
if (isDryRun) {
return;
}
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
isSystemBuild: true,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
workspaceId,
allFlatEntityOperationByMetadataName: {
fieldMetadata: {
flatEntityToCreate: recordingRequestStatusFieldsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
viewField: {
flatEntityToCreate: recordingRequestStatusViewFieldsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
},
},
);
if (validateAndBuildResult.status === 'fail') {
throw new Error(
`Failed to create CallRecording recordingRequestStatus metadata for workspace ${workspaceId}: ${JSON.stringify(
validateAndBuildResult,
null,
2,
)}`,
);
}
this.logger.log(
`Created ${totalOperationCount} CallRecording recordingRequestStatus metadata item(s) for workspace ${workspaceId}`,
);
}
}
const hasFieldNameConflict = ({
flatFieldMetadatas,
callRecordingObjectMetadata,
}: {
flatFieldMetadatas: FlatFieldMetadata[];
callRecordingObjectMetadata: FlatObjectMetadata;
}): boolean =>
flatFieldMetadatas.some(
(flatFieldMetadata) =>
flatFieldMetadata.objectMetadataUniversalIdentifier ===
callRecordingObjectMetadata.universalIdentifier &&
flatFieldMetadata.name === CALL_RECORDING_REQUEST_STATUS_FIELD_NAME,
);
@@ -0,0 +1,109 @@
import { Command } from 'nest-commander';
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 { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
const CALENDAR_EVENT_RECORDING_PREFERENCE_FIELD_UNIVERSAL_IDENTIFIER =
'1d231e7e-9bbe-410b-8007-ea7678a83e58';
@RegisteredWorkspaceCommand('2.14.0', 1799000066000)
@Command({
name: 'upgrade:2-14:drop-calendar-event-recording-preference',
description: 'Drop the CalendarEvent recordingPreference field metadata',
})
export class DropCalendarEventRecordingPreferenceCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
private readonly applicationService: ApplicationService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {
super(workspaceIteratorService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Starting CalendarEvent recordingPreference field removal for workspace ${workspaceId}`,
);
const { flatFieldMetadataMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatFieldMetadataMaps',
]);
const recordingPreferenceFieldMetadata =
findFlatEntityByUniversalIdentifier<FlatFieldMetadata>({
flatEntityMaps: flatFieldMetadataMaps,
universalIdentifier:
CALENDAR_EVENT_RECORDING_PREFERENCE_FIELD_UNIVERSAL_IDENTIFIER,
});
if (!recordingPreferenceFieldMetadata) {
this.logger.log(
`CalendarEvent recordingPreference field already absent for workspace ${workspaceId}`,
);
return;
}
if (isDryRun) {
this.logger.log(
`[DRY RUN] Would delete CalendarEvent recordingPreference field for workspace ${workspaceId}`,
);
return;
}
const { twentyStandardFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
isSystemBuild: true,
allFlatEntityOperationByMetadataName: {
fieldMetadata: {
flatEntityToCreate: [],
flatEntityToDelete: [recordingPreferenceFieldMetadata],
flatEntityToUpdate: [],
},
},
workspaceId,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
},
);
if (validateAndBuildResult.status === 'fail') {
this.logger.error(
`Failed to delete CalendarEvent recordingPreference field:\n${JSON.stringify(
validateAndBuildResult,
null,
2,
)}`,
);
throw new Error(
`Failed to delete CalendarEvent recordingPreference field for workspace ${workspaceId}`,
);
}
this.logger.log(
`Deleted CalendarEvent recordingPreference field for workspace ${workspaceId}`,
);
}
}