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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 16928 additions and 27 deletions

View File

@ -80,8 +80,14 @@ import OptionTable from '@site/src/theme/OptionTable'
['SENTRY_DSN', 'https://xxx@xxx.ingest.sentry.io/xxx', 'The sentry logging endpoint used if sentry logging driver is selected'], ['SENTRY_DSN', 'https://xxx@xxx.ingest.sentry.io/xxx', 'The sentry logging endpoint used if sentry logging driver is selected'],
]}></OptionTable> ]}></OptionTable>
### Support
### Data enrichment and AI
<OptionTable options={[
['OPENROUTER_API_KEY', '', "The API key for openrouter.ai, an abstraction layer over models from Mistral, OpenAI and more"]
]}></OptionTable>
### Support Chat
<OptionTable options={[ <OptionTable options={[
['SUPPORT_DRIVER', 'front', "Support driver ('front' or 'none')"], ['SUPPORT_DRIVER', 'front', "Support driver ('front' or 'none')"],

View File

@ -10,6 +10,7 @@ import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadat
import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock'; import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock';
import { useGenerateCreateManyRecordMutation } from '@/object-record/hooks/useGenerateCreateManyRecordMutation'; import { useGenerateCreateManyRecordMutation } from '@/object-record/hooks/useGenerateCreateManyRecordMutation';
import { useGenerateCreateOneRecordMutation } from '@/object-record/hooks/useGenerateCreateOneRecordMutation'; import { useGenerateCreateOneRecordMutation } from '@/object-record/hooks/useGenerateCreateOneRecordMutation';
import { useGenerateExecuteQuickActionOnOneRecordMutation } from '@/object-record/hooks/useGenerateExecuteQuickActionOnOneRecordMutation';
import { useGenerateFindManyRecordsQuery } from '@/object-record/hooks/useGenerateFindManyRecordsQuery'; import { useGenerateFindManyRecordsQuery } from '@/object-record/hooks/useGenerateFindManyRecordsQuery';
import { useGenerateFindOneRecordQuery } from '@/object-record/hooks/useGenerateFindOneRecordQuery'; import { useGenerateFindOneRecordQuery } from '@/object-record/hooks/useGenerateFindOneRecordQuery';
import { useGenerateUpdateOneRecordMutation } from '@/object-record/hooks/useGenerateUpdateOneRecordMutation'; import { useGenerateUpdateOneRecordMutation } from '@/object-record/hooks/useGenerateUpdateOneRecordMutation';
@ -106,6 +107,11 @@ export const useObjectMetadataItem = (
objectMetadataItem, objectMetadataItem,
}); });
const executeQuickActionOnOneRecordMutation =
useGenerateExecuteQuickActionOnOneRecordMutation({
objectMetadataItem,
});
const labelIdentifierFieldMetadataId = objectMetadataItem.fields.find( const labelIdentifierFieldMetadataId = objectMetadataItem.fields.find(
({ name }) => name === 'name', ({ name }) => name === 'name',
)?.id; )?.id;
@ -123,6 +129,7 @@ export const useObjectMetadataItem = (
createOneRecordMutation, createOneRecordMutation,
updateOneRecordMutation, updateOneRecordMutation,
deleteOneRecordMutation, deleteOneRecordMutation,
executeQuickActionOnOneRecordMutation,
createManyRecordsMutation, createManyRecordsMutation,
mapToObjectRecordIdentifier, mapToObjectRecordIdentifier,
getObjectOrderByField, getObjectOrderByField,

View File

@ -0,0 +1,50 @@
import { useCallback } from 'react';
import { useApolloClient } from '@apollo/client';
import { getOperationName } from '@apollo/client/utilities';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { capitalize } from '~/utils/string/capitalize';
type useExecuteQuickActionOnOneRecordProps = {
objectNameSingular: string;
};
export const useExecuteQuickActionOnOneRecord = <T>({
objectNameSingular,
}: useExecuteQuickActionOnOneRecordProps) => {
const {
objectMetadataItem,
executeQuickActionOnOneRecordMutation,
findManyRecordsQuery,
} = useObjectMetadataItem({
objectNameSingular,
});
const apolloClient = useApolloClient();
const executeQuickActionOnOneRecord = useCallback(
async (idToExecuteQuickActionOn: string) => {
const executeQuickActionOnRecord = await apolloClient.mutate({
mutation: executeQuickActionOnOneRecordMutation,
variables: {
idToExecuteQuickActionOn,
},
refetchQueries: [getOperationName(findManyRecordsQuery) ?? ''],
});
return executeQuickActionOnRecord.data[
`executeQuickActionOn${capitalize(objectMetadataItem.nameSingular)}`
] as T;
},
[
objectMetadataItem.nameSingular,
apolloClient,
executeQuickActionOnOneRecordMutation,
findManyRecordsQuery,
],
);
return {
executeQuickActionOnOneRecord,
};
};

View File

@ -0,0 +1,44 @@
import { gql } from '@apollo/client';
import { useMapFieldMetadataToGraphQLQuery } from '@/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery';
import { EMPTY_MUTATION } from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { capitalize } from '~/utils/string/capitalize';
export const getExecuteQuickActionOnOneRecordMutationGraphQLField = ({
objectNameSingular,
}: {
objectNameSingular: string;
}) => {
return `executeQuickActionOn${capitalize(objectNameSingular)}`;
};
export const useGenerateExecuteQuickActionOnOneRecordMutation = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) => {
const mapFieldMetadataToGraphQLQuery = useMapFieldMetadataToGraphQLQuery();
if (!objectMetadataItem) {
return EMPTY_MUTATION;
}
const capitalizedObjectName = capitalize(objectMetadataItem.nameSingular);
const graphQLFieldForExecuteQuickActionOnOneRecordMutation =
getExecuteQuickActionOnOneRecordMutationGraphQLField({
objectNameSingular: objectMetadataItem.nameSingular,
});
return gql`
mutation ExecuteQuickActionOnOne${capitalizedObjectName}($idToExecuteQuickActionOn: ID!) {
${graphQLFieldForExecuteQuickActionOnOneRecordMutation}(id: $idToExecuteQuickActionOn) {
id
${objectMetadataItem.fields
.map((field) => mapFieldMetadataToGraphQLQuery(field))
.join('\n')}
}
}
`;
};

View File

@ -5,14 +5,21 @@ import { useRecoilCallback, useRecoilValue, useSetRecoilState } from 'recoil';
import { useFavorites } from '@/favorites/hooks/useFavorites'; import { useFavorites } from '@/favorites/hooks/useFavorites';
import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural'; import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural';
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord'; import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
import { useExecuteQuickActionOnOneRecord } from '@/object-record/hooks/useExecuteQuickActionOnOneRecord';
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable'; import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
import { RecordTableScopeInternalContext } from '@/object-record/record-table/scopes/scope-internal-context/RecordTableScopeInternalContext'; import { RecordTableScopeInternalContext } from '@/object-record/record-table/scopes/scope-internal-context/RecordTableScopeInternalContext';
import { selectedRowIdsSelector } from '@/object-record/record-table/states/selectors/selectedRowIdsSelector'; import { selectedRowIdsSelector } from '@/object-record/record-table/states/selectors/selectedRowIdsSelector';
import { IconHeart, IconHeartOff, IconTrash } from '@/ui/display/icon'; import {
IconHeart,
IconHeartOff,
IconTrash,
IconWand,
} from '@/ui/display/icon';
import { actionBarEntriesState } from '@/ui/navigation/action-bar/states/actionBarEntriesState'; import { actionBarEntriesState } from '@/ui/navigation/action-bar/states/actionBarEntriesState';
import { contextMenuEntriesState } from '@/ui/navigation/context-menu/states/contextMenuEntriesState'; import { contextMenuEntriesState } from '@/ui/navigation/context-menu/states/contextMenuEntriesState';
import { ContextMenuEntry } from '@/ui/navigation/context-menu/types/ContextMenuEntry'; import { ContextMenuEntry } from '@/ui/navigation/context-menu/types/ContextMenuEntry';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId'; import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
type useRecordTableContextMenuEntriesProps = { type useRecordTableContextMenuEntriesProps = {
recordTableScopeId?: string; recordTableScopeId?: string;
@ -68,6 +75,10 @@ export const useRecordTableContextMenuEntries = (
objectNameSingular, objectNameSingular,
}); });
const { executeQuickActionOnOneRecord } = useExecuteQuickActionOnOneRecord({
objectNameSingular,
});
const handleDeleteClick = useRecoilCallback( const handleDeleteClick = useRecoilCallback(
({ snapshot }) => ({ snapshot }) =>
async () => { async () => {
@ -85,6 +96,27 @@ export const useRecordTableContextMenuEntries = (
[deleteOneRecord, resetTableRowSelection], [deleteOneRecord, resetTableRowSelection],
); );
const handleExecuteQuickActionOnClick = useRecoilCallback(
({ snapshot }) =>
async () => {
const rowIdsToExecuteQuickActionOn = snapshot
.getLoadable(selectedRowIdsSelector)
.getValue();
resetTableRowSelection();
await Promise.all(
rowIdsToExecuteQuickActionOn.map(async (rowId) => {
await executeQuickActionOnOneRecord(rowId);
}),
);
},
[executeQuickActionOnOneRecord, resetTableRowSelection],
);
const dataExecuteQuickActionOnmentEnabled = useIsFeatureEnabled(
'IS_QUICK_ACTIONS_ENABLED',
);
return { return {
setContextMenuEntries: useCallback(() => { setContextMenuEntries: useCallback(() => {
const selectedRowId = const selectedRowId =
@ -143,6 +175,15 @@ export const useRecordTableContextMenuEntries = (
// Icon: IconNotes, // Icon: IconNotes,
// onClick: () => {}, // onClick: () => {},
// }, // },
...(dataExecuteQuickActionOnmentEnabled
? [
{
label: 'Quick Action',
Icon: IconWand,
onClick: () => handleExecuteQuickActionOnClick(),
},
]
: []),
{ {
label: 'Delete', label: 'Delete',
Icon: IconTrash, Icon: IconTrash,

View File

@ -107,6 +107,7 @@ export {
IconUserCircle, IconUserCircle,
IconUsers, IconUsers,
IconVideo, IconVideo,
IconWand,
IconWorld, IconWorld,
IconX, IconX,
} from '@tabler/icons-react'; } from '@tabler/icons-react';

View File

@ -39,6 +39,7 @@
"@graphql-tools/schema": "^10.0.0", "@graphql-tools/schema": "^10.0.0",
"@graphql-yoga/nestjs": "patch:@graphql-yoga/nestjs@2.1.0#./patches/@graphql-yoga+nestjs+2.1.0.patch", "@graphql-yoga/nestjs": "patch:@graphql-yoga/nestjs@2.1.0#./patches/@graphql-yoga+nestjs+2.1.0.patch",
"@nestjs/apollo": "^11.0.5", "@nestjs/apollo": "^11.0.5",
"@nestjs/axios": "^3.0.1",
"@nestjs/common": "^9.0.0", "@nestjs/common": "^9.0.0",
"@nestjs/config": "^2.3.2", "@nestjs/config": "^2.3.2",
"@nestjs/core": "^9.0.0", "@nestjs/core": "^9.0.0",
@ -59,7 +60,7 @@
"@types/lodash.merge": "^4.6.7", "@types/lodash.merge": "^4.6.7",
"add": "^2.0.6", "add": "^2.0.6",
"apollo-server-express": "^3.12.0", "apollo-server-express": "^3.12.0",
"axios": "^1.4.0", "axios": "^1.6.2",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"body-parser": "^1.20.2", "body-parser": "^1.20.2",
"bullmq": "^4.14.0", "bullmq": "^4.14.0",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -210,10 +210,14 @@ export class EnvironmentService {
} }
getSentryDSN(): string | undefined { getSentryDSN(): string | undefined {
return this.configService.get<string>('SENTRY_DSN'); return this.configService.get<string | undefined>('SENTRY_DSN');
} }
getDemoWorkspaceIds(): string[] { getDemoWorkspaceIds(): string[] {
return this.configService.get<string[]>('DEMO_WORKSPACE_IDS') ?? []; return this.configService.get<string[]>('DEMO_WORKSPACE_IDS') ?? [];
} }
getOpenRouterApiKey(): string | undefined {
return this.configService.get<string | undefined>('OPENROUTER_API_KEY');
}
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
import axios from 'axios'; import { Axios } from 'axios';
const cropRegex = /([w|h])([0-9]+)/; const cropRegex = /([w|h])([0-9]+)/;
@ -22,8 +22,11 @@ export const getCropSize = (value: ShortCropSize): CropSize | null => {
}; };
}; };
export const getImageBufferFromUrl = async (url: string): Promise<Buffer> => { export const getImageBufferFromUrl = async (
const response = await axios.get(url, { url: string,
axiosInstance: Axios,
): Promise<Buffer> => {
const response = await axiosInstance.get(url, {
responseType: 'arraybuffer', responseType: 'arraybuffer',
}); });

View File

@ -0,0 +1,21 @@
import { emailProvidersSet } from 'src/utils/email-providers';
export const isWorkEmail = (email: string) => {
if (!email) {
return false;
}
const fields = email.split('@');
if (fields.length !== 2) {
return false;
}
const domain = fields[1];
if (!domain) {
return false;
}
return !emailProvidersSet.has(domain);
};

View File

@ -15,6 +15,7 @@ describe('getResolverName', () => {
['createOne', 'createEntity'], ['createOne', 'createEntity'],
['updateOne', 'updateEntity'], ['updateOne', 'updateEntity'],
['deleteOne', 'deleteEntity'], ['deleteOne', 'deleteEntity'],
['executeQuickActionOnOne', 'executeQuickActionOnEntity'],
])('should return correct name for %s resolver', (type, expectedResult) => { ])('should return correct name for %s resolver', (type, expectedResult) => {
expect( expect(
getResolverName(metadata, type as WorkspaceResolverBuilderMethodNames), getResolverName(metadata, type as WorkspaceResolverBuilderMethodNames),

View File

@ -21,6 +21,8 @@ export const getResolverName = (
return `update${pascalCase(objectMetadata.nameSingular)}`; return `update${pascalCase(objectMetadata.nameSingular)}`;
case 'deleteOne': case 'deleteOne':
return `delete${pascalCase(objectMetadata.nameSingular)}`; return `delete${pascalCase(objectMetadata.nameSingular)}`;
case 'executeQuickActionOnOne':
return `executeQuickActionOn${pascalCase(objectMetadata.nameSingular)}`;
case 'updateMany': case 'updateMany':
return `update${pascalCase(objectMetadata.namePlural)}`; return `update${pascalCase(objectMetadata.namePlural)}`;
case 'deleteMany': case 'deleteMany':

View File

@ -1,6 +1,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ObjectMetadataModule } from 'src/metadata/object-metadata/object-metadata.module'; import { ObjectMetadataModule } from 'src/metadata/object-metadata/object-metadata.module';
import { FieldsStringFactory } from 'src/workspace/workspace-query-builder/factories/fields-string.factory';
import { WorkspaceQueryBuilderFactory } from './workspace-query-builder.factory'; import { WorkspaceQueryBuilderFactory } from './workspace-query-builder.factory';
@ -9,6 +10,6 @@ import { workspaceQueryBuilderFactories } from './factories/factories';
@Module({ @Module({
imports: [ObjectMetadataModule], imports: [ObjectMetadataModule],
providers: [...workspaceQueryBuilderFactories, WorkspaceQueryBuilderFactory], providers: [...workspaceQueryBuilderFactories, WorkspaceQueryBuilderFactory],
exports: [WorkspaceQueryBuilderFactory], exports: [WorkspaceQueryBuilderFactory, FieldsStringFactory],
}) })
export class WorkspaceQueryBuilderModule {} export class WorkspaceQueryBuilderModule {}

View File

@ -181,7 +181,7 @@ export class WorkspaceQueryRunnerService {
)?.records; )?.records;
} }
private async execute( async execute(
query: string, query: string,
workspaceId: string, workspaceId: string,
): Promise<PGGraphQLResult | undefined> { ): Promise<PGGraphQLResult | undefined> {
@ -215,7 +215,7 @@ export class WorkspaceQueryRunnerService {
const errors = graphqlResult?.[0]?.resolve?.errors; const errors = graphqlResult?.[0]?.resolve?.errors;
if (Array.isArray(errors) && errors.length > 0) { if (Array.isArray(errors) && errors.length > 0) {
console.error('GraphQL errors', errors); console.error(`GraphQL errors on ${command}${targetTableName}`, errors);
} }
if (!result) { if (!result) {
@ -224,4 +224,15 @@ export class WorkspaceQueryRunnerService {
return parseResult(result); return parseResult(result);
} }
async executeAndParse<Result>(
query: string,
targetTableName: string,
command: string,
workspaceId: string,
): Promise<Result> {
const result = await this.execute(query, workspaceId);
return this.parseResult(result, targetTableName, command);
}
} }

View File

@ -0,0 +1,72 @@
import { Injectable } from '@nestjs/common';
import {
Resolver,
FindOneResolverArgs,
ExecuteQuickActionOnOneResolverArgs,
DeleteOneResolverArgs,
} from 'src/workspace/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { Record as IRecord } from 'src/workspace/workspace-query-builder/interfaces/record.interface';
import { WorkspaceSchemaBuilderContext } from 'src/workspace/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { WorkspaceResolverBuilderFactoryInterface } from 'src/workspace/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import { WorkspaceQueryRunnerOptions } from 'src/workspace/workspace-query-runner/interfaces/query-runner-optionts.interface';
import { WorkspaceQueryRunnerService } from 'src/workspace/workspace-query-runner/workspace-query-runner.service';
import { QuickActionsService } from 'src/core/quick-actions/quick-actions.service';
@Injectable()
export class ExecuteQuickActionOnOneResolverFactory
implements WorkspaceResolverBuilderFactoryInterface
{
public static methodName = 'executeQuickActionOnOne' as const;
constructor(
private readonly workspaceQueryRunnerService: WorkspaceQueryRunnerService,
private readonly quickActionsService: QuickActionsService,
) {}
create(
context: WorkspaceSchemaBuilderContext,
): Resolver<ExecuteQuickActionOnOneResolverArgs> {
const internalContext = context;
return (_source, args, context, info) => {
return this.executeQuickActionOnOne(args, {
targetTableName: internalContext.targetTableName,
workspaceId: internalContext.workspaceId,
info,
fieldMetadataCollection: internalContext.fieldMetadataCollection,
});
};
}
private async executeQuickActionOnOne<Record extends IRecord = IRecord>(
args: DeleteOneResolverArgs,
options: WorkspaceQueryRunnerOptions,
): Promise<Record | undefined> {
switch (options.targetTableName) {
case 'company': {
await this.quickActionsService.executeQuickActionOnCompany(
args.id,
options.workspaceId,
);
break;
}
case 'person': {
await this.quickActionsService.createCompanyFromPerson(
args.id,
options.workspaceId,
);
break;
}
default:
// TODO: different quick actions per object on frontend
break;
}
return this.workspaceQueryRunnerService.findOne(
{ filter: { id: { eq: args.id } } } as FindOneResolverArgs,
options,
);
}
}

View File

@ -7,6 +7,7 @@ import { CreateOneResolverFactory } from './create-one-resolver.factory';
import { UpdateOneResolverFactory } from './update-one-resolver.factory'; import { UpdateOneResolverFactory } from './update-one-resolver.factory';
import { DeleteOneResolverFactory } from './delete-one-resolver.factory'; import { DeleteOneResolverFactory } from './delete-one-resolver.factory';
import { DeleteManyResolverFactory } from './delete-many-resolver.factory'; import { DeleteManyResolverFactory } from './delete-many-resolver.factory';
import { ExecuteQuickActionOnOneResolverFactory } from './execute-quick-action-on-one-resolver.factory';
export const workspaceResolverBuilderFactories = [ export const workspaceResolverBuilderFactories = [
FindManyResolverFactory, FindManyResolverFactory,
@ -15,6 +16,7 @@ export const workspaceResolverBuilderFactories = [
CreateOneResolverFactory, CreateOneResolverFactory,
UpdateOneResolverFactory, UpdateOneResolverFactory,
DeleteOneResolverFactory, DeleteOneResolverFactory,
ExecuteQuickActionOnOneResolverFactory,
UpdateManyResolverFactory, UpdateManyResolverFactory,
DeleteManyResolverFactory, DeleteManyResolverFactory,
]; ];
@ -29,6 +31,7 @@ export const workspaceResolverBuilderMethodNames = {
CreateOneResolverFactory.methodName, CreateOneResolverFactory.methodName,
UpdateOneResolverFactory.methodName, UpdateOneResolverFactory.methodName,
DeleteOneResolverFactory.methodName, DeleteOneResolverFactory.methodName,
ExecuteQuickActionOnOneResolverFactory.methodName,
UpdateManyResolverFactory.methodName, UpdateManyResolverFactory.methodName,
DeleteManyResolverFactory.methodName, DeleteManyResolverFactory.methodName,
], ],

View File

@ -51,6 +51,10 @@ export interface DeleteOneResolverArgs {
id: string; id: string;
} }
export interface ExecuteQuickActionOnOneResolverArgs {
id: string;
}
export interface DeleteManyResolverArgs<Filter = any> { export interface DeleteManyResolverArgs<Filter = any> {
filter: Filter; filter: Filter;
} }

View File

@ -1,13 +1,14 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { WorkspaceQueryRunnerModule } from 'src/workspace/workspace-query-runner/workspace-query-runner.module'; import { WorkspaceQueryRunnerModule } from 'src/workspace/workspace-query-runner/workspace-query-runner.module';
import { QuickActionsModule } from 'src/core/quick-actions/quick-actions.module';
import { WorkspaceResolverFactory } from './workspace-resolver.factory'; import { WorkspaceResolverFactory } from './workspace-resolver.factory';
import { workspaceResolverBuilderFactories } from './factories/factories'; import { workspaceResolverBuilderFactories } from './factories/factories';
@Module({ @Module({
imports: [WorkspaceQueryRunnerModule], imports: [WorkspaceQueryRunnerModule, QuickActionsModule],
providers: [...workspaceResolverBuilderFactories, WorkspaceResolverFactory], providers: [...workspaceResolverBuilderFactories, WorkspaceResolverFactory],
exports: [WorkspaceResolverFactory], exports: [WorkspaceResolverFactory],
}) })

View File

@ -7,6 +7,7 @@ import { ObjectMetadataInterface } from 'src/metadata/field-metadata/interfaces/
import { getResolverName } from 'src/workspace/utils/get-resolver-name.util'; import { getResolverName } from 'src/workspace/utils/get-resolver-name.util';
import { UpdateManyResolverFactory } from 'src/workspace/workspace-resolver-builder/factories/update-many-resolver.factory'; import { UpdateManyResolverFactory } from 'src/workspace/workspace-resolver-builder/factories/update-many-resolver.factory';
import { DeleteManyResolverFactory } from 'src/workspace/workspace-resolver-builder/factories/delete-many-resolver.factory'; import { DeleteManyResolverFactory } from 'src/workspace/workspace-resolver-builder/factories/delete-many-resolver.factory';
import { ExecuteQuickActionOnOneResolverFactory } from 'src/workspace/workspace-resolver-builder/factories/execute-quick-action-on-one-resolver.factory';
import { FindManyResolverFactory } from './factories/find-many-resolver.factory'; import { FindManyResolverFactory } from './factories/find-many-resolver.factory';
import { FindOneResolverFactory } from './factories/find-one-resolver.factory'; import { FindOneResolverFactory } from './factories/find-one-resolver.factory';
@ -31,6 +32,7 @@ export class WorkspaceResolverFactory {
private readonly createOneResolverFactory: CreateOneResolverFactory, private readonly createOneResolverFactory: CreateOneResolverFactory,
private readonly updateOneResolverFactory: UpdateOneResolverFactory, private readonly updateOneResolverFactory: UpdateOneResolverFactory,
private readonly deleteOneResolverFactory: DeleteOneResolverFactory, private readonly deleteOneResolverFactory: DeleteOneResolverFactory,
private readonly executeQuickActionOnOneResolverFactory: ExecuteQuickActionOnOneResolverFactory,
private readonly updateManyResolverFactory: UpdateManyResolverFactory, private readonly updateManyResolverFactory: UpdateManyResolverFactory,
private readonly deleteManyResolverFactory: DeleteManyResolverFactory, private readonly deleteManyResolverFactory: DeleteManyResolverFactory,
) {} ) {}
@ -50,6 +52,7 @@ export class WorkspaceResolverFactory {
['createOne', this.createOneResolverFactory], ['createOne', this.createOneResolverFactory],
['updateOne', this.updateOneResolverFactory], ['updateOne', this.updateOneResolverFactory],
['deleteOne', this.deleteOneResolverFactory], ['deleteOne', this.deleteOneResolverFactory],
['executeQuickActionOnOne', this.executeQuickActionOnOneResolverFactory],
['updateMany', this.updateManyResolverFactory], ['updateMany', this.updateManyResolverFactory],
['deleteMany', this.deleteManyResolverFactory], ['deleteMany', this.deleteManyResolverFactory],
]); ]);

View File

@ -34,6 +34,9 @@ describe('getResolverArgs', () => {
deleteOne: { deleteOne: {
id: { type: FieldMetadataType.UUID, isNullable: false }, id: { type: FieldMetadataType.UUID, isNullable: false },
}, },
executeQuickActionOnOne: {
id: { type: FieldMetadataType.UUID, isNullable: false },
},
}; };
// Test each resolver type // Test each resolver type

View File

@ -76,6 +76,13 @@ export const getResolverArgs = (
isNullable: false, isNullable: false,
}, },
}; };
case 'executeQuickActionOnOne':
return {
id: {
type: FieldMetadataType.UUID,
isNullable: false,
},
};
case 'updateMany': case 'updateMany':
return { return {
data: { data: {

View File

@ -7015,6 +7015,18 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@nestjs/axios@npm:^3.0.1":
version: 3.0.1
resolution: "@nestjs/axios@npm:3.0.1"
peerDependencies:
"@nestjs/common": ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0
axios: ^1.3.1
reflect-metadata: ^0.1.12
rxjs: ^6.0.0 || ^7.0.0
checksum: 07f36e260a21fbc52be8f1dcc55d71f4de741264d467adaaf4b1c3926a5c9367bc77a2dac4462e638db3a39ec11051ccad36fe958f8513f54bc3e57fba6329a2
languageName: node
linkType: hard
"@nestjs/cli@npm:^9.0.0": "@nestjs/cli@npm:^9.0.0":
version: 9.5.0 version: 9.5.0
resolution: "@nestjs/cli@npm:9.5.0" resolution: "@nestjs/cli@npm:9.5.0"
@ -16313,7 +16325,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"axios@npm:^1.4.0, axios@npm:^1.5.1, axios@npm:^1.6.1": "axios@npm:^1.5.1, axios@npm:^1.6.1, axios@npm:^1.6.2":
version: 1.6.2 version: 1.6.2
resolution: "axios@npm:1.6.2" resolution: "axios@npm:1.6.2"
dependencies: dependencies:
@ -40988,6 +41000,7 @@ __metadata:
"@graphql-tools/schema": "npm:^10.0.0" "@graphql-tools/schema": "npm:^10.0.0"
"@graphql-yoga/nestjs": "patch:@graphql-yoga/nestjs@2.1.0#./patches/@graphql-yoga+nestjs+2.1.0.patch" "@graphql-yoga/nestjs": "patch:@graphql-yoga/nestjs@2.1.0#./patches/@graphql-yoga+nestjs+2.1.0.patch"
"@nestjs/apollo": "npm:^11.0.5" "@nestjs/apollo": "npm:^11.0.5"
"@nestjs/axios": "npm:^3.0.1"
"@nestjs/cli": "npm:^9.0.0" "@nestjs/cli": "npm:^9.0.0"
"@nestjs/common": "npm:^9.0.0" "@nestjs/common": "npm:^9.0.0"
"@nestjs/config": "npm:^2.3.2" "@nestjs/config": "npm:^2.3.2"
@ -41032,7 +41045,7 @@ __metadata:
"@typescript-eslint/parser": "npm:^5.0.0" "@typescript-eslint/parser": "npm:^5.0.0"
add: "npm:^2.0.6" add: "npm:^2.0.6"
apollo-server-express: "npm:^3.12.0" apollo-server-express: "npm:^3.12.0"
axios: "npm:^1.4.0" axios: "npm:^1.6.2"
bcrypt: "npm:^5.1.1" bcrypt: "npm:^5.1.1"
body-parser: "npm:^1.20.2" body-parser: "npm:^1.20.2"
bullmq: "npm:^4.14.0" bullmq: "npm:^4.14.0"