import { NextRequest, NextResponse } from "next/server"; import { auth, clerkClient } from "@clerk/nextjs/server"; import { db, users, clients } from "@fitai/database"; import { createUserSchema } from "@/lib/validation/user-schemas"; import { sendWelcomeEmail, sendInvitationEmail } from "@/lib/email"; import { successResponse, errorResponse, unauthorizedResponse, } from "@/lib/api/responses"; import { getAuthContext } from "@/lib/auth/context"; import { getInvitableRoles } from "@/lib/auth/permissions"; import log from "@/lib/logger"; /** * POST /api/users/create * * Create a new user with support for: * - Direct creation (database only, no Clerk account) * - Invitation-based creation (sends Clerk invitation email) * - Client-specific fields (membership type, status) * - Welcome emails for clients */ export async function POST(request: NextRequest) { try { const { userId } = await auth(); if (!userId) { return unauthorizedResponse("Unauthorized"); } // Get full auth context (role and gymId) const authContext = await getAuthContext(); const { role: requesterRole, gymId: requesterGymId } = authContext; const body = await request.json(); const validationResult = createUserSchema.safeParse(body); if (!validationResult.success) { return errorResponse("Validation failed", { status: 400, details: validationResult.error.flatten().fieldErrors as Record< string, string[] >, }); } const data = validationResult.data; // Validate role creation permission const allowedRoles = getInvitableRoles(requesterRole); if (!allowedRoles.includes(data.role)) { const roleMessages: Record = { trainer: "Trainers can only create clients. Contact an admin to create other roles.", admin: "Admins can create trainers and clients only. Contact a superAdmin to create other admins.", client: "Clients cannot create users.", }; return errorResponse( roleMessages[requesterRole] || "You are not authorized to create this role", { status: 403 }, ); } // Auto-assign gym for non-superAdmins // SuperAdmins can choose any gym, others must use their own gym const assignedGymId = requesterRole === "superAdmin" ? data.gymId : requesterGymId; // Validate that non-superAdmins have a gym assigned if (!assignedGymId && requesterRole !== "superAdmin") { return errorResponse("You are not assigned to a gym", { status: 400 }); } // Note: Email is required in current schema // TODO: Add schema migration to make email optional for direct creation if (!data.email) { return errorResponse("Email is required", { status: 400 }); } // Case 1: Send Clerk Invitation (requires email) if (data.sendInvitation && data.email) { try { const client = await clerkClient(); // Build publicMetadata - only include gymId if it exists const publicMetadata: Record = { role: data.role, createdBy: userId, }; if (assignedGymId) { publicMetadata.gymId = assignedGymId; } // Determine redirect URL based on role // Staff (admin, trainer, superAdmin) → Admin web app // Clients → Mobile app (handled by Clerk's default) const isStaffRole = ["admin", "trainer", "superAdmin"].includes( data.role, ); const redirectUrl = isStaffRole ? `${process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"}/sign-up` : undefined; // Clients use default Clerk redirect const invitation = await client.invitations.createInvitation({ emailAddress: data.email, publicMetadata, redirectUrl, ignoreExisting: true, // Don't fail if invitation already exists }); log.info("Clerk invitation sent", { email: data.email, role: data.role, gymId: assignedGymId, invitationId: invitation.id, redirectUrl: redirectUrl || "default (mobile app)", }); // Send custom invitation email (in addition to Clerk's) if (invitation.url) { await sendInvitationEmail(data.email, data.role, invitation.url); } return successResponse( { invitation: { id: invitation.id, email: invitation.emailAddress, status: invitation.status, }, }, { status: 200 }, ); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; // Extract Clerk-specific error details const clerkError = error as any; log.error("Failed to send Clerk invitation", error, { email: data.email, role: data.role, gymId: assignedGymId, publicMetadata: assignedGymId ? { role: data.role, gymId: assignedGymId } : { role: data.role }, clerkStatus: clerkError?.status, clerkErrors: clerkError?.errors, clerkTraceId: clerkError?.clerkTraceId, }); return errorResponse(`Failed to send invitation: ${errorMessage}`, { status: 500, }); } } // Case 2: Direct Creation (database only, no Clerk account) // This creates a user record that can later be linked when they sign up // Generate a temporary user ID (will be replaced when they sign up with Clerk) const tempUserId = `temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; // Insert user into database const [newUser] = await db .insert(users) .values({ id: tempUserId, email: data.email, firstName: data.firstName, lastName: data.lastName, role: data.role, phone: data.phone, gymId: assignedGymId, }) .returning(); log.info("User created in database", { userId: newUser.id, role: data.role, gymId: assignedGymId, hasEmail: !!data.email, }); // Case 3: Create client record if role is client if (data.role === "client" && data.membershipType) { const clientId = `client_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; await db.insert(clients).values({ id: clientId, userId: newUser.id, membershipType: data.membershipType, membershipStatus: data.membershipStatus || "active", joinDate: new Date(), }); log.info("Client record created", { userId: newUser.id, membershipType: data.membershipType, }); // Send welcome email if requested and email provided if (data.sendWelcomeEmail && data.email) { await sendWelcomeEmail(data.email, data.firstName); log.info("Welcome email sent", { email: data.email }); } } return successResponse( { user: { id: newUser.id, email: newUser.email, firstName: newUser.firstName, lastName: newUser.lastName, role: newUser.role, }, }, { status: 201 }, ); } catch (error) { log.error("Failed to create user", error); return errorResponse("Failed to create user", { status: 500 }); } }