From bf75ab8982f66e78dc2b3039dc2fb5f6a06b6929 Mon Sep 17 00:00:00 2001 From: Rashad Karanouh <11599358+rashad@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:20:18 +0400 Subject: [PATCH] feat(twenty-partners): notify Discord on new partner application (#21313) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../internal/twenty-partners/package.json | 2 +- .../twenty-partners/src/application-config.ts | 12 ++ .../src/constants/universal-identifiers.ts | 1 + .../on-partner-application-created.test.ts | 146 ++++++++++++++ ...tner-application-created.logic-function.ts | 190 ++++++++++++++++++ 5 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/on-partner-application-created.test.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-partner-application-created.logic-function.ts diff --git a/packages/twenty-apps/internal/twenty-partners/package.json b/packages/twenty-apps/internal/twenty-partners/package.json index 2f73385322..31258153f7 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": "0.3.4", + "version": "0.4.2", "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 03a2817403..6439bb5d16 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/application-config.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/application-config.ts @@ -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, + }, }, }); diff --git a/packages/twenty-apps/internal/twenty-partners/src/constants/universal-identifiers.ts b/packages/twenty-apps/internal/twenty-partners/src/constants/universal-identifiers.ts index 83d4d700cd..7c9892a30a 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/constants/universal-identifiers.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/constants/universal-identifiers.ts @@ -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'; diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/on-partner-application-created.test.ts b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/on-partner-application-created.test.ts new file mode 100644 index 0000000000..ebe575ff7d --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/on-partner-application-created.test.ts @@ -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; + +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 }); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-partner-application-created.logic-function.ts b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-partner-application-created.logic-function.ts new file mode 100644 index 0000000000..1af23bafb3 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-partner-application-created.logic-function.ts @@ -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 { + // 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 = { + 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>, +): Promise> => { + // 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', + }, +});