Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
82e8d82
create logic functions and utils
bosiraphael Jun 5, 2026
e095749
remove comment
bosiraphael Jun 5, 2026
8e56d1d
fixes
bosiraphael Jun 5, 2026
09d68af
Merge branch 'main' of github.com:twentyhq/twenty into r--people-data…
bosiraphael Jun 5, 2026
a21e142
Fixes on selects and add custom errors
bosiraphael Jun 5, 2026
d6ea28b
split files
bosiraphael Jun 5, 2026
eafd31c
Add icons
bosiraphael Jun 5, 2026
fd75c15
Merge branch 'main' into r--people-data-labs-enrichment-mapper
bosiraphael Jun 5, 2026
d7c3f22
fixes and sdk version bump
bosiraphael Jun 5, 2026
5d4424a
Merge branch 'r--people-data-labs-enrichment-mapper' of github.com:tw…
bosiraphael Jun 5, 2026
3dfc7ab
upgrade sdk
bosiraphael Jun 8, 2026
ef4c94a
create enrichment workflows via post-install
bosiraphael Jun 8, 2026
e0bf07b
remove the suffixes
bosiraphael Jun 8, 2026
4205e67
remove indexes
bosiraphael Jun 8, 2026
95e0e2a
Merge branch 'main' into r--people-data-labs-enrichment-mapper
bosiraphael Jun 8, 2026
db470ab
fix post install seeding failure
bosiraphael Jun 8, 2026
292bfbd
update role
bosiraphael Jun 8, 2026
2b69ac4
update enrichment workflows port install
bosiraphael Jun 8, 2026
e1bbe9b
fix
bosiraphael Jun 8, 2026
20b3a50
Merge branch 'main' into r--people-data-labs-enrichment-mapper
bosiraphael Jun 8, 2026
e0479b6
add comments until sdk is fixed
bosiraphael Jun 8, 2026
02440dd
Merge branch 'r--people-data-labs-enrichment-mapper' of github.com:tw…
bosiraphael Jun 8, 2026
0849b36
Merge remote-tracking branch 'origin/main' into r--people-data-labs-e…
bosiraphael Jun 9, 2026
302180b
Merge branch 'main' into r--people-data-labs-enrichment-mapper
bosiraphael Jun 9, 2026
c187e7c
Merge branch 'main' into r--people-data-labs-enrichment-mapper
bosiraphael Jun 9, 2026
2219bcb
Merge branch 'main' into r--people-data-labs-enrichment-mapper
bosiraphael Jun 9, 2026
4c05eb6
logic functions improvements
bosiraphael Jun 9, 2026
ddf03d2
Merge branch 'r--people-data-labs-enrichment-mapper' of github.com:tw…
bosiraphael Jun 9, 2026
8bc4ec3
update functions and add seeded views
bosiraphael Jun 9, 2026
e2524a7
update logic functions
bosiraphael Jun 9, 2026
7ba1fe5
use object signatures
bosiraphael Jun 9, 2026
3478e0f
improvements
bosiraphael Jun 9, 2026
f794acf
improvements
bosiraphael Jun 10, 2026
f832fc4
improvements
bosiraphael Jun 10, 2026
0051996
Merge branch 'main' into r--people-data-labs-enrichment-mapper
bosiraphael Jun 10, 2026
6c1534b
rename variables
bosiraphael Jun 10, 2026
040206c
add new error
bosiraphael Jun 10, 2026
1e5ced3
Merge branch 'main' into r--people-data-labs-enrichment-mapper
bosiraphael Jun 10, 2026
11b84fc
typecheck
bosiraphael Jun 10, 2026
fc6c66d
add navigation menu folder
bosiraphael Jun 10, 2026
f99ac2b
fixes
bosiraphael Jun 10, 2026
cd3927b
capitalize name
bosiraphael Jun 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions packages/twenty-apps/internal/people-data-labs/.oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,6 @@
"ignorePatterns": ["node_modules", "dist"],
"rules": {
"func-style": ["error", "declaration", { "allowArrowFunctions": true }],
"no-console": [
"warn",
{ "allow": ["group", "groupCollapsed", "groupEnd"] }
],
"no-control-regex": "off",
"no-debugger": "error",
"no-duplicate-imports": "error",
Expand Down
79 changes: 69 additions & 10 deletions packages/twenty-apps/internal/people-data-labs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,59 @@

Enriches **Person** and **Company** records with [People Data Labs](https://www.peopledatalabs.com/) (PDL) data.

> **Status: data-model scaffold.** This package defines the fields, relation, indexes,
> views, role, and manifest. The enrichment **logic function (the "mapper") is not yet
> implemented** — see [What the mapper must do](#what-the-mapper-must-do).
> **Status: data model + enrichment mapper + seeded workflows.** This package defines the
> fields, relation, indexes, views, role, and manifest, implements the enrichment **logic
> functions** that call the PDL REST API and map the response onto the standard + `pdl*`
> fields, and seeds the manual "Enrich" record-action workflows on install.

---

## 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/`:

- 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-bulk-enrichment.ts`). The AI tools stay single-record.

- Read the record, guard against re-enriching within a TTL (`pdlLastEnrichedAt`), pick a
match identifier (person: `pdlId` → LinkedIn → email → name; company: `pdlId` → domain →
name), and call the PDL Person/Company Enrichment API (`src/logic-functions/utils/`).
- On a match: fill **standard fields only when empty** (never clobber user data), always
(re)write `pdl*` fields, and set `pdlEnrichmentStatus = MATCHED`, `pdlLastEnrichedAt`,
`pdlRawPayload` (+ `pdlLikelihood` for Person). PDL `404` → `NOT_FOUND`; other errors →
`ERROR`. No identifier / fresh TTL → skipped with no writes.
- SELECT/MULTI_SELECT values are normalized and dropped if not in the field's option set
(`src/logic-functions/utils/`); the option sets are the same `src/constants/*-options.ts`
the field definitions use.

Run locally: `yarn twenty dev:function:exec -n enrich-person -p '{"records":[{"id":"<id>"}]}'`.

### Seeded workflows (post-install)

`post-install.function.ts` runs once on install and creates two ready-to-use
enrichment workflows so the action is available out of the box:

- **Enrich companies** — manual trigger available when one or more Companies are
selected → bulk-enriches them via `enrich-company`.
- **Enrich people** — manual trigger available when one or more People are
selected → bulk-enriches them via `enrich-person`.

Each workflow is a `MANUAL` / `BULK_RECORDS` trigger wired to a single
`LOGIC_FUNCTION` step whose `records` input is bound to the selected records
(`{{trigger.companies}}` / `{{trigger.people}}`). With `BULK_RECORDS` the action
appears whenever one or more records are selected, and a single workflow run hands
the whole selection to one function call. The post-install resolves each function's
runtime id from its `universalIdentifier` (via the metadata API), publishes the
version (`activateWorkflowVersion`) so the record action appears, and is
**idempotent** — it skips a workflow whose name already exists
(`src/logic-functions/handlers/post-install.ts`).

**Deferred to a later PR:** enrichment metering/billing, and auto-enrichment
triggers (on-create event + cron backfill).

---

Expand All @@ -25,9 +75,9 @@ PDL schema v34.1**:
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | ------------------------------------------ |
| `pdlSeniority` (`job_title_levels`, array) | MULTI_SELECT | 10 |
| `pdlFundingStages` (`funding_stages`, array) | MULTI_SELECT | 29 |
| `pdlIndustry` / `pdlJobCompanyIndustry` (`industry`) | SELECT | 147 |
| `pdlIndustry` (`industry`) | SELECT | 147 |
| `pdlJobTitleSubRole` (`job_title_sub_role`) | SELECT | 106 |
| `pdlJobTitleClass`, `pdlInferredSalary`, `pdlSex`, `pdlCompanyType`, `pdlSizeRange`, `pdlJobCompanySize`, `pdlLatestFundingStage`, `pdlLocationContinent`, `pdlLocationMetro`, `pdlMicExchange` | SELECT | 5 / 11 / 2 / 6 / 8 / 8 / 29 / 7 / 384 / 70 |
| `pdlJobTitleClass`, `pdlInferredSalary`, `pdlSex`, `pdlCompanyType`, `pdlSizeRange`, `pdlLatestFundingStage`, `pdlLocationContinent`, `pdlLocationMetro`, `pdlMicExchange` | SELECT | 5 / 11 / 2 / 6 / 8 / 29 / 7 / 384 / 70 |

- Option `value`s are normalized to **GraphQL enum names** (`united states` → `UNITED_STATES`):
uppercase, accents stripped, non-alphanumeric → `_`, digit-leading prefixed.
Expand Down Expand Up @@ -58,11 +108,19 @@ into the standard bags).
- `pdlLocationMetro` (both) and `pdlLocationContinent` (company) stay SELECT — ADDRESS has no slot.
_Trade-off:_ ADDRESS `country` is free text, so the country SELECT was dropped.

### Relation
### Current company → standard `company`

PDL's detected current employer (`job_company_*`) is resolved to a Company record
(**find-or-create**, matched by `pdlId` → domain → LinkedIn → name; created with
`name` / `domainName` / `linkedinLink` + `pdlId` / `pdlIndustry` / `pdlSizeRange` when none
matches) and linked via the **standard `company`** relation, **fill-only-if-empty** — it never
overwrites a company the user already set, and the lookup is skipped entirely when the person
already has one (no orphan companies).

Dedicated **`pdlCurrentCompany`** (Person `MANY_TO_ONE` → Company) ↔ inverse
**`pdlCurrentEmployees`** (Company `ONE_TO_MANY` → Person). Deliberately **not** the standard
`company` relation, so PDL's detected employer can't overwrite the user's CRM account link.
Company attributes live on the **Company** record, not denormalized on the Person. The earlier
`pdlCurrentCompany` / `pdlCurrentEmployees` relation and the six `pdlJobCompany*` scalar fields
were **removed** as duplicates of the standard `company` relation and the linked Company's own
fields.

### Enrichment metadata

Expand Down Expand Up @@ -108,7 +166,8 @@ The logic function (to be built) must:
10. **CURRENCY**: `total_funding_raised` (USD float) → `{ amountMicros: value × 1_000_000, currencyCode: 'USD' }`.
11. **ADDRESS**: split PDL `location.*` into the composite — Company → standard `address`,
Person → `pdlLocation`.
12. **Relation**: resolve `job_company_id` → find/upsert a Company record → link `pdlCurrentCompany`.
12. **Company**: resolve `job_company_*` → find-or-create a Company record → link the standard
`company` relation (fill-only-if-empty).
13. **Dates**: handle partial PDL dates (`YYYY`, `YYYY-MM`) for `job_start_date`,
`last_funding_date`, `birth_date`.
14. Always set `pdlId`, `pdlLastEnrichedAt`, `pdlRawPayload`, `pdlLikelihood` (person).
9 changes: 5 additions & 4 deletions packages/twenty-apps/internal/people-data-labs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,13 @@
"twenty": "twenty",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest"
"test": "vitest run --config vitest.unit.config.ts --passWithNoTests",
"test:watch": "vitest --config vitest.unit.config.ts",
"test:integration": "vitest run --passWithNoTests"
},
"dependencies": {
"twenty-client-sdk": "2.4.0",
"twenty-sdk": "2.4.0"
"twenty-client-sdk": "2.10.0",
"twenty-sdk": "2.10.0"
},
"devDependencies": {
"@types/node": "^24.7.2",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest';

import { COMPANY_TYPE_OPTIONS } from 'src/constants/company-type-options';
import { INFERRED_SALARY_OPTIONS } from 'src/constants/inferred-salary-options';
import { JOB_ROLE_OPTIONS } from 'src/constants/job-role-options';
import { JOB_TITLE_CLASS_OPTIONS } from 'src/constants/job-title-class-options';
import { JOB_TITLE_SUB_ROLE_OPTIONS } from 'src/constants/job-title-sub-role-options';
import { LOCATION_CONTINENT_OPTIONS } from 'src/constants/location-continent-options';
import { MIC_EXCHANGE_OPTIONS } from 'src/constants/mic-exchange-options';
import { SENIORITY_OPTIONS } from 'src/constants/seniority-options';
import { SEX_OPTIONS } from 'src/constants/sex-options';
import { type SelectOptionMeta } from 'src/types/select-option-meta';

import companyTypeField from 'src/fields/company/pdl-company-type.field';
import locationContinentField from 'src/fields/company/pdl-location-continent.field';
import micExchangeField from 'src/fields/company/pdl-mic-exchange.field';
import inferredSalaryField from 'src/fields/person/pdl-inferred-salary.field';
import jobRoleField from 'src/fields/person/pdl-job-role.field';
import jobTitleClassField from 'src/fields/person/pdl-job-title-class.field';
import jobTitleSubRoleField from 'src/fields/person/pdl-job-title-sub-role.field';
import seniorityField from 'src/fields/person/pdl-seniority.field';
import sexField from 'src/fields/person/pdl-sex.field';

type FieldOption = { id?: string; value: string };

const fieldOptions = (field: unknown): FieldOption[] => {
const options = (field as { config?: { options?: FieldOption[] } }).config
?.options;
return options ?? [];
};

const cases: {
name: string;
field: unknown;
options: readonly SelectOptionMeta[];
}[] = [
{ name: 'pdlSeniority', field: seniorityField, options: SENIORITY_OPTIONS },
{ name: 'pdlJobRole', field: jobRoleField, options: JOB_ROLE_OPTIONS },
{
name: 'pdlJobTitleClass',
field: jobTitleClassField,
options: JOB_TITLE_CLASS_OPTIONS,
},
{
name: 'pdlJobTitleSubRole',
field: jobTitleSubRoleField,
options: JOB_TITLE_SUB_ROLE_OPTIONS,
},
{ name: 'pdlSex', field: sexField, options: SEX_OPTIONS },
{
name: 'pdlInferredSalary',
field: inferredSalaryField,
options: INFERRED_SALARY_OPTIONS,
},
{
name: 'pdlCompanyType',
field: companyTypeField,
options: COMPANY_TYPE_OPTIONS,
},
{
name: 'pdlLocationContinent',
field: locationContinentField,
options: LOCATION_CONTINENT_OPTIONS,
},
{
name: 'pdlMicExchange',
field: micExchangeField,
options: MIC_EXCHANGE_OPTIONS,
},
];

describe.each(cases)('$name options', ({ field, options }) => {
it('resolves a universalIdentifier for every option', () => {
expect(fieldOptions(field).every((option) => Boolean(option.id))).toBe(true);
});

it('exposes exactly the values from its source constant', () => {
expect(fieldOptions(field).map((option) => option.value)).toEqual(
options.map((option) => option.value),
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { type SelectOptionMeta } from 'src/types/select-option-meta';

export const COMPANY_TYPE_OPTIONS: readonly SelectOptionMeta[] = [
{
key: 'public',
value: 'PUBLIC',
label: 'Public',
color: 'blue',
position: 0,
},
{
key: 'private',
value: 'PRIVATE',
label: 'Private',
color: 'red',
position: 1,
},
{
key: 'publicSubsidiary',
value: 'PUBLIC_SUBSIDIARY',
label: 'Public Subsidiary',
color: 'green',
position: 2,
},
{
key: 'educational',
value: 'EDUCATIONAL',
label: 'Educational',
color: 'orange',
position: 3,
},
{
key: 'government',
value: 'GOVERNMENT',
label: 'Government',
color: 'purple',
position: 4,
},
{
key: 'nonprofit',
value: 'NONPROFIT',
label: 'Nonprofit',
color: 'yellow',
position: 5,
},
];
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type SelectOptionMeta } from 'src/types/select-option-meta.type';
import { type SelectOptionMeta } from 'src/types/select-option-meta';

export const ENRICHMENT_STATUS_OPTIONS: readonly SelectOptionMeta[] = [
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { PDL_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers';
import { type EnrichmentWorkflowSeed } from 'src/types/enrichment-workflow-seed';

const ENRICHMENT_ICON = 'IconSparkles';

export const ENRICHMENT_WORKFLOW_SEEDS: EnrichmentWorkflowSeed[] = [
{
objectNameSingular: 'company',
workflowName: 'Enrich companies with People Data Labs',
triggerName: 'When companies are selected',
icon: ENRICHMENT_ICON,
stepName: 'Enrich with People Data Labs',
logicFunctionUniversalIdentifier:
PDL_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIERS.enrichCompany,
logicFunctionInput: { records: '{{trigger.companies}}' },
},
{
objectNameSingular: 'person',
workflowName: 'Enrich people with People Data Labs',
triggerName: 'When people are selected',
icon: ENRICHMENT_ICON,
stepName: 'Enrich with People Data Labs',
logicFunctionUniversalIdentifier:
PDL_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIERS.enrichPerson,
logicFunctionInput: { records: '{{trigger.people}}' },
},
];
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type SelectOptionMeta } from 'src/types/select-option-meta.type';
import { type SelectOptionMeta } from 'src/types/select-option-meta';

export const FUNDING_STAGE_OPTIONS: readonly SelectOptionMeta[] = [
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type SelectOptionMeta } from 'src/types/select-option-meta.type';
import { type SelectOptionMeta } from 'src/types/select-option-meta';

export const INDUSTRY_OPTIONS: readonly SelectOptionMeta[] = [
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { type SelectOptionMeta } from 'src/types/select-option-meta';

export const INFERRED_SALARY_OPTIONS: readonly SelectOptionMeta[] = [
{
key: 'under20000',
value: 'UNDER_20000',
label: '<20K',
color: 'blue',
position: 0,
},
{
key: 'from20000To25000',
value: 'FROM_20000_TO_25000',
label: '20K-25K',
color: 'red',
position: 1,
},
{
key: 'from25000To35000',
value: 'FROM_25000_TO_35000',
label: '25K-35K',
color: 'green',
position: 2,
},
{
key: 'from35000To45000',
value: 'FROM_35000_TO_45000',
label: '35K-45K',
color: 'orange',
position: 3,
},
{
key: 'from45000To55000',
value: 'FROM_45000_TO_55000',
label: '45K-55K',
color: 'purple',
position: 4,
},
{
key: 'from55000To70000',
value: 'FROM_55000_TO_70000',
label: '55K-70K',
color: 'yellow',
position: 5,
},
{
key: 'from70000To85000',
value: 'FROM_70000_TO_85000',
label: '70K-85K',
color: 'pink',
position: 6,
},
{
key: 'from85000To100000',
value: 'FROM_85000_TO_100000',
label: '85K-100K',
color: 'cyan',
position: 7,
},
{
key: 'from100000To150000',
value: 'FROM_100000_TO_150000',
label: '100K-150K',
color: 'brown',
position: 8,
},
{
key: 'from150000To250000',
value: 'FROM_150000_TO_250000',
label: '150K-250K',
color: 'lime',
position: 9,
},
{
key: 'over250000',
value: 'OVER_250000',
label: '>250K',
color: 'violet',
position: 10,
},
];
Loading
Loading