659 lines
21 KiB
TypeScript
659 lines
21 KiB
TypeScript
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<string, string | null>(
|
|
(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<string, string>(
|
|
(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<string, typeof allAttendanceHistories>();
|
|
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<string, string[]> = {
|
|
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<string, string[]> = {
|
|
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<string, unknown> = {};
|
|
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();
|
|
}
|
|
}
|