feat: private forum groups - Group/GroupMember models, group CRUD API, invite codes, private threads, groups UI pages

This commit is contained in:
root
2026-06-15 12:25:52 +02:00
parent 947fe3b66d
commit 64ae34e9c9
10 changed files with 1545 additions and 6 deletions
+46 -3
View File
@@ -14,8 +14,28 @@ export async function GET(request: NextRequest) {
where.categoryId = categoryId;
}
// For group-scoped visibility: if user is authenticated, find which group IDs
// they are a member of, so we can include public threads (groupId: null) plus
// private threads where they are a member.
const auth = await requireAuth(request);
let userGroupIds: string[] = [];
if (auth) {
const memberships = await prisma.groupMember.findMany({
where: { userId: auth.userId },
select: { groupId: true },
});
userGroupIds = memberships.map((m) => m.groupId);
}
const threads = await prisma.forumThread.findMany({
where,
where: {
...where,
OR: [
{ groupId: null },
{ groupId: { in: userGroupIds } },
],
},
include: {
author: {
select: { id: true, name: true, isPremium: true, isPro: true },
@@ -23,6 +43,9 @@ export async function GET(request: NextRequest) {
category: {
select: { id: true, name: true, icon: true },
},
group: {
select: { id: true, name: true },
},
_count: {
select: { posts: true },
},
@@ -65,7 +88,7 @@ export async function POST(request: NextRequest) {
);
}
const { title, content, categoryId } = await request.json();
const { title, content, categoryId, groupId } = await request.json();
if (!title || !content || !categoryId) {
return NextResponse.json(
@@ -86,13 +109,30 @@ export async function POST(request: NextRequest) {
);
}
// If groupId is provided, verify user is a member of that group
if (groupId) {
const membership = await prisma.groupMember.findUnique({
where: {
groupId_userId: { groupId, userId: user.id },
},
});
if (!membership) {
return NextResponse.json(
{ error: "You are not a member of this group" },
{ status: 403 }
);
}
}
const thread = await prisma.forumThread.create({
data: {
title,
content,
categoryId,
authorId: user.id,
shariahStatus: "pending",
groupId: groupId || null,
shariahStatus: groupId ? "private" : "pending",
},
include: {
author: {
@@ -101,6 +141,9 @@ export async function POST(request: NextRequest) {
category: {
select: { id: true, name: true, icon: true },
},
group: {
select: { id: true, name: true },
},
_count: {
select: { posts: true },
},
+51
View File
@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
// GET /api/groups/[id]/invite — get invite code (owner only)
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const group = await prisma.group.findUnique({
where: { id },
select: {
id: true,
name: true,
ownerId: true,
inviteCode: true,
},
});
if (!group) {
return NextResponse.json({ error: "Group not found" }, { status: 404 });
}
// Only the owner can view the invite code
if (group.ownerId !== auth.userId) {
return NextResponse.json(
{ error: "Only the group owner can view the invite code" },
{ status: 403 }
);
}
return NextResponse.json({
groupId: group.id,
inviteCode: group.inviteCode,
});
} catch (error) {
console.error("GET /api/groups/[id]/invite error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+80
View File
@@ -0,0 +1,80 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
// POST /api/groups/[id]/join — join by invite code
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const { inviteCode } = await request.json();
if (!inviteCode || typeof inviteCode !== "string") {
return NextResponse.json(
{ error: "Missing required field: inviteCode" },
{ status: 400 }
);
}
// Find the group and verify invite code
const group = await prisma.group.findUnique({
where: { id },
include: {
members: {
where: { userId: auth.userId },
},
},
});
if (!group) {
return NextResponse.json({ error: "Group not found" }, { status: 404 });
}
if (group.inviteCode !== inviteCode) {
return NextResponse.json(
{ error: "Invalid invite code" },
{ status: 403 }
);
}
// Check if user is already a member
if (group.members.length > 0) {
return NextResponse.json(
{ error: "You are already a member of this group" },
{ status: 409 }
);
}
// Add user as member
const membership = await prisma.groupMember.create({
data: {
groupId: id,
userId: auth.userId,
role: "member",
},
include: {
user: {
select: { id: true, name: true, isPremium: true, isPro: true },
},
group: {
select: { id: true, name: true },
},
},
});
return NextResponse.json({ membership }, { status: 201 });
} catch (error) {
console.error("POST /api/groups/[id]/join error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
// DELETE /api/groups/[id]/members/[memberId] — remove member (owner only)
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string; memberId: string }> }
) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id, memberId } = await params;
// Find the group and verify the requester is the owner
const group = await prisma.group.findUnique({
where: { id },
});
if (!group) {
return NextResponse.json({ error: "Group not found" }, { status: 404 });
}
if (group.ownerId !== auth.userId) {
return NextResponse.json(
{ error: "Only the group owner can remove members" },
{ status: 403 }
);
}
// Find the membership to remove
const membership = await prisma.groupMember.findUnique({
where: { id: memberId },
});
if (!membership) {
return NextResponse.json(
{ error: "Member not found" },
{ status: 404 }
);
}
if (membership.groupId !== id) {
return NextResponse.json(
{ error: "Member does not belong to this group" },
{ status: 400 }
);
}
// Cannot remove the owner
if (membership.role === "owner") {
return NextResponse.json(
{ error: "Cannot remove the group owner" },
{ status: 403 }
);
}
await prisma.groupMember.delete({
where: { id: memberId },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error("DELETE /api/groups/[id]/members/[memberId] error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+59
View File
@@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
// GET /api/groups/[id] — group details + member list
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const group = await prisma.group.findUnique({
where: { id },
include: {
owner: {
select: { id: true, name: true, isPremium: true, isPro: true },
},
members: {
include: {
user: {
select: { id: true, name: true, isPremium: true, isPro: true },
},
},
orderBy: { joinedAt: "asc" },
},
_count: {
select: { members: true, threads: true },
},
},
});
if (!group) {
return NextResponse.json({ error: "Group not found" }, { status: 404 });
}
// Verify user is a member of this group
const isMember = group.members.some((m) => m.userId === auth.userId);
if (!isMember) {
return NextResponse.json(
{ error: "You are not a member of this group" },
{ status: 403 }
);
}
return NextResponse.json({ group });
} catch (error) {
console.error("GET /api/groups/[id] error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+120
View File
@@ -0,0 +1,120 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
import { getUserTier, FORUM_LIMITS } from "@/lib/tiers";
import crypto from "crypto";
function generateInviteCode(): string {
return crypto.randomBytes(4).toString("hex").slice(0, 8);
}
// POST /api/groups — create a group (premium only)
export async function POST(request: NextRequest) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({ where: { id: auth.userId } });
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
const tier = getUserTier(user.isPremium, user.isPro);
const limits = FORUM_LIMITS[tier];
if (!limits.canPost) {
return NextResponse.json(
{ error: "Your tier does not support creating groups. Upgrade to Premium or Pro to create groups." },
{ status: 403 }
);
}
const { name, description } = await request.json();
if (!name || typeof name !== "string" || name.trim().length === 0) {
return NextResponse.json(
{ error: "Missing required field: name" },
{ status: 400 }
);
}
const inviteCode = generateInviteCode();
const group = await prisma.group.create({
data: {
name: name.trim(),
description: description?.trim() || null,
ownerId: user.id,
inviteCode,
members: {
create: {
userId: user.id,
role: "owner",
},
},
},
include: {
owner: {
select: { id: true, name: true, isPremium: true, isPro: true },
},
members: {
include: {
user: {
select: { id: true, name: true, isPremium: true, isPro: true },
},
},
},
_count: {
select: { members: true, threads: true },
},
},
});
return NextResponse.json({ group }, { status: 201 });
} catch (error) {
console.error("POST /api/groups error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
// GET /api/groups — list groups where current user is a member
export async function GET(request: NextRequest) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const groups = await prisma.group.findMany({
where: {
members: {
some: {
userId: auth.userId,
},
},
},
include: {
owner: {
select: { id: true, name: true, isPremium: true, isPro: true },
},
_count: {
select: { members: true, threads: true },
},
},
orderBy: { createdAt: "desc" },
});
return NextResponse.json({ groups });
} catch (error) {
console.error("GET /api/groups error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}