Basic data enrichment (#3023)

* Add Enrich to frontend

* Naive backend implementation

* Add work email check

* Rename Enrich to Quick Action

* Refactor logic to a separate service

* Refacto to separate IntelligenceService

* Small fixes

* Missing Break statement

* Address PR comments

* Create company interface

* Improve edge case handling

* Use httpService instead of Axios

* Fix server tests
This commit is contained in:
Félix Malfait
2023-12-18 15:45:30 +01:00
committed by GitHub
parent 576492f3c0
commit fff51a2d91
38 changed files with 16928 additions and 27 deletions

View File

@ -1,9 +1,15 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { AnalyticsService } from './analytics.service';
import { AnalyticsResolver } from './analytics.resolver';
@Module({
providers: [AnalyticsResolver, AnalyticsService],
imports: [
HttpModule.register({
baseURL: 'https://t.twenty.com/api/v1/s2s',
}),
],
})
export class AnalyticsModule {}

View File

@ -1,4 +1,5 @@
import { Test, TestingModule } from '@nestjs/testing';
import { HttpService } from '@nestjs/axios';
import { EnvironmentService } from 'src/integrations/environment/environment.service';
@ -17,6 +18,10 @@ describe('AnalyticsResolver', () => {
provide: EnvironmentService,
useValue: {},
},
{
provide: HttpService,
useValue: {},
},
],
}).compile();

View File

@ -1,4 +1,5 @@
import { Test, TestingModule } from '@nestjs/testing';
import { HttpService } from '@nestjs/axios';
import { EnvironmentService } from 'src/integrations/environment/environment.service';
@ -15,6 +16,10 @@ describe('AnalyticsService', () => {
provide: EnvironmentService,
useValue: {},
},
{
provide: HttpService,
useValue: {},
},
],
}).compile();

View File

@ -1,6 +1,5 @@
import { Injectable } from '@nestjs/common';
import axios, { AxiosInstance } from 'axios';
import { HttpService } from '@nestjs/axios';
import { anonymize } from 'src/utils/anonymize';
import { EnvironmentService } from 'src/integrations/environment/environment.service';
@ -11,13 +10,10 @@ import { CreateAnalyticsInput } from './dto/create-analytics.input';
@Injectable()
export class AnalyticsService {
private readonly httpService: AxiosInstance;
constructor(private readonly environmentService: EnvironmentService) {
this.httpService = axios.create({
baseURL: 'https://t.twenty.com/api/v1/s2s',
});
}
constructor(
private readonly environmentService: EnvironmentService,
private readonly httpService: HttpService,
) {}
async create(
createEventInput: CreateAnalyticsInput,

View File

@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { ApiRestController } from 'src/core/api-rest/api-rest.controller';
import { ApiRestService } from 'src/core/api-rest/api-rest.service';
@ -6,7 +7,7 @@ import { ApiRestQueryBuilderModule } from 'src/core/api-rest/api-rest-query-buil
import { AuthModule } from 'src/core/auth/auth.module';
@Module({
imports: [ApiRestQueryBuilderModule, AuthModule],
imports: [ApiRestQueryBuilderModule, AuthModule, HttpModule],
controllers: [ApiRestController],
providers: [ApiRestService],
})

View File

@ -1,4 +1,5 @@
import { Test, TestingModule } from '@nestjs/testing';
import { HttpService } from '@nestjs/axios';
import { ApiRestService } from 'src/core/api-rest/api-rest.service';
import { EnvironmentService } from 'src/integrations/environment/environment.service';
@ -24,6 +25,10 @@ describe('ApiRestService', () => {
provide: TokenService,
useValue: {},
},
{
provide: HttpService,
useValue: {},
},
],
}).compile();

View File

