[BUG] Refactor actor composite type (#10232)

fixes #10200 

The FieldActor Zod schema was updated to correctly handle null context.

---------

Co-authored-by: prastoin <paul@twenty.com>
This commit is contained in:
Mohammed Abdul Razak Wahab
2025-02-20 15:35:26 +05:30
committed by GitHub
parent 927b8c717e
commit 94c0d0f8d2
19 changed files with 454 additions and 169 deletions

View File

@ -5,9 +5,19 @@ import { CreatedByCreateManyPreQueryHook } from 'src/engine/core-modules/actor/q
import { CreatedByCreateOnePreQueryHook } from 'src/engine/core-modules/actor/query-hooks/created-by.create-one.pre-query-hook';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { CreatedByFromAuthContextService } from './services/created-by-from-auth-context.service';
@Module({
imports: [TypeOrmModule.forFeature([FieldMetadataEntity], 'metadata')],
providers: [CreatedByCreateManyPreQueryHook, CreatedByCreateOnePreQueryHook],
exports: [CreatedByCreateManyPreQueryHook, CreatedByCreateOnePreQueryHook],
providers: [
CreatedByCreateManyPreQueryHook,
CreatedByCreateOnePreQueryHook,
CreatedByFromAuthContextService,
],
exports: [
CreatedByCreateManyPreQueryHook,
CreatedByCreateOnePreQueryHook,
CreatedByFromAuthContextService,
],
})
export class ActorModule {}

View File

@ -1,7 +1,6 @@
import { Logger } from '@nestjs/common/services/logger.service';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'class-validator';
import { isDefined } from 'twenty-shared';
import { Repository } from 'typeorm';
import { WorkspaceQueryHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface';
@ -12,16 +11,10 @@ import {
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator';
import { buildCreatedByFromWorkspaceMember } from 'src/engine/core-modules/actor/utils/build-created-by-from-workspace-member.util';
import { CreatedByFromAuthContextService } from 'src/engine/core-modules/actor/services/created-by-from-auth-context.service';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import {
ActorMetadata,
FieldActorSource,
} from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { CustomWorkspaceEntity } from 'src/engine/twenty-orm/custom.workspace-entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
type CustomWorkspaceItem = Omit<
CustomWorkspaceEntity,
@ -35,12 +28,10 @@ type CustomWorkspaceItem = Omit<
export class CreatedByCreateManyPreQueryHook
implements WorkspaceQueryHookInstance
{
private readonly logger = new Logger(CreatedByCreateManyPreQueryHook.name);
constructor(
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
@InjectRepository(FieldMetadataEntity, 'metadata')
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
private readonly createdByFromAuthContextService: CreatedByFromAuthContextService,
) {}
async execute(
@ -48,8 +39,6 @@ export class CreatedByCreateManyPreQueryHook
objectName: string,
payload: CreateManyResolverArgs<CustomWorkspaceItem>,
): Promise<CreateManyResolverArgs<CustomWorkspaceItem>> {
let createdBy: ActorMetadata | null = null;
if (!isDefined(payload.data)) {
throw new GraphqlQueryRunnerException(
'Payload data is required',
@ -72,46 +61,8 @@ export class CreatedByCreateManyPreQueryHook
return payload;
}
// If user is logged in, we use the workspace member
if (authContext.workspaceMemberId && authContext.user) {
createdBy = buildCreatedByFromWorkspaceMember(
authContext.workspaceMemberId,
authContext.user,
);
// TODO: remove that code once we have the workspace member id in all tokens
} else if (authContext.user) {
this.logger.warn("User doesn't have a workspace member id in the token");
const workspaceMemberRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkspaceMemberWorkspaceEntity>(
authContext.workspace.id,
'workspaceMember',
);
const workspaceMember = await workspaceMemberRepository.findOne({
where: {
userId: authContext.user?.id,
},
});
if (!workspaceMember) {
throw new Error(
`Workspace member can't be found for user ${authContext.user.id}`,
);
}
createdBy = {
source: FieldActorSource.MANUAL,
workspaceMemberId: workspaceMember.id,
name: `${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`,
};
}
if (authContext.apiKey) {
createdBy = {
source: FieldActorSource.API,
name: authContext.apiKey.name,
};
}
const createdBy =
await this.createdByFromAuthContextService.buildCreatedBy(authContext);
for (const datum of payload.data) {
// Front-end can fill the source field

View File

@ -1,4 +1,3 @@
import { Logger } from '@nestjs/common/services/logger.service';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'class-validator';
@ -12,16 +11,10 @@ import {
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator';
import { buildCreatedByFromWorkspaceMember } from 'src/engine/core-modules/actor/utils/build-created-by-from-workspace-member.util';
import { CreatedByFromAuthContextService } from 'src/engine/core-modules/actor/services/created-by-from-auth-context.service';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import {
ActorMetadata,
FieldActorSource,
} from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { CustomWorkspaceEntity } from 'src/engine/twenty-orm/custom.workspace-entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
type CustomWorkspaceItem = Omit<
CustomWorkspaceEntity,
@ -35,12 +28,10 @@ type CustomWorkspaceItem = Omit<
export class CreatedByCreateOnePreQueryHook
implements WorkspaceQueryHookInstance
{
private readonly logger = new Logger(CreatedByCreateOnePreQueryHook.name);
constructor(
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
@InjectRepository(FieldMetadataEntity, 'metadata')
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
private readonly createdByFromAuthContextService: CreatedByFromAuthContextService,
) {}
async execute(
@ -48,8 +39,6 @@ export class CreatedByCreateOnePreQueryHook
objectName: string,
payload: CreateOneResolverArgs<CustomWorkspaceItem>,
): Promise<CreateOneResolverArgs<CustomWorkspaceItem>> {
let createdBy: ActorMetadata | null = null;
if (!isDefined(payload.data)) {
throw new GraphqlQueryRunnerException(
'Payload data is required',
@ -72,46 +61,8 @@ export class CreatedByCreateOnePreQueryHook
return payload;
}
// If user is logged in, we use the workspace member
if (authContext.workspaceMemberId && authContext.user) {
createdBy = buildCreatedByFromWorkspaceMember(
authContext.workspaceMemberId,
authContext.user,
);
// TODO: remove that code once we have the workspace member id in all tokens
} else if (authContext.user) {
this.logger.warn("User doesn't have a workspace member id in the token");
const workspaceMemberRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkspaceMemberWorkspaceEntity>(
authContext.workspace.id,
'workspaceMember',
);
const workspaceMember = await workspaceMemberRepository.findOne({
where: {
userId: authContext.user?.id,
},
});
if (!workspaceMember) {
throw new Error(
`Workspace member can't be found for user ${authContext.user.id}`,
);
}
createdBy = {
source: FieldActorSource.MANUAL,
workspaceMemberId: workspaceMember.id,
name: `${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`,
};
}
if (authContext.apiKey) {
createdBy = {
source: FieldActorSource.API,
name: authContext.apiKey.name,
};
}
const createdBy =
await this.createdByFromAuthContextService.buildCreatedBy(authContext);
// Front-end can fill the source field
if (

View File

@ -0,0 +1,148 @@
import { Test, TestingModule } from '@nestjs/testing';
import { CreatedByFromAuthContextService } from 'src/engine/core-modules/actor/services/created-by-from-auth-context.service';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import {
ActorMetadata,
FieldActorSource,
} from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { FullNameMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/full-name.composite-type';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { ApiKeyWorkspaceEntity } from 'src/modules/api-key/standard-objects/api-key.workspace-entity';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
type TestingAuthContext = Omit<AuthContext, 'workspace' | 'apiKey' | 'user'> & {
workspace: Partial<Workspace>;
apiKey?: Partial<ApiKeyWorkspaceEntity>;
user?: Partial<User>;
};
// TODO create util
const fromFullNameMetadataToName = ({
firstName,
lastName,
}: FullNameMetadata) => `${firstName} ${lastName}`;
describe('CreatedByFromAuthContextService', () => {
let service: CreatedByFromAuthContextService;
const mockWorkspaceMemberRepository = {
findOneOrFail: jest.fn(),
};
const twentyORMGlobalManager: jest.Mocked<
Pick<TwentyORMGlobalManager, 'getRepositoryForWorkspace'>
> = {
getRepositoryForWorkspace: jest
.fn()
.mockResolvedValue(mockWorkspaceMemberRepository),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
CreatedByFromAuthContextService,
{
provide: TwentyORMGlobalManager,
useValue: twentyORMGlobalManager,
},
],
}).compile();
service = module.get<CreatedByFromAuthContextService>(
CreatedByFromAuthContextService,
);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('buildCreatedBy', () => {
it('should build metadata from workspaceMemberId and user when both are present', async () => {
const authContext = {
workspaceMemberId: '20202020-0b5c-4178-bed7-d371f6411eaa',
user: {
firstName: 'John',
lastName: 'Doe',
id: '20202020-9aae-49a8-bafc-ac44bae62d6d',
},
workspace: { id: '20202020-bdec-497f-847a-1bb334fefe58' },
} as const satisfies TestingAuthContext;
const result = await service.buildCreatedBy(authContext as AuthContext);
expect(result).toEqual<ActorMetadata>({
context: {},
name: fromFullNameMetadataToName(authContext.user),
workspaceMemberId: authContext.workspaceMemberId,
source: FieldActorSource.MANUAL,
});
});
it('should build metadata from user when workspaceMemberId is missing', async () => {
const authContext = {
user: {
firstName: 'John',
lastName: 'Doe',
id: '20202020-9aae-49a8-bafc-ac44bae62d6d',
},
workspace: { id: '20202020-bdec-497f-847a-1bb334fefe58' },
} as const satisfies TestingAuthContext;
const mockedWorkspaceMember = {
id: '20202020-78a3-4520-ba74-b0e1b534a501',
name: {
firstName: 'Pepito',
lastName: 'Dubois',
},
} as const satisfies Partial<WorkspaceMemberWorkspaceEntity>;
mockWorkspaceMemberRepository.findOneOrFail.mockResolvedValueOnce(
mockedWorkspaceMember,
);
const result = await service.buildCreatedBy(authContext as AuthContext);
expect(result).toEqual<ActorMetadata>({
context: {},
name: fromFullNameMetadataToName(mockedWorkspaceMember.name),
workspaceMemberId: mockedWorkspaceMember.id,
source: FieldActorSource.MANUAL,
});
});
it('should build metadata from apiKey when only apiKey is present', async () => {
const authContext = {
apiKey: {
id: '20202020-56c2-471b-925d-31ed3ecd0951',
name: 'API Key Name',
},
workspace: { id: '20202020-bdec-497f-847a-1bb334fefe58' },
} as const satisfies TestingAuthContext;
const result = await service.buildCreatedBy(authContext as AuthContext);
expect(result).toEqual<ActorMetadata>({
source: FieldActorSource.API,
workspaceMemberId: null,
name: authContext.apiKey.name,
context: {},
});
});
it('should throw error when no valid actor information is found', async () => {
const authContext = {
workspace: { id: 'workspace-id' },
} as const satisfies TestingAuthContext;
await expect(
service.buildCreatedBy(authContext as AuthContext),
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Unable to build createdBy metadata - no valid actor information found in auth context"`,
);
});
});
});

View File

@ -0,0 +1,67 @@
import { Injectable, Logger } from '@nestjs/common';
import { isDefined } from 'twenty-shared';
import { buildCreatedByFromApiKey } from 'src/engine/core-modules/actor/utils/build-created-by-from-api-key.util';
import { buildCreatedByFromFullNameMetadata } from 'src/engine/core-modules/actor/utils/build-created-by-from-full-name-metadata.util';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { ActorMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@Injectable()
export class CreatedByFromAuthContextService {
private readonly logger = new Logger(CreatedByFromAuthContextService.name);
constructor(
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
public async buildCreatedBy(
authContext: AuthContext,
): Promise<ActorMetadata> {
const { workspace, workspaceMemberId, user, apiKey } = authContext;
// TODO: remove that code once we have the workspace member id in all tokens
if (isDefined(workspaceMemberId) && isDefined(user)) {
return buildCreatedByFromFullNameMetadata({
fullNameMetadata: {
firstName: user.firstName,
lastName: user.lastName,
},
workspaceMemberId,
});
}
if (isDefined(user)) {
this.logger.warn("User doesn't have a workspace member id in the token");
const workspaceMemberRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkspaceMemberWorkspaceEntity>(
workspace.id,
'workspaceMember',
);
const workspaceMember = await workspaceMemberRepository.findOneOrFail({
where: {
userId: user.id,
},
});
return buildCreatedByFromFullNameMetadata({
fullNameMetadata: workspaceMember.name,
workspaceMemberId: workspaceMember.id,
});
}
if (isDefined(apiKey)) {
return buildCreatedByFromApiKey({
apiKey,
});
}
throw new Error(
'Unable to build createdBy metadata - no valid actor information found in auth context',
);
}
}

View File

@ -0,0 +1,17 @@
import {
ActorMetadata,
FieldActorSource,
} from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { ApiKeyWorkspaceEntity } from 'src/modules/api-key/standard-objects/api-key.workspace-entity';
type BuildCreatedByFromApiKeyArgs = {
apiKey: ApiKeyWorkspaceEntity;
};
export const buildCreatedByFromApiKey = ({
apiKey,
}: BuildCreatedByFromApiKeyArgs): ActorMetadata => ({
source: FieldActorSource.API,
name: apiKey.name,
workspaceMemberId: null,
context: {},
});

View File

@ -0,0 +1,19 @@
import {
ActorMetadata,
FieldActorSource,
} from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { FullNameMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/full-name.composite-type';
type BuildCreatedByFromFullNameMetadataArgs = {
workspaceMemberId: string;
fullNameMetadata: FullNameMetadata;
};
export const buildCreatedByFromFullNameMetadata = ({
fullNameMetadata,
workspaceMemberId,
}: BuildCreatedByFromFullNameMetadataArgs): ActorMetadata => ({
workspaceMemberId,
source: FieldActorSource.MANUAL,
name: `${fullNameMetadata.firstName} ${fullNameMetadata.lastName}`,
context: {},
});

View File

@ -1,11 +0,0 @@
import { User } from 'src/engine/core-modules/user/user.entity';
import { FieldActorSource } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
export const buildCreatedByFromWorkspaceMember = (
workspaceMemberId: string,
user: User,
) => ({
workspaceMemberId,
source: FieldActorSource.MANUAL,
name: `${user.firstName} ${user.lastName}`,
});