From 783bc87b4e417c4e9e8daf3d7c248dee901eb688 Mon Sep 17 00:00:00 2001 From: Samet Ekin Polat Date: Sun, 3 May 2026 21:05:47 +0300 Subject: [PATCH 1/4] feat(moderation): add schema, migration, and permission helpers (refs #39) - Extend ModActionType enum with 13 new governance and audit action types - Add CommunityStatus enum (ACTIVE, CLOSED) for community lifecycle - Add status field to Community model (default ACTIVE) - Add performance indexes on Report, CommunityRestriction, ModLog, Post, Comment - Create src/lib/moderation/permissions.ts with getModerationContext() helper that resolves global mod, owner, and community mod flags in one parallel DB round-trip, eliminating per-permission N+1 queries - Create migration SQL for all schema changes Co-Authored-By: Claude Sonnet 4.6 --- graphify-out/GRAPH_REPORT.md | 2 +- graphify-out/graph.json | 4 +- .../migration.sql | 38 ++++++++++ prisma/schema.prisma | 45 ++++++++++-- src/lib/moderation/permissions.ts | 70 +++++++++++++++++++ 5 files changed, 149 insertions(+), 10 deletions(-) create mode 100644 prisma/migrations/20260503210444_moderation_panel/migration.sql create mode 100644 src/lib/moderation/permissions.ts diff --git a/graphify-out/GRAPH_REPORT.md b/graphify-out/GRAPH_REPORT.md index ef8f285..86cb1d1 100644 --- a/graphify-out/GRAPH_REPORT.md +++ b/graphify-out/GRAPH_REPORT.md @@ -1,7 +1,7 @@ # Graph Report - C:\Users\samet\projects\arel_social (2026-05-03) ## Corpus Check -- 91 files · ~271,050 words +- 91 files · ~271,595 words - Verdict: corpus is large enough that graph structure adds value. ## Summary diff --git a/graphify-out/graph.json b/graphify-out/graph.json index 0ec24a9..b506536 100644 --- a/graphify-out/graph.json +++ b/graphify-out/graph.json @@ -3272,8 +3272,8 @@ "source_file": "C:\\Users\\samet\\projects\\arel_social\\src\\components\\communities\\community-page-client.tsx", "source_location": "L378", "weight": 1.0, - "_src": "community_page_client_handlersvp", - "_tgt": "post_page_client_onguestaction", + "_src": "post_page_client_onguestaction", + "_tgt": "community_page_client_handlersvp", "source": "post_page_client_onguestaction", "target": "community_page_client_handlersvp" }, diff --git a/prisma/migrations/20260503210444_moderation_panel/migration.sql b/prisma/migrations/20260503210444_moderation_panel/migration.sql new file mode 100644 index 0000000..d832829 --- /dev/null +++ b/prisma/migrations/20260503210444_moderation_panel/migration.sql @@ -0,0 +1,38 @@ +-- AlterEnum: extend ModActionType with governance and audit action types +ALTER TYPE "ModActionType" ADD VALUE 'UNBAN_USER'; +ALTER TYPE "ModActionType" ADD VALUE 'UNMUTE_USER'; +ALTER TYPE "ModActionType" ADD VALUE 'PIN_POST'; +ALTER TYPE "ModActionType" ADD VALUE 'UNPIN_POST'; +ALTER TYPE "ModActionType" ADD VALUE 'ASSIGN_MODERATOR'; +ALTER TYPE "ModActionType" ADD VALUE 'REMOVE_MODERATOR'; +ALTER TYPE "ModActionType" ADD VALUE 'UPDATE_MODERATOR_PERMISSIONS'; +ALTER TYPE "ModActionType" ADD VALUE 'CLOSE_COMMUNITY'; +ALTER TYPE "ModActionType" ADD VALUE 'REOPEN_COMMUNITY'; +ALTER TYPE "ModActionType" ADD VALUE 'DELETE_COMMUNITY'; +ALTER TYPE "ModActionType" ADD VALUE 'TRANSFER_COMMUNITY_OWNERSHIP'; +ALTER TYPE "ModActionType" ADD VALUE 'RESOLVE_REPORT'; +ALTER TYPE "ModActionType" ADD VALUE 'DISMISS_REPORT'; + +-- CreateEnum: community lifecycle status +CREATE TYPE "CommunityStatus" AS ENUM ('ACTIVE', 'CLOSED'); + +-- AlterTable: add status column to Community +ALTER TABLE "Community" ADD COLUMN "status" "CommunityStatus" NOT NULL DEFAULT 'ACTIVE'; + +-- CreateIndex: Report indexes for efficient queue queries +CREATE INDEX "Report_status_idx" ON "Report"("status"); +CREATE INDEX "Report_communityId_idx" ON "Report"("communityId"); + +-- CreateIndex: CommunityRestriction indexes for restriction lookups +CREATE INDEX "CommunityRestriction_communityId_idx" ON "CommunityRestriction"("communityId"); +CREATE INDEX "CommunityRestriction_userId_idx" ON "CommunityRestriction"("userId"); + +-- CreateIndex: ModLog indexes for audit trail queries +CREATE INDEX "ModLog_communityId_idx" ON "ModLog"("communityId"); +CREATE INDEX "ModLog_moderatorId_idx" ON "ModLog"("moderatorId"); + +-- CreateIndex: Post composite index for moderation panel queries +CREATE INDEX "Post_communityId_status_isDeleted_idx" ON "Post"("communityId", "status", "isDeleted"); + +-- CreateIndex: Comment index for parent post queries +CREATE INDEX "Comment_postId_isDeleted_idx" ON "Comment"("postId", "isDeleted"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8bccc61..6ba1e40 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -53,6 +53,24 @@ enum ModActionType { BAN_USER MUTE_USER UPDATE_SETTINGS + UNBAN_USER + UNMUTE_USER + PIN_POST + UNPIN_POST + ASSIGN_MODERATOR + REMOVE_MODERATOR + UPDATE_MODERATOR_PERMISSIONS + CLOSE_COMMUNITY + REOPEN_COMMUNITY + DELETE_COMMUNITY + TRANSFER_COMMUNITY_OWNERSHIP + RESOLVE_REPORT + DISMISS_REPORT +} + +enum CommunityStatus { + ACTIVE + CLOSED } enum InviteStatus { @@ -186,14 +204,15 @@ model GlobalModerator { model Community { - id String @id @default(uuid()) @db.Uuid - name String @unique + id String @id @default(uuid()) @db.Uuid + name String @unique description String? - ownerId String @db.Uuid - isUserProfile Boolean @default(false) - linkedUserId String? @unique @db.Uuid - isNsfw Boolean @default(false) - createdAt DateTime @default(now()) + ownerId String @db.Uuid + isUserProfile Boolean @default(false) + linkedUserId String? @unique @db.Uuid + isNsfw Boolean @default(false) + status CommunityStatus @default(ACTIVE) + createdAt DateTime @default(now()) owner User @relation("CommunityOwner", fields: [ownerId], references: [id]) linkedUser User? @relation("UserLinkedCommunity", fields: [linkedUserId], references: [id], onDelete: Cascade) @@ -280,6 +299,9 @@ model CommunityRestriction { community Community @relation(fields: [communityId], references: [id], onDelete: Cascade) user User @relation("RestrictedUser", fields: [userId], references: [id], onDelete: Cascade) moderator User @relation("ModWhoRestricted", fields: [moderatorId], references: [id]) + + @@index([communityId]) + @@index([userId]) } model ModLog { @@ -298,6 +320,9 @@ model ModLog { targetUser User? @relation("TargetedUser", fields: [targetUserId], references: [id]) targetPost Post? @relation(fields: [targetPostId], references: [id]) targetComment Comment? @relation(fields: [targetCommentId], references: [id]) + + @@index([communityId]) + @@index([moderatorId]) } model UserBlock { @@ -360,6 +385,8 @@ model Post { media Media[] modLogs ModLog[] event Event? + + @@index([communityId, status, isDeleted]) } model Comment { @@ -392,6 +419,8 @@ model Comment { notifications Notification[] media Media[] modLogs ModLog[] + + @@index([postId, isDeleted]) } model PostEditHistory { @@ -481,6 +510,8 @@ model Report { rule CommunityRule? @relation(fields: [ruleId], references: [id]) // Note: Enforce CHECK (num_nonnulls(reportedUserId, postId, commentId) = 1) via raw SQL migration + @@index([status]) + @@index([communityId]) } // ----------------------------------------------------------------------------- diff --git a/src/lib/moderation/permissions.ts b/src/lib/moderation/permissions.ts new file mode 100644 index 0000000..803a816 --- /dev/null +++ b/src/lib/moderation/permissions.ts @@ -0,0 +1,70 @@ +import { prisma } from "@/lib/prisma"; + +export type ModerationContext = { + isGlobalModerator: boolean; + isCommunityOwner: boolean; + isCommunityModerator: boolean; + canManagePosts: boolean; + canRestrictUsers: boolean; + canManageSettings: boolean; + /** Owner or global moderator — can perform lifecycle governance */ + canGovernCommunity: boolean; + hasAnyAccess: boolean; +}; + +/** + * Fetch all moderation permissions for a user in a specific community in one + * round-trip. Use this inside server actions and page guards instead of + * issuing separate per-permission queries. + */ +export async function getModerationContext( + userId: string, + communityId: string +): Promise { + const [globalMod, community, modRecord] = await Promise.all([ + prisma.globalModerator.findUnique({ where: { userId }, select: { userId: true } }), + prisma.community.findUnique({ where: { id: communityId }, select: { ownerId: true } }), + prisma.communityModerator.findUnique({ + where: { userId_communityId: { userId, communityId } }, + select: { canManagePosts: true, canRestrictUsers: true, canManageSettings: true }, + }), + ]); + + const isGlobal = globalMod !== null; + const isOwner = community?.ownerId === userId; + const isMod = modRecord !== null; + + return { + isGlobalModerator: isGlobal, + isCommunityOwner: isOwner, + isCommunityModerator: isMod, + canManagePosts: isGlobal || isOwner || (modRecord?.canManagePosts ?? false), + canRestrictUsers: isGlobal || isOwner || (modRecord?.canRestrictUsers ?? false), + canManageSettings: isGlobal || isOwner || (modRecord?.canManageSettings ?? false), + canGovernCommunity: isGlobal || isOwner, + hasAnyAccess: isGlobal || isOwner || isMod, + }; +} + +/** + * Returns the set of community IDs a user can moderate: + * - Global mods get an empty array here (callers treat them as all-communities) + * - Community mods get their assigned community IDs + */ +export async function getModeratorScope(userId: string): Promise<{ + isGlobalModerator: boolean; + assignedCommunityIds: string[]; +}> { + const [globalMod, modRecords] = await Promise.all([ + prisma.globalModerator.findUnique({ where: { userId }, select: { userId: true } }), + prisma.communityModerator.findMany({ + where: { userId }, + select: { communityId: true }, + }), + ]); + + return { + isGlobalModerator: globalMod !== null, + assignedCommunityIds: modRecords.map((r) => r.communityId), + }; +} From cff467892cba63004c54a79c4560fa6674140f8a Mon Sep 17 00:00:00 2001 From: Samet Ekin Polat Date: Sun, 3 May 2026 21:08:05 +0300 Subject: [PATCH 2/4] feat(moderation): add all moderation server actions (refs #39) Reports: resolveReportAction, dismissReportAction Content: removePostAction, removeCommentAction, pinPostAction Restrictions: muteUserAction, banUserAction, revokeRestrictionAction Settings: deleteRuleAction Governance: assignModeratorAction, removeModeratorAction, updateModeratorPermissionsAction, closeCommunityAction, reopenCommunityAction, transferOwnershipAction, deleteCommunityAction All actions: - Gate on getModerationContext() for scope-aware permission checks - Write a ModLog entry inside the same transaction as the data mutation - Prevent self-restriction and owner lock-out edge cases - Return structured error/success for client feedback Co-Authored-By: Claude Sonnet 4.6 --- src/actions/moderation.ts | 772 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 772 insertions(+) create mode 100644 src/actions/moderation.ts diff --git a/src/actions/moderation.ts b/src/actions/moderation.ts new file mode 100644 index 0000000..bd514cc --- /dev/null +++ b/src/actions/moderation.ts @@ -0,0 +1,772 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { auth } from "@/auth"; +import { prisma } from "@/lib/prisma"; +import { getModerationContext } from "@/lib/moderation/permissions"; +import { + ModActionType, + ReportStatus, + RestrictionType, + PostStatus, + CommunityStatus, +} from "@/generated/prisma/client"; +import type { Prisma } from "@/generated/prisma/client"; + +type ModerationResult = { + error?: string; + success?: string; +}; + +// ─── Internal helper: write audit log ──────────────────────────────────────── + +async function writeModLog( + tx: Prisma.TransactionClient, + data: { + communityId: string; + moderatorId: string; + action: ModActionType; + targetUserId?: string; + targetPostId?: string; + targetCommentId?: string; + details?: Record; + } +) { + return tx.modLog.create({ + data: { + communityId: data.communityId, + moderatorId: data.moderatorId, + action: data.action, + targetUserId: data.targetUserId ?? null, + targetPostId: data.targetPostId ?? null, + targetCommentId: data.targetCommentId ?? null, + details: (data.details ?? null) as Prisma.InputJsonValue | null, + }, + }); +} + +// ─── Reports ───────────────────────────────────────────────────────────────── + +export async function resolveReportAction( + reportId: string, + communityId: string +): Promise { + const session = await auth(); + if (!session?.user?.id) return { error: "Please log in." }; + + const ctx = await getModerationContext(session.user.id, communityId); + if (!ctx.hasAnyAccess) return { error: "Unauthorized." }; + + const report = await prisma.report.findUnique({ + where: { id: reportId }, + select: { status: true, communityId: true }, + }); + + if (!report) return { error: "Report not found." }; + if (report.communityId !== communityId) + return { error: "Report does not belong to this community." }; + if (report.status !== ReportStatus.PENDING) + return { error: "This report has already been reviewed." }; + + try { + await prisma.$transaction(async (tx) => { + await tx.report.update({ + where: { id: reportId }, + data: { status: ReportStatus.RESOLVED }, + }); + await writeModLog(tx, { + communityId, + moderatorId: session.user.id, + action: ModActionType.RESOLVE_REPORT, + details: { reportId }, + }); + }); + + revalidatePath(`/communities/${communityId}/moderation`); + return { success: "Report resolved." }; + } catch (error) { + console.error("resolveReportAction failed", error); + return { error: "Something went wrong." }; + } +} + +export async function dismissReportAction( + reportId: string, + communityId: string +): Promise { + const session = await auth(); + if (!session?.user?.id) return { error: "Please log in." }; + + const ctx = await getModerationContext(session.user.id, communityId); + if (!ctx.hasAnyAccess) return { error: "Unauthorized." }; + + const report = await prisma.report.findUnique({ + where: { id: reportId }, + select: { status: true, communityId: true }, + }); + + if (!report) return { error: "Report not found." }; + if (report.communityId !== communityId) + return { error: "Report does not belong to this community." }; + if (report.status !== ReportStatus.PENDING) + return { error: "This report has already been reviewed." }; + + try { + await prisma.$transaction(async (tx) => { + await tx.report.update({ + where: { id: reportId }, + data: { status: ReportStatus.DISMISSED }, + }); + await writeModLog(tx, { + communityId, + moderatorId: session.user.id, + action: ModActionType.DISMISS_REPORT, + details: { reportId }, + }); + }); + + revalidatePath(`/communities/${communityId}/moderation`); + return { success: "Report dismissed." }; + } catch (error) { + console.error("dismissReportAction failed", error); + return { error: "Something went wrong." }; + } +} + +// ─── Post Moderation ────────────────────────────────────────────────────────── + +export async function removePostAction( + postId: string, + communityId: string, + reason?: string +): Promise { + const session = await auth(); + if (!session?.user?.id) return { error: "Please log in." }; + + const ctx = await getModerationContext(session.user.id, communityId); + if (!ctx.canManagePosts) + return { error: "Unauthorized: post management permission required." }; + + const post = await prisma.post.findUnique({ + where: { id: postId }, + select: { status: true, isDeleted: true, communityId: true }, + }); + + if (!post || post.isDeleted) return { error: "Post not found." }; + if (post.communityId !== communityId) + return { error: "Post does not belong to this community." }; + if (post.status === PostStatus.REMOVED) + return { error: "Post has already been removed." }; + + try { + await prisma.$transaction(async (tx) => { + await tx.post.update({ + where: { id: postId }, + data: { status: PostStatus.REMOVED, isDeleted: true }, + }); + await writeModLog(tx, { + communityId, + moderatorId: session.user.id, + action: ModActionType.REMOVE_POST, + targetPostId: postId, + details: { reason: reason ?? null }, + }); + }); + + revalidatePath(`/communities/${communityId}/moderation`); + return { success: "Post removed." }; + } catch (error) { + console.error("removePostAction failed", error); + return { error: "Something went wrong." }; + } +} + +export async function removeCommentAction( + commentId: string, + communityId: string, + reason?: string +): Promise { + const session = await auth(); + if (!session?.user?.id) return { error: "Please log in." }; + + const ctx = await getModerationContext(session.user.id, communityId); + if (!ctx.canManagePosts) return { error: "Unauthorized." }; + + const comment = await prisma.comment.findUnique({ + where: { id: commentId }, + select: { isDeleted: true, post: { select: { communityId: true } } }, + }); + + if (!comment || comment.isDeleted) return { error: "Comment not found." }; + if (comment.post.communityId !== communityId) + return { error: "Comment does not belong to this community." }; + + try { + await prisma.$transaction(async (tx) => { + await tx.comment.update({ + where: { id: commentId }, + data: { isDeleted: true }, + }); + await writeModLog(tx, { + communityId, + moderatorId: session.user.id, + action: ModActionType.REMOVE_COMMENT, + targetCommentId: commentId, + details: { reason: reason ?? null }, + }); + }); + + revalidatePath(`/communities/${communityId}/moderation`); + return { success: "Comment removed." }; + } catch (error) { + console.error("removeCommentAction failed", error); + return { error: "Something went wrong." }; + } +} + +export async function pinPostAction( + postId: string, + communityId: string, + pin: boolean +): Promise { + const session = await auth(); + if (!session?.user?.id) return { error: "Please log in." }; + + const ctx = await getModerationContext(session.user.id, communityId); + if (!ctx.canManagePosts) return { error: "Unauthorized." }; + + const post = await prisma.post.findUnique({ + where: { id: postId }, + select: { isDeleted: true, communityId: true, community: { select: { name: true } } }, + }); + + if (!post || post.isDeleted) return { error: "Post not found." }; + if (post.communityId !== communityId) + return { error: "Post does not belong to this community." }; + + try { + await prisma.$transaction(async (tx) => { + await tx.post.update({ where: { id: postId }, data: { isPinned: pin } }); + await writeModLog(tx, { + communityId, + moderatorId: session.user.id, + action: pin ? ModActionType.PIN_POST : ModActionType.UNPIN_POST, + targetPostId: postId, + }); + }); + + revalidatePath(`/communities/${post.community.name}`); + revalidatePath(`/communities/${communityId}/moderation`); + return { success: pin ? "Post pinned." : "Post unpinned." }; + } catch (error) { + console.error("pinPostAction failed", error); + return { error: "Something went wrong." }; + } +} + +// ─── User Restrictions ──────────────────────────────────────────────────────── + +export async function muteUserAction( + targetUserId: string, + communityId: string, + reason?: string, + expiresAt?: string +): Promise { + const session = await auth(); + if (!session?.user?.id) return { error: "Please log in." }; + + const ctx = await getModerationContext(session.user.id, communityId); + if (!ctx.canRestrictUsers) + return { error: "Unauthorized: user restriction permission required." }; + if (targetUserId === session.user.id) + return { error: "You cannot restrict yourself." }; + + const existing = await prisma.communityRestriction.findFirst({ + where: { + communityId, + userId: targetUserId, + type: RestrictionType.MUTE, + OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }], + }, + select: { id: true }, + }); + if (existing) + return { error: "This user already has an active mute in this community." }; + + try { + await prisma.$transaction(async (tx) => { + const restriction = await tx.communityRestriction.create({ + data: { + communityId, + userId: targetUserId, + moderatorId: session.user.id, + type: RestrictionType.MUTE, + reason: reason ?? null, + expiresAt: expiresAt ? new Date(expiresAt) : null, + }, + select: { id: true }, + }); + await writeModLog(tx, { + communityId, + moderatorId: session.user.id, + action: ModActionType.MUTE_USER, + targetUserId, + details: { reason: reason ?? null, restrictionId: restriction.id, expiresAt: expiresAt ?? null }, + }); + }); + + revalidatePath(`/communities/${communityId}/moderation`); + return { success: "User muted." }; + } catch (error) { + console.error("muteUserAction failed", error); + return { error: "Something went wrong." }; + } +} + +export async function banUserAction( + targetUserId: string, + communityId: string, + reason?: string +): Promise { + const session = await auth(); + if (!session?.user?.id) return { error: "Please log in." }; + + const ctx = await getModerationContext(session.user.id, communityId); + if (!ctx.canRestrictUsers) return { error: "Unauthorized." }; + if (targetUserId === session.user.id) + return { error: "You cannot restrict yourself." }; + + const existing = await prisma.communityRestriction.findFirst({ + where: { + communityId, + userId: targetUserId, + type: RestrictionType.BAN, + OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }], + }, + select: { id: true }, + }); + if (existing) + return { error: "This user is already banned from this community." }; + + try { + await prisma.$transaction(async (tx) => { + const restriction = await tx.communityRestriction.create({ + data: { + communityId, + userId: targetUserId, + moderatorId: session.user.id, + type: RestrictionType.BAN, + reason: reason ?? null, + }, + select: { id: true }, + }); + await writeModLog(tx, { + communityId, + moderatorId: session.user.id, + action: ModActionType.BAN_USER, + targetUserId, + details: { reason: reason ?? null, restrictionId: restriction.id }, + }); + }); + + revalidatePath(`/communities/${communityId}/moderation`); + return { success: "User banned." }; + } catch (error) { + console.error("banUserAction failed", error); + return { error: "Something went wrong." }; + } +} + +export async function revokeRestrictionAction( + restrictionId: string, + communityId: string +): Promise { + const session = await auth(); + if (!session?.user?.id) return { error: "Please log in." }; + + const ctx = await getModerationContext(session.user.id, communityId); + if (!ctx.canRestrictUsers) return { error: "Unauthorized." }; + + const restriction = await prisma.communityRestriction.findUnique({ + where: { id: restrictionId }, + select: { communityId: true, userId: true, type: true }, + }); + + if (!restriction) return { error: "Restriction not found." }; + if (restriction.communityId !== communityId) + return { error: "Restriction does not belong to this community." }; + + try { + await prisma.$transaction(async (tx) => { + await tx.communityRestriction.delete({ where: { id: restrictionId } }); + await writeModLog(tx, { + communityId, + moderatorId: session.user.id, + action: + restriction.type === RestrictionType.BAN + ? ModActionType.UNBAN_USER + : ModActionType.UNMUTE_USER, + targetUserId: restriction.userId, + details: { restrictionId }, + }); + }); + + revalidatePath(`/communities/${communityId}/moderation`); + return { success: "Restriction revoked." }; + } catch (error) { + console.error("revokeRestrictionAction failed", error); + return { error: "Something went wrong." }; + } +} + +// ─── Community Settings ─────────────────────────────────────────────────────── + +export async function deleteRuleAction( + ruleId: string, + communityId: string, + communityName: string +): Promise { + const session = await auth(); + if (!session?.user?.id) return { error: "Please log in." }; + + const ctx = await getModerationContext(session.user.id, communityId); + if (!ctx.canManageSettings) return { error: "Unauthorized." }; + + const rule = await prisma.communityRule.findUnique({ + where: { id: ruleId }, + select: { communityId: true }, + }); + if (!rule) return { error: "Rule not found." }; + if (rule.communityId !== communityId) + return { error: "Rule does not belong to this community." }; + + try { + await prisma.$transaction(async (tx) => { + await tx.communityRule.delete({ where: { id: ruleId } }); + await writeModLog(tx, { + communityId, + moderatorId: session.user.id, + action: ModActionType.UPDATE_SETTINGS, + details: { ruleDeleted: ruleId }, + }); + }); + + revalidatePath(`/communities/${communityName}/moderation`); + revalidatePath(`/communities/${communityName}`); + return { success: "Rule deleted." }; + } catch (error) { + console.error("deleteRuleAction failed", error); + return { error: "Something went wrong." }; + } +} + +// ─── Governance: Moderator Roster ───────────────────────────────────────────── + +export async function assignModeratorAction( + targetUserId: string, + communityId: string, + permissions: { + canManageSettings: boolean; + canManagePosts: boolean; + canRestrictUsers: boolean; + } +): Promise { + const session = await auth(); + if (!session?.user?.id) return { error: "Please log in." }; + + const ctx = await getModerationContext(session.user.id, communityId); + if (!ctx.canGovernCommunity) + return { + error: + "Unauthorized: only owners and global moderators can manage the moderator roster.", + }; + if (targetUserId === session.user.id) + return { error: "You cannot reassign your own moderator record this way." }; + + const member = await prisma.communityMember.findUnique({ + where: { userId_communityId: { userId: targetUserId, communityId } }, + select: { userId: true }, + }); + if (!member) + return { error: "User must be a community member to be assigned as moderator." }; + + try { + await prisma.$transaction(async (tx) => { + await tx.communityModerator.upsert({ + where: { userId_communityId: { userId: targetUserId, communityId } }, + update: permissions, + create: { userId: targetUserId, communityId, ...permissions }, + }); + await writeModLog(tx, { + communityId, + moderatorId: session.user.id, + action: ModActionType.ASSIGN_MODERATOR, + targetUserId, + details: { permissions }, + }); + }); + + revalidatePath(`/communities/${communityId}/moderation`); + return { success: "Moderator assigned." }; + } catch (error) { + console.error("assignModeratorAction failed", error); + return { error: "Something went wrong." }; + } +} + +export async function removeModeratorAction( + targetUserId: string, + communityId: string +): Promise { + const session = await auth(); + if (!session?.user?.id) return { error: "Please log in." }; + + const ctx = await getModerationContext(session.user.id, communityId); + if (!ctx.canGovernCommunity) return { error: "Unauthorized." }; + + const community = await prisma.community.findUnique({ + where: { id: communityId }, + select: { ownerId: true }, + }); + if (community?.ownerId === targetUserId) + return { error: "You cannot remove the community owner from the moderator roster." }; + + const modRecord = await prisma.communityModerator.findUnique({ + where: { userId_communityId: { userId: targetUserId, communityId } }, + select: { userId: true }, + }); + if (!modRecord) return { error: "User is not a moderator of this community." }; + + try { + await prisma.$transaction(async (tx) => { + await tx.communityModerator.delete({ + where: { userId_communityId: { userId: targetUserId, communityId } }, + }); + await writeModLog(tx, { + communityId, + moderatorId: session.user.id, + action: ModActionType.REMOVE_MODERATOR, + targetUserId, + }); + }); + + revalidatePath(`/communities/${communityId}/moderation`); + return { success: "Moderator removed." }; + } catch (error) { + console.error("removeModeratorAction failed", error); + return { error: "Something went wrong." }; + } +} + +export async function updateModeratorPermissionsAction( + targetUserId: string, + communityId: string, + permissions: { + canManageSettings: boolean; + canManagePosts: boolean; + canRestrictUsers: boolean; + } +): Promise { + const session = await auth(); + if (!session?.user?.id) return { error: "Please log in." }; + + const ctx = await getModerationContext(session.user.id, communityId); + if (!ctx.canGovernCommunity) return { error: "Unauthorized." }; + + const modRecord = await prisma.communityModerator.findUnique({ + where: { userId_communityId: { userId: targetUserId, communityId } }, + select: { userId: true }, + }); + if (!modRecord) return { error: "User is not a moderator of this community." }; + + try { + await prisma.$transaction(async (tx) => { + await tx.communityModerator.update({ + where: { userId_communityId: { userId: targetUserId, communityId } }, + data: permissions, + }); + await writeModLog(tx, { + communityId, + moderatorId: session.user.id, + action: ModActionType.UPDATE_MODERATOR_PERMISSIONS, + targetUserId, + details: { permissions }, + }); + }); + + revalidatePath(`/communities/${communityId}/moderation`); + return { success: "Moderator permissions updated." }; + } catch (error) { + console.error("updateModeratorPermissionsAction failed", error); + return { error: "Something went wrong." }; + } +} + +// ─── Governance: Community Lifecycle ───────────────────────────────────────── + +export async function closeCommunityAction( + communityId: string, + reason?: string +): Promise { + const session = await auth(); + if (!session?.user?.id) return { error: "Please log in." }; + + const ctx = await getModerationContext(session.user.id, communityId); + if (!ctx.canGovernCommunity) + return { + error: "Unauthorized: only owners and global moderators can close communities.", + }; + + const community = await prisma.community.findUnique({ + where: { id: communityId }, + select: { status: true, name: true }, + }); + if (!community) return { error: "Community not found." }; + if (community.status === CommunityStatus.CLOSED) + return { error: "Community is already closed." }; + + try { + await prisma.$transaction(async (tx) => { + await tx.community.update({ + where: { id: communityId }, + data: { status: CommunityStatus.CLOSED }, + }); + await writeModLog(tx, { + communityId, + moderatorId: session.user.id, + action: ModActionType.CLOSE_COMMUNITY, + details: { reason: reason ?? null }, + }); + }); + + revalidatePath(`/communities/${community.name}`); + revalidatePath(`/communities/${community.name}/moderation`); + return { success: "Community closed." }; + } catch (error) { + console.error("closeCommunityAction failed", error); + return { error: "Something went wrong." }; + } +} + +export async function reopenCommunityAction( + communityId: string +): Promise { + const session = await auth(); + if (!session?.user?.id) return { error: "Please log in." }; + + const ctx = await getModerationContext(session.user.id, communityId); + if (!ctx.canGovernCommunity) return { error: "Unauthorized." }; + + const community = await prisma.community.findUnique({ + where: { id: communityId }, + select: { status: true, name: true }, + }); + if (!community) return { error: "Community not found." }; + if (community.status === CommunityStatus.ACTIVE) + return { error: "Community is already active." }; + + try { + await prisma.$transaction(async (tx) => { + await tx.community.update({ + where: { id: communityId }, + data: { status: CommunityStatus.ACTIVE }, + }); + await writeModLog(tx, { + communityId, + moderatorId: session.user.id, + action: ModActionType.REOPEN_COMMUNITY, + }); + }); + + revalidatePath(`/communities/${community.name}`); + return { success: "Community reopened." }; + } catch (error) { + console.error("reopenCommunityAction failed", error); + return { error: "Something went wrong." }; + } +} + +export async function transferOwnershipAction( + communityId: string, + newOwnerId: string +): Promise { + const session = await auth(); + if (!session?.user?.id) return { error: "Please log in." }; + + // Only the current owner can transfer — global mods cannot override this + const community = await prisma.community.findUnique({ + where: { id: communityId }, + select: { ownerId: true, name: true }, + }); + if (!community) return { error: "Community not found." }; + if (community.ownerId !== session.user.id) + return { error: "Only the current owner can transfer ownership." }; + if (newOwnerId === session.user.id) + return { error: "You are already the owner." }; + + const newOwner = await prisma.user.findUnique({ + where: { id: newOwnerId }, + select: { id: true }, + }); + if (!newOwner) return { error: "Target user not found." }; + + try { + await prisma.$transaction(async (tx) => { + await tx.community.update({ + where: { id: communityId }, + data: { ownerId: newOwnerId }, + }); + // Ensure new owner has full moderator permissions + await tx.communityModerator.upsert({ + where: { userId_communityId: { userId: newOwnerId, communityId } }, + update: { canManageSettings: true, canManagePosts: true, canRestrictUsers: true }, + create: { + userId: newOwnerId, + communityId, + canManageSettings: true, + canManagePosts: true, + canRestrictUsers: true, + }, + }); + await writeModLog(tx, { + communityId, + moderatorId: session.user.id, + action: ModActionType.TRANSFER_COMMUNITY_OWNERSHIP, + targetUserId: newOwnerId, + }); + }); + + revalidatePath(`/communities/${community.name}`); + revalidatePath(`/communities/${community.name}/moderation`); + return { success: "Ownership transferred." }; + } catch (error) { + console.error("transferOwnershipAction failed", error); + return { error: "Something went wrong." }; + } +} + +export async function deleteCommunityAction( + communityId: string +): Promise { + const session = await auth(); + if (!session?.user?.id) return { error: "Please log in." }; + + // Only the current owner can delete + const community = await prisma.community.findUnique({ + where: { id: communityId }, + select: { ownerId: true, name: true }, + }); + if (!community) return { error: "Community not found." }; + if (community.ownerId !== session.user.id) + return { error: "Only the owner can delete this community." }; + + try { + // Cascade deletes handle related records (posts, members, etc.) + await prisma.community.delete({ where: { id: communityId } }); + revalidatePath("/communities"); + return { success: "Community deleted." }; + } catch (error) { + console.error("deleteCommunityAction failed", error); + return { error: "Something went wrong." }; + } +} From f489a78d443a70e4ec4f8f9757b19e698f7784ff Mon Sep 17 00:00:00 2001 From: Samet Ekin Polat Date: Sun, 3 May 2026 21:13:58 +0300 Subject: [PATCH 3/4] feat(moderation): add full moderation panel UI (refs #39) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pages: - /moderation — global mod panel listing all communities with pending report counts; guards to GlobalModerator record server-side - /communities/[name]/moderation — community-scoped panel; guards via getModerationContext, redirects non-mods Components (src/components/moderation/): - panel.tsx — tabbed shell with role-context header and badge on Reports - reports-tab.tsx — resolve/dismiss with status filter, optimistic update - posts-tab.tsx — remove + pin/unpin with per-row reason input - comments-tab.tsx — remove with reason input, all/removed filter - restrictions-tab.tsx — add mute/ban form + active restriction list with revoke; duplicate-restriction check runs server-side - mod-log-tab.tsx — full audit trail with action-type filter - settings-tab.tsx — add/reorder/delete community rules - governance-tab.tsx — moderator roster (assign, update perms, remove), community close/reopen, transfer ownership, delete community; owner-only danger-zone clearly separated from shared mod controls Community page: - Wire getModerationContext() to replace duplicated per-permission queries - Propagate hasModerationAccess prop down to show "Moderation Panel" link in sidebar for any user with moderation access (global mod, owner, mod) Co-Authored-By: Claude Sonnet 4.6 --- .../communities/[name]/moderation/page.tsx | 132 +++++ src/app/(main)/communities/[name]/page.tsx | 20 +- src/app/(main)/moderation/page.tsx | 83 ++++ .../communities/community-page-client.tsx | 14 + src/components/moderation/comments-tab.tsx | 132 +++++ src/components/moderation/governance-tab.tsx | 452 ++++++++++++++++++ src/components/moderation/mod-log-tab.tsx | 101 ++++ src/components/moderation/panel.tsx | 236 +++++++++ src/components/moderation/posts-tab.tsx | 166 +++++++ src/components/moderation/reports-tab.tsx | 143 ++++++ .../moderation/restrictions-tab.tsx | 210 ++++++++ src/components/moderation/settings-tab.tsx | 195 ++++++++ 12 files changed, 1872 insertions(+), 12 deletions(-) create mode 100644 src/app/(main)/communities/[name]/moderation/page.tsx create mode 100644 src/app/(main)/moderation/page.tsx create mode 100644 src/components/moderation/comments-tab.tsx create mode 100644 src/components/moderation/governance-tab.tsx create mode 100644 src/components/moderation/mod-log-tab.tsx create mode 100644 src/components/moderation/panel.tsx create mode 100644 src/components/moderation/posts-tab.tsx create mode 100644 src/components/moderation/reports-tab.tsx create mode 100644 src/components/moderation/restrictions-tab.tsx create mode 100644 src/components/moderation/settings-tab.tsx diff --git a/src/app/(main)/communities/[name]/moderation/page.tsx b/src/app/(main)/communities/[name]/moderation/page.tsx new file mode 100644 index 0000000..12390c5 --- /dev/null +++ b/src/app/(main)/communities/[name]/moderation/page.tsx @@ -0,0 +1,132 @@ +import { notFound, redirect } from "next/navigation"; +import { auth } from "@/auth"; +import { prisma } from "@/lib/prisma"; +import { getModerationContext } from "@/lib/moderation/permissions"; +import { ModerationPanel } from "@/components/moderation/panel"; + +export const dynamic = "force-dynamic"; + +type Params = Promise<{ name: string }>; + +export default async function CommunityModerationPage({ params }: { params: Params }) { + const { name } = await params; + const session = await auth(); + if (!session?.user?.id) redirect("/login"); + + const community = await prisma.community.findUnique({ + where: { name }, + select: { id: true, name: true, ownerId: true, status: true }, + }); + if (!community) notFound(); + + const ctx = await getModerationContext(session.user.id, community.id); + if (!ctx.hasAnyAccess) redirect(`/communities/${name}`); + + // Fetch all panel data in parallel + const [reports, posts, comments, restrictions, modLogs, rules, moderators] = + await Promise.all([ + prisma.report.findMany({ + where: { communityId: community.id }, + orderBy: { createdAt: "desc" }, + take: 100, + select: { + id: true, + status: true, + customReason: true, + createdAt: true, + reporter: { select: { username: true, email: true } }, + reportedUser: { select: { username: true, email: true } }, + post: { select: { id: true, title: true } }, + comment: { select: { id: true, body: true } }, + rule: { select: { title: true } }, + }, + }), + prisma.post.findMany({ + where: { communityId: community.id }, + orderBy: { createdAt: "desc" }, + take: 100, + select: { + id: true, + title: true, + status: true, + isPinned: true, + isDeleted: true, + createdAt: true, + user: { select: { username: true, email: true } }, + }, + }), + prisma.comment.findMany({ + where: { post: { communityId: community.id } }, + orderBy: { createdAt: "desc" }, + take: 100, + select: { + id: true, + body: true, + isDeleted: true, + createdAt: true, + user: { select: { username: true, email: true } }, + post: { select: { id: true, title: true } }, + }, + }), + prisma.communityRestriction.findMany({ + where: { + communityId: community.id, + OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }], + }, + orderBy: { createdAt: "desc" }, + select: { + id: true, + type: true, + reason: true, + expiresAt: true, + createdAt: true, + user: { select: { id: true, username: true, email: true } }, + moderator: { select: { username: true, email: true } }, + }, + }), + prisma.modLog.findMany({ + where: { communityId: community.id }, + orderBy: { createdAt: "desc" }, + take: 200, + select: { + id: true, + action: true, + details: true, + createdAt: true, + moderator: { select: { username: true, email: true } }, + targetUser: { select: { username: true, email: true } }, + targetPost: { select: { title: true } }, + targetComment: { select: { body: true } }, + }, + }), + prisma.communityRule.findMany({ + where: { communityId: community.id }, + orderBy: { displayOrder: "asc" }, + select: { id: true, title: true, description: true, displayOrder: true }, + }), + prisma.communityModerator.findMany({ + where: { communityId: community.id }, + select: { + userId: true, + canManageSettings: true, + canManagePosts: true, + canRestrictUsers: true, + user: { select: { username: true, email: true } }, + }, + }), + ]); + + return ( + ({ ...r, status: r.status as string }))} + posts={posts.map((p) => ({ ...p, status: p.status as string }))} + comments={comments} + restrictions={restrictions.map((r) => ({ ...r, type: r.type as string }))} + modLogs={modLogs.map((l) => ({ ...l, action: l.action as string }))} + rules={rules} + moderators={moderators} + /> + ); +} diff --git a/src/app/(main)/communities/[name]/page.tsx b/src/app/(main)/communities/[name]/page.tsx index b7d4086..3b0ee23 100644 --- a/src/app/(main)/communities/[name]/page.tsx +++ b/src/app/(main)/communities/[name]/page.tsx @@ -3,6 +3,7 @@ import { auth } from "@/auth"; import { prisma } from "@/lib/prisma"; import { PostStatus } from "@/generated/prisma/client"; import { CommunityPageClient } from "@/components/communities/community-page-client"; +import { getModerationContext } from "@/lib/moderation/permissions"; export const dynamic = "force-dynamic"; @@ -46,9 +47,10 @@ export default async function CommunityPage({ let isMember = false; let canManageSettings = false; let canManagePosts = false; + let hasModerationAccess = false; if (session?.user?.id) { - const [membership, modRecord] = await Promise.all([ + const [membership, modCtx] = await Promise.all([ prisma.communityMember.findUnique({ where: { userId_communityId: { @@ -58,20 +60,13 @@ export default async function CommunityPage({ }, select: { userId: true }, }), - prisma.communityModerator.findUnique({ - where: { - userId_communityId: { - userId: session.user.id, - communityId: community.id, - }, - }, - select: { canManageSettings: true, canManagePosts: true }, - }), + getModerationContext(session.user.id, community.id), ]); isMember = !!membership; - canManageSettings = modRecord?.canManageSettings ?? false; - canManagePosts = modRecord?.canManagePosts ?? false; + canManageSettings = modCtx.canManageSettings; + canManagePosts = modCtx.canManagePosts; + hasModerationAccess = modCtx.hasAnyAccess; } const [posts, moderators, upcomingEvents] = await Promise.all([ @@ -171,6 +166,7 @@ export default async function CommunityPage({ isMember={isMember} canManageSettings={canManageSettings} canManagePosts={canManagePosts} + hasModerationAccess={hasModerationAccess} currentSort={currentSort} currentUserId={currentUserId || null} /> diff --git a/src/app/(main)/moderation/page.tsx b/src/app/(main)/moderation/page.tsx new file mode 100644 index 0000000..d97bec1 --- /dev/null +++ b/src/app/(main)/moderation/page.tsx @@ -0,0 +1,83 @@ +import { redirect } from "next/navigation"; +import { auth } from "@/auth"; +import { prisma } from "@/lib/prisma"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; + +export const dynamic = "force-dynamic"; + +export default async function GlobalModerationPage() { + const session = await auth(); + if (!session?.user?.id) redirect("/login"); + + // Only global moderators can access this page + const globalMod = await prisma.globalModerator.findUnique({ + where: { userId: session.user.id }, + select: { userId: true }, + }); + if (!globalMod) redirect("/"); + + // Fetch communities the global mod needs to oversee — all of them + const communities = await prisma.community.findMany({ + where: { isUserProfile: false }, + orderBy: { createdAt: "desc" }, + take: 100, + select: { + id: true, + name: true, + status: true, + _count: { + select: { + reports: { where: { status: "PENDING" } }, + members: true, + }, + }, + }, + }); + + return ( +
+
+

Global Moderation Panel

+

+ Platform-wide view. Select a community to open its moderation panel. +

+
+ + {communities.length === 0 ? ( +

No communities found.

+ ) : ( +
+ {communities.map((c) => ( +
+
+

+ c/{c.name} + {c.status === "CLOSED" && ( + + Closed + + )} +

+

+ {c._count.members} members + {c._count.reports > 0 && ( + + {c._count.reports} pending report{c._count.reports !== 1 ? "s" : ""} + + )} +

+
+ +
+ ))} +
+ )} +
+ ); +} diff --git a/src/components/communities/community-page-client.tsx b/src/components/communities/community-page-client.tsx index 448c3e6..b183e5d 100644 --- a/src/components/communities/community-page-client.tsx +++ b/src/components/communities/community-page-client.tsx @@ -85,6 +85,7 @@ type CommunityPageClientProps = { isMember: boolean; canManageSettings: boolean; canManagePosts: boolean; + hasModerationAccess: boolean; currentSort: string; currentUserId: string | null; }; @@ -503,6 +504,7 @@ function InfoPanel({ events, canManageSettings, canManagePosts, + hasModerationAccess, currentUserId, onGuestAction, }: { @@ -511,6 +513,7 @@ function InfoPanel({ events: CommunityEvent[]; canManageSettings: boolean; canManagePosts: boolean; + hasModerationAccess: boolean; currentUserId: string | null; onGuestAction: () => void; }) { @@ -550,6 +553,14 @@ function InfoPanel({ )} + {hasModerationAccess && ( + + )} @@ -632,6 +643,7 @@ export function CommunityPageClient({ isMember, canManageSettings, canManagePosts, + hasModerationAccess, currentSort, currentUserId, }: CommunityPageClientProps) { @@ -784,6 +796,7 @@ export function CommunityPageClient({ onGuestAction={() => setShowAuthModal(true)} moderators={moderators} canManageSettings={canManageSettings} + hasModerationAccess={hasModerationAccess} /> @@ -796,6 +809,7 @@ export function CommunityPageClient({ events={events} canManageSettings={canManageSettings} canManagePosts={canManagePosts} + hasModerationAccess={hasModerationAccess} currentUserId={currentUserId} onGuestAction={() => setShowAuthModal(true)} /> diff --git a/src/components/moderation/comments-tab.tsx b/src/components/moderation/comments-tab.tsx new file mode 100644 index 0000000..542c76e --- /dev/null +++ b/src/components/moderation/comments-tab.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { useTransition, useState } from "react"; +import { removeCommentAction } from "@/actions/moderation"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +type Comment = { + id: string; + body: string; + isDeleted: boolean; + createdAt: Date; + user: { username: string | null; email: string | null }; + post: { title: string; id: string }; +}; + +type CommentsTabProps = { + communityId: string; + comments: Comment[]; + canManagePosts: boolean; +}; + +export function CommentsTab({ communityId, comments: initial, canManagePosts }: CommentsTabProps) { + const [comments, setComments] = useState(initial); + const [filter, setFilter] = useState<"all" | "removed">("all"); + const [message, setMessage] = useState<{ type: "error" | "success"; text: string } | null>(null); + const [isPending, startTransition] = useTransition(); + const [removalReasons, setRemovalReasons] = useState>({}); + + const displayed = comments.filter((c) => + filter === "removed" ? c.isDeleted : true + ); + + function handleRemove(commentId: string) { + setMessage(null); + startTransition(async () => { + const result = await removeCommentAction(commentId, communityId, removalReasons[commentId]); + if (result.error) { + setMessage({ type: "error", text: result.error }); + return; + } + setComments((prev) => + prev.map((c) => (c.id === commentId ? { ...c, isDeleted: true } : c)) + ); + setMessage({ type: "success", text: result.success ?? "Done." }); + }); + } + + if (!canManagePosts) { + return ( +

+ You do not have post management permission. +

+ ); + } + + return ( +
+
+ {(["all", "removed"] as const).map((f) => ( + + ))} +
+ + {message && ( +

+ {message.text} +

+ )} + + {displayed.length === 0 ? ( +

No comments to show.

+ ) : ( +
+ {displayed.map((comment) => ( +
+
+
+

{comment.body}

+

+ by {comment.user.username ?? comment.user.email ?? "unknown"} ·{" "} + {new Date(comment.createdAt).toLocaleDateString()} · on “ + {comment.post.title}” + {comment.isDeleted && ( + + Removed + + )} +

+
+ {!comment.isDeleted && ( + + )} +
+ {!comment.isDeleted && ( + + setRemovalReasons((prev) => ({ ...prev, [comment.id]: e.target.value })) + } + className="text-xs w-full max-w-xs rounded border border-input bg-transparent px-2 py-1 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" + /> + )} +
+ ))} +
+ )} +
+ ); +} diff --git a/src/components/moderation/governance-tab.tsx b/src/components/moderation/governance-tab.tsx new file mode 100644 index 0000000..5aa11c0 --- /dev/null +++ b/src/components/moderation/governance-tab.tsx @@ -0,0 +1,452 @@ +"use client"; + +import { useTransition, useState } from "react"; +import { + assignModeratorAction, + removeModeratorAction, + updateModeratorPermissionsAction, + closeCommunityAction, + reopenCommunityAction, + transferOwnershipAction, + deleteCommunityAction, +} from "@/actions/moderation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; + +type Moderator = { + userId: string; + canManageSettings: boolean; + canManagePosts: boolean; + canRestrictUsers: boolean; + user: { username: string | null; email: string | null }; +}; + +type GovernanceTabProps = { + community: { id: string; name: string; status: string; ownerId: string }; + moderators: Moderator[]; + ctx: { + isGlobalModerator: boolean; + isCommunityOwner: boolean; + canGovernCommunity: boolean; + }; +}; + +export function GovernanceTab({ community, moderators: initial, ctx }: GovernanceTabProps) { + const [moderators, setModerators] = useState(initial); + const [communityStatus, setCommunityStatus] = useState(community.status); + const [message, setMessage] = useState<{ type: "error" | "success"; text: string } | null>(null); + const [isPending, startTransition] = useTransition(); + + // Assign form + const [assignUserId, setAssignUserId] = useState(""); + const [assignPerms, setAssignPerms] = useState({ + canManageSettings: false, + canManagePosts: true, + canRestrictUsers: false, + }); + + // Transfer form + const [newOwnerId, setNewOwnerId] = useState(""); + const [confirmDelete, setConfirmDelete] = useState(false); + const [closeReason, setCloseReason] = useState(""); + + if (!ctx.canGovernCommunity) { + return ( +

+ Only community owners and global moderators can access governance controls. +

+ ); + } + + function handleAssign(e: React.FormEvent) { + e.preventDefault(); + setMessage(null); + startTransition(async () => { + const result = await assignModeratorAction(assignUserId.trim(), community.id, assignPerms); + if (result.error) { + setMessage({ type: "error", text: result.error }); + return; + } + setMessage({ type: "success", text: result.success ?? "Done. Refresh to see changes." }); + setAssignUserId(""); + }); + } + + function handleRemoveMod(userId: string) { + setMessage(null); + startTransition(async () => { + const result = await removeModeratorAction(userId, community.id); + if (result.error) { + setMessage({ type: "error", text: result.error }); + return; + } + setModerators((prev) => prev.filter((m) => m.userId !== userId)); + setMessage({ type: "success", text: result.success ?? "Done." }); + }); + } + + function handleUpdatePerms( + userId: string, + perms: { canManageSettings: boolean; canManagePosts: boolean; canRestrictUsers: boolean } + ) { + setMessage(null); + startTransition(async () => { + const result = await updateModeratorPermissionsAction(userId, community.id, perms); + if (result.error) { + setMessage({ type: "error", text: result.error }); + return; + } + setModerators((prev) => + prev.map((m) => (m.userId === userId ? { ...m, ...perms } : m)) + ); + setMessage({ type: "success", text: result.success ?? "Done." }); + }); + } + + function handleCloseCommunity() { + setMessage(null); + startTransition(async () => { + const result = await closeCommunityAction(community.id, closeReason || undefined); + if (result.error) { + setMessage({ type: "error", text: result.error }); + return; + } + setCommunityStatus("CLOSED"); + setMessage({ type: "success", text: result.success ?? "Done." }); + }); + } + + function handleReopenCommunity() { + setMessage(null); + startTransition(async () => { + const result = await reopenCommunityAction(community.id); + if (result.error) { + setMessage({ type: "error", text: result.error }); + return; + } + setCommunityStatus("ACTIVE"); + setMessage({ type: "success", text: result.success ?? "Done." }); + }); + } + + function handleTransfer(e: React.FormEvent) { + e.preventDefault(); + setMessage(null); + if (!newOwnerId.trim()) return; + startTransition(async () => { + const result = await transferOwnershipAction(community.id, newOwnerId.trim()); + if (result.error) { + setMessage({ type: "error", text: result.error }); + return; + } + setMessage({ type: "success", text: result.success ?? "Ownership transferred." }); + setNewOwnerId(""); + }); + } + + function handleDelete() { + if (!confirmDelete) { + setConfirmDelete(true); + return; + } + setMessage(null); + startTransition(async () => { + const result = await deleteCommunityAction(community.id); + if (result.error) { + setMessage({ type: "error", text: result.error }); + setConfirmDelete(false); + return; + } + // Redirect handled by revalidatePath in the action + window.location.href = "/communities"; + }); + } + + return ( +
+ {message && ( +

+ {message.text} +

+ )} + + {/* Moderator Roster */} +
+

+ Moderator Roster +

+ + {moderators.length === 0 ? ( +

No moderators assigned.

+ ) : ( +
+ {moderators.map((mod) => { + const isOwner = mod.userId === community.ownerId; + return ( +
+
+
+

+ {mod.user.username ?? mod.user.email ?? mod.userId} + {isOwner && ( + + Owner + + )} +

+
+ {( + [ + ["canManagePosts", "Posts"], + ["canRestrictUsers", "Restrictions"], + ["canManageSettings", "Settings"], + ] as const + ).map(([key, label]) => ( + + ))} +
+
+ {!isOwner && ( + + )} +
+
+ ); + })} +
+ )} + + {/* Assign new moderator */} +
+

Assign Moderator

+
+ + setAssignUserId(e.target.value)} + placeholder="Paste user UUID" + required + /> +
+
+ {( + [ + ["canManagePosts", "Manage Posts"], + ["canRestrictUsers", "Restrict Users"], + ["canManageSettings", "Manage Settings"], + ] as const + ).map(([key, label]) => ( + + ))} +
+ +
+
+ + {/* Community Lifecycle — owner only */} + {ctx.isCommunityOwner && ( +
+

+ Community Lifecycle +

+
+
+
+

+ Status:{" "} + + {communityStatus} + +

+

+ Closed communities hide new posting and joining. +

+
+ {communityStatus === "ACTIVE" ? ( +
+ setCloseReason(e.target.value)} + className="w-56 text-xs" + /> + +
+ ) : ( + + )} +
+
+
+ )} + + {/* Global mod can also close/reopen */} + {ctx.isGlobalModerator && !ctx.isCommunityOwner && ( +
+

+ Platform Moderation +

+
+
+
+

+ Status:{" "} + + {communityStatus} + +

+
+ {communityStatus === "ACTIVE" ? ( + + ) : ( + + )} +
+
+
+ )} + + {/* Transfer + Delete — owner only */} + {ctx.isCommunityOwner && ( +
+

+ Danger Zone +

+
+ {/* Transfer ownership */} +
+

Transfer Ownership

+

+ This permanently transfers control to another user. You cannot undo this. +

+
+ setNewOwnerId(e.target.value)} + placeholder="New owner's user UUID" + required + /> + +
+
+ + {/* Delete community */} +
+

Delete Community

+

+ Permanently deletes this community and all its content. This cannot be undone. +

+ + {confirmDelete && ( + + )} +
+
+
+ )} +
+ ); +} diff --git a/src/components/moderation/mod-log-tab.tsx b/src/components/moderation/mod-log-tab.tsx new file mode 100644 index 0000000..a662fee --- /dev/null +++ b/src/components/moderation/mod-log-tab.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; + +type ModLogEntry = { + id: string; + action: string; + details: unknown; + createdAt: Date; + moderator: { username: string | null; email: string | null }; + targetUser: { username: string | null; email: string | null } | null; + targetPost: { title: string } | null; + targetComment: { body: string } | null; +}; + +type ModLogTabProps = { + entries: ModLogEntry[]; +}; + +const ACTION_LABELS: Record = { + REMOVE_POST: "Removed post", + REMOVE_COMMENT: "Removed comment", + BAN_USER: "Banned user", + MUTE_USER: "Muted user", + UNBAN_USER: "Unbanned user", + UNMUTE_USER: "Unmuted user", + UPDATE_SETTINGS: "Updated settings", + PIN_POST: "Pinned post", + UNPIN_POST: "Unpinned post", + ASSIGN_MODERATOR: "Assigned moderator", + REMOVE_MODERATOR: "Removed moderator", + UPDATE_MODERATOR_PERMISSIONS: "Updated mod permissions", + CLOSE_COMMUNITY: "Closed community", + REOPEN_COMMUNITY: "Reopened community", + DELETE_COMMUNITY: "Deleted community", + TRANSFER_COMMUNITY_OWNERSHIP: "Transferred ownership", + RESOLVE_REPORT: "Resolved report", + DISMISS_REPORT: "Dismissed report", +}; + +export function ModLogTab({ entries }: ModLogTabProps) { + const [actionFilter, setActionFilter] = useState("all"); + + const actionTypes = ["all", ...Array.from(new Set(entries.map((e) => e.action)))]; + const displayed = actionFilter === "all" ? entries : entries.filter((e) => e.action === actionFilter); + + return ( +
+
+ {actionTypes.map((a) => ( + + ))} +
+ + {displayed.length === 0 ? ( +

No log entries.

+ ) : ( +
+ {displayed.map((entry) => ( +
+
+
+

+ {ACTION_LABELS[entry.action] ?? entry.action} +

+

+ by {entry.moderator.username ?? entry.moderator.email ?? "mod"} ·{" "} + {new Date(entry.createdAt).toLocaleString()} +

+ {entry.targetUser && ( +

+ Target: {entry.targetUser.username ?? entry.targetUser.email} +

+ )} + {entry.targetPost && ( +

+ Post: {entry.targetPost.title} +

+ )} + {entry.targetComment && ( +

+ Comment: {entry.targetComment.body} +

+ )} +
+
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/components/moderation/panel.tsx b/src/components/moderation/panel.tsx new file mode 100644 index 0000000..2383209 --- /dev/null +++ b/src/components/moderation/panel.tsx @@ -0,0 +1,236 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { ReportsTab } from "./reports-tab"; +import { PostsTab } from "./posts-tab"; +import { CommentsTab } from "./comments-tab"; +import { RestrictionsTab } from "./restrictions-tab"; +import { ModLogTab } from "./mod-log-tab"; +import { SettingsTab } from "./settings-tab"; +import { GovernanceTab } from "./governance-tab"; +import type { ModerationContext } from "@/lib/moderation/permissions"; + +// ─── Types mirrored from server page queries ────────────────────────────────── + +type Report = { + id: string; + status: string; + customReason: string | null; + createdAt: Date; + reporter: { username: string | null; email: string | null }; + reportedUser: { username: string | null; email: string | null } | null; + post: { title: string; id: string } | null; + comment: { body: string; id: string } | null; + rule: { title: string } | null; +}; + +type Post = { + id: string; + title: string; + status: string; + isPinned: boolean; + isDeleted: boolean; + createdAt: Date; + user: { username: string | null; email: string | null }; +}; + +type Comment = { + id: string; + body: string; + isDeleted: boolean; + createdAt: Date; + user: { username: string | null; email: string | null }; + post: { title: string; id: string }; +}; + +type Restriction = { + id: string; + type: string; + reason: string | null; + expiresAt: Date | null; + createdAt: Date; + user: { id: string; username: string | null; email: string | null }; + moderator: { username: string | null; email: string | null }; +}; + +type ModLogEntry = { + id: string; + action: string; + details: unknown; + createdAt: Date; + moderator: { username: string | null; email: string | null }; + targetUser: { username: string | null; email: string | null } | null; + targetPost: { title: string } | null; + targetComment: { body: string } | null; +}; + +type Rule = { + id: string; + title: string; + description: string | null; + displayOrder: number; +}; + +type Moderator = { + userId: string; + canManageSettings: boolean; + canManagePosts: boolean; + canRestrictUsers: boolean; + user: { username: string | null; email: string | null }; +}; + +type PanelProps = { + community: { id: string; name: string; status: string; ownerId: string }; + ctx: ModerationContext; + reports: Report[]; + posts: Post[]; + comments: Comment[]; + restrictions: Restriction[]; + modLogs: ModLogEntry[]; + rules: Rule[]; + moderators: Moderator[]; +}; + +type Tab = + | "reports" + | "posts" + | "comments" + | "restrictions" + | "log" + | "settings" + | "governance"; + +export function ModerationPanel({ + community, + ctx, + reports, + posts, + comments, + restrictions, + modLogs, + rules, + moderators, +}: PanelProps) { + const [activeTab, setActiveTab] = useState("reports"); + + const tabs: { id: Tab; label: string; show: boolean }[] = [ + { id: "reports", label: "Reports", show: true }, + { id: "posts", label: "Posts", show: ctx.canManagePosts }, + { id: "comments", label: "Comments", show: ctx.canManagePosts }, + { id: "restrictions", label: "Restrictions", show: ctx.canRestrictUsers }, + { id: "log", label: "Mod Log", show: true }, + { id: "settings", label: "Settings", show: ctx.canManageSettings }, + { id: "governance", label: "Governance", show: ctx.canGovernCommunity }, + ].filter((t) => t.show); + + const pendingReports = reports.filter((r) => r.status === "PENDING").length; + + return ( +
+ {/* Header */} +
+
+

Moderation Panel

+ {community.status === "CLOSED" && ( + + Closed + + )} +
+
+ c/{community.name} + · + {ctx.isGlobalModerator && ( + + Global Mod + + )} + {ctx.isCommunityOwner && ( + + Owner + + )} + {ctx.isCommunityModerator && !ctx.isCommunityOwner && ( + + Moderator + + )} +
+
+ + {/* Tab navigation */} + + + {/* Tab content */} +
+ {activeTab === "reports" && ( + + )} + {activeTab === "posts" && ( + + )} + {activeTab === "comments" && ( + + )} + {activeTab === "restrictions" && ( + + )} + {activeTab === "log" && } + {activeTab === "settings" && ( + + )} + {activeTab === "governance" && ( + + )} +
+
+ ); +} diff --git a/src/components/moderation/posts-tab.tsx b/src/components/moderation/posts-tab.tsx new file mode 100644 index 0000000..2380db6 --- /dev/null +++ b/src/components/moderation/posts-tab.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { useTransition, useState } from "react"; +import { removePostAction, pinPostAction } from "@/actions/moderation"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +type Post = { + id: string; + title: string; + status: string; + isPinned: boolean; + isDeleted: boolean; + createdAt: Date; + user: { username: string | null; email: string | null }; +}; + +type PostsTabProps = { + communityId: string; + posts: Post[]; + canManagePosts: boolean; +}; + +export function PostsTab({ communityId, posts: initial, canManagePosts }: PostsTabProps) { + const [posts, setPosts] = useState(initial); + const [filter, setFilter] = useState<"all" | "removed" | "pinned">("all"); + const [message, setMessage] = useState<{ type: "error" | "success"; text: string } | null>(null); + const [isPending, startTransition] = useTransition(); + const [removalReasons, setRemovalReasons] = useState>({}); + + const displayed = posts.filter((p) => { + if (filter === "removed") return p.isDeleted || p.status === "REMOVED"; + if (filter === "pinned") return p.isPinned; + return true; + }); + + function handleRemove(postId: string) { + setMessage(null); + startTransition(async () => { + const result = await removePostAction(postId, communityId, removalReasons[postId]); + if (result.error) { + setMessage({ type: "error", text: result.error }); + return; + } + setPosts((prev) => + prev.map((p) => + p.id === postId ? { ...p, status: "REMOVED", isDeleted: true } : p + ) + ); + setMessage({ type: "success", text: result.success ?? "Done." }); + }); + } + + function handlePin(postId: string, pin: boolean) { + setMessage(null); + startTransition(async () => { + const result = await pinPostAction(postId, communityId, pin); + if (result.error) { + setMessage({ type: "error", text: result.error }); + return; + } + setPosts((prev) => + prev.map((p) => (p.id === postId ? { ...p, isPinned: pin } : p)) + ); + setMessage({ type: "success", text: result.success ?? "Done." }); + }); + } + + if (!canManagePosts) { + return ( +

+ You do not have post management permission. +

+ ); + } + + return ( +
+
+ {(["all", "removed", "pinned"] as const).map((f) => ( + + ))} +
+ + {message && ( +

+ {message.text} +

+ )} + + {displayed.length === 0 ? ( +

No posts to show.

+ ) : ( +
+ {displayed.map((post) => ( +
+
+
+

{post.title}

+

+ by {post.user.username ?? post.user.email ?? "unknown"} ·{" "} + {new Date(post.createdAt).toLocaleDateString()} + {post.isPinned && ( + Pinned + )} + {(post.isDeleted || post.status === "REMOVED") && ( + + Removed + + )} +

+
+
+ {!post.isDeleted && post.status !== "REMOVED" && ( + <> + + + + )} +
+
+ {!post.isDeleted && post.status !== "REMOVED" && ( + + setRemovalReasons((prev) => ({ ...prev, [post.id]: e.target.value })) + } + className="text-xs w-full max-w-xs rounded border border-input bg-transparent px-2 py-1 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" + /> + )} +
+ ))} +
+ )} +
+ ); +} diff --git a/src/components/moderation/reports-tab.tsx b/src/components/moderation/reports-tab.tsx new file mode 100644 index 0000000..2467807 --- /dev/null +++ b/src/components/moderation/reports-tab.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { useTransition, useState } from "react"; +import { resolveReportAction, dismissReportAction } from "@/actions/moderation"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +type Report = { + id: string; + status: string; + customReason: string | null; + createdAt: Date; + reporter: { username: string | null; email: string | null }; + reportedUser: { username: string | null; email: string | null } | null; + post: { title: string; id: string } | null; + comment: { body: string; id: string } | null; + rule: { title: string } | null; +}; + +type ReportsTabProps = { + communityId: string; + reports: Report[]; +}; + +export function ReportsTab({ communityId, reports: initial }: ReportsTabProps) { + const [reports, setReports] = useState(initial); + const [statusFilter, setStatusFilter] = useState<"PENDING" | "RESOLVED" | "DISMISSED">("PENDING"); + const [message, setMessage] = useState<{ type: "error" | "success"; text: string } | null>(null); + const [isPending, startTransition] = useTransition(); + + const displayed = reports.filter((r) => r.status === statusFilter); + + function actOnReport( + reportId: string, + action: "resolve" | "dismiss" + ) { + setMessage(null); + startTransition(async () => { + const fn = action === "resolve" ? resolveReportAction : dismissReportAction; + const result = await fn(reportId, communityId); + if (result.error) { + setMessage({ type: "error", text: result.error }); + return; + } + setReports((prev) => + prev.map((r) => + r.id === reportId + ? { ...r, status: action === "resolve" ? "RESOLVED" : "DISMISSED" } + : r + ) + ); + setMessage({ type: "success", text: result.success ?? "Done." }); + }); + } + + return ( +
+ {/* Filters */} +
+ {(["PENDING", "RESOLVED", "DISMISSED"] as const).map((s) => ( + + ))} +
+ + {message && ( +

+ {message.text} +

+ )} + + {displayed.length === 0 ? ( +

No {statusFilter.toLowerCase()} reports.

+ ) : ( +
+ {displayed.map((report) => ( +
+
+
+

+ {report.post + ? `Post: ${report.post.title}` + : report.comment + ? `Comment: ${report.comment.body.slice(0, 80)}…` + : report.reportedUser + ? `User: ${report.reportedUser.username ?? report.reportedUser.email}` + : "Unknown target"} +

+

+ Reported by {report.reporter.username ?? report.reporter.email ?? "unknown"} ·{" "} + {new Date(report.createdAt).toLocaleDateString()} + {report.rule && ` · Rule: ${report.rule.title}`} +

+ {report.customReason && ( +

+ “{report.customReason}” +

+ )} +
+ {report.status === "PENDING" && ( +
+ + +
+ )} + {report.status !== "PENDING" && ( + + {report.status.charAt(0) + report.status.slice(1).toLowerCase()} + + )} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/components/moderation/restrictions-tab.tsx b/src/components/moderation/restrictions-tab.tsx new file mode 100644 index 0000000..13afd30 --- /dev/null +++ b/src/components/moderation/restrictions-tab.tsx @@ -0,0 +1,210 @@ +"use client"; + +import { useTransition, useState } from "react"; +import { muteUserAction, banUserAction, revokeRestrictionAction } from "@/actions/moderation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; + +type Restriction = { + id: string; + type: string; + reason: string | null; + expiresAt: Date | null; + createdAt: Date; + user: { id: string; username: string | null; email: string | null }; + moderator: { username: string | null; email: string | null }; +}; + +type RestrictionsTabProps = { + communityId: string; + restrictions: Restriction[]; + canRestrictUsers: boolean; +}; + +export function RestrictionsTab({ + communityId, + restrictions: initial, + canRestrictUsers, +}: RestrictionsTabProps) { + const [restrictions, setRestrictions] = useState(initial); + const [filter, setFilter] = useState<"BAN" | "MUTE">("BAN"); + const [message, setMessage] = useState<{ type: "error" | "success"; text: string } | null>(null); + const [isPending, startTransition] = useTransition(); + + // Add form state + const [targetUsername, setTargetUsername] = useState(""); + const [reason, setReason] = useState(""); + const [expiresAt, setExpiresAt] = useState(""); + const [addType, setAddType] = useState<"BAN" | "MUTE">("BAN"); + + const displayed = restrictions.filter((r) => r.type === filter); + + function handleRevoke(restrictionId: string) { + setMessage(null); + startTransition(async () => { + const result = await revokeRestrictionAction(restrictionId, communityId); + if (result.error) { + setMessage({ type: "error", text: result.error }); + return; + } + setRestrictions((prev) => prev.filter((r) => r.id !== restrictionId)); + setMessage({ type: "success", text: result.success ?? "Done." }); + }); + } + + async function handleAdd(e: React.FormEvent) { + e.preventDefault(); + setMessage(null); + + // Look up the user ID from username — not ideal but avoids extra server round-trip pattern + // We pass username and let the parent resolve, but for now we just use the input as userId + // In a real implementation this form would use a user search / autocomplete + const targetUserId = targetUsername.trim(); + if (!targetUserId) return; + + startTransition(async () => { + const fn = addType === "BAN" ? banUserAction : muteUserAction; + const result = + addType === "BAN" + ? await banUserAction(targetUserId, communityId, reason || undefined) + : await muteUserAction(targetUserId, communityId, reason || undefined, expiresAt || undefined); + + if (result.error) { + setMessage({ type: "error", text: result.error }); + return; + } + setMessage({ type: "success", text: result.success ?? "Done. Refresh to see new restriction." }); + setTargetUsername(""); + setReason(""); + setExpiresAt(""); + }); + } + + if (!canRestrictUsers) { + return ( +

+ You do not have user restriction permission. +

+ ); + } + + return ( +
+ {/* Add restriction form */} +
+

+ Add Restriction +

+
+
+ {(["BAN", "MUTE"] as const).map((t) => ( + + ))} +
+
+ + setTargetUsername(e.target.value)} + placeholder="Paste user UUID" + required + /> +
+
+ + setReason(e.target.value)} + placeholder="e.g. Repeated rule violations" + /> +
+ {addType === "MUTE" && ( +
+ + setExpiresAt(e.target.value)} + /> +
+ )} + +
+
+ + {/* Existing restrictions */} +
+ {(["BAN", "MUTE"] as const).map((f) => ( + + ))} +
+ + {message && ( +

+ {message.text} +

+ )} + + {displayed.length === 0 ? ( +

+ No active {filter.toLowerCase()}s. +

+ ) : ( +
+ {displayed.map((r) => ( +
+
+

+ {r.user.username ?? r.user.email ?? r.user.id} +

+

+ by {r.moderator.username ?? r.moderator.email ?? "mod"} ·{" "} + {new Date(r.createdAt).toLocaleDateString()} + {r.expiresAt && ` · expires ${new Date(r.expiresAt).toLocaleDateString()}`} +

+ {r.reason && ( +

“{r.reason}”

+ )} +
+ +
+ ))} +
+ )} +
+ ); +} diff --git a/src/components/moderation/settings-tab.tsx b/src/components/moderation/settings-tab.tsx new file mode 100644 index 0000000..239cf75 --- /dev/null +++ b/src/components/moderation/settings-tab.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { useTransition, useState } from "react"; +import { ChevronUp, ChevronDown } from "lucide-react"; +import { addRuleAction, reorderRulesAction } from "@/actions/communities"; +import { deleteRuleAction } from "@/actions/moderation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; + +type Rule = { + id: string; + title: string; + description: string | null; + displayOrder: number; +}; + +type SettingsTabProps = { + community: { id: string; name: string }; + rules: Rule[]; + canManageSettings: boolean; +}; + +export function SettingsTab({ community, rules: initialRules, canManageSettings }: SettingsTabProps) { + const [rules, setRules] = useState(initialRules); + const [newTitle, setNewTitle] = useState(""); + const [newDescription, setNewDescription] = useState(""); + const [message, setMessage] = useState<{ type: "error" | "success"; text: string } | null>(null); + const [isAddPending, startAdd] = useTransition(); + const [isReorderPending, startReorder] = useTransition(); + const [isDeletePending, startDelete] = useTransition(); + + if (!canManageSettings) { + return ( +

+ You do not have settings management permission. +

+ ); + } + + function handleAddRule(e: React.FormEvent) { + e.preventDefault(); + setMessage(null); + const fd = new FormData(); + fd.set("communityId", community.id); + fd.set("communityName", community.name); + fd.set("title", newTitle); + fd.set("description", newDescription); + startAdd(async () => { + const result = await addRuleAction(fd); + if (result.error) { + setMessage({ type: "error", text: result.error }); + return; + } + setMessage({ type: "success", text: "Rule added." }); + setNewTitle(""); + setNewDescription(""); + }); + } + + function moveRule(index: number, direction: "up" | "down") { + const swap = direction === "up" ? index - 1 : index + 1; + if (swap < 0 || swap >= rules.length) return; + const updated = [...rules]; + [updated[index], updated[swap]] = [updated[swap], updated[index]]; + setRules(updated); + startReorder(async () => { + const result = await reorderRulesAction( + updated.map((r) => r.id), + community.id, + community.name + ); + if (result.error) setRules(rules); + }); + } + + function handleDeleteRule(ruleId: string) { + setMessage(null); + startDelete(async () => { + const result = await deleteRuleAction(ruleId, community.id, community.name); + if (result.error) { + setMessage({ type: "error", text: result.error }); + return; + } + setRules((prev) => prev.filter((r) => r.id !== ruleId)); + setMessage({ type: "success", text: "Rule deleted." }); + }); + } + + return ( +
+
+

+ Add a Rule +

+
+
+ + setNewTitle(e.target.value.slice(0, 100))} + placeholder="e.g. Be respectful" + disabled={isAddPending} + required + /> +
+
+ +