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
23 changes: 21 additions & 2 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ enum NotificationType {
MENTION
DIRECT_MESSAGE
COMMUNITY_INVITE
COMMUNITY_EVENT
}

enum PostStatus {
Expand Down Expand Up @@ -122,6 +123,8 @@ model User {
messagesReceived DirectMessage[] @relation("MessageReceiver")
notifications Notification[] @relation("UserNotification")
triggeredNotifs Notification[] @relation("NotificationActor")

eventParticipations EventParticipant[]
}

model Account {
Expand Down Expand Up @@ -356,6 +359,7 @@ model Post {
notifications Notification[]
media Media[]
modLogs ModLog[]
event Event?
}

model Comment {
Expand Down Expand Up @@ -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])
}
174 changes: 174 additions & 0 deletions src/actions/events.ts
Original file line number Diff line number Diff line change
@@ -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<EventActionResult> {
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<EventActionResult> {
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." };
}
}
34 changes: 32 additions & 2 deletions src/app/(main)/communities/[name]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand 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,
Expand All @@ -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 (
Expand Down Expand Up @@ -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}
/>
Expand Down
Loading