fitaiProto/apps/admin/src/app/api/users/route.ts

467 lines
15 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";
export async function GET(request: NextRequest) {
try {
const db = await getDatabase();
const { searchParams } = new URL(request.url);
const role = searchParams.get("role");
let users = await db.getAllUsers();
// 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),
]),
);
console.log(
"GET /api/users: total users fetched from DB:",
Array.isArray(users) ? users.length : 0,
);
if (role) {
users = users.filter((user) => user.role === role);
}
console.log(
"GET /api/users: role filter:",
role,
"users after filter:",
Array.isArray(users) ? users.length : 0,
"sample:",
users && users[0]
? {
id: users[0].id,
role: users[0].role,
gymId: (users as any)[0].gymId,
}
: null,
);
const usersWithClients = await Promise.all(
users.map(async (user) => {
const { password: _, ...userWithoutPassword } = user;
const client = await db.getClientByUserId(user.id);
// 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,
};
}),
);
console.log(
"GET /api/users: responding users count:",
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 NextResponse.json({ users: usersWithClients });
} catch (error) {
console.error("Get users error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
export async function POST(request: NextRequest) {
try {
const { userId: clerkUserId } = await auth();
if (!clerkUserId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
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 NextResponse.json(
{ error: "Current user not found in database" },
{ status: 403 },
);
}
const body = await request.json();
const { email, firstName, lastName, role, phone } = body;
if (!email || !firstName || !lastName || !role) {
return NextResponse.json(
{ error: "Missing required fields" },
{ status: 400 },
);
}
// 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 NextResponse.json(
{ error: `You are not authorized to create a ${role}` },
{ status: 403 },
);
}
// 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 },
);
}
// 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) {
console.error("Clerk invitation error:", clerkError);
// 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 NextResponse.json(
{
error:
"Failed to send invitation: " +
(clerkError.message || "Unknown error"),
},
{ status: 500 },
);
}
// 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 NextResponse.json(
{ userId: newUserId.id, message: "Invitation sent" },
{ status: 201 },
);
} catch (error) {
console.error("Create user error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
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;
console.log("PUT /api/users received body:", {
id,
email,
firstName,
lastName,
role,
phone,
gymId,
});
if (!id) {
return NextResponse.json(
{ error: "User ID is required" },
{ status: 400 },
);
}
// Authenticate requester
const { userId: requesterId } = await auth();
if (!requesterId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Fetch requester and target user
const requester = await db.getUserById(requesterId);
if (!requester) {
return NextResponse.json(
{ error: "Requester not found" },
{ status: 403 },
);
}
const existingUser = await db.getUserById(id);
if (!existingUser) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
// 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
};
if (role && !allowedByRole[requesterRole]?.includes(role)) {
return NextResponse.json(
{ error: `Not authorized to assign role '${role}'` },
{ status: 403 },
);
}
// 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 },
);
}
}
// Update Clerk publicMetadata (role/gymId) to propagate via webhook
// Note: Only update metadata when a change is requested
try {
const client = await clerkClient();
const publicMetadata: Record<string, unknown> = {};
console.log("PUT /api/users 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) {
console.log(
"PUT /api/users calling Clerk updateUser with metadata:",
publicMetadata,
);
const clerkResult = await client.users.updateUser(id, {
publicMetadata,
});
console.log("PUT /api/users Clerk updateUser result:", {
id: clerkResult.id,
role: clerkResult.publicMetadata?.role,
gymId: clerkResult.publicMetadata?.gymId,
});
} else {
console.log("PUT /api/users no Clerk metadata changes requested");
}
} catch (clerkErr: any) {
console.error("Clerk metadata update error:", clerkErr);
return NextResponse.json(
{ error: "Failed to update role/gym in identity provider" },
{ status: 500 },
);
}
// Update local DB for immediate UI feedback (webhook will also sync)
console.log(
"PUT /api/users raw SQL updating local DB user gym_id and fields",
);
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}`,
);
console.log("PUT /api/users raw DB row after update:", updatedRow);
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?.gym_id !== undefined
? updatedRow.gym_id
: gymId !== undefined
? gymId
: existingUser.gymId,
};
console.log("PUT /api/users responding with updated user:", updatedUser);
return NextResponse.json({ user: updatedUser });
} catch (error) {
console.error("Update user error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
export async function DELETE(request: NextRequest) {
try {
const db = await getDatabase();
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
const body = await request.json().catch(() => ({}));
const { ids } = body;
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 });
} else if (id) {
// Single delete
const user = await db.getUserById(id);
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
await db.deleteUser(id);
return NextResponse.json({ success: true });
} else {
return NextResponse.json(
{ error: "User ID or IDs array required" },
{ status: 400 },
);
}
} catch (error) {
console.error("Delete user error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}