Compare commits
No commits in common. "34e88bdde5f29437b5c75d67eef02ff85d0d888e" and "c36cad9c54ea99f742b71f6cccc671027949db53" have entirely different histories.
34e88bdde5
...
c36cad9c54
@ -1,19 +1,11 @@
|
|||||||
try {
|
import 'react-native-gesture-handler/jestSetup'
|
||||||
require("react-native-gesture-handler/jestSetup");
|
|
||||||
} catch {
|
|
||||||
// Package may be absent in minimal test environments
|
|
||||||
}
|
|
||||||
|
|
||||||
jest.mock(
|
jest.mock('react-native-reanimated', () => {
|
||||||
"react-native-reanimated",
|
const Reanimated = require('react-native-reanimated/mock')
|
||||||
() => {
|
Reanimated.default.call = () => {}
|
||||||
const Reanimated = require("react-native-reanimated/mock");
|
return Reanimated
|
||||||
Reanimated.default.call = () => {};
|
})
|
||||||
return Reanimated;
|
|
||||||
},
|
|
||||||
{ virtual: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
jest.mock("@expo/vector-icons", () => ({
|
jest.mock('@expo/vector-icons', () => ({
|
||||||
Ionicons: "Ionicons",
|
Ionicons: 'Ionicons',
|
||||||
}));
|
}))
|
||||||
@ -1,55 +0,0 @@
|
|||||||
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),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,68 +0,0 @@
|
|||||||
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,52 +1,18 @@
|
|||||||
import axios, { type AxiosRequestConfig } from "axios";
|
import axios from 'axios';
|
||||||
import { API_BASE_URL } from "../config/api";
|
import { API_BASE_URL } from '../config/api';
|
||||||
import log from "../utils/logger";
|
|
||||||
|
|
||||||
export const apiClient = axios.create({
|
export const apiClient = axios.create({
|
||||||
baseURL: API_BASE_URL,
|
baseURL: API_BASE_URL,
|
||||||
timeout: 15000,
|
|
||||||
headers: {
|
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
|
// Helper to set the auth token for a request
|
||||||
export const setAuthToken = (token: string) => {
|
export const setAuthToken = (token: string) => {
|
||||||
if (token) {
|
if (token) {
|
||||||
apiClient.defaults.headers.common.Authorization = `Bearer ${token}`;
|
apiClient.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||||
} else {
|
} else {
|
||||||
delete apiClient.defaults.headers.common.Authorization;
|
delete apiClient.defaults.headers.common['Authorization'];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,56 +0,0 @@
|
|||||||
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,4 +13,3 @@ export * from "./recommendations";
|
|||||||
export * from "./nutrition";
|
export * from "./nutrition";
|
||||||
export * from "./hydration";
|
export * from "./hydration";
|
||||||
export * from "./client";
|
export * from "./client";
|
||||||
export * from "./gyms";
|
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { isAxiosError } from "axios";
|
import { API_BASE_URL } from "../config/api";
|
||||||
import { apiClient, withAuth } from "./client";
|
|
||||||
|
|
||||||
export interface Notification {
|
export interface Notification {
|
||||||
id: string;
|
id: string;
|
||||||
@ -26,15 +25,30 @@ interface ApiResponse<T> {
|
|||||||
export async function fetchNotifications(
|
export async function fetchNotifications(
|
||||||
token: string | null,
|
token: string | null,
|
||||||
): Promise<Notification[]> {
|
): Promise<Notification[]> {
|
||||||
try {
|
const headers: Record<string, string> = {
|
||||||
const response = await apiClient.get<ApiResponse<Notification[]>>(
|
"Content-Type": "application/json",
|
||||||
"/api/notifications",
|
};
|
||||||
withAuth(token),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = response.data;
|
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}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: ApiResponse<Notification[]> = await response.json();
|
||||||
|
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
|
// Convert date strings to Date objects
|
||||||
return result.data.map((notification) => ({
|
return result.data.map((notification) => ({
|
||||||
...notification,
|
...notification,
|
||||||
createdAt: new Date(notification.createdAt),
|
createdAt: new Date(notification.createdAt),
|
||||||
@ -42,33 +56,35 @@ export async function fetchNotifications(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
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
|
* Get unread notification count
|
||||||
*/
|
*/
|
||||||
export async function fetchUnreadCount(token: string | null): Promise<number> {
|
export async function fetchUnreadCount(token: string | null): Promise<number> {
|
||||||
try {
|
const headers: Record<string, string> = {
|
||||||
const response = await apiClient.get<ApiResponse<{ count: number }>>(
|
"Content-Type": "application/json",
|
||||||
"/api/notifications/unread-count",
|
};
|
||||||
withAuth(token),
|
|
||||||
|
if (token) {
|
||||||
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_BASE_URL}/api/notifications/unread-count`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
const result = response.data;
|
|
||||||
|
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;
|
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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -78,51 +94,62 @@ export async function markAsRead(
|
|||||||
notificationId: string,
|
notificationId: string,
|
||||||
token: string | null,
|
token: string | null,
|
||||||
): Promise<Notification> {
|
): Promise<Notification> {
|
||||||
try {
|
const headers: Record<string, string> = {
|
||||||
const response = await apiClient.put<ApiResponse<Notification>>(
|
"Content-Type": "application/json",
|
||||||
`/api/notifications/${notificationId}`,
|
};
|
||||||
{},
|
|
||||||
withAuth(token),
|
if (token) {
|
||||||
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_BASE_URL}/api/notifications/${notificationId}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = response.data;
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to mark notification as read: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: ApiResponse<Notification> = await response.json();
|
||||||
|
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
return {
|
return {
|
||||||
...result.data,
|
...result.data,
|
||||||
createdAt: new Date(result.data.createdAt),
|
createdAt: new Date(result.data.createdAt),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("Invalid response format");
|
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
|
* Mark all notifications as read
|
||||||
*/
|
*/
|
||||||
export async function markAllAsRead(token: string | null): Promise<void> {
|
export async function markAllAsRead(token: string | null): Promise<void> {
|
||||||
try {
|
const headers: Record<string, string> = {
|
||||||
await apiClient.post(
|
"Content-Type": "application/json",
|
||||||
"/api/notifications/mark-all-read",
|
};
|
||||||
{},
|
|
||||||
withAuth(token),
|
if (token) {
|
||||||
);
|
headers["Authorization"] = `Bearer ${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");
|
|
||||||
|
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}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -133,18 +160,24 @@ export async function deleteNotification(
|
|||||||
notificationId: string,
|
notificationId: string,
|
||||||
token: string | null,
|
token: string | null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
const headers: Record<string, string> = {
|
||||||
await apiClient.delete(
|
"Content-Type": "application/json",
|
||||||
`/api/notifications/${notificationId}`,
|
};
|
||||||
withAuth(token),
|
|
||||||
);
|
if (token) {
|
||||||
} catch (error) {
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
if (isAxiosError(error) && error.response) {
|
|
||||||
throw new Error(
|
|
||||||
`Failed to delete notification: ${error.response.status}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
throw new Error("Failed to delete notification");
|
|
||||||
|
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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -156,16 +189,21 @@ export async function savePushToken(
|
|||||||
deviceType: "ios" | "android",
|
deviceType: "ios" | "android",
|
||||||
token: string | null,
|
token: string | null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
const headers: Record<string, string> = {
|
||||||
await apiClient.post(
|
"Content-Type": "application/json",
|
||||||
"/api/notifications/save-token",
|
};
|
||||||
{ expoPushToken, deviceType },
|
|
||||||
withAuth(token),
|
if (token) {
|
||||||
);
|
headers["Authorization"] = `Bearer ${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");
|
|
||||||
|
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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,4 @@
|
|||||||
import { isAxiosError } from "axios";
|
import { API_BASE_URL, API_ENDPOINTS } from "../config/api";
|
||||||
import { apiClient, withAuth } from "./client";
|
|
||||||
import { API_ENDPOINTS } from "../config/api";
|
|
||||||
|
|
||||||
export interface Recommendation {
|
export interface Recommendation {
|
||||||
id: string;
|
id: string;
|
||||||
@ -23,15 +21,6 @@ export interface GenerateRecommendationRequest {
|
|||||||
modelProvider?: "openai" | "deepseek" | "ollama";
|
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
|
* Get recommendations for a user
|
||||||
*
|
*
|
||||||
@ -43,29 +32,28 @@ export async function getRecommendations(
|
|||||||
userId: string,
|
userId: string,
|
||||||
token: string | null,
|
token: string | null,
|
||||||
): Promise<Recommendation[]> {
|
): Promise<Recommendation[]> {
|
||||||
let result: ApiResponse<Recommendation[]> | Recommendation[];
|
const headers: any = {
|
||||||
try {
|
"Content-Type": "application/json",
|
||||||
const response = await apiClient.get(API_ENDPOINTS.RECOMMENDATIONS, {
|
};
|
||||||
params: { userId },
|
|
||||||
...withAuth(token),
|
if (token) {
|
||||||
});
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
result = response.data;
|
}
|
||||||
} catch (error) {
|
|
||||||
if (isAxiosError(error) && error.response) {
|
const response = await fetch(
|
||||||
throw new Error(
|
`${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}?userId=${userId}`,
|
||||||
`Failed to fetch recommendations: ${error.response.status}`,
|
{ headers },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch recommendations: ${response.status}`);
|
||||||
}
|
}
|
||||||
throw new Error("Failed to fetch recommendations");
|
|
||||||
}
|
const result = await response.json();
|
||||||
|
|
||||||
// Handle standardized API response format
|
// Handle standardized API response format
|
||||||
// API returns: { success: true, data: [...], meta: {...} }
|
// API returns: { success: true, data: [...], meta: {...} }
|
||||||
if (
|
if (result.success && result.data) {
|
||||||
isApiResponse<Recommendation[]>(result) &&
|
|
||||||
result.success &&
|
|
||||||
result.data
|
|
||||||
) {
|
|
||||||
return Array.isArray(result.data) ? result.data : [];
|
return Array.isArray(result.data) ? result.data : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -84,30 +72,36 @@ export async function generateRecommendation(
|
|||||||
data: GenerateRecommendationRequest,
|
data: GenerateRecommendationRequest,
|
||||||
token: string | null,
|
token: string | null,
|
||||||
): Promise<Recommendation> {
|
): Promise<Recommendation> {
|
||||||
let result: ApiResponse<Recommendation> | Recommendation;
|
const headers: any = {
|
||||||
try {
|
"Content-Type": "application/json",
|
||||||
const response = await apiClient.post(
|
};
|
||||||
`${API_ENDPOINTS.RECOMMENDATIONS}/generate`,
|
|
||||||
data,
|
if (token) {
|
||||||
withAuth(token),
|
headers["Authorization"] = `Bearer ${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
|
// Handle standardized API response format
|
||||||
if (isApiResponse<Recommendation>(result) && result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
return result.data;
|
return result.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback for legacy format
|
// Fallback for legacy format
|
||||||
return result as Recommendation;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -121,32 +115,40 @@ export async function generateRecommendation(
|
|||||||
export async function approveRecommendation(
|
export async function approveRecommendation(
|
||||||
recommendationId: string,
|
recommendationId: string,
|
||||||
token: string | null,
|
token: string | null,
|
||||||
|
approvedBy?: string,
|
||||||
): Promise<Recommendation> {
|
): Promise<Recommendation> {
|
||||||
let result: ApiResponse<Recommendation> | Recommendation;
|
const headers: any = {
|
||||||
try {
|
"Content-Type": "application/json",
|
||||||
const response = await apiClient.post(
|
};
|
||||||
`${API_ENDPOINTS.RECOMMENDATIONS}/approve`,
|
|
||||||
{
|
if (token) {
|
||||||
recommendationId,
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
status: "approved",
|
|
||||||
},
|
|
||||||
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 response = await fetch(
|
||||||
|
`${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}/approve`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
recommendationId,
|
||||||
|
status: "approved",
|
||||||
|
approvedBy,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to approve recommendation: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
// Handle standardized API response format
|
// Handle standardized API response format
|
||||||
if (isApiResponse<Recommendation>(result) && result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
return result.data;
|
return result.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback for legacy format
|
// Fallback for legacy format
|
||||||
return result as Recommendation;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import {
|
|||||||
import { useUser, useAuth } from "@clerk/clerk-expo";
|
import { useUser, useAuth } from "@clerk/clerk-expo";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { fitnessProfileApi } from "@/api/fitnessProfile";
|
import { fitnessProfileApi } from "@/api/fitnessProfile";
|
||||||
import { gymsApi, type Gym } from "@/api/gyms";
|
import { API_BASE_URL } from "@/config/api";
|
||||||
import log from "../../utils/logger";
|
import log from "../../utils/logger";
|
||||||
|
|
||||||
export default function OnboardingScreen() {
|
export default function OnboardingScreen() {
|
||||||
@ -20,7 +20,9 @@ export default function OnboardingScreen() {
|
|||||||
const { getToken } = useAuth();
|
const { getToken } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [gyms, setGyms] = useState<Gym[]>([]);
|
const [gyms, setGyms] = useState<
|
||||||
|
Array<{ id: string; name: string; location?: string }>
|
||||||
|
>([]);
|
||||||
const [gymsLoading, setGymsLoading] = useState<boolean>(false);
|
const [gymsLoading, setGymsLoading] = useState<boolean>(false);
|
||||||
const [selectedGymId, setSelectedGymId] = useState<string | null>(null);
|
const [selectedGymId, setSelectedGymId] = useState<string | null>(null);
|
||||||
|
|
||||||
@ -29,8 +31,13 @@ export default function OnboardingScreen() {
|
|||||||
try {
|
try {
|
||||||
setGymsLoading(true);
|
setGymsLoading(true);
|
||||||
const token = await getToken();
|
const token = await getToken();
|
||||||
const data = await gymsApi.getGyms(token);
|
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);
|
setGyms(data);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.error("Failed to fetch gyms", e);
|
log.error("Failed to fetch gyms", e);
|
||||||
} finally {
|
} finally {
|
||||||
@ -75,7 +82,14 @@ export default function OnboardingScreen() {
|
|||||||
// If gym was selected or cleared, patch user's gym selection first
|
// If gym was selected or cleared, patch user's gym selection first
|
||||||
// selectedGymId: string gym id, or null to proceed without gym
|
// selectedGymId: string gym id, or null to proceed without gym
|
||||||
try {
|
try {
|
||||||
await gymsApi.updateUserGym(selectedGymId, token);
|
await fetch(`${API_BASE_URL}/api/users/gym`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ gymId: selectedGymId }),
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.warn("Failed to update gym selection", { gymId: selectedGymId });
|
log.warn("Failed to update gym selection", { gymId: selectedGymId });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,8 +18,8 @@ import { ListItem } from "../../components/ListItem";
|
|||||||
import { MinimalButton } from "../../components/MinimalButton";
|
import { MinimalButton } from "../../components/MinimalButton";
|
||||||
import { Badge } from "../../components/Badge";
|
import { Badge } from "../../components/Badge";
|
||||||
import { IconContainer } from "../../components/IconContainer";
|
import { IconContainer } from "../../components/IconContainer";
|
||||||
|
import { API_BASE_URL, API_ENDPOINTS } from "../../config/api";
|
||||||
import { fitnessProfileApi, FitnessProfile } from "../../api/fitnessProfile";
|
import { fitnessProfileApi, FitnessProfile } from "../../api/fitnessProfile";
|
||||||
import { gymsApi, type Gym } from "../../api/gyms";
|
|
||||||
import log from "../../utils/logger";
|
import log from "../../utils/logger";
|
||||||
|
|
||||||
export default function ProfileScreen() {
|
export default function ProfileScreen() {
|
||||||
@ -29,7 +29,9 @@ export default function ProfileScreen() {
|
|||||||
const { colors, typography, theme: activeTheme, setTheme } = useTheme();
|
const { colors, typography, theme: activeTheme, setTheme } = useTheme();
|
||||||
const { getToken } = useAuth();
|
const { getToken } = useAuth();
|
||||||
|
|
||||||
const [gyms, setGyms] = useState<Gym[]>([]);
|
const [gyms, setGyms] = useState<
|
||||||
|
Array<{ id: string; name: string; location?: string }>
|
||||||
|
>([]);
|
||||||
const [gymsLoading, setGymsLoading] = useState(false);
|
const [gymsLoading, setGymsLoading] = useState(false);
|
||||||
const [selectedGymId, setSelectedGymId] = useState<string | null>(null);
|
const [selectedGymId, setSelectedGymId] = useState<string | null>(null);
|
||||||
const [currentGymId, setCurrentGymId] = useState<string | null>(null);
|
const [currentGymId, setCurrentGymId] = useState<string | null>(null);
|
||||||
@ -74,14 +76,51 @@ export default function ProfileScreen() {
|
|||||||
try {
|
try {
|
||||||
setGymsLoading(true);
|
setGymsLoading(true);
|
||||||
const token = await getToken();
|
const token = await getToken();
|
||||||
const list = await gymsApi.getGyms(token);
|
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 : [];
|
||||||
setGyms(list);
|
setGyms(list);
|
||||||
const gid =
|
const gid =
|
||||||
currentGymId ??
|
currentGymId ??
|
||||||
((user?.publicMetadata as any)?.gymId as string | undefined) ??
|
((user?.publicMetadata as any)?.gymId as string | undefined) ??
|
||||||
null;
|
null;
|
||||||
if (gid) {
|
if (gid) {
|
||||||
const g = list.find((x) => x.id === gid);
|
const g = list.find((x: any) => x.id === gid);
|
||||||
setCurrentGymId(gid);
|
setCurrentGymId(gid);
|
||||||
setCurrentGymName(g?.name ?? null);
|
setCurrentGymName(g?.name ?? null);
|
||||||
if (selectedGymId === null) setSelectedGymId(gid);
|
if (selectedGymId === null) setSelectedGymId(gid);
|
||||||
@ -97,7 +136,42 @@ export default function ProfileScreen() {
|
|||||||
const handleApplyGym = async () => {
|
const handleApplyGym = async () => {
|
||||||
try {
|
try {
|
||||||
const token = await getToken();
|
const token = await getToken();
|
||||||
await gymsApi.updateUserGym(selectedGymId, token);
|
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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
setCurrentGymId(selectedGymId);
|
setCurrentGymId(selectedGymId);
|
||||||
setCurrentGymName(
|
setCurrentGymName(
|
||||||
selectedGymId
|
selectedGymId
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import React, {
|
|||||||
useContext,
|
useContext,
|
||||||
useState,
|
useState,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
|
||||||
useRef,
|
useRef,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useUser, useAuth } from "@clerk/clerk-expo";
|
import { useUser, useAuth } from "@clerk/clerk-expo";
|
||||||
@ -169,22 +168,12 @@ export function FitnessGoalsProvider({
|
|||||||
|
|
||||||
const clearCache = useCallback(() => {
|
const clearCache = useCallback(() => {
|
||||||
setGoals([]);
|
setGoals([]);
|
||||||
setLoading(false);
|
|
||||||
setLastFetchTime(0);
|
setLastFetchTime(0);
|
||||||
setError(null);
|
setError(null);
|
||||||
fetchInProgress.current = false;
|
fetchInProgress.current = false;
|
||||||
log.debug("Fitness goals cache cleared");
|
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 (
|
return (
|
||||||
<FitnessGoalsContext.Provider
|
<FitnessGoalsContext.Provider
|
||||||
value={{
|
value={{
|
||||||
|
|||||||
@ -74,18 +74,6 @@ export function HydrationProvider({ children }: { children: React.ReactNode }) {
|
|||||||
fetchTodayHydration();
|
fetchTodayHydration();
|
||||||
}, [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(
|
const addWater = useCallback(
|
||||||
async (amount: number) => {
|
async (amount: number) => {
|
||||||
if (!user?.id) return;
|
if (!user?.id) return;
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import React, {
|
|||||||
useCallback,
|
useCallback,
|
||||||
useRef,
|
useRef,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useAuth, useUser } from "@clerk/clerk-expo";
|
import { useAuth } from "@clerk/clerk-expo";
|
||||||
import {
|
import {
|
||||||
fetchNotifications,
|
fetchNotifications,
|
||||||
fetchUnreadCount,
|
fetchUnreadCount,
|
||||||
@ -37,7 +37,6 @@ export function NotificationsProvider({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { getToken, isSignedIn } = useAuth();
|
const { getToken, isSignedIn } = useAuth();
|
||||||
const { user } = useUser();
|
|
||||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||||
const [unreadCount, setUnreadCount] = useState(0);
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@ -161,19 +160,6 @@ export function NotificationsProvider({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isSignedIn]); // Only run when sign-in state changes
|
}, [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
|
// Periodic refresh every 30 seconds
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isSignedIn) return;
|
if (!isSignedIn) return;
|
||||||
|
|||||||
@ -88,19 +88,6 @@ export function NutritionProvider({ children }: { children: React.ReactNode }) {
|
|||||||
fetchTodayNutrition();
|
fetchTodayNutrition();
|
||||||
}, [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(
|
const addMeal = useCallback(
|
||||||
async (data: Omit<MealEntry, "id" | "createdAt" | "dailyNutritionId">) => {
|
async (data: Omit<MealEntry, "id" | "createdAt" | "dailyNutritionId">) => {
|
||||||
if (!user?.id) return;
|
if (!user?.id) return;
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import React, {
|
|||||||
useContext,
|
useContext,
|
||||||
useState,
|
useState,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
|
||||||
useRef,
|
useRef,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useUser, useAuth } from "@clerk/clerk-expo";
|
import { useUser, useAuth } from "@clerk/clerk-expo";
|
||||||
@ -109,22 +108,12 @@ export function RecommendationsProvider({
|
|||||||
|
|
||||||
const clearCache = useCallback(() => {
|
const clearCache = useCallback(() => {
|
||||||
setRecommendations([]);
|
setRecommendations([]);
|
||||||
setLoading(false);
|
|
||||||
setLastFetchTime(0);
|
setLastFetchTime(0);
|
||||||
setError(null);
|
setError(null);
|
||||||
fetchInProgress.current = false;
|
fetchInProgress.current = false;
|
||||||
log.debug("Recommendations cache cleared");
|
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 (
|
return (
|
||||||
<RecommendationsContext.Provider
|
<RecommendationsContext.Provider
|
||||||
value={{
|
value={{
|
||||||
|
|||||||
@ -1,10 +1,4 @@
|
|||||||
import React, {
|
import React, { createContext, useContext, useState, useCallback } from "react";
|
||||||
createContext,
|
|
||||||
useContext,
|
|
||||||
useState,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
} from "react";
|
|
||||||
import { useUser, useAuth } from "@clerk/clerk-expo";
|
import { useUser, useAuth } from "@clerk/clerk-expo";
|
||||||
import { getUserStatistics } from "../api/statistics";
|
import { getUserStatistics } from "../api/statistics";
|
||||||
import type { UserStatisticsResponse } from "../api/types";
|
import type { UserStatisticsResponse } from "../api/types";
|
||||||
@ -81,21 +75,11 @@ export function StatisticsProvider({
|
|||||||
|
|
||||||
const clearCache = useCallback(() => {
|
const clearCache = useCallback(() => {
|
||||||
setStatistics(null);
|
setStatistics(null);
|
||||||
setLoading(false);
|
|
||||||
setLastFetchTime(0);
|
setLastFetchTime(0);
|
||||||
setError(null);
|
setError(null);
|
||||||
log.debug("Statistics cache cleared");
|
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 () => {
|
const forceRefresh = useCallback(async () => {
|
||||||
if (!user?.id) return;
|
if (!user?.id) return;
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,4 @@
|
|||||||
import { isAxiosError } from "axios";
|
import { API_BASE_URL, API_ENDPOINTS } from "../config/api";
|
||||||
import { apiClient, withAuth } from "../api/client";
|
|
||||||
import { API_ENDPOINTS } from "../config/api";
|
|
||||||
import log from "../utils/logger";
|
import log from "../utils/logger";
|
||||||
|
|
||||||
export interface FitnessGoal {
|
export interface FitnessGoal {
|
||||||
@ -43,21 +41,38 @@ export interface CreateGoalData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class FitnessGoalsService {
|
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(
|
async getGoals(
|
||||||
userId: string,
|
userId: string,
|
||||||
token: string | null,
|
token: string | null,
|
||||||
status?: string,
|
status?: string,
|
||||||
): Promise<FitnessGoal[]> {
|
): Promise<FitnessGoal[]> {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get(API_ENDPOINTS.FITNESS_GOALS.LIST, {
|
const headers = await this.getAuthHeaders(token);
|
||||||
params: {
|
let url = `${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.LIST}?userId=${userId}`;
|
||||||
userId,
|
|
||||||
...(status && status !== "all" ? { status } : {}),
|
|
||||||
},
|
|
||||||
...withAuth(token),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = response.data;
|
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();
|
||||||
|
|
||||||
// Handle standardized API response format
|
// Handle standardized API response format
|
||||||
// API returns: { success: true, data: [...], meta: {...} }
|
// API returns: { success: true, data: [...], meta: {...} }
|
||||||
@ -68,9 +83,6 @@ export class FitnessGoalsService {
|
|||||||
// Fallback for legacy format (direct array)
|
// Fallback for legacy format (direct array)
|
||||||
return Array.isArray(result) ? result : [];
|
return Array.isArray(result) ? result : [];
|
||||||
} catch (error) {
|
} 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);
|
log.error("Failed to fetch fitness goals", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@ -81,13 +93,22 @@ export class FitnessGoalsService {
|
|||||||
token: string | null,
|
token: string | null,
|
||||||
): Promise<FitnessGoal> {
|
): Promise<FitnessGoal> {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post(
|
const headers = await this.getAuthHeaders(token);
|
||||||
API_ENDPOINTS.FITNESS_GOALS.CREATE,
|
const response = await fetch(
|
||||||
goalData,
|
`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.CREATE}`,
|
||||||
withAuth(token),
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(goalData),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = response.data;
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || "Failed to create goal");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
// Handle standardized API response format
|
// Handle standardized API response format
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
@ -97,14 +118,6 @@ export class FitnessGoalsService {
|
|||||||
// Fallback for legacy format
|
// Fallback for legacy format
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} 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);
|
log.error("Failed to create fitness goal", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@ -116,13 +129,21 @@ export class FitnessGoalsService {
|
|||||||
token: string | null,
|
token: string | null,
|
||||||
): Promise<FitnessGoal> {
|
): Promise<FitnessGoal> {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.put(
|
const headers = await this.getAuthHeaders(token);
|
||||||
API_ENDPOINTS.FITNESS_GOALS.UPDATE(id),
|
const response = await fetch(
|
||||||
updates,
|
`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.UPDATE(id)}`,
|
||||||
withAuth(token),
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(updates),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = response.data;
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to update goal");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
// Handle standardized API response format
|
// Handle standardized API response format
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
@ -132,9 +153,6 @@ export class FitnessGoalsService {
|
|||||||
// Fallback for legacy format
|
// Fallback for legacy format
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} 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);
|
log.error("Failed to update fitness goal", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@ -150,13 +168,20 @@ export class FitnessGoalsService {
|
|||||||
|
|
||||||
async completeGoal(id: string, token: string | null): Promise<FitnessGoal> {
|
async completeGoal(id: string, token: string | null): Promise<FitnessGoal> {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post(
|
const headers = await this.getAuthHeaders(token);
|
||||||
API_ENDPOINTS.FITNESS_GOALS.COMPLETE(id),
|
const response = await fetch(
|
||||||
{},
|
`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.COMPLETE(id)}`,
|
||||||
withAuth(token),
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = response.data;
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to complete goal");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
// Note: Complete endpoint returns direct object (legacy format)
|
// Note: Complete endpoint returns direct object (legacy format)
|
||||||
// Handle standardized API response format (if migrated)
|
// Handle standardized API response format (if migrated)
|
||||||
@ -167,9 +192,6 @@ export class FitnessGoalsService {
|
|||||||
// Fallback for legacy format (current implementation)
|
// Fallback for legacy format (current implementation)
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} 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);
|
log.error("Failed to complete fitness goal", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@ -177,17 +199,22 @@ export class FitnessGoalsService {
|
|||||||
|
|
||||||
async deleteGoal(id: string, token: string | null): Promise<void> {
|
async deleteGoal(id: string, token: string | null): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await apiClient.delete(
|
const headers = await this.getAuthHeaders(token);
|
||||||
API_ENDPOINTS.FITNESS_GOALS.DELETE(id),
|
const response = await fetch(
|
||||||
withAuth(token),
|
`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.DELETE(id)}`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
headers,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to delete goal");
|
||||||
|
}
|
||||||
|
|
||||||
// DELETE endpoint returns: { success: true, data: { deleted: true }, meta: {...} }
|
// DELETE endpoint returns: { success: true, data: { deleted: true }, meta: {...} }
|
||||||
// No need to parse the result for void return type
|
// No need to parse the result for void return type
|
||||||
} catch (error) {
|
} 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);
|
log.error("Failed to delete fitness goal", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user