diff --git a/packages/twenty-apps/internal/twenty-partners/package.json b/packages/twenty-apps/internal/twenty-partners/package.json index 0c6eccc280..baaef5531a 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": "1.4.0", + "version": "1.5.1", "license": "MIT", "engines": { "node": "^24.5.0", diff --git a/packages/twenty-apps/internal/twenty-partners/src/application-config.ts b/packages/twenty-apps/internal/twenty-partners/src/application-config.ts index 6439bb5d16..04ade26575 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/application-config.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/application-config.ts @@ -20,13 +20,13 @@ export default defineApplication({ DISCORD_WEBHOOK_URL: { universalIdentifier: '7056c98a-e7e1-4dba-8a40-b578f30b3479', description: - 'Discord incoming webhook URL. When set, a notification is posted to this channel each time the application form creates a new Partner. Leave empty to disable. Set per-workspace in Settings → Apps → Twenty Partners → Variables.', + 'Discord incoming webhook URL. When set, a notification is posted to this channel each time the application form creates a new Partner, and each time the public marketplace form submits a client brief (brief details, contact name and company, and the referring partner). Leave empty to disable both. Set per-workspace in Settings → Apps → Twenty Partners → Variables.', isSecret: true, }, PARTNER_APP_FRONTEND_URL: { universalIdentifier: '746e7bd8-8934-414e-95f5-cc266a624616', description: - 'Workspace front-end base URL (e.g. https://partners.twenty.com), used to build the clickable Partner record link in the Discord notification. Set per-workspace in Settings → Apps → Twenty Partners → Variables.', + 'Workspace front-end base URL (e.g. https://partners.twenty.com), used to build the clickable Partner and Opportunity record links in Discord notifications. When empty, notifications are still sent but carry no links. Set per-workspace in Settings → Apps → Twenty Partners → Variables.', isSecret: false, }, }, diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/referred-by-partner-on-opportunity.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/referred-by-partner-on-opportunity.field.ts new file mode 100644 index 0000000000..1251ed54c2 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/referred-by-partner-on-opportunity.field.ts @@ -0,0 +1,25 @@ +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 REFERRED_BY_PARTNER_ON_OPPORTUNITY_FIELD_ID = '70fb45ca-8be9-418c-b8f8-ce91da3676d0'; +export const REFERRED_OPPORTUNITIES_ON_PARTNER_FIELD_ID = '85bd7d91-c38c-468f-bdb0-60883761ee24'; + +// Whose public profile page the brief was submitted from — not who works the deal +// (that is `partner`). +export default defineField({ + universalIdentifier: REFERRED_BY_PARTNER_ON_OPPORTUNITY_FIELD_ID, + objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + type: FieldType.RELATION, + name: 'referredByPartner', + label: 'Referred by', + icon: 'IconShare', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, + relationTargetFieldMetadataUniversalIdentifier: REFERRED_OPPORTUNITIES_ON_PARTNER_FIELD_ID, + universalSettings: { + relationType: RelationType.MANY_TO_ONE, + onDelete: OnDeleteAction.SET_NULL, + joinColumnName: 'referredByPartnerId', + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/referred-opportunities-on-partner.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/referred-opportunities-on-partner.field.ts new file mode 100644 index 0000000000..b85d23f892 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/referred-opportunities-on-partner.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 { REFERRED_BY_PARTNER_ON_OPPORTUNITY_FIELD_ID, REFERRED_OPPORTUNITIES_ON_PARTNER_FIELD_ID } from './referred-by-partner-on-opportunity.field'; + +export default defineField({ + universalIdentifier: REFERRED_OPPORTUNITIES_ON_PARTNER_FIELD_ID, + objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, + type: FieldType.RELATION, + name: 'referredOpportunities', + label: 'Referred opportunities', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + relationTargetFieldMetadataUniversalIdentifier: REFERRED_BY_PARTNER_ON_OPPORTUNITY_FIELD_ID, + universalSettings: { + relationType: RelationType.ONE_TO_MANY, + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/graphql/queries/find-partner-id-by-slug.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/graphql/queries/find-partner-id-by-slug.ts new file mode 100644 index 0000000000..dd524b1ea9 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/graphql/queries/find-partner-id-by-slug.ts @@ -0,0 +1,12 @@ +import type { CoreApiClient } from 'twenty-client-sdk/core'; + +// Slug-only: a partner who referred a brief stays the referrer even if they +// later go unavailable or lose validation. +export function findPartnerIdBySlug(client: CoreApiClient, slug: string) { + return client.query({ + partners: { + __args: { filter: { slug: { eq: slug } }, first: 1 }, + edges: { node: { id: true, name: true } }, + }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/mappers/brief-embed.mapper.test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/mappers/brief-embed.mapper.test.ts new file mode 100644 index 0000000000..852a1ff90f --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/mappers/brief-embed.mapper.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; + +import { buildBriefEmbed } from './brief-embed.mapper'; + +const input = { + firstName: 'Jane', + lastName: 'Smith', + email: 'jane@acme.com', + companyName: 'Northwind Ltd', + need: 'Migrate 40 seats off HubSpot', +}; + +const fieldNamed = (embed: Record, name: string) => + (embed.fields as { name: string; value: string }[]).find((f) => f.name === name); + +describe('buildBriefEmbed', () => { + it('puts the need in the description and links the record', () => { + const embed = buildBriefEmbed( + { opportunityId: 'opp-1', input, referringPartner: null }, + 'https://partners.twenty.com', + ); + expect(embed.title).toBe('New client brief'); + expect(embed.description).toBe('Migrate 40 seats off HubSpot'); + expect(embed.url).toBe('https://partners.twenty.com/object/opportunity/opp-1'); + }); + + it('links the referring partner record when one resolved', () => { + const embed = buildBriefEmbed( + { opportunityId: 'opp-1', input, referringPartner: { id: 'p-9', name: 'Acme Consulting' } }, + 'https://partners.twenty.com', + ); + expect(fieldNamed(embed, 'Referred by')?.value).toBe( + '[Acme Consulting](https://partners.twenty.com/object/partner/p-9)', + ); + }); + + it('falls back to the listing label when no partner referred it', () => { + const embed = buildBriefEmbed( + { opportunityId: 'opp-1', input, referringPartner: null }, + 'https://partners.twenty.com', + ); + expect(fieldNamed(embed, 'Referred by')?.value).toBe('Marketplace listing'); + }); + + it('degrades the partner link to plain text when no frontend url is set', () => { + const embed = buildBriefEmbed( + { opportunityId: 'opp-1', input, referringPartner: { id: 'p-9', name: 'Acme Consulting' } }, + undefined, + ); + expect(fieldNamed(embed, 'Referred by')?.value).toBe('Acme Consulting'); + expect(embed.url).toBeUndefined(); + }); + + it('never emits the submitter email', () => { + const embed = buildBriefEmbed( + { opportunityId: 'opp-1', input, referringPartner: null }, + 'https://partners.twenty.com', + ); + expect(JSON.stringify(embed)).not.toContain('jane@acme.com'); + }); + + it('truncates a long need and a long requirements block', () => { + const embed = buildBriefEmbed( + { + opportunityId: 'opp-1', + input: { ...input, need: 'n'.repeat(900), requirements: 'r'.repeat(900) }, + referringPartner: null, + }, + 'https://partners.twenty.com', + ); + expect((embed.description as string).length).toBe(600); + expect((embed.description as string).endsWith('…')).toBe(true); + expect(fieldNamed(embed, 'Requirements')?.value.length).toBe(300); + }); + + it('truncates oversized inline values from the public form', () => { + const embed = buildBriefEmbed( + { + opportunityId: 'opp-1', + input: { ...input, country: 'c'.repeat(2000), companyName: 'x'.repeat(2000) }, + referringPartner: { id: 'p-9', name: 'n'.repeat(2000) }, + }, + 'https://partners.twenty.com', + ); + const values = (embed.fields as { value: string }[]).map((f) => f.value); + expect(Math.max(...values.map((v) => v.length))).toBeLessThanOrEqual(1024); + expect(fieldNamed(embed, 'Country')?.value.length).toBe(256); + expect(fieldNamed(embed, 'Company')?.value.length).toBe(256); + }); + + it('omits absent optional fields', () => { + const embed = buildBriefEmbed( + { opportunityId: 'opp-1', input, referringPartner: null }, + 'https://partners.twenty.com', + ); + expect(fieldNamed(embed, 'Hosting')).toBeUndefined(); + expect(fieldNamed(embed, 'Budget')).toBeUndefined(); + expect(fieldNamed(embed, 'Requirements')).toBeUndefined(); + }); + + it('pads each inline row to three columns so rows never reflow', () => { + const embed = buildBriefEmbed( + { opportunityId: 'opp-1', input: { ...input, seatCount: '40' }, referringPartner: null }, + 'https://partners.twenty.com', + ); + const inlineRun = (embed.fields as { inline?: boolean }[]).filter((f) => f.inline === true); + expect(inlineRun.length % 3).toBe(0); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/mappers/brief-embed.mapper.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/mappers/brief-embed.mapper.ts new file mode 100644 index 0000000000..5feeaf7950 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/mappers/brief-embed.mapper.ts @@ -0,0 +1,100 @@ +import { TWENTY_BLUE } from 'src/modules/shared/connector/discord/config'; +import { type DiscordField } from 'src/modules/shared/connector/discord/types'; +import { type SubmitClientBriefInput } from 'src/modules/opportunity/intake/mappers/build-requirements-text.mapper'; +import { type ReferringPartner } from 'src/modules/opportunity/intake/services/submit-client-brief.service'; +import { isNonEmptyString } from 'src/modules/shared/utils/is-non-empty-string.util'; + +export type BriefForEmbed = { + opportunityId: string; + input: SubmitClientBriefInput; + referringPartner: ReferringPartner | null; +}; + +const NEED_MAX = 600; +const REQUIREMENTS_MAX = 300; +const INLINE_MAX = 256; +const PARTNER_NAME_MAX = 100; +const NO_PARTNER_LABEL = 'Marketplace listing'; +const SPACER: DiscordField = { name: '​', value: '​', inline: true }; + +const HOSTING_LABEL: Record<'CLOUD' | 'SELF_HOSTING', string> = { + CLOUD: 'Cloud', + SELF_HOSTING: 'Self-hosting', +}; + +const truncate = (value: string, max: number): string => + value.length <= max ? value : `${value.slice(0, max - 1)}…`; + +const trimTrailingSlash = (url: string): string => url.replace(/\/+$/, ''); + +// Discord packs three inline fields per row, so a group with fewer than three +// would pull the next group's fields up into its row. Pad every group to three. +const pushInlineRow = (target: DiscordField[], row: DiscordField[]): void => { + if (row.length === 0) return; + target.push(...row); + for (let index = row.length; index < 3; index += 1) target.push(SPACER); +}; + +// Every inline value comes from the unbounded public brief form; Discord rejects +// the whole payload above 1024 chars per field, which the caller then swallows. +const inlineField = (name: string, value: string | undefined | null): DiscordField[] => + isNonEmptyString(value) + ? [{ name, value: truncate(value.trim(), INLINE_MAX), inline: true }] + : []; + +const buildReferredBy = ( + partner: ReferringPartner | null, + baseUrl: string | null, +): string => { + if (partner === null) return NO_PARTNER_LABEL; + const name = truncate(partner.name, PARTNER_NAME_MAX); + return baseUrl === null ? name : `[${name}](${baseUrl}/object/partner/${partner.id})`; +}; + +export function buildBriefEmbed( + brief: BriefForEmbed, + frontendUrl: string | undefined, +): Record { + const { input, referringPartner } = brief; + const baseUrl = isNonEmptyString(frontendUrl) ? trimTrailingSlash(frontendUrl) : null; + const fields: DiscordField[] = []; + + fields.push({ name: 'Referred by', value: buildReferredBy(referringPartner, baseUrl) }); + + const contact = [input.firstName, input.lastName].filter(isNonEmptyString).join(' ').trim(); + pushInlineRow(fields, [ + ...inlineField('Contact', contact), + ...inlineField('Company', input.companyName), + ]); + + pushInlineRow(fields, [ + ...inlineField('Hosting', input.hostingType && HOSTING_LABEL[input.hostingType]), + ...inlineField('Seats', input.seatCount), + ...inlineField('Country', input.country), + ]); + + pushInlineRow(fields, [ + ...inlineField('Languages', input.languages?.join(', ')), + ...inlineField('Timeline', input.timeline), + ...inlineField('Budget', input.budgetRange), + ]); + + if (isNonEmptyString(input.requirements)) { + fields.push({ + name: 'Requirements', + value: truncate(input.requirements.trim(), REQUIREMENTS_MAX), + }); + } + + const embed: Record = { + title: 'New client brief', + description: truncate(input.need.trim(), NEED_MAX), + color: TWENTY_BLUE, + timestamp: new Date().toISOString(), + fields, + }; + if (baseUrl !== null) { + embed.url = `${baseUrl}/object/opportunity/${brief.opportunityId}`; + } + return embed; +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/mappers/build-requirements-text.mapper.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/mappers/build-requirements-text.mapper.ts index 84233aba62..72f5dccf0a 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/mappers/build-requirements-text.mapper.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/mappers/build-requirements-text.mapper.ts @@ -15,6 +15,7 @@ export const submitClientBriefSchema = z.object({ seatCount: z.string().optional(), timeline: z.string().optional(), budgetRange: z.string().optional(), + partnerSlug: z.string().trim().regex(/^[a-z0-9-]+$/).max(100).optional(), }); export type SubmitClientBriefInput = z.infer; diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/services/notify-client-brief.service.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/services/notify-client-brief.service.ts new file mode 100644 index 0000000000..12608f52de --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/services/notify-client-brief.service.ts @@ -0,0 +1,20 @@ +import { DISCORD_WEBHOOK_ENV_VAR } from 'src/modules/shared/connector/discord/config'; +import { postWebhook } from 'src/modules/shared/connector/discord/discord.connector'; +import { buildBriefEmbed, type BriefForEmbed } from 'src/modules/opportunity/intake/mappers/brief-embed.mapper'; +import { isNonEmptyString } from 'src/modules/shared/utils/is-non-empty-string.util'; + +// Tighter than the trigger-driven default: this runs inside the visitor's +// request, so a hung webhook must not eat the function's 15s budget. +const BRIEF_WEBHOOK_TIMEOUT_MS = 3000; + +export async function notifyClientBrief(brief: BriefForEmbed): Promise { + const webhookUrl = process.env[DISCORD_WEBHOOK_ENV_VAR]; + if (!isNonEmptyString(webhookUrl)) return; + + try { + const embed = buildBriefEmbed(brief, process.env.PARTNER_APP_FRONTEND_URL); + await postWebhook(webhookUrl, { embeds: [embed] }, 'submit-client-brief', BRIEF_WEBHOOK_TIMEOUT_MS); + } catch { + // Best-effort: a Discord failure must never fail a submitted brief. + } +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/services/submit-client-brief.service.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/services/submit-client-brief.service.ts index 096f81b7b6..b2ad9362b4 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/services/submit-client-brief.service.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/services/submit-client-brief.service.ts @@ -5,15 +5,33 @@ import { findOrCreatePersonByEmail, } from 'src/modules/shared/services/find-or-create-company-and-person.service'; import { createOpportunity } from 'src/modules/opportunity/intake/graphql/mutations/create-opportunity'; +import { findPartnerIdBySlug } from 'src/modules/opportunity/intake/graphql/queries/find-partner-id-by-slug'; import { buildRequirementsText, type SubmitClientBriefInput, } from 'src/modules/opportunity/intake/mappers/build-requirements-text.mapper'; +import { notifyClientBrief } from 'src/modules/opportunity/intake/services/notify-client-brief.service'; export type SubmitClientBriefResult = | { ok: true; opportunityId: string } | { ok: false; reason: string }; +export type ReferringPartner = { id: string; name: string }; + +async function resolveReferringPartner( + client: CoreApiClient, + slug: string | undefined, +): Promise { + if (slug === undefined) return null; + const result = await findPartnerIdBySlug(client, slug); + const node = result.partners?.edges?.[0]?.node; + if (node === undefined) { + console.warn(`submit-client-brief: no partner for slug "${slug}"`); + return null; + } + return { id: node.id, name: node.name }; +} + export async function submitClientBrief( input: SubmitClientBriefInput, ): Promise { @@ -29,6 +47,7 @@ export async function submitClientBrief( lastName: input.lastName, companyId, }); + const referringPartner = await resolveReferringPartner(client, input.partnerSlug); const opportunityData: CoreSchema.OpportunityCreateInput = { name, @@ -41,6 +60,9 @@ export async function submitClientBrief( if (requirements !== null) { opportunityData.requirements = requirements; } + if (referringPartner !== null) { + opportunityData.referredByPartnerId = referringPartner.id; + } const result = await createOpportunity(client, opportunityData); const opportunityId = result.createOpportunity?.id; @@ -48,6 +70,8 @@ export async function submitClientBrief( throw new Error('createOpportunity did not return an id'); } + await notifyClientBrief({ opportunityId, input, referringPartner }); + return { ok: true, opportunityId }; } catch (err) { return { ok: false, reason: err instanceof Error ? err.message : String(err) }; diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/submit-client-brief.test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/submit-client-brief.test.ts index b252a916b6..f4d835142c 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/submit-client-brief.test.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/submit-client-brief.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { buildRequirementsText } from './mappers/build-requirements-text.mapper'; +import { + buildRequirementsText, + submitClientBriefSchema, +} from './mappers/build-requirements-text.mapper'; const base = { firstName: 'Jane', @@ -42,3 +45,33 @@ describe('buildRequirementsText', () => { expect(text).not.toContain('• Seats:'); }); }); + +describe('submitClientBriefSchema partnerSlug', () => { + it('accepts a well-formed slug', () => { + expect( + submitClientBriefSchema.safeParse({ ...base, partnerSlug: 'acme-consulting' }).success, + ).toBe(true); + }); + + it('accepts a payload with no slug at all', () => { + expect(submitClientBriefSchema.safeParse(base).success).toBe(true); + }); + + it('rejects a slug with unsupported characters', () => { + expect( + submitClientBriefSchema.safeParse({ ...base, partnerSlug: 'Acme Consulting!' }).success, + ).toBe(false); + }); + + it('keeps partnerSlug out of the requirements text', () => { + const withoutSlug = buildRequirementsText({ ...base, requirements: 'French UI', seatCount: '~30' }); + const withSlug = buildRequirementsText({ + ...base, + requirements: 'French UI', + seatCount: '~30', + partnerSlug: 'acme-consulting', + }); + expect(withSlug).toBe(withoutSlug); + expect(withSlug).not.toContain('acme-consulting'); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/view-fields/opportunity-record-page-referred-by-partner.view-field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/view-fields/opportunity-record-page-referred-by-partner.view-field.ts new file mode 100644 index 0000000000..36ff682df2 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/view-fields/opportunity-record-page-referred-by-partner.view-field.ts @@ -0,0 +1,23 @@ +import { + defineViewField, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, +} from 'twenty-sdk/define'; + +import { REFERRED_BY_PARTNER_ON_OPPORTUNITY_FIELD_ID } from 'src/modules/opportunity/fields/referred-by-partner-on-opportunity.field'; + +export const OPPORTUNITY_RECORD_PAGE_REFERRED_BY_PARTNER_VIEW_FIELD_UNIVERSAL_IDENTIFIER = + '894a2b31-9c71-4365-aab1-27b805ac1bf8'; + +const OPPORTUNITY_RECORD_PAGE_FIELDS = + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.views.opportunityRecordPageFields; + +export default defineViewField({ + universalIdentifier: + OPPORTUNITY_RECORD_PAGE_REFERRED_BY_PARTNER_VIEW_FIELD_UNIVERSAL_IDENTIFIER, + viewUniversalIdentifier: OPPORTUNITY_RECORD_PAGE_FIELDS.universalIdentifier, + fieldMetadataUniversalIdentifier: REFERRED_BY_PARTNER_ON_OPPORTUNITY_FIELD_ID, + viewFieldGroupUniversalIdentifier: + OPPORTUNITY_RECORD_PAGE_FIELDS.viewFieldGroups.relations.universalIdentifier, + position: 5, + isVisible: true, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/mappers/application-embed.mapper.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/mappers/application-embed.mapper.ts index 09fc2377a9..aa13371803 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/mappers/application-embed.mapper.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/mappers/application-embed.mapper.ts @@ -1,6 +1,6 @@ import { isNonEmptyString } from 'src/modules/shared/utils/is-non-empty-string.util'; -import { TWENTY_BLUE } from 'src/modules/partner/application-intake/connector/discord/config'; -import { type DiscordField } from 'src/modules/partner/application-intake/connector/discord/types'; +import { TWENTY_BLUE } from 'src/modules/shared/connector/discord/config'; +import { type DiscordField } from 'src/modules/shared/connector/discord/types'; export type PartnerForEmbed = { id: string; diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/on-partner-application-created.logic-function.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/on-partner-application-created.logic-function.ts index e832399fa6..fb8cc886a2 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/on-partner-application-created.logic-function.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/on-partner-application-created.logic-function.ts @@ -6,7 +6,7 @@ import { } from 'twenty-sdk/define'; import { ON_PARTNER_APPLICATION_CREATED_FN_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; -import { DISCORD_WEBHOOK_ENV_VAR } from 'src/modules/partner/application-intake/connector/discord/config'; +import { DISCORD_WEBHOOK_ENV_VAR } from 'src/modules/shared/connector/discord/config'; import { notifyPartnerApplication } from 'src/modules/partner/application-intake/services/notify-partner-application.service'; import { isNonEmptyString } from 'src/modules/shared/utils/is-non-empty-string.util'; diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/services/notify-partner-application.service.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/services/notify-partner-application.service.ts index ce753691c9..97da93f1ed 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/services/notify-partner-application.service.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/services/notify-partner-application.service.ts @@ -1,6 +1,6 @@ import { CoreApiClient } from 'twenty-client-sdk/core'; -import { postWebhook } from 'src/modules/partner/application-intake/connector/discord/discord.connector'; +import { postWebhook } from 'src/modules/shared/connector/discord/discord.connector'; import { findPartnerForEmbed } from 'src/modules/partner/application-intake/graphql/queries/find-partner-for-embed'; import { buildApplicationEmbed, @@ -37,7 +37,11 @@ export async function notifyPartnerApplication( }; const embed = buildApplicationEmbed(partnerForEmbed, frontendUrl); - const delivered = await postWebhook(webhookUrl, { embeds: [embed] }); + const delivered = await postWebhook( + webhookUrl, + { embeds: [embed] }, + 'on-partner-application-created', + ); return { notified: delivered }; } catch { // Best-effort: a Discord failure must never fail the trigger. diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/connector/discord/config.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/shared/connector/discord/config.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/connector/discord/config.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/shared/connector/discord/config.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/connector/discord/discord.connector.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/shared/connector/discord/discord.connector.ts similarity index 67% rename from packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/connector/discord/discord.connector.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/shared/connector/discord/discord.connector.ts index 68721d678c..59a7358988 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/connector/discord/discord.connector.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/shared/connector/discord/discord.connector.ts @@ -1,14 +1,16 @@ -import { DISCORD_TIMEOUT_MS } from 'src/modules/partner/application-intake/connector/discord/config'; -import { type DiscordWebhookPayload } from 'src/modules/partner/application-intake/connector/discord/types'; +import { DISCORD_TIMEOUT_MS } from 'src/modules/shared/connector/discord/config'; +import { type DiscordWebhookPayload } from 'src/modules/shared/connector/discord/types'; // Pure outbound transport: POST the payload, honour a hard timeout, and report // delivery. Holds no decision logic — the caller decides whether to notify. export async function postWebhook( url: string, payload: DiscordWebhookPayload, + label: string, + timeoutMs: number = DISCORD_TIMEOUT_MS, ): Promise { const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), DISCORD_TIMEOUT_MS); + const timeout = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(url, { method: 'POST', @@ -20,9 +22,7 @@ export async function postWebhook( // non-2xx (dead webhook, rejected payload, rate limit) must be detected // explicitly — otherwise a failed post is reported as delivered. if (!response.ok) { - console.warn( - `on-partner-application-created: Discord webhook responded ${response.status}`, - ); + console.warn(`${label}: Discord webhook responded ${response.status}`); return false; } return true; diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/connector/discord/types.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/shared/connector/discord/types.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/connector/discord/types.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/shared/connector/discord/types.ts 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 76d2262ff5..ac3aa41531 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 @@ -50,6 +50,7 @@ import { PARTNER_USER_ON_PARTNER_CONTENT_FIELD_ID } from 'src/modules/partner/fi import { PARTNER_USER_ON_PARTNER_LINK_FIELD_ID } from 'src/modules/partner/fields/partner-user-on-partner-link.field'; import { PARTNER_USER_ON_PARTNER_FIELD_ID } from 'src/modules/partner/fields/partner-user-on-partner.field'; import { PARTNER_USER_ON_PARTNER_SERVICE_FIELD_ID } from 'src/modules/partner/fields/partner-user-on-partner-service.field'; +import { REFERRED_BY_PARTNER_ON_OPPORTUNITY_FIELD_ID } from 'src/modules/opportunity/fields/referred-by-partner-on-opportunity.field'; // Shared with configure-partner-rls.ts, which locates the role by this label. export const PARTNER_ROLE_LABEL = 'Partner'; @@ -195,6 +196,12 @@ export default defineRole({ fieldUniversalIdentifier: PARTNER_ON_OPPORTUNITY_FIELD_ID, canUpdateFieldValue: false, }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: REFERRED_BY_PARTNER_ON_OPPORTUNITY_FIELD_ID, + canUpdateFieldValue: false, + }, { objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,