[BREAKING-CHANGE] Add NOT_RECORDED call recording status (#23478)

Adds NOT_RECORDED to the CallRecording status select, for meetings where
nothing was captured (bot never admitted, meeting not started, nobody
joined). First part of twentyhq/core-team-issues#2706, split out so
existing workspaces are upgraded before the call-recorder app starts
writing the new status.

- NOT_RECORDED enum value + standard select option
- 2-26 workspace upgrade command adding the option to existing
workspaces (idempotent, same option id as the standard definition)

App-side classification from Recall sub codes comes in a follow-up PR.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23478?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-07-29 20:14:51 +05:30
committed by GitHub
parent 4ec65ed08d
commit 742f76e318
7 changed files with 212 additions and 3 deletions
@@ -28,6 +28,7 @@ export enum CallRecordingStatus {
COMPLETED = 'COMPLETED', COMPLETED = 'COMPLETED',
FAILED = 'FAILED', FAILED = 'FAILED',
JOINING = 'JOINING', JOINING = 'JOINING',
NOT_RECORDED = 'NOT_RECORDED',
PROCESSING = 'PROCESSING', PROCESSING = 'PROCESSING',
RECORDING = 'RECORDING', RECORDING = 'RECORDING',
SCHEDULED = 'SCHEDULED' SCHEDULED = 'SCHEDULED'
@@ -3,6 +3,7 @@ import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { useId } from 'react'; import { useId } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { assertUnreachable } from 'twenty-shared/utils';
import { AppTooltip, TooltipDelay, TooltipPosition } from 'twenty-ui/surfaces'; import { AppTooltip, TooltipDelay, TooltipPosition } from 'twenty-ui/surfaces';
import { CallRecordingStatus } from '~/generated/graphql'; import { CallRecordingStatus } from '~/generated/graphql';
@@ -12,6 +13,11 @@ const IN_PROGRESS_CALL_RECORDING_STATUSES: CallRecordingStatus[] = [
CallRecordingStatus.PROCESSING, CallRecordingStatus.PROCESSING,
]; ];
const UNAVAILABLE_CALL_RECORDING_STATUSES: CallRecordingStatus[] = [
CallRecordingStatus.FAILED,
CallRecordingStatus.NOT_RECORDED,
];
const getCallRecordingStatusLabel = (status: CallRecordingStatus) => { const getCallRecordingStatusLabel = (status: CallRecordingStatus) => {
switch (status) { switch (status) {
case CallRecordingStatus.SCHEDULED: case CallRecordingStatus.SCHEDULED:
@@ -26,6 +32,10 @@ const getCallRecordingStatusLabel = (status: CallRecordingStatus) => {
return t`Completed`; return t`Completed`;
case CallRecordingStatus.FAILED: case CallRecordingStatus.FAILED:
return t`Failed`; return t`Failed`;
case CallRecordingStatus.NOT_RECORDED:
return t`Not recorded`;
default:
return assertUnreachable(status);
} }
}; };
@@ -44,12 +54,12 @@ export const CalendarEventCallRecorderAvatar = ({
status, status,
}: CalendarEventCallRecorderAvatarProps) => { }: CalendarEventCallRecorderAvatarProps) => {
const instanceId = useId(); const instanceId = useId();
const hasFailed = status === CallRecordingStatus.FAILED; const hasNoRecording = UNAVAILABLE_CALL_RECORDING_STATUSES.includes(status);
const tooltipAnchorId = `call-recorder-${instanceId.replace(/[^a-zA-Z0-9-_]/g, '-')}`; const tooltipAnchorId = `call-recorder-${instanceId.replace(/[^a-zA-Z0-9-_]/g, '-')}`;
return ( return (
<> <>
<StyledContainer id={tooltipAnchorId} isDisabled={hasFailed}> <StyledContainer id={tooltipAnchorId} isDisabled={hasNoRecording}>
<AppChip <AppChip
applicationId={applicationId} applicationId={applicationId}
fallbackApplicationData={{ name: t`Call recorder` }} fallbackApplicationData={{ name: t`Call recorder` }}
@@ -4,7 +4,9 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module'; import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
import { ReconcileIndexViewUniversalIdentifierCommand } from 'src/database/commands/upgrade-version-command/2-26/2-26-workspace-command-1785255689000-reconcile-index-view-universal-identifier.command'; import { ReconcileIndexViewUniversalIdentifierCommand } from 'src/database/commands/upgrade-version-command/2-26/2-26-workspace-command-1785255689000-reconcile-index-view-universal-identifier.command';
import { DemoteAndBackfillApplicationIndexViewCommand } from 'src/database/commands/upgrade-version-command/2-26/2-26-workspace-command-1785255690000-demote-and-backfill-application-index-view.command'; import { DemoteAndBackfillApplicationIndexViewCommand } from 'src/database/commands/upgrade-version-command/2-26/2-26-workspace-command-1785255690000-demote-and-backfill-application-index-view.command';
import { AddNotRecordedCallRecordingStatusCommand } from 'src/database/commands/upgrade-version-command/2-26/2-26-workspace-command-1785334800000-add-not-recorded-call-recording-status.command';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity'; import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/workspace-migration-runner.module'; import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/workspace-migration-runner.module';
@@ -12,7 +14,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([ViewEntity]), TypeOrmModule.forFeature([FieldMetadataEntity, ViewEntity]),
ApplicationModule, ApplicationModule,
WorkspaceCacheModule, WorkspaceCacheModule,
WorkspaceMigrationModule, WorkspaceMigrationModule,
@@ -22,6 +24,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
providers: [ providers: [
ReconcileIndexViewUniversalIdentifierCommand, ReconcileIndexViewUniversalIdentifierCommand,
DemoteAndBackfillApplicationIndexViewCommand, DemoteAndBackfillApplicationIndexViewCommand,
AddNotRecordedCallRecordingStatusCommand,
], ],
}) })
export class V2_26_UpgradeVersionCommandModule {} export class V2_26_UpgradeVersionCommandModule {}
@@ -0,0 +1,168 @@
import { Command } from 'nest-commander';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import {
type FieldMetadataComplexOption,
FieldMetadataType,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { ProvisionedWorkspaceCommandRunner } from 'src/database/commands/command-runners/provisioned-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 { 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';
import { CallRecordingStatus } from 'src/modules/call-recording/common/enums/call-recording-status.enum';
const CALL_RECORDING_STATUS_FIELD_UNIVERSAL_IDENTIFIER =
STANDARD_OBJECTS.callRecording.fields.status.universalIdentifier;
const CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER =
STANDARD_OBJECTS.callRecording.universalIdentifier;
// Same option id as the standard definition so provisioned and upgraded workspaces match.
const NOT_RECORDED_STATUS_OPTION: FieldMetadataComplexOption = {
id: 'cbd14df8-9cc2-4399-92f5-31fc41f3768b',
value: CallRecordingStatus.NOT_RECORDED,
label: 'Not recorded',
position: 6,
color: 'yellow',
};
@RegisteredWorkspaceCommand('2.26.0', 1785334800000)
@Command({
name: 'upgrade:2-26:add-not-recorded-call-recording-status',
description:
'Add the NOT_RECORDED option to the CallRecording status select in existing workspaces',
})
export class AddNotRecordedCallRecordingStatusCommand extends ProvisionedWorkspaceCommandRunner {
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 { flatFieldMetadataMaps, flatObjectMetadataMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatFieldMetadataMaps',
'flatObjectMetadataMaps',
]);
const callRecordingObject =
flatObjectMetadataMaps.byUniversalIdentifier[
CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER
];
if (!isDefined(callRecordingObject)) {
this.logger.log(
`CallRecording object metadata does not exist for workspace ${workspaceId}, skipping`,
);
return;
}
const statusField =
flatFieldMetadataMaps.byUniversalIdentifier[
CALL_RECORDING_STATUS_FIELD_UNIVERSAL_IDENTIFIER
];
if (!isDefined(statusField)) {
this.logger.log(
`CallRecording status field metadata does not exist for workspace ${workspaceId}, skipping`,
);
return;
}
if (statusField.type !== FieldMetadataType.SELECT) {
throw new Error(
`CallRecording status metadata is not a SELECT field for workspace ${workspaceId}`,
);
}
const selectStatusField =
statusField as FlatFieldMetadata<FieldMetadataType.SELECT>;
const currentOptions = selectStatusField.options ?? [];
const hasNotRecordedStatus = currentOptions.some(
(option) => option.value === CallRecordingStatus.NOT_RECORDED,
);
if (hasNotRecordedStatus) {
this.logger.log(
`CallRecording status metadata already has NOT_RECORDED for workspace ${workspaceId}, skipping`,
);
return;
}
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Adding NOT_RECORDED to CallRecording status metadata for workspace ${workspaceId}`,
);
if (isDryRun) {
return;
}
const { twentyStandardFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const nextOptionPosition =
currentOptions.reduce(
(highestPosition, option) => Math.max(highestPosition, option.position),
-1,
) + 1;
const updatedStatusField: FlatFieldMetadata<FieldMetadataType.SELECT> = {
...selectStatusField,
options: [
...currentOptions,
{ ...NOT_RECORDED_STATUS_OPTION, position: nextOptionPosition },
],
updatedAt: new Date().toISOString(),
};
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunLegacyWorkspaceMigration(
{
isSystemBuild: true,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
workspaceId,
allFlatEntityOperationByMetadataName: {
fieldMetadata: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: [updatedStatusField],
},
},
},
);
if (validateAndBuildResult.status === 'fail') {
throw new Error(
`Failed to add NOT_RECORDED to CallRecording status metadata for workspace ${workspaceId}: ${JSON.stringify(
validateAndBuildResult,
null,
2,
)}`,
);
}
this.logger.log(
`Added NOT_RECORDED to CallRecording status metadata for workspace ${workspaceId}`,
);
}
}
@@ -2,8 +2,10 @@ import {
STANDARD_OBJECTS, STANDARD_OBJECTS,
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS, STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-shared/metadata'; } from 'twenty-shared/metadata';
import { type FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type'; import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
import { computeTwentyStandardApplicationAllFlatEntityMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant'; import { computeTwentyStandardApplicationAllFlatEntityMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant';
@@ -36,6 +38,23 @@ describe('CallRecording standard metadata build', () => {
expect(callRecording?.isSystem).toBe(true); expect(callRecording?.isSystem).toBe(true);
}); });
it('offers the full recording lifecycle as status options', () => {
const statusField = allFlatEntityMaps.flatFieldMetadataMaps
.byUniversalIdentifier[
STANDARD_OBJECTS.callRecording.fields.status.universalIdentifier
] as FlatFieldMetadata<FieldMetadataType.SELECT> | undefined;
expect(statusField?.options?.map((option) => option.value)).toEqual([
'SCHEDULED',
'JOINING',
'RECORDING',
'PROCESSING',
'COMPLETED',
'FAILED',
'NOT_RECORDED',
]);
});
it('links callRecording to a calendarEvent through a direct relation', () => { it('links callRecording to a calendarEvent through a direct relation', () => {
const calendarEventField = const calendarEventField =
allFlatEntityMaps.flatFieldMetadataMaps.byUniversalIdentifier[ allFlatEntityMaps.flatFieldMetadataMaps.byUniversalIdentifier[
@@ -178,6 +178,13 @@ export const buildCallRecordingStandardFlatFieldMetadatas = ({
position: 5, position: 5,
color: 'gray', color: 'gray',
}, },
{
id: 'cbd14df8-9cc2-4399-92f5-31fc41f3768b',
value: CallRecordingStatus.NOT_RECORDED,
label: i18nLabel(msg`Not recorded`),
position: 6,
color: 'yellow',
},
], ],
}, },
standardObjectMetadataRelatedEntityIds, standardObjectMetadataRelatedEntityIds,
@@ -5,4 +5,5 @@ export enum CallRecordingStatus {
PROCESSING = 'PROCESSING', PROCESSING = 'PROCESSING',
COMPLETED = 'COMPLETED', COMPLETED = 'COMPLETED',
FAILED = 'FAILED', FAILED = 'FAILED',
NOT_RECORDED = 'NOT_RECORDED',
} }