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', () => {
|
jest.mock(
|
||||||
const Reanimated = require('react-native-reanimated/mock')
|
"react-native-reanimated",
|
||||||
Reanimated.default.call = () => {}
|
() => {
|
||||||
return Reanimated
|
const Reanimated = require("react-native-reanimated/mock");
|
||||||
})
|
Reanimated.default.call = () => {};
|
||||||
|
return Reanimated;
|
||||||
|
},
|
||||||
|
{ virtual: true },
|
||||||
|
);
|
||||||
|
|
||||||
jest.mock('@expo/vector-icons', () => ({
|
jest.mock("@expo/vector-icons", () => ({
|
||||||
Ionicons: 'Ionicons',
|
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 axios, { type AxiosRequestConfig } 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,
|
||||||
headers: {
|
timeout: 15000,
|
||||||
'Content-Type': 'application/json',
|
headers: {
|
||||||
},
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
const status = error.response?.status;
|
||||||
|
if (status === 401) {
|
||||||
|
log.warn("API unauthorized response", { url: error.config?.url });
|
||||||
|
} else if (status === 403) {
|
||||||
|
log.warn("API forbidden response", { url: error.config?.url });
|
||||||
|
} else if (status && status >= 500) {
|
||||||
|
log.error("API server error", error, {
|
||||||
|
status,
|
||||||
|
url: error.config?.url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export function withAuth(token?: string | null): AxiosRequestConfig {
|
||||||
|
if (!token) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Helper to set the auth token for a request
|
// 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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
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 "./nutrition";
|
||||||
export * from "./hydration";
|
export * from "./hydration";
|
||||||
export * from "./client";
|
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 {
|
export interface Notification {
|
||||||
id: string;
|
id: string;
|
||||||
@ -25,66 +26,49 @@ interface ApiResponse<T> {
|
|||||||
export async function fetchNotifications(
|
export async function fetchNotifications(
|
||||||
token: string | null,
|
token: string | null,
|
||||||
): Promise<Notification[]> {
|
): Promise<Notification[]> {
|
||||||
const headers: Record<string, string> = {
|
try {
|
||||||
"Content-Type": "application/json",
|
const response = await apiClient.get<ApiResponse<Notification[]>>(
|
||||||
};
|
"/api/notifications",
|
||||||
|
withAuth(token),
|
||||||
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 = response.data;
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
return result.data.map((notification) => ({
|
||||||
|
...notification,
|
||||||
|
createdAt: new Date(notification.createdAt),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
} catch (error) {
|
||||||
|
if (isAxiosError(error) && error.response) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch notifications: ${error.response.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error("Failed to fetch notifications");
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: ApiResponse<Notification[]> = await response.json();
|
|
||||||
|
|
||||||
if (result.success && result.data) {
|
|
||||||
// Convert date strings to Date objects
|
|
||||||
return result.data.map((notification) => ({
|
|
||||||
...notification,
|
|
||||||
createdAt: new Date(notification.createdAt),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get unread notification count
|
* Get unread notification count
|
||||||
*/
|
*/
|
||||||
export async function fetchUnreadCount(token: string | null): Promise<number> {
|
export async function fetchUnreadCount(token: string | null): Promise<number> {
|
||||||
const headers: Record<string, string> = {
|
try {
|
||||||
"Content-Type": "application/json",
|
const response = await apiClient.get<ApiResponse<{ count: number }>>(
|
||||||
};
|
"/api/notifications/unread-count",
|
||||||
|
withAuth(token),
|
||||||
if (token) {
|
);
|
||||||
headers["Authorization"] = `Bearer ${token}`;
|
const result = response.data;
|
||||||
|
return result.success && result.data ? result.data.count : 0;
|
||||||
|
} catch (error) {
|
||||||
|
if (isAxiosError(error) && error.response) {
|
||||||
|
throw new Error(`Failed to fetch unread count: ${error.response.status}`);
|
||||||
|
}
|
||||||
|
throw new Error("Failed to fetch unread count");
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
`${API_BASE_URL}/api/notifications/unread-count`,
|
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
headers,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch unread count: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: ApiResponse<{ count: number }> = await response.json();
|
|
||||||
|
|
||||||
return result.success && result.data ? result.data.count : 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -94,62 +78,51 @@ export async function markAsRead(
|
|||||||
notificationId: string,
|
notificationId: string,
|
||||||
token: string | null,
|
token: string | null,
|
||||||
): Promise<Notification> {
|
): Promise<Notification> {
|
||||||
const headers: Record<string, string> = {
|
try {
|
||||||
"Content-Type": "application/json",
|
const response = await apiClient.put<ApiResponse<Notification>>(
|
||||||
};
|
`/api/notifications/${notificationId}`,
|
||||||
|
{},
|
||||||
|
withAuth(token),
|
||||||
|
);
|
||||||
|
|
||||||
if (token) {
|
const result = response.data;
|
||||||
headers["Authorization"] = `Bearer ${token}`;
|
if (result.success && result.data) {
|
||||||
|
return {
|
||||||
|
...result.data,
|
||||||
|
createdAt: new Date(result.data.createdAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error("Invalid response format");
|
||||||
|
} catch (error) {
|
||||||
|
if (isAxiosError(error) && error.response) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to mark notification as read: ${error.response.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (error instanceof Error && error.message === "Invalid response format") {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error("Failed to mark notification as read");
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
`${API_BASE_URL}/api/notifications/${notificationId}`,
|
|
||||||
{
|
|
||||||
method: "PUT",
|
|
||||||
headers,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to mark notification as read: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: ApiResponse<Notification> = await response.json();
|
|
||||||
|
|
||||||
if (result.success && result.data) {
|
|
||||||
return {
|
|
||||||
...result.data,
|
|
||||||
createdAt: new Date(result.data.createdAt),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error("Invalid response format");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark all notifications as read
|
* Mark all notifications as read
|
||||||
*/
|
*/
|
||||||
export async function markAllAsRead(token: string | null): Promise<void> {
|
export async function markAllAsRead(token: string | null): Promise<void> {
|
||||||
const headers: Record<string, string> = {
|
try {
|
||||||
"Content-Type": "application/json",
|
await apiClient.post(
|
||||||
};
|
"/api/notifications/mark-all-read",
|
||||||
|
{},
|
||||||
if (token) {
|
withAuth(token),
|
||||||
headers["Authorization"] = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
`${API_BASE_URL}/api/notifications/mark-all-read`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(
|
|
||||||
`Failed to mark all notifications as read: ${response.status}`,
|
|
||||||
);
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (isAxiosError(error) && error.response) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to mark all notifications as read: ${error.response.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error("Failed to mark all notifications as read");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -160,24 +133,18 @@ export async function deleteNotification(
|
|||||||
notificationId: string,
|
notificationId: string,
|
||||||
token: string | null,
|
token: string | null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const headers: Record<string, string> = {
|
try {
|
||||||
"Content-Type": "application/json",
|
await apiClient.delete(
|
||||||
};
|
`/api/notifications/${notificationId}`,
|
||||||
|
withAuth(token),
|
||||||
if (token) {
|
);
|
||||||
headers["Authorization"] = `Bearer ${token}`;
|
} catch (error) {
|
||||||
}
|
if (isAxiosError(error) && error.response) {
|
||||||
|
throw new Error(
|
||||||
const response = await fetch(
|
`Failed to delete notification: ${error.response.status}`,
|
||||||
`${API_BASE_URL}/api/notifications/${notificationId}`,
|
);
|
||||||
{
|
}
|
||||||
method: "DELETE",
|
throw new Error("Failed to delete notification");
|
||||||
headers,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to delete notification: ${response.status}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -189,21 +156,16 @@ export async function savePushToken(
|
|||||||
deviceType: "ios" | "android",
|
deviceType: "ios" | "android",
|
||||||
token: string | null,
|
token: string | null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const headers: Record<string, string> = {
|
try {
|
||||||
"Content-Type": "application/json",
|
await apiClient.post(
|
||||||
};
|
"/api/notifications/save-token",
|
||||||
|
{ expoPushToken, deviceType },
|
||||||
if (token) {
|
withAuth(token),
|
||||||
headers["Authorization"] = `Bearer ${token}`;
|
);
|
||||||
}
|
} catch (error) {
|
||||||
|
if (isAxiosError(error) && error.response) {
|
||||||
const response = await fetch(`${API_BASE_URL}/api/notifications/save-token`, {
|
throw new Error(`Failed to save push token: ${error.response.status}`);
|
||||||
method: "POST",
|
}
|
||||||
headers,
|
throw new Error("Failed to save push token");
|
||||||
body: JSON.stringify({ expoPushToken, deviceType }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to save push token: ${response.status}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
export interface Recommendation {
|
||||||
id: string;
|
id: string;
|
||||||
@ -21,6 +23,15 @@ 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
|
||||||
*
|
*
|
||||||
@ -32,28 +43,29 @@ export async function getRecommendations(
|
|||||||
userId: string,
|
userId: string,
|
||||||
token: string | null,
|
token: string | null,
|
||||||
): Promise<Recommendation[]> {
|
): Promise<Recommendation[]> {
|
||||||
const headers: any = {
|
let result: ApiResponse<Recommendation[]> | Recommendation[];
|
||||||
"Content-Type": "application/json",
|
try {
|
||||||
};
|
const response = await apiClient.get(API_ENDPOINTS.RECOMMENDATIONS, {
|
||||||
|
params: { userId },
|
||||||
if (token) {
|
...withAuth(token),
|
||||||
headers["Authorization"] = `Bearer ${token}`;
|
});
|
||||||
|
result = response.data;
|
||||||
|
} catch (error) {
|
||||||
|
if (isAxiosError(error) && error.response) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch recommendations: ${error.response.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error("Failed to fetch recommendations");
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
`${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}?userId=${userId}`,
|
|
||||||
{ headers },
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch recommendations: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
// Handle standardized API response format
|
// Handle standardized API response format
|
||||||
// API returns: { success: true, data: [...], meta: {...} }
|
// 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 : [];
|
return Array.isArray(result.data) ? result.data : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -72,36 +84,30 @@ export async function generateRecommendation(
|
|||||||
data: GenerateRecommendationRequest,
|
data: GenerateRecommendationRequest,
|
||||||
token: string | null,
|
token: string | null,
|
||||||
): Promise<Recommendation> {
|
): Promise<Recommendation> {
|
||||||
const headers: any = {
|
let result: ApiResponse<Recommendation> | Recommendation;
|
||||||
"Content-Type": "application/json",
|
try {
|
||||||
};
|
const response = await apiClient.post(
|
||||||
|
`${API_ENDPOINTS.RECOMMENDATIONS}/generate`,
|
||||||
if (token) {
|
data,
|
||||||
headers["Authorization"] = `Bearer ${token}`;
|
withAuth(token),
|
||||||
|
);
|
||||||
|
result = response.data;
|
||||||
|
} catch (error) {
|
||||||
|
if (isAxiosError(error) && error.response) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to generate recommendation: ${error.response.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error("Failed to generate recommendation");
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
`${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}/generate`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to generate recommendation: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
// Handle standardized API response format
|
// Handle standardized API response format
|
||||||
if (result.success && result.data) {
|
if (isApiResponse<Recommendation>(result) && result.success && result.data) {
|
||||||
return result.data;
|
return result.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback for legacy format
|
// Fallback for legacy format
|
||||||
return result;
|
return result as Recommendation;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -115,40 +121,32 @@ 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> {
|
||||||
const headers: any = {
|
let result: ApiResponse<Recommendation> | Recommendation;
|
||||||
"Content-Type": "application/json",
|
try {
|
||||||
};
|
const response = await apiClient.post(
|
||||||
|
`${API_ENDPOINTS.RECOMMENDATIONS}/approve`,
|
||||||
if (token) {
|
{
|
||||||
headers["Authorization"] = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
`${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}/approve`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify({
|
|
||||||
recommendationId,
|
recommendationId,
|
||||||
status: "approved",
|
status: "approved",
|
||||||
approvedBy,
|
},
|
||||||
}),
|
withAuth(token),
|
||||||
},
|
);
|
||||||
);
|
result = response.data;
|
||||||
|
} catch (error) {
|
||||||
if (!response.ok) {
|
if (isAxiosError(error) && error.response) {
|
||||||
throw new Error(`Failed to approve recommendation: ${response.status}`);
|
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
|
// Handle standardized API response format
|
||||||
if (result.success && result.data) {
|
if (isApiResponse<Recommendation>(result) && result.success && result.data) {
|
||||||
return result.data;
|
return result.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback for legacy format
|
// Fallback for legacy format
|
||||||
return result;
|
return result as Recommendation;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 { API_BASE_URL } from "@/config/api";
|
import { gymsApi, type Gym } from "@/api/gyms";
|
||||||
import log from "../../utils/logger";
|
import log from "../../utils/logger";
|
||||||
|
|
||||||
export default function OnboardingScreen() {
|
export default function OnboardingScreen() {
|
||||||
@ -20,9 +20,7 @@ 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<
|
const [gyms, setGyms] = useState<Gym[]>([]);
|
||||||
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);
|
||||||
|
|
||||||
@ -31,13 +29,8 @@ export default function OnboardingScreen() {
|
|||||||
try {
|
try {
|
||||||
setGymsLoading(true);
|
setGymsLoading(true);
|
||||||
const token = await getToken();
|
const token = await getToken();
|
||||||
const res = await fetch(`${API_BASE_URL}/api/gyms`, {
|
const data = await gymsApi.getGyms(token);
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
setGyms(data);
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (Array.isArray(data)) {
|
|
||||||
setGyms(data);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.error("Failed to fetch gyms", e);
|
log.error("Failed to fetch gyms", e);
|
||||||
} finally {
|
} finally {
|
||||||
@ -82,14 +75,7 @@ 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 fetch(`${API_BASE_URL}/api/users/gym`, {
|
await gymsApi.updateUserGym(selectedGymId, token);
|
||||||
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,9 +29,7 @@ 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<
|
const [gyms, setGyms] = useState<Gym[]>([]);
|
||||||
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);
|
||||||
@ -76,51 +74,14 @@ export default function ProfileScreen() {
|
|||||||
try {
|
try {
|
||||||
setGymsLoading(true);
|
setGymsLoading(true);
|
||||||
const token = await getToken();
|
const token = await getToken();
|
||||||
const url = `${API_BASE_URL}${API_ENDPOINTS.GYMS}`;
|
const list = await gymsApi.getGyms(token);
|
||||||
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: any) => x.id === gid);
|
const g = list.find((x) => 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);
|
||||||
@ -136,42 +97,7 @@ export default function ProfileScreen() {
|
|||||||
const handleApplyGym = async () => {
|
const handleApplyGym = async () => {
|
||||||
try {
|
try {
|
||||||
const token = await getToken();
|
const token = await getToken();
|
||||||
const url = `${API_BASE_URL}${API_ENDPOINTS.USERS.GYM}`;
|
await gymsApi.updateUserGym(selectedGymId, token);
|
||||||
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,6 +3,7 @@ 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";
|
||||||
@ -168,12 +169,22 @@ 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,6 +74,18 @@ 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 } from "@clerk/clerk-expo";
|
import { useAuth, useUser } from "@clerk/clerk-expo";
|
||||||
import {
|
import {
|
||||||
fetchNotifications,
|
fetchNotifications,
|
||||||
fetchUnreadCount,
|
fetchUnreadCount,
|
||||||
@ -37,6 +37,7 @@ 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);
|
||||||
@ -160,6 +161,19 @@ 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,6 +88,19 @@ 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,6 +3,7 @@ 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";
|
||||||
@ -108,12 +109,22 @@ 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,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 { 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";
|
||||||
@ -75,11 +81,21 @@ 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,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";
|
import log from "../utils/logger";
|
||||||
|
|
||||||
export interface FitnessGoal {
|
export interface FitnessGoal {
|
||||||
@ -41,38 +43,21 @@ 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 headers = await this.getAuthHeaders(token);
|
const response = await apiClient.get(API_ENDPOINTS.FITNESS_GOALS.LIST, {
|
||||||
let url = `${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.LIST}?userId=${userId}`;
|
params: {
|
||||||
|
userId,
|
||||||
|
...(status && status !== "all" ? { status } : {}),
|
||||||
|
},
|
||||||
|
...withAuth(token),
|
||||||
|
});
|
||||||
|
|
||||||
if (status && status !== "all") {
|
const result = response.data;
|
||||||
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: {...} }
|
||||||
@ -83,6 +68,9 @@ 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;
|
||||||
}
|
}
|
||||||
@ -93,22 +81,13 @@ export class FitnessGoalsService {
|
|||||||
token: string | null,
|
token: string | null,
|
||||||
): Promise<FitnessGoal> {
|
): Promise<FitnessGoal> {
|
||||||
try {
|
try {
|
||||||
const headers = await this.getAuthHeaders(token);
|
const response = await apiClient.post(
|
||||||
const response = await fetch(
|
API_ENDPOINTS.FITNESS_GOALS.CREATE,
|
||||||
`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.CREATE}`,
|
goalData,
|
||||||
{
|
withAuth(token),
|
||||||
method: "POST",
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify(goalData),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
const result = response.data;
|
||||||
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) {
|
||||||
@ -118,6 +97,14 @@ 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;
|
||||||
}
|
}
|
||||||
@ -129,21 +116,13 @@ export class FitnessGoalsService {
|
|||||||
token: string | null,
|
token: string | null,
|
||||||
): Promise<FitnessGoal> {
|
): Promise<FitnessGoal> {
|
||||||
try {
|
try {
|
||||||
const headers = await this.getAuthHeaders(token);
|
const response = await apiClient.put(
|
||||||
const response = await fetch(
|
API_ENDPOINTS.FITNESS_GOALS.UPDATE(id),
|
||||||
`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.UPDATE(id)}`,
|
updates,
|
||||||
{
|
withAuth(token),
|
||||||
method: "PUT",
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify(updates),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
const result = response.data;
|
||||||
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) {
|
||||||
@ -153,6 +132,9 @@ 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;
|
||||||
}
|
}
|
||||||
@ -168,20 +150,13 @@ export class FitnessGoalsService {
|
|||||||
|
|
||||||
async completeGoal(id: string, token: string | null): Promise<FitnessGoal> {
|
async completeGoal(id: string, token: string | null): Promise<FitnessGoal> {
|
||||||
try {
|
try {
|
||||||
const headers = await this.getAuthHeaders(token);
|
const response = await apiClient.post(
|
||||||
const response = await fetch(
|
API_ENDPOINTS.FITNESS_GOALS.COMPLETE(id),
|
||||||
`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.COMPLETE(id)}`,
|
{},
|
||||||
{
|
withAuth(token),
|
||||||
method: "POST",
|
|
||||||
headers,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
const result = response.data;
|
||||||
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)
|
||||||
@ -192,6 +167,9 @@ 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;
|
||||||
}
|
}
|
||||||
@ -199,22 +177,17 @@ export class FitnessGoalsService {
|
|||||||
|
|
||||||
async deleteGoal(id: string, token: string | null): Promise<void> {
|
async deleteGoal(id: string, token: string | null): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const headers = await this.getAuthHeaders(token);
|
await apiClient.delete(
|
||||||
const response = await fetch(
|
API_ENDPOINTS.FITNESS_GOALS.DELETE(id),
|
||||||
`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.DELETE(id)}`,
|
withAuth(token),
|
||||||
{
|
|
||||||
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