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`)
---
.../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) {