feat(auth): enhance project access handling with pre-fetched data and new utility functions

This commit is contained in:
Yusuf İpek
2026-04-09 17:17:39 +03:00
parent 173261149f
commit 26cf58a28c
3 changed files with 121 additions and 17 deletions
+10 -10
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db'; 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 { rateLimit } from '@/lib/rate-limit';
import { notifyProjectOwner } from '@/lib/notifications'; import { notifyProjectOwner } from '@/lib/notifications';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
@@ -46,14 +46,15 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
try { try {
const session = await auth(); const session = await auth();
const { versionId } = await params; 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({ const version = await db.videoVersion.findUnique({
where: { id: versionId }, where: { id: versionId },
include: { include: {
video: { video: {
include: { 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 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 shareSession = getShareSessionFromRequest(request, version.video.id);
const shareAccess = shareSession const shareAccess = shareSession
@@ -191,6 +192,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
const session = await auth(); const session = await auth();
const { versionId } = await params; const { versionId } = await params;
const userId = session?.user?.id;
const version = await db.videoVersion.findUnique({ const version = await db.videoVersion.findUnique({
where: { id: versionId }, where: { id: versionId },
@@ -199,11 +201,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
include: { include: {
project: { project: {
include: { include: {
workspace: { ...projectAccessInclude(userId),
select: { // workspace.select is already included by projectAccessInclude;
ownerId: true, // 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 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 shareSession = getShareSessionFromRequest(request, version.video.id);
const shareAccess = shareSession const shareAccess = shareSession
+9 -7
View File
@@ -1,6 +1,6 @@
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import { db } from '@/lib/db'; 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 { apiErrors, successResponse } from '@/lib/api-response';
import { rateLimit } from '@/lib/rate-limit'; import { rateLimit } from '@/lib/rate-limit';
@@ -17,11 +17,12 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
const { videoId } = await params; 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({ const video = await db.video.findUnique({
where: { id: videoId }, where: { id: videoId },
include: { include: {
project: true, project: { include: projectAccessInclude(userId) },
versions: { versions: {
where: { isActive: true }, where: { isActive: true },
take: 1, take: 1,
@@ -33,8 +34,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
return apiErrors.notFound('Video'); return apiErrors.notFound('Video');
} }
// Check access including workspace membership const access = computeProjectAccess(video.project, userId);
const access = await checkProjectAccess(video.project, session?.user?.id);
if (!access.hasAccess) { if (!access.hasAccess) {
return apiErrors.forbidden('Access denied'); 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. // 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. // 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({ const video = await db.video.findUnique({
where: { id: videoId }, where: { id: videoId },
include: { include: {
project: true, project: { include: projectAccessInclude(userId) },
versions: { versions: {
where: versionId ? { id: versionId } : { isActive: true }, where: versionId ? { id: versionId } : { isActive: true },
take: 1, take: 1,
@@ -109,7 +111,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return apiErrors.notFound('Video'); return apiErrors.notFound('Video');
} }
const access = await checkProjectAccess(video.project, session?.user?.id); const access = computeProjectAccess(video.project, userId);
if (!access.hasAccess) { if (!access.hasAccess) {
return apiErrors.forbidden('Access denied'); return apiErrors.forbidden('Access denied');
} }
+102
View File
@@ -90,6 +90,108 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
type ProjectAccessIntent = 'view' | 'manage' | 'delete'; 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<typeof projectAccessInclude>;
type WorkspaceForAccess = ProjectAccessIncludes['workspace']['select'] extends object
? {
id: string;
ownerId: string;
owner: Parameters<typeof hasBillingAccess>[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 // Helper to check project access including workspace membership
export async function checkProjectAccess( export async function checkProjectAccess(
project: { id: string; ownerId: string; workspaceId: string; visibility: string }, project: { id: string; ownerId: string; workspaceId: string; visibility: string },