From 6d2b9d975b53c5e20ac513f1d37960cee6e1d95d Mon Sep 17 00:00:00 2001 From: Samet Ekin Polat Date: Sun, 3 May 2026 15:34:13 +0300 Subject: [PATCH 1/3] feat #69: add Event, EventParticipant models and COMMUNITY_EVENT notification type --- prisma/schema.prisma | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6b8b4ed..8bccc61 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -33,6 +33,7 @@ enum NotificationType { MENTION DIRECT_MESSAGE COMMUNITY_INVITE + COMMUNITY_EVENT } enum PostStatus { @@ -122,6 +123,8 @@ model User { messagesReceived DirectMessage[] @relation("MessageReceiver") notifications Notification[] @relation("UserNotification") triggeredNotifs Notification[] @relation("NotificationActor") + + eventParticipations EventParticipant[] } model Account { @@ -356,6 +359,7 @@ model Post { notifications Notification[] media Media[] modLogs ModLog[] + event Event? } model Comment { @@ -535,11 +539,26 @@ model Event { id String @id @default(uuid()) @db.Uuid communityId String @db.Uuid creatorId String @db.Uuid + postId String? @unique @db.Uuid title String description String? startTime DateTime endTime DateTime + createdAt DateTime @default(now()) + + community Community @relation(fields: [communityId], references: [id], onDelete: Cascade) + creatorMod CommunityModerator @relation(fields: [creatorId, communityId], references: [userId, communityId]) + post Post? @relation(fields: [postId], references: [id]) + participants EventParticipant[] +} + +model EventParticipant { + eventId String @db.Uuid + userId String @db.Uuid + joinedAt DateTime @default(now()) + + event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) - community Community @relation(fields: [communityId], references: [id], onDelete: Cascade) - creatorMod CommunityModerator @relation(fields: [creatorId, communityId], references: [userId, communityId]) + @@id([eventId, userId]) } \ No newline at end of file From a71339293937c9dc548a885e41193c64f8bfa562 Mon Sep 17 00:00:00 2001 From: Samet Ekin Polat Date: Sun, 3 May 2026 15:36:26 +0300 Subject: [PATCH 2/3] feat #69: add createEventAction and rsvpEventAction with moderator permission checks --- src/actions/events.ts | 174 ++++++++++++++++++++++++++++++++++ src/lib/validations/events.ts | 43 +++++++++ 2 files changed, 217 insertions(+) create mode 100644 src/actions/events.ts create mode 100644 src/lib/validations/events.ts diff --git a/src/actions/events.ts b/src/actions/events.ts new file mode 100644 index 0000000..92b916a --- /dev/null +++ b/src/actions/events.ts @@ -0,0 +1,174 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { auth } from "@/auth"; +import { prisma } from "@/lib/prisma"; +import { NotificationType, PostStatus } from "@/generated/prisma/client"; +import { CreateEventSchema } from "@/lib/validations/events"; +import { slugify } from "@/lib/utils"; + +type EventActionResult = { + error?: string; + success?: string; + eventId?: string; +}; + +// ─── Create Event ───────────────────────────────────────────────────────────── + +export async function createEventAction(formData: FormData): Promise { + const session = await auth(); + if (!session?.user?.id) { + return { error: "Please log in to create events." }; + } + + const validated = CreateEventSchema.safeParse({ + communityId: formData.get("communityId"), + communityName: formData.get("communityName"), + title: formData.get("title"), + description: formData.get("description") || undefined, + startTime: formData.get("startTime"), + endTime: formData.get("endTime"), + }); + + if (!validated.success) { + return { error: validated.error.issues[0]?.message ?? "Event could not be created." }; + } + + const { communityId, communityName, title, description, startTime, endTime } = validated.data; + const userId = session.user.id; + + const mod = await prisma.communityModerator.findUnique({ + where: { userId_communityId: { userId, communityId } }, + select: { canManagePosts: true }, + }); + + if (!mod?.canManagePosts) { + return { error: "Unauthorized: you must be a moderator with post management permission." }; + } + + const start = new Date(startTime); + const end = new Date(endTime); + + try { + const result = await prisma.$transaction(async (tx) => { + const formattedStart = start.toLocaleString("en-US", { + dateStyle: "medium", + timeStyle: "short", + }); + const formattedEnd = end.toLocaleString("en-US", { + dateStyle: "medium", + timeStyle: "short", + }); + + const bodyParts: string[] = []; + if (description) bodyParts.push(description, ""); + bodyParts.push(`**Start:** ${formattedStart}`, `**End:** ${formattedEnd}`); + const postBody = bodyParts.join("\n"); + + const post = await tx.post.create({ + data: { + title: `[Event] ${title}`, + body: postBody, + status: PostStatus.PUBLISHED, + communityId, + userId, + isPinned: true, + upvotes: 1, + }, + select: { id: true }, + }); + + await tx.postVote.create({ + data: { userId, postId: post.id, voteValue: 1 }, + }); + + const event = await tx.event.create({ + data: { + communityId, + creatorId: userId, + postId: post.id, + title, + description, + startTime: start, + endTime: end, + }, + select: { id: true }, + }); + + const members = await tx.communityMember.findMany({ + where: { communityId }, + select: { userId: true }, + }); + + const notifData = members + .filter((m) => m.userId !== userId) + .map((m) => ({ + userId: m.userId, + actorId: userId, + type: NotificationType.COMMUNITY_EVENT, + postId: post.id, + })); + + if (notifData.length > 0) { + await tx.notification.createMany({ data: notifData }); + } + + return event; + }); + + revalidatePath(`/communities/${communityName}`); + return { success: "Event created and announcement post published.", eventId: result.id }; + } catch (error) { + console.error("createEventAction failed", error); + return { error: "Something went wrong while creating the event." }; + } +} + +// ─── RSVP Event ─────────────────────────────────────────────────────────────── + +export async function rsvpEventAction( + eventId: string, + communityName: string +): Promise { + const session = await auth(); + if (!session?.user?.id) { + return { error: "Please log in to RSVP." }; + } + + const userId = session.user.id; + + const event = await prisma.event.findUnique({ + where: { id: eventId }, + select: { id: true, endTime: true, postId: true, title: true }, + }); + + if (!event) return { error: "Event not found." }; + if (new Date() > event.endTime) return { error: "This event has already ended." }; + + const existing = await prisma.eventParticipant.findUnique({ + where: { eventId_userId: { eventId, userId } }, + }); + + try { + if (existing) { + await prisma.eventParticipant.delete({ + where: { eventId_userId: { eventId, userId } }, + }); + } else { + await prisma.eventParticipant.create({ + data: { eventId, userId }, + }); + } + + revalidatePath(`/communities/${communityName}`); + if (event.postId) { + revalidatePath( + `/communities/${communityName}/comments/${event.postId}/${slugify(event.title)}` + ); + } + return { success: existing ? "RSVP removed." : "You're going!" }; + } catch (error) { + console.error("rsvpEventAction failed", error); + return { error: "Something went wrong." }; + } +} diff --git a/src/lib/validations/events.ts b/src/lib/validations/events.ts new file mode 100644 index 0000000..566a014 --- /dev/null +++ b/src/lib/validations/events.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; + +export const CreateEventSchema = z + .object({ + communityId: z.string().uuid("Invalid community."), + communityName: z.string().trim().min(1), + title: z + .string() + .trim() + .min(3, "Title must be at least 3 characters.") + .max(120, "Title is too long."), + description: z.string().trim().max(1000, "Description is too long.").optional(), + startTime: z.string().min(1, "Start time is required."), + endTime: z.string().min(1, "End time is required."), + }) + .superRefine((data, ctx) => { + const now = new Date(); + const start = new Date(data.startTime); + const end = new Date(data.endTime); + + if (isNaN(start.getTime())) { + ctx.addIssue({ code: "custom", message: "Invalid start time.", path: ["startTime"] }); + return; + } + if (isNaN(end.getTime())) { + ctx.addIssue({ code: "custom", message: "Invalid end time.", path: ["endTime"] }); + return; + } + if (start <= now) { + ctx.addIssue({ + code: "custom", + message: "Start time must be in the future.", + path: ["startTime"], + }); + } + if (end <= start) { + ctx.addIssue({ + code: "custom", + message: "End time must be after start time.", + path: ["endTime"], + }); + } + }); From e4bc0272900939d743a7d01df610f84cc83d4525 Mon Sep 17 00:00:00 2001 From: Samet Ekin Polat Date: Sun, 3 May 2026 15:36:39 +0300 Subject: [PATCH 3/3] feat #69: add EventsCard sidebar widget with RSVP and mod event scheduling form --- src/app/(main)/communities/[name]/page.tsx | 34 +++- .../communities/community-page-client.tsx | 189 ++++++++++++++++++ .../communities/create-event-form.tsx | 108 ++++++++++ 3 files changed, 329 insertions(+), 2 deletions(-) create mode 100644 src/components/communities/create-event-form.tsx diff --git a/src/app/(main)/communities/[name]/page.tsx b/src/app/(main)/communities/[name]/page.tsx index f475898..b7d4086 100644 --- a/src/app/(main)/communities/[name]/page.tsx +++ b/src/app/(main)/communities/[name]/page.tsx @@ -45,6 +45,7 @@ export default async function CommunityPage({ let isMember = false; let canManageSettings = false; + let canManagePosts = false; if (session?.user?.id) { const [membership, modRecord] = await Promise.all([ @@ -64,15 +65,16 @@ export default async function CommunityPage({ communityId: community.id, }, }, - select: { canManageSettings: true }, + select: { canManageSettings: true, canManagePosts: true }, }), ]); isMember = !!membership; canManageSettings = modRecord?.canManageSettings ?? false; + canManagePosts = modRecord?.canManagePosts ?? false; } - const [posts, moderators] = await Promise.all([ + const [posts, moderators, upcomingEvents] = await Promise.all([ prisma.post.findMany({ where: { communityId: community.id, @@ -99,6 +101,23 @@ export default async function CommunityPage({ user: { select: { id: true, username: true, name: true, image: true } }, }, }), + prisma.event.findMany({ + where: { + communityId: community.id, + endTime: { gt: new Date() }, + }, + orderBy: { startTime: "asc" }, + include: { + _count: { select: { participants: true } }, + participants: { + where: { + userId: currentUserId || "00000000-0000-0000-0000-000000000000", + }, + select: { userId: true }, + }, + }, + take: 5, + }), ]); return ( @@ -139,8 +158,19 @@ export default async function CommunityPage({ handle: m.user.username || m.user.name || "moderator", image: m.user.image, }))} + events={upcomingEvents.map((e) => ({ + id: e.id, + title: e.title, + description: e.description, + startTime: e.startTime.toISOString(), + endTime: e.endTime.toISOString(), + postId: e.postId, + participantCount: e._count.participants, + isParticipating: e.participants.length > 0, + }))} isMember={isMember} canManageSettings={canManageSettings} + canManagePosts={canManagePosts} currentSort={currentSort} currentUserId={currentUserId || null} /> diff --git a/src/components/communities/community-page-client.tsx b/src/components/communities/community-page-client.tsx index e527507..448c3e6 100644 --- a/src/components/communities/community-page-client.tsx +++ b/src/components/communities/community-page-client.tsx @@ -15,11 +15,16 @@ import { Flame, TrendingUp, Clock, + Calendar, + CalendarCheck, + Plus, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { InviteUserForm } from "@/components/communities/invite-user-form"; +import { CreateEventForm } from "@/components/communities/create-event-form"; import { joinCommunityAction, leaveCommunityAction } from "@/actions/communities"; import { votePostAction } from "@/actions/posts"; +import { rsvpEventAction } from "@/actions/events"; import { cn, slugify } from "@/lib/utils"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -52,6 +57,17 @@ type Moderator = { image: string | null; }; +type CommunityEvent = { + id: string; + title: string; + description: string | null; + startTime: string; + endTime: string; + postId: string | null; + participantCount: number; + isParticipating: boolean; +}; + type CommunityPageClientProps = { community: { id: string; @@ -65,8 +81,10 @@ type CommunityPageClientProps = { }; posts: CommunityPost[]; moderators: Moderator[]; + events: CommunityEvent[]; isMember: boolean; canManageSettings: boolean; + canManagePosts: boolean; currentSort: string; currentUserId: string | null; }; @@ -334,16 +352,167 @@ function CommunityPostCard({ ); } +// ─── Events card (sidebar) ──────────────────────────────────────────────────── + +function EventsCard({ + events: initialEvents, + canManagePosts, + communityId, + communityName, + currentUserId, + onGuestAction, +}: { + events: CommunityEvent[]; + canManagePosts: boolean; + communityId: string; + communityName: string; + currentUserId: string | null; + onGuestAction: () => void; +}) { + const [events, setEvents] = useState(initialEvents); + const [showCreateForm, setShowCreateForm] = useState(false); + const [isPending, startTransition] = useTransition(); + + function handleRsvp(eventId: string) { + if (!currentUserId) { + onGuestAction(); + return; + } + + const prev = events; + setEvents((es) => + es.map((e) => + e.id === eventId + ? { + ...e, + isParticipating: !e.isParticipating, + participantCount: e.isParticipating + ? e.participantCount - 1 + : e.participantCount + 1, + } + : e + ) + ); + + startTransition(async () => { + const result = await rsvpEventAction(eventId, communityName); + if (result.error) setEvents(prev); + }); + } + + if (events.length === 0 && !canManagePosts) return null; + + return ( +
+
+

