From 036d9a2bcfc787378195a8c48535c2d01daaec5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Sat, 13 Jun 2026 14:05:42 +0200 Subject: [PATCH] feat: gate record creation on isUICreatable only, decoupled from isSystem (#21527) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context The generic "create a record" UI affordance previously required `!isSystem`, conflating two distinct concerns: **"hidden from Data Model"** and **"not user-creatable"**. This blocked legitimately creatable system objects (e.g. marketing message lists kept `isSystem: true` only to stay out of the Data Model). This PR makes creatability depend on `isUICreatable` alone, so visibility (`isSystem`) and creatability (`isUICreatable`) become independent. ## Changes - **Front gate** (`canCreateRecordsForObjectMetadataItem.ts`): drop the `!isSystem` clause — creatability is now `isUICreatable && !readOnly`. Updated comment + unit test. - **Command menu** (`standard-command-menu-item.constant.ts`): drop the matching `not objectMetadataItem.isSystem` clause from the `createNewRecord` availability expression so the "Create new X" command mirrors the front gate. Existing workspaces get this via the already-present `SyncCreateRecordCommandAvailabilityExpressionCommand`, which re-syncs from the live definition. - **Standard object audit** (`create-standard-flat-object-metadata.util.ts`): the 15 sync/system-created standard objects that relied on `!isSystem` to stay non-creatable now set `isUICreatable: false` (attachment, blocklist, calendar*/message*/note/task targets, message, messageThread, messageParticipant, timelineActivity, callRecording, workflowAutomatedTrigger, …). `workflowRun`/`workflowVersion`/`workspaceMember` were already `false`. Non-system objects (company, person, note, opportunity, dashboard, task, workflow) are untouched. - **Backfill**: new fast instance command (`2-13-…-1781277480000-backfill-non-ui-creatable-standard-system-objects.ts`) runs `UPDATE core.objectMetadata SET isUICreatable = false` for those standard system objects (symmetric `down`), registered in `instance-commands.constant.ts`. `isSystem` and Data-Model visibility logic are unchanged. ## Verification - Front gate unit test (7 pass), standard-application suite incl. callRecording (10 pass) - `oxlint` clean on all changed files - `twenty-server` and `twenty-front` typecheck green 🤖 https://claude.ai/code/session_01TF4kjD56hHjP31wkHPxPv3 --- _Generated by [Claude Code](https://claude.ai/code/session_01TF4kjD56hHjP31wkHPxPv3)_ Review in cubic --------- Co-authored-by: Claude --- ...CreateRecordsForObjectMetadataItem.test.ts | 4 +- .../canCreateRecordsForObjectMetadataItem.ts | 4 +- ...on-ui-creatable-standard-system-objects.ts | 51 +++++++++++++++++++ .../instance-commands.constant.ts | 2 + .../standard-command-menu-item.constant.ts | 2 +- ...eate-standard-flat-object-metadata.util.ts | 15 ++++++ 6 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-13/2-13-instance-command-slow-1781277480000-backfill-non-ui-creatable-standard-system-objects.ts diff --git a/packages/twenty-front/src/modules/object-record/utils/__tests__/canCreateRecordsForObjectMetadataItem.test.ts b/packages/twenty-front/src/modules/object-record/utils/__tests__/canCreateRecordsForObjectMetadataItem.test.ts index 2dc691ca75..0ef345e183 100644 --- a/packages/twenty-front/src/modules/object-record/utils/__tests__/canCreateRecordsForObjectMetadataItem.test.ts +++ b/packages/twenty-front/src/modules/object-record/utils/__tests__/canCreateRecordsForObjectMetadataItem.test.ts @@ -48,7 +48,7 @@ describe('canCreateRecordsForObjectMetadataItem', () => { expect(result).toBe(false); }); - it('should return false when the object is a system object', () => { + it('should return true for a UI-creatable system object (isSystem only controls Data-Model visibility)', () => { const result = canCreateRecordsForObjectMetadataItem({ objectPermissions: objectPermissionsAllowingUpdate, objectMetadataItem: { @@ -57,7 +57,7 @@ describe('canCreateRecordsForObjectMetadataItem', () => { }, }); - expect(result).toBe(false); + expect(result).toBe(true); }); it('should return false when the object is remote', () => { diff --git a/packages/twenty-front/src/modules/object-record/utils/canCreateRecordsForObjectMetadataItem.ts b/packages/twenty-front/src/modules/object-record/utils/canCreateRecordsForObjectMetadataItem.ts index 1c2fc78a18..5c5383086d 100644 --- a/packages/twenty-front/src/modules/object-record/utils/canCreateRecordsForObjectMetadataItem.ts +++ b/packages/twenty-front/src/modules/object-record/utils/canCreateRecordsForObjectMetadataItem.ts @@ -11,6 +11,9 @@ type CanCreateRecordsForObjectMetadataItemParams = { }; // Single predicate for every generic "create a record" UI affordance. +// Creatability is driven solely by isUICreatable: isSystem only controls +// Data-Model visibility, so a system object can still be user-creatable +// (e.g. marketing message lists kept out of the Data Model). // Creation requires effective editability because today's inline creation UX // creates a blank record that the user must then be able to edit. // There is no CREATE permission yet, so canUpdateObjectRecords (checked @@ -21,7 +24,6 @@ export const canCreateRecordsForObjectMetadataItem = ({ }: CanCreateRecordsForObjectMetadataItemParams): boolean => { return ( objectMetadataItem.isUICreatable && - !objectMetadataItem.isSystem && !isObjectMetadataReadOnly({ objectPermissions, objectMetadataItem }) ); }; diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-13/2-13-instance-command-slow-1781277480000-backfill-non-ui-creatable-standard-system-objects.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-13/2-13-instance-command-slow-1781277480000-backfill-non-ui-creatable-standard-system-objects.ts new file mode 100644 index 0000000000..d347170af3 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-13/2-13-instance-command-slow-1781277480000-backfill-non-ui-creatable-standard-system-objects.ts @@ -0,0 +1,51 @@ +import { DataSource, QueryRunner } from 'typeorm'; + +import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; +import { SlowInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/slow-instance-command.interface'; + +// The create-record UI affordance is now gated on isUICreatable alone (the +// !isSystem clause was dropped). Standard system objects relied on that clause +// to stay non-creatable, so their isUICreatable (default true) must be flipped +// to false to preserve behavior. Sync/system-created objects only — the +// user-creatable system objects (e.g. marketing message lists) keep true. +const NON_UI_CREATABLE_STANDARD_SYSTEM_OBJECT_NAMES = [ + 'attachment', + 'blocklist', + 'calendarChannelEventAssociation', + 'calendarEventParticipant', + 'calendarEvent', + 'callRecording', + 'messageChannelMessageAssociation', + 'messageChannelMessageAssociationMessageFolder', + 'messageParticipant', + 'messageThread', + 'message', + 'noteTarget', + 'taskTarget', + 'timelineActivity', + 'workflowAutomatedTrigger', +]; + +@RegisteredInstanceCommand('2.13.0', 1781277480000, { type: 'slow' }) +export class BackfillNonUiCreatableStandardSystemObjectsSlowInstanceCommand + implements SlowInstanceCommand +{ + async runDataMigration(dataSource: DataSource): Promise { + await dataSource.query( + `UPDATE "core"."objectMetadata" + SET "isUICreatable" = false + WHERE "isSystem" = true + AND "isUICreatable" = true + AND "nameSingular" = ANY($1)`, + [NON_UI_CREATABLE_STANDARD_SYSTEM_OBJECT_NAMES], + ); + } + + public async up(_queryRunner: QueryRunner): Promise { + return; + } + + public async down(_queryRunner: QueryRunner): Promise { + return; + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts index 4913d85ee5..3fa916c856 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts @@ -63,6 +63,7 @@ import { DropIsCustomFromObjectAndFieldMetadataFastInstanceCommand } from 'src/d import { DropEmailingDomainDriverColumnFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-11/2-11-instance-command-fast-1780926908000-drop-emailing-domain-driver-column'; import { ViewOverridableEntityFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-12/2-12-instance-command-fast-1781114009075-view-overridable-entity'; import { RenameIsUiReadOnlyToIsUiEditableFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-13/2-13-instance-command-fast-1781277453604-rename-is-ui-read-only-to-is-ui-editable'; +import { BackfillNonUiCreatableStandardSystemObjectsSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-13/2-13-instance-command-slow-1781277480000-backfill-non-ui-creatable-standard-system-objects'; export const INSTANCE_COMMANDS = [ AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand, @@ -128,4 +129,5 @@ export const INSTANCE_COMMANDS = [ DropEmailingDomainDriverColumnFastInstanceCommand, ViewOverridableEntityFastInstanceCommand, RenameIsUiReadOnlyToIsUiEditableFastInstanceCommand, + BackfillNonUiCreatableStandardSystemObjectsSlowInstanceCommand, ]; diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant.ts index a52dfaa8e6..1bd69672f8 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant.ts @@ -44,7 +44,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = { shortLabel: 'New ${capitalize(objectMetadataItem.labelSingular)}', availabilityType: CommandMenuItemAvailabilityType.GLOBAL_OBJECT_CONTEXT, conditionalAvailabilityExpression: - 'pageType == "INDEX_PAGE" and objectPermissions.canUpdateObjectRecords and not hasAnySoftDeleteFilterOnView and objectMetadataItem.isUICreatable and objectMetadataItem.isUIEditable and not objectMetadataItem.isSystem and not objectMetadataItem.isRemote', + 'pageType == "INDEX_PAGE" and objectPermissions.canUpdateObjectRecords and not hasAnySoftDeleteFilterOnView and objectMetadataItem.isUICreatable and objectMetadataItem.isUIEditable and not objectMetadataItem.isRemote', availabilityObjectMetadataUniversalIdentifier: null, frontComponentUniversalIdentifier: null, engineComponentKey: EngineComponentKey.CREATE_NEW_RECORD, diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-flat-object-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-flat-object-metadata.util.ts index 76edb9eccd..fe1eecd9c2 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-flat-object-metadata.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-flat-object-metadata.util.ts @@ -29,6 +29,7 @@ export const STANDARD_FLAT_OBJECT_METADATA_BUILDERS_BY_OBJECT_NAME = { description: i18nLabel(msg`An attachment`), icon: 'IconFileImport', isSystem: true, + isUICreatable: false, labelIdentifierFieldMetadataName: 'name', }, workspaceId, @@ -55,6 +56,7 @@ export const STANDARD_FLAT_OBJECT_METADATA_BUILDERS_BY_OBJECT_NAME = { description: i18nLabel(msg`Blocklist`), icon: 'IconForbid2', isSystem: true, + isUICreatable: false, labelIdentifierFieldMetadataName: 'handle', }, workspaceId, @@ -86,6 +88,7 @@ export const STANDARD_FLAT_OBJECT_METADATA_BUILDERS_BY_OBJECT_NAME = { icon: 'IconCalendar', isSystem: true, isAuditLogged: false, + isUICreatable: false, labelIdentifierFieldMetadataName: 'id', }, workspaceId, @@ -117,6 +120,7 @@ export const STANDARD_FLAT_OBJECT_METADATA_BUILDERS_BY_OBJECT_NAME = { icon: 'IconCalendar', isSystem: true, isAuditLogged: false, + isUICreatable: false, labelIdentifierFieldMetadataName: 'handle', }, workspaceId, @@ -147,6 +151,7 @@ export const STANDARD_FLAT_OBJECT_METADATA_BUILDERS_BY_OBJECT_NAME = { icon: 'IconCalendar', isSystem: true, isAuditLogged: false, + isUICreatable: false, labelIdentifierFieldMetadataName: 'title', }, workspaceId, @@ -177,6 +182,7 @@ export const STANDARD_FLAT_OBJECT_METADATA_BUILDERS_BY_OBJECT_NAME = { icon: 'IconVideo', isSystem: true, isAuditLogged: false, + isUICreatable: false, labelIdentifierFieldMetadataName: 'title', }, workspaceId, @@ -263,6 +269,7 @@ export const STANDARD_FLAT_OBJECT_METADATA_BUILDERS_BY_OBJECT_NAME = { icon: 'IconMessage', isSystem: true, isAuditLogged: false, + isUICreatable: false, labelIdentifierFieldMetadataName: 'id', }, workspaceId, @@ -301,6 +308,7 @@ export const STANDARD_FLAT_OBJECT_METADATA_BUILDERS_BY_OBJECT_NAME = { icon: 'IconFolder', isSystem: true, isAuditLogged: false, + isUICreatable: false, labelIdentifierFieldMetadataName: 'id', }, workspaceId, @@ -332,6 +340,7 @@ export const STANDARD_FLAT_OBJECT_METADATA_BUILDERS_BY_OBJECT_NAME = { icon: 'IconUserCircle', isSystem: true, isAuditLogged: false, + isUICreatable: false, labelIdentifierFieldMetadataName: 'handle', }, workspaceId, @@ -362,6 +371,7 @@ export const STANDARD_FLAT_OBJECT_METADATA_BUILDERS_BY_OBJECT_NAME = { icon: 'IconMessage', isSystem: true, isAuditLogged: false, + isUICreatable: false, labelIdentifierFieldMetadataName: 'subject', }, workspaceId, @@ -389,6 +399,7 @@ export const STANDARD_FLAT_OBJECT_METADATA_BUILDERS_BY_OBJECT_NAME = { icon: 'IconMessage', isSystem: true, isAuditLogged: false, + isUICreatable: false, labelIdentifierFieldMetadataName: 'subject', }, workspaceId, @@ -442,6 +453,7 @@ export const STANDARD_FLAT_OBJECT_METADATA_BUILDERS_BY_OBJECT_NAME = { description: i18nLabel(msg`A note target`), icon: 'IconCheckbox', isSystem: true, + isUICreatable: false, labelIdentifierFieldMetadataName: 'id', }, workspaceId, @@ -555,6 +567,7 @@ export const STANDARD_FLAT_OBJECT_METADATA_BUILDERS_BY_OBJECT_NAME = { description: i18nLabel(msg`A task target`), icon: 'IconCheckbox', isSystem: true, + isUICreatable: false, labelIdentifierFieldMetadataName: 'id', }, workspaceId, @@ -588,6 +601,7 @@ export const STANDARD_FLAT_OBJECT_METADATA_BUILDERS_BY_OBJECT_NAME = { icon: 'IconTimelineEvent', isSystem: true, isAuditLogged: false, + isUICreatable: false, labelIdentifierFieldMetadataName: 'name', }, workspaceId, @@ -645,6 +659,7 @@ export const STANDARD_FLAT_OBJECT_METADATA_BUILDERS_BY_OBJECT_NAME = { description: i18nLabel(msg`A workflow automated trigger`), icon: 'IconSettingsAutomation', isSystem: true, + isUICreatable: false, labelIdentifierFieldMetadataName: 'id', }, workspaceId,