diff --git a/packages/twenty-apps/internal/twenty-partners/package.json b/packages/twenty-apps/internal/twenty-partners/package.json index c89d43de5ff1e..a083adfdc8784 100644 --- a/packages/twenty-apps/internal/twenty-partners/package.json +++ b/packages/twenty-apps/internal/twenty-partners/package.json @@ -1,6 +1,6 @@ { "name": "twenty-partners", - "version": "0.4.3", + "version": "0.5.0", "license": "MIT", "engines": { "node": "^24.5.0", @@ -25,7 +25,9 @@ "import:apply": "IMPORT_APPLY=1 tsx src/scripts/import-from-tft.ts", "import:apply:prod": "ENV_FILE=.env.prod IMPORT_APPLY=1 tsx src/scripts/import-from-tft.ts", "migrate:partner-scope": "tsx src/scripts/migrate-partner-scope.ts", - "migrate:partner-scope:prod": "ENV_FILE=.env.prod tsx src/scripts/migrate-partner-scope.ts" + "migrate:partner-scope:prod": "ENV_FILE=.env.prod tsx src/scripts/migrate-partner-scope.ts", + "rls:configure": "tsx src/scripts/configure-partner-rls.ts", + "rls:configure:prod": "ENV_FILE=.env.prod tsx src/scripts/configure-partner-rls.ts" }, "dependencies": { "twenty-client-sdk": "2.10.1", diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/companies-as-partner-user-on-workspace-member.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/companies-as-partner-user-on-workspace-member.field.ts new file mode 100644 index 0000000000000..541080c7e2d98 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/companies-as-partner-user-on-workspace-member.field.ts @@ -0,0 +1,17 @@ +import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define'; + +import { COMPANIES_AS_PARTNER_USER_FIELD_ID, PARTNER_USER_ON_COMPANY_FIELD_ID } from './partner-user-on-company.field'; + +export default defineField({ + universalIdentifier: COMPANIES_AS_PARTNER_USER_FIELD_ID, + objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier, + type: FieldType.RELATION, + name: 'companiesAsPartnerUser', + label: 'Companies (as partner user)', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier, + relationTargetFieldMetadataUniversalIdentifier: PARTNER_USER_ON_COMPANY_FIELD_ID, + universalSettings: { + relationType: RelationType.ONE_TO_MANY, + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunities-as-partner-user-on-workspace-member.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/opportunities-as-partner-user-on-workspace-member.field.ts new file mode 100644 index 0000000000000..1694df0a49103 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/opportunities-as-partner-user-on-workspace-member.field.ts @@ -0,0 +1,17 @@ +import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define'; + +import { OPPORTUNITIES_AS_PARTNER_USER_FIELD_ID, PARTNER_USER_ON_OPPORTUNITY_FIELD_ID } from './partner-user-on-opportunity.field'; + +export default defineField({ + universalIdentifier: OPPORTUNITIES_AS_PARTNER_USER_FIELD_ID, + objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier, + type: FieldType.RELATION, + name: 'opportunitiesAsPartnerUser', + label: 'Opportunities (as partner user)', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + relationTargetFieldMetadataUniversalIdentifier: PARTNER_USER_ON_OPPORTUNITY_FIELD_ID, + universalSettings: { + relationType: RelationType.ONE_TO_MANY, + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-design-doc-status.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-design-doc-status.field.ts index b5ca6a39e5a07..11866bcabaa55 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-design-doc-status.field.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-design-doc-status.field.ts @@ -1,7 +1,9 @@ import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define'; +export const OPPORTUNITY_DESIGN_DOC_STATUS_FIELD_ID = 'cc6b8a59-f860-493f-8b9a-f138c078fbf1'; + export default defineField({ - universalIdentifier: 'cc6b8a59-f860-493f-8b9a-f138c078fbf1', + universalIdentifier: OPPORTUNITY_DESIGN_DOC_STATUS_FIELD_ID, objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, type: FieldType.SELECT, name: 'designDocStatus', diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-design-doc-url.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-design-doc-url.field.ts index 9d1869a427a16..e8897ad09d6d6 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-design-doc-url.field.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-design-doc-url.field.ts @@ -1,7 +1,9 @@ import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define'; +export const OPPORTUNITY_DESIGN_DOC_URL_FIELD_ID = '37e5428c-6c8c-4616-b626-f0ea1caa443d'; + export default defineField({ - universalIdentifier: '37e5428c-6c8c-4616-b626-f0ea1caa443d', + universalIdentifier: OPPORTUNITY_DESIGN_DOC_URL_FIELD_ID, objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, type: FieldType.LINKS, name: 'designDocUrl', diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-hosting-type.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-hosting-type.field.ts index 21e21547c9ab0..1b54a18b842b3 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-hosting-type.field.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-hosting-type.field.ts @@ -1,7 +1,9 @@ import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define'; +export const OPPORTUNITY_HOSTING_TYPE_FIELD_ID = '7ac7517f-bbca-4b4c-8996-6f864f71219b'; + export default defineField({ - universalIdentifier: '7ac7517f-bbca-4b4c-8996-6f864f71219b', + universalIdentifier: OPPORTUNITY_HOSTING_TYPE_FIELD_ID, objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, type: FieldType.SELECT, name: 'hostingType', diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-lost-reason.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-lost-reason.field.ts index 570a3d107bc2b..15a9ed7832948 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-lost-reason.field.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-lost-reason.field.ts @@ -1,7 +1,9 @@ import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define'; +export const OPPORTUNITY_LOST_REASON_FIELD_ID = '834e233d-b171-409e-825f-77ac49b0f19d'; + export default defineField({ - universalIdentifier: '834e233d-b171-409e-825f-77ac49b0f19d', + universalIdentifier: OPPORTUNITY_LOST_REASON_FIELD_ID, objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, type: FieldType.TEXT, name: 'lostReason', diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-number-of-seats.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-number-of-seats.field.ts index a087f1ee90386..8160cdb8a8625 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-number-of-seats.field.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-number-of-seats.field.ts @@ -1,7 +1,9 @@ import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define'; +export const OPPORTUNITY_NUMBER_OF_SEATS_FIELD_ID = '90c683ec-2365-4533-a187-7b9ae162b753'; + export default defineField({ - universalIdentifier: '90c683ec-2365-4533-a187-7b9ae162b753', + universalIdentifier: OPPORTUNITY_NUMBER_OF_SEATS_FIELD_ID, objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, type: FieldType.NUMBER, name: 'numberOfSeats', diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-subscription-frequency.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-subscription-frequency.field.ts index accee28b0cbb2..a9741c2144d3d 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-subscription-frequency.field.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-subscription-frequency.field.ts @@ -1,7 +1,9 @@ import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define'; +export const OPPORTUNITY_SUBSCRIPTION_FREQUENCY_FIELD_ID = '59d5de53-202f-4913-a417-8a08970d87cc'; + export default defineField({ - universalIdentifier: '59d5de53-202f-4913-a417-8a08970d87cc', + universalIdentifier: OPPORTUNITY_SUBSCRIPTION_FREQUENCY_FIELD_ID, objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, type: FieldType.SELECT, name: 'subscriptionFrequency', diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-subscription-type.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-subscription-type.field.ts index c11bd2f92092e..0a8f4afa5cc1f 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-subscription-type.field.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-subscription-type.field.ts @@ -1,7 +1,9 @@ import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define'; +export const OPPORTUNITY_SUBSCRIPTION_TYPE_FIELD_ID = 'a58214e9-38f9-4faf-8927-09b3980fd8c3'; + export default defineField({ - universalIdentifier: 'a58214e9-38f9-4faf-8927-09b3980fd8c3', + universalIdentifier: OPPORTUNITY_SUBSCRIPTION_TYPE_FIELD_ID, objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, type: FieldType.SELECT, name: 'subscriptionType', diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-tft-id.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-tft-id.field.ts index 1d1ff67ce1c6b..9622291076b4b 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-tft-id.field.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-tft-id.field.ts @@ -1,7 +1,9 @@ import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define'; +export const OPPORTUNITY_TFT_ID_FIELD_ID = '2e3e1d04-2719-4e0d-9a6b-ec73acf896c5'; + export default defineField({ - universalIdentifier: '2e3e1d04-2719-4e0d-9a6b-ec73acf896c5', + universalIdentifier: OPPORTUNITY_TFT_ID_FIELD_ID, objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, type: FieldType.TEXT, name: 'tftOpportunityId', diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-use-case.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-use-case.field.ts index a72a471e5b5f2..2d2336d9ceac6 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-use-case.field.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-use-case.field.ts @@ -1,7 +1,9 @@ import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define'; +export const OPPORTUNITY_USE_CASE_FIELD_ID = '1bc57f52-a621-4243-ae3e-05c3f504b90c'; + export default defineField({ - universalIdentifier: '1bc57f52-a621-4243-ae3e-05c3f504b90c', + universalIdentifier: OPPORTUNITY_USE_CASE_FIELD_ID, objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, type: FieldType.TEXT, name: 'useCase', diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-profiles-as-user-on-workspace-member.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-profiles-as-user-on-workspace-member.field.ts new file mode 100644 index 0000000000000..8dfa54d6d2805 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-profiles-as-user-on-workspace-member.field.ts @@ -0,0 +1,18 @@ +import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define'; + +import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; +import { PARTNER_PROFILES_AS_USER_FIELD_ID, PARTNER_USER_ON_PARTNER_FIELD_ID } from './partner-user-on-partner.field'; + +export default defineField({ + universalIdentifier: PARTNER_PROFILES_AS_USER_FIELD_ID, + objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier, + type: FieldType.RELATION, + name: 'partnerProfilesAsUser', + label: 'Partner Profiles (as user)', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, + relationTargetFieldMetadataUniversalIdentifier: PARTNER_USER_ON_PARTNER_FIELD_ID, + universalSettings: { + relationType: RelationType.ONE_TO_MANY, + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-company.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-company.field.ts new file mode 100644 index 0000000000000..01a6206dc8aa6 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-company.field.ts @@ -0,0 +1,20 @@ +import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define'; + +export const PARTNER_USER_ON_COMPANY_FIELD_ID = 'f8f1dbb6-9f9f-4c13-9dab-a19ea6223801'; +export const COMPANIES_AS_PARTNER_USER_FIELD_ID = '6235bf5b-9ecf-482f-b8b0-e6ae37659638'; + +export default defineField({ + universalIdentifier: PARTNER_USER_ON_COMPANY_FIELD_ID, + objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier, + type: FieldType.RELATION, + name: 'partnerUser', + label: 'Partner User', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier, + relationTargetFieldMetadataUniversalIdentifier: COMPANIES_AS_PARTNER_USER_FIELD_ID, + universalSettings: { + relationType: RelationType.MANY_TO_ONE, + onDelete: OnDeleteAction.SET_NULL, + joinColumnName: 'partnerUserId', + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-opportunity.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-opportunity.field.ts new file mode 100644 index 0000000000000..a045a626dac39 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-opportunity.field.ts @@ -0,0 +1,20 @@ +import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define'; + +export const PARTNER_USER_ON_OPPORTUNITY_FIELD_ID = '7022a40a-a954-4e6b-96b8-1faff0919ec0'; +export const OPPORTUNITIES_AS_PARTNER_USER_FIELD_ID = 'b03a26e8-6d9d-4d70-930b-2006929c9869'; + +export default defineField({ + universalIdentifier: PARTNER_USER_ON_OPPORTUNITY_FIELD_ID, + objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + type: FieldType.RELATION, + name: 'partnerUser', + label: 'Partner User', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier, + relationTargetFieldMetadataUniversalIdentifier: OPPORTUNITIES_AS_PARTNER_USER_FIELD_ID, + universalSettings: { + relationType: RelationType.MANY_TO_ONE, + onDelete: OnDeleteAction.SET_NULL, + joinColumnName: 'partnerUserId', + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-partner.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-partner.field.ts new file mode 100644 index 0000000000000..8dab5f9dadd2d --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-partner.field.ts @@ -0,0 +1,22 @@ +import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define'; + +import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; + +export const PARTNER_USER_ON_PARTNER_FIELD_ID = '0e49f2e4-1e45-433d-bf49-79acc0b06d0e'; +export const PARTNER_PROFILES_AS_USER_FIELD_ID = 'c0791b65-802f-4ae0-85e0-5434459214f1'; + +export default defineField({ + universalIdentifier: PARTNER_USER_ON_PARTNER_FIELD_ID, + objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, + type: FieldType.RELATION, + name: 'partnerUser', + label: 'Partner User', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier, + relationTargetFieldMetadataUniversalIdentifier: PARTNER_PROFILES_AS_USER_FIELD_ID, + universalSettings: { + relationType: RelationType.MANY_TO_ONE, + onDelete: OnDeleteAction.SET_NULL, + joinColumnName: 'partnerUserId', + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-person.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-person.field.ts new file mode 100644 index 0000000000000..c6768fa0c0fdd --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-person.field.ts @@ -0,0 +1,20 @@ +import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define'; + +export const PARTNER_USER_ON_PERSON_FIELD_ID = '2fc8b812-b7f9-49f2-829a-050be7ee1e5e'; +export const PERSONS_AS_PARTNER_USER_FIELD_ID = '94d17fac-3d32-4b13-9fb3-a671d4bf9c46'; + +export default defineField({ + universalIdentifier: PARTNER_USER_ON_PERSON_FIELD_ID, + objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier, + type: FieldType.RELATION, + name: 'partnerUser', + label: 'Partner User', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier, + relationTargetFieldMetadataUniversalIdentifier: PERSONS_AS_PARTNER_USER_FIELD_ID, + universalSettings: { + relationType: RelationType.MANY_TO_ONE, + onDelete: OnDeleteAction.SET_NULL, + joinColumnName: 'partnerUserId', + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/persons-as-partner-user-on-workspace-member.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/persons-as-partner-user-on-workspace-member.field.ts new file mode 100644 index 0000000000000..97a45a93ff11d --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/persons-as-partner-user-on-workspace-member.field.ts @@ -0,0 +1,17 @@ +import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define'; + +import { PARTNER_USER_ON_PERSON_FIELD_ID, PERSONS_AS_PARTNER_USER_FIELD_ID } from './partner-user-on-person.field'; + +export default defineField({ + universalIdentifier: PERSONS_AS_PARTNER_USER_FIELD_ID, + objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier, + type: FieldType.RELATION, + name: 'personsAsPartnerUser', + label: 'People (as partner user)', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier, + relationTargetFieldMetadataUniversalIdentifier: PARTNER_USER_ON_PERSON_FIELD_ID, + universalSettings: { + relationType: RelationType.ONE_TO_MANY, + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/on-opportunity-partner-assigned.integration-test.ts b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/on-opportunity-partner-assigned.integration-test.ts new file mode 100644 index 0000000000000..0598e7e26ce9f --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/on-opportunity-partner-assigned.integration-test.ts @@ -0,0 +1,273 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import onOpportunityPartnerAssigned from '../on-opportunity-partner-assigned'; + +// defineLogicFunction wraps the handler in a ValidationResult; the fn is on config.handler. +const handler = onOpportunityPartnerAssigned.config.handler; + +function requireId(id: string | null | undefined, what: string): string { + if (!id) throw new Error(`${what} returned no id`); + return id; +} + +async function getWorkspaceMemberId(client: CoreApiClient): Promise { + const r = await client.query({ + workspaceMembers: { + __args: { first: 1 }, + edges: { node: { id: true } }, + }, + }); + const memberId = r.workspaceMembers?.edges?.[0]?.node?.id; + if (!memberId) throw new Error('No workspace members found — cannot run test'); + return memberId; +} + +async function createPartner(client: CoreApiClient, memberId: string): Promise { + const r = await client.mutation({ + createPartner: { + __args: { + data: { + name: `[test-rls] partner ${Date.now()}`, + slug: `test-rls-${Date.now()}`, + partnerUserId: memberId, + }, + }, + id: true, + }, + }); + return requireId(r.createPartner?.id, 'createPartner'); +} + +async function destroyPartner(client: CoreApiClient, id: string) { + await client.mutation({ destroyPartner: { __args: { id }, id: true } }).catch(() => {}); +} + +async function createCompany(client: CoreApiClient, name: string): Promise { + const r = await client.mutation({ + createCompany: { __args: { data: { name } }, id: true }, + }); + return requireId(r.createCompany?.id, 'createCompany'); +} + +async function destroyCompany(client: CoreApiClient, id: string) { + await client.mutation({ destroyCompany: { __args: { id }, id: true } }).catch(() => {}); +} + +async function createPerson(client: CoreApiClient, companyId: string): Promise { + const r = await client.mutation({ + createPerson: { + __args: { data: { name: { firstName: 'Test', lastName: `RLS-${Date.now()}` }, companyId } }, + id: true, + }, + }); + return requireId(r.createPerson?.id, 'createPerson'); +} + +async function destroyPerson(client: CoreApiClient, id: string) { + await client.mutation({ destroyPerson: { __args: { id }, id: true } }).catch(() => {}); +} + +async function createOpportunity( + client: CoreApiClient, + name: string, + companyId: string, +): Promise { + const r = await client.mutation({ + createOpportunity: { __args: { data: { name, companyId } }, id: true }, + }); + return requireId(r.createOpportunity?.id, 'createOpportunity'); +} + +async function destroyOpportunity(client: CoreApiClient, id: string) { + await client.mutation({ destroyOpportunity: { __args: { id }, id: true } }).catch(() => {}); +} + +async function getOpportunityPartnerUserId( + client: CoreApiClient, + id: string, +): Promise { + const r = await client.query({ + opportunity: { + __args: { filter: { id: { eq: id } } }, + id: true, + partnerUserId: true, + }, + }); + return r.opportunity?.partnerUserId ?? null; +} + +async function getCompanyPartnerUserId( + client: CoreApiClient, + id: string, +): Promise { + const r = await client.query({ + company: { + __args: { filter: { id: { eq: id } } }, + id: true, + partnerUserId: true, + }, + }); + return r.company?.partnerUserId ?? null; +} + +async function getPersonPartnerUserId( + client: CoreApiClient, + id: string, +): Promise { + const r = await client.query({ + person: { + __args: { filter: { id: { eq: id } } }, + id: true, + partnerUserId: true, + }, + }); + return r.person?.partnerUserId ?? null; +} + +// Invoke the handler directly against a live workspace to assert its cascade logic and +// resulting DB state deterministically, without depending on the event bus. The trigger +// wiring (opportunity.updated → fires) is covered separately by app sync + manual E2E. +describe('on-opportunity-partner-assigned', () => { + let client: CoreApiClient; + + // Track created record IDs for cleanup — order matters for FK constraints. + const createdOpportunityIds: string[] = []; + const createdPersonIds: string[] = []; + const createdCompanyIds: string[] = []; + const createdPartnerIds: string[] = []; + + beforeEach(() => { + client = new CoreApiClient(); + }); + + afterEach(async () => { + // Destroy in reverse FK order. + for (const id of createdOpportunityIds) await destroyOpportunity(client, id); + createdOpportunityIds.length = 0; + + for (const id of createdPersonIds) await destroyPerson(client, id); + createdPersonIds.length = 0; + + for (const id of createdCompanyIds) await destroyCompany(client, id); + createdCompanyIds.length = 0; + + for (const id of createdPartnerIds) await destroyPartner(client, id); + createdPartnerIds.length = 0; + }); + + it('returns {} without any mutations when partnerId is not in updatedFields', async () => { + const result = await handler({ + properties: { updatedFields: ['name'], after: { id: 'fake-id' } }, + }); + + expect(result).toEqual({}); + }); + + it('clears partnerUser on the opportunity, company, and people when the partner is removed (unassignment)', async () => { + const memberId = await getWorkspaceMemberId(client); + const partnerId = await createPartner(client, memberId); + createdPartnerIds.push(partnerId); + const companyId = await createCompany(client, `[test-rls] company ${Date.now()}`); + createdCompanyIds.push(companyId); + const personId = await createPerson(client, companyId); + createdPersonIds.push(personId); + const oppId = await createOpportunity(client, `[test-rls] opp ${Date.now()}`, companyId); + createdOpportunityIds.push(oppId); + + // Assign first so the opportunity + company + person have partnerUser stamped. + await handler({ + properties: { updatedFields: ['partnerId'], after: { id: oppId, partnerId, companyId } }, + }); + expect(await getOpportunityPartnerUserId(client, oppId)).toBe(memberId); + expect(await getCompanyPartnerUserId(client, companyId)).toBe(memberId); + expect(await getPersonPartnerUserId(client, personId)).toBe(memberId); + + // Now unassign: partnerId set to null. No other opportunity uses this company for the + // member, so the company + person should be cleared too. `before` carries the removed + // partnerId so the handler can decide on the source of truth. + const result = await handler({ + properties: { + updatedFields: ['partnerId'], + before: { id: oppId, partnerId, partnerUserId: memberId, companyId }, + after: { id: oppId, partnerId: null, companyId }, + }, + }); + + expect(result.cascaded).toBe(true); + expect(result.cleared).toBe(true); + expect(await getOpportunityPartnerUserId(client, oppId)).toBeNull(); + expect(await getCompanyPartnerUserId(client, companyId)).toBeNull(); + expect(await getPersonPartnerUserId(client, personId)).toBeNull(); + }); + + it('stamps partnerUserId on opportunity, company, and people when partner is assigned', async () => { + const memberId = await getWorkspaceMemberId(client); + const partnerId = await createPartner(client, memberId); + createdPartnerIds.push(partnerId); + const companyId = await createCompany(client, `[test-rls] company ${Date.now()}`); + createdCompanyIds.push(companyId); + const personId = await createPerson(client, companyId); + createdPersonIds.push(personId); + const oppId = await createOpportunity(client, `[test-rls] opp ${Date.now()}`, companyId); + createdOpportunityIds.push(oppId); + + const result = await handler({ + properties: { updatedFields: ['partnerId'], after: { id: oppId, partnerId, companyId } }, + }); + + expect(result.cascaded).toBe(true); + expect(result.partnerUserId).toBe(memberId); + expect(await getOpportunityPartnerUserId(client, oppId)).toBe(memberId); + expect(await getCompanyPartnerUserId(client, companyId)).toBe(memberId); + expect(await getPersonPartnerUserId(client, personId)).toBe(memberId); + }); + + it('stamps partnerUserId on opportunity and returns cascaded: true when no companyId is present', async () => { + const memberId = await getWorkspaceMemberId(client); + const partnerId = await createPartner(client, memberId); + createdPartnerIds.push(partnerId); + + // Opportunity with no linked company, so there is no company/people cascade. + const r = await client.mutation({ + createOpportunity: { + __args: { data: { name: `[test-rls] no-company opp ${Date.now()}` } }, + id: true, + }, + }); + const oppId = requireId(r.createOpportunity?.id, 'createOpportunity'); + createdOpportunityIds.push(oppId); + + const result = await handler({ + properties: { updatedFields: ['partnerId'], after: { id: oppId, partnerId } }, + }); + + expect(result.cascaded).toBe(true); + expect(result.partnerUserId).toBe(memberId); + expect(await getOpportunityPartnerUserId(client, oppId)).toBe(memberId); + }); + + it('returns { cascaded: false, reason: "partner_has_no_user" } when partner.partnerUserId is null', async () => { + // Create a partner WITHOUT a partnerUserId. + const r = await client.mutation({ + createPartner: { + __args: { + data: { + name: `[test-rls] no-user partner ${Date.now()}`, + slug: `test-rls-nouser-${Date.now()}`, + }, + }, + id: true, + }, + }); + const noUserPartnerId = requireId(r.createPartner?.id, 'createPartner'); + createdPartnerIds.push(noUserPartnerId); + + const result = await handler({ + properties: { updatedFields: ['partnerId'], after: { id: 'fake-opp-id', partnerId: noUserPartnerId } }, + }); + + expect(result.cascaded).toBe(false); + expect(result.reason).toBe('partner_has_no_user'); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-opportunity-partner-assigned.ts b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-opportunity-partner-assigned.ts new file mode 100644 index 0000000000000..e6481ec78f12f --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-opportunity-partner-assigned.ts @@ -0,0 +1,212 @@ +import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core'; +import { + type DatabaseEventPayload, + defineLogicFunction, + type ObjectRecordUpdateEvent, +} from 'twenty-sdk/define'; + +// Defined here (not in universal-identifiers.ts) to avoid touching that file's local-only id. +const ON_OPP_PARTNER_ASSIGNED_FN_UNIVERSAL_IDENTIFIER = 'd7e4a4e6-9142-4597-adcf-6fb83c0f042d'; + +const PEOPLE_PAGE_SIZE = 200; + +// Every Person id matching the filter, paginated fully — a company can have more than one +// page of contacts and a single capped page would leave RLS stamps stale on the rest. +async function collectPeopleIds( + client: CoreApiClient, + filter: CoreSchema.PersonFilterInput, +): Promise { + const ids: string[] = []; + let after: string | undefined; + + for (;;) { + const page = await client.query({ + people: { + __args: { filter, first: PEOPLE_PAGE_SIZE, ...(after ? { after } : {}) }, + edges: { node: { id: true } }, + pageInfo: { hasNextPage: true, endCursor: true }, + }, + }); + for (const edge of page.people?.edges ?? []) { + if (edge?.node?.id) ids.push(edge.node.id); + } + if (!page.people?.pageInfo?.hasNextPage) break; + after = page.people.pageInfo.endCursor ?? undefined; + } + + return ids; +} + +// Set (or clear, with null) partnerUser on each Person, attempting all of them even if some +// fail. Returns the failure count so the caller can force a retry rather than silently +// leaving a partial stamp. +async function setPeoplePartnerUser( + client: CoreApiClient, + personIds: string[], + partnerUserId: string | null, +): Promise { + const results = await Promise.allSettled( + personIds.map((id) => + client.mutation({ + updatePerson: { __args: { id, data: { partnerUserId } }, id: true }, + }), + ), + ); + return results.filter((result) => result.status === 'rejected').length; +} + +// On opportunity.updated when partnerId changes, propagate partnerUser (the assigned +// Partner's workspace member) across the deal so RLS visibility tracks the assignment: +// assign stamps it onto the Opportunity + linked Company + People; unassign clears it, +// keeping the Company/People if the same Partner still has another opportunity on that +// Company. Runs under the app identity, so its writes bypass partner RLS / field locks. +const handler = async ( + payload: DatabaseEventPayload>, +): Promise> => { + const { after, before, updatedFields } = payload.properties; + + if (!updatedFields?.includes('partnerId')) return {}; + const opportunityId = after?.id; + if (!opportunityId) return {}; + + const client = new CoreApiClient(); + const partnerId = after?.partnerId; + + // ── Unassign: the partner was removed from the opportunity ─────────────────── + if (!partnerId) { + // Resolve who/what to revoke from the event's before-image so a retry (after a partial + // failure below) still has these values even though the opportunity's own partnerUser is + // cleared first. The removed partnerId is the source of truth for "is the company still + // in use". Fall back to reading the not-yet-cleared opportunity if before is incomplete. + let removedMemberId = before?.partnerUserId ?? null; + let companyId = before?.companyId ?? null; + const removedPartnerId = before?.partnerId; + + if (!removedMemberId || !companyId) { + const oppResult = await client.query({ + opportunity: { + __args: { filter: { id: { eq: opportunityId } } }, + id: true, + partnerUserId: true, + companyId: true, + }, + }); + removedMemberId = removedMemberId ?? oppResult.opportunity?.partnerUserId ?? null; + companyId = companyId ?? oppResult.opportunity?.companyId ?? null; + } + + await client.mutation({ + updateOpportunity: { + __args: { id: opportunityId, data: { partnerUserId: null } }, + id: true, + }, + }); + + if (!removedMemberId || !companyId) { + return { cascaded: true, cleared: true }; + } + + // Keep the company (and its people) if the same Partner still has another opportunity on + // it. Decided on partnerId, not the derived partnerUser stamp (which a prior partial + // cascade may have left unset); fall back to the stamp only if the old partnerId is + // unavailable. The just-cleared opportunity no longer matches either filter. + const stillInUse = await client.query({ + opportunities: { + __args: { + filter: { + companyId: { eq: companyId }, + ...(removedPartnerId + ? { partnerId: { eq: removedPartnerId } } + : { partnerUserId: { eq: removedMemberId } }), + }, + first: 1, + }, + edges: { node: { id: true } }, + }, + }); + if ((stillInUse.opportunities?.edges?.length ?? 0) > 0) { + return { cascaded: true, cleared: true, companyKept: true }; + } + + // Clear the company (only if it belongs to this member) and every person stamped for them. + const companyResult = await client.query({ + company: { + __args: { filter: { id: { eq: companyId } } }, + id: true, + partnerUserId: true, + }, + }); + if (companyResult.company?.partnerUserId === removedMemberId) { + await client.mutation({ + updateCompany: { + __args: { id: companyId, data: { partnerUserId: null } }, + id: true, + }, + }); + } + + const peopleIds = await collectPeopleIds(client, { + companyId: { eq: companyId }, + partnerUserId: { eq: removedMemberId }, + }); + const failed = await setPeoplePartnerUser(client, peopleIds, null); + if (failed > 0) { + throw new Error( + `on-opportunity-partner-assigned: ${failed} person clear(s) failed — retrying`, + ); + } + return { cascaded: true, cleared: true, companyCleared: true }; + } + + // ── Assign / reassign ──────────────────────────────────────────────────────── + const partnerResult = await client.query({ + partner: { __args: { filter: { id: { eq: partnerId } } }, id: true, partnerUserId: true }, + }); + const partnerUserId = partnerResult.partner?.partnerUserId; + if (!partnerUserId) return { cascaded: false, reason: 'partner_has_no_user' }; + + await client.mutation({ + updateOpportunity: { __args: { id: opportunityId, data: { partnerUserId } }, id: true }, + }); + + const companyId = after?.companyId; + if (!companyId) return { cascaded: true, partnerUserId }; + + // Don't clobber a company already owned by a DIFFERENT partner member. The single + // partnerUser column on Company/Person models one owner per company, so reassigning it + // here would steal the company (and its contacts) from the other partner and expose their + // data. Stamp only the opportunity in that case and leave the company/people alone. + const companyResult = await client.query({ + company: { + __args: { filter: { id: { eq: companyId } } }, + id: true, + partnerUserId: true, + }, + }); + const companyOwner = companyResult.company?.partnerUserId; + if (companyOwner && companyOwner !== partnerUserId) { + return { cascaded: true, partnerUserId, companyShared: true }; + } + + await client.mutation({ + updateCompany: { __args: { id: companyId, data: { partnerUserId } }, id: true }, + }); + + const peopleIds = await collectPeopleIds(client, { companyId: { eq: companyId } }); + const failed = await setPeoplePartnerUser(client, peopleIds, partnerUserId); + if (failed > 0) { + throw new Error( + `on-opportunity-partner-assigned: ${failed} person stamp(s) failed — retrying`, + ); + } + + return { cascaded: true, partnerUserId }; +}; + +export default defineLogicFunction({ + universalIdentifier: ON_OPP_PARTNER_ASSIGNED_FN_UNIVERSAL_IDENTIFIER, + name: 'on-opportunity-partner-assigned', + timeoutSeconds: 15, + handler, + databaseEventTriggerSettings: { eventName: 'opportunity.updated' }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/roles/partner.role.ts b/packages/twenty-apps/internal/twenty-partners/src/roles/partner.role.ts index c4c193d20fe4e..96a14267aa034 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/roles/partner.role.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/roles/partner.role.ts @@ -1,18 +1,41 @@ import { STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineRole } from 'twenty-sdk/define'; import { + INTRO_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER, + MATCH_STATUS_FIELD_UNIVERSAL_IDENTIFIER, PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, PARTNER_ROLE_UNIVERSAL_IDENTIFIER, } from 'src/constants/universal-identifiers'; +import { OPPORTUNITY_DESIGN_DOC_STATUS_FIELD_ID } from 'src/fields/opportunity-design-doc-status.field'; +import { OPPORTUNITY_DESIGN_DOC_URL_FIELD_ID } from 'src/fields/opportunity-design-doc-url.field'; +import { OPPORTUNITY_HOSTING_TYPE_FIELD_ID } from 'src/fields/opportunity-hosting-type.field'; +import { OPPORTUNITY_LOST_REASON_FIELD_ID } from 'src/fields/opportunity-lost-reason.field'; +import { OPPORTUNITY_NUMBER_OF_SEATS_FIELD_ID } from 'src/fields/opportunity-number-of-seats.field'; +import { OPPORTUNITY_SUBSCRIPTION_FREQUENCY_FIELD_ID } from 'src/fields/opportunity-subscription-frequency.field'; +import { OPPORTUNITY_SUBSCRIPTION_TYPE_FIELD_ID } from 'src/fields/opportunity-subscription-type.field'; +import { OPPORTUNITY_TFT_ID_FIELD_ID } from 'src/fields/opportunity-tft-id.field'; +import { OPPORTUNITY_USE_CASE_FIELD_ID } from 'src/fields/opportunity-use-case.field'; +import { PARTNER_ON_OPPORTUNITY_FIELD_ID } from 'src/fields/partner-on-opportunity.field'; +import { PARTNER_USER_ON_OPPORTUNITY_FIELD_ID } from 'src/fields/partner-user-on-opportunity.field'; -// PLACEHOLDER role for external partners. Twenty has no row-level filtering yet, -// so anyone assigned this role currently sees every record — do not hand out -// until RLP ships and we can scope to records owned by the assigned user. +// Shared with configure-partner-rls.ts, which locates the role by this label. +export const PARTNER_ROLE_LABEL = 'Partner'; + +// External partner self-service role: a partner sees only its own records, can edit its +// own Partner profile and an Opportunity's stage + amount; Company/Person are read-only. +// Row-level predicates can't ship in the manifest, so run `yarn rls:configure` after install. +// +// `updatedBy` and `position` must stay editable even though they're not partner-facing: the +// server injects `updatedBy` into every update (ActorFromAuthContextService) and co-writes +// `position` with `stage` on a kanban drag, so locking either makes ALL opportunity updates +// fail with PERMISSION_DENIED. The server overwrites both regardless, so there's nothing to +// protect. `createdBy` stays locked (not injected on update); `searchVector` is a generated +// column, so its lock is an inert no-op. export default defineRole({ universalIdentifier: PARTNER_ROLE_UNIVERSAL_IDENTIFIER, - label: 'Partner', + label: PARTNER_ROLE_LABEL, description: - 'PLACEHOLDER. External partner self-service role. Sees ALL Partner/Opportunity records today because Twenty does not yet support row-level record filtering. When RLP ships, scope these permissions to records owned by the assigned user. DO NOT assign to real external partners until then.', + 'External partner self-service role. Sees only its own Partner/Person/Company/Opportunity records (row-level). Can edit its own Partner profile and an Opportunity’s stage + amount; Company and Person are read-only. Configure predicates with `yarn rls:configure` after install.', icon: 'IconBuildingStore', canBeAssignedToUsers: true, canUpdateAllSettings: false, @@ -20,6 +43,177 @@ export default defineRole({ canUpdateAllObjectRecords: false, canSoftDeleteAllObjectRecords: false, canDestroyAllObjectRecords: false, + // Lock every Opportunity field except stage/amount (editable) and the system/ + // server-managed fields left out here (id, timestamps, updatedBy, position — see header). + fieldPermissions: [ + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields.name + .universalIdentifier, + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields.closeDate + .universalIdentifier, + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields.createdBy + .universalIdentifier, + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields.pointOfContact + .universalIdentifier, + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields.company + .universalIdentifier, + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields.owner + .universalIdentifier, + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields.taskTargets + .universalIdentifier, + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields.noteTargets + .universalIdentifier, + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields.attachments + .universalIdentifier, + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields + .timelineActivities.universalIdentifier, + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields.searchVector + .universalIdentifier, + canUpdateFieldValue: false, + }, + // App-specific Opportunity fields (added by this app, also read-only for Partner). + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: PARTNER_ON_OPPORTUNITY_FIELD_ID, + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: PARTNER_USER_ON_OPPORTUNITY_FIELD_ID, + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: MATCH_STATUS_FIELD_UNIVERSAL_IDENTIFIER, + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: INTRO_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER, + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: OPPORTUNITY_USE_CASE_FIELD_ID, + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: OPPORTUNITY_TFT_ID_FIELD_ID, + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: OPPORTUNITY_DESIGN_DOC_URL_FIELD_ID, + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: OPPORTUNITY_SUBSCRIPTION_FREQUENCY_FIELD_ID, + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: OPPORTUNITY_HOSTING_TYPE_FIELD_ID, + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: OPPORTUNITY_LOST_REASON_FIELD_ID, + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: OPPORTUNITY_NUMBER_OF_SEATS_FIELD_ID, + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: OPPORTUNITY_SUBSCRIPTION_TYPE_FIELD_ID, + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: OPPORTUNITY_DESIGN_DOC_STATUS_FIELD_ID, + canUpdateFieldValue: false, + }, + ], objectPermissions: [ { objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, @@ -32,7 +226,7 @@ export default defineRole({ objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, canReadObjectRecords: true, - canUpdateObjectRecords: false, + canUpdateObjectRecords: true, canSoftDeleteObjectRecords: false, canDestroyObjectRecords: false, }, @@ -52,5 +246,16 @@ export default defineRole({ canSoftDeleteObjectRecords: false, canDestroyObjectRecords: false, }, + { + // Read-only so the UI can resolve member-typed relations (own partnerUser link, + // owner/createdBy). An RLS predicate scopes this to the partner's own member record + // (see scripts/configure-partner-rls.ts) so the internal roster stays hidden. + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier, + canReadObjectRecords: true, + canUpdateObjectRecords: false, + canSoftDeleteObjectRecords: false, + canDestroyObjectRecords: false, + }, ], }); diff --git a/packages/twenty-apps/internal/twenty-partners/src/scripts/configure-partner-rls.ts b/packages/twenty-apps/internal/twenty-partners/src/scripts/configure-partner-rls.ts new file mode 100644 index 0000000000000..b2a472751b734 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/scripts/configure-partner-rls.ts @@ -0,0 +1,484 @@ +// Idempotent. Re-run after every install/reinstall — the app manifest can't ship RLS +// predicates. Does two things: +// +// 1. Upserts row-level-permission predicates on the Partner role: "partnerUser IS the +// current member" on each of partner/person/company/opportunity, plus "id IS the +// current member" on workspaceMember so the partner sees only its own member record +// (member-typed relations still resolve; the internal roster stays hidden). +// +// 2. Verifies (does NOT set) the Opportunity field permissions from `partner.role.ts`. +// upsertFieldPermissions rejects out-of-band changes to app-owned roles +// (ROLE_BELONGS_TO_ANOTHER_APPLICATION), so those must come from the manifest; if any +// expected lock is missing, the script exits non-zero and tells you to re-sync. +// +// Usage: +// yarn rls:configure # against .env.local +// yarn rls:configure:prod # against .env.prod + +import { config } from 'dotenv'; +config({ path: process.env.ENV_FILE ?? '.env.local' }); + +import { PARTNER_ROLE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; +import { PARTNER_ROLE_LABEL } from 'src/roles/partner.role'; + +const requireEnv = (name: string): string => { + const value = process.env[name]; + if (!value) throw new Error(`Missing env var: ${name}`); + return value; +}; + +const TARGET_OBJECTS = ['partner', 'person', 'company', 'opportunity'] as const; +type TargetObject = (typeof TARGET_OBJECTS)[number]; + +// Opportunity fields that must NOT be locked: system columns, the editable business +// fields (stage, amount), and updatedBy/position (server-managed — locking them breaks +// every update; see src/roles/partner.role.ts). Everything else is expected to be locked. +const OPPORTUNITY_FIELD_LOCK_SKIP = new Set([ + 'id', + 'createdAt', + 'updatedAt', + 'deletedAt', + 'updatedBy', + 'position', + 'stage', + 'amount', +]); + +type ObjectInfo = { + objectMetadataId: string; + partnerUserFieldMetadataId: string; +}; + +type FieldEdge = { + node: { + id: string; + name: string; + type: string; + }; +}; + +type PageInfo = { + hasNextPage: boolean; + endCursor: string | null; +}; + +type FieldsPage = { + edges: FieldEdge[]; + pageInfo: PageInfo; +}; + +type MetadataResponse = { + data: T; + errors?: { message: string }[]; +}; + +async function metadataFetch( + metadataUrl: string, + apiKey: string, + query: string, + variables?: Record, +): Promise { + const res = await fetch(metadataUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ query, variables }), + }); + + const json = (await res.json()) as MetadataResponse; + + if (json.errors && json.errors.length > 0) { + throw new Error( + `GraphQL errors: ${json.errors.map((e) => e.message).join('; ')}`, + ); + } + + return json.data; +} + +// Pages through an object's fields until it finds a field with the given name. +// Uses cursor-based pagination to avoid truncation on large objects (company/opportunity +// have >200 fields, so a single 200-cap request may miss partnerUser). +async function findFieldByName( + metadataUrl: string, + apiKey: string, + objectId: string, + objectName: string, + fieldName: string, +): Promise { + let after: string | null = null; + let page = 0; + + // eslint-disable-next-line no-constant-condition + while (true) { + page++; + const pagingArg = after + ? `paging:{first:200, after:"${after}"}` + : `paging:{first:200}`; + + const query = `{ + object(id: "${objectId}") { + fields(${pagingArg}) { + edges { node { id name type } } + pageInfo { hasNextPage endCursor } + } + } + }`; + + const data = await metadataFetch<{ + object: { fields: FieldsPage }; + }>(metadataUrl, apiKey, query); + + const match = data.object.fields.edges.find( + (e) => e.node.name === fieldName, + ); + + if (match) { + console.log( + ` [rls:configure] ${objectName}.${fieldName} found on page ${page} ` + + `(type=${match.node.type}, id=${match.node.id})`, + ); + return match.node.id; + } + + if (!data.object.fields.pageInfo.hasNextPage) { + // Print available fields to help diagnose a schema mismatch. + const names = data.object.fields.edges + .map((e) => e.node.name) + .join(', '); + throw new Error( + `Field "${fieldName}" not found on object "${objectName}" after ${page} page(s). ` + + `Last-page fields: ${names}`, + ); + } + + after = data.object.fields.pageInfo.endCursor; + } +} + +// Collects every field on an object across all cursor pages. +// Required for Opportunity which can grow; always paginate fully. +async function collectAllFields( + metadataUrl: string, + apiKey: string, + objectId: string, +): Promise<{ id: string; name: string; type: string }[]> { + const all: { id: string; name: string; type: string }[] = []; + let after: string | null = null; + + // eslint-disable-next-line no-constant-condition + while (true) { + const pagingArg = after + ? `paging:{first:200, after:"${after}"}` + : `paging:{first:200}`; + + const query = `{ + object(id: "${objectId}") { + fields(${pagingArg}) { + edges { node { id name type } } + pageInfo { hasNextPage endCursor } + } + } + }`; + + const data = await metadataFetch<{ + object: { fields: FieldsPage }; + }>(metadataUrl, apiKey, query); + + for (const edge of data.object.fields.edges) { + all.push(edge.node); + } + + if (!data.object.fields.pageInfo.hasNextPage) break; + after = data.object.fields.pageInfo.endCursor; + } + + return all; +} + +type FieldPermissionResult = { + id: string; + objectMetadataId: string; + fieldMetadataId: string; + canReadFieldValue: boolean | null; + canUpdateFieldValue: boolean | null; +}; + +type PredicateResult = { + id: string; + fieldMetadataId: string; + objectMetadataId: string; + operand: string; + workspaceMemberFieldMetadataId: string | null; + roleId: string; +}; + +async function main() { + const baseUrl = requireEnv('TWENTY_PARTNERS_API_URL').replace(/\/$/, ''); + const apiKey = requireEnv('TWENTY_PARTNERS_API_KEY'); + const metadataUrl = `${baseUrl}/metadata`; + + console.log(`[rls:configure] target: ${metadataUrl}`); + + // ── 1. Resolve all object metadata IDs and their partnerUser field IDs ────── + + const objectsData = await metadataFetch<{ + objects: { edges: { node: { id: string; nameSingular: string } }[] }; + }>( + metadataUrl, + apiKey, + `{ objects(paging:{first:100}) { edges { node { id nameSingular } } } }`, + ); + + const objectIdByName = new Map( + objectsData.objects.edges.map((e) => [e.node.nameSingular, e.node.id]), + ); + + for (const name of TARGET_OBJECTS) { + if (!objectIdByName.has(name)) { + throw new Error( + `Object "${name}" not found in workspace metadata. ` + + `Has the app been installed and synced?`, + ); + } + } + + if (!objectIdByName.has('workspaceMember')) { + throw new Error('workspaceMember object not found in workspace metadata.'); + } + + const workspaceMemberId = objectIdByName.get('workspaceMember') as string; + + const objectInfoByName = new Map(); + + for (const name of TARGET_OBJECTS) { + const objectId = objectIdByName.get(name) as string; + const partnerUserFieldMetadataId = await findFieldByName( + metadataUrl, + apiKey, + objectId, + name, + 'partnerUser', + ); + objectInfoByName.set(name, { + objectMetadataId: objectId, + partnerUserFieldMetadataId, + }); + } + + // ── 2. Resolve workspaceMember.id field metadata id ────────────────────────── + + const workspaceMemberIdFieldId = await findFieldByName( + metadataUrl, + apiKey, + workspaceMemberId, + 'workspaceMember', + 'id', + ); + + // ── 3. Resolve Partner role id and fetch field permissions in one request ────── + // + // getRoles returns a flat array (not a connection) and does NOT expose + // universalIdentifier, so we match on the role label via the shared PARTNER_ROLE_LABEL + // constant (exported from partner.role.ts) — a rename there can't desync this script. + // Fetching fieldPermissions here avoids a second getRoles call later in step 5. + const rolesData = await metadataFetch<{ + getRoles: { + id: string; + label: string; + fieldPermissions: FieldPermissionResult[]; + }[]; + }>( + metadataUrl, + apiKey, + `{ getRoles { id label fieldPermissions { id fieldMetadataId objectMetadataId canUpdateFieldValue canReadFieldValue } } }`, + ); + + const roles = rolesData.getRoles; + const partnerRole = roles.find((r) => r.label === PARTNER_ROLE_LABEL); + + if (!partnerRole) { + const labels = roles.map((r) => r.label).join(', '); + throw new Error( + `Partner role not found. Available roles: ${labels}. ` + + `Ensure the app is installed (universalIdentifier=${PARTNER_ROLE_UNIVERSAL_IDENTIFIER}).`, + ); + } + + console.log( + `[rls:configure] Partner role id: ${partnerRole.id} ` + + `(universalIdentifier in manifest: ${PARTNER_ROLE_UNIVERSAL_IDENTIFIER})`, + ); + + // ── 4. Upsert one predicate per object ─────────────────────────────────────── + // + // "the record's partnerUser relation IS the current workspace member". Operand must be IS, + // not CONTAINS: the upsert accepts CONTAINS but the RELATION query filter only allows + // IS / IS_NOT and throws "Unknown operand CONTAINS for RELATION filter" at query time. + // value stays null; workspaceMemberFieldMetadataId injects the current member at query time. + + const MUTATION = ` + mutation UpsertRLSPredicates($input: UpsertRowLevelPermissionPredicatesInput!) { + upsertRowLevelPermissionPredicates(input: $input) { + predicates { + id + fieldMetadataId + objectMetadataId + operand + workspaceMemberFieldMetadataId + roleId + } + } + } + `; + + const results: PredicateResult[] = []; + + for (const name of TARGET_OBJECTS) { + const info = objectInfoByName.get(name) as ObjectInfo; + + const data = await metadataFetch<{ + upsertRowLevelPermissionPredicates: { + predicates: PredicateResult[]; + }; + }>(metadataUrl, apiKey, MUTATION, { + input: { + roleId: partnerRole.id, + objectMetadataId: info.objectMetadataId, + predicates: [ + { + fieldMetadataId: info.partnerUserFieldMetadataId, + operand: 'IS', + workspaceMemberFieldMetadataId: workspaceMemberIdFieldId, + }, + ], + predicateGroups: [], + }, + }); + + const predicate = + data.upsertRowLevelPermissionPredicates.predicates[0]; + + if (!predicate) { + throw new Error( + `upsertRowLevelPermissionPredicates returned no predicates for object "${name}"`, + ); + } + + results.push(predicate); + console.log( + `[rls:configure] ✓ ${name}: predicate id=${predicate.id} ` + + `(fieldMetadataId=${predicate.fieldMetadataId}, operand=${predicate.operand})`, + ); + } + + // workspaceMember predicate: "id IS the current member", scoping the role's read to the + // partner's own record. Other members (e.g. an opportunity's internal owner) resolve to null. + { + const wmData = await metadataFetch<{ + upsertRowLevelPermissionPredicates: { predicates: PredicateResult[] }; + }>(metadataUrl, apiKey, MUTATION, { + input: { + roleId: partnerRole.id, + objectMetadataId: workspaceMemberId, + predicates: [ + { + fieldMetadataId: workspaceMemberIdFieldId, + operand: 'IS', + workspaceMemberFieldMetadataId: workspaceMemberIdFieldId, + }, + ], + predicateGroups: [], + }, + }); + + const wmPredicate = + wmData.upsertRowLevelPermissionPredicates.predicates[0]; + + if (!wmPredicate) { + throw new Error( + 'upsertRowLevelPermissionPredicates returned no predicate for workspaceMember', + ); + } + + results.push(wmPredicate); + console.log( + `[rls:configure] ✓ workspaceMember: predicate id=${wmPredicate.id} ` + + `(fieldMetadataId=${wmPredicate.fieldMetadataId}, operand=${wmPredicate.operand})`, + ); + } + + console.log( + `\n[rls:configure] Done — ${results.length} predicates upserted on Partner role ` + + `(${TARGET_OBJECTS.length} objects + workspaceMember self-scope)`, + ); + + // ── 5. Verify Opportunity field permissions (set via manifest, not here — see header) ─ + // Read back the role's existing fieldPermissions so one run confirms the full state. + + const oppInfo = objectInfoByName.get('opportunity') as ObjectInfo; + const oppObjectId = oppInfo.objectMetadataId; + + const allOppFields = await collectAllFields(metadataUrl, apiKey, oppObjectId); + const oppFieldIdToName = new Map( + allOppFields.map((f) => [f.id, f.name]), + ); + + // Build the expected lock set: every non-system, non-stage Opportunity field. + const expectedLockedNames = new Set( + allOppFields + .filter((f) => !OPPORTUNITY_FIELD_LOCK_SKIP.has(f.name)) + .map((f) => f.name), + ); + + // Filter to Opportunity field permissions that lock update access. + // partnerRole was fetched with fieldPermissions in step 3 — no second getRoles needed. + const oppLockedFps = partnerRole.fieldPermissions.filter( + (fp) => + fp.objectMetadataId === oppObjectId && fp.canUpdateFieldValue === false, + ); + + const missingLocks = [...expectedLockedNames].filter( + (name) => + !oppLockedFps.some( + (fp) => oppFieldIdToName.get(fp.fieldMetadataId) === name, + ), + ); + + const stageIsLocked = oppLockedFps.some( + (fp) => oppFieldIdToName.get(fp.fieldMetadataId) === 'stage', + ); + + if (stageIsLocked) { + console.warn( + `[rls:configure] WARNING: stage field is locked — it should be editable. ` + + `Remove it from fieldPermissions in partner.role.ts and re-sync.`, + ); + } + + if (missingLocks.length > 0) { + console.error( + `\n[rls:configure] DRIFT DETECTED: ${missingLocks.length} Opportunity field(s) ` + + `are NOT locked (canUpdateFieldValue: false) on the Partner role:\n` + + ` ${missingLocks.join(', ')}\n\n` + + `These permissions are declared in partner.role.ts and must be deployed via the\n` + + `app manifest. Run the following to deploy them:\n\n` + + ` yarn twenty dev --once -r \n\n` + + `(e.g. \`yarn twenty dev --once\` for local, ` + + `\`yarn twenty dev --once -r partner-twenty-com\` for prod)\n`, + ); + process.exitCode = 1; + return; + } + + console.log( + `[rls:configure] ✓ ${oppLockedFps.length} Opportunity fields locked (stage + amount editable) — field permissions verified`, + ); +} + +main().catch((err: unknown) => { + console.error('[rls:configure] FAILED:', err); + process.exit(1); +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/views/all-partners.view.ts b/packages/twenty-apps/internal/twenty-partners/src/views/all-partners.view.ts index 0696ae21b1c67..aa904be347773 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/views/all-partners.view.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/views/all-partners.view.ts @@ -4,6 +4,7 @@ import { ALL_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER, PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, } from 'src/constants/universal-identifiers'; +import { PARTNER_USER_ON_PARTNER_FIELD_ID } from 'src/fields/partner-user-on-partner.field'; // Every partner, all stages — kept last in the Partners folder. No Last Match column. export default defineView({ @@ -25,5 +26,7 @@ export default defineView({ { universalIdentifier: '52408b5f-5e13-4e3c-af2d-ce50033ec126', fieldMetadataUniversalIdentifier: '560503de-6330-4c1d-af97-a8dee125f2ad', position: 4, isVisible: true }, // Categories (partnerScope) { universalIdentifier: '34d58668-a689-42e2-9aff-3fee315092d6', fieldMetadataUniversalIdentifier: '500021ad-ca42-4fd3-8727-392dd26b722a', position: 5, isVisible: true, size: 260 }, + // partnerUser: the member this partner logs in as — prerequisite for the cascade + RLS visibility. + { universalIdentifier: '19d1fc62-2486-4f59-8e77-b4ee177481b1', fieldMetadataUniversalIdentifier: PARTNER_USER_ON_PARTNER_FIELD_ID, position: 6, isVisible: true }, ], }); diff --git a/packages/twenty-apps/internal/twenty-partners/src/views/validated-partners.view.ts b/packages/twenty-apps/internal/twenty-partners/src/views/validated-partners.view.ts index 999d9332836b8..7f14fd78d956c 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/views/validated-partners.view.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/views/validated-partners.view.ts @@ -4,6 +4,7 @@ import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, VALIDATED_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER, } from 'src/constants/universal-identifiers'; +import { PARTNER_USER_ON_PARTNER_FIELD_ID } from 'src/fields/partner-user-on-partner.field'; // Validated partners — serves both partner intros and the public website list. // Columns mirror the Applications view (for consistent reading) plus Partner Tier; @@ -38,8 +39,10 @@ export default defineView({ { universalIdentifier: '75d5dad8-259f-4911-9607-06f247410329', fieldMetadataUniversalIdentifier: 'a77d7fa6-c398-47db-af0f-036a5c719f20', position: 5, isVisible: true }, // LinkedIn { universalIdentifier: '72390fd5-b148-4074-b41d-c64bc28097a6', fieldMetadataUniversalIdentifier: '640bbf33-45d7-4174-a862-dbe611ab8d1a', position: 6, isVisible: true }, - // Type of Team (kept last) + // Type of Team { universalIdentifier: '300ea008-d311-4375-b8eb-74e8731c52cc', fieldMetadataUniversalIdentifier: 'a451e557-a488-470a-8b35-6f9b8cfb1a10', position: 7, isVisible: true }, + // partnerUser: the login member this partner signs in as (set inline here, like setting an owner). + { universalIdentifier: '9e06eeaa-649d-412a-aacd-0f8bf7ee69c3', fieldMetadataUniversalIdentifier: PARTNER_USER_ON_PARTNER_FIELD_ID, position: 8, isVisible: true }, ], filters: [ {