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:
@@ -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';
|
||||
|
||||
|
||||
+146
@@ -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 });
|
||||
});
|
||||
});
|
||||
+190
@@ -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',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user