Default workspaceMember openRecordIn when the workspace is not upgraded yet (#23723)

`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`.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23723?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:
Félix Malfait
2026-08-04 06:51:53 +02:00
committed by GitHub
parent e623d6fd88
commit 66e0f620bc
2 changed files with 82 additions and 2 deletions
@@ -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<WorkspaceMemberWorkspaceEntity> = {},
) =>
({
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>(
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);
});
});
@@ -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,