[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:
+118
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user