Skip to content

Commit e63eb52

Browse files
committed
feat: Collaboration functionality added (share dialog implemented, owner invite and remove collaborators, collaborators have read-only access as default, collaborators name and avatar load from Clerk)
1 parent 8ef898e commit e63eb52

7 files changed

Lines changed: 663 additions & 5 deletions

File tree

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { auth, currentUser } from "@clerk/nextjs/server";
2+
3+
import { prisma } from "@/lib/prisma";
4+
5+
function unauthorizedResponse() {
6+
return Response.json({ error: "Unauthorized" }, { status: 401 });
7+
}
8+
9+
function forbiddenResponse() {
10+
return Response.json({ error: "Forbidden" }, { status: 403 });
11+
}
12+
13+
function normalizeEmail(email: string) {
14+
return email.trim().toLowerCase();
15+
}
16+
17+
async function getCurrentIdentity() {
18+
const { userId } = await auth();
19+
20+
if (!userId) {
21+
return { userId: null, primaryEmail: null };
22+
}
23+
24+
const user = await currentUser();
25+
const primaryEmail = user?.primaryEmailAddress?.emailAddress
26+
? normalizeEmail(user.primaryEmailAddress.emailAddress)
27+
: null;
28+
29+
return { userId, primaryEmail };
30+
}
31+
32+
async function isOwner(userId: string, projectId: string) {
33+
const project = await prisma.project.findUnique({
34+
where: { id: projectId },
35+
select: { ownerId: true },
36+
});
37+
38+
return Boolean(project && project.ownerId === userId);
39+
}
40+
41+
export async function DELETE(
42+
_request: Request,
43+
context: { params: Promise<{ projectId: string; collaboratorId: string }> },
44+
) {
45+
const identity = await getCurrentIdentity();
46+
if (!identity.userId) {
47+
return unauthorizedResponse();
48+
}
49+
50+
const { projectId, collaboratorId } = await context.params;
51+
const owner = await isOwner(identity.userId, projectId);
52+
if (!owner) {
53+
return forbiddenResponse();
54+
}
55+
56+
const collaborator = await prisma.projectCollaborator.findUnique({
57+
where: { id: collaboratorId },
58+
select: { id: true, projectId: true },
59+
});
60+
61+
if (!collaborator || collaborator.projectId !== projectId) {
62+
return forbiddenResponse();
63+
}
64+
65+
await prisma.projectCollaborator.delete({
66+
where: { id: collaboratorId },
67+
});
68+
69+
return Response.json({ success: true });
70+
}
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import { auth, clerkClient, currentUser } from "@clerk/nextjs/server";
2+
3+
import { prisma } from "@/lib/prisma";
4+
5+
interface ClerkUserSummary {
6+
imageUrl: string;
7+
firstName: string | null;
8+
lastName: string | null;
9+
username: string | null;
10+
}
11+
12+
function unauthorizedResponse() {
13+
return Response.json({ error: "Unauthorized" }, { status: 401 });
14+
}
15+
16+
function forbiddenResponse() {
17+
return Response.json({ error: "Forbidden" }, { status: 403 });
18+
}
19+
20+
function badRequestResponse(message: string) {
21+
return Response.json({ error: message }, { status: 400 });
22+
}
23+
24+
function normalizeEmail(email: string) {
25+
return email.trim().toLowerCase();
26+
}
27+
28+
function isValidEmail(email: string) {
29+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
30+
}
31+
32+
function getDisplayName(user: ClerkUserSummary) {
33+
const fullName = `${user.firstName ?? ""} ${user.lastName ?? ""}`.trim();
34+
if (fullName.length > 0) {
35+
return fullName;
36+
}
37+
if (user.username && user.username.length > 0) {
38+
return user.username;
39+
}
40+
return null;
41+
}
42+
43+
async function getProjectMembership(
44+
userId: string,
45+
primaryEmail: string | null,
46+
projectId: string,
47+
): Promise<{ projectId: string; isOwner: boolean } | null> {
48+
const project = await prisma.project.findUnique({
49+
where: { id: projectId },
50+
select: {
51+
id: true,
52+
ownerId: true,
53+
collaborators: primaryEmail
54+
? {
55+
where: {
56+
collaboratorEmail: primaryEmail,
57+
},
58+
select: { id: true },
59+
take: 1,
60+
}
61+
: false,
62+
},
63+
});
64+
65+
if (!project) {
66+
return null;
67+
}
68+
69+
if (project.ownerId === userId) {
70+
return { projectId: project.id, isOwner: true };
71+
}
72+
73+
if (!primaryEmail) {
74+
return null;
75+
}
76+
77+
const isCollaborator = Array.isArray(project.collaborators) && project.collaborators.length > 0;
78+
if (!isCollaborator) {
79+
return null;
80+
}
81+
82+
return { projectId: project.id, isOwner: false };
83+
}
84+
85+
async function getCurrentIdentity() {
86+
const { userId } = await auth();
87+
88+
if (!userId) {
89+
return { userId: null, primaryEmail: null };
90+
}
91+
92+
const user = await currentUser();
93+
const primaryEmail = user?.primaryEmailAddress?.emailAddress
94+
? normalizeEmail(user.primaryEmailAddress.emailAddress)
95+
: null;
96+
97+
return { userId, primaryEmail };
98+
}
99+
100+
async function getClerkUsersByEmail(emails: string[]) {
101+
if (emails.length === 0) {
102+
return new Map<string, ClerkUserSummary>();
103+
}
104+
105+
const client = await clerkClient();
106+
const users = await client.users.getUserList({
107+
emailAddress: emails,
108+
limit: emails.length,
109+
});
110+
111+
const usersByEmail = new Map<string, ClerkUserSummary>();
112+
for (const user of users.data) {
113+
for (const address of user.emailAddresses) {
114+
usersByEmail.set(normalizeEmail(address.emailAddress), {
115+
imageUrl: user.imageUrl,
116+
firstName: user.firstName,
117+
lastName: user.lastName,
118+
username: user.username,
119+
});
120+
}
121+
}
122+
123+
return usersByEmail;
124+
}
125+
126+
export async function GET(
127+
_request: Request,
128+
context: { params: Promise<{ projectId: string }> },
129+
) {
130+
const identity = await getCurrentIdentity();
131+
if (!identity.userId) {
132+
return unauthorizedResponse();
133+
}
134+
135+
const { projectId } = await context.params;
136+
const membership = await getProjectMembership(identity.userId, identity.primaryEmail, projectId);
137+
if (!membership) {
138+
return forbiddenResponse();
139+
}
140+
141+
const collaborators = await prisma.projectCollaborator.findMany({
142+
where: { projectId: membership.projectId },
143+
orderBy: { createdAt: "asc" },
144+
select: {
145+
id: true,
146+
collaboratorEmail: true,
147+
createdAt: true,
148+
},
149+
});
150+
151+
const collaboratorEmails = collaborators.map((entry) => entry.collaboratorEmail.toLowerCase());
152+
const usersByEmail = await getClerkUsersByEmail(collaboratorEmails);
153+
154+
return Response.json({
155+
canManage: membership.isOwner,
156+
collaborators: collaborators.map((entry) => {
157+
const email = entry.collaboratorEmail.toLowerCase();
158+
const user = usersByEmail.get(email);
159+
return {
160+
id: entry.id,
161+
email,
162+
name: user ? getDisplayName(user) : null,
163+
avatarUrl: user ? user.imageUrl : null,
164+
createdAt: entry.createdAt.toISOString(),
165+
};
166+
}),
167+
});
168+
}
169+
170+
export async function POST(
171+
request: Request,
172+
context: { params: Promise<{ projectId: string }> },
173+
) {
174+
const identity = await getCurrentIdentity();
175+
if (!identity.userId) {
176+
return unauthorizedResponse();
177+
}
178+
179+
const { projectId } = await context.params;
180+
const membership = await getProjectMembership(identity.userId, identity.primaryEmail, projectId);
181+
if (!membership) {
182+
return forbiddenResponse();
183+
}
184+
if (!membership.isOwner) {
185+
return forbiddenResponse();
186+
}
187+
188+
let payload: unknown = null;
189+
try {
190+
payload = await request.json();
191+
} catch {
192+
payload = null;
193+
}
194+
195+
const invitedEmail =
196+
typeof payload === "object" &&
197+
payload !== null &&
198+
"email" in payload &&
199+
typeof payload.email === "string"
200+
? normalizeEmail(payload.email)
201+
: "";
202+
203+
if (!isValidEmail(invitedEmail)) {
204+
return badRequestResponse("Invalid email");
205+
}
206+
207+
if (identity.primaryEmail && invitedEmail === identity.primaryEmail) {
208+
return badRequestResponse("Owner is already a member");
209+
}
210+
211+
try {
212+
const collaborator = await prisma.projectCollaborator.create({
213+
data: {
214+
projectId: membership.projectId,
215+
collaboratorEmail: invitedEmail,
216+
},
217+
select: {
218+
id: true,
219+
collaboratorEmail: true,
220+
},
221+
});
222+
223+
return Response.json(
224+
{
225+
collaborator: {
226+
id: collaborator.id,
227+
email: collaborator.collaboratorEmail,
228+
},
229+
},
230+
{ status: 201 },
231+
);
232+
} catch {
233+
return Response.json({ error: "Collaborator already exists" }, { status: 409 });
234+
}
235+
}

