diff --git a/.env.example b/.env.example index 284837894..62442e99d 100644 --- a/.env.example +++ b/.env.example @@ -54,6 +54,9 @@ FRESHSALES_CRM_CLOUD_CLIENT_SECRET= # Attio ATTIO_CRM_CLOUD_CLIENT_ID= ATTIO_CRM_CLOUD_CLIENT_SECRET= +#close +CLOSE_CRM_CLOUD_CLIENT_ID= +CLOSE_CRM_CLOUD_CLIENT_SECRET= # ================================================ # Ticketing # ================================================ diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 1c88a0a7c..a7f752731 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -188,22 +188,22 @@ services: volumes: - .:/app - ngrok: - image: ngrok/ngrok:latest - restart: always - command: - - "start" - - "--all" - - "--config" - - "/etc/ngrok.yml" - volumes: - - ./ngrok.yml:/etc/ngrok.yml - ports: - - 4040:4040 - depends_on: - api: - condition: service_healthy - network_mode: "host" + #ngrok: + #image: ngrok/ngrok:latest + #restart: always + #command: + # - "start" + # - "--all" + # - "--config" + # - "/etc/ngrok.yml" + #volumes: + # - ./ngrok.yml:/etc/ngrok.yml + #ports: + # - 4040:4040 + #epends_on: + # api: + # condition: service_healthy + #network_mode: "host" docs: build: diff --git a/packages/api/prisma/schema.prisma b/packages/api/prisma/schema.prisma index b65b22a22..081b202d8 100644 --- a/packages/api/prisma/schema.prisma +++ b/packages/api/prisma/schema.prisma @@ -436,9 +436,12 @@ model projects { id_connector_set String @db.Uuid api_keys api_keys[] connections connections[] + fs_folders fs_folders[] linked_users linked_users[] users users @relation(fields: [id_user], references: [id_user], onDelete: NoAction, onUpdate: NoAction, map: "fk_46_1") connector_sets connector_sets @relation(fields: [id_connector_set], references: [id_connector_set], onDelete: NoAction, onUpdate: NoAction, map: "fk_project_connectorsetid") + + @@index([id_connector_set], map: "fk_connectors_sets") } /// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments @@ -647,6 +650,8 @@ model connector_sets { crm_hubspot Boolean crm_zoho Boolean crm_attio Boolean + crm_close Boolean + crm_zendesk Boolean crm_pipedrive Boolean tcg_zendesk Boolean tcg_jira Boolean @@ -668,3 +673,64 @@ model managed_webhooks { modified_at DateTime @db.Timestamp(6) created_at DateTime @db.Timestamp(6) } + +model fs_drives { + id_fs_drive String @id(map: "pk_fs_drives") @db.Uuid + remote_created_at DateTime? @db.Timestamp(6) + drive_url String? + created_at DateTime @db.Timestamp(6) + modified_at DateTime @db.Timestamp(6) + remote_id String? +} + +model fs_files { + id_fs_file String @id(map: "pk_fs_files") @db.Uuid + name String? + type String? + path String? + mime_type String? + size BigInt? + remote_id String? + id_fs_folder String? @db.Uuid + created_at DateTime @db.Timestamp(6) + modified_at DateTime @db.Timestamp(6) + id_fs_permission String @db.Uuid + + @@index([id_fs_folder], map: "fk_fs_file_folderid") + @@index([id_fs_permission], map: "fk_fs_file_permissionid") +} + +model fs_folders { + id_project_connector String @id(map: "pk_project_connectors") @db.Uuid + id_project String @db.Uuid + crm_hubspot Boolean + crm_zoho Boolean + crm_zendesk Boolean + crm_pipedrive Boolean + crm_attio Boolean + crm_close Boolean + tcg_zendesk Boolean + tcg_gorgias Boolean + tcg_front Boolean + tcg_jira Boolean + tcg_gitlab Boolean + projects projects @relation(fields: [id_project], references: [id_project], onDelete: NoAction, onUpdate: NoAction, map: "fk_project_connectors") +} + +/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments +model fs_permissions { + id_fs_permission String @id(map: "pk_fs_permissions") @db.Uuid + remote_id String? + created_at DateTime @db.Timestamp(6) + modified_at DateTime @db.Timestamp(6) + user String @db.Uuid + group String @db.Uuid + type String[] + roles String[] +} + +model fs_shared_links { + id_fs_shared_link String @id(map: "pk_fs_shared_links") @db.Uuid + created_at DateTime @db.Timestamp(6) + modified_at DateTime @db.Timestamp(6) +} diff --git a/packages/api/scripts/init.sql b/packages/api/scripts/init.sql index 023530d1e..4dc3a67b1 100644 --- a/packages/api/scripts/init.sql +++ b/packages/api/scripts/init.sql @@ -399,6 +399,8 @@ CREATE TABLE connector_sets crm_hubspot boolean NOT NULL, crm_zoho boolean NOT NULL, crm_attio boolean NOT NULL, + crm_close boolean NOT NULL, + crm_zendesk boolean NOT NULL, crm_pipedrive boolean NOT NULL, tcg_zendesk boolean NOT NULL, tcg_jira boolean NOT NULL, @@ -542,27 +544,21 @@ ex 3600 for one hour'; CREATE TABLE fs_folders ( - id_fs_folder uuid NOT NULL, - folder_url text NULL, - "size" bigint NULL, - description text NULL, - parent_folder uuid NULL, - remote_id text NULL, - created_at timestamp NOT NULL, - modified_at timestamp NOT NULL, - id_fs_drive uuid NULL, - id_fs_permission uuid NOT NULL, - CONSTRAINT PK_fs_folders PRIMARY KEY ( id_fs_folder ) -); - -CREATE INDEX FK_fs_folder_driveID ON fs_folders -( - id_fs_drive -); - -CREATE INDEX FK_fs_folder_permissionID ON fs_folders -( - id_fs_permission + id_project_connector uuid NOT NULL, + id_project uuid NOT NULL, + crm_hubspot boolean NOT NULL, + crm_zoho boolean NOT NULL, + crm_zendesk boolean NOT NULL, + crm_pipedrive boolean NOT NULL, + crm_attio boolean NOT NULL, + crm_close boolean NOT NULL, + tcg_zendesk boolean NOT NULL, + tcg_gorgias boolean NOT NULL, + tcg_front boolean NOT NULL, + tcg_jira boolean NOT NULL, + tcg_gitlab boolean NOT NULL, + CONSTRAINT PK_project_connectors PRIMARY KEY ( id_project_connector ), + CONSTRAINT FK_project_connectors FOREIGN KEY ( id_project ) REFERENCES projects ( id_project ) ); diff --git a/packages/api/scripts/seed.sql b/packages/api/scripts/seed.sql index bc95a73fc..d09768dec 100644 --- a/packages/api/scripts/seed.sql +++ b/packages/api/scripts/seed.sql @@ -1,10 +1,10 @@ INSERT INTO users (id_user, identification_strategy, email, password_hash, first_name, last_name) VALUES ('0ce39030-2901-4c56-8db0-5e326182ec6b', 'b2c','local@panora.dev', '$2b$10$Y7Q8TWGyGuc5ecdIASbBsuXMo3q/Rs3/cnY.mLZP4tUgfGUOCUBlG', 'local', 'Panora'); -INSERT INTO connector_sets (id_connector_set, crm_hubspot, crm_zoho, crm_pipedrive, crm_attio, tcg_zendesk, tcg_gorgias, tcg_front, tcg_jira, tcg_gitlab) VALUES - ('1709da40-17f7-4d3a-93a0-96dc5da6ddd7', TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE), - ('852dfff8-ab63-4530-ae49-e4b2924407f8', TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE), - ('aed0f856-f802-4a79-8640-66d441581a99', TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE); +INSERT INTO connector_sets (id_connector_set, crm_hubspot, crm_zoho, crm_pipedrive, crm_attio, crm_zendesk, crm_close, tcg_zendesk, tcg_gorgias, tcg_front, tcg_jira, tcg_gitlab) VALUES + ('852dfff8-ab63-4530-ae49-e4b2924407f8', TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE), + ('aed0f856-f802-4a79-8640-66d441581a99', TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE), + ('1709da40-17f7-4d3a-93a0-96dc5da6ddd7', TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE); INSERT INTO projects (id_project, name, sync_mode, id_user, id_connector_set) VALUES ('1e468c15-aa57-4448-aa2b-7fed640d1e3d', 'Project 1', 'pool', '0ce39030-2901-4c56-8db0-5e326182ec6b', '1709da40-17f7-4d3a-93a0-96dc5da6ddd7'), diff --git a/packages/api/src/@core/connections/crm/crm.connection.module.ts b/packages/api/src/@core/connections/crm/crm.connection.module.ts index 9dc79933d..42d8b537c 100644 --- a/packages/api/src/@core/connections/crm/crm.connection.module.ts +++ b/packages/api/src/@core/connections/crm/crm.connection.module.ts @@ -13,6 +13,7 @@ import { ZendeskConnectionService } from './services/zendesk/zendesk.service'; import { PipedriveConnectionService } from './services/pipedrive/pipedrive.service'; import { AttioConnectionService } from './services/attio/attio.service'; import { ConnectionsStrategiesService } from '@@core/connections-strategies/connections-strategies.service'; +import { CloseConnectionService } from './services/close/close.service'; @Module({ imports: [WebhookModule], @@ -31,6 +32,7 @@ import { ConnectionsStrategiesService } from '@@core/connections-strategies/conn ZohoConnectionService, ZendeskConnectionService, PipedriveConnectionService, + CloseConnectionService, ], exports: [CrmConnectionsService], }) diff --git a/packages/api/src/@core/connections/crm/services/close/close.service.ts b/packages/api/src/@core/connections/crm/services/close/close.service.ts index 89469aebf..9b8b139ee 100644 --- a/packages/api/src/@core/connections/crm/services/close/close.service.ts +++ b/packages/api/src/@core/connections/crm/services/close/close.service.ts @@ -85,10 +85,7 @@ export class CloseConnectionService implements ICrmConnectionService { }, ); const data: CloseOAuthResponse = res.data; - this.logger.log( - 'OAuth credentials : close ticketing ' + JSON.stringify(data), - ); - + this.logger.log('OAuth credentials : close CRM ' + JSON.stringify(data)); let db_res; const connection_token = uuidv4(); @@ -100,7 +97,7 @@ export class CloseConnectionService implements ICrmConnectionService { data: { access_token: this.cryptoService.encrypt(data.access_token), refresh_token: this.cryptoService.encrypt(data.refresh_token), - account_url: CONNECTORS_METADATA['crm']['close'].urls.apiUrl, + account_url: CONNECTORS_METADATA['crm']['close']?.urls?.apiUrl, expiration_timestamp: new Date( new Date().getTime() + Number(data.expires_in) * 1000, ), @@ -116,7 +113,7 @@ export class CloseConnectionService implements ICrmConnectionService { provider_slug: 'close', vertical: 'crm', token_type: 'oauth', - account_url: CONNECTORS_METADATA['crm']['close'].urls.apiUrl, + account_url: CONNECTORS_METADATA['crm']?.close?.urls?.apiUrl, access_token: this.cryptoService.encrypt(data.access_token), refresh_token: this.cryptoService.encrypt(data.refresh_token), expiration_timestamp: new Date( @@ -153,10 +150,10 @@ export class CloseConnectionService implements ICrmConnectionService { )) as OAuth2AuthData; const formData = new URLSearchParams({ - grant_type: 'refresh_token', refresh_token: this.cryptoService.decrypt(refreshToken), client_id: CREDENTIALS.CLIENT_ID, client_secret: CREDENTIALS.CLIENT_SECRET, + grant_type: 'refresh_token', }); const res = await axios.post( 'https://api.close.com/oauth2/token', @@ -168,18 +165,21 @@ export class CloseConnectionService implements ICrmConnectionService { }, ); const data: CloseOAuthResponse = res.data; - await this.prisma.connections.update({ - where: { - id_connection: connectionId, - }, - data: { - access_token: this.cryptoService.encrypt(data.access_token), - refresh_token: this.cryptoService.encrypt(data.refresh_token), - expiration_timestamp: new Date( - new Date().getTime() + Number(data.expires_in) * 1000, - ), - }, - }); + if (res?.data?.access_token) { + //only update when it is successful + await this.prisma.connections.update({ + where: { + id_connection: connectionId, + }, + data: { + access_token: this.cryptoService.encrypt(data?.access_token), + refresh_token: this.cryptoService.encrypt(data?.refresh_token), + expiration_timestamp: new Date( + new Date().getTime() + Number(data?.expires_in) * 1000, + ), + }, + }); + } this.logger.log('OAuth credentials updated : close '); } catch (error) { handleServiceError(error, this.logger, 'close', Action.oauthRefresh); diff --git a/packages/api/src/@core/project-connectors/project-connectors.controller.ts b/packages/api/src/@core/project-connectors/project-connectors.controller.ts index 033b48441..748c92610 100644 --- a/packages/api/src/@core/project-connectors/project-connectors.controller.ts +++ b/packages/api/src/@core/project-connectors/project-connectors.controller.ts @@ -30,6 +30,7 @@ export interface TypeCustom { tcg_front: boolean; tcg_jira: boolean; tcg_gitlab: boolean; + crm_close: boolean; } @ApiTags('project-connectors') @Controller('project-connectors') diff --git a/packages/api/src/@core/project-connectors/project-connectors.service.ts b/packages/api/src/@core/project-connectors/project-connectors.service.ts index 067d9a892..394c03fca 100644 --- a/packages/api/src/@core/project-connectors/project-connectors.service.ts +++ b/packages/api/src/@core/project-connectors/project-connectors.service.ts @@ -67,6 +67,7 @@ export class ProjectConnectorsService { tcg_front: data.tcg_front, tcg_jira: data.tcg_jira, tcg_gitlab: data.tcg_gitlab, + crm_close: data.crm_close, }; const res = await this.prisma.connector_sets.create({ diff --git a/packages/api/src/@core/utils/types/original/original.crm.ts b/packages/api/src/@core/utils/types/original/original.crm.ts index fee9b6d29..3f06b1c10 100644 --- a/packages/api/src/@core/utils/types/original/original.crm.ts +++ b/packages/api/src/@core/utils/types/original/original.crm.ts @@ -3,6 +3,7 @@ import { HubspotCompanyOutput } from '@crm/company/services/hubspot/types'; import { PipedriveCompanyOutput } from '@crm/company/services/pipedrive/types'; import { ZendeskCompanyOutput } from '@crm/company/services/zendesk/types'; import { ZohoCompanyOutput } from '@crm/company/services/zoho/types'; +import { CloseCompanyOutput } from '@crm/company/services/close/types'; import { AttioContactInput, AttioContactOutput, @@ -19,10 +20,15 @@ import { ZohoContactInput, ZohoContactOutput, } from '@crm/contact/services/zoho/types'; +import { + CloseContactInput, + CloseContactOutput, +} from '@crm/contact/services/close/types'; import { HubspotDealOutput } from '@crm/deal/services/hubspot/types'; import { PipedriveDealOutput } from '@crm/deal/services/pipedrive/types'; import { ZendeskDealOutput } from '@crm/deal/services/zendesk/types'; import { ZohoDealOutput } from '@crm/deal/services/zoho/types'; +import { CloseDealOutput } from '@crm/deal/services/close/types'; import { HubspotEngagementInput, HubspotEngagementOutput, @@ -39,6 +45,10 @@ import { ZohoEngagementInput, ZohoEngagementOutput, } from '@crm/engagement/services/zoho/types'; +import { + CloseEngagementInput, + CloseEngagementOutput, +} from '@crm/engagement/services/close/types'; import { HubspotNoteInput, HubspotNoteOutput, @@ -52,6 +62,10 @@ import { ZendeskNoteOutput, } from '@crm/note/services/zendesk/types'; import { ZohoNoteInput, ZohoNoteOutput } from '@crm/note/services/zoho/types'; +import { + CloseNoteInput, + CloseNoteOutput, +} from '@crm/note/services/close/types'; import { HubspotStageInput, HubspotStageOutput, @@ -68,6 +82,10 @@ import { ZohoStageInput, ZohoStageOutput, } from '@crm/stage/services/zoho/types'; +import { + CloseStageInput, + CloseStageOutput, +} from '@crm/stage/services/close/types'; import { HubspotTaskInput, HubspotTaskOutput, @@ -81,6 +99,10 @@ import { ZendeskTaskOutput, } from '@crm/task/services/zendesk/types'; import { ZohoTaskInput, ZohoTaskOutput } from '@crm/task/services/zoho/types'; +import { + CloseTaskInput, + CloseTaskOutput, +} from '@crm/task/services/close/types'; import { HubspotUserInput, HubspotUserOutput, @@ -90,6 +112,10 @@ import { PipedriveUserOutput, } from '@crm/user/services/pipedrive/types'; import { ZohoUserInput, ZohoUserOutput } from '@crm/user/services/zoho/types'; +import { + CloseUserInput, + CloseUserOutput, +} from '@crm/user/services/close/types'; import { ZendeskContactInput, ZendeskContactOutput, @@ -107,14 +133,16 @@ export type OriginalContactInput = | ZohoContactInput | ZendeskContactInput | PipedriveContactInput - | AttioContactInput; + | AttioContactInput + | CloseContactInput; /* deal */ export type OriginalDealInput = | HubspotDealOutput | ZohoDealOutput | ZendeskDealOutput - | PipedriveDealOutput; + | PipedriveDealOutput + | CloseDealOutput; /* company */ export type OriginalCompanyInput = @@ -122,35 +150,40 @@ export type OriginalCompanyInput = | ZohoCompanyOutput | ZendeskCompanyOutput | PipedriveCompanyOutput - | AttioCompanyOutput; + | AttioCompanyOutput + | CloseCompanyOutput; /* engagement */ export type OriginalEngagementInput = | HubspotEngagementInput | ZohoEngagementInput | ZendeskEngagementInput - | PipedriveEngagementInput; + | PipedriveEngagementInput + | CloseEngagementInput; /* note */ export type OriginalNoteInput = | HubspotNoteInput | ZohoNoteInput | ZendeskNoteInput - | PipedriveNoteInput; + | PipedriveNoteInput + | CloseNoteInput; /* task */ export type OriginalTaskInput = | HubspotTaskInput | ZohoTaskInput | ZendeskTaskInput - | PipedriveTaskInput; + | PipedriveTaskInput + | CloseTaskInput; /* stage */ export type OriginalStageInput = | HubspotStageInput | ZohoStageInput | ZendeskStageInput - | PipedriveStageInput; + | PipedriveStageInput + | CloseStageInput; /* engagementType */ @@ -159,7 +192,8 @@ export type OriginalUserInput = | HubspotUserInput | ZohoUserInput | ZendeskUserInput - | PipedriveUserInput; + | PipedriveUserInput + | CloseUserOutput; export type CrmObjectInput = | OriginalContactInput @@ -178,14 +212,16 @@ export type OriginalContactOutput = | ZohoContactOutput | ZendeskContactOutput | PipedriveContactOutput - | AttioContactOutput; + | AttioContactOutput + | CloseContactOutput; /* deal */ export type OriginalDealOutput = | HubspotDealOutput | ZohoDealOutput | ZendeskDealOutput - | PipedriveDealOutput; + | PipedriveDealOutput + | CloseDealOutput; /* company */ export type OriginalCompanyOutput = @@ -193,35 +229,40 @@ export type OriginalCompanyOutput = | ZohoCompanyOutput | ZendeskCompanyOutput | PipedriveCompanyOutput - | AttioCompanyOutput; + | AttioCompanyOutput + | CloseCompanyOutput; /* engagement */ export type OriginalEngagementOutput = | HubspotEngagementOutput | ZohoEngagementOutput | ZendeskEngagementOutput - | PipedriveEngagementOutput; + | PipedriveEngagementOutput + | CloseEngagementOutput; /* note */ export type OriginalNoteOutput = | HubspotNoteOutput | ZohoNoteOutput | ZendeskNoteOutput - | PipedriveNoteOutput; + | PipedriveNoteOutput + | CloseNoteOutput; /* task */ export type OriginalTaskOutput = | HubspotTaskOutput | ZohoTaskOutput | ZendeskTaskOutput - | PipedriveTaskOutput; + | PipedriveTaskOutput + | CloseTaskOutput; /* stage */ export type OriginalStageOutput = | HubspotStageOutput | ZohoStageOutput | ZendeskStageOutput - | PipedriveStageOutput; + | PipedriveStageOutput + | CloseStageOutput; /* engagementType */ @@ -230,7 +271,8 @@ export type OriginalUserOutput = | HubspotUserOutput | ZohoUserOutput | ZendeskUserOutput - | PipedriveUserOutput; + | PipedriveUserOutput + | CloseUserInput; export type CrmObjectOutput = | OriginalContactOutput diff --git a/packages/api/src/crm/company/company.module.ts b/packages/api/src/crm/company/company.module.ts index 3c6450280..141521f89 100644 --- a/packages/api/src/crm/company/company.module.ts +++ b/packages/api/src/crm/company/company.module.ts @@ -14,6 +14,7 @@ import { PipedriveService } from './services/pipedrive'; import { ZendeskService } from './services/zendesk'; import { ZohoService } from './services/zoho'; import { AttioService } from './services/attio'; +import { CloseService } from './services/close'; @Module({ imports: [ @@ -38,6 +39,7 @@ import { AttioService } from './services/attio'; PipedriveService, HubspotService, AttioService, + CloseService, ], exports: [ SyncService, diff --git a/packages/api/src/crm/company/services/close/index.ts b/packages/api/src/crm/company/services/close/index.ts new file mode 100644 index 000000000..2f9b0b23f --- /dev/null +++ b/packages/api/src/crm/company/services/close/index.ts @@ -0,0 +1,117 @@ +import { Injectable } from '@nestjs/common'; +import { ICompanyService } from '@crm/company/types'; +import { CrmObject } from '@crm/@lib/@types'; +import axios from 'axios'; +import { PrismaService } from '@@core/prisma/prisma.service'; +import { LoggerService } from '@@core/logger/logger.service'; +import { ActionType, handleServiceError } from '@@core/utils/errors'; +import { EncryptionService } from '@@core/encryption/encryption.service'; +import { ApiResponse } from '@@core/utils/types'; +import { ServiceRegistry } from '../registry.service'; +import { + commonCompanyCloseProperties, + CloseCompanyInput, + CloseCompanyOutput, +} from './types'; + +@Injectable() +export class CloseService implements ICompanyService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + CrmObject.company.toUpperCase() + ':' + CloseService.name, + ); + this.registry.registerService('close', this); + } + async addCompany( + companyData: CloseCompanyInput, + linkedUserId: string, + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'close', + vertical: 'crm', + }, + }); + const resp = await axios.post( + `${connection.account_url}/lead/`, + JSON.stringify(companyData), + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }, + ); + return { + data: resp?.data, + message: 'Close company created', + statusCode: 201, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Close', + CrmObject.company, + ActionType.POST, + ); + } + } + + async syncCompanies( + linkedUserId: string, + custom_properties?: string[], + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'close', + vertical: 'crm', + }, + }); + + const commonPropertyNames = Object.keys(commonCompanyCloseProperties); + const allProperties = [...commonPropertyNames, ...custom_properties]; + const baseURL = `${connection.account_url}/lead/`; + const queryString = allProperties + .map((prop) => `properties=${encodeURIComponent(prop)}`) + .join('&'); + + const url = `${baseURL}?${queryString}`; + + const resp = await axios.get(url, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }); + this.logger.log(`Synced close companies!`); + + return { + data: resp?.data?.data, + message: 'Close companies retrieved', + statusCode: 200, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Close', + CrmObject.company, + ActionType.GET, + ); + } + } +} diff --git a/packages/api/src/crm/company/services/close/mappers.ts b/packages/api/src/crm/company/services/close/mappers.ts new file mode 100644 index 000000000..83fd20ef8 --- /dev/null +++ b/packages/api/src/crm/company/services/close/mappers.ts @@ -0,0 +1,111 @@ +import { CloseCompanyInput, CloseCompanyOutput } from './types'; +import { + UnifiedCompanyInput, + UnifiedCompanyOutput, +} from '@crm/company/types/model.unified'; +import { ICompanyMapper } from '@crm/company/types'; +import { Utils } from '@crm/@lib/@utils'; + +export class CloseCompanyMapper implements ICompanyMapper { + private readonly utils: Utils; + + constructor() { + this.utils = new Utils(); + } + + async desunify( + source: UnifiedCompanyInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + const result: CloseCompanyInput = { + name: source?.name, + addresses: source?.addresses?.map((address) => ({ + address_1: address.street_1, + address_2: address.street_2, + city: address.city, + state: address.state, + zipcode: address.postal_code, + label: address.address_type, + })) as CloseCompanyInput['addresses'], + }; + + if (customFieldMappings && source.field_mappings) { + for (const [k, v] of Object.entries(source.field_mappings)) { + const mapping = customFieldMappings.find( + (mapping) => mapping.slug === k, + ); + if (mapping) { + result[mapping.remote_id] = v; + } + } + } + return result; + } + + async unify( + source: CloseCompanyOutput | CloseCompanyOutput[], + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + if (!Array.isArray(source)) { + return this.mapSingleCompanyToUnified(source, customFieldMappings); + } + // Handling array of CloseCompanyOutput + return Promise.all( + source.map((company) => + this.mapSingleCompanyToUnified(company, customFieldMappings), + ), + ); + } + + private async mapSingleCompanyToUnified( + company: CloseCompanyOutput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + const field_mappings: { [key: string]: any } = {}; + if (customFieldMappings) { + for (const mapping of customFieldMappings) { + field_mappings[mapping.slug] = company[mapping.remote_id]; + } + } + let opts: any = {}; + if (company?.created_by) { + const owner_id = await this.utils.getUserUuidFromRemoteId( + company?.created_by as string, + 'close', + ); + if (owner_id) { + opts = { + user_id: owner_id, + }; + } + } + return { + remote_id: company.id, + name: company.name, + industry: company?.custom?.Industry || '', + number_of_employees: company?.custom?.employees || 0, // Placeholder, as there's no direct mapping provided + addresses: company?.addresses?.map((address) => ({ + street_1: address.address_1, + street_2: address.address_2, + city: address.city, + state: address.state, + postal_code: address.zipcode, + country: address.country, + address_type: address.label, + owner_type: 'company', + })), // Assuming 'street', 'city', 'state', 'postal_code', 'country' are properties in company.properties + phone_numbers: [], + field_mappings, + ...opts, + }; + } +} diff --git a/packages/api/src/crm/company/services/close/types.ts b/packages/api/src/crm/company/services/close/types.ts new file mode 100644 index 000000000..8fe69ac4c --- /dev/null +++ b/packages/api/src/crm/company/services/close/types.ts @@ -0,0 +1,133 @@ +interface Email { + type: string; + email: string; +} + +interface Phone { + type: string; + phone: string; +} + +interface Contact { + name: string; + title: string; + emails: Email[]; + phones: Phone[]; +} + +interface Address { + label: string; + address_1: string; + address_2: string; + city: string; + state: string; + zipcode: string; + country: string; +} + +interface CustomFields { + [key: string]: string | string[]; +} + +interface CompanyInput { + name: string; + url: string; + description: string; + status_id: string; + contacts: Contact[]; + custom: CustomFields; + addresses: Address[]; + status?: string; +} + +export type CloseCompanyInput = Partial; + +interface Phone { + phone: string; + phone_formatted: string; + type: string; +} + +interface Email { + type: string; + email: string; + is_unsubscribed: boolean; +} + +interface Contact { + name: string; + title: string; + date_updated: string; + phones: Phone[]; + custom: { + [key: string]: string; + }; + created_by: string | null; + id: string; + organization_id: string; + date_created: string; + emails: Email[]; + updated_by: string; +} + +interface Opportunity { + status_id: string; + status_label: string; + status_type: string; + pipeline_id: string; + pipeline_name: string; + date_won: string | null; + confidence: number; + user_id: string; + contact_id: string | null; + updated_by: string | null; + date_updated: string; + value_period: string; + created_by: string | null; + note: string; + value: number; + value_formatted: string; + value_currency: string; + lead_name: string; + organization_id: string; + date_created: string; + user_name: string; + id: string; + lead_id: string; +} + +interface Company { + status_id: string; + status_label: string; + tasks: any[]; + display_name: string; + addresses: Partial
[]; + name: string; + contacts: Contact[]; + custom: { + [key: string]: string | string[]; + }; + date_updated: string; + html_url: string; + created_by: string | null; + organization_id: string; + url: string | null; + opportunities: Opportunity[]; + updated_by: string; + date_created: string; + id: string; + description: string; +} + +export type CloseCompanyOutput = Partial; + +export const commonCompanyCloseProperties = { + city: '', + createdate: '', + domain: '', + industry: '', + name: '', + phone: '', + state: '', + close_owner_id: '', +}; diff --git a/packages/api/src/crm/company/types/mappingsTypes.ts b/packages/api/src/crm/company/types/mappingsTypes.ts index 660920a6a..d394f8bb8 100644 --- a/packages/api/src/crm/company/types/mappingsTypes.ts +++ b/packages/api/src/crm/company/types/mappingsTypes.ts @@ -3,12 +3,14 @@ import { HubspotCompanyMapper } from '../services/hubspot/mappers'; import { PipedriveCompanyMapper } from '../services/pipedrive/mappers'; import { ZendeskCompanyMapper } from '../services/zendesk/mappers'; import { ZohoCompanyMapper } from '../services/zoho/mappers'; +import { CloseCompanyMapper } from '../services/close/mappers'; const hubspotCompanyMapper = new HubspotCompanyMapper(); const zendeskCompanyMapper = new ZendeskCompanyMapper(); const zohoCompanyMapper = new ZohoCompanyMapper(); const pipedriveCompanyMapper = new PipedriveCompanyMapper(); const attioCompanyMapper = new AttioCompanyMapper(); +const closeCompanyMapper = new CloseCompanyMapper(); export const companyUnificationMapping = { hubspot: { @@ -31,4 +33,8 @@ export const companyUnificationMapping = { unify: attioCompanyMapper.unify.bind(attioCompanyMapper), desunify: attioCompanyMapper.desunify.bind(attioCompanyMapper), }, + close: { + unify: closeCompanyMapper.unify.bind(closeCompanyMapper), + desunify: closeCompanyMapper.desunify.bind(closeCompanyMapper), + }, }; diff --git a/packages/api/src/crm/contact/contact.controller.ts b/packages/api/src/crm/contact/contact.controller.ts index 3518a35fd..7b1c54c4e 100644 --- a/packages/api/src/crm/contact/contact.controller.ts +++ b/packages/api/src/crm/contact/contact.controller.ts @@ -31,7 +31,6 @@ import { ConnectionUtils } from '@@core/connections/@utils'; import { ApiCustomResponse } from '@@core/utils/types'; import { FetchObjectsQueryDto } from '@@core/utils/dtos/fetch-objects-query.dto'; - @ApiBearerAuth('JWT') @ApiTags('crm/contacts') @Controller('crm/contacts') @@ -74,7 +73,7 @@ export class ContactController { linkedUserId, pageSize, remote_data, - cursor + cursor, ); } catch (error) { throw new Error(error); diff --git a/packages/api/src/crm/contact/contact.module.ts b/packages/api/src/crm/contact/contact.module.ts index 5a3de9885..d592fe7d0 100644 --- a/packages/api/src/crm/contact/contact.module.ts +++ b/packages/api/src/crm/contact/contact.module.ts @@ -14,6 +14,7 @@ import { WebhookService } from '@@core/webhook/webhook.service'; import { BullModule } from '@nestjs/bull'; import { EncryptionService } from '@@core/encryption/encryption.service'; import { ServiceRegistry } from './services/registry.service'; +import { CloseService } from './services/close'; @Module({ imports: [ @@ -40,6 +41,7 @@ import { ServiceRegistry } from './services/registry.service'; ZohoService, PipedriveService, HubspotService, + CloseService, ], exports: [ SyncService, diff --git a/packages/api/src/crm/contact/services/close/index.ts b/packages/api/src/crm/contact/services/close/index.ts new file mode 100644 index 000000000..579d6bd84 --- /dev/null +++ b/packages/api/src/crm/contact/services/close/index.ts @@ -0,0 +1,107 @@ +import { Injectable } from '@nestjs/common'; +import { IContactService } from '@crm/contact/types'; +import { CrmObject } from '@crm/@lib/@types'; +import axios from 'axios'; +import { PrismaService } from '@@core/prisma/prisma.service'; +import { LoggerService } from '@@core/logger/logger.service'; +import { ActionType, handleServiceError } from '@@core/utils/errors'; +import { EncryptionService } from '@@core/encryption/encryption.service'; +import { ApiResponse } from '@@core/utils/types'; +import { ServiceRegistry } from '../registry.service'; +import { CloseContactInput, CloseContactOutput } from './types'; + +@Injectable() +export class CloseService implements IContactService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + CrmObject.contact.toUpperCase() + ':' + CloseService.name, + ); + this.registry.registerService('close', this); + } + async addContact( + contactData: CloseContactInput, + linkedUserId: string, + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'close', + vertical: 'crm', + }, + }); + + const resp = await axios.post( + `${connection.account_url}/contact`, + JSON.stringify(contactData), + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }, + ); + return { + data: resp?.data, + message: 'Close contact created', + statusCode: 201, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Close', + CrmObject.contact, + ActionType.POST, + ); + } + } + + async syncContacts( + linkedUserId: string, + custom_properties?: string[], + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'close', + vertical: 'crm', + }, + }); + + const baseURL = `${connection.account_url}/contact`; + + const resp = await axios.get(baseURL, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }); + this.logger.log(`Synced close contacts !`); + + return { + data: resp?.data?.data, + message: 'Close contacts retrieved', + statusCode: 200, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Close', + CrmObject.contact, + ActionType.GET, + ); + } + } +} diff --git a/packages/api/src/crm/contact/services/close/mappers.ts b/packages/api/src/crm/contact/services/close/mappers.ts new file mode 100644 index 000000000..16bba49b5 --- /dev/null +++ b/packages/api/src/crm/contact/services/close/mappers.ts @@ -0,0 +1,126 @@ +import { + UnifiedContactInput, + UnifiedContactOutput, +} from '@crm/contact/types/model.unified'; +import { IContactMapper } from '@crm/contact/types'; +import { + CloseContactInput, + CloseContactOutput, + InputPhone, + InputEmail, +} from './types'; +import { Utils } from '@crm/@lib/@utils'; + +export class CloseContactMapper implements IContactMapper { + private readonly utils: Utils; + + constructor() { + this.utils = new Utils(); + } + + async desunify( + source: UnifiedContactInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + // Assuming 'email_addresses' array contains at least one email and 'phone_numbers' array contains at least one phone number + const result: CloseContactInput = { + name: `${source.first_name ?? ''} ${source.last_name ?? ''}`, + phones: source?.phone_numbers?.map( + ({ phone_number, phone_type }) => + ({ + phone: phone_number, + type: phone_type, + } as InputPhone), + ), + emails: source?.email_addresses?.map( + ({ email_address, email_address_type }) => + ({ + email: email_address, + type: email_address_type, + } as InputEmail), + ), + }; + + if (source.user_id) { + result.lead_id = await this.utils.getRemoteIdFromCompanyUuid( + source.user_id, + ); + } + + if (customFieldMappings && source.field_mappings) { + for (const [k, v] of Object.entries(source.field_mappings)) { + const mapping = customFieldMappings.find( + (mapping) => mapping.slug === k, + ); + if (mapping) { + result[mapping.remote_id] = v; + } + } + } + + return result; + } + + async unify( + source: CloseContactOutput | CloseContactOutput[], + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + if (!Array.isArray(source)) { + return await this.mapSingleContactToUnified(source, customFieldMappings); + } + // Handling array of CloseContactOutput + return await Promise.all( + source.map((contact) => + this.mapSingleContactToUnified(contact, customFieldMappings), + ), + ); + } + + private async mapSingleContactToUnified( + contact: CloseContactOutput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + const field_mappings: { [key: string]: any } = {}; + if (customFieldMappings) { + for (const mapping of customFieldMappings) { + field_mappings[mapping.slug] = contact[mapping.remote_id]; + } + } + const opts: any = {}; + if (contact.created_by) { + opts.user_id = await this.utils.getUserUuidFromRemoteId( + contact.created_by, + 'close', + ); + } + + const [firstName, lastName] = contact?.name?.split(' '); + return { + remote_id: contact.id, + first_name: firstName || contact?.name, + last_name: lastName ?? '', + email_addresses: contact.emails?.map(({ email, type }) => ({ + email_address: email, + email_address_type: type, + owner_type: 'contact', + })), + phone_numbers: contact.phones?.map(({ phone, type }) => ({ + phone_number: phone, + phone_type: type, + owner_type: 'contact', + })), + field_mappings, + ...opts, + addresses: [], + }; + } +} diff --git a/packages/api/src/crm/contact/services/close/types.ts b/packages/api/src/crm/contact/services/close/types.ts new file mode 100644 index 000000000..6bd5ce1e2 --- /dev/null +++ b/packages/api/src/crm/contact/services/close/types.ts @@ -0,0 +1,69 @@ +export interface InputPhone { + phone: string; + type: string; +} + +export interface InputEmail { + email: string; + type: string; +} + +interface Url { + url: string; + type: string; +} + +interface CustomFields { + [key: string]: string; // Allows dynamic keys for custom fields +} + +interface ContactInput { + lead_id: string; + name: string; + title: string; + phones: InputPhone[]; + emails: InputEmail[]; + urls: Url[]; + custom?: CustomFields; +} + +interface Phone { + country: string; + phone: string; + phone_formatted: string; + type: string; +} + +interface Email { + type: string; + email: string; + is_unsubscribed: boolean; +} + +interface Contact { + id: string; + organization_id: string; + lead_id: string; + name: string; + title: string; + phones: Phone[]; + emails: Email[]; + date_created: string; + date_updated: string; + created_by: string; + updated_by: string; +} + +export type CloseContactInput = Partial; + +export type CloseContactOutput = Partial; + +export const commonCloseProperties = { + createdate: '', + email: '', + firstname: '', + hs_object_id: '', + lastmodifieddate: '', + lastname: '', + close_owner_id: '', +}; diff --git a/packages/api/src/crm/contact/types/mappingsTypes.ts b/packages/api/src/crm/contact/types/mappingsTypes.ts index 46d3b57d5..d1cce1878 100644 --- a/packages/api/src/crm/contact/types/mappingsTypes.ts +++ b/packages/api/src/crm/contact/types/mappingsTypes.ts @@ -3,12 +3,14 @@ import { HubspotContactMapper } from '../services/hubspot/mappers'; import { PipedriveContactMapper } from '../services/pipedrive/mappers'; import { ZendeskContactMapper } from '../services/zendesk/mappers'; import { ZohoContactMapper } from '../services/zoho/mappers'; +import { CloseContactMapper } from '../services/close/mappers'; const hubspotContactMapper = new HubspotContactMapper(); const zendeskContactMapper = new ZendeskContactMapper(); const zohoContactMapper = new ZohoContactMapper(); const pipedriveContactMapper = new PipedriveContactMapper(); const attioContactMapper = new AttioContactMapper(); +const closeContactMapper = new CloseContactMapper(); export const contactUnificationMapping = { hubspot: { @@ -31,4 +33,8 @@ export const contactUnificationMapping = { unify: attioContactMapper.unify.bind(attioContactMapper), desunify: attioContactMapper.desunify.bind(attioContactMapper), }, + close: { + unify: closeContactMapper.unify.bind(closeContactMapper), + desunify: closeContactMapper.desunify.bind(closeContactMapper), + }, }; diff --git a/packages/api/src/crm/deal/deal.module.ts b/packages/api/src/crm/deal/deal.module.ts index ba2f66537..e13d5cdf8 100644 --- a/packages/api/src/crm/deal/deal.module.ts +++ b/packages/api/src/crm/deal/deal.module.ts @@ -13,6 +13,7 @@ import { HubspotService } from './services/hubspot'; import { PipedriveService } from './services/pipedrive'; import { ZendeskService } from './services/zendesk'; import { ZohoService } from './services/zoho'; +import { CloseService } from './services/close'; @Module({ imports: [ @@ -38,6 +39,7 @@ import { ZohoService } from './services/zoho'; ZohoService, PipedriveService, HubspotService, + CloseService, ], exports: [ SyncService, diff --git a/packages/api/src/crm/deal/services/close/index.ts b/packages/api/src/crm/deal/services/close/index.ts new file mode 100644 index 000000000..c12148a5a --- /dev/null +++ b/packages/api/src/crm/deal/services/close/index.ts @@ -0,0 +1,106 @@ +import { Injectable } from '@nestjs/common'; +import { IDealService } from '@crm/deal/types'; +import { CrmObject } from '@crm/@lib/@types'; +import axios from 'axios'; +import { PrismaService } from '@@core/prisma/prisma.service'; +import { LoggerService } from '@@core/logger/logger.service'; +import { ActionType, handleServiceError } from '@@core/utils/errors'; +import { EncryptionService } from '@@core/encryption/encryption.service'; +import { ApiResponse } from '@@core/utils/types'; +import { ServiceRegistry } from '../registry.service'; +import { CloseDealInput, CloseDealOutput } from './types'; +@Injectable() +export class CloseService implements IDealService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + CrmObject.deal.toUpperCase() + ':' + CloseService.name, + ); + this.registry.registerService('close', this); + } + async addDeal( + dealData: CloseDealInput, + linkedUserId: string, + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'close', + vertical: 'crm', + }, + }); + const resp = await axios.post( + `${connection.account_url}/opportunity/`, + JSON.stringify(dealData), + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }, + ); + + return { + data: resp?.data, + message: 'Close deal created', + statusCode: 201, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Close', + CrmObject.deal, + ActionType.POST, + ); + } + } + + async syncDeals( + linkedUserId: string, + custom_properties?: string[], + ): Promise> { + try { + //crm.schemas.deals.read","crm.objects.deals.read + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'close', + vertical: 'crm', + }, + }); + + const baseURL = `${connection.account_url}/opportunity/`; + const resp = await axios.get(baseURL, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }); + this.logger.log(`Synced close deals !`); + + return { + data: resp?.data?.data, + message: 'Close deals retrieved', + statusCode: 200, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Close', + CrmObject.deal, + ActionType.GET, + ); + } + } +} diff --git a/packages/api/src/crm/deal/services/close/mappers.ts b/packages/api/src/crm/deal/services/close/mappers.ts new file mode 100644 index 000000000..2a0538f73 --- /dev/null +++ b/packages/api/src/crm/deal/services/close/mappers.ts @@ -0,0 +1,128 @@ +import { CloseDealInput, CloseDealOutput } from './types'; +import { + UnifiedDealInput, + UnifiedDealOutput, +} from '@crm/deal/types/model.unified'; +import { IDealMapper } from '@crm/deal/types'; +import { Utils } from '@crm/@lib/@utils'; + +export class CloseDealMapper implements IDealMapper { + private readonly utils: Utils; + + constructor() { + this.utils = new Utils(); + } + + async desunify( + source: UnifiedDealInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + const emptyPromise = new Promise((resolve) => { + return resolve(''); + }); + const promises = []; + + promises.push( + source.company_id + ? await this.utils.getRemoteIdFromCompanyUuid(source.company_id) + : emptyPromise, + ); + + // promises.push( + // source.stage_id + // ? await this.utils.getStageIdFromStageUuid(source.stage_id) + // : emptyPromise, + // ); + const [lead_id] = await Promise.all(promises); + const result: CloseDealInput = { + note: source.description, + confidence: 0, + value: source.amount || 0, + value_period: 'monthly', + custom: {}, + lead_id, + status_id: source.stage_id, + }; + + if (customFieldMappings && source.field_mappings) { + for (const [k, v] of Object.entries(source.field_mappings)) { + const mapping = customFieldMappings.find( + (mapping) => mapping.slug === k, + ); + if (mapping) { + result[mapping.remote_id] = v; + } + } + } + return result; + } + + async unify( + source: CloseDealOutput | CloseDealOutput[], + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + if (!Array.isArray(source)) { + return await this.mapSingleDealToUnified(source, customFieldMappings); + } + // Handling array of CloseDealOutput + return Promise.all( + source.map((deal) => + this.mapSingleDealToUnified(deal, customFieldMappings), + ), + ); + } + + private async mapSingleDealToUnified( + deal: CloseDealOutput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + const field_mappings: { [key: string]: any } = {}; + if (customFieldMappings) { + for (const mapping of customFieldMappings) { + field_mappings[mapping.slug] = deal.custom[mapping.remote_id]; + } + } + + const emptyPromise = new Promise((resolve) => { + return resolve(''); + }); + const promises = []; + + promises.push( + deal.user_id + ? await this.utils.getUserUuidFromRemoteId(deal.user_id, 'close') + : emptyPromise, + ); + promises.push( + deal.lead_id + ? await this.utils.getCompanyUuidFromRemoteId(deal.lead_id, 'close') + : emptyPromise, + ); + // promises.push( + // deal.status_id + // ? await this.utils.getStageUuidFromRemoteId(deal.status_id, 'close') + // : emptyPromise, + // ); + + const [user_id, company_id] = await Promise.all(promises); + + return { + remote_id: deal.id, + name: deal.note, + description: deal.note, // Placeholder if there's no direct mapping + amount: parseFloat(`${deal.value || 0}`), + field_mappings, + user_id, + company_id, + }; + } +} diff --git a/packages/api/src/crm/deal/services/close/types.ts b/packages/api/src/crm/deal/services/close/types.ts new file mode 100644 index 000000000..45e7a4cea --- /dev/null +++ b/packages/api/src/crm/deal/services/close/types.ts @@ -0,0 +1,45 @@ +interface CustomFields { + [key: string]: string; +} + +export interface CloseDealInput { + note?: string; + confidence?: number; + lead_id: string; + status_id?: string; + value?: number; + value_period?: string; + custom?: CustomFields; +} + +interface Opportunity { + id: string; + organization_id: string; + lead_id: string; + lead_name: string; + status_id: string; + status_label: string; + status_type: string; + pipeline_id: string; + pipeline_name: string; + value: number; + value_period: string; + value_formatted: string; + value_currency: string; + expected_value: number; + annualized_value: number; + annualized_expected_value: number; + date_won: string | null; + confidence: number; + note: string; + user_id: string; + user_name: string; + contact_id: string | null; + created_by: string; + updated_by: string; + date_updated: string; + date_created: string; + custom: CustomFields; +} + +export type CloseDealOutput = Partial; diff --git a/packages/api/src/crm/deal/types/mappingsTypes.ts b/packages/api/src/crm/deal/types/mappingsTypes.ts index c665474e4..729c27586 100644 --- a/packages/api/src/crm/deal/types/mappingsTypes.ts +++ b/packages/api/src/crm/deal/types/mappingsTypes.ts @@ -2,11 +2,13 @@ import { HubspotDealMapper } from '../services/hubspot/mappers'; import { PipedriveDealMapper } from '../services/pipedrive/mappers'; import { ZendeskDealMapper } from '../services/zendesk/mappers'; import { ZohoDealMapper } from '../services/zoho/mappers'; +import { CloseDealMapper } from '../services/close/mappers'; const hubspotDealMapper = new HubspotDealMapper(); const zendeskDealMapper = new ZendeskDealMapper(); const zohoDealMapper = new ZohoDealMapper(); const pipedriveDealMapper = new PipedriveDealMapper(); +const closeDealMapper = new CloseDealMapper(); export const dealUnificationMapping = { hubspot: { @@ -25,4 +27,8 @@ export const dealUnificationMapping = { unify: zendeskDealMapper.unify.bind(zendeskDealMapper), desunify: zendeskDealMapper.desunify.bind(zendeskDealMapper), }, + close: { + unify: closeDealMapper.unify.bind(closeDealMapper), + desunify: closeDealMapper.desunify.bind(closeDealMapper), + }, }; diff --git a/packages/api/src/crm/engagement/engagement.module.ts b/packages/api/src/crm/engagement/engagement.module.ts index 1f068bf0e..40c3ebd88 100644 --- a/packages/api/src/crm/engagement/engagement.module.ts +++ b/packages/api/src/crm/engagement/engagement.module.ts @@ -13,6 +13,7 @@ import { HubspotService } from './services/hubspot'; import { PipedriveService } from './services/pipedrive'; import { ZendeskService } from './services/zendesk'; import { ZohoService } from './services/zoho'; +import { CloseService } from './services/close'; @Module({ imports: [ @@ -38,6 +39,7 @@ import { ZohoService } from './services/zoho'; ZohoService, PipedriveService, HubspotService, + CloseService, ], exports: [ SyncService, diff --git a/packages/api/src/crm/engagement/services/close/index.ts b/packages/api/src/crm/engagement/services/close/index.ts new file mode 100644 index 000000000..7662a3e73 --- /dev/null +++ b/packages/api/src/crm/engagement/services/close/index.ts @@ -0,0 +1,334 @@ +import { Injectable } from '@nestjs/common'; +import { IEngagementService } from '@crm/engagement/types'; +import { CrmObject } from '@crm/@lib/@types'; +import axios from 'axios'; +import { PrismaService } from '@@core/prisma/prisma.service'; +import { LoggerService } from '@@core/logger/logger.service'; +import { ActionType, handleServiceError } from '@@core/utils/errors'; +import { EncryptionService } from '@@core/encryption/encryption.service'; +import { ApiResponse } from '@@core/utils/types'; +import { ServiceRegistry } from '../registry.service'; +import { + CloseEngagementCallInput, + CloseEngagementCallOutput, + CloseEngagementEmailInput, + CloseEngagementEmailOutput, + CloseEngagementInput, + CloseEngagementMeetingInput, + CloseEngagementMeetingOutput, + CloseEngagementOutput, +} from './types'; + +@Injectable() +export class CloseService implements IEngagementService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + CrmObject.engagement.toUpperCase() + ':' + CloseService.name, + ); + this.registry.registerService('close', this); + } + async addEngagement( + engagementData: CloseEngagementInput, + linkedUserId: string, + engagement_type: string, + ): Promise> { + try { + switch (engagement_type) { + case 'CALL': + return this.addCall( + engagementData as CloseEngagementCallInput, + linkedUserId, + ); + case 'MEETING': + return this.addMeeting( + engagementData as CloseEngagementMeetingInput, + linkedUserId, + ); + case 'EMAIL': + return this.addEmail( + engagementData as CloseEngagementEmailInput, + linkedUserId, + ); + default: + break; + } + } catch (error) { + handleServiceError( + error, + this.logger, + 'Close', + CrmObject.engagement, + ActionType.POST, + ); + } + } + + private async addCall( + engagementData: CloseEngagementCallInput, + linkedUserId: string, + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'close', + vertical: 'crm', + }, + }); + const resp = await axios.post( + `${connection.account_url}/activity/call`, + JSON.stringify(engagementData), + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }, + ); + return { + data: resp?.data, + message: 'Close call created', + statusCode: 201, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Close', + CrmObject.engagement_call, + ActionType.POST, + ); + } + } + + private async addMeeting( + engagementData: CloseEngagementMeetingInput, + linkedUserId: string, + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'close', + vertical: 'crm', + }, + }); + const resp = await axios.post( + `${connection.account_url}/activity/meeting`, + JSON.stringify(engagementData), + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }, + ); + return { + data: resp?.data, + message: 'Close meeting created', + statusCode: 201, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Close', + CrmObject.engagement_meeting, + ActionType.POST, + ); + } + } + + private async addEmail( + engagementData: CloseEngagementEmailInput, + linkedUserId: string, + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'close', + vertical: 'crm', + }, + }); + const dataBody = { + properties: engagementData, + }; + const resp = await axios.post( + `${connection.account_url}/activity/email`, + JSON.stringify(dataBody), + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }, + ); + return { + data: resp?.data, + message: 'Close email created', + statusCode: 201, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Close', + CrmObject.engagement_email, + ActionType.POST, + ); + } + } + + async syncEngagements( + linkedUserId: string, + engagement_type: string, + custom_properties?: string[], + ): Promise> { + try { + switch (engagement_type) { + case 'CALL': + return this.syncCalls(linkedUserId, custom_properties); + case 'MEETING': + return this.syncMeetings(linkedUserId, custom_properties); + case 'EMAIL': + return this.syncEmails(linkedUserId, custom_properties); + default: + break; + } + } catch (error) { + handleServiceError( + error, + this.logger, + 'Close', + CrmObject.engagement, + ActionType.GET, + ); + } + } + + private async syncCalls(linkedUserId: string, custom_properties?: string[]) { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'close', + vertical: 'crm', + }, + }); + + const baseURL = `${connection.account_url}/activity/call`; + + const resp = await axios.get(baseURL, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }); + this.logger.log(`Synced close engagements calls !`); + + return { + data: resp?.data?.data || [], + message: 'Close engagements calls retrieved', + statusCode: 200, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Close', + CrmObject.engagement_call, + ActionType.GET, + ); + } + } + + private async syncMeetings( + linkedUserId: string, + custom_properties?: string[], + ) { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'close', + vertical: 'crm', + }, + }); + + const baseURL = `${connection.account_url}/activity/meeting`; + + const resp = await axios.get(baseURL, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }); + this.logger.log(`Synced close engagements meetings !`); + return { + data: resp?.data?.data, + message: 'Close engagements meetings retrieved', + statusCode: 200, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Close', + CrmObject.engagement_meeting, + ActionType.GET, + ); + } + } + + private async syncEmails(linkedUserId: string, custom_properties?: string[]) { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'close', + vertical: 'crm', + }, + }); + + const baseURL = `${connection.account_url}/activity/email`; + const resp = await axios.get(baseURL, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }); + this.logger.log(`Synced close engagements emails !`); + return { + data: resp?.data?.data, + message: 'Close engagements emails retrieved', + statusCode: 200, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Close', + CrmObject.engagement_email, + ActionType.GET, + ); + } + } +} diff --git a/packages/api/src/crm/engagement/services/close/mappers.ts b/packages/api/src/crm/engagement/services/close/mappers.ts new file mode 100644 index 000000000..751c2b1e5 --- /dev/null +++ b/packages/api/src/crm/engagement/services/close/mappers.ts @@ -0,0 +1,459 @@ +import { + CloseEngagementCallInput, + CloseEngagementCallOutput, + CloseEngagementEmailInput, + CloseEngagementEmailOutput, + CloseEngagementInput, + CloseEngagementMeetingInput, + CloseEngagementMeetingOutput, + CloseEngagementOutput, +} from './types'; +import { + UnifiedEngagementInput, + UnifiedEngagementOutput, +} from '@crm/engagement/types/model.unified'; +import { IEngagementMapper } from '@crm/engagement/types'; +import { Utils } from '@crm/@lib/@utils'; + +export class CloseEngagementMapper implements IEngagementMapper { + private readonly utils: Utils; + + constructor() { + this.utils = new Utils(); + } + + async desunify( + source: UnifiedEngagementInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + const type = source.type; + switch (type) { + case 'CALL': + return await this.desunifyCall(source, customFieldMappings); + case 'MEETING': + return await this.desunifyMeeting(source, customFieldMappings); + case 'EMAIL': + return await this.desunifyEmail(source, customFieldMappings); + default: + break; + } + return; + } + + private async desunifyCall( + source: UnifiedEngagementInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + const diffInMilliseconds = + source.start_at && source.end_time + ? new Date(source.end_time).getTime() - + new Date(source.start_at).getTime() + : 0; + const result: CloseEngagementCallInput = { + note_html: source.content || '', + direction: ( + (source.direction === 'OUTBOUND' ? 'outgoing' : source.direction) || '' + ).toLowerCase(), + duration: Math.floor(diffInMilliseconds / (1000 * 60)), + }; + + // Map HubSpot owner ID from user ID + if (source.user_id) { + const owner_id = await this.utils.getRemoteIdFromUserUuid(source.user_id); + if (owner_id) { + result.created_by = owner_id; + result.user_id = owner_id; + } + } + if (source.company_id) { + result.lead_id = await this.utils.getRemoteIdFromCompanyUuid( + source.company_id, + ); + } + if (source?.contacts && source?.contacts?.length) { + const contactId = await this.utils.getRemoteIdFromUserUuid( + source.contacts[0], + ); + if (contactId) { + result.contact_id = contactId; + } + } + + if (customFieldMappings && source.field_mappings) { + for (const [k, v] of Object.entries(source.field_mappings)) { + const mapping = customFieldMappings.find( + (mapping) => mapping.slug === k, + ); + if (mapping) { + result[mapping.remote_id] = v; + } + } + } + + return result; + } + + private async desunifyMeeting( + source: UnifiedEngagementInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + return {}; + } + + private async desunifyEmail( + source: UnifiedEngagementInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + const result: CloseEngagementEmailInput = { + body_text: source.content || '', + status: '', // Placeholder, needs appropriate mapping + sender: '', + to: [], + bcc: [], + cc: [], + direction: ( + (source.direction === 'OUTBOUND' ? 'outgoing' : source.direction) || '' + ).toLowerCase(), + }; + + // Map HubSpot owner ID from user ID + if (source.user_id) { + const owner_id = await this.utils.getRemoteIdFromUserUuid(source.user_id); + if (owner_id) { + result.user_id = owner_id; + } + } + if (source.company_id) { + result.lead_id = await this.utils.getRemoteIdFromCompanyUuid( + source.company_id, + ); + } + + if (customFieldMappings && source.field_mappings) { + for (const [k, v] of Object.entries(source.field_mappings)) { + const mapping = customFieldMappings.find( + (mapping) => mapping.slug === k, + ); + if (mapping) { + result[mapping.remote_id] = v; + } + } + } + + return result; + } + + async unify( + source: CloseEngagementOutput | CloseEngagementOutput[], + engagement_type: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + switch (engagement_type) { + case 'CALL': + return await this.unifyCall( + source as CloseEngagementCallOutput | CloseEngagementCallOutput[], + customFieldMappings, + ); + case 'MEETING': + return await this.unifyMeeting( + source as + | CloseEngagementMeetingOutput + | CloseEngagementMeetingOutput[], + customFieldMappings, + ); + case 'EMAIL': + return await this.unifyEmail( + source as CloseEngagementEmailOutput | CloseEngagementEmailOutput[], + customFieldMappings, + ); + default: + break; + } + } + + private async unifyCall( + source: CloseEngagementCallOutput | CloseEngagementCallOutput[], + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ) { + if (!Array.isArray(source)) { + return this.mapSingleEngagementCallToUnified(source, customFieldMappings); + } + // Handling array of CloseEngagementOutput + return Promise.all( + source.map((engagement) => + this.mapSingleEngagementCallToUnified(engagement, customFieldMappings), + ), + ); + } + + private async unifyMeeting( + source: CloseEngagementMeetingOutput | CloseEngagementMeetingOutput[], + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ) { + if (!Array.isArray(source)) { + return this.mapSingleEngagementMeetingToUnified( + source, + customFieldMappings, + ); + } + // Handling array of CloseEngagementOutput + return Promise.all( + source.map((engagement) => + this.mapSingleEngagementMeetingToUnified( + engagement, + customFieldMappings, + ), + ), + ); + } + + private async unifyEmail( + source: CloseEngagementEmailOutput | CloseEngagementEmailOutput[], + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ) { + if (!Array.isArray(source)) { + return this.mapSingleEngagementEmailToUnified( + source, + customFieldMappings, + ); + } + // Handling array of CloseEngagementOutput + return Promise.all( + source.map((engagement) => + this.mapSingleEngagementEmailToUnified(engagement, customFieldMappings), + ), + ); + } + + private async mapSingleEngagementCallToUnified( + engagement: CloseEngagementCallOutput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + const field_mappings: { [key: string]: any } = {}; + if (customFieldMappings) { + for (const mapping of customFieldMappings) { + field_mappings[mapping.slug] = engagement[mapping.remote_id]; + } + } + + let opts: any = {}; + if (engagement.created_by || engagement.user_id) { + const owner_id = await this.utils.getUserUuidFromRemoteId( + engagement.created_by || engagement.user_id, + 'close', + ); + if (owner_id) { + opts = { + user_id: owner_id, + }; + } + } + if (engagement.contact_id) { + const contact_id = await this.utils.getContactUuidFromRemoteId( + engagement.contact_id, + 'close', + ); + if (contact_id) { + opts = { + ...opts, + contact_id: contact_id, + }; + } + } + if (engagement.lead_id) { + const lead_id = await this.utils.getCompanyUuidFromRemoteId( + engagement.lead_id, + 'close', + ); + if (lead_id) { + opts = { + ...opts, + company_id: lead_id, + }; + } + } + + return { + remote_id: engagement.id, + content: engagement.note_html || engagement.note, + subject: engagement.note, + start_at: new Date(engagement.date_created), + end_time: new Date(engagement.date_updated), // Assuming end time is mapped from last modified date + type: 'CALL', + direction: engagement.direction, + field_mappings, + ...opts, + }; + } + + private async mapSingleEngagementMeetingToUnified( + engagement: CloseEngagementMeetingOutput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + const field_mappings: { [key: string]: any } = {}; + if (customFieldMappings) { + for (const mapping of customFieldMappings) { + field_mappings[mapping.slug] = engagement[mapping.remote_id]; + } + } + + let opts: any = {}; + if (engagement.user_id) { + const owner_id = await this.utils.getUserUuidFromRemoteId( + engagement.user_id, + 'close', + ); + if (owner_id) { + opts = { + user_id: owner_id, + }; + } + } + if (engagement.user_id) { + const owner_id = await this.utils.getUserUuidFromRemoteId( + engagement.user_id, + 'close', + ); + if (owner_id) { + opts = { + user_id: owner_id, + }; + } + } + if (engagement.contact_id) { + const contact_id = await this.utils.getContactUuidFromRemoteId( + engagement.contact_id, + 'close', + ); + if (contact_id) { + opts = { + ...opts, + contact_id: contact_id, + }; + } + } + if (engagement.lead_id) { + const lead_id = await this.utils.getCompanyUuidFromRemoteId( + engagement.lead_id, + 'close', + ); + if (lead_id) { + opts = { + ...opts, + company_id: lead_id, + }; + } + } + + return { + remote_id: engagement.id, + content: engagement.note, + subject: engagement.title, + start_at: new Date(engagement.starts_at), + end_time: new Date(engagement.ends_at), + type: 'MEETING', + field_mappings, + duration: engagement.duration, + ...opts, + }; + } + + private async mapSingleEngagementEmailToUnified( + engagement: CloseEngagementEmailOutput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + const field_mappings: { [key: string]: any } = {}; + if (customFieldMappings) { + for (const mapping of customFieldMappings) { + field_mappings[mapping.slug] = engagement[mapping.remote_id]; + } + } + + let opts: any = {}; + if (engagement.user_id) { + const owner_id = await this.utils.getUserUuidFromRemoteId( + engagement.user_id, + 'close', + ); + if (owner_id) { + opts = { + user_id: owner_id, + }; + } + } + if (engagement.contact_id) { + const contact_id = await this.utils.getContactUuidFromRemoteId( + engagement.contact_id, + 'close', + ); + if (contact_id) { + opts = { + ...opts, + contact_id: contact_id, + }; + } + } + if (engagement.lead_id) { + const lead_id = await this.utils.getCompanyUuidFromRemoteId( + engagement.lead_id, + 'close', + ); + if (lead_id) { + opts = { + ...opts, + company_id: lead_id, + }; + } + } + + return { + remote_id: engagement.id, + content: engagement.body_html, + subject: '', + start_at: new Date(engagement.date_created), + end_time: new Date(engagement.date_updated), // Assuming end time can be mapped from last modified date + type: 'EMAIL', + direction: + engagement.direction === 'outgoing' + ? 'OUTBOUND' + : engagement.direction === 'inbound' + ? 'INBOUND' + : '', + field_mappings, + ...opts, + }; + } +} diff --git a/packages/api/src/crm/engagement/services/close/types.ts b/packages/api/src/crm/engagement/services/close/types.ts new file mode 100644 index 000000000..97c7430c4 --- /dev/null +++ b/packages/api/src/crm/engagement/services/close/types.ts @@ -0,0 +1,221 @@ +interface Call { + lead_id: string; + contact_id: string; + created_by: string; + user_id: string; + direction: string; + status: string; + note_html: string; + duration: number; + phone: string; +} + +export type CloseEngagementCallInput = Partial; + +interface OutputCall { + id: string; + _type: string; + recording_url: string | null; + voicemail_url: string | null; + voicemail_duration: number | null; + direction: string; + disposition: string; + source: string; + note_html: string; + note: string; + duration: number; + local_phone: string; + local_phone_formatted: string; + remote_phone: string; + remote_phone_formatted: string; + phone: string; + created_by: string; + updated_by: string; + date_created: string; + date_updated: string; + organization_id: string; + user_id: string; + lead_id: string; + contact_id: string; + call_method: string; + dialer_id: string | null; + dialer_saved_search_id: string | null; + cost: string; + local_country_iso: string; + remote_country_iso: string; +} + +export type CloseEngagementCallOutput = Partial; + +export interface CloseEngagementMeetingInput { + note_html?: string; +} + +interface Meeting { + id: string; + _type: string; + title: string; + location: string; + status: string; + note: string; + starts_at: string; // Date in ISO 8601 format + ends_at: string; // Date in ISO 8601 format + duration: number; // Duration of the meeting in seconds + date_created: string; // Date in ISO 8601 format + date_updated: string; // Date in ISO 8601 format + created_by: string | null; + created_by_name: string | null; + updated_by: string | null; + updated_by_name: string | null; + user_id: string; + user_name: string; + organization_id: string; + connected_account_id: string; + source: string; + is_recurring: boolean; + lead_id: string | null; + contact_id: string | null; + attendees: Attendee[]; +} + +interface Attendee { + status: string; + user_id: string | null; + name: string | null; + contact_id: string | null; + is_organizer: boolean; + email: string; +} + +export type CloseEngagementMeetingOutput = Partial; + +export const commonMeetingCloseProperties = { + createdate: '', + hs_internal_meeting_notes: '', + hs_lastmodifieddate: '', + hs_meeting_body: '', + hs_meeting_end_time: '', + hs_meeting_external_url: '', + hs_meeting_location: '', + hs_meeting_outcome: '', + hs_meeting_start_time: '', + hs_meeting_title: '', + hs_timestamp: '', + close_owner_id: '', +}; + +interface Email { + contact_id: string; + user_id: string; + lead_id: string; + direction: string; + created_by: string | null; + created_by_name: string; + date_created: string; + subject: string; + sender: string; + to: string[]; + bcc: string[]; + cc: string[]; + status: string; + body_text: string; + body_html: string; + attachments: Attachment[]; + email_account_id: string; + template_id: string | null; +} + +interface Attachment { + url: string; + filename: string; + size: number; + content_type: string; +} + +export type CloseEngagementEmailInput = Partial; + +interface EmailOuput { + attachments: any[]; + body_text: string; + date_updated: string; + direction: string; + contact_id: string; + id: string; + user_id: string; + created_by: string | null; + to: string[]; + subject: string; + opens: any[]; + status: string; + _type: string; + updated_by: string; + updated_by_name: string; + envelope: Envelope; + body_html: string; + organization_id: string; + body_text_quoted: BodyTextQuoted[]; + send_attempts: any[]; + lead_id: string; + sender: string; + bcc: any[]; + date_created: string; + template_id: string | null; + cc: any[]; + sequence_subscription_id: string; + sequence_id: string; + sequence_name: string; +} + +interface Envelope { + from: EmailAddress[]; + sender: EmailAddress[]; + to: EmailAddress[]; + cc: any[]; + bcc: any[]; + reply_to: any[]; + date: string; + in_reply_to: any; + message_id: string; + subject: string; +} + +interface EmailAddress { + email: string; + name: string; +} + +interface BodyTextQuoted { + text: string; + expand: boolean; +} + +// Example usage + +export type CloseEngagementEmailOutput = Partial; + +export const commonEmailCloseProperties = { + createdate: '', + hs_email_direction: '', + hs_email_sender_email: '', + hs_email_sender_firstname: '', + hs_email_sender_lastname: '', + hs_email_status: '', + hs_email_subject: '', + hs_email_text: '', + hs_email_to_email: '', + hs_email_to_firstname: '', + hs_email_to_lastname: '', + hs_lastmodifieddate: '', + hs_timestamp: '', + close_owner_id: '', +}; + +export type CloseEngagementInput = + | CloseEngagementCallInput + | CloseEngagementMeetingInput + | CloseEngagementEmailInput; + +export type CloseEngagementOutput = + | CloseEngagementCallOutput + | CloseEngagementMeetingOutput + | CloseEngagementEmailOutput; diff --git a/packages/api/src/crm/engagement/sync/sync.service.ts b/packages/api/src/crm/engagement/sync/sync.service.ts index 034c7de98..a494ef883 100644 --- a/packages/api/src/crm/engagement/sync/sync.service.ts +++ b/packages/api/src/crm/engagement/sync/sync.service.ts @@ -169,7 +169,9 @@ export class SyncService implements OnModuleInit { //unify the data according to the target obj wanted const unifiedObject = (await unify({ sourceObject, - targetType: CrmObject.engagement, + targetType: `engagement${ + engagement_type ? `_${engagement_type}` : '' + }` as CrmObject, providerName: integrationId, vertical: 'crm', customFieldMappings, diff --git a/packages/api/src/crm/engagement/types/mappingsTypes.ts b/packages/api/src/crm/engagement/types/mappingsTypes.ts index c59c8cf6f..e78f433a5 100644 --- a/packages/api/src/crm/engagement/types/mappingsTypes.ts +++ b/packages/api/src/crm/engagement/types/mappingsTypes.ts @@ -2,11 +2,13 @@ import { HubspotEngagementMapper } from '../services/hubspot/mappers'; import { PipedriveEngagementMapper } from '../services/pipedrive/mappers'; import { ZendeskEngagementMapper } from '../services/zendesk/mappers'; import { ZohoEngagementMapper } from '../services/zoho/mappers'; +import { CloseEngagementMapper } from '../services/close/mappers'; const hubspotEngagementMapper = new HubspotEngagementMapper(); const zendeskEngagementMapper = new ZendeskEngagementMapper(); const zohoEngagementMapper = new ZohoEngagementMapper(); const pipedriveEngagementMapper = new PipedriveEngagementMapper(); +const closeEngagmentMapper = new CloseEngagementMapper(); export const engagementUnificationMapping = { hubspot: { @@ -27,4 +29,8 @@ export const engagementUnificationMapping = { unify: zendeskEngagementMapper.unify.bind(zendeskEngagementMapper), desunify: zendeskEngagementMapper.desunify.bind(zendeskEngagementMapper), }, + close: { + unify: closeEngagmentMapper.unify.bind(closeEngagmentMapper), + desunify: closeEngagmentMapper.desunify.bind(closeEngagmentMapper), + }, }; diff --git a/packages/api/src/crm/note/note.module.ts b/packages/api/src/crm/note/note.module.ts index 1c2de8847..ba97f6b97 100644 --- a/packages/api/src/crm/note/note.module.ts +++ b/packages/api/src/crm/note/note.module.ts @@ -13,6 +13,7 @@ import { HubspotService } from './services/hubspot'; import { PipedriveService } from './services/pipedrive'; import { ZendeskService } from './services/zendesk'; import { ZohoService } from './services/zoho'; +import { CloseService } from './services/close'; @Module({ imports: [ @@ -38,6 +39,7 @@ import { ZohoService } from './services/zoho'; ZohoService, PipedriveService, HubspotService, + CloseService, ], exports: [ SyncService, diff --git a/packages/api/src/crm/note/services/close/index.ts b/packages/api/src/crm/note/services/close/index.ts new file mode 100644 index 000000000..ab278defd --- /dev/null +++ b/packages/api/src/crm/note/services/close/index.ts @@ -0,0 +1,105 @@ +import { Injectable } from '@nestjs/common'; +import { INoteService } from '@crm/note/types'; +import { CrmObject } from '@crm/@lib/@types'; +import { CloseNoteInput, CloseNoteOutput } from './types'; +import axios from 'axios'; +import { PrismaService } from '@@core/prisma/prisma.service'; +import { LoggerService } from '@@core/logger/logger.service'; +import { ActionType, handleServiceError } from '@@core/utils/errors'; +import { EncryptionService } from '@@core/encryption/encryption.service'; +import { ApiResponse } from '@@core/utils/types'; +import { ServiceRegistry } from '../registry.service'; + +@Injectable() +export class CloseService implements INoteService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + CrmObject.note.toUpperCase() + ':' + CloseService.name, + ); + this.registry.registerService('close', this); + } + async addNote( + noteData: CloseNoteInput, + linkedUserId: string, + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'close', + vertical: 'crm', + }, + }); + const resp = await axios.post( + `${connection.account_url}/activity/note`, + JSON.stringify(noteData), + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }, + ); + return { + data: resp?.data, + message: 'Close note created', + statusCode: 201, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Close', + CrmObject.note, + ActionType.POST, + ); + } + } + + async syncNotes( + linkedUserId: string, + custom_properties?: string[], + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'close', + vertical: 'crm', + }, + }); + + const baseURL = `${connection.account_url}/activity/note`; + + const resp = await axios.get(baseURL, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }); + this.logger.log(`Synced close notes !`); + return { + data: resp?.data?.data, + message: 'Close notes retrieved', + statusCode: 200, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Close', + CrmObject.note, + ActionType.GET, + ); + } + } +} diff --git a/packages/api/src/crm/note/services/close/mappers.ts b/packages/api/src/crm/note/services/close/mappers.ts new file mode 100644 index 000000000..31c12256a --- /dev/null +++ b/packages/api/src/crm/note/services/close/mappers.ts @@ -0,0 +1,125 @@ +import { CloseNoteInput, CloseNoteOutput } from './types'; +import { + UnifiedNoteInput, + UnifiedNoteOutput, +} from '@crm/note/types/model.unified'; +import { INoteMapper } from '@crm/note/types'; +import { Utils } from '@crm/@lib/@utils'; + +export class CloseNoteMapper implements INoteMapper { + private readonly utils: Utils; + + constructor() { + this.utils = new Utils(); + } + + async desunify( + source: UnifiedNoteInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + const result: CloseNoteInput = { + note_html: source.content, + }; + + if (source.company_id) { + const company_id = await this.utils.getRemoteIdFromCompanyUuid( + source.company_id, + ); + if (company_id) { + result.lead_id = company_id; + } + } + + if (customFieldMappings && source.field_mappings) { + for (const [k, v] of Object.entries(source.field_mappings)) { + const mapping = customFieldMappings.find( + (mapping) => mapping.slug === k, + ); + if (mapping) { + result[mapping.remote_id] = v; + } + } + } + + return result; + } + + async unify( + source: CloseNoteOutput | CloseNoteOutput[], + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + if (!Array.isArray(source)) { + return await this.mapSingleNoteToUnified(source, customFieldMappings); + } + + return Promise.all( + source.map((note) => + this.mapSingleNoteToUnified(note, customFieldMappings), + ), + ); + } + + private async mapSingleNoteToUnified( + note: CloseNoteOutput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + const field_mappings: { [key: string]: any } = {}; + if (customFieldMappings) { + for (const mapping of customFieldMappings) { + field_mappings[mapping.slug] = note[mapping.remote_id]; + } + } + + let opts: any = {}; + if (note.created_by || note.user_id) { + const owner_id = await this.utils.getUserUuidFromRemoteId( + note.created_by || note.user_id, + 'close', + ); + if (owner_id) { + opts = { + user_id: owner_id, + }; + } + } + if (note.contact_id) { + const contact_id = await this.utils.getContactUuidFromRemoteId( + note.contact_id, + 'close', + ); + if (contact_id) { + opts = { + ...opts, + contact_id: contact_id, + }; + } + } + if (note.lead_id) { + const lead_id = await this.utils.getCompanyUuidFromRemoteId( + note.lead_id, + 'close', + ); + if (lead_id) { + opts = { + ...opts, + company_id: lead_id, + }; + } + } + return { + remote_id: note.id, + content: note.note_html, + field_mappings, + ...opts, + }; + } +} diff --git a/packages/api/src/crm/note/services/close/types.ts b/packages/api/src/crm/note/services/close/types.ts new file mode 100644 index 000000000..4add97287 --- /dev/null +++ b/packages/api/src/crm/note/services/close/types.ts @@ -0,0 +1,42 @@ +interface NoteInput { + note_html: string; + lead_id: string; + attachments: Attachment[]; +} + +interface Attachment { + content_type: string; + filename: string; + url: string; +} + +export type CloseNoteInput = Partial; + +interface Note { + organization_id: string; + _type: 'Note'; + user_id: string; + user_name: string; + updated_by: string; + updated_by_name: string; + date_updated: string; + created_by: string; + created_by_name: string; + note_html: string; + note: string; + attachments: Attachment[]; + contact_id: string | null; + date_created: string; + id: string; + lead_id: string; +} + +interface Attachment { + content_type: string; + filename: string; + size: number; + url: string; + thumbnail_url: string; +} + +export type CloseNoteOutput = Partial; diff --git a/packages/api/src/crm/note/types/mappingsTypes.ts b/packages/api/src/crm/note/types/mappingsTypes.ts index dbed4456e..43b7f3446 100644 --- a/packages/api/src/crm/note/types/mappingsTypes.ts +++ b/packages/api/src/crm/note/types/mappingsTypes.ts @@ -2,11 +2,13 @@ import { HubspotNoteMapper } from '../services/hubspot/mappers'; import { PipedriveNoteMapper } from '../services/pipedrive/mappers'; import { ZendeskNoteMapper } from '../services/zendesk/mappers'; import { ZohoNoteMapper } from '../services/zoho/mappers'; +import { CloseNoteMapper } from '../services/close/mappers'; const hubspotNoteMapper = new HubspotNoteMapper(); const zendeskNoteMapper = new ZendeskNoteMapper(); const zohoNoteMapper = new ZohoNoteMapper(); const pipedriveNoteMapper = new PipedriveNoteMapper(); +const closeNoteMapper = new CloseNoteMapper(); export const noteUnificationMapping = { hubspot: { @@ -25,4 +27,8 @@ export const noteUnificationMapping = { unify: zendeskNoteMapper.unify.bind(zendeskNoteMapper), desunify: zendeskNoteMapper.desunify.bind(zendeskNoteMapper), }, + close: { + unify: closeNoteMapper.unify.bind(closeNoteMapper), + desunify: closeNoteMapper.desunify.bind(closeNoteMapper), + }, }; diff --git a/packages/api/src/crm/stage/services/close/index.ts b/packages/api/src/crm/stage/services/close/index.ts new file mode 100644 index 000000000..29235b67d --- /dev/null +++ b/packages/api/src/crm/stage/services/close/index.ts @@ -0,0 +1,70 @@ +import { Injectable } from '@nestjs/common'; +import { IStageService } from '@crm/stage/types'; +import { CrmObject } from '@crm/@lib/@types'; +import { CloseStageOutput, commonStageCloseProperties } from './types'; +import axios from 'axios'; +import { PrismaService } from '@@core/prisma/prisma.service'; +import { LoggerService } from '@@core/logger/logger.service'; +import { ActionType, handleServiceError } from '@@core/utils/errors'; +import { EncryptionService } from '@@core/encryption/encryption.service'; +import { ApiResponse } from '@@core/utils/types'; +import { ServiceRegistry } from '../registry.service'; + +@Injectable() +export class CloseService implements IStageService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + CrmObject.stage.toUpperCase() + ':' + CloseService.name, + ); + this.registry.registerService('close', this); + } + + async syncStages( + linkedUserId: string, + deal_id: string, + custom_properties?: string[], + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'close', + vertical: 'crm', + }, + }); + + const res = await this.prisma.crm_deals.findUnique({ + where: { id_crm_deal: deal_id }, + }); + const baseURL = `${connection.account_url}/activity/status_change/opportunity/?opportunity_id=${res?.remote_id}`; + + const resp = await axios.get(baseURL, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }); + this.logger.log(`Synced close stages !`); + return { + data: resp?.data?.data, + message: 'Close stages retrieved', + statusCode: 200, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Close', + CrmObject.stage, + ActionType.GET, + ); + } + } +} diff --git a/packages/api/src/crm/stage/services/close/mappers.ts b/packages/api/src/crm/stage/services/close/mappers.ts new file mode 100644 index 000000000..c26e06354 --- /dev/null +++ b/packages/api/src/crm/stage/services/close/mappers.ts @@ -0,0 +1,54 @@ +import { CloseStageOutput, CloseStageInput } from './types'; +import { + UnifiedStageInput, + UnifiedStageOutput, +} from '@crm/stage/types/model.unified'; +import { IStageMapper } from '@crm/stage/types'; + +export class CloseStageMapper implements IStageMapper { + desunify( + source: UnifiedStageInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): CloseStageInput { + return {}; + } + + unify( + source: CloseStageOutput | CloseStageOutput[], + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedStageOutput | UnifiedStageOutput[] { + if (!Array.isArray(source)) { + return this.mapSingleStageToUnified(source, customFieldMappings); + } + // Handling array of CloseStageOutput + return source.map((stage) => + this.mapSingleStageToUnified(stage, customFieldMappings), + ); + } + + private mapSingleStageToUnified( + stage: CloseStageOutput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedStageOutput { + const field_mappings: { [key: string]: any } = {}; + if (customFieldMappings) { + for (const mapping of customFieldMappings) { + field_mappings[mapping.slug] = stage[mapping.remote_id]; + } + } + return { + remote_id: stage.id, + stage_name: stage.new_status_label, + field_mappings, + }; + } +} diff --git a/packages/api/src/crm/stage/services/close/types.ts b/packages/api/src/crm/stage/services/close/types.ts new file mode 100644 index 000000000..84868e02d --- /dev/null +++ b/packages/api/src/crm/stage/services/close/types.ts @@ -0,0 +1,52 @@ +export interface CloseStageInput { + email?: string; + firstname?: string; + phone?: string; + lastname?: string; + city?: string; + country?: string; + zip?: string; + state?: string; + address?: string; + mobilephone?: string; + close_owner_id?: string; + associatedcompanyid?: string; + fax?: string; + jobtitle?: string; + [key: string]: any; +} + +interface OpportunityStatusChange { + organization_id: string; + _type: string; + contact_id: string | null; + created_by: string; + created_by_name: string; + date_created: string; + date_updated: string; + lead_id: string; + new_status_id: string; + new_status_label: string; + new_status_type: string; + new_pipeline_id: string; + old_status_id: string; + old_status_label: string; + old_status_type: string; + old_pipeline_id: string; + opportunity_date_won: string; + opportunity_id: string; + opportunity_value: number; + opportunity_value_formatted: string | null; + opportunity_value_currency: string; + updated_by: string; + updated_by_name: string; + user_id: string; + user_name: string; + id: string; +} + +export type CloseStageOutput = Partial; + +export const commonStageCloseProperties = { + dealstage: '', +}; diff --git a/packages/api/src/crm/stage/stage.module.ts b/packages/api/src/crm/stage/stage.module.ts index 3105933f1..32bd79860 100644 --- a/packages/api/src/crm/stage/stage.module.ts +++ b/packages/api/src/crm/stage/stage.module.ts @@ -13,6 +13,7 @@ import { HubspotService } from './services/hubspot'; import { PipedriveService } from './services/pipedrive'; import { ZendeskService } from './services/zendesk'; import { ZohoService } from './services/zoho'; +import { CloseService } from './services/close'; @Module({ imports: [ @@ -38,6 +39,7 @@ import { ZohoService } from './services/zoho'; ZohoService, PipedriveService, HubspotService, + CloseService, ], exports: [ SyncService, diff --git a/packages/api/src/crm/stage/types/mappingsTypes.ts b/packages/api/src/crm/stage/types/mappingsTypes.ts index cf9ca7ef1..3b26a390f 100644 --- a/packages/api/src/crm/stage/types/mappingsTypes.ts +++ b/packages/api/src/crm/stage/types/mappingsTypes.ts @@ -2,11 +2,13 @@ import { HubspotStageMapper } from '../services/hubspot/mappers'; import { PipedriveStageMapper } from '../services/pipedrive/mappers'; import { ZendeskStageMapper } from '../services/zendesk/mappers'; import { ZohoStageMapper } from '../services/zoho/mappers'; +import { CloseStageMapper } from '../services/close/mappers'; const hubspotStageMapper = new HubspotStageMapper(); const zendeskStageMapper = new ZendeskStageMapper(); const zohoStageMapper = new ZohoStageMapper(); const pipedriveStageMapper = new PipedriveStageMapper(); +const closeStageMapper = new CloseStageMapper(); export const stageUnificationMapping = { hubspot: { @@ -25,4 +27,8 @@ export const stageUnificationMapping = { unify: zendeskStageMapper.unify.bind(zendeskStageMapper), desunify: zendeskStageMapper.desunify.bind(zendeskStageMapper), }, + close: { + unify: closeStageMapper.unify.bind(closeStageMapper), + desunify: closeStageMapper.desunify.bind(closeStageMapper), + }, }; diff --git a/packages/api/src/crm/task/services/close/index.ts b/packages/api/src/crm/task/services/close/index.ts new file mode 100644 index 000000000..49ca59e24 --- /dev/null +++ b/packages/api/src/crm/task/services/close/index.ts @@ -0,0 +1,105 @@ +import { Injectable } from '@nestjs/common'; +import { ITaskService } from '@crm/task/types'; +import { CrmObject } from '@crm/@lib/@types'; +import { CloseTaskInput, CloseTaskOutput } from './types'; +import axios from 'axios'; +import { PrismaService } from '@@core/prisma/prisma.service'; +import { LoggerService } from '@@core/logger/logger.service'; +import { ActionType, handleServiceError } from '@@core/utils/errors'; +import { EncryptionService } from '@@core/encryption/encryption.service'; +import { ApiResponse } from '@@core/utils/types'; +import { ServiceRegistry } from '../registry.service'; + +@Injectable() +export class CloseService implements ITaskService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + CrmObject.task.toUpperCase() + ':' + CloseService.name, + ); + this.registry.registerService('close', this); + } + async addTask( + taskData: CloseTaskInput, + linkedUserId: string, + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'close', + vertical: 'crm', + }, + }); + const resp = await axios.post( + `${connection.account_url}/task`, + JSON.stringify(taskData), + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }, + ); + return { + data: resp?.data, + message: 'Close task created', + statusCode: 201, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Close', + CrmObject.task, + ActionType.POST, + ); + } + } + + async syncTasks( + linkedUserId: string, + custom_properties?: string[], + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'close', + vertical: 'crm', + }, + }); + + const baseURL = `${connection.account_url}/task`; + + const resp = await axios.get(baseURL, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }); + this.logger.log(`Synced close tasks !`); + return { + data: resp?.data?.data, + message: 'Close tasks retrieved', + statusCode: 200, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Close', + CrmObject.task, + ActionType.GET, + ); + } + } +} diff --git a/packages/api/src/crm/task/services/close/mappers.ts b/packages/api/src/crm/task/services/close/mappers.ts new file mode 100644 index 000000000..8a58ff67b --- /dev/null +++ b/packages/api/src/crm/task/services/close/mappers.ts @@ -0,0 +1,120 @@ +import { CloseTaskInput, CloseTaskOutput } from './types'; +import { + UnifiedTaskInput, + UnifiedTaskOutput, +} from '@crm/task/types/model.unified'; +import { ITaskMapper } from '@crm/task/types'; +import { Utils } from '@crm/@lib/@utils'; + +export class CloseTaskMapper implements ITaskMapper { + private readonly utils: Utils; + + constructor() { + this.utils = new Utils(); + } + async desunify( + source: UnifiedTaskInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + const result: CloseTaskInput = { + text: source?.content ?? '', + is_complete: source.status === 'COMPLETED', + _type: 'lead', + lead_id: '', + assigned_to: '', + date: '', + }; + + if (source.user_id) { + const owner_id = await this.utils.getRemoteIdFromUserUuid(source.user_id); + if (owner_id) { + result.assigned_to = owner_id; + } + } + if (source.company_id) { + const company_id = await this.utils.getRemoteIdFromCompanyUuid( + source.company_id, + ); + if (company_id) { + result.lead_id = company_id; + } + } + + if (customFieldMappings && source.field_mappings) { + for (const [k, v] of Object.entries(source.field_mappings)) { + const mapping = customFieldMappings.find( + (mapping) => mapping.slug === k, + ); + if (mapping) { + result[mapping.remote_id] = v; + } + } + } + + return result; + } + + async unify( + source: CloseTaskOutput | CloseTaskOutput[], + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + if (!Array.isArray(source)) { + return await this.mapSingleTaskToUnified(source, customFieldMappings); + } + + return Promise.all( + source.map((task) => + this.mapSingleTaskToUnified(task, customFieldMappings), + ), + ); + } + + private async mapSingleTaskToUnified( + task: CloseTaskOutput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + const field_mappings: { [key: string]: any } = {}; + if (customFieldMappings) { + for (const mapping of customFieldMappings) { + field_mappings[mapping.slug] = task[mapping.remote_id]; + } + } + const emptyPromise = new Promise((resolve) => { + return resolve(''); + }); + const promises = []; + + promises.push( + task.assigned_to + ? await this.utils.getUserUuidFromRemoteId(task.assigned_to, 'close') + : emptyPromise, + ); + promises.push( + task.lead_id + ? await this.utils.getCompanyUuidFromRemoteId(task.lead_id, 'close') + : emptyPromise, + ); + const [user_id, company_id] = await Promise.all(promises); + + return { + remote_id: task.id, + subject: '', + content: task.text, + status: task?.is_complete ? 'COMPLETED' : 'PENDING', + due_date: new Date(task.due_date), + finished_date: task.finished_date ? new Date(task.finished_date) : null, + field_mappings, + user_id, + company_id, + }; + } +} diff --git a/packages/api/src/crm/task/services/close/types.ts b/packages/api/src/crm/task/services/close/types.ts new file mode 100644 index 000000000..19c56a518 --- /dev/null +++ b/packages/api/src/crm/task/services/close/types.ts @@ -0,0 +1,36 @@ +export interface CloseTaskInput { + _type: string; + lead_id: string; + assigned_to: string; + text: string; + date: string; + is_complete: boolean; +} + +interface LeadTask { + _type: string; + assigned_to: string; + assigned_to_name: string; + contact_id: string | null; + contact_name: string | null; + created_by: string; + created_by_name: string; + date: string; + date_created: string; + date_updated: string; + id: string; + is_complete: boolean; + is_dateless: boolean; + lead_id: string; + lead_name: string; + object_id: string | null; + object_type: string | null; + organization_id: string; + text: string; + updated_by: string; + updated_by_name: string; + due_date: string | null; + finished_date: string | null; +} + +export type CloseTaskOutput = Partial; diff --git a/packages/api/src/crm/task/task.module.ts b/packages/api/src/crm/task/task.module.ts index e392a5f94..486134a06 100644 --- a/packages/api/src/crm/task/task.module.ts +++ b/packages/api/src/crm/task/task.module.ts @@ -13,6 +13,7 @@ import { HubspotService } from './services/hubspot'; import { PipedriveService } from './services/pipedrive'; import { ZendeskService } from './services/zendesk'; import { ZohoService } from './services/zoho'; +import { CloseService } from './services/close'; @Module({ imports: [ @@ -38,6 +39,7 @@ import { ZohoService } from './services/zoho'; ZohoService, PipedriveService, HubspotService, + CloseService, ], exports: [ SyncService, diff --git a/packages/api/src/crm/task/types/mappingsTypes.ts b/packages/api/src/crm/task/types/mappingsTypes.ts index 12c0fd786..37e4400ea 100644 --- a/packages/api/src/crm/task/types/mappingsTypes.ts +++ b/packages/api/src/crm/task/types/mappingsTypes.ts @@ -2,11 +2,13 @@ import { HubspotTaskMapper } from '../services/hubspot/mappers'; import { PipedriveTaskMapper } from '../services/pipedrive/mappers'; import { ZendeskTaskMapper } from '../services/zendesk/mappers'; import { ZohoTaskMapper } from '../services/zoho/mappers'; +import { CloseTaskMapper } from '../services/close/mappers'; const hubspotTaskMapper = new HubspotTaskMapper(); const zendeskTaskMapper = new ZendeskTaskMapper(); const zohoTaskMapper = new ZohoTaskMapper(); const pipedriveTaskMapper = new PipedriveTaskMapper(); +const closeTaskMapper = new CloseTaskMapper(); export const taskUnificationMapping = { hubspot: { @@ -25,4 +27,8 @@ export const taskUnificationMapping = { unify: zendeskTaskMapper.unify.bind(zendeskTaskMapper), desunify: zendeskTaskMapper.desunify.bind(zendeskTaskMapper), }, + close: { + unify: closeTaskMapper.unify.bind(closeTaskMapper), + desunify: closeTaskMapper.desunify.bind(closeTaskMapper), + }, }; diff --git a/packages/api/src/crm/user/services/close/index.ts b/packages/api/src/crm/user/services/close/index.ts new file mode 100644 index 000000000..d9ad8981b --- /dev/null +++ b/packages/api/src/crm/user/services/close/index.ts @@ -0,0 +1,67 @@ +import { Injectable } from '@nestjs/common'; +import { IUserService } from '@crm/user/types'; +import { CrmObject } from '@crm/@lib/@types'; +import { CloseUserOutput } from './types'; +import axios from 'axios'; +import { PrismaService } from '@@core/prisma/prisma.service'; +import { LoggerService } from '@@core/logger/logger.service'; +import { ActionType, handleServiceError } from '@@core/utils/errors'; +import { EncryptionService } from '@@core/encryption/encryption.service'; +import { ApiResponse } from '@@core/utils/types'; +import { ServiceRegistry } from '../registry.service'; + +@Injectable() +export class CloseService implements IUserService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + CrmObject.user.toUpperCase() + ':' + CloseService.name, + ); + this.registry.registerService('close', this); + } + + async syncUsers( + linkedUserId: string, + custom_properties?: string[], + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'close', + vertical: 'crm', + }, + }); + + const baseURL = `${connection.account_url}/user`; + const resp = await axios.get(baseURL, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }); + + this.logger.log(`Synced close users !`); + + return { + data: resp?.data?.data, + message: 'Close users retrieved', + statusCode: 200, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Close', + CrmObject.user, + ActionType.GET, + ); + } + } +} diff --git a/packages/api/src/crm/user/services/close/mappers.ts b/packages/api/src/crm/user/services/close/mappers.ts new file mode 100644 index 000000000..46ddf9fda --- /dev/null +++ b/packages/api/src/crm/user/services/close/mappers.ts @@ -0,0 +1,55 @@ +import { CloseUserInput, CloseUserOutput } from './types'; +import { + UnifiedUserInput, + UnifiedUserOutput, +} from '@crm/user/types/model.unified'; +import { IUserMapper } from '@crm/user/types'; + +export class CloseUserMapper implements IUserMapper { + desunify( + source: UnifiedUserInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): CloseUserInput { + return; + } + + unify( + source: CloseUserOutput | CloseUserOutput[], + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedUserOutput | UnifiedUserOutput[] { + if (!Array.isArray(source)) { + return this.mapSingleUserToUnified(source, customFieldMappings); + } + // Handling array of CloseUserOutput + return source.map((user) => + this.mapSingleUserToUnified(user, customFieldMappings), + ); + } + + private mapSingleUserToUnified( + user: CloseUserOutput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedUserOutput { + const field_mappings: { [key: string]: any } = {}; + if (customFieldMappings) { + for (const mapping of customFieldMappings) { + field_mappings[mapping.slug] = user[mapping.remote_id]; + } + } + return { + remote_id: user.id, + name: `${user.first_name} ${user.last_name}`, + email: user.email, + field_mappings, + }; + } +} diff --git a/packages/api/src/crm/user/services/close/types.ts b/packages/api/src/crm/user/services/close/types.ts new file mode 100644 index 000000000..b71512782 --- /dev/null +++ b/packages/api/src/crm/user/services/close/types.ts @@ -0,0 +1,14 @@ +export interface CloseUserInput { + [key: string]: any; +} + +export interface CloseUserOutput { + id: string; + email: string; + first_name: string; + last_name: string; + image: string; + organizations: string[]; + date_created: string; + date_updated: string; +} diff --git a/packages/api/src/crm/user/types/mappingsTypes.ts b/packages/api/src/crm/user/types/mappingsTypes.ts index df8a6ecbb..2c26f50f4 100644 --- a/packages/api/src/crm/user/types/mappingsTypes.ts +++ b/packages/api/src/crm/user/types/mappingsTypes.ts @@ -2,11 +2,13 @@ import { HubspotUserMapper } from '../services/hubspot/mappers'; import { PipedriveUserMapper } from '../services/pipedrive/mappers'; import { ZendeskUserMapper } from '../services/zendesk/mappers'; import { ZohoUserMapper } from '../services/zoho/mappers'; +import { CloseUserMapper } from '../services/close/mappers'; const hubspotUserMapper = new HubspotUserMapper(); const zendeskUserMapper = new ZendeskUserMapper(); const zohoUserMapper = new ZohoUserMapper(); const pipedriveUserMapper = new PipedriveUserMapper(); +const closeUserMapper = new CloseUserMapper(); export const userUnificationMapping = { hubspot: { @@ -25,4 +27,8 @@ export const userUnificationMapping = { unify: zendeskUserMapper.unify.bind(zendeskUserMapper), desunify: zendeskUserMapper.desunify.bind(zendeskUserMapper), }, + close: { + unify: closeUserMapper.unify.bind(closeUserMapper), + desunify: closeUserMapper.desunify.bind(closeUserMapper), + }, }; diff --git a/packages/api/src/crm/user/user.module.ts b/packages/api/src/crm/user/user.module.ts index 46960ac91..6d251c785 100644 --- a/packages/api/src/crm/user/user.module.ts +++ b/packages/api/src/crm/user/user.module.ts @@ -13,6 +13,7 @@ import { HubspotService } from './services/hubspot'; import { PipedriveService } from './services/pipedrive'; import { ZendeskService } from './services/zendesk'; import { ZohoService } from './services/zoho'; +import { CloseService } from './services/close'; @Module({ imports: [ @@ -38,6 +39,7 @@ import { ZohoService } from './services/zoho'; ZohoService, PipedriveService, HubspotService, + CloseService, ], exports: [ SyncService, diff --git a/packages/shared/src/authUrl.ts b/packages/shared/src/authUrl.ts index bb2463234..ac4d9922f 100644 --- a/packages/shared/src/authUrl.ts +++ b/packages/shared/src/authUrl.ts @@ -106,7 +106,8 @@ const handleOAuth2Url = async (input: HandleOAuth2Url) => { let params = `client_id=${encodeURIComponent(clientId)}&redirect_uri=${encodedRedirectUrl}&state=${state}`; // Adding scope for providers that require it, except for 'pipedrive' - if (scopes) { + const ignoreScopes = ['close'] + if (scopes && !ignoreScopes.includes(providerName)) { params += `&scope=${encodeURIComponent(scopes)}`; } diff --git a/packages/shared/src/connectors/enum.ts b/packages/shared/src/connectors/enum.ts index b1a2ffc88..7c3a770e9 100644 --- a/packages/shared/src/connectors/enum.ts +++ b/packages/shared/src/connectors/enum.ts @@ -3,7 +3,8 @@ export enum CrmConnectors { ZENDESK = 'zendesk', HUBSPOT = 'hubspot', PIPEDRIVE = 'pipedrive', - ATTIO = 'attio' + ATTIO = 'attio', + CLOSE = 'close' } export enum TicketingConnectors { diff --git a/packages/shared/src/connectors/index.ts b/packages/shared/src/connectors/index.ts index f7d873782..8de1dda5c 100644 --- a/packages/shared/src/connectors/index.ts +++ b/packages/shared/src/connectors/index.ts @@ -1,4 +1,4 @@ -export const CRM_PROVIDERS = ['zoho', 'zendesk', 'hubspot', 'pipedrive', 'attio']; +export const CRM_PROVIDERS = ['zoho', 'zendesk', 'hubspot', 'pipedrive', 'attio', 'close']; export const HRIS_PROVIDERS = ['']; export const ATS_PROVIDERS = ['']; export const ACCOUNTING_PROVIDERS = ['']; diff --git a/packages/shared/src/connectors/metadata.ts b/packages/shared/src/connectors/metadata.ts index a9ee5c056..6257d6866 100644 --- a/packages/shared/src/connectors/metadata.ts +++ b/packages/shared/src/connectors/metadata.ts @@ -131,7 +131,6 @@ export const CONNECTORS_METADATA: ProvidersConfig = { }, logoPath: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTEH77yPBUkStmoc1ZtgJS4XeBmQiaq_Q1vgF5oerOGbg&s', description: 'Sync & Create contacts, deals, companies, notes, engagements, stages, tasks and users', - active: false, authStrategy: AuthStrategy.oauth2 }, 'copper': {