From 05150d5a202665964591014c00299f55bb353eed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0pek?= Date: Sun, 8 Feb 2026 13:54:04 +0300 Subject: [PATCH] feat(comments): Grant workspace members access to comments - Allow workspace members to view comments - Enable workspace members to resolve/unresolve comments - Permit workspace members to delete comments - Verify workspace membership or ownership for access --- app/api/comments/[commentId]/route.ts | 71 +++++++++++++++++-- .../[projectId]/videos/[videoId]/route.ts | 33 +++++++-- .../videos/[videoId]/versions/route.ts | 17 ++++- app/api/projects/[projectId]/videos/route.ts | 17 +++-- .../versions/[versionId]/comments/route.ts | 40 ++++++++++- app/api/watch/[videoId]/route.ts | 37 ++++++++-- 6 files changed, 189 insertions(+), 26 deletions(-) diff --git a/app/api/comments/[commentId]/route.ts b/app/api/comments/[commentId]/route.ts index f3a3812..cf8f4d4 100644 --- a/app/api/comments/[commentId]/route.ts +++ b/app/api/comments/[commentId]/route.ts @@ -50,7 +50,25 @@ export async function GET(request: NextRequest, { params }: RouteParams) { const isMember = project.members.length > 0; const isPublic = project.visibility === 'PUBLIC'; - if (!isOwner && !isMember && !isPublic) { + // Check workspace membership for access + let isWorkspaceMember = false; + if (!isOwner && !isMember && !isPublic && session?.user?.id) { + const wsMember = await db.workspaceMember.findUnique({ + where: { + workspaceId_userId: { + workspaceId: project.workspaceId, + userId: session.user.id, + }, + }, + }); + const wsOwner = await db.workspace.findUnique({ + where: { id: project.workspaceId }, + select: { ownerId: true }, + }); + isWorkspaceMember = !!wsMember || wsOwner?.ownerId === session.user.id; + } + + if (!isOwner && !isMember && !isPublic && !isWorkspaceMember) { return apiErrors.forbidden('Access denied'); } @@ -105,6 +123,24 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { const isAuthor = comment.authorId === session.user.id; const isMember = project.members.length > 0; + // Check workspace membership for resolve permissions + let isWorkspaceMember = false; + if (!isOwner && !isMember && session.user.id) { + const wsMember = await db.workspaceMember.findUnique({ + where: { + workspaceId_userId: { + workspaceId: project.workspaceId, + userId: session.user.id, + }, + }, + }); + const wsOwner = await db.workspace.findUnique({ + where: { id: project.workspaceId }, + select: { ownerId: true }, + }); + isWorkspaceMember = !!wsMember || wsOwner?.ownerId === session.user.id; + } + const body = await request.json(); const { content, isResolved } = body; @@ -113,8 +149,8 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { return apiErrors.forbidden('Only the author can edit comment content'); } - // Owner, author, or members can resolve/unresolve - if (isResolved !== undefined && !isOwner && !isAuthor && !isMember) { + // Owner, author, members, or workspace members can resolve/unresolve + if (isResolved !== undefined && !isOwner && !isAuthor && !isMember && !isWorkspaceMember) { return apiErrors.forbidden('Access denied'); } @@ -165,7 +201,11 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { replies: { select: { voiceUrl: true } }, version: { include: { - video: { include: { project: true } }, + video: { + include: { + project: true, + }, + }, }, }, }, @@ -175,10 +215,29 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { return apiErrors.notFound('Comment'); } - const isOwner = comment.version.video.project.ownerId === session.user.id; + const project = comment.version.video.project; + const isOwner = project.ownerId === session.user.id; const isAuthor = comment.authorId === session.user.id; - if (!isOwner && !isAuthor) { + // Check workspace membership for delete permissions + let isWorkspaceMember = false; + if (!isOwner && !isAuthor && session.user.id) { + const wsMember = await db.workspaceMember.findUnique({ + where: { + workspaceId_userId: { + workspaceId: project.workspaceId, + userId: session.user.id, + }, + }, + }); + const wsOwner = await db.workspace.findUnique({ + where: { id: project.workspaceId }, + select: { ownerId: true }, + }); + isWorkspaceMember = !!wsMember || wsOwner?.ownerId === session.user.id; + } + + if (!isOwner && !isAuthor && !isWorkspaceMember) { return apiErrors.forbidden('Only the author or project owner can delete this comment'); } diff --git a/app/api/projects/[projectId]/videos/[videoId]/route.ts b/app/api/projects/[projectId]/videos/[videoId]/route.ts index a48112c..8df61ba 100644 --- a/app/api/projects/[projectId]/videos/[videoId]/route.ts +++ b/app/api/projects/[projectId]/videos/[videoId]/route.ts @@ -2,7 +2,7 @@ import { NextRequest } from 'next/server'; import { revalidatePath } from 'next/cache'; import { db } from '@/lib/db'; import { auth } from '@/lib/auth'; -import { ProjectMemberRole } from '@prisma/client'; +import { ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client'; import { rateLimit } from '@/lib/rate-limit'; import { cleanupVideoVoiceFiles } from '@/lib/r2-cleanup'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; @@ -86,7 +86,16 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { const video = await db.video.findFirst({ where: { id: videoId, projectId }, include: { - project: { include: { members: { where: { userId: session.user.id } } } }, + project: { + include: { + members: { where: { userId: session.user.id } }, + workspace: { + include: { + members: { where: { userId: session.user.id } }, + }, + }, + }, + }, }, }); @@ -96,8 +105,10 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { const isOwner = video.project.ownerId === session.user.id; const membership = video.project.members[0]; + const workspaceMembership = video.project.workspace.members[0]; const canEdit = isOwner || - membership?.role === ProjectMemberRole.ADMIN; + membership?.role === ProjectMemberRole.ADMIN || + workspaceMembership?.role === WorkspaceMemberRole.ADMIN; if (!canEdit) { return apiErrors.forbidden('Access denied'); @@ -144,7 +155,16 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { const video = await db.video.findFirst({ where: { id: videoId, projectId }, include: { - project: { include: { members: { where: { userId: session.user.id } } } }, + project: { + include: { + members: { where: { userId: session.user.id } }, + workspace: { + include: { + members: { where: { userId: session.user.id } }, + }, + }, + }, + }, }, }); @@ -154,8 +174,11 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { const isOwner = video.project.ownerId === session.user.id; const membership = video.project.members[0]; + const workspaceMembership = video.project.workspace.members[0]; // Destructive actions limited to OWNER and ADMIN only - const canDelete = isOwner || membership?.role === ProjectMemberRole.ADMIN; + const canDelete = isOwner || + membership?.role === ProjectMemberRole.ADMIN || + workspaceMembership?.role === WorkspaceMemberRole.ADMIN; if (!canDelete) { return apiErrors.forbidden('Only project owner or admin can delete videos'); diff --git a/app/api/projects/[projectId]/videos/[videoId]/versions/route.ts b/app/api/projects/[projectId]/videos/[videoId]/versions/route.ts index e08ff22..e440104 100644 --- a/app/api/projects/[projectId]/videos/[videoId]/versions/route.ts +++ b/app/api/projects/[projectId]/videos/[videoId]/versions/route.ts @@ -1,7 +1,7 @@ import { NextRequest } from 'next/server'; import { db } from '@/lib/db'; import { auth } from '@/lib/auth'; -import { ProjectMemberRole } from '@prisma/client'; +import { ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client'; import { validateUrl, validateOptionalUrl } from '@/lib/validation'; import { rateLimit } from '@/lib/rate-limit'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; @@ -67,7 +67,16 @@ export async function POST(request: NextRequest, { params }: RouteParams) { const video = await db.video.findFirst({ where: { id: videoId, projectId }, include: { - project: { include: { members: { where: { userId: session.user.id } } } }, + project: { + include: { + members: { where: { userId: session.user.id } }, + workspace: { + include: { + members: { where: { userId: session.user.id } }, + }, + }, + }, + }, versions: { orderBy: { versionNumber: 'desc' }, take: 1 }, }, }); @@ -78,8 +87,10 @@ export async function POST(request: NextRequest, { params }: RouteParams) { const isOwner = video.project.ownerId === session.user.id; const membership = video.project.members[0]; + const workspaceMembership = video.project.workspace.members[0]; const canEdit = isOwner || - membership?.role === ProjectMemberRole.ADMIN; + membership?.role === ProjectMemberRole.ADMIN || + workspaceMembership?.role === WorkspaceMemberRole.ADMIN; if (!canEdit) { return apiErrors.forbidden('Access denied'); diff --git a/app/api/projects/[projectId]/videos/route.ts b/app/api/projects/[projectId]/videos/route.ts index 70f7637..444b401 100644 --- a/app/api/projects/[projectId]/videos/route.ts +++ b/app/api/projects/[projectId]/videos/route.ts @@ -1,7 +1,7 @@ import { NextRequest } from 'next/server'; import { db } from '@/lib/db'; import { auth } from '@/lib/auth'; -import { ProjectMemberRole } from '@prisma/client'; +import { ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client'; import { validateUrl, validateOptionalUrl } from '@/lib/validation'; import { rateLimit } from '@/lib/rate-limit'; import { notifyProjectOwner } from '@/lib/notifications'; @@ -68,10 +68,17 @@ export async function POST(request: NextRequest, { params }: RouteParams) { return apiErrors.unauthorized(); } - // Check project access (must be owner or admin) + // Check project access (must be owner, project admin, or workspace admin) const project = await db.project.findUnique({ where: { id: projectId }, - include: { members: { where: { userId: session.user.id } } }, + include: { + members: { where: { userId: session.user.id } }, + workspace: { + include: { + members: { where: { userId: session.user.id } }, + }, + }, + }, }); if (!project) { @@ -80,8 +87,10 @@ export async function POST(request: NextRequest, { params }: RouteParams) { const isOwner = project.ownerId === session.user.id; const membership = project.members[0]; + const workspaceMembership = project.workspace.members[0]; const canEdit = isOwner || - membership?.role === ProjectMemberRole.ADMIN; + membership?.role === ProjectMemberRole.ADMIN || + workspaceMembership?.role === WorkspaceMemberRole.ADMIN; if (!canEdit) { return apiErrors.forbidden('Access denied'); diff --git a/app/api/versions/[versionId]/comments/route.ts b/app/api/versions/[versionId]/comments/route.ts index 2651f3e..63244f8 100644 --- a/app/api/versions/[versionId]/comments/route.ts +++ b/app/api/versions/[versionId]/comments/route.ts @@ -39,7 +39,25 @@ export async function GET(request: NextRequest, { params }: RouteParams) { const isMember = project.members.length > 0; const isPublic = project.visibility === 'PUBLIC'; - if (!isOwner && !isMember && !isPublic) { + // Check workspace membership for access + let isWorkspaceMember = false; + if (!isOwner && !isMember && !isPublic && session?.user?.id) { + const wsMember = await db.workspaceMember.findUnique({ + where: { + workspaceId_userId: { + workspaceId: project.workspaceId, + userId: session.user.id, + }, + }, + }); + const wsOwner = await db.workspace.findUnique({ + where: { id: project.workspaceId }, + select: { ownerId: true }, + }); + isWorkspaceMember = !!wsMember || wsOwner?.ownerId === session.user.id; + } + + if (!isOwner && !isMember && !isPublic && !isWorkspaceMember) { return apiErrors.forbidden('Access denied'); } @@ -109,8 +127,26 @@ export async function POST(request: NextRequest, { params }: RouteParams) { const hasCommentLink = project.shareLinks.length > 0; const isPublic = project.visibility === 'PUBLIC'; + // Check workspace membership for comment access + let isWorkspaceMember = false; + if (!isOwner && !isMember && !isPublic && !hasCommentLink && session?.user?.id) { + const wsMember = await db.workspaceMember.findUnique({ + where: { + workspaceId_userId: { + workspaceId: project.workspaceId, + userId: session.user.id, + }, + }, + }); + const wsOwner = await db.workspace.findUnique({ + where: { id: project.workspaceId }, + select: { ownerId: true }, + }); + isWorkspaceMember = !!wsMember || wsOwner?.ownerId === session.user.id; + } + // Check if user can comment - const canComment = isOwner || isMember || isPublic || hasCommentLink; + const canComment = isOwner || isMember || isPublic || hasCommentLink || isWorkspaceMember; if (!canComment) { return apiErrors.forbidden('Access denied'); } diff --git a/app/api/watch/[videoId]/route.ts b/app/api/watch/[videoId]/route.ts index 5fd6858..a5f173c 100644 --- a/app/api/watch/[videoId]/route.ts +++ b/app/api/watch/[videoId]/route.ts @@ -5,6 +5,33 @@ import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response type RouteParams = { params: Promise<{ videoId: string }> }; +// Helper to check project access including workspace membership +async function checkProjectAccess(project: { ownerId: string; workspaceId: string; visibility: string; members: { userId: string }[] }, userId: string | undefined) { + const isOwner = userId === project.ownerId; + const isMember = project.members.some(m => m.userId === userId); + const isPublic = project.visibility === 'PUBLIC'; + + // Check workspace membership for access + let isWorkspaceMember = false; + if (!isOwner && !isMember && !isPublic && userId) { + const wsMember = await db.workspaceMember.findUnique({ + where: { + workspaceId_userId: { + workspaceId: project.workspaceId, + userId: userId, + }, + }, + }); + const wsOwner = await db.workspace.findUnique({ + where: { id: project.workspaceId }, + select: { ownerId: true }, + }); + isWorkspaceMember = !!wsMember || wsOwner?.ownerId === userId; + } + + return { isOwner, isMember, isPublic, isWorkspaceMember, hasAccess: isOwner || isMember || isPublic || isWorkspaceMember }; +} + // GET /api/watch/[videoId] - Public watch endpoint (no projectId needed) export async function GET(request: NextRequest, { params }: RouteParams) { try { @@ -47,12 +74,10 @@ export async function GET(request: NextRequest, { params }: RouteParams) { return apiErrors.notFound('Video'); } - // Check access - const isOwner = session?.user?.id === video.project.ownerId; - const isMember = video.project.members.length > 0; - const isPublic = video.project.visibility === 'PUBLIC'; + // Check access including workspace membership + const access = await checkProjectAccess(video.project, session?.user?.id); - if (!isOwner && !isMember && !isPublic) { + if (!access.hasAccess) { return apiErrors.forbidden('Access denied'); } @@ -67,7 +92,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { visibility: project.visibility, }, isAuthenticated: !!session?.user?.id, - canComment: isOwner || isMember || isPublic, + canComment: access.isOwner || access.isMember || access.isPublic || access.isWorkspaceMember, }); return withCacheControl(response, 'private, no-cache');