feat(sprint7): Phase 3 — Forum MVP

- Flyway V15: forum_topics, forum_replies, forum_reactions, forum_reports tables
- Enums: ForumTargetType, ReactionType, ReportStatus
- Extended AuditEventType with FORUM_REPLY_CREATED, FORUM_REPORT_REVIEWED
- Entities: ForumTopic, ForumReply, ForumReaction, ForumReport
- Repositories: ForumTopicRepository, ForumReplyRepository, ForumReactionRepository, ForumReportRepository
- ForumService: full CRUD, moderation (lock/pin/delete), 60-min edit window,
  toggle reactions, content reporting, notifications on new topics/replies
- ForumController: admin + portal endpoints (topics, replies, reactions, reports, moderation)
- Frontend: forum.ts service with React Query hooks (admin + portal)
- Frontend: Admin forum page with topic list, moderation actions (lock/pin/delete)
- Frontend: Portal forum page with topic list, reply thread, reactions, report
- Navigation: added Forum with MessageSquare icon
- i18n: forum.* keys in de.json and en.json
This commit is contained in:
Patrick Plate
2026-06-13 20:31:17 +02:00
parent 05fd679c4d
commit a539ed9eb2
21 changed files with 2059 additions and 14 deletions
@@ -0,0 +1,227 @@
"use client"
import { useState } from "react"
import { useTranslations } from "next-intl"
import {
Lock,
MessageSquare,
Pin,
Plus,
Trash2,
Unlock,
Flag,
PinOff,
} from "lucide-react"
import {
useForumTopics,
useCreateTopic,
useLockTopic,
useUnlockTopic,
usePinTopic,
useUnpinTopic,
useDeleteTopic,
useOpenReportCount,
type ForumTopic,
} from "@/services/forum"
export default function ForumPage() {
const t = useTranslations("forum")
const [showCreate, setShowCreate] = useState(false)
const [title, setTitle] = useState("")
const [content, setContent] = useState("")
const { data: topicsData, isLoading } = useForumTopics()
const { data: reportCount } = useOpenReportCount()
const createTopic = useCreateTopic()
const lockTopic = useLockTopic()
const unlockTopic = useUnlockTopic()
const pinTopic = usePinTopic()
const unpinTopic = useUnpinTopic()
const deleteTopic = useDeleteTopic()
const topics: ForumTopic[] = topicsData?.content ?? []
const handleCreate = () => {
if (!title.trim() || !content.trim()) return
createTopic.mutate(
{ title: title.trim(), content: content.trim() },
{
onSuccess: () => {
setTitle("")
setContent("")
setShowCreate(false)
},
}
)
}
const handleDelete = (topicId: string) => {
const reason = prompt(t("deleteReason"))
if (reason !== null) {
deleteTopic.mutate({ topicId, reason })
}
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">{t("title")}</h1>
<p className="text-muted-foreground text-sm">{t("description")}</p>
</div>
<div className="flex items-center gap-3">
{reportCount?.count > 0 && (
<a
href="/forum/reports"
className="bg-destructive/10 text-destructive inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium"
>
<Flag className="h-4 w-4" />
{reportCount.count} {t("openReports")}
</a>
)}
<button
onClick={() => setShowCreate(!showCreate)}
className="bg-primary text-primary-foreground inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium"
>
<Plus className="h-4 w-4" />
{t("newTopic")}
</button>
</div>
</div>
{/* Create Topic Form */}
{showCreate && (
<div className="bg-card rounded-lg border p-4 space-y-3">
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("topicTitlePlaceholder")}
className="bg-background w-full rounded-md border px-3 py-2 text-sm"
/>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={t("topicContentPlaceholder")}
rows={4}
className="bg-background w-full rounded-md border px-3 py-2 text-sm"
/>
<div className="flex gap-2">
<button
onClick={handleCreate}
disabled={createTopic.isPending}
className="bg-primary text-primary-foreground rounded-md px-4 py-2 text-sm font-medium disabled:opacity-50"
>
{createTopic.isPending ? t("creating") : t("create")}
</button>
<button
onClick={() => setShowCreate(false)}
className="text-muted-foreground rounded-md px-4 py-2 text-sm"
>
{t("cancel")}
</button>
</div>
</div>
)}
{/* Topic List */}
{isLoading ? (
<div className="text-muted-foreground py-8 text-center text-sm">
{t("loading")}
</div>
) : topics.length === 0 ? (
<div className="text-muted-foreground py-8 text-center text-sm">
{t("noTopics")}
</div>
) : (
<div className="space-y-2">
{topics.map((topic) => (
<div
key={topic.id}
className="bg-card flex items-center justify-between rounded-lg border p-4"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
{topic.pinned && (
<Pin className="text-primary h-4 w-4 shrink-0" />
)}
{topic.locked && (
<Lock className="text-muted-foreground h-4 w-4 shrink-0" />
)}
<a
href={`/forum/${topic.id}`}
className="font-medium hover:underline truncate"
>
{topic.title}
</a>
</div>
<div className="text-muted-foreground mt-1 flex items-center gap-3 text-xs">
<span className="inline-flex items-center gap-1">
<MessageSquare className="h-3 w-3" />
{topic.replyCount} {t("replies")}
</span>
<span>
{new Date(topic.createdAt).toLocaleDateString("de-DE")}
</span>
{topic.lastReplyAt && (
<span>
{t("lastReply")}:{" "}
{new Date(topic.lastReplyAt).toLocaleDateString("de-DE")}
</span>
)}
</div>
</div>
{/* Moderation Actions */}
<div className="flex items-center gap-1 ml-4">
{topic.pinned ? (
<button
onClick={() => unpinTopic.mutate(topic.id)}
className="text-muted-foreground hover:text-foreground rounded p-1.5"
title={t("unpin")}
>
<PinOff className="h-4 w-4" />
</button>
) : (
<button
onClick={() => pinTopic.mutate(topic.id)}
className="text-muted-foreground hover:text-foreground rounded p-1.5"
title={t("pin")}
>
<Pin className="h-4 w-4" />
</button>
)}
{topic.locked ? (
<button
onClick={() => unlockTopic.mutate(topic.id)}
className="text-muted-foreground hover:text-foreground rounded p-1.5"
title={t("unlock")}
>
<Unlock className="h-4 w-4" />
</button>
) : (
<button
onClick={() => lockTopic.mutate(topic.id)}
className="text-muted-foreground hover:text-foreground rounded p-1.5"
title={t("lock")}
>
<Lock className="h-4 w-4" />
</button>
)}
<button
onClick={() => handleDelete(topic.id)}
className="text-muted-foreground hover:text-destructive rounded p-1.5"
title={t("delete")}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,299 @@
"use client"
import { useState } from "react"
import { useTranslations } from "next-intl"
import {
Lock,
MessageSquare,
Pin,
Plus,
ThumbsUp,
ThumbsDown,
Flag,
ArrowLeft,
} from "lucide-react"
import {
usePortalForumTopics,
usePortalForumTopic,
usePortalForumReplies,
usePortalCreateTopic,
usePortalCreateReply,
usePortalToggleReaction,
usePortalReportContent,
type ForumTopic,
type ForumReply,
} from "@/services/forum"
export default function PortalForumPage() {
const t = useTranslations("forum")
const [selectedTopicId, setSelectedTopicId] = useState<string | null>(null)
const [showCreate, setShowCreate] = useState(false)
const [title, setTitle] = useState("")
const [content, setContent] = useState("")
const [replyContent, setReplyContent] = useState("")
const { data: topicsData, isLoading } = usePortalForumTopics()
const { data: topicDetail } = usePortalForumTopic(selectedTopicId ?? undefined)
const { data: repliesData } = usePortalForumReplies(selectedTopicId ?? undefined)
const createTopic = usePortalCreateTopic()
const createReply = usePortalCreateReply(selectedTopicId ?? "")
const toggleReaction = usePortalToggleReaction()
const reportContent = usePortalReportContent()
const topics: ForumTopic[] = topicsData?.content ?? []
const replies: ForumReply[] = repliesData?.content ?? []
const handleCreate = () => {
if (!title.trim() || !content.trim()) return
createTopic.mutate(
{ title: title.trim(), content: content.trim() },
{
onSuccess: () => {
setTitle("")
setContent("")
setShowCreate(false)
},
}
)
}
const handleReply = () => {
if (!replyContent.trim() || !selectedTopicId) return
createReply.mutate(
{ content: replyContent.trim() },
{
onSuccess: () => setReplyContent(""),
}
)
}
const handleReport = (targetType: "TOPIC" | "REPLY", targetId: string) => {
const reason = prompt(t("reportReason"))
if (reason) {
reportContent.mutate({ targetType, targetId, reason })
}
}
// Topic Detail View
if (selectedTopicId && topicDetail) {
return (
<div className="space-y-4">
<button
onClick={() => setSelectedTopicId(null)}
className="text-muted-foreground inline-flex items-center gap-1 text-sm hover:underline"
>
<ArrowLeft className="h-4 w-4" />
{t("backToTopics")}
</button>
{/* Topic */}
<div className="bg-card rounded-lg border p-4">
<div className="flex items-center gap-2 mb-2">
{topicDetail.pinned && <Pin className="text-primary h-4 w-4" />}
{topicDetail.locked && <Lock className="text-muted-foreground h-4 w-4" />}
<h2 className="text-lg font-bold">{topicDetail.title}</h2>
</div>
<div
className="prose prose-sm dark:prose-invert mb-3"
dangerouslySetInnerHTML={{ __html: topicDetail.content }}
/>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span>{new Date(topicDetail.createdAt).toLocaleDateString("de-DE")}</span>
<div className="flex items-center gap-2">
<button
onClick={() =>
toggleReaction.mutate({
targetType: "TOPIC",
targetId: topicDetail.id,
reactionType: "THUMBS_UP",
})
}
className="inline-flex items-center gap-1 rounded px-2 py-1 hover:bg-muted"
>
<ThumbsUp className="h-3 w-3" />
</button>
<button
onClick={() =>
toggleReaction.mutate({
targetType: "TOPIC",
targetId: topicDetail.id,
reactionType: "THUMBS_DOWN",
})
}
className="inline-flex items-center gap-1 rounded px-2 py-1 hover:bg-muted"
>
<ThumbsDown className="h-3 w-3" />
</button>
<button
onClick={() => handleReport("TOPIC", topicDetail.id)}
className="inline-flex items-center gap-1 rounded px-2 py-1 hover:bg-muted text-muted-foreground"
>
<Flag className="h-3 w-3" />
</button>
</div>
</div>
</div>
{/* Replies */}
<div className="space-y-2">
{replies.map((reply) => (
<div key={reply.id} className="bg-card rounded-lg border p-3">
<div
className="prose prose-sm dark:prose-invert mb-2"
dangerouslySetInnerHTML={{ __html: reply.content }}
/>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span>{new Date(reply.createdAt).toLocaleDateString("de-DE")}</span>
{reply.edited && <span className="italic">({t("edited")})</span>}
<div className="flex items-center gap-1">
<button
onClick={() =>
toggleReaction.mutate({
targetType: "REPLY",
targetId: reply.id,
reactionType: "THUMBS_UP",
})
}
className="rounded px-1.5 py-0.5 hover:bg-muted"
>
<ThumbsUp className="h-3 w-3" />
</button>
<button
onClick={() =>
toggleReaction.mutate({
targetType: "REPLY",
targetId: reply.id,
reactionType: "THUMBS_DOWN",
})
}
className="rounded px-1.5 py-0.5 hover:bg-muted"
>
<ThumbsDown className="h-3 w-3" />
</button>
<button
onClick={() => handleReport("REPLY", reply.id)}
className="rounded px-1.5 py-0.5 hover:bg-muted"
>
<Flag className="h-3 w-3" />
</button>
</div>
</div>
</div>
))}
</div>
{/* Reply Form */}
{!topicDetail.locked && (
<div className="bg-card rounded-lg border p-3 space-y-2">
<textarea
value={replyContent}
onChange={(e) => setReplyContent(e.target.value)}
placeholder={t("replyPlaceholder")}
rows={3}
className="bg-background w-full rounded-md border px-3 py-2 text-sm"
/>
<button
onClick={handleReply}
disabled={createReply.isPending || !replyContent.trim()}
className="bg-primary text-primary-foreground rounded-md px-4 py-2 text-sm font-medium disabled:opacity-50"
>
{createReply.isPending ? t("sending") : t("reply")}
</button>
</div>
)}
{topicDetail.locked && (
<div className="text-muted-foreground text-center text-sm py-4">
<Lock className="h-4 w-4 inline mr-1" />
{t("topicLocked")}
</div>
)}
</div>
)
}
// Topic List View
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold">{t("title")}</h2>
<button
onClick={() => setShowCreate(!showCreate)}
className="bg-primary text-primary-foreground inline-flex items-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium"
>
<Plus className="h-4 w-4" />
{t("newTopic")}
</button>
</div>
{showCreate && (
<div className="bg-card rounded-lg border p-4 space-y-3">
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("topicTitlePlaceholder")}
className="bg-background w-full rounded-md border px-3 py-2 text-sm"
/>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={t("topicContentPlaceholder")}
rows={4}
className="bg-background w-full rounded-md border px-3 py-2 text-sm"
/>
<div className="flex gap-2">
<button
onClick={handleCreate}
disabled={createTopic.isPending}
className="bg-primary text-primary-foreground rounded-md px-4 py-2 text-sm font-medium disabled:opacity-50"
>
{createTopic.isPending ? t("creating") : t("create")}
</button>
<button
onClick={() => setShowCreate(false)}
className="text-muted-foreground rounded-md px-4 py-2 text-sm"
>
{t("cancel")}
</button>
</div>
</div>
)}
{isLoading ? (
<div className="text-muted-foreground py-8 text-center text-sm">
{t("loading")}
</div>
) : topics.length === 0 ? (
<div className="text-muted-foreground py-8 text-center text-sm">
{t("noTopics")}
</div>
) : (
<div className="space-y-2">
{topics.map((topic) => (
<button
key={topic.id}
onClick={() => setSelectedTopicId(topic.id)}
className="bg-card w-full text-left rounded-lg border p-4 hover:border-primary/50 transition-colors"
>
<div className="flex items-center gap-2">
{topic.pinned && <Pin className="text-primary h-4 w-4 shrink-0" />}
{topic.locked && <Lock className="text-muted-foreground h-4 w-4 shrink-0" />}
<span className="font-medium truncate">{topic.title}</span>
</div>
<div className="text-muted-foreground mt-1 flex items-center gap-3 text-xs">
<span className="inline-flex items-center gap-1">
<MessageSquare className="h-3 w-3" />
{topic.replyCount} {t("replies")}
</span>
<span>
{new Date(topic.createdAt).toLocaleDateString("de-DE")}
</span>
</div>
</button>
))}
</div>
)}
</div>
)
}
@@ -44,6 +44,11 @@ export const navigationsData: NavigationType[] = [
href: "/calendar",
iconName: "Calendar",
},
{
title: "Forum",
href: "/forum",
iconName: "MessageSquare",
},
{
title: "Personal",
href: "/settings/staff",
+382
View File
@@ -0,0 +1,382 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { apiClient } from "@/lib/api-client"
// --- Types ---
export type ForumTargetType = "TOPIC" | "REPLY"
export type ReactionType = "THUMBS_UP" | "THUMBS_DOWN"
export type ReportStatus = "OPEN" | "REVIEWED" | "DISMISSED"
export interface ForumTopic {
id: string
clubId: string
title: string
content: string
authorId: string
locked: boolean
pinned: boolean
replyCount: number
lastReplyAt: string | null
createdAt: string
updatedAt: string
}
export interface ForumReply {
id: string
topicId: string
clubId: string
content: string
authorId: string
edited: boolean
editedAt: string | null
createdAt: string
}
export interface ForumReport {
id: string
clubId: string
targetType: ForumTargetType
targetId: string
reporterId: string
reason: string
status: ReportStatus
reviewedBy: string | null
reviewedAt: string | null
createdAt: string
}
export interface CreateTopicRequest {
title: string
content: string
}
export interface CreateReplyRequest {
content: string
}
export interface ReactionRequest {
targetType: ForumTargetType
targetId: string
reactionType: ReactionType
}
export interface ReportRequest {
targetType: ForumTargetType
targetId: string
reason: string
}
interface PageResponse<T> {
content: T[]
totalElements: number
totalPages: number
number: number
size: number
}
// --- Admin Hooks ---
export function useForumTopics(page = 0, size = 20) {
return useQuery({
queryKey: ["forum-topics", page, size],
queryFn: () =>
apiClient<PageResponse<ForumTopic>>(
`/forum/topics?page=${page}&size=${size}`
),
})
}
export function useForumTopic(topicId: string | undefined) {
return useQuery({
queryKey: ["forum-topic", topicId],
queryFn: () => apiClient<ForumTopic>(`/forum/topics/${topicId}`),
enabled: !!topicId,
})
}
export function useForumReplies(
topicId: string | undefined,
page = 0,
size = 50
) {
return useQuery({
queryKey: ["forum-replies", topicId, page, size],
queryFn: () =>
apiClient<PageResponse<ForumReply>>(
`/forum/topics/${topicId}/replies?page=${page}&size=${size}`
),
enabled: !!topicId,
})
}
export function useForumReports(status: ReportStatus = "OPEN", page = 0) {
return useQuery({
queryKey: ["forum-reports", status, page],
queryFn: () =>
apiClient<PageResponse<ForumReport>>(
`/forum/reports?status=${status}&page=${page}`
),
})
}
export function useOpenReportCount() {
return useQuery({
queryKey: ["forum-reports-count"],
queryFn: () => apiClient<{ count: number }>("/forum/reports/count"),
})
}
export function useCreateTopic() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: CreateTopicRequest) =>
apiClient<ForumTopic>("/forum/topics", { method: "POST", body: data }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["forum-topics"] })
},
})
}
export function useCreateReply(topicId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: CreateReplyRequest) =>
apiClient<ForumReply>(`/forum/topics/${topicId}/replies`, {
method: "POST",
body: data,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["forum-replies", topicId] })
queryClient.invalidateQueries({ queryKey: ["forum-topic", topicId] })
queryClient.invalidateQueries({ queryKey: ["forum-topics"] })
},
})
}
export function useEditReply() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ replyId, content }: { replyId: string; content: string }) =>
apiClient<ForumReply>(`/forum/replies/${replyId}`, {
method: "PUT",
body: { content },
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["forum-replies"] })
},
})
}
export function useLockTopic() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (topicId: string) =>
apiClient<ForumTopic>(`/forum/topics/${topicId}/lock`, {
method: "POST",
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["forum-topics"] })
queryClient.invalidateQueries({ queryKey: ["forum-topic"] })
},
})
}
export function useUnlockTopic() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (topicId: string) =>
apiClient<ForumTopic>(`/forum/topics/${topicId}/unlock`, {
method: "POST",
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["forum-topics"] })
queryClient.invalidateQueries({ queryKey: ["forum-topic"] })
},
})
}
export function usePinTopic() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (topicId: string) =>
apiClient<ForumTopic>(`/forum/topics/${topicId}/pin`, {
method: "POST",
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["forum-topics"] })
},
})
}
export function useUnpinTopic() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (topicId: string) =>
apiClient<ForumTopic>(`/forum/topics/${topicId}/unpin`, {
method: "POST",
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["forum-topics"] })
},
})
}
export function useDeleteTopic() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ topicId, reason }: { topicId: string; reason?: string }) =>
apiClient<void>(
`/forum/topics/${topicId}${reason ? `?reason=${encodeURIComponent(reason)}` : ""}`,
{ method: "DELETE" }
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["forum-topics"] })
},
})
}
export function useDeleteReply() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (replyId: string) =>
apiClient<void>(`/forum/replies/${replyId}`, { method: "DELETE" }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["forum-replies"] })
queryClient.invalidateQueries({ queryKey: ["forum-topics"] })
},
})
}
export function useToggleReaction() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: ReactionRequest) =>
apiClient<{ active: boolean; reactionType: string }>("/forum/reactions", {
method: "POST",
body: data,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["forum-topic"] })
queryClient.invalidateQueries({ queryKey: ["forum-replies"] })
},
})
}
export function useReportContent() {
return useMutation({
mutationFn: (data: ReportRequest) =>
apiClient<{ status: string }>("/forum/reports", {
method: "POST",
body: data,
}),
})
}
export function useReviewReport() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({
reportId,
status,
}: {
reportId: string
status: ReportStatus
}) =>
apiClient<ForumReport>(`/forum/reports/${reportId}/review`, {
method: "POST",
body: { status },
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["forum-reports"] })
queryClient.invalidateQueries({ queryKey: ["forum-reports-count"] })
},
})
}
// --- Portal Hooks ---
export function usePortalForumTopics(page = 0, size = 20) {
return useQuery({
queryKey: ["portal-forum-topics", page, size],
queryFn: () =>
apiClient<PageResponse<ForumTopic>>(
`/portal/forum/topics?page=${page}&size=${size}`
),
})
}
export function usePortalForumTopic(topicId: string | undefined) {
return useQuery({
queryKey: ["portal-forum-topic", topicId],
queryFn: () => apiClient<ForumTopic>(`/portal/forum/topics/${topicId}`),
enabled: !!topicId,
})
}
export function usePortalForumReplies(topicId: string | undefined, page = 0) {
return useQuery({
queryKey: ["portal-forum-replies", topicId, page],
queryFn: () =>
apiClient<PageResponse<ForumReply>>(
`/portal/forum/topics/${topicId}/replies?page=${page}`
),
enabled: !!topicId,
})
}
export function usePortalCreateTopic() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: CreateTopicRequest) =>
apiClient<ForumTopic>("/portal/forum/topics", {
method: "POST",
body: data,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["portal-forum-topics"] })
},
})
}
export function usePortalCreateReply(topicId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: CreateReplyRequest) =>
apiClient<ForumReply>(`/portal/forum/topics/${topicId}/replies`, {
method: "POST",
body: data,
}),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["portal-forum-replies", topicId],
})
queryClient.invalidateQueries({ queryKey: ["portal-forum-topics"] })
},
})
}
export function usePortalToggleReaction() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: ReactionRequest) =>
apiClient<{ active: boolean; reactionType: string }>(
"/portal/forum/reactions",
{ method: "POST", body: data }
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["portal-forum-topic"] })
queryClient.invalidateQueries({ queryKey: ["portal-forum-replies"] })
},
})
}
export function usePortalReportContent() {
return useMutation({
mutationFn: (data: ReportRequest) =>
apiClient<{ status: string }>("/portal/forum/reports", {
method: "POST",
body: data,
}),
})
}