Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add integration with Redtail (#554) #643

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions packages/api/src/crm/contact/services/redtail/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
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/@core-services/prisma/prisma.service';
import { LoggerService } from '@@core/@core-services/logger/logger.service';
import { EncryptionService } from '@@core/@core-services/encryption/encryption.service';
import { ApiResponse } from '@@core/utils/types';
import { ServiceRegistry } from '../registry.service';
import { RedtailContactInput, RedtailContactOutput } from './types';
import { SyncParam } from '@@core/utils/types/interface';

@Injectable()
export class RedtailService implements IContactService {
constructor(
private prisma: PrismaService,
private logger: LoggerService,
private cryptoService: EncryptionService,
private registry: ServiceRegistry,
) {
this.logger.setContext(
CrmObject.contact.toUpperCase() + ':' + RedtailService.name,
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use template literals for string concatenation.

Instead of using string concatenation, prefer template literals for better readability.

- this.logger.setContext(
-   CrmObject.contact.toUpperCase() + ':' + RedtailService.name,
- );
+ this.logger.setContext(
+   `${CrmObject.contact.toUpperCase()}:${RedtailService.name}`,
+ );
Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
this.logger.setContext(
CrmObject.contact.toUpperCase() + ':' + RedtailService.name,
);
this.logger.setContext(
`${CrmObject.contact.toUpperCase()}:${RedtailService.name}`,
);
Tools
Biome

[error] 22-22: Template literals are preferred over string concatenation.

Unsafe fix: Use a template literal.

(lint/style/useTemplate)

this.registry.registerService('redtail', this);
}

async addContact(
contactData: RedtailContactInput,
linkedUserId: string,
): Promise<ApiResponse<RedtailContactOutput>> {
try {
const connection = await this.prisma.connections.findFirst({
where: {
id_linked_user: linkedUserId,
provider_slug: 'redtail',
vertical: 'crm',
},
});

const authHeader = this.createAuthHeader(connection.api_key, connection.user_key);

const resp = await axios.post(
`${connection.account_url}/contacts`,
JSON.stringify(contactData),
{
headers: {
'Content-Type': 'application/json',
Authorization: authHeader,
},
},
);
return {
data: resp.data.data,
message: 'Redtail contact created',
statusCode: 201,
};
} catch (error) {
throw error;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove redundant catch clause.

The catch clause that only rethrows the original error is unnecessary and can be removed to simplify the code.

- } catch (error) {
-   throw error;
+ }
Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch (error) {
throw error;
}
Tools
Biome

[error] 58-58: The catch clause that only rethrows the original error is redundant.

These unnecessary catch clauses can be confusing. It is recommended to remove them.

(lint/complexity/noUselessCatch)

}
}

async sync(data: SyncParam): Promise<ApiResponse<RedtailContactOutput[]>> {
try {
const { linkedUserId } = data;

const connection = await this.prisma.connections.findFirst({
where: {
id_linked_user: linkedUserId,
provider_slug: 'redtail',
vertical: 'crm',
},
});

const authHeader = this.createAuthHeader(connection.api_key, connection.user_key);

const resp = await axios.get(`${connection.account_url}/contacts`, {
headers: {
'Content-Type': 'application/json',
Authorization: authHeader,
},
});

return {
data: resp.data.data,
message: 'Redtail contacts retrieved',
statusCode: 200,
};
} catch (error) {
throw error;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove redundant catch clause.

The catch clause that only rethrows the original error is unnecessary and can be removed to simplify the code.

- } catch (error) {
-   throw error;
+ }
Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
throw error;
}
}
Tools
Biome

[error] 89-89: The catch clause that only rethrows the original error is redundant.

These unnecessary catch clauses can be confusing. It is recommended to remove them.

(lint/complexity/noUselessCatch)

}

private createAuthHeader(apiKey: string, userKey: string): string {
const credentials = `${apiKey}:${userKey}`;
return `Userkeyauth ${Buffer.from(credentials).toString('base64')}`;
}
}
147 changes: 147 additions & 0 deletions packages/api/src/crm/contact/services/redtail/mappers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import {
UnifiedContactInput,
UnifiedContactOutput,
} from '@crm/contact/types/model.unified';
import { IContactMapper } from '@crm/contact/types';
import { RedtailContactInput, RedtailContactOutput } from './types';
import { Utils } from '@crm/lib/utils';
import { MappersRegistry } from '@core/core-services/registries/mappers.registry';
import { Injectable } from '@nestjs/common';

