feat(twenty-partners): notify Discord on new partner application (#21313)

Adds an `on-partner-application-created` logic function triggered on the
`partner.created` database event. When the website application form
creates a new Partner, it posts a rich embed to a Discord channel
(applicant, company, country, languages, partner scope, skills) with a
deep link to the record.

## How it works
- Fires only on genuine form submissions — discriminates via
`createdBy.source === 'APPLICATION'`, which excludes seed/import (`API`)
and manual UI (`MANUAL`) creation.
- Runs out-of-band on the worker (database event trigger), so it adds
**no latency** to the applicant's submission, and the linked Person
already exists by the time it runs.
- Best-effort: a Discord failure never fails the trigger (wrapped in
`try/catch`, 8s timeout).

## Configuration (per workspace — Settings → Apps → Twenty Partners →
Variables)
- `DISCORD_WEBHOOK_URL` (secret) — the incoming webhook URL. **The
feature is a no-op when unset.**
- `PARTNER_APP_FRONTEND_URL` — workspace front-end base URL for the
record deep link (e.g. `https://partners.twenty.com`).

## Notes
- New logic function + two application variables; version bumped to
**0.4.0** (minor).
- Unit tests cover the source-guard branches, the on/off switch, the
embed contents/ordering, and best-effort failure handling.
- The website and the existing `submit-partner-application` handler are
untouched.
This commit is contained in:
Rashad Karanouh
2026-06-08 22:20:18 +04:00
committed by GitHub
parent 822beb6a86
commit bf75ab8982
5 changed files with 350 additions and 1 deletions
@@ -1,6 +1,6 @@
{
"name": "twenty-partners",
"version": "0.3.4",
"version": "0.4.2",
"license": "MIT",
"engines": {
"node": "^24.5.0",
@@ -17,5 +17,17 @@ export default defineApplication({
'Shared secret required in the X-Application-Secret header on POST /partner-applications. Must match the website route\'s PARTNER_APPLICATION_SECRET env var. Set per-workspace in Settings → Apps → Twenty Partners → Variables.',
isSecret: true,
},
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.',
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.',
isSecret: false,
},
},
});
@@ -10,6 +10,7 @@ export const PARTNERS_NAV_UNIVERSAL_IDENTIFIER = '3fe15ab5-e38b-4914-af17-2270b2
export const PARTNER_DEALS_NAV_UNIVERSAL_IDENTIFIER = 'c5e4ac36-bede-4f4b-bfe8-bbd09518abae';
export const ON_OPP_AUTO_MATCH_FN_UNIVERSAL_IDENTIFIER = 'eb8d4d26-8103-4b66-9026-6a86556f7ca5';
export const POST_INSTALL_FN_UNIVERSAL_IDENTIFIER = 'f92bad2e-5905-4757-96ee-af9869d4ca0c';
export const ON_PARTNER_APPLICATION_CREATED_FN_UNIVERSAL_IDENTIFIER = '43888cce-a2aa-4100-afbc-59a4f978ce53';
export const MATCH_STATUS_FIELD_UNIVERSAL_IDENTIFIER = 'd8dd0623-3a4c-4ab3-a1e0-4ece7df24fb2';
export const INTRO_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER = 'fcf39b0c-0547-415e-806d-b238131ad7cc';
@@ -0,0 +1,146 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock the codegen client BEFORE importing the handler. vi.hoisted lets the
// factory reference the mock fn safely despite hoisting.
const { queryMock } = vi.hoisted(() => ({ queryMock: vi.fn() }));
vi.mock('twenty-client-sdk/core', () => ({
CoreApiClient: vi.fn(() => ({ query: queryMock })),
}));
import {
buildApplicationEmbed,
handler,
} from '../on-partner-application-created.logic-function';
const PARTNER_ID = '11111111-1111-1111-1111-111111111111';
const partnerQueryResult = {
partner: {
id: PARTNER_ID,
name: 'Analytical Engines Ltd',
country: 'FRANCE',
partnerScope: ['ADVISORY', 'SOLUTIONING'],
skills: ['Onboarding', 'Migration'],
languagesSpoken: ['ENGLISH', 'FRENCH'],
persons: {
edges: [
{
node: {
name: { firstName: 'Ada', lastName: 'Lovelace' },
},
},
],
},
},
};
// The database event 'after' hydrates the actor composite as a NESTED object
// (after.createdBy.source), confirmed from the live partner.created payload.
const createdEvent = (source: string) =>
({ properties: { after: { id: PARTNER_ID, createdBy: { source } } } }) as never;
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
queryMock.mockReset();
queryMock.mockResolvedValue(partnerQueryResult);
fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 204, text: async () => '' });
vi.stubGlobal('fetch', fetchMock);
process.env.DISCORD_WEBHOOK_URL = 'https://discord.test/webhook';
process.env.PARTNER_APP_FRONTEND_URL = 'https://partners.twenty.com';
});
afterEach(() => {
vi.unstubAllGlobals();
delete process.env.DISCORD_WEBHOOK_URL;
delete process.env.PARTNER_APP_FRONTEND_URL;
});
describe('buildApplicationEmbed', () => {
it('builds a rich embed with a deep link, includes Languages, omits empty fields, and never sends Email or Hourly rate', () => {
const embed = buildApplicationEmbed(
{
id: PARTNER_ID,
name: 'Acme',
country: null, // omitted
partnerScope: [], // omitted
skills: ['Migration'],
languagesSpoken: ['ENGLISH', 'FRENCH'],
applicant: { firstName: 'Ada', lastName: 'Lovelace' },
},
'https://partners.twenty.com',
);
expect(embed.title).toBe('New partner application');
expect(embed.url).toBe(`https://partners.twenty.com/object/partner/${PARTNER_ID}`);
const fieldNames = (embed.fields as Array<{ name: string }>)
.map((f) => f.name)
.filter((n) => n !== ''); // drop layout spacers
// country (null) and partnerScope ([]) omitted; Email and Hourly rate never sent.
expect(fieldNames).toEqual(['Applicant', 'Company', 'Languages', 'Skills']);
expect(fieldNames).not.toContain('Email');
expect(fieldNames).not.toContain('Hourly rate');
});
it('omits the url when no frontend URL is configured', () => {
const embed = buildApplicationEmbed({ id: PARTNER_ID, name: 'Acme' }, undefined);
expect(embed.url).toBeUndefined();
});
});
describe('on-partner-application-created handler', () => {
it('posts a Discord embed when an APPLICATION-sourced partner is created', async () => {
const result = await handler(createdEvent('APPLICATION'));
expect(queryMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('https://discord.test/webhook');
const body = JSON.parse((init as { body: string }).body);
expect(body.embeds[0].title).toBe('New partner application');
expect(body.embeds[0].url).toBe(`https://partners.twenty.com/object/partner/${PARTNER_ID}`);
const fieldNames = body.embeds[0].fields
.map((f: { name: string }) => f.name)
.filter((n: string) => n !== ''); // drop layout spacers
expect(fieldNames).toEqual(['Applicant', 'Company', 'Country', 'Languages', 'Partner scope', 'Skills']);
expect(fieldNames).not.toContain('Email');
expect(fieldNames).not.toContain('Hourly rate');
expect(result).toEqual({ notified: true });
});
it('does not post for API-sourced creation (seed/import)', async () => {
const result = await handler(createdEvent('API'));
expect(queryMock).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
expect(result).toEqual({});
});
it('does not post for MANUAL-sourced creation', async () => {
await handler(createdEvent('MANUAL'));
expect(fetchMock).not.toHaveBeenCalled();
});
it('does not post when DISCORD_WEBHOOK_URL is unset', async () => {
delete process.env.DISCORD_WEBHOOK_URL;
const result = await handler(createdEvent('APPLICATION'));
expect(fetchMock).not.toHaveBeenCalled();
expect(result).toEqual({});
});
it('never throws when the Discord POST fails', async () => {
fetchMock.mockRejectedValue(new Error('discord down'));
const result = await handler(createdEvent('APPLICATION'));
expect(result).toEqual({ notified: false });
});
it('reports notified: false on a non-2xx Discord response', async () => {
// fetch resolves on HTTP errors; the handler must not report these as delivered.
fetchMock.mockResolvedValue({ ok: false, status: 404, text: async () => 'Not Found' });
const result = await handler(createdEvent('APPLICATION'));
expect(result).toEqual({ notified: false });
});
});
@@ -0,0 +1,190 @@
import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
import {
type DatabaseEventPayload,
defineLogicFunction,
type ObjectRecordCreateEvent,
} from 'twenty-sdk/define';
import { ON_PARTNER_APPLICATION_CREATED_FN_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
// Generous bound on the Discord call. The trigger runs out-of-band on the
// worker (no user waiting), so this only needs to stay under the function's
// 10s budget — not be tight. Guards against a hung/slow webhook.
const DISCORD_TIMEOUT_MS = 8000;
// Twenty brand blue (#4a38f5) as a Discord embed integer color.
const TWENTY_BLUE = 0x4a38f5;
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
type DiscordField = { name: string; value: string; inline?: boolean };
type PartnerForEmbed = {
id: string;
name?: string | null;
country?: string | null;
partnerScope?: string[] | null;
skills?: string[] | null;
languagesSpoken?: string[] | null;
applicant?: {
firstName?: string | null;
lastName?: string | null;
} | null;
};
// Pure embed builder, exported for unit testing. Empty fields are omitted.
export function buildApplicationEmbed(
partner: PartnerForEmbed,
frontendUrl: string | undefined,
): Record<string, unknown> {
// Discord packs up to 3 inline fields per row. We want two paired rows —
// Applicant | Company, then Country | Languages — followed by two full-width
// rows (Partner scope, Skills). A trailing zero-width inline "spacer" fills
// each paired row to 3 columns so the next group starts on its own line.
const SPACER: DiscordField = { name: '', value: '', inline: true };
const fields: DiscordField[] = [];
const applicantName = [partner.applicant?.firstName, partner.applicant?.lastName]
.filter(isNonEmptyString)
.join(' ')
.trim();
const pairedRow1: DiscordField[] = [];
if (isNonEmptyString(applicantName)) {
pairedRow1.push({ name: 'Applicant', value: applicantName, inline: true });
}
if (isNonEmptyString(partner.name)) {
pairedRow1.push({ name: 'Company', value: partner.name.trim(), inline: true });
}
const pairedRow2: DiscordField[] = [];
if (isNonEmptyString(partner.country)) {
pairedRow2.push({ name: 'Country', value: partner.country, inline: true });
}
if (partner.languagesSpoken && partner.languagesSpoken.length > 0) {
pairedRow2.push({ name: 'Languages', value: partner.languagesSpoken.join(', '), inline: true });
}
for (const field of pairedRow1) fields.push(field);
if (pairedRow1.length > 0) fields.push(SPACER);
for (const field of pairedRow2) fields.push(field);
if (pairedRow2.length > 0) fields.push(SPACER);
if (partner.partnerScope && partner.partnerScope.length > 0) {
fields.push({ name: 'Partner scope', value: partner.partnerScope.join(', ') });
}
if (partner.skills && partner.skills.length > 0) {
fields.push({ name: 'Skills', value: partner.skills.join(', ') });
}
const embed: Record<string, unknown> = {
title: 'New partner application',
color: TWENTY_BLUE,
timestamp: new Date().toISOString(),
fields,
};
if (isNonEmptyString(frontendUrl)) {
embed.url = `${frontendUrl.replace(/\/+$/, '')}/object/partner/${partner.id}`;
}
return embed;
}
export const handler = async (
payload: DatabaseEventPayload<ObjectRecordCreateEvent<CoreSchema.Partner>>,
): Promise<Record<string, unknown>> => {
// The event 'after' hydrates the actor composite as a NESTED object — the
// generated Partner type matches the live partner.created payload shape.
const after = payload.properties.after;
if (!after?.id) return {};
// Form-only: the installed app stamps createdBy.source = 'APPLICATION'.
// Seed/import authenticate via API key (API); the UI is MANUAL — both skipped.
if (after.createdBy?.source !== 'APPLICATION') return {};
const webhookUrl = process.env.DISCORD_WEBHOOK_URL;
if (!isNonEmptyString(webhookUrl)) return {};
const frontendUrl = process.env.PARTNER_APP_FRONTEND_URL;
try {
const client = new CoreApiClient();
const res = await client.query({
partner: {
__args: { filter: { id: { eq: after.id } } },
id: true,
name: true,
country: true,
partnerScope: true,
skills: true,
languagesSpoken: true,
persons: {
edges: {
node: {
name: { firstName: true, lastName: true },
},
},
},
},
});
const node = res.partner;
if (!node) return {};
const applicantNode = node.persons?.edges?.[0]?.node;
const partnerForEmbed: PartnerForEmbed = {
id: after.id,
name: node.name,
country: node.country,
partnerScope: node.partnerScope,
skills: node.skills,
languagesSpoken: node.languagesSpoken,
applicant: applicantNode
? {
firstName: applicantNode.name?.firstName,
lastName: applicantNode.name?.lastName,
}
: null,
};
const embed = buildApplicationEmbed(partnerForEmbed, frontendUrl);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), DISCORD_TIMEOUT_MS);
try {
const response = await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ embeds: [embed] }),
signal: controller.signal,
});
// fetch only rejects on network errors, not on HTTP error statuses, so a
// 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}`,
);
return { notified: false };
}
} finally {
clearTimeout(timeout);
}
return { notified: true };
} catch {
// Best-effort: a Discord failure must never fail the trigger.
return { notified: false };
}
};
export default defineLogicFunction({
universalIdentifier: ON_PARTNER_APPLICATION_CREATED_FN_UNIVERSAL_IDENTIFIER,
name: 'on-partner-application-created',
description:
'Posts a Discord notification when the partner application form creates a new Partner.',
timeoutSeconds: 10,
handler,
databaseEventTriggerSettings: {
eventName: 'partner.created',
},
});