+ + Upcoming Events +

+ {canManagePosts && ( + + )} +
+ + {showCreateForm && ( +
+ setShowCreateForm(false)} + /> +
+ )} + + {events.length === 0 ? ( +

+ No upcoming events yet. +

+ ) : ( +
+ {events.map((event) => { + const start = new Date(event.startTime); + const formattedDate = new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + }).format(start); + const formattedTime = new Intl.DateTimeFormat("en", { + hour: "numeric", + minute: "2-digit", + }).format(start); + + return ( +
+
+
+ {event.postId ? ( + + {event.title} + + ) : ( +

+ {event.title} +

+ )} +

+ {formattedDate} · {formattedTime} +

+

+ + {event.participantCount}{" "} + {event.participantCount === 1 ? "going" : "going"} +

+
+ +
+
+ ); + })} +
+ )} +
+ ); +} + // ─── Right info panel ───────────────────────────────────────────────────────── function InfoPanel({ community, moderators, + events, canManageSettings, + canManagePosts, + currentUserId, + onGuestAction, }: { community: CommunityPageClientProps["community"]; moderators: Moderator[]; + events: CommunityEvent[]; canManageSettings: boolean; + canManagePosts: boolean; + currentUserId: string | null; + onGuestAction: () => void; }) { const createdAt = new Intl.DateTimeFormat("en", { month: "long", @@ -384,6 +553,16 @@ function InfoPanel({ + {/* Events card */} + + {/* Rules card */} {community.rules.length > 0 && (
@@ -449,8 +628,10 @@ export function CommunityPageClient({ community, posts, moderators, + events, isMember, canManageSettings, + canManagePosts, currentSort, currentUserId, }: CommunityPageClientProps) { @@ -597,6 +778,10 @@ export function CommunityPageClient({
setShowAuthModal(true)} moderators={moderators} canManageSettings={canManageSettings} /> @@ -608,7 +793,11 @@ export function CommunityPageClient({ setShowAuthModal(true)} />
diff --git a/src/components/communities/create-event-form.tsx b/src/components/communities/create-event-form.tsx new file mode 100644 index 0000000..6940a0e --- /dev/null +++ b/src/components/communities/create-event-form.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { Button } from "@/components/ui/button"; +import { createEventAction } from "@/actions/events"; + +type CreateEventFormProps = { + communityId: string; + communityName: string; + onSuccess?: () => void; +}; + +export function CreateEventForm({ communityId, communityName, onSuccess }: CreateEventFormProps) { + const [error, setError] = useState(null); + const [isPending, startTransition] = useTransition(); + + const inputClass = + "w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"; + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const form = e.currentTarget; + const formData = new FormData(form); + formData.set("communityId", communityId); + formData.set("communityName", communityName); + + setError(null); + startTransition(async () => { + const result = await createEventAction(formData); + if (result.error) { + setError(result.error); + } else { + form.reset(); + onSuccess?.(); + } + }); + } + + return ( +
+
+ + +
+ +
+ +