From e631c986a1bdac66cc6d34829da559e816b8e2c6 Mon Sep 17 00:00:00 2001 From: Rashad Karanouh <11599358+rashad@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:57:23 +0200 Subject: [PATCH] =?UTF-8?q?v1.4.0=20=E2=80=94=20partners:=20auto-link=20pa?= =?UTF-8?q?rtner=20user=20on=20workspaceMember.created=20(#23295)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **App version:** `1.4.0` (partners app — `packages/twenty-apps/internal/twenty-partners`) ## What Adds partner onboarding auto-linking: when a `workspaceMember` is created (invite signup), a DB-event-triggered logic function resolves the partner by the member's email and stamps `partnerUser` across the partner and its cascade (person, company, links, services, content, applications). ## Key design decision — data-linking only, no role assignment The trigger **does not** assign the Partner role. A logic function runs as an app **agent**, with no user session; `updateWorkspaceMemberRole` is guarded by `UserAuthGuard` + `AuthWorkspaceMemberId` and is unreachable from an agent, so the mutation silently no-ops regardless of permission flags. The dead role code (`ensure-partner-role` service, its role query/mutation, and the role mocks) is removed so the trigger's responsibility is unambiguous: resolve partner by email → link `partnerUser` cascade with retry-on-partial-failure. Role assignment, if wanted, belongs on the invite path (`sendInvitations` accepts a `roleId`), not the trigger. ## Changes - `on-workspace-member-created.logic-function.ts` — DB-event trigger on `workspaceMember.created`; skips internal (`@twenty.com`) and unmatched emails - `resolve-partner-by-email` / `link-partner-user` services + typed `graphql/` operations for the cascade - `normalize-invite-email` util - `partnerUserLinkedAt` field on Partner - Seed: one contact `Person` (with `partnerId` + email) and one `Company` per partner so onboarding is testable via a seeded invite email; drops the `person.city` write removed in SDK 2.25 that broke `yarn seed` ## Verification - Unit: **173/173 pass** (27 files) · `tsc --noEmit` clean · `oxlint` 0 warnings/0 errors - End-to-end: invited + signed in a seeded partner (`lena@act-education.example`) on the workspace subdomain; the trigger linked the member to the **Act Education** partner and the self-service **My Profile** page rendered the linked profile (`POST /s/my-partner-profile → 200`) Review in cubic --- .../src/constants/universal-identifiers.ts | 1 + .../modules/partner/objects/partner.object.ts | 8 + .../update-application-partner-user.ts | 5 + .../mutations/update-company-partner-user.ts | 5 + .../update-partner-content-partner-user.ts | 5 + .../update-partner-link-partner-user.ts | 5 + .../mutations/update-partner-partner-user.ts | 12 ++ .../update-partner-service-partner-user.ts | 5 + .../mutations/update-person-partner-user.ts | 5 + .../queries/find-partner-by-person-email.ts | 15 ++ .../queries/get-company-partner-user.ts | 7 + .../queries/get-partner-cascade-fields.ts | 43 +++++ .../graphql/queries/get-partner-owner.ts | 7 + ...workspace-member-created.logic-function.ts | 43 +++++ .../on-workspace-member-created.test.ts | 53 ++++++ .../link-partner-user.service.test.ts | 170 ++++++++++++++++++ .../services/link-partner-user.service.ts | 86 +++++++++ .../resolve-partner-by-email.service.test.ts | 41 +++++ .../resolve-partner-by-email.service.ts | 15 ++ .../utils/normalize-invite-email.test.ts | 13 ++ .../utils/normalize-invite-email.ts | 3 + .../twenty-partners/src/scripts/seed.ts | 81 +++++++-- 22 files changed, 613 insertions(+), 15 deletions(-) create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/mutations/update-application-partner-user.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/mutations/update-company-partner-user.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/mutations/update-partner-content-partner-user.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/mutations/update-partner-link-partner-user.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/mutations/update-partner-partner-user.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/mutations/update-partner-service-partner-user.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/mutations/update-person-partner-user.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/queries/find-partner-by-person-email.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/queries/get-company-partner-user.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/queries/get-partner-cascade-fields.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/queries/get-partner-owner.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/on-workspace-member-created.logic-function.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/on-workspace-member-created.test.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/services/link-partner-user.service.test.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/services/link-partner-user.service.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/services/resolve-partner-by-email.service.test.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/services/resolve-partner-by-email.service.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/utils/normalize-invite-email.test.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/utils/normalize-invite-email.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/constants/universal-identifiers.ts b/packages/twenty-apps/internal/twenty-partners/src/constants/universal-identifiers.ts index e1ab29c441..ab7b936b6b 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/constants/universal-identifiers.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/constants/universal-identifiers.ts @@ -14,6 +14,7 @@ export const ON_PARTNER_APPLICATION_CREATED_FN_UNIVERSAL_IDENTIFIER = '43888cce- export const ON_PARTNER_LINK_CREATED_FN_UNIVERSAL_IDENTIFIER = 'a80a85f8-e32b-429e-82e9-b71cb5da54cd'; export const ON_PARTNER_SERVICE_CREATED_FN_UNIVERSAL_IDENTIFIER = '20129ceb-981e-43da-a25d-0ff6fbe52060'; export const ON_PARTNER_CONTENT_CREATED_FN_UNIVERSAL_IDENTIFIER = '18d0f329-d4a6-4b82-87ea-b78aa1a73e47'; +export const ON_WORKSPACE_MEMBER_CREATED_FN_UNIVERSAL_IDENTIFIER = '30c0857b-acd9-4362-b50c-4557d18a13b0'; export const INTRO_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER = 'fcf39b0c-0547-415e-806d-b238131ad7cc'; // Roles (Task 2) diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/objects/partner.object.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/objects/partner.object.ts index 4bf0ebafb6..faddc27754 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/objects/partner.object.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/objects/partner.object.ts @@ -554,5 +554,13 @@ export default defineObject({ icon: 'IconClock', isNullable: true, }, + { + universalIdentifier: 'fcfd5b9e-1ea0-4c26-a40c-43631bb3e0d0', + type: FieldType.DATE_TIME, + name: 'partnerUserLinkedAt', + label: 'Partner User Linked At', + icon: 'IconLink', + isNullable: true, + }, ], }); diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/mutations/update-application-partner-user.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/mutations/update-application-partner-user.ts new file mode 100644 index 0000000000..ede7bec9cb --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/mutations/update-application-partner-user.ts @@ -0,0 +1,5 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +export function updateApplicationPartnerUser(client: CoreApiClient, id: string, partnerUserId: string) { + return client.mutation({ updateApplication: { __args: { id, data: { partnerUserId } }, id: true } }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/mutations/update-company-partner-user.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/mutations/update-company-partner-user.ts new file mode 100644 index 0000000000..c706a0e3b5 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/mutations/update-company-partner-user.ts @@ -0,0 +1,5 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +export function updateCompanyPartnerUser(client: CoreApiClient, id: string, partnerUserId: string) { + return client.mutation({ updateCompany: { __args: { id, data: { partnerUserId } }, id: true } }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/mutations/update-partner-content-partner-user.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/mutations/update-partner-content-partner-user.ts new file mode 100644 index 0000000000..0dc1c31906 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/mutations/update-partner-content-partner-user.ts @@ -0,0 +1,5 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +export function updatePartnerContentPartnerUser(client: CoreApiClient, id: string, partnerUserId: string) { + return client.mutation({ updatePartnerContent: { __args: { id, data: { partnerUserId } }, id: true } }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/mutations/update-partner-link-partner-user.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/mutations/update-partner-link-partner-user.ts new file mode 100644 index 0000000000..084704b883 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/mutations/update-partner-link-partner-user.ts @@ -0,0 +1,5 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +export function updatePartnerLinkPartnerUser(client: CoreApiClient, id: string, partnerUserId: string) { + return client.mutation({ updatePartnerLink: { __args: { id, data: { partnerUserId } }, id: true } }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/mutations/update-partner-partner-user.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/mutations/update-partner-partner-user.ts new file mode 100644 index 0000000000..af9547da24 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/mutations/update-partner-partner-user.ts @@ -0,0 +1,12 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +export function updatePartnerPartnerUser( + client: CoreApiClient, + id: string, + partnerUserId: string, + partnerUserLinkedAt: string, +) { + return client.mutation({ + updatePartner: { __args: { id, data: { partnerUserId, partnerUserLinkedAt } }, id: true }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/mutations/update-partner-service-partner-user.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/mutations/update-partner-service-partner-user.ts new file mode 100644 index 0000000000..68265ec37b --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/mutations/update-partner-service-partner-user.ts @@ -0,0 +1,5 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +export function updatePartnerServicePartnerUser(client: CoreApiClient, id: string, partnerUserId: string) { + return client.mutation({ updatePartnerService: { __args: { id, data: { partnerUserId } }, id: true } }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/mutations/update-person-partner-user.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/mutations/update-person-partner-user.ts new file mode 100644 index 0000000000..885e678bd7 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/mutations/update-person-partner-user.ts @@ -0,0 +1,5 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +export function updatePersonPartnerUser(client: CoreApiClient, id: string, partnerUserId: string) { + return client.mutation({ updatePerson: { __args: { id, data: { partnerUserId } }, id: true } }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/queries/find-partner-by-person-email.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/queries/find-partner-by-person-email.ts new file mode 100644 index 0000000000..8d1168514b --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/queries/find-partner-by-person-email.ts @@ -0,0 +1,15 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +export function findPartnerByPersonEmail(client: CoreApiClient, email: string) { + return client.query({ + people: { + __args: { filter: { emails: { primaryEmail: { eq: email } } }, first: 1 }, + edges: { + node: { + id: true, + partner: { id: true, validationStage: true }, + }, + }, + }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/queries/get-company-partner-user.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/queries/get-company-partner-user.ts new file mode 100644 index 0000000000..6fb6381afe --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/queries/get-company-partner-user.ts @@ -0,0 +1,7 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +export function getCompanyPartnerUser(client: CoreApiClient, companyId: string) { + return client.query({ + company: { __args: { filter: { id: { eq: companyId } } }, id: true, partnerUserId: true }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/queries/get-partner-cascade-fields.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/queries/get-partner-cascade-fields.ts new file mode 100644 index 0000000000..5da2ca2719 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/queries/get-partner-cascade-fields.ts @@ -0,0 +1,43 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +// ponytail: applications are capped at 200 (first: 200); persons fetch a single default page. +// A partner has only a handful of each, so neither cap is expected to bind in practice. +export function getPartnerCascadeFields(client: CoreApiClient, partnerId: string) { + return client.query({ + partner: { + __args: { filter: { id: { eq: partnerId } } }, + id: true, + companyId: true, + partnerUserId: true, + persons: { edges: { node: { id: true, partnerUserId: true } } }, + }, + applications: { + __args: { + filter: { partnerId: { eq: partnerId }, partnerUserId: { is: 'NULL' } }, + first: 200, + }, + edges: { node: { id: true } }, + }, + partnerLinks: { + __args: { + filter: { partnerId: { eq: partnerId }, partnerUserId: { is: 'NULL' } }, + first: 200, + }, + edges: { node: { id: true } }, + }, + partnerServices: { + __args: { + filter: { partnerId: { eq: partnerId }, partnerUserId: { is: 'NULL' } }, + first: 200, + }, + edges: { node: { id: true } }, + }, + partnerContents: { + __args: { + filter: { partnerId: { eq: partnerId }, partnerUserId: { is: 'NULL' } }, + first: 200, + }, + edges: { node: { id: true } }, + }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/queries/get-partner-owner.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/queries/get-partner-owner.ts new file mode 100644 index 0000000000..6c6de332b3 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/queries/get-partner-owner.ts @@ -0,0 +1,7 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +export function getPartnerOwner(client: CoreApiClient, partnerId: string) { + return client.query({ + partner: { __args: { filter: { id: { eq: partnerId } } }, id: true, partnerUserId: true }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/on-workspace-member-created.logic-function.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/on-workspace-member-created.logic-function.ts new file mode 100644 index 0000000000..a3ead50f55 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/on-workspace-member-created.logic-function.ts @@ -0,0 +1,43 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { + type DatabaseEventPayload, + defineLogicFunction, + type ObjectRecordCreateEvent, +} from 'twenty-sdk/define'; + +import { ON_WORKSPACE_MEMBER_CREATED_FN_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; +import { linkPartnerUser } from 'src/modules/partner/onboarding/services/link-partner-user.service'; +import { resolvePartnerByEmail } from 'src/modules/partner/onboarding/services/resolve-partner-by-email.service'; +import { normalizeInviteEmail } from 'src/modules/partner/onboarding/utils/normalize-invite-email'; + +type WorkspaceMemberCreated = { userEmail?: string | null }; + +export const handler = async ( + payload: DatabaseEventPayload>, +): Promise> => { + const memberId = payload.recordId; + const rawEmail = payload.properties.after?.userEmail; + if (!memberId || !rawEmail) return { skipped: true, reason: 'no_email' }; + + const email = normalizeInviteEmail(rawEmail); + if (email.endsWith('@twenty.com')) return { skipped: true, reason: 'internal_email' }; + + const client = new CoreApiClient(); + const partnerId = await resolvePartnerByEmail(client, email); + if (!partnerId) return { skipped: true, reason: 'no_partner_match' }; + + const link = await linkPartnerUser(client, { partnerId, memberId }); + if (link.linked === false && link.reason === 'partner_already_linked_other') { + return { skipped: true, reason: 'partner_already_linked' }; + } + + return link; +}; + +export default defineLogicFunction({ + universalIdentifier: ON_WORKSPACE_MEMBER_CREATED_FN_UNIVERSAL_IDENTIFIER, + name: 'on-workspace-member-created', + timeoutSeconds: 30, + handler, + databaseEventTriggerSettings: { eventName: 'workspaceMember.created' }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/on-workspace-member-created.test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/on-workspace-member-created.test.ts new file mode 100644 index 0000000000..59877b7fb5 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/on-workspace-member-created.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { handler } from './on-workspace-member-created.logic-function'; +import { resolvePartnerByEmail } from './services/resolve-partner-by-email.service'; +import { linkPartnerUser } from './services/link-partner-user.service'; + +vi.mock('twenty-client-sdk/core', () => ({ CoreApiClient: vi.fn() })); +vi.mock('./services/resolve-partner-by-email.service', () => ({ resolvePartnerByEmail: vi.fn() })); +vi.mock('./services/link-partner-user.service', () => ({ linkPartnerUser: vi.fn() })); + +const makePayload = (userEmail: string | null | undefined) => ({ + recordId: 'member-1', + properties: { after: { userEmail } }, +}) as never; + +describe('on-workspace-member-created handler', () => { + beforeEach(() => vi.clearAllMocks()); + + it('skips when userEmail is missing', async () => { + const res = await handler(makePayload(undefined)); + expect(res).toEqual({ skipped: true, reason: 'no_email' }); + expect(resolvePartnerByEmail).not.toHaveBeenCalled(); + }); + + it('skips @twenty.com addresses', async () => { + const res = await handler(makePayload('staff@twenty.com')); + expect(res).toEqual({ skipped: true, reason: 'internal_email' }); + expect(resolvePartnerByEmail).not.toHaveBeenCalled(); + }); + + it('skips when no partner matches', async () => { + vi.mocked(resolvePartnerByEmail).mockResolvedValue(null); + const res = await handler(makePayload('nobody@acme.com')); + expect(res).toEqual({ skipped: true, reason: 'no_partner_match' }); + expect(linkPartnerUser).not.toHaveBeenCalled(); + }); + + it('skips when partner is already claimed by another member', async () => { + vi.mocked(resolvePartnerByEmail).mockResolvedValue('partner-1'); + vi.mocked(linkPartnerUser).mockResolvedValue({ linked: false, reason: 'partner_already_linked_other' }); + const res = await handler(makePayload('a@acme.com')); + expect(res).toEqual({ skipped: true, reason: 'partner_already_linked' }); + }); + + it('links the member on a fresh match', async () => { + vi.mocked(resolvePartnerByEmail).mockResolvedValue('partner-1'); + vi.mocked(linkPartnerUser).mockResolvedValue({ linked: true, partnerId: 'partner-1' }); + const res = await handler(makePayload('A@Acme.com')); + expect(resolvePartnerByEmail).toHaveBeenCalledWith(expect.anything(), 'a@acme.com'); + expect(linkPartnerUser).toHaveBeenCalledWith(expect.anything(), { partnerId: 'partner-1', memberId: 'member-1' }); + expect(res).toEqual({ linked: true, partnerId: 'partner-1' }); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/services/link-partner-user.service.test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/services/link-partner-user.service.test.ts new file mode 100644 index 0000000000..8de3642482 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/services/link-partner-user.service.test.ts @@ -0,0 +1,170 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +import { linkPartnerUser } from './link-partner-user.service'; + +describe('linkPartnerUser', () => { + const query = vi.fn(); + const mutation = vi.fn(); + const client = { query, mutation } as unknown as CoreApiClient; + + // Route each read by the shape of its selection: the cascade query asks for `applications`, + // getCompanyPartnerUser for `company`, getPartnerOwner for `partner` only. + const routeQueries = (opts: { + cascade: Record; + companyOwner?: string | null; + ownerRecheck?: string | null; + }) => { + query.mockImplementation((q: Record) => { + if ('applications' in q) return Promise.resolve(opts.cascade); + if ('company' in q) return Promise.resolve({ company: { id: 'company-1', partnerUserId: opts.companyOwner ?? null } }); + if ('partner' in q) return Promise.resolve({ partner: { id: 'partner-1', partnerUserId: opts.ownerRecheck ?? null } }); + return Promise.resolve({}); + }); + }; + + beforeEach(() => { + query.mockReset(); + mutation.mockReset(); + mutation.mockResolvedValue({}); + }); + + it('stamps the partner and cascades to persons, company, applications, links, services and content', async () => { + routeQueries({ + cascade: { + partner: { + id: 'partner-1', + companyId: 'company-1', + partnerUserId: null, + persons: { edges: [{ node: { id: 'person-1', partnerUserId: null } }, { node: { id: 'person-2', partnerUserId: null } }] }, + }, + applications: { edges: [{ node: { id: 'app-1' } }] }, + partnerLinks: { edges: [{ node: { id: 'link-1' } }] }, + partnerServices: { edges: [{ node: { id: 'service-1' } }] }, + partnerContents: { edges: [{ node: { id: 'content-1' } }] }, + }, + }); + + const result = await linkPartnerUser(client, { partnerId: 'partner-1', memberId: 'member-1' }); + + expect(result).toEqual({ linked: true, partnerId: 'partner-1' }); + // 2 persons + 1 application + 1 link + 1 service + 1 content + 1 company + 1 partner = 8 + expect(mutation).toHaveBeenCalledTimes(8); + // verify every cascade write carries the right id + memberId (not just the count) + expect(mutation).toHaveBeenCalledWith({ updatePerson: { __args: { id: 'person-1', data: { partnerUserId: 'member-1' } }, id: true } }); + expect(mutation).toHaveBeenCalledWith({ updatePerson: { __args: { id: 'person-2', data: { partnerUserId: 'member-1' } }, id: true } }); + expect(mutation).toHaveBeenCalledWith({ updateApplication: { __args: { id: 'app-1', data: { partnerUserId: 'member-1' } }, id: true } }); + expect(mutation).toHaveBeenCalledWith({ updateCompany: { __args: { id: 'company-1', data: { partnerUserId: 'member-1' } }, id: true } }); + expect(mutation).toHaveBeenCalledWith({ updatePartnerLink: { __args: { id: 'link-1', data: { partnerUserId: 'member-1' } }, id: true } }); + expect(mutation).toHaveBeenCalledWith({ updatePartnerService: { __args: { id: 'service-1', data: { partnerUserId: 'member-1' } }, id: true } }); + expect(mutation).toHaveBeenCalledWith({ updatePartnerContent: { __args: { id: 'content-1', data: { partnerUserId: 'member-1' } }, id: true } }); + expect(mutation).toHaveBeenCalledWith({ + updatePartner: { + __args: { id: 'partner-1', data: { partnerUserId: 'member-1', partnerUserLinkedAt: expect.any(String) } }, + id: true, + }, + }); + }); + + it('skips persons already linked to a member', async () => { + routeQueries({ + cascade: { + partner: { + id: 'partner-1', + companyId: null, + partnerUserId: null, + persons: { edges: [{ node: { id: 'person-1', partnerUserId: 'member-7' } }, { node: { id: 'person-2', partnerUserId: null } }] }, + }, + applications: { edges: [] }, + partnerLinks: { edges: [] }, + partnerServices: { edges: [] }, + partnerContents: { edges: [] }, + }, + }); + + const result = await linkPartnerUser(client, { partnerId: 'partner-1', memberId: 'member-1' }); + + expect(result).toEqual({ linked: true, partnerId: 'partner-1' }); + // only the unlinked person-2 + the partner get stamped; person-1 is left alone + expect(mutation).toHaveBeenCalledWith({ updatePerson: { __args: { id: 'person-2', data: { partnerUserId: 'member-1' } }, id: true } }); + expect(mutation).not.toHaveBeenCalledWith(expect.objectContaining({ updatePerson: expect.objectContaining({ __args: expect.objectContaining({ id: 'person-1' }) }) })); + }); + + it('does not steal a company already owned by a different member', async () => { + routeQueries({ + companyOwner: 'member-9', // company belongs to another partner's member + cascade: { + partner: { id: 'partner-1', companyId: 'company-1', partnerUserId: null, persons: { edges: [{ node: { id: 'person-1', partnerUserId: null } }] } }, + applications: { edges: [] }, + partnerLinks: { edges: [] }, + partnerServices: { edges: [] }, + partnerContents: { edges: [] }, + }, + }); + + const result = await linkPartnerUser(client, { partnerId: 'partner-1', memberId: 'member-1' }); + + expect(result).toEqual({ linked: true, partnerId: 'partner-1' }); + // person + partner are stamped, but the shared company is left untouched + expect(mutation).not.toHaveBeenCalledWith(expect.objectContaining({ updateCompany: expect.anything() })); + expect(mutation).toHaveBeenCalledWith(expect.objectContaining({ updatePartner: expect.anything() })); + }); + + it('aborts before stamping when another member claimed the partner during the cascade', async () => { + routeQueries({ + ownerRecheck: 'member-9', // a concurrent onboarding won the claim between read and stamp + cascade: { + partner: { id: 'partner-1', companyId: null, partnerUserId: null, persons: { edges: [{ node: { id: 'person-1', partnerUserId: null } }] } }, + applications: { edges: [] }, + partnerLinks: { edges: [] }, + partnerServices: { edges: [] }, + partnerContents: { edges: [] }, + }, + }); + + const result = await linkPartnerUser(client, { partnerId: 'partner-1', memberId: 'member-1' }); + + expect(result).toEqual({ linked: false, reason: 'partner_already_linked_other' }); + // the partner is never stamped by this loser + expect(mutation).not.toHaveBeenCalledWith(expect.objectContaining({ updatePartner: expect.anything() })); + }); + + it('no-ops when the partner is already linked to the same member', async () => { + routeQueries({ + cascade: { + partner: { id: 'partner-1', companyId: null, partnerUserId: 'member-1', persons: { edges: [] } }, + applications: { edges: [] }, + }, + }); + const result = await linkPartnerUser(client, { partnerId: 'partner-1', memberId: 'member-1' }); + expect(result).toEqual({ linked: false, reason: 'already_linked_same' }); + expect(mutation).not.toHaveBeenCalled(); + }); + + it('reports partner_already_linked_other when claimed by a different member', async () => { + routeQueries({ + cascade: { + partner: { id: 'partner-1', companyId: null, partnerUserId: 'member-9', persons: { edges: [] } }, + applications: { edges: [] }, + }, + }); + const result = await linkPartnerUser(client, { partnerId: 'partner-1', memberId: 'member-1' }); + expect(result).toEqual({ linked: false, reason: 'partner_already_linked_other' }); + expect(mutation).not.toHaveBeenCalled(); + }); + + it('throws when a cascade write fails (retry semantics)', async () => { + routeQueries({ + cascade: { + partner: { id: 'partner-1', companyId: null, partnerUserId: null, persons: { edges: [{ node: { id: 'person-1', partnerUserId: null } }] } }, + applications: { edges: [] }, + partnerLinks: { edges: [] }, + partnerServices: { edges: [] }, + partnerContents: { edges: [] }, + }, + }); + mutation.mockRejectedValueOnce(new Error('boom')); // person stamp fails, before the partner is ever touched + await expect(linkPartnerUser(client, { partnerId: 'partner-1', memberId: 'member-1' })).rejects.toThrow(/cascade write/); + expect(mutation).not.toHaveBeenCalledWith(expect.objectContaining({ updatePartner: expect.anything() })); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/services/link-partner-user.service.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/services/link-partner-user.service.ts new file mode 100644 index 0000000000..56ef242e9a --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/services/link-partner-user.service.ts @@ -0,0 +1,86 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +import { getCompanyPartnerUser } from 'src/modules/partner/onboarding/graphql/queries/get-company-partner-user'; +import { getPartnerCascadeFields } from 'src/modules/partner/onboarding/graphql/queries/get-partner-cascade-fields'; +import { getPartnerOwner } from 'src/modules/partner/onboarding/graphql/queries/get-partner-owner'; +import { updateApplicationPartnerUser } from 'src/modules/partner/onboarding/graphql/mutations/update-application-partner-user'; +import { updateCompanyPartnerUser } from 'src/modules/partner/onboarding/graphql/mutations/update-company-partner-user'; +import { updatePartnerContentPartnerUser } from 'src/modules/partner/onboarding/graphql/mutations/update-partner-content-partner-user'; +import { updatePartnerLinkPartnerUser } from 'src/modules/partner/onboarding/graphql/mutations/update-partner-link-partner-user'; +import { updatePartnerPartnerUser } from 'src/modules/partner/onboarding/graphql/mutations/update-partner-partner-user'; +import { updatePartnerServicePartnerUser } from 'src/modules/partner/onboarding/graphql/mutations/update-partner-service-partner-user'; +import { updatePersonPartnerUser } from 'src/modules/partner/onboarding/graphql/mutations/update-person-partner-user'; + +export type LinkPartnerUserResult = + | { linked: true; partnerId: string } + | { linked: false; reason: 'already_linked_same' | 'partner_already_linked_other' }; + +const collectIds = (edges: ({ node?: { id?: string | null } | null } | null)[] | null | undefined): string[] => + (edges ?? []).map((e) => e?.node?.id).filter((id): id is string => Boolean(id)); + +export async function linkPartnerUser( + client: CoreApiClient, + input: { partnerId: string; memberId: string }, +): Promise { + const { partnerId, memberId } = input; + const detail = await getPartnerCascadeFields(client, partnerId); + + const existing = detail.partner?.partnerUserId; + if (existing) { + return existing === memberId + ? { linked: false, reason: 'already_linked_same' } + : { linked: false, reason: 'partner_already_linked_other' }; + } + + // Only stamp persons not already linked. The other cascade collections are filtered + // server-side (partnerUserId IS NULL); persons come nested off the partner, so filter here. + const personIds = (detail.partner?.persons?.edges ?? []) + .filter((e) => !e?.node?.partnerUserId) + .map((e) => e?.node?.id) + .filter((id): id is string => Boolean(id)); + const applicationIds = collectIds(detail.applications?.edges); + const partnerLinkIds = collectIds(detail.partnerLinks?.edges); + const partnerServiceIds = collectIds(detail.partnerServices?.edges); + const partnerContentIds = collectIds(detail.partnerContents?.edges); + + // Don't clobber a company already owned by a DIFFERENT partner's member. The single + // partnerUser column models one owner per company, so overwriting would revoke that + // partner's access to the company and its contacts. Mirrors propagatePartnerUser's + // companyShared guard. Leave the company alone in that case; stamp only the rest. + const companyId = detail.partner?.companyId; + const companyWrites: Promise[] = []; + if (companyId) { + const companyOwner = (await getCompanyPartnerUser(client, companyId)).company?.partnerUserId; + if (!companyOwner || companyOwner === memberId) { + companyWrites.push(updateCompanyPartnerUser(client, companyId, memberId)); + } + } + + const results = await Promise.allSettled([ + ...personIds.map((id) => updatePersonPartnerUser(client, id, memberId)), + ...applicationIds.map((id) => updateApplicationPartnerUser(client, id, memberId)), + ...partnerLinkIds.map((id) => updatePartnerLinkPartnerUser(client, id, memberId)), + ...partnerServiceIds.map((id) => updatePartnerServicePartnerUser(client, id, memberId)), + ...partnerContentIds.map((id) => updatePartnerContentPartnerUser(client, id, memberId)), + ...companyWrites, + ]); + + const failed = results.filter((r) => r.status === 'rejected').length; + if (failed > 0) { + throw new Error(`link-partner-user: ${failed} cascade write(s) failed for ${partnerId} — retrying`); + } + + // Re-check the claim immediately before the final stamp to narrow the concurrent-onboarding + // race (two members whose emails resolve to the same partner, created at once). This is NOT + // atomic — the API has no conditional update — so a simultaneous claim can still interleave; + // it only shrinks the window. Known limitation, consistent with the rest of the app. + const claimant = (await getPartnerOwner(client, partnerId)).partner?.partnerUserId; + if (claimant && claimant !== memberId) { + return { linked: false, reason: 'partner_already_linked_other' }; + } + + // Stamp the partner LAST — its own partnerUserId is the already-linked guard, so if any + // cascade write throws, the partner stays unstamped and a re-invocation redoes the cascade. + await updatePartnerPartnerUser(client, partnerId, memberId, new Date().toISOString()); + return { linked: true, partnerId }; +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/services/resolve-partner-by-email.service.test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/services/resolve-partner-by-email.service.test.ts new file mode 100644 index 0000000000..239510d3b3 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/services/resolve-partner-by-email.service.test.ts @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +import { resolvePartnerByEmail } from './resolve-partner-by-email.service'; + +describe('resolvePartnerByEmail', () => { + const query = vi.fn(); + const client = { query } as unknown as CoreApiClient; + + beforeEach(() => query.mockReset()); + + it('returns the partner id for a VALIDATED, unlinked partner', async () => { + query.mockResolvedValueOnce({ + people: { + edges: [ + { node: { id: 'p-1', partner: { id: 'partner-1', validationStage: 'VALIDATED', partnerUserId: null } } }, + ], + }, + }); + await expect(resolvePartnerByEmail(client, 'a@b.com')).resolves.toBe('partner-1'); + }); + + it('returns null when no Person matches', async () => { + query.mockResolvedValueOnce({ people: { edges: [] } }); + await expect(resolvePartnerByEmail(client, 'a@b.com')).resolves.toBeNull(); + }); + + it('returns the partner id even when already linked (classification deferred to linkPartnerUser)', async () => { + query.mockResolvedValueOnce({ + people: { edges: [{ node: { id: 'p-1', partner: { id: 'partner-1', validationStage: 'VALIDATED', partnerUserId: 'member-9' } } }] }, + }); + await expect(resolvePartnerByEmail(client, 'a@b.com')).resolves.toBe('partner-1'); + }); + + it('returns null when the matched partner is not VALIDATED', async () => { + query.mockResolvedValueOnce({ + people: { edges: [{ node: { id: 'p-1', partner: { id: 'partner-1', validationStage: 'APPLICATION', partnerUserId: null } } }] }, + }); + await expect(resolvePartnerByEmail(client, 'a@b.com')).resolves.toBeNull(); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/services/resolve-partner-by-email.service.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/services/resolve-partner-by-email.service.ts new file mode 100644 index 0000000000..0ed91b3836 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/services/resolve-partner-by-email.service.ts @@ -0,0 +1,15 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +import { findPartnerByPersonEmail } from 'src/modules/partner/onboarding/graphql/queries/find-partner-by-person-email'; + +export async function resolvePartnerByEmail( + client: CoreApiClient, + email: string, +): Promise { + const res = await findPartnerByPersonEmail(client, email); + const partner = res.people?.edges?.[0]?.node?.partner; + if (partner?.id && partner.validationStage === 'VALIDATED') { + return partner.id; + } + return null; +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/utils/normalize-invite-email.test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/utils/normalize-invite-email.test.ts new file mode 100644 index 0000000000..452014153b --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/utils/normalize-invite-email.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; + +import { normalizeInviteEmail } from './normalize-invite-email'; + +describe('normalizeInviteEmail', () => { + it('trims surrounding whitespace and lowercases', () => { + expect(normalizeInviteEmail(' Foo@Bar.COM ')).toBe('foo@bar.com'); + }); + + it('returns empty string unchanged', () => { + expect(normalizeInviteEmail('')).toBe(''); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/utils/normalize-invite-email.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/utils/normalize-invite-email.ts new file mode 100644 index 0000000000..094cfc4080 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/utils/normalize-invite-email.ts @@ -0,0 +1,3 @@ +export function normalizeInviteEmail(email: string): string { + return email.trim().toLowerCase(); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/scripts/seed.ts b/packages/twenty-apps/internal/twenty-partners/src/scripts/seed.ts index 0f400ac129..2b0bd26de0 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/scripts/seed.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/scripts/seed.ts @@ -220,6 +220,24 @@ const PARTNERS: Partner[] = [ { slug: 'declined-co', name: 'Declined Co', validationStage: 'REJECTED', availability: 'UNAVAILABLE', introduction: 'Application rejected after review.', calendarLink: CAL, deploymentExpertise: ['CLOUD'], region: ['MENA'], languagesSpoken: ['ENGLISH', 'ARABIC'], partnerTier: 'NEW', partnerScope: ['DEVELOPMENT'], typeOfTeam: 'SOLO', country: 'UNITED_ARAB_EMIRATES', city: 'Dubai', hourlyRateUsd: null, projectBudgetMinUsd: null, skills: ['MENA', 'Arabic'] }, ]; +// One contact per partner. The onboarding trigger matches a new member's email to a +// Person, then links that Person's partner — so signing up with one of these emails is +// what auto-links a member to the partner. Each partner also gets its own Company. +type PartnerContact = { firstName: string; lastName: string; email: string }; +const PARTNER_CONTACTS: Record = { + 'nine-dots-ventures': { firstName: 'Yasmine', lastName: 'Haddad', email: 'yasmine@nine-dots-ventures.example' }, + 'elevate-consulting': { firstName: 'Diego', lastName: 'Ramirez', email: 'diego@elevate-consulting.example' }, + 'w3villa-technologies': { firstName: 'Arjun', lastName: 'Mehta', email: 'arjun@w3villa-technologies.example' }, + 'act-education': { firstName: 'Lena', lastName: 'Fischer', email: 'lena@act-education.example' }, + 'netzero-systems': { firstName: 'Beatriz', lastName: 'Costa', email: 'beatriz@netzero-systems.example' }, + 'meridian-craft': { firstName: 'Mei', lastName: 'Lim', email: 'mei@meridian-craft.example' }, + 'applicant-studio': { firstName: 'Hugo', lastName: 'Bernard', email: 'hugo@applicant-studio.example' }, + 'rising-crm': { firstName: 'Sarah', lastName: 'Johnson', email: 'sarah@rising-crm.example' }, + 'legacy-partners': { firstName: 'Oliver', lastName: 'Smith', email: 'oliver@legacy-partners.example' }, + 'declined-co': { firstName: 'Omar', lastName: 'Farouk', email: 'omar@declined-co.example' }, +}; +const partnerDomain = (slug: string): string => `https://${slug}.example`; + const COMPANIES = [ { name: 'Acme Real Estate', domain: 'https://acmerealestate.example' }, { name: 'Helix Bio', domain: 'https://helixbio.example' }, @@ -227,9 +245,9 @@ const COMPANIES = [ ]; const PERSONS = [ - { firstName: 'Camille', lastName: 'Durand', companyName: 'Acme Real Estate', email: 'camille@acmerealestate.example', city: 'Paris' }, - { firstName: 'Maya', lastName: 'Patel', companyName: 'Helix Bio', email: 'maya@helixbio.example', city: 'Boston' }, - { firstName: 'Wei', lastName: 'Chen', companyName: 'Sunrise Logistics', email: 'wei@sunriselogistics.example', city: 'Singapore' }, + { firstName: 'Camille', lastName: 'Durand', companyName: 'Acme Real Estate', email: 'camille@acmerealestate.example' }, + { firstName: 'Maya', lastName: 'Patel', companyName: 'Helix Bio', email: 'maya@helixbio.example' }, + { firstName: 'Wei', lastName: 'Chen', companyName: 'Sunrise Logistics', email: 'wei@sunriselogistics.example' }, ]; type Opp = { @@ -274,6 +292,17 @@ const withPartnerUserId = ( ): Record => partnerUserId ? { ...data, partnerUserId } : data; +async function upsertCompanyByName( + client: CoreApiClient, + name: string, + domain: string, +): Promise { + const existing = nodes(await client.query({ companies: { __args: { filter: { name: { eq: name } }, first: 1 }, edges: { node: { id: true } } } } as any), 'companies'); + if (existing[0]?.id) return existing[0].id; + const r: any = await client.mutation({ createCompany: { __args: { data: { name, domainName: { primaryLinkUrl: domain } } }, id: true } } as any); + return r.createCompany.id; +} + async function main() { const client = new CoreApiClient({ url: `${requireEnv('TWENTY_PARTNERS_API_URL').replace(/\/$/, '')}/graphql`, @@ -286,14 +315,18 @@ async function main() { 'partners', ); const partnerIdBySlug = new Map(existingPartners.map((n: any) => [n.slug, n.id])); + const companyIdByName = new Map(); for (const p of PARTNERS) { + // Partner's own company — linked so the profile isn't bare and the contact has an employer. + const companyId = await upsertCompanyByName(client, p.name, partnerDomain(p.slug)); + companyIdByName.set(p.name, companyId); const data = { name: p.name, slug: p.slug, validationStage: p.validationStage, availability: p.availability, introduction: p.introduction, calendarLink: { primaryLinkUrl: p.calendarLink }, deploymentExpertise: p.deploymentExpertise, region: p.region, languagesSpoken: p.languagesSpoken, partnerTier: p.partnerTier, partnerScope: p.partnerScope, typeOfTeam: p.typeOfTeam, country: p.country, city: p.city, - skills: p.skills, + skills: p.skills, companyId, linkedin: { primaryLinkUrl: linkedin(p.slug) }, ...(p.hourlyRateUsd != null ? { hourlyRate: usd(p.hourlyRateUsd) } : {}), ...(p.projectBudgetMinUsd != null ? { projectBudgetMin: usd(p.projectBudgetMinUsd) } : {}), @@ -308,27 +341,45 @@ async function main() { } console.log(`[seed] partners: ${partnerIdBySlug.size}`); - // -- Companies (upsert by name) -- - const companyIdByName = new Map(); + // -- Companies (customer accounts, upsert by name) -- for (const c of COMPANIES) { - const existing = nodes(await client.query({ companies: { __args: { filter: { name: { eq: c.name } }, first: 1 }, edges: { node: { id: true } } } } as any), 'companies'); - let id = existing[0]?.id; - if (!id) { - const r: any = await client.mutation({ createCompany: { __args: { data: { name: c.name, domainName: { primaryLinkUrl: c.domain } } }, id: true } } as any); - id = r.createCompany.id; - } - companyIdByName.set(c.name, id); + companyIdByName.set(c.name, await upsertCompanyByName(client, c.name, c.domain)); } - // -- People (upsert by firstName+lastName) -- + // -- People (customer contacts, upsert by firstName+lastName) -- for (const person of PERSONS) { const existing = nodes(await client.query({ people: { __args: { filter: { name: { firstName: { eq: person.firstName } } }, first: 10 }, edges: { node: { id: true, name: { firstName: true, lastName: true } } } } } as any), 'people'); const match = existing.find((n: any) => n.name?.firstName === person.firstName && n.name?.lastName === person.lastName); if (!match) { - await client.mutation({ createPerson: { __args: { data: { name: { firstName: person.firstName, lastName: person.lastName }, emails: { primaryEmail: person.email }, city: person.city, companyId: companyIdByName.get(person.companyName) } }, id: true } } as any); + await client.mutation({ createPerson: { __args: { data: { name: { firstName: person.firstName, lastName: person.lastName }, emails: { primaryEmail: person.email }, companyId: companyIdByName.get(person.companyName) } }, id: true } } as any); } } + // -- Partner contacts (one per partner) — email→Person→partner is the onboarding match key -- + let partnerContactCount = 0; + for (const p of PARTNERS) { + const contact = PARTNER_CONTACTS[p.slug]; + if (!contact) continue; + partnerContactCount++; + const companyId = companyIdByName.get(p.name); + const partnerId = partnerIdBySlug.get(p.slug); + const existing = nodes(await client.query({ people: { __args: { filter: { emails: { primaryEmail: { eq: contact.email } } }, first: 1 }, edges: { node: { id: true, partnerId: true, companyId: true } } } } as any), 'people'); + const found = existing[0]; + if (found?.id) { + // Rerun against a workspace where the Person exists but lost its relations would leave + // the onboarding match broken (no_partner_match). Backfill only the missing links. + const patch: Record = {}; + if (!found.partnerId && partnerId) patch.partnerId = partnerId; + if (!found.companyId && companyId) patch.companyId = companyId; + if (Object.keys(patch).length > 0) { + await client.mutation({ updatePerson: { __args: { id: found.id, data: patch }, id: true } } as any); + } + continue; + } + await client.mutation({ createPerson: { __args: { data: { name: { firstName: contact.firstName, lastName: contact.lastName }, emails: { primaryEmail: contact.email }, companyId, partnerId } }, id: true } } as any); + } + console.log(`[seed] partner contacts: ${partnerContactCount}`); + // -- Opportunities (upsert by name) -- const oppIdByName = new Map(); for (const o of OPPORTUNITIES) {