Compare commits

..

No commits in common. "0ddac10c59348a4e9a628e1ef7af884802a5b469" and "b1f84722af38f71a2e2288d9ff0b77589d461afc" have entirely different histories.

9 changed files with 105 additions and 268 deletions

View File

@ -1,120 +0,0 @@
import { beforeEach, describe, expect, it, jest } from "@jest/globals";
import {
deleteNotification,
fetchNotifications,
fetchUnreadCount,
markAllAsRead,
markAsRead,
savePushToken,
} from "../notifications";
import { apiClient, withAuth } from "../client";
jest.mock("../client", () => ({
apiClient: {
get: jest.fn(),
put: jest.fn(),
post: jest.fn(),
delete: jest.fn(),
},
withAuth: jest.fn((token?: string | null) =>
token ? { headers: { Authorization: `Bearer ${token}` } } : {},
),
}));
describe("notifications api", () => {
const getMock = apiClient.get as any;
const putMock = apiClient.put as any;
const postMock = apiClient.post as any;
const deleteMock = apiClient.delete as any;
beforeEach(() => {
jest.clearAllMocks();
});
it("returns notifications and normalizes createdAt", async () => {
getMock.mockResolvedValue({
data: {
success: true,
data: [
{
id: "n_1",
userId: "u_1",
title: "Hi",
message: "Welcome",
type: "system",
read: false,
createdAt: "2026-03-29T10:00:00.000Z",
},
],
},
});
const result = await fetchNotifications("token_1");
expect(result).toHaveLength(1);
expect(result[0].createdAt).toBeInstanceOf(Date);
expect(withAuth).toHaveBeenCalledWith("token_1");
});
it("returns unread count from response wrapper", async () => {
getMock.mockResolvedValue({
data: {
success: true,
data: { count: 3 },
},
});
const count = await fetchUnreadCount(null);
expect(count).toBe(3);
});
it("marks notification as read", async () => {
putMock.mockResolvedValue({
data: {
success: true,
data: {
id: "n_1",
userId: "u_1",
title: "Hi",
message: "Welcome",
type: "system",
read: true,
createdAt: "2026-03-29T10:00:00.000Z",
},
},
});
const result = await markAsRead("n_1", "token_1");
expect(result.read).toBe(true);
expect(putMock).toHaveBeenCalledWith(
"/api/notifications/n_1",
{},
expect.any(Object),
);
});
it("calls mark-all, delete and save-token endpoints", async () => {
postMock.mockResolvedValue({});
deleteMock.mockResolvedValue({});
await markAllAsRead("token_1");
await deleteNotification("n_2", "token_1");
await savePushToken("expo-token", "android", "token_1");
expect(postMock).toHaveBeenCalledWith(
"/api/notifications/mark-all-read",
{},
expect.any(Object),
);
expect(deleteMock).toHaveBeenCalledWith(
"/api/notifications/n_2",
expect.any(Object),
);
expect(postMock).toHaveBeenCalledWith(
"/api/notifications/save-token",
{ expoPushToken: "expo-token", deviceType: "android" },
expect.any(Object),
);
});
});

View File

