UNPKG

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

699 lines (618 loc) 19.5 kB
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 createVideoSchema = z.object({ title: z.string().min(1, 'Video title is required').max(200, 'Title too long'), description: z.string().max(5000, 'Description too long').optional(), channelId: z.string(), // Video file details videoUrl: z.string().url('Invalid video URL'), duration: z.number().min(1, 'Duration must be positive'), fileSize: z.number().min(1, 'File size must be positive'), resolution: z.string().optional(), fps: z.number().optional(), bitrate: z.number().optional(), // Metadata category: z.enum(['GENERAL', 'GAMING', 'MUSIC', 'SPORTS', 'ENTERTAINMENT', 'NEWS', 'EDUCATION', 'SCIENCE_TECH', 'TRAVEL', 'LIFESTYLE', 'COOKING', 'FITNESS', 'BUSINESS', 'COMEDY', 'DOCUMENTARY', 'KIDS', 'LIVE', 'ANIMATION', 'TUTORIAL', 'REVIEW', 'VLOG']).default('GENERAL'), tags: z.array(z.string()).max(20, 'Too many tags').optional(), language: z.string().default('en'), // Privacy and settings visibility: z.enum(['PUBLIC', 'UNLISTED', 'PRIVATE', 'SCHEDULED']).default('PRIVATE'), isAgeRestricted: z.boolean().default(false), isMonetized: z.boolean().default(false), // Scheduling publishedAt: z.string().datetime().optional(), // SEO slug: z.string().optional(), thumbnail: z.string().url().optional(), customThumbnails: z.array(z.string().url()).optional() }); const updateVideoSchema = createVideoSchema.partial(); const likeVideoSchema = z.object({ videoId: z.string(), isLike: z.boolean() // true for like, false for dislike }); const commentSchema = z.object({ videoId: z.string(), content: z.string().min(1, 'Comment cannot be empty').max(1000, 'Comment too long'), parentId: z.string().optional() // For replies }); async function hasVideoPermission(userId: string, videoId: string, action: string = 'read') { const video = await prisma.video.findUnique({ where: { id: videoId }, include: { channel: { include: { collaborators: { where: { userId, isActive: true } } } } } }); if (!video) return false; // Video owner or channel owner has all permissions if (video.uploadedById === userId || video.channel.ownerId === userId) return true; // Check channel collaborator permissions const collaborator = video.channel.collaborators[0]; if (!collaborator) { // Public videos are readable by everyone return action === 'read' && video.visibility === 'PUBLIC' && video.status === 'READY'; } switch (action) { case 'read': return true; case 'update': return ['ADMIN', 'MODERATOR', 'EDITOR'].includes(collaborator.role); case 'delete': return ['ADMIN', 'MODERATOR'].includes(collaborator.role); case 'moderate': return ['ADMIN', 'MODERATOR'].includes(collaborator.role); 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 getVideos(req, res); case 'POST': if (action === 'like') { return await likeVideo(req, res); } else if (action === 'comment') { return await addComment(req, res); } else if (action === 'watch') { return await recordWatch(req, res); } return await createVideo(req, res); default: res.setHeader('Allow', ['GET', 'POST']); return res.status(405).end('Method Not Allowed'); } } catch (error) { console.error('Videos API error:', error); return res.status(500).json({ error: 'Internal server error' }); } finally { await prisma.$disconnect(); } } async function getVideos(req: NextApiRequest, res: NextApiResponse) { const session = await getSession({ req }); const { channelId, category, search, sortBy = 'publishedAt', sortOrder = 'desc', limit = '20', page = '1', visibility = 'PUBLIC', status = 'READY' } = 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: status === 'ALL' ? undefined : status }; if (channelId && typeof channelId === 'string') { whereClause.channelId = channelId; } if (category && category !== 'ALL') { whereClause.category = category; } if (visibility && visibility !== 'ALL') { whereClause.visibility = visibility; } if (search && typeof search === 'string') { whereClause.OR = [ { title: { contains: search, mode: 'insensitive' } }, { description: { contains: search, mode: 'insensitive' } }, { tags: { hasSome: [search] } } ]; } // Only show published videos unless user owns the channel if (!channelId || visibility === 'PUBLIC') { whereClause.publishedAt = { lte: new Date() }; } // Get videos with comprehensive data const videos = await prisma.video.findMany({ where: whereClause, include: { channel: { select: { id: true, name: true, handle: true, avatar: true, isVerified: true, subscriberCount: true } }, uploadedBy: { select: { id: true, name: true, image: true } }, qualities: { where: { processingStatus: 'completed' }, orderBy: { quality: 'desc' } }, _count: { select: { comments: true, likes: true, playlistItems: true } } }, orderBy: { [sortBy as string]: sortOrder }, skip: offset, take: limitNum }); // Enrich videos with user interaction data const enrichedVideos = await Promise.all( videos.map(async (video) => { // Check if user has liked/disliked the video const userLike = await prisma.videoLike.findUnique({ where: { videoId_userId: { videoId: video.id, userId: session.user.id } } }); // Check watch history const watchHistory = await prisma.watchHistory.findUnique({ where: { userId_videoId: { userId: session.user.id, videoId: video.id } } }); // Calculate engagement rate const totalEngagement = video.likeCount + video.dislikeCount + video.commentCount; const engagementRate = video.viewCount > 0 ? (totalEngagement / video.viewCount) * 100 : 0; return { ...video, userInteraction: { hasLiked: userLike?.isLike === true, hasDisliked: userLike?.isLike === false, watchProgress: watchHistory?.lastPosition || 0, isCompleted: watchHistory?.isCompleted || false }, stats: { engagementRate: Math.round(engagementRate * 100) / 100, avgViewDuration: watchHistory?.watchDuration || 0, totalComments: video._count.comments, totalLikes: video._count.likes, inPlaylists: video._count.playlistItems } }; }) ); // Get total count for pagination const total = await prisma.video.count({ where: whereClause }); res.status(200).json({ videos: enrichedVideos, pagination: { page: pageNum, limit: limitNum, total, pages: Math.ceil(total / limitNum) } }); } catch (error) { console.error('Error fetching videos:', error); res.status(500).json({ error: 'Failed to fetch videos' }); } } async function createVideo(req: NextApiRequest, res: NextApiResponse) { const session = await getSession({ req }); try { const validatedData = createVideoSchema.parse(req.body); // Verify user has permission to upload to this channel const channel = await prisma.videoChannel.findUnique({ where: { id: validatedData.channelId }, include: { collaborators: { where: { userId: session.user.id, isActive: true } } } }); if (!channel) { return res.status(404).json({ error: 'Channel not found' }); } const hasPermission = channel.ownerId === session.user.id || channel.collaborators.some(c => ['ADMIN', 'MODERATOR', 'EDITOR'].includes(c.role)); if (!hasPermission) { return res.status(403).json({ error: 'No permission to upload to this channel' }); } // 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 existingVideo = await prisma.video.findUnique({ where: { slug } }); if (existingVideo) { slug = `${slug}-${Date.now()}`; } } // Create the video const video = await prisma.video.create({ data: { ...validatedData, slug, uploadedById: session.user.id, uploadedAt: new Date(), publishedAt: validatedData.publishedAt ? new Date(validatedData.publishedAt) : (validatedData.visibility === 'PUBLIC' ? new Date() : undefined), tags: validatedData.tags || [] }, include: { channel: { select: { id: true, name: true, handle: true, avatar: true } }, uploadedBy: { select: { id: true, name: true, image: true } } } }); // Update channel video count await prisma.videoChannel.update({ where: { id: validatedData.channelId }, data: { videoCount: { increment: 1 } } }); // Create initial analytics entry if published if (video.publishedAt) { const today = new Date(); today.setHours(0, 0, 0, 0); await prisma.videoAnalytics.create({ data: { videoId: video.id, date: today, views: 0, uniqueViews: 0, watchTime: 0, averageViewDuration: 0 } }); // Update channel analytics await prisma.channelAnalytics.upsert({ where: { channelId_date: { channelId: validatedData.channelId, date: today } }, update: { videoPublished: { increment: 1 } }, create: { channelId: validatedData.channelId, date: today, subscriberCount: channel.subscriberCount, totalViews: 0, totalWatchTime: 0, videoPublished: 1 } }); } res.status(201).json({ video, message: 'Video uploaded successfully' }); } catch (error) { if (error instanceof z.ZodError) { return res.status(400).json({ error: 'Validation failed', details: error.errors }); } console.error('Error creating video:', error); res.status(500).json({ error: 'Failed to create video' }); } } async function likeVideo(req: NextApiRequest, res: NextApiResponse) { const session = await getSession({ req }); try { const validatedData = likeVideoSchema.parse(req.body); // Check if video exists and is accessible const video = await prisma.video.findUnique({ where: { id: validatedData.videoId } }); if (!video) { return res.status(404).json({ error: 'Video not found' }); } // Check existing like/dislike const existingLike = await prisma.videoLike.findUnique({ where: { videoId_userId: { videoId: validatedData.videoId, userId: session.user.id } } }); if (existingLike) { if (existingLike.isLike === validatedData.isLike) { // Remove like/dislike if clicking same button await prisma.videoLike.delete({ where: { id: existingLike.id } }); // Update video counts await prisma.video.update({ where: { id: validatedData.videoId }, data: { [validatedData.isLike ? 'likeCount' : 'dislikeCount']: { decrement: 1 } } }); return res.status(200).json({ action: 'removed', message: validatedData.isLike ? 'Like removed' : 'Dislike removed' }); } else { // Switch between like and dislike await prisma.videoLike.update({ where: { id: existingLike.id }, data: { isLike: validatedData.isLike } }); // Update video counts await prisma.video.update({ where: { id: validatedData.videoId }, data: { likeCount: { [validatedData.isLike ? 'increment' : 'decrement']: 1 }, dislikeCount: { [validatedData.isLike ? 'decrement' : 'increment']: 1 } } }); return res.status(200).json({ action: 'updated', message: validatedData.isLike ? 'Video liked' : 'Video disliked' }); } } // Create new like/dislike await prisma.videoLike.create({ data: { videoId: validatedData.videoId, userId: session.user.id, isLike: validatedData.isLike } }); // Update video counts await prisma.video.update({ where: { id: validatedData.videoId }, data: { [validatedData.isLike ? 'likeCount' : 'dislikeCount']: { increment: 1 } } }); res.status(201).json({ action: 'created', message: validatedData.isLike ? 'Video liked' : 'Video disliked' }); } catch (error) { if (error instanceof z.ZodError) { return res.status(400).json({ error: 'Validation failed', details: error.errors }); } console.error('Error liking video:', error); res.status(500).json({ error: 'Failed to like video' }); } } async function addComment(req: NextApiRequest, res: NextApiResponse) { const session = await getSession({ req }); try { const validatedData = commentSchema.parse(req.body); // Verify video exists and is accessible const video = await prisma.video.findUnique({ where: { id: validatedData.videoId } }); if (!video) { return res.status(404).json({ error: 'Video not found' }); } // Verify parent comment if replying if (validatedData.parentId) { const parentComment = await prisma.videoComment.findFirst({ where: { id: validatedData.parentId, videoId: validatedData.videoId } }); if (!parentComment) { return res.status(400).json({ error: 'Parent comment not found' }); } } // Create comment const comment = await prisma.videoComment.create({ data: { videoId: validatedData.videoId, authorId: session.user.id, content: validatedData.content, parentId: validatedData.parentId }, include: { author: { select: { id: true, name: true, image: true } }, _count: { select: { replies: true, likes: true } } } }); // Update video comment count await prisma.video.update({ where: { id: validatedData.videoId }, data: { commentCount: { increment: 1 } } }); res.status(201).json({ comment, message: 'Comment added successfully' }); } catch (error) { if (error instanceof z.ZodError) { return res.status(400).json({ error: 'Validation failed', details: error.errors }); } console.error('Error adding comment:', error); res.status(500).json({ error: 'Failed to add comment' }); } } async function recordWatch(req: NextApiRequest, res: NextApiResponse) { const session = await getSession({ req }); const { videoId, watchDuration, lastPosition, quality, deviceType } = req.body; try { // Update or create watch history const watchHistory = await prisma.watchHistory.upsert({ where: { userId_videoId: { userId: session.user.id, videoId } }, update: { watchedAt: new Date(), watchDuration: Math.max(watchDuration, 0), lastPosition: Math.max(lastPosition, 0), isCompleted: lastPosition >= (await prisma.video.findUnique({ where: { id: videoId }, select: { duration: true } }))?.duration! * 0.9, quality, deviceType }, create: { userId: session.user.id, videoId, watchDuration: Math.max(watchDuration, 0), lastPosition: Math.max(lastPosition, 0), quality, deviceType } }); // Update video view count (only count as view if watched > 30 seconds) if (watchDuration >= 30) { await prisma.video.update({ where: { id: videoId }, data: { viewCount: { increment: 1 } } }); // Update analytics const today = new Date(); today.setHours(0, 0, 0, 0); await prisma.videoAnalytics.upsert({ where: { videoId_date: { videoId, date: today } }, update: { views: { increment: 1 }, watchTime: { increment: watchDuration } }, create: { videoId, date: today, views: 1, uniqueViews: 1, watchTime: watchDuration, averageViewDuration: watchDuration } }); } res.status(200).json({ watchHistory, message: 'Watch progress recorded' }); } catch (error) { console.error('Error recording watch:', error); res.status(500).json({ error: 'Failed to record watch progress' }); } }