Merge branch 'nextPhase'
This commit is contained in:
commit
b1f84722af
@ -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',
|
||||
}))
|
||||
jest.mock("@expo/vector-icons", () => ({
|
||||
Ionicons: "Ionicons",
|
||||
}));
|
||||
|
||||
55
apps/mobile/src/api/__tests__/gyms.test.ts
Normal file
55
apps/mobile/src/api/__tests__/gyms.test.ts
Normal file
@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
68
apps/mobile/src/api/__tests__/recommendations.test.ts
Normal file
68
apps/mobile/src/api/__tests__/recommendations.test.ts
Normal file
@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
56
apps/mobile/src/api/gyms.ts
Normal file
56
apps/mobile/src/api/gyms.ts
Normal 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");
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -13,3 +13,4 @@ export * from "./recommendations";
|
||||
export * from "./nutrition";
|
||||
export * from "./hydration";
|
||||
export * from "./client";
|
||||
export * from "./gyms";
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 (
|
||||
<FitnessGoalsContext.Provider
|
||||
value={{
|
||||
|
||||
@ -74,6 +74,18 @@ export function HydrationProvider({ children }: { children: React.ReactNode }) {
|
||||
fetchTodayHydration();
|
||||
}, [fetchTodayHydration]);
|
||||
|
||||
useEffect(() => {
|
||||
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;
|
||||
|
||||
@ -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<Notification[]>([]);
|
||||
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;
|
||||
|
||||
@ -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<MealEntry, "id" | "createdAt" | "dailyNutritionId">) => {
|
||||
if (!user?.id) return;
|
||||
|
||||
@ -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 (
|
||||
<RecommendationsContext.Provider
|
||||
value={{
|
||||
|
||||
@ -1,4 +1,10 @@
|
||||
import React, { createContext, useContext, useState, useCallback } from "react";
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import { useUser, useAuth } from "@clerk/clerk-expo";
|
||||
import { getUserStatistics } from "../api/statistics";
|
||||
import type { UserStatisticsResponse } from "../api/types";
|
||||
@ -75,11 +81,21 @@ export function StatisticsProvider({
|
||||
|
||||
const clearCache = useCallback(() => {
|
||||
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;
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user