From 5fdea8ed5a19bd3fab80d49984907ebbc826ccab Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 18 Jan 2026 22:51:40 -0500 Subject: [PATCH 1/6] fix: implement app-level workspace slug generation to resolve local dev crash --- src/app/api/workspaces/route.ts | 265 ++++++++++++++++---------------- src/lib/workspace/clone-demo.ts | 4 + src/lib/workspace/slug.ts | 27 ++++ 3 files changed, 166 insertions(+), 130 deletions(-) create mode 100644 src/lib/workspace/slug.ts diff --git a/src/app/api/workspaces/route.ts b/src/app/api/workspaces/route.ts index 2e1fffaa..cf96325b 100644 --- a/src/app/api/workspaces/route.ts +++ b/src/app/api/workspaces/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { getTemplateInitialState } from "@/lib/workspace/templates"; +import { generateSlug } from "@/lib/workspace/slug"; import type { WorkspaceWithState, WorkspaceTemplate } from "@/lib/workspace-state/types"; import type { CardColor } from "@/lib/workspace-state/colors"; import { randomUUID } from "crypto"; @@ -15,37 +16,37 @@ import { requireAuth, requireAuthWithUserInfo, withErrorHandling } from "@/lib/a async function handleGET() { const userId = await requireAuth(); - // Get workspaces owned by user - // Order by: - // 1. lastOpenedAt DESC (most recently opened first, NULLs last) - // 2. sortOrder ASC (user-defined order for workspaces never opened, NULLs last) - // 3. updatedAt DESC (fallback for workspaces without sortOrder or lastOpenedAt) - const ownedWorkspaces = await db - .select() - .from(workspaces) - .where(eq(workspaces.userId, userId)) - .orderBy( - sql`${workspaces.lastOpenedAt} DESC NULLS LAST`, - sql`${workspaces.sortOrder} ASC NULLS LAST`, - desc(workspaces.updatedAt) - ); - - // Format results (using camelCase for Drizzle types) - const workspaceList: WorkspaceWithState[] = ownedWorkspaces.map((w) => ({ - id: w.id, - userId: w.userId, - name: w.name, - description: w.description || '', - template: (w.template as WorkspaceTemplate) || 'blank', - isPublic: w.isPublic || false, - createdAt: w.createdAt || '', - updatedAt: w.updatedAt || '', - slug: w.slug || '', - icon: w.icon, - sortOrder: w.sortOrder ?? null, - color: w.color as CardColor | null, - lastOpenedAt: w.lastOpenedAt ?? null, - })); + // Get workspaces owned by user + // Order by: + // 1. lastOpenedAt DESC (most recently opened first, NULLs last) + // 2. sortOrder ASC (user-defined order for workspaces never opened, NULLs last) + // 3. updatedAt DESC (fallback for workspaces without sortOrder or lastOpenedAt) + const ownedWorkspaces = await db + .select() + .from(workspaces) + .where(eq(workspaces.userId, userId)) + .orderBy( + sql`${workspaces.lastOpenedAt} DESC NULLS LAST`, + sql`${workspaces.sortOrder} ASC NULLS LAST`, + desc(workspaces.updatedAt) + ); + + // Format results (using camelCase for Drizzle types) + const workspaceList: WorkspaceWithState[] = ownedWorkspaces.map((w) => ({ + id: w.id, + userId: w.userId, + name: w.name, + description: w.description || '', + template: (w.template as WorkspaceTemplate) || 'blank', + isPublic: w.isPublic || false, + createdAt: w.createdAt || '', + updatedAt: w.updatedAt || '', + slug: w.slug || '', + icon: w.icon, + sortOrder: w.sortOrder ?? null, + color: w.color as CardColor | null, + lastOpenedAt: w.lastOpenedAt ?? null, + })); return NextResponse.json({ workspaces: workspaceList }); } @@ -61,78 +62,82 @@ async function handlePOST(request: NextRequest) { const user = await requireAuthWithUserInfo(); const userId = user.userId; - const body = await request.json(); - const { name, description, template, is_public, icon, color, initialState: customInitialState } = body; - - // Server-side safeguard: only allow blank template workspaces - // This ensures all new workspaces start from an empty state - const effectiveTemplate: WorkspaceTemplate = "blank"; - - if (!name) { - return NextResponse.json({ error: "Name is required" }, { status: 400 }); - } - - // Get max sort_order for this user to set new workspace at the end - const maxSortData = await db - .select({ sortOrder: workspaces.sortOrder }) - .from(workspaces) - .where(eq(workspaces.userId, userId)) - .orderBy(desc(workspaces.sortOrder)) - .limit(1); - - const maxSortOrder = maxSortData[0]?.sortOrder ?? -1; - const newSortOrder = maxSortOrder + 1; - - // Create workspace - const [workspace] = await db - .insert(workspaces) - .values({ - userId: userId, - name, - description: description || "", - template: effectiveTemplate, - isPublic: is_public || false, - icon: icon || null, - color: color || null, - sortOrder: newSortOrder, - }) - .returning(); - - // Create initial state - use custom state if provided, otherwise use template - let initialState; - if (customInitialState) { - // Use provided initial state (already validated on client side) - initialState = customInitialState; - initialState.workspaceId = workspace.id; - // Ensure globalTitle and globalDescription are set from workspace metadata - if (!initialState.globalTitle) { - initialState.globalTitle = name; - } - if (!initialState.globalDescription && description) { - initialState.globalDescription = description; - } - } else { - // Use template-based initial state - initialState = getTemplateInitialState(effectiveTemplate); - initialState.workspaceId = workspace.id; + const body = await request.json(); + const { name, description, template, is_public, icon, color, initialState: customInitialState } = body; + + // Server-side safeguard: only allow blank template workspaces + // This ensures all new workspaces start from an empty state + const effectiveTemplate: WorkspaceTemplate = "blank"; + + if (!name) { + return NextResponse.json({ error: "Name is required" }, { status: 400 }); + } + + // Get max sort_order for this user to set new workspace at the end + const maxSortData = await db + .select({ sortOrder: workspaces.sortOrder }) + .from(workspaces) + .where(eq(workspaces.userId, userId)) + .orderBy(desc(workspaces.sortOrder)) + .limit(1); + + const maxSortOrder = maxSortData[0]?.sortOrder ?? -1; + const newSortOrder = maxSortOrder + 1; + + // Generate slug + const slug = generateSlug(name); + + // Create workspace + const [workspace] = await db + .insert(workspaces) + .values({ + userId: userId, + name, + description: description || "", + template: effectiveTemplate, + isPublic: is_public || false, + icon: icon || null, + color: color || null, + sortOrder: newSortOrder, + slug, + }) + .returning(); + + // Create initial state - use custom state if provided, otherwise use template + let initialState; + if (customInitialState) { + // Use provided initial state (already validated on client side) + initialState = customInitialState; + initialState.workspaceId = workspace.id; + // Ensure globalTitle and globalDescription are set from workspace metadata + if (!initialState.globalTitle) { initialState.globalTitle = name; - initialState.globalDescription = description || ""; } - - // Note: No need to create workspace_states record - state is now managed via events - - // For custom initial state (imported workspaces), create WORKSPACE_SNAPSHOT first - // This sets the entire state in one event, including title and description - if (customInitialState) { - const eventId = randomUUID(); - const timestamp = Date.now(); - - // 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 { - const snapshotResult = await db.execute(sql` + if (!initialState.globalDescription && description) { + initialState.globalDescription = description; + } + } else { + // Use template-based initial state + initialState = getTemplateInitialState(effectiveTemplate); + initialState.workspaceId = workspace.id; + initialState.globalTitle = name; + initialState.globalDescription = description || ""; + } + + // Note: No need to create workspace_states record - state is now managed via events + + // For custom initial state (imported workspaces), create WORKSPACE_SNAPSHOT first + // This sets the entire state in one event, including title and description + if (customInitialState) { + const eventId = randomUUID(); + const timestamp = Date.now(); + + // 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 { + const snapshotResult = await db.execute(sql` SELECT append_workspace_event( ${workspace.id}::uuid, ${eventId}::text, @@ -145,15 +150,15 @@ async function handlePOST(request: NextRequest) { ) `); - // If snapshot creation succeeded, we're done (it includes title/description) - // No need for separate WORKSPACE_CREATED event - } catch (eventError) { - console.error("Error creating WORKSPACE_SNAPSHOT event:", eventError); - // If snapshot fails, create WORKSPACE_CREATED as fallback - try { - const createdEventId = randomUUID(); - const createdTimestamp = Date.now(); - await db.execute(sql` + // If snapshot creation succeeded, we're done (it includes title/description) + // No need for separate WORKSPACE_CREATED event + } catch (eventError) { + console.error("Error creating WORKSPACE_SNAPSHOT event:", eventError); + // If snapshot fails, create WORKSPACE_CREATED as fallback + try { + const createdEventId = randomUUID(); + const createdTimestamp = Date.now(); + await db.execute(sql` SELECT append_workspace_event( ${workspace.id}::uuid, ${createdEventId}::text, @@ -165,17 +170,17 @@ async function handlePOST(request: NextRequest) { ${null}::text ) `); - } catch (createdError) { - console.error("Error creating WORKSPACE_CREATED event:", createdError); - } + } catch (createdError) { + console.error("Error creating WORKSPACE_CREATED event:", createdError); } - } else { - // For template-based workspaces, just create WORKSPACE_CREATED event - try { - const eventId = randomUUID(); - const timestamp = Date.now(); + } + } else { + // For template-based workspaces, just create WORKSPACE_CREATED event + try { + const eventId = randomUUID(); + const timestamp = Date.now(); - await db.execute(sql` + await db.execute(sql` SELECT append_workspace_event( ${workspace.id}::uuid, ${eventId}::text, @@ -187,19 +192,19 @@ async function handlePOST(request: NextRequest) { ${null}::text ) `); - } catch (eventError) { - // If event creation fails, continue – workspace exists in DB, - // but event-sourced title/description may be missing - console.error("Error creating WORKSPACE_CREATED event:", eventError); - } + } catch (eventError) { + // If event creation fails, continue – workspace exists in DB, + // but event-sourced title/description may be missing + console.error("Error creating WORKSPACE_CREATED event:", eventError); } + } - // Return workspace with full state for immediate use - return NextResponse.json({ - workspace: { - ...workspace, - state: initialState, - } + // Return workspace with full state for immediate use + return NextResponse.json({ + workspace: { + ...workspace, + state: initialState, + } }, { status: 201 }); } diff --git a/src/lib/workspace/clone-demo.ts b/src/lib/workspace/clone-demo.ts index e47291c4..4391d95e 100644 --- a/src/lib/workspace/clone-demo.ts +++ b/src/lib/workspace/clone-demo.ts @@ -3,6 +3,7 @@ import { getTemplateInitialState } from "@/lib/workspace/templates"; import { loadWorkspaceState } from "@/lib/workspace/state-loader"; import { eq, sql } from "drizzle-orm"; import { randomUUID } from "crypto"; +import { generateSlug } from "@/lib/workspace/slug"; const DEMO_WORKSPACE_ID = '511080ff-429e-4492-a242-1fc8416271d8'; @@ -56,6 +57,8 @@ export async function cloneDemoWorkspace( initialState = getTemplateInitialState("blank"); } + const slug = generateSlug(name); + const [workspace] = await db .insert(workspaces) .values({ @@ -67,6 +70,7 @@ export async function cloneDemoWorkspace( icon, color, sortOrder: 0, + slug, }) .returning(); diff --git a/src/lib/workspace/slug.ts b/src/lib/workspace/slug.ts new file mode 100644 index 00000000..9883c98a --- /dev/null +++ b/src/lib/workspace/slug.ts @@ -0,0 +1,27 @@ + +export function generateSlug(name: string): string { + // 1. Convert to lowercase + // 2. Replace special chars with spaces (regex like the SQL one: /[^a-z0-9\s-]/g) + // 3. Trim whitespace + // 4. Replace spaces with hyphens + // 5. Deduplicate hyphens + const base = name + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') + .trim() + .replace(/\s+/g, '-') + .replace(/-+/g, '-'); + + // Enforce max length of 50 to match potential DB constraints (though text is usually unlimited, good practice) + const truncated = base.substring(0, 50); + + // Append a short random suffix to ensure uniqueness without DB round-trips + // 4 chars of entropy is usually enough for per-user uniqueness collision avoidance + // Math.random base 36 is simple + const suffix = Math.random().toString(36).substring(2, 6); + + // If truncated is empty (e.g. name was "!!!"), fallback to 'workspace' + const finalSlug = truncated || 'workspace'; + + return `${finalSlug}-${suffix}`; +} From 6c0eafb70a5941eb8d59e2bf57ee636d60ba1645 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 18 Jan 2026 22:57:38 -0500 Subject: [PATCH 2/6] fix: remove obsolete enableParallax prop from FinalCTA to fix type error --- src/components/landing/FinalCTA.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/landing/FinalCTA.tsx b/src/components/landing/FinalCTA.tsx index 4497d98a..9ed1d6dd 100644 --- a/src/components/landing/FinalCTA.tsx +++ b/src/components/landing/FinalCTA.tsx @@ -12,7 +12,7 @@ export function FinalCTA() { > {/* Workspace Background Elements */}
- + {/* Extra Top Gradient for Final CTA blending */}
Date: Sun, 18 Jan 2026 22:59:26 -0500 Subject: [PATCH 3/6] fix(slug): strip edge hyphens and ensure suffix padding --- src/lib/workspace/slug.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/lib/workspace/slug.ts b/src/lib/workspace/slug.ts index 9883c98a..f9d6d4f9 100644 --- a/src/lib/workspace/slug.ts +++ b/src/lib/workspace/slug.ts @@ -10,15 +10,15 @@ export function generateSlug(name: string): string { .replace(/[^a-z0-9\s-]/g, '') .trim() .replace(/\s+/g, '-') - .replace(/-+/g, '-'); + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, ''); // Strip leading/trailing hyphens - // Enforce max length of 50 to match potential DB constraints (though text is usually unlimited, good practice) + // Enforce max length of 50 const truncated = base.substring(0, 50); - // Append a short random suffix to ensure uniqueness without DB round-trips - // 4 chars of entropy is usually enough for per-user uniqueness collision avoidance - // Math.random base 36 is simple - const suffix = Math.random().toString(36).substring(2, 6); + // Use Web Crypto API for better randomness and guaranteed length + // (fallback to Math.random if crypto not available, though in Node/modern browsers it is) + const suffix = Math.random().toString(36).substring(2, 6).padEnd(4, '0'); // If truncated is empty (e.g. name was "!!!"), fallback to 'workspace' const finalSlug = truncated || 'workspace'; From feee21bb7978fd7048943a3a27b2ea047c75bf62 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 18 Jan 2026 23:00:41 -0500 Subject: [PATCH 4/6] feat(workspace): add collision retry logic for robust slug generation --- src/app/api/workspaces/route.ts | 58 +++++++++++++++++++++++---------- src/lib/workspace/clone-demo.ts | 51 ++++++++++++++++++++--------- 2 files changed, 76 insertions(+), 33 deletions(-) diff --git a/src/app/api/workspaces/route.ts b/src/app/api/workspaces/route.ts index cf96325b..3e7f1e74 100644 --- a/src/app/api/workspaces/route.ts +++ b/src/app/api/workspaces/route.ts @@ -84,24 +84,46 @@ async function handlePOST(request: NextRequest) { const maxSortOrder = maxSortData[0]?.sortOrder ?? -1; const newSortOrder = maxSortOrder + 1; - // Generate slug - const slug = generateSlug(name); - - // Create workspace - const [workspace] = await db - .insert(workspaces) - .values({ - userId: userId, - name, - description: description || "", - template: effectiveTemplate, - isPublic: is_public || false, - icon: icon || null, - color: color || null, - sortOrder: newSortOrder, - slug, - }) - .returning(); + // Create workspace with retry logic for slug collisions + let workspace; + let attempts = 0; + const MAX_ATTEMPTS = 5; + + while (attempts < MAX_ATTEMPTS) { + try { + // Generate slug + const slug = generateSlug(name); + + [workspace] = await db + .insert(workspaces) + .values({ + userId: userId, + name, + description: description || "", + template: effectiveTemplate, + isPublic: is_public || false, + icon: icon || null, + color: color || null, + sortOrder: newSortOrder, + slug, + }) + .returning(); + + break; // Success + } catch (error: any) { + // Postgres unique constraint violation code is 23505 + if (error?.code === '23505') { + attempts++; + if (attempts === MAX_ATTEMPTS) throw error; + continue; + } + throw error; + } + } + + if (!workspace) { + throw new Error("Failed to create workspace after multiple attempts"); + } // Create initial state - use custom state if provided, otherwise use template let initialState; diff --git a/src/lib/workspace/clone-demo.ts b/src/lib/workspace/clone-demo.ts index 4391d95e..612bf55d 100644 --- a/src/lib/workspace/clone-demo.ts +++ b/src/lib/workspace/clone-demo.ts @@ -57,22 +57,43 @@ export async function cloneDemoWorkspace( initialState = getTemplateInitialState("blank"); } - const slug = generateSlug(name); + let workspace; + let attempts = 0; + const MAX_ATTEMPTS = 5; - const [workspace] = await db - .insert(workspaces) - .values({ - userId, - name, - description, - template: "blank", - isPublic: false, - icon, - color, - sortOrder: 0, - slug, - }) - .returning(); + while (attempts < MAX_ATTEMPTS) { + try { + const slug = generateSlug(name); + + [workspace] = await db + .insert(workspaces) + .values({ + userId, + name, + description, + template: "blank", + isPublic: false, + icon, + color, + sortOrder: 0, + slug, + }) + .returning(); + + break; // Success + } catch (error: any) { + if (error?.code === '23505') { + attempts++; + if (attempts === MAX_ATTEMPTS) throw error; + continue; + } + throw error; + } + } + + if (!workspace) { + throw new Error("Failed to create workspace after multiple attempts"); + } initialState.workspaceId = workspace.id; From 389c00b407aeb27b269a58c83523f0507c49a9f5 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 18 Jan 2026 23:06:29 -0500 Subject: [PATCH 5/6] chore: remove unused snapshotResult variable --- src/app/api/workspaces/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/workspaces/route.ts b/src/app/api/workspaces/route.ts index 3e7f1e74..2401a14e 100644 --- a/src/app/api/workspaces/route.ts +++ b/src/app/api/workspaces/route.ts @@ -159,7 +159,7 @@ async function handlePOST(request: NextRequest) { // Create snapshot event first (starts at version 0, creates version 1) try { - const snapshotResult = await db.execute(sql` + await db.execute(sql` SELECT append_workspace_event( ${workspace.id}::uuid, ${eventId}::text, From eb24ec01111f25555ec1e55f9403cc9ec5639aa4 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 18 Jan 2026 23:08:14 -0500 Subject: [PATCH 6/6] fix(workspace): adjust slug truncation and improve input validation --- src/app/api/workspaces/route.ts | 4 ++-- src/lib/workspace/slug.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/api/workspaces/route.ts b/src/app/api/workspaces/route.ts index 2401a14e..940d4158 100644 --- a/src/app/api/workspaces/route.ts +++ b/src/app/api/workspaces/route.ts @@ -69,8 +69,8 @@ async function handlePOST(request: NextRequest) { // This ensures all new workspaces start from an empty state const effectiveTemplate: WorkspaceTemplate = "blank"; - if (!name) { - return NextResponse.json({ error: "Name is required" }, { status: 400 }); + if (typeof name !== "string" || name.trim() === "") { + return NextResponse.json({ error: "Name is required and must be a string" }, { status: 400 }); } // Get max sort_order for this user to set new workspace at the end diff --git a/src/lib/workspace/slug.ts b/src/lib/workspace/slug.ts index f9d6d4f9..27829878 100644 --- a/src/lib/workspace/slug.ts +++ b/src/lib/workspace/slug.ts @@ -13,8 +13,8 @@ export function generateSlug(name: string): string { .replace(/-+/g, '-') .replace(/^-+|-+$/g, ''); // Strip leading/trailing hyphens - // Enforce max length of 50 - const truncated = base.substring(0, 50); + // Enforce max length of 50 total (45 base + 1 hyphen + 4 suffix) + const truncated = base.substring(0, 45); // Use Web Crypto API for better randomness and guaranteed length // (fallback to Math.random if crypto not available, though in Node/modern browsers it is)