[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:
+48
@@ -1,8 +1,10 @@
|
||||
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
|
||||
import { v5 } from 'uuid';
|
||||
|
||||
import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/enums/command-menu-item-availability-type.enum';
|
||||
import { EngineComponentKey } from 'src/engine/metadata-modules/command-menu-item/enums/engine-component-key.enum';
|
||||
import {
|
||||
buildNavigationConditionalAvailabilityExpression,
|
||||
buildNavigationFlatCommandMenuItem,
|
||||
NAVIGATION_INTERPOLATED_ICON,
|
||||
NAVIGATION_INTERPOLATED_LABEL,
|
||||
@@ -114,6 +116,21 @@ describe('buildNavigationFlatCommandMenuItem', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should additionally gate conditionalAvailabilityExpression behind the feature flag for feature-flagged objects', () => {
|
||||
const result = buildNavigationFlatCommandMenuItem({
|
||||
...baseArgs,
|
||||
objectMetadata: {
|
||||
...baseObjectMetadata,
|
||||
universalIdentifier: STANDARD_OBJECTS.callRecording.universalIdentifier,
|
||||
nameSingular: 'callRecording',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.conditionalAvailabilityExpression).toBe(
|
||||
'featureFlags.IS_CALL_RECORDING_ENABLED and targetObjectReadPermissions.callRecording',
|
||||
);
|
||||
});
|
||||
|
||||
it('should set isPinned to false', () => {
|
||||
const result = buildNavigationFlatCommandMenuItem(baseArgs);
|
||||
|
||||
@@ -137,3 +154,34 @@ describe('buildNavigationFlatCommandMenuItem', () => {
|
||||
expect(result.updatedAt).toBe('2026-01-01T00:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildNavigationConditionalAvailabilityExpression', () => {
|
||||
it('gates the standard call recording object behind both the flag and read permission', () => {
|
||||
expect(
|
||||
buildNavigationConditionalAvailabilityExpression({
|
||||
universalIdentifier: STANDARD_OBJECTS.callRecording.universalIdentifier,
|
||||
nameSingular: 'callRecording',
|
||||
}),
|
||||
).toBe(
|
||||
'featureFlags.IS_CALL_RECORDING_ENABLED and targetObjectReadPermissions.callRecording',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns only the read-permission expression for non-gated objects', () => {
|
||||
expect(
|
||||
buildNavigationConditionalAvailabilityExpression({
|
||||
universalIdentifier: 'obj-universal-1',
|
||||
nameSingular: 'person',
|
||||
}),
|
||||
).toBe('targetObjectReadPermissions.person');
|
||||
});
|
||||
|
||||
it('does not gate a custom object that reuses the callRecording name', () => {
|
||||
expect(
|
||||
buildNavigationConditionalAvailabilityExpression({
|
||||
universalIdentifier: 'custom-object-universal-id',
|
||||
nameSingular: 'callRecording',
|
||||
}),
|
||||
).toBe('targetObjectReadPermissions.callRecording');
|
||||
});
|
||||
});
|
||||
+34
-1
@@ -1,3 +1,5 @@
|
||||
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
|
||||
import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { v5 } from 'uuid';
|
||||
|
||||
@@ -16,6 +18,31 @@ export const NAVIGATION_INTERPOLATED_SHORT_LABEL =
|
||||
export const NAVIGATION_INTERPOLATED_ICON =
|
||||
'${navigateToObjectMetadataItem.icon}';
|
||||
|
||||
const NAVIGATION_FEATURE_FLAG_GATE_BY_OBJECT_UNIVERSAL_IDENTIFIER: Partial<
|
||||
Record<string, FeatureFlagKey>
|
||||
> = {
|
||||
[STANDARD_OBJECTS.callRecording.universalIdentifier]:
|
||||
FeatureFlagKey.IS_CALL_RECORDING_ENABLED,
|
||||
};
|
||||
|
||||
export const buildNavigationConditionalAvailabilityExpression = ({
|
||||
universalIdentifier,
|
||||
nameSingular,
|
||||
}: {
|
||||
universalIdentifier: string;
|
||||
nameSingular: string;
|
||||
}): string => {
|
||||
const targetObjectReadPermissionExpression = `targetObjectReadPermissions.${nameSingular}`;
|
||||
const featureFlagGate =
|
||||
NAVIGATION_FEATURE_FLAG_GATE_BY_OBJECT_UNIVERSAL_IDENTIFIER[
|
||||
universalIdentifier
|
||||
];
|
||||
|
||||
return isDefined(featureFlagGate)
|
||||
? `featureFlags.${featureFlagGate} and ${targetObjectReadPermissionExpression}`
|
||||
: targetObjectReadPermissionExpression;
|
||||
};
|
||||
|
||||
export const buildNavigationFlatCommandMenuItem = ({
|
||||
objectMetadata,
|
||||
commandMenuItemId,
|
||||
@@ -41,6 +68,12 @@ export const buildNavigationFlatCommandMenuItem = ({
|
||||
NAVIGATION_COMMAND_UUID_NAMESPACE,
|
||||
);
|
||||
|
||||
const conditionalAvailabilityExpression =
|
||||
buildNavigationConditionalAvailabilityExpression({
|
||||
universalIdentifier: objectMetadata.universalIdentifier,
|
||||
nameSingular: objectMetadata.nameSingular,
|
||||
});
|
||||
|
||||
return {
|
||||
id: commandMenuItemId,
|
||||
universalIdentifier,
|
||||
@@ -54,7 +87,7 @@ export const buildNavigationFlatCommandMenuItem = ({
|
||||
position,
|
||||
isPinned: false,
|
||||
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
|
||||
conditionalAvailabilityExpression: `targetObjectReadPermissions.${objectMetadata.nameSingular}`,
|
||||
conditionalAvailabilityExpression,
|
||||
frontComponentId: null,
|
||||
frontComponentUniversalIdentifier: null,
|
||||
engineComponentKey: EngineComponentKey.NAVIGATION,
|
||||
|
||||
Reference in New Issue
Block a user