import { NextRequest, NextResponse } from "next/server"; import { getDatabase } from "../../../lib/database/index"; import bcrypt from "bcryptjs"; import { auth, clerkClient } from "@clerk/nextjs/server"; import { db as rawDb, sql } from "@fitai/database"; import log from "@/lib/logger"; import { roleFilterSchema, userInviteSchema, userUpdateWithIdSchema, } from "@/lib/validation/schemas"; import { validateRequestBody, validateQueryParams, validationErrorResponse, } from "@/lib/validation/helpers"; import { successResponse, createdResponse, unauthorizedResponse, forbiddenResponse, notFoundResponse, conflictResponse, 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"); log.debug("User API called", { currentUserId: currentUser.id, currentUserRole: currentUser.role, currentUserGymId: currentUser.gymId, }); // Get target gym based on role const targetGymId = currentUser.role === "superAdmin" ? (searchParams.get("gymId") ?? undefined) : (currentUser.gymId ?? undefined); log.debug("Target gym calculation", { targetGymId, currentUserRole: currentUser.role, currentUserGymId: currentUser.gymId, }); // 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(); log.debug("Users fetched from database", { targetGymId, totalUsers: Array.isArray(users) ? users.length : 0, sampleGymId: users && users[0] ? (users[0] as any).gymId : null, }); // Hydrate gymId from raw DB to ensure consistency with writes const rawUserRows = await rawDb.all(sql`SELECT id, gym_id FROM users`); const gymById = new Map( (rawUserRows || []).map((r: any) => [ r.id as string, (r.gym_id as string | null) ?? null, ]), ); // Load gym names for mapping gymId -> gymName const gymRows = await rawDb.all(sql`SELECT id, name FROM gyms`); const gymNames = new Map( (gymRows || []) .filter((g: any) => !!g && typeof g.id === "string") .map((g: any) => [ g.id as string, (g.name as string) || (g.id as string), ]), ); log.debug("Fetched users from database", { totalUsers: Array.isArray(users) ? users.length : 0, }); if (role) { users = users.filter((user) => user.role === role); } log.debug("Applied role filter", { role, usersAfterFilter: Array.isArray(users) ? users.length : 0, sampleUser: users && users[0] ? { id: users[0].id, role: users[0].role, gymId: (users[0] as any).gymId, } : null, }); // Batch load all related data at once to avoid N+1 queries const userIds = users.map((u) => u.id); const [allClients, allActiveCheckIns, allAttendanceHistories] = await Promise.all([ db.getClientsByUserIds(userIds), db.getActiveCheckInsByUserIds(userIds), db.getAttendanceHistoriesByUserIds(userIds), ]); // Create lookup maps for O(1) access const clientsByUserId = new Map(allClients.map((c) => [c.userId, c])); const activeCheckInsByUserId = new Map( allActiveCheckIns.map((a) => [a.userId, a]), ); // Group attendance histories by userId const attendanceByUserId = new Map(); for (const attendance of allAttendanceHistories) { const existing = attendanceByUserId.get(attendance.userId) || []; existing.push(attendance); attendanceByUserId.set(attendance.userId, existing); } const usersWithClients = users.map((user) => { const { password: _, ...userWithoutPassword } = user; const client = clientsByUserId.get(user.id) || null; // Get active check-in status and statistics for ALL users let isCheckedIn = false; let checkInTime = null; let lastCheckInTime = null; let checkInsThisWeek = 0; let checkInsThisMonth = 0; // Query attendance by userId (works for all user types now) const activeCheckIn = activeCheckInsByUserId.get(user.id); if (activeCheckIn) { isCheckedIn = true; checkInTime = activeCheckIn.checkInTime; } // Get attendance history for statistics const attendanceHistory = attendanceByUserId.get(user.id) || []; if (attendanceHistory.length > 0) { // Last check-in is the most recent attendance record lastCheckInTime = attendanceHistory[0].checkInTime; // Calculate check-ins in last 7 days const weekAgo = new Date(); weekAgo.setDate(weekAgo.getDate() - 7); checkInsThisWeek = attendanceHistory.filter( (a) => new Date(a.checkInTime) >= weekAgo, ).length; // Calculate check-ins in last 30 days const monthAgo = new Date(); monthAgo.setDate(monthAgo.getDate() - 30); checkInsThisMonth = attendanceHistory.filter( (a) => new Date(a.checkInTime) >= monthAgo, ).length; } return { ...userWithoutPassword, // Override gymId from raw DB hydration to avoid undefined from Drizzle mapping gymId: gymById.get(user.id) ?? (user as any).gymId ?? undefined, // Provide gymName mapped from gyms table gymName: (() => { const gid = gymById.get(user.id) ?? (user as any).gymId ?? undefined; if (!gid) return null; return gymNames.get(gid) ?? null; })(), client, isCheckedIn, checkInTime, lastCheckInTime, checkInsThisWeek, checkInsThisMonth, }; }); log.debug("Prepared response with user details", { usersCount: Array.isArray(usersWithClients) ? usersWithClients.length : 0, sample: usersWithClients && usersWithClients[0] ? { id: usersWithClients[0].id, role: usersWithClients[0].role, gymId: (usersWithClients as any)[0].gymId, } : null, }); return successResponse({ users: usersWithClients }); } catch (error) { log.error("Failed to get users", error); return internalErrorResponse(); } } export async function POST(request: NextRequest) { try { const { userId: clerkUserId } = await auth(); if (!clerkUserId) { return unauthorizedResponse(); } const db = await getDatabase(); // Get current user to check role // Note: In a real app, we'd map Clerk ID to our DB ID. // For now, we'll assume we can find the user by some means or trust the Clerk metadata if we synced it. // Since we don't have Clerk ID in our local DB users table yet (we only have our own ID), // we might need to rely on the user being synced. // Let's assume the user calling this API is already in our DB. // For the prototype, we'll fetch the user by matching the Clerk ID if we stored it, // OR we'll assume the first user is Super Admin if no users exist? // Actually, we should look up the user by email if we can't by ID, or add a clerkId column. // For this step, let's assume we can get the user. // WAIT: The current `users` table has `id` as a string. Is it the Clerk ID? // In `sync-user.ts`, we use `evt.data.id` as the `id` when creating the user. // So yes, `users.id` IS the Clerk ID. const currentUser = await db.getUserById(clerkUserId); if (!currentUser) { return forbiddenResponse("Current user not found in database"); } // Validate request body const validation = await validateRequestBody(request, userInviteSchema); if (!validation.success) { log.warn("User invitation validation failed", { errors: validation.errors, }); return validationErrorResponse(validation.errors); } const { email, firstName, lastName, role, phone } = validation.data; // Enforce Hierarchy const allowed: Record = { superAdmin: ["admin", "trainer", "client"], admin: ["trainer", "client"], trainer: ["client"], client: [], }; const userRole = currentUser.role as keyof typeof allowed; if (!allowed[userRole] || !allowed[userRole].includes(role)) { return forbiddenResponse(`You are not authorized to create a ${role}`); } // Check if user already exists locally const existingUser = await db.getUserByEmail(email); if (existingUser) { return conflictResponse("User with this email already exists"); } // Create Clerk Invitation // Note: We pass the role in publicMetadata so it persists when they sign up try { const client = await clerkClient(); await client.invitations.createInvitation({ emailAddress: email, publicMetadata: { role, }, ignoreExisting: true, // Don't fail if invite exists }); } catch (clerkError: any) { log.error("Clerk invitation failed", clerkError, { email, role, errorCode: clerkError.errors?.[0]?.code, }); // If user already exists in Clerk, we might want to handle it. // But for now, let's proceed to create local record if invite sent or if they exist. if (clerkError.errors?.[0]?.code === "form_identifier_exists") { return conflictResponse("User already exists in Clerk system"); } return internalErrorResponse( "Failed to send invitation: " + (clerkError.message || "Unknown error"), ); } // Create user in local DB with temporary ID (will be migrated on first login) // We set a placeholder password since it's required by schema but won't be used const newUserId = await db.createUser({ email, password: "INVITED_USER_PENDING", firstName, lastName, role, phone, }); // If creating a client, create the client record too if (role === "client") { await db.createClient({ userId: newUserId.id, membershipType: "basic", membershipStatus: "active", joinDate: new Date(), }); } return createdResponse({ userId: newUserId.id, message: "Invitation sent", }); } catch (error) { log.error("Failed to create user", error); return internalErrorResponse(); } } export async function PUT(request: NextRequest) { try { // Validate request body const validation = await validateRequestBody( request, userUpdateWithIdSchema, ); if (!validation.success) { log.warn("User update validation failed", { errors: validation.errors }); return validationErrorResponse(validation.errors); } const { id, email, firstName, lastName, role, phone, gymId, membershipType, membershipStatus, } = validation.data; log.debug("Updating user", { id, email, firstName, lastName, role, phone, gymId, membershipType, membershipStatus, }); const db = await getDatabase(); // Authenticate requester const { userId: requesterId } = await auth(); if (!requesterId) { return unauthorizedResponse(); } // Fetch requester and target user const requester = await db.getUserById(requesterId); if (!requester) { return forbiddenResponse("Requester not found"); } const existingUser = await db.getUserById(id); if (!existingUser) { 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 const requesterRole = requester.role; const allowedByRole: Record = { superAdmin: ["superAdmin", "admin", "trainer", "client", "generalUser"], admin: ["admin", "trainer", "client", "generalUser"], trainer: [], // trainers cannot change roles client: [], // clients cannot change roles generalUser: [], // general users cannot change roles }; // 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}'`); } // Authorization: trainers and admins cannot reassign users to different gyms // Only superAdmins can reassign gyms if ( requesterRole !== "superAdmin" && gymId !== undefined && gymId !== existingUser.gymId ) { return forbiddenResponse( "Only superAdmins can reassign users to different gyms", ); } // Check if email is being changed and if it's already taken if (email && email !== existingUser.email) { const userWithEmail = await db.getUserByEmail(email); if (userWithEmail) { return conflictResponse("Email already in use"); } } // Update Clerk publicMetadata (role/gymId) to propagate via webhook // Note: Only update metadata when a change is requested const client = await clerkClient(); const publicMetadata: Record = {}; log.debug("Preparing Clerk metadata update", { targetUserId: id, role, gymId, }); if (role) { publicMetadata.role = role; } if (gymId !== undefined) { 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); const clerkResult = await client.users.updateUser(id, { publicMetadata, }); log.debug("Clerk user updated successfully", { id: clerkResult.id, role: clerkResult.publicMetadata?.role, gymId: clerkResult.publicMetadata?.gymId, }); } catch (clerkErr: any) { // 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 } } } // Update local DB for immediate UI feedback (webhook will also sync) log.debug("Updating local database user"); await rawDb.run( sql`UPDATE users SET email = ${email ?? existingUser.email}, first_name = ${firstName ?? existingUser.firstName}, last_name = ${lastName ?? existingUser.lastName}, role = ${role ?? existingUser.role}, phone = ${phone !== undefined && typeof phone === "string" ? phone : (existingUser.phone ?? null)}, gym_id = ${gymId !== undefined ? gymId : (existingUser.gymId ?? null)}, updated_at = ${Date.now()} WHERE id = ${id}`, ); // Read back the updated row to surface gym_id and confirm write const updatedRow = await rawDb.get( sql`SELECT id, email, first_name, last_name, role, phone, gym_id, created_at, updated_at FROM users WHERE id = ${id}`, ); 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 = { ...existingUser, email: email ?? existingUser.email, firstName: firstName ?? existingUser.firstName, lastName: lastName ?? existingUser.lastName, role: role ?? existingUser.role, phone: phone !== undefined ? phone : existingUser.phone, gymId: updatedRow && (updatedRow as any).gym_id !== undefined ? (updatedRow as any).gym_id : gymId !== undefined ? gymId : existingUser.gymId, }; log.debug("Sending updated user response", { updatedUser }); return successResponse({ user: updatedUser }); } catch (error) { log.error("Failed to update user", error); return internalErrorResponse(); } } export async function DELETE(request: NextRequest) { try { const { userId: clerkUserId } = await auth(); if (!clerkUserId) { return unauthorizedResponse(); } const db = await getDatabase(); const currentUser = await ensureUserSynced(clerkUserId, db); if (!currentUser) { return forbiddenResponse("Current user not found"); } const canDeleteUsers = currentUser.role === "admin" || currentUser.role === "superAdmin"; if (!canDeleteUsers) { return forbiddenResponse("Only admins can delete users"); } const { searchParams } = new URL(request.url); const id = searchParams.get("id"); const body = await request.json().catch(() => ({})); const { ids } = body; const targetIds: string[] = Array.isArray(ids) ? ids.filter( (userId: unknown): userId is string => typeof userId === "string", ) : id ? [id] : []; if (targetIds.length === 0) { return badRequestResponse("User ID or IDs array required"); } if (targetIds.includes(clerkUserId)) { return forbiddenResponse("Cannot delete your own account"); } const targetUsers = await Promise.all( targetIds.map((targetId) => db.getUserById(targetId)), ); const missingTargets = targetUsers.filter((user) => !user).length; if (missingTargets > 0) { return notFoundResponse("One or more users were not found"); } if (currentUser.role !== "superAdmin") { if (!currentUser.gymId) { return forbiddenResponse("No gym assigned to current user"); } const hasCrossGymTarget = targetUsers.some( (targetUser) => targetUser && targetUser.gymId !== currentUser.gymId, ); if (hasCrossGymTarget) { return forbiddenResponse("Cannot delete users from other gyms"); } } if (ids && Array.isArray(ids)) { // Bulk delete await Promise.all(ids.map((userId: string) => db.deleteUser(userId))); return successResponse({ deleted: ids.length }); } else { await db.deleteUser(id as string); return successResponse({ deleted: 1 }); } } catch (error) { log.error("Failed to delete user(s)", error); return internalErrorResponse(); } }