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:
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
+25
@@ -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',
|
||||
},
|
||||
});
|
||||
+18
@@ -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,
|
||||
},
|
||||
});
|
||||
+12
@@ -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 } },
|
||||
},
|
||||
});
|
||||
}
|
||||
+109
@@ -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);
|
||||
});
|
||||
});
|
||||
+100
@@ -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;
|
||||
}
|
||||
+1
@@ -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>;
|
||||
|
||||
+20
@@ -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.
|
||||
}
|
||||
}
|
||||
+24
@@ -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) };
|
||||
|
||||
+34
-1
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
+23
@@ -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,
|
||||
});
|
||||
+2
-2
@@ -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;
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
|
||||
+6
-2
@@ -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.
|
||||
|
||||
+6
-6
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user