v1.5.1 — partners: Discord notification for client briefs + referring-partner attribution (#23344)

**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.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23344?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Rashad Karanouh
2026-07-28 11:42:23 +02:00
committed by GitHub
parent 1f55234d0b
commit fea06bdd4b
19 changed files with 391 additions and 15 deletions
@@ -1,6 +1,6 @@
{
"name": "twenty-partners",
"version": "1.4.0",
"version": "1.5.1",
"license": "MIT",
"engines": {
"node": "^24.5.0",
@@ -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,
},
},
@@ -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',
},
});
@@ -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,
},
});
@@ -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 } },
},
});
}
@@ -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<string, unknown>, 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);
});
});
@@ -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<string, unknown> {
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<string, unknown> = {
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;
}
@@ -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<typeof submitClientBriefSchema>;
@@ -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<void> {
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.
}
}
@@ -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<ReferringPartner | null> {
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<SubmitClientBriefResult> {
@@ -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) };
@@ -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');
});
});
@@ -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,
});
@@ -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;
@@ -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';
@@ -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.
@@ -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<boolean> {
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;
@@ -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,