Refactor connected account module (#6225)

- Refactor connected account module
- Move blocklist into it's own module
- Move contact-creation-manager into it's own module

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
bosiraphael
2024-07-12 20:15:33 +02:00
committed by GitHub
parent c8a889995f
commit 11da718482
53 changed files with 212 additions and 192 deletions

View File

@ -1,35 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CreateCompanyAndContactService } from 'src/modules/connected-account/auto-companies-and-contacts-creation/services/create-company-and-contact.service';
import { CreateCompanyModule } from 'src/modules/connected-account/auto-companies-and-contacts-creation/create-company/create-company.module';
import { CreateContactModule } from 'src/modules/connected-account/auto-companies-and-contacts-creation/create-contact/create-contact.module';
import { ObjectMetadataRepositoryModule } from 'src/engine/object-metadata-repository/object-metadata-repository.module';
import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { AutoCompaniesAndContactsCreationMessageChannelListener } from 'src/modules/connected-account/auto-companies-and-contacts-creation/listeners/auto-companies-and-contacts-creation-message-channel.listener';
import { AutoCompaniesAndContactsCreationCalendarChannelListener } from 'src/modules/connected-account/auto-companies-and-contacts-creation/listeners/auto-companies-and-contacts-creation-calendar-channel.listener';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
@Module({
imports: [
CreateContactModule,
CreateCompanyModule,
ObjectMetadataRepositoryModule.forFeature([
PersonWorkspaceEntity,
WorkspaceMemberWorkspaceEntity,
]),
WorkspaceDataSourceModule,
TypeOrmModule.forFeature([FeatureFlagEntity], 'core'),
TypeOrmModule.forFeature([ObjectMetadataEntity], 'metadata'),
],
providers: [
CreateCompanyAndContactService,
AutoCompaniesAndContactsCreationMessageChannelListener,
AutoCompaniesAndContactsCreationCalendarChannelListener,
],
exports: [CreateCompanyAndContactService],
})
export class AutoCompaniesAndContactsCreationModule {}

View File

@ -1,14 +0,0 @@
import { Module } from '@nestjs/common';
import { ObjectMetadataRepositoryModule } from 'src/engine/object-metadata-repository/object-metadata-repository.module';
import { CompanyWorkspaceEntity } from 'src/modules/company/standard-objects/company.workspace-entity';
import { CreateCompanyService } from 'src/modules/connected-account/auto-companies-and-contacts-creation/create-company/create-company.service';
@Module({
imports: [
ObjectMetadataRepositoryModule.forFeature([CompanyWorkspaceEntity]),
],
providers: [CreateCompanyService],
exports: [CreateCompanyService],
})
export class CreateCompanyModule {}

View File

