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:
+78
@@ -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);
|
||||
});
|
||||
});
|
||||
+4
-2
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user