enhancment complete with phase 10

This commit is contained in:
dimitar committed 2026-03-10 05:37:22 +01:00
1 parent 2c4927478a
commit 6adb541967
44 files changed
+7820 -845

No files matched your search

+150 -145
View File
@@ -4,6 +4,26 @@ 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";
export async function GET(request: NextRequest) {
try {
@@ -52,67 +72,86 @@ export async function GET(request: NextRequest) {
: null,
});
const usersWithClients = await Promise.all(
users.map(async (user) => {
const { password: _, ...userWithoutPassword } = user;
const client = await db.getClientByUserId(user.id);
// 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),
]);
// 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 = await db.getActiveCheckIn(user.id);
if (activeCheckIn) {
isCheckedIn = true;
checkInTime = activeCheckIn.checkInTime;
}
// Get attendance history for statistics
const attendanceHistory = await db.getAttendanceHistory(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,
};
}),
// 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,
@@ -125,13 +164,10 @@ export async function GET(request: NextRequest) {
}
: null,
});
return NextResponse.json({ users: usersWithClients });
return successResponse({ users: usersWithClients });
} catch (error) {
log.error("Failed to get users", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
return internalErrorResponse();
}
}
@@ -139,7 +175,7 @@ export async function POST(request: NextRequest) {
try {
const { userId: clerkUserId } = await auth();
if (!clerkUserId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
return unauthorizedResponse();
}
const db = await getDatabase();
@@ -162,22 +198,21 @@ export async function POST(request: NextRequest) {
const currentUser = await db.getUserById(clerkUserId);
if (!currentUser) {
return NextResponse.json(
{ error: "Current user not found in database" },
{ status: 403 },
);
return forbiddenResponse("Current user not found in database");
}
const body = await request.json();
const { email, firstName, lastName, role, phone } = body;
// Validate request body
const validation = await validateRequestBody(request, userInviteSchema);
if (!email || !firstName || !lastName || !role) {
return NextResponse.json(
{ error: "Missing required fields" },
{ status: 400 },
);
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"],
@@ -188,19 +223,13 @@ export async function POST(request: NextRequest) {
const userRole = currentUser.role as keyof typeof allowed;
if (!allowed[userRole] || !allowed[userRole].includes(role)) {
return NextResponse.json(
{ error: `You are not authorized to create a ${role}` },
{ status: 403 },
);
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 NextResponse.json(
{ error: "User with this email already exists" },
{ status: 409 },
);
return conflictResponse("User with this email already exists");
}
// Create Clerk Invitation
@@ -223,18 +252,10 @@ export async function POST(request: NextRequest) {
// 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 NextResponse.json(
{ error: "User already exists in Clerk system" },
{ status: 409 },
);
return conflictResponse("User already exists in Clerk system");
}
return NextResponse.json(
{
error:
"Failed to send invitation: " +
(clerkError.message || "Unknown error"),
},
{ status: 500 },
return internalErrorResponse(
"Failed to send invitation: " + (clerkError.message || "Unknown error"),
);
}
@@ -259,24 +280,32 @@ export async function POST(request: NextRequest) {
});
}
return NextResponse.json(
{ userId: newUserId.id, message: "Invitation sent" },
{ status: 201 },
);
return createdResponse({
userId: newUserId.id,
message: "Invitation sent",
});
} catch (error) {
log.error("Failed to create user", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
return internalErrorResponse();
}
}
export async function PUT(request: NextRequest) {
try {
const db = await getDatabase();
const body = await request.json();
const { id, email, firstName, lastName, role, phone, gymId } = body;
// 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 } =
validation.data;
log.debug("Updating user", {
id,
email,
@@ -287,31 +316,23 @@ export async function PUT(request: NextRequest) {
gymId,
});
if (!id) {
return NextResponse.json(
{ error: "User ID is required" },
{ status: 400 },
);
}
const db = await getDatabase();
// Authenticate requester
const { userId: requesterId } = await auth();
if (!requesterId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
return unauthorizedResponse();
}
// Fetch requester and target user
const requester = await db.getUserById(requesterId);
if (!requester) {
return NextResponse.json(
{ error: "Requester not found" },
{ status: 403 },
);
return forbiddenResponse("Requester not found");
}
const existingUser = await db.getUserById(id);
if (!existingUser) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
return notFoundResponse("User not found");
}
// Authorization: determine allowed role changes
@@ -325,20 +346,14 @@ export async function PUT(request: NextRequest) {
};
if (role && !allowedByRole[requesterRole]?.includes(role)) {
return NextResponse.json(
{ error: `Not authorized to assign role '${role}'` },
{ status: 403 },
);
return forbiddenResponse(`Not authorized to assign role '${role}'`);
}
// 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 NextResponse.json(
{ error: "Email already in use" },
{ status: 409 },
);
return conflictResponse("Email already in use");
}
}
@@ -375,9 +390,8 @@ export async function PUT(request: NextRequest) {
}
} catch (clerkErr: any) {
log.error("Clerk metadata update failed", clerkErr, { userId: id });
return NextResponse.json(
{ error: "Failed to update role/gym in identity provider" },
{ status: 500 },
return internalErrorResponse(
"Failed to update role/gym in identity provider",
);
}
@@ -416,13 +430,10 @@ export async function PUT(request: NextRequest) {
};
log.debug("Sending updated user response", { updatedUser });
return NextResponse.json({ user: updatedUser });
return successResponse({ user: updatedUser });
} catch (error) {
log.error("Failed to update user", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
return internalErrorResponse();
}
}
@@ -437,26 +448,20 @@ export async function DELETE(request: NextRequest) {
if (ids && Array.isArray(ids)) {
// Bulk delete
await Promise.all(ids.map((userId: string) => db.deleteUser(userId)));
return NextResponse.json({ success: true, deleted: ids.length });
return successResponse({ deleted: ids.length });
} else if (id) {
// Single delete
const user = await db.getUserById(id);
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
return notFoundResponse("User not found");
}
await db.deleteUser(id);
return NextResponse.json({ success: true });
return successResponse({ deleted: 1 });
} else {
return NextResponse.json(
{ error: "User ID or IDs array required" },
{ status: 400 },
);
return badRequestResponse("User ID or IDs array required");
}
} catch (error) {
log.error("Failed to delete user(s)", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
return internalErrorResponse();
}
}