Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(invite): ✨ [WIP] invite to project and board #35

Draft
wants to merge 2 commits into
base: dev
Choose a base branch
from
Draft
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
2 changes: 1 addition & 1 deletion app/[slug]/[board]/(main)/layout.tsx
Original file line number Diff line number Diff line change
@@ -64,7 +64,7 @@ export default async function BoardLayout({
<CreatePost
boardId={board.id as string}
projectId={board.projectId as string}
text="New Post"
text="New"
/>
) : null}
</div>
4 changes: 2 additions & 2 deletions app/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -62,7 +62,8 @@ export default async function ProjectPage({
/>
<section className="flex w-full flex-wrap items-center justify-center gap-2 sm:w-auto sm:justify-end">
<BoardFilter />
<ProjectOptions />
{hasAccess && <BoardView />}
<ProjectOptions projectId={project.id} />
{session.user.isInstanceAdmin && (
<CreateBoard projectId={project.id} />
)}
@@ -83,7 +84,6 @@ export default async function ProjectPage({
<div>
<span className="text-md mb-2 block sm:mb-0">Boards</span>
</div>
{hasAccess && <BoardView />}
</div>
<div className="mt-4">
<BoardsList
5 changes: 0 additions & 5 deletions app/api/invite/accept/[id]/route.ts

This file was deleted.

39 changes: 0 additions & 39 deletions app/api/invite/route.ts

This file was deleted.

72 changes: 72 additions & 0 deletions app/api/invites/accept/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { InviteStatus, ProjectBoardRole } from "@prisma/client";

import { authOptions } from "@/lib/auth";
import { db } from "@/lib/db";

export async function POST(
req: Request,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);

if (!session?.user) {
return new NextResponse("Unauthorized", { status: 401 });
}

const invite = await db.invite.findUnique({
where: { id: params.id },
include: { project: true, board: true },
});

if (!invite) {
return new NextResponse("Invite not found", { status: 404 });
}

if (
invite.recipientId !== session.user.id &&
invite.recipientEmail !== session.user.email
) {
return new NextResponse("Unauthorized", { status: 401 });
}

if (invite.status !== InviteStatus.PENDING) {
return new NextResponse("Invite is no longer valid", { status: 400 });
}

// Update invite status
await db.invite.update({
where: { id: params.id },
data: { status: InviteStatus.ACCEPTED },
});

// Add user to projectUsers or boardUsers
if (invite.projectId) {
await db.projectUser.create({
data: {
userId: session.user.id,
projectId: invite.projectId,
role: ProjectBoardRole.MEMBER,
},
});
}

if (invite.boardId) {
await db.boardUser.create({
data: {
userId: session.user.id,
boardId: invite.boardId,
role: ProjectBoardRole.MEMBER,
},
});
}

return NextResponse.json({ message: "Invite accepted successfully" });
} catch (error) {
console.error(error);

return new NextResponse("Internal Server Error", { status: 500 });
}
}
48 changes: 48 additions & 0 deletions app/api/invites/reject/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { InviteStatus } from "@prisma/client";

import { authOptions } from "@/lib/auth";
import { db } from "@/lib/db";

export async function POST(
req: Request,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);

if (!session?.user) {
return new NextResponse("Unauthorized", { status: 401 });
}

const invite = await db.invite.findUnique({
where: { id: params.id },
});

if (!invite) {
return new NextResponse("Invite not found", { status: 404 });
}

if (
invite.recipientId !== session.user.id &&
invite.recipientEmail !== session.user.email
) {
return new NextResponse("Unauthorized", { status: 401 });
}

if (invite.status !== InviteStatus.PENDING) {
return new NextResponse("Invite is no longer valid", { status: 400 });
}

// Update invite status to REJECTED
await db.invite.update({
where: { id: params.id },
data: { status: InviteStatus.REJECTED },
});

return NextResponse.json({ message: "Invite rejected successfully" });
} catch (error) {
return new NextResponse("Internal Server Error", { status: 500 });
}
}
104 changes: 104 additions & 0 deletions app/api/invites/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { NextRequest, NextResponse } from "next/server";
import { InviteStatus } from "@prisma/client";
import { z } from "zod";

