enhancment complete with phase 10
This commit is contained in:
1 parent
2c4927478a
commit
6adb541967
44 files changed
+7820
-845
No files matched your search
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user