mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
feat: Implement core API routes for managing projects, videos, versions, and comments, including database seeding.
This commit is contained in:
@@ -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<string, unknown> = {};
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, unknown> = {};
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, unknown> = {};
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/pg": "^8.16.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
@@ -496,6 +497,8 @@
|
|||||||
|
|
||||||
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Ez8QE4DMfhjjTsES9K2dwfV258qBui7qxUsoaixZDiTzbde4U12e1pXGNu/ECsUIOi5/zoCxAQxIhQnaUQ2VvA=="],
|
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Ez8QE4DMfhjjTsES9K2dwfV258qBui7qxUsoaixZDiTzbde4U12e1pXGNu/ECsUIOi5/zoCxAQxIhQnaUQ2VvA=="],
|
||||||
|
|
||||||
|
"@types/pg": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ=="],
|
||||||
|
|
||||||
"@types/react": ["@types/[email protected]", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ=="],
|
"@types/react": ["@types/[email protected]", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ=="],
|
||||||
|
|
||||||
"@types/react-dom": ["@types/[email protected]", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
"@types/react-dom": ["@types/[email protected]", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/pg": "^8.16.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
|
|||||||
+287
@@ -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: '[email protected]',
|
||||||
|
image: 'https://avatars.githubusercontent.com/u/12345678',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const collaborator = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
id: 'demo-user-002',
|
||||||
|
name: 'Ahmet Editör',
|
||||||
|
email: '[email protected]',
|
||||||
|
image: 'https://avatars.githubusercontent.com/u/87654321',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const reviewer = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
id: 'demo-user-003',
|
||||||
|
name: 'Elif Reviewer',
|
||||||
|
email: '[email protected]',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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: '[email protected]',
|
||||||
|
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(' - [email protected] (Owner)');
|
||||||
|
console.log(' - [email protected] (Editor)');
|
||||||
|
console.log(' - [email protected] (Viewer)');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('❌ Seed failed:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user