import { db } from "@/lib/db";
import { authenticate } from "@/middleware/auth";
import { checkUserAccess } from "@/helpers/common/hasAccess";

const inviteSchema = z.object({
email: z.string().email(),
projectId: z.string(),
boardId: z.string().optional(),
});

type InviteData = {
projectId?: string;
status: InviteStatus;
expiresAt: Date;
senderId: string;
recipientId?: string;
recipientEmail: string;
boardId?: string;
};

export async function POST(req: NextRequest) {
const session = await authenticate(req);

if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const userId = session.id;

try {
const { email, projectId, boardId } = inviteSchema.parse(await req.json());

const hasAccess = await checkUserAccess({
userId: userId,
projectId,
});

if (!hasAccess) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

// Fetch user and check recipient existence in a single call
const [user, checkRecipient] = await Promise.all([
db.user.findUnique({ where: { id: userId } }),
db.user.findUnique({ where: { email } }),
]);

if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}

// Check if an invite already exists with the same email, projectId, or boardId
const existingInvite = await db.invite.findFirst({
where: {
OR: [
{ recipientEmail: email, projectId },
{ recipientEmail: email, boardId },
{ projectId, boardId },
],
},
});

if (existingInvite) {
return NextResponse.json(
{ message: "Invite already exists" },
{ status: 409 }
);
}

const inviteData: InviteData = {
senderId: userId,
recipientEmail: email,
projectId,
status: InviteStatus.PENDING,
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
};

// If the recipient exists, set their ID in the invite data
if (checkRecipient) {
inviteData.recipientId = checkRecipient.id;
}

// If boardId is provided, include it in the invite data
if (boardId) {
inviteData.boardId = boardId;
}

// Create the invite in the database
const createdInvite = await db.invite.create({
data: inviteData,
});

return NextResponse.json({ invite: createdInvite }, { status: 201 });
} catch (error) {
return NextResponse.json(
{ error: "An error occurred while processing the request." },
{ status: 500 }
);
}
}
82 changes: 82 additions & 0 deletions app/profile/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { UserPlus } from "lucide-react";

import { authOptions } from "@/lib/auth";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { InviteCard } from "@/components/user/invite-card";
import { db } from "@/lib/db";
import { Invite } from "@prisma/client";

export default async function Profile() {
const session = await getServerSession(authOptions);

if (!session) {
redirect("/login");
}

// get invites data
const invites = await db.invite.findMany({
where: {
recipientId: session.user.id,
},
orderBy: {
createdAt: "desc",
},
include: {
project: true,
board: true,
sender: true,
},
});

return (
<div className="mx-auto h-auto max-w-7xl overflow-hidden px-4 py-4 sm:px-6 sm:py-6 lg:px-8">
<h2 className="text-2xl font-bold">Profile</h2>
<Avatar>
<AvatarImage
alt={session.user.name ?? ""}
src={session.user.image ?? ""}
/>
<AvatarFallback>{session.user.name?.[0]}</AvatarFallback>
</Avatar>
<p>{session.user.name}</p>
<Tabs className="w-full" defaultValue="invites">
<TabsList className="bg-card">
<TabsTrigger
className="data-[state=active]:shadow-none data-[state=active]:bg-gray-100/50"
value="invites"
>
<UserPlus className="w-4 h-4 mr-2" /> Invites
</TabsTrigger>
<TabsTrigger
className="data-[state=active]:shadow-none data-[state=active]:bg-gray-100/50"
value="youtubers"
>
YouTubers
</TabsTrigger>
</TabsList>
<TabsContent value="invites">
<div>
{invites.filter((invite) => invite.status === "PENDING").length >
0 ? (
invites
.filter((invite) => invite.status === "PENDING")
.map((invite) => (
<InviteCard key={invite.id} invite={invite as Invite} />
))
) : (
<p>No invites</p>
)}
</div>
</TabsContent>
<TabsContent value="youtubers">
<div className="h-80 max-w-5xl rounded-2xl bg-red-100 flex items-center justify-center border-[1px] border-red-200 mx-2">
Test
</div>
</TabsContent>
</Tabs>
</div>
);
}
Loading