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 WorkspaceMemberTimeFormatEnum,
|
||||||
type WorkspaceMemberWorkspaceEntity,
|
type WorkspaceMemberWorkspaceEntity,
|
||||||
} from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
|
} 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 = {
|
export type ToWorkspaceMemberDtoArgs = {
|
||||||
workspaceMemberEntity: WorkspaceMemberWorkspaceEntity;
|
workspaceMemberEntity: WorkspaceMemberWorkspaceEntity;
|
||||||
@@ -99,7 +99,9 @@ export class WorkspaceMemberTranspiler {
|
|||||||
avatarUrl,
|
avatarUrl,
|
||||||
userWorkspaceId: userWorkspace.id,
|
userWorkspaceId: userWorkspace.id,
|
||||||
colorScheme,
|
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,
|
dateFormat: dateFormat as WorkspaceMemberDateFormatEnum,
|
||||||
locale,
|
locale,
|
||||||
timeFormat: timeFormat as WorkspaceMemberTimeFormatEnum,
|
timeFormat: timeFormat as WorkspaceMemberTimeFormatEnum,
|
||||||
|
|||||||
Reference in New Issue
Block a user