sysrot-hub
Version:
CLI de nueva generación para proyectos Next.js 14+ con IA multi-modelo, Web3 integration, internacionalización completa y roadmap realista 2025-2026
485 lines (426 loc) • 14.1 kB
text/typescript
import { NextApiRequest, NextApiResponse } from 'next';
import { PrismaClient } from '@prisma/client';
import { getSession } from 'next-auth/react';
import { z } from 'zod';
const prisma = new PrismaClient();
// Validation schemas
const createCourseSchema = z.object({
title: z.string().min(1, 'Course title is required').max(200, 'Title too long'),
description: z.string().max(5000, 'Description too long').optional(),
slug: z.string().min(3, 'Slug too short').max(100, 'Slug too long').regex(/^[a-z0-9-]+$/, 'Slug can only contain lowercase letters, numbers, and hyphens').optional(),
// Course content
thumbnail: z.string().url().optional(),
trailer: z.string().url().optional(),
language: z.string().default('en'),
level: z.enum(['BEGINNER', 'INTERMEDIATE', 'ADVANCED', 'EXPERT', 'ALL_LEVELS']).default('BEGINNER'),
category: z.enum(['TECHNOLOGY', 'BUSINESS', 'DESIGN', 'MARKETING', 'PERSONAL_DEVELOPMENT', 'HEALTH_FITNESS', 'MUSIC', 'LANGUAGE', 'PHOTOGRAPHY', 'COOKING', 'ARTS_CRAFTS', 'LIFESTYLE', 'ACADEMIC', 'TEST_PREP']).default('TECHNOLOGY'),
tags: z.array(z.string()).max(10, 'Too many tags').optional(),
// Pricing
price: z.number().min(0, 'Price cannot be negative').max(10000, 'Price too high'),
originalPrice: z.number().min(0).optional(),
currency: z.string().default('USD'),
access: z.enum(['FREE', 'PAID', 'SUBSCRIPTION', 'INVITATION_ONLY']).default('PAID'),
// Course metadata
requirements: z.array(z.string()).max(20, 'Too many requirements').optional(),
outcomes: z.array(z.string()).max(30, 'Too many outcomes').optional(),
// Publishing
publishedAt: z.string().datetime().optional()
});
const updateCourseSchema = createCourseSchema.partial();
const enrollmentSchema = z.object({
courseId: z.string(),
paymentMethod: z.string().optional(),
transactionId: z.string().optional()
});
async function hasCoursePermission(userId: string, courseId: string, action: string = 'read') {
const course = await prisma.course.findUnique({
where: { id: courseId },
include: {
enrollments: {
where: { studentId: userId }
}
}
});
if (!course) return false;
// Course instructor has all permissions
if (course.instructorId === userId) return true;
// Check enrollment for students
const enrollment = course.enrollments[0];
switch (action) {
case 'read':
// Public courses or enrolled students
return course.status === 'PUBLISHED' && (course.access === 'FREE' || enrollment);
case 'enroll':
// Can enroll if not already enrolled and course is published
return course.status === 'PUBLISHED' && !enrollment;
case 'update':
case 'delete':
// Only instructor can modify
return course.instructorId === userId;
case 'view_analytics':
// Instructor or enrolled student
return course.instructorId === userId || enrollment;
default:
return false;
}
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const session = await getSession({ req });
if (!session?.user) {
return res.status(401).json({ error: 'Authentication required' });
}
const { method } = req;
const { action } = req.query;
switch (method) {
case 'GET':
return await getCourses(req, res);
case 'POST':
if (action === 'enroll') {
return await enrollInCourse(req, res);
}
return await createCourse(req, res);
default:
res.setHeader('Allow', ['GET', 'POST']);
return res.status(405).end('Method Not Allowed');
}
} catch (error) {
console.error('Courses API error:', error);
return res.status(500).json({ error: 'Internal server error' });
} finally {
await prisma.$disconnect();
}
}
async function getCourses(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req });
const {
category,
level,
search,
sortBy = 'createdAt',
sortOrder = 'desc',
limit = '20',
page = '1',
featured = 'false',
trending = 'false',
access = 'all',
instructorId,
enrolled = 'false'
} = req.query;
try {
const pageNum = parseInt(page as string);
const limitNum = parseInt(limit as string);
const offset = (pageNum - 1) * limitNum;
// Build where clause
const whereClause: any = {
status: 'PUBLISHED' // Only show published courses by default
};
if (category && category !== 'ALL') {
whereClause.category = category;
}
if (level && level !== 'ALL') {
whereClause.level = level;
}
if (access && access !== 'all') {
whereClause.access = access.toUpperCase();
}
if (featured === 'true') {
whereClause.featured = true;
}
if (trending === 'true') {
whereClause.trending = true;
}
if (instructorId && typeof instructorId === 'string') {
whereClause.instructorId = instructorId;
// Allow instructor to see their own drafts
if (instructorId === session.user.id) {
delete whereClause.status;
}
}
if (enrolled === 'true') {
whereClause.enrollments = {
some: {
studentId: session.user.id,
status: 'ACTIVE'
}
};
}
if (search && typeof search === 'string') {
whereClause.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
{ tags: { hasSome: [search] } }
];
}
// Get courses with comprehensive data
const courses = await prisma.course.findMany({
where: whereClause,
include: {
instructor: {
select: {
id: true,
name: true,
email: true,
image: true
}
},
_count: {
select: {
enrollments: {
where: { status: 'ACTIVE' }
},
lessons: true,
reviews: true,
modules: true
}
},
enrollments: {
where: {
studentId: session.user.id
},
select: {
id: true,
status: true,
progress: true,
enrolledAt: true,
completedAt: true
}
}
},
orderBy: {
[sortBy as string]: sortOrder
},
skip: offset,
take: limitNum
});
// Enrich courses with user-specific data
const enrichedCourses = courses.map(course => {
const userEnrollment = course.enrollments[0];
return {
...course,
enrollments: undefined, // Remove from response
userEnrollment: userEnrollment || null,
isEnrolled: !!userEnrollment,
stats: {
totalEnrollments: course._count.enrollments,
totalLessons: course._count.lessons,
totalReviews: course._count.reviews,
totalModules: course._count.modules,
completionRate: course.enrollmentCount > 0 ?
Math.round((course.completionCount / course.enrollmentCount) * 100) : 0
}
};
});
// Get total count for pagination
const total = await prisma.course.count({
where: whereClause
});
res.status(200).json({
courses: enrichedCourses,
pagination: {
page: pageNum,
limit: limitNum,
total,
pages: Math.ceil(total / limitNum)
}
});
} catch (error) {
console.error('Error fetching courses:', error);
res.status(500).json({ error: 'Failed to fetch courses' });
}
}
async function createCourse(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req });
try {
const validatedData = createCourseSchema.parse(req.body);
// Generate slug if not provided
let slug = validatedData.slug;
if (!slug) {
slug = validatedData.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
// Ensure slug uniqueness
const existingCourse = await prisma.course.findUnique({ where: { slug } });
if (existingCourse) {
slug = `${slug}-${Date.now()}`;
}
} else {
// Check if provided slug is unique
const existingCourse = await prisma.course.findUnique({ where: { slug } });
if (existingCourse) {
return res.status(400).json({ error: 'Slug already exists' });
}
}
// Create the course
const course = await prisma.course.create({
data: {
...validatedData,
slug,
instructorId: session.user.id,
publishedAt: validatedData.publishedAt ? new Date(validatedData.publishedAt) : undefined,
status: validatedData.publishedAt ? 'PUBLISHED' : 'DRAFT',
tags: validatedData.tags || [],
requirements: validatedData.requirements || [],
outcomes: validatedData.outcomes || []
},
include: {
instructor: {
select: {
id: true,
name: true,
email: true,
image: true
}
}
}
});
// Create initial analytics entry if published
if (course.publishedAt) {
const today = new Date();
today.setHours(0, 0, 0, 0);
await prisma.courseAnalytics.create({
data: {
courseId: course.id,
date: today,
newEnrollments: 0,
totalEnrollments: 0,
completions: 0,
activeLearners: 0,
revenue: 0
}
});
}
res.status(201).json({
course,
message: 'Course created successfully'
});
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
error: 'Validation failed',
details: error.errors
});
}
console.error('Error creating course:', error);
res.status(500).json({ error: 'Failed to create course' });
}
}
async function enrollInCourse(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req });
try {
const validatedData = enrollmentSchema.parse(req.body);
// Check if course exists and is available for enrollment
const course = await prisma.course.findUnique({
where: { id: validatedData.courseId },
include: {
enrollments: {
where: { studentId: session.user.id }
}
}
});
if (!course) {
return res.status(404).json({ error: 'Course not found' });
}
if (course.status !== 'PUBLISHED') {
return res.status(400).json({ error: 'Course is not available for enrollment' });
}
// Check if user is already enrolled
if (course.enrollments.length > 0) {
return res.status(400).json({ error: 'Already enrolled in this course' });
}
// Check if user is trying to enroll in their own course
if (course.instructorId === session.user.id) {
return res.status(400).json({ error: 'Cannot enroll in your own course' });
}
// Calculate payment amount
const amount = course.access === 'FREE' ? 0 : course.price;
// Get course lesson count for progress tracking
const lessonCount = await prisma.lesson.count({
where: { courseId: validatedData.courseId }
});
// Create enrollment
const enrollment = await prisma.enrollment.create({
data: {
courseId: validatedData.courseId,
studentId: session.user.id,
amount,
currency: course.currency,
paymentMethod: validatedData.paymentMethod,
transactionId: validatedData.transactionId,
totalLessons: lessonCount,
expiresAt: course.access === 'SUBSCRIPTION' ?
new Date(Date.now() + 365 * 24 * 60 * 60 * 1000) : undefined // 1 year for subscriptions
},
include: {
course: {
select: {
id: true,
title: true,
slug: true,
thumbnail: true,
instructor: {
select: {
name: true,
image: true
}
}
}
}
}
});
// Update course enrollment count
await prisma.course.update({
where: { id: validatedData.courseId },
data: {
enrollmentCount: {
increment: 1
}
}
});
// Update today's analytics
const today = new Date();
today.setHours(0, 0, 0, 0);
await prisma.courseAnalytics.upsert({
where: {
courseId_date: {
courseId: validatedData.courseId,
date: today
}
},
update: {
newEnrollments: {
increment: 1
},
totalEnrollments: {
increment: 1
},
revenue: {
increment: amount
}
},
create: {
courseId: validatedData.courseId,
date: today,
newEnrollments: 1,
totalEnrollments: course.enrollmentCount + 1,
completions: 0,
activeLearners: 1,
revenue: amount
}
});
res.status(201).json({
enrollment,
message: course.access === 'FREE' ? 'Successfully enrolled in free course' : 'Successfully enrolled in course'
});
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
error: 'Validation failed',
details: error.errors
});
}
console.error('Error enrolling in course:', error);
res.status(500).json({ error: 'Failed to enroll in course' });
}
}