From 80110acbf7dbff961681d354e929643bca30d4c5 Mon Sep 17 00:00:00 2001 From: echo Date: Sun, 29 Mar 2026 15:31:06 +0200 Subject: [PATCH 1/3] standardize mobile api transport for gyms and core services --- apps/mobile/src/api/client.ts | 56 ++++-- apps/mobile/src/api/gyms.ts | 56 ++++++ apps/mobile/src/api/index.ts | 1 + apps/mobile/src/api/notifications.ts | 228 +++++++++------------- apps/mobile/src/api/recommendations.ts | 134 +++++++------ apps/mobile/src/app/(auth)/onboarding.tsx | 24 +-- apps/mobile/src/app/(tabs)/profile.tsx | 84 +------- apps/mobile/src/services/fitnessGoals.ts | 125 +++++------- 8 files changed, 322 insertions(+), 386 deletions(-) create mode 100644 apps/mobile/src/api/gyms.ts diff --git a/apps/mobile/src/api/client.ts b/apps/mobile/src/api/client.ts index 35ac7f9..00c1a80 100644 --- a/apps/mobile/src/api/client.ts +++ b/apps/mobile/src/api/client.ts @@ -1,18 +1,52 @@ -import axios from 'axios'; -import { API_BASE_URL } from '../config/api'; +import axios, { type AxiosRequestConfig } from "axios"; +import { API_BASE_URL } from "../config/api"; +import log from "../utils/logger"; export const apiClient = axios.create({ - baseURL: API_BASE_URL, - headers: { - 'Content-Type': 'application/json', - }, + baseURL: API_BASE_URL, + timeout: 15000, + headers: { + "Content-Type": "application/json", + }, }); +apiClient.interceptors.response.use( + (response) => response, + (error) => { + if (axios.isAxiosError(error)) { + const status = error.response?.status; + if (status === 401) { + log.warn("API unauthorized response", { url: error.config?.url }); + } else if (status === 403) { + log.warn("API forbidden response", { url: error.config?.url }); + } else if (status && status >= 500) { + log.error("API server error", error, { + status, + url: error.config?.url, + }); + } + } + return Promise.reject(error); + }, +); + +export function withAuth(token?: string | null): AxiosRequestConfig { + if (!token) { + return {}; + } + + return { + headers: { + Authorization: `Bearer ${token}`, + }, + }; +} + // Helper to set the auth token for a request export const setAuthToken = (token: string) => { - if (token) { - apiClient.defaults.headers.common['Authorization'] = `Bearer ${token}`; - } else { - delete apiClient.defaults.headers.common['Authorization']; - } + if (token) { + apiClient.defaults.headers.common.Authorization = `Bearer ${token}`; + } else { + delete apiClient.defaults.headers.common.Authorization; + } }; diff --git a/apps/mobile/src/api/gyms.ts b/apps/mobile/src/api/gyms.ts new file mode 100644 index 0000000..3988597 --- /dev/null +++ b/apps/mobile/src/api/gyms.ts @@ -0,0 +1,56 @@ +import { isAxiosError } from "axios"; +import { API_ENDPOINTS } from "../config/api"; +import { apiClient, withAuth } from "./client"; + +export interface Gym { + id: string; + name: string; + location?: string; +} + +export const gymsApi = { + getGyms: async (token: string | null): Promise => { + try { + const response = await apiClient.get( + API_ENDPOINTS.GYMS, + withAuth(token), + ); + + const payload = response.data; + if (Array.isArray(payload)) { + return payload; + } + + if (payload && Array.isArray(payload.data)) { + return payload.data; + } + + return []; + } catch (error) { + if (isAxiosError(error) && error.response) { + throw new Error(`Failed to fetch gyms: ${error.response.status}`); + } + throw new Error("Failed to fetch gyms"); + } + }, + + updateUserGym: async ( + gymId: string | null, + token: string | null, + ): Promise => { + try { + await apiClient.patch( + API_ENDPOINTS.USERS.GYM, + { gymId }, + withAuth(token), + ); + } catch (error) { + if (isAxiosError(error) && error.response) { + throw new Error( + `Failed to update gym selection: ${error.response.status}`, + ); + } + throw new Error("Failed to update gym selection"); + } + }, +}; diff --git a/apps/mobile/src/api/index.ts b/apps/mobile/src/api/index.ts index 34d72ff..528eba6 100644 --- a/apps/mobile/src/api/index.ts +++ b/apps/mobile/src/api/index.ts @@ -13,3 +13,4 @@ export * from "./recommendations"; export * from "./nutrition"; export * from "./hydration"; export * from "./client"; +export * from "./gyms"; diff --git a/apps/mobile/src/api/notifications.ts b/apps/mobile/src/api/notifications.ts index 22c32bd..4ddaf60 100644 --- a/apps/mobile/src/api/notifications.ts +++ b/apps/mobile/src/api/notifications.ts @@ -1,4 +1,5 @@ -import { API_BASE_URL } from "../config/api"; +import { isAxiosError } from "axios"; +import { apiClient, withAuth } from "./client"; export interface Notification { id: string; @@ -25,66 +26,49 @@ interface ApiResponse { export async function fetchNotifications( token: string | null, ): Promise { - const headers: Record = { - "Content-Type": "application/json", - }; - - if (token) { - headers["Authorization"] = `Bearer ${token}`; - } - - const response = await fetch(`${API_BASE_URL}/api/notifications`, { - method: "GET", - headers, - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error( - `Failed to fetch notifications: ${response.status} - ${errorText}`, + try { + const response = await apiClient.get>( + "/api/notifications", + withAuth(token), ); + + const result = response.data; + + if (result.success && result.data) { + return result.data.map((notification) => ({ + ...notification, + createdAt: new Date(notification.createdAt), + })); + } + + return []; + } catch (error) { + if (isAxiosError(error) && error.response) { + throw new Error( + `Failed to fetch notifications: ${error.response.status}`, + ); + } + throw new Error("Failed to fetch notifications"); } - - const result: ApiResponse = await response.json(); - - if (result.success && result.data) { - // Convert date strings to Date objects - return result.data.map((notification) => ({ - ...notification, - createdAt: new Date(notification.createdAt), - })); - } - - return []; } /** * Get unread notification count */ export async function fetchUnreadCount(token: string | null): Promise { - const headers: Record = { - "Content-Type": "application/json", - }; - - if (token) { - headers["Authorization"] = `Bearer ${token}`; + try { + const response = await apiClient.get>( + "/api/notifications/unread-count", + withAuth(token), + ); + const result = response.data; + return result.success && result.data ? result.data.count : 0; + } catch (error) { + if (isAxiosError(error) && error.response) { + throw new Error(`Failed to fetch unread count: ${error.response.status}`); + } + throw new Error("Failed to fetch unread count"); } - - const response = await fetch( - `${API_BASE_URL}/api/notifications/unread-count`, - { - method: "GET", - headers, - }, - ); - - if (!response.ok) { - throw new Error(`Failed to fetch unread count: ${response.status}`); - } - - const result: ApiResponse<{ count: number }> = await response.json(); - - return result.success && result.data ? result.data.count : 0; } /** @@ -94,62 +78,51 @@ export async function markAsRead( notificationId: string, token: string | null, ): Promise { - const headers: Record = { - "Content-Type": "application/json", - }; + try { + const response = await apiClient.put>( + `/api/notifications/${notificationId}`, + {}, + withAuth(token), + ); - if (token) { - headers["Authorization"] = `Bearer ${token}`; + const result = response.data; + if (result.success && result.data) { + return { + ...result.data, + createdAt: new Date(result.data.createdAt), + }; + } + throw new Error("Invalid response format"); + } catch (error) { + if (isAxiosError(error) && error.response) { + throw new Error( + `Failed to mark notification as read: ${error.response.status}`, + ); + } + if (error instanceof Error && error.message === "Invalid response format") { + throw error; + } + throw new Error("Failed to mark notification as read"); } - - const response = await fetch( - `${API_BASE_URL}/api/notifications/${notificationId}`, - { - method: "PUT", - headers, - }, - ); - - if (!response.ok) { - throw new Error(`Failed to mark notification as read: ${response.status}`); - } - - const result: ApiResponse = await response.json(); - - if (result.success && result.data) { - return { - ...result.data, - createdAt: new Date(result.data.createdAt), - }; - } - - throw new Error("Invalid response format"); } /** * Mark all notifications as read */ export async function markAllAsRead(token: string | null): Promise { - const headers: Record = { - "Content-Type": "application/json", - }; - - if (token) { - headers["Authorization"] = `Bearer ${token}`; - } - - const response = await fetch( - `${API_BASE_URL}/api/notifications/mark-all-read`, - { - method: "POST", - headers, - }, - ); - - if (!response.ok) { - throw new Error( - `Failed to mark all notifications as read: ${response.status}`, + try { + await apiClient.post( + "/api/notifications/mark-all-read", + {}, + withAuth(token), ); + } catch (error) { + if (isAxiosError(error) && error.response) { + throw new Error( + `Failed to mark all notifications as read: ${error.response.status}`, + ); + } + throw new Error("Failed to mark all notifications as read"); } } @@ -160,24 +133,18 @@ export async function deleteNotification( notificationId: string, token: string | null, ): Promise { - const headers: Record = { - "Content-Type": "application/json", - }; - - if (token) { - headers["Authorization"] = `Bearer ${token}`; - } - - const response = await fetch( - `${API_BASE_URL}/api/notifications/${notificationId}`, - { - method: "DELETE", - headers, - }, - ); - - if (!response.ok) { - throw new Error(`Failed to delete notification: ${response.status}`); + try { + await apiClient.delete( + `/api/notifications/${notificationId}`, + withAuth(token), + ); + } catch (error) { + if (isAxiosError(error) && error.response) { + throw new Error( + `Failed to delete notification: ${error.response.status}`, + ); + } + throw new Error("Failed to delete notification"); } } @@ -189,21 +156,16 @@ export async function savePushToken( deviceType: "ios" | "android", token: string | null, ): Promise { - const headers: Record = { - "Content-Type": "application/json", - }; - - if (token) { - headers["Authorization"] = `Bearer ${token}`; - } - - const response = await fetch(`${API_BASE_URL}/api/notifications/save-token`, { - method: "POST", - headers, - body: JSON.stringify({ expoPushToken, deviceType }), - }); - - if (!response.ok) { - throw new Error(`Failed to save push token: ${response.status}`); + try { + await apiClient.post( + "/api/notifications/save-token", + { expoPushToken, deviceType }, + withAuth(token), + ); + } catch (error) { + if (isAxiosError(error) && error.response) { + throw new Error(`Failed to save push token: ${error.response.status}`); + } + throw new Error("Failed to save push token"); } } diff --git a/apps/mobile/src/api/recommendations.ts b/apps/mobile/src/api/recommendations.ts index 4186ab5..3e42eda 100644 --- a/apps/mobile/src/api/recommendations.ts +++ b/apps/mobile/src/api/recommendations.ts @@ -1,4 +1,6 @@ -import { API_BASE_URL, API_ENDPOINTS } from "../config/api"; +import { isAxiosError } from "axios"; +import { apiClient, withAuth } from "./client"; +import { API_ENDPOINTS } from "../config/api"; export interface Recommendation { id: string; @@ -21,6 +23,15 @@ export interface GenerateRecommendationRequest { modelProvider?: "openai" | "deepseek" | "ollama"; } +interface ApiResponse { + success?: boolean; + data?: T; +} + +function isApiResponse(value: unknown): value is ApiResponse { + return typeof value === "object" && value !== null && "success" in value; +} + /** * Get recommendations for a user * @@ -32,28 +43,29 @@ export async function getRecommendations( userId: string, token: string | null, ): Promise { - const headers: any = { - "Content-Type": "application/json", - }; - - if (token) { - headers["Authorization"] = `Bearer ${token}`; + let result: ApiResponse | Recommendation[]; + try { + const response = await apiClient.get(API_ENDPOINTS.RECOMMENDATIONS, { + params: { userId }, + ...withAuth(token), + }); + result = response.data; + } catch (error) { + if (isAxiosError(error) && error.response) { + throw new Error( + `Failed to fetch recommendations: ${error.response.status}`, + ); + } + throw new Error("Failed to fetch recommendations"); } - const response = await fetch( - `${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}?userId=${userId}`, - { headers }, - ); - - if (!response.ok) { - throw new Error(`Failed to fetch recommendations: ${response.status}`); - } - - const result = await response.json(); - // Handle standardized API response format // API returns: { success: true, data: [...], meta: {...} } - if (result.success && result.data) { + if ( + isApiResponse(result) && + result.success && + result.data + ) { return Array.isArray(result.data) ? result.data : []; } @@ -72,36 +84,30 @@ export async function generateRecommendation( data: GenerateRecommendationRequest, token: string | null, ): Promise { - const headers: any = { - "Content-Type": "application/json", - }; - - if (token) { - headers["Authorization"] = `Bearer ${token}`; + let result: ApiResponse | Recommendation; + try { + const response = await apiClient.post( + `${API_ENDPOINTS.RECOMMENDATIONS}/generate`, + data, + withAuth(token), + ); + result = response.data; + } catch (error) { + if (isAxiosError(error) && error.response) { + throw new Error( + `Failed to generate recommendation: ${error.response.status}`, + ); + } + throw new Error("Failed to generate recommendation"); } - const response = await fetch( - `${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}/generate`, - { - method: "POST", - headers, - body: JSON.stringify(data), - }, - ); - - if (!response.ok) { - throw new Error(`Failed to generate recommendation: ${response.status}`); - } - - const result = await response.json(); - // Handle standardized API response format - if (result.success && result.data) { + if (isApiResponse(result) && result.success && result.data) { return result.data; } // Fallback for legacy format - return result; + return result as Recommendation; } /** @@ -115,40 +121,32 @@ export async function generateRecommendation( export async function approveRecommendation( recommendationId: string, token: string | null, - approvedBy?: string, ): Promise { - const headers: any = { - "Content-Type": "application/json", - }; - - if (token) { - headers["Authorization"] = `Bearer ${token}`; - } - - const response = await fetch( - `${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}/approve`, - { - method: "POST", - headers, - body: JSON.stringify({ + let result: ApiResponse | Recommendation; + try { + const response = await apiClient.post( + `${API_ENDPOINTS.RECOMMENDATIONS}/approve`, + { recommendationId, status: "approved", - approvedBy, - }), - }, - ); - - if (!response.ok) { - throw new Error(`Failed to approve recommendation: ${response.status}`); + }, + withAuth(token), + ); + result = response.data; + } catch (error) { + if (isAxiosError(error) && error.response) { + throw new Error( + `Failed to approve recommendation: ${error.response.status}`, + ); + } + throw new Error("Failed to approve recommendation"); } - const result = await response.json(); - // Handle standardized API response format - if (result.success && result.data) { + if (isApiResponse(result) && result.success && result.data) { return result.data; } // Fallback for legacy format - return result; + return result as Recommendation; } diff --git a/apps/mobile/src/app/(auth)/onboarding.tsx b/apps/mobile/src/app/(auth)/onboarding.tsx index 5f6c661..343a12c 100644 --- a/apps/mobile/src/app/(auth)/onboarding.tsx +++ b/apps/mobile/src/app/(auth)/onboarding.tsx @@ -12,7 +12,7 @@ import { import { useUser, useAuth } from "@clerk/clerk-expo"; import { useRouter } from "expo-router"; import { fitnessProfileApi } from "@/api/fitnessProfile"; -import { API_BASE_URL } from "@/config/api"; +import { gymsApi, type Gym } from "@/api/gyms"; import log from "../../utils/logger"; export default function OnboardingScreen() { @@ -20,9 +20,7 @@ export default function OnboardingScreen() { const { getToken } = useAuth(); const router = useRouter(); const [isSubmitting, setIsSubmitting] = useState(false); - const [gyms, setGyms] = useState< - Array<{ id: string; name: string; location?: string }> - >([]); + const [gyms, setGyms] = useState([]); const [gymsLoading, setGymsLoading] = useState(false); const [selectedGymId, setSelectedGymId] = useState(null); @@ -31,13 +29,8 @@ export default function OnboardingScreen() { try { setGymsLoading(true); const token = await getToken(); - const res = await fetch(`${API_BASE_URL}/api/gyms`, { - headers: token ? { Authorization: `Bearer ${token}` } : undefined, - }); - const data = await res.json(); - if (Array.isArray(data)) { - setGyms(data); - } + const data = await gymsApi.getGyms(token); + setGyms(data); } catch (e) { log.error("Failed to fetch gyms", e); } finally { @@ -82,14 +75,7 @@ export default function OnboardingScreen() { // If gym was selected or cleared, patch user's gym selection first // selectedGymId: string gym id, or null to proceed without gym try { - await fetch(`${API_BASE_URL}/api/users/gym`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ gymId: selectedGymId }), - }); + await gymsApi.updateUserGym(selectedGymId, token); } catch (e) { log.warn("Failed to update gym selection", { gymId: selectedGymId }); } diff --git a/apps/mobile/src/app/(tabs)/profile.tsx b/apps/mobile/src/app/(tabs)/profile.tsx index d6808a3..b460ce0 100644 --- a/apps/mobile/src/app/(tabs)/profile.tsx +++ b/apps/mobile/src/app/(tabs)/profile.tsx @@ -18,8 +18,8 @@ import { ListItem } from "../../components/ListItem"; import { MinimalButton } from "../../components/MinimalButton"; import { Badge } from "../../components/Badge"; import { IconContainer } from "../../components/IconContainer"; -import { API_BASE_URL, API_ENDPOINTS } from "../../config/api"; import { fitnessProfileApi, FitnessProfile } from "../../api/fitnessProfile"; +import { gymsApi, type Gym } from "../../api/gyms"; import log from "../../utils/logger"; export default function ProfileScreen() { @@ -29,9 +29,7 @@ export default function ProfileScreen() { const { colors, typography, theme: activeTheme, setTheme } = useTheme(); const { getToken } = useAuth(); - const [gyms, setGyms] = useState< - Array<{ id: string; name: string; location?: string }> - >([]); + const [gyms, setGyms] = useState([]); const [gymsLoading, setGymsLoading] = useState(false); const [selectedGymId, setSelectedGymId] = useState(null); const [currentGymId, setCurrentGymId] = useState(null); @@ -76,51 +74,14 @@ export default function ProfileScreen() { try { setGymsLoading(true); const token = await getToken(); - const url = `${API_BASE_URL}${API_ENDPOINTS.GYMS}`; - log.debug("Loading gyms", { url }); - const res = await fetch(url, { - headers: token ? { Authorization: `Bearer ${token}` } : undefined, - }); - const contentType = res.headers.get("content-type") || ""; - if (!res.ok) { - const text = await res.text().catch(() => ""); - log.error( - "Failed to fetch gyms - non-OK response", - new Error(text.slice(0, 200)), - { status: res.status }, - ); - setGyms([]); - return; - } - if (!contentType.includes("application/json")) { - const text = await res.text().catch(() => ""); - log.error( - "Failed to fetch gyms - expected JSON", - new Error(text.slice(0, 200)), - { contentType }, - ); - setGyms([]); - return; - } - let data: any = null; - try { - data = await res.json(); - } catch (e) { - const text = await res.text().catch(() => ""); - log.error("Failed to parse gyms JSON", e, { - bodyPreview: text?.slice(0, 200), - }); - setGyms([]); - return; - } - const list = Array.isArray(data) ? data : []; + const list = await gymsApi.getGyms(token); setGyms(list); const gid = currentGymId ?? ((user?.publicMetadata as any)?.gymId as string | undefined) ?? null; if (gid) { - const g = list.find((x: any) => x.id === gid); + const g = list.find((x) => x.id === gid); setCurrentGymId(gid); setCurrentGymName(g?.name ?? null); if (selectedGymId === null) setSelectedGymId(gid); @@ -136,42 +97,7 @@ export default function ProfileScreen() { const handleApplyGym = async () => { try { const token = await getToken(); - const url = `${API_BASE_URL}${API_ENDPOINTS.USERS.GYM}`; - log.debug("Updating gym selection", { - url, - gymId: selectedGymId, - token: token ? "present" : "missing", - }); - const res = await fetch(url, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - body: JSON.stringify({ gymId: selectedGymId }), - }); - const contentType = res.headers.get("content-type") || ""; - if (!res.ok) { - const text = await res.text().catch(() => ""); - log.error( - "Failed to update gym selection - non-OK response", - new Error(text.slice(0, 200)), - { status: res.status }, - ); - Alert.alert("Error", "Failed to update gym selection"); - return; - } - if (contentType.includes("application/json")) { - try { - const data = await res.json(); - log.debug("Gym selection updated", { data }); - } catch (e) { - const text = await res.text().catch(() => ""); - log.error("Failed to parse update response JSON", e, { - bodyPreview: text?.slice(0, 200), - }); - } - } + await gymsApi.updateUserGym(selectedGymId, token); setCurrentGymId(selectedGymId); setCurrentGymName( selectedGymId diff --git a/apps/mobile/src/services/fitnessGoals.ts b/apps/mobile/src/services/fitnessGoals.ts index 15c4867..e64e9d8 100644 --- a/apps/mobile/src/services/fitnessGoals.ts +++ b/apps/mobile/src/services/fitnessGoals.ts @@ -1,4 +1,6 @@ -import { API_BASE_URL, API_ENDPOINTS } from "../config/api"; +import { isAxiosError } from "axios"; +import { apiClient, withAuth } from "../api/client"; +import { API_ENDPOINTS } from "../config/api"; import log from "../utils/logger"; export interface FitnessGoal { @@ -41,38 +43,21 @@ export interface CreateGoalData { } export class FitnessGoalsService { - private async getAuthHeaders(token: string | null): Promise { - const headers: any = { - "Content-Type": "application/json", - }; - - if (token) { - headers["Authorization"] = `Bearer ${token}`; - } - - return headers; - } - async getGoals( userId: string, token: string | null, status?: string, ): Promise { try { - const headers = await this.getAuthHeaders(token); - let url = `${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.LIST}?userId=${userId}`; + const response = await apiClient.get(API_ENDPOINTS.FITNESS_GOALS.LIST, { + params: { + userId, + ...(status && status !== "all" ? { status } : {}), + }, + ...withAuth(token), + }); - if (status && status !== "all") { - url += `&status=${status}`; - } - - const response = await fetch(url, { headers }); - - if (!response.ok) { - throw new Error(`Failed to fetch goals: ${response.status}`); - } - - const result = await response.json(); + const result = response.data; // Handle standardized API response format // API returns: { success: true, data: [...], meta: {...} } @@ -83,6 +68,9 @@ export class FitnessGoalsService { // Fallback for legacy format (direct array) return Array.isArray(result) ? result : []; } catch (error) { + if (isAxiosError(error) && error.response) { + throw new Error(`Failed to fetch goals: ${error.response.status}`); + } log.error("Failed to fetch fitness goals", error); throw error; } @@ -93,22 +81,13 @@ export class FitnessGoalsService { token: string | null, ): Promise { try { - const headers = await this.getAuthHeaders(token); - const response = await fetch( - `${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.CREATE}`, - { - method: "POST", - headers, - body: JSON.stringify(goalData), - }, + const response = await apiClient.post( + API_ENDPOINTS.FITNESS_GOALS.CREATE, + goalData, + withAuth(token), ); - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || "Failed to create goal"); - } - - const result = await response.json(); + const result = response.data; // Handle standardized API response format if (result.success && result.data) { @@ -118,6 +97,14 @@ export class FitnessGoalsService { // Fallback for legacy format return result; } catch (error) { + if (isAxiosError(error)) { + const message = + (error.response?.data as { error?: string } | undefined)?.error || + (error.response + ? `Failed to create goal: ${error.response.status}` + : "Failed to create goal"); + throw new Error(message); + } log.error("Failed to create fitness goal", error); throw error; } @@ -129,21 +116,13 @@ export class FitnessGoalsService { token: string | null, ): Promise { try { - const headers = await this.getAuthHeaders(token); - const response = await fetch( - `${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.UPDATE(id)}`, - { - method: "PUT", - headers, - body: JSON.stringify(updates), - }, + const response = await apiClient.put( + API_ENDPOINTS.FITNESS_GOALS.UPDATE(id), + updates, + withAuth(token), ); - if (!response.ok) { - throw new Error("Failed to update goal"); - } - - const result = await response.json(); + const result = response.data; // Handle standardized API response format if (result.success && result.data) { @@ -153,6 +132,9 @@ export class FitnessGoalsService { // Fallback for legacy format return result; } catch (error) { + if (isAxiosError(error) && error.response) { + throw new Error(`Failed to update goal: ${error.response.status}`); + } log.error("Failed to update fitness goal", error); throw error; } @@ -168,20 +150,13 @@ export class FitnessGoalsService { async completeGoal(id: string, token: string | null): Promise { try { - const headers = await this.getAuthHeaders(token); - const response = await fetch( - `${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.COMPLETE(id)}`, - { - method: "POST", - headers, - }, + const response = await apiClient.post( + API_ENDPOINTS.FITNESS_GOALS.COMPLETE(id), + {}, + withAuth(token), ); - if (!response.ok) { - throw new Error("Failed to complete goal"); - } - - const result = await response.json(); + const result = response.data; // Note: Complete endpoint returns direct object (legacy format) // Handle standardized API response format (if migrated) @@ -192,6 +167,9 @@ export class FitnessGoalsService { // Fallback for legacy format (current implementation) return result; } catch (error) { + if (isAxiosError(error) && error.response) { + throw new Error(`Failed to complete goal: ${error.response.status}`); + } log.error("Failed to complete fitness goal", error); throw error; } @@ -199,22 +177,17 @@ export class FitnessGoalsService { async deleteGoal(id: string, token: string | null): Promise { try { - const headers = await this.getAuthHeaders(token); - const response = await fetch( - `${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.DELETE(id)}`, - { - method: "DELETE", - headers, - }, + await apiClient.delete( + API_ENDPOINTS.FITNESS_GOALS.DELETE(id), + withAuth(token), ); - if (!response.ok) { - throw new Error("Failed to delete goal"); - } - // DELETE endpoint returns: { success: true, data: { deleted: true }, meta: {...} } // No need to parse the result for void return type } catch (error) { + if (isAxiosError(error) && error.response) { + throw new Error(`Failed to delete goal: ${error.response.status}`); + } log.error("Failed to delete fitness goal", error); throw error; } From aa662a9b74251104eb754ddb82462980fd157055 Mon Sep 17 00:00:00 2001 From: echo Date: Sun, 29 Mar 2026 15:34:43 +0200 Subject: [PATCH 2/3] add mobile api unit tests for gyms and recommendations --- apps/mobile/jest.setup.js | 26 ++++--- apps/mobile/src/api/__tests__/gyms.test.ts | 55 +++++++++++++++ .../src/api/__tests__/recommendations.test.ts | 68 +++++++++++++++++++ 3 files changed, 140 insertions(+), 9 deletions(-) create mode 100644 apps/mobile/src/api/__tests__/gyms.test.ts create mode 100644 apps/mobile/src/api/__tests__/recommendations.test.ts diff --git a/apps/mobile/jest.setup.js b/apps/mobile/jest.setup.js index f76afc3..fe9d681 100644 --- a/apps/mobile/jest.setup.js +++ b/apps/mobile/jest.setup.js @@ -1,11 +1,19 @@ -import 'react-native-gesture-handler/jestSetup' +try { + require("react-native-gesture-handler/jestSetup"); +} catch { + // Package may be absent in minimal test environments +} -jest.mock('react-native-reanimated', () => { - const Reanimated = require('react-native-reanimated/mock') - Reanimated.default.call = () => {} - return Reanimated -}) +jest.mock( + "react-native-reanimated", + () => { + const Reanimated = require("react-native-reanimated/mock"); + Reanimated.default.call = () => {}; + return Reanimated; + }, + { virtual: true }, +); -jest.mock('@expo/vector-icons', () => ({ - Ionicons: 'Ionicons', -})) \ No newline at end of file +jest.mock("@expo/vector-icons", () => ({ + Ionicons: "Ionicons", +})); diff --git a/apps/mobile/src/api/__tests__/gyms.test.ts b/apps/mobile/src/api/__tests__/gyms.test.ts new file mode 100644 index 0000000..44b23f0 --- /dev/null +++ b/apps/mobile/src/api/__tests__/gyms.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { gymsApi } from "../gyms"; +import { apiClient, withAuth } from "../client"; + +jest.mock("../client", () => ({ + apiClient: { + get: jest.fn(), + patch: jest.fn(), + }, + withAuth: jest.fn((token?: string | null) => + token ? { headers: { Authorization: `Bearer ${token}` } } : {}, + ), +})); + +describe("gymsApi", () => { + const getMock = apiClient.get as any; + const patchMock = apiClient.patch as any; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("returns array payload from getGyms", async () => { + getMock.mockResolvedValue({ + data: [{ id: "gym_1", name: "Gym One" }], + }); + + const result = await gymsApi.getGyms("token_1"); + + expect(result).toEqual([{ id: "gym_1", name: "Gym One" }]); + expect(withAuth).toHaveBeenCalledWith("token_1"); + }); + + it("returns nested data payload from getGyms", async () => { + getMock.mockResolvedValue({ + data: { data: [{ id: "gym_2", name: "Gym Two" }] }, + }); + + const result = await gymsApi.getGyms(null); + + expect(result).toEqual([{ id: "gym_2", name: "Gym Two" }]); + }); + + it("patches selected gym for current user", async () => { + patchMock.mockResolvedValue({}); + + await gymsApi.updateUserGym("gym_2", "token_2"); + + expect(apiClient.patch).toHaveBeenCalledWith( + "/api/users/gym", + { gymId: "gym_2" }, + expect.any(Object), + ); + }); +}); diff --git a/apps/mobile/src/api/__tests__/recommendations.test.ts b/apps/mobile/src/api/__tests__/recommendations.test.ts new file mode 100644 index 0000000..f1cd079 --- /dev/null +++ b/apps/mobile/src/api/__tests__/recommendations.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { + approveRecommendation, + generateRecommendation, + getRecommendations, +} from "../recommendations"; +import { apiClient, withAuth } from "../client"; + +jest.mock("../client", () => ({ + apiClient: { + get: jest.fn(), + post: jest.fn(), + }, + withAuth: jest.fn((token?: string | null) => + token ? { headers: { Authorization: `Bearer ${token}` } } : {}, + ), +})); + +describe("recommendations api", () => { + const getMock = apiClient.get as any; + const postMock = apiClient.post as any; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("returns normalized list from standardized response", async () => { + getMock.mockResolvedValue({ + data: { + success: true, + data: [{ id: "rec_1", status: "pending" }], + }, + }); + + const result = await getRecommendations("user_1", "token_1"); + + expect(result).toEqual([{ id: "rec_1", status: "pending" }]); + expect(withAuth).toHaveBeenCalledWith("token_1"); + }); + + it("falls back to legacy response for generate", async () => { + postMock.mockResolvedValue({ + data: { id: "rec_2", status: "pending" }, + }); + + const result = await generateRecommendation({ userId: "user_1" }, null); + + expect(result).toEqual({ id: "rec_2", status: "pending" }); + }); + + it("sends approval payload without approvedBy", async () => { + postMock.mockResolvedValue({ + data: { + success: true, + data: { id: "rec_3", status: "approved" }, + }, + }); + + const result = await approveRecommendation("rec_3", "token_3"); + + expect(result).toEqual({ id: "rec_3", status: "approved" }); + expect(apiClient.post).toHaveBeenCalledWith( + "/api/recommendations/approve", + { recommendationId: "rec_3", status: "approved" }, + expect.any(Object), + ); + }); +}); From 34e88bdde5f29437b5c75d67eef02ff85d0d888e Mon Sep 17 00:00:00 2001 From: echo Date: Sun, 29 Mar 2026 15:38:51 +0200 Subject: [PATCH 3/3] reset mobile context caches on user identity changes --- .../src/contexts/FitnessGoalsContext.tsx | 11 +++++++++++ apps/mobile/src/contexts/HydrationContext.tsx | 12 ++++++++++++ .../src/contexts/NotificationsContext.tsx | 16 +++++++++++++++- apps/mobile/src/contexts/NutritionContext.tsx | 13 +++++++++++++ .../src/contexts/RecommendationsContext.tsx | 11 +++++++++++ apps/mobile/src/contexts/StatisticsContext.tsx | 18 +++++++++++++++++- 6 files changed, 79 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/contexts/FitnessGoalsContext.tsx b/apps/mobile/src/contexts/FitnessGoalsContext.tsx index d4150c1..cf861a9 100644 --- a/apps/mobile/src/contexts/FitnessGoalsContext.tsx +++ b/apps/mobile/src/contexts/FitnessGoalsContext.tsx @@ -3,6 +3,7 @@ import React, { useContext, useState, useCallback, + useEffect, useRef, } from "react"; import { useUser, useAuth } from "@clerk/clerk-expo"; @@ -168,12 +169,22 @@ export function FitnessGoalsProvider({ const clearCache = useCallback(() => { setGoals([]); + setLoading(false); setLastFetchTime(0); setError(null); fetchInProgress.current = false; log.debug("Fitness goals cache cleared"); }, []); + useEffect(() => { + clearCache(); + if (user?.id) { + log.debug("Fitness goals cache reset for user", { userId: user.id }); + } else { + log.debug("Fitness goals cache reset on sign-out"); + } + }, [user?.id, clearCache]); + return ( { + setHydration(null); + setError(null); + setLoading(false); + setWaterGoal(2000); + if (user?.id) { + log.debug("Hydration state reset for user", { userId: user.id }); + } else { + log.debug("Hydration state reset on sign-out"); + } + }, [user?.id]); + const addWater = useCallback( async (amount: number) => { if (!user?.id) return; diff --git a/apps/mobile/src/contexts/NotificationsContext.tsx b/apps/mobile/src/contexts/NotificationsContext.tsx index f2159e2..9e227ba 100644 --- a/apps/mobile/src/contexts/NotificationsContext.tsx +++ b/apps/mobile/src/contexts/NotificationsContext.tsx @@ -6,7 +6,7 @@ import React, { useCallback, useRef, } from "react"; -import { useAuth } from "@clerk/clerk-expo"; +import { useAuth, useUser } from "@clerk/clerk-expo"; import { fetchNotifications, fetchUnreadCount, @@ -37,6 +37,7 @@ export function NotificationsProvider({ children: React.ReactNode; }) { const { getToken, isSignedIn } = useAuth(); + const { user } = useUser(); const [notifications, setNotifications] = useState([]); const [unreadCount, setUnreadCount] = useState(0); const [loading, setLoading] = useState(false); @@ -160,6 +161,19 @@ export function NotificationsProvider({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [isSignedIn]); // Only run when sign-in state changes + useEffect(() => { + setNotifications([]); + setUnreadCount(0); + setLoading(false); + fetchInProgressRef.current = false; + lastFetchTimeRef.current = 0; + if (user?.id) { + log.debug("Notifications state reset for user", { userId: user.id }); + } else { + log.debug("Notifications state reset on sign-out"); + } + }, [user?.id]); + // Periodic refresh every 30 seconds useEffect(() => { if (!isSignedIn) return; diff --git a/apps/mobile/src/contexts/NutritionContext.tsx b/apps/mobile/src/contexts/NutritionContext.tsx index d97b82c..f2bb6fe 100644 --- a/apps/mobile/src/contexts/NutritionContext.tsx +++ b/apps/mobile/src/contexts/NutritionContext.tsx @@ -88,6 +88,19 @@ export function NutritionProvider({ children }: { children: React.ReactNode }) { fetchTodayNutrition(); }, [fetchTodayNutrition]); + useEffect(() => { + setNutrition(null); + setMeals([]); + setError(null); + setLoading(false); + setCalorieGoal(2000); + if (user?.id) { + log.debug("Nutrition state reset for user", { userId: user.id }); + } else { + log.debug("Nutrition state reset on sign-out"); + } + }, [user?.id]); + const addMeal = useCallback( async (data: Omit) => { if (!user?.id) return; diff --git a/apps/mobile/src/contexts/RecommendationsContext.tsx b/apps/mobile/src/contexts/RecommendationsContext.tsx index a2c81d5..211afde 100644 --- a/apps/mobile/src/contexts/RecommendationsContext.tsx +++ b/apps/mobile/src/contexts/RecommendationsContext.tsx @@ -3,6 +3,7 @@ import React, { useContext, useState, useCallback, + useEffect, useRef, } from "react"; import { useUser, useAuth } from "@clerk/clerk-expo"; @@ -108,12 +109,22 @@ export function RecommendationsProvider({ const clearCache = useCallback(() => { setRecommendations([]); + setLoading(false); setLastFetchTime(0); setError(null); fetchInProgress.current = false; log.debug("Recommendations cache cleared"); }, []); + useEffect(() => { + clearCache(); + if (user?.id) { + log.debug("Recommendations cache reset for user", { userId: user.id }); + } else { + log.debug("Recommendations cache reset on sign-out"); + } + }, [user?.id, clearCache]); + return ( { setStatistics(null); + setLoading(false); setLastFetchTime(0); setError(null); log.debug("Statistics cache cleared"); }, []); + useEffect(() => { + clearCache(); + if (user?.id) { + log.debug("Statistics cache reset for user", { userId: user.id }); + } else { + log.debug("Statistics cache reset on sign-out"); + } + }, [user?.id, clearCache]); + const forceRefresh = useCallback(async () => { if (!user?.id) return;