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
+28
View File
@@ -808,5 +808,33 @@
"WORKSHOP": "Workshop",
"OTHER": "Sonstiges"
}
},
"forum": {
"title": "Forum",
"description": "Vereinsinternes Diskussionsforum",
"newTopic": "Neues Thema",
"topicTitlePlaceholder": "Titel des Themas...",
"topicContentPlaceholder": "Beschreibe dein Thema...",
"creating": "Wird erstellt...",
"create": "Erstellen",
"cancel": "Abbrechen",
"loading": "Wird geladen...",
"noTopics": "Noch keine Themen vorhanden. Erstelle das erste!",
"replies": "Antworten",
"lastReply": "Letzte Antwort",
"openReports": "offene Meldungen",
"pin": "Anheften",
"unpin": "Lösen",
"lock": "Sperren",
"unlock": "Entsperren",
"delete": "Löschen",
"deleteReason": "Grund für die Löschung (optional):",
"replyPlaceholder": "Schreibe eine Antwort...",
"sending": "Wird gesendet...",
"reply": "Antworten",
"edited": "bearbeitet",
"topicLocked": "Dieses Thema ist gesperrt. Neue Antworten sind nicht möglich.",
"reportReason": "Grund der Meldung:",
"backToTopics": "Zurück zur Übersicht"
}
}
+96 -13
View File
@@ -610,21 +610,76 @@
"onboarding": "Personal onboarding"
},
"comparison": {
"compMembers": { "label": "Members", "starter": "Up to 30", "pro": "Up to 100", "enterprise": "Unlimited" },
"compDistributions": { "label": "Distribution tracking", "starter": "✓", "pro": "✓", "enterprise": "✓" },
"compReports": { "label": "Reports (PDF/CSV)", "starter": "Standard", "pro": "Advanced", "enterprise": "Custom" },
"compGrow": { "label": "Grow calendar", "starter": "—", "pro": "✓", "enterprise": "✓" },
"compStaff": { "label": "Staff management", "starter": "—", "pro": "✓", "enterprise": "✓" },
"compApi": { "label": "API access", "starter": "—", "pro": "✓", "enterprise": "✓" },
"compMultiClub": { "label": "Multi-club", "starter": "—", "pro": "—", "enterprise": "✓" },
"compSupport": { "label": "Support", "starter": "Email", "pro": "Priority", "enterprise": "Dedicated" }
"compMembers": {
"label": "Members",
"starter": "Up to 30",
"pro": "Up to 100",
"enterprise": "Unlimited"
},
"compDistributions": {
"label": "Distribution tracking",
"starter": "✓",
"pro": "✓",
"enterprise": "✓"
},
"compReports": {
"label": "Reports (PDF/CSV)",
"starter": "Standard",
"pro": "Advanced",
"enterprise": "Custom"
},
"compGrow": {
"label": "Grow calendar",
"starter": "—",
"pro": "✓",
"enterprise": "✓"
},
"compStaff": {
"label": "Staff management",
"starter": "—",
"pro": "✓",
"enterprise": "✓"
},
"compApi": {
"label": "API access",
"starter": "—",
"pro": "✓",
"enterprise": "✓"
},
"compMultiClub": {
"label": "Multi-club",
"starter": "—",
"pro": "—",
"enterprise": "✓"
},
"compSupport": {
"label": "Support",
"starter": "Email",
"pro": "Priority",
"enterprise": "Dedicated"
}
},
"faq": {
"trial": { "question": "How does the free trial work?", "answer": "You can test CannaManage free for 3 months with no commitment. All features of your chosen plan are available immediately. After the trial, you decide whether to continue." },
"payment": { "question": "Which payment methods are accepted?", "answer": "We accept SEPA direct debit, credit card (Visa, Mastercard) and PayPal. Billing is monthly through our payment partner Stripe." },
"cancel": { "question": "Can I cancel anytime?", "answer": "Yes, you can cancel your subscription at any time at the end of the current billing period. There is no minimum contract period." },
"data": { "question": "What happens to my data after cancellation?", "answer": "After cancellation, you have 30 days to export your data. After that, all personal data is deleted in accordance with GDPR. Data subject to retention requirements remains stored in compliance with the law." },
"migration": { "question": "Can I switch plans later?", "answer": "Yes, you can switch between Starter and Pro at any time. Upgrades take effect immediately, downgrades at the next billing period." }
"trial": {
"question": "How does the free trial work?",
"answer": "You can test CannaManage free for 3 months with no commitment. All features of your chosen plan are available immediately. After the trial, you decide whether to continue."
},
"payment": {
"question": "Which payment methods are accepted?",
"answer": "We accept SEPA direct debit, credit card (Visa, Mastercard) and PayPal. Billing is monthly through our payment partner Stripe."
},
"cancel": {
"question": "Can I cancel anytime?",
"answer": "Yes, you can cancel your subscription at any time at the end of the current billing period. There is no minimum contract period."
},
"data": {
"question": "What happens to my data after cancellation?",
"answer": "After cancellation, you have 30 days to export your data. After that, all personal data is deleted in accordance with GDPR. Data subject to retention requirements remains stored in compliance with the law."
},
"migration": {
"question": "Can I switch plans later?",
"answer": "Yes, you can switch between Starter and Pro at any time. Upgrades take effect immediately, downgrades at the next billing period."
}
}
},
"impressum": {
@@ -753,5 +808,33 @@
"WORKSHOP": "Workshop",
"OTHER": "Other"
}
},
"forum": {
"title": "Forum",
"description": "Club-internal discussion forum",
"newTopic": "New Topic",
"topicTitlePlaceholder": "Topic title...",
"topicContentPlaceholder": "Describe your topic...",
"creating": "Creating...",
"create": "Create",
"cancel": "Cancel",
"loading": "Loading...",
"noTopics": "No topics yet. Create the first one!",
"replies": "Replies",
"lastReply": "Last reply",
"openReports": "open reports",
"pin": "Pin",
"unpin": "Unpin",
"lock": "Lock",
"unlock": "Unlock",
"delete": "Delete",
"deleteReason": "Reason for deletion (optional):",
"replyPlaceholder": "Write a reply...",
"sending": "Sending...",
"reply": "Reply",
"edited": "edited",
"topicLocked": "This topic is locked. New replies are not possible.",
"reportReason": "Reason for report:",
"backToTopics": "Back to overview"
}
}
@@ -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,
}),
})
}