diff --git a/app/api/comments/[commentId]/route.ts b/app/api/comments/[commentId]/route.ts new file mode 100644 index 0000000..7a3ffd1 --- /dev/null +++ b/app/api/comments/[commentId]/route.ts @@ -0,0 +1,197 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { auth } from '@/lib/auth'; + +type RouteParams = { params: Promise<{ commentId: string }> }; + +// GET /api/comments/[commentId] +export async function GET(request: NextRequest, { params }: RouteParams) { + try { + const session = await auth(); + const { commentId } = await params; + + const comment = await db.comment.findUnique({ + where: { id: commentId }, + include: { + author: { select: { id: true, name: true, image: true } }, + replies: { + orderBy: { createdAt: 'asc' }, + include: { + author: { select: { id: true, name: true, image: true } }, + }, + }, + version: { + include: { + video: { + include: { + project: { + include: { + members: { where: { userId: session?.user?.id || '' } }, + }, + }, + }, + }, + }, + }, + }, + }); + + if (!comment) { + return NextResponse.json({ error: 'Comment not found' }, { status: 404 }); + } + + // Authorization check: verify user has access to the project + const project = comment.version.video.project; + const isOwner = session?.user?.id === project.ownerId; + const isMember = project.members.length > 0; + const isPublicOrLink = project.visibility !== 'PRIVATE'; + + if (!isOwner && !isMember && !isPublicOrLink) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + // Strip internal project data from response + const { version: _version, ...commentData } = comment; + return NextResponse.json(commentData); + } catch (error) { + console.error('Error fetching comment:', error); + return NextResponse.json( + { error: 'Failed to fetch comment' }, + { status: 500 } + ); + } +} + +// PATCH /api/comments/[commentId] +export async function PATCH(request: NextRequest, { params }: RouteParams) { + try { + const session = await auth(); + const { commentId } = await params; + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const comment = await db.comment.findUnique({ + where: { id: commentId }, + include: { + version: { + include: { + video: { + include: { + project: { + include: { + members: { where: { userId: session.user.id } }, + }, + }, + }, + }, + }, + }, + }, + }); + + if (!comment) { + return NextResponse.json({ error: 'Comment not found' }, { status: 404 }); + } + + const project = comment.version.video.project; + const isOwner = project.ownerId === session.user.id; + const isAuthor = comment.authorId === session.user.id; + const isMember = project.members.length > 0; + + const body = await request.json(); + const { content, isResolved } = body; + + // Only author can edit content + if (content !== undefined && !isAuthor) { + return NextResponse.json( + { error: 'Only the author can edit comment content' }, + { status: 403 } + ); + } + + // Owner, author, or members can resolve/unresolve + if (isResolved !== undefined && !isOwner && !isAuthor && !isMember) { + return NextResponse.json( + { error: 'Access denied' }, + { status: 403 } + ); + } + + const updateData: Record = {}; + if (content !== undefined) updateData.content = content.trim(); + if (isResolved !== undefined) { + updateData.isResolved = isResolved; + updateData.resolvedAt = isResolved ? new Date() : null; + } + + const updatedComment = await db.comment.update({ + where: { id: commentId }, + data: updateData, + include: { + author: { select: { id: true, name: true, image: true } }, + replies: { + include: { + author: { select: { id: true, name: true, image: true } }, + }, + }, + }, + }); + + return NextResponse.json(updatedComment); + } catch (error) { + console.error('Error updating comment:', error); + return NextResponse.json( + { error: 'Failed to update comment' }, + { status: 500 } + ); + } +} + +// DELETE /api/comments/[commentId] +export async function DELETE(request: NextRequest, { params }: RouteParams) { + try { + const session = await auth(); + const { commentId } = await params; + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const comment = await db.comment.findUnique({ + where: { id: commentId }, + include: { + version: { + include: { + video: { include: { project: true } }, + }, + }, + }, + }); + + if (!comment) { + return NextResponse.json({ error: 'Comment not found' }, { status: 404 }); + } + + const isOwner = comment.version.video.project.ownerId === session.user.id; + const isAuthor = comment.authorId === session.user.id; + + if (!isOwner && !isAuthor) { + return NextResponse.json( + { error: 'Only the author or project owner can delete this comment' }, + { status: 403 } + ); + } + + await db.comment.delete({ where: { id: commentId } }); + + return NextResponse.json({ success: true, message: 'Comment deleted' }); + } catch (error) { + console.error('Error deleting comment:', error); + return NextResponse.json( + { error: 'Failed to delete comment' }, + { status: 500 } + ); + } +} diff --git a/app/api/projects/[projectId]/route.ts b/app/api/projects/[projectId]/route.ts new file mode 100644 index 0000000..f99dff4 --- /dev/null +++ b/app/api/projects/[projectId]/route.ts @@ -0,0 +1,158 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { auth } from '@/lib/auth'; +import { ProjectMemberRole, ProjectVisibility } from '@prisma/client'; + +type RouteParams = { params: Promise<{ projectId: string }> }; + +// Helper to check project access +async function checkProjectAccess(projectId: string, userId: string) { + const project = await db.project.findUnique({ + where: { id: projectId }, + include: { + members: { where: { userId } }, + }, + }); + + if (!project) return { project: null, role: null, canEdit: false, canDelete: false }; + + const isOwner = project.ownerId === userId; + const membership = project.members[0]; + const role = isOwner ? 'OWNER' : membership?.role || null; + + return { + project, + role, + canEdit: isOwner || role === ProjectMemberRole.ADMIN || role === ProjectMemberRole.EDITOR, + canDelete: isOwner, + }; +} + +// GET /api/projects/[projectId] - Get a single project +export async function GET(request: NextRequest, { params }: RouteParams) { + try { + const session = await auth(); + const { projectId } = await params; + + const project = await db.project.findUnique({ + where: { id: projectId }, + include: { + owner: { select: { id: true, name: true, image: true } }, + members: { + include: { + user: { select: { id: true, name: true, image: true } }, + }, + }, + videos: { + orderBy: { position: 'asc' }, + include: { + versions: { + orderBy: { versionNumber: 'desc' }, + take: 1, + }, + _count: { select: { versions: true } }, + }, + }, + _count: { select: { videos: true, members: true, shareLinks: true } }, + }, + }); + + if (!project) { + return NextResponse.json({ error: 'Project not found' }, { status: 404 }); + } + + // Check access + const isPublic = project.visibility === ProjectVisibility.PUBLIC; + const isOwner = session?.user?.id === project.ownerId; + const isMember = project.members.some(m => m.userId === session?.user?.id); + + if (!isPublic && !isOwner && !isMember) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + return NextResponse.json(project); + } catch (error) { + console.error('Error fetching project:', error); + return NextResponse.json( + { error: 'Failed to fetch project' }, + { status: 500 } + ); + } +} + +// PATCH /api/projects/[projectId] - Update a project +export async function PATCH(request: NextRequest, { params }: RouteParams) { + try { + const session = await auth(); + const { projectId } = await params; + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { canEdit } = await checkProjectAccess(projectId, session.user.id); + if (!canEdit) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + const body = await request.json(); + const { name, description, visibility } = body; + + const updateData: Record = {}; + if (name !== undefined) updateData.name = name.trim(); + if (description !== undefined) updateData.description = description?.trim() || null; + if (visibility !== undefined) updateData.visibility = visibility; + + const project = await db.project.update({ + where: { id: projectId }, + data: updateData, + include: { + owner: { select: { id: true, name: true, image: true } }, + _count: { select: { videos: true, members: true } }, + }, + }); + + return NextResponse.json(project); + } catch (error) { + console.error('Error updating project:', error); + return NextResponse.json( + { error: 'Failed to update project' }, + { status: 500 } + ); + } +} + +// DELETE /api/projects/[projectId] - Delete a project +export async function DELETE(request: NextRequest, { params }: RouteParams) { + try { + const session = await auth(); + const { projectId } = await params; + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { canDelete, project } = await checkProjectAccess(projectId, session.user.id); + + if (!project) { + return NextResponse.json({ error: 'Project not found' }, { status: 404 }); + } + + if (!canDelete) { + return NextResponse.json( + { error: 'Only the project owner can delete it' }, + { status: 403 } + ); + } + + await db.project.delete({ where: { id: projectId } }); + + return NextResponse.json({ success: true, message: 'Project deleted' }); + } catch (error) { + console.error('Error deleting project:', error); + return NextResponse.json( + { error: 'Failed to delete project' }, + { status: 500 } + ); + } +} diff --git a/app/api/projects/[projectId]/videos/[videoId]/route.ts b/app/api/projects/[projectId]/videos/[videoId]/route.ts new file mode 100644 index 0000000..5959552 --- /dev/null +++ b/app/api/projects/[projectId]/videos/[videoId]/route.ts @@ -0,0 +1,166 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { auth } from '@/lib/auth'; +import { ProjectMemberRole } from '@prisma/client'; + +type RouteParams = { params: Promise<{ projectId: string; videoId: string }> }; + +// GET /api/projects/[projectId]/videos/[videoId] +export async function GET(request: NextRequest, { params }: RouteParams) { + try { + const session = await auth(); + const { projectId, videoId } = await params; + + const video = await db.video.findFirst({ + where: { id: videoId, projectId }, + include: { + project: { + include: { members: { where: { userId: session?.user?.id || '' } } }, + }, + versions: { + orderBy: { versionNumber: 'desc' }, + include: { + comments: { + orderBy: { timestamp: 'asc' }, + include: { + author: { select: { id: true, name: true, image: true } }, + replies: { + orderBy: { createdAt: 'asc' }, + include: { + author: { select: { id: true, name: true, image: true } }, + }, + }, + }, + where: { parentId: null }, // Only top-level comments + }, + _count: { select: { comments: true } }, + }, + }, + }, + }); + + if (!video) { + return NextResponse.json({ error: 'Video not found' }, { status: 404 }); + } + + // Check access + const isOwner = session?.user?.id === video.project.ownerId; + const isMember = video.project.members.length > 0; + const isPublicOrLink = video.project.visibility !== 'PRIVATE'; + + if (!isOwner && !isMember && !isPublicOrLink) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + return NextResponse.json(video); + } catch (error) { + console.error('Error fetching video:', error); + return NextResponse.json( + { error: 'Failed to fetch video' }, + { status: 500 } + ); + } +} + +// PATCH /api/projects/[projectId]/videos/[videoId] +export async function PATCH(request: NextRequest, { params }: RouteParams) { + try { + const session = await auth(); + const { projectId, videoId } = await params; + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const video = await db.video.findFirst({ + where: { id: videoId, projectId }, + include: { + project: { include: { members: { where: { userId: session.user.id } } } }, + }, + }); + + if (!video) { + return NextResponse.json({ error: 'Video not found' }, { status: 404 }); + } + + const isOwner = video.project.ownerId === session.user.id; + const membership = video.project.members[0]; + const canEdit = isOwner || + membership?.role === ProjectMemberRole.ADMIN || + membership?.role === ProjectMemberRole.EDITOR; + + if (!canEdit) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + const body = await request.json(); + const { title, description, position } = body; + + const updateData: Record = {}; + if (title !== undefined) updateData.title = title.trim(); + if (description !== undefined) updateData.description = description?.trim() || null; + if (position !== undefined) updateData.position = position; + + const updatedVideo = await db.video.update({ + where: { id: videoId }, + data: updateData, + include: { + versions: { orderBy: { versionNumber: 'desc' } }, + _count: { select: { versions: true } }, + }, + }); + + return NextResponse.json(updatedVideo); + } catch (error) { + console.error('Error updating video:', error); + return NextResponse.json( + { error: 'Failed to update video' }, + { status: 500 } + ); + } +} + +// DELETE /api/projects/[projectId]/videos/[videoId] +export async function DELETE(request: NextRequest, { params }: RouteParams) { + try { + const session = await auth(); + const { projectId, videoId } = await params; + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const video = await db.video.findFirst({ + where: { id: videoId, projectId }, + include: { + project: { include: { members: { where: { userId: session.user.id } } } }, + }, + }); + + if (!video) { + return NextResponse.json({ error: 'Video not found' }, { status: 404 }); + } + + const isOwner = video.project.ownerId === session.user.id; + const membership = video.project.members[0]; + // Destructive actions limited to OWNER and ADMIN only + const canDelete = isOwner || membership?.role === ProjectMemberRole.ADMIN; + + if (!canDelete) { + return NextResponse.json( + { error: 'Only project owner or admin can delete videos' }, + { status: 403 } + ); + } + + await db.video.delete({ where: { id: videoId } }); + + return NextResponse.json({ success: true, message: 'Video deleted' }); + } catch (error) { + console.error('Error deleting video:', error); + return NextResponse.json( + { error: 'Failed to delete video' }, + { status: 500 } + ); + } +} diff --git a/app/api/projects/[projectId]/videos/[videoId]/versions/route.ts b/app/api/projects/[projectId]/videos/[videoId]/versions/route.ts new file mode 100644 index 0000000..abe5daa --- /dev/null +++ b/app/api/projects/[projectId]/videos/[videoId]/versions/route.ts @@ -0,0 +1,146 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { auth } from '@/lib/auth'; +import { ProjectMemberRole } from '@prisma/client'; +import { validateUrl, validateOptionalUrl } from '@/lib/validation'; + +type RouteParams = { params: Promise<{ projectId: string; videoId: string }> }; + +// GET /api/projects/[projectId]/videos/[videoId]/versions +export async function GET(request: NextRequest, { params }: RouteParams) { + try { + const session = await auth(); + const { projectId, videoId } = await params; + + const video = await db.video.findFirst({ + where: { id: videoId, projectId }, + include: { + project: { + include: { members: { where: { userId: session?.user?.id || '' } } }, + }, + }, + }); + + if (!video) { + return NextResponse.json({ error: 'Video not found' }, { status: 404 }); + } + + const isOwner = session?.user?.id === video.project.ownerId; + const isMember = video.project.members.length > 0; + const isPublicOrLink = video.project.visibility !== 'PRIVATE'; + + if (!isOwner && !isMember && !isPublicOrLink) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + const versions = await db.videoVersion.findMany({ + where: { videoParentId: videoId }, + orderBy: { versionNumber: 'desc' }, + include: { + _count: { select: { comments: true } }, + }, + }); + + return NextResponse.json({ versions }); + } catch (error) { + console.error('Error fetching versions:', error); + return NextResponse.json( + { error: 'Failed to fetch versions' }, + { status: 500 } + ); + } +} + +// POST /api/projects/[projectId]/videos/[videoId]/versions - Add a new version +export async function POST(request: NextRequest, { params }: RouteParams) { + try { + const session = await auth(); + const { projectId, videoId } = await params; + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const video = await db.video.findFirst({ + where: { id: videoId, projectId }, + include: { + project: { include: { members: { where: { userId: session.user.id } } } }, + versions: { orderBy: { versionNumber: 'desc' }, take: 1 }, + }, + }); + + if (!video) { + return NextResponse.json({ error: 'Video not found' }, { status: 404 }); + } + + const isOwner = video.project.ownerId === session.user.id; + const membership = video.project.members[0]; + const canEdit = isOwner || + membership?.role === ProjectMemberRole.ADMIN || + membership?.role === ProjectMemberRole.EDITOR; + + if (!canEdit) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + const body = await request.json(); + const { videoUrl, providerId, providerVideoId, versionLabel, thumbnailUrl, duration, setActive } = body; + + if (!videoUrl) { + return NextResponse.json( + { error: 'Video URL is required' }, + { status: 400 } + ); + } + + // Validate URLs use safe schemes (http/https only) + const videoUrlError = validateUrl(videoUrl, 'Video URL'); + if (videoUrlError) { + return NextResponse.json({ error: videoUrlError }, { status: 400 }); + } + + const thumbnailUrlError = validateOptionalUrl(thumbnailUrl, 'Thumbnail URL'); + if (thumbnailUrlError) { + return NextResponse.json({ error: thumbnailUrlError }, { status: 400 }); + } + + const nextVersionNumber = (video.versions[0]?.versionNumber || 0) + 1; + + // Use transaction to handle active flag + const version = await db.$transaction(async (tx) => { + // If setActive, deactivate all other versions + if (setActive) { + await tx.videoVersion.updateMany({ + where: { videoParentId: videoId }, + data: { isActive: false }, + }); + } + + return tx.videoVersion.create({ + data: { + versionNumber: nextVersionNumber, + versionLabel: versionLabel?.trim() || null, + providerId: providerId || 'youtube', + videoId: providerVideoId || '', + originalUrl: videoUrl, + title: versionLabel?.trim() || `Version ${nextVersionNumber}`, + thumbnailUrl: thumbnailUrl || null, + duration: duration || null, + isActive: setActive ?? false, + videoParentId: videoId, + }, + include: { + _count: { select: { comments: true } }, + }, + }); + }); + + return NextResponse.json(version, { status: 201 }); + } catch (error) { + console.error('Error creating version:', error); + return NextResponse.json( + { error: 'Failed to create version' }, + { status: 500 } + ); + } +} diff --git a/app/api/projects/[projectId]/videos/route.ts b/app/api/projects/[projectId]/videos/route.ts new file mode 100644 index 0000000..5ebbb31 --- /dev/null +++ b/app/api/projects/[projectId]/videos/route.ts @@ -0,0 +1,149 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { auth } from '@/lib/auth'; +import { ProjectMemberRole } from '@prisma/client'; +import { validateUrl, validateOptionalUrl } from '@/lib/validation'; + +type RouteParams = { params: Promise<{ projectId: string }> }; + +// GET /api/projects/[projectId]/videos - List all videos in a project +export async function GET(request: NextRequest, { params }: RouteParams) { + try { + const session = await auth(); + const { projectId } = await params; + + // Check project exists and user has access + const project = await db.project.findUnique({ + where: { id: projectId }, + include: { members: { where: { userId: session?.user?.id || '' } } }, + }); + + if (!project) { + return NextResponse.json({ error: 'Project not found' }, { status: 404 }); + } + + const isOwner = session?.user?.id === project.ownerId; + const isMember = project.members.length > 0; + const isPublicOrLink = project.visibility !== 'PRIVATE'; + + if (!isOwner && !isMember && !isPublicOrLink) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + const videos = await db.video.findMany({ + where: { projectId }, + orderBy: { position: 'asc' }, + include: { + versions: { + orderBy: { versionNumber: 'desc' }, + include: { + _count: { select: { comments: true } }, + }, + }, + _count: { select: { versions: true } }, + }, + }); + + return NextResponse.json({ videos }); + } catch (error) { + console.error('Error fetching videos:', error); + return NextResponse.json( + { error: 'Failed to fetch videos' }, + { status: 500 } + ); + } +} + +// POST /api/projects/[projectId]/videos - Add a new video to the project +export async function POST(request: NextRequest, { params }: RouteParams) { + try { + const session = await auth(); + const { projectId } = await params; + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Check project access (must be owner, admin, or editor) + const project = await db.project.findUnique({ + where: { id: projectId }, + include: { members: { where: { userId: session.user.id } } }, + }); + + if (!project) { + return NextResponse.json({ error: 'Project not found' }, { status: 404 }); + } + + const isOwner = project.ownerId === session.user.id; + const membership = project.members[0]; + const canEdit = isOwner || + membership?.role === ProjectMemberRole.ADMIN || + membership?.role === ProjectMemberRole.EDITOR; + + if (!canEdit) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + const body = await request.json(); + const { title, description, videoUrl, providerId, videoId, thumbnailUrl, duration } = body; + + if (!title || !videoUrl) { + return NextResponse.json( + { error: 'Title and video URL are required' }, + { status: 400 } + ); + } + + // Validate URLs use safe schemes (http/https only) + const videoUrlError = validateUrl(videoUrl, 'Video URL'); + if (videoUrlError) { + return NextResponse.json({ error: videoUrlError }, { status: 400 }); + } + + const thumbnailUrlError = validateOptionalUrl(thumbnailUrl, 'Thumbnail URL'); + if (thumbnailUrlError) { + return NextResponse.json({ error: thumbnailUrlError }, { status: 400 }); + } + + // Get the next position + const lastVideo = await db.video.findFirst({ + where: { projectId }, + orderBy: { position: 'desc' }, + }); + const nextPosition = (lastVideo?.position ?? -1) + 1; + + // Create video with initial version + const video = await db.video.create({ + data: { + title: title.trim(), + description: description?.trim() || null, + position: nextPosition, + projectId, + versions: { + create: { + versionNumber: 1, + providerId: providerId || 'youtube', + videoId: videoId || '', + originalUrl: videoUrl, + title: title.trim(), + thumbnailUrl: thumbnailUrl || null, + duration: duration || null, + isActive: true, + }, + }, + }, + include: { + versions: true, + _count: { select: { versions: true } }, + }, + }); + + return NextResponse.json(video, { status: 201 }); + } catch (error) { + console.error('Error creating video:', error); + return NextResponse.json( + { error: 'Failed to create video' }, + { status: 500 } + ); + } +} diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts new file mode 100644 index 0000000..5443c81 --- /dev/null +++ b/app/api/projects/route.ts @@ -0,0 +1,124 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { auth } from '@/lib/auth'; +import { ProjectVisibility } from '@prisma/client'; + +// GET /api/projects - List all projects for the authenticated user +export async function GET(request: NextRequest) { + try { + const session = await auth(); + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const page = parseInt(searchParams.get('page') || '1'); + const limit = parseInt(searchParams.get('limit') || '10'); + const skip = (page - 1) * limit; + + // Get projects where user is owner OR a member + const [projects, total] = await Promise.all([ + db.project.findMany({ + where: { + OR: [ + { ownerId: session.user.id }, + { members: { some: { userId: session.user.id } } }, + ], + }, + include: { + owner: { select: { id: true, name: true, image: true } }, + _count: { select: { videos: true, members: true } }, + }, + orderBy: { updatedAt: 'desc' }, + skip, + take: limit, + }), + db.project.count({ + where: { + OR: [ + { ownerId: session.user.id }, + { members: { some: { userId: session.user.id } } }, + ], + }, + }), + ]); + + return NextResponse.json({ + projects, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }); + } catch (error) { + console.error('Error fetching projects:', error); + return NextResponse.json( + { error: 'Failed to fetch projects' }, + { status: 500 } + ); + } +} + +// POST /api/projects - Create a new project +export async function POST(request: NextRequest) { + try { + const session = await auth(); + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const { name, description, visibility } = body; + + if (!name || typeof name !== 'string' || name.trim().length === 0) { + return NextResponse.json( + { error: 'Project name is required' }, + { status: 400 } + ); + } + + // Generate URL-friendly slug + const baseSlug = name + .toLowerCase() + .trim() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-'); + + // Ensure uniqueness by appending random suffix if needed + let slug = baseSlug; + let attempts = 0; + while (attempts < 10) { + const existing = await db.project.findUnique({ where: { slug } }); + if (!existing) break; + slug = `${baseSlug}-${Math.random().toString(36).substring(2, 6)}`; + attempts++; + } + + const project = await db.project.create({ + data: { + name: name.trim(), + description: description?.trim() || null, + slug, + visibility: visibility || ProjectVisibility.PRIVATE, + ownerId: session.user.id, + }, + include: { + owner: { select: { id: true, name: true, image: true } }, + _count: { select: { videos: true, members: true } }, + }, + }); + + return NextResponse.json(project, { status: 201 }); + } catch (error) { + console.error('Error creating project:', error); + return NextResponse.json( + { error: 'Failed to create project' }, + { status: 500 } + ); + } +} diff --git a/app/api/versions/[versionId]/comments/route.ts b/app/api/versions/[versionId]/comments/route.ts new file mode 100644 index 0000000..ec753e5 --- /dev/null +++ b/app/api/versions/[versionId]/comments/route.ts @@ -0,0 +1,189 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { auth } from '@/lib/auth'; +import { validateOptionalUrl } from '@/lib/validation'; + +type RouteParams = { params: Promise<{ versionId: string }> }; + +// GET /api/versions/[versionId]/comments +export async function GET(request: NextRequest, { params }: RouteParams) { + try { + const session = await auth(); + const { versionId } = await params; + + // Get version with project access info + const version = await db.videoVersion.findUnique({ + where: { id: versionId }, + include: { + video: { + include: { + project: { + include: { + members: { where: { userId: session?.user?.id || '' } }, + }, + }, + }, + }, + }, + }); + + if (!version) { + return NextResponse.json({ error: 'Version not found' }, { status: 404 }); + } + + const project = version.video.project; + const isOwner = session?.user?.id === project.ownerId; + const isMember = project.members.length > 0; + const isPublicOrLink = project.visibility !== 'PRIVATE'; + + if (!isOwner && !isMember && !isPublicOrLink) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + const { searchParams } = new URL(request.url); + const includeResolved = searchParams.get('includeResolved') !== 'false'; + + const comments = await db.comment.findMany({ + where: { + versionId, + parentId: null, // Only top-level comments + ...(includeResolved ? {} : { isResolved: false }), + }, + orderBy: { timestamp: 'asc' }, + include: { + author: { select: { id: true, name: true, image: true } }, + replies: { + orderBy: { createdAt: 'asc' }, + include: { + author: { select: { id: true, name: true, image: true } }, + }, + }, + }, + }); + + return NextResponse.json({ comments }); + } catch (error) { + console.error('Error fetching comments:', error); + return NextResponse.json( + { error: 'Failed to fetch comments' }, + { status: 500 } + ); + } +} + +// POST /api/versions/[versionId]/comments +export async function POST(request: NextRequest, { params }: RouteParams) { + try { + const session = await auth(); + const { versionId } = await params; + + const version = await db.videoVersion.findUnique({ + where: { id: versionId }, + include: { + video: { + include: { + project: { + include: { + members: { where: { userId: session?.user?.id || '' } }, + shareLinks: { where: { permission: 'COMMENT' } }, + }, + }, + }, + }, + }, + }); + + if (!version) { + return NextResponse.json({ error: 'Version not found' }, { status: 404 }); + } + + const project = version.video.project; + const isOwner = session?.user?.id === project.ownerId; + const isMember = project.members.length > 0; + const hasCommentLink = project.shareLinks.length > 0; + const isPublic = project.visibility === 'PUBLIC'; + + // Check if user can comment + const canComment = isOwner || isMember || isPublic || hasCommentLink; + if (!canComment) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + const body = await request.json(); + const { content, timestamp, timestampEnd, parentId, voiceUrl, voiceDuration, guestName, guestEmail } = body; + + // Validate required fields + if (timestamp === undefined || timestamp === null) { + return NextResponse.json( + { error: 'Timestamp is required' }, + { status: 400 } + ); + } + + if (!content && !voiceUrl) { + return NextResponse.json( + { error: 'Either content or voice recording is required' }, + { status: 400 } + ); + } + + // If replying, verify parent exists in same version + if (parentId) { + const parent = await db.comment.findFirst({ + where: { id: parentId, versionId }, + }); + if (!parent) { + return NextResponse.json( + { error: 'Parent comment not found' }, + { status: 400 } + ); + } + } + + // Guest comment validation + const isGuest = !session?.user?.id; + if (isGuest && !guestName) { + return NextResponse.json( + { error: 'Guest name is required for guest comments' }, + { status: 400 } + ); + } + + // Validate voice URL uses safe scheme + const voiceUrlError = validateOptionalUrl(voiceUrl, 'Voice URL'); + if (voiceUrlError) { + return NextResponse.json({ error: voiceUrlError }, { status: 400 }); + } + + const comment = await db.comment.create({ + data: { + content: content?.trim() || null, + timestamp: parseFloat(timestamp), + timestampEnd: timestampEnd ? parseFloat(timestampEnd) : null, + parentId: parentId || null, + voiceUrl: voiceUrl || null, + voiceDuration: voiceDuration || null, + authorId: session?.user?.id || null, + guestName: isGuest ? guestName : null, + guestEmail: isGuest ? guestEmail : null, + versionId, + }, + include: { + author: { select: { id: true, name: true, image: true } }, + replies: { + include: { + author: { select: { id: true, name: true, image: true } }, + }, + }, + }, + }); + + return NextResponse.json(comment, { status: 201 }); + } catch (error) { + console.error('Error creating comment:', error); + return NextResponse.json( + { error: 'Failed to create comment' }, + { status: 500 } + ); + } +} diff --git a/bun.lock b/bun.lock index 585c8b6..6e867f0 100644 --- a/bun.lock +++ b/bun.lock @@ -30,6 +30,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^20", + "@types/pg": "^8.16.0", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", @@ -496,6 +497,8 @@ "@types/node": ["@types/node@20.19.32", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Ez8QE4DMfhjjTsES9K2dwfV258qBui7qxUsoaixZDiTzbde4U12e1pXGNu/ECsUIOi5/zoCxAQxIhQnaUQ2VvA=="], + "@types/pg": ["@types/pg@8.16.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ=="], + "@types/react": ["@types/react@19.2.13", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], diff --git a/lib/validation.ts b/lib/validation.ts new file mode 100644 index 0000000..4871aed --- /dev/null +++ b/lib/validation.ts @@ -0,0 +1,38 @@ +/** + * Validates that a URL uses only safe schemes (http/https) + * Prevents javascript:, data:, and other potentially dangerous URI schemes + */ +export function isValidHttpUrl(urlString: string): boolean { + try { + const url = new URL(urlString); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +} + +/** + * Validates a URL and returns an error message if invalid + */ +export function validateUrl(urlString: string, fieldName: string = 'URL'): string | null { + if (!urlString || typeof urlString !== 'string') { + return `${fieldName} is required`; + } + + if (!isValidHttpUrl(urlString)) { + return `${fieldName} must be a valid HTTP or HTTPS URL`; + } + + return null; +} + +/** + * Validates an optional URL - returns null if empty/undefined, error if invalid + */ +export function validateOptionalUrl(urlString: string | null | undefined, fieldName: string = 'URL'): string | null { + if (!urlString) { + return null; // Optional URLs can be empty + } + + return validateUrl(urlString, fieldName); +} diff --git a/package.json b/package.json index 861d8f6..afaef4a 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^20", + "@types/pg": "^8.16.0", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..7fc0115 --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,287 @@ +import { PrismaClient, ProjectVisibility, ProjectMemberRole, SharePermission } from '@prisma/client'; +import { PrismaPg } from '@prisma/adapter-pg'; +import pg from 'pg'; + +const { Pool } = pg; + +// Create a direct Prisma client for seeding +const connectionString = process.env.DATABASE_URL || ''; +const pool = new Pool({ connectionString }); +const adapter = new PrismaPg(pool); + +const prisma = new PrismaClient({ adapter }); + +async function main() { + console.log('🌱 Seeding database...'); + + // Clean up existing data (in reverse order of dependencies) + await prisma.comment.deleteMany(); + await prisma.videoVersion.deleteMany(); + await prisma.video.deleteMany(); + await prisma.shareLink.deleteMany(); + await prisma.projectMember.deleteMany(); + await prisma.project.deleteMany(); + await prisma.session.deleteMany(); + await prisma.account.deleteMany(); + await prisma.verificationToken.deleteMany(); + await prisma.user.deleteMany(); + + console.log('✓ Cleaned existing data'); + + // Create demo users + const demoUser = await prisma.user.create({ + data: { + id: 'demo-user-001', + name: 'Yusuf İpek', + email: 'yusuf@openframe.dev', + image: 'https://avatars.githubusercontent.com/u/12345678', + }, + }); + + const collaborator = await prisma.user.create({ + data: { + id: 'demo-user-002', + name: 'Ahmet Editör', + email: 'ahmet@example.com', + image: 'https://avatars.githubusercontent.com/u/87654321', + }, + }); + + const reviewer = await prisma.user.create({ + data: { + id: 'demo-user-003', + name: 'Elif Reviewer', + email: 'elif@example.com', + }, + }); + + console.log('✓ Created 3 demo users'); + + // Create projects + const techProject = await prisma.project.create({ + data: { + name: 'Tech Review Series', + description: 'Weekly tech reviews and tutorials for the YouTube channel. Each video goes through multiple review cycles.', + slug: 'tech-review-series', + visibility: ProjectVisibility.PRIVATE, + ownerId: demoUser.id, + members: { + create: [ + { userId: collaborator.id, role: ProjectMemberRole.EDITOR }, + { userId: reviewer.id, role: ProjectMemberRole.VIEWER }, + ], + }, + }, + }); + + const tutorialProject = await prisma.project.create({ + data: { + name: 'Programming Tutorials', + description: 'In-depth programming tutorials covering modern web development.', + slug: 'programming-tutorials', + visibility: ProjectVisibility.LINK_ONLY, + ownerId: demoUser.id, + }, + }); + + const clientProject = await prisma.project.create({ + data: { + name: 'Client: XYZ Corp Promo', + description: 'Promotional video for XYZ Corporation product launch.', + slug: 'xyz-corp-promo', + visibility: ProjectVisibility.PRIVATE, + ownerId: collaborator.id, + members: { + create: [{ userId: demoUser.id, role: ProjectMemberRole.ADMIN }], + }, + }, + }); + + console.log('✓ Created 3 projects'); + + // Create videos with versions for Tech Review project + const techReviewVideo = await prisma.video.create({ + data: { + title: 'M4 MacBook Pro Review', + description: 'Complete review of the new M4 MacBook Pro lineup.', + position: 0, + projectId: techProject.id, + versions: { + create: [ + { + versionNumber: 1, + versionLabel: 'First Draft', + providerId: 'youtube', + videoId: 'dQw4w9WgXcQ', // Placeholder + originalUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + title: 'M4 MacBook Pro - First Look', + thumbnailUrl: 'https://img.youtube.com/vi/dQw4w9WgXcQ/maxresdefault.jpg', + duration: 1245, + isActive: false, + }, + { + versionNumber: 2, + versionLabel: 'Updated Intro', + providerId: 'youtube', + videoId: 'L_jWHffIx5E', // Placeholder + originalUrl: 'https://www.youtube.com/watch?v=L_jWHffIx5E', + title: 'M4 MacBook Pro Review - V2', + thumbnailUrl: 'https://img.youtube.com/vi/L_jWHffIx5E/maxresdefault.jpg', + duration: 1312, + isActive: true, + }, + ], + }, + }, + }); + + const aiToolsVideo = await prisma.video.create({ + data: { + title: 'Best AI Tools for Developers 2025', + description: 'A curated list of AI tools that actually boost productivity.', + position: 1, + projectId: techProject.id, + versions: { + create: [ + { + versionNumber: 1, + versionLabel: 'Initial Cut', + providerId: 'youtube', + videoId: 'jNQXAC9IVRw', + originalUrl: 'https://www.youtube.com/watch?v=jNQXAC9IVRw', + title: 'AI Tools for Devs', + thumbnailUrl: 'https://img.youtube.com/vi/jNQXAC9IVRw/maxresdefault.jpg', + duration: 892, + isActive: true, + }, + ], + }, + }, + }); + + // Tutorial video + const reactVideo = await prisma.video.create({ + data: { + title: 'React Server Components Deep Dive', + description: 'Understanding RSC from first principles.', + position: 0, + projectId: tutorialProject.id, + versions: { + create: [ + { + versionNumber: 1, + providerId: 'youtube', + videoId: 'y8AwLxn42HU', + originalUrl: 'https://www.youtube.com/watch?v=y8AwLxn42HU', + title: 'React Server Components', + thumbnailUrl: 'https://img.youtube.com/vi/y8AwLxn42HU/maxresdefault.jpg', + duration: 2156, + isActive: true, + }, + ], + }, + }, + }); + + console.log('✓ Created 3 videos with 4 versions total'); + + // Get version IDs for comments + const macbookVersions = await prisma.videoVersion.findMany({ + where: { videoParentId: techReviewVideo.id }, + orderBy: { versionNumber: 'desc' }, + }); + + const activeVersion = macbookVersions[0]; // V2 + + // Create comments (some threaded) + const comment1 = await prisma.comment.create({ + data: { + content: 'The intro is too long. Can we cut it down to 15 seconds max?', + timestamp: 0, + timestampEnd: 32, + authorId: collaborator.id, + versionId: activeVersion.id, + }, + }); + + await prisma.comment.create({ + data: { + content: 'Agreed. I\'ll trim the first section and jump straight to the unboxing.', + timestamp: 0, + parentId: comment1.id, + authorId: demoUser.id, + versionId: activeVersion.id, + }, + }); + + await prisma.comment.create({ + data: { + content: 'Great B-roll here! Maybe add some slow-mo for the product shots?', + timestamp: 145.5, + authorId: reviewer.id, + versionId: activeVersion.id, + }, + }); + + await prisma.comment.create({ + data: { + content: 'Audio levels drop significantly here. Check the lavalier mic.', + timestamp: 423, + authorId: collaborator.id, + versionId: activeVersion.id, + isResolved: true, + resolvedAt: new Date(), + }, + }); + + await prisma.comment.create({ + data: { + content: 'Can you add a sponsor segment transition here?', + timestamp: 612, + timestampEnd: 615, + authorId: demoUser.id, + versionId: activeVersion.id, + }, + }); + + // Guest comment + await prisma.comment.create({ + data: { + content: 'Love the editing style! When will this be published?', + timestamp: 800, + guestName: 'Client Viewer', + guestEmail: 'client@company.com', + versionId: activeVersion.id, + }, + }); + + console.log('✓ Created 6 comments (including threaded replies and guest comment)'); + + // Create share link + await prisma.shareLink.create({ + data: { + token: 'review-abc123xyz', + projectId: techProject.id, + permission: SharePermission.COMMENT, + allowGuests: true, + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days + }, + }); + + console.log('✓ Created share link'); + + console.log('\n✅ Seeding complete!\n'); + console.log('Demo accounts:'); + console.log(' - yusuf@openframe.dev (Owner)'); + console.log(' - ahmet@example.com (Editor)'); + console.log(' - elif@example.com (Viewer)'); +} + +main() + .catch((e) => { + console.error('❌ Seed failed:', e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + });