From d7c3f2282d92fabdd688b77f41dee05bb00f3c4a Mon Sep 17 00:00:00 2001 From: bosiraphael Date: Fri, 5 Jun 2026 18:38:49 +0200 Subject: [PATCH] fixes and sdk version bump --- .../internal/people-data-labs/README.md | 26 +- .../internal/people-data-labs/package.json | 4 +- .../src/constants/universal-identifiers.ts | 167 --------- .../company/pdl-current-employees.field.ts | 28 -- .../person/pdl-current-company.field.ts | 27 -- .../fields/person/pdl-job-company-id.field.ts | 19 -- .../person/pdl-job-company-industry.field.ts | 30 -- .../pdl-job-company-linkedin-url.field.ts | 20 -- .../person/pdl-job-company-name.field.ts | 19 -- .../person/pdl-job-company-size.field.ts | 28 -- .../person/pdl-job-company-website.field.ts | 20 -- .../__mocks__/create-core-api-client-mock.ts | 20 +- .../__tests__/enrich-person.core.spec.ts | 81 ++++- .../handlers/enrich-person.core.ts | 6 + .../build-company-create-data.spec.ts | 37 ++ .../build-company-match-keys.spec.ts | 35 ++ .../find-company-id-by-filter.spec.ts | 42 +++ .../utils/__tests__/find-company-id.spec.ts | 90 +++++ .../find-or-create-current-company.spec.ts | 106 ++++++ .../utils/__tests__/map-person.spec.ts | 8 +- .../utils/build-company-create-data.ts | 24 ++ .../utils/build-company-match-keys.ts | 12 + .../utils/find-company-id-by-filter.ts | 17 + .../logic-functions/utils/find-company-id.ts | 49 +++ .../utils/find-or-create-current-company.ts | 52 +++ .../src/logic-functions/utils/map-person.ts | 14 - .../src/logic-functions/utils/read-person.ts | 1 + .../src/types/company-match-keys.type.ts | 6 + .../src/types/person-node.type.ts | 1 + .../is-unique-violation-error.spec.ts | 27 ++ .../src/utils/is-unique-violation-error.ts | 19 ++ .../internal/people-data-labs/yarn.lock | 322 ++++++++++++++++-- 32 files changed, 932 insertions(+), 425 deletions(-) delete mode 100644 packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-current-employees.field.ts delete mode 100644 packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-current-company.field.ts delete mode 100644 packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-job-company-id.field.ts delete mode 100644 packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-job-company-industry.field.ts delete mode 100644 packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-job-company-linkedin-url.field.ts delete mode 100644 packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-job-company-name.field.ts delete mode 100644 packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-job-company-size.field.ts delete mode 100644 packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-job-company-website.field.ts create mode 100644 packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-company-create-data.spec.ts create mode 100644 packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-company-match-keys.spec.ts create mode 100644 packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/find-company-id-by-filter.spec.ts create mode 100644 packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/find-company-id.spec.ts create mode 100644 packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/find-or-create-current-company.spec.ts create mode 100644 packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-company-create-data.ts create mode 100644 packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-company-match-keys.ts create mode 100644 packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/find-company-id-by-filter.ts create mode 100644 packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/find-company-id.ts create mode 100644 packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/find-or-create-current-company.ts create mode 100644 packages/twenty-apps/internal/people-data-labs/src/types/company-match-keys.type.ts create mode 100644 packages/twenty-apps/internal/people-data-labs/src/utils/__tests__/is-unique-violation-error.spec.ts create mode 100644 packages/twenty-apps/internal/people-data-labs/src/utils/is-unique-violation-error.ts diff --git a/packages/twenty-apps/internal/people-data-labs/README.md b/packages/twenty-apps/internal/people-data-labs/README.md index 2a98c4f8029..9966b6f9823 100644 --- a/packages/twenty-apps/internal/people-data-labs/README.md +++ b/packages/twenty-apps/internal/people-data-labs/README.md @@ -27,8 +27,7 @@ trigger-agnostic core in `src/logic-functions/handlers/`: Run locally: `yarn twenty dev:function:exec -n enrich-person -p '{"recordId":""}'`. -**Deferred to a later PR:** linking the `pdlCurrentCompany` relation (find-or-create a -Company from `job_company_*`), enrichment metering/billing, and auto-enrichment triggers. +**Deferred to a later PR:** enrichment metering/billing, and auto-enrichment triggers. --- @@ -49,9 +48,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. @@ -82,11 +81,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` -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. +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). + +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 @@ -132,7 +139,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). diff --git a/packages/twenty-apps/internal/people-data-labs/package.json b/packages/twenty-apps/internal/people-data-labs/package.json index ab2dc41dbe5..d60769d8b95 100644 --- a/packages/twenty-apps/internal/people-data-labs/package.json +++ b/packages/twenty-apps/internal/people-data-labs/package.json @@ -21,8 +21,8 @@ "test:integration": "vitest run --passWithNoTests" }, "dependencies": { - "twenty-client-sdk": "2.4.0", - "twenty-sdk": "2.4.0" + "twenty-client-sdk": "2.9.1", + "twenty-sdk": "2.9.1" }, "devDependencies": { "@types/node": "^24.7.2", 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 2c040cf4377..d0e4b6fb3f8 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 @@ -28,7 +28,6 @@ export const PDL_FIELD_UNIVERSAL_IDENTIFIERS = { pdlRawPayload: '91153108-0576-4da8-a58d-bf8bfc693af1', pdlLastEnrichedAt: '437e08e7-4a94-45af-9def-f202d54ea910', pdlLocation: 'e25302a6-72ed-41e9-a089-1789a1e7ff3c', - pdlCurrentCompany: '0ed142f6-f4b9-4517-a4ad-4c09da25ef21', pdlLinkedinUsername: 'b897abde-d051-459e-8761-1f8cdc2a93a5', pdlFacebookUrl: '733a1f4e-43d7-4450-aaee-03436c6f3df8', pdlProfiles: '63fd9ead-07a0-42b0-b230-17fb5c1b2666', @@ -47,12 +46,6 @@ export const PDL_FIELD_UNIVERSAL_IDENTIFIERS = { pdlCertifications: '02006f53-8c32-48a0-9738-73c38ad9412d', pdlSummary: '243d13f9-74e7-4638-9f9a-33fe76d584dd', pdlJobOnetCode: '48f70f1b-cad8-44e4-a27e-5dc26edfd391', - pdlJobCompanyName: '1426a17d-09c2-48aa-bd49-3adffeb2ec59', - pdlJobCompanyId: '3c8cd41b-1974-4da0-975c-1b3c18c436d9', - pdlJobCompanyWebsite: '7e44f20d-2443-4e33-999d-9c3b575112e1', - pdlJobCompanyLinkedinUrl: '3035757a-c93a-4d1f-9fb2-e4b581ea6cfa', - pdlJobCompanyIndustry: '4c37d4f1-3bf9-4881-afce-bbbc1e113832', - pdlJobCompanySize: '1c7c4e5a-fafa-4c7b-b4a7-3bf0b0ca07c5', pdlLocationMetro: 'f5e7434d-c8f5-4eab-866b-59693c9e7111', pdlLikelihood: '50583b08-f422-4964-a9ff-a4e4d6f7a6f0', pdlEnrichmentStatus: 'f03774c1-2b8b-4c62-89af-6add78efc4df', @@ -73,7 +66,6 @@ export const PDL_FIELD_UNIVERSAL_IDENTIFIERS = { pdlFundingStages: 'ced2c9d6-93ea-40de-9636-c0e719b9dc0b', pdlRawPayload: 'cc5bb8e5-c60d-4791-b838-3526942d6548', pdlLastEnrichedAt: '4fd64027-0ba9-4d2c-85eb-26ff90b7c721', - pdlCurrentEmployees: 'e654855f-1f10-462b-9721-cdf953c9b1bb', pdlLinkedinId: 'dd57bf00-e5b5-414d-a811-5480dac3b537', pdlFacebookUrl: '4f7fe9ad-5c4f-451c-9dc0-d368e669879f', pdlAlternativeNames: 'c53914e4-58b3-4fc2-b04b-94305675e130', @@ -1097,155 +1089,6 @@ export const PDL_SELECT_OPTION_UNIVERSAL_IDENTIFIERS = { wireless: '3702d3db-f99b-44a3-b7ff-4bea043050db', writingAndEditing: 'e25ffec5-db05-4488-85a6-4578c9ce1c9c', }, - personJobCompanyIndustry: { - accounting: 'c7646bb3-309c-4f7e-bd92-1145cd8fc53e', - airlinesAviation: 'ce4b50c2-81ce-473f-832c-d03dad5eb15c', - alternativeDisputeResolution: 'f3adb3b8-6d55-44f9-83d2-8c314f9e1b7f', - alternativeMedicine: 'e50695b4-e961-424c-ba35-f1552afb2972', - animation: '3025feb7-36ef-4c7c-94f3-0e6d8c198b12', - apparelFashion: '25f1bcec-3c54-48e7-8f27-5d1c905fc8b9', - architecturePlanning: 'ab4dee7a-3b6c-4494-b9c1-bd6cece38372', - artsAndCrafts: 'c61baa2c-3fba-444b-ab7d-a057b15a86d0', - automotive: 'dbc0cb0b-4d91-4ed9-be79-51bbf07e0ad2', - aviationAerospace: '73bf83ab-7ae0-438e-8c16-aff98d7a1bf5', - banking: 'b8358792-323c-4d65-af43-a550fba41896', - biotechnology: 'caa1993b-b5bc-4a38-bb35-461e9ac64cd9', - broadcastMedia: 'ebce7aa1-6d24-48f4-afa9-1ce469496447', - buildingMaterials: 'd0b16ab4-690a-4676-b88d-69b1879e5d6e', - businessSuppliesAndEquipment: '98c3aa81-d777-4b09-b94d-bf3a64214917', - capitalMarkets: 'af0ce50f-8d50-489e-962a-cf360bdb4b69', - chemicals: '11a423de-7ddd-466e-8914-bd8cd54e8a14', - civicSocialOrganization: 'e81d39f0-5411-49ab-90ab-d6fc1341d7f5', - civilEngineering: '30d1ce66-d5f9-4839-af59-0beca8b77bc4', - commercialRealEstate: '907f9a17-4090-4ff7-ad92-1fb1f2e14d7d', - computerNetworkSecurity: '6bd9dfae-fa74-48f1-83c9-59005daaa95a', - computerGames: '6587be1f-4d50-4155-846f-ea26da225f40', - computerHardware: 'fff38d7e-a166-4c6a-96b7-f28209b73136', - computerNetworking: 'f172e9f0-4217-4f59-ba31-0d6eaac5939f', - computerSoftware: '40092912-7856-4a92-b39f-8f17c7d477a7', - construction: '847712c3-37e8-4030-9735-5a04fccef373', - consumerElectronics: 'b76caec2-ce78-4aec-a1a5-d8a1b7a5b337', - consumerGoods: '3e59570f-4791-4545-aea8-1992cdc7af08', - consumerServices: '53ddcc27-a1d1-426e-9950-172ad4ac53f5', - cosmetics: '27740b0f-76a5-477d-a7e3-6335e5d1886f', - dairy: 'fdccb7e6-033f-460a-a237-e1b3ff8eeeb3', - defenseSpace: 'd87f7ba9-4f64-4b1c-a3cb-e4b26ffe9b28', - design: 'c58ea607-1436-42c6-b49b-01bede5d65be', - eLearning: '4dd2bbef-04f5-41a1-852c-a9066e9b56f4', - educationManagement: '5f99d759-6789-4e32-967b-53de2d247e65', - electricalElectronicManufacturing: '8ba94c6e-3d42-45d1-8a37-abfe0c916b09', - entertainment: '2534f3b2-2efd-4399-9189-ae0e80dea0b8', - environmentalServices: '4899a93c-912c-4969-a86d-481a9976cf7b', - eventsServices: '7a3af453-5241-4dd4-9962-4355a1569d26', - executiveOffice: 'e5563e78-6ebf-4ab4-a094-83f67b110c2a', - facilitiesServices: '4794543b-ade7-4975-a723-85d0c3c25e1f', - farming: '8ebd9eab-4fa9-45e4-8763-2386aa704729', - financialServices: '352126e6-b731-4531-bb55-45ed262b8790', - fineArt: 'e797e22f-f467-4e02-9678-6895183b12af', - fishery: 'f20950cd-5338-48b2-a368-19e3c5f54090', - foodBeverages: '0d25b31f-9715-4ef9-bb08-9332cd111a49', - foodProduction: '3509b838-e3e4-4b85-a2f7-c9350197a14e', - fundRaising: 'fd0d42aa-0b4e-46ef-b319-389bd77968d0', - furniture: '3e4a3b44-edeb-4266-aad7-64524e3db97b', - gamblingCasinos: '5e7bd38d-f0e1-4b8a-9085-d62bbbe5210d', - glassCeramicsConcrete: 'c0e67757-aaf3-40b3-833b-80c76f37c51c', - governmentAdministration: '930f7ae7-063a-429a-8130-6e4aed9b8f41', - governmentRelations: '26fe8ba1-c71a-4ad3-8026-ec3c147effe1', - graphicDesign: '124ba3b2-2557-4e63-9090-62ec0963f8d3', - healthWellnessAndFitness: 'a40465be-5a7a-44e3-a823-f5b9419c5be0', - higherEducation: 'cf1b074d-8cf5-484f-828e-2d2f9d037ad2', - hospitalHealthCare: '059bd3c7-88e6-468b-9842-81d33556ac17', - hospitality: '4df1f2ea-82a3-4ff4-890b-1cbe1f3203e7', - humanResources: '123531cc-7f88-4c0b-bd98-74c7fbe4ccbb', - importAndExport: '2053fa78-d93a-461d-8e84-695099a5e0b7', - individualFamilyServices: '48ecdf2b-faab-4979-904a-0d082bfcc83e', - industrialAutomation: 'be305ba6-fab3-4952-9fa0-2b4dc682b145', - informationServices: '40ae4e28-2c3a-416d-9f66-214808ec0cbf', - informationTechnologyAndServices: '58fc2d7c-a2e0-4284-b263-ff34f5a37d1b', - insurance: 'f3c59c70-6ad4-446e-97e1-27c5ea251c82', - internationalAffairs: '88b3db39-344c-4df7-a21b-9841d6d5e3c9', - internationalTradeAndDevelopment: 'f0b3eca1-3e03-402c-8398-ffcbd8148880', - internet: '26c060e8-e8bc-4ac6-a25a-e547cafd0050', - investmentBanking: '5ee3fbdc-2965-46f8-9b40-e748ce9cbe17', - investmentManagement: '76ffc3b2-6228-44fb-9cb9-9cf8cfff35ed', - judiciary: '373db33d-eae8-4946-a6b7-377a78bfc210', - lawEnforcement: 'cdaff85b-db82-4ef2-9493-51ab9523076b', - lawPractice: 'b26a48d2-1fec-444a-a39c-64b476588899', - legalServices: 'a7d8ff6a-a754-4a05-9830-d9b20e34160c', - legislativeOffice: '3f80fd4c-d564-468e-9808-9b43cb3167d0', - leisureTravelTourism: '3e7560d7-77af-4181-9f7e-38bb8d17a8b4', - libraries: '988b13ae-7bb1-472b-a991-bf040d079b11', - logisticsAndSupplyChain: 'c6cb1c2c-53d0-46f0-8004-0d9962d6106d', - luxuryGoodsJewelry: '15ac9e52-adb5-48e3-893d-87789511a966', - machinery: '509fbd7a-0a44-4f94-aa5c-685cce3faba4', - managementConsulting: '3a3d9a9b-f72c-4cde-bb76-ef9458bdc516', - maritime: '028d0304-43ba-48f6-aac9-33971bd5aac2', - marketResearch: '32f05431-ca91-456f-a1cf-8e45f64be5d2', - marketingAndAdvertising: '6e0c1593-b08a-4fe2-a985-186706777c13', - mechanicalOrIndustrialEngineering: '72a8c609-7ed2-454d-ae28-5ed7c10932ab', - mediaProduction: 'd82defb2-294b-4552-af09-0d0b3e3f87e4', - medicalDevices: 'efe813c7-5d54-4a8a-b97e-4290f670d1da', - medicalPractice: '58bad1fd-667f-40ca-ba18-aac0396a02b4', - mentalHealthCare: 'f03985f0-5cc7-4850-b97c-a7ea425cbee0', - military: '4a410e16-54ef-4312-bd3f-36c2ee36943e', - miningMetals: 'cd00c312-130f-49c8-9942-00ea24553ba5', - motionPicturesAndFilm: '5d935c3c-c8a3-4e51-a593-da3dbbab5783', - museumsAndInstitutions: 'd4c2a66f-88ae-4e2a-b3fd-855951d0376c', - music: '74cebb0e-dfa6-4fe4-9e10-2055d7265078', - nanotechnology: '1f02fa05-1464-4e32-9090-230ad2d0557b', - newspapers: 'b62a3423-075c-4bf9-a78c-528754df2841', - nonProfitOrganizationManagement: '92ae1c10-12c5-423d-bb73-6f7730c5d282', - oilEnergy: '3bea7331-2355-4a37-8604-3e83a363ef1b', - onlineMedia: '2f893d39-9975-44cf-a607-072be812d9a4', - outsourcingOffshoring: '29f227ec-2a83-418a-8bde-7627dd25add3', - packageFreightDelivery: '652bfc40-a5be-4191-a9ed-10db3f5ba059', - packagingAndContainers: '223e7263-2e3a-4a39-bef0-5a5c95759d7d', - paperForestProducts: '083402a8-f06d-4b0f-9654-2d4e0c51d4ae', - performingArts: 'd74ee411-6486-47ba-b0e2-56e606249a91', - pharmaceuticals: '99a8dae3-60a3-4cd5-80e6-a83651a28146', - philanthropy: '1744e009-9330-42e4-98ad-23da6d1cc898', - photography: 'a93a38e3-847e-4280-bb9a-cf7269115e13', - plastics: '8b06307f-df3b-45e2-adf6-f01b6c9a44a2', - politicalOrganization: '1a40650c-ed34-4d3e-97b9-70bfd48b1606', - primarySecondaryEducation: 'aeaa7034-3847-4e2b-83c6-5ad3c2c7370b', - printing: '29c4dd98-5f87-436f-8a9a-846daf36e648', - professionalTrainingCoaching: 'ddc493cb-208f-4c8f-b122-23cbe45c0ffe', - programDevelopment: 'e83e628a-9f57-4727-925f-37114ce125c1', - publicPolicy: '58c6e64d-e5a9-495d-b3ed-87987f976c4f', - publicRelationsAndCommunications: '19bc1020-8038-4be4-a4b5-be85e54c7535', - publicSafety: '69f06a55-17d5-4c4c-94ba-44625ec8e1f5', - publishing: '95e47f5e-d02b-4707-8510-c1310158260b', - railroadManufacture: '1eff24a9-db0f-463a-a9c6-2bdebd760ec6', - ranching: 'd3f2e4ad-d933-4cc0-ae2d-65f925bee53d', - realEstate: '9b81a975-c52e-46d2-b853-334a484f24e5', - recreationalFacilitiesAndServices: 'a2b4ba9b-65b7-4f06-8f99-78b4b03a5a8b', - religiousInstitutions: '9dc283bc-dfb2-4a7f-bce7-433ce0e4c22d', - renewablesEnvironment: 'dffe3cc0-13d3-48f4-8a72-f8f5509d97e5', - research: 'f3a47e4f-30c4-4b02-8cb0-8d493518ce57', - restaurants: 'ec0efd93-dffe-465d-9dfe-d23e91c3aa71', - retail: '143fdd79-0167-4701-b910-0640b69e6909', - securityAndInvestigations: '4072f339-ee30-465a-af76-0f58d8469e25', - semiconductors: 'c1103955-5e09-4f78-bfc2-9366c18bf22e', - shipbuilding: '2d6481c3-d46b-4e67-9328-dd2e22eacc02', - sportingGoods: 'ed197d67-bf55-4e50-a1ca-264c93a62961', - sports: '5183468a-5b8d-4442-9ea3-9a45de9a7630', - staffingAndRecruiting: '5e610095-5d70-4816-a56d-b9fc0705e0b0', - supermarkets: '4531f90a-d45a-4f84-8066-da2987fc46e6', - telecommunications: '94c7b837-0f22-4fca-a7a2-8951cf869c81', - textiles: 'a09422a2-e181-4faa-b904-3816b52bb7ee', - thinkTanks: 'c434dbe6-fa9a-4af1-8b40-9165b6a34530', - tobacco: '16ebae0f-59fc-4b8d-932f-840d54ae8b5c', - translationAndLocalization: 'b94949a2-bf18-4cd3-943a-c381540d088e', - transportationTruckingRailroad: '19882453-428c-4179-8614-a5e43dd40e5a', - utilities: '911dfaf3-d735-4fdb-92c0-d038dc993144', - ventureCapitalPrivateEquity: '478e405c-b780-45a9-9979-0910a9d10a40', - veterinary: '2ed603fb-0491-4386-82ca-d5e43bbcdd51', - warehousing: 'e0758679-11f6-45cf-af21-da21e297edbc', - wholesale: '14d5d2c2-cd70-4436-9e42-2cb913f942e8', - wineAndSpirits: 'd470d512-1736-4fe2-8c96-af30d8f03e60', - wireless: 'dcf165e4-c123-436f-bd52-b6ddafcb3d74', - writingAndEditing: 'a78d7014-682e-4455-a3d1-9f0c4bb21b01', - }, companyIndustry: { accounting: '67406afe-dd7e-4a39-a86b-394e1f3b7428', airlinesAviation: '6e5ea79c-fc34-4fd1-aaf0-67d94585ad84', @@ -1523,16 +1366,6 @@ export const PDL_SELECT_OPTION_UNIVERSAL_IDENTIFIERS = { from150000To250000: '063ce36e-51ff-4dc2-86eb-3a4b2f823297', over250000: 'fc3658bf-dec7-443b-84f3-0770f3aae62f', }, - personJobCompanySize: { - oneToTen: '9a958c46-b3e3-4e36-ba2f-08b5e0dd1ee7', - elevenToFifty: 'b383e032-cf52-4082-ba2e-67b02228e283', - fiftyOneToTwoHundred: '32fb12cb-49aa-4fbf-b683-365cea2f8d30', - twoHundredOneToFiveHundred: '333ec4cb-e922-4b80-8830-13bf89d600a0', - fiveHundredOneToOneThousand: '8d1612c8-5991-4d98-8901-f86effd8bd79', - oneThousandOneToFiveThousand: '77383346-4ec3-4c45-9473-4687ee8d58af', - fiveThousandOneToTenThousand: '9d654179-01b3-4694-ac24-f03f14023d4a', - tenThousandOnePlus: '2ff37073-c0ca-4893-ac0c-d0f52cce1ee2', - }, companyLocationContinent: { africa: '0efd52cc-6ee3-4890-81ab-0a12a6c56d81', antarctica: '68bdf16c-bf40-445f-86b6-093c51ed6416', diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-current-employees.field.ts b/packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-current-employees.field.ts deleted file mode 100644 index 52c92ee97e6..00000000000 --- a/packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-current-employees.field.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { - defineField, - FieldType, - RelationType, - STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, -} from 'twenty-sdk/define'; - -import { PDL_FIELD_UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers'; - -export default defineField({ - universalIdentifier: - PDL_FIELD_UNIVERSAL_IDENTIFIERS.company.pdlCurrentEmployees, - objectUniversalIdentifier: - STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier, - type: FieldType.RELATION, - name: 'pdlCurrentEmployees', - label: 'PDL Current Employees', - description: - 'People whose current employer is this company, per People Data Labs.', - relationTargetObjectMetadataUniversalIdentifier: - STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier, - relationTargetFieldMetadataUniversalIdentifier: - PDL_FIELD_UNIVERSAL_IDENTIFIERS.person.pdlCurrentCompany, - universalSettings: { - relationType: RelationType.ONE_TO_MANY, - }, - icon: 'IconUsers', -}); diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-current-company.field.ts b/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-current-company.field.ts deleted file mode 100644 index f3a26650e3a..00000000000 --- a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-current-company.field.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { - defineField, - FieldType, - RelationType, - STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, -} from 'twenty-sdk/define'; - -import { PDL_FIELD_UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers'; - -export default defineField({ - universalIdentifier: PDL_FIELD_UNIVERSAL_IDENTIFIERS.person.pdlCurrentCompany, - objectUniversalIdentifier: - STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier, - type: FieldType.RELATION, - name: 'pdlCurrentCompany', - label: 'PDL Current Company', - description: 'Current employer detected by People Data Labs.', - relationTargetObjectMetadataUniversalIdentifier: - STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier, - relationTargetFieldMetadataUniversalIdentifier: - PDL_FIELD_UNIVERSAL_IDENTIFIERS.company.pdlCurrentEmployees, - universalSettings: { - relationType: RelationType.MANY_TO_ONE, - joinColumnName: 'pdlCurrentCompanyId', - }, - icon: 'IconBuilding', -}); diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-job-company-id.field.ts b/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-job-company-id.field.ts deleted file mode 100644 index 7e5941c52d4..00000000000 --- a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-job-company-id.field.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { - defineField, - FieldType, - STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, -} from 'twenty-sdk/define'; - -import { PDL_FIELD_UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers'; - -export default defineField({ - universalIdentifier: PDL_FIELD_UNIVERSAL_IDENTIFIERS.person.pdlJobCompanyId, - objectUniversalIdentifier: - STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier, - type: FieldType.TEXT, - name: 'pdlJobCompanyId', - label: 'Current Company PDL ID', - description: 'Current company PDL identifier returned by People Data Labs.', - icon: 'IconId', - isNullable: true, -}); diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-job-company-industry.field.ts b/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-job-company-industry.field.ts deleted file mode 100644 index 6a35b5f0cde..00000000000 --- a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-job-company-industry.field.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { - defineField, - FieldType, - STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, -} from 'twenty-sdk/define'; - -import { INDUSTRY_OPTIONS } from 'src/constants/industry-options'; -import { - PDL_FIELD_UNIVERSAL_IDENTIFIERS, - PDL_SELECT_OPTION_UNIVERSAL_IDENTIFIERS, -} from 'src/constants/universal-identifiers'; -import { buildSelectOptions } from 'src/utils/build-select-options'; - -export default defineField({ - universalIdentifier: - PDL_FIELD_UNIVERSAL_IDENTIFIERS.person.pdlJobCompanyIndustry, - objectUniversalIdentifier: - STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier, - type: FieldType.SELECT, - name: 'pdlJobCompanyIndustry', - label: 'Current Company Industry', - description: - 'Current company canonical industry returned by People Data Labs.', - icon: 'IconBuildingFactory2', - isNullable: true, - options: buildSelectOptions( - INDUSTRY_OPTIONS, - PDL_SELECT_OPTION_UNIVERSAL_IDENTIFIERS.personJobCompanyIndustry, - ), -}); diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-job-company-linkedin-url.field.ts b/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-job-company-linkedin-url.field.ts deleted file mode 100644 index 899239a8af4..00000000000 --- a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-job-company-linkedin-url.field.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { - defineField, - FieldType, - STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, -} from 'twenty-sdk/define'; - -import { PDL_FIELD_UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers'; - -export default defineField({ - universalIdentifier: - PDL_FIELD_UNIVERSAL_IDENTIFIERS.person.pdlJobCompanyLinkedinUrl, - objectUniversalIdentifier: - STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier, - type: FieldType.LINKS, - name: 'pdlJobCompanyLinkedinUrl', - label: 'Current Company LinkedIn', - description: 'Current company LinkedIn returned by People Data Labs.', - icon: 'IconBrandLinkedin', - isNullable: true, -}); diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-job-company-name.field.ts b/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-job-company-name.field.ts deleted file mode 100644 index a9ee867412c..00000000000 --- a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-job-company-name.field.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { - defineField, - FieldType, - STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, -} from 'twenty-sdk/define'; - -import { PDL_FIELD_UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers'; - -export default defineField({ - universalIdentifier: PDL_FIELD_UNIVERSAL_IDENTIFIERS.person.pdlJobCompanyName, - objectUniversalIdentifier: - STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier, - type: FieldType.TEXT, - name: 'pdlJobCompanyName', - label: 'Current Company', - description: 'Current company name returned by People Data Labs.', - icon: 'IconBuilding', - isNullable: true, -}); diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-job-company-size.field.ts b/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-job-company-size.field.ts deleted file mode 100644 index f40f2c9ae78..00000000000 --- a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-job-company-size.field.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { - defineField, - FieldType, - STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, -} from 'twenty-sdk/define'; - -import { SIZE_OPTIONS } from 'src/constants/size-options'; -import { - PDL_FIELD_UNIVERSAL_IDENTIFIERS, - PDL_SELECT_OPTION_UNIVERSAL_IDENTIFIERS, -} from 'src/constants/universal-identifiers'; -import { buildSelectOptions } from 'src/utils/build-select-options'; - -export default defineField({ - universalIdentifier: PDL_FIELD_UNIVERSAL_IDENTIFIERS.person.pdlJobCompanySize, - objectUniversalIdentifier: - STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier, - type: FieldType.SELECT, - name: 'pdlJobCompanySize', - label: 'Current Company Size', - description: 'Current company size range returned by People Data Labs.', - icon: 'IconChartBar', - isNullable: true, - options: buildSelectOptions( - SIZE_OPTIONS, - PDL_SELECT_OPTION_UNIVERSAL_IDENTIFIERS.personJobCompanySize, - ), -}); diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-job-company-website.field.ts b/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-job-company-website.field.ts deleted file mode 100644 index c1ef06d9ef8..00000000000 --- a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-job-company-website.field.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { - defineField, - FieldType, - STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, -} from 'twenty-sdk/define'; - -import { PDL_FIELD_UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers'; - -export default defineField({ - universalIdentifier: - PDL_FIELD_UNIVERSAL_IDENTIFIERS.person.pdlJobCompanyWebsite, - objectUniversalIdentifier: - STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier, - type: FieldType.LINKS, - name: 'pdlJobCompanyWebsite', - label: 'Current Company Website', - description: 'Current company website returned by People Data Labs.', - icon: 'IconWorld', - isNullable: true, -}); diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/__mocks__/create-core-api-client-mock.ts b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/__mocks__/create-core-api-client-mock.ts index 166b4304b41..ce7875a070c 100644 --- a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/__mocks__/create-core-api-client-mock.ts +++ b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/__mocks__/create-core-api-client-mock.ts @@ -1,20 +1,34 @@ import { type CoreApiClient } from 'twenty-client-sdk/core'; import { vi } from 'vitest'; +type Resolver = TResult | ((request: unknown) => TResult); + type CoreApiClientMockOptions = { - queryResult?: unknown; + queryResult?: Resolver; + mutationResult?: Resolver; onMutation?: (request: unknown) => void; }; +const resolve = ( + resolver: Resolver | undefined, + request: unknown, +): TResult | undefined => + typeof resolver === 'function' + ? (resolver as (request: unknown) => TResult)(request) + : resolver; + export const createCoreApiClientMock = ({ queryResult, + mutationResult, onMutation, }: CoreApiClientMockOptions = {}): CoreApiClient => { - const query = vi.fn(() => Promise.resolve(queryResult)); + const query = vi.fn((request: unknown) => + Promise.resolve(resolve(queryResult, request)), + ); const mutation = vi.fn((request: unknown) => { onMutation?.(request); - return Promise.resolve({}); + return Promise.resolve(resolve(mutationResult, request) ?? {}); }); return { query, mutation } as unknown as CoreApiClient; diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/__tests__/enrich-person.core.spec.ts b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/__tests__/enrich-person.core.spec.ts index f3f2dab8873..ea5a00e9573 100644 --- a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/__tests__/enrich-person.core.spec.ts +++ b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/__tests__/enrich-person.core.spec.ts @@ -18,12 +18,16 @@ type MutationRequest = { updatePerson: { __args: { data: Record } }; }; +const captureUpdatePerson = (captured: Captured) => (request: unknown) => { + if ('updatePerson' in (request as object)) { + captured.data = (request as MutationRequest).updatePerson.__args.data; + } +}; + const buildClient = (node: PersonNode, captured: Captured) => createCoreApiClientMock({ queryResult: { people: { edges: [{ node }] } }, - onMutation: (request) => { - captured.data = (request as MutationRequest).updatePerson.__args.data; - }, + onMutation: captureUpdatePerson(captured), }); beforeEach(() => { @@ -62,6 +66,77 @@ describe('enrichPersonCore', () => { expect('linkedinLink' in (captured.data ?? {})).toBe(false); }); + it('links the standard company (find-or-create) when the person has none', async () => { + enrichPersonMock.mockResolvedValue({ + outcome: 'matched', + httpStatus: 200, + likelihood: 8, + data: { + id: 'pdl1', + work_email: 'jane@acme.com', + job_company_name: 'Acme', + job_company_website: 'acme.com', + }, + }); + const captured: Captured = {}; + const client = createCoreApiClientMock({ + queryResult: (request: unknown) => + 'companies' in (request as object) + ? { companies: { edges: [] } } + : { people: { edges: [{ node: PERSON_NODE_MOCK }] } }, + mutationResult: (request: unknown) => + 'createCompany' in (request as object) + ? { createCompany: { id: 'co-new' } } + : {}, + onMutation: captureUpdatePerson(captured), + }); + + await enrichPersonCore({ recordId: 'p1' }, client); + + expect(captured.data?.companyId).toBe('co-new'); + }); + + it('does not touch company when the person already has one', async () => { + enrichPersonMock.mockResolvedValue({ + outcome: 'matched', + httpStatus: 200, + likelihood: 8, + data: { id: 'pdl1', work_email: 'jane@acme.com', job_company_name: 'Acme' }, + }); + const captured: Captured = {}; + let companiesQueried = false; + let createCompanyCalled = false; + const client = createCoreApiClientMock({ + queryResult: (request: unknown) => { + if ('companies' in (request as object)) { + companiesQueried = true; + + return { companies: { edges: [] } }; + } + + return { + people: { + edges: [{ node: { ...PERSON_NODE_MOCK, company: { id: 'co-existing' } } }], + }, + }; + }, + mutationResult: (request: unknown) => { + if ('createCompany' in (request as object)) { + createCompanyCalled = true; + } + + return {}; + }, + onMutation: captureUpdatePerson(captured), + }); + + await enrichPersonCore({ recordId: 'p1' }, client); + + expect(companiesQueried).toBe(false); + expect(createCompanyCalled).toBe(false); + expect('companyId' in (captured.data ?? {})).toBe(false); + }); + it('does not overwrite a populated standard field', async () => { enrichPersonMock.mockResolvedValue({ outcome: 'matched', diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/enrich-person.core.ts b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/enrich-person.core.ts index e544b584697..4acbf8f56d2 100644 --- a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/enrich-person.core.ts +++ b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/enrich-person.core.ts @@ -5,6 +5,7 @@ import { PdlInvalidInputError } from 'src/logic-functions/errors/pdl-invalid-inp import { PdlRecordNotFoundError } from 'src/logic-functions/errors/pdl-record-not-found-error'; import { buildPersonNameParam } from 'src/logic-functions/utils/build-person-name-param'; import { buildSkippedResult } from 'src/logic-functions/utils/build-skipped-result'; +import { findOrCreateCurrentCompany } from 'src/logic-functions/utils/find-or-create-current-company'; import { toText } from 'src/logic-functions/utils/to-text'; import { isWithinTtl } from 'src/logic-functions/utils/is-within-ttl'; import { mapPerson } from 'src/logic-functions/utils/map-person'; @@ -138,9 +139,14 @@ export const enrichPersonCore = async ( PERSON_EMPTY_CHECKS, ); + const currentCompanyId = isDefined(node.company?.id) + ? undefined + : await findOrCreateCurrentCompany(client, outcome.data as PdlPersonData); + const data = pruneUndefined({ ...writableStandard, ...mapped.pdl, + companyId: currentCompanyId, pdlLikelihood: outcome.likelihood, pdlRawPayload: outcome.data, pdlLastEnrichedAt: enrichedAt, diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-company-create-data.spec.ts b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-company-create-data.spec.ts new file mode 100644 index 00000000000..0cb72ca67b3 --- /dev/null +++ b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-company-create-data.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; + +import { buildCompanyCreateData } from 'src/logic-functions/utils/build-company-create-data'; +import { type PdlPersonData } from 'src/types/pdl-person-data.type'; + +describe('buildCompanyCreateData', () => { + it('maps job_company_* onto Company standard and pdl fields', () => { + const data = buildCompanyCreateData({ + job_company_id: 'pdl-co-1', + job_company_name: 'Acme', + job_company_website: 'acme.com', + job_company_linkedin_url: 'https://linkedin.com/company/acme', + job_company_industry: 'accounting', + job_company_size: '11-50', + } as PdlPersonData); + + expect(data).toMatchObject({ + name: 'Acme', + pdlId: 'pdl-co-1', + pdlIndustry: 'ACCOUNTING', + pdlSizeRange: 'ELEVEN_TO_FIFTY', + }); + expect(data.domainName).toMatchObject({ primaryLinkUrl: 'acme.com' }); + expect(data.linkedinLink).toMatchObject({ + primaryLinkUrl: 'https://linkedin.com/company/acme', + }); + }); + + it('omits fields PDL did not return and drops unknown select values', () => { + const data = buildCompanyCreateData({ + job_company_name: 'Acme', + job_company_industry: 'not-a-real-industry', + } as PdlPersonData); + + expect(data).toEqual({ name: 'Acme' }); + }); +}); diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-company-match-keys.spec.ts b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-company-match-keys.spec.ts new file mode 100644 index 00000000000..6f642fd7564 --- /dev/null +++ b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-company-match-keys.spec.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; + +import { buildCompanyMatchKeys } from 'src/logic-functions/utils/build-company-match-keys'; +import { type PdlPersonData } from 'src/types/pdl-person-data.type'; + +describe('buildCompanyMatchKeys', () => { + it('extracts and trims the company identifiers from PDL data', () => { + const keys = buildCompanyMatchKeys({ + job_company_id: ' pdl-co-1 ', + job_company_website: 'acme.com', + job_company_linkedin_url: 'https://linkedin.com/company/acme', + job_company_name: 'Acme', + } as PdlPersonData); + + expect(keys).toEqual({ + pdlId: 'pdl-co-1', + website: 'acme.com', + linkedinUrl: 'https://linkedin.com/company/acme', + name: 'Acme', + }); + }); + + it('omits identifiers PDL did not return or that are blank', () => { + expect( + buildCompanyMatchKeys({ + job_company_name: ' ', + job_company_website: 'acme.com', + } as PdlPersonData), + ).toEqual({ website: 'acme.com' }); + }); + + it('returns an empty object when no company identifiers are present', () => { + expect(buildCompanyMatchKeys({} as PdlPersonData)).toEqual({}); + }); +}); diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/find-company-id-by-filter.spec.ts b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/find-company-id-by-filter.spec.ts new file mode 100644 index 00000000000..01fd8f06cd1 --- /dev/null +++ b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/find-company-id-by-filter.spec.ts @@ -0,0 +1,42 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; +import { describe, expect, it, vi } from 'vitest'; + +import { findCompanyIdByFilter } from 'src/logic-functions/utils/find-company-id-by-filter'; + +describe('findCompanyIdByFilter', () => { + it('returns the first matching company id and forwards the filter', async () => { + const query = vi.fn(() => + Promise.resolve({ companies: { edges: [{ node: { id: 'co-1' } }] } }), + ); + const client = { query } as unknown as CoreApiClient; + const filter = { pdlId: { eq: 'pdl-co-1' } }; + + const result = await findCompanyIdByFilter(client, filter); + + expect(result).toBe('co-1'); + expect(query).toHaveBeenCalledWith({ + companies: { + __args: { filter, first: 1 }, + edges: { node: { id: true } }, + }, + }); + }); + + it('returns undefined when there are no matches', async () => { + const query = vi.fn(() => Promise.resolve({ companies: { edges: [] } })); + const client = { query } as unknown as CoreApiClient; + + expect( + await findCompanyIdByFilter(client, { name: { eq: 'Acme' } }), + ).toBeUndefined(); + }); + + it('returns undefined when the companies connection is absent', async () => { + const query = vi.fn(() => Promise.resolve({})); + const client = { query } as unknown as CoreApiClient; + + expect( + await findCompanyIdByFilter(client, { name: { eq: 'Acme' } }), + ).toBeUndefined(); + }); +}); diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/find-company-id.spec.ts b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/find-company-id.spec.ts new file mode 100644 index 00000000000..154b576932c --- /dev/null +++ b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/find-company-id.spec.ts @@ -0,0 +1,90 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; +import { describe, expect, it, vi } from 'vitest'; + +import { findCompanyId } from 'src/logic-functions/utils/find-company-id'; + +type CompanyQueryRequest = { + companies: { __args: { filter: Record } }; +}; + +const filterKeyOf = (request: unknown) => + Object.keys((request as CompanyQueryRequest).companies.__args.filter)[0]; + +const ALL_KEYS = { + pdlId: 'pdl-co-1', + website: 'acme.com', + linkedinUrl: 'https://linkedin.com/company/acme', + name: 'Acme', +}; + +describe('findCompanyId', () => { + it('matches by pdlId first and does not query the lower-priority keys', async () => { + const query = vi.fn((request: unknown) => + Promise.resolve( + filterKeyOf(request) === 'pdlId' + ? { companies: { edges: [{ node: { id: 'co-pdl' } }] } } + : { companies: { edges: [] } }, + ), + ); + const client = { query } as unknown as CoreApiClient; + + const result = await findCompanyId(client, ALL_KEYS); + + expect(result).toBe('co-pdl'); + expect(query).toHaveBeenCalledTimes(1); + }); + + it('falls through to lower-priority keys in order until one matches', async () => { + const queriedKeys: string[] = []; + const query = vi.fn((request: unknown) => { + const key = filterKeyOf(request); + queriedKeys.push(key); + + return Promise.resolve( + key === 'name' + ? { companies: { edges: [{ node: { id: 'co-name' } }] } } + : { companies: { edges: [] } }, + ); + }); + const client = { query } as unknown as CoreApiClient; + + const result = await findCompanyId(client, ALL_KEYS); + + expect(result).toBe('co-name'); + expect(queriedKeys).toEqual([ + 'pdlId', + 'domainName', + 'linkedinLink', + 'name', + ]); + }); + + it('skips filters whose match key is absent', async () => { + const queriedKeys: string[] = []; + const query = vi.fn((request: unknown) => { + queriedKeys.push(filterKeyOf(request)); + + return Promise.resolve({ companies: { edges: [] } }); + }); + const client = { query } as unknown as CoreApiClient; + + await findCompanyId(client, { name: 'Acme' }); + + expect(queriedKeys).toEqual(['name']); + }); + + it('returns undefined when no key matches', async () => { + const query = vi.fn(() => Promise.resolve({ companies: { edges: [] } })); + const client = { query } as unknown as CoreApiClient; + + expect(await findCompanyId(client, ALL_KEYS)).toBeUndefined(); + }); + + it('returns undefined and issues no query when no keys are provided', async () => { + const query = vi.fn(); + const client = { query } as unknown as CoreApiClient; + + expect(await findCompanyId(client, {})).toBeUndefined(); + expect(query).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/find-or-create-current-company.spec.ts b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/find-or-create-current-company.spec.ts new file mode 100644 index 00000000000..39538d0cb23 --- /dev/null +++ b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/find-or-create-current-company.spec.ts @@ -0,0 +1,106 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; +import { describe, expect, it, vi } from 'vitest'; + +import { findOrCreateCurrentCompany } from 'src/logic-functions/utils/find-or-create-current-company'; +import { type PdlPersonData } from 'src/types/pdl-person-data.type'; + +type CompanyQueryRequest = { + companies: { __args: { filter: Record } }; +}; +type CreateCompanyRequest = { + createCompany: { __args: { data: Record } }; +}; + +const filterOf = (request: unknown) => + (request as CompanyQueryRequest).companies.__args.filter; +const createDataOf = (request: unknown) => + (request as CreateCompanyRequest).createCompany.__args.data; + +describe('findOrCreateCurrentCompany', () => { + it('returns undefined and issues no calls when PDL gives no name or website', async () => { + const query = vi.fn(); + const mutation = vi.fn(); + const client = { query, mutation } as unknown as CoreApiClient; + + const result = await findOrCreateCurrentCompany(client, { + job_company_id: 'pdl-co-1', + } as PdlPersonData); + + expect(result).toBeUndefined(); + expect(query).not.toHaveBeenCalled(); + expect(mutation).not.toHaveBeenCalled(); + }); + + it('returns an existing company id without creating one', async () => { + const query = vi.fn(() => + Promise.resolve({ companies: { edges: [{ node: { id: 'co-existing' } }] } }), + ); + const mutation = vi.fn(); + const client = { query, mutation } as unknown as CoreApiClient; + + const result = await findOrCreateCurrentCompany(client, { + job_company_name: 'Acme', + } as PdlPersonData); + + expect(result).toBe('co-existing'); + expect(mutation).not.toHaveBeenCalled(); + }); + + it('creates a company from the PDL data when none matches', async () => { + const query = vi.fn(() => Promise.resolve({ companies: { edges: [] } })); + let createdData: Record | undefined; + const mutation = vi.fn((request: unknown) => { + createdData = createDataOf(request); + + return Promise.resolve({ createCompany: { id: 'co-new' } }); + }); + const client = { query, mutation } as unknown as CoreApiClient; + + const result = await findOrCreateCurrentCompany(client, { + job_company_id: 'pdl-co-2', + job_company_name: 'Acme', + job_company_website: 'acme.com', + } as PdlPersonData); + + expect(result).toBe('co-new'); + expect(createdData).toMatchObject({ name: 'Acme', pdlId: 'pdl-co-2' }); + }); + + it('re-finds the winner when a concurrent create hits a unique violation', async () => { + let created = false; + const query = vi.fn((request: unknown) => + Promise.resolve( + created && 'pdlId' in filterOf(request) + ? { companies: { edges: [{ node: { id: 'co-race' } }] } } + : { companies: { edges: [] } }, + ), + ); + const mutation = vi.fn(() => { + created = true; + + return Promise.reject( + new Error('duplicate key value violates unique constraint'), + ); + }); + const client = { query, mutation } as unknown as CoreApiClient; + + const result = await findOrCreateCurrentCompany(client, { + job_company_id: 'pdl-co-3', + job_company_name: 'Acme', + } as PdlPersonData); + + expect(result).toBe('co-race'); + }); + + it('rethrows a non-unique-violation create error', async () => { + const query = vi.fn(() => Promise.resolve({ companies: { edges: [] } })); + const mutation = vi.fn(() => Promise.reject(new Error('network down'))); + const client = { query, mutation } as unknown as CoreApiClient; + + await expect( + findOrCreateCurrentCompany(client, { + job_company_name: 'Acme', + } as PdlPersonData), + ).rejects.toThrow('network down'); + }); +}); diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/map-person.spec.ts b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/map-person.spec.ts index 96da0d0c32f..0ee5522d616 100644 --- a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/map-person.spec.ts +++ b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/map-person.spec.ts @@ -25,7 +25,13 @@ describe('mapPerson', () => { expect(pdl.pdlJobRole).toBe('ENGINEERING'); expect(pdl.pdlIndustry).toBe('ACCOUNTING'); expect(pdl.pdlInferredSalary).toBe('FROM_45000_TO_55000'); - expect(pdl.pdlJobCompanySize).toBe('ELEVEN_TO_FIFTY'); + }); + + it('does not map company attributes onto the person', () => { + const { pdl } = mapPerson(PDL_PERSON_DATA_MOCK); + + expect('pdlJobCompanyName' in pdl).toBe(false); + expect('pdlJobCompanySize' in pdl).toBe(false); }); it('drops unknown multi-select values', () => { diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-company-create-data.ts b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-company-create-data.ts new file mode 100644 index 00000000000..2f9922c2a76 --- /dev/null +++ b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-company-create-data.ts @@ -0,0 +1,24 @@ +import { INDUSTRY_OPTIONS } from 'src/constants/industry-options'; +import { SIZE_OPTIONS } from 'src/constants/size-options'; +import { buildAllowedValues } from 'src/logic-functions/utils/build-allowed-values'; +import { buildLinks } from 'src/logic-functions/utils/build-links'; +import { pickSelect } from 'src/logic-functions/utils/pick-select'; +import { sizeTransform } from 'src/logic-functions/utils/size-transform'; +import { toText } from 'src/logic-functions/utils/to-text'; +import { type PdlPersonData } from 'src/types/pdl-person-data.type'; +import { pruneUndefined } from 'src/utils/prune-undefined'; + +const INDUSTRY_VALUES = buildAllowedValues(INDUSTRY_OPTIONS); +const SIZE_VALUES = buildAllowedValues(SIZE_OPTIONS); + +export const buildCompanyCreateData = ( + data: PdlPersonData, +): Record => + pruneUndefined({ + name: toText(data.job_company_name), + domainName: buildLinks(data.job_company_website), + linkedinLink: buildLinks(data.job_company_linkedin_url), + pdlId: toText(data.job_company_id), + pdlIndustry: pickSelect(data.job_company_industry, INDUSTRY_VALUES), + pdlSizeRange: pickSelect(data.job_company_size, SIZE_VALUES, sizeTransform), + }); diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-company-match-keys.ts b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-company-match-keys.ts new file mode 100644 index 00000000000..68f8c4b7dd3 --- /dev/null +++ b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-company-match-keys.ts @@ -0,0 +1,12 @@ +import { toText } from 'src/logic-functions/utils/to-text'; +import { type CompanyMatchKeys } from 'src/types/company-match-keys.type'; +import { type PdlPersonData } from 'src/types/pdl-person-data.type'; +import { pruneUndefined } from 'src/utils/prune-undefined'; + +export const buildCompanyMatchKeys = (data: PdlPersonData): CompanyMatchKeys => + pruneUndefined({ + pdlId: toText(data.job_company_id), + website: toText(data.job_company_website), + linkedinUrl: toText(data.job_company_linkedin_url), + name: toText(data.job_company_name), + }); diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/find-company-id-by-filter.ts b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/find-company-id-by-filter.ts new file mode 100644 index 00000000000..5e8870e0c02 --- /dev/null +++ b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/find-company-id-by-filter.ts @@ -0,0 +1,17 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +type CompaniesConnection = { edges?: { node: { id: string } }[] }; + +export const findCompanyIdByFilter = async ( + client: CoreApiClient, + filter: Record, +): Promise => { + const result = (await client.query({ + companies: { + __args: { filter, first: 1 }, + edges: { node: { id: true } }, + }, + })) as { companies?: CompaniesConnection }; + + return result.companies?.edges?.[0]?.node?.id; +}; diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/find-company-id.ts b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/find-company-id.ts new file mode 100644 index 00000000000..5ee1f735e71 --- /dev/null +++ b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/find-company-id.ts @@ -0,0 +1,49 @@ +import { isNonEmptyString } from '@sniptt/guards'; +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +import { findCompanyIdByFilter } from 'src/logic-functions/utils/find-company-id-by-filter'; +import { type CompanyMatchKeys } from 'src/types/company-match-keys.type'; +import { isDefined } from 'src/utils/is-defined'; + +export const findCompanyId = async ( + client: CoreApiClient, + { pdlId, website, linkedinUrl, name }: CompanyMatchKeys, +): Promise => { + if (isNonEmptyString(pdlId)) { + const matchById = await findCompanyIdByFilter(client, { + pdlId: { eq: pdlId }, + }); + if (isDefined(matchById)) { + return matchById; + } + } + + if (isNonEmptyString(website)) { + const matchByDomain = await findCompanyIdByFilter(client, { + domainName: { primaryLinkUrl: { eq: website } }, + }); + if (isDefined(matchByDomain)) { + return matchByDomain; + } + } + + if (isNonEmptyString(linkedinUrl)) { + const matchByLinkedin = await findCompanyIdByFilter(client, { + linkedinLink: { primaryLinkUrl: { eq: linkedinUrl } }, + }); + if (isDefined(matchByLinkedin)) { + return matchByLinkedin; + } + } + + if (isNonEmptyString(name)) { + const matchByName = await findCompanyIdByFilter(client, { + name: { eq: name }, + }); + if (isDefined(matchByName)) { + return matchByName; + } + } + + return undefined; +}; diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/find-or-create-current-company.ts b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/find-or-create-current-company.ts new file mode 100644 index 00000000000..e885065fc07 --- /dev/null +++ b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/find-or-create-current-company.ts @@ -0,0 +1,52 @@ +import { isNonEmptyString } from '@sniptt/guards'; +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +import { buildCompanyCreateData } from 'src/logic-functions/utils/build-company-create-data'; +import { buildCompanyMatchKeys } from 'src/logic-functions/utils/build-company-match-keys'; +import { findCompanyId } from 'src/logic-functions/utils/find-company-id'; +import { type PdlPersonData } from 'src/types/pdl-person-data.type'; +import { isDefined } from 'src/utils/is-defined'; +import { isUniqueViolationError } from 'src/utils/is-unique-violation-error'; + +type CreateCompanyResult = { createCompany?: { id: string } }; + +export const findOrCreateCurrentCompany = async ( + client: CoreApiClient, + data: PdlPersonData, +): Promise => { + const matchKeys = buildCompanyMatchKeys(data); + + if ( + !isNonEmptyString(matchKeys.name) && + !isNonEmptyString(matchKeys.website) + ) { + return undefined; + } + + const existingId = await findCompanyId(client, matchKeys); + if (isDefined(existingId)) { + return existingId; + } + + try { + const result = (await client.mutation({ + createCompany: { + __args: { data: buildCompanyCreateData(data) }, + id: true, + }, + })) as CreateCompanyResult; + + return result.createCompany?.id; + } catch (createError) { + if (!isUniqueViolationError(createError)) { + throw createError; + } + + const raceWinnerId = await findCompanyId(client, matchKeys); + if (isDefined(raceWinnerId)) { + return raceWinnerId; + } + + throw createError; + } +}; diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/map-person.ts b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/map-person.ts index 14e9e52230a..743e93cf04e 100644 --- a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/map-person.ts +++ b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/map-person.ts @@ -8,7 +8,6 @@ import { JOB_TITLE_SUB_ROLE_OPTIONS } from 'src/constants/job-title-sub-role-opt import { METRO_OPTIONS } from 'src/constants/metro-options'; import { SENIORITY_OPTIONS } from 'src/constants/seniority-options'; import { SEX_OPTIONS } from 'src/constants/sex-options'; -import { SIZE_OPTIONS } from 'src/constants/size-options'; import { toJsonArray } from 'src/logic-functions/utils/to-json-array'; import { toNumber } from 'src/logic-functions/utils/to-number'; import { toStringArray } from 'src/logic-functions/utils/to-string-array'; @@ -23,7 +22,6 @@ import { buildAllowedValues } from 'src/logic-functions/utils/build-allowed-valu import { pickMultiSelect } from 'src/logic-functions/utils/pick-multi-select'; import { pickSelect } from 'src/logic-functions/utils/pick-select'; import { salaryTransform } from 'src/logic-functions/utils/salary-transform'; -import { sizeTransform } from 'src/logic-functions/utils/size-transform'; import { type MappedRecord } from 'src/types/mapped-record.type'; import { type PdlPersonData } from 'src/types/pdl-person-data.type'; import { pruneUndefined } from 'src/utils/prune-undefined'; @@ -36,7 +34,6 @@ const INDUSTRY_VALUES = buildAllowedValues(INDUSTRY_OPTIONS); const INFERRED_SALARY_VALUES = buildAllowedValues(INFERRED_SALARY_OPTIONS); const SEX_VALUES = buildAllowedValues(SEX_OPTIONS); const METRO_VALUES = buildAllowedValues(METRO_OPTIONS); -const SIZE_VALUES = buildAllowedValues(SIZE_OPTIONS); export const mapPerson = (data: PdlPersonData): MappedRecord => { const emailEntries = (data.emails ?? []).map((entry) => @@ -111,17 +108,6 @@ export const mapPerson = (data: PdlPersonData): MappedRecord => { country: data.location_country, geo: data.location_geo, }), - - pdlJobCompanyId: toText(data.job_company_id), - pdlJobCompanyName: toText(data.job_company_name), - pdlJobCompanyWebsite: buildLinks(data.job_company_website), - pdlJobCompanyLinkedinUrl: buildLinks(data.job_company_linkedin_url), - pdlJobCompanyIndustry: pickSelect(data.job_company_industry, INDUSTRY_VALUES), - pdlJobCompanySize: pickSelect( - data.job_company_size, - SIZE_VALUES, - sizeTransform, - ), }); return { standard, pdl }; diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/read-person.ts b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/read-person.ts index 769c900e54a..eb1e28cae51 100644 --- a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/read-person.ts +++ b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/read-person.ts @@ -17,6 +17,7 @@ export const readPerson = async ( phones: { primaryPhoneNumber: true }, jobTitle: true, linkedinLink: { primaryLinkUrl: true }, + company: { id: true }, pdlId: true, pdlLastEnrichedAt: true, }, diff --git a/packages/twenty-apps/internal/people-data-labs/src/types/company-match-keys.type.ts b/packages/twenty-apps/internal/people-data-labs/src/types/company-match-keys.type.ts new file mode 100644 index 00000000000..4c7c14577a0 --- /dev/null +++ b/packages/twenty-apps/internal/people-data-labs/src/types/company-match-keys.type.ts @@ -0,0 +1,6 @@ +export type CompanyMatchKeys = { + pdlId?: string; + website?: string; + linkedinUrl?: string; + name?: string; +}; diff --git a/packages/twenty-apps/internal/people-data-labs/src/types/person-node.type.ts b/packages/twenty-apps/internal/people-data-labs/src/types/person-node.type.ts index 74bee3014a5..b01e6a5b422 100644 --- a/packages/twenty-apps/internal/people-data-labs/src/types/person-node.type.ts +++ b/packages/twenty-apps/internal/people-data-labs/src/types/person-node.type.ts @@ -5,6 +5,7 @@ export type PersonNode = { phones?: { primaryPhoneNumber?: string | null } | null; jobTitle?: string | null; linkedinLink?: { primaryLinkUrl?: string | null } | null; + company?: { id?: string | null } | null; pdlId?: string | null; pdlLastEnrichedAt?: string | null; }; diff --git a/packages/twenty-apps/internal/people-data-labs/src/utils/__tests__/is-unique-violation-error.spec.ts b/packages/twenty-apps/internal/people-data-labs/src/utils/__tests__/is-unique-violation-error.spec.ts new file mode 100644 index 00000000000..4abad4e72c6 --- /dev/null +++ b/packages/twenty-apps/internal/people-data-labs/src/utils/__tests__/is-unique-violation-error.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { isUniqueViolationError } from 'src/utils/is-unique-violation-error'; + +describe('isUniqueViolationError', () => { + it('detects common unique-violation phrasings (case-insensitive)', () => { + expect( + isUniqueViolationError( + new Error('duplicate key value violates unique constraint'), + ), + ).toBe(true); + expect(isUniqueViolationError(new Error('Record already exists'))).toBe( + true, + ); + expect(isUniqueViolationError({ message: 'Uniqueness violated' })).toBe( + true, + ); + expect(isUniqueViolationError('VIOLATES UNIQUE index')).toBe(true); + }); + + it('returns false for unrelated errors and non-error values', () => { + expect(isUniqueViolationError(new Error('network down'))).toBe(false); + expect(isUniqueViolationError(null)).toBe(false); + expect(isUniqueViolationError(undefined)).toBe(false); + expect(isUniqueViolationError(42)).toBe(false); + }); +}); diff --git a/packages/twenty-apps/internal/people-data-labs/src/utils/is-unique-violation-error.ts b/packages/twenty-apps/internal/people-data-labs/src/utils/is-unique-violation-error.ts new file mode 100644 index 00000000000..061489cff2a --- /dev/null +++ b/packages/twenty-apps/internal/people-data-labs/src/utils/is-unique-violation-error.ts @@ -0,0 +1,19 @@ +export const isUniqueViolationError = (error: unknown): boolean => { + const text = + error instanceof Error + ? error.message + : typeof error === 'object' && error !== null + ? JSON.stringify(error) + : typeof error === 'string' + ? error + : ''; + const lower = text.toLowerCase(); + + return ( + lower.includes('duplicate') || + lower.includes('unique constraint') || + lower.includes('uniqueness') || + lower.includes('already exists') || + lower.includes('violates unique') + ); +}; diff --git a/packages/twenty-apps/internal/people-data-labs/yarn.lock b/packages/twenty-apps/internal/people-data-labs/yarn.lock index 396cc450884..da5b07a32a9 100644 --- a/packages/twenty-apps/internal/people-data-labs/yarn.lock +++ b/packages/twenty-apps/internal/people-data-labs/yarn.lock @@ -29,6 +29,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/aix-ppc64@npm:0.28.0": + version: 0.28.0 + resolution: "@esbuild/aix-ppc64@npm:0.28.0" + conditions: os=aix & cpu=ppc64 + languageName: node + linkType: hard + "@esbuild/android-arm64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/android-arm64@npm:0.25.12" @@ -43,6 +50,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-arm64@npm:0.28.0": + version: 0.28.0 + resolution: "@esbuild/android-arm64@npm:0.28.0" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/android-arm@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/android-arm@npm:0.25.12" @@ -57,6 +71,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-arm@npm:0.28.0": + version: 0.28.0 + resolution: "@esbuild/android-arm@npm:0.28.0" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + "@esbuild/android-x64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/android-x64@npm:0.25.12" @@ -71,6 +92,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-x64@npm:0.28.0": + version: 0.28.0 + resolution: "@esbuild/android-x64@npm:0.28.0" + conditions: os=android & cpu=x64 + languageName: node + linkType: hard + "@esbuild/darwin-arm64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/darwin-arm64@npm:0.25.12" @@ -85,6 +113,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/darwin-arm64@npm:0.28.0": + version: 0.28.0 + resolution: "@esbuild/darwin-arm64@npm:0.28.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/darwin-x64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/darwin-x64@npm:0.25.12" @@ -99,6 +134,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/darwin-x64@npm:0.28.0": + version: 0.28.0 + resolution: "@esbuild/darwin-x64@npm:0.28.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@esbuild/freebsd-arm64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/freebsd-arm64@npm:0.25.12" @@ -113,6 +155,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/freebsd-arm64@npm:0.28.0": + version: 0.28.0 + resolution: "@esbuild/freebsd-arm64@npm:0.28.0" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/freebsd-x64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/freebsd-x64@npm:0.25.12" @@ -127,6 +176,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/freebsd-x64@npm:0.28.0": + version: 0.28.0 + resolution: "@esbuild/freebsd-x64@npm:0.28.0" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/linux-arm64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/linux-arm64@npm:0.25.12" @@ -141,6 +197,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-arm64@npm:0.28.0": + version: 0.28.0 + resolution: "@esbuild/linux-arm64@npm:0.28.0" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/linux-arm@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/linux-arm@npm:0.25.12" @@ -155,6 +218,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-arm@npm:0.28.0": + version: 0.28.0 + resolution: "@esbuild/linux-arm@npm:0.28.0" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + "@esbuild/linux-ia32@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/linux-ia32@npm:0.25.12" @@ -169,6 +239,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-ia32@npm:0.28.0": + version: 0.28.0 + resolution: "@esbuild/linux-ia32@npm:0.28.0" + conditions: os=linux & cpu=ia32 + languageName: node + linkType: hard + "@esbuild/linux-loong64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/linux-loong64@npm:0.25.12" @@ -183,6 +260,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-loong64@npm:0.28.0": + version: 0.28.0 + resolution: "@esbuild/linux-loong64@npm:0.28.0" + conditions: os=linux & cpu=loong64 + languageName: node + linkType: hard + "@esbuild/linux-mips64el@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/linux-mips64el@npm:0.25.12" @@ -197,6 +281,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-mips64el@npm:0.28.0": + version: 0.28.0 + resolution: "@esbuild/linux-mips64el@npm:0.28.0" + conditions: os=linux & cpu=mips64el + languageName: node + linkType: hard + "@esbuild/linux-ppc64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/linux-ppc64@npm:0.25.12" @@ -211,6 +302,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-ppc64@npm:0.28.0": + version: 0.28.0 + resolution: "@esbuild/linux-ppc64@npm:0.28.0" + conditions: os=linux & cpu=ppc64 + languageName: node + linkType: hard + "@esbuild/linux-riscv64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/linux-riscv64@npm:0.25.12" @@ -225,6 +323,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-riscv64@npm:0.28.0": + version: 0.28.0 + resolution: "@esbuild/linux-riscv64@npm:0.28.0" + conditions: os=linux & cpu=riscv64 + languageName: node + linkType: hard + "@esbuild/linux-s390x@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/linux-s390x@npm:0.25.12" @@ -239,6 +344,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-s390x@npm:0.28.0": + version: 0.28.0 + resolution: "@esbuild/linux-s390x@npm:0.28.0" + conditions: os=linux & cpu=s390x + languageName: node + linkType: hard + "@esbuild/linux-x64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/linux-x64@npm:0.25.12" @@ -253,6 +365,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-x64@npm:0.28.0": + version: 0.28.0 + resolution: "@esbuild/linux-x64@npm:0.28.0" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + "@esbuild/netbsd-arm64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/netbsd-arm64@npm:0.25.12" @@ -267,6 +386,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/netbsd-arm64@npm:0.28.0": + version: 0.28.0 + resolution: "@esbuild/netbsd-arm64@npm:0.28.0" + conditions: os=netbsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/netbsd-x64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/netbsd-x64@npm:0.25.12" @@ -281,6 +407,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/netbsd-x64@npm:0.28.0": + version: 0.28.0 + resolution: "@esbuild/netbsd-x64@npm:0.28.0" + conditions: os=netbsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/openbsd-arm64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/openbsd-arm64@npm:0.25.12" @@ -295,6 +428,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openbsd-arm64@npm:0.28.0": + version: 0.28.0 + resolution: "@esbuild/openbsd-arm64@npm:0.28.0" + conditions: os=openbsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/openbsd-x64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/openbsd-x64@npm:0.25.12" @@ -309,6 +449,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openbsd-x64@npm:0.28.0": + version: 0.28.0 + resolution: "@esbuild/openbsd-x64@npm:0.28.0" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/openharmony-arm64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/openharmony-arm64@npm:0.25.12" @@ -323,6 +470,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openharmony-arm64@npm:0.28.0": + version: 0.28.0 + resolution: "@esbuild/openharmony-arm64@npm:0.28.0" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/sunos-x64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/sunos-x64@npm:0.25.12" @@ -337,6 +491,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/sunos-x64@npm:0.28.0": + version: 0.28.0 + resolution: "@esbuild/sunos-x64@npm:0.28.0" + conditions: os=sunos & cpu=x64 + languageName: node + linkType: hard + "@esbuild/win32-arm64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/win32-arm64@npm:0.25.12" @@ -351,6 +512,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-arm64@npm:0.28.0": + version: 0.28.0 + resolution: "@esbuild/win32-arm64@npm:0.28.0" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/win32-ia32@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/win32-ia32@npm:0.25.12" @@ -365,6 +533,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-ia32@npm:0.28.0": + version: 0.28.0 + resolution: "@esbuild/win32-ia32@npm:0.28.0" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + "@esbuild/win32-x64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/win32-x64@npm:0.25.12" @@ -379,6 +554,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-x64@npm:0.28.0": + version: 0.28.0 + resolution: "@esbuild/win32-x64@npm:0.28.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@fastify/busboy@npm:^2.0.0": version: 2.1.1 resolution: "@fastify/busboy@npm:2.1.1" @@ -1678,13 +1860,6 @@ __metadata: languageName: node linkType: hard -"dotenv@npm:^16.4.0": - version: 16.6.1 - resolution: "dotenv@npm:16.6.1" - checksum: 10c0/15ce56608326ea0d1d9414a5c8ee6dcf0fffc79d2c16422b4ac2268e7e2d76ff5a572d37ffe747c377de12005f14b3cc22361e79fc7f1061cce81f77d2c973dc - languageName: node - linkType: hard - "dunder-proto@npm:^1.0.1": version: 1.0.1 resolution: "dunder-proto@npm:1.0.1" @@ -1965,6 +2140,95 @@ __metadata: languageName: node linkType: hard +"esbuild@npm:^0.28.0": + version: 0.28.0 + resolution: "esbuild@npm:0.28.0" + dependencies: + "@esbuild/aix-ppc64": "npm:0.28.0" + "@esbuild/android-arm": "npm:0.28.0" + "@esbuild/android-arm64": "npm:0.28.0" + "@esbuild/android-x64": "npm:0.28.0" + "@esbuild/darwin-arm64": "npm:0.28.0" + "@esbuild/darwin-x64": "npm:0.28.0" + "@esbuild/freebsd-arm64": "npm:0.28.0" + "@esbuild/freebsd-x64": "npm:0.28.0" + "@esbuild/linux-arm": "npm:0.28.0" + "@esbuild/linux-arm64": "npm:0.28.0" + "@esbuild/linux-ia32": "npm:0.28.0" + "@esbuild/linux-loong64": "npm:0.28.0" + "@esbuild/linux-mips64el": "npm:0.28.0" + "@esbuild/linux-ppc64": "npm:0.28.0" + "@esbuild/linux-riscv64": "npm:0.28.0" + "@esbuild/linux-s390x": "npm:0.28.0" + "@esbuild/linux-x64": "npm:0.28.0" + "@esbuild/netbsd-arm64": "npm:0.28.0" + "@esbuild/netbsd-x64": "npm:0.28.0" + "@esbuild/openbsd-arm64": "npm:0.28.0" + "@esbuild/openbsd-x64": "npm:0.28.0" + "@esbuild/openharmony-arm64": "npm:0.28.0" + "@esbuild/sunos-x64": "npm:0.28.0" + "@esbuild/win32-arm64": "npm:0.28.0" + "@esbuild/win32-ia32": "npm:0.28.0" + "@esbuild/win32-x64": "npm:0.28.0" + dependenciesMeta: + "@esbuild/aix-ppc64": + optional: true + "@esbuild/android-arm": + optional: true + "@esbuild/android-arm64": + optional: true + "@esbuild/android-x64": + optional: true + "@esbuild/darwin-arm64": + optional: true + "@esbuild/darwin-x64": + optional: true + "@esbuild/freebsd-arm64": + optional: true + "@esbuild/freebsd-x64": + optional: true + "@esbuild/linux-arm": + optional: true + "@esbuild/linux-arm64": + optional: true + "@esbuild/linux-ia32": + optional: true + "@esbuild/linux-loong64": + optional: true + "@esbuild/linux-mips64el": + optional: true + "@esbuild/linux-ppc64": + optional: true + "@esbuild/linux-riscv64": + optional: true + "@esbuild/linux-s390x": + optional: true + "@esbuild/linux-x64": + optional: true + "@esbuild/netbsd-arm64": + optional: true + "@esbuild/netbsd-x64": + optional: true + "@esbuild/openbsd-arm64": + optional: true + "@esbuild/openbsd-x64": + optional: true + "@esbuild/openharmony-arm64": + optional: true + "@esbuild/sunos-x64": + optional: true + "@esbuild/win32-arm64": + optional: true + "@esbuild/win32-ia32": + optional: true + "@esbuild/win32-x64": + optional: true + bin: + esbuild: bin/esbuild + checksum: 10c0/8acd95c238ec6c4a9d16163277faf228a8994b642d187b3fe667ffbb469008e6748cde144fdc3c175bf8e78ee49e15a0ed9b9f183fdb5fcea1772f87fb1372a4 + languageName: node + linkType: hard + "escape-string-regexp@npm:^1.0.2, escape-string-regexp@npm:^1.0.5": version: 1.0.5 resolution: "escape-string-regexp@npm:1.0.5" @@ -3065,8 +3329,8 @@ __metadata: oxlint: "npm:^0.16.0" react: "npm:^19.0.0" react-dom: "npm:^19.0.0" - twenty-client-sdk: "npm:2.4.0" - twenty-sdk: "npm:2.4.0" + twenty-client-sdk: "npm:2.9.1" + twenty-sdk: "npm:2.9.1" typescript: "npm:^5.9.3" vite-tsconfig-paths: "npm:^4.2.1" vitest: "npm:^3.1.1" @@ -3791,30 +4055,28 @@ __metadata: languageName: node linkType: hard -"twenty-client-sdk@npm:2.4.0": - version: 2.4.0 - resolution: "twenty-client-sdk@npm:2.4.0" +"twenty-client-sdk@npm:2.9.1": + version: 2.9.1 + resolution: "twenty-client-sdk@npm:2.9.1" dependencies: "@genql/cli": "npm:^3.0.3" "@genql/runtime": "npm:^2.10.0" - esbuild: "npm:^0.25.0" + esbuild: "npm:^0.28.0" graphql: "npm:^16.8.1" - checksum: 10c0/cdd9e70a59b8ce16f40e84b79c16e89b984dfcbb04e4646341fea40911acee8eab5aa61872421f25212949e677a75ed51af99e61829f2902af0f0a10e6e5614d + checksum: 10c0/fecc82f090f7f5261330a552017e31decf9f4e134e9cf54d8631666d47f303e5d1d229436f30d1ebd9983613eeba1adb8f7244741b88234e3e8cfe956f3a8ffb languageName: node linkType: hard -"twenty-sdk@npm:2.4.0": - version: 2.4.0 - resolution: "twenty-sdk@npm:2.4.0" +"twenty-sdk@npm:2.9.1": + version: 2.9.1 + resolution: "twenty-sdk@npm:2.9.1" dependencies: - "@genql/cli": "npm:^3.0.3" "@genql/runtime": "npm:^2.10.0" "@sniptt/guards": "npm:^0.2.0" axios: "npm:^1.13.5" chalk: "npm:^5.3.0" chokidar: "npm:^4.0.0" commander: "npm:^12.0.0" - dotenv: "npm:^16.4.0" esbuild: "npm:^0.25.0" graphql: "npm:^16.8.1" graphql-sse: "npm:^2.5.4" @@ -3825,15 +4087,12 @@ __metadata: react: "npm:^19.0.0" react-dom: "npm:^19.0.0" tinyglobby: "npm:^0.2.15" - twenty-client-sdk: "npm:2.4.0" - typescript: "npm:^5.9.2" + twenty-client-sdk: "npm:2.9.1" + typescript: "npm:^5.9.3" uuid: "npm:^13.0.0" - vite: "npm:^7.0.0" - vite-tsconfig-paths: "npm:^4.2.1" - zod: "npm:^4.1.11" bin: twenty: dist/cli.cjs - checksum: 10c0/7266755dd0f192f0107b25b72db7340a8476f4b97f3e8d2fa539010f6702f9104463da8ddca9318b2e7df2d6be43362be5eddb95af2067d8863578907b713617 + checksum: 10c0/3bb4cd1cee344569a5a1c5f0f3af5afc1c4f84587aec983645c0d113dcb594b4d0723679b6181afbd389e83e84572e24e5fba60d6e0a9b062271dce0aeb9bbb4 languageName: node linkType: hard @@ -3853,7 +4112,7 @@ __metadata: languageName: node linkType: hard -"typescript@npm:^5.9.2, typescript@npm:^5.9.3": +"typescript@npm:^5.9.3": version: 5.9.3 resolution: "typescript@npm:5.9.3" bin: @@ -3863,7 +4122,7 @@ __metadata: languageName: node linkType: hard -"typescript@patch:typescript@npm%3A^5.9.2#optional!builtin, typescript@patch:typescript@npm%3A^5.9.3#optional!builtin": +"typescript@patch:typescript@npm%3A^5.9.3#optional!builtin": version: 5.9.3 resolution: "typescript@patch:typescript@npm%3A5.9.3#optional!builtin::version=5.9.3&hash=5786d5" bin: @@ -3987,7 +4246,7 @@ __metadata: languageName: node linkType: hard -"vite@npm:^5.0.0 || ^6.0.0 || ^7.0.0-0, vite@npm:^7.0.0": +"vite@npm:^5.0.0 || ^6.0.0 || ^7.0.0-0": version: 7.3.3 resolution: "vite@npm:7.3.3" dependencies: @@ -4312,10 +4571,3 @@ __metadata: checksum: 10c0/71cc2f2bbb537300c3f569e25693d37b3bc91f225cefce251a71c30bc6bb3e7f8e9420ca0eb57f2ac9e492b085b8dfa075fd1e8195c40b83c951dd59c6e4fbf8 languageName: node linkType: hard - -"zod@npm:^4.1.11": - version: 4.4.3 - resolution: "zod@npm:4.4.3" - checksum: 10c0/7ea31b558e88f9faf44f31dd185e2e1cbf51fed3081787fb96cc2534749b50c0acfc6da7f0922a7353ed092dd358c7d50c28ea96c94d04af64191bd33152eca3 - languageName: node - linkType: hard