Add single-record People Data Labs enrich functions (company & person) (#21650)

Adds two single-record enrichment logic functions to the People Data
Labs app — `enrich-company` and `enrich-person` — that call PDL's
single-record Enrichment endpoints (`/company/enrich`,
`/person/enrich`). Each function declares both a workflow-action trigger
and an AI-tool trigger, so the same function is usable as a workflow
step and as an AI tool. They take a single `{ recordId,
overrideExistingValues? }` and return a single `EnrichResult`.

The new functions replace the previous `enrich-company-tool` /
`enrich-person-tool` AI-tool functions (which delegated to the bulk
endpoints), avoiding duplicate near-identical tools for the LLM. The
bulk `enrich-companies` / `enrich-people` workflow actions are
unchanged.

Implementation reuses the existing enrichment machinery: the
single-record adapters spread the existing company/person adapters and
only override `enrichBatch`, so identifier extraction, TTL guard, field
mapping (fill-only-if-empty), billing, and error backoff all carry over.
A new `post-pdl-single-enrich` util posts params directly and classifies
the response via the existing `parsePdlItem`.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21650?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Raphaël Bosi
2026-06-16 10:54:38 +02:00
committed by GitHub
parent ceb7698689
commit e83fa2108d
25 changed files with 990 additions and 165 deletions
@@ -12,14 +12,21 @@ Enriches **Person** and **Company** records with [People Data Labs](https://www.
## Enrichment logic functions
`enrich-person` / `enrich-company` (bulk workflow actions, for the manual record action) plus
`enrich-person-tool` / `enrich-company-tool` (single-record AI tools) all delegate to a shared,
trigger-agnostic core in `src/logic-functions/handlers/`:
`enrich-companies` / `enrich-people` (bulk workflow actions, for the manual record action) and
`enrich-company` / `enrich-person` (single-record functions exposed **both** as a workflow action
and as an AI tool) all delegate to a shared, trigger-agnostic core in `src/logic-functions/handlers/`:
- The workflow-action functions accept a **list of records** (`{ records, force? }`) and loop
the single-record core over each, aggregating the outcome (`total` / `matched` / `notFound` /
`skipped` / `errored`); a per-record failure is captured as `ERROR` without aborting the batch
(`src/logic-functions/utils/run-batch-enrichment.ts`). The AI tools stay single-record.
- The bulk workflow-action functions accept a **list of records** (`{ records, overrideExistingValues? }`),
call the PDL **bulk** Enrichment endpoints (`/person/bulk`, `/company/enrich/bulk`), and loop the
single-record core over each, aggregating the outcome (`total` / `matched` / `notFound` / `skipped` /
`errored`); a per-record failure is captured as `ERROR` without aborting the batch
(`src/logic-functions/utils/run-batch-enrichment.ts`).
- The single-record functions accept one record (`{ recordId, overrideExistingValues? }`), call the PDL
**single-record** Enrichment endpoints (`/person/enrich`, `/company/enrich`
`src/logic-functions/utils/post-pdl-single-enrich.ts`), and return a single `EnrichResult`
(`src/logic-functions/utils/run-single-enrichment.ts`). They declare both a
`workflowActionTriggerSettings` and a `toolTriggerSettings`, so one function is usable as a workflow
step and as an AI tool.
- Read the record, guard against re-enriching within a TTL (`pdlLastEnrichedAt`), pick a
match identifier (person: `pdlId` → LinkedIn → email → name; company: `pdlId` → domain →
@@ -32,7 +39,8 @@ trigger-agnostic core in `src/logic-functions/handlers/`:
(`src/logic-functions/utils/`); the option sets are the same `src/constants/*-options.ts`
the field definitions use.
Run locally: `yarn twenty dev:function:exec -n enrich-person -p '{"records":[{"id":"<id>"}]}'`.
Run locally: `yarn twenty dev:function:exec -n enrich-people -p '{"records":[{"id":"<id>"}]}'` (bulk)
or `yarn twenty dev:function:exec -n enrich-person -p '{"recordId":"<id>"}'` (single record).
### Billing
@@ -64,8 +72,8 @@ When re-enabled, each workflow is a `MANUAL` / `BULK_RECORDS` trigger wired to a
`LOGIC_FUNCTION` step whose `records` input is bound to the selected records
(`{{trigger.companies}}` / `{{trigger.people}}`):
- **Enrich companies** — runs `enrich-company` over the selected Companies.
- **Enrich people** — runs `enrich-person` over the selected People.
- **Enrich companies** — runs `enrich-companies` over the selected Companies.
- **Enrich people** — runs `enrich-people` over the selected People.
The intended seeding (`postInstallCore`) resolves each function's runtime id from its
`universalIdentifier` via the metadata API, publishes the version
@@ -165,7 +173,8 @@ fields.
**Orchestration** (`src/logic-functions/`)
1. Runs from the manual "Enrich" record action (`BULK_RECORDS`) or the single-record AI tools.
1. Runs from the manual "Enrich" record action (`BULK_RECORDS`) or the single-record
`enrich-company` / `enrich-person` functions (as a workflow step or an AI tool).
2. Calls the PDL Person / Company Enrichment API with `PDL_API_KEY`, passing a `min_likelihood`
chosen by identifier strength (2 with a strong identifier, 6 for a weaker name-based match;
overridable per call).
@@ -0,0 +1 @@
export const PDL_BASE_URL = 'https://api.peopledatalabs.com/v5';
@@ -6,9 +6,9 @@ export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
export const PDL_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIERS = {
enrichPeople: '65356a82-6734-4fc9-8172-7d30ed1b7859',
enrichPersonTool: 'c1539ca9-6f57-4036-a2a7-621ec23a66e6',
enrichPerson: '864ad69b-ff9f-4635-9aed-16fd0c2ad210',
enrichCompanies: 'c769fb49-d495-469f-a58f-1a69ab90ec24',
enrichCompanyTool: '88d126e1-a8f4-49f2-883f-39a7fa69cede',
enrichCompany: '560bbfd9-1107-4f7f-8398-ea835e7e5bbe',
postInstall: '9de46f15-05ec-4314-84c1-b9919b545269',
} as const;
@@ -1,52 +0,0 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { PDL_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers';
import { enrichCompaniesCore } from 'src/logic-functions/handlers/enrich-companies';
import { buildEmptyToolResult } from 'src/logic-functions/utils/build-empty-tool-result';
import { buildToolRecordIds } from 'src/logic-functions/utils/build-tool-record-ids';
import { type EnrichToolInput } from 'src/types/enrich-tool-input';
const handler = (input: EnrichToolInput) => {
const records = buildToolRecordIds(input);
if (records.length === 0) {
return Promise.resolve(buildEmptyToolResult());
}
return enrichCompaniesCore({
input: { records, overrideExistingValues: input.overrideExistingValues },
});
};
export default defineLogicFunction({
universalIdentifier:
PDL_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIERS.enrichCompanyTool,
name: 'enrich-company-tool',
description:
'Enrich one or more Company records with People Data Labs data (industry, size, funding, location, etc.) given their record ids. Provide recordId for a single record or recordIds for multiple.',
timeoutSeconds: 300,
handler,
toolTriggerSettings: {
inputSchema: {
type: 'object',
properties: {
recordId: {
type: 'string',
description: 'The id of a single Company record to enrich.',
},
recordIds: {
type: 'array',
items: { type: 'string' },
description:
'The ids of multiple Company records to enrich in one call.',
},
overrideExistingValues: {
type: 'boolean',
description:
'Overwrite existing field values with the enriched data instead of only filling empty fields.',
},
},
additionalProperties: false,
},
},
});
@@ -0,0 +1,69 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { PDL_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers';
import { enrichCompanyCore } from 'src/logic-functions/handlers/enrich-company';
import { type SingleEnrichInput } from 'src/types/single-enrich-input';
const handler = (input: SingleEnrichInput) => enrichCompanyCore({ input });
export default defineLogicFunction({
universalIdentifier: PDL_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIERS.enrichCompany,
name: 'enrich-company',
description:
'Enrich a single Company record with People Data Labs data (industry, size, funding, location, etc.) given its record id.',
timeoutSeconds: 60,
handler,
workflowActionTriggerSettings: {
label: 'Enrich Company',
icon: 'IconSparkles',
inputSchema: [
{
type: 'object',
properties: {
recordId: {
type: 'string',
label: 'Record',
},
overrideExistingValues: {
type: 'boolean',
label: 'Override Existing Values',
},
},
},
],
outputSchema: [
{
type: 'object',
properties: {
success: { type: 'boolean', label: 'Success' },
recordId: { type: 'string', label: 'Record Id' },
status: { type: 'string', label: 'Status' },
updatedFields: {
type: 'array',
items: { type: 'string' },
label: 'Updated Fields',
},
message: { type: 'string', label: 'Message' },
},
},
],
},
toolTriggerSettings: {
inputSchema: {
type: 'object',
properties: {
recordId: {
type: 'string',
description: 'The id of the Company record to enrich.',
},
overrideExistingValues: {
type: 'boolean',
description:
'Overwrite existing field values with the enriched data instead of only filling empty fields.',
},
},
required: ['recordId'],
additionalProperties: false,
},
},
});
@@ -1,50 +0,0 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { PDL_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers';
import { enrichPeopleCore } from 'src/logic-functions/handlers/enrich-people';
import { buildEmptyToolResult } from 'src/logic-functions/utils/build-empty-tool-result';
import { buildToolRecordIds } from 'src/logic-functions/utils/build-tool-record-ids';
import { type EnrichToolInput } from 'src/types/enrich-tool-input';
const handler = (input: EnrichToolInput) => {
const records = buildToolRecordIds(input);
if (records.length === 0) {
return Promise.resolve(buildEmptyToolResult());
}
return enrichPeopleCore({
input: { records, overrideExistingValues: input.overrideExistingValues },
});
};
export default defineLogicFunction({
universalIdentifier: PDL_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIERS.enrichPersonTool,
name: 'enrich-person-tool',
description:
'Enrich one or more Person records with People Data Labs data (job, location, social profiles, etc.) given their record ids. Provide recordId for a single record or recordIds for multiple.',
timeoutSeconds: 300,
handler,
toolTriggerSettings: {
inputSchema: {
type: 'object',
properties: {
recordId: {
type: 'string',
description: 'The id of a single Person record to enrich.',
},
recordIds: {
type: 'array',
items: { type: 'string' },
description: 'The ids of multiple Person records to enrich in one call.',
},
overrideExistingValues: {
type: 'boolean',
description:
'Overwrite existing field values with the enriched data instead of only filling empty fields.',
},
},
additionalProperties: false,
},
},
});
@@ -0,0 +1,69 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { PDL_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers';
import { enrichPersonCore } from 'src/logic-functions/handlers/enrich-person';
import { type SingleEnrichInput } from 'src/types/single-enrich-input';
const handler = (input: SingleEnrichInput) => enrichPersonCore({ input });
export default defineLogicFunction({
universalIdentifier: PDL_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIERS.enrichPerson,
name: 'enrich-person',
description:
'Enrich a single Person record with People Data Labs data (job, location, social profiles, etc.) given its record id.',
timeoutSeconds: 60,
handler,
workflowActionTriggerSettings: {
label: 'Enrich Person',
icon: 'IconSparkles',
inputSchema: [
{
type: 'object',
properties: {
recordId: {
type: 'string',
label: 'Record',
},
overrideExistingValues: {
type: 'boolean',
label: 'Override Existing Values',
},
},
},
],
outputSchema: [
{
type: 'object',
properties: {
success: { type: 'boolean', label: 'Success' },
recordId: { type: 'string', label: 'Record Id' },
status: { type: 'string', label: 'Status' },
updatedFields: {
type: 'array',
items: { type: 'string' },
label: 'Updated Fields',
},
message: { type: 'string', label: 'Message' },
},
},
],
},
toolTriggerSettings: {
inputSchema: {
type: 'object',
properties: {
recordId: {
type: 'string',
description: 'The id of the Person record to enrich.',
},
overrideExistingValues: {
type: 'boolean',
description:
'Overwrite existing field values with the enriched data instead of only filling empty fields.',
},
},
required: ['recordId'],
additionalProperties: false,
},
},
});
@@ -0,0 +1,147 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { COMPANY_NODE_MOCK } from 'src/logic-functions/__mocks__/company-node.mock';
import { createCoreApiClientMock } from 'src/logic-functions/__mocks__/create-core-api-client-mock';
import { enrichCompanyCore } from 'src/logic-functions/handlers/enrich-company';
import { enrichCompany } from 'src/logic-functions/utils/enrich-company';
import { type CompanyNode } from 'src/types/company-node';
vi.mock('src/logic-functions/utils/enrich-company', () => ({
enrichCompany: vi.fn(),
}));
const enrichCompanyMock = vi.mocked(enrichCompany);
type Captured = {
updateCompany?: Record<string, unknown>;
updateCompanies?: { filter: unknown; data: Record<string, unknown> };
};
type MutationRequest = {
updateCompany?: { __args: { id: string; data: Record<string, unknown> } };
updateCompanies?: { __args: { filter: unknown; data: Record<string, unknown> } };
};
const captureMutation = (captured: Captured) => (request: unknown) => {
const mutation = request as MutationRequest;
if (mutation.updateCompany) {
captured.updateCompany = mutation.updateCompany.__args.data;
}
if (mutation.updateCompanies) {
captured.updateCompanies = {
filter: mutation.updateCompanies.__args.filter,
data: mutation.updateCompanies.__args.data,
};
}
};
const buildClient = (companies: CompanyNode[], captured: Captured): CoreApiClient =>
createCoreApiClientMock({
queryResult: { companies: { edges: companies.map((node) => ({ node })) } },
onMutation: captureMutation(captured),
});
const runOne = (client: CoreApiClient, recordId = 'c1') =>
enrichCompanyCore({ input: { recordId }, client });
beforeEach(() => {
enrichCompanyMock.mockReset();
});
describe('enrichCompanyCore', () => {
it('fills empty standard fields and writes pdl metadata via updateCompany on a match', async () => {
enrichCompanyMock.mockResolvedValue([
{
outcome: 'matched',
httpStatus: 200,
data: {
id: 'pdlc',
display_name: 'Acme Corp',
website: 'newsite.com',
industry: 'accounting',
},
},
]);
const captured: Captured = {};
const client = buildClient([COMPANY_NODE_MOCK], captured);
const result = await runOne(client);
expect(enrichCompanyMock).toHaveBeenCalledTimes(1);
expect(result.status).toBe('MATCHED');
expect(result.success).toBe(true);
expect(result.recordId).toBe('c1');
expect(result.updatedFields).toContain('name');
expect(captured.updateCompany?.name).toBe('Acme Corp');
expect(captured.updateCompany?.pdlIndustry).toBe('ACCOUNTING');
expect(captured.updateCompany?.pdlEnrichmentStatus).toBe('MATCHED');
});
it('records NOT_FOUND and writes the status via updateCompanies', async () => {
enrichCompanyMock.mockResolvedValue([{ outcome: 'not_found', httpStatus: 404 }]);
const captured: Captured = {};
const client = buildClient([COMPANY_NODE_MOCK], captured);
const result = await runOne(client);
expect(result.status).toBe('NOT_FOUND');
expect(result.success).toBe(true);
expect(captured.updateCompany).toBeUndefined();
expect(captured.updateCompanies?.data.pdlEnrichmentStatus).toBe('NOT_FOUND');
expect(captured.updateCompanies?.filter).toEqual({ id: { in: ['c1'] } });
});
it('records ERROR and reports failure on a PDL error', async () => {
enrichCompanyMock.mockResolvedValue([
{ outcome: 'error', httpStatus: 500, message: 'boom' },
]);
const captured: Captured = {};
const client = buildClient([COMPANY_NODE_MOCK], captured);
const result = await runOne(client);
expect(result.status).toBe('ERROR');
expect(result.success).toBe(false);
expect(result.error).toBe('boom');
expect(captured.updateCompanies?.data).toEqual({
pdlEnrichmentStatus: 'ERROR',
pdlLastEnrichedAt: expect.any(String),
});
});
it('skips when there is no usable identifier', async () => {
const captured: Captured = {};
const client = buildClient(
[{ ...COMPANY_NODE_MOCK, domainName: null, name: '' }],
captured,
);
const result = await runOne(client);
expect(result.status).toBe('SKIPPED');
expect(enrichCompanyMock).not.toHaveBeenCalled();
});
it('marks a missing record as ERROR', async () => {
const captured: Captured = {};
const client = buildClient([], captured);
const result = await runOne(client, 'missing');
expect(result.status).toBe('ERROR');
expect(result.error).toBe('Company missing not found');
expect(enrichCompanyMock).not.toHaveBeenCalled();
});
it('returns an ERROR without touching PDL when no record id is provided', async () => {
const captured: Captured = {};
const client = buildClient([COMPANY_NODE_MOCK], captured);
const result = await enrichCompanyCore({ input: {}, client });
expect(result.status).toBe('ERROR');
expect(result.error).toBe('No record id was provided to enrich.');
expect(enrichCompanyMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,173 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { createCoreApiClientMock } from 'src/logic-functions/__mocks__/create-core-api-client-mock';
import { PERSON_NODE_MOCK } from 'src/logic-functions/__mocks__/person-node.mock';
import { enrichPersonCore } from 'src/logic-functions/handlers/enrich-person';
import { enrichPerson } from 'src/logic-functions/utils/enrich-person';
import { type PersonNode } from 'src/types/person-node';
vi.mock('src/logic-functions/utils/enrich-person', () => ({
enrichPerson: vi.fn(),
}));
const enrichPersonMock = vi.mocked(enrichPerson);
type Captured = {
updatePerson?: Record<string, unknown>;
updatePeople?: { filter: unknown; data: Record<string, unknown> };
};
type MutationRequest = {
updatePerson?: { __args: { id: string; data: Record<string, unknown> } };
updatePeople?: { __args: { filter: unknown; data: Record<string, unknown> } };
};
const captureMutation = (captured: Captured) => (request: unknown) => {
const mutation = request as MutationRequest;
if (mutation.updatePerson) {
captured.updatePerson = mutation.updatePerson.__args.data;
}
if (mutation.updatePeople) {
captured.updatePeople = {
filter: mutation.updatePeople.__args.filter,
data: mutation.updatePeople.__args.data,
};
}
};
const buildClient = (people: PersonNode[], captured: Captured): CoreApiClient =>
createCoreApiClientMock({
queryResult: { people: { edges: people.map((node) => ({ node })) } },
onMutation: captureMutation(captured),
});
const runOne = (client: CoreApiClient, recordId = 'p1') =>
enrichPersonCore({ input: { recordId }, client });
beforeEach(() => {
enrichPersonMock.mockReset();
});
describe('enrichPersonCore', () => {
it('fills empty standard fields and writes pdl metadata via updatePerson on a match', async () => {
enrichPersonMock.mockResolvedValue([
{
outcome: 'matched',
httpStatus: 200,
likelihood: 8,
data: {
id: 'pdl1',
first_name: 'Jane',
last_name: 'Doe',
job_title: 'CEO',
},
},
]);
const captured: Captured = {};
const client = buildClient([PERSON_NODE_MOCK], captured);
const result = await runOne(client);
expect(enrichPersonMock).toHaveBeenCalledTimes(1);
expect(result.status).toBe('MATCHED');
expect(result.success).toBe(true);
expect(result.recordId).toBe('p1');
expect(captured.updatePerson?.name).toEqual({
firstName: 'Jane',
lastName: 'Doe',
});
expect(captured.updatePerson?.jobTitle).toBe('CEO');
expect(captured.updatePerson?.pdlEnrichmentStatus).toBe('MATCHED');
expect(captured.updatePerson?.pdlLikelihood).toBe(8);
});
it('records NOT_FOUND and writes the status via updatePeople', async () => {
enrichPersonMock.mockResolvedValue([{ outcome: 'not_found', httpStatus: 404 }]);
const captured: Captured = {};
const client = buildClient([PERSON_NODE_MOCK], captured);
const result = await runOne(client);
expect(result.status).toBe('NOT_FOUND');
expect(captured.updatePerson).toBeUndefined();
expect(captured.updatePeople?.data.pdlEnrichmentStatus).toBe('NOT_FOUND');
expect(captured.updatePeople?.filter).toEqual({ id: { in: ['p1'] } });
});
it('records ERROR and reports failure on a PDL error', async () => {
enrichPersonMock.mockResolvedValue([
{ outcome: 'error', httpStatus: 500, message: 'boom' },
]);
const captured: Captured = {};
const client = buildClient([PERSON_NODE_MOCK], captured);
const result = await runOne(client);
expect(result.status).toBe('ERROR');
expect(result.success).toBe(false);
expect(result.error).toBe('boom');
expect(captured.updatePeople?.data).toEqual({
pdlEnrichmentStatus: 'ERROR',
pdlLastEnrichedAt: expect.any(String),
});
});
it('overwrites a populated standard field when overrideExistingValues is set', async () => {
enrichPersonMock.mockResolvedValue([
{
outcome: 'matched',
httpStatus: 200,
likelihood: 5,
data: { id: 'pdl1', job_title: 'CEO' },
},
]);
const captured: Captured = {};
const client = buildClient(
[{ ...PERSON_NODE_MOCK, jobTitle: 'Existing Title' }],
captured,
);
await enrichPersonCore({
input: { recordId: 'p1', overrideExistingValues: true },
client,
});
expect(captured.updatePerson?.jobTitle).toBe('CEO');
});
it('skips when there is no usable identifier', async () => {
const captured: Captured = {};
const client = buildClient(
[{ ...PERSON_NODE_MOCK, linkedinLink: null }],
captured,
);
const result = await runOne(client);
expect(result.status).toBe('SKIPPED');
expect(enrichPersonMock).not.toHaveBeenCalled();
});
it('marks a missing record as ERROR', async () => {
const captured: Captured = {};
const client = buildClient([], captured);
const result = await runOne(client, 'missing');
expect(result.status).toBe('ERROR');
expect(result.error).toBe('Person missing not found');
expect(enrichPersonMock).not.toHaveBeenCalled();
});
it('returns an ERROR without touching PDL when no record id is provided', async () => {
const captured: Captured = {};
const client = buildClient([PERSON_NODE_MOCK], captured);
const result = await enrichPersonCore({ input: {}, client });
expect(result.status).toBe('ERROR');
expect(result.error).toBe('No record id was provided to enrich.');
expect(enrichPersonMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,15 @@
import { companyEnrichmentAdapter } from 'src/logic-functions/handlers/company-enrichment-adapter';
import { enrichCompany } from 'src/logic-functions/utils/enrich-company';
import { type BatchEnrichmentAdapter } from 'src/types/batch-enrichment-adapter';
import { type CompanyNode } from 'src/types/company-node';
import { type PdlCompanyData } from 'src/types/pdl-company-data';
import { type PdlCompanyEnrichParams } from 'src/types/pdl-company-enrich-params';
export const companySingleEnrichmentAdapter: BatchEnrichmentAdapter<
CompanyNode,
PdlCompanyData,
PdlCompanyEnrichParams
> = {
...companyEnrichmentAdapter,
enrichBatch: enrichCompany,
};
@@ -0,0 +1,19 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { companySingleEnrichmentAdapter } from 'src/logic-functions/handlers/company-single-enrichment-adapter';
import { runSingleEnrichment } from 'src/logic-functions/utils/run-single-enrichment';
import { type EnrichResult } from 'src/types/enrich-result';
import { type SingleEnrichInput } from 'src/types/single-enrich-input';
export const enrichCompanyCore = ({
input,
client = new CoreApiClient(),
}: {
input: SingleEnrichInput;
client?: CoreApiClient;
}): Promise<EnrichResult> =>
runSingleEnrichment({
client,
input,
adapter: companySingleEnrichmentAdapter,
});
@@ -0,0 +1,19 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { personSingleEnrichmentAdapter } from 'src/logic-functions/handlers/person-single-enrichment-adapter';
import { runSingleEnrichment } from 'src/logic-functions/utils/run-single-enrichment';
import { type EnrichResult } from 'src/types/enrich-result';
import { type SingleEnrichInput } from 'src/types/single-enrich-input';
export const enrichPersonCore = ({
input,
client = new CoreApiClient(),
}: {
input: SingleEnrichInput;
client?: CoreApiClient;
}): Promise<EnrichResult> =>
runSingleEnrichment({
client,
input,
adapter: personSingleEnrichmentAdapter,
});
@@ -0,0 +1,15 @@
import { personEnrichmentAdapter } from 'src/logic-functions/handlers/person-enrichment-adapter';
import { enrichPerson } from 'src/logic-functions/utils/enrich-person';
import { type BatchEnrichmentAdapter } from 'src/types/batch-enrichment-adapter';
import { type PdlPersonData } from 'src/types/pdl-person-data';
import { type PdlPersonEnrichParams } from 'src/types/pdl-person-enrich-params';
import { type PersonNode } from 'src/types/person-node';
export const personSingleEnrichmentAdapter: BatchEnrichmentAdapter<
PersonNode,
PdlPersonData,
PdlPersonEnrichParams
> = {
...personEnrichmentAdapter,
enrichBatch: enrichPerson,
};
@@ -1,23 +0,0 @@
import { describe, expect, it } from 'vitest';
import { buildToolRecordIds } from 'src/logic-functions/utils/build-tool-record-ids';
describe('buildToolRecordIds', () => {
it('accepts a single recordId', () => {
expect(buildToolRecordIds({ recordId: 'a' })).toEqual(['a']);
});
it('accepts multiple recordIds', () => {
expect(buildToolRecordIds({ recordIds: ['a', 'b'] })).toEqual(['a', 'b']);
});
it('merges recordId and recordIds, deduping overlaps', () => {
expect(
buildToolRecordIds({ recordId: 'a', recordIds: ['b', 'a'] }),
).toEqual(['b', 'a']);
});
it('returns an empty array when neither is provided', () => {
expect(buildToolRecordIds({})).toEqual([]);
});
});
@@ -0,0 +1,54 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { enrichCompany } from 'src/logic-functions/utils/enrich-company';
import { postPdlSingleEnrich } from 'src/logic-functions/utils/post-pdl-single-enrich';
vi.mock('src/logic-functions/utils/post-pdl-single-enrich', () => ({
postPdlSingleEnrich: vi.fn(() =>
Promise.resolve({ outcome: 'not_found', httpStatus: 404 }),
),
}));
const postPdlSingleEnrichMock = vi.mocked(postPdlSingleEnrich);
describe('enrichCompany', () => {
beforeEach(() => {
postPdlSingleEnrichMock.mockClear();
});
it('posts each record to the single company enrich endpoint with only the provided params', async () => {
await enrichCompany([{ website: 'acme.com' }, { pdlId: 'abc' }]);
expect(postPdlSingleEnrichMock).toHaveBeenCalledTimes(2);
expect(postPdlSingleEnrichMock).toHaveBeenNthCalledWith(1, {
path: '/company/enrich',
params: { website: 'acme.com' },
});
expect(postPdlSingleEnrichMock).toHaveBeenNthCalledWith(2, {
path: '/company/enrich',
params: { pdl_id: 'abc' },
});
});
it('forwards min_likelihood when provided', async () => {
await enrichCompany([{ name: 'Acme', minLikelihood: 2 }]);
expect(postPdlSingleEnrichMock).toHaveBeenCalledWith({
path: '/company/enrich',
params: { name: 'Acme', min_likelihood: 2 },
});
});
it('returns one outcome per request, preserving order', async () => {
postPdlSingleEnrichMock
.mockResolvedValueOnce({ outcome: 'matched', httpStatus: 200, data: { id: '1' } })
.mockResolvedValueOnce({ outcome: 'not_found', httpStatus: 404 });
const results = await enrichCompany([{ website: 'a.com' }, { website: 'b.com' }]);
expect(results).toEqual([
{ outcome: 'matched', httpStatus: 200, data: { id: '1' } },
{ outcome: 'not_found', httpStatus: 404 },
]);
});
});
@@ -0,0 +1,62 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { enrichPerson } from 'src/logic-functions/utils/enrich-person';
import { postPdlSingleEnrich } from 'src/logic-functions/utils/post-pdl-single-enrich';
vi.mock('src/logic-functions/utils/post-pdl-single-enrich', () => ({
postPdlSingleEnrich: vi.fn(() =>
Promise.resolve({ outcome: 'not_found', httpStatus: 404 }),
),
}));
const postPdlSingleEnrichMock = vi.mocked(postPdlSingleEnrich);
describe('enrichPerson', () => {
beforeEach(() => {
postPdlSingleEnrichMock.mockClear();
});
it('posts each record to the single person enrich endpoint with only the provided params', async () => {
await enrichPerson([
{ email: 'a@b.com' },
{ pdlId: 'abc', name: 'Jane Doe', company: 'Acme' },
]);
expect(postPdlSingleEnrichMock).toHaveBeenCalledTimes(2);
expect(postPdlSingleEnrichMock).toHaveBeenNthCalledWith(1, {
path: '/person/enrich',
params: { email: 'a@b.com' },
});
expect(postPdlSingleEnrichMock).toHaveBeenNthCalledWith(2, {
path: '/person/enrich',
params: { pdl_id: 'abc', name: 'Jane Doe', company: 'Acme' },
});
});
it('forwards min_likelihood when provided', async () => {
await enrichPerson([{ profile: 'linkedin.com/in/jane', minLikelihood: 6 }]);
expect(postPdlSingleEnrichMock).toHaveBeenCalledWith({
path: '/person/enrich',
params: { profile: 'linkedin.com/in/jane', min_likelihood: 6 },
});
});
it('returns one outcome per request, preserving order', async () => {
postPdlSingleEnrichMock
.mockResolvedValueOnce({
outcome: 'matched',
httpStatus: 200,
likelihood: 9,
data: { id: '1' },
})
.mockResolvedValueOnce({ outcome: 'not_found', httpStatus: 404 });
const results = await enrichPerson([{ email: 'a@b.com' }, { email: 'b@b.com' }]);
expect(results).toEqual([
{ outcome: 'matched', httpStatus: 200, likelihood: 9, data: { id: '1' } },
{ outcome: 'not_found', httpStatus: 404 },
]);
});
});
@@ -0,0 +1,172 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { postPdlSingleEnrich } from 'src/logic-functions/utils/post-pdl-single-enrich';
type FetchResponse = {
status: number;
ok: boolean;
json: () => Promise<unknown>;
};
const buildResponse = (
status: number,
json: unknown,
jsonThrows = false,
): FetchResponse => ({
status,
ok: status >= 200 && status < 300,
json: () =>
jsonThrows ? Promise.reject(new Error('invalid json')) : Promise.resolve(json),
});
const stubFetch = (response: FetchResponse | Error) => {
const fetchMock = vi.fn((_url: string, _init?: RequestInit) =>
response instanceof Error
? Promise.reject(response)
: Promise.resolve(response),
);
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
};
describe('postPdlSingleEnrich', () => {
beforeEach(() => {
process.env.PDL_API_KEY = 'secret-key';
});
afterEach(() => {
vi.unstubAllGlobals();
delete process.env.PDL_API_KEY;
});
it('posts the params directly in the body (not wrapped under requests)', async () => {
const fetchMock = stubFetch(buildResponse(200, { status: 200, data: {} }));
await postPdlSingleEnrich({
path: '/person/enrich',
params: { email: 'a@b.com' },
});
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe('https://api.peopledatalabs.com/v5/person/enrich');
expect(JSON.parse(init.body as string)).toEqual({ email: 'a@b.com' });
});
it('maps a 200 person match with a data envelope', async () => {
stubFetch(
buildResponse(200, { status: 200, likelihood: 8, data: { id: 'a' } }),
);
const result = await postPdlSingleEnrich({
path: '/person/enrich',
params: { email: 'a@b.com' },
});
expect(result).toEqual({
outcome: 'matched',
httpStatus: 200,
likelihood: 8,
data: { id: 'a' },
});
});
it('maps a 200 company match whose fields are at the top level', async () => {
stubFetch(
buildResponse(200, { status: 200, likelihood: 6, id: 'c1', name: 'Acme' }),
);
const result = await postPdlSingleEnrich({
path: '/company/enrich',
params: { name: 'Acme' },
});
expect(result).toEqual({
outcome: 'matched',
httpStatus: 200,
likelihood: 6,
data: { id: 'c1', name: 'Acme' },
});
});
it('treats a 404 no-match as not_found from the body status', async () => {
stubFetch(buildResponse(404, { status: 404, error: { message: 'no match' } }));
const result = await postPdlSingleEnrich({
path: '/person/enrich',
params: { email: 'a@b.com' },
});
expect(result).toEqual({ outcome: 'not_found', httpStatus: 404 });
});
it('falls back to the HTTP status when the body omits status', async () => {
stubFetch(buildResponse(404, { error: { message: 'no match' } }));
const result = await postPdlSingleEnrich({
path: '/person/enrich',
params: { email: 'a@b.com' },
});
expect(result).toEqual({ outcome: 'not_found', httpStatus: 404 });
});
it('reports an error on a non-2xx response with an error body', async () => {
stubFetch(buildResponse(401, { status: 401, error: { message: 'unauthorized' } }));
const result = await postPdlSingleEnrich({
path: '/person/enrich',
params: { email: 'a@b.com' },
});
expect(result).toEqual({
outcome: 'error',
httpStatus: 401,
message: 'unauthorized',
});
});
it('reports an error when fetch throws', async () => {
stubFetch(new Error('network down'));
const result = await postPdlSingleEnrich({
path: '/person/enrich',
params: { email: 'a@b.com' },
});
expect(result).toEqual({
outcome: 'error',
httpStatus: 0,
message: 'PDL request failed: network down',
});
});
it('reports an error on a non-JSON response', async () => {
stubFetch(buildResponse(200, null, true));
const result = await postPdlSingleEnrich({
path: '/person/enrich',
params: { email: 'a@b.com' },
});
expect(result).toEqual({
outcome: 'error',
httpStatus: 200,
message: 'PDL returned a non-JSON response (HTTP 200).',
});
});
it('drops a match below the requested min_likelihood', async () => {
stubFetch(
buildResponse(200, { status: 200, likelihood: 3, data: { id: 'a' } }),
);
const result = await postPdlSingleEnrich({
path: '/person/enrich',
params: { email: 'a@b.com', min_likelihood: 6 },
});
expect(result).toEqual({ outcome: 'not_found', httpStatus: 200 });
});
});
@@ -1,11 +0,0 @@
import { type BulkEnrichResult } from 'src/types/bulk-enrich-result';
export const buildEmptyToolResult = (): BulkEnrichResult => ({
success: false,
total: 0,
matched: 0,
notFound: 0,
skipped: 0,
errored: 0,
results: [],
});
@@ -1,12 +0,0 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type EnrichToolInput } from 'src/types/enrich-tool-input';
export const buildToolRecordIds = (input: EnrichToolInput): string[] => {
const nonEmptyRecordIds = [
...(input.recordIds ?? []),
...(input.recordId !== undefined ? [input.recordId] : []),
].filter(isNonEmptyString);
return Array.from(new Set(nonEmptyRecordIds));
};
@@ -0,0 +1,23 @@
import { postPdlSingleEnrich } from 'src/logic-functions/utils/post-pdl-single-enrich';
import { type PdlCompanyData } from 'src/types/pdl-company-data';
import { type PdlCompanyEnrichParams } from 'src/types/pdl-company-enrich-params';
import { type PdlEnrichResult } from 'src/types/pdl-enrich-result';
import { pruneUndefined } from 'src/utils/prune-undefined';
export const enrichCompany = (
params: PdlCompanyEnrichParams[],
): Promise<PdlEnrichResult<PdlCompanyData>[]> =>
Promise.all(
params.map((entry) =>
postPdlSingleEnrich<PdlCompanyData>({
path: '/company/enrich',
params: pruneUndefined({
pdl_id: entry.pdlId,
website: entry.website,
profile: entry.profile,
name: entry.name,
min_likelihood: entry.minLikelihood,
}),
}),
),
);
@@ -0,0 +1,24 @@
import { postPdlSingleEnrich } from 'src/logic-functions/utils/post-pdl-single-enrich';
import { type PdlEnrichResult } from 'src/types/pdl-enrich-result';
import { type PdlPersonData } from 'src/types/pdl-person-data';
import { type PdlPersonEnrichParams } from 'src/types/pdl-person-enrich-params';
import { pruneUndefined } from 'src/utils/prune-undefined';
export const enrichPerson = (
params: PdlPersonEnrichParams[],
): Promise<PdlEnrichResult<PdlPersonData>[]> =>
Promise.all(
params.map((entry) =>
postPdlSingleEnrich<PdlPersonData>({
path: '/person/enrich',
params: pruneUndefined({
pdl_id: entry.pdlId,
profile: entry.profile,
email: entry.email,
name: entry.name,
company: entry.company,
min_likelihood: entry.minLikelihood,
}),
}),
),
);
@@ -1,12 +1,11 @@
import { isNumber, isObject } from '@sniptt/guards';
import { PDL_BASE_URL } from 'src/constants/pdl-base-url';
import { extractPdlErrorMessage } from 'src/logic-functions/utils/extract-pdl-error-message';
import { getPdlApiKey } from 'src/logic-functions/utils/get-pdl-api-key';
import { parsePdlItem } from 'src/logic-functions/utils/parse-pdl-item';
import { type PdlEnrichResult } from 'src/types/pdl-enrich-result';
const PDL_BASE_URL = 'https://api.peopledatalabs.com/v5';
export const postPdlBulkEnrich = async <TData>({
path,
requests,
@@ -0,0 +1,59 @@
import { isNumber, isObject } from '@sniptt/guards';
import { PDL_BASE_URL } from 'src/constants/pdl-base-url';
import { getPdlApiKey } from 'src/logic-functions/utils/get-pdl-api-key';
import { parsePdlItem } from 'src/logic-functions/utils/parse-pdl-item';
import { type PdlEnrichResult } from 'src/types/pdl-enrich-result';
export const postPdlSingleEnrich = async <TData>({
path,
params,
}: {
path: string;
params: Record<string, unknown>;
}): Promise<PdlEnrichResult<TData>> => {
const apiKey = getPdlApiKey();
let response: Response;
try {
response = await fetch(`${PDL_BASE_URL}${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Api-Key': apiKey,
},
body: JSON.stringify(params),
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
outcome: 'error',
httpStatus: 0,
message: `PDL request failed: ${message}`,
};
}
let json: unknown;
try {
json = await response.json();
} catch {
return {
outcome: 'error',
httpStatus: response.status,
message: `PDL returned a non-JSON response (HTTP ${response.status}).`,
};
}
const responseItem = isObject(json) ? (json as Record<string, unknown>) : {};
const responseItemWithStatus = isNumber(responseItem.status)
? responseItem
: { ...responseItem, status: response.status };
return parsePdlItem<TData>({
item: responseItemWithStatus,
requestedMinLikelihood: isNumber(params.min_likelihood)
? params.min_likelihood
: undefined,
});
};
@@ -0,0 +1,44 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type CoreApiClient } from 'twenty-client-sdk/core';
import {
buildErrorResult,
ENRICHMENT_FAILED_MESSAGE,
} from 'src/logic-functions/utils/build-error-result';
import { runBatchEnrichment } from 'src/logic-functions/utils/run-batch-enrichment';
import { type BatchEnrichmentAdapter } from 'src/types/batch-enrichment-adapter';
import { type EnrichResult } from 'src/types/enrich-result';
import { type SingleEnrichInput } from 'src/types/single-enrich-input';
const NO_RECORD_ID_MESSAGE = 'No record id was provided to enrich.';
export const runSingleEnrichment = async <TNode, TData, TParams>({
client,
input,
adapter,
}: {
client: CoreApiClient;
input: SingleEnrichInput;
adapter: BatchEnrichmentAdapter<TNode, TData, TParams>;
}): Promise<EnrichResult> => {
const recordId = input.recordId?.trim();
if (!isNonEmptyString(recordId)) {
return buildErrorResult({ recordId: '', error: NO_RECORD_ID_MESSAGE });
}
const bulkResult = await runBatchEnrichment({
client,
input: {
records: recordId,
overrideExistingValues: input.overrideExistingValues,
minLikelihood: input.minLikelihood,
},
adapter,
});
return (
bulkResult.results[0] ??
buildErrorResult({ recordId, error: ENRICHMENT_FAILED_MESSAGE })
);
};
@@ -1,5 +1,5 @@
export type EnrichToolInput = {
export type SingleEnrichInput = {
recordId?: string;
recordIds?: string[];
overrideExistingValues?: boolean;
minLikelihood?: number;
};