v1.6.0 — Partners: let partners set the regions they serve on My Profile (#23605)

**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.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23605?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:
Rashad Karanouh
2026-07-31 15:42:32 +02:00
committed by GitHub
parent cb338962f5
commit 4f429a3976
8 changed files with 210 additions and 107 deletions
@@ -1,6 +1,6 @@
{
"name": "twenty-partners",
"version": "1.5.1",
"version": "1.6.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
@@ -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']);
});
@@ -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' },
@@ -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<string, unknown> => ({
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 = () => {
</Field>
</div>
</div>
<Field label="Regions served">
<ChipMultiSelect
value={form.region}
options={options.region}
onChange={(value) => set('region', value)}
/>
</Field>
</Section>
<Section title="Links">
@@ -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' });
});
});
@@ -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<string, unknown> => ({
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,
});
@@ -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);
});
});
@@ -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<string> =>
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;