@Injectable()
export class RedtailContactMapper implements IContactMapper {
constructor(private mappersRegistry: MappersRegistry, private utils: Utils) {
this.mappersRegistry.registerService('crm', 'contact', 'redtail', this);
}

async desunify(
source: UnifiedContactInput,
customFieldMappings?: {
slug: string;
remote_id: string;
}[],
): Promise<RedtailContactInput> {
const primaryEmail = source.email_addresses?.[0]?.email_address;
const primaryPhone = source.phone_numbers?.[0]?.phone_number;

const emailObject = primaryEmail
? [{ value: primaryEmail, primary: true, label: '' }]
: [];
const phoneObject = primaryPhone
? [
{
value: primaryPhone,
primary: true,
label:
source.phone_numbers?.[0]?.phone_type == 'MOBILE'
? 'Mobile'
: '',
},
]
: [];
const result: RedtailContactInput = {
name: `${source.first_name} ${source.last_name}`,
email: emailObject,
phone: phoneObject,
};

if (source.user_id) {
const owner = await this.utils.getUser(source.user_id);
if (owner) {
result.owner_id = {
id: Number(owner.remote_id),
name: owner.name,
email: owner.email,
has_pic: 0,
pic_hash: "",
active_flag: false,
value: 0,
};
}
}

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: RedtailContactOutput | RedtailContactOutput[],
connectionId: string,
customFieldMappings?: {
slug: string;
remote_id: string;
}[],
): Promise<UnifiedContactOutput | UnifiedContactOutput[]> {
if (!Array.isArray(source)) {
return await this.mapSingleContactToUnified(
source,
connectionId,
customFieldMappings,
);
}

return Promise.all(
source.map((contact) =>
this.mapSingleContactToUnified(
contact,
connectionId,
customFieldMappings,
),
),
);
}

private async mapSingleContactToUnified(
contact: RedtailContactOutput,
connectionId: string,
customFieldMappings?: {
slug: string;
remote_id: string;
}[],
): Promise<UnifiedContactOutput> {
const field_mappings: { [key: string]: any } = {};
if (customFieldMappings) {
for (const mapping of customFieldMappings) {
field_mappings[mapping.slug] = contact[mapping.remote_id];
}
}
let opts: any = {};
if (contact.owner_id?.id) {
const user_id = await this.utils.getUserUuidFromRemoteId(
String(contact.owner_id.id),
connectionId,
);
if (user_id) {
opts = {
user_id: user_id,
};
}
}

return {
remote_id: String(contact.id),
remote_data: contact,
first_name: contact.first_name,
last_name: contact.last_name,
email_addresses: contact.email?.map((e) => ({
email_address: e.value,
email_address_type: e.label ? e.label.toUpperCase() : null,
})), // Map each email
phone_numbers: contact.phone?.map((p) => ({
phone_number: p.value,
phone_type: p.label ? p.label.toUpperCase() : null,
})), // Map each phone number,
field_mappings,
...opts,
};
}
}
79 changes: 79 additions & 0 deletions packages/api/src/crm/contact/services/redtail/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
export interface RedtailContact {
id: string;
company_id: number;
owner_id: {
id: number;
name: string;
email: string;
has_pic: number;
pic_hash: string;
active_flag: boolean;
value: number;
};
org_id: {
name: string;
people_count: number;
owner_id: number;
address: string;
active_flag: boolean;
cc_email: string;
value: number;
};
name: string;
first_name: string;
last_name: string;
open_deals_count: number;
related_open_deals_count: number;
closed_deals_count: number;
related_closed_deals_count: number;
participant_open_deals_count: number;
participant_closed_deals_count: number;
email_messages_count: number;
activities_count: number;
done_activities_count: number;
undone_activities_count: number;
files_count: number;
notes_count: number;
followers_count: number;
won_deals_count: number;
related_won_deals_count: number;
lost_deals_count: number;
related_lost_deals_count: number;
active_flag: boolean;
phone: { value: string; primary: boolean; label: string }[];
email: { value: string; primary: boolean; label: string }[];
primary_email: string;
first_char: string;
update_time: Date;
add_time: Date;
visible_to: string;
marketing_status: string;
picture_id: {
item_type: string;
item_id: number;
active_flag: boolean;
add_time: string;
update_time: string;
added_by_user_id: number;
pictures: {
'128': string;
'512': string;
};
value: number;
};
next_activity_date: string;
next_activity_time: string;
next_activity_id: number;
last_activity_id: number;
last_activity_date: string;
last_incoming_mail_time: string;
last_outgoing_mail_time: string;
label: number;
org_name: string;
owner_name: string;
cc_email: string;
[key: string]: any;
}

export type RedtailContactInput = Partial<RedtailContact>;
export type RedtailContactOutput = RedtailContactInput;
Comment on lines +78 to +79
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clarify the purpose of RedtailContactOutput.

The RedtailContactOutput type is identical to RedtailContactInput. If there are no specific transformations or constraints, consider using RedtailContactInput directly to avoid redundancy.

Loading
Loading