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; };