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. Review in cubic --- .../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;