components/editor/editor-workspace-shell.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { UserButton } from "@clerk/nextjs";
66

77
import { ProjectDialogs } from "@/components/editor/project-dialogs";
88
import { ProjectSidebar } from "@/components/editor/project-sidebar";
9+
import { ShareDialog } from "@/components/editor/share-dialog";
910
import { Button } from "@/components/ui/button";
1011
import { useProjectActions } from "@/hooks/use-project-actions";
1112
import type { SidebarProject } from "@/lib/project-data";
@@ -25,6 +26,7 @@ export function EditorWorkspaceShell({
2526
}: EditorWorkspaceShellProps) {
2627
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
2728
const [isAiSidebarOpen, setIsAiSidebarOpen] = useState(true);
29+
const [isShareDialogOpen, setIsShareDialogOpen] = useState(false);
2830
const {
2931
activeDialog,
3032
selectedProject,
@@ -58,7 +60,7 @@ export function EditorWorkspaceShell({
5860
<p className="truncate text-sm font-semibold text-foreground">{projectName}</p>
5961
</div>
6062
<div className="flex items-center gap-2">
61-
<Button type="button" variant="outline" size="sm" disabled>
63+
<Button type="button" variant="outline" size="sm" onClick={() => setIsShareDialogOpen(true)}>
6264
<Share2 className="h-4 w-4" />
6365
Share
6466
</Button>
@@ -125,6 +127,7 @@ export function EditorWorkspaceShell({
125127
onRename={submitRename}
126128
onDelete={submitDelete}
127129
/>
130+
<ShareDialog open={isShareDialogOpen} onOpenChange={setIsShareDialogOpen} projectId={projectId} />
128131
</div>
129132
);
130133
}

0 commit comments

Comments
 (0)