import { NextRequest, NextResponse } from "next/server"; import { auth, clerkClient } from "@clerk/nextjs/server"; import { getAuthContext } from "@/lib/auth/context"; import { validateGymAccess } from "@/lib/auth/permissions"; import log from "@/lib/logger"; /** * GET /api/invitations * * Fetch pending invitations with gym and creator filtering. * Users can only see invitations they created, scoped to their gym access. * * Query params: * - gymId: Optional gym filter (superAdmin only) */ export async function GET(request: NextRequest) { try { const { userId } = await auth(); if (!userId) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } // Get auth context const authContext = await getAuthContext(); const { role, gymId: userGymId } = authContext; // Get query params const { searchParams } = new URL(request.url); const gymIdParam = searchParams.get("gymId"); // Validate gym access const targetGymId = gymIdParam || undefined; const accessError = validateGymAccess(role, userGymId, targetGymId); if (accessError) return accessError; // Fetch all pending invitations from Clerk const client = await clerkClient(); const invitationList = await client.invitations.getInvitationList({ status: "pending", }); // Filter invitations based on: // 1. Creator (only show invitations created by current user) // 2. Gym (apply gym-scoping rules) const filteredInvitations = invitationList.data .filter((inv) => { const metadata = inv.publicMetadata as any; // Creator filter: only show if user created it if (metadata?.createdBy !== userId) { return false; } // Gym filter: SuperAdmin can see all, others only their gym if (role === "superAdmin") { // If gymId param provided, filter by it if (targetGymId && (metadata?.gymId ?? null) !== targetGymId) { return false; } return true; } else { // Non-superAdmins: must match their gym (normalize null/undefined) return (metadata?.gymId ?? null) === (userGymId ?? null); } }) .map((inv) => ({ id: inv.id, emailAddress: inv.emailAddress, publicMetadata: inv.publicMetadata, status: inv.status, url: inv.url, createdAt: inv.createdAt, updatedAt: inv.updatedAt, revoked: inv.revoked, })); return NextResponse.json({ data: { invitations: filteredInvitations }, }); } catch (error) { log.error("GET /api/invitations error:", error); return NextResponse.json( { error: "Failed to fetch invitations" }, { status: 500 }, ); } } /** * POST /api/invitations * * Create a Clerk-managed invitation and store role/gym context in publicMetadata. * This endpoint does not implement invitation acceptance; Clerk’s acceptance link flow and webhooks will handle that. * * Body: { * inviteeEmail: string, * roleAssigned: 'trainer' | 'client' | 'admin' * } * * Rules: * - admin: can invite trainer or client; the gymId is taken from inviter's publicMetadata or user record. * - trainer: can invite client; the gymId is taken from inviter's publicMetadata or user record. * - superAdmin: can invite admin; requires `gymId` in body, or falls back to inviter's `gymId` if present. * * Returns Clerk invitation payload. */ export async function POST(req: Request) { try { const { userId } = await auth(); if (!userId) { return new NextResponse("Unauthorized", { status: 401 }); } const body = await req.json().catch(() => null); if (!body || typeof body !== "object") { return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); } const inviteeEmail = String(body.inviteeEmail ?? "") .trim() .toLowerCase(); const roleAssigned = String(body.roleAssigned ?? "").trim() as | "trainer" | "client" | "admin"; let requestedGymId: string | null = body.gymId ? String(body.gymId) : null; if (!inviteeEmail || !roleAssigned) { return NextResponse.json( { error: "inviteeEmail and roleAssigned are required" }, { status: 400 }, ); } // Fetch inviter user from Clerk const client = await clerkClient(); const inviter = await client.users.getUser(userId); const inviterRole = (inviter.publicMetadata?.role as | "superAdmin" | "admin" | "trainer" | "client" | "generalUser") ?? "client"; const inviterGymId = (inviter.publicMetadata?.gymId as string | undefined) ?? undefined; // Enforce role-based rules and resolve target gymId for the invitation let gymIdForInvite: string | null = null; switch (inviterRole) { case "admin": { if (roleAssigned !== "trainer" && roleAssigned !== "client") { return NextResponse.json( { error: "Admin can only invite trainer or client" }, { status: 403 }, ); } if (!inviterGymId) { return NextResponse.json( { error: "Inviter admin must be assigned to a gym" }, { status: 400 }, ); } gymIdForInvite = inviterGymId; break; } case "trainer": { if (roleAssigned !== "client") { return NextResponse.json( { error: "Trainer can only invite client" }, { status: 403 }, ); } if (!inviterGymId) { return NextResponse.json( { error: "Inviter trainer must be assigned to a gym" }, { status: 400 }, ); } gymIdForInvite = inviterGymId; break; } case "superAdmin": { if ( roleAssigned !== "admin" && roleAssigned !== "trainer" && roleAssigned !== "client" ) { return NextResponse.json( { error: "Invalid roleAssigned for SuperAdmin" }, { status: 400 }, ); } // Prefer explicitly provided gymId, otherwise fall back to inviter's gymId if present gymIdForInvite = requestedGymId || inviterGymId || null; if (!gymIdForInvite) { return NextResponse.json( { error: "gymId is required for SuperAdmin when inviting" }, { status: 400 }, ); } break; } default: { return NextResponse.json( { error: "Inviter role not permitted to create invitations" }, { status: 403 }, ); } } // Create Clerk invitation with metadata needed by webhook to assign role & gym // reuse existing Clerk client instance const invitation = await client.invitations.createInvitation({ emailAddress: inviteeEmail, publicMetadata: { role: roleAssigned, gymId: gymIdForInvite, createdBy: inviter.id, }, }); return NextResponse.json(invitation, { status: 201 }); } catch (error: any) { // Surface Clerk errors where possible const message = error?.errors?.[0]?.message || error?.message || "Failed to create invitation"; console.error("POST /api/invitations error:", error); return NextResponse.json({ error: message }, { status: 500 }); } }