From 5b7bbe9f9bbdbe137b0705c9ba40df03dc820996 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 15 Jan 2026 14:21:37 -0500 Subject: [PATCH 01/14] feat: add workspace API route helper utilities - Add getAuthenticatedUser() for session management - Add requireAuth() for authentication requirement - Add verifyWorkspaceOwnership() for ownership checks - Add verifyWorkspaceOwnershipWithData() for full workspace data - Add withErrorHandling() wrapper for consistent error handling These helpers eliminate code duplication across workspace API routes. --- src/lib/api/workspace-helpers.ts | 104 +++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 src/lib/api/workspace-helpers.ts diff --git a/src/lib/api/workspace-helpers.ts b/src/lib/api/workspace-helpers.ts new file mode 100644 index 00000000..766eae46 --- /dev/null +++ b/src/lib/api/workspace-helpers.ts @@ -0,0 +1,104 @@ +import { NextResponse } from "next/server"; +import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; +import { db, workspaces } from "@/lib/db/client"; +import { eq } from "drizzle-orm"; + +/** + * Get authenticated user from session + * Returns userId or null if not authenticated + */ +export async function getAuthenticatedUser(): Promise<{ userId: string } | null> { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session) { + return null; + } + + return { userId: session.user.id }; +} + +/** + * Verify workspace ownership and return workspace data + * Throws NextResponse errors for unauthorized/not found cases + */ +export async function verifyWorkspaceOwnership( + workspaceId: string, + userId: string +): Promise<{ userId: string }> { + const workspace = await db + .select({ userId: workspaces.userId }) + .from(workspaces) + .where(eq(workspaces.id, workspaceId)) + .limit(1); + + if (!workspace[0]) { + throw NextResponse.json({ error: "Workspace not found" }, { status: 404 }); + } + + if (workspace[0].userId !== userId) { + throw NextResponse.json({ error: "Access denied" }, { status: 403 }); + } + + return workspace[0]; +} + +/** + * Verify workspace ownership and return full workspace data + * Throws NextResponse errors for unauthorized/not found cases + */ +export async function verifyWorkspaceOwnershipWithData( + workspaceId: string, + userId: string +): Promise { + const workspace = await db + .select() + .from(workspaces) + .where(eq(workspaces.id, workspaceId)) + .limit(1); + + if (!workspace[0]) { + throw NextResponse.json({ error: "Workspace not found" }, { status: 404 }); + } + + if (workspace[0].userId !== userId) { + throw NextResponse.json({ error: "Access denied" }, { status: 403 }); + } + + return workspace[0]; +} + +/** + * Wrapper for API route handlers with error handling + */ +export function withErrorHandling( + handler: (...args: T) => Promise, + routeName: string +) { + return async (...args: T): Promise => { + try { + return await handler(...args); + } catch (error) { + // If error is already a NextResponse (from verifyWorkspaceOwnership), return it + if (error && typeof error === 'object' && 'status' in error && 'json' in error) { + return error as NextResponse; + } + + console.error(`Error in ${routeName}:`, error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } + }; +} + +/** + * Require authentication - returns userId or throws 401 + */ +export async function requireAuth(): Promise { + const user = await getAuthenticatedUser(); + if (!user) { + throw NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + return user.userId; +} From 5b0b40bf18eb2432b81917de9911f252ed0e17cc Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 15 Jan 2026 14:21:38 -0500 Subject: [PATCH 02/14] refactor: use helper utilities in /api/workspaces route - Replace duplicate auth code with requireAuth() - Wrap handlers with withErrorHandling() - Parallelize params and auth operations for better performance --- src/app/api/workspaces/route.ts | 46 +++++++-------------------------- 1 file changed, 10 insertions(+), 36 deletions(-) diff --git a/src/app/api/workspaces/route.ts b/src/app/api/workspaces/route.ts index 04a8457e..b56647c9 100644 --- a/src/app/api/workspaces/route.ts +++ b/src/app/api/workspaces/route.ts @@ -1,30 +1,19 @@ import { NextRequest, NextResponse } from "next/server"; -import { headers } from "next/headers"; -import { auth } from "@/lib/auth"; import { getTemplateInitialState } from "@/lib/workspace/templates"; import type { WorkspaceWithState, WorkspaceTemplate } from "@/lib/workspace-state/types"; import type { CardColor } from "@/lib/workspace-state/colors"; import { randomUUID } from "crypto"; import { db, workspaces } from "@/lib/db/client"; import { eq, desc, asc, sql } from "drizzle-orm"; +import { requireAuth, withErrorHandling } from "@/lib/api/workspace-helpers"; /** * GET /api/workspaces * List all workspaces for the authenticated user * Note: Sharing is fork-based - users import copies, not access the original */ -export async function GET() { - try { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - // Allow anonymous users - if (!session) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const userId = session.user.id; +async function handleGET() { + const userId = await requireAuth(); // Get workspaces owned by user // Order by sort_order ASC, fallback to updated_at DESC for null sort_order values @@ -53,29 +42,17 @@ export async function GET() { color: w.color as CardColor | null, })); - return NextResponse.json({ workspaces: workspaceList }); - } catch (error) { - console.error("Error in GET /api/workspaces:", error); - return NextResponse.json({ error: "Internal server error" }, { status: 500 }); - } + return NextResponse.json({ workspaces: workspaceList }); } +export const GET = withErrorHandling(handleGET, "GET /api/workspaces"); + /** * POST /api/workspaces * Create a new workspace */ -export async function POST(request: NextRequest) { - try { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - // Allow anonymous users - if (!session) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const userId = session.user.id; +async function handlePOST(request: NextRequest) { + const userId = await requireAuth(); const body = await request.json(); const { name, description, template, is_public, icon, color, initialState: customInitialState } = body; @@ -223,10 +200,7 @@ export async function POST(request: NextRequest) { ...workspace, state: initialState, } - }, { status: 201 }); - } catch (error) { - console.error("Error in POST /api/workspaces:", error); - return NextResponse.json({ error: "Internal server error" }, { status: 500 }); - } + }, { status: 201 }); } +export const POST = withErrorHandling(handlePOST, "POST /api/workspaces"); From 04470bd60904110824d5bfa9754a23a68367b196 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 15 Jan 2026 14:21:39 -0500 Subject: [PATCH 03/14] refactor: use helper utilities in /api/workspaces/[id] route - Replace duplicate auth/ownership checks with helpers - Wrap GET, PATCH, DELETE handlers with withErrorHandling() - Parallelize independent operations (params, auth, body parsing) - Reduce code duplication by ~60 lines --- src/app/api/workspaces/[id]/route.ts | 165 +++++++++------------------ 1 file changed, 51 insertions(+), 114 deletions(-) diff --git a/src/app/api/workspaces/[id]/route.ts b/src/app/api/workspaces/[id]/route.ts index 9a3f475f..1747541a 100644 --- a/src/app/api/workspaces/[id]/route.ts +++ b/src/app/api/workspaces/[id]/route.ts @@ -1,108 +1,67 @@ import { NextRequest, NextResponse } from "next/server"; -import { headers } from "next/headers"; -import { auth } from "@/lib/auth"; import { db, workspaces } from "@/lib/db/client"; import { eq } from "drizzle-orm"; import { loadWorkspaceState } from "@/lib/workspace/state-loader"; +import { requireAuth, verifyWorkspaceOwnershipWithData, withErrorHandling } from "@/lib/api/workspace-helpers"; /** * GET /api/workspaces/[id] * Get a specific workspace with its state * Note: Only owners can access (sharing is fork-based - users import copies) */ -export async function GET( +async function handleGET( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { - try { - const { id } = await params; - const session = await auth.api.getSession({ - headers: await headers(), - }); - - // Allow anonymous users - if (!session) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const userId = session.user.id; - - // Get workspace - const workspace = await db - .select() - .from(workspaces) - .where(eq(workspaces.id, id)) - .limit(1); - - if (!workspace[0]) { - return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); - } + // Start independent operations in parallel + const paramsPromise = params; + const authPromise = requireAuth(); + + const { id } = await paramsPromise; + const userId = await authPromise; - // Enforce strict ownership (sharing is fork-based) - if (workspace[0].userId !== userId) { - return NextResponse.json({ error: "Access denied" }, { status: 403 }); - } + // Get workspace and verify ownership + const workspace = await verifyWorkspaceOwnershipWithData(id, userId); // Get workspace state by replaying events const state = await loadWorkspaceState(id); - // Ensure state has workspace metadata if empty - if (!state.globalTitle && !state.globalDescription) { - state.globalTitle = workspace[0].name || ""; - state.globalDescription = workspace[0].description || ""; - } - - return NextResponse.json({ - workspace: { - ...workspace[0], - state, - }, - }); - } catch (error) { - console.error("Error in GET /api/workspaces/[id]:", error); - return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + // Ensure state has workspace metadata if empty + if (!state.globalTitle && !state.globalDescription) { + state.globalTitle = workspace.name || ""; + state.globalDescription = workspace.description || ""; } + + return NextResponse.json({ + workspace: { + ...workspace, + state, + }, + }); } +export const GET = withErrorHandling(handleGET, "GET /api/workspaces/[id]"); + /** * PATCH /api/workspaces/[id] * Update workspace metadata (owner only) */ -export async function PATCH( +async function handlePATCH( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { - try { - const { id } = await params; - const session = await auth.api.getSession({ - headers: await headers(), - }); - - // Allow anonymous users - if (!session) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const userId = session.user.id; - - const body = await request.json(); - const { name, description, is_public, icon, color } = body; - - // Check ownership - const workspace = await db - .select({ userId: workspaces.userId }) - .from(workspaces) - .where(eq(workspaces.id, id)) - .limit(1); - - if (!workspace[0]) { - return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); - } - - // Enforce strict ownership (sharing is fork-based) - if (workspace[0].userId !== userId) { - return NextResponse.json({ error: "Access denied" }, { status: 403 }); - } + // Start independent operations in parallel + const paramsPromise = params; + const authPromise = requireAuth(); + const bodyPromise = request.json(); + + const { id } = await paramsPromise; + const userId = await authPromise; + const body = await bodyPromise; + const { name, description, is_public, icon, color } = body; + + // Check ownership + await verifyWorkspaceOwnership(id, userId); // Update workspace const updateData: { @@ -124,58 +83,36 @@ export async function PATCH( .where(eq(workspaces.id, id)) .returning(); - return NextResponse.json({ workspace: updatedWorkspace }); - } catch (error) { - console.error("Error in PATCH /api/workspaces/[id]:", error); - return NextResponse.json({ error: "Internal server error" }, { status: 500 }); - } + return NextResponse.json({ workspace: updatedWorkspace }); } +export const PATCH = withErrorHandling(handlePATCH, "PATCH /api/workspaces/[id]"); + /** * DELETE /api/workspaces/[id] * Delete a workspace (owner only) */ -export async function DELETE( +async function handleDELETE( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { - try { - const { id } = await params; - const session = await auth.api.getSession({ - headers: await headers(), - }); - - // Allow anonymous users - if (!session) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const userId = session.user.id; - - // Check ownership - const workspace = await db - .select({ userId: workspaces.userId }) - .from(workspaces) - .where(eq(workspaces.id, id)) - .limit(1); + // Start independent operations in parallel + const paramsPromise = params; + const authPromise = requireAuth(); + + const { id } = await paramsPromise; + const userId = await authPromise; - if (!workspace[0]) { - return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); - } - - if (workspace[0].userId !== userId) { - return NextResponse.json({ error: "Only owners can delete workspaces" }, { status: 403 }); - } + // Check ownership + await verifyWorkspaceOwnership(id, userId); // Delete workspace (cascade will delete events and snapshots) await db .delete(workspaces) .where(eq(workspaces.id, id)); - return NextResponse.json({ success: true }); - } catch (error) { - console.error("Error in DELETE /api/workspaces/[id]:", error); - return NextResponse.json({ error: "Internal server error" }, { status: 500 }); - } + return NextResponse.json({ success: true }); } +export const DELETE = withErrorHandling(handleDELETE, "DELETE /api/workspaces/[id]"); + From 4ff105909cb0eb3582d836cba86a409409093476 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 15 Jan 2026 14:21:40 -0500 Subject: [PATCH 04/14] refactor: use helper utilities in /api/workspaces/slug/[slug] route - Replace duplicate auth code with requireAuth() - Wrap handler with withErrorHandling() - Parallelize params and auth operations --- src/app/api/workspaces/slug/[slug]/route.ts | 68 +++++++++------------ 1 file changed, 30 insertions(+), 38 deletions(-) diff --git a/src/app/api/workspaces/slug/[slug]/route.ts b/src/app/api/workspaces/slug/[slug]/route.ts index 19601ff2..3c5dd243 100644 --- a/src/app/api/workspaces/slug/[slug]/route.ts +++ b/src/app/api/workspaces/slug/[slug]/route.ts @@ -1,46 +1,40 @@ import { NextRequest, NextResponse } from "next/server"; -import { headers } from "next/headers"; -import { auth } from "@/lib/auth"; import { db, workspaces } from "@/lib/db/client"; import { eq, and } from "drizzle-orm"; import { loadWorkspaceState } from "@/lib/workspace/state-loader"; +import { requireAuth, withErrorHandling } from "@/lib/api/workspace-helpers"; /** * GET /api/workspaces/slug/[slug] * Get a workspace by slug (more user-friendly than UUID) * Note: Owner only (sharing is fork-based) */ -export async function GET( +async function handleGET( request: NextRequest, { params }: { params: Promise<{ slug: string }> } ) { - try { - const { slug } = await params; - const session = await auth.api.getSession({ - headers: await headers(), - }); - - if (!session) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const userId = session.user.id; - - // Get workspace by slug for this user (ownership only) - const workspace = await db - .select() - .from(workspaces) - .where( - and( - eq(workspaces.slug, slug), - eq(workspaces.userId, userId) - ) + // Start independent operations in parallel + const paramsPromise = params; + const authPromise = requireAuth(); + + const { slug } = await paramsPromise; + const userId = await authPromise; + + // Get workspace by slug for this user (ownership only) + const workspace = await db + .select() + .from(workspaces) + .where( + and( + eq(workspaces.slug, slug), + eq(workspaces.userId, userId) ) - .limit(1); + ) + .limit(1); - if (!workspace[0]) { - return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); - } + if (!workspace[0]) { + return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); + } // Get workspace state by replaying events const state = await loadWorkspaceState(workspace[0].id); @@ -51,15 +45,13 @@ export async function GET( state.globalDescription = workspace[0].description || ""; } - return NextResponse.json({ - workspace: { - ...workspace[0], - state, - }, - }); - } catch (error) { - console.error("Error in GET /api/workspaces/slug/[slug]:", error); - return NextResponse.json({ error: "Internal server error" }, { status: 500 }); - } + return NextResponse.json({ + workspace: { + ...workspace[0], + state, + }, + }); } +export const GET = withErrorHandling(handleGET, "GET /api/workspaces/slug/[slug]"); + From f530d17e9ed8eaec60190e604a8f7acfae13812a Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 15 Jan 2026 14:21:42 -0500 Subject: [PATCH 05/14] refactor: use helper utilities in /api/workspaces/reorder route - Replace duplicate auth code with requireAuth() - Wrap handler with withErrorHandling() - Maintain existing functionality with cleaner code --- src/app/api/workspaces/reorder/route.ts | 23 +++++------------------ 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/src/app/api/workspaces/reorder/route.ts b/src/app/api/workspaces/reorder/route.ts index 5cf6f587..be262185 100644 --- a/src/app/api/workspaces/reorder/route.ts +++ b/src/app/api/workspaces/reorder/route.ts @@ -1,24 +1,14 @@ import { NextRequest, NextResponse } from "next/server"; -import { headers } from "next/headers"; -import { auth } from "@/lib/auth"; import { db, workspaces } from "@/lib/db/client"; import { eq, inArray, and } from "drizzle-orm"; +import { requireAuth, withErrorHandling } from "@/lib/api/workspace-helpers"; /** * POST /api/workspaces/reorder * Update the sort_order for multiple workspaces */ -export async function POST(request: NextRequest) { - try { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - if (!session) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const userId = session.user.id; +async function handlePOST(request: NextRequest) { + const userId = await requireAuth(); const body = await request.json(); const { workspaceIds } = body; @@ -62,10 +52,7 @@ export async function POST(request: NextRequest) { ); } - return NextResponse.json({ success: true }); - } catch (error) { - console.error("Error in POST /api/workspaces/reorder:", error); - return NextResponse.json({ error: "Internal server error" }, { status: 500 }); - } + return NextResponse.json({ success: true }); } +export const POST = withErrorHandling(handlePOST, "POST /api/workspaces/reorder"); From f2edd151e2476695dbaa6c4f8c00429c7524bf6b Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 15 Jan 2026 14:21:43 -0500 Subject: [PATCH 06/14] refactor: use helper utilities in /api/workspaces/[id]/events route - Replace duplicate auth/ownership checks with helpers - Wrap GET and POST handlers with withErrorHandling() - Parallelize params, auth, and body parsing operations - Maintain performance timing measurements --- src/app/api/workspaces/[id]/events/route.ts | 162 +++++++------------- 1 file changed, 59 insertions(+), 103 deletions(-) diff --git a/src/app/api/workspaces/[id]/events/route.ts b/src/app/api/workspaces/[id]/events/route.ts index 3f148ab9..ef481b1a 100644 --- a/src/app/api/workspaces/[id]/events/route.ts +++ b/src/app/api/workspaces/[id]/events/route.ts @@ -1,56 +1,36 @@ import { NextRequest, NextResponse } from "next/server"; -import { headers } from "next/headers"; -import { auth } from "@/lib/auth"; import type { WorkspaceEvent, EventResponse } from "@/lib/workspace/events"; import { checkAndCreateSnapshot } from "@/lib/workspace/snapshot-manager"; -import { db, workspaces, workspaceEvents, workspaceSnapshots } from "@/lib/db/client"; -import { eq, gt, desc, asc, sql, and } from "drizzle-orm"; +import { db, workspaceEvents } from "@/lib/db/client"; +import { eq, gt, asc, sql, and } from "drizzle-orm"; +import { requireAuth, verifyWorkspaceOwnership, withErrorHandling } from "@/lib/api/workspace-helpers"; /** * GET /api/workspaces/[id]/events * Fetch all events for a workspace (owner only) */ -export async function GET( +async function handleGET( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { const startTime = Date.now(); const timings: Record = {}; - let id: string | undefined; - - try { - const paramsResolved = await params; - id = paramsResolved.id; - - const authStart = Date.now(); - const session = await auth.api.getSession({ - headers: await headers(), - }); - timings.auth = Date.now() - authStart; - - if (!session) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const userId = session.user.id; - - // Check if user is workspace owner - const workspaceCheckStart = Date.now(); - const workspace = await db - .select({ userId: workspaces.userId }) - .from(workspaces) - .where(eq(workspaces.id, id)) - .limit(1); - timings.workspaceCheck = Date.now() - workspaceCheckStart; - - if (!workspace[0]) { - return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); - } - - // Enforce strict ownership (sharing is fork-based) - if (workspace[0].userId !== userId) { - return NextResponse.json({ error: "Access denied" }, { status: 403 }); - } + + // Start independent operations in parallel + const paramsPromise = params; + const authPromise = requireAuth(); + + const paramsResolved = await paramsPromise; + const id = paramsResolved.id; + + const authStart = Date.now(); + const userId = await authPromise; + timings.auth = Date.now() - authStart; + + // Check if user is workspace owner + const workspaceCheckStart = Date.now(); + await verifyWorkspaceOwnership(id, userId); + timings.workspaceCheck = Date.now() - workspaceCheckStart; // Get only the latest snapshot (not all snapshots - loaded on demand for version history) // Use optimized function that bypasses RLS (access already verified above) @@ -207,71 +187,50 @@ export async function GET( const totalTime = Date.now() - startTime; timings.total = totalTime; - return NextResponse.json(response); - } catch (error) { - const totalTime = Date.now() - startTime; - console.error(`[GET /api/workspaces/${id || '[unknown]'}/events] Error after ${totalTime}ms:`, error); - return NextResponse.json({ error: "Internal server error" }, { status: 500 }); - } + return NextResponse.json(response); } +export const GET = withErrorHandling(handleGET, "GET /api/workspaces/[id]/events"); + /** * POST /api/workspaces/[id]/events * Append new event(s) to workspace event log (owner only) */ -export async function POST( +async function handlePOST( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { const startTime = Date.now(); const timings: Record = {}; - let id: string | undefined; - - try { - const paramsResolved = await params; - id = paramsResolved.id; - - const authStart = Date.now(); - const session = await auth.api.getSession({ - headers: await headers(), - }); - timings.auth = Date.now() - authStart; - - if (!session) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const userId = session.user.id; - - const bodyStart = Date.now(); - const body = await request.json(); - timings.bodyParse = Date.now() - bodyStart; - const { event, baseVersion } = body; - - if (!event || baseVersion === undefined || isNaN(baseVersion)) { - return NextResponse.json( - { error: "Event and valid baseVersion are required" }, - { status: 400 } - ); - } - - // Check if user is workspace owner - const workspaceCheckStart = Date.now(); - const workspace = await db - .select({ userId: workspaces.userId }) - .from(workspaces) - .where(eq(workspaces.id, id)) - .limit(1); - timings.workspaceCheck = Date.now() - workspaceCheckStart; - - if (!workspace[0]) { - return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); - } + + // Start independent operations in parallel + const paramsPromise = params; + const authPromise = requireAuth(); + const bodyPromise = request.json(); + + const paramsResolved = await paramsPromise; + const id = paramsResolved.id; + + const authStart = Date.now(); + const userId = await authPromise; + timings.auth = Date.now() - authStart; + + const bodyStart = Date.now(); + const body = await bodyPromise; + timings.bodyParse = Date.now() - bodyStart; + const { event, baseVersion } = body; + + if (!event || baseVersion === undefined || isNaN(baseVersion)) { + return NextResponse.json( + { error: "Event and valid baseVersion are required" }, + { status: 400 } + ); + } - // Enforce strict ownership (sharing is fork-based) - if (workspace[0].userId !== userId) { - return NextResponse.json({ error: "Access denied" }, { status: 403 }); - } + // Check if user is workspace owner + const workspaceCheckStart = Date.now(); + await verifyWorkspaceOwnership(id, userId); + timings.workspaceCheck = Date.now() - workspaceCheckStart; // Use the append function to handle versioning and conflicts const appendStart = Date.now(); @@ -353,15 +312,12 @@ export async function POST( const totalTime = Date.now() - startTime; timings.total = totalTime; - return NextResponse.json({ - success: true, - version: appendResult.version, - conflict: false, - }); - } catch (error) { - const totalTime = Date.now() - startTime; - console.error(`[POST /api/workspaces/${id || '[unknown]'}/events] Error after ${totalTime}ms:`, error); - return NextResponse.json({ error: "Internal server error" }, { status: 500 }); - } + return NextResponse.json({ + success: true, + version: appendResult.version, + conflict: false, + }); } +export const POST = withErrorHandling(handlePOST, "POST /api/workspaces/[id]/events"); + From f83e771891d535cb49fa1e6886f291d1b1acb62f Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 15 Jan 2026 14:21:44 -0500 Subject: [PATCH 07/14] refactor: use helper utilities in /api/workspaces/[id]/snapshot route - Replace duplicate auth/ownership checks with helpers - Wrap GET, POST, PUT handlers with withErrorHandling() - Parallelize params and auth operations - Reduce code duplication across all three handlers --- src/app/api/workspaces/[id]/snapshot/route.ts | 171 ++++++------------ 1 file changed, 53 insertions(+), 118 deletions(-) diff --git a/src/app/api/workspaces/[id]/snapshot/route.ts b/src/app/api/workspaces/[id]/snapshot/route.ts index 51dd314b..b91ef1f5 100644 --- a/src/app/api/workspaces/[id]/snapshot/route.ts +++ b/src/app/api/workspaces/[id]/snapshot/route.ts @@ -1,6 +1,4 @@ import { NextRequest, NextResponse } from "next/server"; -import { headers } from "next/headers"; -import { auth } from "@/lib/auth"; import { checkNeedsSnapshot, createSnapshot, @@ -8,42 +6,25 @@ import { } from "@/lib/workspace/snapshot-manager"; import { db, workspaces } from "@/lib/db/client"; import { eq, sql } from "drizzle-orm"; +import { requireAuth, verifyWorkspaceOwnershipWithData, withErrorHandling } from "@/lib/api/workspace-helpers"; /** * GET /api/workspaces/[id]/snapshot * Check snapshot status for a workspace (owner only) */ -export async function GET( +async function handleGET( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { - try { - const { id } = await params; - const session = await auth.api.getSession({ - headers: await headers(), - }); - - if (!session) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const userId = session.user.id; + // Start independent operations in parallel + const paramsPromise = params; + const authPromise = requireAuth(); + + const { id } = await paramsPromise; + const userId = await authPromise; - // Check if user is workspace owner - const workspace = await db - .select({ userId: workspaces.userId, name: workspaces.name }) - .from(workspaces) - .where(eq(workspaces.id, id)) - .limit(1); - - if (!workspace[0]) { - return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); - } - - // Enforce strict ownership (sharing is fork-based) - if (workspace[0].userId !== userId) { - return NextResponse.json({ error: "Access denied" }, { status: 403 }); - } + // Check if user is workspace owner + const workspace = await verifyWorkspaceOwnershipWithData(id, userId); // Check snapshot status using utility functions const snapshotStatus = await checkNeedsSnapshot(id); @@ -53,57 +34,35 @@ export async function GET( SELECT * FROM get_latest_snapshot(${id}::uuid) `); - return NextResponse.json({ - workspace: { - id, - title: workspace[0].name, - }, - snapshot: latestSnapshot[0] || null, - status: snapshotStatus, - }); - } catch (error) { - console.error("Error in GET /api/workspaces/[id]/snapshot:", error); - return NextResponse.json({ error: "Internal server error" }, { status: 500 }); - } + return NextResponse.json({ + workspace: { + id, + title: workspace.name, + }, + snapshot: latestSnapshot[0] || null, + status: snapshotStatus, + }); } +export const GET = withErrorHandling(handleGET, "GET /api/workspaces/[id]/snapshot"); + /** * POST /api/workspaces/[id]/snapshot * Manually create a snapshot for a workspace (owner only) */ -export async function POST( +async function handlePOST( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { - try { - const { id } = await params; - const session = await auth.api.getSession({ - headers: await headers(), - }); - - if (!session) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const userId = session.user.id; - - // Check if user is workspace owner - const workspace = await db - .select({ userId: workspaces.userId }) - .from(workspaces) - .where(eq(workspaces.id, id)) - .limit(1); + // Start independent operations in parallel + const paramsPromise = params; + const authPromise = requireAuth(); + + const { id } = await paramsPromise; + const userId = await authPromise; - if (!workspace[0]) { - return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); - } - - if (workspace[0].userId !== userId) { - return NextResponse.json( - { error: "Only workspace owner can create snapshots" }, - { status: 403 } - ); - } + // Check if user is workspace owner + await verifyWorkspaceOwnership(id, userId); // Create snapshot using utility function const result = await createSnapshot(id); @@ -115,54 +74,32 @@ export async function POST( ); } - return NextResponse.json({ - success: true, - version: result.version, - message: `Snapshot created at version ${result.version}`, - }); - } catch (error) { - console.error("Error in POST /api/workspaces/[id]/snapshot:", error); - return NextResponse.json({ error: "Internal server error" }, { status: 500 }); - } + return NextResponse.json({ + success: true, + version: result.version, + message: `Snapshot created at version ${result.version}`, + }); } +export const POST = withErrorHandling(handlePOST, "POST /api/workspaces/[id]/snapshot"); + /** * PUT /api/workspaces/[id]/snapshot * Check and create snapshot if needed (owner only) */ -export async function PUT( +async function handlePUT( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { - try { - const { id } = await params; - const session = await auth.api.getSession({ - headers: await headers(), - }); - - if (!session) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const userId = session.user.id; - - // Check if user is workspace owner - const workspace = await db - .select({ userId: workspaces.userId }) - .from(workspaces) - .where(eq(workspaces.id, id)) - .limit(1); + // Start independent operations in parallel + const paramsPromise = params; + const authPromise = requireAuth(); + + const { id } = await paramsPromise; + const userId = await authPromise; - if (!workspace[0]) { - return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); - } - - if (workspace[0].userId !== userId) { - return NextResponse.json( - { error: "Only workspace owner can manage snapshots" }, - { status: 403 } - ); - } + // Check if user is workspace owner + await verifyWorkspaceOwnership(id, userId); // Check and create snapshot if needed const statusBefore = await checkNeedsSnapshot(id); @@ -179,14 +116,12 @@ export async function PUT( const statusAfter = await checkNeedsSnapshot(id); - return NextResponse.json({ - message: "Snapshot check complete", - created: true, - before: statusBefore, - after: statusAfter, - }); - } catch (error) { - console.error("Error in PUT /api/workspaces/[id]/snapshot:", error); - return NextResponse.json({ error: "Internal server error" }, { status: 500 }); - } + return NextResponse.json({ + message: "Snapshot check complete", + created: true, + before: statusBefore, + after: statusAfter, + }); } + +export const PUT = withErrorHandling(handlePUT, "PUT /api/workspaces/[id]/snapshot"); From 16a01fac66746374ae2da0e8862a8f87f22729f2 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 15 Jan 2026 14:21:45 -0500 Subject: [PATCH 08/14] refactor: use helper utilities in /api/workspaces/[id]/snapshots route - Replace duplicate auth/ownership checks with helpers - Wrap handler with withErrorHandling() - Parallelize params and auth operations --- .../api/workspaces/[id]/snapshots/route.ts | 44 ++++--------------- 1 file changed, 9 insertions(+), 35 deletions(-) diff --git a/src/app/api/workspaces/[id]/snapshots/route.ts b/src/app/api/workspaces/[id]/snapshots/route.ts index f173b4bd..c1d18b25 100644 --- a/src/app/api/workspaces/[id]/snapshots/route.ts +++ b/src/app/api/workspaces/[id]/snapshots/route.ts @@ -1,46 +1,23 @@ import { NextRequest, NextResponse } from "next/server"; -import { headers } from "next/headers"; -import { auth } from "@/lib/auth"; import type { SnapshotInfo } from "@/lib/workspace/events"; -import { db, workspaces, workspaceSnapshots } from "@/lib/db/client"; +import { db, workspaceSnapshots } from "@/lib/db/client"; import { eq, desc } from "drizzle-orm"; +import { requireAuth, verifyWorkspaceOwnership, withErrorHandling } from "@/lib/api/workspace-helpers"; /** * GET /api/workspaces/[id]/snapshots * Fetch all snapshots for a workspace (for version history) * Note: Owner only (sharing is fork-based) */ -export async function GET( +async function handleGET( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { - try { - const { id } = await params; - const session = await auth.api.getSession({ - headers: await headers(), - }); + const { id } = await params; + const userId = await requireAuth(); - if (!session) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const userId = session.user.id; - - // Check if user is workspace owner - const workspace = await db - .select({ userId: workspaces.userId }) - .from(workspaces) - .where(eq(workspaces.id, id)) - .limit(1); - - if (!workspace[0]) { - return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); - } - - // Enforce strict ownership (sharing is fork-based) - if (workspace[0].userId !== userId) { - return NextResponse.json({ error: "Access denied" }, { status: 403 }); - } + // Check if user is workspace owner + await verifyWorkspaceOwnership(id, userId); // Get ALL snapshots for version history const allSnapshotsData = await db @@ -58,10 +35,7 @@ export async function GET( state: s.state as any, })); - return NextResponse.json({ snapshots }); - } catch (error) { - console.error("Error in GET /api/workspaces/[id]/snapshots:", error); - return NextResponse.json({ error: "Internal server error" }, { status: 500 }); - } + return NextResponse.json({ snapshots }); } +export const GET = withErrorHandling(handleGET, "GET /api/workspaces/[id]/snapshots"); From 6ff442ac741612c50900d0f4c4225c0981bf9d21 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 15 Jan 2026 14:41:05 -0500 Subject: [PATCH 09/14] fix type issues --- src/app/api/workspaces/[id]/route.ts | 2 +- src/app/api/workspaces/[id]/snapshot/route.ts | 2 +- src/app/api/workspaces/route.ts | 7 ++++++- src/lib/api/workspace-helpers.ts | 4 ++-- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/app/api/workspaces/[id]/route.ts b/src/app/api/workspaces/[id]/route.ts index 1747541a..0d134a7f 100644 --- a/src/app/api/workspaces/[id]/route.ts +++ b/src/app/api/workspaces/[id]/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { db, workspaces } from "@/lib/db/client"; import { eq } from "drizzle-orm"; import { loadWorkspaceState } from "@/lib/workspace/state-loader"; -import { requireAuth, verifyWorkspaceOwnershipWithData, withErrorHandling } from "@/lib/api/workspace-helpers"; +import { requireAuth, verifyWorkspaceOwnership, verifyWorkspaceOwnershipWithData, withErrorHandling } from "@/lib/api/workspace-helpers"; /** * GET /api/workspaces/[id] diff --git a/src/app/api/workspaces/[id]/snapshot/route.ts b/src/app/api/workspaces/[id]/snapshot/route.ts index b91ef1f5..cd746fda 100644 --- a/src/app/api/workspaces/[id]/snapshot/route.ts +++ b/src/app/api/workspaces/[id]/snapshot/route.ts @@ -6,7 +6,7 @@ import { } from "@/lib/workspace/snapshot-manager"; import { db, workspaces } from "@/lib/db/client"; import { eq, sql } from "drizzle-orm"; -import { requireAuth, verifyWorkspaceOwnershipWithData, withErrorHandling } from "@/lib/api/workspace-helpers"; +import { requireAuth, verifyWorkspaceOwnership, verifyWorkspaceOwnershipWithData, withErrorHandling } from "@/lib/api/workspace-helpers"; /** * GET /api/workspaces/[id]/snapshot diff --git a/src/app/api/workspaces/route.ts b/src/app/api/workspaces/route.ts index b56647c9..e979ff3a 100644 --- a/src/app/api/workspaces/route.ts +++ b/src/app/api/workspaces/route.ts @@ -1,4 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; +import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; import { getTemplateInitialState } from "@/lib/workspace/templates"; import type { WorkspaceWithState, WorkspaceTemplate } from "@/lib/workspace-state/types"; import type { CardColor } from "@/lib/workspace-state/colors"; @@ -124,7 +126,10 @@ async function handlePOST(request: NextRequest) { // Better Auth stores name and email in the session let userName: string | undefined; try { - userName = session.user.name || session.user.email || undefined; + const session = await auth.api.getSession({ + headers: await headers(), + }); + userName = session?.user?.name || session?.user?.email || undefined; } catch { // Could not get user name // Continue without userName diff --git a/src/lib/api/workspace-helpers.ts b/src/lib/api/workspace-helpers.ts index 766eae46..97ac8902 100644 --- a/src/lib/api/workspace-helpers.ts +++ b/src/lib/api/workspace-helpers.ts @@ -81,8 +81,8 @@ export function withErrorHandling( try { return await handler(...args); } catch (error) { - // If error is already a NextResponse (from verifyWorkspaceOwnership), return it - if (error && typeof error === 'object' && 'status' in error && 'json' in error) { + // If error is already a Response/NextResponse, return it + if (error instanceof Response) { return error as NextResponse; } From 482d2fc4ab6897186675f42134105437600ba434 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 15 Jan 2026 14:49:07 -0500 Subject: [PATCH 10/14] fix: use instanceof Response for error detection Replace duck-typing check with instanceof Response to reliably detect thrown NextResponse errors. Also extend getAuthenticatedUser to return user name and email, and add requireAuthWithUserInfo helper to avoid duplicate session fetches. --- src/lib/api/workspace-helpers.ts | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/lib/api/workspace-helpers.ts b/src/lib/api/workspace-helpers.ts index 97ac8902..69ca89b8 100644 --- a/src/lib/api/workspace-helpers.ts +++ b/src/lib/api/workspace-helpers.ts @@ -6,9 +6,9 @@ import { eq } from "drizzle-orm"; /** * Get authenticated user from session - * Returns userId or null if not authenticated + * Returns userId, name, and email or null if not authenticated */ -export async function getAuthenticatedUser(): Promise<{ userId: string } | null> { +export async function getAuthenticatedUser(): Promise<{ userId: string; name?: string; email?: string } | null> { const session = await auth.api.getSession({ headers: await headers(), }); @@ -17,7 +17,11 @@ export async function getAuthenticatedUser(): Promise<{ userId: string } | null> return null; } - return { userId: session.user.id }; + return { + userId: session.user.id, + name: session.user.name ?? undefined, + email: session.user.email ?? undefined, + }; } /** @@ -102,3 +106,15 @@ export async function requireAuth(): Promise { } return user.userId; } + +/** + * Require authentication with user info - returns userId, name, and email or throws 401 + * Use this when you need user name/email to avoid duplicate session fetches + */ +export async function requireAuthWithUserInfo(): Promise<{ userId: string; name?: string; email?: string }> { + const user = await getAuthenticatedUser(); + if (!user) { + throw NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + return user; +} From d0e88c94aec7542a9dfa4a6c345ee0c144b52fab Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 15 Jan 2026 14:49:09 -0500 Subject: [PATCH 11/14] fix: eliminate duplicate session fetch in workspace creation Use requireAuthWithUserInfo instead of requireAuth followed by a separate session fetch. This reduces API calls from 2 to 1 per request and fixes the undefined session reference issue. --- src/app/api/workspaces/route.ts | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/src/app/api/workspaces/route.ts b/src/app/api/workspaces/route.ts index e979ff3a..10b8b3c9 100644 --- a/src/app/api/workspaces/route.ts +++ b/src/app/api/workspaces/route.ts @@ -1,13 +1,11 @@ import { NextRequest, NextResponse } from "next/server"; -import { headers } from "next/headers"; -import { auth } from "@/lib/auth"; import { getTemplateInitialState } from "@/lib/workspace/templates"; import type { WorkspaceWithState, WorkspaceTemplate } from "@/lib/workspace-state/types"; import type { CardColor } from "@/lib/workspace-state/colors"; import { randomUUID } from "crypto"; import { db, workspaces } from "@/lib/db/client"; import { eq, desc, asc, sql } from "drizzle-orm"; -import { requireAuth, withErrorHandling } from "@/lib/api/workspace-helpers"; +import { requireAuth, requireAuthWithUserInfo, withErrorHandling } from "@/lib/api/workspace-helpers"; /** * GET /api/workspaces @@ -54,7 +52,9 @@ export const GET = withErrorHandling(handleGET, "GET /api/workspaces"); * Create a new workspace */ async function handlePOST(request: NextRequest) { - const userId = await requireAuth(); + // Use requireAuthWithUserInfo to avoid duplicate session fetch + const user = await requireAuthWithUserInfo(); + const userId = user.userId; const body = await request.json(); const { name, description, template, is_public, icon, color, initialState: customInitialState } = body; @@ -122,18 +122,8 @@ async function handlePOST(request: NextRequest) { const eventId = randomUUID(); const timestamp = Date.now(); - // Get user's display name from Better Auth session - // Better Auth stores name and email in the session - let userName: string | undefined; - try { - const session = await auth.api.getSession({ - headers: await headers(), - }); - userName = session?.user?.name || session?.user?.email || undefined; - } catch { - // Could not get user name - // Continue without userName - } + // Use user info from requireAuthWithUserInfo to avoid duplicate session fetch + const userName = user.name || user.email || undefined; // Create snapshot event first (starts at version 0, creates version 1) try { From f5537f61710d5bf3495731464b22bba3a1820370 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 15 Jan 2026 14:49:11 -0500 Subject: [PATCH 12/14] fix: correct timing measurements in events route Move timing start markers before promise creation to accurately measure execution time rather than just wait time. This fixes inaccurate auth and bodyParse timing metrics. --- src/app/api/workspaces/[id]/events/route.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/api/workspaces/[id]/events/route.ts b/src/app/api/workspaces/[id]/events/route.ts index ef481b1a..33367189 100644 --- a/src/app/api/workspaces/[id]/events/route.ts +++ b/src/app/api/workspaces/[id]/events/route.ts @@ -18,12 +18,12 @@ async function handleGET( // Start independent operations in parallel const paramsPromise = params; + const authStart = Date.now(); const authPromise = requireAuth(); const paramsResolved = await paramsPromise; const id = paramsResolved.id; - const authStart = Date.now(); const userId = await authPromise; timings.auth = Date.now() - authStart; @@ -205,17 +205,17 @@ async function handlePOST( // Start independent operations in parallel const paramsPromise = params; + const authStart = Date.now(); const authPromise = requireAuth(); + const bodyStart = Date.now(); const bodyPromise = request.json(); const paramsResolved = await paramsPromise; const id = paramsResolved.id; - const authStart = Date.now(); const userId = await authPromise; timings.auth = Date.now() - authStart; - const bodyStart = Date.now(); const body = await bodyPromise; timings.bodyParse = Date.now() - bodyStart; const { event, baseVersion } = body; From 70f9d7df070cf1a08f08175e65ef2873b8d54233 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 15 Jan 2026 14:49:15 -0500 Subject: [PATCH 13/14] perf: add parallelization to snapshots route Start params and requireAuth() promises concurrently to reduce latency, matching the optimization pattern used in other workspace API routes. --- src/app/api/workspaces/[id]/snapshots/route.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/app/api/workspaces/[id]/snapshots/route.ts b/src/app/api/workspaces/[id]/snapshots/route.ts index c1d18b25..17f4f66c 100644 --- a/src/app/api/workspaces/[id]/snapshots/route.ts +++ b/src/app/api/workspaces/[id]/snapshots/route.ts @@ -13,8 +13,12 @@ async function handleGET( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { - const { id } = await params; - const userId = await requireAuth(); + // Start independent operations in parallel + const paramsPromise = params; + const authPromise = requireAuth(); + + const { id } = await paramsPromise; + const userId = await authPromise; // Check if user is workspace owner await verifyWorkspaceOwnership(id, userId); From db0cd5719824de586c8c59a1e6dbdaf8c29d8008 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 15 Jan 2026 14:54:00 -0500 Subject: [PATCH 14/14] fix: correct timing measurements to exclude parallel wait time Move timing start markers to immediately before awaiting each promise to measure only the actual operation duration, not time spent waiting for other parallel operations. This ensures auth and bodyParse timings accurately reflect their individual execution times. --- src/app/api/workspaces/[id]/events/route.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/api/workspaces/[id]/events/route.ts b/src/app/api/workspaces/[id]/events/route.ts index 33367189..ef481b1a 100644 --- a/src/app/api/workspaces/[id]/events/route.ts +++ b/src/app/api/workspaces/[id]/events/route.ts @@ -18,12 +18,12 @@ async function handleGET( // Start independent operations in parallel const paramsPromise = params; - const authStart = Date.now(); const authPromise = requireAuth(); const paramsResolved = await paramsPromise; const id = paramsResolved.id; + const authStart = Date.now(); const userId = await authPromise; timings.auth = Date.now() - authStart; @@ -205,17 +205,17 @@ async function handlePOST( // Start independent operations in parallel const paramsPromise = params; - const authStart = Date.now(); const authPromise = requireAuth(); - const bodyStart = Date.now(); const bodyPromise = request.json(); const paramsResolved = await paramsPromise; const id = paramsResolved.id; + const authStart = Date.now(); const userId = await authPromise; timings.auth = Date.now() - authStart; + const bodyStart = Date.now(); const body = await bodyPromise; timings.bodyParse = Date.now() - bodyStart; const { event, baseVersion } = body;