From 66e0f620bcfd85daadab293d9cf3660ec2114f4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Tue, 4 Aug 2026 06:51:53 +0200 Subject: [PATCH] Default workspaceMember openRecordIn when the workspace is not upgraded yet (#23723) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `WorkspaceMemberDTO.openRecordIn` is `@Field(() => OpenRecordIn, { nullable: false })`, and the transpiler passed the entity value straight through. The field is created per workspace by the 2-27 workspace command `upgrade:2-27:add-workspace-member-open-record-in`, which runs *after* the code is already serving traffic — the deploy job only runs instance commands. Until a workspace's turn comes, `openRecordIn` is `undefined`, GraphQL raises `Cannot return null for non-nullable field WorkspaceMember.openRecordIn`, `GetCurrentUser` fails outright, and nobody in that workspace can load the app. This is not theoretical. On main it broke all 68 live workspaces and stayed broken for three days: the instance command ran on Jul 31 with #23614, and `core."upgradeMigration"` had no row for any 2-27 workspace command until the sequence was run manually today. On prod the window is however long `upgrade` takes to walk every workspace sequentially. `SIDE_PANEL` is already the declared `defaultValue` of the standard field, so behaviour is unchanged once a workspace is upgraded. The same function already guards `userEmail` this way. The other write path, `user-workspace.service.ts` inserting `openRecordIn` on workspace member creation, does not need a guard: the workspace entity metadata is built per workspace from its own field metadata, so TypeORM's insert builder omits a property that has no column rather than failing. `OpenRecordIn` moves from a type-only to a value import since it is now referenced at runtime. ## Test Two cases in a new spec: the value is preserved when present, and falls back to `SIDE_PANEL` when the workspace has not been upgraded. The second fails on `main`. Review in cubic --- ...orkspace-member-transpiler.service.spec.ts | 78 +++++++++++++++++++ .../workspace-member-transpiler.service.ts | 6 +- 2 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/user/services/__tests__/workspace-member-transpiler.service.spec.ts diff --git a/packages/twenty-server/src/engine/core-modules/user/services/__tests__/workspace-member-transpiler.service.spec.ts b/packages/twenty-server/src/engine/core-modules/user/services/__tests__/workspace-member-transpiler.service.spec.ts new file mode 100644 index 0000000000..d32946aa3a --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user/services/__tests__/workspace-member-transpiler.service.spec.ts @@ -0,0 +1,78 @@ +import { Test, type TestingModule } from '@nestjs/testing'; + +import { OpenRecordIn } from 'twenty-shared/types'; + +import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service'; +import { type UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; +import { WorkspaceMemberTranspiler } from 'src/engine/core-modules/user/services/workspace-member-transpiler.service'; +import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; + +const buildWorkspaceMemberEntity = ( + overrides: Partial = {}, +) => + ({ + id: '20202020-0687-4c41-b707-ed1bfca972a7', + name: { firstName: 'Tim', lastName: 'Apple' }, + userEmail: 'tim@apple.dev', + colorScheme: 'System', + openRecordIn: OpenRecordIn.RECORD_PAGE, + locale: 'en', + avatarUrl: null, + timeFormat: 'SYSTEM', + timeZone: 'system', + dateFormat: 'SYSTEM', + calendarStartDay: 0, + numberFormat: 'SYSTEM', + ...overrides, + }) as unknown as WorkspaceMemberWorkspaceEntity; + +const userWorkspace = { + id: '20202020-9e3b-46d4-a556-88b9ddc2b034', + workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419', +} as UserWorkspaceEntity; + +describe('WorkspaceMemberTranspiler', () => { + let transpiler: WorkspaceMemberTranspiler; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + WorkspaceMemberTranspiler, + { + provide: FileUrlService, + useValue: { signFileByIdUrl: jest.fn().mockResolvedValue('') }, + }, + ], + }).compile(); + + transpiler = module.get( + WorkspaceMemberTranspiler, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should preserve openRecordIn when the workspace member has one', async () => { + const dto = await transpiler.toWorkspaceMemberDto({ + userWorkspace, + workspaceMemberEntity: buildWorkspaceMemberEntity(), + userWorkspaceRoles: [], + }); + + expect(dto.openRecordIn).toBe(OpenRecordIn.RECORD_PAGE); + }); + + it('should fall back to SIDE_PANEL when the workspace has not been upgraded yet', async () => { + const dto = await transpiler.toWorkspaceMemberDto({ + userWorkspace, + workspaceMemberEntity: buildWorkspaceMemberEntity({ + openRecordIn: undefined as unknown as string, + }), + userWorkspaceRoles: [], + }); + + expect(dto.openRecordIn).toBe(OpenRecordIn.SIDE_PANEL); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/user/services/workspace-member-transpiler.service.ts b/packages/twenty-server/src/engine/core-modules/user/services/workspace-member-transpiler.service.ts index 4bf3820f0c..c516b51fed 100644 --- a/packages/twenty-server/src/engine/core-modules/user/services/workspace-member-transpiler.service.ts +++ b/packages/twenty-server/src/engine/core-modules/user/services/workspace-member-transpiler.service.ts @@ -16,7 +16,7 @@ import { type WorkspaceMemberTimeFormatEnum, type WorkspaceMemberWorkspaceEntity, } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; -import { FileFolder, type OpenRecordIn } from 'twenty-shared/types'; +import { FileFolder, OpenRecordIn } from 'twenty-shared/types'; export type ToWorkspaceMemberDtoArgs = { workspaceMemberEntity: WorkspaceMemberWorkspaceEntity; @@ -99,7 +99,9 @@ export class WorkspaceMemberTranspiler { avatarUrl, userWorkspaceId: userWorkspace.id, colorScheme, - openRecordIn: openRecordIn as OpenRecordIn, + // Workspaces upgrade after the code rolls out, so the field is absent + // until the 2-27 workspace command reaches them. + openRecordIn: (openRecordIn as OpenRecordIn) ?? OpenRecordIn.SIDE_PANEL, dateFormat: dateFormat as WorkspaceMemberDateFormatEnum, locale, timeFormat: timeFormat as WorkspaceMemberTimeFormatEnum,