Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { NextResponse } from "next/server";

import { getCurrentUserId } from "@/lib/auth";
import { prisma } from "@/lib/prisma";

/**
* Remove a collaborator. Only the owner may remove.
*/
export async function DELETE(
request: Request,
{
params,
}: {
params: Promise<{ projectId: string; collaboratorId: string }>;
},
) {
const userId = await getCurrentUserId();
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const { projectId, collaboratorId } = await params;

const project = await prisma.project.findUnique({
where: { id: projectId },
select: { id: true, ownerId: true },
});

if (!project) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}

// Only owner can remove collaborators
if (project.ownerId !== userId) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}

// Find the collaborator
const collaborator = await prisma.projectCollaborator.findUnique({
where: { id: collaboratorId },
select: { id: true, projectId: true },
});

if (!collaborator || collaborator.projectId !== projectId) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}

await prisma.projectCollaborator.delete({
where: { id: collaboratorId },
});
Comment on lines +39 to +50

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Non-atomic find-then-delete can throw on a concurrent duplicate request.

Between the existence check (39-42) and the delete call (48-50), another request removing the same collaborator (e.g., a double-click before the UI disables the button) can delete the row first; the second delete then throws an uncaught Prisma "record not found" error instead of returning a clean 404.

🛡️ Proposed fix
-  const collaborator = await prisma.projectCollaborator.findUnique({
-    where: { id: collaboratorId },
-    select: { id: true, projectId: true },
-  });
-
-  if (!collaborator || collaborator.projectId !== projectId) {
-    return NextResponse.json({ error: "Not found" }, { status: 404 });
-  }
-
-  await prisma.projectCollaborator.delete({
-    where: { id: collaboratorId },
-  });
+  const { count } = await prisma.projectCollaborator.deleteMany({
+    where: { id: collaboratorId, projectId },
+  });
+
+  if (count === 0) {
+    return NextResponse.json({ error: "Not found" }, { status: 404 });
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const collaborator = await prisma.projectCollaborator.findUnique({
where: { id: collaboratorId },
select: { id: true, projectId: true },
});
if (!collaborator || collaborator.projectId !== projectId) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
await prisma.projectCollaborator.delete({
where: { id: collaboratorId },
});
const { count } = await prisma.projectCollaborator.deleteMany({
where: { id: collaboratorId, projectId },
});
if (count === 0) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/projects/`[projectId]/collaborators/[collaboratorId]/route.ts around
lines 39 - 50, Update the collaborator deletion flow to handle concurrent
duplicate requests: keep the project ownership validation, but catch Prisma’s
record-not-found failure from the `projectCollaborator.delete` call and return
the same 404 response instead of allowing the exception to escape. Preserve
successful deletion behavior and avoid changing unrelated route logic.


return new NextResponse(null, { status: 204 });
}
187 changes: 187 additions & 0 deletions app/api/projects/[projectId]/collaborators/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { NextResponse } from "next/server";

import { getCurrentUserId, getClerkUsersByEmails } from "@/lib/auth";
import { prisma } from "@/lib/prisma";

interface InviteBody {
email?: unknown;
}

function readEmail(body: unknown): string | null {
if (body === null || typeof body !== "object") return null;
const raw = (body as InviteBody).email;
if (typeof raw !== "string") return null;
const trimmed = raw.trim();
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(trimmed) ? trimmed : null;
}
Comment on lines +10 to +18

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Duplicate-collaborator check is case-sensitive while stored emails are lowercased.

readEmail only trims the input (Line 14), so the existing-collaborator lookup at Line 134-142 compares raw-case input against records that are always persisted lowercased (Line 168). Inviting Foo@Bar.com then foo@bar.com will bypass the "already a collaborator" check and create a duplicate row for the same person. The owner-email comparison later (Line 158) already lowercases both sides, showing the inconsistency is unintentional.

🐛 Proposed fix
 function readEmail(body: unknown): string | null {
   if (body === null || typeof body !== "object") return null;
   const raw = (body as InviteBody).email;
   if (typeof raw !== "string") return null;
-  const trimmed = raw.trim();
+  const trimmed = raw.trim().toLowerCase();
   // Basic email validation
   const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
   return emailRegex.test(trimmed) ? trimmed : null;
 }

Also applies to: 134-149

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/projects/`[projectId]/collaborators/route.ts around lines 10 - 18,
Normalize the validated email to lowercase in readEmail before returning it, so
the existing-collaborator lookup and subsequent persistence use a consistent
canonical value. Preserve trimming and validation, and keep the owner-email
comparison behavior unchanged.


/**
* List collaborators for a project. Owner and collaborators can view.
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ projectId: string }> },
) {
const userId = await getCurrentUserId();
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const { projectId } = await params;

const project = await prisma.project.findUnique({
where: { id: projectId },
select: { id: true, ownerId: true },
});

if (!project) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}

// Check access: owner or collaborator
const isOwner = project.ownerId === userId;

// Get user's email from Clerk
const { clerkClient } = await import("@clerk/nextjs/server");
const clerk = await clerkClient();
const user = await clerk.users.getUser(userId);
const primaryEmail = user.emailAddresses.find(
(e) => e.id === user.primaryEmailAddressId
);
const userEmail = primaryEmail?.emailAddress ?? "";

const actualCollaborator = await prisma.projectCollaborator.findUnique({
where: {
projectId_email: {
projectId: project.id,
email: userEmail,
},
},
});

const hasAccess = isOwner || !!actualCollaborator;
if (!hasAccess) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}

const collaborators = await prisma.projectCollaborator.findMany({
where: { projectId },
orderBy: { createdAt: "asc" },
});

// Enrich with Clerk user data
const emails = collaborators.map((c) => c.email);
const clerkUsers = await getClerkUsersByEmails(emails);

const enrichedCollaborators = collaborators.map((c) => {
const clerkUser = clerkUsers.get(c.email);
return {
...c,
createdAt: c.createdAt.toISOString(),
name: clerkUser?.name ?? null,
avatarUrl: clerkUser?.avatarUrl ?? null,
};
});

return NextResponse.json({ collaborators: enrichedCollaborators });
}

/**
* Invite a collaborator. Only the owner may invite.
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ projectId: string }> },
) {
const userId = await getCurrentUserId();
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const { projectId } = await params;

const project = await prisma.project.findUnique({
where: { id: projectId },
select: { id: true, ownerId: true },
});

if (!project) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}

// Only owner can invite
if (project.ownerId !== userId) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}

let body: unknown = null;
try {
body = await request.json();
} catch {
body = null;
}

const email = readEmail(body);
if (!email) {
return NextResponse.json(
{ error: "Valid email is required" },
{ status: 400 },
);
}

// Check if already a collaborator
const existing = await prisma.projectCollaborator.findUnique({
where: {
projectId_email: {
projectId: project.id,
email,
},
},
});

if (existing) {
return NextResponse.json(
{ error: "User is already a collaborator" },
{ status: 409 },
);
}

// Don't allow inviting the owner
const { clerkClient } = await import("@clerk/nextjs/server");
const clerk = await clerkClient();
const owner = await clerk.users.getUser(project.ownerId);
const ownerEmail = owner.emailAddresses.find(
(e) => e.id === owner.primaryEmailAddressId
);
if (ownerEmail?.emailAddress.toLowerCase() === email.toLowerCase()) {
return NextResponse.json(
{ error: "Cannot invite the project owner" },
{ status: 400 },
);
}

const collaborator = await prisma.projectCollaborator.create({
data: {
projectId: project.id,
email: email.toLowerCase(),
},
});
Comment on lines +165 to +170

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Concurrent invites for the same email can throw an unhandled Prisma unique-constraint error instead of a clean 409.

The existing-collaborator check (134-149) and this create call are not atomic. Two overlapping invite requests for the same email can both pass the check and race to create; the loser hits the projectId_email unique constraint and throws, resulting in an ungraceful 500 rather than the intended 409 response.

🛡️ Proposed fix
-  const collaborator = await prisma.projectCollaborator.create({
-    data: {
-      projectId: project.id,
-      email: email.toLowerCase(),
-    },
-  });
+  let collaborator;
+  try {
+    collaborator = await prisma.projectCollaborator.create({
+      data: { projectId: project.id, email: email.toLowerCase() },
+    });
+  } catch (err) {
+    if (
+      err instanceof Prisma.PrismaClientKnownRequestError &&
+      err.code === "P2002"
+    ) {
+      return NextResponse.json(
+        { error: "User is already a collaborator" },
+        { status: 409 },
+      );
+    }
+    throw err;
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const collaborator = await prisma.projectCollaborator.create({
data: {
projectId: project.id,
email: email.toLowerCase(),
},
});
let collaborator;
try {
collaborator = await prisma.projectCollaborator.create({
data: { projectId: project.id, email: email.toLowerCase() },
});
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === "P2002"
) {
return NextResponse.json(
{ error: "User is already a collaborator" },
{ status: 409 },
);
}
throw err;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/projects/`[projectId]/collaborators/route.ts around lines 165 - 170,
Update the collaborator creation flow around prisma.projectCollaborator.create
to catch Prisma unique-constraint violations for the projectId_email constraint
caused by concurrent invites, and return the same clean 409 response used by the
existing-collaborator check. Preserve normal creation and unrelated database
errors.


// Try to get Clerk user data for the response
const { getClerkUserByEmail } = await import("@/lib/auth");
const clerkUser = await getClerkUserByEmail(email.toLowerCase());

return NextResponse.json(
{
collaborator: {
...collaborator,
createdAt: collaborator.createdAt.toISOString(),
name: clerkUser?.name ?? null,
avatarUrl: clerkUser?.avatarUrl ?? null,
},
},
{ status: 201 },
);
}
66 changes: 66 additions & 0 deletions app/editor/[roomId]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { redirect } from "next/navigation";

import { getCurrentUser } from "@/lib/auth";
import { getProjectWithAccess } from "@/lib/project-access";
import { getAllProjects } from "@/lib/projects";
import { slugify } from "@/lib/mock-projects";
import { WorkspaceShell } from "@/components/editor/workspace-shell";

interface EditorRoomPageProps {
params: Promise<{ roomId: string }>;
}

/**
* Editor workspace page for a specific project. Server component that checks
* access permissions and renders the workspace shell.
*/
export default async function EditorRoomPage({ params }: EditorRoomPageProps) {
const { roomId } = await params;

// Check authentication
const currentUser = await getCurrentUser();
if (!currentUser) {
redirect("/sign-in");
}

// Check project access
const project = await getProjectWithAccess(roomId);
if (!project) {
// Return the shell with AccessDenied - we'll render it client-side
return (
<WorkspaceShell
project={null}
currentRoomId={roomId}
ownedProjects={[]}
sharedProjects={[]}
/>
);
}

// Fetch all projects for the sidebar (owned by userId, shared by email)
const { owned, shared } = await getAllProjects(currentUser.userId, currentUser.email);

// Transform to UI shape
const ownedProjects = owned.map((p) => ({
id: p.id,
name: p.name,
slug: slugify(p.name),
role: "owner" as const,
}));

const sharedProjects = shared.map((p) => ({
id: p.id,
name: p.name,
slug: slugify(p.name),
role: "collaborator" as const,
}));

return (
<WorkspaceShell
project={{ id: project.id, name: project.name, ownerId: project.ownerId }}
currentRoomId={roomId}
ownedProjects={ownedProjects}
sharedProjects={sharedProjects}
/>
);
}
8 changes: 4 additions & 4 deletions app/editor/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { redirect } from "next/navigation";

import { getCurrentUserId } from "@/lib/auth";
import { getCurrentUser } from "@/lib/auth";
import { getAllProjects } from "@/lib/projects";
import { slugify } from "@/lib/mock-projects";
import { EditorShell } from "@/components/editor/editor-shell";
Expand All @@ -10,14 +10,14 @@ import { EditorShell } from "@/components/editor/editor-shell";
* server-side and passes them to the client shell for dialog/mutation handling.
*/
export default async function EditorPage() {
const userId = await getCurrentUserId();
const currentUser = await getCurrentUser();

// Redirect unauthenticated users to sign-in
if (!userId) {
if (!currentUser) {
redirect("/sign-in");
}

const { owned, shared } = await getAllProjects(userId);
const { owned, shared } = await getAllProjects(currentUser.userId, currentUser.email);

// Transform database records to the UI shape
const ownedProjects = owned.map((p) => ({
Expand Down
33 changes: 33 additions & 0 deletions components/editor/access-denied.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"use client";

import { Lock } from "lucide-react";
import Link from "next/link";

/**
* AccessDenied component shown when a user doesn't have access to a project.
* Displays a centered layout with a lock icon, message, and link back to editor.
*/
export function AccessDenied() {
return (
<div className="flex h-full w-full items-center justify-center px-6">
<div className="flex max-w-md flex-col items-center gap-4 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-bg-elevated">
<Lock className="h-8 w-8 text-text-muted" />
</div>
<h1 className="text-xl font-semibold tracking-tight text-text-primary">
Access Denied
</h1>
<p className="text-sm text-text-muted">
You don&apos;t have access to this project. It may not exist or you
may not be invited as a collaborator.
</p>
<Link
href="/editor"
className="mt-2 text-sm font-medium text-accent-primary hover:underline"
>
← Back to Editor
</Link>
</div>
</div>
);
}
3 changes: 3 additions & 0 deletions components/editor/editor-shell.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";

import { EditorHome } from "@/components/editor/editor-home";
import { EditorNavbar } from "@/components/editor/editor-navbar";
Expand All @@ -16,6 +17,7 @@ interface EditorShellProps {
}

export function EditorShell({ initialOwned, initialShared }: EditorShellProps) {
const router = useRouter();
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
const {
ownedProjects,
Expand Down Expand Up @@ -55,6 +57,7 @@ export function EditorShell({ initialOwned, initialShared }: EditorShellProps) {
onCreateProject={openCreate}
onRenameProject={openRename}
onDeleteProject={openDelete}
onOpenProject={(projectId) => router.push(`/editor/${projectId}`)}
/>
<EditorHome onCreateProject={openCreate} />
</main>
Expand Down
Loading