3889 activate settingsaccountsemailsinboxsettings (#3962)

* update email visibility in settings

* improve styling

* Add contact auto creation toggle to inbox settings

* re
move soonpill

* update Icon

* create job

* Add logic to create contacts and companies for message participants without personId and workspaceMemberId

* add listener

* wip

* wip

* refactoring

* improve structure

* Add isContactAutoCreationEnabled method to MessageChannelService

* wip

* wip

* clean

* add job

* fix bug

* contact creation is working

* wip

* working

* improve code

* improve typing

* resolve conflicts

* fix

* create company repository

* move util

* wip

* fix
This commit is contained in:
bosiraphael 2024-02-14 17:30:17 +01:00 committed by GitHub
parent 0b2ffb0ee6
commit 94ad0e33ec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 607 additions and 164 deletions

View File

@ -3,7 +3,7 @@ import styled from '@emotion/styled';
import { MessageChannel } from '@/accounts/types/MessageChannel';
import { SettingsAccountsInboxSettingsCardMedia } from '@/settings/accounts/components/SettingsAccountsInboxSettingsCardMedia';
import { IconSend } from '@/ui/display/icon';
import { IconUser } from '@/ui/display/icon';
import { H2Title } from '@/ui/display/typography/components/H2Title';
import { Toggle } from '@/ui/input/components/Toggle';
import { Card } from '@/ui/layout/card/components/Card';
@ -43,7 +43,7 @@ export const SettingsAccountsInboxSettingsContactAutoCreateSection = ({
<Card>
<StyledCardContent>
<SettingsAccountsInboxSettingsCardMedia>
<IconSend size={theme.icon.size.sm} stroke={theme.icon.stroke.lg} />
<IconUser size={theme.icon.size.sm} stroke={theme.icon.stroke.lg} />
</SettingsAccountsInboxSettingsCardMedia>
<StyledTitle>Auto-creation</StyledTitle>
<Toggle

View File

@ -1,5 +1,4 @@
import styled from '@emotion/styled';
import { SoonPill } from 'tsup.ui.index';
import { SettingsAccountsInboxSettingsCardMedia } from '@/settings/accounts/components/SettingsAccountsInboxSettingsCardMedia';
import { H2Title } from '@/ui/display/typography/components/H2Title';
@ -23,7 +22,11 @@ const StyledCardContent = styled(CardContent)`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(4)};
opacity: 0.56;
cursor: pointer;
&:hover {
background: ${({ theme }) => theme.background.transparent.lighter};
}
`;
const StyledCardMedia = styled(SettingsAccountsInboxSettingsCardMedia)`
@ -61,16 +64,6 @@ const StyledRadio = styled(Radio)`
margin-left: auto;
`;
const StyledSoonPill = styled(SoonPill)`
position: absolute;
right: 0;
top: 0;
`;
const StyledSection = styled(Section)`
position: relative;
`;
const inboxSettingsVisibilityOptions = [
{
title: 'Everything',
@ -108,12 +101,11 @@ export const SettingsAccountsInboxSettingsVisibilitySection = ({
onChange,
value = InboxSettingsVisibilityValue.Everything,
}: SettingsAccountsInboxSettingsVisibilitySectionProps) => (
<StyledSection>
<Section>
<H2Title
title="Email visibility"
description="Define what will be visible to other users in your workspace"
/>
<StyledSoonPill />
<Card>
{inboxSettingsVisibilityOptions.map(
(
@ -123,6 +115,7 @@ export const SettingsAccountsInboxSettingsVisibilitySection = ({
<StyledCardContent
key={optionValue}
divider={index < inboxSettingsVisibilityOptions.length - 1}
onClick={() => onChange(optionValue)}
>
<StyledCardMedia>
<StyledMetadataSkeleton isActive={visibleElements.metadata} />
@ -137,11 +130,10 @@ export const SettingsAccountsInboxSettingsVisibilitySection = ({
value={optionValue}
onCheckedChange={() => onChange(optionValue)}
checked={value === optionValue}
disabled={true}
/>
</StyledCardContent>
),
)}
</Card>
</StyledSection>
</Section>
);

View File

@ -4,6 +4,7 @@ import { useNavigate, useParams } from 'react-router-dom';
import { MessageChannel } from '@/accounts/types/MessageChannel';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { SettingsAccountsInboxSettingsContactAutoCreateSection } from '@/settings/accounts/components/SettingsAccountsInboxSettingsContactAutoCreationSection';
import {
InboxSettingsVisibilityValue,
SettingsAccountsInboxSettingsVisibilitySection,
@ -36,6 +37,15 @@ export const SettingsAccountsEmailsInboxSettings = () => {
});
};
const handleContactAutoCreationToggle = (value: boolean) => {
updateOneRecord({
idToUpdate: messageChannelId,
updateOneRecordInput: {
isContactAutoCreationEnabled: value,
},
});
};
useEffect(() => {
if (!loading && !messageChannel) navigate(AppPath.NotFound);
}, [loading, messageChannel, navigate]);
@ -61,11 +71,10 @@ export const SettingsAccountsEmailsInboxSettings = () => {
value={messageChannel?.visibility}
onChange={handleVisibilityChange}
/>
{/* TODO : Add this section when the backend will be ready to auto create contacts */}
{/* <SettingsAccountsInboxSettingsContactAutoCreateSection
<SettingsAccountsInboxSettingsContactAutoCreateSection
messageChannel={messageChannel}
onToggle={handleContactAutoCreationToggle}
/> */}
/>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);

View File

@ -67,8 +67,8 @@ export class GoogleGmailService {
);
await manager.query(
`INSERT INTO ${dataSourceMetadata.schema}."messageChannel" ("visibility", "handle", "connectedAccountId", "type") VALUES ($1, $2, $3, $4)`,
['share_everything', handle, connectedAccountId, 'email'],
`INSERT INTO ${dataSourceMetadata.schema}."messageChannel" ("visibility", "handle", "connectedAccountId", "type", "isContactAutoCreationEnabled") VALUES ($1, $2, $3, $4, $5)`,
['share_everything', handle, connectedAccountId, 'email', true],
);
});

View File

@ -20,6 +20,9 @@ import { FeatureFlagEntity } from 'src/core/feature-flag/feature-flag.entity';
import { FetchAllWorkspacesMessagesJob } from 'src/workspace/messaging/commands/crons/fetch-all-workspaces-messages.job';
import { ConnectedAccountModule } from 'src/workspace/messaging/repositories/connected-account/connected-account.module';
import { MatchMessageParticipantJob } from 'src/workspace/messaging/jobs/match-message-participant.job';
import { CreateCompaniesAndContactsAfterSyncJob } from 'src/workspace/messaging/jobs/create-companies-and-contacts-after-sync.job';
import { CreateCompaniesAndContactsModule } from 'src/workspace/messaging/services/create-companies-and-contacts/create-companies-and-contacts.module';
import { MessageChannelModule } from 'src/workspace/messaging/repositories/message-channel/message-channel.module';
import { MessageParticipantModule } from 'src/workspace/messaging/repositories/message-participant/message-participant.module';
@Module({
@ -36,6 +39,8 @@ import { MessageParticipantModule } from 'src/workspace/messaging/repositories/m
TypeOrmModule.forFeature([FeatureFlagEntity], 'core'),
ConnectedAccountModule,
MessageParticipantModule,
CreateCompaniesAndContactsModule,
MessageChannelModule,
],
providers: [
{
@ -67,6 +72,10 @@ import { MessageParticipantModule } from 'src/workspace/messaging/repositories/m
provide: MatchMessageParticipantJob.name,
useClass: MatchMessageParticipantJob,
},
{
provide: CreateCompaniesAndContactsAfterSyncJob.name,
useClass: CreateCompaniesAndContactsAfterSyncJob,
},
],
})
export class JobsModule {

View File

@ -0,0 +1,67 @@
import { Injectable, Logger } from '@nestjs/common';
import { CreateContactsAndCompaniesAfterSyncJobData } from 'packages/twenty-server/dist/src/workspace/messaging/jobs/create-contacts-and-companies-after-sync.job';
import { MessageQueueJob } from 'src/integrations/message-queue/interfaces/message-queue-job.interface';
import { CreateCompaniesAndContactsService } from 'src/workspace/messaging/services/create-companies-and-contacts/create-companies-and-contacts.service';
import { MessageChannelService } from 'src/workspace/messaging/repositories/message-channel/message-channel.service';
import { MessageParticipantService } from 'src/workspace/messaging/repositories/message-participant/message-participant.service';
export type CreateCompaniesAndContactsAfterSyncJobData = {
workspaceId: string;
messageChannelId: string;
};
@Injectable()
export class CreateCompaniesAndContactsAfterSyncJob
implements MessageQueueJob<CreateCompaniesAndContactsAfterSyncJobData>
{
private readonly logger = new Logger(
CreateCompaniesAndContactsAfterSyncJob.name,
);
constructor(
private readonly createCompaniesAndContactsService: CreateCompaniesAndContactsService,
private readonly messageChannelService: MessageChannelService,
private readonly messageParticipantService: MessageParticipantService,
) {}
async handle(
data: CreateContactsAndCompaniesAfterSyncJobData,
): Promise<void> {
this.logger.log(
`create contacts and companies after sync for workspace ${data.workspaceId} and messageChannel ${data.messageChannelId}`,
);
const { workspaceId, messageChannelId } = data;
const isContactAutoCreationEnabled =
await this.messageChannelService.getIsContactAutoCreationEnabledByMessageChannelId(
messageChannelId,
workspaceId,
);
if (!isContactAutoCreationEnabled) {
return;
}
const messageParticipantsWithoutPersonIdAndWorkspaceMemberId =
await this.messageParticipantService.getByMessageChannelIdWithoutPersonIdAndWorkspaceMemberId(
messageChannelId,
workspaceId,
);
await this.createCompaniesAndContactsService.createCompaniesAndContacts(
messageParticipantsWithoutPersonIdAndWorkspaceMemberId,
workspaceId,
);
await this.messageParticipantService.updateMessageParticipantsAfterPeopleCreation(
messageParticipantsWithoutPersonIdAndWorkspaceMemberId,
workspaceId,
);
this.logger.log(
`create contacts and companies after sync for workspace ${data.workspaceId} and messageChannel ${data.messageChannelId} done`,
);
}
}

View File

@ -0,0 +1,40 @@
import { Injectable, Inject } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { CreateContactsAndCompaniesAfterSyncJobData } from 'packages/twenty-server/dist/src/workspace/messaging/jobs/create-contacts-and-companies-after-sync.job';
import { ObjectRecordUpdateEvent } from 'src/integrations/event-emitter/types/object-record-update.event';
import { MessageQueue } from 'src/integrations/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/integrations/message-queue/services/message-queue.service';
import { MessageChannelObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/message-channel.object-metadata';
import { objectRecordChangedProperties as objectRecordUpdateEventChangedProperties } from 'src/integrations/event-emitter/utils/object-record-changed-properties.util';
import { CreateCompaniesAndContactsAfterSyncJob } from 'src/workspace/messaging/jobs/create-companies-and-contacts-after-sync.job';
@Injectable()
export class IsContactAutoCreationEnabledListener {
constructor(
@Inject(MessageQueue.messagingQueue)
private readonly messageQueueService: MessageQueueService,
) {}
@OnEvent('messageChannel.updated')
handleUpdatedEvent(
payload: ObjectRecordUpdateEvent<MessageChannelObjectMetadata>,
) {
if (
objectRecordUpdateEventChangedProperties(
payload.previousRecord,
payload.updatedRecord,
).includes('isContactAutoCreationEnabled') &&
payload.updatedRecord.isContactAutoCreationEnabled
) {
this.messageQueueService.add<CreateContactsAndCompaniesAfterSyncJobData>(
CreateCompaniesAndContactsAfterSyncJob.name,
{
workspaceId: payload.workspaceId,
messageChannelId: payload.updatedRecord.id,
},
);
}
}
}

View File

@ -18,10 +18,14 @@ import { GmailRefreshAccessTokenService } from 'src/workspace/messaging/services
import { WorkspaceDataSourceModule } from 'src/workspace/workspace-datasource/workspace-datasource.module';
import { MessageParticipantModule } from 'src/workspace/messaging/repositories/message-participant/message-participant.module';
import { MessagingWorkspaceMemberListener } from 'src/workspace/messaging/listeners/messaging-workspace-member.listener';
import { IsContactAutoCreationEnabledListener } from 'src/workspace/messaging/listeners/is-contact-auto-creation-enabled-listener';
import { MessagingMessageChannelListener } from 'src/workspace/messaging/listeners/messaging-message-channel.listener';
import { MessageService } from 'src/workspace/messaging/repositories/message/message.service';
import { WorkspaceMemberModule } from 'src/workspace/messaging/repositories/workspace-member/workspace-member.module';
import { FeatureFlagEntity } from 'src/core/feature-flag/feature-flag.entity';
import { CreateCompaniesAndContactsModule } from 'src/workspace/messaging/services/create-companies-and-contacts/create-companies-and-contacts.module';
import { CompanyModule } from 'src/workspace/messaging/repositories/company/company.module';
import { PersonModule } from 'src/workspace/messaging/repositories/person/person.module';
@Module({
imports: [
EnvironmentModule,
@ -32,8 +36,11 @@ import { FeatureFlagEntity } from 'src/core/feature-flag/feature-flag.entity';
MessageModule,
MessageThreadModule,
MessageParticipantModule,
CreateCompaniesAndContactsModule,
WorkspaceMemberModule,
TypeOrmModule.forFeature([FeatureFlagEntity], 'core'),
CompanyModule,
PersonModule,
],
providers: [
GmailFullSyncService,
@ -45,6 +52,7 @@ import { FeatureFlagEntity } from 'src/core/feature-flag/feature-flag.entity';
CreateCompanyService,
MessagingPersonListener,
MessagingWorkspaceMemberListener,
IsContactAutoCreationEnabledListener,
MessagingMessageChannelListener,
MessageService,
],

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { CompanyService } from 'src/workspace/messaging/repositories/company/company.service';
import { WorkspaceDataSourceModule } from 'src/workspace/workspace-datasource/workspace-datasource.module';
// TODO: Move outside of the messaging module
@Module({
imports: [WorkspaceDataSourceModule],
providers: [CompanyService],
exports: [CompanyService],
})
export class CompanyModule {}

View File

@ -0,0 +1,52 @@
import { Injectable } from '@nestjs/common';
import { EntityManager } from 'typeorm';
import { WorkspaceDataSourceService } from 'src/workspace/workspace-datasource/workspace-datasource.service';
// TODO: Move outside of the messaging module
@Injectable()
export class CompanyService {
constructor(
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
) {}
public async getExistingCompaniesByDomainNames(
domainNames: string[],
workspaceId: string,
transactionManager?: EntityManager,
): Promise<{ id: string; domainName: string }[]> {
const dataSourceSchema =
this.workspaceDataSourceService.getSchemaName(workspaceId);
const existingCompanies =
await this.workspaceDataSourceService.executeRawQuery(
`SELECT id, "domainName" FROM ${dataSourceSchema}.company WHERE "domainName" = ANY($1)`,
[domainNames],
workspaceId,
transactionManager,
);
return existingCompanies;
}
public async createCompany(
id: string,
name: string,
domainName: string,
city: string,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<void> {
const dataSourceSchema =
this.workspaceDataSourceService.getSchemaName(workspaceId);
await this.workspaceDataSourceService.executeRawQuery(
`INSERT INTO ${dataSourceSchema}.company (id, name, "domainName", address)
VALUES ($1, $2, $3, $4)`,
[id, name, domainName, city],
workspaceId,
transactionManager,
);
}
}

View File

@ -44,6 +44,35 @@ export class MessageChannelService {
return messageChannels[0];
}
public async getIsContactAutoCreationEnabledByConnectedAccountIdOrFail(
connectedAccountId: string,
workspaceId: string,
): Promise<boolean> {
const messageChannel = await this.getFirstByConnectedAccountIdOrFail(
connectedAccountId,
workspaceId,
);
return messageChannel.isContactAutoCreationEnabled;
}
public async getIsContactAutoCreationEnabledByMessageChannelId(
messageChannelId: string,
workspaceId: string,
): Promise<boolean> {
const dataSourceSchema =
this.workspaceDataSourceService.getSchemaName(workspaceId);
const messageChannels =
await this.workspaceDataSourceService.executeRawQuery(
`SELECT * FROM ${dataSourceSchema}."messageChannel" WHERE "id" = $1 LIMIT 1`,
[messageChannelId],
workspaceId,
);
return messageChannels[0]?.isContactAutoCreationEnabled;
}
public async getByIds(
ids: string[],
workspaceId: string,

View File

@ -1,16 +1,11 @@
import { Module } from '@nestjs/common';
import { CreateCompanyModule } from 'src/workspace/messaging/services/create-company/create-company.module';
import { CreateContactModule } from 'src/workspace/messaging/services/create-contact/create-contact.module';
import { CreateCompaniesAndContactsModule } from 'src/workspace/messaging/services/create-companies-and-contacts/create-companies-and-contacts.module';
import { MessageParticipantService } from 'src/workspace/messaging/repositories/message-participant/message-participant.service';
import { WorkspaceDataSourceModule } from 'src/workspace/workspace-datasource/workspace-datasource.module';
@Module({
imports: [
WorkspaceDataSourceModule,
CreateContactModule,
CreateCompanyModule,
],
imports: [WorkspaceDataSourceModule, CreateCompaniesAndContactsModule],
providers: [MessageParticipantService],
exports: [MessageParticipantService],
})

View File

@ -5,17 +5,15 @@ import { EntityManager } from 'typeorm';
import { WorkspaceDataSourceService } from 'src/workspace/workspace-datasource/workspace-datasource.service';
import { MessageParticipantObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/message-participant.object-metadata';
import { ObjectRecord } from 'src/workspace/workspace-sync-metadata/types/object-record';
import { DataSourceEntity } from 'src/metadata/data-source/data-source.entity';
import { Participant } from 'src/workspace/messaging/types/gmail-message';
import { CreateContactService } from 'src/workspace/messaging/services/create-contact/create-contact.service';
import { CreateCompanyService } from 'src/workspace/messaging/services/create-company/create-company.service';
import {
ParticipantWithId,
Participant,
} from 'src/workspace/messaging/types/gmail-message';
@Injectable()
export class MessageParticipantService {
constructor(
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
private readonly createContactService: CreateContactService,
private readonly createCompaniesService: CreateCompanyService,
) {}
public async getByHandles(
@ -68,79 +66,71 @@ export class MessageParticipantService {
);
}
public async getByMessageChannelIdWithoutPersonIdAndWorkspaceMemberId(
messageChannelId: string,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<ParticipantWithId[]> {
if (!messageChannelId || !workspaceId) {
return [];
}
const dataSourceSchema =
this.workspaceDataSourceService.getSchemaName(workspaceId);
const messageParticipants: ParticipantWithId[] =
await this.workspaceDataSourceService.executeRawQuery(
`SELECT "messageParticipant".id,
"messageParticipant"."role",
"messageParticipant"."handle",
"messageParticipant"."displayName",
"messageParticipant"."personId",
"messageParticipant"."workspaceMemberId",
"messageParticipant"."messageId"
FROM ${dataSourceSchema}."messageParticipant" "messageParticipant"
LEFT JOIN ${dataSourceSchema}."message" ON "messageParticipant"."messageId" = ${dataSourceSchema}."message"."id"
LEFT JOIN ${dataSourceSchema}."messageChannelMessageAssociation" ON ${dataSourceSchema}."messageChannelMessageAssociation"."messageId" = ${dataSourceSchema}."message"."id"
WHERE ${dataSourceSchema}."messageChannelMessageAssociation"."messageChannelId" = $1
AND "messageParticipant"."personId" IS NULL
AND "messageParticipant"."workspaceMemberId" IS NULL`,
[messageChannelId],
workspaceId,
transactionManager,
);
return messageParticipants;
}
public async saveMessageParticipants(
participants: Participant[],
messageId: string,
dataSourceMetadata: DataSourceEntity,
manager: EntityManager,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<void> {
if (!participants) return;
const alreadyCreatedContacts = await manager.query(
`SELECT email FROM ${dataSourceMetadata.schema}."person" WHERE "email" = ANY($1)`,
[participants.map((participant) => participant.handle)],
);
const alreadyCreatedContactEmails: string[] = alreadyCreatedContacts?.map(
({ email }) => email,
);
const filteredParticipants = participants.filter(
(participant) =>
!alreadyCreatedContactEmails.includes(participant.handle) &&
participant.handle.includes('@'),
);
const filteredParticipantsWihCompanyDomainNames = filteredParticipants?.map(
(participant) => ({
handle: participant.handle,
displayName: participant.displayName,
companyDomainName: participant.handle
.split('@')?.[1]
.split('.')
.slice(-2)
.join('.')
.toLowerCase(),
}),
);
const domainNamesToCreate = filteredParticipantsWihCompanyDomainNames.map(
(participant) => participant.companyDomainName,
);
const companiesObject = await this.createCompaniesService.createCompanies(
domainNamesToCreate,
dataSourceMetadata,
manager,
);
const contactsToCreate = filteredParticipantsWihCompanyDomainNames.map(
(participant) => ({
handle: participant.handle,
displayName: participant.displayName,
companyId: companiesObject[participant.companyDomainName],
}),
);
await this.createContactService.createContacts(
contactsToCreate,
dataSourceMetadata,
manager,
);
const dataSourceSchema =
this.workspaceDataSourceService.getSchemaName(workspaceId);
const handles = participants.map((participant) => participant.handle);
const participantPersonIds = await manager.query(
`SELECT id, email FROM ${dataSourceMetadata.schema}."person" WHERE "email" = ANY($1)`,
[handles],
);
const participantPersonIds =
await this.workspaceDataSourceService.executeRawQuery(
`SELECT id, email FROM ${dataSourceSchema}."person" WHERE "email" = ANY($1)`,
[handles],
workspaceId,
transactionManager,
);
const participantWorkspaceMemberIds = await manager.query(
`SELECT "workspaceMember"."id", "connectedAccount"."handle" AS email FROM ${dataSourceMetadata.schema}."workspaceMember"
JOIN ${dataSourceMetadata.schema}."connectedAccount" ON ${dataSourceMetadata.schema}."workspaceMember"."id" = ${dataSourceMetadata.schema}."connectedAccount"."accountOwnerId"
WHERE ${dataSourceMetadata.schema}."connectedAccount"."handle" = ANY($1)`,
[handles],
);
const participantWorkspaceMemberIds =
await this.workspaceDataSourceService.executeRawQuery(
`SELECT "workspaceMember"."id", "connectedAccount"."handle" AS email FROM ${dataSourceSchema}."workspaceMember"
JOIN ${dataSourceSchema}."connectedAccount" ON ${dataSourceSchema}."workspaceMember"."id" = ${dataSourceSchema}."connectedAccount"."accountOwnerId"
WHERE ${dataSourceSchema}."connectedAccount"."handle" = ANY($1)`,
[handles],
workspaceId,
transactionManager,
);
const messageParticipantsToSave = participants.map((participant) => [
messageId,
@ -163,9 +153,54 @@ export class MessageParticipantService {
if (messageParticipantsToSave.length === 0) return;
await manager.query(
`INSERT INTO ${dataSourceMetadata.schema}."messageParticipant" ("messageId", "role", "handle", "displayName", "personId", "workspaceMemberId") VALUES ${valuesString}`,
await this.workspaceDataSourceService.executeRawQuery(
`INSERT INTO ${dataSourceSchema}."messageParticipant" ("messageId", "role", "handle", "displayName", "personId", "workspaceMemberId") VALUES ${valuesString}`,
messageParticipantsToSave.flat(),
workspaceId,
transactionManager,
);
}
public async updateMessageParticipantsAfterPeopleCreation(
participants: ParticipantWithId[],
workspaceId: string,
transactionManager?: EntityManager,
): Promise<void> {
if (!participants) return;
const dataSourceSchema =
this.workspaceDataSourceService.getSchemaName(workspaceId);
const handles = participants.map((participant) => participant.handle);
const participantPersonIds =
await this.workspaceDataSourceService.executeRawQuery(
`SELECT id, email FROM ${dataSourceSchema}."person" WHERE "email" = ANY($1)`,
[handles],
workspaceId,
transactionManager,
);
const messageParticipantsToUpdate = participants.map((participant) => [
participant.id,
participantPersonIds.find(
(e: { id: string; email: string }) => e.email === participant.handle,
)?.id,
]);
if (messageParticipantsToUpdate.length === 0) return;
const valuesString = messageParticipantsToUpdate
.map((_, index) => `($${index * 2 + 1}::uuid, $${index * 2 + 2}::uuid)`)
.join(', ');
await this.workspaceDataSourceService.executeRawQuery(
`UPDATE ${dataSourceSchema}."messageParticipant" AS "messageParticipant" SET "personId" = "data"."personId"
FROM (VALUES ${valuesString}) AS "data"("id", "personId")
WHERE "messageParticipant"."id" = "data"."id"`,
messageParticipantsToUpdate.flat(),
workspaceId,
transactionManager,
);
}
}

View File

@ -1,10 +1,12 @@
import { Module } from '@nestjs/common';
import { MessageChannelModule } from 'src/workspace/messaging/repositories/message-channel/message-channel.module';
import { MessageChannelMessageAssociationModule } from 'src/workspace/messaging/repositories/message-channel-message-association/message-channel-message-assocation.module';
import { MessageParticipantModule } from 'src/workspace/messaging/repositories/message-participant/message-participant.module';
import { MessageThreadModule } from 'src/workspace/messaging/repositories/message-thread/message-thread.module';
import { MessageService } from 'src/workspace/messaging/repositories/message/message.service';
import { WorkspaceDataSourceModule } from 'src/workspace/workspace-datasource/workspace-datasource.module';
import { CreateCompaniesAndContactsModule } from 'src/workspace/messaging/services/create-companies-and-contacts/create-companies-and-contacts.module';
@Module({
imports: [
@ -12,6 +14,8 @@ import { WorkspaceDataSourceModule } from 'src/workspace/workspace-datasource/wo
MessageThreadModule,
MessageParticipantModule,
MessageChannelMessageAssociationModule,
MessageChannelModule,
CreateCompaniesAndContactsModule,
],
providers: [MessageService],
exports: [MessageService],

View File

@ -9,10 +9,12 @@ import { ObjectRecord } from 'src/workspace/workspace-sync-metadata/types/object
import { DataSourceEntity } from 'src/metadata/data-source/data-source.entity';
import { GmailMessage } from 'src/workspace/messaging/types/gmail-message';
import { ConnectedAccountObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/connected-account.object-metadata';
import { MessageChannelService } from 'src/workspace/messaging/repositories/message-channel/message-channel.service';
import { MessageChannelMessageAssociationService } from 'src/workspace/messaging/repositories/message-channel-message-association/message-channel-message-association.service';
import { MessageParticipantService } from 'src/workspace/messaging/repositories/message-participant/message-participant.service';
import { MessageThreadService } from 'src/workspace/messaging/repositories/message-thread/message-thread.service';
import { isPersonEmail } from 'src/workspace/messaging/utils/is-person-email.util';
import { CreateCompaniesAndContactsService } from 'src/workspace/messaging/services/create-companies-and-contacts/create-companies-and-contacts.service';
@Injectable()
export class MessageService {
constructor(
@ -20,6 +22,8 @@ export class MessageService {
private readonly messageChannelMessageAssociationService: MessageChannelMessageAssociationService,
private readonly messageThreadService: MessageThreadService,
private readonly messageParticipantService: MessageParticipantService,
private readonly messageChannelService: MessageChannelService,
private readonly createCompaniesAndContactsService: CreateCompaniesAndContactsService,
) {}
public async getFirstByHeaderMessageId(
@ -193,11 +197,24 @@ export class MessageService {
],
);
const isContactAutoCreationEnabled =
await this.messageChannelService.getIsContactAutoCreationEnabledByConnectedAccountIdOrFail(
connectedAccount.id,
workspaceId,
);
if (isContactAutoCreationEnabled) {
await this.createCompaniesAndContactsService.createCompaniesAndContacts(
message.participants,
workspaceId,
manager,
);
}
await this.messageParticipantService.saveMessageParticipants(
message.participants,
newMessageId,
dataSourceMetadata,
manager,
workspaceId,
);
return Promise.resolve(newMessageId);

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PersonService } from 'src/workspace/messaging/repositories/person/person.service';
import { WorkspaceDataSourceModule } from 'src/workspace/workspace-datasource/workspace-datasource.module';
// TODO: Move outside of the messaging module
@Module({
imports: [WorkspaceDataSourceModule],
providers: [PersonService],
exports: [PersonService],
})
export class PersonModule {}

View File

@ -0,0 +1,52 @@
import { Injectable } from '@nestjs/common';
import { EntityManager } from 'typeorm';
import { WorkspaceDataSourceService } from 'src/workspace/workspace-datasource/workspace-datasource.service';
// TODO: Move outside of the messaging module
@Injectable()
export class PersonService {
constructor(
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
) {}
async createPeople(
peopleToCreate: {
id: string;
handle: string;
firstName: string;
lastName: string;
companyId: string;
}[],
workspaceId: string,
transactionManager?: EntityManager,
): Promise<void> {
const dataSourceSchema =
this.workspaceDataSourceService.getSchemaName(workspaceId);
const valuesString = peopleToCreate
.map(
(_, index) =>
`($${index * 5 + 1}, $${index * 5 + 2}, $${index * 5 + 3}, $${
index * 5 + 4
}, $${index * 5 + 5})`,
)
.join(', ');
return await this.workspaceDataSourceService.executeRawQuery(
`INSERT INTO ${dataSourceSchema}.person (id, email, "nameFirstName", "nameLastName", "companyId") VALUES ${valuesString}`,
peopleToCreate
.map((contact) => [
contact.id,
contact.handle,
contact.firstName,
contact.lastName,
contact.companyId,
])
.flat(),
workspaceId,
transactionManager,
);
}
}

View File

@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { CreateCompaniesAndContactsService } from 'src/workspace/messaging/services/create-companies-and-contacts/create-companies-and-contacts.service';
import { CreateCompanyModule } from 'src/workspace/messaging/services/create-company/create-company.module';
import { CreateContactModule } from 'src/workspace/messaging/services/create-contact/create-contact.module';
import { WorkspaceDataSourceModule } from 'src/workspace/workspace-datasource/workspace-datasource.module';
@Module({
imports: [
WorkspaceDataSourceModule,
CreateContactModule,
CreateCompanyModule,
],
providers: [CreateCompaniesAndContactsService],
exports: [CreateCompaniesAndContactsService],
})
export class CreateCompaniesAndContactsModule {}

View File

@ -0,0 +1,76 @@
import { Injectable } from '@nestjs/common';
import { EntityManager } from 'typeorm';
import { WorkspaceDataSourceService } from 'src/workspace/workspace-datasource/workspace-datasource.service';
import { Participant } from 'src/workspace/messaging/types/gmail-message';
import { getDomainNameFromHandle } from 'src/workspace/messaging/utils/get-domain-name-from-handle.util';
import { CreateCompanyService } from 'src/workspace/messaging/services/create-company/create-company.service';
import { CreateContactService } from 'src/workspace/messaging/services/create-contact/create-contact.service';
@Injectable()
export class CreateCompaniesAndContactsService {
constructor(
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
private readonly createContactService: CreateContactService,
private readonly createCompaniesService: CreateCompanyService,
) {}
async createCompaniesAndContacts(
participants: Participant[],
workspaceId: string,
transactionManager?: EntityManager,
) {
const dataSourceSchema =
this.workspaceDataSourceService.getSchemaName(workspaceId);
const alreadyCreatedContacts =
await this.workspaceDataSourceService.executeRawQuery(
`SELECT email FROM ${dataSourceSchema}."person" WHERE "email" = ANY($1)`,
[participants.map((participant) => participant.handle)],
workspaceId,
transactionManager,
);
const alreadyCreatedContactEmails: string[] = alreadyCreatedContacts?.map(
({ email }) => email,
);
const filteredParticipants = participants.filter(
(participant) =>
!alreadyCreatedContactEmails.includes(participant.handle) &&
participant.handle.includes('@'),
);
const filteredParticipantsWithCompanyDomainNames =
filteredParticipants?.map((participant) => ({
handle: participant.handle,
displayName: participant.displayName,
companyDomainName: getDomainNameFromHandle(participant.handle),
}));
const domainNamesToCreate = filteredParticipantsWithCompanyDomainNames.map(
(participant) => participant.companyDomainName,
);
const companiesObject = await this.createCompaniesService.createCompanies(
domainNamesToCreate,
workspaceId,
transactionManager,
);
const contactsToCreate = filteredParticipantsWithCompanyDomainNames.map(
(participant) => ({
handle: participant.handle,
displayName: participant.displayName,
companyId: companiesObject[participant.companyDomainName],
}),
);
await this.createContactService.createContacts(
contactsToCreate,
workspaceId,
transactionManager,
);
}
}

View File

@ -1,9 +1,11 @@
import { Module } from '@nestjs/common';
import { WorkspaceDataSourceModule } from 'src/workspace/workspace-datasource/workspace-datasource.module';
import { CreateCompanyService } from 'src/workspace/messaging/services/create-company/create-company.service';
import { CompanyModule } from 'src/workspace/messaging/repositories/company/company.module';
@Module({
imports: [],
imports: [WorkspaceDataSourceModule, CompanyModule],
providers: [CreateCompanyService],
exports: [CreateCompanyService],
})

View File

@ -1,16 +1,16 @@
import { Injectable } from '@nestjs/common';
import { EntityManager } from 'typeorm';
import axios, { AxiosInstance } from 'axios';
import { v4 } from 'uuid';
import axios, { AxiosInstance } from 'axios';
import { DataSourceEntity } from 'src/metadata/data-source/data-source.entity';
import { CompanyService } from 'src/workspace/messaging/repositories/company/company.service';
import { capitalize } from 'src/utils/capitalize';
@Injectable()
export class CreateCompanyService {
private readonly httpService: AxiosInstance;
constructor() {
constructor(private readonly companyService: CompanyService) {
this.httpService = axios.create({
baseURL: 'https://companies.twenty.com',
});
@ -18,17 +18,19 @@ export class CreateCompanyService {
async createCompanies(
domainNames: string[],
dataSourceMetadata: DataSourceEntity,
manager: EntityManager,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<{
[domainName: string]: string;
}> {
const uniqueDomainNames = [...new Set(domainNames)];
const existingCompanies = await manager.query(
`SELECT id, "domainName" FROM ${dataSourceMetadata.schema}.company WHERE "domainName" = ANY($1)`,
[uniqueDomainNames],
);
const existingCompanies =
await this.companyService.getExistingCompaniesByDomainNames(
uniqueDomainNames,
workspaceId,
transactionManager,
);
const companiesObject = existingCompanies.reduce(
(
@ -57,8 +59,8 @@ export class CreateCompanyService {
for (const domainName of filteredDomainNames) {
companiesObject[domainName] = await this.createCompany(
domainName,
dataSourceMetadata,
manager,
workspaceId,
transactionManager,
);
}
@ -67,17 +69,20 @@ export class CreateCompanyService {
async createCompany(
domainName: string,
dataSourceMetadata: DataSourceEntity,
manager: EntityManager,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<string> {
const companyId = v4();
const { name, city } = await this.getCompanyInfoFromDomainName(domainName);
await manager.query(
`INSERT INTO ${dataSourceMetadata.schema}.company (id, name, "domainName", address)
VALUES ($1, $2, $3, $4)`,
[companyId, name, domainName, city],
this.companyService.createCompany(
companyId,
name,
domainName,
city,
workspaceId,
transactionManager,
);
return companyId;

View File

@ -1,9 +1,11 @@
import { Module } from '@nestjs/common';
import { WorkspaceDataSourceModule } from 'src/workspace/workspace-datasource/workspace-datasource.module';
import { CreateContactService } from 'src/workspace/messaging/services/create-contact/create-contact.service';
import { PersonModule } from 'src/workspace/messaging/repositories/person/person.module';
@Module({
imports: [],
imports: [WorkspaceDataSourceModule, PersonModule],
providers: [CreateContactService],
exports: [CreateContactService],
})

View File

@ -3,8 +3,8 @@ import { Injectable } from '@nestjs/common';
import { EntityManager } from 'typeorm';
import { v4 } from 'uuid';
import { DataSourceEntity } from 'src/metadata/data-source/data-source.entity';
import { capitalize } from 'src/utils/capitalize';
import { PersonService } from 'src/workspace/messaging/repositories/person/person.service';
import { getFirstNameAndLastNameFromHandleAndDisplayName } from 'src/workspace/messaging/utils/get-first-name-and-last-name-from-handle-and-display-name.util';
type ContactToCreate = {
handle: string;
@ -22,67 +22,42 @@ type FormattedContactToCreate = {
@Injectable()
export class CreateContactService {
constructor() {}
constructor(private readonly personService: PersonService) {}
formatContacts(
public formatContacts(
contactsToCreate: ContactToCreate[],
): FormattedContactToCreate[] {
return contactsToCreate.map((contact) => {
const id = v4();
const { handle, displayName, companyId } = contact;
const contactFirstName = displayName.split(' ')[0];
const contactLastName = displayName.split(' ')[1];
const contactFullNameFromHandle = handle.split('@')[0];
const contactFirstNameFromHandle =
contactFullNameFromHandle.split('.')[0];
const contactLastNameFromHandle = contactFullNameFromHandle.split('.')[1];
const id = v4();
const { firstName, lastName } =
getFirstNameAndLastNameFromHandleAndDisplayName(handle, displayName);
return {
id,
handle,
firstName: capitalize(
contactFirstName || contactFirstNameFromHandle || '',
),
lastName: capitalize(
contactLastName || contactLastNameFromHandle || '',
),
firstName,
lastName,
companyId,
};
});
}
async createContacts(
public async createContacts(
contactsToCreate: ContactToCreate[],
dataSourceMetadata: DataSourceEntity,
manager: EntityManager,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<void> {
if (contactsToCreate.length === 0) return;
const formattedContacts = this.formatContacts(contactsToCreate);
const valuesString = formattedContacts
.map(
(_, index) =>
`($${index * 5 + 1}, $${index * 5 + 2}, $${index * 5 + 3}, $${
index * 5 + 4
}, $${index * 5 + 5})`,
)
.join(', ');
await manager.query(
`INSERT INTO ${dataSourceMetadata.schema}.person (id, email, "nameFirstName", "nameLastName", "companyId") VALUES ${valuesString}`,
formattedContacts
.map((contact) => [
contact.id,
contact.handle,
contact.firstName,
contact.lastName,
contact.companyId,
])
.flat(),
await this.personService.createPeople(
formattedContacts,
workspaceId,
transactionManager,
);
}
}

View File

@ -20,3 +20,7 @@ export type Participant = {
handle: string;
displayName: string;
};
export type ParticipantWithId = Participant & {
id: string;
};

View File

@ -0,0 +1,3 @@
export function getDomainNameFromHandle(handle: string): string {
return handle.split('@')?.[1].split('.').slice(-2).join('.').toLowerCase();
}

View File

@ -0,0 +1,18 @@
import { capitalize } from 'src/utils/capitalize';
export function getFirstNameAndLastNameFromHandleAndDisplayName(
handle: string,
displayName: string,
): { firstName: string; lastName: string } {
const firstName = displayName.split(' ')[0];
const lastName = displayName.split(' ')[1];
const contactFullNameFromHandle = handle.split('@')[0];
const firstNameFromHandle = contactFullNameFromHandle.split('.')[0];
const lastNameFromHandle = contactFullNameFromHandle.split('.')[1];
return {
firstName: capitalize(firstName || firstNameFromHandle || ''),
lastName: capitalize(lastName || lastNameFromHandle || ''),
};
}

View File

@ -71,6 +71,14 @@ export class MessageChannelObjectMetadata extends BaseObjectMetadata {
})
type: string;
@FieldMetadata({
type: FieldMetadataType.BOOLEAN,
label: 'Is Contact Auto Creation Enabled',
description: 'Is Contact Auto Creation Enabled',
icon: 'IconUserCircle',
})
isContactAutoCreationEnabled: boolean;
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'Message Channel Association',