[Website] Partner application wizard + logic-function handover (#21039)

## Summary

Replaces the single-screen partner-application modal with a **4-step
wizard** on the public form, and points the route's upstream at the new
`submit-partner-application` HTTP logic function in the twenty-partners
SDK app.

After design review, the Expertise step landed on the validated
**Category + Skills** model: a small set of *stable* macro categories
the partner operates in, plus a *free, semi-structured* Skills field for
the concrete things that differentiate them (React, SAP, Shopify, …).

Companion PR (partners-app side): #21040

## ⚠️ Deployment notes

Before this can ship to prod, the website worker needs a new env var:

- **Add `PARTNER_APPLICATION_SECRET`** to the deploy config at
https://github.com/twentyhq/twenty-infra/tree/main/cloudflare/website.
Without it the route returns `503` ("Partner application endpoint is not
configured.").
- The value must **match** the `PARTNER_APPLICATION_SECRET` workspace
variable set in the partners workspace UI (Settings → Apps → Twenty
Partners → Variables) — that's how the handler authenticates the
incoming `X-Application-Secret` header.
- `PARTNER_APPLICATION_WEBHOOK_URL` also needs repointing from the TFT
webhook to the logic-function URL
(`https://partner.twenty.com/s/partner-applications` or equivalent) at
the same time.

## Wizard

- 4 steps inside `Modal.Root`: **Identity → Profile → Expertise →
Commercials**. Step-dot indicator, per-step required-field gating, reset
on close. The big serif hero shows **only on step 1**; later steps use
the compact `STEP n OF 4 · NAME` strip to reclaim vertical space.
- **Profile** captures Type of team (Solo/Agency), LinkedIn, City,
Country, Languages. Country uses the searchable Select (placeholder-only
label).
- **Expertise = Category + Skills + Notes:**
- **Category** — multi-select cards over 5 macro categories (`ADVISORY`,
`SOLUTIONING`, `DEVELOPMENT`, `HOSTING`, `SUPPORT`), each with a
one-line description + examples. (Replaces the old draft `partnerScope`
enum; the backend keeps the field name — see #21040.)
- **Skills** — free tag input with a clickable suggestion row + keyboard
autocomplete (↑/↓/Enter/Esc) and "add your own". Empty by default.
- **Notes** — one free textarea (merges the former `workspaceUrl` +
`customerReferences`), reviewed manually.
- `deploymentExpertise` removed from the form (covered by the Hosting
category).
- **In-modal success view** on submit ("Thanks, / we'll be in touch!")
with a Close button — replaces the old silent close.
- Removes the partners-page "Which partner program is right for you?"
three-cards section.

## Design-system primitives

- **`Form.Select`** — searchable popup whose dropdown is **portaled to
`<body>`** (fixed, anchored to the trigger, flips up, height-capped) so
the modal's `overflow`/`transform` can't clip it; pointer events are
stopped so clicking inside it doesn't dismiss the dialog.
- **`Form.TagInput`** — optional `suggestions` prop adds the suggestion
row + autocomplete menu (used by Skills); behaviour unchanged when no
suggestions are passed.
- **`CategoryCardSelect`** — compact multi-select cards.
- `Form.MultiSelect`, `Form.Currency`.

## Validation & payload

- **Single validation source:** client and server share Zod field
schemas (`partner-application-field-schemas.ts`). The reducer validates
via those instead of hand-rolled regexes, so client and server agree by
construction (e.g. both reject non-TLD URLs).
- **Typed request body:** `buildPartnerApplicationRequestBody(state)`
returns a typed `PartnerApplicationRequest` (unit-tested);
`handleSubmit` just serializes it.
- Payload is camelCase matching the logic-function input;
`applicationNotes` replaces `workspaceUrl`/`customerReferences`.
- Auth: the upstream call carries an `X-Application-Secret` header
backed by `PARTNER_APPLICATION_SECRET` (handler-enforced — the SDK's
`isAuthRequired` only accepts user-session JWTs, not workspace API
keys). The webhook-URL env uses `z.url()` (not `z.httpUrl()`) so
`http://localhost:2020/...` dev destinations parse.

## Demo

📹 _Screen recording of the wizard end-to-end (open → walk steps → submit
→ Partner record lands):_


https://github.com/user-attachments/assets/7458dd86-e3ff-47b5-9878-0eb134ff38e3

## Tests

- **62 passing** across reducer, Zod schema, route, the new
payload-builder suite, and Form helper suites. `npx tsc` clean, `nx
lint:diff-with-main` clean, Lingui catalogs regenerated (French slots
are a follow-up).

## Test plan

- [ ] `/partners` → "Become a partner" → wizard opens on Step 1 (full
hero)
- [ ] Identity: name / work email / company → Next
- [ ] Profile: pick **Type of team**; search country ("fra" → France);
pick languages → Next (compact header from here on)
- [ ] Expertise: select 1+ **Category** cards; add **Skills** (click a
suggestion, type one + Enter, drive the ↑/↓ autocomplete); optionally
fill **Notes**
- [ ] Country dropdown opens without being clipped by the modal, and
clicking inside it does **not** close the wizard
- [ ] Commercials → Submit → **in-modal "Thanks, we'll be in touch!"**;
Network shows POST `/api/partner-application` `200`
- [ ] Partner record lands with the chosen categories in `partnerScope`,
plus `skills`, `applicationNotes`, `slug` from company, `reviewed:
false`, `partnerTier: 'NEW'`
- [ ] Re-submit same email + different city → Partner updates;
`validationStage`/`reviewed`/`partnerTier` preserved
- [ ] Back/Next preserves entered values; Reset on close; mobile
single-column / chips wrap

---------

Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
This commit is contained in:
Rashad Karanouh
2026-06-02 14:23:31 +04:00
committed by GitHub
parent 6a908b7876
commit d0e0e27035
35 changed files with 3342 additions and 1014 deletions
@@ -0,0 +1,118 @@
import {
buildLogicFunctionPayload,
partnerApplicationRequestSchema,
} from '@/app/api/partner-application/partner-application-schema';
const minimalValid = {
name: 'Ada Lovelace',
email: 'ada@example.com',
company: 'Analytical Engines Ltd',
};
const fullValid = {
...minimalValid,
website: 'https://analyticalengines.example',
linkedin: 'https://www.linkedin.com/in/ada',
city: 'London',
country: 'UNITED_KINGDOM',
languages: ['ENGLISH', 'FRENCH'],
typeOfTeam: 'SOLO',
partnerScope: ['ADVISORY', 'SOLUTIONING'],
skills: ['React', 'TypeScript'],
applicationNotes:
'Workspace https://app.twenty.com/ws/ada · refs: Acme, Globex',
hourlyRate: 150,
projectBudgetMin: 5000,
calendarLink: 'https://cal.com/ada',
};
describe('partnerApplicationRequestSchema', () => {
it('accepts the minimal required payload', () => {
const parsed = partnerApplicationRequestSchema.safeParse(minimalValid);
expect(parsed.success).toBe(true);
});
it('accepts the full payload', () => {
const parsed = partnerApplicationRequestSchema.safeParse(fullValid);
expect(parsed.success).toBe(true);
});
it('rejects an unknown country enum value', () => {
const parsed = partnerApplicationRequestSchema.safeParse({
...minimalValid,
country: 'ATLANTIS',
});
expect(parsed.success).toBe(false);
});
it('rejects an unknown scope enum value', () => {
const parsed = partnerApplicationRequestSchema.safeParse({
...minimalValid,
partnerScope: ['NOPE'],
});
expect(parsed.success).toBe(false);
});
it('rejects a legacy scope enum value', () => {
const parsed = partnerApplicationRequestSchema.safeParse({
...minimalValid,
partnerScope: ['APPS'],
});
expect(parsed.success).toBe(false);
});
it('forwards applicationNotes through to the payload', () => {
const payload = buildLogicFunctionPayload(fullValid as never);
expect(payload.applicationNotes).toContain('Acme');
});
it('rejects the removed deploymentExpertise key (strictObject)', () => {
const parsed = partnerApplicationRequestSchema.safeParse({
...minimalValid,
deploymentExpertise: ['CLOUD'],
});
expect(parsed.success).toBe(false);
});
it('rejects unknown top-level keys (strictObject)', () => {
const parsed = partnerApplicationRequestSchema.safeParse({
...minimalValid,
countryOther: 'Republic of Examples',
});
expect(parsed.success).toBe(false);
});
it('rejects an invalid email', () => {
const parsed = partnerApplicationRequestSchema.safeParse({
...minimalValid,
email: 'not-an-email',
});
expect(parsed.success).toBe(false);
});
});
describe('buildLogicFunctionPayload', () => {
it('splits firstName/lastName from name and uses camelCase keys', () => {
const payload = buildLogicFunctionPayload(fullValid as never);
expect(payload.firstName).toBe('Ada');
expect(payload.lastName).toBe('Lovelace');
expect(payload.email).toBe('ada@example.com');
expect(payload.companyName).toBe('Analytical Engines Ltd');
expect(payload.hourlyRate).toBe(150);
expect((payload as Record<string, unknown>).CurrencyCode).toBeUndefined();
});
it('omits keys for undefined optional fields', () => {
const payload = buildLogicFunctionPayload(minimalValid as never);
expect('linkedin' in payload).toBe(false);
expect('country' in payload).toBe(false);
expect('languages' in payload).toBe(false);
expect('partnerScope' in payload).toBe(false);
expect('domainName' in payload).toBe(false);
});
it('forwards website as domainName when provided', () => {
const payload = buildLogicFunctionPayload(fullValid as never);
expect(payload.domainName).toBe('https://analyticalengines.example');
});
});
@@ -1,13 +1,12 @@
const ORIGINAL_FETCH = global.fetch;
const ORIGINAL_WEBHOOK_URL = process.env.PARTNER_APPLICATION_WEBHOOK_URL;
const ORIGINAL_API_KEY = process.env.PARTNER_APPLICATION_SECRET;
const VALID_PAYLOAD = {
email: 'a@b.co',
name: 'Ada Lovelace',
company: 'Analytical Engines',
website: 'https://analytical.example/',
message: 'We would like to integrate Twenty with our analytical engine.',
programId: 'technology' as const,
};
const VALID_BODY = JSON.stringify(VALID_PAYLOAD);
@@ -27,7 +26,6 @@ function buildRequest({
if (contentType !== null) headers.set('content-type', contentType);
headers.set('x-forwarded-for', ip);
if (contentLength !== undefined) headers.set('content-length', contentLength);
return new Request('https://example.com/api/partner-application', {
method: 'POST',
headers,
@@ -44,26 +42,26 @@ async function loadRoute() {
describe('POST /api/partner-application', () => {
beforeEach(() => {
process.env.PARTNER_APPLICATION_WEBHOOK_URL = 'https://hooks.example/test';
process.env.PARTNER_APPLICATION_SECRET = 'test-key-abc123';
});
afterEach(() => {
global.fetch = ORIGINAL_FETCH;
process.env.PARTNER_APPLICATION_WEBHOOK_URL = ORIGINAL_WEBHOOK_URL;
process.env.PARTNER_APPLICATION_SECRET = ORIGINAL_API_KEY;
});
it('returns 503 when the webhook URL is not configured', async () => {
delete process.env.PARTNER_APPLICATION_WEBHOOK_URL;
const { POST } = await loadRoute();
const response = await POST(buildRequest());
expect(response.status).toBe(503);
});
it('returns 503 when the webhook URL is not a valid URL', async () => {
process.env.PARTNER_APPLICATION_WEBHOOK_URL = 'not-a-url';
it('returns 503 when the application secret is not configured', async () => {
delete process.env.PARTNER_APPLICATION_SECRET;
const { POST } = await loadRoute();
const response = await POST(buildRequest());
const response = await POST(buildRequest({ ip: '203.0.113.2' }));
expect(response.status).toBe(503);
});
@@ -81,10 +79,7 @@ describe('POST /api/partner-application', () => {
it('returns 413 when content-length declares a too-large body', async () => {
const { POST } = await loadRoute();
const response = await POST(
buildRequest({
contentLength: '99999999',
ip: '203.0.113.11',
}),
buildRequest({ contentLength: '99999999', ip: '203.0.113.11' }),
);
expect(response.status).toBe(413);
});
@@ -119,53 +114,32 @@ describe('POST /api/partner-application', () => {
expect(response.status).toBe(400);
});
it('returns 400 when extra fields are present (strict schema)', async () => {
it('returns 400 when an extra (legacy) field is present (strictObject)', async () => {
const { POST } = await loadRoute();
const response = await POST(
buildRequest({
body: JSON.stringify({ ...VALID_PAYLOAD, extra: 'nope' }),
body: JSON.stringify({
...VALID_PAYLOAD,
countryOther: 'Republic of Examples',
}),
ip: '203.0.113.15',
}),
);
expect(response.status).toBe(400);
});
it('returns 400 when company is missing', async () => {
it('returns 400 when country enum is unknown', async () => {
const { POST } = await loadRoute();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { company: _omitted, ...withoutCompany } = VALID_PAYLOAD;
const response = await POST(
buildRequest({
body: JSON.stringify(withoutCompany),
body: JSON.stringify({ ...VALID_PAYLOAD, country: 'ATLANTIS' }),
ip: '203.0.113.16',
}),
);
expect(response.status).toBe(400);
});
it('returns 400 when website is not a URL', async () => {
const { POST } = await loadRoute();
const response = await POST(
buildRequest({
body: JSON.stringify({ ...VALID_PAYLOAD, website: 'not-a-url' }),
ip: '203.0.113.17',
}),
);
expect(response.status).toBe(400);
});
it('returns 400 when programId is unknown', async () => {
const { POST } = await loadRoute();
const response = await POST(
buildRequest({
body: JSON.stringify({ ...VALID_PAYLOAD, programId: 'wat' }),
ip: '203.0.113.18',
}),
);
expect(response.status).toBe(400);
});
it('forwards a valid submission to the webhook with all fields and returns 200', async () => {
it('forwards a valid submission to the webhook with camelCase payload + header auth and returns 200', async () => {
const fetchSpy = jest
.fn()
.mockResolvedValue(new Response(null, { status: 200 }));
@@ -176,24 +150,24 @@ describe('POST /api/partner-application', () => {
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ success: true });
expect(fetchSpy).toHaveBeenCalledTimes(1);
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('https://hooks.example/test');
expect(init.method).toBe('POST');
expect(init.headers['X-Application-Secret']).toBe('test-key-abc123');
expect(init.headers['Content-Type']).toBe('application/json');
expect(JSON.parse(init.body as string)).toEqual({
Email: 'a@b.co',
FirstName: 'Ada',
LastName: 'Lovelace',
Company: 'Analytical Engines',
Website: 'https://analytical.example/',
Message: 'We would like to integrate Twenty with our analytical engine.',
ProgramId: 'technology',
email: 'a@b.co',
firstName: 'Ada',
lastName: 'Lovelace',
companyName: 'Analytical Engines',
domainName: 'https://analytical.example/',
});
expect(init.signal).toBeInstanceOf(AbortSignal);
});
it('forwards optional Opportunities when provided', async () => {
it('forwards rich optional wizard fields with camelCase keys', async () => {
const fetchSpy = jest
.fn()
.mockResolvedValue(new Response(null, { status: 200 }));
@@ -204,7 +178,11 @@ describe('POST /api/partner-application', () => {
buildRequest({
body: JSON.stringify({
...VALID_PAYLOAD,
opportunities: '50/month',
country: 'FRANCE',
languages: ['ENGLISH', 'FRENCH'],
typeOfTeam: 'SOLO',
partnerScope: ['ADVISORY'],
hourlyRate: 150,
}),
ip: '203.0.113.24',
}),
@@ -213,11 +191,15 @@ describe('POST /api/partner-application', () => {
expect(response.status).toBe(200);
const [, init] = fetchSpy.mock.calls[0];
expect(JSON.parse(init.body as string)).toMatchObject({
Opportunities: '50/month',
country: 'FRANCE',
languages: ['ENGLISH', 'FRENCH'],
typeOfTeam: 'SOLO',
partnerScope: ['ADVISORY'],
hourlyRate: 150,
});
});
it('omits optional Opportunities when not provided', async () => {
it('omits optional rich fields when not provided', async () => {
const fetchSpy = jest
.fn()
.mockResolvedValue(new Response(null, { status: 200 }));
@@ -228,14 +210,16 @@ describe('POST /api/partner-application', () => {
expect(response.status).toBe(200);
const [, init] = fetchSpy.mock.calls[0];
expect(JSON.parse(init.body as string)).not.toHaveProperty('Opportunities');
const body = JSON.parse(init.body as string);
expect(body).not.toHaveProperty('country');
expect(body).not.toHaveProperty('languages');
expect(body).not.toHaveProperty('partnerScope');
});
it('returns 502 when the webhook responds with a non-2xx status', async () => {
global.fetch = jest
.fn()
.mockResolvedValue(new Response('boom', { status: 500 }));
const { POST } = await loadRoute();
const response = await POST(buildRequest({ ip: '203.0.113.21' }));
expect(response.status).toBe(502);
@@ -243,7 +227,6 @@ describe('POST /api/partner-application', () => {
it('returns 502 when the webhook fetch throws (network error)', async () => {
global.fetch = jest.fn().mockRejectedValue(new Error('connection refused'));
const { POST } = await loadRoute();
const response = await POST(buildRequest({ ip: '203.0.113.22' }));
expect(response.status).toBe(502);
@@ -255,7 +238,6 @@ describe('POST /api/partner-application', () => {
.mockRejectedValue(
Object.assign(new Error('aborted'), { name: 'AbortError' }),
);
const { POST } = await loadRoute();
const response = await POST(buildRequest({ ip: '203.0.113.23' }));
expect(response.status).toBe(504);
@@ -265,16 +247,13 @@ describe('POST /api/partner-application', () => {
global.fetch = jest
.fn()
.mockResolvedValue(new Response(null, { status: 200 }));
const { POST } = await loadRoute();
const ip = '203.0.113.99';
const statuses: number[] = [];
for (let i = 0; i < 6; i++) {
const r = await POST(buildRequest({ ip }));
statuses.push(r.status);
}
expect(statuses.slice(0, 5).every((s) => s === 200)).toBe(true);
expect(statuses[5]).toBe(429);
});
@@ -283,13 +262,9 @@ describe('POST /api/partner-application', () => {
global.fetch = jest
.fn()
.mockResolvedValue(new Response(null, { status: 200 }));
const { POST } = await loadRoute();
const ip = '203.0.113.100';
for (let i = 0; i < 5; i++) {
await POST(buildRequest({ ip }));
}
for (let i = 0; i < 5; i++) await POST(buildRequest({ ip }));
const denied = await POST(buildRequest({ ip }));
expect(denied.status).toBe(429);
const retryAfter = denied.headers.get('Retry-After');
@@ -0,0 +1,69 @@
import {
type PARTNER_COUNTRY_VALUES,
type PARTNER_LANGUAGE_VALUES,
type PARTNER_SCOPE_VALUES,
type PARTNER_TYPE_OF_TEAM_VALUES,
} from '@/sections/PartnerApplication/wizard/partner-fields.data';
import { type PartnerApplicationRequest } from '@/sections/PartnerApplication/partner-application-field-schemas';
import { splitFullName } from '@/sections/PartnerApplication';
// The request schema and type live in the section layer so the client wizard
// can share them without `sections/**` importing from `@/app/**`. Re-export so
// existing route-side import paths keep working.
export {
partnerApplicationRequestSchema,
type PartnerApplicationRequest,
} from '@/sections/PartnerApplication/partner-application-field-schemas';
export type PartnerApplicationLogicFunctionPayload = {
firstName: string;
lastName: string;
email: string;
companyName: string;
domainName?: string;
linkedin?: string;
city?: string;
country?: (typeof PARTNER_COUNTRY_VALUES)[number];
languages?: ReadonlyArray<(typeof PARTNER_LANGUAGE_VALUES)[number]>;
typeOfTeam?: (typeof PARTNER_TYPE_OF_TEAM_VALUES)[number];
partnerScope?: ReadonlyArray<(typeof PARTNER_SCOPE_VALUES)[number]>;
skills?: ReadonlyArray<string>;
applicationNotes?: string;
hourlyRate?: number;
projectBudgetMin?: number;
calendarLink?: string;
};
export function buildLogicFunctionPayload(
request: PartnerApplicationRequest,
): PartnerApplicationLogicFunctionPayload {
const { firstName, lastName } = splitFullName(request.name);
const payload: PartnerApplicationLogicFunctionPayload = {
firstName,
lastName,
email: request.email,
companyName: request.company,
};
if (request.website !== undefined) payload.domainName = request.website;
if (request.linkedin !== undefined) payload.linkedin = request.linkedin;
if (request.city !== undefined) payload.city = request.city;
if (request.country !== undefined) payload.country = request.country;
if (request.languages !== undefined && request.languages.length > 0)
payload.languages = request.languages;
if (request.typeOfTeam !== undefined) payload.typeOfTeam = request.typeOfTeam;
if (request.partnerScope !== undefined && request.partnerScope.length > 0)
payload.partnerScope = request.partnerScope;
if (request.skills !== undefined && request.skills.length > 0)
payload.skills = request.skills;
if (request.applicationNotes !== undefined)
payload.applicationNotes = request.applicationNotes;
if (request.hourlyRate !== undefined) payload.hourlyRate = request.hourlyRate;
if (request.projectBudgetMin !== undefined)
payload.projectBudgetMin = request.projectBudgetMin;
if (request.calendarLink !== undefined)
payload.calendarLink = request.calendarLink;
return payload;
}
@@ -4,37 +4,23 @@ import {
getClientIpKey,
readJsonBody,
} from '@/lib/api';
import { splitFullName } from '@/sections/PartnerApplication';
import {
buildLogicFunctionPayload,
partnerApplicationRequestSchema,
} from '@/app/api/partner-application/partner-application-schema';
import { NextResponse } from 'next/server';
import { z } from 'zod';
const PARTNER_PROGRAM_IDS = ['technology', 'content', 'solutions'] as const;
const partnerApplicationRequestSchema = z.strictObject({
email: z
.string()
.trim()
.min(1, { error: 'Email is required.' })
.pipe(z.email({ error: 'Invalid email address.' })),
name: z.string().trim().min(1, { error: 'Name is required.' }),
company: z.string().trim().min(1, { error: 'Company is required.' }),
website: z
.string()
.trim()
.min(1, { error: 'Website is required.' })
.pipe(z.httpUrl({ error: 'Invalid website URL.' })),
message: z.string().trim().min(1, { error: 'Message is required.' }),
programId: z.enum(PARTNER_PROGRAM_IDS).optional(),
opportunities: z.string().trim().optional(),
});
// Use z.url() (not z.httpUrl) so localhost destinations are accepted in dev.
// z.httpUrl enforces a TLD-shaped hostname and would reject http://localhost:2020/...
const webhookUrlSchema = z
.string()
.trim()
.pipe(z.httpUrl({ error: 'Invalid webhook URL.' }));
.pipe(z.url({ error: 'Invalid webhook URL.' }));
const applicationSecretSchema = z.string().trim().min(1);
const MAX_BODY_BYTES = 16 * 1024;
const WEBHOOK_TIMEOUT_MS = 8_000;
const checkRateLimit = createRateLimiter({
@@ -46,15 +32,45 @@ export async function POST(request: Request) {
const webhookUrlResult = webhookUrlSchema.safeParse(
process.env.PARTNER_APPLICATION_WEBHOOK_URL,
);
const secretResult = applicationSecretSchema.safeParse(
process.env.PARTNER_APPLICATION_SECRET,
);
if (!webhookUrlResult.success) {
if (!webhookUrlResult.success || !secretResult.success) {
const rawWebhookUrl = process.env.PARTNER_APPLICATION_WEBHOOK_URL;
const rawSecret = process.env.PARTNER_APPLICATION_SECRET;
console.error(
'[partner-application] 503 — endpoint env vars failed validation',
JSON.stringify({
PARTNER_APPLICATION_WEBHOOK_URL: {
present: rawWebhookUrl !== undefined,
isEmptyAfterTrim: (rawWebhookUrl ?? '').trim() === '',
length: (rawWebhookUrl ?? '').length,
value: rawWebhookUrl,
parseError: webhookUrlResult.success
? null
: (webhookUrlResult.error.issues[0]?.message ?? 'unknown'),
},
PARTNER_APPLICATION_SECRET: {
present: rawSecret !== undefined,
isEmptyAfterTrim: (rawSecret ?? '').trim() === '',
length: (rawSecret ?? '').length,
parseError: secretResult.success
? null
: (secretResult.error.issues[0]?.message ?? 'unknown'),
},
envFileHint:
'Next dev reads .env.local (not .env.prod). After editing env vars you must restart `yarn nx dev twenty-website` — Next does not hot-reload env vars.',
}),
);
return NextResponse.json(
{ error: 'Partner application webhook is not configured.' },
{ error: 'Partner application endpoint is not configured.' },
{ status: 503 },
);
}
const webhookUrl = webhookUrlResult.data;
const applicationSecret = secretResult.data;
const rateLimit = checkRateLimit(getClientIpKey(request));
if (!rateLimit.allowed) {
@@ -102,25 +118,16 @@ export async function POST(request: Request) {
return NextResponse.json({ error: message }, { status: 400 });
}
const { name, email, company, website, message, programId, opportunities } =
parsed.data;
const { firstName, lastName } = splitFullName(name);
const payload = buildLogicFunctionPayload(parsed.data);
const upstream = await fetchWithTimeout(
webhookUrl,
{
body: JSON.stringify({
Email: email,
FirstName: firstName,
LastName: lastName,
Company: company,
Website: website,
Message: message,
...(programId !== undefined && { ProgramId: programId }),
...(opportunities !== undefined &&
opportunities !== '' && { Opportunities: opportunities }),
}),
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
headers: {
'Content-Type': 'application/json',
'X-Application-Secret': applicationSecret,
},
method: 'POST',
},
{ timeoutMs: WEBHOOK_TIMEOUT_MS },
@@ -128,6 +135,14 @@ export async function POST(request: Request) {
if (!upstream.ok) {
const status = upstream.error === 'timeout' ? 504 : 502;
console.error(
'[partner-application] upstream fetch failed',
JSON.stringify({
url: webhookUrl,
error: upstream.error,
payloadKeys: Object.keys(payload),
}),
);
return NextResponse.json(
{ error: 'Partner application could not be submitted.' },
{ status },
@@ -135,6 +150,22 @@ export async function POST(request: Request) {
}
if (!upstream.response.ok) {
let upstreamBody = '';
try {
upstreamBody = await upstream.response.text();
} catch {
upstreamBody = '(could not read upstream body)';
}
console.error(
'[partner-application] upstream returned non-2xx',
JSON.stringify({
url: webhookUrl,
status: upstream.response.status,
statusText: upstream.response.statusText,
body: upstreamBody.slice(0, 2000),
payloadKeys: Object.keys(payload),
}),
);
return NextResponse.json(
{ error: 'Partner application could not be submitted.' },
{ status: 502 },