Add support for indexes on composite fields and unicity constraint on indexes This pull request includes several changes across multiple files to improve error handling, enforce unique constraints, and update database migrations. The most important changes include updating error messages for snack bars, adding a new command to enforce unique constraints, and updating database migrations to include new fields and constraints. ### Error Handling Improvements: * [`packages/twenty-front/src/modules/error-handler/components/PromiseRejectionEffect.tsx`](diffhunk://#diff-e7dc05ced8e4730430f5c7fcd0c75b3aa723da438c26e0bef8130b614427dd9aL23-R23): Updated error messages in `enqueueSnackBar` to use `error.message` directly. * [`packages/twenty-front/src/modules/object-metadata/hooks/useFindManyObjectMetadataItems.ts`](diffhunk://#diff-74c126d6bc7a5ed6b63be994d298df6669058034bfbc367b11045f9f31a3abe6L44-R46): Simplified error messages in `enqueueSnackBar`. * [`packages/twenty-front/src/modules/object-record/hooks/useFindDuplicateRecords.ts`](diffhunk://#diff-af23a1d99639a66c251f87473e63e2b7bceaa4ee4f70fedfa0fcffe5c7d79181L56-R58): Simplified error messages in `enqueueSnackBar`. * [`packages/twenty-front/src/modules/object-record/hooks/useHandleFindManyRecordsError.ts`](diffhunk://#diff-da04296cbe280202a1eaf6b1244a30490d4f400411bee139651172c59719088eL22-R24): Simplified error messages in `enqueueSnackBar`. ### New Command for Unique Constraints: * [`packages/twenty-server/src/database/commands/upgrade-version/0-31/0-31-enforce-unique-constraints.command.ts`](diffhunk://#diff-8337096c8c80dd2619a5ba691ae5145101f8ae0368a75192a050047e8c6ab7cbR1-R159): Added a new command to enforce unique constraints on company domain names and person emails. * [`packages/twenty-server/src/database/commands/upgrade-version/0-31/0-31-upgrade-version.command.ts`](diffhunk://#diff-20215e9981a53c7566e9cbff96715685125878f5bcb84fe461a7440f2e68f6fcR13-R14): Integrated the new `EnforceUniqueConstraintsCommand` into the upgrade process. [[1]](diffhunk://#diff-20215e9981a53c7566e9cbff96715685125878f5bcb84fe461a7440f2e68f6fcR13-R14) [[2]](diffhunk://#diff-20215e9981a53c7566e9cbff96715685125878f5bcb84fe461a7440f2e68f6fcR31) [[3]](diffhunk://#diff-20215e9981a53c7566e9cbff96715685125878f5bcb84fe461a7440f2e68f6fcR64-R68) * [`packages/twenty-server/src/database/commands/upgrade-version/0-31/0-31-upgrade-version.module.ts`](diffhunk://#diff-da52814efc674c25ed55645f8ee2561013641a407f88423e705dd6c77b405527R7): Registered the new `EnforceUniqueConstraintsCommand` in the module. [[1]](diffhunk://#diff-da52814efc674c25ed55645f8ee2561013641a407f88423e705dd6c77b405527R7) [[2]](diffhunk://#diff-da52814efc674c25ed55645f8ee2561013641a407f88423e705dd6c77b405527R24) ### Database Migrations: * [`packages/twenty-server/src/database/typeorm/metadata/migrations/1726757368824-migrationDebt.ts`](diffhunk://#diff-c450aeae7bc0ef4416a0ade2dc613ca3f688629f35d2a32f90a09c3f494febdcR1-R53): Added a migration to update the `relationMetadata_ondeleteaction_enum` and set default values. * [`packages/twenty-server/src/database/typeorm/metadata/migrations/1726757368825-addIsUniqueToIndexMetadata.ts`](diffhunk://#diff-8f1e14bd7f6835ec2c3bb39bcc51e3c318a3008d576a981e682f4c985e746fbfR1-R19): Added a migration to include the `isUnique` field in `indexMetadata`. * [`packages/twenty-server/src/database/typeorm/metadata/migrations/1726762935841-addCompostiveColumnToIndexFieldMetadata.ts`](diffhunk://#diff-7c96b7276c7722d41ff31de23b2de4d6e09adfdc74815356ba63bc96a2669440R1-R19): Added a migration to include the `compositeColumn` field in `indexFieldMetadata`. * [`packages/twenty-server/src/database/typeorm/metadata/migrations/1726766871572-addWhereToIndexMetadata.ts`](diffhunk://#diff-26651295a975eb50e672dce0e4e274e861f66feb1b68105eee5a04df32796190R1-R14): Added a migration to include the `indexWhereClause` field in `indexMetadata`. ### GraphQL Exception Handling: * [`packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util.ts`](diffhunk://#diff-58445eb362dc89e31107777d39b592d7842d2ab09a223012ccd055da325270a8R1-R4): Enhanced exception handling for `QueryFailedError` to provide more specific error messages for unique constraint violations. [[1]](diffhunk://#diff-58445eb362dc89e31107777d39b592d7842d2ab09a223012ccd055da325270a8R1-R4) [[2]](diffhunk://#diff-58445eb362dc89e31107777d39b592d7842d2ab09a223012ccd055da325270a8R23-R59) * [`packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-many-resolver.factory.ts`](diffhunk://#diff-233d58ab2333586dd45e46e33d4f07e04a4b8adde4a11a48e25d86985e5a7943L58-R58): Updated the `workspaceQueryRunnerGraphqlApiExceptionHandler` call to include context. * [`packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-one-resolver.factory.ts`](diffhunk://#diff-68b803f0762c407f5d2d1f5f8d389655a60654a2dd2394a81318655dcd44dc43L58-R58): Updated the `workspaceQueryRunnerGraphqlApiExceptionHandler` call to include context. --------- Co-authored-by: Charles Bochet <charles@twenty.com>
312 lines
12 KiB
TypeScript
312 lines
12 KiB
TypeScript
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface';
|
||
|
||
import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/search-vector-field.constants';
|
||
import {
|
||
ActorMetadata,
|
||
FieldActorSource,
|
||
} from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
|
||
import { EmailsMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/emails.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 { PhonesMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/phones.composite-type';
|
||
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||
import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
|
||
import {
|
||
RelationMetadataType,
|
||
RelationOnDeleteAction,
|
||
} from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity';
|
||
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
|
||
import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator';
|
||
import { WorkspaceFieldIndex } from 'src/engine/twenty-orm/decorators/workspace-field-index.decorator';
|
||
import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator';
|
||
import { WorkspaceIsDeprecated } from 'src/engine/twenty-orm/decorators/workspace-is-deprecated.decorator';
|
||
import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator';
|
||
import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator';
|
||
import { WorkspaceIsUnique } from 'src/engine/twenty-orm/decorators/workspace-is-unique.decorator';
|
||
import { WorkspaceJoinColumn } from 'src/engine/twenty-orm/decorators/workspace-join-column.decorator';
|
||
import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-relation.decorator';
|
||
import { PERSON_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
|
||
import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids';
|
||
import { getTsVectorColumnExpressionFromFields } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util';
|
||
import { ActivityTargetWorkspaceEntity } from 'src/modules/activity/standard-objects/activity-target.workspace-entity';
|
||
import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity';
|
||
import { CalendarEventParticipantWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity';
|
||
import { CompanyWorkspaceEntity } from 'src/modules/company/standard-objects/company.workspace-entity';
|
||
import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity';
|
||
import { MessageParticipantWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-participant.workspace-entity';
|
||
import { NoteTargetWorkspaceEntity } from 'src/modules/note/standard-objects/note-target.workspace-entity';
|
||
import { OpportunityWorkspaceEntity } from 'src/modules/opportunity/standard-objects/opportunity.workspace-entity';
|
||
import { TaskTargetWorkspaceEntity } from 'src/modules/task/standard-objects/task-target.workspace-entity';
|
||
import { TimelineActivityWorkspaceEntity } from 'src/modules/timeline/standard-objects/timeline-activity.workspace-entity';
|
||
|
||
const NAME_FIELD_NAME = 'name';
|
||
const EMAILS_FIELD_NAME = 'emails';
|
||
const JOB_TITLE_FIELD_NAME = 'jobTitle';
|
||
|
||
@WorkspaceEntity({
|
||
standardId: STANDARD_OBJECT_IDS.person,
|
||
namePlural: 'people',
|
||
labelSingular: 'Person',
|
||
labelPlural: 'People',
|
||
description: 'A person',
|
||
icon: 'IconUser',
|
||
labelIdentifierStandardId: PERSON_STANDARD_FIELD_IDS.name,
|
||
imageIdentifierStandardId: PERSON_STANDARD_FIELD_IDS.avatarUrl,
|
||
})
|
||
export class PersonWorkspaceEntity extends BaseWorkspaceEntity {
|
||
@WorkspaceField({
|
||
standardId: PERSON_STANDARD_FIELD_IDS.name,
|
||
type: FieldMetadataType.FULL_NAME,
|
||
label: 'Name',
|
||
description: 'Contact’s name',
|
||
icon: 'IconUser',
|
||
})
|
||
@WorkspaceIsNullable()
|
||
[NAME_FIELD_NAME]: FullNameMetadata | null;
|
||
|
||
@WorkspaceField({
|
||
standardId: PERSON_STANDARD_FIELD_IDS.emails,
|
||
type: FieldMetadataType.EMAILS,
|
||
label: 'Emails',
|
||
description: 'Contact’s Emails',
|
||
icon: 'IconMail',
|
||
})
|
||
@WorkspaceIsUnique()
|
||
[EMAILS_FIELD_NAME]: EmailsMetadata;
|
||
|
||
@WorkspaceField({
|
||
standardId: PERSON_STANDARD_FIELD_IDS.linkedinLink,
|
||
type: FieldMetadataType.LINKS,
|
||
label: 'Linkedin',
|
||
description: 'Contact’s Linkedin account',
|
||
icon: 'IconBrandLinkedin',
|
||
})
|
||
@WorkspaceIsNullable()
|
||
linkedinLink: LinksMetadata | null;
|
||
|
||
@WorkspaceField({
|
||
standardId: PERSON_STANDARD_FIELD_IDS.xLink,
|
||
type: FieldMetadataType.LINKS,
|
||
label: 'X',
|
||
description: 'Contact’s X/Twitter account',
|
||
icon: 'IconBrandX',
|
||
})
|
||
@WorkspaceIsNullable()
|
||
xLink: LinksMetadata | null;
|
||
|
||
@WorkspaceField({
|
||
standardId: PERSON_STANDARD_FIELD_IDS.jobTitle,
|
||
type: FieldMetadataType.TEXT,
|
||
label: 'Job Title',
|
||
description: 'Contact’s job title',
|
||
icon: 'IconBriefcase',
|
||
})
|
||
[JOB_TITLE_FIELD_NAME]: string;
|
||
|
||
@WorkspaceField({
|
||
standardId: PERSON_STANDARD_FIELD_IDS.phone,
|
||
type: FieldMetadataType.TEXT,
|
||
label: 'Phone',
|
||
description: 'Contact’s phone number',
|
||
icon: 'IconPhone',
|
||
})
|
||
@WorkspaceIsDeprecated()
|
||
phone: string;
|
||
|
||
@WorkspaceField({
|
||
standardId: PERSON_STANDARD_FIELD_IDS.phones,
|
||
type: FieldMetadataType.PHONES,
|
||
label: 'Phones',
|
||
description: 'Contact’s phone numbers',
|
||
icon: 'IconPhone',
|
||
})
|
||
phones: PhonesMetadata;
|
||
|
||
@WorkspaceField({
|
||
standardId: PERSON_STANDARD_FIELD_IDS.city,
|
||
type: FieldMetadataType.TEXT,
|
||
label: 'City',
|
||
description: 'Contact’s city',
|
||
icon: 'IconMap',
|
||
})
|
||
city: string;
|
||
|
||
@WorkspaceField({
|
||
standardId: PERSON_STANDARD_FIELD_IDS.avatarUrl,
|
||
type: FieldMetadataType.TEXT,
|
||
label: 'Avatar',
|
||
description: 'Contact’s avatar',
|
||
icon: 'IconFileUpload',
|
||
})
|
||
@WorkspaceIsSystem()
|
||
avatarUrl: string;
|
||
|
||
@WorkspaceField({
|
||
standardId: PERSON_STANDARD_FIELD_IDS.position,
|
||
type: FieldMetadataType.POSITION,
|
||
label: 'Position',
|
||
description: 'Person record Position',
|
||
icon: 'IconHierarchy2',
|
||
})
|
||
@WorkspaceIsSystem()
|
||
@WorkspaceIsNullable()
|
||
position: number;
|
||
|
||
@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,
|
||
type: RelationMetadataType.MANY_TO_ONE,
|
||
label: 'Company',
|
||
description: 'Contact’s company',
|
||
icon: 'IconBuildingSkyscraper',
|
||
inverseSideTarget: () => CompanyWorkspaceEntity,
|
||
inverseSideFieldKey: 'people',
|
||
})
|
||
@WorkspaceIsNullable()
|
||
company: Relation<CompanyWorkspaceEntity> | null;
|
||
|
||
@WorkspaceJoinColumn('company')
|
||
companyId: string | null;
|
||
|
||
@WorkspaceRelation({
|
||
standardId: PERSON_STANDARD_FIELD_IDS.pointOfContactForOpportunities,
|
||
type: RelationMetadataType.ONE_TO_MANY,
|
||
label: 'Linked Opportunities',
|
||
description:
|
||
'List of opportunities for which that person is the point of contact',
|
||
icon: 'IconTargetArrow',
|
||
inverseSideTarget: () => OpportunityWorkspaceEntity,
|
||
inverseSideFieldKey: 'pointOfContact',
|
||
onDelete: RelationOnDeleteAction.SET_NULL,
|
||
})
|
||
pointOfContactForOpportunities: Relation<OpportunityWorkspaceEntity[]>;
|
||
|
||
@WorkspaceRelation({
|
||
standardId: PERSON_STANDARD_FIELD_IDS.activityTargets,
|
||
type: RelationMetadataType.ONE_TO_MANY,
|
||
label: 'Activities',
|
||
description: 'Activities tied to the contact',
|
||
icon: 'IconCheckbox',
|
||
inverseSideTarget: () => ActivityTargetWorkspaceEntity,
|
||
onDelete: RelationOnDeleteAction.CASCADE,
|
||
})
|
||
@WorkspaceIsSystem()
|
||
activityTargets: Relation<ActivityTargetWorkspaceEntity[]>;
|
||
|
||
@WorkspaceRelation({
|
||
standardId: PERSON_STANDARD_FIELD_IDS.taskTargets,
|
||
type: RelationMetadataType.ONE_TO_MANY,
|
||
label: 'Tasks',
|
||
description: 'Tasks tied to the contact',
|
||
icon: 'IconCheckbox',
|
||
inverseSideTarget: () => TaskTargetWorkspaceEntity,
|
||
onDelete: RelationOnDeleteAction.CASCADE,
|
||
})
|
||
taskTargets: Relation<TaskTargetWorkspaceEntity[]>;
|
||
|
||
@WorkspaceRelation({
|
||
standardId: PERSON_STANDARD_FIELD_IDS.noteTargets,
|
||
type: RelationMetadataType.ONE_TO_MANY,
|
||
label: 'Notes',
|
||
description: 'Notes tied to the contact',
|
||
icon: 'IconNotes',
|
||
inverseSideTarget: () => NoteTargetWorkspaceEntity,
|
||
onDelete: RelationOnDeleteAction.CASCADE,
|
||
})
|
||
noteTargets: Relation<NoteTargetWorkspaceEntity[]>;
|
||
|
||
@WorkspaceRelation({
|
||
standardId: PERSON_STANDARD_FIELD_IDS.favorites,
|
||
type: RelationMetadataType.ONE_TO_MANY,
|
||
label: 'Favorites',
|
||
description: 'Favorites linked to the contact',
|
||
icon: 'IconHeart',
|
||
inverseSideTarget: () => FavoriteWorkspaceEntity,
|
||
onDelete: RelationOnDeleteAction.CASCADE,
|
||
})
|
||
@WorkspaceIsSystem()
|
||
favorites: Relation<FavoriteWorkspaceEntity[]>;
|
||
|
||
@WorkspaceRelation({
|
||
standardId: PERSON_STANDARD_FIELD_IDS.attachments,
|
||
type: RelationMetadataType.ONE_TO_MANY,
|
||
label: 'Attachments',
|
||
description: 'Attachments linked to the contact.',
|
||
icon: 'IconFileImport',
|
||
inverseSideTarget: () => AttachmentWorkspaceEntity,
|
||
onDelete: RelationOnDeleteAction.CASCADE,
|
||
})
|
||
attachments: Relation<AttachmentWorkspaceEntity[]>;
|
||
|
||
@WorkspaceRelation({
|
||
standardId: PERSON_STANDARD_FIELD_IDS.messageParticipants,
|
||
type: RelationMetadataType.ONE_TO_MANY,
|
||
label: 'Message Participants',
|
||
description: 'Message Participants',
|
||
icon: 'IconUserCircle',
|
||
inverseSideTarget: () => MessageParticipantWorkspaceEntity,
|
||
inverseSideFieldKey: 'person',
|
||
onDelete: RelationOnDeleteAction.SET_NULL,
|
||
})
|
||
@WorkspaceIsSystem()
|
||
messageParticipants: Relation<MessageParticipantWorkspaceEntity[]>;
|
||
|
||
@WorkspaceRelation({
|
||
standardId: PERSON_STANDARD_FIELD_IDS.calendarEventParticipants,
|
||
type: RelationMetadataType.ONE_TO_MANY,
|
||
label: 'Calendar Event Participants',
|
||
description: 'Calendar Event Participants',
|
||
icon: 'IconCalendar',
|
||
inverseSideTarget: () => CalendarEventParticipantWorkspaceEntity,
|
||
onDelete: RelationOnDeleteAction.SET_NULL,
|
||
})
|
||
@WorkspaceIsSystem()
|
||
calendarEventParticipants: Relation<
|
||
CalendarEventParticipantWorkspaceEntity[]
|
||
>;
|
||
|
||
@WorkspaceRelation({
|
||
standardId: PERSON_STANDARD_FIELD_IDS.timelineActivities,
|
||
type: RelationMetadataType.ONE_TO_MANY,
|
||
label: 'Events',
|
||
description: 'Events linked to the person',
|
||
icon: 'IconTimelineEvent',
|
||
inverseSideTarget: () => TimelineActivityWorkspaceEntity,
|
||
onDelete: RelationOnDeleteAction.CASCADE,
|
||
})
|
||
@WorkspaceIsNullable()
|
||
@WorkspaceIsSystem()
|
||
timelineActivities: Relation<TimelineActivityWorkspaceEntity[]>;
|
||
|
||
@WorkspaceField({
|
||
standardId: PERSON_STANDARD_FIELD_IDS.searchVector,
|
||
type: FieldMetadataType.TS_VECTOR,
|
||
label: SEARCH_VECTOR_FIELD.label,
|
||
description: SEARCH_VECTOR_FIELD.description,
|
||
icon: 'IconUser',
|
||
generatedType: 'STORED',
|
||
asExpression: getTsVectorColumnExpressionFromFields([
|
||
{ name: NAME_FIELD_NAME, type: FieldMetadataType.FULL_NAME },
|
||
{ name: EMAILS_FIELD_NAME, type: FieldMetadataType.EMAILS },
|
||
{ name: JOB_TITLE_FIELD_NAME, type: FieldMetadataType.TEXT },
|
||
]),
|
||
})
|
||
@WorkspaceIsNullable()
|
||
@WorkspaceIsSystem()
|
||
@WorkspaceFieldIndex({ indexType: IndexType.GIN })
|
||
[SEARCH_VECTOR_FIELD.name]: any;
|
||
}
|