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.
---
.../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,