diff --git a/apps/portal/src/app/features/profile/api/address-api.ts b/apps/portal/src/app/features/profile/api/address-api.ts index 28f85ecd2..16c26a994 100644 --- a/apps/portal/src/app/features/profile/api/address-api.ts +++ b/apps/portal/src/app/features/profile/api/address-api.ts @@ -5,7 +5,7 @@ const addressApi = baseApi .enhanceEndpoints({ addTagTypes: ['CurrentProfile'] as const }) .injectEndpoints({ endpoints: (builder) => ({ - /** Create-or-update the signed-in user's address. */ + /** The profile-address endpoint creates or updates through POST. */ saveMyAddress: builder.mutation({ query: ({ profileId, body }) => ({ url: `/addresss/profile/${profileId}`, diff --git a/apps/portal/src/app/features/profile/components/AddressFormContent.tsx b/apps/portal/src/app/features/profile/components/AddressFormContent.tsx index 33f5cd4ff..b05c6986c 100644 --- a/apps/portal/src/app/features/profile/components/AddressFormContent.tsx +++ b/apps/portal/src/app/features/profile/components/AddressFormContent.tsx @@ -5,7 +5,7 @@ import { z } from 'zod'; import { ethiopianPhone, optionalEthiopianPhone, CountrySelect } from '@ema-platform/ui'; import { LocationPicker } from '../../location/components/LocationPicker'; import { useGetLocationTypesQuery } from '../../location/api/location-api'; -import type { Location } from '../../location/types/location'; +import type { Location, LocationType } from '../../location/types/location'; export const addressSchema = z.object({ idType: z.string().min(1, 'Select ID type'), @@ -17,7 +17,7 @@ export const addressSchema = z.object({ email: z.string().trim().email('Invalid email').optional().or(z.literal('')), regionId: z.string().optional(), cityId: z.string().optional(), - subcityId: z.string().optional(), + subCityId: z.string().optional(), woredaId: z.string().optional(), kebeleId: z.string().optional(), streetAddress: z.string().trim().optional(), @@ -45,13 +45,24 @@ export const ID_TYPES = [ * mapped to a field by type *code* rather than by numeric level — this * resolves correctly whether or not a COUNTRY type sits above REGION. */ -const FIELD_BY_CODE: Record = { - REGION: 'regionId', - CITY: 'cityId', - SUBCITY: 'subcityId', - SUB_CITY: 'subcityId', - WOREDA: 'woredaId', -}; +function fieldsForLocationType(type: LocationType): Array { + // Location types have historically used variants such as SUBCITY, + // SUB_CITY, and SUB-CITY. Normalize both the code and display name so a + // selected sub-city is never dropped from the address payload. + const typeName = `${type.code} ${type.names.en}` + .toUpperCase() + .replace(/[^A-Z0-9]/g, ''); + + if (typeName.includes('SUBCITY')) return ['subCityId']; + if (typeName.includes('WOREDA')) return ['woredaId']; + if (typeName.includes('REGION')) return ['regionId']; + if (typeName.includes('CITY')) { + // The profile-completeness API treats the selected City as its region. + // Keep its conventional cityId too, as other profile consumers use it. + return ['regionId', 'cityId']; + } + return []; +} interface AddressFormContentProps { register: UseFormRegister; @@ -69,29 +80,30 @@ export function AddressFormContent({ trigger, }: AddressFormContentProps) { const { data: typesRes } = useGetLocationTypesQuery(); - const locationTypes = typesRes?.items ?? []; + const locationTypes = typesRes?.items; const typeFieldMap = useMemo(() => { - const map = new Map(); - locationTypes.forEach((lt) => { - const field = FIELD_BY_CODE[lt.code.toUpperCase()]; - if (field) map.set(lt.id, field); + const map = new Map>(); + locationTypes?.forEach((lt) => { + const fields = fieldsForLocationType(lt); + if (fields.length) map.set(lt.id, fields); }); return map; }, [locationTypes]); - const leafId = watch('woredaId') || watch('subcityId') || watch('cityId') || watch('regionId') || undefined; + const leafId = watch('woredaId') || watch('subCityId') || watch('cityId') || watch('regionId') || undefined; const handleChainChange = useCallback( (chain: Location[]) => { setValue('regionId', ''); setValue('cityId', ''); - setValue('subcityId', ''); + setValue('subCityId', ''); setValue('woredaId', ''); chain.forEach((loc) => { - const field = typeFieldMap.get(loc.locationTypeId); - if (field) setValue(field, loc.id); + typeFieldMap.get(loc.locationTypeId)?.forEach((field) => { + setValue(field, loc.id); + }); }); }, [setValue, typeFieldMap], diff --git a/apps/portal/src/app/features/profile/pages/ProfilePage.tsx b/apps/portal/src/app/features/profile/pages/ProfilePage.tsx index cc638ed40..87cc9921b 100644 --- a/apps/portal/src/app/features/profile/pages/ProfilePage.tsx +++ b/apps/portal/src/app/features/profile/pages/ProfilePage.tsx @@ -86,6 +86,19 @@ function getInitials(name: string, fallback: string) { return letters.toUpperCase(); } +function splitProfileName(fullName: string) { + const [firstName = '', middleName = '', ...lastName] = fullName.trim().split(/\s+/); + return { firstName, middleName, lastName: lastName.join(' ') }; +} + +function formatProfileName({ firstName, middleName, lastName }: Pick) { + return [firstName, middleName, lastName].filter(Boolean).join(' '); +} + +function normalizeName(name: string) { + return name.trim().replace(/\s+/g, ' '); +} + function passwordScore(pw: string) { if (!pw) return 0; let score = 0; @@ -177,11 +190,12 @@ export function ProfilePage() { // already holds so the form does not flash empty on a refetch. const currentProfile = resolvedProfile ?? storedProfile; if (currentProfile) { + const accountName = user?.name?.en ? splitProfileName(user.name.en) : null; setLoadedProfile({ professionId: currentProfile.professionId || currentProfile.profession?.id || '', - firstName: currentProfile.firstName || '', - middleName: currentProfile.middleName || '', - lastName: currentProfile.lastName || '', + firstName: accountName?.firstName || currentProfile.firstName || '', + middleName: accountName?.middleName || currentProfile.middleName || '', + lastName: accountName?.lastName || currentProfile.lastName || '', gender: currentProfile.gender || '', dob: currentProfile.dob ? currentProfile.dob.split('T')[0] : '', pob: currentProfile.pob || '', @@ -200,8 +214,8 @@ export function ProfilePage() { secondaryPhoneNumber: currentProfile.address?.secondaryPhoneNumber || '', email: user?.email || '', regionId: currentProfile.address?.regionId || '', - cityId: currentProfile.address?.cityId || '', - subcityId: currentProfile.address?.subCityId || '', + cityId: currentProfile.address?.cityId || currentProfile.address?.regionId || '', + subCityId: currentProfile.address?.subCityId || '', woredaId: currentProfile.address?.woredaId || '', streetAddress: currentProfile.address?.streetAddress || '', postalAddress: currentProfile.address?.postalAddress || '', @@ -222,7 +236,12 @@ export function ProfilePage() { // ---- Personal form (auth user data) ---- const personalSchema = z.object({ - nameEn: z.string().min(1, { message: t('profile.validation.nameRequired') }), + nameEn: z + .string() + .refine( + (name) => Object.values(splitProfileName(name)).every(Boolean), + { message: 'Enter your first, middle, and last name' }, + ), nameAm: z.string().min(1, { message: t('profile.validation.nameRequired') }), username: z.string().min(1, { message: t('profile.validation.usernameRequired') }), email: z.string().email({ message: t('profile.validation.emailInvalid') }), @@ -251,16 +270,33 @@ export function ProfilePage() { setIsSavingProfile(true); try { - await updateTrigger({ - url: '/auth/update-profile', - method: 'PATCH', - body: { - email: values.email, - username: values.username, - phoneNumber: values.phoneNumber, - name: { am: values.nameAm, en: values.nameEn }, - }, - }).unwrap(); + const profileName = splitProfileName(values.nameEn); + const saves: Promise[] = [ + updateTrigger({ + url: '/auth/update-profile', + method: 'PATCH', + body: { + email: values.email, + username: values.username, + phoneNumber: values.phoneNumber, + name: { am: values.nameAm, en: values.nameEn }, + }, + }).unwrap(), + ]; + + // The Profile tab stores names separately as first/middle/last. + // Save those fields alongside the account's display name so either tab + // always describes the same person. + if (profileId) { + saves.push( + updateProfile({ + url: `/profiles/${profileId}`, + method: 'PATCH', + body: profileName, + }).unwrap(), + ); + } + await Promise.all(saves); // Update the session from the values that were just accepted. The // endpoint is allowed to return no body (or a response wrapper), so @@ -274,6 +310,9 @@ export function ProfilePage() { name: { am: values.nameAm, en: values.nameEn }, }; dispatch(setUser(updatedUser)); + setLoadedProfile((current) => + current ? { ...current, ...profileName } : current, + ); resetPersonal({ nameEn: updatedUser.name.en, nameAm: updatedUser.name.am, @@ -307,6 +346,13 @@ export function ProfilePage() { const onSaveProfile = async (values: ProfileValues) => { if (!profileId) return; + + const fullName = formatProfileName(values); + if (user && normalizeName(fullName) !== normalizeName(user.name.en)) { + notify.error('Profile name must match the name in the Personal tab.'); + return; + } + setIsSavingMaritime(true); try { await updateProfile({ @@ -314,6 +360,7 @@ export function ProfilePage() { method: 'PUT', body: values, }).unwrap(); + setLoadedProfile({ ...values, pob: values.pob ?? '' }); // This endpoint doesn't invalidate the `CurrentProfile` tag (unlike // the address save below) — without this, `missing` stays stale until @@ -343,7 +390,10 @@ export function ProfilePage() { const onSaveAddress = async (values: AddressValues) => { if (!profileId) return; try { - await saveMyAddress({ profileId, body: toAddressPayload(values) }).unwrap(); + await saveMyAddress({ + profileId, + body: toAddressPayload(values), + }).unwrap(); notify.success('Address saved'); } catch (e) { handleError(e); diff --git a/apps/portal/src/app/features/profile/types/address.ts b/apps/portal/src/app/features/profile/types/address.ts index 47b1f9850..b860e3d37 100644 --- a/apps/portal/src/app/features/profile/types/address.ts +++ b/apps/portal/src/app/features/profile/types/address.ts @@ -8,6 +8,8 @@ export interface AddressPayload { nationality: string; regionId?: string; cityId?: string; + subCityId?: string; + /** Legacy spelling still accepted by the address upsert endpoint. */ subcityId?: string; woredaId?: string; kebeleId?: string; @@ -25,15 +27,25 @@ export interface AddressPayload { /** Blank optional strings drop out; `kebeleId` always mirrors `woredaId`. */ export function toAddressPayload(values: AddressValues): AddressPayload { const clean = (v?: string) => (v && v.trim() ? v.trim() : undefined); + const regionId = clean(values.regionId); + // The location service uses the selected City for the profile's region. + // Send that same id as cityId too, because profile completeness requires + // both fields even when the location tree has no separate region node. + const cityId = clean(values.cityId) ?? regionId; + const subCityId = clean(values.subCityId); return { idType: values.idType.trim(), idNumber: values.idNumber.trim(), // Form holds the alpha-2 code (CountrySelect); API stores the full name. nationality: getCountryName(values.nationality), - regionId: clean(values.regionId), - cityId: clean(values.cityId), - subcityId: clean(values.subcityId), + regionId, + cityId, + // `subCityId` is the profile field. The address endpoint has accepted + // the older lower-case spelling too, so submit the same selected id under + // both names until its DTO is fully aligned. + subCityId, + subcityId: subCityId, woredaId: clean(values.woredaId), kebeleId: clean(values.woredaId), // no separate Kebele field — mirrors woredaId streetAddress: clean(values.streetAddress),