FitnessApp/app/api/classes/[id]/join/route.ts

159 lines
4.3 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { prisma, getTenantPrisma } from '@/lib/prisma';
import {
generateJitsiToken,
generateJitsiMeetingUrl,
JITSI_DOMAIN,
} from '@/lib/jitsi';
const JoinRequestSchema = z.object({
userId: z.string().min(1, 'userId is required'),
role: z.enum(['INSTRUCTOR', 'CLIENT', 'SUPER_ADMIN']).optional(),
});
export async function POST(
req: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const { id: classSessionId } = await context.params;
const body = await req.json();
const validated = JoinRequestSchema.parse(body);
// 1. Fetch ClassSession with its parent Tenant and existing bookings
const session = await prisma.classSession.findUnique({
where: { id: classSessionId },
include: {
tenant: true,
bookings: {
include: {
user: true,
},
},
},
});
if (!session) {
return NextResponse.json(
{ error: 'Class session not found' },
{ status: 404 }
);
}
// 2. Fetch User and verify tenant membership
const user = await prisma.user.findUnique({
where: { id: validated.userId },
});
if (!user) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
if (user.tenantId !== session.tenantId) {
return NextResponse.json(
{ error: 'Cross-tenant access forbidden. User does not belong to this tenant.' },
{ status: 403 }
);
}
const effectiveRole = validated.role || user.role;
const isInstructor = effectiveRole === 'INSTRUCTOR' || effectiveRole === 'SUPER_ADMIN';
// 3. For CLIENT, validate booking or enroll if space is available
let booking = session.bookings.find((b) => b.userId === user.id);
if (!isInstructor) {
if (!booking) {
// Check capacity
if (session.bookings.length >= session.capacity) {
return NextResponse.json(
{ error: 'Class is full. Maximum capacity reached.' },
{ status: 409 }
);
}
// Auto-enroll client into booking
const tenantPrisma = getTenantPrisma(session.tenantId);
booking = await tenantPrisma.booking.create({
data: {
tenantId: session.tenantId,
userId: user.id,
classSessionId: session.id,
status: 'CONFIRMED',
signedWaiverAt: new Date(),
},
include: {
user: true,
},
});
} else if (booking.status === 'CANCELLED') {
return NextResponse.json(
{ error: 'Booking for this class has been cancelled.' },
{ status: 403 }
);
}
}
// 4. Generate JWT room authentication token
const token = generateJitsiToken({
roomName: session.jitsiRoomId,
tenantSlug: session.tenant.slug,
user: {
id: user.id,
name: user.name || (isInstructor ? 'Instructor' : 'Athlete'),
email: user.email,
role: effectiveRole,
},
expiresInSeconds: 7200, // 2 hours
});
// 5. Construct full meeting URL
const meetingUrl = generateJitsiMeetingUrl(session.jitsiRoomId, token);
return NextResponse.json({
success: true,
roomName: session.jitsiRoomId,
token,
meetingUrl,
domain: JITSI_DOMAIN,
user: {
id: user.id,
name: user.name || 'Participant',
email: user.email,
role: effectiveRole,
isHost: isInstructor,
},
session: {
id: session.id,
title: session.title,
description: session.description,
startTime: session.startTime,
endTime: session.endTime,
capacity: session.capacity,
currentParticipants: session.bookings.length,
},
tenant: {
id: session.tenant.id,
name: session.tenant.name,
slug: session.tenant.slug,
},
});
} catch (error: any) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation failed', details: error.issues },
{ status: 400 }
);
}
console.error('Error joining classroom:', error);
return NextResponse.json(
{ error: error.message || 'Internal server error' },
{ status: 500 }
);
}
}