diff --git a/apps/admin/src/app/api/admin/analytics/route.ts b/apps/admin/src/app/api/admin/analytics/route.ts
index 638587f..1c0e7c5 100644
--- a/apps/admin/src/app/api/admin/analytics/route.ts
+++ b/apps/admin/src/app/api/admin/analytics/route.ts
@@ -4,6 +4,7 @@ import { getDatabase } from "@/lib/database";
import { ensureUserSynced } from "@/lib/sync-user";
import { successResponse } from "@/lib/api/responses";
import { db as rawDb, sql } from "@fitai/database";
+import { getUsersByGym, getClientsByGym } from "@/lib/gym-context";
interface UserGrowthPoint {
label: string;
@@ -45,12 +46,36 @@ export async function GET(req: NextRequest) {
const url = new URL(req.url);
const months = parseInt(url.searchParams.get("months") || "6");
- const allUsers = await database.getAllUsers();
- const allClients = await database.getAllClients();
+ // Get target gym based on role
+ const targetGymId =
+ user.role === "superAdmin"
+ ? (url.searchParams.get("gymId") ?? undefined)
+ : (user.gymId ?? undefined);
- const paymentsRaw = await rawDb.all(
- sql`SELECT * FROM payments WHERE status = 'completed' AND paid_at IS NOT NULL`,
- );
+ // Validate gym access for non-superAdmins
+ if (user.role !== "superAdmin" && !targetGymId) {
+ return NextResponse.json(
+ { error: "Forbidden - No gym assigned" },
+ { status: 403 },
+ );
+ }
+
+ // Get gym-scoped data
+ const allUsers = targetGymId
+ ? await getUsersByGym(targetGymId)
+ : await database.getAllUsers();
+
+ const allClients = targetGymId
+ ? await getClientsByGym(targetGymId)
+ : await database.getAllClients();
+
+ // For payments, we'd need a similar filter - for now, skip payments if gym-scoped
+ // TODO: Add getPaymentsByGym when needed
+ const paymentsRaw = targetGymId
+ ? [] // Skip payments for gym-scoped for now
+ : await rawDb.all(
+ sql`SELECT * FROM payments WHERE status = 'completed' AND paid_at IS NOT NULL`,
+ );
const payments: any[] = paymentsRaw || [];
const now = new Date();
diff --git a/apps/admin/src/app/api/admin/attendance/route.ts b/apps/admin/src/app/api/admin/attendance/route.ts
index c540337..03a5d50 100644
--- a/apps/admin/src/app/api/admin/attendance/route.ts
+++ b/apps/admin/src/app/api/admin/attendance/route.ts
@@ -1,23 +1,39 @@
import { auth } from "@clerk/nextjs/server";
-import { NextResponse } from "next/server";
+import { NextRequest, NextResponse } from "next/server";
import { getDatabase } from "@/lib/database";
import { ensureUserSynced } from "@/lib/sync-user";
import { successResponse } from "@/lib/api/responses";
+import { getAttendanceByGym } from "@/lib/gym-context";
-export async function GET(req: Request) {
+export async function GET(req: NextRequest) {
try {
const { userId } = await auth();
if (!userId) return new NextResponse("Unauthorized", { status: 401 });
const db = await getDatabase();
-
const user = await ensureUserSynced(userId, db);
if (!user || (user.role !== "admin" && user.role !== "superAdmin")) {
return new NextResponse("Forbidden", { status: 403 });
}
- const attendance = await db.getAllAttendance();
+ // Get target gym based on role
+ const url = new URL(req.url);
+ const targetGymId =
+ user.role === "superAdmin"
+ ? (url.searchParams.get("gymId") ?? undefined)
+ : (user.gymId ?? undefined);
+
+ // Validate gym access for non-superAdmins
+ if (user.role !== "superAdmin" && !targetGymId) {
+ return new NextResponse("Forbidden - No gym assigned", { status: 403 });
+ }
+
+ // Get attendance filtered by gym
+ const attendance = targetGymId
+ ? await getAttendanceByGym(targetGymId)
+ : await db.getAllAttendance();
+
return successResponse({ records: attendance });
} catch (error) {
console.error("Admin attendance error:", error);
diff --git a/apps/admin/src/app/api/profile/fitness/route.ts b/apps/admin/src/app/api/profile/fitness/route.ts
index e49825b..b4b6867 100644
--- a/apps/admin/src/app/api/profile/fitness/route.ts
+++ b/apps/admin/src/app/api/profile/fitness/route.ts
@@ -1,11 +1,14 @@
import { NextRequest, NextResponse } from "next/server";
import { getDatabase } from "../../../../lib/database/index";
+import { auth } from "@clerk/nextjs/server";
import log from "@/lib/logger";
import { fitnessProfileSchema } from "@/lib/validation/schemas";
import {
validateRequestBody,
validationErrorResponse,
} from "@/lib/validation/helpers";
+import { getFitnessProfilesByGym } from "@/lib/gym-context";
+import { ensureUserSynced } from "@/lib/sync-user";
export async function POST(request: NextRequest) {
try {
@@ -63,12 +66,47 @@ export async function POST(request: NextRequest) {
export async function GET(request: NextRequest) {
try {
- const db = await getDatabase();
- const { searchParams } = new URL(request.url);
- const userId = searchParams.get("userId");
+ // First authenticate
+ const { userId: clerkUserId } = await auth();
+ if (!clerkUserId) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
- if (userId) {
- const profile = await db.getFitnessProfileByUserId(userId);
+ const db = await getDatabase();
+ const currentUser = await ensureUserSynced(clerkUserId, db);
+
+ if (!currentUser) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const targetUserId = searchParams.get("userId");
+
+ // If accessing another user's profile, verify gym access
+ if (targetUserId && targetUserId !== clerkUserId) {
+ const isStaff =
+ currentUser.role === "admin" ||
+ currentUser.role === "superAdmin" ||
+ currentUser.role === "trainer";
+
+ if (!isStaff) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ // Staff need to verify target user is in same gym
+ if (currentUser.role !== "superAdmin") {
+ const targetUser = await db.getUserById(targetUserId);
+ if (!targetUser || targetUser.gymId !== currentUser.gymId) {
+ return NextResponse.json(
+ { error: "Forbidden - Cannot access users from other gyms" },
+ { status: 403 },
+ );
+ }
+ }
+ }
+
+ if (targetUserId) {
+ const profile = await db.getFitnessProfileByUserId(targetUserId);
if (!profile) {
return NextResponse.json(
{ error: "Profile not found" },
@@ -78,8 +116,28 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ profile });
}
- const profiles = await db.getAllFitnessProfiles();
- return NextResponse.json({ profiles });
+ // Staff get gym-scoped profiles
+ const isStaff =
+ currentUser.role === "admin" ||
+ currentUser.role === "superAdmin" ||
+ currentUser.role === "trainer";
+
+ if (isStaff) {
+ const targetGymId =
+ currentUser.role === "superAdmin"
+ ? (searchParams.get("gymId") ?? undefined)
+ : (currentUser.gymId ?? undefined);
+
+ const profiles = targetGymId
+ ? await getFitnessProfilesByGym(targetGymId)
+ : await db.getAllFitnessProfiles();
+
+ return NextResponse.json({ profiles });
+ }
+
+ // Regular users only get their own
+ const profile = await db.getFitnessProfileByUserId(clerkUserId);
+ return NextResponse.json({ profile: profile ? [profile] : [] });
} catch (error) {
log.error("Failed to get fitness profiles", error);
return NextResponse.json(
diff --git a/apps/admin/src/app/api/recommendations/route.ts b/apps/admin/src/app/api/recommendations/route.ts
index d084d03..d2b9eeb 100644
--- a/apps/admin/src/app/api/recommendations/route.ts
+++ b/apps/admin/src/app/api/recommendations/route.ts
@@ -17,6 +17,8 @@ import {
badRequestResponse,
internalErrorResponse,
} from "@/lib/api/responses";
+import { getRecommendationsByGym } from "@/lib/gym-context";
+import { ensureUserSynced } from "@/lib/sync-user";
export async function GET(request: NextRequest) {
try {
@@ -25,38 +27,59 @@ export async function GET(request: NextRequest) {
return unauthorizedResponse();
}
+ const db = await getDatabase();
+ const currentUser = await ensureUserSynced(currentUserId, db);
+
+ if (!currentUser) {
+ return forbiddenResponse("User not found");
+ }
+
const { searchParams } = new URL(request.url);
const targetUserId = searchParams.get("userId");
- const db = await getDatabase();
-
- // If no userId provided, check if staff and return all recommendations
+ // If no userId provided, staff gets gym-scoped recommendations
if (!targetUserId) {
- const currentUser = await db.getUserById(currentUserId);
const isStaff =
- currentUser?.role === "admin" ||
- currentUser?.role === "superAdmin" ||
- currentUser?.role === "trainer";
+ currentUser.role === "admin" ||
+ currentUser.role === "superAdmin" ||
+ currentUser.role === "trainer";
if (!isStaff) {
return badRequestResponse("User ID is required");
}
- const recommendations = await db.getAllRecommendations();
+ // Get target gym based on role
+ const targetGymId =
+ currentUser.role === "superAdmin"
+ ? (searchParams.get("gymId") ?? undefined)
+ : (currentUser.gymId ?? undefined);
+
+ // Get recommendations filtered by gym
+ const recommendations = targetGymId
+ ? await getRecommendationsByGym(targetGymId)
+ : await db.getAllRecommendations();
+
return successResponse({ recommendations });
}
- // Check permissions: Users can view their own, Admins/Trainers can view anyone's
- const currentUser = await db.getUserById(currentUserId);
+ // Check permissions: Users can view their own, Admins/Trainers can view anyone's in their gym
const isStaff =
- currentUser?.role === "admin" ||
- currentUser?.role === "superAdmin" ||
- currentUser?.role === "trainer";
+ currentUser.role === "admin" ||
+ currentUser.role === "superAdmin" ||
+ currentUser.role === "trainer";
if (currentUserId !== targetUserId) {
if (!isStaff) {
return forbiddenResponse();
}
+
+ // Staff need to verify target user is in same gym
+ if (currentUser.role !== "superAdmin") {
+ const targetUser = await db.getUserById(targetUserId);
+ if (!targetUser || targetUser.gymId !== currentUser.gymId) {
+ return forbiddenResponse("Cannot access users from other gyms");
+ }
+ }
}
let recommendations = await db.getRecommendationsByUserId(targetUserId);
diff --git a/apps/admin/src/app/api/users/route.ts b/apps/admin/src/app/api/users/route.ts
index ee57b71..832b00d 100644
--- a/apps/admin/src/app/api/users/route.ts
+++ b/apps/admin/src/app/api/users/route.ts
@@ -24,14 +24,42 @@ import {
internalErrorResponse,
badRequestResponse,
} from "@/lib/api/responses";
+import { getUsersByGym } from "@/lib/gym-context";
+import { ensureUserSynced } from "@/lib/sync-user";
export async function GET(request: NextRequest) {
try {
+ // First authenticate the user
+ const { userId: clerkUserId } = await auth();
+ if (!clerkUserId) {
+ return unauthorizedResponse();
+ }
+
const db = await getDatabase();
+ const currentUser = await ensureUserSynced(clerkUserId, db);
+
+ if (!currentUser) {
+ return forbiddenResponse("User not found");
+ }
+
const { searchParams } = new URL(request.url);
const role = searchParams.get("role");
- let users = await db.getAllUsers();
+ // Get target gym based on role
+ const targetGymId =
+ currentUser.role === "superAdmin"
+ ? (searchParams.get("gymId") ?? undefined)
+ : (currentUser.gymId ?? undefined);
+
+ // Validate gym access for non-superAdmins
+ if (currentUser.role !== "superAdmin" && !targetGymId) {
+ return forbiddenResponse("No gym assigned");
+ }
+
+ // Get users filtered by gym
+ let users = targetGymId
+ ? await getUsersByGym(targetGymId)
+ : await db.getAllUsers();
// Hydrate gymId from raw DB to ensure consistency with writes
const rawUserRows = await rawDb.all(sql`SELECT id, gym_id FROM users`);
diff --git a/apps/admin/src/app/page.tsx b/apps/admin/src/app/page.tsx
index 76278eb..2a00a55 100644
--- a/apps/admin/src/app/page.tsx
+++ b/apps/admin/src/app/page.tsx
@@ -14,11 +14,26 @@ import { AnalyticsDashboard } from "@/components/analytics/AnalyticsDashboard";
import { useDashboardStats } from "@/hooks/use-api";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
+import { useUser } from "@clerk/nextjs";
+import { useSearchParams } from "next/navigation";
+import { GymSelector } from "@/components/gym/GymSelector";
export default function Home() {
- const { data: stats, isLoading, refetch, isFetching } = useDashboardStats();
+ const { user } = useUser();
+ const searchParams = useSearchParams();
+ const gymId = searchParams.get("gymId") ?? undefined;
+
+ const {
+ data: stats,
+ isLoading,
+ refetch,
+ isFetching,
+ } = useDashboardStats(gymId);
const queryClient = useQueryClient();
+ // Get user role from metadata
+ const userRole = (user?.publicMetadata?.role as string) ?? "client";
+
const handleRefresh = () => {
queryClient.invalidateQueries({ queryKey: ["dashboard-stats"] });
queryClient.invalidateQueries({ queryKey: ["analytics"] });
@@ -40,18 +55,21 @@ export default function Home() {
title="Dashboard"
description="Welcome back! Here's what's happening with your gym today."
actions={
-
+
+
+
+
}
/>
@@ -125,7 +143,7 @@ export default function Home() {
Overview of your gym metrics
-
+
diff --git a/apps/admin/src/app/users/page.tsx b/apps/admin/src/app/users/page.tsx
index f631cb4..e5eed4a 100644
--- a/apps/admin/src/app/users/page.tsx
+++ b/apps/admin/src/app/users/page.tsx
@@ -1,17 +1,29 @@
+"use client";
+
import { UserManagement } from "@/components/users/UserManagement";
import { PageHeader } from "@/components/ui/PageHeader";
+import { useUser } from "@clerk/nextjs";
+import { useSearchParams } from "next/navigation";
+import { GymSelector } from "@/components/gym/GymSelector";
export default function UsersPage() {
+ const { user } = useUser();
+ const searchParams = useSearchParams();
+
+ const userRole = (user?.publicMetadata?.role as string) ?? "client";
+ const gymId = searchParams.get("gymId") ?? undefined;
+
return (
);
diff --git a/apps/admin/src/components/analytics/AnalyticsDashboard.tsx b/apps/admin/src/components/analytics/AnalyticsDashboard.tsx
index 3b3ec94..e94f828 100644
--- a/apps/admin/src/components/analytics/AnalyticsDashboard.tsx
+++ b/apps/admin/src/components/analytics/AnalyticsDashboard.tsx
@@ -6,8 +6,12 @@ import { RevenueChart } from "@/components/charts/RevenueChart";
import { Card, CardHeader, CardContent } from "@/components/ui/card";
import { useAnalytics } from "@/hooks/use-api";
-export function AnalyticsDashboard() {
- const { data: analytics, isLoading } = useAnalytics(6);
+interface AnalyticsDashboardProps {
+ gymId?: string;
+}
+
+export function AnalyticsDashboard({ gymId }: AnalyticsDashboardProps) {
+ const { data: analytics, isLoading } = useAnalytics(6, gymId);
const userGrowthData = analytics?.userGrowth ?? [];
const membershipData = analytics?.membershipDistribution ?? [];
diff --git a/apps/admin/src/components/gym/GymSelector.tsx b/apps/admin/src/components/gym/GymSelector.tsx
new file mode 100644
index 0000000..7907e79
--- /dev/null
+++ b/apps/admin/src/components/gym/GymSelector.tsx
@@ -0,0 +1,91 @@
+"use client";
+
+import { useRouter, useSearchParams } from "next/navigation";
+import { useEffect, useState } from "react";
+
+interface Gym {
+ id: string;
+ name: string;
+}
+
+interface GymSelectorProps {
+ currentGymId?: string;
+ userRole: string;
+}
+
+export function GymSelector({ userRole }: GymSelectorProps) {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const [gyms, setGyms] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ // Only fetch gyms for superAdmin
+ if (userRole !== "superAdmin") {
+ setLoading(false);
+ return;
+ }
+
+ async function fetchGyms() {
+ try {
+ const response = await fetch("/api/gyms");
+ const data = await response.json();
+
+ // API returns array directly
+ if (Array.isArray(data)) {
+ setGyms(data);
+ }
+ } catch (error) {
+ console.error("Failed to fetch gyms:", error);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ fetchGyms();
+ }, [userRole]);
+
+ const handleGymChange = (gymId: string) => {
+ const params = new URLSearchParams(searchParams.toString());
+
+ if (gymId === "all") {
+ params.delete("gymId");
+ } else {
+ params.set("gymId", gymId);
+ }
+
+ router.push(`?${params.toString()}`);
+ };
+
+ // Only show for superAdmin
+ if (userRole !== "superAdmin") {
+ return null;
+ }
+
+ const selectedGymId = searchParams.get("gymId") ?? "all";
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/admin/src/components/users/UserManagement.tsx b/apps/admin/src/components/users/UserManagement.tsx
index 24a1fa4..a5ce0a7 100644
--- a/apps/admin/src/components/users/UserManagement.tsx
+++ b/apps/admin/src/components/users/UserManagement.tsx
@@ -16,7 +16,11 @@ import {
useSendInvitation,
} from "@/hooks/use-api";
-export function UserManagement() {
+interface UserManagementProps {
+ gymId?: string;
+}
+
+export function UserManagement({ gymId }: UserManagementProps) {
const { user } = useUser();
const [filter, setFilter] = useState("all");
const [selectedUser, setSelectedUser] = useState(null);
@@ -38,6 +42,7 @@ export function UserManagement() {
refetch,
} = useUsers({
role: filter !== "all" ? filter : undefined,
+ gymId,
});
const { data: gyms = [] } = useGyms();
const updateUser = useUpdateUser();
diff --git a/apps/admin/src/hooks/use-api.ts b/apps/admin/src/hooks/use-api.ts
index 87be6d3..287eab0 100644
--- a/apps/admin/src/hooks/use-api.ts
+++ b/apps/admin/src/hooks/use-api.ts
@@ -370,12 +370,16 @@ export interface AnalyticsData {
revenue: { label: string; value: number; color: string }[];
}
-export function useAnalytics(months: number = 6) {
+export function useAnalytics(months: number = 6, gymId?: string) {
return useQuery({
- queryKey: ["analytics", months],
- queryFn: () =>
- fetchApi<{ data: { analytics: AnalyticsData } }>(
- `/api/admin/analytics?months=${months}`,
- ).then((res) => res.data?.analytics),
+ queryKey: ["analytics", months, gymId],
+ queryFn: () => {
+ const url = gymId
+ ? `/api/admin/analytics?months=${months}&gymId=${gymId}`
+ : `/api/admin/analytics?months=${months}`;
+ return fetchApi<{ data: { analytics: AnalyticsData } }>(url).then(
+ (res) => res.data?.analytics,
+ );
+ },
});
}
diff --git a/apps/admin/src/lib/auth/context.ts b/apps/admin/src/lib/auth/context.ts
new file mode 100644
index 0000000..50f1333
--- /dev/null
+++ b/apps/admin/src/lib/auth/context.ts
@@ -0,0 +1,231 @@
+import { NextRequest, NextResponse } from "next/server";
+import { auth } from "@clerk/nextjs/server";
+import { USER_ROLES, type UserRole } from "@fitai/shared";
+import { getDatabase } from "../database";
+
+export interface AuthContext {
+ userId: string;
+ role: UserRole;
+ gymId: string | null;
+}
+
+export interface AuthResult extends AuthContext {}
+
+/**
+ * Get the full authentication context including gymId
+ * Combines Clerk session claims with database lookup for accurate gym assignment
+ *
+ * @returns AuthContext with userId, role, and gymId
+ *
+ * @example
+ * const { userId, role, gymId } = await getAuthContext();
+ * // userId: "user_abc123"
+ * // role: "admin"
+ * // gymId: "gym_xyz"
+ */
+export async function getAuthContext(): Promise {
+ const { userId, sessionClaims } = await auth();
+
+ if (!userId) {
+ throw new Error("Unauthorized: No authenticated user");
+ }
+
+ // Get role from Clerk metadata
+ const role = (sessionClaims?.metadata as { role?: UserRole })?.role;
+
+ if (!role || !USER_ROLES.includes(role)) {
+ throw new Error(`Forbidden: Invalid or missing role - ${role}`);
+ }
+
+ // Get gymId from Clerk metadata or database
+ let gymId: string | null = null;
+
+ // First try Clerk metadata (faster path)
+ const clerkGymId = (sessionClaims?.metadata as { gymId?: string })?.gymId;
+ if (clerkGymId) {
+ gymId = clerkGymId;
+ } else {
+ // Fallback to database lookup
+ try {
+ const db = await getDatabase();
+ const user = await db.getUserById(userId);
+ gymId = user?.gymId ?? null;
+ } catch (error) {
+ console.error("Failed to get gymId from database:", error);
+ gymId = null;
+ }
+ }
+
+ return { userId, role, gymId };
+}
+
+/**
+ * Middleware to require authentication and optionally check user role
+ * Enhanced version that also retrieves gymId
+ *
+ * @param allowedRoles - Array of roles allowed to access the endpoint (optional)
+ * @returns Authentication result with userId, role, and gymId, or NextResponse error
+ *
+ * @example
+ * export async function DELETE(request: NextRequest) {
+ * const authResult = await requireAuth(["admin", "superAdmin"]);
+ * if (authResult instanceof NextResponse) return authResult;
+ * const { userId, role, gymId } = authResult;
+ * // ... proceed with authorized logic
+ * }
+ */
+export async function requireAuth(
+ allowedRoles?: UserRole[],
+): Promise {
+ try {
+ const context = await getAuthContext();
+
+ // Check if user has required role
+ if (allowedRoles && allowedRoles.length > 0) {
+ if (!allowedRoles.includes(context.role)) {
+ return NextResponse.json(
+ {
+ error: `Forbidden - Requires one of: ${allowedRoles.join(", ")}`,
+ requiredRoles: allowedRoles,
+ userRole: context.role,
+ },
+ { status: 403 },
+ );
+ }
+ }
+
+ return context;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Unknown error";
+
+ if (message.includes("Unauthorized")) {
+ return NextResponse.json(
+ { error: "Unauthorized - Authentication required" },
+ { status: 401 },
+ );
+ }
+
+ if (message.includes("Forbidden")) {
+ return NextResponse.json({ error: message }, { status: 403 });
+ }
+
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 },
+ );
+ }
+}
+
+/**
+ * Require authentication with gym access validation
+ * Use this for endpoints that need to verify the user can access a specific gym
+ *
+ * @param allowedRoles - Roles allowed to access this endpoint
+ * @param requiredGymId - Gym ID that must be accessed (optional - uses user's gym if not provided)
+ * @returns AuthResult with full context, or NextResponse error
+ *
+ * @example
+ * // Admin accessing their own gym
+ * const result = await requireGymAccess(["admin"]);
+ *
+ * // SuperAdmin accessing specific gym
+ * const result = await requireGymAccess(["superAdmin"], "gym_123");
+ */
+export async function requireGymAccess(
+ allowedRoles: UserRole[],
+ requiredGymId?: string,
+): Promise {
+ const authResult = await requireAuth(allowedRoles);
+
+ if (authResult instanceof NextResponse) {
+ return authResult;
+ }
+
+ const { role, gymId: userGymId } = authResult;
+
+ // Determine target gym
+ const targetGymId = requiredGymId ?? userGymId;
+
+ // SuperAdmin can access any gym
+ if (role === "superAdmin") {
+ return { ...authResult, gymId: targetGymId };
+ }
+
+ // Other roles must have matching gym
+ if (!targetGymId) {
+ return NextResponse.json(
+ { error: "Forbidden - User is not assigned to a gym" },
+ { status: 403 },
+ );
+ }
+
+ if (userGymId !== targetGymId) {
+ return NextResponse.json(
+ { error: "Forbidden - Cannot access other gym's data" },
+ { status: 403 },
+ );
+ }
+
+ return { ...authResult, gymId: targetGymId };
+}
+
+/**
+ * Get the target gym ID from request for superAdmin context switching
+ * SuperAdmins can pass ?gymId to view specific gym data
+ *
+ * @param request - NextRequest with query params
+ * @param userRole - Current user's role
+ * @param userGymId - Current user's gym ID
+ * @returns Target gym ID or undefined for superAdmin viewing all
+ *
+ * @example
+ * const targetGymId = getTargetGymId(request, role, gymId);
+ * // For superAdmin with ?gymId=abc -> "abc"
+ * // For superAdmin without param -> undefined (all gyms)
+ * // For admin -> always returns their gymId
+ */
+export function getTargetGymId(
+ request: NextRequest,
+ userRole: UserRole,
+ userGymId: string | null,
+): string | undefined {
+ // For superAdmin, allow gymId query param for context switching
+ if (userRole === "superAdmin") {
+ const queryGymId = request.nextUrl.searchParams.get("gymId");
+ return queryGymId ?? undefined;
+ }
+
+ // For other roles, they can only access their own gym
+ return userGymId ?? undefined;
+}
+
+/**
+ * Check if the authenticated user is trying to modify their own account
+ * Useful for preventing self-deletion or self-demotion
+ */
+export function isSelfModification(
+ userId: string,
+ targetUserId: string,
+): boolean {
+ return userId === targetUserId;
+}
+
+/**
+ * Validate that an admin isn't demoting themselves or deleting their own account
+ */
+export function preventSelfModification(
+ userId: string,
+ targetUserId: string,
+ action: string,
+): NextResponse | undefined {
+ if (isSelfModification(userId, targetUserId)) {
+ return NextResponse.json(
+ {
+ error: `Cannot ${action} your own account`,
+ hint: "Ask another administrator to perform this action",
+ },
+ { status: 403 },
+ );
+ }
+ return undefined;
+}
diff --git a/apps/admin/src/lib/auth/permissions.ts b/apps/admin/src/lib/auth/permissions.ts
new file mode 100644
index 0000000..907106d
--- /dev/null
+++ b/apps/admin/src/lib/auth/permissions.ts
@@ -0,0 +1,249 @@
+import type { UserRole } from "@fitai/shared";
+import { NextResponse } from "next/server";
+
+/**
+ * Check if a user can access data from a specific gym
+ *
+ * @param userRole - The role of the requesting user
+ * @param userGymId - The gym ID of the requesting user (null for superAdmin)
+ * @param targetGymId - The gym ID being accessed (undefined means all gyms)
+ * @returns true if access is allowed
+ *
+ * @example
+ * // SuperAdmin accessing any gym
+ * canAccessGym("superAdmin", null, "gym_123") // true
+ *
+ * // Admin accessing their own gym
+ * canAccessGym("admin", "gym_abc", "gym_abc") // true
+ *
+ * // Admin trying to access other gym
+ * canAccessGym("admin", "gym_abc", "gym_xyz") // false
+ */
+export function canAccessGym(
+ userRole: UserRole,
+ userGymId: string | null,
+ targetGymId: string | undefined,
+): boolean {
+ // SuperAdmin can access any gym
+ if (userRole === "superAdmin") {
+ return true;
+ }
+
+ // If no target gym specified, allow access to own gym
+ if (targetGymId === undefined) {
+ return true;
+ }
+
+ // Non-superAdmins must have a gymId
+ if (!userGymId) {
+ return false;
+ }
+
+ // Must match the gym
+ return userGymId === targetGymId;
+}
+
+/**
+ * Check if a user can manage (create/update/delete) another user
+ * Based on role hierarchy and gym membership
+ *
+ * @param requesterRole - Role of the user making the request
+ * @param requesterGymId - Gym ID of the requesting user
+ * @param targetRole - Role of the user being managed
+ * @param targetGymId - Gym ID of the user being managed
+ * @returns true if management is allowed
+ *
+ * @example
+ * // SuperAdmin can manage anyone
+ * canManageUser("superAdmin", null, "admin", "gym_abc") // true
+ *
+ * // Admin can manage trainers and clients in their gym
+ * canManageUser("admin", "gym_abc", "trainer", "gym_abc") // true
+ * canManageUser("admin", "gym_abc", "client", "gym_abc") // true
+ *
+ * // Admin cannot manage users in other gyms
+ * canManageUser("admin", "gym_abc", "trainer", "gym_xyz") // false
+ *
+ * // Trainer can manage clients in their gym
+ * canManageUser("trainer", "gym_abc", "client", "gym_abc") // true
+ */
+export function canManageUser(
+ requesterRole: UserRole,
+ requesterGymId: string | null,
+ targetRole: UserRole,
+ targetGymId: string | null,
+): boolean {
+ // SuperAdmin can manage anyone
+ if (requesterRole === "superAdmin") {
+ return true;
+ }
+
+ // Role hierarchy: admin > trainer > client
+ const roleHierarchy: Record = {
+ superAdmin: 4,
+ admin: 3,
+ trainer: 2,
+ client: 1,
+ };
+
+ const requesterLevel = roleHierarchy[requesterRole];
+ const targetLevel = roleHierarchy[targetRole];
+
+ // Cannot manage users at same or higher level
+ if (requesterLevel <= targetLevel) {
+ return false;
+ }
+
+ // Must have gym access
+ if (!requesterGymId) {
+ return false;
+ }
+
+ // For admin/trainer, target must be in same gym
+ if (requesterRole === "admin" || requesterRole === "trainer") {
+ return requesterGymId === targetGymId;
+ }
+
+ return false;
+}
+
+/**
+ * Check if a trainer can access a specific client's data
+ * Trainer must be assigned to that client via trainerClients table
+ *
+ * @param trainerGymId - Gym ID of the trainer
+ * @param clientGymId - Gym ID of the client
+ * @returns true if in same gym
+ *
+ * @note In a more complex system, this would query trainerClients table
+ * For now, we use gym-based access (trainer and client in same gym)
+ */
+export function canTrainerAccessClient(
+ trainerGymId: string | null,
+ clientGymId: string | null,
+): boolean {
+ if (!trainerGymId || !clientGymId) {
+ return false;
+ }
+ return trainerGymId === clientGymId;
+}
+
+/**
+ * Get the roles that a specific role can invite
+ * Based on role hierarchy
+ *
+ * @param role - The role wanting to invite
+ * @returns Array of roles that can be invited
+ *
+ * @example
+ * getInvitableRoles("superAdmin") // ["admin", "trainer", "client"]
+ * getInvitableRoles("admin") // ["trainer", "client"]
+ * getInvitableRoles("trainer") // ["client"]
+ * getInvitableRoles("client") // []
+ */
+export function getInvitableRoles(role: UserRole): UserRole[] {
+ const invitationRules: Record = {
+ superAdmin: ["admin", "trainer", "client"],
+ admin: ["trainer", "client"],
+ trainer: ["client"],
+ client: [],
+ };
+
+ return invitationRules[role] ?? [];
+}
+
+/**
+ * Validate gym access and return error response if denied
+ * Use this in API routes to enforce gym-based access control
+ *
+ * @param request - NextRequest for getting query params
+ * @param userRole - Current user's role
+ * @param userGymId - Current user's gym ID
+ * @param targetGymId - Gym being accessed (optional)
+ * @returns NextResponse if denied, undefined if allowed
+ *
+ * @example
+ * export async function GET(request: NextRequest) {
+ * const error = await validateGymAccess(request, role, gymId);
+ * if (error) return error;
+ * // ... proceed
+ * }
+ */
+export function validateGymAccess(
+ userRole: UserRole,
+ userGymId: string | null,
+ targetGymId?: string,
+): NextResponse | undefined {
+ if (!canAccessGym(userRole, userGymId, targetGymId)) {
+ return NextResponse.json(
+ { error: "Forbidden - Cannot access this gym's data" },
+ { status: 403 },
+ );
+ }
+ return undefined;
+}
+
+/**
+ * Get filter condition for gym-scoped queries
+ * Returns appropriate filter based on user role
+ *
+ * @param userRole - Current user's role
+ * @param userGymId - Current user's gym ID
+ * @returns Filter object or undefined (no filter = all gyms)
+ *
+ * @example
+ * // For superAdmin: returns undefined (no filter)
+ * getGymFilter("superAdmin", null) // undefined
+ *
+ * // For admin: returns { gymId: "gym_abc" }
+ * getGymFilter("admin", "gym_abc") // { gymId: "gym_abc" }
+ */
+export function getGymFilter(
+ userRole: UserRole,
+ userGymId: string | null,
+): { gymId: string } | undefined {
+ // SuperAdmin sees all gyms
+ if (userRole === "superAdmin") {
+ return undefined;
+ }
+
+ // Other roles filtered by their gym
+ if (userGymId) {
+ return { gymId: userGymId };
+ }
+
+ // No gym assigned - return filter that matches nothing
+ return { gymId: "" };
+}
+
+/**
+ * Get all gym IDs a user can access
+ * SuperAdmin gets all gyms, others get only their own
+ *
+ * @param userRole - Current user's role
+ * @param userGymId - Current user's gym ID
+ * @param allGymIds - List of all gym IDs in system (for superAdmin)
+ * @returns Array of accessible gym IDs
+ *
+ * @example
+ * getAccessibleGymIds("admin", "gym_abc", ["gym_abc", "gym_xyz"])
+ * // Returns: ["gym_abc"]
+ *
+ * getAccessibleGymIds("superAdmin", null, ["gym_abc", "gym_xyz"])
+ * // Returns: ["gym_abc", "gym_xyz"]
+ */
+export function getAccessibleGymIds(
+ userRole: UserRole,
+ userGymId: string | null,
+ allGymIds: string[],
+): string[] {
+ if (userRole === "superAdmin") {
+ return allGymIds;
+ }
+
+ if (userGymId) {
+ return [userGymId];
+ }
+
+ return [];
+}
diff --git a/apps/admin/src/lib/gym-context.ts b/apps/admin/src/lib/gym-context.ts
new file mode 100644
index 0000000..049b733
--- /dev/null
+++ b/apps/admin/src/lib/gym-context.ts
@@ -0,0 +1,149 @@
+import { getDatabase } from "@/lib/database";
+
+/**
+ * Get users filtered by gym ID
+ *
+ * @param gymId - Gym ID to filter by (undefined returns all users for superAdmin)
+ * @returns Array of users in the gym
+ */
+export async function getUsersByGym(gymId?: string) {
+ const db = await getDatabase();
+ const allUsers = await db.getAllUsers();
+
+ if (!gymId) {
+ return allUsers;
+ }
+
+ return allUsers.filter((u) => u.gymId === gymId);
+}
+
+/**
+ * Get clients filtered by gym ID
+ *
+ * @param gymId - Gym ID to filter by
+ * @returns Array of clients in the gym
+ */
+export async function getClientsByGym(gymId: string) {
+ const db = await getDatabase();
+ const allUsers = await db.getAllUsers();
+ const allClients = await db.getAllClients();
+
+ const gymClientUserIds = allUsers
+ .filter((u) => u.gymId === gymId && u.role === "client")
+ .map((u) => u.id);
+
+ if (gymClientUserIds.length === 0) {
+ return [];
+ }
+
+ return allClients.filter((c) => gymClientUserIds.includes(c.userId));
+}
+
+/**
+ * Get trainers filtered by gym ID
+ *
+ * @param gymId - Gym ID to filter by
+ * @returns Array of trainers in the gym
+ */
+export async function getTrainersByGym(gymId: string) {
+ const db = await getDatabase();
+ const allUsers = await db.getAllUsers();
+
+ return allUsers.filter((u) => u.gymId === gymId && u.role === "trainer");
+}
+
+/**
+ * Get attendance filtered by gym ID
+ *
+ * @param gymId - Gym ID to filter by
+ * @returns Array of attendance records in the gym
+ */
+export async function getAttendanceByGym(gymId: string) {
+ const db = await getDatabase();
+ const allUsers = await db.getAllUsers();
+ const allAttendance = await db.getAllAttendance();
+
+ const gymUserIds = allUsers.filter((u) => u.gymId === gymId).map((u) => u.id);
+
+ if (gymUserIds.length === 0) {
+ return [];
+ }
+
+ return allAttendance.filter((a) => gymUserIds.includes(a.userId));
+}
+
+/**
+ * Get recommendations filtered by gym ID
+ *
+ * @param gymId - Gym ID to filter by
+ * @returns Array of recommendations in the gym
+ */
+export async function getRecommendationsByGym(gymId: string) {
+ const db = await getDatabase();
+ const allUsers = await db.getAllUsers();
+ const allRecommendations = await db.getAllRecommendations();
+
+ const gymUserIds = allUsers.filter((u) => u.gymId === gymId).map((u) => u.id);
+
+ if (gymUserIds.length === 0) {
+ return [];
+ }
+
+ return allRecommendations.filter((r) => gymUserIds.includes(r.userId));
+}
+
+/**
+ * Get fitness profiles filtered by gym ID
+ *
+ * @param gymId - Gym ID to filter by
+ * @returns Array of fitness profiles in the gym
+ */
+export async function getFitnessProfilesByGym(gymId: string) {
+ const db = await getDatabase();
+ const allUsers = await db.getAllUsers();
+ const allProfiles = await db.getAllFitnessProfiles();
+
+ const gymUserIds = allUsers.filter((u) => u.gymId === gymId).map((u) => u.id);
+
+ if (gymUserIds.length === 0) {
+ return [];
+ }
+
+ return allProfiles.filter((p) => gymUserIds.includes(p.userId));
+}
+
+/**
+ * Get fitness goals filtered by gym ID
+ *
+ * @param gymId - Gym ID to filter by
+ * @returns Array of fitness goals in the gym
+ */
+export async function getFitnessGoalsByGym(gymId: string) {
+ const db = await getDatabase();
+ const allUsers = await db.getAllUsers();
+
+ const gymUserIds = allUsers.filter((u) => u.gymId === gymId).map((u) => u.id);
+
+ if (gymUserIds.length === 0) {
+ return [];
+ }
+
+ const allGoals: any[] = [];
+ for (const userId of gymUserIds) {
+ const goals = await db.getFitnessGoalsByUserId(userId);
+ allGoals.push(...goals);
+ }
+
+ return allGoals.sort(
+ (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
+ );
+}
+
+/**
+ * Get user by ID and check if they belong to a specific gym
+ */
+export async function getUserGymId(userId: string): Promise {
+ const db = await getDatabase();
+ const user = await db.getUserById(userId);
+ return user?.gymId ?? null;
+}
diff --git a/apps/admin/src/lib/migrations/fix-gym-assignments.js b/apps/admin/src/lib/migrations/fix-gym-assignments.js
new file mode 100644
index 0000000..8e466b4
--- /dev/null
+++ b/apps/admin/src/lib/migrations/fix-gym-assignments.js
@@ -0,0 +1,138 @@
+/**
+ * Migration Script: Fix gym assignments for users without gymId
+ *
+ * This script:
+ * 1. Finds all users with null gymId
+ * 2. For admins: Assigns them to gym where they are adminUserId
+ * 3. For trainers: Gets gymId from their trainerClients records
+ * 4. For clients: Gets gymId from trainerClients or leaves as null for manual review
+ *
+ * Run with: node apps/admin/src/lib/migrations/fix-gym-assignments.js
+ *
+ * Note: Run this AFTER setting up the database, before starting the app
+ */
+
+const Database = require("better-sqlite3");
+
+// Use absolute path to the database - this is where the app stores data
+const dbPath = "/home/echo/dev/prototype/apps/admin/data/fitai.db";
+
+function fixGymAssignments() {
+ console.log("Starting gym assignment migration...\n");
+ console.log(`Database path: ${dbPath}\n`);
+
+ const db = new Database(dbPath);
+
+ // Step 1: Find all users without gymId
+ const usersWithoutGym = db
+ .prepare(
+ `
+ SELECT id, email, role FROM users WHERE gym_id IS NULL
+ `,
+ )
+ .all();
+
+ console.log(`Found ${usersWithoutGym.length} users without gymId`);
+
+ let adminsFixed = 0;
+ let trainersFixed = 0;
+ let clientsFixed = 0;
+ const unableToFix = [];
+
+ for (const user of usersWithoutGym) {
+ try {
+ if (user.role === "admin") {
+ // Find gym where this user is admin
+ const gym = db
+ .prepare(
+ `
+ SELECT id, name FROM gyms WHERE admin_user_id = ?
+ `,
+ )
+ .get(user.id);
+
+ if (gym) {
+ db.prepare(
+ `
+ UPDATE users SET gym_id = ? WHERE id = ?
+ `,
+ ).run(gym.id, user.id);
+
+ console.log(` ✓ Fixed admin ${user.email} -> gym ${gym.name}`);
+ adminsFixed++;
+ } else {
+ unableToFix.push(`Admin ${user.email} (no gym found)`);
+ }
+ } else if (user.role === "trainer") {
+ // Get gym from trainerClients
+ const tc = db
+ .prepare(
+ `
+ SELECT gym_id FROM trainer_clients WHERE trainer_user_id = ? LIMIT 1
+ `,
+ )
+ .get(user.id);
+
+ if (tc) {
+ db.prepare(
+ `
+ UPDATE users SET gym_id = ? WHERE id = ?
+ `,
+ ).run(tc.gym_id, user.id);
+
+ console.log(` ✓ Fixed trainer ${user.email} -> gym ${tc.gym_id}`);
+ trainersFixed++;
+ } else {
+ unableToFix.push(`Trainer ${user.email} (no trainerClients record)`);
+ }
+ } else if (user.role === "client") {
+ // Get gym from trainerClients
+ const tc = db
+ .prepare(
+ `
+ SELECT gym_id FROM trainer_clients WHERE client_user_id = ? LIMIT 1
+ `,
+ )
+ .get(user.id);
+
+ if (tc) {
+ db.prepare(
+ `
+ UPDATE users SET gym_id = ? WHERE id = ?
+ `,
+ ).run(tc.gym_id, user.id);
+
+ console.log(` ✓ Fixed client ${user.email} -> gym ${tc.gym_id}`);
+ clientsFixed++;
+ } else {
+ unableToFix.push(`Client ${user.email} (no trainer assignment)`);
+ }
+ }
+ } catch (error) {
+ console.error(` ✗ Error fixing user ${user.email}:`, error.message);
+ unableToFix.push(`User ${user.email} (error: ${error.message})`);
+ }
+ }
+
+ db.close();
+
+ console.log("\n=== Migration Summary ===");
+ console.log(`Admins fixed: ${adminsFixed}`);
+ console.log(`Trainers fixed: ${trainersFixed}`);
+ console.log(`Clients fixed: ${clientsFixed}`);
+ console.log(`Unable to fix: ${unableToFix.length}`);
+
+ if (unableToFix.length > 0) {
+ console.log("\nUsers requiring manual review:");
+ unableToFix.forEach((u) => console.log(` - ${u}`));
+ }
+
+ console.log("\nMigration complete!");
+}
+
+// Run if called directly
+if (require.main === module) {
+ fixGymAssignments();
+}
+
+module.exports = { fixGymAssignments };
diff --git a/data/fitai.db b/data/fitai.db
new file mode 100644
index 0000000..e69de29