From e83fa2108d7740e28b7d5b0715ca983cfadaa1d9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?=
<71827178+bosiraphael@users.noreply.github.com>
Date: Tue, 16 Jun 2026 10:54:38 +0200
Subject: [PATCH] Add single-record People Data Labs enrich functions (company
& person) (#21650)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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`.
---
.../internal/people-data-labs/README.md | 31 ++--
.../src/constants/pdl-base-url.ts | 1 +
.../src/constants/universal-identifiers.ts | 4 +-
.../enrich-company-tool.function.ts | 52 ------
.../enrich-company.function.ts | 69 +++++++
.../enrich-person-tool.function.ts | 50 -----
.../logic-functions/enrich-person.function.ts | 69 +++++++
.../handlers/__tests__/enrich-company.spec.ts | 147 +++++++++++++++
.../handlers/__tests__/enrich-person.spec.ts | 173 ++++++++++++++++++
.../company-single-enrichment-adapter.ts | 15 ++
.../handlers/enrich-company.ts | 19 ++
.../logic-functions/handlers/enrich-person.ts | 19 ++
.../person-single-enrichment-adapter.ts | 15 ++
.../__tests__/build-tool-record-ids.spec.ts | 23 ---
.../utils/__tests__/enrich-company.spec.ts | 54 ++++++
.../utils/__tests__/enrich-person.spec.ts | 62 +++++++
.../__tests__/post-pdl-single-enrich.spec.ts | 172 +++++++++++++++++
.../utils/build-empty-tool-result.ts | 11 --
.../utils/build-tool-record-ids.ts | 12 --
.../logic-functions/utils/enrich-company.ts | 23 +++
.../logic-functions/utils/enrich-person.ts | 24 +++
.../logic-functions/utils/post-pdl-enrich.ts | 3 +-
.../utils/post-pdl-single-enrich.ts | 59 ++++++
.../utils/run-single-enrichment.ts | 44 +++++
...h-tool-input.ts => single-enrich-input.ts} | 4 +-
25 files changed, 990 insertions(+), 165 deletions(-)
create mode 100644 packages/twenty-apps/internal/people-data-labs/src/constants/pdl-base-url.ts
delete mode 100644 packages/twenty-apps/internal/people-data-labs/src/logic-functions/enrich-company-tool.function.ts
create mode 100644 packages/twenty-apps/internal/people-data-labs/src/logic-functions/enrich-company.function.ts
delete mode 100644 packages/twenty-apps/internal/people-data-labs/src/logic-functions/enrich-person-tool.function.ts
create mode 100644 packages/twenty-apps/internal/people-data-labs/src/logic-functions/enrich-person.function.ts
create mode 100644 packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/__tests__/enrich-company.spec.ts
create mode 100644 packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/__tests__/enrich-person.spec.ts
create mode 100644 packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/company-single-enrichment-adapter.ts
create mode 100644 packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/enrich-company.ts
create mode 100644 packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/enrich-person.ts
create mode 100644 packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/person-single-enrichment-adapter.ts
delete mode 100644 packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-tool-record-ids.spec.ts
create mode 100644 packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/enrich-company.spec.ts
create mode 100644 packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/enrich-person.spec.ts
create mode 100644 packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/post-pdl-single-enrich.spec.ts
delete mode 100644 packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-empty-tool-result.ts
delete mode 100644 packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-tool-record-ids.ts
create mode 100644 packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/enrich-company.ts
create mode 100644 packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/enrich-person.ts
create mode 100644 packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/post-pdl-single-enrich.ts
create mode 100644 packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/run-single-enrichment.ts
rename packages/twenty-apps/internal/people-data-labs/src/types/{enrich-tool-input.ts => single-enrich-input.ts} (50%)
diff --git a/packages/twenty-apps/internal/people-data-labs/README.md b/packages/twenty-apps/internal/people-data-labs/README.md
index 7c5917ef00..af3bc71aad 100644
--- a/packages/twenty-apps/internal/people-data-labs/README.md
+++ b/packages/twenty-apps/internal/people-data-labs/README.md
@@ -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":""}]}'`.
+Run locally: `yarn twenty dev:function:exec -n enrich-people -p '{"records":[{"id":""}]}'` (bulk)
+or `yarn twenty dev:function:exec -n enrich-person -p '{"recordId":""}'` (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).
diff --git a/packages/twenty-apps/internal/people-data-labs/src/constants/pdl-base-url.ts b/packages/twenty-apps/internal/people-data-labs/src/constants/pdl-base-url.ts
new file mode 100644
index 0000000000..5a84af4f89
--- /dev/null
+++ b/packages/twenty-apps/internal/people-data-labs/src/constants/pdl-base-url.ts
@@ -0,0 +1 @@
+export const PDL_BASE_URL = 'https://api.peopledatalabs.com/v5';
diff --git a/packages/twenty-apps/internal/people-data-labs/src/constants/universal-identifiers.ts b/packages/twenty-apps/internal/people-data-labs/src/constants/universal-identifiers.ts
index f27cb54962..b229cfdf11 100644
--- a/packages/twenty-apps/internal/people-data-labs/src/constants/universal-identifiers.ts
+++ b/packages/twenty-apps/internal/people-data-labs/src/constants/universal-identifiers.ts
@@ -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;
diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/enrich-company-tool.function.ts b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/enrich-company-tool.function.ts
deleted file mode 100644
index 0105ef05a2..0000000000
--- a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/enrich-company-tool.function.ts
+++ /dev/null
@@ -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,
- },
- },
-});
diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/enrich-company.function.ts b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/enrich-company.function.ts
new file mode 100644
index 0000000000..4a6b56ab2a
--- /dev/null
+++ b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/enrich-company.function.ts
@@ -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,
+ },
+ },
+});
diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/enrich-person-tool.function.ts b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/enrich-person-tool.function.ts
deleted file mode 100644
index 2b8e8cfd0d..0000000000
--- a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/enrich-person-tool.function.ts
+++ /dev/null
@@ -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,
- },
- },
-});
diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/enrich-person.function.ts b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/enrich-person.function.ts
new file mode 100644
index 0000000000..21fc3d1209
--- /dev/null
+++ b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/enrich-person.function.ts
@@ -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,
+ },
+ },
+});
diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/__tests__/enrich-company.spec.ts b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/__tests__/enrich-company.spec.ts
new file mode 100644
index 0000000000..3cecfcc1c0
--- /dev/null
+++ b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/__tests__/enrich-company.spec.ts
@@ -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;
+ updateCompanies?: { filter: unknown; data: Record };
+};
+
+type MutationRequest = {
+ updateCompany?: { __args: { id: string; data: Record } };
+ updateCompanies?: { __args: { filter: unknown; data: Record } };
+};
+
+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();
+ });
+});
diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/__tests__/enrich-person.spec.ts b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/__tests__/enrich-person.spec.ts
new file mode 100644
index 0000000000..e47b016bf2
--- /dev/null
+++ b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/__tests__/enrich-person.spec.ts
@@ -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;
+ updatePeople?: { filter: unknown; data: Record };
+};
+
+type MutationRequest = {
+ updatePerson?: { __args: { id: string; data: Record } };
+ updatePeople?: { __args: { filter: unknown; data: Record } };
+};
+
+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();
+ });
+});
diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/company-single-enrichment-adapter.ts b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/company-single-enrichment-adapter.ts
new file mode 100644
index 0000000000..4a7f3539e1
--- /dev/null
+++ b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/company-single-enrichment-adapter.ts
@@ -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,
+};
diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/enrich-company.ts b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/enrich-company.ts
new file mode 100644
index 0000000000..00d974df6a
--- /dev/null
+++ b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/enrich-company.ts
@@ -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 =>
+ runSingleEnrichment({
+ client,
+ input,
+ adapter: companySingleEnrichmentAdapter,
+ });
diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/enrich-person.ts b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/enrich-person.ts
new file mode 100644
index 0000000000..2f4256ffd8
--- /dev/null
+++ b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/enrich-person.ts
@@ -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 =>
+ runSingleEnrichment({
+ client,
+ input,
+ adapter: personSingleEnrichmentAdapter,
+ });
diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/person-single-enrichment-adapter.ts b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/person-single-enrichment-adapter.ts
new file mode 100644
index 0000000000..9e89209de7
--- /dev/null
+++ b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/person-single-enrichment-adapter.ts
@@ -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,
+};
diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-tool-record-ids.spec.ts b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-tool-record-ids.spec.ts
deleted file mode 100644
index fc905dd38f..0000000000
--- a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-tool-record-ids.spec.ts
+++ /dev/null
@@ -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([]);
- });
-});
diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/enrich-company.spec.ts b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/enrich-company.spec.ts
new file mode 100644
index 0000000000..35ec308dc0
--- /dev/null
+++ b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/enrich-company.spec.ts
@@ -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 },
+ ]);
+ });
+});
diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/enrich-person.spec.ts b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/enrich-person.spec.ts
new file mode 100644
index 0000000000..f8c9a27173
--- /dev/null
+++ b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/enrich-person.spec.ts
@@ -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 },
+ ]);
+ });
+});
diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/post-pdl-single-enrich.spec.ts b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/post-pdl-single-enrich.spec.ts
new file mode 100644
index 0000000000..2c7ec6d3f7
--- /dev/null
+++ b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/post-pdl-single-enrich.spec.ts
@@ -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;
+};
+
+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 });
+ });
+});
diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-empty-tool-result.ts b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-empty-tool-result.ts
deleted file mode 100644
index 1484fd06ca..0000000000
--- a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-empty-tool-result.ts
+++ /dev/null
@@ -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: [],
-});
diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-tool-record-ids.ts b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-tool-record-ids.ts
deleted file mode 100644
index c3d9419286..0000000000
--- a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-tool-record-ids.ts
+++ /dev/null
@@ -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));
-};
diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/enrich-company.ts b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/enrich-company.ts
new file mode 100644
index 0000000000..99b86f031a
--- /dev/null
+++ b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/enrich-company.ts
@@ -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[]> =>
+ Promise.all(
+ params.map((entry) =>
+ postPdlSingleEnrich({
+ path: '/company/enrich',
+ params: pruneUndefined({
+ pdl_id: entry.pdlId,
+ website: entry.website,
+ profile: entry.profile,
+ name: entry.name,
+ min_likelihood: entry.minLikelihood,
+ }),
+ }),
+ ),
+ );
diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/enrich-person.ts b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/enrich-person.ts
new file mode 100644
index 0000000000..822b64e353
--- /dev/null
+++ b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/enrich-person.ts
@@ -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[]> =>
+ Promise.all(
+ params.map((entry) =>
+ postPdlSingleEnrich({
+ 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,
+ }),
+ }),
+ ),
+ );
diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/post-pdl-enrich.ts b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/post-pdl-enrich.ts
index eff1e9a819..ae805865ca 100644
--- a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/post-pdl-enrich.ts
+++ b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/post-pdl-enrich.ts
@@ -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 ({
path,
requests,
diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/post-pdl-single-enrich.ts b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/post-pdl-single-enrich.ts
new file mode 100644
index 0000000000..888c486a6d
--- /dev/null
+++ b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/post-pdl-single-enrich.ts
@@ -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 ({
+ path,
+ params,
+}: {
+ path: string;
+ params: Record;
+}): Promise> => {
+ 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) : {};
+ const responseItemWithStatus = isNumber(responseItem.status)
+ ? responseItem
+ : { ...responseItem, status: response.status };
+
+ return parsePdlItem({
+ item: responseItemWithStatus,
+ requestedMinLikelihood: isNumber(params.min_likelihood)
+ ? params.min_likelihood
+ : undefined,
+ });
+};
diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/run-single-enrichment.ts b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/run-single-enrichment.ts
new file mode 100644
index 0000000000..9ec1aa696f
--- /dev/null
+++ b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/run-single-enrichment.ts
@@ -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 ({
+ client,
+ input,
+ adapter,
+}: {
+ client: CoreApiClient;
+ input: SingleEnrichInput;
+ adapter: BatchEnrichmentAdapter;
+}): Promise => {
+ 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 })
+ );
+};
diff --git a/packages/twenty-apps/internal/people-data-labs/src/types/enrich-tool-input.ts b/packages/twenty-apps/internal/people-data-labs/src/types/single-enrich-input.ts
similarity index 50%
rename from packages/twenty-apps/internal/people-data-labs/src/types/enrich-tool-input.ts
rename to packages/twenty-apps/internal/people-data-labs/src/types/single-enrich-input.ts
index 90c4799b73..a3ac41f60c 100644
--- a/packages/twenty-apps/internal/people-data-labs/src/types/enrich-tool-input.ts
+++ b/packages/twenty-apps/internal/people-data-labs/src/types/single-enrich-input.ts
@@ -1,5 +1,5 @@
-export type EnrichToolInput = {
+export type SingleEnrichInput = {
recordId?: string;
- recordIds?: string[];
overrideExistingValues?: boolean;
+ minLikelihood?: number;
};