v1.4.0 — partners: auto-link partner user on workspaceMember.created (#23295)

**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`)

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23295?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Rashad Karanouh
2026-07-27 13:57:23 +02:00
committed by GitHub
parent 302f46f0ea
commit e631c986a1
22 changed files with 613 additions and 15 deletions
@@ -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)
@@ -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,
},
],
});
@@ -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 } });
}
@@ -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 } });
}
@@ -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 } });
}
@@ -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 } });
}
@@ -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 },
});
}
@@ -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 } });
}
@@ -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 } });
}
@@ -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 },
},
},
},
});
}
@@ -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 },
});
}
@@ -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 } },
},
});
}
@@ -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 },
});
}
@@ -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<ObjectRecordCreateEvent<WorkspaceMemberCreated>>,
): Promise<Record<string, unknown>> => {
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' },
});
@@ -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' });
});
});
@@ -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<string, unknown>;
companyOwner?: string | null;
ownerRecheck?: string | null;
}) => {
query.mockImplementation((q: Record<string, unknown>) => {
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() }));
});
});
@@ -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<LinkPartnerUserResult> {
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<unknown>[] = [];
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 };
}
@@ -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();
});
});
@@ -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<string | null> {
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;
}
@@ -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('');
});
});
@@ -0,0 +1,3 @@
export function normalizeInviteEmail(email: string): string {
return email.trim().toLowerCase();
}
@@ -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<string, PartnerContact> = {
'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<string, unknown> =>
partnerUserId ? { ...data, partnerUserId } : data;
async function upsertCompanyByName(
client: CoreApiClient,
name: string,
domain: string,
): Promise<string> {
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<string, string>(existingPartners.map((n: any) => [n.slug, n.id]));
const companyIdByName = new Map<string, string>();
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<string, string>();
// -- 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<string, unknown> = {};
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<string, string>();
for (const o of OPPORTUNITIES) {