From 26cf58a28c5b52ddcbc1e65e1e57928d54b9b0da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0pek?= Date: Thu, 9 Apr 2026 17:17:39 +0300 Subject: [PATCH] feat(auth): enhance project access handling with pre-fetched data and new utility functions --- .../versions/[versionId]/comments/route.ts | 20 ++-- app/api/watch/[videoId]/progress/route.ts | 16 +-- lib/auth.ts | 102 ++++++++++++++++++ 3 files changed, 121 insertions(+), 17 deletions(-) diff --git a/app/api/versions/[versionId]/comments/route.ts b/app/api/versions/[versionId]/comments/route.ts index 7687e85..135a733 100644 --- a/app/api/versions/[versionId]/comments/route.ts +++ b/app/api/versions/[versionId]/comments/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; -import { auth, checkProjectAccess } from '@/lib/auth'; +import { auth, computeProjectAccess, projectAccessInclude } from '@/lib/auth'; import { rateLimit } from '@/lib/rate-limit'; import { notifyProjectOwner } from '@/lib/notifications'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; @@ -46,14 +46,15 @@ export async function GET(request: NextRequest, { params }: RouteParams) { try { const session = await auth(); const { versionId } = await params; + const userId = session?.user?.id; - // Get version with project access info + // Get version with project access data pre-fetched in the same query const version = await db.videoVersion.findUnique({ where: { id: versionId }, include: { video: { include: { - project: true, + project: { include: projectAccessInclude(userId) }, }, }, }, @@ -64,7 +65,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { } const project = version.video.project; - const access = await checkProjectAccess(project, session?.user?.id); + const access = computeProjectAccess(project, userId); const shareSession = getShareSessionFromRequest(request, version.video.id); const shareAccess = shareSession @@ -191,6 +192,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) { const session = await auth(); const { versionId } = await params; + const userId = session?.user?.id; const version = await db.videoVersion.findUnique({ where: { id: versionId }, @@ -199,11 +201,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) { include: { project: { include: { - workspace: { - select: { - ownerId: true, - }, - }, + ...projectAccessInclude(userId), + // workspace.select is already included by projectAccessInclude; + // ownerId is present on workspace via projectAccessInclude }, }, }, @@ -216,7 +216,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) { } const project = version.video.project; - const access = await checkProjectAccess(project, session?.user?.id); + const access = computeProjectAccess(project, userId); const shareSession = getShareSessionFromRequest(request, version.video.id); const shareAccess = shareSession diff --git a/app/api/watch/[videoId]/progress/route.ts b/app/api/watch/[videoId]/progress/route.ts index b8c2d1b..2237149 100644 --- a/app/api/watch/[videoId]/progress/route.ts +++ b/app/api/watch/[videoId]/progress/route.ts @@ -1,6 +1,6 @@ import { NextRequest } from 'next/server'; import { db } from '@/lib/db'; -import { auth, checkProjectAccess } from '@/lib/auth'; +import { auth, computeProjectAccess, projectAccessInclude } from '@/lib/auth'; import { apiErrors, successResponse } from '@/lib/api-response'; import { rateLimit } from '@/lib/rate-limit'; @@ -17,11 +17,12 @@ export async function GET(request: NextRequest, { params }: RouteParams) { const { videoId } = await params; - // Get the video and its active version + // Get the video and its active version (project access data pre-fetched in same query) + const userId = session.user.id; const video = await db.video.findUnique({ where: { id: videoId }, include: { - project: true, + project: { include: projectAccessInclude(userId) }, versions: { where: { isActive: true }, take: 1, @@ -33,8 +34,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { return apiErrors.notFound('Video'); } - // Check access including workspace membership - const access = await checkProjectAccess(video.project, session?.user?.id); + const access = computeProjectAccess(video.project, userId); if (!access.hasAccess) { return apiErrors.forbidden('Access denied'); @@ -94,10 +94,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) { // Always load the requested video and validate access before writing progress. // If versionId is provided, verify it belongs to this video; otherwise resolve active version. + // Project access data is pre-fetched in the same query — no extra round-trips. + const userId = session.user.id; const video = await db.video.findUnique({ where: { id: videoId }, include: { - project: true, + project: { include: projectAccessInclude(userId) }, versions: { where: versionId ? { id: versionId } : { isActive: true }, take: 1, @@ -109,7 +111,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) { return apiErrors.notFound('Video'); } - const access = await checkProjectAccess(video.project, session?.user?.id); + const access = computeProjectAccess(video.project, userId); if (!access.hasAccess) { return apiErrors.forbidden('Access denied'); } diff --git a/lib/auth.ts b/lib/auth.ts index 508530a..bac6d5a 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -90,6 +90,108 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ type ProjectAccessIntent = 'view' | 'manage' | 'delete'; +// --------------------------------------------------------------------------- +// Fast-path: pre-fetch access data alongside any existing DB query so that +// computeProjectAccess() can resolve the result with zero extra round-trips. +// --------------------------------------------------------------------------- + +/** Prisma include fragment to attach to any project fetch. */ +export function projectAccessInclude(userId: string | undefined) { + return { + workspace: { + select: { + id: true, + ownerId: true, + owner: { + select: { + subscriptionStatus: true, + trialEndsAt: true, + stripeCurrentPeriodEnd: true, + billingAccessEndedAt: true, + }, + }, + members: userId + ? { where: { userId }, take: 1, orderBy: { createdAt: 'asc' as const }, select: { role: true } } + : { take: 0, select: { role: true } }, + }, + }, + members: userId + ? { where: { userId }, take: 1, orderBy: { createdAt: 'asc' as const }, select: { role: true } } + : { take: 0, select: { role: true } }, + }; +} + +type ProjectAccessIncludes = ReturnType; +type WorkspaceForAccess = ProjectAccessIncludes['workspace']['select'] extends object + ? { + id: string; + ownerId: string; + owner: Parameters[0] | null; + members: Array<{ role: WorkspaceMemberRole }>; + } + : never; + +export type EnrichedProjectForAccess = { + id: string; + ownerId: string; + workspaceId: string; + visibility: string; + workspace: WorkspaceForAccess; + members: Array<{ role: ProjectMemberRole }>; +}; + +/** + * Pure access computation — no DB queries. + * Use after fetching a project with `projectAccessInclude(userId)`. + */ +export function computeProjectAccess( + project: EnrichedProjectForAccess, + userId: string | undefined, +) { + const isOwner = userId === project.ownerId; + const isPublic = project.visibility === 'PUBLIC'; + + const projectMember = project.members[0] ?? null; + const isProjectMember = !!projectMember; + const isProjectAdmin = projectMember?.role === ProjectMemberRole.ADMIN; + + const workspaceOwnerBillingAccess = project.workspace.owner + ? hasBillingAccess(project.workspace.owner) + : false; + + let workspaceRole: WorkspaceMemberRole | 'OWNER' | null = null; + if (userId === project.workspace.ownerId) { + workspaceRole = 'OWNER'; + } else { + const wsMember = project.workspace.members[0] ?? null; + if (wsMember) workspaceRole = wsMember.role; + } + + const isWorkspaceMember = !!workspaceRole; + const isWorkspaceAdmin = + workspaceRole === WorkspaceMemberRole.ADMIN || workspaceRole === 'OWNER'; + + const hasAccess = + workspaceOwnerBillingAccess && + (isOwner || isProjectMember || isPublic || isWorkspaceMember); + const canEdit = + workspaceOwnerBillingAccess && (isOwner || isProjectAdmin || isWorkspaceAdmin); + const canDelete = + workspaceOwnerBillingAccess && (isOwner || workspaceRole === 'OWNER'); + + return { + isOwner, + isProjectMember, + isProjectAdmin, + isWorkspaceMember, + isWorkspaceAdmin, + hasAccess, + canEdit, + canDelete, + ownerBillingActive: workspaceOwnerBillingAccess, + }; +} + // Helper to check project access including workspace membership export async function checkProjectAccess( project: { id: string; ownerId: string; workspaceId: string; visibility: string },