standardize mobile api transport for gyms and core services

This commit is contained in:
echo 2026-03-29 15:31:06 +02:00
parent c36cad9c54
commit 80110acbf7
8 changed files with 322 additions and 386 deletions

View File

@ -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,
timeout: 15000,
headers: {
'Content-Type': 'application/json',
"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}`;
apiClient.defaults.headers.common.Authorization = `Bearer ${token}`;
} else {
delete apiClient.defaults.headers.common['Authorization'];
delete apiClient.defaults.headers.common.Authorization;
}
};

View File

@ -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<Gym[]> => {
try {
const response = await apiClient.get<Gym[] | { data?: Gym[] }>(
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<void> => {
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");
}
},
};

View File

@ -13,3 +13,4 @@ export * from "./recommendations";
export * from "./nutrition";
export * from "./hydration";
export * from "./client";
export * from "./gyms";

View File

@ -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,30 +26,15 @@ interface ApiResponse<T> {
export async function fetchNotifications(
token: string | null,
): Promise<Notification[]> {
const headers: Record<string, string> = {
"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<ApiResponse<Notification[]>>(
"/api/notifications",
withAuth(token),
);
}
const result: ApiResponse<Notification[]> = await response.json();
const result = response.data;
if (result.success && result.data) {
// Convert date strings to Date objects
return result.data.map((notification) => ({
...notification,
createdAt: new Date(notification.createdAt),
@ -56,35 +42,33 @@ export async function fetchNotifications(
}
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");
}
}
/**
* Get unread notification count
*/
export async function fetchUnreadCount(token: string | null): Promise<number> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const response = await fetch(
`${API_BASE_URL}/api/notifications/unread-count`,
{
method: "GET",
headers,
},
try {
const response = await apiClient.get<ApiResponse<{ count: number }>>(
"/api/notifications/unread-count",
withAuth(token),
);
if (!response.ok) {
throw new Error(`Failed to fetch unread count: ${response.status}`);
}
const result: ApiResponse<{ count: number }> = await response.json();
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");
}
}
/**
@ -94,63 +78,52 @@ export async function markAsRead(
notificationId: string,
token: string | null,
): Promise<Notification> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const response = await fetch(
`${API_BASE_URL}/api/notifications/${notificationId}`,
{
method: "PUT",
headers,
},
try {
const response = await apiClient.put<ApiResponse<Notification>>(
`/api/notifications/${notificationId}`,
{},
withAuth(token),
);
if (!response.ok) {
throw new Error(`Failed to mark notification as read: ${response.status}`);
}
const result: ApiResponse<Notification> = await response.json();
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");
}
}
/**
* Mark all notifications as read
*/
export async function markAllAsRead(token: string | null): Promise<void> {
const headers: Record<string, string> = {
"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,
},
try {
await apiClient.post(
"/api/notifications/mark-all-read",
{},
withAuth(token),
);
if (!response.ok) {
} catch (error) {
if (isAxiosError(error) && error.response) {
throw new Error(
`Failed to mark all notifications as read: ${response.status}`,
`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<void> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const response = await fetch(
`${API_BASE_URL}/api/notifications/${notificationId}`,
{
method: "DELETE",
headers,
},
try {
await apiClient.delete(
`/api/notifications/${notificationId}`,
withAuth(token),
);
if (!response.ok) {
throw new Error(`Failed to delete notification: ${response.status}`);
} 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<void> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
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}`);
}
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}`);
throw new Error("Failed to save push token");
}
}

View File

@ -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<T> {
success?: boolean;
data?: T;
}
function isApiResponse<T>(value: unknown): value is ApiResponse<T> {
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<Recommendation[]> {
const headers: any = {
"Content-Type": "application/json",
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const response = await fetch(
`${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}?userId=${userId}`,
{ headers },
let result: ApiResponse<Recommendation[]> | 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}`,
);
if (!response.ok) {
throw new Error(`Failed to fetch recommendations: ${response.status}`);
}
const result = await response.json();
throw new Error("Failed to fetch recommendations");
}
// Handle standardized API response format
// API returns: { success: true, data: [...], meta: {...} }
if (result.success && result.data) {
if (
isApiResponse<Recommendation[]>(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<Recommendation> {
const headers: any = {
"Content-Type": "application/json",
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const response = await fetch(
`${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}/generate`,
{
method: "POST",
headers,
body: JSON.stringify(data),
},
let result: ApiResponse<Recommendation> | 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}`,
);
if (!response.ok) {
throw new Error(`Failed to generate recommendation: ${response.status}`);
}
const result = await response.json();
throw new Error("Failed to generate recommendation");
}
// Handle standardized API response format
if (result.success && result.data) {
if (isApiResponse<Recommendation>(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<Recommendation> {
const headers: any = {
"Content-Type": "application/json",
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const response = await fetch(
`${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}/approve`,
let result: ApiResponse<Recommendation> | Recommendation;
try {
const response = await apiClient.post(
`${API_ENDPOINTS.RECOMMENDATIONS}/approve`,
{
method: "POST",
headers,
body: JSON.stringify({
recommendationId,
status: "approved",
approvedBy,
}),
},
withAuth(token),
);
if (!response.ok) {
throw new Error(`Failed to approve recommendation: ${response.status}`);
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<Recommendation>(result) && result.success && result.data) {
return result.data;
}
// Fallback for legacy format
return result;
return result as Recommendation;
}

View File

@ -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<Gym[]>([]);
const [gymsLoading, setGymsLoading] = useState<boolean>(false);
const [selectedGymId, setSelectedGymId] = useState<string | null>(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)) {
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 });
}

View File

@ -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<Gym[]>([]);
const [gymsLoading, setGymsLoading] = useState(false);
const [selectedGymId, setSelectedGymId] = useState<string | null>(null);
const [currentGymId, setCurrentGymId] = useState<string | null>(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

View File

@ -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<any> {
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<FitnessGoal[]> {
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<FitnessGoal> {
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<FitnessGoal> {
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<FitnessGoal> {
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<void> {
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;
}