From 4f429a39762a1b55d48967c8b211126179b62460 Mon Sep 17 00:00:00 2001
From: Rashad Karanouh <11599358+rashad@users.noreply.github.com>
Date: Fri, 31 Jul 2026 15:42:32 +0200
Subject: [PATCH] =?UTF-8?q?v1.6.0=20=E2=80=94=20Partners:=20let=20partners?=
=?UTF-8?q?=20set=20the=20regions=20they=20serve=20on=20My=20Profile=20(#2?=
=?UTF-8?q?3605)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
**Partners app version: `1.6.0`** (minor — new backwards-compatible
field, no schema change)
## Problem
`Partner.region` is a `MULTI_SELECT` (`EUROPE`, `US`, `LATAM`, `MENA`,
`APAC`, `AFRICA`) that was **readable everywhere but writable nowhere**:
- the public partner marketplace **filters** on it
(`filter-partners.ts`) and **displays** it (`PartnerProfile.tsx`)
- the self-service loader already selected and mapped it
(`find-my-partner-profile.ts`, `get-my-partner-profile.mapper.ts`)
- but the **My Profile form never rendered it**, and
`save-my-partner-profile.mapper.ts` uses a `.strict()` zod schema that
rejected the key outright
The only way a partner got a region was `deriveRegion(country)` at
application intake — a single region, derived from their home country,
set once. So a partner serving several regions could not say so, and
anyone who applied without a mapped country had a null region and was
invisible to every region filter on the marketplace.
## Change
"Regions served" becomes partner-editable, in the Location section of My
Profile under Country/City.
| File | |
|---|---|
| `self-service/constants/my-profile.constants.ts` | six region options,
mirroring `partner.object.ts` |
| `self-service/mappers/save-my-partner-profile.mapper.ts` | schema key,
value validation, mapping onto the update payload |
| `front-components/my-profile/profile-form.ts` | **new** — the form's
pure helpers, extracted so they can be unit-tested |
| `front-components/my-profile/profile-form.test.ts` | **new** |
| `front-components/my-profile.front-component.tsx` | the
`ChipMultiSelect` field; ~110 lines lighter after the extraction |
The extraction follows the pattern already used by
`my-case-studies/case-study-rows.ts` and its co-located test. It is a
separate, behaviour-free commit (`321f2c42`) to keep it reviewable apart
from the feature.
## Deliberately out of scope
- **No country → region coupling.** Country is where you are; region is
where you sell. Changing Country does not re-derive, seed, or clear
Region. `deriveRegion` stays intake-only.
- **No backfill.** Existing partners with a null region keep it until
they edit their profile.
- **No marketplace or `completenessScore` change** — both already handle
`region` correctly.
- **No object/schema change.** `yarn twenty plan` reports `0 to add, 3
to change, 0 to destroy` (two logic-function checksums plus the
front-component checksum).
## Permissions
This does not widen partner privileges. `region`'s field UUID is absent
from the locked-field list in `src/roles/partner.role.ts`, so the
partner role already permitted region writes on the partner's own record
via the CRM page — this only surfaces it in the self-service form. The
save path resolves `partnerId` from the request JWT
(`resolve-partner-from-request.service.ts`); the body never supplies a
record id, and `.strict()` rejects one if sent.
## Verification
- `yarn test:unit` — 217/217
- `yarn lint` (oxlint) — 0 warnings, 0 errors
- SDK build typecheck — passes
- Verified end to end against a local workspace: the field renders with
six chips, pre-selects from the stored value, saving two regions
persists `["EUROPE","MENA"]`, and deselecting all persists `[]` while
leaving name/city/country untouched.
## Known, pre-existing, not addressed here
`toMoneyField` round-trips the stored `currencyCode`, but
`saveProfileSchema` pins `currencyCode: z.literal('USD')`. A partner
whose `hourlyRate` was set to a non-USD currency in the CRM therefore
has **every** profile save rejected, with no currency picker in the UI
to correct it. Surfaced while reviewing this branch; it predates it and
is left for a separate fix.
---
.../internal/twenty-partners/package.json | 2 +-
...ave-my-partner-profile.integration-test.ts | 2 +-
.../constants/my-profile.constants.ts | 9 ++
.../my-profile.front-component.tsx | 116 +++---------------
.../my-profile/profile-form.test.ts | 59 +++++++++
.../my-profile/profile-form.ts | 94 ++++++++++++++
.../save-my-partner-profile.mapper.test.ts | 26 +++-
.../mappers/save-my-partner-profile.mapper.ts | 9 ++
8 files changed, 210 insertions(+), 107 deletions(-)
create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/front-components/my-profile/profile-form.test.ts
create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/front-components/my-profile/profile-form.ts
diff --git a/packages/twenty-apps/internal/twenty-partners/package.json b/packages/twenty-apps/internal/twenty-partners/package.json
index baaef5531a..b24c7c83f4 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": "1.5.1",
+ "version": "1.6.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/__tests__/save-my-partner-profile.integration-test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/__tests__/save-my-partner-profile.integration-test.ts
index 1aeff132f8..f1fe9ab4b8 100644
--- a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/__tests__/save-my-partner-profile.integration-test.ts
+++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/__tests__/save-my-partner-profile.integration-test.ts
@@ -207,7 +207,7 @@ describe('save-my-partner-profile', () => {
expect(partner.hourlyRate).toEqual({ amountMicros: 150000000, currencyCode: 'USD' });
expect(partner.website?.primaryLinkUrl).toBe('https://updated.example.com');
- // Admin-only fields are never in the editable schema, so they must survive untouched.
+ // Fields absent from the body must survive untouched.
expect(partner.region).toEqual(['EUROPE']);
expect(partner.deploymentExpertise).toEqual(['CLOUD']);
});
diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/constants/my-profile.constants.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/constants/my-profile.constants.ts
index 38141c903d..4914df3f47 100644
--- a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/constants/my-profile.constants.ts
+++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/constants/my-profile.constants.ts
@@ -15,6 +15,7 @@ export type ProfileOptions = {
partnerScope: SelectOption[];
typeOfTeam: SelectOption[];
availability: SelectOption[];
+ region: SelectOption[];
};
// These mirror the option lists declared on the Partner object (partner.object.ts).
@@ -36,6 +37,14 @@ export const PROFILE_OPTIONS: ProfileOptions = {
{ value: 'AVAILABLE', label: 'Available' },
{ value: 'UNAVAILABLE', label: 'Unavailable' },
],
+ region: [
+ { value: 'EUROPE', label: 'Europe' },
+ { value: 'US', label: 'US' },
+ { value: 'LATAM', label: 'LATAM' },
+ { value: 'MENA', label: 'MENA' },
+ { value: 'APAC', label: 'APAC' },
+ { value: 'AFRICA', label: 'Africa' },
+ ],
languagesSpoken: [
{ value: 'ENGLISH', label: 'English' },
{ value: 'FRENCH', label: 'French' },
diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/front-components/my-profile.front-component.tsx b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/front-components/my-profile.front-component.tsx
index 11703f8892..8df9930199 100644
--- a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/front-components/my-profile.front-component.tsx
+++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/front-components/my-profile.front-component.tsx
@@ -17,61 +17,13 @@ import {
TagInput,
TextInput,
UrlInput,
- type SelectOption,
} from './my-profile/form-fields';
-
-type Currency = { amountMicros: number | null; currencyCode: string | null } | null;
-
-type ProfilePayload = {
- id: string;
- name: string | null;
- profilePictureUrl: string | null;
- introduction: string | null;
- city: string | null;
- country: string | null;
- languagesSpoken: string[] | null;
- partnerScope: string[] | null;
- skills: string[] | null;
- typeOfTeam: string | null;
- availability: string | null;
- hourlyRate: Currency;
- projectBudgetMin: Currency;
- website: string | null;
- linkedin: string | null;
- calendarLink: string | null;
-};
-
-type ProfileOptions = {
- country: SelectOption[];
- languagesSpoken: SelectOption[];
- partnerScope: SelectOption[];
- typeOfTeam: SelectOption[];
- availability: SelectOption[];
-};
-
-type LoadResult =
- | { ok: true; profile: ProfilePayload; options: ProfileOptions }
- | { ok: false; reason: string };
-type SaveResult = { ok: true } | { ok: false; reason: string };
-
-type MoneyField = { amount: number | null; currencyCode: string };
-
-type ProfileForm = {
- name: string;
- introduction: string;
- availability: string;
- typeOfTeam: string;
- hourlyRate: MoneyField;
- projectBudgetMin: MoneyField;
- partnerScope: string[];
- skills: string[];
- languagesSpoken: string[];
- country: string;
- city: string;
- website: string;
- linkedin: string;
- calendarLink: string;
-};
+import { toProfileForm, toSaveBody, type ProfileForm } from './my-profile/profile-form';
+import type {
+ MyPartnerProfileResult,
+ ProfileOptions,
+ SaveResult,
+} from './my-profile/types';
const SKILL_SUGGESTIONS = [
'Migrations',
@@ -90,53 +42,6 @@ const SKILL_SUGGESTIONS = [
'Custom development',
];
-const MICROS = 1_000_000;
-
-const toMoneyField = (value: Currency): MoneyField => ({
- amount: value?.amountMicros != null ? value.amountMicros / MICROS : null,
- currencyCode: value?.currencyCode ?? 'USD',
-});
-
-const toProfileForm = (profile: ProfilePayload): ProfileForm => ({
- name: profile.name ?? '',
- introduction: profile.introduction ?? '',
- availability: profile.availability ?? '',
- typeOfTeam: profile.typeOfTeam ?? '',
- hourlyRate: toMoneyField(profile.hourlyRate),
- projectBudgetMin: toMoneyField(profile.projectBudgetMin),
- partnerScope: profile.partnerScope ?? [],
- skills: profile.skills ?? [],
- languagesSpoken: profile.languagesSpoken ?? [],
- country: profile.country ?? '',
- city: profile.city ?? '',
- website: profile.website ?? '',
- linkedin: profile.linkedin ?? '',
- calendarLink: profile.calendarLink ?? '',
-});
-
-const toMicros = (money: MoneyField) =>
- money.amount == null
- ? null
- : { amountMicros: Math.round(money.amount * MICROS), currencyCode: money.currencyCode || 'USD' };
-
-// Enum/country selectors send null (not '') when reset to blank so the field clears.
-const toSaveBody = (form: ProfileForm): Record => ({
- name: form.name,
- introduction: form.introduction,
- city: form.city,
- languagesSpoken: form.languagesSpoken,
- partnerScope: form.partnerScope,
- skills: form.skills,
- website: form.website,
- linkedin: form.linkedin,
- calendarLink: form.calendarLink,
- hourlyRate: toMicros(form.hourlyRate),
- projectBudgetMin: toMicros(form.projectBudgetMin),
- availability: form.availability === '' ? null : form.availability,
- typeOfTeam: form.typeOfTeam === '' ? null : form.typeOfTeam,
- country: form.country === '' ? null : form.country,
-});
-
const styles = {
root: {
display: 'flex',
@@ -213,7 +118,7 @@ const MyProfile = () => {
const load = useCallback(async () => {
setLoading(true);
try {
- const res = (await callAppRoute('/my-partner-profile', {})) as LoadResult;
+ const res = (await callAppRoute('/my-partner-profile', {})) as MyPartnerProfileResult;
if (res.ok) {
setForm(toProfileForm(res.profile));
setOptions(res.options);
@@ -370,6 +275,13 @@ const MyProfile = () => {
+
+ set('region', value)}
+ />
+
diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/front-components/my-profile/profile-form.test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/front-components/my-profile/profile-form.test.ts
new file mode 100644
index 0000000000..732bc9f114
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/front-components/my-profile/profile-form.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, it } from 'vitest';
+
+import { toProfileForm, toSaveBody, type ProfilePayload } from './profile-form';
+
+const emptyPayload: ProfilePayload = {
+ id: 'partner-1',
+ name: 'Nine Dots Ventures',
+ profilePictureUrl: null,
+ introduction: null,
+ city: null,
+ country: null,
+ languagesSpoken: null,
+ region: null,
+ partnerScope: null,
+ skills: null,
+ typeOfTeam: null,
+ availability: null,
+ hourlyRate: null,
+ projectBudgetMin: null,
+ website: null,
+ linkedin: null,
+ calendarLink: null,
+};
+
+describe('toProfileForm', () => {
+ it('carries region through', () => {
+ expect(toProfileForm({ ...emptyPayload, region: ['EUROPE', 'MENA'] }).region).toEqual([
+ 'EUROPE',
+ 'MENA',
+ ]);
+ });
+
+ it('defaults a null region to an empty array', () => {
+ expect(toProfileForm(emptyPayload).region).toEqual([]);
+ });
+});
+
+describe('toSaveBody', () => {
+ it('sends region as an array', () => {
+ const body = toSaveBody({ ...toProfileForm(emptyPayload), region: ['APAC'] });
+ expect(body.region).toEqual(['APAC']);
+ });
+
+ it('sends an empty region array when nothing is selected', () => {
+ expect(toSaveBody(toProfileForm(emptyPayload)).region).toEqual([]);
+ });
+
+ it('sends null for a blank country, not an empty string', () => {
+ expect(toSaveBody(toProfileForm(emptyPayload)).country).toBeNull();
+ });
+
+ it('converts a money field to micros', () => {
+ const form = toProfileForm({
+ ...emptyPayload,
+ hourlyRate: { amountMicros: 150000000, currencyCode: 'USD' },
+ });
+ expect(toSaveBody(form).hourlyRate).toEqual({ amountMicros: 150000000, currencyCode: 'USD' });
+ });
+});
diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/front-components/my-profile/profile-form.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/front-components/my-profile/profile-form.ts
new file mode 100644
index 0000000000..976405896f
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/front-components/my-profile/profile-form.ts
@@ -0,0 +1,94 @@
+import type { MyProfilePayload } from './types';
+
+// Narrowed from the loader's payload so a field rename upstream breaks the build here.
+export type ProfilePayload = Pick<
+ MyProfilePayload,
+ | 'id'
+ | 'name'
+ | 'profilePictureUrl'
+ | 'introduction'
+ | 'city'
+ | 'country'
+ | 'languagesSpoken'
+ | 'region'
+ | 'partnerScope'
+ | 'skills'
+ | 'typeOfTeam'
+ | 'availability'
+ | 'hourlyRate'
+ | 'projectBudgetMin'
+ | 'website'
+ | 'linkedin'
+ | 'calendarLink'
+>;
+
+export type Currency = MyProfilePayload['hourlyRate'];
+
+export type MoneyField = { amount: number | null; currencyCode: string };
+
+export type ProfileForm = {
+ name: string;
+ introduction: string;
+ availability: string;
+ typeOfTeam: string;
+ hourlyRate: MoneyField;
+ projectBudgetMin: MoneyField;
+ partnerScope: string[];
+ skills: string[];
+ languagesSpoken: string[];
+ region: string[];
+ country: string;
+ city: string;
+ website: string;
+ linkedin: string;
+ calendarLink: string;
+};
+
+const MICROS = 1_000_000;
+
+export const toMoneyField = (value: Currency): MoneyField => ({
+ amount: value?.amountMicros != null ? value.amountMicros / MICROS : null,
+ currencyCode: value?.currencyCode ?? 'USD',
+});
+
+export const toProfileForm = (profile: ProfilePayload): ProfileForm => ({
+ name: profile.name ?? '',
+ introduction: profile.introduction ?? '',
+ availability: profile.availability ?? '',
+ typeOfTeam: profile.typeOfTeam ?? '',
+ hourlyRate: toMoneyField(profile.hourlyRate),
+ projectBudgetMin: toMoneyField(profile.projectBudgetMin),
+ partnerScope: profile.partnerScope ?? [],
+ skills: profile.skills ?? [],
+ languagesSpoken: profile.languagesSpoken ?? [],
+ region: profile.region ?? [],
+ country: profile.country ?? '',
+ city: profile.city ?? '',
+ website: profile.website ?? '',
+ linkedin: profile.linkedin ?? '',
+ calendarLink: profile.calendarLink ?? '',
+});
+
+export const toMicros = (money: MoneyField) =>
+ money.amount == null
+ ? null
+ : { amountMicros: Math.round(money.amount * MICROS), currencyCode: money.currencyCode || 'USD' };
+
+// Enum/country selectors send null (not '') when reset to blank so the field clears.
+export const toSaveBody = (form: ProfileForm): Record => ({
+ name: form.name,
+ introduction: form.introduction,
+ city: form.city,
+ languagesSpoken: form.languagesSpoken,
+ region: form.region,
+ partnerScope: form.partnerScope,
+ skills: form.skills,
+ website: form.website,
+ linkedin: form.linkedin,
+ calendarLink: form.calendarLink,
+ hourlyRate: toMicros(form.hourlyRate),
+ projectBudgetMin: toMicros(form.projectBudgetMin),
+ availability: form.availability === '' ? null : form.availability,
+ typeOfTeam: form.typeOfTeam === '' ? null : form.typeOfTeam,
+ country: form.country === '' ? null : form.country,
+});
diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/mappers/save-my-partner-profile.mapper.test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/mappers/save-my-partner-profile.mapper.test.ts
index 48e223e69b..29b5c40f6c 100644
--- a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/mappers/save-my-partner-profile.mapper.test.ts
+++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/mappers/save-my-partner-profile.mapper.test.ts
@@ -16,9 +16,9 @@ describe('saveProfileSchema', () => {
expect(saveProfileSchema.safeParse({}).success).toBe(true);
});
- it('rejects an unknown key (region)', () => {
- const parsed = saveProfileSchema.safeParse({ region: ['EUROPE'] });
- expect(parsed.success).toBe(false);
+ it('accepts a region array', () => {
+ const parsed = saveProfileSchema.safeParse({ region: ['EUROPE', 'MENA'] });
+ expect(parsed.success).toBe(true);
});
it('rejects an unknown key (validationStage)', () => {
@@ -101,6 +101,15 @@ describe('validateProfileOptionValues', () => {
it('accepts a null country (clearing it)', () => {
expect(validateProfileOptionValues({ country: null })).toBeNull();
});
+
+ it('rejects an unknown region', () => {
+ const result = validateProfileOptionValues({ region: ['EUROPE', 'ATLANTIS'] });
+ expect(result).toEqual({ error: 'Unknown region: ATLANTIS' });
+ });
+
+ it('accepts known regions', () => {
+ expect(validateProfileOptionValues({ region: ['EUROPE', 'MENA'] })).toBeNull();
+ });
});
describe('buildPartnerUpdateData', () => {
@@ -166,4 +175,15 @@ describe('buildPartnerUpdateData', () => {
it('returns an empty object for an empty input', () => {
expect(buildPartnerUpdateData({})).toEqual({});
});
+
+ it('maps region values as provided', () => {
+ expect(buildPartnerUpdateData({ region: ['EUROPE', 'MENA'] }).region).toEqual([
+ 'EUROPE',
+ 'MENA',
+ ]);
+ });
+
+ it('leaves region off the payload when absent', () => {
+ expect('region' in buildPartnerUpdateData({ name: 'Nine Dots Ventures' })).toBe(false);
+ });
});
diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/mappers/save-my-partner-profile.mapper.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/mappers/save-my-partner-profile.mapper.ts
index f8fe1c73b1..46f0e32069 100644
--- a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/mappers/save-my-partner-profile.mapper.ts
+++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/mappers/save-my-partner-profile.mapper.ts
@@ -25,6 +25,7 @@ export const saveProfileSchema = z
country: z.string().nullable().optional(),
languagesSpoken: z.array(z.string()).optional(),
partnerScope: z.array(z.string()).optional(),
+ region: z.array(z.string()).optional(),
skills: z.array(z.string()).optional(),
typeOfTeam: z.enum(['SOLO', 'AGENCY']).nullable().optional(),
availability: z.enum(['AVAILABLE', 'UNAVAILABLE']).nullable().optional(),
@@ -44,6 +45,7 @@ const optionValueSet = (options: { value: string }[]): Set =>
const COUNTRY_VALUES = optionValueSet(PROFILE_OPTIONS.country);
const LANGUAGE_VALUES = optionValueSet(PROFILE_OPTIONS.languagesSpoken);
const PARTNER_SCOPE_VALUES = optionValueSet(PROFILE_OPTIONS.partnerScope);
+const REGION_VALUES = optionValueSet(PROFILE_OPTIONS.region);
// Kept separate from buildPartnerUpdateData so each concern (validation vs.
// mapping) is independently unit-testable.
@@ -61,6 +63,10 @@ export function validateProfileOptionValues(
const unknown = input.partnerScope.find((value) => !PARTNER_SCOPE_VALUES.has(value));
if (unknown !== undefined) return { error: `Unknown partner scope: ${unknown}` };
}
+ if (input.region !== undefined) {
+ const unknown = input.region.find((value) => !REGION_VALUES.has(value));
+ if (unknown !== undefined) return { error: `Unknown region: ${unknown}` };
+ }
return null;
}
@@ -88,6 +94,9 @@ export function buildPartnerUpdateData(
(value) => value as CoreSchema.PartnerPartnerScopeEnum,
);
}
+ if (input.region !== undefined) {
+ data.region = input.region.map((value) => value as CoreSchema.PartnerRegionEnum);
+ }
if (input.skills !== undefined) data.skills = input.skills;
if (input.typeOfTeam !== undefined) data.typeOfTeam = input.typeOfTeam;
if (input.availability !== undefined) data.availability = input.availability;