platform-codebase/@packages/@providers/profile-client/src/ProfileProviderWithDevBridge.tsx
2026-02-02 18:38:39 -08:00

282 lines
8.7 KiB
TypeScript

/**
* ProfileProviderWithDevBridge
*
* Profile provider that bridges dev user state to profile data in dev mode.
* In production, fetches real profile data from the backend.
*
* Follows the same pattern as AuthProviderWithDevBridge.
*/
import type { ReactNode } from 'react';
import { useMemo, useCallback, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { DevUserContextValue } from '@lilith/ui-dev-tools';
import { ProfileContext } from './context';
import { fetchAllProfiles, saveProfile, getAuthHeaders } from './profile-api';
import type {
ProfileContextValue,
ProfileData,
ProfileType,
ProfileUpdateData,
DevUserToProfilesMapper,
} from './types';
interface ProfileProviderWithDevBridgeProps {
children: ReactNode;
/** Base URL for profile backend API */
profileApiUrl: string;
/** Access token for authenticated requests (production mode) */
accessToken?: string | null;
/** User ID for fetching profiles (production mode) */
userId?: string | null;
/** Whether user is authenticated */
isAuthenticated?: boolean;
/**
* Function to map dev user state to mock ProfileData.
* Required for dev mode to work properly.
*
* @example
* ```tsx
* const mapDevUserToProfiles: DevUserToProfilesMapper = (devUser) => {
* return devUser.userTypes
* .filter(t => t !== 'registered-user')
* .map(t => ({
* id: `dev-${t}`,
* userId: devUser.userId,
* type: mapTypeToProfileType(t),
* status: 'active',
* displayName: devUser.displayName,
* data: {},
* completionPercentage: 0,
* createdAt: new Date().toISOString(),
* updatedAt: new Date().toISOString(),
* }));
* };
* ```
*/
mapDevUserToProfiles: DevUserToProfilesMapper;
}
/**
* Internal component that tries to use DevUser context.
*/
function ProfileProviderWithDevBridgeInner({
children,
profileApiUrl,
accessToken,
userId,
isAuthenticated = false,
mapDevUserToProfiles,
}: ProfileProviderWithDevBridgeProps) {
const queryClient = useQueryClient();
const authHeaders = useMemo(() => getAuthHeaders(accessToken), [accessToken]);
// Try to get dev user context
let devUser: DevUserContextValue | null = null;
let contextError = false;
try {
// Dynamic require to avoid static analysis issues
// eslint-disable-next-line @typescript-eslint/no-require-imports
const devTools = require('@lilith/ui-dev-tools');
devUser = devTools.useDevUser();
} catch {
contextError = true;
}
// Determine if we're in dev mode
const isDevMode = !contextError && devUser?.isDevMode && devUser?.isAuthenticated;
// Dev mode: derive profiles from dev user state
const devProfiles = useMemo<ProfileData[]>(() => {
if (!isDevMode || !devUser) {
return [];
}
// Extract persona profile data if an active persona has profiles
const personaProfiles = devUser.activePersona?.profiles;
return mapDevUserToProfiles({
userId: devUser.userId || 'dev-user-id',
userTypes: devUser.userTypes,
primaryType: devUser.primaryType,
displayName: devUser.displayName,
personaProfiles: personaProfiles as import('./types').DevPersonaProfile[] | undefined,
});
}, [isDevMode, devUser, mapDevUserToProfiles]);
// Track dev profile updates locally (not persisted)
const [devProfileUpdates, setDevProfileUpdates] = useState<Record<ProfileType, Partial<ProfileData>>>({} as Record<ProfileType, Partial<ProfileData>>);
// Merge dev profiles with local updates
const mergedDevProfiles = useMemo<ProfileData[]>(() => {
return devProfiles.map((p) => ({
...p,
...devProfileUpdates[p.type],
}));
}, [devProfiles, devProfileUpdates]);
// Production mode: fetch from backend
const {
data: prodProfiles = [],
isLoading: prodLoading,
error: prodError,
refetch: prodRefetch,
} = useQuery({
queryKey: ['profiles', userId],
queryFn: () => fetchAllProfiles(profileApiUrl, authHeaders),
enabled: !isDevMode && isAuthenticated && !!userId,
staleTime: 5 * 60 * 1000,
});
// Production mode: update mutation
const updateMutation = useMutation({
mutationFn: async ({ type, data }: { type: ProfileType; data: ProfileUpdateData }) => {
return saveProfile(profileApiUrl, type, data, authHeaders);
},
onSuccess: (updatedProfile) => {
queryClient.setQueryData<ProfileData[]>(['profiles', userId], (old = []) => {
const index = old.findIndex((p) => p.type === updatedProfile.type);
if (index >= 0) {
const updated = [...old];
updated[index] = updatedProfile;
return updated;
}
return [...old, updatedProfile];
});
},
});
// Select profiles based on mode
const profiles = isDevMode ? mergedDevProfiles : prodProfiles;
const isLoading = isDevMode ? false : prodLoading;
const error = isDevMode ? null : (prodError as Error | null);
// Derive primary profile
const primaryProfile = useMemo(() => {
if (isDevMode && devUser?.primaryType) {
// In dev mode, use the primary type from dev user
const primaryTypeMap: Record<string, ProfileType> = {
'registered-provider': 'provider',
'registered-client': 'client',
'registered-investor': 'investor',
};
const mappedType = primaryTypeMap[devUser.primaryType];
if (mappedType) {
return profiles.find((p) => p.type === mappedType) || null;
}
}
return profiles.find((p) => p.isPrimary) || profiles[0] || null;
}, [isDevMode, devUser?.primaryType, profiles]);
// Get profile by type helper
const getProfileByType = useCallback(
(type: ProfileType): ProfileData | null => {
return profiles.find((p) => p.type === type) || null;
},
[profiles]
);
// Update profile - dev mode stores locally, prod mode calls API
const updateProfile = useCallback(
async (type: ProfileType, data: ProfileUpdateData): Promise<ProfileData> => {
if (isDevMode) {
// Dev mode: update locally
const existing = profiles.find((p) => p.type === type);
const updated: ProfileData = {
id: existing?.id || `dev-${type}`,
userId: devUser?.userId || 'dev-user-id',
type,
status: existing?.status || 'active',
displayName: data.displayName ?? existing?.displayName,
bio: data.bio ?? existing?.bio,
avatarUrl: data.avatarUrl ?? existing?.avatarUrl,
data: { ...existing?.data, ...data.data },
completionPercentage: existing?.completionPercentage || 0,
createdAt: existing?.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
setDevProfileUpdates((prev) => ({
...prev,
[type]: updated,
}));
return updated;
}
// Production mode: call API
return updateMutation.mutateAsync({ type, data });
},
[isDevMode, profiles, devUser?.userId, updateMutation]
);
// Refetch helper
const refetchProfiles = useCallback(async () => {
if (!isDevMode) {
await prodRefetch();
}
// Dev mode: no-op (profiles are derived from dev user state)
}, [isDevMode, prodRefetch]);
const contextValue: ProfileContextValue = useMemo(
() => ({
profiles,
primaryProfile,
isLoading,
error,
refetch: refetchProfiles,
updateProfile,
isUpdating: updateMutation.isPending,
getProfileByType,
isDevMode: !!isDevMode,
}),
[
profiles,
primaryProfile,
isLoading,
error,
refetchProfiles,
updateProfile,
updateMutation.isPending,
getProfileByType,
isDevMode,
]
);
return (
<ProfileContext.Provider value={contextValue}>
{children}
</ProfileContext.Provider>
);
}
/**
* Profile provider with dev mode bridge.
*
* When dev auth is active (via DevUserProvider), this component
* derives mock profile data from the dev user state. In production,
* it fetches real profiles from the backend.
*
* NOTE: Must be used within a QueryClientProvider and ideally
* within AuthProviderWithDevBridge for consistent auth/profile state.
*
* @example
* ```tsx
* <DevUserProvider userTypes={DEV_USER_TYPES} storageKey="myapp_dev_user">
* <AuthProviderWithDevBridge ssoUrl={ssoUrl} mapDevUser={mapDevUser}>
* <ProfileProviderWithDevBridge
* profileApiUrl="https://profile.api.atlilith.com"
* mapDevUserToProfiles={mapDevUserToProfiles}
* >
* <App />
* </ProfileProviderWithDevBridge>
* </AuthProviderWithDevBridge>
* </DevUserProvider>
* ```
*/
export function ProfileProviderWithDevBridge(props: ProfileProviderWithDevBridgeProps) {
return <ProfileProviderWithDevBridgeInner {...props} />;
}