feat: add new ACTOR field type and createdBy standard fields (#6324)

This pull request introduces a new `FieldMetadataType` called `ACTOR`.
The primary objective of this new type is to add an extra column to the
following objects: `person`, `company`, `opportunity`, `note`, `task`,
and all custom objects.

This composite type contains three properties:

- `source`
    ```typescript
    export enum FieldActorSource {
      EMAIL = 'EMAIL',
      CALENDAR = 'CALENDAR',
      API = 'API',
      IMPORT = 'IMPORT',
      MANUAL = 'MANUAL',
    }
    ```
- `workspaceMemberId`
- This property can be `undefined` in some cases and refers to the
member who created the record.
- `name`
- Serves as a fallback if the `workspaceMember` is deleted and is used
for other source types like `API`.

### Functionality

The pre-hook system has been updated to allow real-time argument
updates. When a record is created, a pre-hook can now compute and update
the arguments accordingly. This enhancement enables the `createdBy`
field to be populated with the correct values based on the
`authContext`.

The `authContext` now includes:
- An optional User entity
- An optional ApiKey entity
- The workspace entity

This provides access to the necessary data for the `createdBy` field.

In the GraphQL API, only the `source` can be specified in the
`createdBy` input. This allows the front-end to specify the source when
creating records from a CSV file.

### Front-End Handling

On the front-end, `orderBy` and `filter` are only applied to the name
property of the `ACTOR` composite type. Currently, we are unable to
apply these operations to the workspace member relation. This means that
if a workspace member changes their first name or last name, there may
be a mismatch because the name will differ from the new one. The name
displayed on the screen is based on the workspace member entity when
available.

### Missing Components

Currently, this PR does not include a `createdBy` value for the `MAIL`
and `CALENDAR` sources. These records are created in a job, and at
present, we only have access to the workspaceId within the job. To
address this, we should use a function similar to
`loadServiceWithContext`, which was recently removed from `TwentyORM`.
This function would allow us to pass the `authContext` to the jobs
without disrupting existing jobs.
Another PR will be created to handle these cases.

### Related Issues

Fixes issue #5155.

### Additional Notes

This PR doesn't include the migrations of the current records and views.
Everything works properly when the database is reset but this part is
still missing for now. We'll add that in another PR.

- There is a minor issue: front-end tests are broken since this commit:
[80c0fc7ff1).

---------

Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Jérémy M
2024-08-03 15:43:31 +02:00
committed by GitHub
parent 9cf08d912a
commit 6432ad39b9
152 changed files with 24425 additions and 14968 deletions

View File

@ -1,3 +1,5 @@
import { BadRequestException } from '@nestjs/common';
import { WorkspaceQueryHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface';
import { CreateManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
@ -6,6 +8,7 @@ import {
BlocklistItem,
BlocklistValidationService,
} from 'src/modules/blocklist/blocklist-validation-manager/services/blocklist-validation.service';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
@WorkspaceQueryHook(`blocklist.createMany`)
export class BlocklistCreateManyPreQueryHook
@ -16,14 +19,20 @@ export class BlocklistCreateManyPreQueryHook
) {}
async execute(
userId: string,
workspaceId: string,
authContext: AuthContext,
objectName: string,
payload: CreateManyResolverArgs<BlocklistItem>,
): Promise<void> {
): Promise<CreateManyResolverArgs<BlocklistItem>> {
if (!authContext.user?.id) {
throw new BadRequestException('User id is required');
}
await this.blocklistValidationService.validateBlocklistForCreateMany(
payload,
userId,
workspaceId,
authContext.user?.id,
authContext.workspace.id,
);
return payload;
}
}

View File

@ -1,8 +1,10 @@
import { MethodNotAllowedException } from '@nestjs/common';
import { WorkspaceQueryHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface';
import { UpdateManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator';
import { BlocklistItem } from 'src/modules/blocklist/blocklist-validation-manager/services/blocklist-validation.service';
@WorkspaceQueryHook(`blocklist.updateMany`)
export class BlocklistUpdateManyPreQueryHook
@ -10,7 +12,7 @@ export class BlocklistUpdateManyPreQueryHook
{
constructor() {}
async execute(): Promise<void> {
async execute(): Promise<UpdateManyResolverArgs<BlocklistItem>> {
throw new MethodNotAllowedException('Method not allowed.');
}
}

View File

@ -1,3 +1,5 @@
import { BadRequestException } from '@nestjs/common';
import { WorkspaceQueryHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface';
import { UpdateOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
@ -6,6 +8,7 @@ import {
BlocklistItem,
BlocklistValidationService,
} from 'src/modules/blocklist/blocklist-validation-manager/services/blocklist-validation.service';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
@WorkspaceQueryHook(`blocklist.updateOne`)
export class BlocklistUpdateOnePreQueryHook
@ -16,14 +19,20 @@ export class BlocklistUpdateOnePreQueryHook
) {}
async execute(
userId: string,
workspaceId: string,
authContext: AuthContext,
objectName: string,
payload: UpdateOneResolverArgs<BlocklistItem>,
): Promise<void> {
): Promise<UpdateOneResolverArgs<BlocklistItem>> {
if (!authContext.user?.id) {
throw new BadRequestException('User id is required');
}
await this.blocklistValidationService.validateBlocklistForUpdateOne(
payload,
userId,
workspaceId,
authContext.user?.id,
authContext.workspace.id,
);
return payload;
}
}

View File

@ -7,6 +7,7 @@ import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runne
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import { CanAccessCalendarEventService } from 'src/modules/calendar/common/query-hooks/calendar-event/services/can-access-calendar-event.service';
import { CalendarChannelEventAssociationWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-channel-event-association.workspace-entity';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
@WorkspaceQueryHook({
key: `calendarEvent.findMany`,
@ -21,14 +22,18 @@ export class CalendarEventFindManyPreQueryHook
) {}
async execute(
userId: string,
workspaceId: string,
authContext: AuthContext,
objectName: string,
payload: FindManyResolverArgs,
): Promise<void> {
): Promise<FindManyResolverArgs> {
if (!payload?.filter?.id?.eq) {
throw new BadRequestException('id filter is required');
}
if (!authContext.user?.id) {
throw new BadRequestException('User id is required');
}
const calendarChannelEventAssociationRepository =
await this.twentyORMManager.getRepository<CalendarChannelEventAssociationWorkspaceEntity>(
'calendarChannelEventAssociation',
@ -47,9 +52,11 @@ export class CalendarEventFindManyPreQueryHook
}
await this.canAccessCalendarEventService.canAccessCalendarEvent(
userId,
workspaceId,
authContext.user.id,
authContext.workspace.id,
calendarChannelCalendarEventAssociations,
);
return payload;
}
}

View File

@ -7,6 +7,7 @@ import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runne
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import { CanAccessCalendarEventService } from 'src/modules/calendar/common/query-hooks/calendar-event/services/can-access-calendar-event.service';
import { CalendarChannelEventAssociationWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-channel-event-association.workspace-entity';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
@WorkspaceQueryHook({
key: `calendarEvent.findOne`,
@ -21,14 +22,18 @@ export class CalendarEventFindOnePreQueryHook
) {}
async execute(
userId: string,
workspaceId: string,
authContext: AuthContext,
objectName: string,
payload: FindOneResolverArgs,
): Promise<void> {
): Promise<FindOneResolverArgs> {
if (!payload?.filter?.id?.eq) {
throw new BadRequestException('id filter is required');
}
if (!authContext.user?.id) {
throw new BadRequestException('User id is required');
}
const calendarChannelEventAssociationRepository =
await this.twentyORMManager.getRepository<CalendarChannelEventAssociationWorkspaceEntity>(
'calendarChannelEventAssociation',
@ -48,9 +53,11 @@ export class CalendarEventFindOnePreQueryHook
}
await this.canAccessCalendarEventService.canAccessCalendarEvent(
userId,
workspaceId,
authContext.user.id,
authContext.workspace.id,
calendarChannelCalendarEventAssociations,
);
return payload;
}
}

View File

@ -2,6 +2,10 @@ import { Address } from 'nodemailer/lib/mailer';
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface';
import {
ActorMetadata,
FieldActorSource,
} from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { CurrencyMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/currency.composite-type';
import { LinksMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/links.composite-type';
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
@ -131,6 +135,19 @@ export class CompanyWorkspaceEntity extends BaseWorkspaceEntity {
@WorkspaceIsNullable()
position: number | null;
@WorkspaceField({
standardId: COMPANY_STANDARD_FIELD_IDS.createdBy,
type: FieldMetadataType.ACTOR,
label: 'Created by',
icon: 'IconCreativeCommonsSa',
description: 'The creator of the record',
defaultValue: {
source: `'${FieldActorSource.MANUAL}'`,
name: "''",
},
})
createdBy: ActorMetadata;
// Relations
@WorkspaceRelation({
standardId: COMPANY_STANDARD_FIELD_IDS.people,

View File

@ -4,6 +4,7 @@ import { WorkspaceQueryHookInstance } from 'src/engine/api/graphql/workspace-que
import { DeleteOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { ObjectRecordDeleteEvent } from 'src/engine/integrations/event-emitter/types/object-record-delete.event';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import { MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
@ -18,10 +19,10 @@ export class ConnectedAccountDeleteOnePreQueryHook
) {}
async execute(
_userId: string,
workspaceId: string,
authContext: AuthContext,
objectName: string,
payload: DeleteOneResolverArgs,
): Promise<void> {
): Promise<DeleteOneResolverArgs> {
const connectedAccountId = payload.id;
const messageChannelRepository =
@ -35,12 +36,15 @@ export class ConnectedAccountDeleteOnePreQueryHook
messageChannels.forEach((messageChannel) => {
this.eventEmitter.emit('messageChannel.deleted', {
workspaceId,
workspaceId: authContext.workspace.id,
name: 'messageChannel.deleted',
recordId: messageChannel.id,
} satisfies Pick<
ObjectRecordDeleteEvent<MessageChannelWorkspaceEntity>,
'workspaceId' | 'recordId'
'workspaceId' | 'recordId' | 'name'
>);
});
return payload;
}
}

View File

@ -8,6 +8,7 @@ import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repos
import { CanAccessMessageThreadService } from 'src/modules/messaging/common/query-hooks/message/can-access-message-thread.service';
import { MessageChannelMessageAssociationRepository } from 'src/modules/messaging/common/repositories/message-channel-message-association.repository';
import { MessageChannelMessageAssociationWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
@WorkspaceQueryHook(`message.findMany`)
export class MessageFindManyPreQueryHook implements WorkspaceQueryHookInstance {
@ -20,18 +21,22 @@ export class MessageFindManyPreQueryHook implements WorkspaceQueryHookInstance {
) {}
async execute(
userId: string,
workspaceId: string,
authContext: AuthContext,
objectName: string,
payload: FindManyResolverArgs,
): Promise<void> {
): Promise<FindManyResolverArgs> {
if (!payload?.filter?.messageThreadId?.eq) {
throw new BadRequestException('messageThreadId filter is required');
}
if (!authContext.user?.id) {
throw new BadRequestException('User id is required');
}
const messageChannelMessageAssociations =
await this.messageChannelMessageAssociationService.getByMessageThreadId(
payload?.filter?.messageThreadId?.eq,
workspaceId,
authContext.workspace.id,
);
if (messageChannelMessageAssociations.length === 0) {
@ -39,9 +44,11 @@ export class MessageFindManyPreQueryHook implements WorkspaceQueryHookInstance {
}
await this.canAccessMessageThreadService.canAccessMessageThread(
userId,
workspaceId,
authContext.user.id,
authContext.workspace.id,
messageChannelMessageAssociations,
);
return payload;
}
}

View File

@ -9,6 +9,7 @@ import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repos
import { CanAccessMessageThreadService } from 'src/modules/messaging/common/query-hooks/message/can-access-message-thread.service';
import { MessageChannelMessageAssociationRepository } from 'src/modules/messaging/common/repositories/message-channel-message-association.repository';
import { MessageChannelMessageAssociationWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
@WorkspaceQueryHook(`message.findOne`)
export class MessageFindOnePreQueryHook implements WorkspaceQueryHookInstance {
@ -21,14 +22,18 @@ export class MessageFindOnePreQueryHook implements WorkspaceQueryHookInstance {
) {}
async execute(
userId: string,
workspaceId: string,
authContext: AuthContext,
objectName: string,
payload: FindOneResolverArgs,
): Promise<void> {
): Promise<FindOneResolverArgs> {
if (!authContext.user?.id) {
throw new NotFoundException('User id is required');
}
const messageChannelMessageAssociations =
await this.messageChannelMessageAssociationService.getByMessageIds(
[payload?.filter?.id?.eq],
workspaceId,
authContext.workspace.id,
);
if (messageChannelMessageAssociations.length === 0) {
@ -36,9 +41,11 @@ export class MessageFindOnePreQueryHook implements WorkspaceQueryHookInstance {
}
await this.canAccessMessageThreadService.canAccessMessageThread(
userId,
workspaceId,
authContext.user.id,
authContext.workspace.id,
messageChannelMessageAssociations,
);
return payload;
}
}

View File

@ -1,5 +1,9 @@
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface';
import {
ActorMetadata,
FieldActorSource,
} from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import {
RelationMetadataType,
@ -57,6 +61,19 @@ export class NoteWorkspaceEntity extends BaseWorkspaceEntity {
@WorkspaceIsNullable()
body: string | null;
@WorkspaceField({
standardId: NOTE_STANDARD_FIELD_IDS.createdBy,
type: FieldMetadataType.ACTOR,
label: 'Created by',
icon: 'IconCreativeCommonsSa',
description: 'The creator of the record',
defaultValue: {
source: `'${FieldActorSource.MANUAL}'`,
name: "''",
},
})
createdBy: ActorMetadata;
@WorkspaceRelation({
standardId: NOTE_STANDARD_FIELD_IDS.noteTargets,
label: 'Targets',

View File

@ -1,5 +1,9 @@
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface';
import {
ActorMetadata,
FieldActorSource,
} from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { CurrencyMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/currency.composite-type';
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import {
@ -99,6 +103,19 @@ export class OpportunityWorkspaceEntity extends BaseWorkspaceEntity {
@WorkspaceIsNullable()
position: number | null;
@WorkspaceField({
standardId: OPPORTUNITY_STANDARD_FIELD_IDS.createdBy,
type: FieldMetadataType.ACTOR,
label: 'Created by',
icon: 'IconCreativeCommonsSa',
description: 'The creator of the record',
defaultValue: {
source: `'${FieldActorSource.MANUAL}'`,
name: "''",
},
})
createdBy: ActorMetadata;
@WorkspaceRelation({
standardId: OPPORTUNITY_STANDARD_FIELD_IDS.pointOfContact,
type: RelationMetadataType.MANY_TO_ONE,

View File

@ -1,5 +1,9 @@
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface';
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 { LinksMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/links.composite-type';
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
@ -124,6 +128,19 @@ export class PersonWorkspaceEntity extends BaseWorkspaceEntity {
@WorkspaceIsNullable()
position: number | null;
@WorkspaceField({
standardId: PERSON_STANDARD_FIELD_IDS.createdBy,
type: FieldMetadataType.ACTOR,
label: 'Created by',
icon: 'IconCreativeCommonsSa',
description: 'The creator of the record',
defaultValue: {
source: `'${FieldActorSource.MANUAL}'`,
name: "''",
},
})
createdBy: ActorMetadata;
// Relations
@WorkspaceRelation({
standardId: PERSON_STANDARD_FIELD_IDS.company,

View File

@ -1,5 +1,9 @@
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface';
import {
ActorMetadata,
FieldActorSource,
} from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import {
RelationMetadataType,
@ -95,6 +99,19 @@ export class TaskWorkspaceEntity extends BaseWorkspaceEntity {
@WorkspaceIsNullable()
status: string | null;
@WorkspaceField({
standardId: TASK_STANDARD_FIELD_IDS.createdBy,
type: FieldMetadataType.ACTOR,
label: 'Created by',
icon: 'IconCreativeCommonsSa',
description: 'The creator of the record',
defaultValue: {
source: `'${FieldActorSource.MANUAL}'`,
name: "''",
},
})
createdBy: ActorMetadata;
@WorkspaceRelation({
standardId: TASK_STANDARD_FIELD_IDS.taskTargets,
label: 'Targets',

View File

@ -1,6 +1,7 @@
import { MethodNotAllowedException } from '@nestjs/common';
import { WorkspaceQueryHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface';
import { DeleteManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator';
@ -10,7 +11,7 @@ export class WorkspaceMemberDeleteManyPreQueryHook
{
constructor() {}
async execute(): Promise<void> {
async execute(): Promise<DeleteManyResolverArgs> {
throw new MethodNotAllowedException('Method not allowed.');
}
}

View File

@ -5,6 +5,7 @@ import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runne
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import { CommentWorkspaceEntity } from 'src/modules/activity/standard-objects/comment.workspace-entity';
import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
@WorkspaceQueryHook(`workspaceMember.deleteOne`)
export class WorkspaceMemberDeleteOnePreQueryHook
@ -14,10 +15,10 @@ export class WorkspaceMemberDeleteOnePreQueryHook
// There is no need to validate the user's access to the workspace member since we don't have permission yet.
async execute(
userId: string,
workspaceId: string,
authContext: AuthContext,
objectName: string,
payload: DeleteOneResolverArgs,
): Promise<void> {
): Promise<DeleteOneResolverArgs> {
const attachmentRepository =
await this.twentyORMManager.getRepository<AttachmentWorkspaceEntity>(
'attachment',
@ -37,5 +38,7 @@ export class WorkspaceMemberDeleteOnePreQueryHook
await commentRepository.delete({
authorId,
});
return payload;
}
}