diff --git a/app/api/comments/[commentId]/route.ts b/app/api/comments/[commentId]/route.ts index 605f5c7..a5ad423 100644 --- a/app/api/comments/[commentId]/route.ts +++ b/app/api/comments/[commentId]/route.ts @@ -8,6 +8,7 @@ import { validateShareLinkAccess } from '@/lib/share-links'; import { getShareSessionFromRequest } from '@/lib/share-session'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { getGuestIdentityFromRequest } from '@/lib/guest-identity'; +import { ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client'; type RouteParams = { params: Promise<{ commentId: string }> }; @@ -156,7 +157,10 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { const userId = session?.user?.id ?? null; const isOwner = userId === project.ownerId; const isAuthor = !!userId && comment.authorId === userId; - const isMember = !!userId && project.members.some((member) => member.userId === userId); + const projectMembership = userId + ? project.members.find((member) => member.userId === userId) ?? null + : null; + const isProjectAdmin = projectMembership?.role === ProjectMemberRole.ADMIN; const guestIdentityId = !userId ? getGuestIdentityFromRequest(request) : null; const isGuestAuthor = !userId && !comment.authorId @@ -164,9 +168,9 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { && guestIdentityId === comment.guestIdentityId; const canEditOwnContent = isAuthor || isGuestAuthor; - // Check workspace membership for resolve permissions - let isWorkspaceMember = false; - if (!isOwner && !isMember && userId) { + // Check workspace role for resolve permissions. + let workspaceRole: WorkspaceMemberRole | 'OWNER' | null = null; + if (!isOwner && userId) { const wsMember = await db.workspaceMember.findUnique({ where: { workspaceId_userId: { @@ -179,8 +183,14 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { where: { id: project.workspaceId }, select: { ownerId: true }, }); - isWorkspaceMember = !!wsMember || wsOwner?.ownerId === userId; + if (wsOwner?.ownerId === userId) { + workspaceRole = 'OWNER'; + } else if (wsMember) { + workspaceRole = wsMember.role; + } } + const isWorkspaceAdmin = workspaceRole === 'OWNER' || workspaceRole === WorkspaceMemberRole.ADMIN; + const canResolveComment = isOwner || isProjectAdmin || isWorkspaceAdmin; if (!userId && !isGuestAuthor) { const shareSession = getShareSessionFromRequest(request, comment.version.video.id); @@ -205,8 +215,8 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { } // Owner, author, members, or workspace members can resolve/unresolve - if (isResolved !== undefined && !isOwner && !canEditOwnContent && !isMember && !isWorkspaceMember) { - return apiErrors.forbidden('Access denied'); + if (isResolved !== undefined && !canResolveComment) { + return apiErrors.forbidden('Only admins can resolve comments'); } const updateData: Record = {}; diff --git a/app/api/projects/[projectId]/videos/[videoId]/route.ts b/app/api/projects/[projectId]/videos/[videoId]/route.ts index a543769..8680700 100644 --- a/app/api/projects/[projectId]/videos/[videoId]/route.ts +++ b/app/api/projects/[projectId]/videos/[videoId]/route.ts @@ -108,6 +108,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { currentUserName: session?.user?.name || null, canDownload: access.hasAccess, canManageTags: access.canEdit, + canResolveComments: access.canEdit, }); return withCacheControl(response, 'private, no-cache'); diff --git a/app/api/watch/[videoId]/route.ts b/app/api/watch/[videoId]/route.ts index 690f8b4..9ca3a9c 100644 --- a/app/api/watch/[videoId]/route.ts +++ b/app/api/watch/[videoId]/route.ts @@ -191,6 +191,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { canComment: canCommentWithMembership || canCommentWithShareLink, canDownload: canDownloadWithMembership || canDownloadWithShareLink, canManageTags: access.canEdit, + canResolveComments: access.canEdit, }); return withCacheControl(response, 'private, no-cache'); diff --git a/components/video-page-content.tsx b/components/video-page-content.tsx index 5fbb2cf..8bebd35 100644 --- a/components/video-page-content.tsx +++ b/components/video-page-content.tsx @@ -167,6 +167,7 @@ interface VideoData { canComment?: boolean; canDownload?: boolean; canManageTags?: boolean; + canResolveComments?: boolean; } function formatTime(seconds: number): string { @@ -511,6 +512,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi // Determine current user info for permission checks and comment display const currentUserId = video?.currentUserId || null; const currentUserName = video?.currentUserName || null; + const canResolveComments = !!video?.canResolveComments; const apiBasePath = mode === 'dashboard' ? `/api/projects/${propProjectId}/videos/${videoId}` @@ -1959,6 +1961,11 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi const handleResolveComment = useCallback( async (commentId: string, currentlyResolved: boolean) => { + if (!video?.canResolveComments) { + toast.error('Only admins can resolve comments'); + return; + } + isMutatingRef.current = true; setVideo((prev) => { if (!prev) return prev; @@ -2025,7 +2032,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi isMutatingRef.current = false; } }, - [activeVersionId] + [activeVersionId, video?.canResolveComments] ); const handleReplyComment = useCallback(async (parentId: string, voiceData?: { url: string; duration: number }, imageData?: { url: string }) => { @@ -3605,20 +3612,22 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi {formatTime(comment.timestamp)} - + {canResolveComments && ( + + )} {canManageComment && (