From 3458bb0bbc2b003d572286c6db023c264a12dfbd Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 28 Jan 2026 04:40:15 +0530 Subject: [PATCH] Add UserProvider and user data management with Redux - Introduced UserProvider to automatically fetch user data when a JWT token is available, enhancing user experience by ensuring data is readily accessible. - Created useUser hook for accessing user data and managing loading states, streamlining the process of fetching and utilizing user information. - Implemented userSlice for managing user state in Redux, including actions for fetching and clearing user data, improving state management consistency. - Added apiClient for handling API requests, including error handling and authentication, to facilitate communication with the backend. These changes enhance the application's user data management and improve the overall architecture by integrating user state with Redux. --- src/App.tsx | 9 +- src/hooks/useUser.ts | 28 ++++++ src/providers/UserProvider.tsx | 24 +++++ src/services/api/userApi.ts | 19 ++++ src/services/apiClient.ts | 159 +++++++++++++++++++++++++++++++++ src/store/authSlice.ts | 13 ++- src/store/index.ts | 8 +- src/store/userSlice.ts | 69 ++++++++++++++ src/types/api.ts | 70 +++++++++++++++ 9 files changed, 390 insertions(+), 9 deletions(-) create mode 100644 src/hooks/useUser.ts create mode 100644 src/providers/UserProvider.tsx create mode 100644 src/services/api/userApi.ts create mode 100644 src/services/apiClient.ts create mode 100644 src/store/userSlice.ts create mode 100644 src/types/api.ts diff --git a/src/App.tsx b/src/App.tsx index 0a06a9648..54bb79888 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import { Provider } from 'react-redux'; import { PersistGate } from 'redux-persist/integration/react'; import { store, persistor } from './store'; import SocketProvider from './providers/SocketProvider'; +import UserProvider from './providers/UserProvider'; import AppRoutes from './AppRoutes'; function App() { @@ -10,9 +11,11 @@ function App() { - - - + + + + + diff --git a/src/hooks/useUser.ts b/src/hooks/useUser.ts new file mode 100644 index 000000000..48bf54640 --- /dev/null +++ b/src/hooks/useUser.ts @@ -0,0 +1,28 @@ +import { useEffect } from 'react'; +import { useAppSelector, useAppDispatch } from '../store/hooks'; +import { fetchCurrentUser } from '../store/userSlice'; + +/** + * Hook to access user data and automatically fetch it when token is available + */ +export const useUser = () => { + const dispatch = useAppDispatch(); + const token = useAppSelector((state) => state.auth.token); + const user = useAppSelector((state) => state.user.user); + const isLoading = useAppSelector((state) => state.user.isLoading); + const error = useAppSelector((state) => state.user.error); + + useEffect(() => { + // Fetch user data when token is available and user is not loaded + if (token && !user && !isLoading) { + dispatch(fetchCurrentUser()); + } + }, [token, user, isLoading, dispatch]); + + return { + user, + isLoading, + error, + refetch: () => dispatch(fetchCurrentUser()), + }; +}; diff --git a/src/providers/UserProvider.tsx b/src/providers/UserProvider.tsx new file mode 100644 index 000000000..02eace3b7 --- /dev/null +++ b/src/providers/UserProvider.tsx @@ -0,0 +1,24 @@ +import { useEffect } from 'react'; +import { useAppSelector, useAppDispatch } from '../store/hooks'; +import { fetchCurrentUser } from '../store/userSlice'; + +/** + * UserProvider automatically fetches user data when JWT token is available + */ +const UserProvider = ({ children }: { children: React.ReactNode }) => { + const dispatch = useAppDispatch(); + const token = useAppSelector((state) => state.auth.token); + const user = useAppSelector((state) => state.user.user); + const isLoading = useAppSelector((state) => state.user.isLoading); + + useEffect(() => { + // Fetch user data when token is available and user is not loaded + if (token && !user && !isLoading) { + dispatch(fetchCurrentUser()); + } + }, [token, user, isLoading, dispatch]); + + return <>{children}; +}; + +export default UserProvider; diff --git a/src/services/api/userApi.ts b/src/services/api/userApi.ts new file mode 100644 index 000000000..19e8d332e --- /dev/null +++ b/src/services/api/userApi.ts @@ -0,0 +1,19 @@ +import { apiClient } from '../apiClient'; +import type { GetMeResponse, User } from '../../types/api'; + +/** + * User API endpoints + */ +export const userApi = { + /** + * Get current authenticated user information + * GET /telegram/me + */ + getMe: async (): Promise => { + const response = await apiClient.get('/telegram/me'); + if (!response.success) { + throw new Error(response.error || 'Failed to fetch user data'); + } + return response.data; + }, +}; diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts new file mode 100644 index 000000000..7ddc17f41 --- /dev/null +++ b/src/services/apiClient.ts @@ -0,0 +1,159 @@ +import { BACKEND_URL } from '../utils/config'; +import type { ApiResponse, ApiError } from '../types/api'; +import { store } from '../store'; + +type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + +interface RequestOptions { + method?: HttpMethod; + body?: unknown; + headers?: Record; + requireAuth?: boolean; +} + +/** + * API Client for making requests to the backend + * Handles authentication, error handling, and response typing + */ +class ApiClient { + private baseUrl: string; + + constructor(baseUrl: string) { + this.baseUrl = baseUrl; + } + + /** + * Get the current JWT token from Redux store + */ + private getToken(): string | null { + return store.getState().auth.token; + } + + /** + * Build headers for the request + */ + private buildHeaders(options: RequestOptions): HeadersInit { + const headers: Record = { + 'Content-Type': 'application/json', + ...options.headers, + }; + + // Add authorization header if auth is required + if (options.requireAuth !== false) { + const token = this.getToken(); + if (token) { + headers.Authorization = `Bearer ${token}`; + } + } + + return headers; + } + + /** + * Make an API request + */ + private async request( + endpoint: string, + options: RequestOptions = {} + ): Promise { + const { method = 'GET', body, requireAuth = true } = options; + + const url = `${this.baseUrl}${endpoint}`; + const headers = this.buildHeaders({ ...options, requireAuth }); + + const config: RequestInit = { + method, + headers, + }; + + if (body && method !== 'GET') { + config.body = JSON.stringify(body); + } + + try { + const response = await fetch(url, config); + + // Handle non-JSON responses + const contentType = response.headers.get('content-type'); + if (!contentType || !contentType.includes('application/json')) { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return {} as T; + } + + const data = await response.json(); + + // Handle error responses + if (!response.ok) { + const error: ApiError = data.error + ? { success: false, error: data.error, message: data.message } + : { success: false, error: `HTTP ${response.status}: ${response.statusText}` }; + throw error; + } + + return data as T; + } catch (error) { + // Re-throw API errors as-is + if (error && typeof error === 'object' && 'error' in error) { + throw error; + } + + // Wrap network/other errors + throw { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred', + } as ApiError; + } + } + + /** + * GET request + */ + async get(endpoint: string, options?: Omit): Promise { + return this.request(endpoint, { ...options, method: 'GET' }); + } + + /** + * POST request + */ + async post( + endpoint: string, + body?: unknown, + options?: Omit + ): Promise { + return this.request(endpoint, { ...options, method: 'POST', body }); + } + + /** + * PUT request + */ + async put( + endpoint: string, + body?: unknown, + options?: Omit + ): Promise { + return this.request(endpoint, { ...options, method: 'PUT', body }); + } + + /** + * PATCH request + */ + async patch( + endpoint: string, + body?: unknown, + options?: Omit + ): Promise { + return this.request(endpoint, { ...options, method: 'PATCH', body }); + } + + /** + * DELETE request + */ + async delete(endpoint: string, options?: Omit): Promise { + return this.request(endpoint, { ...options, method: 'DELETE' }); + } +} + +// Export singleton instance +export const apiClient = new ApiClient(BACKEND_URL); diff --git a/src/store/authSlice.ts b/src/store/authSlice.ts index 2ebbb5497..b7a39ca3f 100644 --- a/src/store/authSlice.ts +++ b/src/store/authSlice.ts @@ -1,4 +1,5 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { createSlice, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit'; +import { clearUser } from './userSlice'; interface AuthState { token: string | null; @@ -17,7 +18,7 @@ const authSlice = createSlice({ setToken: (state, action: PayloadAction) => { state.token = action.payload; }, - clearToken: (state) => { + _clearToken: (state) => { state.token = null; state.isOnboarded = false; }, @@ -27,5 +28,11 @@ const authSlice = createSlice({ }, }); -export const { setToken, clearToken, setOnboarded } = authSlice.actions; +// Thunk that clears both token and user data +export const clearToken = createAsyncThunk('auth/clearToken', async (_, { dispatch }) => { + dispatch(authSlice.actions._clearToken()); + dispatch(clearUser()); +}); + +export const { setToken, setOnboarded } = authSlice.actions; export default authSlice.reducer; diff --git a/src/store/index.ts b/src/store/index.ts index c695e14bf..7aea6745c 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,8 +1,9 @@ -import { configureStore } from '@reduxjs/toolkit'; +import { configureStore, Middleware } from '@reduxjs/toolkit'; import { persistStore, persistReducer, FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER } from 'redux-persist'; import storage from 'redux-persist/lib/storage'; import authReducer from './authSlice'; import socketReducer from './socketSlice'; +import userReducer from './userSlice'; // Persist config for auth only const authPersistConfig = { @@ -15,12 +16,12 @@ const authPersistConfig = { const persistedAuthReducer = persistReducer(authPersistConfig, authReducer); // Get logger only in dev mode -let loggerMiddleware: unknown = undefined; +let loggerMiddleware: Middleware | undefined = undefined; if (import.meta.env.DEV) { try { // eslint-disable-next-line @typescript-eslint/no-var-requires const createLogger = require('redux-logger'); - loggerMiddleware = createLogger.createLogger(); + loggerMiddleware = createLogger.createLogger() as Middleware; } catch { // Logger not available, continue without it } @@ -30,6 +31,7 @@ export const store = configureStore({ reducer: { auth: persistedAuthReducer, socket: socketReducer, + user: userReducer, }, middleware: (getDefaultMiddleware) => { const middleware = getDefaultMiddleware({ diff --git a/src/store/userSlice.ts b/src/store/userSlice.ts new file mode 100644 index 000000000..3c102848b --- /dev/null +++ b/src/store/userSlice.ts @@ -0,0 +1,69 @@ +import { createSlice, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit'; +import { userApi } from '../services/api/userApi'; +import type { User } from '../types/api'; + +interface UserState { + user: User | null; + isLoading: boolean; + error: string | null; +} + +const initialState: UserState = { + user: null, + isLoading: false, + error: null, +}; + +/** + * Async thunk to fetch current user data + */ +export const fetchCurrentUser = createAsyncThunk( + 'user/fetchCurrentUser', + async (_, { rejectWithValue }) => { + try { + const user = await userApi.getMe(); + return user; + } catch (error) { + const errorMessage = + error && typeof error === 'object' && 'error' in error + ? String(error.error) + : 'Failed to fetch user data'; + return rejectWithValue(errorMessage); + } + } +); + +const userSlice = createSlice({ + name: 'user', + initialState, + reducers: { + setUser: (state, action: PayloadAction) => { + state.user = action.payload; + state.error = null; + }, + clearUser: (state) => { + state.user = null; + state.error = null; + state.isLoading = false; + }, + }, + extraReducers: (builder) => { + builder + .addCase(fetchCurrentUser.pending, (state) => { + state.isLoading = true; + state.error = null; + }) + .addCase(fetchCurrentUser.fulfilled, (state, action) => { + state.isLoading = false; + state.user = action.payload; + state.error = null; + }) + .addCase(fetchCurrentUser.rejected, (state, action) => { + state.isLoading = false; + state.error = action.payload as string; + }); + }, +}); + +export const { setUser, clearUser } = userSlice.actions; +export default userSlice.reducer; diff --git a/src/types/api.ts b/src/types/api.ts new file mode 100644 index 000000000..e060d5785 --- /dev/null +++ b/src/types/api.ts @@ -0,0 +1,70 @@ +// API Response wrapper +export interface ApiResponse { + success: boolean; + data: T; + error?: string; + message?: string; +} + +// API Error response +export interface ApiError { + success: false; + error: string; + message?: string; +} + +// User types based on backend ITgUser model +export interface UserSubscription { + hasActiveSubscription: boolean; + plan: 'FREE' | 'BASIC' | 'PRO'; + planExpiry?: string; + stripeCustomerId?: string; +} + +export interface UserUsage { + dailyTokenLimit: number; + remainingTokens: number; + activeSessionCount: number; + lastTokenResetAt?: string; +} + +export interface UserReferral { + inviteCode?: string | null; + inviteCodeUsages: number; + maxInviteCodeUsages?: number | null; + inviteCodeUsedAt?: string; + invitedBy?: string | null; + pendingInviteCode?: string | null; +} + +export interface UserSettings { + dailySummariesEnabled: boolean; + dailySummaryUtcTriggerHour?: number; + dailySummaryChatIds: number[]; + autoCompleteEnabled: boolean; + autoCompleteVisibility: 'always' | 'groups_only' | 'private_chats_only'; + autoCompleteWhitelistChatIds: number[]; + autoCompleteBlacklistChatIds: number[]; +} + +export interface User { + id: string; + telegramId: number; + hasAccess: boolean; + magicWord: string; + referral: UserReferral; + subscription: UserSubscription; + usage: UserUsage; + role: 'admin' | 'team' | 'user'; + settings: UserSettings; + autoDeleteTelegramMessagesAfterDays: number; + autoDeleteThreadsAfterDays: number; + firstName?: string; + lastName?: string; + username?: string; + languageCode?: string; + waitlist?: string; +} + +// API Endpoints +export type GetMeResponse = ApiResponse;