From 7b53811dd58182c41ef62261601698c36356c7cd Mon Sep 17 00:00:00 2001 From: Samet Ekin Polat Date: Fri, 1 May 2026 23:05:48 +0300 Subject: [PATCH 1/5] feat #34: add validation schemas and server actions Implements all 4 community user stories server-side: create community (with uniqueness check), join/leave (with owner restriction), add/reorder rules (with permission check), send and respond to invites (with notification). --- src/actions/communities.ts | 390 +++++++++++++++++++++++++++++++ src/lib/validations/community.ts | 27 +++ 2 files changed, 417 insertions(+) create mode 100644 src/actions/communities.ts create mode 100644 src/lib/validations/community.ts diff --git a/src/actions/communities.ts b/src/actions/communities.ts new file mode 100644 index 0000000..020ca97 --- /dev/null +++ b/src/actions/communities.ts @@ -0,0 +1,390 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { auth } from "@/auth"; +import { prisma } from "@/lib/prisma"; +import { Prisma, InviteStatus, NotificationType } from "@/generated/prisma/client"; +import { AddRuleSchema, CreateCommunitySchema, InviteUserSchema } from "@/lib/validations/community"; + +type CommunityActionResult = { + error?: string; + success?: string; +}; + +// ─── Story 1: Create Community ─────────────────────────────────────────────── + +export async function createCommunityAction( + formData: FormData +): Promise { + const session = await auth(); + if (!session?.user?.id) { + return { error: "Please log in before creating a community." }; + } + + const validated = CreateCommunitySchema.safeParse({ + name: formData.get("name"), + description: formData.get("description"), + isNsfw: formData.get("isNsfw"), + }); + + if (!validated.success) { + return { error: validated.error.issues[0]?.message ?? "Community could not be created." }; + } + + const { name, description, isNsfw } = validated.data; + const userId = session.user.id; + + try { + await prisma.$transaction(async (tx) => { + const community = await tx.community.create({ + data: { name, description, isNsfw, ownerId: userId }, + select: { id: true }, + }); + + await tx.communityMember.create({ + data: { userId, communityId: community.id }, + }); + + await tx.communityModerator.create({ + data: { + userId, + communityId: community.id, + canManageSettings: true, + canManagePosts: true, + canRestrictUsers: true, + }, + }); + }); + + revalidatePath("/communities"); + return { success: name }; + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + return { error: "This community name is already taken." }; + } + console.error("createCommunityAction failed", error); + return { error: "Something went wrong while creating the community." }; + } +} + +// ─── Story 3: Join Community ───────────────────────────────────────────────── + +export async function joinCommunityAction( + communityId: string, + communityName: string +): Promise { + const session = await auth(); + if (!session?.user?.id) { + return { error: "Please log in to join a community." }; + } + + try { + await prisma.communityMember.create({ + data: { userId: session.user.id, communityId }, + }); + + revalidatePath(`/communities/${communityName}`); + return { success: "You have joined the community." }; + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + return { error: "You are already a member of this community." }; + } + console.error("joinCommunityAction failed", error); + return { error: "Something went wrong while joining the community." }; + } +} + +// ─── Story 3: Leave Community ──────────────────────────────────────────────── + +export async function leaveCommunityAction( + communityId: string, + communityName: string +): Promise { + const session = await auth(); + if (!session?.user?.id) { + return { error: "Please log in to leave a community." }; + } + + const community = await prisma.community.findUnique({ + where: { id: communityId }, + select: { ownerId: true }, + }); + + if (!community) { + return { error: "Community not found." }; + } + + if (community.ownerId === session.user.id) { + return { + error: "You cannot leave a community you own. Transfer ownership or delete it first.", + }; + } + + try { + await prisma.communityMember.delete({ + where: { + userId_communityId: { userId: session.user.id, communityId }, + }, + }); + + revalidatePath(`/communities/${communityName}`); + return { success: "You have left the community." }; + } catch (error) { + console.error("leaveCommunityAction failed", error); + return { error: "Something went wrong while leaving the community." }; + } +} + +// ─── Story 2: Add Community Rule ───────────────────────────────────────────── + +export async function addRuleAction( + formData: FormData +): Promise { + const session = await auth(); + if (!session?.user?.id) { + return { error: "Please log in to manage community rules." }; + } + + const validated = AddRuleSchema.safeParse({ + communityId: formData.get("communityId"), + communityName: formData.get("communityName"), + title: formData.get("title"), + description: formData.get("description"), + }); + + if (!validated.success) { + return { error: validated.error.issues[0]?.message ?? "Rule could not be added." }; + } + + const { communityId, communityName, title, description } = validated.data; + + const mod = await prisma.communityModerator.findUnique({ + where: { userId_communityId: { userId: session.user.id, communityId } }, + select: { canManageSettings: true }, + }); + + if (!mod?.canManageSettings) { + return { error: "Unauthorized: you do not have permission to manage rules." }; + } + + const lastRule = await prisma.communityRule.findFirst({ + where: { communityId }, + orderBy: { displayOrder: "desc" }, + select: { displayOrder: true }, + }); + + const nextOrder = (lastRule?.displayOrder ?? -1) + 1; + + try { + await prisma.communityRule.create({ + data: { communityId, title, description, displayOrder: nextOrder }, + }); + + revalidatePath(`/communities/${communityName}/settings`); + revalidatePath(`/communities/${communityName}`); + return { success: "Rule added." }; + } catch (error) { + console.error("addRuleAction failed", error); + return { error: "Something went wrong while adding the rule." }; + } +} + +// ─── Story 2: Reorder Rules ─────────────────────────────────────────────────── + +export async function reorderRulesAction( + orderedIds: string[], + communityId: string, + communityName: string +): Promise { + const session = await auth(); + if (!session?.user?.id) { + return { error: "Please log in to manage community rules." }; + } + + const mod = await prisma.communityModerator.findUnique({ + where: { userId_communityId: { userId: session.user.id, communityId } }, + select: { canManageSettings: true }, + }); + + if (!mod?.canManageSettings) { + return { error: "Unauthorized: you do not have permission to manage rules." }; + } + + try { + await prisma.$transaction( + orderedIds.map((id, index) => + prisma.communityRule.updateMany({ + where: { id, communityId }, + data: { displayOrder: index }, + }) + ) + ); + + revalidatePath(`/communities/${communityName}/settings`); + revalidatePath(`/communities/${communityName}`); + return { success: "Rules reordered." }; + } catch (error) { + console.error("reorderRulesAction failed", error); + return { error: "Something went wrong while reordering rules." }; + } +} + +// ─── Story 4: Send Community Invite ────────────────────────────────────────── + +export async function sendInviteAction( + formData: FormData +): Promise { + const session = await auth(); + if (!session?.user?.id) { + return { error: "Please log in to invite users." }; + } + + const validated = InviteUserSchema.safeParse({ + communityId: formData.get("communityId"), + communityName: formData.get("communityName"), + inviteeUsername: formData.get("inviteeUsername"), + }); + + if (!validated.success) { + return { error: validated.error.issues[0]?.message ?? "Invite could not be sent." }; + } + + const { communityId, communityName, inviteeUsername } = validated.data; + const inviterId = session.user.id; + + const invitee = await prisma.user.findUnique({ + where: { username: inviteeUsername }, + select: { id: true }, + }); + + if (!invitee) { + return { error: "User not found." }; + } + + if (invitee.id === inviterId) { + return { error: "You cannot invite yourself." }; + } + + const isMember = await prisma.communityMember.findUnique({ + where: { userId_communityId: { userId: inviterId, communityId } }, + select: { userId: true }, + }); + + if (!isMember) { + return { error: "You must be a member to invite others." }; + } + + const alreadyMember = await prisma.communityMember.findUnique({ + where: { userId_communityId: { userId: invitee.id, communityId } }, + select: { userId: true }, + }); + + if (alreadyMember) { + return { error: "This user is already a member." }; + } + + const pendingInvite = await prisma.communityInvite.findFirst({ + where: { communityId, inviteeId: invitee.id, status: InviteStatus.PENDING }, + select: { id: true }, + }); + + if (pendingInvite) { + return { error: "An invite is already pending for this user." }; + } + + try { + await prisma.$transaction(async (tx) => { + const invite = await tx.communityInvite.create({ + data: { + communityId, + inviterId, + inviteeId: invitee.id, + status: InviteStatus.PENDING, + }, + select: { id: true }, + }); + + await tx.notification.create({ + data: { + userId: invitee.id, + actorId: inviterId, + type: NotificationType.COMMUNITY_INVITE, + inviteId: invite.id, + }, + }); + }); + + revalidatePath(`/communities/${communityName}`); + return { success: `Invite sent to ${inviteeUsername}.` }; + } catch (error) { + console.error("sendInviteAction failed", error); + return { error: "Something went wrong while sending the invite." }; + } +} + +// ─── Story 4: Respond to Invite ─────────────────────────────────────────────── + +export async function respondToInviteAction( + inviteId: string, + accept: boolean +): Promise { + const session = await auth(); + if (!session?.user?.id) { + return { error: "Please log in to respond to invites." }; + } + + const invite = await prisma.communityInvite.findUnique({ + where: { id: inviteId }, + select: { inviteeId: true, communityId: true, status: true }, + }); + + if (!invite) { + return { error: "Invite not found." }; + } + + if (invite.inviteeId !== session.user.id) { + return { error: "Unauthorized." }; + } + + if (invite.status !== InviteStatus.PENDING) { + return { error: "This invite has already been responded to." }; + } + + try { + if (accept) { + await prisma.$transaction([ + prisma.communityInvite.update({ + where: { id: inviteId }, + data: { status: InviteStatus.ACCEPTED }, + }), + prisma.communityMember.upsert({ + where: { + userId_communityId: { + userId: session.user.id, + communityId: invite.communityId, + }, + }, + update: {}, + create: { userId: session.user.id, communityId: invite.communityId }, + }), + ]); + } else { + await prisma.communityInvite.update({ + where: { id: inviteId }, + data: { status: InviteStatus.REJECTED }, + }); + } + + revalidatePath("/communities"); + return { success: accept ? "You joined the community." : "Invite declined." }; + } catch (error) { + console.error("respondToInviteAction failed", error); + return { error: "Something went wrong while responding to the invite." }; + } +} diff --git a/src/lib/validations/community.ts b/src/lib/validations/community.ts new file mode 100644 index 0000000..d9a8048 --- /dev/null +++ b/src/lib/validations/community.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; + +export const CreateCommunitySchema = z.object({ + name: z + .string() + .trim() + .min(3, "Community name must be at least 3 characters.") + .max(21, "Community name must be 21 characters or fewer.") + .regex(/^[a-zA-Z0-9_]+$/, "Only letters, numbers, and underscores are allowed."), + description: z.string().trim().max(500, "Description is too long.").optional(), + isNsfw: z + .preprocess((val) => val === "on" || val === true, z.boolean()) + .default(false), +}); + +export const AddRuleSchema = z.object({ + communityId: z.string().uuid("Invalid community."), + communityName: z.string().trim().min(1), + title: z.string().trim().min(1, "Rule title is required.").max(100, "Title is too long."), + description: z.string().trim().max(500, "Description is too long.").optional(), +}); + +export const InviteUserSchema = z.object({ + communityId: z.string().uuid("Invalid community."), + communityName: z.string().trim().min(1), + inviteeUsername: z.string().trim().min(1, "Username is required."), +}); From b3aa415ea3999942f2255fe9ef959bbefc808a17 Mon Sep 17 00:00:00 2001 From: Samet Ekin Polat Date: Fri, 1 May 2026 23:07:11 +0300 Subject: [PATCH 2/5] feat #34: add browse page and create community form Adds /communities listing page with community cards and a Sheet-based create form. Name validation, uniqueness error, and redirect on success. --- src/app/(main)/communities/page.tsx | 26 +++++ .../communities/communities-page-client.tsx | 104 ++++++++++++++++++ .../communities/create-community-form.tsx | 98 +++++++++++++++++ 3 files changed, 228 insertions(+) create mode 100644 src/app/(main)/communities/page.tsx create mode 100644 src/components/communities/communities-page-client.tsx create mode 100644 src/components/communities/create-community-form.tsx diff --git a/src/app/(main)/communities/page.tsx b/src/app/(main)/communities/page.tsx new file mode 100644 index 0000000..344f01c --- /dev/null +++ b/src/app/(main)/communities/page.tsx @@ -0,0 +1,26 @@ +import { prisma } from "@/lib/prisma"; +import { CommunitiesPageClient } from "@/components/communities/communities-page-client"; + +export const dynamic = "force-dynamic"; + +export default async function CommunitiesPage() { + const communities = await prisma.community.findMany({ + where: { isUserProfile: false }, + include: { + _count: { select: { members: true } }, + }, + orderBy: { createdAt: "desc" }, + take: 50, + }); + + const serialized = communities.map((c) => ({ + id: c.id, + name: c.name, + description: c.description, + isNsfw: c.isNsfw, + memberCount: c._count.members, + createdAt: c.createdAt.toISOString(), + })); + + return ; +} diff --git a/src/components/communities/communities-page-client.tsx b/src/components/communities/communities-page-client.tsx new file mode 100644 index 0000000..77c3b1e --- /dev/null +++ b/src/components/communities/communities-page-client.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { Users, Plus, ShieldAlert } from "lucide-react"; +import { useSession } from "next-auth/react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; +import { CreateCommunityForm } from "@/components/communities/create-community-form"; + +type CommunityItem = { + id: string; + name: string; + description: string | null; + isNsfw: boolean; + memberCount: number; + createdAt: string; +}; + +type CommunitiesPageClientProps = { + communities: CommunityItem[]; +}; + +export function CommunitiesPageClient({ communities }: CommunitiesPageClientProps) { + const { data: session } = useSession(); + const [isSheetOpen, setIsSheetOpen] = useState(false); + + return ( +
+ {/* Header row */} +
+
+