@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import axios from 'axios';
import { Request } from 'express';
import { EnvironmentService } from 'src/integrations/environment/environment.service';
@ -15,6 +15,7 @@ export class ApiRestService {
private readonly tokenService: TokenService,
private readonly environmentService: EnvironmentService,
private readonly apiRestQueryBuilderFactory: ApiRestQueryBuilderFactory,
private readonly httpService: HttpService,
) {}
async callGraphql(
@ -26,7 +27,7 @@ export class ApiRestService {
`${request.protocol}://${request.get('host')}`;
try {
return await axios.post(`${baseUrl}/graphql`, data, {
return await this.httpService.axiosRef.post(`${baseUrl}/graphql`, data, {
headers: {
'Content-Type': 'application/json',
Authorization: request.headers.authorization,

View File

@ -2,6 +2,7 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { TypeOrmModule } from '@nestjs/typeorm';
import { HttpModule } from '@nestjs/axios';
import { EnvironmentService } from 'src/integrations/environment/environment.service';
import { FileModule } from 'src/core/file/file.module';
@ -43,6 +44,7 @@ const jwtModule = JwtModule.registerAsync({
WorkspaceManagerModule,
TypeORMModule,
TypeOrmModule.forFeature([Workspace, User, RefreshToken], 'core'),
HttpModule,
],
controllers: [
GoogleAuthController,

View File

@ -1,5 +1,6 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { HttpService } from '@nestjs/axios';
import { UserService } from 'src/core/user/services/user.service';
import { WorkspaceManagerService } from 'src/workspace/workspace-manager/workspace-manager.service';
@ -33,6 +34,10 @@ describe('AuthService', () => {
provide: FileUploadService,
useValue: {},
},
{
provide: HttpService,
useValue: {},
},
{
provide: getRepositoryToken(Workspace, 'core'),
useValue: {},

View File

@ -5,6 +5,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { HttpService } from '@nestjs/axios';
import FileType from 'file-type';
import { Repository } from 'typeorm';
@ -48,6 +49,7 @@ export class AuthService {
private readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
private readonly httpService: HttpService,
) {}
async challenge(challengeInput: ChallengeInput) {
@ -135,7 +137,10 @@ export class AuthService {
let imagePath: string | undefined = undefined;
if (picture) {
const buffer = await getImageBufferFromUrl(picture);
const buffer = await getImageBufferFromUrl(
picture,
this.httpService.axiosRef,
);
const type = await FileType.fromBuffer(buffer);

View File

@ -0,0 +1,52 @@
import { HttpService } from '@nestjs/axios';
import { Injectable } from '@nestjs/common';
import { CompanyInteface } from 'src/core/quick-actions/interfaces/company.interface';
import { EnvironmentService } from 'src/integrations/environment/environment.service';
@Injectable()
export class IntelligenceService {
constructor(
private readonly environmentService: EnvironmentService,
private readonly httpService: HttpService,
) {}
async enrichCompany(domainName: string): Promise<CompanyInteface> {
const enrichedCompany = await this.httpService.axiosRef.get(
`https://companies.twenty.com/${domainName}`,
{
validateStatus: function () {
// This ensures the promise is always resolved, preventing axios from throwing an error
return true;
},
},
);
if (enrichedCompany.status !== 200) {
return {};
}
return {
linkedinLinkUrl: `https://linkedin.com/` + enrichedCompany.data.handle,
};
}
async completeWithAi(content: string) {
return this.httpService.axiosRef.post(
'https://openrouter.ai/api/v1/chat/completions',
{
headers: {
Authorization: `Bearer ${this.environmentService.getOpenRouterApiKey()}`,
'HTTP-Referer': `https://twenty.com`,
'X-Title': `Twenty CRM`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'mistralai/mixtral-8x7b-instruct',
messages: [{ role: 'user', content: content }],
}),
},
);
}
}

View File

@ -0,0 +1,3 @@
export interface CompanyInteface {
linkedinLinkUrl?: string;
}

View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { IntelligenceService } from 'src/core/quick-actions/intelligence.service';
import { QuickActionsService } from 'src/core/quick-actions/quick-actions.service';
import { WorkspaceQueryRunnerModule } from 'src/workspace/workspace-query-runner/workspace-query-runner.module';
@Module({
imports: [WorkspaceQueryRunnerModule, HttpModule],
controllers: [],
providers: [QuickActionsService, IntelligenceService],
exports: [QuickActionsService, IntelligenceService],
})
export class QuickActionsModule {}

View File

@ -0,0 +1,154 @@
import { Injectable } from '@nestjs/common';
import { v4 as uuidv4 } from 'uuid';
import { Record as IRecord } from 'src/workspace/workspace-query-builder/interfaces/record.interface';
import { isWorkEmail } from 'src/utils/is-work-email';
import { stringifyWithoutKeyQuote } from 'src/workspace/workspace-query-builder/utils/stringify-without-key-quote.util';
import { WorkspaceQueryRunnerService } from 'src/workspace/workspace-query-runner/workspace-query-runner.service';
import { IntelligenceService } from 'src/core/quick-actions/intelligence.service';
import { capitalize } from 'src/utils/capitalize';
@Injectable()
export class QuickActionsService {
constructor(
private readonly workspaceQueryRunnunerService: WorkspaceQueryRunnerService,
private readonly intelligenceService: IntelligenceService,
) {}
async createCompanyFromPerson(id: string, workspaceId: string) {
const personRequest =
await this.workspaceQueryRunnunerService.executeAndParse<IRecord>(
`query {
personCollection(filter: {id: {eq: "${id}"}}) {
edges {
node {
id
email
companyId
}
}
}
}
`,
'person',
'',
workspaceId,
);
const person = personRequest.edges?.[0]?.node;
if (!person) {
return;
}
if (!person.companyId && person.email && isWorkEmail(person.email)) {
const companyDomainName = person.email.split('@')?.[1].toLowerCase();
const companyName = capitalize(companyDomainName.split('.')[0]);
let relatedCompanyId = uuidv4();
const existingCompany =
await this.workspaceQueryRunnunerService.executeAndParse<IRecord>(
`query {companyCollection(filter: {domainName: {eq: "${companyDomainName}"}}) {
edges {
node {
id
}
}
}
}
`,
'company',
'',
workspaceId,
);
if (existingCompany.edges?.length) {
relatedCompanyId = existingCompany.edges[0].node.id;
}
await this.workspaceQueryRunnunerService.execute(
`mutation {
insertIntocompanyCollection(objects: ${stringifyWithoutKeyQuote([
{
id: relatedCompanyId,
name: companyName,
domainName: companyDomainName,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
])}) {
affectedCount
records {
id
}
}
}
`,
workspaceId,
);
await this.workspaceQueryRunnunerService.execute(
`mutation {
updatepersonCollection(set: ${stringifyWithoutKeyQuote({
companyId: relatedCompanyId,
})}, filter: { id: { eq: "${person.id}" } }) {
affectedCount
records {
id
}
}
}
`,
workspaceId,
);
}
}
async executeQuickActionOnCompany(id: string, workspaceId: string) {
const companyQuery = `query {
companyCollection(filter: {id: {eq: "${id}"}}) {
edges {
node {
id
domainName
createdAt
linkedinLinkUrl
}
}
}
}
`;
const companyRequest =
await this.workspaceQueryRunnunerService.executeAndParse<IRecord>(
companyQuery,
'company',
'',
workspaceId,
);
const company = companyRequest.edges?.[0]?.node;
if (!company) {
return;
}
const enrichedData = await this.intelligenceService.enrichCompany(
company.domainName,
);
await this.workspaceQueryRunnunerService.execute(
`mutation {
updatecompanyCollection(set: ${stringifyWithoutKeyQuote(
enrichedData,
)}, filter: { id: { eq: "${id}" } }) {
affectedCount
records {
id
}
}
}`,
workspaceId,
);
}
}