@ -1,122 +0,0 @@
import { Injectable } from '@nestjs/common';
import { EntityManager } from 'typeorm';
import { v4 } from 'uuid';
import axios, { AxiosInstance } from 'axios';
import { CompanyRepository } from 'src/modules/company/repositories/company.repository';
import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator';
import { CompanyWorkspaceEntity } from 'src/modules/company/standard-objects/company.workspace-entity';
import { getCompanyNameFromDomainName } from 'src/modules/connected-account/auto-companies-and-contacts-creation/utils/get-company-name-from-domain-name.util';
@Injectable()
export class CreateCompanyService {
private readonly httpService: AxiosInstance;
constructor(
@InjectObjectMetadataRepository(CompanyWorkspaceEntity)
private readonly companyRepository: CompanyRepository,
) {
this.httpService = axios.create({
baseURL: 'https://companies.twenty.com',
});
}
async createCompanies(
domainNames: string[],
workspaceId: string,
transactionManager?: EntityManager,
): Promise<{
[domainName: string]: string;
}> {
if (domainNames.length === 0) {
return {};
}
const uniqueDomainNames = [...new Set(domainNames)];
const existingCompanies =
await this.companyRepository.getExistingCompaniesByDomainNames(
uniqueDomainNames,
workspaceId,
transactionManager,
);
const companiesObject = existingCompanies.reduce(
(
acc: {
[domainName: string]: string;
},
company: {
domainName: string;
id: string;
},
) => ({
...acc,
[company.domainName]: company.id,
}),
{},
);
const filteredDomainNames = uniqueDomainNames.filter(
(domainName) =>
!existingCompanies.some(
(company: { domainName: string }) =>
company.domainName === domainName,
),
);
for (const domainName of filteredDomainNames) {
companiesObject[domainName] = await this.createCompany(
domainName,
workspaceId,
transactionManager,
);
}
return companiesObject;
}
async createCompany(
domainName: string,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<string> {
const companyId = v4();
const { name, city } = await this.getCompanyInfoFromDomainName(domainName);
await this.companyRepository.createCompany(
workspaceId,
{
id: companyId,
domainName,
name,
city,
},
transactionManager,
);
return companyId;
}
async getCompanyInfoFromDomainName(domainName: string): Promise<{
name: string;
city: string;
}> {
try {
const response = await this.httpService.get(`/${domainName}`);
const data = response.data;
return {
name: data.name ?? getCompanyNameFromDomainName(domainName),
city: data.city,
};
} catch (e) {
return {
name: getCompanyNameFromDomainName(domainName),
city: '',
};
}
}
}

View File

@ -1,12 +0,0 @@
import { Module } from '@nestjs/common';
import { ObjectMetadataRepositoryModule } from 'src/engine/object-metadata-repository/object-metadata-repository.module';
import { CreateContactService } from 'src/modules/connected-account/auto-companies-and-contacts-creation/create-contact/create-contact.service';
import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
@Module({
imports: [ObjectMetadataRepositoryModule.forFeature([PersonWorkspaceEntity])],
providers: [CreateContactService],
exports: [CreateContactService],
})
export class CreateContactModule {}

View File

@ -1,68 +0,0 @@
import { Injectable } from '@nestjs/common';
import { EntityManager } from 'typeorm';
import { v4 } from 'uuid';
import { PersonRepository } from 'src/modules/person/repositories/person.repository';
import { getFirstNameAndLastNameFromHandleAndDisplayName } from 'src/modules/connected-account/auto-companies-and-contacts-creation/utils/get-first-name-and-last-name-from-handle-and-display-name.util';
import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator';
import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
type ContactToCreate = {
handle: string;
displayName: string;
companyId?: string;
};
type FormattedContactToCreate = {
id: string;
handle: string;
firstName: string;
lastName: string;
companyId?: string;
};
@Injectable()
export class CreateContactService {
constructor(
@InjectObjectMetadataRepository(PersonWorkspaceEntity)
private readonly personRepository: PersonRepository,
) {}
public formatContacts(
contactsToCreate: ContactToCreate[],
): FormattedContactToCreate[] {
return contactsToCreate.map((contact) => {
const id = v4();
const { handle, displayName, companyId } = contact;
const { firstName, lastName } =
getFirstNameAndLastNameFromHandleAndDisplayName(handle, displayName);
return {
id,
handle,
firstName,
lastName,
companyId,
};
});
}
public async createPeople(
contactsToCreate: ContactToCreate[],
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PersonWorkspaceEntity[]> {
if (contactsToCreate.length === 0) return [];
const formattedContacts = this.formatContacts(contactsToCreate);
return await this.personRepository.createPeople(
formattedContacts,
workspaceId,
transactionManager,
);
}
}

View File

@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { AutoCompaniesAndContactsCreationModule } from 'src/modules/connected-account/auto-companies-and-contacts-creation/auto-companies-and-contacts-creation.module';
import { CreateCompanyAndContactJob } from 'src/modules/connected-account/auto-companies-and-contacts-creation/jobs/create-company-and-contact.job';
@Module({
imports: [AutoCompaniesAndContactsCreationModule],
providers: [CreateCompanyAndContactJob],
})
export class AutoCompaniesAndContactsCreationJobModule {}

View File

@ -1,35 +0,0 @@
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 { CreateCompanyAndContactService } from 'src/modules/connected-account/auto-companies-and-contacts-creation/services/create-company-and-contact.service';
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
export type CreateCompanyAndContactJobData = {
workspaceId: string;
connectedAccount: ConnectedAccountWorkspaceEntity;
contactsToCreate: {
displayName: string;
handle: string;
}[];
};
@Processor(MessageQueue.contactCreationQueue)
export class CreateCompanyAndContactJob {
constructor(
private readonly createCompanyAndContactService: CreateCompanyAndContactService,
) {}
@Process(CreateCompanyAndContactJob.name)
async handle(data: CreateCompanyAndContactJobData): Promise<void> {
const { workspaceId, connectedAccount, contactsToCreate } = data;
await this.createCompanyAndContactService.createCompaniesAndContactsAndUpdateParticipants(
connectedAccount,
contactsToCreate.map((contact) => ({
handle: contact.handle,
displayName: contact.displayName,
})),
workspaceId,
);
}
}

View File

@ -1,42 +0,0 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { ObjectRecordUpdateEvent } from 'src/engine/integrations/event-emitter/types/object-record-update.event';
import { objectRecordChangedProperties } from 'src/engine/integrations/event-emitter/utils/object-record-changed-properties.util';
import { InjectMessageQueue } from 'src/engine/integrations/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/integrations/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/integrations/message-queue/services/message-queue.service';
import {
CalendarCreateCompanyAndContactAfterSyncJobData,
CalendarCreateCompanyAndContactAfterSyncJob,
} from 'src/modules/calendar/calendar-event-participant-manager/jobs/calendar-create-company-and-contact-after-sync.job';
import { MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
@Injectable()
export class AutoCompaniesAndContactsCreationCalendarChannelListener {
constructor(
@InjectMessageQueue(MessageQueue.calendarQueue)
private readonly messageQueueService: MessageQueueService,
) {}
@OnEvent('calendarChannel.updated')
async handleUpdatedEvent(
payload: ObjectRecordUpdateEvent<MessageChannelWorkspaceEntity>,
) {
if (
objectRecordChangedProperties(
payload.properties.before,
payload.properties.after,
).includes('isContactAutoCreationEnabled') &&
payload.properties.after.isContactAutoCreationEnabled
) {
await this.messageQueueService.add<CalendarCreateCompanyAndContactAfterSyncJobData>(
CalendarCreateCompanyAndContactAfterSyncJob.name,
{
workspaceId: payload.workspaceId,
calendarChannelId: payload.recordId,
},
);
}
}
}

View File

@ -1,42 +0,0 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { ObjectRecordUpdateEvent } from 'src/engine/integrations/event-emitter/types/object-record-update.event';
import { objectRecordChangedProperties } from 'src/engine/integrations/event-emitter/utils/object-record-changed-properties.util';
import { InjectMessageQueue } from 'src/engine/integrations/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/integrations/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/integrations/message-queue/services/message-queue.service';
import { MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
import {
MessagingCreateCompanyAndContactAfterSyncJobData,
MessagingCreateCompanyAndContactAfterSyncJob,
} from 'src/modules/messaging/message-participant-manager/jobs/messaging-create-company-and-contact-after-sync.job';
@Injectable()
export class AutoCompaniesAndContactsCreationMessageChannelListener {
constructor(
@InjectMessageQueue(MessageQueue.contactCreationQueue)
private readonly messageQueueService: MessageQueueService,
) {}
@OnEvent('messageChannel.updated')
async handleUpdatedEvent(
payload: ObjectRecordUpdateEvent<MessageChannelWorkspaceEntity>,
) {
if (
objectRecordChangedProperties(
payload.properties.before,
payload.properties.after,
).includes('isContactAutoCreationEnabled') &&
payload.properties.after.isContactAutoCreationEnabled
) {
await this.messageQueueService.add<MessagingCreateCompanyAndContactAfterSyncJobData>(
MessagingCreateCompanyAndContactAfterSyncJob.name,
{
workspaceId: payload.workspaceId,
messageChannelId: payload.recordId,
},
);
}
}
}

View File

@ -1,169 +0,0 @@
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectRepository } from '@nestjs/typeorm';
import chunk from 'lodash.chunk';
import compact from 'lodash.compact';
import { EntityManager, Repository } from 'typeorm';
import { ObjectRecordCreateEvent } from 'src/engine/integrations/event-emitter/types/object-record-create.event';
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 { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids';
import { CONTACTS_CREATION_BATCH_SIZE } from 'src/modules/connected-account/auto-companies-and-contacts-creation/constants/contacts-creation-batch-size.constant';
import { CreateCompanyService } from 'src/modules/connected-account/auto-companies-and-contacts-creation/create-company/create-company.service';
import { CreateContactService } from 'src/modules/connected-account/auto-companies-and-contacts-creation/create-contact/create-contact.service';
import { Contact } from 'src/modules/connected-account/auto-companies-and-contacts-creation/types/contact.type';
import { filterOutSelfAndContactsFromCompanyOrWorkspace } from 'src/modules/connected-account/auto-companies-and-contacts-creation/utils/filter-out-contacts-from-company-or-workspace.util';
import { getDomainNameFromHandle } from 'src/modules/connected-account/auto-companies-and-contacts-creation/utils/get-domain-name-from-handle.util';
import { getUniqueContactsAndHandles } from 'src/modules/connected-account/auto-companies-and-contacts-creation/utils/get-unique-contacts-and-handles.util';
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
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';
@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>,
) {}
private async createCompaniesAndPeople(
connectedAccount: ConnectedAccountWorkspaceEntity,
contactsToCreate: Contact[],
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PersonWorkspaceEntity[]> {
if (!contactsToCreate || contactsToCreate.length === 0) {
return [];
}
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 this.personRepository.getByEmails(
uniqueHandles,
workspaceId,
transactionManager,
);
const alreadyCreatedContactEmails: string[] = alreadyCreatedContacts?.map(
({ email }) => email,
);
const filteredContactsToCreate = uniqueContacts.filter(
(participant) =>
!alreadyCreatedContactEmails.includes(participant.handle) &&
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.map(
(participant) => participant.companyDomainName,
),
);
const companiesObject = await this.createCompaniesService.createCompanies(
domainNamesToCreate,
workspaceId,
transactionManager,
);
const formattedContactsToCreate =
filteredContactsToCreateWithCompanyDomainNames.map((contact) => ({
handle: contact.handle,
displayName: contact.displayName,
companyId:
contact.companyDomainName && contact.companyDomainName !== ''
? companiesObject[contact.companyDomainName]
: undefined,
}));
return await this.createContactService.createPeople(
formattedContactsToCreate,
workspaceId,
transactionManager,
);
}
async createCompaniesAndContactsAndUpdateParticipants(
connectedAccount: ConnectedAccountWorkspaceEntity,
contactsToCreate: Contact[],
workspaceId: string,
) {
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');
}
for (const contactsBatch of contactsBatches) {
const createdPeople = await this.createCompaniesAndPeople(
connectedAccount,
contactsBatch,
workspaceId,
);
for (const createdPerson of createdPeople) {
this.eventEmitter.emit('person.created', {
name: 'person.created',
workspaceId,
recordId: createdPerson.id,
objectMetadata,
properties: {
after: createdPerson,
},
} satisfies ObjectRecordCreateEvent<any>);
}
}
}
}

View File

@ -1,4 +0,0 @@
export type Contact = {
handle: string;
displayName: string;
};

View File

@ -1,32 +0,0 @@
import { Contact } from 'src/modules/connected-account/auto-companies-and-contacts-creation/types/contact.type';
import { getUniqueContactsAndHandles } from 'src/modules/connected-account/auto-companies-and-contacts-creation/utils/get-unique-contacts-and-handles.util';
describe('getUniqueContactsAndHandles', () => {
it('should return empty arrays when contacts is empty', () => {
const contacts: Contact[] = [];
const result = getUniqueContactsAndHandles(contacts);
expect(result.uniqueContacts).toEqual([]);
expect(result.uniqueHandles).toEqual([]);
});
it('should return unique contacts and handles', () => {
const contacts: Contact[] = [
{ handle: 'john@twenty.com', displayName: 'John Doe' },
{ handle: 'john@twenty.com', displayName: 'John Doe' },
{ handle: 'jane@twenty.com', displayName: 'Jane Smith' },
{ handle: 'jane@twenty.com', displayName: 'Jane Smith' },
{ handle: 'jane@twenty.com', displayName: 'Jane Smith' },
];
const result = getUniqueContactsAndHandles(contacts);
expect(result.uniqueContacts).toEqual([
{ handle: 'john@twenty.com', displayName: 'John Doe' },
{ handle: 'jane@twenty.com', displayName: 'Jane Smith' },
]);
expect(result.uniqueHandles).toEqual([
'john@twenty.com',
'jane@twenty.com',
]);
});
});

View File

@ -1,30 +0,0 @@
import { Contact } from 'src/modules/connected-account/auto-companies-and-contacts-creation/types/contact.type';
import { getDomainNameFromHandle } from 'src/modules/connected-account/auto-companies-and-contacts-creation/utils/get-domain-name-from-handle.util';
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
export function filterOutSelfAndContactsFromCompanyOrWorkspace(
contacts: Contact[],
connectedAccount: ConnectedAccountWorkspaceEntity,
workspaceMembers: WorkspaceMemberWorkspaceEntity[],
): Contact[] {
const selfDomainName = getDomainNameFromHandle(connectedAccount.handle);
const handleAliases = connectedAccount.handleAliases?.split(',') || [];
const workspaceMembersMap = workspaceMembers.reduce(
(map, workspaceMember) => {
map[workspaceMember.userEmail] = true;
return map;
},
new Map<string, boolean>(),
);
return contacts.filter(
(contact) =>
getDomainNameFromHandle(contact.handle) !== selfDomainName &&
!workspaceMembersMap[contact.handle] &&
!handleAliases.includes(contact.handle),
);
}

View File

@ -1,9 +0,0 @@
import psl from 'psl';
import { capitalize } from 'src/utils/capitalize';
export const getCompanyNameFromDomainName = (domainName: string) => {
const { sld } = psl.parse(domainName);
return sld ? capitalize(sld) : '';
};

View File

@ -1,9 +0,0 @@
import psl from 'psl';
export const getDomainNameFromHandle = (handle: string): string => {
const wholeDomain = handle?.split('@')?.[1] || '';
const { domain } = psl.parse(wholeDomain);
return domain || '';
};

View File

@ -1,18 +0,0 @@
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

@ -1,19 +0,0 @@
import uniq from 'lodash.uniq';
import uniqBy from 'lodash.uniqby';
import { Contact } from 'src/modules/connected-account/auto-companies-and-contacts-creation/types/contact.type';
export function getUniqueContactsAndHandles(contacts: Contact[]): {
uniqueContacts: Contact[];
uniqueHandles: string[];
} {
if (contacts.length === 0) {
return { uniqueContacts: [], uniqueHandles: [] };
}
const uniqueHandles = uniq(contacts.map((participant) => participant.handle));
const uniqueContacts = uniqBy(contacts, 'handle');
return { uniqueContacts, uniqueHandles };
}

View File

@ -1,29 +0,0 @@
import { WorkspaceQueryHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface';
import { CreateManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator';
import {
BlocklistItem,
BlocklistValidationService,
} from 'src/modules/connected-account/services/blocklist/blocklist-validation.service';
@WorkspaceQueryHook(`blocklist.createMany`)
export class BlocklistCreateManyPreQueryHook
implements WorkspaceQueryHookInstance
{
constructor(
private readonly blocklistValidationService: BlocklistValidationService,
) {}
async execute(
userId: string,
workspaceId: string,
payload: CreateManyResolverArgs<BlocklistItem>,
): Promise<void> {
await this.blocklistValidationService.validateBlocklistForCreateMany(
payload,
userId,
workspaceId,
);
}
}

View File

@ -1,16 +0,0 @@
import { MethodNotAllowedException } from '@nestjs/common';
import { WorkspaceQueryHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface';
import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator';
@WorkspaceQueryHook(`blocklist.updateMany`)
export class BlocklistUpdateManyPreQueryHook
implements WorkspaceQueryHookInstance
{
constructor() {}
async execute(): Promise<void> {
throw new MethodNotAllowedException('Method not allowed.');
}
}

View File

@ -1,29 +0,0 @@
import { WorkspaceQueryHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface';
import { UpdateOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator';
import {
BlocklistItem,
BlocklistValidationService,
} from 'src/modules/connected-account/services/blocklist/blocklist-validation.service';
@WorkspaceQueryHook(`blocklist.updateOne`)
export class BlocklistUpdateOnePreQueryHook
implements WorkspaceQueryHookInstance
{
constructor(
private readonly blocklistValidationService: BlocklistValidationService,
) {}
async execute(
userId: string,
workspaceId: string,
payload: UpdateOneResolverArgs<BlocklistItem>,
): Promise<void> {
await this.blocklistValidationService.validateBlocklistForUpdateOne(
payload,
userId,
workspaceId,
);
}
}

View File

@ -1,23 +1,11 @@
import { Module } from '@nestjs/common';
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
import { BlocklistCreateManyPreQueryHook } from 'src/modules/connected-account/query-hooks/blocklist/blocklist-create-many.pre-query.hook';
import { BlocklistUpdateManyPreQueryHook } from 'src/modules/connected-account/query-hooks/blocklist/blocklist-update-many.pre-query.hook';
import { BlocklistUpdateOnePreQueryHook } from 'src/modules/connected-account/query-hooks/blocklist/blocklist-update-one.pre-query.hook';
import { ConnectedAccountDeleteOnePreQueryHook } from 'src/modules/connected-account/query-hooks/connected-account/connected-account-delete-one.pre-query.hook';
import { BlocklistValidationModule } from 'src/modules/connected-account/services/blocklist/blocklist-validation.module';
import { ConnectedAccountDeleteOnePreQueryHook } from 'src/modules/connected-account/query-hooks/connected-account-delete-one.pre-query.hook';
import { MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
@Module({
imports: [
BlocklistValidationModule,
TwentyORMModule.forFeature([MessageChannelWorkspaceEntity]),
],
providers: [
BlocklistCreateManyPreQueryHook,
BlocklistUpdateManyPreQueryHook,
BlocklistUpdateOnePreQueryHook,
ConnectedAccountDeleteOnePreQueryHook,
],
imports: [TwentyORMModule.forFeature([MessageChannelWorkspaceEntity])],
providers: [ConnectedAccountDeleteOnePreQueryHook],
})
export class ConnectedAccountQueryHookModule {}

View File

@ -1,7 +1,7 @@
import { Module } from '@nestjs/common';
import { ObjectMetadataRepositoryModule } from 'src/engine/object-metadata-repository/object-metadata-repository.module';
import { GoogleAPIRefreshAccessTokenService } from 'src/modules/connected-account/services/google-api-refresh-access-token/google-api-refresh-access-token.service';
import { GoogleAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-access-token-manager/drivers/google/services/google-api-refresh-access-token.service';
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { MessagingCommonModule } from 'src/modules/messaging/common/messaging-common.module';

View File

@ -0,0 +1,29 @@
import { Injectable } from '@nestjs/common';
import axios from 'axios';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
@Injectable()
export class GoogleAPIRefreshAccessTokenService {
constructor(private readonly environmentService: EnvironmentService) {}
async refreshAccessToken(refreshToken: string): Promise<string> {
const response = await axios.post(
'https://oauth2.googleapis.com/token',
{
client_id: this.environmentService.get('AUTH_GOOGLE_CLIENT_ID'),
client_secret: this.environmentService.get('AUTH_GOOGLE_CLIENT_SECRET'),
refresh_token: refreshToken,
grant_type: 'refresh_token',
},
{
headers: {
'Content-Type': 'application/json',
},
},
);
return response.data.access_token;
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { GoogleAPIRefreshAccessTokenModule } from 'src/modules/connected-account/refresh-access-token-manager/drivers/google/google-api-refresh-access-token.module';
import { RefreshAccessTokenService } from 'src/modules/connected-account/refresh-access-token-manager/services/refresh-access-token.service';
@Module({
imports: [GoogleAPIRefreshAccessTokenModule],
providers: [RefreshAccessTokenService],
exports: [RefreshAccessTokenService],
})
export class RefreshAccessTokenManagerModule {}

View File

@ -1,16 +1,14 @@
import { Injectable } from '@nestjs/common';
import axios from 'axios';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator';
import { GoogleAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-access-token-manager/drivers/google/services/google-api-refresh-access-token.service';
import { ConnectedAccountRepository } from 'src/modules/connected-account/repositories/connected-account.repository';
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
@Injectable()
export class GoogleAPIRefreshAccessTokenService {
export class RefreshAccessTokenService {
constructor(
private readonly environmentService: EnvironmentService,
private readonly googleAPIRefreshAccessTokenService: GoogleAPIRefreshAccessTokenService,
@InjectObjectMetadataRepository(ConnectedAccountWorkspaceEntity)
private readonly connectedAccountRepository: ConnectedAccountRepository,
) {}
@ -26,7 +24,10 @@ export class GoogleAPIRefreshAccessTokenService {
`No refresh token found for connected account ${connectedAccount.id} in workspace ${workspaceId}`,
);
}
const accessToken = await this.refreshAccessToken(refreshToken);
const accessToken = await this.refreshAccessToken(
connectedAccount,
refreshToken,
);
await this.connectedAccountRepository.updateAccessToken(
accessToken,
@ -43,22 +44,19 @@ export class GoogleAPIRefreshAccessTokenService {
return accessToken;
}
async refreshAccessToken(refreshToken: string): Promise<string> {
const response = await axios.post(
'https://oauth2.googleapis.com/token',
{
client_id: this.environmentService.get('AUTH_GOOGLE_CLIENT_ID'),
client_secret: this.environmentService.get('AUTH_GOOGLE_CLIENT_SECRET'),
refresh_token: refreshToken,
grant_type: 'refresh_token',
},
{
headers: {
'Content-Type': 'application/json',
},
},
);
return response.data.access_token;
async refreshAccessToken(
connectedAccount: ConnectedAccountWorkspaceEntity,
refreshToken: string,
): Promise<string> {
switch (connectedAccount.provider) {
case 'google':
return this.googleAPIRefreshAccessTokenService.refreshAccessToken(
refreshToken,
);
default:
throw new Error(
`Provider ${connectedAccount.provider} is not supported.`,
);
}
}
}

View File

@ -1,69 +0,0 @@
import { Injectable } from '@nestjs/common';
import { EntityManager } from 'typeorm';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
import { BlocklistWorkspaceEntity } from 'src/modules/connected-account/standard-objects/blocklist.workspace-entity';
@Injectable()
export class BlocklistRepository {
constructor(
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
) {}
public async getById(
id: string,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<BlocklistWorkspaceEntity | null> {
const dataSourceSchema =
this.workspaceDataSourceService.getSchemaName(workspaceId);
const blocklistItems =
await this.workspaceDataSourceService.executeRawQuery(
`SELECT * FROM ${dataSourceSchema}."blocklist" WHERE "id" = $1`,
[id],
workspaceId,
transactionManager,
);
if (!blocklistItems || blocklistItems.length === 0) {
return null;
}
return blocklistItems[0];
}
public async getByWorkspaceMemberId(
workspaceMemberId: string,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<BlocklistWorkspaceEntity[]> {
const dataSourceSchema =
this.workspaceDataSourceService.getSchemaName(workspaceId);
return await this.workspaceDataSourceService.executeRawQuery(
`SELECT * FROM ${dataSourceSchema}."blocklist" WHERE "workspaceMemberId" = $1`,
[workspaceMemberId],
workspaceId,
transactionManager,
);
}
public async getByWorkspaceMemberIdAndHandle(
workspaceMemberId: string,
handle: string,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<BlocklistWorkspaceEntity[]> {
const dataSourceSchema =
this.workspaceDataSourceService.getSchemaName(workspaceId);
return await this.workspaceDataSourceService.executeRawQuery(
`SELECT * FROM ${dataSourceSchema}."blocklist" WHERE "workspaceMemberId" = $1 AND "handle" = $2`,
[workspaceMemberId, handle],
workspaceId,
transactionManager,
);
}
}

View File

@ -1,18 +0,0 @@
import { Module } from '@nestjs/common';
import { ObjectMetadataRepositoryModule } from 'src/engine/object-metadata-repository/object-metadata-repository.module';
import { BlocklistValidationService } from 'src/modules/connected-account/services/blocklist/blocklist-validation.service';
import { BlocklistWorkspaceEntity } from 'src/modules/connected-account/standard-objects/blocklist.workspace-entity';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@Module({
imports: [
ObjectMetadataRepositoryModule.forFeature([
BlocklistWorkspaceEntity,
WorkspaceMemberWorkspaceEntity,
]),
],
providers: [BlocklistValidationService],
exports: [BlocklistValidationService],
})
export class BlocklistValidationModule {}

View File

@ -1,146 +0,0 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { z } from 'zod';
import {
CreateManyResolverArgs,
UpdateOneResolverArgs,
} from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator';
import { isDomain } from 'src/engine/utils/is-domain';
import { BlocklistRepository } from 'src/modules/connected-account/repositories/blocklist.repository';
import { BlocklistWorkspaceEntity } from 'src/modules/connected-account/standard-objects/blocklist.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';
export type BlocklistItem = Omit<
BlocklistWorkspaceEntity,
'createdAt' | 'updatedAt' | 'workspaceMember'
> & {
createdAt: string;
updatedAt: string;
workspaceMemberId: string;
};
@Injectable()
export class BlocklistValidationService {
constructor(
@InjectObjectMetadataRepository(BlocklistWorkspaceEntity)
private readonly blocklistRepository: BlocklistRepository,
@InjectObjectMetadataRepository(WorkspaceMemberWorkspaceEntity)
private readonly workspaceMemberRepository: WorkspaceMemberRepository,
) {}
public async validateBlocklistForCreateMany(
payload: CreateManyResolverArgs<BlocklistItem>,
userId: string,
workspaceId: string,
) {
await this.validateSchema(payload.data);
await this.validateUniquenessForCreateMany(payload, userId, workspaceId);
}
public async validateBlocklistForUpdateOne(
payload: UpdateOneResolverArgs<BlocklistItem>,
userId: string,
workspaceId: string,
) {
if (payload.data.handle) {
await this.validateSchema([payload.data]);
}
await this.validateUniquenessForUpdateOne(payload, userId, workspaceId);
}
public async validateSchema(blocklist: BlocklistItem[]) {
const emailOrDomainSchema = z
.string()
.trim()
.email('Invalid email or domain')
.or(
z
.string()
.refine(
(value) => value.startsWith('@') && isDomain(value.slice(1)),
'Invalid email or domain',
),
);
for (const handle of blocklist.map((item) => item.handle)) {
if (!handle) {
throw new BadRequestException('Blocklist handle is required');
}
const result = emailOrDomainSchema.safeParse(handle);
if (!result.success) {
throw new BadRequestException(result.error.errors[0].message);
}
}
}
public async validateUniquenessForCreateMany(
payload: CreateManyResolverArgs<BlocklistItem>,
userId: string,
workspaceId: string,
) {
const currentWorkspaceMember =
await this.workspaceMemberRepository.getByIdOrFail(userId, workspaceId);
const currentBlocklist =
await this.blocklistRepository.getByWorkspaceMemberId(
currentWorkspaceMember.id,
workspaceId,
);
const currentBlocklistHandles = currentBlocklist.map(
(blocklist) => blocklist.handle,
);
if (
payload.data.some((item) => currentBlocklistHandles.includes(item.handle))
) {
throw new BadRequestException('Blocklist handle already exists');
}
}
public async validateUniquenessForUpdateOne(
payload: UpdateOneResolverArgs<BlocklistItem>,
userId: string,
workspaceId: string,
) {
const existingRecord = await this.blocklistRepository.getById(
payload.id,
workspaceId,
);
if (!existingRecord) {
throw new BadRequestException('Blocklist item not found');
}
if (existingRecord.workspaceMemberId !== payload.data.workspaceMemberId) {
throw new BadRequestException('Workspace member cannot be updated');
}
if (existingRecord.handle === payload.data.handle) {
return;
}
const currentWorkspaceMember =
await this.workspaceMemberRepository.getByIdOrFail(userId, workspaceId);
const currentBlocklist =
await this.blocklistRepository.getByWorkspaceMemberId(
currentWorkspaceMember.id,
workspaceId,
);
const currentBlocklistHandles = currentBlocklist
.filter((blocklist) => blocklist.id !== payload.id)
.map((blocklist) => blocklist.handle);
if (currentBlocklistHandles.includes(payload.data.handle)) {
throw new BadRequestException('Blocklist handle already exists');
}
}
}

View File

@ -1,49 +0,0 @@
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface';
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { BLOCKLIST_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 { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator';
import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator';
import { WorkspaceIsNotAuditLogged } from 'src/engine/twenty-orm/decorators/workspace-is-not-audit-logged.decorator';
import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator';
import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-relation.decorator';
import { RelationMetadataType } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity';
import { WorkspaceJoinColumn } from 'src/engine/twenty-orm/decorators/workspace-join-column.decorator';
@WorkspaceEntity({
standardId: STANDARD_OBJECT_IDS.blocklist,
namePlural: 'blocklists',
labelSingular: 'Blocklist',
labelPlural: 'Blocklists',
description: 'Blocklist',
icon: 'IconForbid2',
})
@WorkspaceIsSystem()
@WorkspaceIsNotAuditLogged()
export class BlocklistWorkspaceEntity extends BaseWorkspaceEntity {
@WorkspaceField({
standardId: BLOCKLIST_STANDARD_FIELD_IDS.handle,
type: FieldMetadataType.TEXT,
label: 'Handle',
description: 'Handle',
icon: 'IconAt',
})
handle: string;
@WorkspaceRelation({
standardId: BLOCKLIST_STANDARD_FIELD_IDS.workspaceMember,
type: RelationMetadataType.MANY_TO_ONE,
label: 'WorkspaceMember',
description: 'WorkspaceMember',
icon: 'IconCircleUser',
inverseSideTarget: () => WorkspaceMemberWorkspaceEntity,
inverseSideFieldKey: 'blocklist',
})
workspaceMember: Relation<WorkspaceMemberWorkspaceEntity>;
@WorkspaceJoinColumn('workspaceMember')
workspaceMemberId: string;
}