@ -38,12 +38,9 @@ describe("recommendations api", () => {
expect(withAuth).toHaveBeenCalledWith("token_1"); expect(withAuth).toHaveBeenCalledWith("token_1");
}); });
it("returns recommendation from standardized response for generate", async () => { it("falls back to legacy response for generate", async () => {
postMock.mockResolvedValue({ postMock.mockResolvedValue({
data: {
success: true,
data: { id: "rec_2", status: "pending" }, data: { id: "rec_2", status: "pending" },
},
}); });
const result = await generateRecommendation({ userId: "user_1" }, null); const result = await generateRecommendation({ userId: "user_1" }, null);

View File

@ -1,14 +0,0 @@
import { ApiError, handleResponse } from "./responses";
import { type ApiResponse } from "./types";
export function parseApiData<T>(payload: unknown): T {
if (Array.isArray(payload)) {
return payload as T;
}
if (payload && typeof payload === "object" && "success" in payload) {
return handleResponse(payload as ApiResponse<T>);
}
throw new ApiError("Invalid response format");
}

View File

@ -13,5 +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 "./helpers";
export * from "./gyms"; export * from "./gyms";

View File

@ -1,6 +1,5 @@
import { isAxiosError } from "axios"; import { isAxiosError } from "axios";
import { apiClient, withAuth } from "./client"; import { apiClient, withAuth } from "./client";
import { parseApiData } from "./helpers";
export interface Notification { export interface Notification {
id: string; id: string;
@ -12,6 +11,15 @@ export interface Notification {
createdAt: Date; createdAt: Date;
} }
interface ApiResponse<T> {
success: boolean;
data: T;
meta?: {
timestamp: string;
count?: number;
};
}
/** /**
* Fetch all notifications for the authenticated user * Fetch all notifications for the authenticated user
*/ */
@ -19,12 +27,21 @@ export async function fetchNotifications(
token: string | null, token: string | null,
): Promise<Notification[]> { ): Promise<Notification[]> {
try { try {
const response = await apiClient.get("/api/notifications", withAuth(token)); const response = await apiClient.get<ApiResponse<Notification[]>>(
const notifications = parseApiData<Notification[]>(response.data); "/api/notifications",
return notifications.map((notification) => ({ withAuth(token),
);
const result = response.data;
if (result.success && result.data) {
return result.data.map((notification) => ({
...notification, ...notification,
createdAt: new Date(notification.createdAt), createdAt: new Date(notification.createdAt),
})); }));
}
return [];
} catch (error) { } catch (error) {
if (isAxiosError(error) && error.response) { if (isAxiosError(error) && error.response) {
throw new Error( throw new Error(
@ -40,12 +57,12 @@ export async function fetchNotifications(
*/ */
export async function fetchUnreadCount(token: string | null): Promise<number> { export async function fetchUnreadCount(token: string | null): Promise<number> {
try { try {
const response = await apiClient.get( const response = await apiClient.get<ApiResponse<{ count: number }>>(
"/api/notifications/unread-count", "/api/notifications/unread-count",
withAuth(token), withAuth(token),
); );
const data = parseApiData<{ count: number }>(response.data); const result = response.data;
return data.count; return result.success && result.data ? result.data.count : 0;
} catch (error) { } catch (error) {
if (isAxiosError(error) && error.response) { if (isAxiosError(error) && error.response) {
throw new Error(`Failed to fetch unread count: ${error.response.status}`); throw new Error(`Failed to fetch unread count: ${error.response.status}`);
@ -62,22 +79,29 @@ export async function markAsRead(
token: string | null, token: string | null,
): Promise<Notification> { ): Promise<Notification> {
try { try {
const response = await apiClient.put( const response = await apiClient.put<ApiResponse<Notification>>(
`/api/notifications/${notificationId}`, `/api/notifications/${notificationId}`,
{}, {},
withAuth(token), withAuth(token),
); );
const notification = parseApiData<Notification>(response.data);
const result = response.data;
if (result.success && result.data) {
return { return {
...notification, ...result.data,
createdAt: new Date(notification.createdAt), createdAt: new Date(result.data.createdAt),
}; };
}
throw new Error("Invalid response format");
} catch (error) { } catch (error) {
if (isAxiosError(error) && error.response) { if (isAxiosError(error) && error.response) {
throw new Error( throw new Error(
`Failed to mark notification as read: ${error.response.status}`, `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"); throw new Error("Failed to mark notification as read");
} }
} }

View File

@ -1,7 +1,6 @@
import { isAxiosError } from "axios"; import { isAxiosError } from "axios";
import { apiClient, withAuth } from "./client"; import { apiClient, withAuth } from "./client";
import { API_ENDPOINTS } from "../config/api"; import { API_ENDPOINTS } from "../config/api";
import { parseApiData } from "./helpers";
export interface Recommendation { export interface Recommendation {
id: string; id: string;
@ -24,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
* *
@ -35,20 +43,34 @@ export async function getRecommendations(
userId: string, userId: string,
token: string | null, token: string | null,
): Promise<Recommendation[]> { ): Promise<Recommendation[]> {
let result: ApiResponse<Recommendation[]> | Recommendation[];
try { try {
const response = await apiClient.get(API_ENDPOINTS.RECOMMENDATIONS, { const response = await apiClient.get(API_ENDPOINTS.RECOMMENDATIONS, {
params: { userId }, params: { userId },
...withAuth(token), ...withAuth(token),
}); });
return parseApiData<Recommendation[]>(response.data); result = response.data;
} catch (error) { } catch (error) {
if (isAxiosError(error) && error.response) { if (isAxiosError(error) && error.response) {
throw new Error( throw new Error(
`Failed to fetch recommendations: ${error.response.status}`, `Failed to fetch recommendations: ${error.response.status}`,
); );
} }
throw error; throw new Error("Failed to fetch recommendations");
} }
// Handle standardized API response format
// API returns: { success: true, data: [...], meta: {...} }
if (
isApiResponse<Recommendation[]>(result) &&
result.success &&
result.data
) {
return Array.isArray(result.data) ? result.data : [];
}
// Fallback for legacy format (direct array)
return Array.isArray(result) ? result : [];
} }
/** /**
@ -62,21 +84,30 @@ export async function generateRecommendation(
data: GenerateRecommendationRequest, data: GenerateRecommendationRequest,
token: string | null, token: string | null,
): Promise<Recommendation> { ): Promise<Recommendation> {
let result: ApiResponse<Recommendation> | Recommendation;
try { try {
const response = await apiClient.post( const response = await apiClient.post(
`${API_ENDPOINTS.RECOMMENDATIONS}/generate`, `${API_ENDPOINTS.RECOMMENDATIONS}/generate`,
data, data,
withAuth(token), withAuth(token),
); );
return parseApiData<Recommendation>(response.data); result = response.data;
} catch (error) { } catch (error) {
if (isAxiosError(error) && error.response) { if (isAxiosError(error) && error.response) {
throw new Error( throw new Error(
`Failed to generate recommendation: ${error.response.status}`, `Failed to generate recommendation: ${error.response.status}`,
); );
} }
throw error; throw new Error("Failed to generate recommendation");
} }
// Handle standardized API response format
if (isApiResponse<Recommendation>(result) && result.success && result.data) {
return result.data;
}
// Fallback for legacy format
return result as Recommendation;
} }
/** /**
@ -84,12 +115,14 @@ export async function generateRecommendation(
* *
* @param recommendationId - Recommendation ID * @param recommendationId - Recommendation ID
* @param token - Auth token * @param token - Auth token
* @param approvedBy - User ID of the approver (optional)
* @returns The approved recommendation * @returns The approved recommendation
*/ */
export async function approveRecommendation( export async function approveRecommendation(
recommendationId: string, recommendationId: string,
token: string | null, token: string | null,
): Promise<Recommendation> { ): Promise<Recommendation> {
let result: ApiResponse<Recommendation> | Recommendation;
try { try {
const response = await apiClient.post( const response = await apiClient.post(
`${API_ENDPOINTS.RECOMMENDATIONS}/approve`, `${API_ENDPOINTS.RECOMMENDATIONS}/approve`,
@ -99,13 +132,21 @@ export async function approveRecommendation(
}, },
withAuth(token), withAuth(token),
); );
return parseApiData<Recommendation>(response.data); result = response.data;
} catch (error) { } catch (error) {
if (isAxiosError(error) && error.response) { if (isAxiosError(error) && error.response) {
throw new Error( throw new Error(
`Failed to approve recommendation: ${error.response.status}`, `Failed to approve recommendation: ${error.response.status}`,
); );
} }
throw error; throw new Error("Failed to approve recommendation");
} }
// Handle standardized API response format
if (isApiResponse<Recommendation>(result) && result.success && result.data) {
return result.data;
}
// Fallback for legacy format
return result as Recommendation;
} }

View File

@ -4,14 +4,12 @@ import * as SecureStore from "expo-secure-store";
import { View, Text } from "react-native"; import { View, Text } from "react-native";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { SafeAreaProvider } from "react-native-safe-area-context"; import { SafeAreaProvider } from "react-native-safe-area-context";
import { QueryClientProvider } from "@tanstack/react-query";
import { validateEnv } from "../utils/env"; import { validateEnv } from "../utils/env";
import { ThemeProvider } from "../contexts/ThemeContext"; import { ThemeProvider } from "../contexts/ThemeContext";
import { StatisticsProvider } from "../contexts/StatisticsContext"; import { StatisticsProvider } from "../contexts/StatisticsContext";
import { FitnessGoalsProvider } from "../contexts/FitnessGoalsContext"; import { FitnessGoalsProvider } from "../contexts/FitnessGoalsContext";
import { RecommendationsProvider } from "../contexts/RecommendationsContext"; import { RecommendationsProvider } from "../contexts/RecommendationsContext";
import { NotificationsProvider } from "../contexts/NotificationsContext"; import { NotificationsProvider } from "../contexts/NotificationsContext";
import { queryClient } from "../lib/query-client";
import log from "../utils/logger"; import log from "../utils/logger";
// Wrapper to use notification permissions hook after ClerkLoaded // Wrapper to use notification permissions hook after ClerkLoaded
@ -174,7 +172,6 @@ export default function RootLayout() {
return ( return (
<SafeAreaProvider> <SafeAreaProvider>
<QueryClientProvider client={queryClient}>
<ClerkProvider tokenCache={tokenCache} publishableKey={publishableKey}> <ClerkProvider tokenCache={tokenCache} publishableKey={publishableKey}>
<ClerkLoaded> <ClerkLoaded>
<ThemeProvider> <ThemeProvider>
@ -190,7 +187,6 @@ export default function RootLayout() {
</ThemeProvider> </ThemeProvider>
</ClerkLoaded> </ClerkLoaded>
</ClerkProvider> </ClerkProvider>
</QueryClientProvider>
</SafeAreaProvider> </SafeAreaProvider>
); );
} }

View File

@ -1,18 +0,0 @@
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30000,
gcTime: 5 * 60 * 1000,
retry: 1,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
},
},
});
export const userScopedKey = (
resource: string,
userId: string,
): [string, string] => [resource, userId];

View File

@ -1,68 +0,0 @@
# Release Hardening Checklist
This checklist is the final validation gate before shipping security and mobile API changes.
## 1) Pre-Release Validation
- [ ] Confirm target branch is up to date with `master`
- [ ] Verify no unintended files are staged (`git status --short`)
- [ ] Confirm release notes summarize risky changes (authz, API contracts, caching)
## 2) Automated Checks
Run from repository root:
```bash
npm run typecheck:admin
npm run typecheck:mobile
npm run test:admin
```
Run mobile API-focused tests:
```bash
cd apps/mobile
npm run test -- src/api/__tests__/gyms.test.ts src/api/__tests__/recommendations.test.ts src/api/__tests__/notifications.test.ts
```
## 3) Admin Security Spot Checks
- [ ] Verify non-admin receives `403` on privileged routes
- [ ] Verify unauthenticated requests receive `401`
- [ ] Verify cross-gym actions are denied for non-superAdmin
- [ ] Verify `DELETE /api/users` blocks self-delete for admin users
- [ ] Verify recommendation approval derives approver from auth context (not request body)
## 4) Mobile Functional Smoke Checks
- [ ] Sign in as User A and load tabs/profile data
- [ ] Sign out and sign in as User B
- [ ] Confirm no User A data remains in goals, hydration, nutrition, stats, recommendations, notifications
- [ ] Confirm onboarding gym selection and profile save flow still succeed
- [ ] Confirm notifications load and unread count updates after read/delete actions
## 5) Rollback Plan
If release incidents occur:
1. Revert the release commit(s) from newest to oldest.
2. Redeploy reverted build.
3. Validate authentication and onboarding flows.
4. Post incident note with root cause and follow-up action.
Suggested rollback command pattern:
```bash
git revert <latest_commit_sha>
git revert <previous_commit_sha>
```
## 6) Deployment Record
Capture this in PR/release notes:
- Release date/time:
- Release owner:
- Commits included:
- Validation commands run:
- Known caveats (if any):