Communities

+

+ Discover spaces to discuss what you care about. +

+
+ {session?.user && ( + + + + + + + Create a Community + +
+ setIsSheetOpen(false)} /> +
+
+
+ )} +
+ + {/* List */} + {communities.length === 0 ? ( +
+ +

No communities yet. Be the first to create one!

+
+ ) : ( +
+ {communities.map((community) => ( + + + + + c/{community.name} + {community.isNsfw && ( + + NSFW + + )} + + + + {community.description && ( +

+ {community.description} +

+ )} +

+ {community.memberCount.toLocaleString()}{" "} + {community.memberCount === 1 ? "member" : "members"} +

+
+
+ + ))} +
+ )} +
+ ); +} diff --git a/src/components/communities/create-community-form.tsx b/src/components/communities/create-community-form.tsx new file mode 100644 index 0000000..1427ae6 --- /dev/null +++ b/src/components/communities/create-community-form.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { useTransition, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { createCommunityAction } from "@/actions/communities"; + +export function CreateCommunityForm({ onSuccess }: { onSuccess?: () => void }) { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [isNsfw, setIsNsfw] = useState(false); + const [statusMessage, setStatusMessage] = useState<{ type: "error" | "success"; text: string } | null>(null); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setStatusMessage(null); + + const formData = new FormData(); + formData.set("name", name); + formData.set("description", description); + if (isNsfw) formData.set("isNsfw", "on"); + + startTransition(async () => { + const result = await createCommunityAction(formData); + if (result.error) { + setStatusMessage({ type: "error", text: result.error }); + return; + } + // success contains the community name + onSuccess?.(); + router.push(`/communities/${result.success}`); + }); + } + + return ( +
+
+
+ + {name.length}/21 +
+ setName(e.target.value.slice(0, 21))} + placeholder="community_name" + disabled={isPending} + required + /> +

Letters, numbers, and underscores only.

+
+ +
+
+ + {description.length}/500 +
+