Files
twenty/packages/twenty-server/src/modules/contact-creation-manager/services/create-company-and-contact.service.ts
Guillim 8b513a7d3b contact creation was recreating a primaryemail du to casing (#11042)
a first step to fix some of the issues i notices in grafana for netzero

in grafana, we log this error :  
```
error: error: duplicate key value violates unique constraint "IDX_UNIQUE_87914cd3ce963115f8cb943e2ac"

query failed: INSERT INTO "workspace_20fjk7nksxxxxxxxxxxx"."person"("cdrOpportunityId", "siteOpportunityId", "xLinkPrimaryLinkLabel", "xLinkPrimaryLinkUrl", "xLinkSecondaryLinks", "linkedinLinkPrimaryLinkLabel", "linkedinLinkPrimaryLinkUrl", "linkedinLinkSecondaryLinks", "emailsPrimaryEmail", "emailsAdditionalEmails", "companyId", "deletedAt", "position", "phonesPrimaryPhoneNumber", "phonesPrimaryPhoneCountryCode", "phonesPrimaryPhoneCallingCode", "phonesAdditionalPhones", "createdAt", "id", "searchVector", "avatarUrl", "city", "jobTitle", "nameFirstName", "nameLastName", "updatedAt", "createdBySource", "createdByWorkspaceMemberId", "createdByName", "createdByContext") VALUES (DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, $1, $2, $3, DEFAULT, $4, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, $5, DEFAULT, DEFAULT, DEFAULT, DEFAULT, $6, $7, DEFAULT, $8, $9, $10, $11), (DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, $12, $13, $14, DEFAULT, $15, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, $16, DEFAULT, DEFAULT, DEFAULT, DEFAULT, $17, $18, DEFAULT, $19, $20, $21, $22), (DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, $23, $24, $25, DEFAULT, $26, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, $27, DEFAULT, DEFAULT, DEFAULT, DEFAULT, $28, $29, DEFAULT, $30, $31, $32, $33), (DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, $34, $35, $36, DEFAULT, $37, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, $38, DEFAULT, DEFAULT, DEFAULT, DEFAULT, $39, $40, DEFAULT, $41, $42, $43, $44) RETURNING "xLinkPrimaryLinkLabel", "xLinkPrimaryLinkUrl", "xLinkSecondaryLinks", "linkedinLinkPrimaryLinkLabel", "linkedinLinkPrimaryLinkUrl", "linkedinLinkSecondaryLinks", "emailsPrimaryEmail", "emailsAdditionalEmails", "companyId", "deletedAt", "position", "phonesPrimaryPhoneNumber", "phonesPrimaryPhoneCountryCode", "phonesPrimaryPhoneCallingCode", "phonesAdditionalPhones", "createdAt", "id", "searchVector", "avatarUrl", "city", "jobTitle", "nameFirstName", "nameLastName", "updatedAt", "createdBySource", "createdByWorkspaceMemberId", "createdByName", "createdByContext" 
```
The index is related to the People table on the netzero workspace
schema. When I looked at the email trying to be inserted, it contains
captial letters. I looked at the DB and there are existing small caps
emails. That's why my guess it that the failure comes from the contact
creation service.

Intersting fact, it comes from the CALENDAR source, not the email one.
That's why i added a little bit of error logging in case it reproduces
itself
2025-03-19 18:24:35 +01:00

231 lines
8.5 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import chunk from 'lodash.chunk';
import compact from 'lodash.compact';
import { Any, EntityManager, Repository } from 'typeorm';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { FieldActorSource } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids';
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { CONTACTS_CREATION_BATCH_SIZE } from 'src/modules/contact-creation-manager/constants/contacts-creation-batch-size.constant';
import { CreateCompanyService } from 'src/modules/contact-creation-manager/services/create-company.service';
import { CreateContactService } from 'src/modules/contact-creation-manager/services/create-contact.service';
import { Contact } from 'src/modules/contact-creation-manager/types/contact.type';
import { filterOutSelfAndContactsFromCompanyOrWorkspace } from 'src/modules/contact-creation-manager/utils/filter-out-contacts-from-company-or-workspace.util';
import { getDomainNameFromHandle } from 'src/modules/contact-creation-manager/utils/get-domain-name-from-handle.util';
import { getUniqueContactsAndHandles } from 'src/modules/contact-creation-manager/utils/get-unique-contacts-and-handles.util';
import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
import { WorkspaceMemberRepository } from 'src/modules/workspace-member/repositories/workspace-member.repository';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { isWorkDomain, isWorkEmail } from 'src/utils/is-work-email';
@Injectable()
export class CreateCompanyAndContactService {
constructor(
private readonly createContactService: CreateContactService,
private readonly createCompaniesService: CreateCompanyService,
@InjectObjectMetadataRepository(WorkspaceMemberWorkspaceEntity)
private readonly workspaceMemberRepository: WorkspaceMemberRepository,
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
@InjectRepository(ObjectMetadataEntity, 'metadata')
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
private async createCompaniesAndPeople(
connectedAccount: ConnectedAccountWorkspaceEntity,
contactsToCreate: Contact[],
workspaceId: string,
source: FieldActorSource,
transactionManager?: EntityManager,
): Promise<DeepPartial<PersonWorkspaceEntity>[]> {
if (!contactsToCreate || contactsToCreate.length === 0) {
return [];
}
const personRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
PersonWorkspaceEntity,
);
const workspaceMembers =
await this.workspaceMemberRepository.getAllByWorkspaceId(
workspaceId,
transactionManager,
);
const contactsToCreateFromOtherCompanies =
filterOutSelfAndContactsFromCompanyOrWorkspace(
contactsToCreate,
connectedAccount,
workspaceMembers,
);
const { uniqueContacts, uniqueHandles } = getUniqueContactsAndHandles(
contactsToCreateFromOtherCompanies,
);
if (uniqueHandles.length === 0) {
return [];
}
const alreadyCreatedContacts = await personRepository.find({
withDeleted: true,
where: {
emails: { primaryEmail: Any(uniqueHandles) },
},
});
const alreadyCreatedContactEmails: string[] = alreadyCreatedContacts?.map(
({ emails }) => emails?.primaryEmail?.toLowerCase(),
);
const filteredContactsToCreate = uniqueContacts.filter(
(participant) =>
!alreadyCreatedContactEmails.includes(
participant.handle.toLowerCase(),
) && participant.handle.includes('@'),
);
const filteredContactsToCreateWithCompanyDomainNames =
filteredContactsToCreate?.map((participant) => ({
handle: participant.handle,
displayName: participant.displayName,
companyDomainName: isWorkEmail(participant.handle)
? getDomainNameFromHandle(participant.handle)
: undefined,
}));
const domainNamesToCreate = compact(
filteredContactsToCreateWithCompanyDomainNames
.filter((participant) => participant.companyDomainName)
.map((participant) => ({
domainName: participant.companyDomainName,
createdBySource: source,
createdByWorkspaceMember: connectedAccount.accountOwner,
})),
);
const workDomainNamesToCreate = domainNamesToCreate.filter(
(domainName) =>
domainName?.domainName && isWorkDomain(domainName.domainName),
);
const workDomainNamesToCreateFormatted = workDomainNamesToCreate.map(
(domainName) => ({
...domainName,
createdBySource: source,
createdByWorkspaceMember: connectedAccount.accountOwner,
createdByContext: {
provider: connectedAccount.provider,
},
}),
);
const companiesObject = await this.createCompaniesService.createCompanies(
workDomainNamesToCreateFormatted,
workspaceId,
transactionManager,
);
const formattedContactsToCreate =
filteredContactsToCreateWithCompanyDomainNames.map((contact) => ({
handle: contact.handle,
displayName: contact.displayName,
companyId:
contact.companyDomainName && contact.companyDomainName !== ''
? companiesObject[contact.companyDomainName]
: undefined,
createdBySource: source,
createdByWorkspaceMember: connectedAccount.accountOwner,
createdByContext: {
provider: connectedAccount.provider,
},
}));
return this.createContactService.createPeople(
formattedContactsToCreate,
workspaceId,
transactionManager,
);
}
async createCompaniesAndContactsAndUpdateParticipants(
connectedAccount: ConnectedAccountWorkspaceEntity,
contactsToCreate: Contact[],
workspaceId: string,
source: FieldActorSource,
) {
const contactsBatches = chunk(
contactsToCreate,
CONTACTS_CREATION_BATCH_SIZE,
);
// TODO: Remove this when events are emitted directly inside TwentyORM
const objectMetadata = await this.objectMetadataRepository.findOne({
where: {
standardId: STANDARD_OBJECT_IDS.person,
workspaceId,
},
});
if (!objectMetadata) {
throw new Error('Object metadata not found');
}
// In some jobs the accountOwner is not populated
if (!connectedAccount.accountOwner) {
const workspaceMemberRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
WorkspaceMemberWorkspaceEntity,
);
const workspaceMember = await workspaceMemberRepository.findOne({
where: {
id: connectedAccount.accountOwnerId,
},
});
if (!workspaceMember) {
throw new Error(
`Workspace member with id ${connectedAccount.accountOwnerId} not found in workspace ${workspaceId}`,
);
}
connectedAccount.accountOwner = workspaceMember;
}
for (const contactsBatch of contactsBatches) {
const createdPeople = await this.createCompaniesAndPeople(
connectedAccount,
contactsBatch,
workspaceId,
source,
);
this.workspaceEventEmitter.emitDatabaseBatchEvent({
objectMetadataNameSingular: 'person',
action: DatabaseEventAction.CREATED,
events: createdPeople.map((createdPerson) => ({
// Fix ' as string': TypeORM typing issue... id is always returned when using save
recordId: createdPerson.id as string,
objectMetadata,
properties: {
after: createdPerson,
},
})),
workspaceId,
});
}
}
}