refactor: eslint and prettier conflict will be resolved and formatted

This commit is contained in:
Enes Köksal
2026-04-23 17:05:43 +03:00
parent 385b61f29b
commit 3cfea40fbd
219 changed files with 16638 additions and 13663 deletions
@@ -10,114 +10,114 @@ type RouteParams = { params: Promise<{ projectId: string; memberId: string }> };
// PATCH /api/projects/[projectId]/members/[memberId] - Update member role
export async function PATCH(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'manage-member');
if (limited) return limited;
try {
const limited = await rateLimit(request, 'manage-member');
if (limited) return limited;
const session = await auth();
const { projectId, memberId } = await params;
const session = await auth();
const { projectId, memberId } = await params;
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const project = await db.project.findUnique({
where: { id: projectId },
include: { members: { where: { userId: session.user.id } } },
});
if (!project) {
return apiErrors.notFound('Project');
}
const access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
const isOwner = project.ownerId === session.user.id;
const isAdmin = project.members[0]?.role === ProjectMemberRole.ADMIN;
if (!access.canEdit || (!isOwner && !isAdmin)) {
return apiErrors.forbidden('Access denied');
}
const body = await request.json();
const { role } = body;
const validRoles = ['ADMIN', 'COMMENTATOR'];
if (!validRoles.includes(role)) {
return apiErrors.badRequest('Invalid role. Must be ADMIN or COMMENTATOR.');
}
const member = await db.projectMember.findFirst({
where: { id: memberId, projectId },
select: { id: true },
});
if (!member) {
return apiErrors.notFound('Member');
}
const updatedMember = await db.projectMember.update({
where: { id: member.id },
data: { role: role as ProjectMemberRole },
include: {
user: { select: { id: true, name: true, image: true } },
},
});
const response = successResponse(updatedMember);
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error updating member role:', error);
return apiErrors.internalError('Failed to update member role');
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const project = await db.project.findUnique({
where: { id: projectId },
include: { members: { where: { userId: session.user.id } } },
});
if (!project) {
return apiErrors.notFound('Project');
}
const access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
const isOwner = project.ownerId === session.user.id;
const isAdmin = project.members[0]?.role === ProjectMemberRole.ADMIN;
if (!access.canEdit || (!isOwner && !isAdmin)) {
return apiErrors.forbidden('Access denied');
}
const body = await request.json();
const { role } = body;
const validRoles = ['ADMIN', 'COMMENTATOR'];
if (!validRoles.includes(role)) {
return apiErrors.badRequest('Invalid role. Must be ADMIN or COMMENTATOR.');
}
const member = await db.projectMember.findFirst({
where: { id: memberId, projectId },
select: { id: true },
});
if (!member) {
return apiErrors.notFound('Member');
}
const updatedMember = await db.projectMember.update({
where: { id: member.id },
data: { role: role as ProjectMemberRole },
include: {
user: { select: { id: true, name: true, image: true } },
},
});
const response = successResponse(updatedMember);
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error updating member role:', error);
return apiErrors.internalError('Failed to update member role');
}
}
// DELETE /api/projects/[projectId]/members/[memberId] - Remove member
export async function DELETE(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'manage-member');
if (limited) return limited;
try {
const limited = await rateLimit(request, 'manage-member');
if (limited) return limited;
const session = await auth();
const { projectId, memberId } = await params;
const session = await auth();
const { projectId, memberId } = await params;
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const project = await db.project.findUnique({
where: { id: projectId },
include: { members: { where: { userId: session.user.id } } },
});
if (!project) {
return apiErrors.notFound('Project');
}
const access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
const isOwner = project.ownerId === session.user.id;
const isAdmin = project.members[0]?.role === ProjectMemberRole.ADMIN;
const memberToRemove = await db.projectMember.findFirst({
where: { id: memberId, projectId },
select: { id: true, userId: true },
});
if (!memberToRemove) {
return apiErrors.notFound('Member');
}
const isSelf = memberToRemove.userId === session.user.id;
if ((!access.canEdit || (!isOwner && !isAdmin)) && !isSelf) {
return apiErrors.forbidden('Access denied');
}
await db.projectMember.delete({ where: { id: memberToRemove.id } });
const response = successResponse({ message: 'Member removed' });
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error removing member:', error);
return apiErrors.internalError('Failed to remove member');
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const project = await db.project.findUnique({
where: { id: projectId },
include: { members: { where: { userId: session.user.id } } },
});
if (!project) {
return apiErrors.notFound('Project');
}
const access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
const isOwner = project.ownerId === session.user.id;
const isAdmin = project.members[0]?.role === ProjectMemberRole.ADMIN;
const memberToRemove = await db.projectMember.findFirst({
where: { id: memberId, projectId },
select: { id: true, userId: true },
});
if (!memberToRemove) {
return apiErrors.notFound('Member');
}
const isSelf = memberToRemove.userId === session.user.id;
if ((!access.canEdit || (!isOwner && !isAdmin)) && !isSelf) {
return apiErrors.forbidden('Access denied');
}
await db.projectMember.delete({ where: { id: memberToRemove.id } });
const response = successResponse({ message: 'Member removed' });
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error removing member:', error);
return apiErrors.internalError('Failed to remove member');
}
}
+161 -157
View File
@@ -3,7 +3,11 @@ import { db } from '@/lib/db';
import { auth, checkProjectAccess } from '@/lib/auth';
import { InvitationRole, ProjectMemberRole } from '@prisma/client';
import { rateLimit } from '@/lib/rate-limit';
import { buildInvitationUrl, createOrRefreshInvitation, sendInvitationEmail } from '@/lib/invitations';
import {
buildInvitationUrl,
createOrRefreshInvitation,
sendInvitationEmail,
} from '@/lib/invitations';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { logError } from '@/lib/logger';
@@ -11,169 +15,169 @@ type RouteParams = { params: Promise<{ projectId: string }> };
// GET /api/projects/[projectId]/members - List members
export async function GET(request: NextRequest, { params }: RouteParams) {
try {
const session = await auth();
const { projectId } = await params;
try {
const session = await auth();
const { projectId } = await params;
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const project = await db.project.findUnique({
where: { id: projectId },
include: {
members: { where: { userId: session.user.id } },
},
});
if (!project) {
return apiErrors.notFound('Project');
}
const access = await checkProjectAccess(project, session.user.id);
const isOwner = project.ownerId === session.user.id;
const isMember = project.members.length > 0;
const isAdmin = project.members[0]?.role === ProjectMemberRole.ADMIN;
if (!access.hasAccess || (!isOwner && !isMember)) {
return apiErrors.forbidden('Access denied');
}
const now = new Date();
const canViewPendingInvitations = isOwner || isAdmin;
const [members, owner, pendingInvitations] = await Promise.all([
db.projectMember.findMany({
where: { projectId },
include: {
user: { select: { id: true, name: true, email: true, image: true } },
},
orderBy: { createdAt: 'asc' },
}),
db.user.findUnique({
where: { id: project.ownerId },
select: { id: true, name: true, email: true, image: true },
}),
canViewPendingInvitations
? db.invitation.findMany({
where: {
projectId,
scope: 'PROJECT',
status: 'PENDING',
expiresAt: { gt: now },
},
select: {
id: true,
email: true,
role: true,
createdAt: true,
expiresAt: true,
invitedBy: {
select: { id: true, name: true, email: true },
},
},
orderBy: { createdAt: 'desc' },
})
: Promise.resolve([]),
]);
const response = successResponse({ members, owner, pendingInvitations });
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error fetching project members:', error);
return apiErrors.internalError('Failed to fetch members');
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const project = await db.project.findUnique({
where: { id: projectId },
include: {
members: { where: { userId: session.user.id } },
},
});
if (!project) {
return apiErrors.notFound('Project');
}
const access = await checkProjectAccess(project, session.user.id);
const isOwner = project.ownerId === session.user.id;
const isMember = project.members.length > 0;
const isAdmin = project.members[0]?.role === ProjectMemberRole.ADMIN;
if (!access.hasAccess || (!isOwner && !isMember)) {
return apiErrors.forbidden('Access denied');
}
const now = new Date();
const canViewPendingInvitations = isOwner || isAdmin;
const [members, owner, pendingInvitations] = await Promise.all([
db.projectMember.findMany({
where: { projectId },
include: {
user: { select: { id: true, name: true, email: true, image: true } },
},
orderBy: { createdAt: 'asc' },
}),
db.user.findUnique({
where: { id: project.ownerId },
select: { id: true, name: true, email: true, image: true },
}),
canViewPendingInvitations
? db.invitation.findMany({
where: {
projectId,
scope: 'PROJECT',
status: 'PENDING',
expiresAt: { gt: now },
},
select: {
id: true,
email: true,
role: true,
createdAt: true,
expiresAt: true,
invitedBy: {
select: { id: true, name: true, email: true },
},
},
orderBy: { createdAt: 'desc' },
})
: Promise.resolve([]),
]);
const response = successResponse({ members, owner, pendingInvitations });
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error fetching project members:', error);
return apiErrors.internalError('Failed to fetch members');
}
}
// POST /api/projects/[projectId]/members - Invite a member
export async function POST(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'invite-member');
if (limited) return limited;
try {
const limited = await rateLimit(request, 'invite-member');
if (limited) return limited;
const session = await auth();
const { projectId } = await params;
const session = await auth();
const { projectId } = await params;
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
// Check if user is owner or admin
const project = await db.project.findUnique({
where: { id: projectId },
include: { members: { where: { userId: session.user.id } } },
});
if (!project) {
return apiErrors.notFound('Project');
}
const access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
const isOwner = project.ownerId === session.user.id;
const isAdmin = project.members[0]?.role === ProjectMemberRole.ADMIN;
if (!access.canEdit || (!isOwner && !isAdmin)) {
return apiErrors.forbidden('Only project owners and admins can invite members');
}
const body = await request.json();
const { email, role } = body;
if (!email || typeof email !== 'string') {
return apiErrors.badRequest('Email is required');
}
const normalizedEmail = email.toLowerCase().trim();
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(normalizedEmail)) {
return apiErrors.validationError('Invalid email format');
}
// Validate role
const validRoles = ['ADMIN', 'COMMENTATOR'];
const memberRole = validRoles.includes(role) ? role : 'COMMENTATOR';
// If this email belongs to an existing user, validate owner/member conflicts.
const userToInvite = await db.user.findUnique({
where: { email: normalizedEmail },
select: { id: true },
});
if (userToInvite?.id === project.ownerId) {
return apiErrors.badRequest('Cannot invite the project owner as a member');
}
if (userToInvite) {
const existingMember = await db.projectMember.findUnique({
where: { projectId_userId: { projectId, userId: userToInvite.id } },
});
if (existingMember) {
return apiErrors.conflict('User is already a member of this project');
}
}
const invitation = await createOrRefreshInvitation({
email: normalizedEmail,
scope: 'PROJECT',
role: memberRole as InvitationRole,
invitedById: session.user.id,
projectId,
});
const invitationUrl = buildInvitationUrl(invitation.token);
void sendInvitationEmail({
to: normalizedEmail,
inviterName: session.user.name || 'A team member',
role: invitation.role,
scope: invitation.scope,
targetName: project.name,
invitationUrl,
});
const response = successResponse({ message: 'Invitation email sent.' });
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error inviting project member:', error);
return apiErrors.internalError('Failed to invite member');
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
// Check if user is owner or admin
const project = await db.project.findUnique({
where: { id: projectId },
include: { members: { where: { userId: session.user.id } } },
});
if (!project) {
return apiErrors.notFound('Project');
}
const access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
const isOwner = project.ownerId === session.user.id;
const isAdmin = project.members[0]?.role === ProjectMemberRole.ADMIN;
if (!access.canEdit || (!isOwner && !isAdmin)) {
return apiErrors.forbidden('Only project owners and admins can invite members');
}
const body = await request.json();
const { email, role } = body;
if (!email || typeof email !== 'string') {
return apiErrors.badRequest('Email is required');
}
const normalizedEmail = email.toLowerCase().trim();
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(normalizedEmail)) {
return apiErrors.validationError('Invalid email format');
}
// Validate role
const validRoles = ['ADMIN', 'COMMENTATOR'];
const memberRole = validRoles.includes(role) ? role : 'COMMENTATOR';
// If this email belongs to an existing user, validate owner/member conflicts.
const userToInvite = await db.user.findUnique({
where: { email: normalizedEmail },
select: { id: true },
});
if (userToInvite?.id === project.ownerId) {
return apiErrors.badRequest('Cannot invite the project owner as a member');
}
if (userToInvite) {
const existingMember = await db.projectMember.findUnique({
where: { projectId_userId: { projectId, userId: userToInvite.id } },
});
if (existingMember) {
return apiErrors.conflict('User is already a member of this project');
}
}
const invitation = await createOrRefreshInvitation({
email: normalizedEmail,
scope: 'PROJECT',
role: memberRole as InvitationRole,
invitedById: session.user.id,
projectId,
});
const invitationUrl = buildInvitationUrl(invitation.token);
void sendInvitationEmail({
to: normalizedEmail,
inviterName: session.user.name || 'A team member',
role: invitation.role,
scope: invitation.scope,
targetName: project.name,
invitationUrl,
});
const response = successResponse({ message: 'Invitation email sent.' });
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error inviting project member:', error);
return apiErrors.internalError('Failed to invite member');
}
}
+209 -209
View File
@@ -12,230 +12,230 @@ type RouteParams = { params: Promise<{ projectId: string }> };
// 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 MAX_LIMIT = 100;
const MAX_OFFSET = 10000;
try {
const session = await auth();
const { projectId } = await params;
const MAX_LIMIT = 100;
const MAX_OFFSET = 10000;
// Parse pagination params
const searchParams = request.nextUrl.searchParams;
const limitParam = searchParams.get('limit');
const offsetParam = searchParams.get('offset');
// Parse pagination params
const searchParams = request.nextUrl.searchParams;
const limitParam = searchParams.get('limit');
const offsetParam = searchParams.get('offset');
const limitRaw = limitParam === null ? 20 : Number(limitParam);
if (!Number.isSafeInteger(limitRaw) || limitRaw < 1 || limitRaw > MAX_LIMIT) {
return apiErrors.badRequest('Invalid limit. Must be a positive integer between 1 and 100.');
}
const offset = offsetParam === null ? 0 : Number(offsetParam);
if (!Number.isSafeInteger(offset) || offset < 0 || offset > MAX_OFFSET) {
return apiErrors.badRequest('Invalid offset. Must be a non-negative integer up to 10000.');
}
const limit = limitRaw;
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' },
skip: offset,
take: limit,
include: {
versions: {
where: { isActive: true },
orderBy: { versionNumber: 'desc' },
take: 1,
select: {
id: true,
thumbnailUrl: true,
duration: true,
versionNumber: true,
_count: { select: { comments: true } },
},
},
_count: { select: { versions: true } },
},
},
_count: { select: { videos: true, members: true, shareLinks: true } },
},
});
if (!project) {
return apiErrors.notFound('Project');
}
const access = await checkProjectAccess(project, session?.user?.id);
if (!access.hasAccess) {
return apiErrors.forbidden('Access denied');
}
const response = successResponse(project);
return withCacheControl(response, 'private, max-age=30, stale-while-revalidate=60');
} catch (error) {
logError('Error fetching project:', error);
return apiErrors.internalError('Failed to fetch project');
const limitRaw = limitParam === null ? 20 : Number(limitParam);
if (!Number.isSafeInteger(limitRaw) || limitRaw < 1 || limitRaw > MAX_LIMIT) {
return apiErrors.badRequest('Invalid limit. Must be a positive integer between 1 and 100.');
}
const offset = offsetParam === null ? 0 : Number(offsetParam);
if (!Number.isSafeInteger(offset) || offset < 0 || offset > MAX_OFFSET) {
return apiErrors.badRequest('Invalid offset. Must be a non-negative integer up to 10000.');
}
const limit = limitRaw;
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' },
skip: offset,
take: limit,
include: {
versions: {
where: { isActive: true },
orderBy: { versionNumber: 'desc' },
take: 1,
select: {
id: true,
thumbnailUrl: true,
duration: true,
versionNumber: true,
_count: { select: { comments: true } },
},
},
_count: { select: { versions: true } },
},
},
_count: { select: { videos: true, members: true, shareLinks: true } },
},
});
if (!project) {
return apiErrors.notFound('Project');
}
const access = await checkProjectAccess(project, session?.user?.id);
if (!access.hasAccess) {
return apiErrors.forbidden('Access denied');
}
const response = successResponse(project);
return withCacheControl(response, 'private, max-age=30, stale-while-revalidate=60');
} catch (error) {
logError('Error fetching project:', error);
return apiErrors.internalError('Failed to fetch project');
}
}
// PATCH /api/projects/[projectId] - Update a project
export async function PATCH(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
const session = await auth();
const { projectId } = await params;
const session = await auth();
const { projectId } = await params;
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const projectAccessTarget = await db.project.findUnique({
where: { id: projectId },
select: { id: true, ownerId: true, workspaceId: true, visibility: true },
});
const access = projectAccessTarget
? await checkProjectAccess(projectAccessTarget, session.user.id, { intent: 'manage' })
: null;
if (!access?.canEdit) {
return apiErrors.forbidden('Access denied');
}
const body = await request.json();
const { name, description, visibility } = body;
if (name !== undefined) {
if (typeof name !== 'string' || name.trim().length === 0) {
return apiErrors.badRequest('Name must be a non-empty string');
}
if (name.trim().length > 100) {
return apiErrors.badRequest('Name must be 100 characters or fewer');
}
}
if (description !== undefined && description !== null) {
if (typeof description !== 'string') {
return apiErrors.badRequest('Description must be a string');
}
if (description.trim().length > 1000) {
return apiErrors.badRequest('Description must be 1000 characters or fewer');
}
}
const VALID_VISIBILITY = ['PRIVATE', 'INVITE', 'PUBLIC'] as const;
if (visibility !== undefined && !VALID_VISIBILITY.includes(visibility)) {
return apiErrors.badRequest('Invalid visibility value');
}
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 } },
},
});
const response = successResponse(project);
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error updating project:', error);
return apiErrors.internalError('Failed to update project');
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const projectAccessTarget = await db.project.findUnique({
where: { id: projectId },
select: { id: true, ownerId: true, workspaceId: true, visibility: true },
});
const access = projectAccessTarget
? await checkProjectAccess(projectAccessTarget, session.user.id, { intent: 'manage' })
: null;
if (!access?.canEdit) {
return apiErrors.forbidden('Access denied');
}
const body = await request.json();
const { name, description, visibility } = body;
if (name !== undefined) {
if (typeof name !== 'string' || name.trim().length === 0) {
return apiErrors.badRequest('Name must be a non-empty string');
}
if (name.trim().length > 100) {
return apiErrors.badRequest('Name must be 100 characters or fewer');
}
}
if (description !== undefined && description !== null) {
if (typeof description !== 'string') {
return apiErrors.badRequest('Description must be a string');
}
if (description.trim().length > 1000) {
return apiErrors.badRequest('Description must be 1000 characters or fewer');
}
}
const VALID_VISIBILITY = ['PRIVATE', 'INVITE', 'PUBLIC'] as const;
if (visibility !== undefined && !VALID_VISIBILITY.includes(visibility)) {
return apiErrors.badRequest('Invalid visibility value');
}
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 } },
},
});
const response = successResponse(project);
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error updating project:', error);
return apiErrors.internalError('Failed to update project');
}
}
// DELETE /api/projects/[projectId] - Delete a project
export async function DELETE(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
const session = await auth();
const { projectId } = await params;
const session = await auth();
const { projectId } = await params;
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const project = await db.project.findUnique({
where: { id: projectId },
select: { id: true, ownerId: true, workspaceId: true, visibility: true },
});
if (!project) {
return apiErrors.notFound('Project');
}
const access = await checkProjectAccess(project, session.user.id, { intent: 'delete' });
if (!access.canDelete) {
return apiErrors.forbidden('Only the project owner can delete it');
}
const [projectVersionRefs, projectAssetRefs, mediaUrls] = await Promise.all([
db.videoVersion.findMany({
where: {
video: { projectId },
},
select: {
providerId: true,
videoId: true,
},
}),
db.videoAsset.findMany({
where: {
video: { projectId },
provider: 'BUNNY',
providerVideoId: { not: null },
},
select: {
providerVideoId: true,
},
}),
collectProjectMediaUrls(projectId),
]);
const bunnyRefs = [
...projectVersionRefs,
...projectAssetRefs.map((asset) => ({
providerId: 'bunny',
videoId: asset.providerVideoId as string,
})),
];
await db.project.delete({ where: { id: projectId } });
const [bunnyCleanupResult, r2CleanupResult] = await Promise.all([
cleanupBunnyStreamVideosBestEffort(bunnyRefs),
deleteMediaFilesBestEffort(mediaUrls),
]);
const cleanupInput = {
bunny: bunnyCleanupResult,
r2: r2CleanupResult,
};
const cleanupWarnings = buildCleanupWarnings(cleanupInput);
if (cleanupWarnings) {
logCleanupWarnings({ entityType: 'project', entityId: projectId }, cleanupInput);
}
const response = successResponse({
message: 'Project deleted',
...(cleanupWarnings ? { cleanupWarnings } : {}),
});
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error deleting project:', error);
return apiErrors.internalError('Failed to delete project');
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const project = await db.project.findUnique({
where: { id: projectId },
select: { id: true, ownerId: true, workspaceId: true, visibility: true },
});
if (!project) {
return apiErrors.notFound('Project');
}
const access = await checkProjectAccess(project, session.user.id, { intent: 'delete' });
if (!access.canDelete) {
return apiErrors.forbidden('Only the project owner can delete it');
}
const [projectVersionRefs, projectAssetRefs, mediaUrls] = await Promise.all([
db.videoVersion.findMany({
where: {
video: { projectId },
},
select: {
providerId: true,
videoId: true,
},
}),
db.videoAsset.findMany({
where: {
video: { projectId },
provider: 'BUNNY',
providerVideoId: { not: null },
},
select: {
providerVideoId: true,
},
}),
collectProjectMediaUrls(projectId),
]);
const bunnyRefs = [
...projectVersionRefs,
...projectAssetRefs.map((asset) => ({
providerId: 'bunny',
videoId: asset.providerVideoId as string,
})),
];
await db.project.delete({ where: { id: projectId } });
const [bunnyCleanupResult, r2CleanupResult] = await Promise.all([
cleanupBunnyStreamVideosBestEffort(bunnyRefs),
deleteMediaFilesBestEffort(mediaUrls),
]);
const cleanupInput = {
bunny: bunnyCleanupResult,
r2: r2CleanupResult,
};
const cleanupWarnings = buildCleanupWarnings(cleanupInput);
if (cleanupWarnings) {
logCleanupWarnings({ entityType: 'project', entityId: projectId }, cleanupInput);
}
const response = successResponse({
message: 'Project deleted',
...(cleanupWarnings ? { cleanupWarnings } : {}),
});
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error deleting project:', error);
return apiErrors.internalError('Failed to delete project');
}
}
+100 -100
View File
@@ -9,114 +9,114 @@ type RouteParams = { params: Promise<{ projectId: string; tagId: string }> };
// PATCH /api/projects/[projectId]/tags/[tagId] - Update a tag
export async function PATCH(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
const session = await auth();
const { projectId, tagId } = await params;
const session = await auth();
const { projectId, tagId } = await params;
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const project = await db.project.findUnique({
where: { id: projectId },
select: { id: true, ownerId: true, workspaceId: true, visibility: true },
});
if (!project) {
return apiErrors.notFound('Project');
}
const access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
if (!access.canEdit) {
return apiErrors.forbidden('Access denied');
}
// Verify tag belongs to this project
const existingTag = await db.commentTag.findUnique({
where: { id: tagId },
});
if (!existingTag || existingTag.projectId !== projectId) {
return apiErrors.notFound('Tag');
}
const body = await request.json();
const { name, color, position } = body;
const updateData: Record<string, unknown> = {};
if (name !== undefined) {
if (!name.trim()) {
return apiErrors.badRequest('Name cannot be empty');
}
updateData.name = name.trim();
}
if (color !== undefined) {
if (!/^#[0-9A-Fa-f]{6}$/.test(color)) {
return apiErrors.badRequest('Invalid color format');
}
updateData.color = color.toUpperCase();
}
if (position !== undefined) {
updateData.position = position;
}
const tag = await db.commentTag.update({
where: { id: tagId },
data: updateData,
});
const response = successResponse(tag);
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error updating tag:', error);
if ((error as { code?: string }).code === 'P2002') {
return apiErrors.conflict('Tag name already exists');
}
return apiErrors.internalError('Failed to update tag');
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const project = await db.project.findUnique({
where: { id: projectId },
select: { id: true, ownerId: true, workspaceId: true, visibility: true },
});
if (!project) {
return apiErrors.notFound('Project');
}
const access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
if (!access.canEdit) {
return apiErrors.forbidden('Access denied');
}
// Verify tag belongs to this project
const existingTag = await db.commentTag.findUnique({
where: { id: tagId },
});
if (!existingTag || existingTag.projectId !== projectId) {
return apiErrors.notFound('Tag');
}
const body = await request.json();
const { name, color, position } = body;
const updateData: Record<string, unknown> = {};
if (name !== undefined) {
if (!name.trim()) {
return apiErrors.badRequest('Name cannot be empty');
}
updateData.name = name.trim();
}
if (color !== undefined) {
if (!/^#[0-9A-Fa-f]{6}$/.test(color)) {
return apiErrors.badRequest('Invalid color format');
}
updateData.color = color.toUpperCase();
}
if (position !== undefined) {
updateData.position = position;
}
const tag = await db.commentTag.update({
where: { id: tagId },
data: updateData,
});
const response = successResponse(tag);
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error updating tag:', error);
if ((error as { code?: string }).code === 'P2002') {
return apiErrors.conflict('Tag name already exists');
}
return apiErrors.internalError('Failed to update tag');
}
}
// DELETE /api/projects/[projectId]/tags/[tagId] - Delete a tag
export async function DELETE(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
const session = await auth();
const { projectId, tagId } = await params;
const session = await auth();
const { projectId, tagId } = await params;
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const project = await db.project.findUnique({
where: { id: projectId },
select: { id: true, ownerId: true, workspaceId: true, visibility: true },
});
if (!project) {
return apiErrors.notFound('Project');
}
const access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
if (!access.canEdit) {
return apiErrors.forbidden('Access denied');
}
// Verify tag belongs to this project
const existingTag = await db.commentTag.findUnique({
where: { id: tagId },
});
if (!existingTag || existingTag.projectId !== projectId) {
return apiErrors.notFound('Tag');
}
await db.commentTag.delete({ where: { id: tagId } });
const response = successResponse({ message: 'Tag deleted' });
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error deleting tag:', error);
return apiErrors.internalError('Failed to delete tag');
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const project = await db.project.findUnique({
where: { id: projectId },
select: { id: true, ownerId: true, workspaceId: true, visibility: true },
});
if (!project) {
return apiErrors.notFound('Project');
}
const access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
if (!access.canEdit) {
return apiErrors.forbidden('Access denied');
}
// Verify tag belongs to this project
const existingTag = await db.commentTag.findUnique({
where: { id: tagId },
});
if (!existingTag || existingTag.projectId !== projectId) {
return apiErrors.notFound('Tag');
}
await db.commentTag.delete({ where: { id: tagId } });
const response = successResponse({ message: 'Tag deleted' });
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error deleting tag:', error);
return apiErrors.internalError('Failed to delete tag');
}
}
+115 -109
View File
@@ -11,125 +11,131 @@ type RouteParams = { params: Promise<{ projectId: string }> };
// GET /api/projects/[projectId]/tags - Get all tags for a project
export async function GET(request: NextRequest, { params }: RouteParams) {
try {
const session = await auth();
const { projectId } = await params;
const videoId = request.nextUrl.searchParams.get('videoId');
try {
const session = await auth();
const { projectId } = await params;
const videoId = request.nextUrl.searchParams.get('videoId');
const project = await db.project.findUnique({
where: { id: projectId },
select: { id: true, ownerId: true, workspaceId: true, visibility: true },
const project = await db.project.findUnique({
where: { id: projectId },
select: { id: true, ownerId: true, workspaceId: true, visibility: true },
});
if (!project) return apiErrors.notFound('Project');
if (session?.user?.id) {
const access = await checkProjectAccess(project, session.user.id);
if (!access.hasAccess) {
return apiErrors.notFound('Project');
}
} else {
let hasGuestAccess = project.visibility === 'PUBLIC';
if (!hasGuestAccess && videoId) {
const video = await db.video.findFirst({
where: { id: videoId, projectId },
select: { id: true },
});
if (!project) return apiErrors.notFound('Project');
if (session?.user?.id) {
const access = await checkProjectAccess(project, session.user.id);
if (!access.hasAccess) {
return apiErrors.notFound('Project');
}
} else {
let hasGuestAccess = project.visibility === 'PUBLIC';
if (!hasGuestAccess && videoId) {
const video = await db.video.findFirst({
where: { id: videoId, projectId },
select: { id: true },
});
if (video) {
const shareSession = getShareSessionFromRequest(request, video.id);
const shareAccess = shareSession
? await validateShareLinkAccess({
token: shareSession.token,
projectId,
videoId: video.id,
requiredPermission: 'COMMENT',
passwordVerified: shareSession.passwordVerified,
})
: { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false };
hasGuestAccess = shareAccess.canComment && shareAccess.allowGuests;
}
}
if (!hasGuestAccess) {
return apiErrors.forbidden('Access denied');
}
if (video) {
const shareSession = getShareSessionFromRequest(request, video.id);
const shareAccess = shareSession
? await validateShareLinkAccess({
token: shareSession.token,
projectId,
videoId: video.id,
requiredPermission: 'COMMENT',
passwordVerified: shareSession.passwordVerified,
})
: {
hasAccess: false,
canComment: false,
canDownload: false,
allowGuests: false,
requiresPassword: false,
};
hasGuestAccess = shareAccess.canComment && shareAccess.allowGuests;
}
}
const tags = await db.commentTag.findMany({
where: { projectId },
orderBy: { position: 'asc' },
});
const response = successResponse(tags);
const cacheControl = session?.user?.id
? 'private, max-age=120, stale-while-revalidate=300'
: 'private, no-cache';
return withCacheControl(response, cacheControl);
} catch (error) {
logError('Error fetching tags:', error);
return apiErrors.internalError('Failed to fetch tags');
if (!hasGuestAccess) {
return apiErrors.forbidden('Access denied');
}
}
const tags = await db.commentTag.findMany({
where: { projectId },
orderBy: { position: 'asc' },
});
const response = successResponse(tags);
const cacheControl = session?.user?.id
? 'private, max-age=120, stale-while-revalidate=300'
: 'private, no-cache';
return withCacheControl(response, cacheControl);
} catch (error) {
logError('Error fetching tags:', error);
return apiErrors.internalError('Failed to fetch tags');
}
}
// POST /api/projects/[projectId]/tags - Create a new tag
export async function POST(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
const session = await auth();
const { projectId } = await params;
const session = await auth();
const { projectId } = await params;
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const project = await db.project.findUnique({
where: { id: projectId },
select: { id: true, ownerId: true, workspaceId: true, visibility: true },
});
if (!project) {
return apiErrors.notFound('Project');
}
const access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
if (!access.canEdit) {
return apiErrors.forbidden('Access denied');
}
const body = await request.json();
const { name, color } = body;
if (!name?.trim() || !color?.trim()) {
return apiErrors.badRequest('Name and color are required');
}
// Hex color validation
if (!/^#[0-9A-Fa-f]{6}$/.test(color)) {
return apiErrors.badRequest('Invalid color format');
}
// Get max position
const maxPos = await db.commentTag.aggregate({
where: { projectId },
_max: { position: true },
});
const tag = await db.commentTag.create({
data: {
name: name.trim(),
color: color.toUpperCase(),
position: (maxPos._max.position ?? -1) + 1,
projectId,
},
});
const response = successResponse(tag, 201);
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error creating tag:', error);
if ((error as { code?: string }).code === 'P2002') {
return apiErrors.conflict('Tag name already exists');
}
return apiErrors.internalError('Failed to create tag');
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const project = await db.project.findUnique({
where: { id: projectId },
select: { id: true, ownerId: true, workspaceId: true, visibility: true },
});
if (!project) {
return apiErrors.notFound('Project');
}
const access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
if (!access.canEdit) {
return apiErrors.forbidden('Access denied');
}
const body = await request.json();
const { name, color } = body;
if (!name?.trim() || !color?.trim()) {
return apiErrors.badRequest('Name and color are required');
}
// Hex color validation
if (!/^#[0-9A-Fa-f]{6}$/.test(color)) {
return apiErrors.badRequest('Invalid color format');
}
// Get max position
const maxPos = await db.commentTag.aggregate({
where: { projectId },
_max: { position: true },
});
const tag = await db.commentTag.create({
data: {
name: name.trim(),
color: color.toUpperCase(),
position: (maxPos._max.position ?? -1) + 1,
projectId,
},
});
const response = successResponse(tag, 201);
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error creating tag:', error);
if ((error as { code?: string }).code === 'P2002') {
return apiErrors.conflict('Tag name already exists');
}
return apiErrors.internalError('Failed to create tag');
}
}
@@ -13,272 +13,276 @@ 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;
try {
const session = await auth();
const { projectId, videoId } = await params;
// Parse query params for pagination and options
const searchParams = request.nextUrl.searchParams;
const includeComments = searchParams.get('includeComments') !== 'false';
const commentLimit = Math.min(parseInt(searchParams.get('commentLimit') || '50'), 100);
const commentOffset = Math.max(0, parseInt(searchParams.get('commentOffset') || '0'));
const includeReplies = searchParams.get('includeReplies') === 'true';
// Parse query params for pagination and options
const searchParams = request.nextUrl.searchParams;
const includeComments = searchParams.get('includeComments') !== 'false';
const commentLimit = Math.min(parseInt(searchParams.get('commentLimit') || '50'), 100);
const commentOffset = Math.max(0, parseInt(searchParams.get('commentOffset') || '0'));
const includeReplies = searchParams.get('includeReplies') === 'true';
const video = await db.video.findFirst({
where: { id: videoId, projectId },
include: {
project: true,
versions: {
orderBy: { versionNumber: 'desc' },
...(includeComments ? {
include: {
comments: {
orderBy: { timestamp: 'asc' },
skip: commentOffset,
take: commentLimit,
select: {
id: true,
content: true,
timestamp: true,
timestampEnd: true,
createdAt: true,
updatedAt: true,
isResolved: true,
resolvedAt: true,
voiceUrl: true,
voiceDuration: true,
imageUrl: true,
annotationData: true,
parentId: true,
authorId: true,
tagId: true,
versionId: true,
guestName: true,
// guestEmail excluded for privacy
author: { select: { id: true, name: true, image: true } },
tag: { select: { id: true, name: true, color: true } },
...(includeReplies ? {
replies: {
orderBy: { createdAt: 'asc' },
select: {
id: true,
content: true,
timestamp: true,
timestampEnd: true,
createdAt: true,
updatedAt: true,
isResolved: true,
resolvedAt: true,
voiceUrl: true,
voiceDuration: true,
imageUrl: true,
annotationData: true,
parentId: true,
authorId: true,
tagId: true,
versionId: true,
guestName: true,
// guestEmail excluded for privacy
author: { select: { id: true, name: true, image: true } },
tag: { select: { id: true, name: true, color: true } },
},
},
} : {}),
},
where: { parentId: null },
const video = await db.video.findFirst({
where: { id: videoId, projectId },
include: {
project: true,
versions: {
orderBy: { versionNumber: 'desc' },
...(includeComments
? {
include: {
comments: {
orderBy: { timestamp: 'asc' },
skip: commentOffset,
take: commentLimit,
select: {
id: true,
content: true,
timestamp: true,
timestampEnd: true,
createdAt: true,
updatedAt: true,
isResolved: true,
resolvedAt: true,
voiceUrl: true,
voiceDuration: true,
imageUrl: true,
annotationData: true,
parentId: true,
authorId: true,
tagId: true,
versionId: true,
guestName: true,
// guestEmail excluded for privacy
author: { select: { id: true, name: true, image: true } },
tag: { select: { id: true, name: true, color: true } },
...(includeReplies
? {
replies: {
orderBy: { createdAt: 'asc' },
select: {
id: true,
content: true,
timestamp: true,
timestampEnd: true,
createdAt: true,
updatedAt: true,
isResolved: true,
resolvedAt: true,
voiceUrl: true,
voiceDuration: true,
imageUrl: true,
annotationData: true,
parentId: true,
authorId: true,
tagId: true,
versionId: true,
guestName: true,
// guestEmail excluded for privacy
author: { select: { id: true, name: true, image: true } },
tag: { select: { id: true, name: true, color: true } },
},
},
_count: { select: { comments: true } },
},
} : {
select: {
id: true,
thumbnailUrl: true,
duration: true,
versionNumber: true,
versionLabel: true,
providerId: true,
videoId: true,
originalUrl: true,
title: true,
isActive: true,
_count: { select: { comments: true } },
},
}),
}
: {}),
},
where: { parentId: null },
},
_count: { select: { comments: true } },
},
},
});
}
: {
select: {
id: true,
thumbnailUrl: true,
duration: true,
versionNumber: true,
versionLabel: true,
providerId: true,
videoId: true,
originalUrl: true,
title: true,
isActive: true,
_count: { select: { comments: true } },
},
}),
},
},
});
if (!video) {
return apiErrors.notFound('Video');
}
// Check access including workspace membership
const access = await checkProjectAccess(video.project, session?.user?.id);
if (!access.hasAccess) {
return apiErrors.forbidden('Access denied');
}
const response = successResponse({
...video,
isAuthenticated: !!session?.user?.id,
currentUserId: session?.user?.id || null,
currentUserName: session?.user?.name || null,
canDownload: access.hasAccess,
canManageTags: access.canEdit,
canResolveComments: access.canEdit,
canRequestApproval: access.canEdit,
canShareVideo: access.canEdit,
canUploadAssets: access.hasAccess,
canDownloadAssets: !!session?.user?.id && access.hasAccess,
});
return withCacheControl(response, 'private, no-cache');
} catch (error) {
logError('Error fetching video:', error);
return apiErrors.internalError('Failed to fetch video');
if (!video) {
return apiErrors.notFound('Video');
}
// Check access including workspace membership
const access = await checkProjectAccess(video.project, session?.user?.id);
if (!access.hasAccess) {
return apiErrors.forbidden('Access denied');
}
const response = successResponse({
...video,
isAuthenticated: !!session?.user?.id,
currentUserId: session?.user?.id || null,
currentUserName: session?.user?.name || null,
canDownload: access.hasAccess,
canManageTags: access.canEdit,
canResolveComments: access.canEdit,
canRequestApproval: access.canEdit,
canShareVideo: access.canEdit,
canUploadAssets: access.hasAccess,
canDownloadAssets: !!session?.user?.id && access.hasAccess,
});
return withCacheControl(response, 'private, no-cache');
} catch (error) {
logError('Error fetching video:', error);
return apiErrors.internalError('Failed to fetch video');
}
}
// PATCH /api/projects/[projectId]/videos/[videoId]
export async function PATCH(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
const session = await auth();
const { projectId, videoId } = await params;
const session = await auth();
const { projectId, videoId } = await params;
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const video = await db.video.findFirst({
where: { id: videoId, projectId },
include: {
project: true,
},
});
if (!video) {
return apiErrors.notFound('Video');
}
const access = await checkProjectAccess(video.project, session.user.id, { intent: 'manage' });
if (!access.canEdit) {
return apiErrors.forbidden('Access denied');
}
const body = await request.json();
const { title, description, position } = body;
// Validate types before using string methods to prevent type confusion attacks
if (
position !== undefined &&
(typeof position !== 'number' || !Number.isInteger(position) || position < 0)
) {
return apiErrors.badRequest('position must be a non-negative integer');
}
const updateData: Record<string, unknown> = {};
if (typeof title === 'string') updateData.title = title.trim();
if (typeof description === 'string') 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 } },
},
});
const response = successResponse(updatedVideo);
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error updating video:', error);
return apiErrors.internalError('Failed to update video');
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const video = await db.video.findFirst({
where: { id: videoId, projectId },
include: {
project: true,
},
});
if (!video) {
return apiErrors.notFound('Video');
}
const access = await checkProjectAccess(video.project, session.user.id, { intent: 'manage' });
if (!access.canEdit) {
return apiErrors.forbidden('Access denied');
}
const body = await request.json();
const { title, description, position } = body;
// Validate types before using string methods to prevent type confusion attacks
if (
position !== undefined &&
(typeof position !== 'number' || !Number.isInteger(position) || position < 0)
) {
return apiErrors.badRequest('position must be a non-negative integer');
}
const updateData: Record<string, unknown> = {};
if (typeof title === 'string') updateData.title = title.trim();
if (typeof description === 'string') 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 } },
},
});
const response = successResponse(updatedVideo);
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error updating video:', error);
return apiErrors.internalError('Failed to update video');
}
}
// DELETE /api/projects/[projectId]/videos/[videoId]
export async function DELETE(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
const session = await auth();
const { projectId, videoId } = await params;
const session = await auth();
const { projectId, videoId } = await params;
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const video = await db.video.findFirst({
where: { id: videoId, projectId },
include: {
versions: {
select: {
providerId: true,
videoId: true,
},
},
assets: {
select: {
provider: true,
providerVideoId: true,
},
},
project: true,
},
});
if (!video) {
return apiErrors.notFound('Video');
}
const access = await checkProjectAccess(video.project, session.user.id, { intent: 'manage' });
if (!access.canEdit) {
return apiErrors.forbidden('Only project owner or admin can delete videos');
}
const bunnyRefs = [
...video.versions,
...video.assets
.filter((asset) => asset.provider === 'BUNNY' && !!asset.providerVideoId)
.map((asset) => ({
providerId: 'bunny',
videoId: asset.providerVideoId as string,
})),
];
const mediaUrls = await collectVideoMediaUrls(videoId);
await db.video.delete({ where: { id: videoId } });
revalidatePath(`/projects/${projectId}`);
const [bunnyCleanupResult, r2CleanupResult] = await Promise.all([
cleanupBunnyStreamVideosBestEffort(bunnyRefs),
deleteMediaFilesBestEffort(mediaUrls),
]);
const cleanupInput = {
bunny: bunnyCleanupResult,
r2: r2CleanupResult,
};
const cleanupWarnings = buildCleanupWarnings(cleanupInput);
if (cleanupWarnings) {
logCleanupWarnings({ entityType: 'video', entityId: videoId }, cleanupInput);
}
const response = successResponse({
message: 'Video deleted',
...(cleanupWarnings ? { cleanupWarnings } : {}),
});
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error deleting video:', error);
return apiErrors.internalError('Failed to delete video');
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const video = await db.video.findFirst({
where: { id: videoId, projectId },
include: {
versions: {
select: {
providerId: true,
videoId: true,
},
},
assets: {
select: {
provider: true,
providerVideoId: true,
},
},
project: true,
},
});
if (!video) {
return apiErrors.notFound('Video');
}
const access = await checkProjectAccess(video.project, session.user.id, { intent: 'manage' });
if (!access.canEdit) {
return apiErrors.forbidden('Only project owner or admin can delete videos');
}
const bunnyRefs = [
...video.versions,
...video.assets
.filter((asset) => asset.provider === 'BUNNY' && !!asset.providerVideoId)
.map((asset) => ({
providerId: 'bunny',
videoId: asset.providerVideoId as string,
})),
];
const mediaUrls = await collectVideoMediaUrls(videoId);
await db.video.delete({ where: { id: videoId } });
revalidatePath(`/projects/${projectId}`);
const [bunnyCleanupResult, r2CleanupResult] = await Promise.all([
cleanupBunnyStreamVideosBestEffort(bunnyRefs),
deleteMediaFilesBestEffort(mediaUrls),
]);
const cleanupInput = {
bunny: bunnyCleanupResult,
r2: r2CleanupResult,
};
const cleanupWarnings = buildCleanupWarnings(cleanupInput);
if (cleanupWarnings) {
logCleanupWarnings({ entityType: 'video', entityId: videoId }, cleanupInput);
}
const response = successResponse({
message: 'Video deleted',
...(cleanupWarnings ? { cleanupWarnings } : {}),
});
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error deleting video:', error);
return apiErrors.internalError('Failed to delete video');
}
}
@@ -149,7 +149,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
const allowDownloads = typeof body?.allowDownloads === 'boolean' ? body.allowDownloads : false;
const password = typeof body?.password === 'string' ? body.password.trim() : '';
if (password.length > MAX_SHARE_PASSWORD_LENGTH) {
return apiErrors.badRequest(`Password must be ${MAX_SHARE_PASSWORD_LENGTH} characters or fewer`);
return apiErrors.badRequest(
`Password must be ${MAX_SHARE_PASSWORD_LENGTH} characters or fewer`
);
}
const passwordHash = password ? await bcrypt.hash(password, 12) : null;
const token = randomBytes(24).toString('base64url');
@@ -166,26 +168,50 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
} | null = null;
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
link = await db.$transaction(async (tx) => {
const existing = await tx.shareLink.findFirst({
where: {
projectId,
videoId,
permission: 'COMMENT',
},
orderBy: { createdAt: 'desc' },
select: { id: true },
});
link = await db.$transaction(
async (tx) => {
const existing = await tx.shareLink.findFirst({
where: {
projectId,
videoId,
permission: 'COMMENT',
},
orderBy: { createdAt: 'desc' },
select: { id: true },
});
if (existing) {
return tx.shareLink.update({
where: { id: existing.id },
if (existing) {
return tx.shareLink.update({
where: { id: existing.id },
data: {
token,
allowGuests,
allowDownloads,
passwordHash,
expiresAt: null,
},
select: {
id: true,
token: true,
permission: true,
allowGuests: true,
allowDownloads: true,
expiresAt: true,
createdAt: true,
passwordHash: true,
},
});
}
return tx.shareLink.create({
data: {
token,
projectId,
videoId,
permission: 'COMMENT',
allowGuests,
allowDownloads,
passwordHash,
expiresAt: null,
},
select: {
id: true,
@@ -198,33 +224,16 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
passwordHash: true,
},
});
}
return tx.shareLink.create({
data: {
token,
projectId,
videoId,
permission: 'COMMENT',
allowGuests,
allowDownloads,
passwordHash,
},
select: {
id: true,
token: true,
permission: true,
allowGuests: true,
allowDownloads: true,
expiresAt: true,
createdAt: true,
passwordHash: true,
},
});
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
},
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable }
);
break;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2034' && attempt < 2) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2034' &&
attempt < 2
) {
continue;
}
throw error;
@@ -261,11 +270,14 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
const body = await request.json().catch(() => ({}));
const allowGuests = typeof body?.allowGuests === 'boolean' ? body.allowGuests : undefined;
const allowDownloads = typeof body?.allowDownloads === 'boolean' ? body.allowDownloads : undefined;
const allowDownloads =
typeof body?.allowDownloads === 'boolean' ? body.allowDownloads : undefined;
const rawPassword = typeof body?.password === 'string' ? body.password : undefined;
const clearPassword = body?.clearPassword === true;
if (rawPassword !== undefined && rawPassword.length > MAX_SHARE_PASSWORD_LENGTH) {
return apiErrors.badRequest(`Password must be ${MAX_SHARE_PASSWORD_LENGTH} characters or fewer`);
return apiErrors.badRequest(
`Password must be ${MAX_SHARE_PASSWORD_LENGTH} characters or fewer`
);
}
const existing = await db.shareLink.findFirst({
@@ -9,151 +9,159 @@ import { logError } from '@/lib/logger';
type RouteParams = { params: Promise<{ projectId: string; videoId: string; versionId: string }> };
async function getVersionWithAccess(projectId: string, videoId: string, versionId: string, userId: string) {
const version = await db.videoVersion.findFirst({
where: { id: versionId, videoParentId: videoId },
async function getVersionWithAccess(
projectId: string,
videoId: string,
versionId: string,
userId: string
) {
const version = await db.videoVersion.findFirst({
where: { id: versionId, videoParentId: videoId },
include: {
video: {
include: {
video: {
include: {
project: true,
},
},
project: true,
},
});
},
},
});
if (!version || version.video.projectId !== projectId) {
return null;
}
if (!version || version.video.projectId !== projectId) {
return null;
}
const project = version.video.project;
const access = await checkProjectAccess(project, userId, { intent: 'manage' });
const project = version.video.project;
const access = await checkProjectAccess(project, userId, { intent: 'manage' });
return { version, canEdit: access.canEdit, isOwner: access.isOwner };
return { version, canEdit: access.canEdit, isOwner: access.isOwner };
}
// PATCH /api/projects/[projectId]/videos/[videoId]/versions/[versionId]
export async function PATCH(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
const session = await auth();
const { projectId, videoId, versionId } = await params;
const session = await auth();
const { projectId, videoId, versionId } = await params;
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const result = await getVersionWithAccess(projectId, videoId, versionId, session.user.id);
if (!result) {
return apiErrors.notFound('Version');
}
if (!result.canEdit) {
return apiErrors.forbidden('Access denied');
}
const body = await request.json();
const { duration, versionLabel, isActive } = body;
if (duration !== undefined && (typeof duration !== 'number' || !isFinite(duration) || duration < 0)) {
return apiErrors.badRequest('Invalid duration value');
}
const updateData: Record<string, unknown> = {};
if (duration !== undefined) updateData.duration = duration;
if (versionLabel !== undefined) updateData.versionLabel = versionLabel?.trim() || null;
if (isActive === true) {
// Deactivate all other versions, then activate this one
await db.videoVersion.updateMany({
where: { videoParentId: videoId },
data: { isActive: false },
});
updateData.isActive = true;
}
const updated = await db.videoVersion.update({
where: { id: versionId },
data: updateData,
});
const response = successResponse(updated);
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error updating version:', error);
return apiErrors.internalError('Failed to update version');
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const result = await getVersionWithAccess(projectId, videoId, versionId, session.user.id);
if (!result) {
return apiErrors.notFound('Version');
}
if (!result.canEdit) {
return apiErrors.forbidden('Access denied');
}
const body = await request.json();
const { duration, versionLabel, isActive } = body;
if (
duration !== undefined &&
(typeof duration !== 'number' || !isFinite(duration) || duration < 0)
) {
return apiErrors.badRequest('Invalid duration value');
}
const updateData: Record<string, unknown> = {};
if (duration !== undefined) updateData.duration = duration;
if (versionLabel !== undefined) updateData.versionLabel = versionLabel?.trim() || null;
if (isActive === true) {
// Deactivate all other versions, then activate this one
await db.videoVersion.updateMany({
where: { videoParentId: videoId },
data: { isActive: false },
});
updateData.isActive = true;
}
const updated = await db.videoVersion.update({
where: { id: versionId },
data: updateData,
});
const response = successResponse(updated);
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error updating version:', error);
return apiErrors.internalError('Failed to update version');
}
}
// DELETE /api/projects/[projectId]/videos/[videoId]/versions/[versionId]
export async function DELETE(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
const session = await auth();
const { projectId, videoId, versionId } = await params;
const session = await auth();
const { projectId, videoId, versionId } = await params;
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const result = await getVersionWithAccess(projectId, videoId, versionId, session.user.id);
if (!result) {
return apiErrors.notFound('Version');
}
if (!result.canEdit) {
return apiErrors.forbidden('Access denied');
}
// Check there's more than one version — can't delete the last one
const versionCount = await db.videoVersion.count({
where: { videoParentId: videoId },
});
if (versionCount <= 1) {
return apiErrors.badRequest('Cannot delete the only version. Delete the video instead.');
}
const wasActive = result.version.isActive;
const bunnyRef = {
providerId: result.version.providerId,
videoId: result.version.videoId,
};
await db.$transaction(async (tx) => {
// Delete the version (cascades to comments).
await tx.videoVersion.delete({ where: { id: versionId } });
// If the deleted version was active, activate the latest remaining one.
if (wasActive) {
const latestVersion = await tx.videoVersion.findFirst({
where: { videoParentId: videoId },
orderBy: { versionNumber: 'desc' },
});
if (latestVersion) {
await tx.videoVersion.update({
where: { id: latestVersion.id },
data: { isActive: true },
});
}
}
});
const bunnyCleanupResult = await cleanupBunnyStreamVideosBestEffort([bunnyRef]);
const cleanupInput = { bunny: bunnyCleanupResult };
const cleanupWarnings = buildCleanupWarnings(cleanupInput);
if (cleanupWarnings) {
logCleanupWarnings({ entityType: 'video-version', entityId: versionId }, cleanupInput);
}
const response = successResponse({
message: 'Version deleted',
...(cleanupWarnings ? { cleanupWarnings } : {}),
});
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error deleting version:', error);
return apiErrors.internalError('Failed to delete version');
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const result = await getVersionWithAccess(projectId, videoId, versionId, session.user.id);
if (!result) {
return apiErrors.notFound('Version');
}
if (!result.canEdit) {
return apiErrors.forbidden('Access denied');
}
// Check there's more than one version — can't delete the last one
const versionCount = await db.videoVersion.count({
where: { videoParentId: videoId },
});
if (versionCount <= 1) {
return apiErrors.badRequest('Cannot delete the only version. Delete the video instead.');
}
const wasActive = result.version.isActive;
const bunnyRef = {
providerId: result.version.providerId,
videoId: result.version.videoId,
};
await db.$transaction(async (tx) => {
// Delete the version (cascades to comments).
await tx.videoVersion.delete({ where: { id: versionId } });
// If the deleted version was active, activate the latest remaining one.
if (wasActive) {
const latestVersion = await tx.videoVersion.findFirst({
where: { videoParentId: videoId },
orderBy: { versionNumber: 'desc' },
});
if (latestVersion) {
await tx.videoVersion.update({
where: { id: latestVersion.id },
data: { isActive: true },
});
}
}
});
const bunnyCleanupResult = await cleanupBunnyStreamVideosBestEffort([bunnyRef]);
const cleanupInput = { bunny: bunnyCleanupResult };
const cleanupWarnings = buildCleanupWarnings(cleanupInput);
if (cleanupWarnings) {
logCleanupWarnings({ entityType: 'video-version', entityId: versionId }, cleanupInput);
}
const response = successResponse({
message: 'Version deleted',
...(cleanupWarnings ? { cleanupWarnings } : {}),
});
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error deleting version:', error);
return apiErrors.internalError('Failed to delete version');
}
}
@@ -12,177 +12,181 @@ 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;
try {
const session = await auth();
const { projectId, videoId } = await params;
const video = await db.video.findFirst({
where: { id: videoId, projectId },
include: {
project: true,
},
});
const video = await db.video.findFirst({
where: { id: videoId, projectId },
include: {
project: true,
},
});
if (!video) {
return apiErrors.notFound('Video');
}
const access = await checkProjectAccess(video.project, session?.user?.id);
if (!access.hasAccess) {
return apiErrors.forbidden('Access denied');
}
const versions = await db.videoVersion.findMany({
where: { videoParentId: videoId },
orderBy: { versionNumber: 'desc' },
include: {
_count: { select: { comments: true } },
},
});
const response = successResponse({ versions });
return withCacheControl(response, 'private, max-age=30, stale-while-revalidate=60');
} catch (error) {
logError('Error fetching versions:', error);
return apiErrors.internalError('Failed to fetch versions');
if (!video) {
return apiErrors.notFound('Video');
}
const access = await checkProjectAccess(video.project, session?.user?.id);
if (!access.hasAccess) {
return apiErrors.forbidden('Access denied');
}
const versions = await db.videoVersion.findMany({
where: { videoParentId: videoId },
orderBy: { versionNumber: 'desc' },
include: {
_count: { select: { comments: true } },
},
});
const response = successResponse({ versions });
return withCacheControl(response, 'private, max-age=30, stale-while-revalidate=60');
} catch (error) {
logError('Error fetching versions:', error);
return apiErrors.internalError('Failed to fetch versions');
}
}
// POST /api/projects/[projectId]/videos/[videoId]/versions - Add a new version
export async function POST(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'create-version');
if (limited) return limited;
try {
const limited = await rateLimit(request, 'create-version');
if (limited) return limited;
const session = await auth();
const { projectId, videoId } = await params;
const session = await auth();
const { projectId, videoId } = await params;
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const video = await db.video.findFirst({
where: { id: videoId, projectId },
include: {
project: true,
versions: { orderBy: { versionNumber: 'desc' }, take: 1 },
},
});
if (!video) {
return apiErrors.notFound('Video');
}
const access = await checkProjectAccess(video.project, session.user.id, { intent: 'manage' });
if (!access.canEdit) {
return apiErrors.forbidden('Access denied');
}
const body = await request.json();
const {
videoUrl,
providerId,
providerVideoId,
versionLabel,
thumbnailUrl,
duration,
setActive,
uploadToken
} = body;
if (!videoUrl) {
return apiErrors.badRequest('Video URL is required');
}
if (versionLabel !== undefined && versionLabel !== null) {
if (typeof versionLabel !== 'string') {
return apiErrors.badRequest('Version label must be a string');
}
if (versionLabel.trim().length > 100) {
return apiErrors.badRequest('Version label must be 100 characters or fewer');
}
}
// Validate URLs use safe schemes (http/https only)
const videoUrlError = validateUrl(videoUrl, 'Video URL');
if (videoUrlError) {
return apiErrors.badRequest(videoUrlError);
}
const thumbnailUrlError = validateOptionalUrl(thumbnailUrl, 'Thumbnail URL');
if (thumbnailUrlError) {
return apiErrors.badRequest(thumbnailUrlError);
}
const normalizedProviderId = typeof providerId === 'string' && providerId.trim()
? providerId.trim().toLowerCase()
: 'youtube';
const normalizedProviderVideoId = typeof providerVideoId === 'string' ? providerVideoId.trim() : '';
const normalizedUploadToken = typeof uploadToken === 'string' ? uploadToken.trim() : '';
if (normalizedProviderId === 'bunny') {
if (!normalizedProviderVideoId || !normalizedUploadToken) {
return apiErrors.badRequest('Bunny uploads must include providerVideoId and uploadToken');
}
const isValidUploadToken = verifyBunnyUploadToken(normalizedUploadToken, {
userId: session.user.id,
projectId,
videoId: normalizedProviderVideoId,
});
if (!isValidUploadToken) {
return apiErrors.forbidden('Invalid Bunny upload token');
}
}
const nextVersionNumber = (video.versions[0]?.versionNumber || 0) + 1;
// Use transaction to handle active flag
const version = await db.$transaction(async (tx: Parameters<Parameters<typeof db.$transaction>[0]>[0]) => {
// 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: normalizedProviderId,
videoId: normalizedProviderVideoId,
originalUrl: videoUrl,
title: versionLabel?.trim() || `Version ${nextVersionNumber}`,
thumbnailUrl: thumbnailUrl || null,
duration: duration || null,
isActive: setActive ?? false,
videoParentId: videoId,
},
include: {
_count: { select: { comments: true } },
},
});
});
// Notify project owner (fire-and-forget, skip if they added it themselves)
if (video.project.ownerId !== session.user.id) {
const baseUrl = process.env.NEXTAUTH_URL || '';
notifyProjectOwner(video.project.ownerId, {
type: 'new_version',
projectName: video.project.name,
videoTitle: video.title,
versionLabel: version.versionLabel || `Version ${version.versionNumber}`,
addedBy: session.user.name || 'A team member',
url: `${baseUrl}/watch/${video.id}`,
}).catch((err) => logError('Notification failed:', err));
}
const response = successResponse(version, 201);
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error creating version:', error);
return apiErrors.internalError('Failed to create version');
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const video = await db.video.findFirst({
where: { id: videoId, projectId },
include: {
project: true,
versions: { orderBy: { versionNumber: 'desc' }, take: 1 },
},
});
if (!video) {
return apiErrors.notFound('Video');
}
const access = await checkProjectAccess(video.project, session.user.id, { intent: 'manage' });
if (!access.canEdit) {
return apiErrors.forbidden('Access denied');
}
const body = await request.json();
const {
videoUrl,
providerId,
providerVideoId,
versionLabel,
thumbnailUrl,
duration,
setActive,
uploadToken,
} = body;
if (!videoUrl) {
return apiErrors.badRequest('Video URL is required');
}
if (versionLabel !== undefined && versionLabel !== null) {
if (typeof versionLabel !== 'string') {
return apiErrors.badRequest('Version label must be a string');
}
if (versionLabel.trim().length > 100) {
return apiErrors.badRequest('Version label must be 100 characters or fewer');
}
}
// Validate URLs use safe schemes (http/https only)
const videoUrlError = validateUrl(videoUrl, 'Video URL');
if (videoUrlError) {
return apiErrors.badRequest(videoUrlError);
}
const thumbnailUrlError = validateOptionalUrl(thumbnailUrl, 'Thumbnail URL');
if (thumbnailUrlError) {
return apiErrors.badRequest(thumbnailUrlError);
}
const normalizedProviderId =
typeof providerId === 'string' && providerId.trim()
? providerId.trim().toLowerCase()
: 'youtube';
const normalizedProviderVideoId =
typeof providerVideoId === 'string' ? providerVideoId.trim() : '';
const normalizedUploadToken = typeof uploadToken === 'string' ? uploadToken.trim() : '';
if (normalizedProviderId === 'bunny') {
if (!normalizedProviderVideoId || !normalizedUploadToken) {
return apiErrors.badRequest('Bunny uploads must include providerVideoId and uploadToken');
}
const isValidUploadToken = verifyBunnyUploadToken(normalizedUploadToken, {
userId: session.user.id,
projectId,
videoId: normalizedProviderVideoId,
});
if (!isValidUploadToken) {
return apiErrors.forbidden('Invalid Bunny upload token');
}
}
const nextVersionNumber = (video.versions[0]?.versionNumber || 0) + 1;
// Use transaction to handle active flag
const version = await db.$transaction(
async (tx: Parameters<Parameters<typeof db.$transaction>[0]>[0]) => {
// 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: normalizedProviderId,
videoId: normalizedProviderVideoId,
originalUrl: videoUrl,
title: versionLabel?.trim() || `Version ${nextVersionNumber}`,
thumbnailUrl: thumbnailUrl || null,
duration: duration || null,
isActive: setActive ?? false,
videoParentId: videoId,
},
include: {
_count: { select: { comments: true } },
},
});
}
);
// Notify project owner (fire-and-forget, skip if they added it themselves)
if (video.project.ownerId !== session.user.id) {
const baseUrl = process.env.NEXTAUTH_URL || '';
notifyProjectOwner(video.project.ownerId, {
type: 'new_version',
projectName: video.project.name,
videoTitle: video.title,
versionLabel: version.versionLabel || `Version ${version.versionNumber}`,
addedBy: session.user.name || 'A team member',
url: `${baseUrl}/watch/${video.id}`,
}).catch((err) => logError('Notification failed:', err));
}
const response = successResponse(version, 201);
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error creating version:', error);
return apiErrors.internalError('Failed to create version');
}
}
@@ -13,152 +13,163 @@ import { enforceStorageQuota } from '@/lib/storage-quota';
type RouteParams = { params: Promise<{ projectId: string }> };
async function getProjectWithEditAccess(projectId: string, userId: string) {
const project = await db.project.findUnique({
where: { id: projectId },
select: { id: true, name: true, ownerId: true, workspaceId: true, visibility: true, workspace: { select: { ownerId: true } } },
});
const project = await db.project.findUnique({
where: { id: projectId },
select: {
id: true,
name: true,
ownerId: true,
workspaceId: true,
visibility: true,
workspace: { select: { ownerId: true } },
},
});
if (!project) return null;
if (!project) return null;
const access = await checkProjectAccess(project, userId, { intent: 'manage' });
const canEdit = access.canEdit;
const access = await checkProjectAccess(project, userId, { intent: 'manage' });
const canEdit = access.canEdit;
if (!canEdit) return null;
if (!canEdit) return null;
return project;
return project;
}
// POST /api/projects/[projectId]/videos/bunny-init
export async function POST(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
const session = await auth();
const { projectId } = await params;
const session = await auth();
const { projectId } = await params;
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const project = await getProjectWithEditAccess(projectId, session.user.id);
if (!project) {
return apiErrors.forbidden('Access denied');
}
const body = await request.json().catch(() => null);
const title = typeof body?.title === 'string' ? body.title.trim() : '';
if (!title) {
return apiErrors.badRequest('Title is required');
}
if (!isBunnyUploadsFeatureEnabled()) {
return apiErrors.badRequest('Direct uploads are disabled by this host');
}
const quotaError = await enforceStorageQuota(project.workspace.ownerId, BigInt(0));
if (quotaError) return quotaError;
const apiKey = process.env.BUNNY_STREAM_API_KEY;
const libraryId = process.env.BUNNY_STREAM_LIBRARY_ID || process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID;
if (!apiKey || !libraryId) {
return apiErrors.internalError('Bunny Stream is not configured correctly');
}
// 1. Create video object in Bunny Stream
const bunnyRes = await fetch(`https://video.bunnycdn.com/library/${libraryId}/videos`, {
method: 'POST',
headers: {
'AccessKey': apiKey,
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({ title })
});
if (!bunnyRes.ok) {
logError('Failed to create Bunny Stream video', await bunnyRes.text());
return apiErrors.internalError('Failed to initialize video upload with provider');
}
const bunnyVideo = await bunnyRes.json();
const videoId = bunnyVideo.guid;
if (typeof videoId !== 'string' || videoId.length === 0) {
return apiErrors.internalError('Upload provider did not return a valid video identifier');
}
// 2. Generate TUS upload signature
const expirationTime = Math.floor(Date.now() / 1000) + 3600; // 1 hour validity
// SHA256(library_id + api_key + expiration_time + video_id)
const hash = crypto.createHash('sha256');
hash.update(libraryId + apiKey + expirationTime + videoId);
const signature = hash.digest('hex');
const uploadToken = createBunnyUploadToken({
userId: session.user.id,
projectId,
videoId,
}, 3600);
const response = successResponse({
videoId,
libraryId,
signature,
expirationTime,
uploadToken,
});
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error initializing Bunny upload:', error);
return apiErrors.internalError('Failed to initialize upload');
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const project = await getProjectWithEditAccess(projectId, session.user.id);
if (!project) {
return apiErrors.forbidden('Access denied');
}
const body = await request.json().catch(() => null);
const title = typeof body?.title === 'string' ? body.title.trim() : '';
if (!title) {
return apiErrors.badRequest('Title is required');
}
if (!isBunnyUploadsFeatureEnabled()) {
return apiErrors.badRequest('Direct uploads are disabled by this host');
}
const quotaError = await enforceStorageQuota(project.workspace.ownerId, BigInt(0));
if (quotaError) return quotaError;
const apiKey = process.env.BUNNY_STREAM_API_KEY;
const libraryId =
process.env.BUNNY_STREAM_LIBRARY_ID || process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID;
if (!apiKey || !libraryId) {
return apiErrors.internalError('Bunny Stream is not configured correctly');
}
// 1. Create video object in Bunny Stream
const bunnyRes = await fetch(`https://video.bunnycdn.com/library/${libraryId}/videos`, {
method: 'POST',
headers: {
AccessKey: apiKey,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ title }),
});
if (!bunnyRes.ok) {
logError('Failed to create Bunny Stream video', await bunnyRes.text());
return apiErrors.internalError('Failed to initialize video upload with provider');
}
const bunnyVideo = await bunnyRes.json();
const videoId = bunnyVideo.guid;
if (typeof videoId !== 'string' || videoId.length === 0) {
return apiErrors.internalError('Upload provider did not return a valid video identifier');
}
// 2. Generate TUS upload signature
const expirationTime = Math.floor(Date.now() / 1000) + 3600; // 1 hour validity
// SHA256(library_id + api_key + expiration_time + video_id)
const hash = crypto.createHash('sha256');
hash.update(libraryId + apiKey + expirationTime + videoId);
const signature = hash.digest('hex');
const uploadToken = createBunnyUploadToken(
{
userId: session.user.id,
projectId,
videoId,
},
3600
);
const response = successResponse({
videoId,
libraryId,
signature,
expirationTime,
uploadToken,
});
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error initializing Bunny upload:', error);
return apiErrors.internalError('Failed to initialize upload');
}
}
// DELETE /api/projects/[projectId]/videos/bunny-init
// Best-effort cleanup for interrupted uploads before a DB row is created.
export async function DELETE(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
const session = await auth();
const { projectId } = await params;
const session = await auth();
const { projectId } = await params;
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const project = await getProjectWithEditAccess(projectId, session.user.id);
if (!project) {
return apiErrors.forbidden('Access denied');
}
const body = await request.json().catch(() => null);
const videoId = typeof body?.videoId === 'string' ? body.videoId.trim() : '';
const uploadToken = typeof body?.uploadToken === 'string' ? body.uploadToken.trim() : '';
if (!videoId || !uploadToken) {
return apiErrors.badRequest('videoId and uploadToken are required');
}
const isValidUploadToken = verifyBunnyUploadToken(uploadToken, {
userId: session.user.id,
projectId,
videoId,
});
if (!isValidUploadToken) {
return apiErrors.forbidden('Invalid Bunny upload token');
}
await cleanupBunnyStreamVideos([{ providerId: 'bunny', videoId }]);
const response = successResponse({ message: 'Pending upload cleaned up' });
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error cleaning up pending Bunny upload:', error);
return apiErrors.internalError('Failed to cleanup pending upload');
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const project = await getProjectWithEditAccess(projectId, session.user.id);
if (!project) {
return apiErrors.forbidden('Access denied');
}
const body = await request.json().catch(() => null);
const videoId = typeof body?.videoId === 'string' ? body.videoId.trim() : '';
const uploadToken = typeof body?.uploadToken === 'string' ? body.uploadToken.trim() : '';
if (!videoId || !uploadToken) {
return apiErrors.badRequest('videoId and uploadToken are required');
}
const isValidUploadToken = verifyBunnyUploadToken(uploadToken, {
userId: session.user.id,
projectId,
videoId,
});
if (!isValidUploadToken) {
return apiErrors.forbidden('Invalid Bunny upload token');
}
await cleanupBunnyStreamVideos([{ providerId: 'bunny', videoId }]);
const response = successResponse({ message: 'Pending upload cleaned up' });
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error cleaning up pending Bunny upload:', error);
return apiErrors.internalError('Failed to cleanup pending upload');
}
}
+165 -155
View File
@@ -12,169 +12,179 @@ 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;
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 },
select: { id: true, ownerId: true, workspaceId: true, visibility: true },
});
// Check project exists and user has access
const project = await db.project.findUnique({
where: { id: projectId },
select: { id: true, ownerId: true, workspaceId: true, visibility: true },
});
if (!project) {
return apiErrors.notFound('Project');
}
const access = await checkProjectAccess(project, session?.user?.id);
if (!access.hasAccess) {
return apiErrors.forbidden('Access denied');
}
const videos = await db.video.findMany({
where: { projectId },
orderBy: { position: 'asc' },
include: {
versions: {
where: { isActive: true },
orderBy: { versionNumber: 'desc' },
take: 1,
select: {
id: true,
thumbnailUrl: true,
duration: true,
versionNumber: true,
_count: { select: { comments: true } },
},
},
_count: { select: { versions: true } },
},
});
const response = successResponse({ videos });
return withCacheControl(response, 'private, max-age=30, stale-while-revalidate=60');
} catch (error) {
logError('Error fetching videos:', error);
return apiErrors.internalError('Failed to fetch videos');
if (!project) {
return apiErrors.notFound('Project');
}
const access = await checkProjectAccess(project, session?.user?.id);
if (!access.hasAccess) {
return apiErrors.forbidden('Access denied');
}
const videos = await db.video.findMany({
where: { projectId },
orderBy: { position: 'asc' },
include: {
versions: {
where: { isActive: true },
orderBy: { versionNumber: 'desc' },
take: 1,
select: {
id: true,
thumbnailUrl: true,
duration: true,
versionNumber: true,
_count: { select: { comments: true } },
},
},
_count: { select: { versions: true } },
},
});
const response = successResponse({ videos });
return withCacheControl(response, 'private, max-age=30, stale-while-revalidate=60');
} catch (error) {
logError('Error fetching videos:', error);
return apiErrors.internalError('Failed to fetch videos');
}
}
// POST /api/projects/[projectId]/videos - Add a new video to the project
export async function POST(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'create-video');
if (limited) return limited;
try {
const limited = await rateLimit(request, 'create-video');
if (limited) return limited;
const session = await auth();
const { projectId } = await params;
const session = await auth();
const { projectId } = await params;
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
// Check project access (must be owner, project admin, or workspace admin)
const project = await db.project.findUnique({
where: { id: projectId },
select: { id: true, name: true, ownerId: true, workspaceId: true, visibility: true },
});
if (!project) {
return apiErrors.notFound('Project');
}
const access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
if (!access.canEdit) {
return apiErrors.forbidden('Access denied');
}
const body = await request.json();
const { title, description, videoUrl, providerId, videoId, thumbnailUrl, duration, uploadToken } = body;
if (!title || !videoUrl) {
return apiErrors.badRequest('Title and video URL are required');
}
// Validate URLs use safe schemes (http/https only)
const videoUrlError = validateUrl(videoUrl, 'Video URL');
if (videoUrlError) {
return apiErrors.badRequest(videoUrlError);
}
const thumbnailUrlError = validateOptionalUrl(thumbnailUrl, 'Thumbnail URL');
if (thumbnailUrlError) {
return apiErrors.badRequest(thumbnailUrlError);
}
const normalizedProviderId = typeof providerId === 'string' && providerId.trim()
? providerId.trim().toLowerCase()
: 'youtube';
const normalizedVideoId = typeof videoId === 'string' ? videoId.trim() : '';
const normalizedUploadToken = typeof uploadToken === 'string' ? uploadToken.trim() : '';
if (normalizedProviderId === 'bunny') {
if (!normalizedVideoId || !normalizedUploadToken) {
return apiErrors.badRequest('Bunny uploads must include videoId and uploadToken');
}
const isValidUploadToken = verifyBunnyUploadToken(normalizedUploadToken, {
userId: session.user.id,
projectId,
videoId: normalizedVideoId,
});
if (!isValidUploadToken) {
return apiErrors.forbidden('Invalid Bunny upload token');
}
}
// 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: normalizedProviderId,
videoId: normalizedVideoId,
originalUrl: videoUrl,
title: title.trim(),
thumbnailUrl: thumbnailUrl || null,
duration: duration || null,
isActive: true,
},
},
},
include: {
versions: true,
_count: { select: { versions: true } },
},
});
// Notify project owner (fire-and-forget, skip if they added it themselves)
if (project.ownerId !== session.user.id) {
const baseUrl = process.env.NEXTAUTH_URL || '';
notifyProjectOwner(project.ownerId, {
type: 'new_video',
projectName: project.name,
videoTitle: title.trim(),
addedBy: session.user.name || 'A team member',
url: `${baseUrl}/watch/${video.id}`,
}).catch((err) => logError('Notification failed:', err));
}
const response = successResponse(video, 201);
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error creating video:', error);
return apiErrors.internalError('Failed to create video');
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
// Check project access (must be owner, project admin, or workspace admin)
const project = await db.project.findUnique({
where: { id: projectId },
select: { id: true, name: true, ownerId: true, workspaceId: true, visibility: true },
});
if (!project) {
return apiErrors.notFound('Project');
}
const access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
if (!access.canEdit) {
return apiErrors.forbidden('Access denied');
}
const body = await request.json();
const {
title,
description,
videoUrl,
providerId,
videoId,
thumbnailUrl,
duration,
uploadToken,
} = body;
if (!title || !videoUrl) {
return apiErrors.badRequest('Title and video URL are required');
}
// Validate URLs use safe schemes (http/https only)
const videoUrlError = validateUrl(videoUrl, 'Video URL');
if (videoUrlError) {
return apiErrors.badRequest(videoUrlError);
}
const thumbnailUrlError = validateOptionalUrl(thumbnailUrl, 'Thumbnail URL');
if (thumbnailUrlError) {
return apiErrors.badRequest(thumbnailUrlError);
}
const normalizedProviderId =
typeof providerId === 'string' && providerId.trim()
? providerId.trim().toLowerCase()
: 'youtube';
const normalizedVideoId = typeof videoId === 'string' ? videoId.trim() : '';
const normalizedUploadToken = typeof uploadToken === 'string' ? uploadToken.trim() : '';
if (normalizedProviderId === 'bunny') {
if (!normalizedVideoId || !normalizedUploadToken) {
return apiErrors.badRequest('Bunny uploads must include videoId and uploadToken');
}
const isValidUploadToken = verifyBunnyUploadToken(normalizedUploadToken, {
userId: session.user.id,
projectId,
videoId: normalizedVideoId,
});
if (!isValidUploadToken) {
return apiErrors.forbidden('Invalid Bunny upload token');
}
}
// 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: normalizedProviderId,
videoId: normalizedVideoId,
originalUrl: videoUrl,
title: title.trim(),
thumbnailUrl: thumbnailUrl || null,
duration: duration || null,
isActive: true,
},
},
},
include: {
versions: true,
_count: { select: { versions: true } },
},
});
// Notify project owner (fire-and-forget, skip if they added it themselves)
if (project.ownerId !== session.user.id) {
const baseUrl = process.env.NEXTAUTH_URL || '';
notifyProjectOwner(project.ownerId, {
type: 'new_video',
projectName: project.name,
videoTitle: title.trim(),
addedBy: session.user.name || 'A team member',
url: `${baseUrl}/watch/${video.id}`,
}).catch((err) => logError('Notification failed:', err));
}
const response = successResponse(video, 201);
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error creating video:', error);
return apiErrors.internalError('Failed to create video');
}
}
+181 -179
View File
@@ -10,192 +10,194 @@ import { logError } from '@/lib/logger';
// GET /api/projects - List all projects for the authenticated user
export async function GET(request: NextRequest) {
try {
const session = await auth();
const MAX_LIMIT = 100;
const MAX_PAGE = 1000;
const MAX_OFFSET = 10000;
try {
const session = await auth();
const MAX_LIMIT = 100;
const MAX_PAGE = 1000;
const MAX_OFFSET = 10000;
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const { searchParams } = new URL(request.url);
const pageParam = searchParams.get('page');
const limitParam = searchParams.get('limit');
const workspaceId = searchParams.get('workspaceId');
const pageRaw = pageParam === null ? 1 : Number(pageParam);
if (!Number.isSafeInteger(pageRaw) || pageRaw < 1 || pageRaw > MAX_PAGE) {
return apiErrors.badRequest('Invalid page. Must be a positive integer.');
}
const limitRaw = limitParam === null ? 10 : Number(limitParam);
if (!Number.isSafeInteger(limitRaw) || limitRaw < 1 || limitRaw > MAX_LIMIT) {
return apiErrors.badRequest('Invalid limit. Must be a positive integer between 1 and 100.');
}
const page = pageRaw;
const limit = limitRaw;
const skip = (page - 1) * limit;
if (!Number.isSafeInteger(skip) || skip > MAX_OFFSET) {
return apiErrors.badRequest('Invalid page range. Offset must be 10000 or less.');
}
// Build base filter: user is owner OR a member
const baseFilter: Record<string, unknown> = {
OR: [
{ ownerId: session.user.id },
{ members: { some: { userId: session.user.id } } },
// Also include projects in workspaces where the user is a workspace member
...(workspaceId ? [] : [{
workspace: {
owner: buildBillingAccessWhereInput(),
members: { some: { userId: session.user.id } },
},
}]),
],
workspace: {
owner: buildBillingAccessWhereInput(),
},
};
// Filter by workspace if provided
if (workspaceId) {
baseFilter.workspaceId = workspaceId;
}
// Get projects where user is owner OR a member
const [projects, total] = await Promise.all([
db.project.findMany({
where: baseFilter,
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: baseFilter,
}),
]);
const response = successResponse(
{ projects },
200,
{
page,
limit,
total,
totalPages: Math.ceil(total / limit),
}
);
return withCacheControl(response, 'private, max-age=30, stale-while-revalidate=60');
} catch (error) {
logError('Error fetching projects:', error);
return apiErrors.internalError('Failed to fetch projects');
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const { searchParams } = new URL(request.url);
const pageParam = searchParams.get('page');
const limitParam = searchParams.get('limit');
const workspaceId = searchParams.get('workspaceId');
const pageRaw = pageParam === null ? 1 : Number(pageParam);
if (!Number.isSafeInteger(pageRaw) || pageRaw < 1 || pageRaw > MAX_PAGE) {
return apiErrors.badRequest('Invalid page. Must be a positive integer.');
}
const limitRaw = limitParam === null ? 10 : Number(limitParam);
if (!Number.isSafeInteger(limitRaw) || limitRaw < 1 || limitRaw > MAX_LIMIT) {
return apiErrors.badRequest('Invalid limit. Must be a positive integer between 1 and 100.');
}
const page = pageRaw;
const limit = limitRaw;
const skip = (page - 1) * limit;
if (!Number.isSafeInteger(skip) || skip > MAX_OFFSET) {
return apiErrors.badRequest('Invalid page range. Offset must be 10000 or less.');
}
// Build base filter: user is owner OR a member
const baseFilter: Record<string, unknown> = {
OR: [
{ ownerId: session.user.id },
{ members: { some: { userId: session.user.id } } },
// Also include projects in workspaces where the user is a workspace member
...(workspaceId
? []
: [
{
workspace: {
owner: buildBillingAccessWhereInput(),
members: { some: { userId: session.user.id } },
},
},
]),
],
workspace: {
owner: buildBillingAccessWhereInput(),
},
};
// Filter by workspace if provided
if (workspaceId) {
baseFilter.workspaceId = workspaceId;
}
// Get projects where user is owner OR a member
const [projects, total] = await Promise.all([
db.project.findMany({
where: baseFilter,
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: baseFilter,
}),
]);
const response = successResponse({ projects }, 200, {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
});
return withCacheControl(response, 'private, max-age=30, stale-while-revalidate=60');
} catch (error) {
logError('Error fetching projects:', error);
return apiErrors.internalError('Failed to fetch projects');
}
}
// POST /api/projects - Create a new project
export async function POST(request: NextRequest) {
try {
const limited = await rateLimit(request, 'create-project');
if (limited) return limited;
try {
const limited = await rateLimit(request, 'create-project');
if (limited) return limited;
const session = await auth();
const session = await auth();
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const body = await request.json();
const { name, description, visibility, workspaceId } = body;
if (!name || typeof name !== 'string' || name.trim().length === 0) {
return apiErrors.badRequest('Project name is required');
}
if (!workspaceId || typeof workspaceId !== 'string') {
return apiErrors.badRequest('A workspace is required. Every project must belong to a workspace.');
}
// Generate URL-friendly slug
const baseSlug = name
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
// Find all existing slugs with the same prefix in a single query
const existingProjects = await db.project.findMany({
where: { slug: { startsWith: baseSlug } },
select: { slug: true },
});
// Generate unique slug from the results
const usedSlugs = new Set(existingProjects.map(p => p.slug));
let slug = baseSlug;
let counter = 1;
while (usedSlugs.has(slug)) {
slug = `${baseSlug}-${counter}`;
counter++;
}
// Verify user has access to the workspace
const workspace = await db.workspace.findUnique({
where: { id: workspaceId },
include: { members: { where: { userId: session.user.id } } },
});
if (!workspace) {
return apiErrors.notFound('Workspace');
}
const access = await checkWorkspaceAccess(
{ id: workspace.id, ownerId: workspace.ownerId },
session.user.id
);
if (!access.canEdit) {
return apiErrors.forbidden('Only workspace owners and admins can create projects');
}
const project = await db.$transaction(async (tx) => {
const createdProject = await tx.project.create({
data: {
name: name.trim(),
description: description?.trim() || null,
slug,
visibility: visibility || ProjectVisibility.PRIVATE,
ownerId: workspace.ownerId,
workspaceId,
},
include: {
owner: { select: { id: true, name: true, image: true } },
_count: { select: { videos: true, members: true } },
},
});
await tx.commentTag.createMany({
data: DEFAULT_COMMENT_TAGS.map((tag) => ({
...tag,
projectId: createdProject.id,
})),
skipDuplicates: true,
});
return createdProject;
});
const response = successResponse(project, 201);
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error creating project:', error);
return apiErrors.internalError('Failed to create project');
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const body = await request.json();
const { name, description, visibility, workspaceId } = body;
if (!name || typeof name !== 'string' || name.trim().length === 0) {
return apiErrors.badRequest('Project name is required');
}
if (!workspaceId || typeof workspaceId !== 'string') {
return apiErrors.badRequest(
'A workspace is required. Every project must belong to a workspace.'
);
}
// Generate URL-friendly slug
const baseSlug = name
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
// Find all existing slugs with the same prefix in a single query
const existingProjects = await db.project.findMany({
where: { slug: { startsWith: baseSlug } },
select: { slug: true },
});
// Generate unique slug from the results
const usedSlugs = new Set(existingProjects.map((p) => p.slug));
let slug = baseSlug;
let counter = 1;
while (usedSlugs.has(slug)) {
slug = `${baseSlug}-${counter}`;
counter++;
}
// Verify user has access to the workspace
const workspace = await db.workspace.findUnique({
where: { id: workspaceId },
include: { members: { where: { userId: session.user.id } } },
});
if (!workspace) {
return apiErrors.notFound('Workspace');
}
const access = await checkWorkspaceAccess(
{ id: workspace.id, ownerId: workspace.ownerId },
session.user.id
);
if (!access.canEdit) {
return apiErrors.forbidden('Only workspace owners and admins can create projects');
}
const project = await db.$transaction(async (tx) => {
const createdProject = await tx.project.create({
data: {
name: name.trim(),
description: description?.trim() || null,
slug,
visibility: visibility || ProjectVisibility.PRIVATE,
ownerId: workspace.ownerId,
workspaceId,
},
include: {
owner: { select: { id: true, name: true, image: true } },
_count: { select: { videos: true, members: true } },
},
});
await tx.commentTag.createMany({
data: DEFAULT_COMMENT_TAGS.map((tag) => ({
...tag,
projectId: createdProject.id,
})),
skipDuplicates: true,
});
return createdProject;
});
const response = successResponse(project, 201);
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error creating project:', error);
return apiErrors.internalError('Failed to create project');
}
}