234 lines
7.3 KiB
TypeScript
234 lines
7.3 KiB
TypeScript
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 });
|
||
}
|
||
}
|