From fea06bdd4b5e021344482d7576c23d9abcc620c2 Mon Sep 17 00:00:00 2001 From: Rashad Karanouh <11599358+rashad@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:42:23 +0200 Subject: [PATCH] =?UTF-8?q?v1.5.1=20=E2=80=94=20partners:=20Discord=20noti?= =?UTF-8?q?fication=20for=20client=20briefs=20+=20referring-partner=20attr?= =?UTF-8?q?ibution=20(#23344)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Merge after #23295.** Targets `main`, but must land second: #23295 bumps `1.3.2 → 1.4.0`, and this bumps `1.4.0 → 1.5.1`. Merging this first would leave `main` at 1.5.1 and make #23295's bump conflict and regress the version. `package.json` is the only file the two branches share. App version: **v1.5.1**. ## What this does Posts a Discord notification when a client brief is submitted through the public marketplace form, and records which partner's profile page the brief came from. A visitor can reach the brief form from the marketplace listing page or from a specific partner's profile. Until now that context was lost. This adds a `referredByPartner` relation on Opportunity so the attribution is a queryable CRM fact rather than a line in a chat message. ## How it works `submitClientBrief` resolves the incoming `partnerSlug` to a Partner, sets the relation on create, then posts the embed inline. Inline rather than an `opportunity.created` database trigger, because that event cannot distinguish a brief from a TFT import — both are created by logic functions and both carry `createdBy.source === 'APPLICATION'`. A trigger would need a discriminator like "source is APPLICATION and `tftOpportunityId` is empty", which silently breaks the day a third logic function creates an Opportunity. The cost of going inline is that the Discord call sits in the visitor's request, so it uses a 3s timeout rather than the trigger path's 8s, and every failure is swallowed — a dead webhook can never turn a submitted brief into a failed one. ## Notable decisions - **Slug resolution ignores `validationStage` and `availability`**, unlike the marketplace profile query. If someone submitted a brief from a partner's page, that partner referred it, even if they go unavailable a minute later. Filtering would silently drop real attribution. - **An unresolved slug never fails the brief.** It logs a warning, leaves the relation unset, and still notifies. A brief is a sales lead; losing one over an attribution field the visitor never saw would be a bad trade. - **`referredByPartner` is separate from the existing `partner` field.** One is who sent the lead, the other is who works it. - **The Discord connector moved to `modules/shared/connector/`.** Two domains now need it, and `AGENTS.md` forbids importing logic sideways between domains. `postWebhook` gained `label` and `timeoutMs` parameters; the transport is otherwise unchanged. - Reuses the existing `DISCORD_WEBHOOK_URL` and `PARTNER_APP_FRONTEND_URL` variables — no new configuration to set on prod. ## Permissions `partner.role.ts` locks the new Opportunity field. `configure-partner-rls.ts` treats its skip-list as a closed allowlist of system columns, so an unlocked new field is reported as a discrepancy. Note that Opportunity RLS for partners is `(partnerUser IS me) OR (isListed = true)`, so on a **listed** brief any partner can read `referredByPartner` — i.e. see that a competitor referred it. Called out deliberately; happy to restrict it if that's not wanted. ## Testing 8 unit tests for the embed mapper (partner present/absent, truncation, absent optionals, no email in the payload, inline-row padding) and 4 for the schema. Full suite: 188 passing, lint clean. Verified end to end against a local workspace with a real Discord webhook. All three paths return `ok: true`; the persisted relation was confirmed via GraphQL rather than inferred from the status code: | Submission | `referredByPartner` | |---|---| | valid slug | linked to the partner | | no slug | `null`, embed reads "Marketplace listing" | | unknown slug | `null`, brief still succeeds | ## Follow-up, not in this PR `yarn rls:configure` fails before reaching its field-lock check — its retry path strips `predicateGroups` but the predicates still carry `rowLevelPermissionPredicateGroupId`, so the retry fails identically. Pre-existing and unrelated to this change (`configure-partner-rls.ts` is untouched here), but it means the script cannot currently verify the lock on a fresh workspace. The website side that sends `partnerSlug` is #23351. Until it ships, this is inert: no caller sends the field, and briefs behave exactly as before. Merge this one first — #23351 is the sender, this is the receiver. Review in cubic --- .../internal/twenty-partners/package.json | 2 +- .../twenty-partners/src/application-config.ts | 4 +- ...eferred-by-partner-on-opportunity.field.ts | 25 ++++ ...referred-opportunities-on-partner.field.ts | 18 +++ .../queries/find-partner-id-by-slug.ts | 12 ++ .../intake/mappers/brief-embed.mapper.test.ts | 109 ++++++++++++++++++ .../intake/mappers/brief-embed.mapper.ts | 100 ++++++++++++++++ .../mappers/build-requirements-text.mapper.ts | 1 + .../services/notify-client-brief.service.ts | 20 ++++ .../services/submit-client-brief.service.ts | 24 ++++ .../intake/submit-client-brief.test.ts | 35 +++++- ...ord-page-referred-by-partner.view-field.ts | 23 ++++ .../mappers/application-embed.mapper.ts | 4 +- ...tner-application-created.logic-function.ts | 2 +- .../notify-partner-application.service.ts | 8 +- .../connector/discord/config.ts | 0 .../connector/discord/discord.connector.ts | 12 +- .../connector/discord/types.ts | 0 .../twenty-partners/src/roles/partner.role.ts | 7 ++ 19 files changed, 391 insertions(+), 15 deletions(-) create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/referred-by-partner-on-opportunity.field.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/referred-opportunities-on-partner.field.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/graphql/queries/find-partner-id-by-slug.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/mappers/brief-embed.mapper.test.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/mappers/brief-embed.mapper.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/services/notify-client-brief.service.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/view-fields/opportunity-record-page-referred-by-partner.view-field.ts rename packages/twenty-apps/internal/twenty-partners/src/modules/{partner/application-intake => shared}/connector/discord/config.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/modules/{partner/application-intake => shared}/connector/discord/discord.connector.ts (67%) rename packages/twenty-apps/internal/twenty-partners/src/modules/{partner/application-intake => shared}/connector/discord/types.ts (100%) 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,