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"); } // Try to get role from Clerk metadata (could be in publicMetadata or metadata) let role = (sessionClaims?.publicMetadata as { role?: UserRole })?.role; // Fallback: Try the metadata field if publicMetadata doesn't have it if (!role) { role = (sessionClaims?.metadata as { role?: UserRole })?.role; } // If still no role, try database lookup as last resort if (!role || !USER_ROLES.includes(role)) { console.log("Role not found in session claims, fetching from database", { userId, roleFromPublicMetadata: ( sessionClaims?.publicMetadata as { role?: UserRole } )?.role, roleFromMetadata: (sessionClaims?.metadata as { role?: UserRole })?.role, }); try { const db = await getDatabase(); const user = await db.getUserById(userId); if (!user) { console.error("User not found in database", { userId }); throw new Error(`Forbidden: User not found in database - ${userId}`); } if (user.role && USER_ROLES.includes(user.role as UserRole)) { console.log("Role found in database", { userId, role: user.role }); role = user.role as UserRole; } else { console.error("Invalid role in database", { userId, role: user.role }); throw new Error(`Forbidden: Invalid role in database - ${user.role}`); } } catch (error) { console.error("Failed to get role from database:", error); throw new Error( `Forbidden: Could not determine user role - ${error instanceof Error ? error.message : String(error)}`, ); } } // Get gymId from Clerk metadata or database let gymId: string | null = null; // First try Clerk publicMetadata (faster path) const clerkGymId = (sessionClaims?.publicMetadata as { gymId?: string }) ?.gymId; if (clerkGymId) { gymId = clerkGymId; } else { // Try metadata field const metadataGymId = (sessionClaims?.metadata as { gymId?: string }) ?.gymId; if (metadataGymId) { gymId = metadataGymId; } 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; }