feat: private forum groups - Group/GroupMember models, group CRUD API, invite codes, private threads, groups UI pages
This commit is contained in:
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user