230 lines
7.3 KiB
TypeScript
230 lines
7.3 KiB
TypeScript
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<string, string> = {
|
|
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<string, any> = {
|
|
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 });
|
|
}
|
|
}
|