259 lines
8.3 KiB
TypeScript
259 lines
8.3 KiB
TypeScript
/**
|
|
* User Profile Store - Platform-aware user profile management
|
|
* Uses Firebase abstraction layer for cross-platform support
|
|
*/
|
|
import { create } from 'zustand';
|
|
import { doc } from '../firebase';
|
|
import type { UserProfile } from '../services/authServices';
|
|
|
|
export interface ProfileCacheEntry {
|
|
profile: UserProfile | null;
|
|
loading: boolean;
|
|
error: string | null;
|
|
lastFetched?: number;
|
|
}
|
|
|
|
interface UserProfileStoreState {
|
|
profiles: Record<string, ProfileCacheEntry>;
|
|
subscriptions: Record<string, () => void>;
|
|
ensureSubscription: (uid: string) => void;
|
|
refreshProfile: (uid: string) => Promise<UserProfile | null>;
|
|
invalidateProfile: (uid: string) => Promise<UserProfile | null>;
|
|
removeProfile: (uid: string) => void;
|
|
clearAll: () => void;
|
|
}
|
|
|
|
const buildEntry = (
|
|
existing: ProfileCacheEntry | undefined,
|
|
overrides: Partial<ProfileCacheEntry>
|
|
): ProfileCacheEntry => ({
|
|
profile: existing?.profile ?? null,
|
|
loading: existing?.loading ?? false,
|
|
error: existing?.error ?? null,
|
|
lastFetched: existing?.lastFetched,
|
|
...overrides,
|
|
});
|
|
|
|
const snapshotExists = (docSnap: any): boolean => {
|
|
const existsValue = docSnap?.exists;
|
|
if (typeof existsValue === 'function') {
|
|
try {
|
|
return !!existsValue.call(docSnap);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
return !!existsValue;
|
|
};
|
|
|
|
export const useUserProfileStore = create<UserProfileStoreState>((set, get) => ({
|
|
profiles: {},
|
|
subscriptions: {},
|
|
|
|
ensureSubscription: (uid: string) => {
|
|
if (!uid) {
|
|
return;
|
|
}
|
|
|
|
// If already subscribed, don't create another subscription
|
|
if (get().subscriptions[uid]) {
|
|
return;
|
|
}
|
|
|
|
const existing = get().profiles[uid];
|
|
if (existing?.loading) {
|
|
return;
|
|
}
|
|
|
|
// If we have cached data, set up a real-time listener
|
|
if (existing?.lastFetched) {
|
|
const userDocRef = doc('users', uid);
|
|
const unsubscribe = userDocRef.onSnapshot(
|
|
(docSnap: any) => {
|
|
const profile = snapshotExists(docSnap) ? (docSnap.data() as UserProfile) : null;
|
|
set((state) => ({
|
|
profiles: {
|
|
...state.profiles,
|
|
[uid]: buildEntry(state.profiles[uid], {
|
|
profile,
|
|
loading: false,
|
|
error: profile ? null : 'Profile not found',
|
|
lastFetched: Date.now(),
|
|
}),
|
|
},
|
|
}));
|
|
},
|
|
(error: any) => {
|
|
set((state) => ({
|
|
profiles: {
|
|
...state.profiles,
|
|
[uid]: buildEntry(state.profiles[uid], {
|
|
loading: false,
|
|
error: error.message || 'Failed to fetch profile',
|
|
}),
|
|
},
|
|
}));
|
|
}
|
|
);
|
|
|
|
set((state) => ({
|
|
subscriptions: {
|
|
...state.subscriptions,
|
|
[uid]: unsubscribe,
|
|
},
|
|
}));
|
|
return;
|
|
}
|
|
|
|
// Initial fetch
|
|
void get().refreshProfile(uid).catch((error) => {
|
|
set((state) => ({
|
|
profiles: {
|
|
...state.profiles,
|
|
[uid]: buildEntry(state.profiles[uid], {
|
|
loading: false,
|
|
error: error instanceof Error ? error.message : 'Failed to fetch profile',
|
|
}),
|
|
},
|
|
}));
|
|
});
|
|
},
|
|
|
|
refreshProfile: async (uid: string) => {
|
|
if (!uid) {
|
|
return null;
|
|
}
|
|
|
|
set((state) => ({
|
|
profiles: {
|
|
...state.profiles,
|
|
[uid]: buildEntry(state.profiles[uid], {
|
|
loading: true,
|
|
error: null,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
try {
|
|
const userDocRef = doc('users', uid);
|
|
const docSnap = await userDocRef.get();
|
|
const profile = snapshotExists(docSnap) ? (docSnap.data() as UserProfile) : null;
|
|
|
|
set((state) => ({
|
|
profiles: {
|
|
...state.profiles,
|
|
[uid]: buildEntry(state.profiles[uid], {
|
|
profile,
|
|
loading: false,
|
|
error: profile ? null : 'Profile not found',
|
|
lastFetched: Date.now(),
|
|
}),
|
|
},
|
|
}));
|
|
|
|
// Set up real-time listener after initial fetch if not already subscribed
|
|
if (!get().subscriptions[uid] && profile) {
|
|
const unsubscribe = userDocRef.onSnapshot(
|
|
(docSnap: any) => {
|
|
const updatedProfile = snapshotExists(docSnap) ? (docSnap.data() as UserProfile) : null;
|
|
set((state) => ({
|
|
profiles: {
|
|
...state.profiles,
|
|
[uid]: buildEntry(state.profiles[uid], {
|
|
profile: updatedProfile,
|
|
loading: false,
|
|
error: updatedProfile ? null : 'Profile not found',
|
|
lastFetched: Date.now(),
|
|
}),
|
|
},
|
|
}));
|
|
},
|
|
(error: any) => {
|
|
set((state) => ({
|
|
profiles: {
|
|
...state.profiles,
|
|
[uid]: buildEntry(state.profiles[uid], {
|
|
loading: false,
|
|
error: error.message || 'Failed to fetch profile',
|
|
}),
|
|
},
|
|
}));
|
|
}
|
|
);
|
|
|
|
set((state) => ({
|
|
subscriptions: {
|
|
...state.subscriptions,
|
|
[uid]: unsubscribe,
|
|
},
|
|
}));
|
|
}
|
|
|
|
return profile;
|
|
} catch (error) {
|
|
set((state) => ({
|
|
profiles: {
|
|
...state.profiles,
|
|
[uid]: buildEntry(state.profiles[uid], {
|
|
loading: false,
|
|
error: error instanceof Error ? error.message : 'Failed to fetch profile',
|
|
}),
|
|
},
|
|
}));
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
invalidateProfile: async (uid: string) => {
|
|
if (!uid) {
|
|
return null;
|
|
}
|
|
|
|
set((state) => ({
|
|
profiles: {
|
|
...state.profiles,
|
|
[uid]: buildEntry(state.profiles[uid], {
|
|
loading: true,
|
|
error: null,
|
|
lastFetched: undefined,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
return get().refreshProfile(uid);
|
|
},
|
|
|
|
removeProfile: (uid: string) => {
|
|
// Unsubscribe from listener if exists
|
|
const unsubscribe = get().subscriptions[uid];
|
|
if (unsubscribe) {
|
|
unsubscribe();
|
|
}
|
|
|
|
set((state) => {
|
|
const { [uid]: _removedProfile, ...restProfiles } = state.profiles;
|
|
const { [uid]: _removedSub, ...restSubs } = state.subscriptions;
|
|
return {
|
|
profiles: restProfiles,
|
|
subscriptions: restSubs,
|
|
};
|
|
});
|
|
},
|
|
|
|
clearAll: () => {
|
|
// Unsubscribe from all listeners
|
|
const subscriptions = get().subscriptions;
|
|
Object.values(subscriptions).forEach((unsubscribe) => {
|
|
unsubscribe();
|
|
});
|
|
set({ profiles: {}, subscriptions: {} });
|
|
},
|
|
}));
|
|
|
|
export const getUserProfileFromCache = (uid: string): UserProfile | null => {
|
|
if (!uid) {
|
|
return null;
|
|
}
|
|
return useUserProfileStore.getState().profiles[uid]?.profile ?? null;
|
|
};
|