Compare commits

...
7 Commits
Author SHA1 Message Date
dimitar d112dbb122 rb fine tune 2026-03-18 13:14:47 +01:00
dimitar 7043487fc2 edit local user fix 2026-03-18 12:37:21 +01:00
dimitar bb9c675421 setings page enhanced
gym managment added
2026-03-18 06:23:40 +01:00
dimitar 624cdfc45c role based auth
implemented superadmin -> admin -> traniner
2026-03-18 06:06:01 +01:00
dimitar 16217f46ff schema update 2026-03-18 04:27:08 +01:00
dimitar cc2dbc3423 Merge branch 'adminUI' 2026-03-18 03:57:25 +01:00
dimitar b1d6489c70 css 2026-03-18 03:55:28 +01:00
26 changed files with 1932 additions and 182 deletions

No files matched your search

Binary file not shown.
@@ -4,6 +4,7 @@ import { getDatabase } from "@/lib/database";
import { ensureUserSynced } from "@/lib/sync-user"; import { ensureUserSynced } from "@/lib/sync-user";
import { successResponse } from "@/lib/api/responses"; import { successResponse } from "@/lib/api/responses";
import { db as rawDb, sql } from "@fitai/database"; import { db as rawDb, sql } from "@fitai/database";
import { getUsersByGym, getClientsByGym } from "@/lib/gym-context";
interface UserGrowthPoint { interface UserGrowthPoint {
label: string; label: string;
@@ -45,12 +46,36 @@ export async function GET(req: NextRequest) {
const url = new URL(req.url); const url = new URL(req.url);
const months = parseInt(url.searchParams.get("months") || "6"); const months = parseInt(url.searchParams.get("months") || "6");
const allUsers = await database.getAllUsers(); // Get target gym based on role
const allClients = await database.getAllClients(); const targetGymId =
user.role === "superAdmin"
? (url.searchParams.get("gymId") ?? undefined)
: (user.gymId ?? undefined);
const paymentsRaw = await rawDb.all( // Validate gym access for non-superAdmins
sql`SELECT * FROM payments WHERE status = 'completed' AND paid_at IS NOT NULL`, 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 payments: any[] = paymentsRaw || [];
const now = new Date(); const now = new Date();
@@ -1,23 +1,39 @@
import { auth } from "@clerk/nextjs/server"; import { auth } from "@clerk/nextjs/server";
import { NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getDatabase } from "@/lib/database"; import { getDatabase } from "@/lib/database";
import { ensureUserSynced } from "@/lib/sync-user"; import { ensureUserSynced } from "@/lib/sync-user";
import { successResponse } from "@/lib/api/responses"; 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 { try {
const { userId } = await auth(); const { userId } = await auth();
if (!userId) return new NextResponse("Unauthorized", { status: 401 }); if (!userId) return new NextResponse("Unauthorized", { status: 401 });
const db = await getDatabase(); const db = await getDatabase();
const user = await ensureUserSynced(userId, db); const user = await ensureUserSynced(userId, db);
if (!user || (user.role !== "admin" && user.role !== "superAdmin")) { if (!user || (user.role !== "admin" && user.role !== "superAdmin")) {
return new NextResponse("Forbidden", { status: 403 }); 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 }); return successResponse({ records: attendance });
} catch (error) { } catch (error) {
console.error("Admin attendance error:", error); console.error("Admin attendance error:", error);
+89
View File
@@ -0,0 +1,89 @@
import { NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { eq, sql } from "@fitai/database";
import { db, users as usersTable } from "@fitai/database";
import { ensureUserSynced } from "@/lib/sync-user";
import log from "@/lib/logger";
async function ensureGymsTable() {
await db.run(sql`
CREATE TABLE IF NOT EXISTS gyms (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
location TEXT,
status TEXT NOT NULL CHECK (status IN ('active','inactive')) DEFAULT 'active',
admin_user_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
}
// DELETE /api/gyms/[id]
// Delete a gym (soft delete - mark as inactive)
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const { id: gymId } = await params;
const { userId } = await auth();
if (!userId) {
return new NextResponse("Unauthorized", { status: 401 });
}
// Ensure user is synced
const currentUser = await ensureUserSynced(userId, {
getUserById: async (id: string) => {
const row = await db
.select()
.from(usersTable)
.where(eq(usersTable.id, id))
.get();
return row
? {
id: row.id,
email: row.email,
firstName: row.firstName,
lastName: row.lastName,
password: row.password ?? "",
phone: row.phone ?? undefined,
role: row.role,
imageUrl: undefined,
createdAt: new Date(row.createdAt),
updatedAt: new Date(row.updatedAt),
}
: null;
},
} as any);
// Only superAdmin can delete gyms
if (!currentUser || currentUser.role !== "superAdmin") {
return new NextResponse("Forbidden - Only superAdmin can delete gyms", {
status: 403,
});
}
await ensureGymsTable();
// Check if gym exists
const gymRows = await db.all(sql`SELECT * FROM gyms WHERE id = ${gymId}`);
if (gymRows.length === 0) {
return new NextResponse("Gym not found", { status: 404 });
}
// Soft delete - mark as inactive
await db.run(
sql`UPDATE gyms SET status = 'inactive', updated_at = ${Date.now()} WHERE id = ${gymId}`,
);
return NextResponse.json({
success: true,
message: "Gym deleted successfully",
});
} catch (error) {
log.error("Failed to delete gym", error);
return new NextResponse("Internal Server Error", { status: 500 });
}
}
@@ -0,0 +1,105 @@
import { NextResponse } from "next/server";
import { eq, sql } from "@fitai/database";
import { db } from "@fitai/database";
import log from "@/lib/logger";
async function ensureGymsTable() {
await db.run(sql`
CREATE TABLE IF NOT EXISTS gyms (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
location TEXT,
status TEXT NOT NULL CHECK (status IN ('active','inactive')) DEFAULT 'active',
admin_user_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
}
// GET /api/gyms/[id]/stats
// Get stats for a specific gym
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const { id: gymId } = await params;
await ensureGymsTable();
// Get gym info
const gymRows = await db.all(sql`SELECT * FROM gyms WHERE id = ${gymId}`);
if (gymRows.length === 0) {
return new NextResponse("Gym not found", { status: 404 });
}
const gym = gymRows[0];
// Get user counts
const usersResult = await db.all(
sql`SELECT role, COUNT(*) as count FROM users WHERE gym_id = ${gymId} GROUP BY role`,
);
const userCounts: Record<string, number> = {
admin: 0,
trainer: 0,
client: 0,
};
for (const row of usersResult as any[]) {
if (row.role in userCounts) {
userCounts[row.role] = row.count;
}
}
// Get client stats
const clientsResult = (await db.all(
sql`SELECT
membership_type,
membership_status,
COUNT(*) as count
FROM clients
WHERE user_id IN (SELECT id FROM users WHERE gym_id = ${gymId})
GROUP BY membership_type, membership_status`,
)) as any[];
const membershipStats: Record<string, number> = {
basic: 0,
premium: 0,
vip: 0,
};
const activeClients = clientsResult.filter(
(c: any) => c.membership_status === "active",
);
for (const row of activeClients) {
if (row.membership_type in membershipStats) {
membershipStats[row.membership_type] = row.count;
}
}
// Get recent activity (attendance in last 30 days)
const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000;
const attendanceResult = (await db.all(
sql`SELECT COUNT(*) as count FROM attendance
WHERE user_id IN (SELECT id FROM users WHERE gym_id = ${gymId})
AND check_in_time > ${thirtyDaysAgo}`,
)) as any[];
const attendanceCount = attendanceResult[0]?.count || 0;
const stats = {
totalUsers: userCounts.admin + userCounts.trainer + userCounts.client,
admins: userCounts.admin,
trainers: userCounts.trainer,
clients: userCounts.client,
membershipStats,
activeClients: activeClients.reduce(
(sum: number, c: any) => sum + c.count,
0,
),
attendanceLast30Days: attendanceCount,
};
return NextResponse.json({ gym, stats });
} catch (error) {
log.error("Failed to get gym stats", error);
return new NextResponse("Internal Server Error", { status: 500 });
}
}
@@ -1,11 +1,14 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getDatabase } from "../../../../lib/database/index"; import { getDatabase } from "../../../../lib/database/index";
import { auth } from "@clerk/nextjs/server";
import log from "@/lib/logger"; import log from "@/lib/logger";
import { fitnessProfileSchema } from "@/lib/validation/schemas"; import { fitnessProfileSchema } from "@/lib/validation/schemas";
import { import {
validateRequestBody, validateRequestBody,
validationErrorResponse, validationErrorResponse,
} from "@/lib/validation/helpers"; } from "@/lib/validation/helpers";
import { getFitnessProfilesByGym } from "@/lib/gym-context";
import { ensureUserSynced } from "@/lib/sync-user";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@@ -63,12 +66,47 @@ export async function POST(request: NextRequest) {
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const db = await getDatabase(); // First authenticate
const { searchParams } = new URL(request.url); const { userId: clerkUserId } = await auth();
const userId = searchParams.get("userId"); if (!clerkUserId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (userId) { const db = await getDatabase();
const profile = await db.getFitnessProfileByUserId(userId); 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) { if (!profile) {
return NextResponse.json( return NextResponse.json(
{ error: "Profile not found" }, { error: "Profile not found" },
@@ -78,8 +116,28 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ profile }); return NextResponse.json({ profile });
} }
const profiles = await db.getAllFitnessProfiles(); // Staff get gym-scoped profiles
return NextResponse.json({ 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) { } catch (error) {
log.error("Failed to get fitness profiles", error); log.error("Failed to get fitness profiles", error);
return NextResponse.json( return NextResponse.json(
+36 -13
View File
@@ -17,6 +17,8 @@ import {
badRequestResponse, badRequestResponse,
internalErrorResponse, internalErrorResponse,
} from "@/lib/api/responses"; } from "@/lib/api/responses";
import { getRecommendationsByGym } from "@/lib/gym-context";
import { ensureUserSynced } from "@/lib/sync-user";
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
@@ -25,38 +27,59 @@ export async function GET(request: NextRequest) {
return unauthorizedResponse(); 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 { searchParams } = new URL(request.url);
const targetUserId = searchParams.get("userId"); const targetUserId = searchParams.get("userId");
const db = await getDatabase(); // If no userId provided, staff gets gym-scoped recommendations
// If no userId provided, check if staff and return all recommendations
if (!targetUserId) { if (!targetUserId) {
const currentUser = await db.getUserById(currentUserId);
const isStaff = const isStaff =
currentUser?.role === "admin" || currentUser.role === "admin" ||
currentUser?.role === "superAdmin" || currentUser.role === "superAdmin" ||
currentUser?.role === "trainer"; currentUser.role === "trainer";
if (!isStaff) { if (!isStaff) {
return badRequestResponse("User ID is required"); 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 }); return successResponse({ recommendations });
} }
// Check permissions: Users can view their own, Admins/Trainers can view anyone's // Check permissions: Users can view their own, Admins/Trainers can view anyone's in their gym
const currentUser = await db.getUserById(currentUserId);
const isStaff = const isStaff =
currentUser?.role === "admin" || currentUser.role === "admin" ||
currentUser?.role === "superAdmin" || currentUser.role === "superAdmin" ||
currentUser?.role === "trainer"; currentUser.role === "trainer";
if (currentUserId !== targetUserId) { if (currentUserId !== targetUserId) {
if (!isStaff) { if (!isStaff) {
return forbiddenResponse(); 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); let recommendations = await db.getRecommendationsByUserId(targetUserId);
+37 -2
View File
@@ -8,6 +8,8 @@ import {
errorResponse, errorResponse,
unauthorizedResponse, unauthorizedResponse,
} from "@/lib/api/responses"; } from "@/lib/api/responses";
import { getAuthContext } from "@/lib/auth/context";
import { getInvitableRoles } from "@/lib/auth/permissions";
import log from "@/lib/logger"; import log from "@/lib/logger";
/** /**
@@ -26,6 +28,10 @@ export async function POST(request: NextRequest) {
return unauthorizedResponse("Unauthorized"); return unauthorizedResponse("Unauthorized");
} }
// Get full auth context (role and gymId)
const authContext = await getAuthContext();
const { role: requesterRole, gymId: requesterGymId } = authContext;
const body = await request.json(); const body = await request.json();
const validationResult = createUserSchema.safeParse(body); const validationResult = createUserSchema.safeParse(body);
@@ -41,6 +47,33 @@ export async function POST(request: NextRequest) {
const data = validationResult.data; const data = validationResult.data;
// Validate role creation permission
const allowedRoles = getInvitableRoles(requesterRole);
if (!allowedRoles.includes(data.role)) {
const roleMessages: Record<string, string> = {
trainer:
"Trainers can only create clients. Contact an admin to create other roles.",
admin:
"Admins can create trainers and clients only. Contact a superAdmin to create other admins.",
client: "Clients cannot create users.",
};
return errorResponse(
roleMessages[requesterRole] ||
"You are not authorized to create this role",
{ status: 403 },
);
}
// Auto-assign gym for non-superAdmins
// SuperAdmins can choose any gym, others must use their own gym
const assignedGymId =
requesterRole === "superAdmin" ? data.gymId : requesterGymId;
// Validate that non-superAdmins have a gym assigned
if (!assignedGymId && requesterRole !== "superAdmin") {
return errorResponse("You are not assigned to a gym", { status: 400 });
}
// Note: Email is required in current schema // Note: Email is required in current schema
// TODO: Add schema migration to make email optional for direct creation // TODO: Add schema migration to make email optional for direct creation
if (!data.email) { if (!data.email) {
@@ -55,13 +88,14 @@ export async function POST(request: NextRequest) {
emailAddress: data.email, emailAddress: data.email,
publicMetadata: { publicMetadata: {
role: data.role, role: data.role,
gymId: data.gymId, gymId: assignedGymId,
}, },
}); });
log.info("Clerk invitation sent", { log.info("Clerk invitation sent", {
email: data.email, email: data.email,
role: data.role, role: data.role,
gymId: assignedGymId,
invitationId: invitation.id, invitationId: invitation.id,
}); });
@@ -106,13 +140,14 @@ export async function POST(request: NextRequest) {
lastName: data.lastName, lastName: data.lastName,
role: data.role, role: data.role,
phone: data.phone, phone: data.phone,
gymId: data.gymId, gymId: assignedGymId,
}) })
.returning(); .returning();
log.info("User created in database", { log.info("User created in database", {
userId: newUser.id, userId: newUser.id,
role: data.role, role: data.role,
gymId: assignedGymId,
hasEmail: !!data.email, hasEmail: !!data.email,
}); });
+149 -27
View File
@@ -24,14 +24,42 @@ import {
internalErrorResponse, internalErrorResponse,
badRequestResponse, badRequestResponse,
} from "@/lib/api/responses"; } from "@/lib/api/responses";
import { getUsersByGym } from "@/lib/gym-context";
import { ensureUserSynced } from "@/lib/sync-user";
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
// First authenticate the user
const { userId: clerkUserId } = await auth();
if (!clerkUserId) {
return unauthorizedResponse();
}
const db = await getDatabase(); const db = await getDatabase();
const currentUser = await ensureUserSynced(clerkUserId, db);
if (!currentUser) {
return forbiddenResponse("User not found");
}
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const role = searchParams.get("role"); 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 // Hydrate gymId from raw DB to ensure consistency with writes
const rawUserRows = await rawDb.all(sql`SELECT id, gym_id FROM users`); const rawUserRows = await rawDb.all(sql`SELECT id, gym_id FROM users`);
@@ -303,8 +331,17 @@ export async function PUT(request: NextRequest) {
return validationErrorResponse(validation.errors); return validationErrorResponse(validation.errors);
} }
const { id, email, firstName, lastName, role, phone, gymId } = const {
validation.data; id,
email,
firstName,
lastName,
role,
phone,
gymId,
membershipType,
membershipStatus,
} = validation.data;
log.debug("Updating user", { log.debug("Updating user", {
id, id,
@@ -314,6 +351,8 @@ export async function PUT(request: NextRequest) {
role, role,
phone, phone,
gymId, gymId,
membershipType,
membershipStatus,
}); });
const db = await getDatabase(); const db = await getDatabase();
@@ -335,6 +374,27 @@ export async function PUT(request: NextRequest) {
return notFoundResponse("User not found"); return notFoundResponse("User not found");
} }
log.debug("Edit authorization check", {
requesterId,
requesterRole: requester.role,
requesterGymId: requester.gymId,
targetUserId: id,
targetRole: existingUser.role,
targetGymId: existingUser.gymId,
requestedRole: role,
});
// Authorization: check gym-based permissions
// Non-superAdmins can only edit users in their own gym
if (requester.role !== "superAdmin") {
if (!requester.gymId) {
return forbiddenResponse("No gym assigned to requester");
}
if (existingUser.gymId !== requester.gymId) {
return forbiddenResponse("Cannot edit users from other gyms");
}
}
// Authorization: determine allowed role changes // Authorization: determine allowed role changes
const requesterRole = requester.role; const requesterRole = requester.role;
const allowedByRole: Record<string, string[]> = { const allowedByRole: Record<string, string[]> = {
@@ -345,10 +405,26 @@ export async function PUT(request: NextRequest) {
generalUser: [], // general users cannot change roles generalUser: [], // general users cannot change roles
}; };
if (role && !allowedByRole[requesterRole]?.includes(role)) { // Only check authorization if the role is actually being changed
if (
role &&
role !== existingUser.role &&
!allowedByRole[requesterRole]?.includes(role)
) {
return forbiddenResponse(`Not authorized to assign role '${role}'`); return forbiddenResponse(`Not authorized to assign role '${role}'`);
} }
// Authorization: trainers cannot reassign users to different gyms
if (
requesterRole === "trainer" &&
gymId !== undefined &&
gymId !== existingUser.gymId
) {
return forbiddenResponse(
"Trainers cannot reassign users to different gyms",
);
}
// Check if email is being changed and if it's already taken // Check if email is being changed and if it's already taken
if (email && email !== existingUser.email) { if (email && email !== existingUser.email) {
const userWithEmail = await db.getUserByEmail(email); const userWithEmail = await db.getUserByEmail(email);
@@ -359,24 +435,27 @@ export async function PUT(request: NextRequest) {
// Update Clerk publicMetadata (role/gymId) to propagate via webhook // Update Clerk publicMetadata (role/gymId) to propagate via webhook
// Note: Only update metadata when a change is requested // Note: Only update metadata when a change is requested
try { const client = await clerkClient();
const client = await clerkClient(); const publicMetadata: Record<string, unknown> = {};
const publicMetadata: Record<string, unknown> = {}; log.debug("Preparing Clerk metadata update", {
log.debug("Preparing Clerk metadata update", { targetUserId: id,
targetUserId: id, role,
role, gymId,
gymId, });
});
if (role) { if (role) {
publicMetadata.role = role; publicMetadata.role = role;
} }
if (gymId !== undefined) { if (gymId !== undefined) {
publicMetadata.gymId = gymId === null ? null : String(gymId); publicMetadata.gymId = gymId === null ? null : String(gymId);
} }
if (Object.keys(publicMetadata).length > 0) {
log.debug("Updating Clerk user metadata", { publicMetadata });
try {
// Check if user exists in Clerk first
await client.users.getUser(id);
if (Object.keys(publicMetadata).length > 0) {
log.debug("Updating Clerk user metadata", { publicMetadata });
const clerkResult = await client.users.updateUser(id, { const clerkResult = await client.users.updateUser(id, {
publicMetadata, publicMetadata,
}); });
@@ -385,14 +464,20 @@ export async function PUT(request: NextRequest) {
role: clerkResult.publicMetadata?.role, role: clerkResult.publicMetadata?.role,
gymId: clerkResult.publicMetadata?.gymId, gymId: clerkResult.publicMetadata?.gymId,
}); });
} else { } catch (clerkErr: any) {
log.debug("No Clerk metadata changes requested"); // User might not exist in Clerk yet - that's OK for local users
if (
clerkErr?.status === 404 ||
clerkErr?.errors?.[0]?.code === "resource_not_found"
) {
log.debug(
"User not found in Clerk, skipping metadata update (local-only user)",
);
} else {
log.error("Clerk metadata update failed", clerkErr, { userId: id });
// Don't fail the whole request - local DB update can still proceed
}
} }
} catch (clerkErr: any) {
log.error("Clerk metadata update failed", clerkErr, { userId: id });
return internalErrorResponse(
"Failed to update role/gym in identity provider",
);
} }
// Update local DB for immediate UI feedback (webhook will also sync) // Update local DB for immediate UI feedback (webhook will also sync)
@@ -414,6 +499,43 @@ export async function PUT(request: NextRequest) {
); );
log.debug("User updated in database", { updatedRow }); log.debug("User updated in database", { updatedRow });
// If the user is a client, update membership fields in clients table
const finalRole = role ?? existingUser.role;
if (
finalRole === "client" &&
(membershipType !== undefined || membershipStatus !== undefined)
) {
log.debug("Updating client membership fields", {
userId: id,
membershipType,
membershipStatus,
});
// Check if client record exists
const existingClient = await rawDb.get(
sql`SELECT * FROM clients WHERE user_id = ${id}`,
);
if (existingClient) {
// Update existing client record
await rawDb.run(
sql`UPDATE clients
SET membership_type = ${membershipType ?? (existingClient as any).membership_type},
membership_status = ${membershipStatus ?? (existingClient as any).membership_status},
updated_at = ${Date.now()}
WHERE user_id = ${id}`,
);
log.debug("Client membership updated");
} else {
// Create client record if it doesn't exist
await rawDb.run(
sql`INSERT INTO clients (id, user_id, membership_type, membership_status, join_date, created_at, updated_at)
VALUES (${`client_${id}_${Date.now()}`}, ${id}, ${membershipType ?? "basic"}, ${membershipStatus ?? "active"}, ${Date.now()}, ${Date.now()}, ${Date.now()})`,
);
log.debug("Client record created");
}
}
const updatedUser = { const updatedUser = {
...existingUser, ...existingUser,
email: email ?? existingUser.email, email: email ?? existingUser.email,
+1 -1
View File
@@ -107,7 +107,7 @@
@apply border-border; @apply border-border;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground antialiased;
} }
} }
+2 -2
View File
@@ -9,7 +9,7 @@ import {
UserButton, UserButton,
} from "@clerk/nextjs"; } from "@clerk/nextjs";
import { Sidebar } from "@/components/ui/Sidebar"; import { Sidebar } from "@/components/ui/Sidebar";
import { Toaster } from "sonner"; import { ToasterProvider } from "@/components/providers/ToasterProvider";
import { QueryProvider } from "@/components/providers/QueryProvider"; import { QueryProvider } from "@/components/providers/QueryProvider";
const inter = Inter({ subsets: ["latin"] }); const inter = Inter({ subsets: ["latin"] });
@@ -35,7 +35,7 @@ export default function RootLayout({
<div className="max-w-7xl mx-auto space-y-8">{children}</div> <div className="max-w-7xl mx-auto space-y-8">{children}</div>
</main> </main>
</div> </div>
<Toaster richColors position="top-right" /> <ToasterProvider />
</body> </body>
</html> </html>
</QueryProvider> </QueryProvider>
+32 -14
View File
@@ -14,11 +14,26 @@ import { AnalyticsDashboard } from "@/components/analytics/AnalyticsDashboard";
import { useDashboardStats } from "@/hooks/use-api"; import { useDashboardStats } from "@/hooks/use-api";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button"; 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() { 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(); const queryClient = useQueryClient();
// Get user role from metadata
const userRole = (user?.publicMetadata?.role as string) ?? "client";
const handleRefresh = () => { const handleRefresh = () => {
queryClient.invalidateQueries({ queryKey: ["dashboard-stats"] }); queryClient.invalidateQueries({ queryKey: ["dashboard-stats"] });
queryClient.invalidateQueries({ queryKey: ["analytics"] }); queryClient.invalidateQueries({ queryKey: ["analytics"] });
@@ -40,18 +55,21 @@ export default function Home() {
title="Dashboard" title="Dashboard"
description="Welcome back! Here's what's happening with your gym today." description="Welcome back! Here's what's happening with your gym today."
actions={ actions={
<Button <div className="flex items-center gap-3">
variant="outline" <GymSelector userRole={userRole} />
size="sm" <Button
onClick={handleRefresh} variant="outline"
disabled={isFetching} size="sm"
className="gap-2" onClick={handleRefresh}
> disabled={isFetching}
<RefreshCw className="gap-2"
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} >
/> <RefreshCw
Refresh className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
</Button> />
Refresh
</Button>
</div>
} }
/> />
@@ -125,7 +143,7 @@ export default function Home() {
Overview of your gym metrics Overview of your gym metrics
</p> </p>
</div> </div>
<AnalyticsDashboard /> <AnalyticsDashboard gymId={gymId} />
</div> </div>
</div> </div>
</div> </div>
+271 -71
View File
@@ -9,6 +9,10 @@ import {
AlertTriangle, AlertTriangle,
Check, Check,
Loader2, Loader2,
Trash2,
Users,
CalendarCheck,
TrendingUp,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useUser } from "@clerk/nextjs"; import { useUser } from "@clerk/nextjs";
@@ -26,6 +30,21 @@ interface Gym {
location?: string | null; location?: string | null;
status: "active" | "inactive"; status: "active" | "inactive";
adminUserId: string; adminUserId: string;
createdAt?: number;
}
interface GymStats {
totalUsers: number;
admins: number;
trainers: number;
clients: number;
membershipStats: {
basic: number;
premium: number;
vip: number;
};
activeClients: number;
attendanceLast30Days: number;
} }
export default function SettingsPage() { export default function SettingsPage() {
@@ -46,6 +65,13 @@ export default function SettingsPage() {
type: "success" | "error"; type: "success" | "error";
text: string; text: string;
} | null>(null); } | null>(null);
// Selected gym for details
const [selectedGym, setSelectedGym] = useState<Gym | null>(null);
const [gymStats, setGymStats] = useState<GymStats | null>(null);
const [statsLoading, setStatsLoading] = useState(false);
const [deletingGym, setDeletingGym] = useState(false);
// Create Gym modal state // Create Gym modal state
const [showCreateGym, setShowCreateGym] = useState(false); const [showCreateGym, setShowCreateGym] = useState(false);
const [gymName, setGymName] = useState(""); const [gymName, setGymName] = useState("");
@@ -77,11 +103,33 @@ export default function SettingsPage() {
} }
}; };
const fetchGymStats = async (gymId: string) => {
setStatsLoading(true);
try {
const res = await axios.get(`/api/gyms/${gymId}/stats`);
if (res.data?.stats) {
setGymStats(res.data.stats);
}
} catch (error) {
log.error("Failed to fetch gym stats", error);
} finally {
setStatsLoading(false);
}
};
useEffect(() => { useEffect(() => {
fetchBackups(); fetchBackups();
fetchGyms(); fetchGyms();
}, []); }, []);
useEffect(() => {
if (selectedGym) {
fetchGymStats(selectedGym.id);
} else {
setGymStats(null);
}
}, [selectedGym]);
const handleCreateBackup = async () => { const handleCreateBackup = async () => {
setCreatingBackup(true); setCreatingBackup(true);
setMessage(null); setMessage(null);
@@ -111,7 +159,6 @@ export default function SettingsPage() {
try { try {
await axios.post("/api/admin/backups/restore", { filename }); await axios.post("/api/admin/backups/restore", { filename });
setMessage({ type: "success", text: "Database restored successfully" }); setMessage({ type: "success", text: "Database restored successfully" });
// Optional: Refresh page or force re-login if session is invalidated
} catch (error) { } catch (error) {
log.error("Failed to restore backup", error); log.error("Failed to restore backup", error);
setMessage({ type: "error", text: "Failed to restore backup" }); setMessage({ type: "error", text: "Failed to restore backup" });
@@ -135,21 +182,39 @@ export default function SettingsPage() {
return new Date(dateString).toLocaleString(); return new Date(dateString).toLocaleString();
}; };
const handleSelectGym = async (gymId: string | null) => { const handleSelectGym = async (gym: Gym | null) => {
setSelectedGym(gym);
setGymStats(null);
};
const handleDeleteGym = async (gymId: string) => {
if (
!window.confirm(
"Are you sure you want to delete this gym? This action cannot be undone.",
)
) {
return;
}
setDeletingGym(true);
setGymMessage(null); setGymMessage(null);
try { try {
// Update current user's gym selection await axios.delete(`/api/gyms/${gymId}`);
await axios.patch("/api/users/gym", { gymId }); setGymMessage({ type: "success", text: "Gym deleted successfully" });
setGymMessage({ setSelectedGym(null);
type: "success", setGymStats(null);
text: gymId ? "Gym selected successfully" : "Proceeding without gym", fetchGyms();
});
} catch (error) { } catch (error) {
log.error("Failed to set gym", error); log.error("Failed to delete gym", error);
setGymMessage({ type: "error", text: "Failed to set gym" }); setGymMessage({ type: "error", text: "Failed to delete gym" });
} finally {
setDeletingGym(false);
} }
}; };
const userRole = (user?.publicMetadata?.role as string) ?? "client";
const isSuperAdmin = userRole === "superAdmin";
return ( return (
<div className="space-y-8 p-8"> <div className="space-y-8 p-8">
<div> <div>
@@ -159,7 +224,7 @@ export default function SettingsPage() {
</p> </p>
</div> </div>
{/* Gym Picker */} {/* Gym Management */}
<div className="bg-white rounded-xl shadow-sm border border-slate-100 p-6"> <div className="bg-white rounded-xl shadow-sm border border-slate-100 p-6">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -168,10 +233,12 @@ export default function SettingsPage() {
</div> </div>
<div> <div>
<h3 className="text-xl font-bold text-slate-900"> <h3 className="text-xl font-bold text-slate-900">
Gym Selection Gym Management
</h3> </h3>
<p className="text-sm text-slate-500"> <p className="text-sm text-slate-500">
Select your gym or proceed without a gym {isSuperAdmin
? "Select a gym to view details, create or delete gyms"
: "Select your gym or proceed without a gym"}
</p> </p>
<p className="text-xs text-blue-600 mt-1"> <p className="text-xs text-blue-600 mt-1">
{user ? ( {user ? (
@@ -180,11 +247,6 @@ export default function SettingsPage() {
<span className="font-medium"> <span className="font-medium">
{String(user.publicMetadata?.role ?? "unknown")} {String(user.publicMetadata?.role ?? "unknown")}
</span> </span>
{" • "}
Gym ID:{" "}
<span className="font-medium">
{String(user.publicMetadata?.gymId ?? "none")}
</span>
</> </>
) : ( ) : (
"Loading user metadata..." "Loading user metadata..."
@@ -196,6 +258,8 @@ export default function SettingsPage() {
<Button <Button
onClick={fetchGyms} onClick={fetchGyms}
disabled={gymsLoading} disabled={gymsLoading}
variant="outline"
size="sm"
className="flex items-center gap-2" className="flex items-center gap-2"
> >
{gymsLoading ? ( {gymsLoading ? (
@@ -203,14 +267,17 @@ export default function SettingsPage() {
) : ( ) : (
<RefreshCw className="w-4 h-4" /> <RefreshCw className="w-4 h-4" />
)} )}
Refresh Gyms Refresh
</Button>
<Button
onClick={() => setShowCreateGym(true)}
className="flex items-center gap-2"
>
Create Gym
</Button> </Button>
{isSuperAdmin && (
<Button
onClick={() => setShowCreateGym(true)}
size="sm"
className="flex items-center gap-2"
>
Create Gym
</Button>
)}
</div> </div>
</div> </div>
@@ -226,6 +293,7 @@ export default function SettingsPage() {
{gymMessage.text} {gymMessage.text}
</div> </div>
)} )}
{showCreateGym && ( {showCreateGym && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white p-6 rounded-lg shadow-lg max-w-md w-full"> <div className="bg-white p-6 rounded-lg shadow-lg max-w-md w-full">
@@ -310,59 +378,191 @@ export default function SettingsPage() {
</div> </div>
)} )}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="border rounded-lg p-4 flex flex-col justify-between"> {/* Gym List */}
<div> <div className="lg:col-span-1 space-y-4">
<h4 className="font-semibold text-slate-900"> <h4 className="font-semibold text-slate-900">Gyms</h4>
Proceed without gym {gymsLoading ? (
</h4> <div className="flex items-center justify-center p-8 text-slate-500">
<p className="text-sm text-slate-600 mt-1"> <Loader2 className="w-5 h-5 animate-spin mr-2" />
You can select a gym later. Loading...
</p> </div>
</div> ) : gyms.length === 0 ? (
<Button <div className="p-4 text-center text-slate-500">
variant="outline" No active gyms found.
className="mt-4" </div>
onClick={() => handleSelectGym(null)} ) : (
> <div className="space-y-2 max-h-96 overflow-y-auto">
Proceed without gym {gyms.map((gym) => (
</Button> <div
key={gym.id}
className={`border rounded-lg p-3 cursor-pointer transition-colors ${
selectedGym?.id === gym.id
? "border-blue-500 bg-blue-50"
: "hover:bg-slate-50"
}`}
onClick={() => handleSelectGym(gym)}
>
<div className="flex items-center justify-between">
<div>
<h5 className="font-medium text-slate-900">
{gym.name}
</h5>
<p className="text-xs text-slate-500">
{gym.location || "No location"}
</p>
</div>
<span
className={`text-xs px-2 py-1 rounded ${
gym.status === "active"
? "bg-green-100 text-green-700"
: "bg-red-100 text-red-700"
}`}
>
{gym.status}
</span>
</div>
</div>
))}
</div>
)}
</div> </div>
{gymsLoading ? ( {/* Gym Details / Stats */}
<div className="col-span-full flex items-center justify-center p-8 text-slate-500"> <div className="lg:col-span-2">
<Loader2 className="w-5 h-5 animate-spin mr-2" /> {selectedGym ? (
Loading gyms... <div className="space-y-4">
</div> <div className="flex items-center justify-between">
) : gyms.length === 0 ? ( <h4 className="font-semibold text-slate-900">
<div className="col-span-full p-8 text-center text-slate-500"> {selectedGym.name} - Details
No active gyms found. </h4>
</div> {isSuperAdmin && (
) : ( <Button
gyms.map((gym) => ( variant="ghost"
<div size="sm"
key={gym.id} className="text-red-600 hover:text-red-700 hover:bg-red-50"
className="border rounded-lg p-4 flex flex-col justify-between" onClick={() => handleDeleteGym(selectedGym.id)}
> disabled={deletingGym}
<div> >
<h4 className="font-semibold text-slate-900">{gym.name}</h4> {deletingGym ? (
<p className="text-sm text-slate-600 mt-1"> <Loader2 className="w-4 h-4 animate-spin" />
{gym.location || "No location provided"} ) : (
</p> <Trash2 className="w-4 h-4 mr-1" />
)}
Delete Gym
</Button>
)}
</div> </div>
<Button
variant="default" {/* Gym Info */}
className="mt-4" <div className="grid grid-cols-2 gap-4 p-4 bg-slate-50 rounded-lg">
onClick={() => handleSelectGym(gym.id)} <div>
> <p className="text-xs text-slate-500">Location</p>
Select this gym <p className="font-medium">
</Button> {selectedGym.location || "Not specified"}
</p>
</div>
<div>
<p className="text-xs text-slate-500">Status</p>
<p className="font-medium capitalize">
{selectedGym.status}
</p>
</div>
</div>
{/* Stats */}
{statsLoading ? (
<div className="flex items-center justify-center p-8">
<Loader2 className="w-6 h-6 animate-spin text-slate-400" />
</div>
) : gymStats ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="p-4 bg-blue-50 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<Users className="w-4 h-4 text-blue-600" />
<span className="text-xs text-blue-600">
Total Users
</span>
</div>
<p className="text-2xl font-bold text-blue-700">
{gymStats.totalUsers}
</p>
</div>
<div className="p-4 bg-green-50 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<CalendarCheck className="w-4 h-4 text-green-600" />
<span className="text-xs text-green-600">
Active Clients
</span>
</div>
<p className="text-2xl font-bold text-green-700">
{gymStats.activeClients}
</p>
</div>
<div className="p-4 bg-purple-50 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<TrendingUp className="w-4 h-4 text-purple-600" />
<span className="text-xs text-purple-600">
Check-ins (30d)
</span>
</div>
<p className="text-2xl font-bold text-purple-700">
{gymStats.attendanceLast30Days}
</p>
</div>
<div className="p-4 bg-orange-50 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<Users className="w-4 h-4 text-orange-600" />
<span className="text-xs text-orange-600">
Trainers
</span>
</div>
<p className="text-2xl font-bold text-orange-700">
{gymStats.trainers}
</p>
</div>
</div>
) : null}
{/* Membership Distribution */}
{gymStats && (
<div className="mt-4">
<h5 className="text-sm font-medium text-slate-700 mb-2">
Membership Distribution
</h5>
<div className="flex gap-4">
<div className="flex-1 p-3 bg-slate-100 rounded-lg text-center">
<p className="text-2xl font-bold text-slate-700">
{gymStats.membershipStats.basic}
</p>
<p className="text-xs text-slate-500">Basic</p>
</div>
<div className="flex-1 p-3 bg-blue-50 rounded-lg text-center">
<p className="text-2xl font-bold text-blue-700">
{gymStats.membershipStats.premium}
</p>
<p className="text-xs text-blue-500">Premium</p>
</div>
<div className="flex-1 p-3 bg-yellow-50 rounded-lg text-center">
<p className="text-2xl font-bold text-yellow-700">
{gymStats.membershipStats.vip}
</p>
<p className="text-xs text-yellow-600">VIP</p>
</div>
</div>
</div>
)}
</div> </div>
)) ) : (
)} <div className="flex items-center justify-center h-64 bg-slate-50 rounded-lg">
<p className="text-slate-500">Select a gym to view details</p>
</div>
)}
</div>
</div> </div>
</div> </div>
{/* Database Management */}
<div className="bg-white rounded-xl shadow-sm border border-slate-100 p-6"> <div className="bg-white rounded-xl shadow-sm border border-slate-100 p-6">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
+13 -1
View File
@@ -1,17 +1,29 @@
"use client";
import { UserManagement } from "@/components/users/UserManagement"; import { UserManagement } from "@/components/users/UserManagement";
import { PageHeader } from "@/components/ui/PageHeader"; 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() { 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 ( return (
<div className="space-y-8"> <div className="space-y-8">
<PageHeader <PageHeader
title="Users" title="Users"
description="Manage your gym members, trainers, and administrators" description="Manage your gym members, trainers, and administrators"
breadcrumbs={[{ label: "Users", href: "/users" }]} breadcrumbs={[{ label: "Users", href: "/users" }]}
actions={<GymSelector userRole={userRole} />}
/> />
<div className="card-modern"> <div className="card-modern">
<UserManagement /> <UserManagement gymId={gymId} />
</div> </div>
</div> </div>
); );
@@ -6,8 +6,12 @@ import { RevenueChart } from "@/components/charts/RevenueChart";
import { Card, CardHeader, CardContent } from "@/components/ui/card"; import { Card, CardHeader, CardContent } from "@/components/ui/card";
import { useAnalytics } from "@/hooks/use-api"; import { useAnalytics } from "@/hooks/use-api";
export function AnalyticsDashboard() { interface AnalyticsDashboardProps {
const { data: analytics, isLoading } = useAnalytics(6); gymId?: string;
}
export function AnalyticsDashboard({ gymId }: AnalyticsDashboardProps) {
const { data: analytics, isLoading } = useAnalytics(6, gymId);
const userGrowthData = analytics?.userGrowth ?? []; const userGrowthData = analytics?.userGrowth ?? [];
const membershipData = analytics?.membershipDistribution ?? []; const membershipData = analytics?.membershipDistribution ?? [];
@@ -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<Gym[]>([]);
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 (
<div className="flex items-center gap-2">
<div className="h-9 w-40 animate-pulse rounded-md bg-muted" />
</div>
);
}
return (
<div className="flex items-center gap-2">
<select
value={selectedGymId}
onChange={(e) => handleGymChange(e.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<option value="all">All Gyms</option>
{gyms.map((gym) => (
<option key={gym.id} value={gym.id}>
{gym.name}
</option>
))}
</select>
</div>
);
}
@@ -0,0 +1,7 @@
"use client";
import { Toaster } from "sonner";
export function ToasterProvider() {
return <Toaster richColors position="top-right" />;
}
@@ -3,6 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useUser } from "@clerk/nextjs";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -26,6 +27,9 @@ import {
type InvitationOptionsData, type InvitationOptionsData,
} from "@/lib/validation/user-schemas"; } from "@/lib/validation/user-schemas";
import { ChevronLeft, ChevronRight, Check } from "lucide-react"; import { ChevronLeft, ChevronRight, Check } from "lucide-react";
import { useGyms } from "@/hooks/use-api";
import { getInvitableRoles } from "@/lib/auth/permissions";
import type { UserRole } from "@fitai/shared";
interface CreateUserModalProps { interface CreateUserModalProps {
open: boolean; open: boolean;
@@ -45,6 +49,15 @@ export function CreateUserModal({
const [basicInfo, setBasicInfo] = useState<BasicInfoData | null>(null); const [basicInfo, setBasicInfo] = useState<BasicInfoData | null>(null);
const [clientInfo, setClientInfo] = useState<ClientInfoData | null>(null); const [clientInfo, setClientInfo] = useState<ClientInfoData | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const { data: gyms = [] } = useGyms();
const { user } = useUser();
// Get current user's role and gym from Clerk metadata
const currentUserRole = (user?.publicMetadata?.role as UserRole) || "client";
const currentUserGymId = user?.publicMetadata?.gymId as string | null;
// Determine which roles the current user can create
const invitableRoles = getInvitableRoles(currentUserRole);
// Step 1: Role Selection Form // Step 1: Role Selection Form
const roleForm = useForm<RoleSelectionData>({ const roleForm = useForm<RoleSelectionData>({
@@ -136,10 +149,18 @@ export function CreateUserModal({
invitationData, invitationData,
); );
// Auto-assign gym for non-superAdmins
// SuperAdmins can select gym manually, others use their own gym
const finalPayload = {
...payload,
gymId:
currentUserRole === "superAdmin" ? payload.gymId : currentUserGymId,
};
const response = await fetch("/api/users/create", { const response = await fetch("/api/users/create", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload), body: JSON.stringify(finalPayload),
}); });
if (!response.ok) { if (!response.ok) {
@@ -259,7 +280,7 @@ export function CreateUserModal({
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Role *</label> <label className="text-sm font-medium">Role *</label>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
{(["admin", "trainer", "client"] as const).map((role) => ( {invitableRoles.map((role) => (
<label <label
key={role} key={role}
className={`flex items-center justify-center p-4 border-2 rounded-lg cursor-pointer transition-colors ${ className={`flex items-center justify-center p-4 border-2 rounded-lg cursor-pointer transition-colors ${
@@ -461,6 +482,39 @@ export function CreateUserModal({
</p> </p>
</div> </div>
)} )}
{/* Gym Selector - Only for SuperAdmins */}
{currentUserRole === "superAdmin" && (
<div className="space-y-2">
<label className="text-sm font-medium">
Assign to Gym (Optional)
</label>
<select
{...invitationForm.register("gymId")}
className="w-full p-2 border border-gray-300 rounded-md"
>
<option value="">Select a gym...</option>
{gyms.map((gym) => (
<option key={gym.id} value={gym.id}>
{gym.name}
</option>
))}
</select>
<p className="text-xs text-gray-500">
If not selected, user will not be assigned to any gym
</p>
</div>
)}
{/* Info message for non-superAdmins */}
{currentUserRole !== "superAdmin" && currentUserGymId && (
<div className="p-4 bg-gray-50 border border-gray-200 rounded-md">
<p className="text-sm text-gray-700">
User will be automatically assigned to your gym
</p>
</div>
)}
<DialogFooter className="flex justify-between"> <DialogFooter className="flex justify-between">
<Button type="button" variant="outline" onClick={handleBack}> <Button type="button" variant="outline" onClick={handleBack}>
<ChevronLeft className="mr-2 h-4 w-4" /> Back <ChevronLeft className="mr-2 h-4 w-4" /> Back
@@ -16,7 +16,11 @@ import {
useSendInvitation, useSendInvitation,
} from "@/hooks/use-api"; } from "@/hooks/use-api";
export function UserManagement() { interface UserManagementProps {
gymId?: string;
}
export function UserManagement({ gymId }: UserManagementProps) {
const { user } = useUser(); const { user } = useUser();
const [filter, setFilter] = useState<string>("all"); const [filter, setFilter] = useState<string>("all");
const [selectedUser, setSelectedUser] = useState<User | null>(null); const [selectedUser, setSelectedUser] = useState<User | null>(null);
@@ -30,6 +34,8 @@ export function UserManagement() {
role: string; role: string;
phone: string; phone: string;
gymId: string; gymId: string;
membershipType?: string;
membershipStatus?: string;
} | null>(null); } | null>(null);
const { const {
@@ -38,6 +44,7 @@ export function UserManagement() {
refetch, refetch,
} = useUsers({ } = useUsers({
role: filter !== "all" ? filter : undefined, role: filter !== "all" ? filter : undefined,
gymId,
}); });
const { data: gyms = [] } = useGyms(); const { data: gyms = [] } = useGyms();
const updateUser = useUpdateUser(); const updateUser = useUpdateUser();
@@ -57,6 +64,8 @@ export function UserManagement() {
role: user.role, role: user.role,
phone: user.phone || "", phone: user.phone || "",
gymId: user.gymId || "", gymId: user.gymId || "",
membershipType: user.client?.membershipType || "basic",
membershipStatus: user.client?.membershipStatus || "active",
}); });
setIsEditing(true); setIsEditing(true);
}; };
@@ -128,7 +137,7 @@ export function UserManagement() {
try { try {
if (selectedUser) { if (selectedUser) {
const payload = { const payload: any = {
id: selectedUser.id, id: selectedUser.id,
email: editForm.email, email: editForm.email,
firstName: editForm.firstName, firstName: editForm.firstName,
@@ -138,6 +147,12 @@ export function UserManagement() {
gymId: editForm.gymId === "" ? null : editForm.gymId, gymId: editForm.gymId === "" ? null : editForm.gymId,
}; };
// Include membership fields if user is a client
if (editForm.role === "client") {
payload.membershipType = editForm.membershipType;
payload.membershipStatus = editForm.membershipStatus;
}
await updateUser.mutateAsync(payload); await updateUser.mutateAsync(payload);
setIsEditing(false); setIsEditing(false);
setEditForm(null); setEditForm(null);
@@ -346,26 +361,74 @@ export function UserManagement() {
className="w-full border border-gray-300 rounded px-3 py-2" className="w-full border border-gray-300 rounded px-3 py-2"
/> />
</div> </div>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Gym</label> {/* Only superAdmins and admins can reassign gyms */}
<select {user?.publicMetadata?.role !== "trainer" && (
value={editForm.gymId} <div className="mb-4">
onChange={(e) => <label className="block text-sm font-medium mb-1">Gym</label>
setEditForm({ ...editForm, gymId: e.target.value }) <select
} value={editForm.gymId}
className="w-full border border-gray-300 rounded px-3 py-2" onChange={(e) =>
> setEditForm({ ...editForm, gymId: e.target.value })
<option value="">Proceed without gym</option> }
{gyms.map((g) => ( className="w-full border border-gray-300 rounded px-3 py-2"
<option key={g.id} value={g.id}> >
{g.name} <option value="">Proceed without gym</option>
</option> {gyms.map((g) => (
))} <option key={g.id} value={g.id}>
</select> {g.name}
<p className="text-xs text-gray-500 mt-1"> </option>
Select an active gym or proceed without a gym. ))}
</p> </select>
</div> <p className="text-xs text-gray-500 mt-1">
Select an active gym or proceed without a gym.
</p>
</div>
)}
{/* Client-specific fields - only show when role is client */}
{editForm.role === "client" && (
<>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">
Membership Type
</label>
<select
value={editForm.membershipType || "basic"}
onChange={(e) =>
setEditForm({
...editForm,
membershipType: e.target.value,
})
}
className="w-full border border-gray-300 rounded px-3 py-2"
>
<option value="basic">Basic</option>
<option value="premium">Premium</option>
<option value="vip">VIP</option>
</select>
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">
Membership Status
</label>
<select
value={editForm.membershipStatus || "active"}
onChange={(e) =>
setEditForm({
...editForm,
membershipStatus: e.target.value,
})
}
className="w-full border border-gray-300 rounded px-3 py-2"
>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="suspended">Suspended</option>
</select>
</div>
</>
)}
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button <button
type="button" type="button"
+11 -7
View File
@@ -170,7 +170,7 @@ export function useGyms() {
queryKey: ["gyms"], queryKey: ["gyms"],
queryFn: () => queryFn: () =>
fetchApi<{ data: { gyms: Gym[] } }>("/api/gyms").then( fetchApi<{ data: { gyms: Gym[] } }>("/api/gyms").then(
(res) => res.data?.gyms ?? [], (res) => res.data?.gyms ?? (Array.isArray(res) ? res : []),
), ),
}); });
} }
@@ -370,12 +370,16 @@ export interface AnalyticsData {
revenue: { label: string; value: number; color: string }[]; revenue: { label: string; value: number; color: string }[];
} }
export function useAnalytics(months: number = 6) { export function useAnalytics(months: number = 6, gymId?: string) {
return useQuery({ return useQuery({
queryKey: ["analytics", months], queryKey: ["analytics", months, gymId],
queryFn: () => queryFn: () => {
fetchApi<{ data: { analytics: AnalyticsData } }>( const url = gymId
`/api/admin/analytics?months=${months}`, ? `/api/admin/analytics?months=${months}&gymId=${gymId}`
).then((res) => res.data?.analytics), : `/api/admin/analytics?months=${months}`;
return fetchApi<{ data: { analytics: AnalyticsData } }>(url).then(
(res) => res.data?.analytics,
);
},
}); });
} }
+274
View File
@@ -0,0 +1,274 @@
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<AuthContext> {
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<AuthResult | NextResponse> {
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<AuthResult | NextResponse> {
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;
}
+249
View File
@@ -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<UserRole, number> = {
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<UserRole, UserRole[]> = {
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 [];
}
+149
View File
@@ -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<string | null> {
const db = await getDatabase();
const user = await db.getUserById(userId);
return user?.gymId ?? null;
}
@@ -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 };
+16 -2
View File
@@ -15,8 +15,10 @@ export const passwordSchema = z
export const phoneSchema = z export const phoneSchema = z
.string() .string()
.regex(/^\+?[1-9]\d{1,14}$/, "Invalid phone number format") .optional()
.optional(); .refine((val) => !val || /^\+?[1-9]\d{1,14}$/.test(val), {
message: "Invalid phone number format",
});
export const dateTimeSchema = z.string().datetime("Invalid datetime format"); export const dateTimeSchema = z.string().datetime("Invalid datetime format");
@@ -31,6 +33,14 @@ export const userRoleSchema = z.enum([
"superAdmin", "superAdmin",
]); ]);
export const membershipTypeSchema = z.enum(["basic", "premium", "vip"]);
export const membershipStatusSchema = z.enum([
"active",
"inactive",
"suspended",
]);
export const userSchema = z.object({ export const userSchema = z.object({
email: emailSchema, email: emailSchema,
password: passwordSchema, password: passwordSchema,
@@ -63,6 +73,8 @@ export const userUpdateWithIdSchema = z.object({
role: userRoleSchema.optional(), role: userRoleSchema.optional(),
phone: phoneSchema, phone: phoneSchema,
gymId: z.string().nullable().optional(), gymId: z.string().nullable().optional(),
membershipType: membershipTypeSchema.optional(),
membershipStatus: membershipStatusSchema.optional(),
}); });
// ============================================================================ // ============================================================================
@@ -333,6 +345,8 @@ export type User = z.infer<typeof userSchema>;
export type UserUpdate = z.infer<typeof userUpdateSchema>; export type UserUpdate = z.infer<typeof userUpdateSchema>;
export type UserLogin = z.infer<typeof userLoginSchema>; export type UserLogin = z.infer<typeof userLoginSchema>;
export type UserRole = z.infer<typeof userRoleSchema>; export type UserRole = z.infer<typeof userRoleSchema>;
export type MembershipType = z.infer<typeof membershipTypeSchema>;
export type MembershipStatus = z.infer<typeof membershipStatusSchema>;
export type FitnessGoal = z.infer<typeof fitnessGoalSchema>; export type FitnessGoal = z.infer<typeof fitnessGoalSchema>;
export type FitnessGoalUpdate = z.infer<typeof fitnessGoalUpdateSchema>; export type FitnessGoalUpdate = z.infer<typeof fitnessGoalUpdateSchema>;
View File
Whitespace-only changes.