feat: created by email calendar (#6536)

This PR is a followup of #6324 to add support of EMAIL and CALENDAR
source for the created by composite field.
This commit is contained in:
Jérémy M 2024-08-07 15:03:06 +02:00 committed by GitHub
parent dce5a64ec5
commit 11a41b3d97
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 381 additions and 318 deletions

View File

@ -192,7 +192,7 @@
"tslib": "^2.3.0",
"tsup": "^8.0.1",
"type-fest": "4.10.1",
"typeorm": "^0.3.20",
"typeorm": "patch:typeorm@0.3.20#./packages/twenty-server/patches/typeorm+0.3.20.patch",
"typescript": "5.3.3",
"use-context-selector": "^2.0.0",
"use-debounce": "^10.0.0",

View File

@ -0,0 +1,14 @@
diff --git a/node_modules/typeorm/common/PickKeysByType.d.ts b/node_modules/typeorm/common/PickKeysByType.d.ts
index 55ad347..1a8a184 100644
--- a/common/PickKeysByType.d.ts
+++ b/common/PickKeysByType.d.ts
@@ -1,6 +1,6 @@
/**
* Pick only the keys that match the Type `U`
*/
-export type PickKeysByType<T, U> = string & keyof {
- [P in keyof T as T[P] extends U ? P : never]: T[P];
-};
+export type PickKeysByType<T, U> = string & {
+ [P in keyof T]: Exclude<T[P], null> extends U ? P : never;
+}[keyof T];

View File

@ -1,12 +1,10 @@
import { BlocklistRepository } from 'src/modules/blocklist/repositories/blocklist.repository';
import { CompanyRepository } from 'src/modules/company/repositories/company.repository';
import { ConnectedAccountRepository } from 'src/modules/connected-account/repositories/connected-account.repository';
import { MessageChannelMessageAssociationRepository } from 'src/modules/messaging/common/repositories/message-channel-message-association.repository';
import { MessageChannelRepository } from 'src/modules/messaging/common/repositories/message-channel.repository';
import { MessageParticipantRepository } from 'src/modules/messaging/common/repositories/message-participant.repository';
import { MessageThreadRepository } from 'src/modules/messaging/common/repositories/message-thread.repository';
import { MessageRepository } from 'src/modules/messaging/common/repositories/message.repository';
import { PersonRepository } from 'src/modules/person/repositories/person.repository';
import { AuditLogRepository } from 'src/modules/timeline/repositiories/audit-log.repository';
import { TimelineActivityRepository } from 'src/modules/timeline/repositiories/timeline-activity.repository';
import { WorkspaceMemberRepository } from 'src/modules/workspace-member/repositories/workspace-member.repository';
@ -14,7 +12,6 @@ import { WorkspaceMemberRepository } from 'src/modules/workspace-member/reposito
export const metadataToRepositoryMapping = {
AuditLogWorkspaceEntity: AuditLogRepository,
BlocklistWorkspaceEntity: BlocklistRepository,
CompanyWorkspaceEntity: CompanyRepository,
ConnectedAccountWorkspaceEntity: ConnectedAccountRepository,
MessageChannelMessageAssociationWorkspaceEntity:
MessageChannelMessageAssociationRepository,
@ -22,7 +19,6 @@ export const metadataToRepositoryMapping = {
MessageWorkspaceEntity: MessageRepository,
MessageParticipantWorkspaceEntity: MessageParticipantRepository,
MessageThreadWorkspaceEntity: MessageThreadRepository,
PersonWorkspaceEntity: PersonRepository,
TimelineActivityWorkspaceEntity: TimelineActivityRepository,
WorkspaceMemberWorkspaceEntity: WorkspaceMemberRepository,
};

View File

@ -18,9 +18,9 @@ import {
SaveOptions,
UpdateResult,
} from 'typeorm';
import { PickKeysByType } from 'typeorm/common/PickKeysByType';
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { UpsertOptions } from 'typeorm/repository/UpsertOptions';
import { PickKeysByType } from 'typeorm/common/PickKeysByType';
import { WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/workspace-internal-context.interface';

View File

@ -27,7 +27,6 @@ import { CalendarEventWorkspaceEntity } from 'src/modules/calendar/common/standa
import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module';
import { RefreshAccessTokenManagerModule } from 'src/modules/connected-account/refresh-access-token-manager/refresh-access-token-manager.module';
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@Module({
@ -41,7 +40,6 @@ import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/sta
ObjectMetadataRepositoryModule.forFeature([
ConnectedAccountWorkspaceEntity,
BlocklistWorkspaceEntity,
PersonWorkspaceEntity,
WorkspaceMemberWorkspaceEntity,
]),
CalendarEventParticipantManagerModule,

View File

@ -19,6 +19,7 @@ import {
CreateCompanyAndContactJob,
CreateCompanyAndContactJobData,
} from 'src/modules/contact-creation-manager/jobs/create-company-and-contact.job';
import { FieldActorSource } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
@Injectable()
export class CalendarSaveEventsService {
@ -153,6 +154,7 @@ export class CalendarSaveEventsService {
workspaceId,
connectedAccount,
contactsToCreate: participantsToSave,
source: FieldActorSource.CALENDAR,
},
);
}

View File

@ -9,6 +9,7 @@ import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import { CalendarChannelWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
import { CalendarEventParticipantWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity';
import { CreateCompanyAndContactService } from 'src/modules/contact-creation-manager/services/create-company-and-contact.service';
import { FieldActorSource } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
export type CalendarCreateCompanyAndContactAfterSyncJobData = {
workspaceId: string;
@ -96,6 +97,7 @@ export class CalendarCreateCompanyAndContactAfterSyncJob {
connectedAccount,
calendarEventParticipantsWithoutPersonIdAndWorkspaceMemberId,
workspaceId,
FieldActorSource.CALENDAR,
);
this.logger.log(

View File

@ -1,85 +0,0 @@
import { Injectable } from '@nestjs/common';
import { EntityManager } from 'typeorm';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
export type CompanyToCreate = {
id: string;
domainName: string;
name?: string;
city?: string;
};
@Injectable()
export class CompanyRepository {
constructor(
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
) {}
public async getExistingCompaniesByDomainNames(
domainNames: string[],
workspaceId: string,
companyDomainNameColumnName: string,
transactionManager?: EntityManager,
): Promise<{ id: string; domainName: string }[]> {
const dataSourceSchema =
this.workspaceDataSourceService.getSchemaName(workspaceId);
const existingCompanies =
await this.workspaceDataSourceService.executeRawQuery(
`SELECT id, "${companyDomainNameColumnName}" AS "domainName" FROM ${dataSourceSchema}.company WHERE REGEXP_REPLACE("${companyDomainNameColumnName}", '^https?://', '') = ANY($1)`,
[domainNames],
workspaceId,
transactionManager,
);
return existingCompanies;
}
public async getLastCompanyPosition(
workspaceId: string,
transactionManager?: EntityManager,
): Promise<number> {
const dataSourceSchema =
this.workspaceDataSourceService.getSchemaName(workspaceId);
const result = await this.workspaceDataSourceService.executeRawQuery(
`SELECT MAX(position) FROM ${dataSourceSchema}.company`,
[],
workspaceId,
transactionManager,
);
return result[0].max ?? 0;
}
public async createCompany(
workspaceId: string,
companyToCreate: CompanyToCreate,
companyDomainNameColumnName: string,
transactionManager?: EntityManager,
): Promise<void> {
const dataSourceSchema =
this.workspaceDataSourceService.getSchemaName(workspaceId);
const lastCompanyPosition = await this.getLastCompanyPosition(
workspaceId,
transactionManager,
);
await this.workspaceDataSourceService.executeRawQuery(
`INSERT INTO ${dataSourceSchema}.company (id, "${companyDomainNameColumnName}", name, "addressAddressCity", position)
VALUES ($1, $2, $3, $4, $5)`,
[
companyToCreate.id,
'https://' + companyToCreate.domainName,
companyToCreate.name ?? '',
companyToCreate.city ?? '',
lastCompanyPosition + 1,
],
workspaceId,
transactionManager,
);
}
}

View File

@ -1,5 +1,3 @@
import { Address } from 'nodemailer/lib/mailer';
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface';
import {
@ -32,6 +30,7 @@ import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/perso
import { TaskTargetWorkspaceEntity } from 'src/modules/task/standard-objects/task-target.workspace-entity';
import { TimelineActivityWorkspaceEntity } from 'src/modules/timeline/standard-objects/timeline-activity.workspace-entity';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { AddressMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/address.composite-type';
@WorkspaceEntity({
standardId: STANDARD_OBJECT_IDS.company,
@ -60,7 +59,7 @@ export class CompanyWorkspaceEntity extends BaseWorkspaceEntity {
'The company website URL. We use this url to fetch the company icon',
icon: 'IconLink',
})
domainName?: string;
domainName?: LinksMetadata;
@WorkspaceField({
standardId: COMPANY_STANDARD_FIELD_IDS.employees,
@ -111,7 +110,7 @@ export class CompanyWorkspaceEntity extends BaseWorkspaceEntity {
icon: 'IconMap',
})
@WorkspaceIsNullable()
address: Address;
address: AddressMetadata;
@WorkspaceField({
standardId: COMPANY_STANDARD_FIELD_IDS.idealCustomerProfile,

View File

@ -2,30 +2,22 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ObjectMetadataRepositoryModule } from 'src/engine/object-metadata-repository/object-metadata-repository.module';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
import { CompanyWorkspaceEntity } from 'src/modules/company/standard-objects/company.workspace-entity';
import { AutoCompaniesAndContactsCreationCalendarChannelListener } from 'src/modules/contact-creation-manager/listeners/auto-companies-and-contacts-creation-calendar-channel.listener';
import { AutoCompaniesAndContactsCreationMessageChannelListener } from 'src/modules/contact-creation-manager/listeners/auto-companies-and-contacts-creation-message-channel.listener';
import { CreateCompanyAndContactService } from 'src/modules/contact-creation-manager/services/create-company-and-contact.service';
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 { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@Module({
imports: [
ObjectMetadataRepositoryModule.forFeature([
PersonWorkspaceEntity,
WorkspaceMemberWorkspaceEntity,
CompanyWorkspaceEntity,
]),
ObjectMetadataRepositoryModule.forFeature([WorkspaceMemberWorkspaceEntity]),
WorkspaceDataSourceModule,
TypeOrmModule.forFeature([FeatureFlagEntity], 'core'),
TypeOrmModule.forFeature([ObjectMetadataEntity], 'metadata'),
TypeOrmModule.forFeature([FieldMetadataEntity], 'metadata'),
],
providers: [
CreateCompanyService,

View File

@ -1,6 +1,7 @@
import { Process } from 'src/engine/integrations/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/integrations/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/integrations/message-queue/message-queue.constants';
import { FieldActorSource } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { CreateCompanyAndContactService } from 'src/modules/contact-creation-manager/services/create-company-and-contact.service';
@ -11,6 +12,7 @@ export type CreateCompanyAndContactJobData = {
displayName: string;
handle: string;
}[];
source: FieldActorSource;
};
@Processor(MessageQueue.contactCreationQueue)
@ -21,7 +23,7 @@ export class CreateCompanyAndContactJob {
@Process(CreateCompanyAndContactJob.name)
async handle(data: CreateCompanyAndContactJobData): Promise<void> {
const { workspaceId, connectedAccount, contactsToCreate } = data;
const { workspaceId, connectedAccount, contactsToCreate, source } = data;
await this.createCompanyAndContactService.createCompaniesAndContactsAndUpdateParticipants(
connectedAccount,
@ -30,6 +32,7 @@ export class CreateCompanyAndContactJob {
displayName: contact.displayName,
})),
workspaceId,
source,
);
}
}

View File

@ -4,16 +4,11 @@ import { InjectRepository } from '@nestjs/typeorm';
import chunk from 'lodash.chunk';
import compact from 'lodash.compact';
import { EntityManager, Repository } from 'typeorm';
import { Any, EntityManager, Repository } from 'typeorm';
import { ObjectRecordCreateEvent } from 'src/engine/integrations/event-emitter/types/object-record-create.event';
import {
FieldMetadataEntity,
FieldMetadataType,
} from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
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 { COMPANY_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 { 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';
@ -23,39 +18,43 @@ 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 { PersonRepository } from 'src/modules/person/repositories/person.repository';
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 { isWorkEmail } from 'src/utils/is-work-email';
import { FieldActorSource } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
@Injectable()
export class CreateCompanyAndContactService {
constructor(
private readonly createContactService: CreateContactService,
private readonly createCompaniesService: CreateCompanyService,
@InjectObjectMetadataRepository(PersonWorkspaceEntity)
private readonly personRepository: PersonRepository,
@InjectObjectMetadataRepository(WorkspaceMemberWorkspaceEntity)
private readonly workspaceMemberRepository: WorkspaceMemberRepository,
private readonly eventEmitter: EventEmitter2,
@InjectRepository(ObjectMetadataEntity, 'metadata')
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
@InjectRepository(FieldMetadataEntity, 'metadata')
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
private async createCompaniesAndPeople(
connectedAccount: ConnectedAccountWorkspaceEntity,
contactsToCreate: Contact[],
workspaceId: string,
companyDomainNameColumnName: string,
source: FieldActorSource,
transactionManager?: EntityManager,
): Promise<PersonWorkspaceEntity[]> {
): Promise<DeepPartial<PersonWorkspaceEntity>[]> {
if (!contactsToCreate || contactsToCreate.length === 0) {
return [];
}
const personRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
PersonWorkspaceEntity,
);
const workspaceMembers =
await this.workspaceMemberRepository.getAllByWorkspaceId(
workspaceId,
@ -77,11 +76,11 @@ export class CreateCompanyAndContactService {
return [];
}
const alreadyCreatedContacts = await this.personRepository.getByEmails(
uniqueHandles,
workspaceId,
transactionManager,
);
const alreadyCreatedContacts = await personRepository.find({
where: {
email: Any(uniqueHandles),
},
});
const alreadyCreatedContactEmails: string[] = alreadyCreatedContacts?.map(
({ email }) => email,
@ -103,15 +102,18 @@ export class CreateCompanyAndContactService {
}));
const domainNamesToCreate = compact(
filteredContactsToCreateWithCompanyDomainNames.map(
(participant) => participant.companyDomainName,
),
filteredContactsToCreateWithCompanyDomainNames
.filter((participant) => participant.companyDomainName)
.map((participant) => ({
domainName: participant.companyDomainName!,
createdBySource: source,
createdByWorkspaceMember: connectedAccount.accountOwner,
})),
);
const companiesObject = await this.createCompaniesService.createCompanies(
domainNamesToCreate,
workspaceId,
companyDomainNameColumnName,
transactionManager,
);
@ -123,9 +125,11 @@ export class CreateCompanyAndContactService {
contact.companyDomainName && contact.companyDomainName !== ''
? companiesObject[contact.companyDomainName]
: undefined,
createdBySource: source,
createdByWorkspaceMember: connectedAccount.accountOwner,
}));
return await this.createContactService.createPeople(
return this.createContactService.createPeople(
formattedContactsToCreate,
workspaceId,
transactionManager,
@ -136,6 +140,7 @@ export class CreateCompanyAndContactService {
connectedAccount: ConnectedAccountWorkspaceEntity,
contactsToCreate: Contact[],
workspaceId: string,
source: FieldActorSource,
) {
const contactsBatches = chunk(
contactsToCreate,
@ -155,31 +160,43 @@ export class CreateCompanyAndContactService {
throw new Error('Object metadata not found');
}
const domainNameFieldMetadata = await this.fieldMetadataRepository.findOne({
where: {
workspaceId: workspaceId,
standardId: COMPANY_STANDARD_FIELD_IDS.domainName,
},
});
// In some jobs the accountOwner is not populated
if (!connectedAccount.accountOwner) {
const workspaceMemberRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
WorkspaceMemberWorkspaceEntity,
);
const companyDomainNameColumnName =
domainNameFieldMetadata?.type === FieldMetadataType.LINKS
? 'domainNamePrimaryLinkUrl'
: 'domainName';
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,
companyDomainNameColumnName,
source,
);
for (const createdPerson of createdPeople) {
this.eventEmitter.emit('person.created', {
name: 'person.created',
workspaceId,
recordId: createdPerson.id,
// FixMe: TypeORM typing issue... id is always returned when using save
recordId: createdPerson.id!,
objectMetadata,
properties: {
after: createdPerson,

View File

@ -1,108 +1,189 @@
import { Injectable } from '@nestjs/common';
import axios, { AxiosInstance } from 'axios';
import { EntityManager } from 'typeorm';
import { v4 } from 'uuid';
import { EntityManager, ILike } from 'typeorm';
import uniqBy from 'lodash.uniqby';
import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator';
import { CompanyRepository } from 'src/modules/company/repositories/company.repository';
import { CompanyWorkspaceEntity } from 'src/modules/company/standard-objects/company.workspace-entity';
import { extractDomainFromLink } from 'src/modules/contact-creation-manager/utils/extract-domain-from-link.util';
import { getCompanyNameFromDomainName } from 'src/modules/contact-creation-manager/utils/get-company-name-from-domain-name.util';
import { FieldActorSource } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { computeDisplayName } from 'src/utils/compute-display-name';
import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
type CompanyToCreate = {
domainName: string;
createdBySource: FieldActorSource;
createdByWorkspaceMember?: WorkspaceMemberWorkspaceEntity | null;
};
@Injectable()
export class CreateCompanyService {
private readonly httpService: AxiosInstance;
constructor(
@InjectObjectMetadataRepository(CompanyWorkspaceEntity)
private readonly companyRepository: CompanyRepository,
) {
constructor(private readonly twentyORMGlobalManager: TwentyORMGlobalManager) {
this.httpService = axios.create({
baseURL: 'https://companies.twenty.com',
});
}
async createCompanies(
domainNames: string[],
companies: CompanyToCreate[],
workspaceId: string,
companyDomainNameColumnName: string,
transactionManager?: EntityManager,
): Promise<{
[domainName: string]: string;
}> {
if (domainNames.length === 0) {
if (companies.length === 0) {
return {};
}
const uniqueDomainNames = [...new Set(domainNames)];
const existingCompanies =
await this.companyRepository.getExistingCompaniesByDomainNames(
uniqueDomainNames,
const companyRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
companyDomainNameColumnName,
transactionManager,
CompanyWorkspaceEntity,
);
const companiesObject = existingCompanies.reduce(
(
acc: {
[domainName: string]: string;
},
company: {
domainName: string;
id: string;
},
) => ({
...acc,
[extractDomainFromLink(company.domainName)]: company.id,
}),
{},
);
// Avoid creating duplicate companies
const uniqueCompanies = uniqBy(companies, 'domainName');
const conditions = uniqueCompanies.map((companyToCreate) => ({
domainName: {
primaryLinkUrl: ILike(`%${companyToCreate.domainName}%`),
},
}));
const filteredDomainNames = uniqueDomainNames.filter(
(domainName) =>
// Find existing companies
const existingCompanies = await companyRepository.find(
{
where: conditions,
},
transactionManager,
);
const existingCompanyIdsMap = this.createCompanyMap(existingCompanies);
// Filter out companies that already exist
const newCompaniesToCreate = uniqueCompanies.filter(
(company) =>
!existingCompanies.some(
(company: { domainName: string }) =>
extractDomainFromLink(company.domainName) === domainName,
(existingCompany) =>
existingCompany.domainName &&
extractDomainFromLink(existingCompany.domainName.primaryLinkUrl) ===
company.domainName,
),
);
for (const domainName of filteredDomainNames) {
companiesObject[domainName] = await this.createCompany(
domainName,
workspaceId,
companyDomainNameColumnName,
transactionManager,
);
if (newCompaniesToCreate.length === 0) {
return existingCompanyIdsMap;
}
return companiesObject;
// Retrieve the last company position
let lastCompanyPosition = await this.getLastCompanyPosition(
companyRepository,
transactionManager,
);
const newCompaniesData = await Promise.all(
newCompaniesToCreate.map((company) =>
this.prepareCompanyData(company, ++lastCompanyPosition),
),
);
// Create new companies
const createdCompanies = await companyRepository.save(
newCompaniesData,
undefined,
transactionManager,
);
const createdCompanyIdsMap = this.createCompanyMap(createdCompanies);
return {
...existingCompanyIdsMap,
...createdCompanyIdsMap,
};
}
private async createCompany(
domainName: string,
async createCompany(
company: CompanyToCreate,
workspaceId: string,
companyDomainNameColumnName,
transactionManager?: EntityManager,
): Promise<string> {
const companyId = v4();
const { name, city } = await this.getCompanyInfoFromDomainName(domainName);
await this.companyRepository.createCompany(
workspaceId,
{
id: companyId,
domainName,
name,
city,
},
companyDomainNameColumnName,
const companyRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
CompanyWorkspaceEntity,
);
let lastCompanyPosition = await this.getLastCompanyPosition(
companyRepository,
transactionManager,
);
return companyId;
const data = await this.prepareCompanyData(company, ++lastCompanyPosition);
const createdCompany = await companyRepository.save(
data,
undefined,
transactionManager,
);
return createdCompany.id;
}
private async prepareCompanyData(
company: CompanyToCreate,
position: number,
): Promise<DeepPartial<CompanyWorkspaceEntity>> {
const { name, city } = await this.getCompanyInfoFromDomainName(
company.domainName,
);
const createdByName = computeDisplayName(
company.createdByWorkspaceMember?.name,
);
return {
domainName: {
primaryLinkUrl: 'https://' + company.domainName,
},
name,
createdBy: {
source: company.createdBySource,
workspaceMemberId: company.createdByWorkspaceMember?.id,
name: createdByName,
},
address: {
addressCity: city,
},
position,
};
}
private async createCompanyMap(companies: CompanyWorkspaceEntity[]) {
return companies.reduce(
(acc, company) => {
if (!company.domainName) {
return acc;
}
const key = extractDomainFromLink(company.domainName.primaryLinkUrl);
acc[key] = company.id;
return acc;
},
{} as { [domainName: string]: string },
);
}
private async getLastCompanyPosition(
companyRepository: WorkspaceRepository<CompanyWorkspaceEntity>,
transactionManager?: EntityManager,
): Promise<number> {
const lastCompanyPosition = await companyRepository.maximum(
'position',
undefined,
transactionManager,
);
return lastCompanyPosition ?? 0;
}
private async getCompanyInfoFromDomainName(domainName: string): Promise<{

View File

@ -3,49 +3,61 @@ import { Injectable } from '@nestjs/common';
import { EntityManager } from 'typeorm';
import { v4 } from 'uuid';
import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator';
import { getFirstNameAndLastNameFromHandleAndDisplayName } from 'src/modules/contact-creation-manager/utils/get-first-name-and-last-name-from-handle-and-display-name.util';
import { PersonRepository } from 'src/modules/person/repositories/person.repository';
import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { FieldActorSource } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { computeDisplayName } from 'src/utils/compute-display-name';
import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
type ContactToCreate = {
handle: string;
displayName: string;
companyId?: string;
};
type FormattedContactToCreate = {
id: string;
handle: string;
firstName: string;
lastName: string;
companyId?: string;
createdBySource: FieldActorSource;
createdByWorkspaceMember?: WorkspaceMemberWorkspaceEntity | null;
};
@Injectable()
export class CreateContactService {
constructor(
@InjectObjectMetadataRepository(PersonWorkspaceEntity)
private readonly personRepository: PersonRepository,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
private formatContacts(
contactsToCreate: ContactToCreate[],
): FormattedContactToCreate[] {
lastPersonPosition: number,
): DeepPartial<PersonWorkspaceEntity>[] {
return contactsToCreate.map((contact) => {
const id = v4();
const { handle, displayName, companyId } = contact;
const {
handle,
displayName,
companyId,
createdBySource,
createdByWorkspaceMember,
} = contact;
const { firstName, lastName } =
getFirstNameAndLastNameFromHandleAndDisplayName(handle, displayName);
const createdByName = computeDisplayName(createdByWorkspaceMember?.name);
return {
id,
handle,
firstName,
lastName,
email: handle,
name: {
firstName,
lastName,
},
companyId,
createdBy: {
source: createdBySource,
workspaceMemberId: contact.createdByWorkspaceMember?.id,
name: createdByName,
},
position: ++lastPersonPosition,
};
});
}
@ -54,15 +66,42 @@ export class CreateContactService {
contactsToCreate: ContactToCreate[],
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PersonWorkspaceEntity[]> {
): Promise<DeepPartial<PersonWorkspaceEntity>[]> {
if (contactsToCreate.length === 0) return [];
const formattedContacts = this.formatContacts(contactsToCreate);
const personRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
PersonWorkspaceEntity,
);
return await this.personRepository.createPeople(
const lastPersonPosition = await this.getLastPersonPosition(
personRepository,
transactionManager,
);
const formattedContacts = this.formatContacts(
contactsToCreate,
lastPersonPosition,
);
return personRepository.save(
formattedContacts,
workspaceId,
undefined,
transactionManager,
);
}
private async getLastPersonPosition(
personRepository: WorkspaceRepository<PersonWorkspaceEntity>,
transactionManager?: EntityManager,
): Promise<number> {
const lastPersonPosition = await personRepository.maximum(
'position',
undefined,
transactionManager,
);
return lastPersonPosition ?? 0;
}
}

View File

@ -9,13 +9,11 @@ import { MessagingChannelSyncStatusService } from 'src/modules/messaging/common/
import { MessageParticipantWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-participant.workspace-entity';
import { MessageThreadWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-thread.workspace-entity';
import { MessageWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message.workspace-entity';
import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
@Module({
imports: [
WorkspaceDataSourceModule,
ObjectMetadataRepositoryModule.forFeature([
PersonWorkspaceEntity,
MessageParticipantWorkspaceEntity,
MessageWorkspaceEntity,
MessageThreadWorkspaceEntity,

View File

@ -24,6 +24,7 @@ import { MessagingMessageService } from 'src/modules/messaging/message-import-ma
import { MessagingMessageParticipantService } from 'src/modules/messaging/message-participant-manager/services/messaging-message-participant.service';
import { isGroupEmail } from 'src/utils/is-group-email';
import { isWorkEmail } from 'src/utils/is-work-email';
import { FieldActorSource } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
@Injectable()
export class MessagingSaveMessagesAndEnqueueContactCreationService {
@ -121,6 +122,7 @@ export class MessagingSaveMessagesAndEnqueueContactCreationService {
workspaceId,
connectedAccount,
contactsToCreate,
source: FieldActorSource.EMAIL,
},
);
}

View File

@ -3,6 +3,7 @@ import { Logger } from '@nestjs/common';
import { Process } from 'src/engine/integrations/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/integrations/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/integrations/message-queue/message-queue.constants';
import { FieldActorSource } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator';
import { ConnectedAccountRepository } from 'src/modules/connected-account/repositories/connected-account.repository';
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
@ -84,6 +85,7 @@ export class MessagingCreateCompanyAndContactAfterSyncJob {
connectedAccount,
contactsToCreate,
workspaceId,
FieldActorSource.EMAIL,
);
this.logger.log(

View File

@ -1,89 +0,0 @@
import { Injectable } from '@nestjs/common';
import { EntityManager } from 'typeorm';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
import { getFlattenedValuesAndValuesStringForBatchRawQuery } from 'src/modules/calendar/calendar-event-import-manager/utils/get-flattened-values-and-values-string-for-batch-raw-query.util';
@Injectable()
export class PersonRepository {
constructor(
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
) {}
async getByEmails(
emails: string[],
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PersonWorkspaceEntity[]> {
const dataSourceSchema =
this.workspaceDataSourceService.getSchemaName(workspaceId);
return await this.workspaceDataSourceService.executeRawQuery(
`SELECT * FROM ${dataSourceSchema}.person WHERE email = ANY($1)`,
[emails],
workspaceId,
transactionManager,
);
}
async getLastPersonPosition(
workspaceId: string,
transactionManager?: EntityManager,
): Promise<number> {
const dataSourceSchema =
this.workspaceDataSourceService.getSchemaName(workspaceId);
const result = await this.workspaceDataSourceService.executeRawQuery(
`SELECT MAX(position) FROM ${dataSourceSchema}.person`,
[],
workspaceId,
transactionManager,
);
return result[0].max ?? 0;
}
async createPeople(
peopleToCreate: {
id: string;
handle: string;
firstName: string;
lastName: string;
companyId?: string;
}[],
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PersonWorkspaceEntity[]> {
const dataSourceSchema =
this.workspaceDataSourceService.getSchemaName(workspaceId);
const lastPersonPosition = await this.getLastPersonPosition(
workspaceId,
transactionManager,
);
peopleToCreate = peopleToCreate.map((contact, index) => ({
...contact,
position: lastPersonPosition + index + 1,
}));
const { flattenedValues, valuesString } =
getFlattenedValuesAndValuesStringForBatchRawQuery(peopleToCreate, {
id: 'uuid',
handle: 'text',
firstName: 'text',
lastName: 'text',
companyId: 'uuid',
position: 'double precision',
});
return await this.workspaceDataSourceService.executeRawQuery(
`INSERT INTO ${dataSourceSchema}.person (id, email, "nameFirstName", "nameLastName", "companyId", "position") VALUES ${valuesString} RETURNING *`,
flattenedValues,
workspaceId,
transactionManager,
);
}
}

View File

@ -0,0 +1,12 @@
import { FullNameMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/full-name.composite-type';
import { isDefined } from 'src/utils/is-defined';
export const computeDisplayName = (
name: FullNameMetadata | null | undefined,
) => {
if (!name) {
return '';
}
return Object.values(name).filter(isDefined).join(' ');
};

View File

@ -49531,7 +49531,7 @@ __metadata:
tsup: "npm:^8.0.1"
tsx: "npm:^4.7.2"
type-fest: "npm:4.10.1"
typeorm: "npm:^0.3.20"
typeorm: "patch:typeorm@0.3.20#./packages/twenty-server/patches/typeorm+0.3.20.patch"
typescript: "npm:5.3.3"
use-context-selector: "npm:^2.0.0"
use-debounce: "npm:^10.0.0"
@ -49657,7 +49657,7 @@ __metadata:
languageName: node
linkType: hard
"typeorm@npm:^0.3.20":
"typeorm@npm:0.3.20":
version: 0.3.20
resolution: "typeorm@npm:0.3.20"
dependencies:
@ -49737,6 +49737,86 @@ __metadata:
languageName: node
linkType: hard
"typeorm@patch:typeorm@0.3.20#./packages/twenty-server/patches/typeorm+0.3.20.patch::locator=twenty%40workspace%3A.":
version: 0.3.20
resolution: "typeorm@patch:typeorm@npm%3A0.3.20#./packages/twenty-server/patches/typeorm+0.3.20.patch::version=0.3.20&hash=9584e4&locator=twenty%40workspace%3A."
dependencies:
"@sqltools/formatter": "npm:^1.2.5"
app-root-path: "npm:^3.1.0"
buffer: "npm:^6.0.3"
chalk: "npm:^4.1.2"
cli-highlight: "npm:^2.1.11"
dayjs: "npm:^1.11.9"
debug: "npm:^4.3.4"
dotenv: "npm:^16.0.3"
glob: "npm:^10.3.10"
mkdirp: "npm:^2.1.3"
reflect-metadata: "npm:^0.2.1"
sha.js: "npm:^2.4.11"
tslib: "npm:^2.5.0"
uuid: "npm:^9.0.0"
yargs: "npm:^17.6.2"
peerDependencies:
"@google-cloud/spanner": ^5.18.0
"@sap/hana-client": ^2.12.25
better-sqlite3: ^7.1.2 || ^8.0.0 || ^9.0.0
hdb-pool: ^0.1.6
ioredis: ^5.0.4
mongodb: ^5.8.0
mssql: ^9.1.1 || ^10.0.1
mysql2: ^2.2.5 || ^3.0.1
oracledb: ^6.3.0
pg: ^8.5.1
pg-native: ^3.0.0
pg-query-stream: ^4.0.0
redis: ^3.1.1 || ^4.0.0
sql.js: ^1.4.0
sqlite3: ^5.0.3
ts-node: ^10.7.0
typeorm-aurora-data-api-driver: ^2.0.0
peerDependenciesMeta:
"@google-cloud/spanner":
optional: true
"@sap/hana-client":
optional: true
better-sqlite3:
optional: true
hdb-pool:
optional: true
ioredis:
optional: true
mongodb:
optional: true
mssql:
optional: true
mysql2:
optional: true
oracledb:
optional: true
pg:
optional: true
pg-native:
optional: true
pg-query-stream:
optional: true
redis:
optional: true
sql.js:
optional: true
sqlite3:
optional: true
ts-node:
optional: true
typeorm-aurora-data-api-driver:
optional: true
bin:
typeorm: cli.js
typeorm-ts-node-commonjs: cli-ts-node-commonjs.js
typeorm-ts-node-esm: cli-ts-node-esm.js
checksum: 10c0/f817ca0a7a6c2d4d242fd64ffc1e5337b2db4b03bc87004daf307a9ad77cf79d7050d461a0103a2674df2fba09cc2e95275b6bdef9eb6287d2c779c6ec673676
languageName: node
linkType: hard
"typescript@npm:5.3.3":
version: 5.3.3
resolution: "typescript@npm:5.3.3"