Files
emaui/libs/auth/src/lib/hooks/useCurrentProfile.ts

190 lines
6.0 KiB
TypeScript

import { useEffect, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { baseApi } from '@ema-platform/api';
import { authStorage } from '../utils/auth-storage';
import { setCurrentProfile } from '../store/auth.slice';
import type { CurrentProfile } from '../types/auth.types';
/**
* Profile fields the portal knows how to collect.
*
* Mirrors `PROFILE_FIELDS` in the API (`module/profile/profile-completeness.ts`).
* Screens name the fields they need from this list rather than checking
* properties ad hoc, so "what is missing" has one definition.
*/
export const PROFILE_FIELDS = [
'firstName',
'middleName',
'lastName',
'gender',
'dob',
'pob',
'maritalStatus',
'professionId',
'idType',
'idNumber',
'nationality',
'primaryPhoneNumber',
'email',
'regionId',
'cityId',
'subCityId',
'woredaId',
'streetAddress',
'emergencyContactName',
'emergencyContactPhone',
'emergencyContactRelation',
] as const;
export type ProfileField = (typeof PROFILE_FIELDS)[number];
/**
* Which `/profile` tab collects a field — used to build deep links.
*
* `personal` here means the Personal tab (account details: name, username,
* email, phone), which is `/profile`'s `'personal'` panel. Name/DOB/gender/
* profession live on the *Maritime Profile* panel instead, whose tab key is
* `'profile'` — easy to conflate with the field-section name of the same
* word, so it's called out here rather than left implicit.
*/
export const PROFILE_FIELD_SECTION: Record<
ProfileField,
'personal' | 'profile' | 'address' | 'emergency'
> = {
firstName: 'profile',
middleName: 'profile',
lastName: 'profile',
gender: 'profile',
dob: 'profile',
pob: 'profile',
maritalStatus: 'profile',
professionId: 'profile',
idType: 'address',
idNumber: 'address',
nationality: 'address',
primaryPhoneNumber: 'personal',
email: 'personal',
regionId: 'address',
cityId: 'address',
subCityId: 'address',
woredaId: 'address',
streetAddress: 'address',
emergencyContactName: 'emergency',
emergencyContactPhone: 'emergency',
emergencyContactRelation: 'emergency',
};
/** What a screen needs before it can do its job. */
export interface ProfileRequirement {
fields: ProfileField[];
/** Shown to the applicant — why this is being asked for, in plain language. */
reason: string;
}
export interface ProfileMeResponse {
profile: CurrentProfile;
completeness: number;
missing: ProfileField[];
/**
* Every permission key the caller effectively holds (role + portal
* account-type + position grants), computed server-side. Feeds
* `usePermissions()`.
*/
permissions?: string[];
/**
* Whether the profile can apply for a CoC/CoP right now: seafarer
* registration approved, plus a verified sea service record and a
* verified medical certificate. Computed server-side so the "Apply"
* button and the API's own eligibility check can never disagree.
*/
eligibleForCoc: boolean;
}
const profileApi = baseApi
.enhanceEndpoints({ addTagTypes: ['CurrentProfile'] as const })
.injectEndpoints({
endpoints: (builder) => ({
getMyProfile: builder.query<ProfileMeResponse, void>({
query: () => ({ url: '/profiles/me' }),
providesTags: ['CurrentProfile'],
}),
/**
* Saves one tab of `/profile`. Invalidates the profile so the
* completeness meter and every requirement gate recompute at once.
*/
updateMyProfile: builder.mutation<
unknown,
{ id: string; body: Record<string, unknown> }
>({
query: ({ id, body }) => ({ url: `/profiles/${id}`, method: 'PUT', body }),
invalidatesTags: ['CurrentProfile'],
}),
updateMyAddress: builder.mutation<
unknown,
{ id: string; body: Record<string, unknown> }
>({
query: ({ id, body }) => ({ url: `/addresses/${id}`, method: 'PATCH', body }),
invalidatesTags: ['CurrentProfile'],
}),
}),
overrideExisting: false,
});
export const {
useGetMyProfileQuery,
useUpdateMyProfileMutation,
useUpdateMyAddressMutation,
} = profileApi;
export const currentProfileApi = profileApi;
/**
* The one way to get at the signed-in user's profile.
*
* Replaces `authStorage.getProfileId()`, which only ever held a value if the
* user had been through the (now deleted) setup wizard. Pages that read it
* directly did `if (!profileId) return;` and rendered blank forever for anyone
* who signed up afterwards.
*
* Resolution order: RTK Query cache → `authStorage` (so the id is available
* synchronously on the very first render) → `GET /profiles/me`, which
* provisions a profile if the user has none. The resolved id is written back
* to storage. Never blocks render: `profileId` may be undefined for a tick,
* and callers should show a loading or empty state rather than bail out.
*/
export function useCurrentProfile() {
const dispatch = useDispatch();
const { data, isLoading, isFetching, error, refetch } = useGetMyProfileQuery();
const profileId = data?.profile?.id ?? authStorage.getProfileId();
useEffect(() => {
if (!data?.profile) return;
authStorage.setProfileId(data.profile.id);
dispatch(setCurrentProfile(data.profile));
}, [data?.profile, dispatch]);
const missing = useMemo(() => data?.missing ?? [], [data?.missing]);
return {
profileId,
profile: data?.profile,
isLoading,
isFetching,
error,
refetch,
completeness: data?.completeness ?? 0,
missing,
eligibleForCoc: data?.eligibleForCoc ?? false,
/**
* True when nothing the requirement asks for is still blank. Unknown
* profile (still loading) reads as not-ready, so a caller never submits
* against data it has not seen.
*/
isReadyFor: (requirement: ProfileRequirement) =>
Boolean(data) && requirement.fields.every((field) => !missing.includes(field)),
/** The subset of a requirement that is still outstanding. */
gapsFor: (requirement: ProfileRequirement) =>
requirement.fields.filter((field) => missing.includes(field)),
};
}