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:
@@ -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)
|
||||
|
||||
+8
@@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
+5
@@ -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 } });
|
||||
}
|
||||
+5
@@ -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 } });
|
||||
}
|
||||
+5
@@ -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 } });
|
||||
}
|
||||
+5
@@ -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 } });
|
||||
}
|
||||
+12
@@ -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 },
|
||||
});
|
||||
}
|
||||
+5
@@ -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 } });
|
||||
}
|
||||
+5
@@ -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 } });
|
||||
}
|
||||
+15
@@ -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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
+7
@@ -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 },
|
||||
});
|
||||
}
|
||||
+43
@@ -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 } },
|
||||
},
|
||||
});
|
||||
}
|
||||
+7
@@ -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 },
|
||||
});
|
||||
}
|
||||
+43
@@ -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' },
|
||||
});
|
||||
+53
@@ -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' });
|
||||
});
|
||||
});
|
||||
+170
@@ -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() }));
|
||||
});
|
||||
});
|
||||
+86
@@ -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 };
|
||||
}
|
||||
+41
@@ -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();
|
||||
});
|
||||
});
|
||||
+15
@@ -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;
|
||||
}
|
||||
+13
@@ -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('');
|
||||
});
|
||||
});
|
||||
+3
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user