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:
@@ -0,0 +1,223 @@
|
|||||||
|
package de.cannamanage.api.controller;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.*;
|
||||||
|
import de.cannamanage.domain.enums.*;
|
||||||
|
import de.cannamanage.service.ForumService;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forum controller — admin and portal endpoints for forum topics, replies, reactions, and reports.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1")
|
||||||
|
public class ForumController {
|
||||||
|
|
||||||
|
private final ForumService forumService;
|
||||||
|
|
||||||
|
public ForumController(ForumService forumService) {
|
||||||
|
this.forumService = forumService;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Admin Topic Endpoints ----
|
||||||
|
|
||||||
|
@PostMapping("/forum/topics")
|
||||||
|
public ResponseEntity<ForumTopic> createTopic(@RequestBody CreateTopicRequest request,
|
||||||
|
@RequestHeader("X-Club-Id") UUID clubId,
|
||||||
|
@RequestHeader("X-User-Id") UUID userId) {
|
||||||
|
ForumTopic topic = forumService.createTopic(clubId, request.title(), request.content(), userId);
|
||||||
|
return ResponseEntity.ok(topic);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/forum/topics")
|
||||||
|
public ResponseEntity<Page<ForumTopic>> getTopics(@RequestHeader("X-Club-Id") UUID clubId,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
|
return ResponseEntity.ok(forumService.getTopics(clubId, page, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/forum/topics/{id}")
|
||||||
|
public ResponseEntity<ForumTopic> getTopic(@PathVariable UUID id) {
|
||||||
|
return forumService.getTopic(id)
|
||||||
|
.map(ResponseEntity::ok)
|
||||||
|
.orElse(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/forum/topics/{id}/lock")
|
||||||
|
public ResponseEntity<ForumTopic> lockTopic(@PathVariable UUID id,
|
||||||
|
@RequestHeader("X-User-Id") UUID userId) {
|
||||||
|
return ResponseEntity.ok(forumService.lockTopic(id, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/forum/topics/{id}/unlock")
|
||||||
|
public ResponseEntity<ForumTopic> unlockTopic(@PathVariable UUID id,
|
||||||
|
@RequestHeader("X-User-Id") UUID userId) {
|
||||||
|
return ResponseEntity.ok(forumService.unlockTopic(id, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/forum/topics/{id}/pin")
|
||||||
|
public ResponseEntity<ForumTopic> pinTopic(@PathVariable UUID id,
|
||||||
|
@RequestHeader("X-User-Id") UUID userId) {
|
||||||
|
return ResponseEntity.ok(forumService.pinTopic(id, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/forum/topics/{id}/unpin")
|
||||||
|
public ResponseEntity<ForumTopic> unpinTopic(@PathVariable UUID id,
|
||||||
|
@RequestHeader("X-User-Id") UUID userId) {
|
||||||
|
return ResponseEntity.ok(forumService.unpinTopic(id, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/forum/topics/{id}")
|
||||||
|
public ResponseEntity<Void> deleteTopic(@PathVariable UUID id,
|
||||||
|
@RequestHeader("X-User-Id") UUID userId,
|
||||||
|
@RequestParam(required = false) String reason) {
|
||||||
|
forumService.deleteTopic(id, userId, reason);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Reply Endpoints ----
|
||||||
|
|
||||||
|
@GetMapping("/forum/topics/{topicId}/replies")
|
||||||
|
public ResponseEntity<Page<ForumReply>> getReplies(@PathVariable UUID topicId,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "50") int size) {
|
||||||
|
return ResponseEntity.ok(forumService.getReplies(topicId, page, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/forum/topics/{topicId}/replies")
|
||||||
|
public ResponseEntity<ForumReply> createReply(@PathVariable UUID topicId,
|
||||||
|
@RequestBody CreateReplyRequest request,
|
||||||
|
@RequestHeader("X-User-Id") UUID userId) {
|
||||||
|
ForumReply reply = forumService.createReply(topicId, request.content(), userId);
|
||||||
|
return ResponseEntity.ok(reply);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/forum/replies/{id}")
|
||||||
|
public ResponseEntity<ForumReply> editReply(@PathVariable UUID id,
|
||||||
|
@RequestBody CreateReplyRequest request,
|
||||||
|
@RequestHeader("X-User-Id") UUID userId) {
|
||||||
|
ForumReply reply = forumService.editReply(id, request.content(), userId);
|
||||||
|
return ResponseEntity.ok(reply);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/forum/replies/{id}")
|
||||||
|
public ResponseEntity<Void> deleteReply(@PathVariable UUID id,
|
||||||
|
@RequestHeader("X-User-Id") UUID userId) {
|
||||||
|
forumService.deleteReply(id, userId);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Reaction Endpoints ----
|
||||||
|
|
||||||
|
@PostMapping("/forum/reactions")
|
||||||
|
public ResponseEntity<Map<String, Object>> toggleReaction(@RequestBody ReactionRequest request,
|
||||||
|
@RequestHeader("X-User-Id") UUID userId) {
|
||||||
|
var result = forumService.toggleReaction(
|
||||||
|
request.targetType(), request.targetId(), userId, request.reactionType());
|
||||||
|
boolean active = result.isPresent();
|
||||||
|
return ResponseEntity.ok(Map.of("active", active, "reactionType", request.reactionType().name()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Report Endpoints ----
|
||||||
|
|
||||||
|
@PostMapping("/forum/reports")
|
||||||
|
public ResponseEntity<Map<String, String>> reportContent(@RequestBody ReportRequest request,
|
||||||
|
@RequestHeader("X-Club-Id") UUID clubId,
|
||||||
|
@RequestHeader("X-User-Id") UUID userId) {
|
||||||
|
forumService.reportContent(clubId, request.targetType(), request.targetId(), userId, request.reason());
|
||||||
|
return ResponseEntity.ok(Map.of("status", "reported"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/forum/reports")
|
||||||
|
public ResponseEntity<Page<ForumReport>> getReports(@RequestHeader("X-Club-Id") UUID clubId,
|
||||||
|
@RequestParam(defaultValue = "OPEN") ReportStatus status,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
|
return ResponseEntity.ok(forumService.getReports(clubId, status, page, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/forum/reports/count")
|
||||||
|
public ResponseEntity<Map<String, Long>> getOpenReportCount(@RequestHeader("X-Club-Id") UUID clubId) {
|
||||||
|
return ResponseEntity.ok(Map.of("count", forumService.getOpenReportCount(clubId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/forum/reports/{id}/review")
|
||||||
|
public ResponseEntity<ForumReport> reviewReport(@PathVariable UUID id,
|
||||||
|
@RequestBody ReviewReportRequest request,
|
||||||
|
@RequestHeader("X-User-Id") UUID userId) {
|
||||||
|
ForumReport report = forumService.reviewReport(id, userId, request.status());
|
||||||
|
return ResponseEntity.ok(report);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Portal Endpoints (member-scoped, same logic) ----
|
||||||
|
|
||||||
|
@PostMapping("/portal/forum/topics")
|
||||||
|
public ResponseEntity<ForumTopic> portalCreateTopic(@RequestBody CreateTopicRequest request,
|
||||||
|
@RequestHeader("X-Club-Id") UUID clubId,
|
||||||
|
@RequestHeader("X-User-Id") UUID userId) {
|
||||||
|
return ResponseEntity.ok(forumService.createTopic(clubId, request.title(), request.content(), userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/portal/forum/topics")
|
||||||
|
public ResponseEntity<Page<ForumTopic>> portalGetTopics(@RequestHeader("X-Club-Id") UUID clubId,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
|
return ResponseEntity.ok(forumService.getTopics(clubId, page, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/portal/forum/topics/{id}")
|
||||||
|
public ResponseEntity<ForumTopic> portalGetTopic(@PathVariable UUID id) {
|
||||||
|
return forumService.getTopic(id)
|
||||||
|
.map(ResponseEntity::ok)
|
||||||
|
.orElse(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/portal/forum/topics/{topicId}/replies")
|
||||||
|
public ResponseEntity<Page<ForumReply>> portalGetReplies(@PathVariable UUID topicId,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "50") int size) {
|
||||||
|
return ResponseEntity.ok(forumService.getReplies(topicId, page, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/portal/forum/topics/{topicId}/replies")
|
||||||
|
public ResponseEntity<ForumReply> portalCreateReply(@PathVariable UUID topicId,
|
||||||
|
@RequestBody CreateReplyRequest request,
|
||||||
|
@RequestHeader("X-User-Id") UUID userId) {
|
||||||
|
return ResponseEntity.ok(forumService.createReply(topicId, request.content(), userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/portal/forum/replies/{id}")
|
||||||
|
public ResponseEntity<ForumReply> portalEditReply(@PathVariable UUID id,
|
||||||
|
@RequestBody CreateReplyRequest request,
|
||||||
|
@RequestHeader("X-User-Id") UUID userId) {
|
||||||
|
return ResponseEntity.ok(forumService.editReply(id, request.content(), userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/portal/forum/reactions")
|
||||||
|
public ResponseEntity<Map<String, Object>> portalToggleReaction(@RequestBody ReactionRequest request,
|
||||||
|
@RequestHeader("X-User-Id") UUID userId) {
|
||||||
|
var result = forumService.toggleReaction(
|
||||||
|
request.targetType(), request.targetId(), userId, request.reactionType());
|
||||||
|
return ResponseEntity.ok(Map.of("active", result.isPresent(), "reactionType", request.reactionType().name()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/portal/forum/reports")
|
||||||
|
public ResponseEntity<Map<String, String>> portalReportContent(@RequestBody ReportRequest request,
|
||||||
|
@RequestHeader("X-Club-Id") UUID clubId,
|
||||||
|
@RequestHeader("X-User-Id") UUID userId) {
|
||||||
|
forumService.reportContent(clubId, request.targetType(), request.targetId(), userId, request.reason());
|
||||||
|
return ResponseEntity.ok(Map.of("status", "reported"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Request Records ----
|
||||||
|
|
||||||
|
public record CreateTopicRequest(String title, String content) {}
|
||||||
|
public record CreateReplyRequest(String content) {}
|
||||||
|
public record ReactionRequest(ForumTargetType targetType, UUID targetId, ReactionType reactionType) {}
|
||||||
|
public record ReportRequest(ForumTargetType targetType, UUID targetId, String reason) {}
|
||||||
|
public record ReviewReportRequest(ReportStatus status) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
-- V15: Forum MVP — topics, replies, reactions, reports
|
||||||
|
-- Phase 3 of Sprint 7
|
||||||
|
|
||||||
|
CREATE TABLE forum_topics (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
title VARCHAR(300) NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
author_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
is_locked BOOLEAN DEFAULT FALSE,
|
||||||
|
is_pinned BOOLEAN DEFAULT FALSE,
|
||||||
|
reply_count INTEGER DEFAULT 0,
|
||||||
|
last_reply_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE forum_replies (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
topic_id UUID NOT NULL REFERENCES forum_topics(id) ON DELETE CASCADE,
|
||||||
|
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
author_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
is_edited BOOLEAN DEFAULT FALSE,
|
||||||
|
edited_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE forum_reactions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
target_type VARCHAR(10) NOT NULL,
|
||||||
|
target_id UUID NOT NULL,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
reaction_type VARCHAR(20) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
UNIQUE(target_type, target_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE forum_reports (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
target_type VARCHAR(10) NOT NULL,
|
||||||
|
target_id UUID NOT NULL,
|
||||||
|
reporter_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
reason TEXT NOT NULL,
|
||||||
|
status VARCHAR(20) DEFAULT 'OPEN',
|
||||||
|
reviewed_by UUID REFERENCES users(id),
|
||||||
|
reviewed_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_forum_topics_club_id ON forum_topics(club_id);
|
||||||
|
CREATE INDEX idx_forum_topics_tenant_id ON forum_topics(tenant_id);
|
||||||
|
CREATE INDEX idx_forum_replies_topic_id ON forum_replies(topic_id);
|
||||||
|
CREATE INDEX idx_forum_replies_tenant_id ON forum_replies(tenant_id);
|
||||||
|
CREATE INDEX idx_forum_reactions_target ON forum_reactions(target_type, target_id);
|
||||||
|
CREATE INDEX idx_forum_reports_club_status ON forum_reports(club_id, status);
|
||||||
|
CREATE INDEX idx_forum_reports_tenant_id ON forum_reports(tenant_id);
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package de.cannamanage.domain.entity;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.ForumTargetType;
|
||||||
|
import de.cannamanage.domain.enums.ReactionType;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forum reaction entity — one reaction per user per target (topic or reply).
|
||||||
|
* Toggle behavior: clicking again removes the reaction.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "forum_reactions", uniqueConstraints = {
|
||||||
|
@UniqueConstraint(name = "uq_forum_reactions_target_user",
|
||||||
|
columnNames = {"target_type", "target_id", "user_id"})
|
||||||
|
}, indexes = {
|
||||||
|
@Index(name = "idx_forum_reactions_target", columnList = "target_type, target_id")
|
||||||
|
})
|
||||||
|
public class ForumReaction {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
@Column(name = "id", nullable = false, updatable = false)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "target_type", nullable = false, length = 10)
|
||||||
|
private ForumTargetType targetType;
|
||||||
|
|
||||||
|
@Column(name = "target_id", nullable = false)
|
||||||
|
private UUID targetId;
|
||||||
|
|
||||||
|
@Column(name = "user_id", nullable = false)
|
||||||
|
private UUID userId;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "reaction_type", nullable = false, length = 20)
|
||||||
|
private ReactionType reactionType;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private Instant createdAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
void onCreate() {
|
||||||
|
this.createdAt = Instant.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ForumReaction() {}
|
||||||
|
|
||||||
|
public ForumReaction(ForumTargetType targetType, UUID targetId, UUID userId, ReactionType reactionType) {
|
||||||
|
this.targetType = targetType;
|
||||||
|
this.targetId = targetId;
|
||||||
|
this.userId = userId;
|
||||||
|
this.reactionType = reactionType;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and setters
|
||||||
|
|
||||||
|
public UUID getId() { return id; }
|
||||||
|
public void setId(UUID id) { this.id = id; }
|
||||||
|
|
||||||
|
public ForumTargetType getTargetType() { return targetType; }
|
||||||
|
public void setTargetType(ForumTargetType targetType) { this.targetType = targetType; }
|
||||||
|
|
||||||
|
public UUID getTargetId() { return targetId; }
|
||||||
|
public void setTargetId(UUID targetId) { this.targetId = targetId; }
|
||||||
|
|
||||||
|
public UUID getUserId() { return userId; }
|
||||||
|
public void setUserId(UUID userId) { this.userId = userId; }
|
||||||
|
|
||||||
|
public ReactionType getReactionType() { return reactionType; }
|
||||||
|
public void setReactionType(ReactionType reactionType) { this.reactionType = reactionType; }
|
||||||
|
|
||||||
|
public Instant getCreatedAt() { return createdAt; }
|
||||||
|
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package de.cannamanage.domain.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forum reply entity — a response to a forum topic.
|
||||||
|
* Content stored as HTML. Replies can be edited within a 60-minute window.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "forum_replies", indexes = {
|
||||||
|
@Index(name = "idx_forum_replies_topic_id", columnList = "topic_id"),
|
||||||
|
@Index(name = "idx_forum_replies_tenant_id", columnList = "tenant_id")
|
||||||
|
})
|
||||||
|
public class ForumReply extends AbstractTenantEntity {
|
||||||
|
|
||||||
|
@Column(name = "topic_id", nullable = false)
|
||||||
|
private UUID topicId;
|
||||||
|
|
||||||
|
@Column(name = "club_id", nullable = false)
|
||||||
|
private UUID clubId;
|
||||||
|
|
||||||
|
@Column(name = "content", nullable = false, columnDefinition = "TEXT")
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
@Column(name = "author_id", nullable = false)
|
||||||
|
private UUID authorId;
|
||||||
|
|
||||||
|
@Column(name = "is_edited", nullable = false)
|
||||||
|
private boolean edited = false;
|
||||||
|
|
||||||
|
@Column(name = "edited_at")
|
||||||
|
private Instant editedAt;
|
||||||
|
|
||||||
|
public ForumReply() {}
|
||||||
|
|
||||||
|
public ForumReply(UUID topicId, UUID clubId, String content, UUID authorId) {
|
||||||
|
this.topicId = topicId;
|
||||||
|
this.clubId = clubId;
|
||||||
|
this.content = content;
|
||||||
|
this.authorId = authorId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and setters
|
||||||
|
|
||||||
|
public UUID getTopicId() { return topicId; }
|
||||||
|
public void setTopicId(UUID topicId) { this.topicId = topicId; }
|
||||||
|
|
||||||
|
public UUID getClubId() { return clubId; }
|
||||||
|
public void setClubId(UUID clubId) { this.clubId = clubId; }
|
||||||
|
|
||||||
|
public String getContent() { return content; }
|
||||||
|
public void setContent(String content) { this.content = content; }
|
||||||
|
|
||||||
|
public UUID getAuthorId() { return authorId; }
|
||||||
|
public void setAuthorId(UUID authorId) { this.authorId = authorId; }
|
||||||
|
|
||||||
|
public boolean isEdited() { return edited; }
|
||||||
|
public void setEdited(boolean edited) { this.edited = edited; }
|
||||||
|
|
||||||
|
public Instant getEditedAt() { return editedAt; }
|
||||||
|
public void setEditedAt(Instant editedAt) { this.editedAt = editedAt; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package de.cannamanage.domain.entity;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.ForumTargetType;
|
||||||
|
import de.cannamanage.domain.enums.ReportStatus;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forum report entity — allows members to report inappropriate content.
|
||||||
|
* Reporter identity is protected: reporterId is NOT exposed in public DTOs (only visible to moderators).
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "forum_reports", indexes = {
|
||||||
|
@Index(name = "idx_forum_reports_club_status", columnList = "club_id, status"),
|
||||||
|
@Index(name = "idx_forum_reports_tenant_id", columnList = "tenant_id")
|
||||||
|
})
|
||||||
|
public class ForumReport extends AbstractTenantEntity {
|
||||||
|
|
||||||
|
@Column(name = "club_id", nullable = false)
|
||||||
|
private UUID clubId;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "target_type", nullable = false, length = 10)
|
||||||
|
private ForumTargetType targetType;
|
||||||
|
|
||||||
|
@Column(name = "target_id", nullable = false)
|
||||||
|
private UUID targetId;
|
||||||
|
|
||||||
|
@Column(name = "reporter_id", nullable = false)
|
||||||
|
private UUID reporterId;
|
||||||
|
|
||||||
|
@Column(name = "reason", nullable = false, columnDefinition = "TEXT")
|
||||||
|
private String reason;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "status", nullable = false, length = 20)
|
||||||
|
private ReportStatus status = ReportStatus.OPEN;
|
||||||
|
|
||||||
|
@Column(name = "reviewed_by")
|
||||||
|
private UUID reviewedBy;
|
||||||
|
|
||||||
|
@Column(name = "reviewed_at")
|
||||||
|
private Instant reviewedAt;
|
||||||
|
|
||||||
|
public ForumReport() {}
|
||||||
|
|
||||||
|
public ForumReport(UUID clubId, ForumTargetType targetType, UUID targetId, UUID reporterId, String reason) {
|
||||||
|
this.clubId = clubId;
|
||||||
|
this.targetType = targetType;
|
||||||
|
this.targetId = targetId;
|
||||||
|
this.reporterId = reporterId;
|
||||||
|
this.reason = reason;
|
||||||
|
this.status = ReportStatus.OPEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and setters
|
||||||
|
|
||||||
|
public UUID getClubId() { return clubId; }
|
||||||
|
public void setClubId(UUID clubId) { this.clubId = clubId; }
|
||||||
|
|
||||||
|
public ForumTargetType getTargetType() { return targetType; }
|
||||||
|
public void setTargetType(ForumTargetType targetType) { this.targetType = targetType; }
|
||||||
|
|
||||||
|
public UUID getTargetId() { return targetId; }
|
||||||
|
public void setTargetId(UUID targetId) { this.targetId = targetId; }
|
||||||
|
|
||||||
|
public UUID getReporterId() { return reporterId; }
|
||||||
|
public void setReporterId(UUID reporterId) { this.reporterId = reporterId; }
|
||||||
|
|
||||||
|
public String getReason() { return reason; }
|
||||||
|
public void setReason(String reason) { this.reason = reason; }
|
||||||
|
|
||||||
|
public ReportStatus getStatus() { return status; }
|
||||||
|
public void setStatus(ReportStatus status) { this.status = status; }
|
||||||
|
|
||||||
|
public UUID getReviewedBy() { return reviewedBy; }
|
||||||
|
public void setReviewedBy(UUID reviewedBy) { this.reviewedBy = reviewedBy; }
|
||||||
|
|
||||||
|
public Instant getReviewedAt() { return reviewedAt; }
|
||||||
|
public void setReviewedAt(Instant reviewedAt) { this.reviewedAt = reviewedAt; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package de.cannamanage.domain.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forum topic entity — club-scoped discussion thread.
|
||||||
|
* Content is stored as HTML (from Tiptap rich text editor).
|
||||||
|
* Extends AbstractTenantEntity for automatic tenant isolation.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "forum_topics", indexes = {
|
||||||
|
@Index(name = "idx_forum_topics_club_id", columnList = "club_id"),
|
||||||
|
@Index(name = "idx_forum_topics_tenant_id", columnList = "tenant_id")
|
||||||
|
})
|
||||||
|
public class ForumTopic extends AbstractTenantEntity {
|
||||||
|
|
||||||
|
@Column(name = "club_id", nullable = false)
|
||||||
|
private UUID clubId;
|
||||||
|
|
||||||
|
@Column(name = "title", nullable = false, length = 300)
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
@Column(name = "content", nullable = false, columnDefinition = "TEXT")
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
@Column(name = "author_id", nullable = false)
|
||||||
|
private UUID authorId;
|
||||||
|
|
||||||
|
@Column(name = "is_locked", nullable = false)
|
||||||
|
private boolean locked = false;
|
||||||
|
|
||||||
|
@Column(name = "is_pinned", nullable = false)
|
||||||
|
private boolean pinned = false;
|
||||||
|
|
||||||
|
@Column(name = "reply_count", nullable = false)
|
||||||
|
private int replyCount = 0;
|
||||||
|
|
||||||
|
@Column(name = "last_reply_at")
|
||||||
|
private Instant lastReplyAt;
|
||||||
|
|
||||||
|
@Column(name = "updated_at")
|
||||||
|
private Instant updatedAt;
|
||||||
|
|
||||||
|
public ForumTopic() {}
|
||||||
|
|
||||||
|
public ForumTopic(UUID clubId, String title, String content, UUID authorId) {
|
||||||
|
this.clubId = clubId;
|
||||||
|
this.title = title;
|
||||||
|
this.content = content;
|
||||||
|
this.authorId = authorId;
|
||||||
|
this.updatedAt = Instant.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
@Override
|
||||||
|
void onCreate() {
|
||||||
|
super.onCreate();
|
||||||
|
if (this.updatedAt == null) {
|
||||||
|
this.updatedAt = Instant.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreUpdate
|
||||||
|
void onUpdate() {
|
||||||
|
this.updatedAt = Instant.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and setters
|
||||||
|
|
||||||
|
public UUID getClubId() { return clubId; }
|
||||||
|
public void setClubId(UUID clubId) { this.clubId = clubId; }
|
||||||
|
|
||||||
|
public String getTitle() { return title; }
|
||||||
|
public void setTitle(String title) { this.title = title; }
|
||||||
|
|
||||||
|
public String getContent() { return content; }
|
||||||
|
public void setContent(String content) { this.content = content; }
|
||||||
|
|
||||||
|
public UUID getAuthorId() { return authorId; }
|
||||||
|
public void setAuthorId(UUID authorId) { this.authorId = authorId; }
|
||||||
|
|
||||||
|
public boolean isLocked() { return locked; }
|
||||||
|
public void setLocked(boolean locked) { this.locked = locked; }
|
||||||
|
|
||||||
|
public boolean isPinned() { return pinned; }
|
||||||
|
public void setPinned(boolean pinned) { this.pinned = pinned; }
|
||||||
|
|
||||||
|
public int getReplyCount() { return replyCount; }
|
||||||
|
public void setReplyCount(int replyCount) { this.replyCount = replyCount; }
|
||||||
|
|
||||||
|
public Instant getLastReplyAt() { return lastReplyAt; }
|
||||||
|
public void setLastReplyAt(Instant lastReplyAt) { this.lastReplyAt = lastReplyAt; }
|
||||||
|
|
||||||
|
public Instant getUpdatedAt() { return updatedAt; }
|
||||||
|
public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
|
||||||
|
}
|
||||||
@@ -60,6 +60,7 @@ public enum AuditEventType {
|
|||||||
FORUM_TOPIC_CREATED,
|
FORUM_TOPIC_CREATED,
|
||||||
FORUM_TOPIC_LOCKED,
|
FORUM_TOPIC_LOCKED,
|
||||||
FORUM_TOPIC_DELETED,
|
FORUM_TOPIC_DELETED,
|
||||||
|
FORUM_REPLY_CREATED,
|
||||||
FORUM_REPLY_DELETED,
|
FORUM_REPLY_DELETED,
|
||||||
FORUM_REPORT_RESOLVED
|
FORUM_REPORT_REVIEWED
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package de.cannamanage.domain.enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Target type for forum reactions and reports.
|
||||||
|
*/
|
||||||
|
public enum ForumTargetType {
|
||||||
|
TOPIC,
|
||||||
|
REPLY
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package de.cannamanage.domain.enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reaction types for forum content.
|
||||||
|
*/
|
||||||
|
public enum ReactionType {
|
||||||
|
THUMBS_UP,
|
||||||
|
THUMBS_DOWN
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package de.cannamanage.domain.enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status of a forum content report.
|
||||||
|
*/
|
||||||
|
public enum ReportStatus {
|
||||||
|
OPEN,
|
||||||
|
REVIEWED,
|
||||||
|
DISMISSED
|
||||||
|
}
|
||||||
@@ -808,5 +808,33 @@
|
|||||||
"WORKSHOP": "Workshop",
|
"WORKSHOP": "Workshop",
|
||||||
"OTHER": "Sonstiges"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -610,21 +610,76 @@
|
|||||||
"onboarding": "Personal onboarding"
|
"onboarding": "Personal onboarding"
|
||||||
},
|
},
|
||||||
"comparison": {
|
"comparison": {
|
||||||
"compMembers": { "label": "Members", "starter": "Up to 30", "pro": "Up to 100", "enterprise": "Unlimited" },
|
"compMembers": {
|
||||||
"compDistributions": { "label": "Distribution tracking", "starter": "✓", "pro": "✓", "enterprise": "✓" },
|
"label": "Members",
|
||||||
"compReports": { "label": "Reports (PDF/CSV)", "starter": "Standard", "pro": "Advanced", "enterprise": "Custom" },
|
"starter": "Up to 30",
|
||||||
"compGrow": { "label": "Grow calendar", "starter": "—", "pro": "✓", "enterprise": "✓" },
|
"pro": "Up to 100",
|
||||||
"compStaff": { "label": "Staff management", "starter": "—", "pro": "✓", "enterprise": "✓" },
|
"enterprise": "Unlimited"
|
||||||
"compApi": { "label": "API access", "starter": "—", "pro": "✓", "enterprise": "✓" },
|
},
|
||||||
"compMultiClub": { "label": "Multi-club", "starter": "—", "pro": "—", "enterprise": "✓" },
|
"compDistributions": {
|
||||||
"compSupport": { "label": "Support", "starter": "Email", "pro": "Priority", "enterprise": "Dedicated" }
|
"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": {
|
"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." },
|
"trial": {
|
||||||
"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." },
|
"question": "How does the free trial work?",
|
||||||
"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." },
|
"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."
|
||||||
"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." }
|
"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": {
|
"impressum": {
|
||||||
@@ -753,5 +808,33 @@
|
|||||||
"WORKSHOP": "Workshop",
|
"WORKSHOP": "Workshop",
|
||||||
"OTHER": "Other"
|
"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",
|
href: "/calendar",
|
||||||
iconName: "Calendar",
|
iconName: "Calendar",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Forum",
|
||||||
|
href: "/forum",
|
||||||
|
iconName: "MessageSquare",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Personal",
|
title: "Personal",
|
||||||
href: "/settings/staff",
|
href: "/settings/staff",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
package de.cannamanage.service;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.*;
|
||||||
|
import de.cannamanage.domain.enums.*;
|
||||||
|
import de.cannamanage.service.repository.*;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forum service — CRUD, moderation, reactions, reports, and notifications for the club forum.
|
||||||
|
* Key constraints:
|
||||||
|
* - 60-minute edit window for replies
|
||||||
|
* - Reporter identity protected (not exposed in public DTOs)
|
||||||
|
* - Locked topics: no new replies, existing content visible
|
||||||
|
* - Reactions: one per user per target (toggle behavior)
|
||||||
|
* - All content is club-scoped (tenant isolation via club_id)
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Transactional
|
||||||
|
public class ForumService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ForumService.class);
|
||||||
|
private static final Duration EDIT_WINDOW = Duration.ofMinutes(60);
|
||||||
|
|
||||||
|
private final ForumTopicRepository topicRepository;
|
||||||
|
private final ForumReplyRepository replyRepository;
|
||||||
|
private final ForumReactionRepository reactionRepository;
|
||||||
|
private final ForumReportRepository reportRepository;
|
||||||
|
private final MemberRepository memberRepository;
|
||||||
|
private final NotificationService notificationService;
|
||||||
|
private final AuditService auditService;
|
||||||
|
|
||||||
|
public ForumService(ForumTopicRepository topicRepository,
|
||||||
|
ForumReplyRepository replyRepository,
|
||||||
|
ForumReactionRepository reactionRepository,
|
||||||
|
ForumReportRepository reportRepository,
|
||||||
|
MemberRepository memberRepository,
|
||||||
|
NotificationService notificationService,
|
||||||
|
AuditService auditService) {
|
||||||
|
this.topicRepository = topicRepository;
|
||||||
|
this.replyRepository = replyRepository;
|
||||||
|
this.reactionRepository = reactionRepository;
|
||||||
|
this.reportRepository = reportRepository;
|
||||||
|
this.memberRepository = memberRepository;
|
||||||
|
this.notificationService = notificationService;
|
||||||
|
this.auditService = auditService;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Topics ----
|
||||||
|
|
||||||
|
public ForumTopic createTopic(UUID clubId, String title, String content, UUID authorId) {
|
||||||
|
var topic = new ForumTopic(clubId, title, content, authorId);
|
||||||
|
ForumTopic saved = topicRepository.save(topic);
|
||||||
|
log.info("Forum topic created: '{}' by {} in club {}", title, authorId, clubId);
|
||||||
|
|
||||||
|
auditService.logEvent(AuditEventType.FORUM_TOPIC_CREATED, authorId,
|
||||||
|
"Forum topic created: " + title, saved.getId().toString());
|
||||||
|
|
||||||
|
// Notify club members about new topic
|
||||||
|
try {
|
||||||
|
var members = memberRepository.findByClubId(clubId);
|
||||||
|
members.forEach(member -> {
|
||||||
|
if (member.getUserId() != null && !member.getUserId().equals(authorId)) {
|
||||||
|
notificationService.sendNotification(
|
||||||
|
member.getUserId(),
|
||||||
|
NotificationType.FORUM_REPLY,
|
||||||
|
"Neues Thema: " + title,
|
||||||
|
"Ein neues Thema wurde im Forum erstellt.",
|
||||||
|
"/portal/forum/" + saved.getId()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to send forum topic notifications: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Optional<ForumTopic> getTopic(UUID topicId) {
|
||||||
|
return topicRepository.findById(topicId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Page<ForumTopic> getTopics(UUID clubId, int page, int size) {
|
||||||
|
return topicRepository.findByClubIdOrderByPinnedDescLastReplyAtDescCreatedAtDesc(
|
||||||
|
clubId, PageRequest.of(page, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
public ForumTopic lockTopic(UUID topicId, UUID moderatorId) {
|
||||||
|
ForumTopic topic = topicRepository.findById(topicId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Topic not found: " + topicId));
|
||||||
|
topic.setLocked(true);
|
||||||
|
auditService.logEvent(AuditEventType.FORUM_TOPIC_LOCKED, moderatorId,
|
||||||
|
"Topic locked: " + topic.getTitle(), topicId.toString());
|
||||||
|
log.info("Forum topic locked: {} by moderator {}", topicId, moderatorId);
|
||||||
|
return topicRepository.save(topic);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ForumTopic unlockTopic(UUID topicId, UUID moderatorId) {
|
||||||
|
ForumTopic topic = topicRepository.findById(topicId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Topic not found: " + topicId));
|
||||||
|
topic.setLocked(false);
|
||||||
|
log.info("Forum topic unlocked: {} by moderator {}", topicId, moderatorId);
|
||||||
|
return topicRepository.save(topic);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ForumTopic pinTopic(UUID topicId, UUID moderatorId) {
|
||||||
|
ForumTopic topic = topicRepository.findById(topicId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Topic not found: " + topicId));
|
||||||
|
topic.setPinned(true);
|
||||||
|
log.info("Forum topic pinned: {} by moderator {}", topicId, moderatorId);
|
||||||
|
return topicRepository.save(topic);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ForumTopic unpinTopic(UUID topicId, UUID moderatorId) {
|
||||||
|
ForumTopic topic = topicRepository.findById(topicId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Topic not found: " + topicId));
|
||||||
|
topic.setPinned(false);
|
||||||
|
log.info("Forum topic unpinned: {} by moderator {}", topicId, moderatorId);
|
||||||
|
return topicRepository.save(topic);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteTopic(UUID topicId, UUID moderatorId, String reason) {
|
||||||
|
ForumTopic topic = topicRepository.findById(topicId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Topic not found: " + topicId));
|
||||||
|
|
||||||
|
// Notify the author about deletion
|
||||||
|
if (reason != null && !reason.isBlank()) {
|
||||||
|
notificationService.sendNotification(
|
||||||
|
topic.getAuthorId(),
|
||||||
|
NotificationType.FORUM_REPLY,
|
||||||
|
"Dein Thema wurde entfernt",
|
||||||
|
"Grund: " + reason,
|
||||||
|
"/portal/forum"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
auditService.logEvent(AuditEventType.FORUM_TOPIC_DELETED, moderatorId,
|
||||||
|
"Topic deleted: " + topic.getTitle() + " (reason: " + reason + ")", topicId.toString());
|
||||||
|
|
||||||
|
replyRepository.deleteByTopicId(topicId);
|
||||||
|
topicRepository.delete(topic);
|
||||||
|
log.info("Forum topic deleted: {} by moderator {} (reason: {})", topicId, moderatorId, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Replies ----
|
||||||
|
|
||||||
|
public ForumReply createReply(UUID topicId, String content, UUID authorId) {
|
||||||
|
ForumTopic topic = topicRepository.findById(topicId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Topic not found: " + topicId));
|
||||||
|
|
||||||
|
if (topic.isLocked()) {
|
||||||
|
throw new IllegalStateException("Cannot reply to a locked topic");
|
||||||
|
}
|
||||||
|
|
||||||
|
var reply = new ForumReply(topicId, topic.getClubId(), content, authorId);
|
||||||
|
ForumReply saved = replyRepository.save(reply);
|
||||||
|
|
||||||
|
// Update topic reply count and last reply timestamp
|
||||||
|
topic.setReplyCount(topic.getReplyCount() + 1);
|
||||||
|
topic.setLastReplyAt(Instant.now());
|
||||||
|
topicRepository.save(topic);
|
||||||
|
|
||||||
|
log.info("Forum reply created on topic {} by {}", topicId, authorId);
|
||||||
|
|
||||||
|
auditService.logEvent(AuditEventType.FORUM_REPLY_CREATED, authorId,
|
||||||
|
"Reply to topic: " + topic.getTitle(), saved.getId().toString());
|
||||||
|
|
||||||
|
// Notify topic author about new reply
|
||||||
|
if (!topic.getAuthorId().equals(authorId)) {
|
||||||
|
notificationService.sendNotification(
|
||||||
|
topic.getAuthorId(),
|
||||||
|
NotificationType.FORUM_REPLY,
|
||||||
|
"Neue Antwort auf: " + topic.getTitle(),
|
||||||
|
"Jemand hat auf dein Thema geantwortet.",
|
||||||
|
"/portal/forum/" + topicId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ForumReply editReply(UUID replyId, String content, UUID authorId) {
|
||||||
|
ForumReply reply = replyRepository.findById(replyId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Reply not found: " + replyId));
|
||||||
|
|
||||||
|
if (!reply.getAuthorId().equals(authorId)) {
|
||||||
|
throw new IllegalStateException("Only the author can edit their reply");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 60-minute edit window
|
||||||
|
if (Duration.between(reply.getCreatedAt(), Instant.now()).compareTo(EDIT_WINDOW) > 0) {
|
||||||
|
throw new IllegalStateException("Edit window (60 minutes) has expired");
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.setContent(content);
|
||||||
|
reply.setEdited(true);
|
||||||
|
reply.setEditedAt(Instant.now());
|
||||||
|
log.info("Forum reply edited: {} by {}", replyId, authorId);
|
||||||
|
return replyRepository.save(reply);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteReply(UUID replyId, UUID moderatorId) {
|
||||||
|
ForumReply reply = replyRepository.findById(replyId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Reply not found: " + replyId));
|
||||||
|
|
||||||
|
// Update topic reply count
|
||||||
|
topicRepository.findById(reply.getTopicId()).ifPresent(topic -> {
|
||||||
|
topic.setReplyCount(Math.max(0, topic.getReplyCount() - 1));
|
||||||
|
topicRepository.save(topic);
|
||||||
|
});
|
||||||
|
|
||||||
|
auditService.logEvent(AuditEventType.FORUM_REPLY_DELETED, moderatorId,
|
||||||
|
"Reply deleted from topic", replyId.toString());
|
||||||
|
|
||||||
|
replyRepository.delete(reply);
|
||||||
|
log.info("Forum reply deleted: {} by moderator {}", replyId, moderatorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Page<ForumReply> getReplies(UUID topicId, int page, int size) {
|
||||||
|
return replyRepository.findByTopicIdOrderByCreatedAtAsc(topicId, PageRequest.of(page, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Reactions ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle a reaction — if the same reaction exists, remove it; otherwise add/replace it.
|
||||||
|
* Returns the reaction if added, empty if removed.
|
||||||
|
*/
|
||||||
|
public Optional<ForumReaction> toggleReaction(ForumTargetType targetType, UUID targetId,
|
||||||
|
UUID userId, ReactionType reactionType) {
|
||||||
|
Optional<ForumReaction> existing = reactionRepository
|
||||||
|
.findByTargetTypeAndTargetIdAndUserId(targetType, targetId, userId);
|
||||||
|
|
||||||
|
if (existing.isPresent()) {
|
||||||
|
ForumReaction current = existing.get();
|
||||||
|
if (current.getReactionType() == reactionType) {
|
||||||
|
// Same reaction — remove (toggle off)
|
||||||
|
reactionRepository.delete(current);
|
||||||
|
return Optional.empty();
|
||||||
|
} else {
|
||||||
|
// Different reaction — update
|
||||||
|
current.setReactionType(reactionType);
|
||||||
|
return Optional.of(reactionRepository.save(current));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No existing reaction — create new
|
||||||
|
var reaction = new ForumReaction(targetType, targetId, userId, reactionType);
|
||||||
|
return Optional.of(reactionRepository.save(reaction));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public long getReactionCount(ForumTargetType targetType, UUID targetId, ReactionType reactionType) {
|
||||||
|
return reactionRepository.countByTargetTypeAndTargetIdAndReactionType(targetType, targetId, reactionType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Reports ----
|
||||||
|
|
||||||
|
public ForumReport reportContent(UUID clubId, ForumTargetType targetType, UUID targetId,
|
||||||
|
UUID reporterId, String reason) {
|
||||||
|
var report = new ForumReport(clubId, targetType, targetId, reporterId, reason);
|
||||||
|
ForumReport saved = reportRepository.save(report);
|
||||||
|
log.info("Content reported: {} {} by {} (reason: {})", targetType, targetId, reporterId, reason);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Page<ForumReport> getReports(UUID clubId, ReportStatus status, int page, int size) {
|
||||||
|
return reportRepository.findByClubIdAndStatusOrderByCreatedAtDesc(
|
||||||
|
clubId, status, PageRequest.of(page, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public long getOpenReportCount(UUID clubId) {
|
||||||
|
return reportRepository.countByClubIdAndStatus(clubId, ReportStatus.OPEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ForumReport reviewReport(UUID reportId, UUID reviewerId, ReportStatus status) {
|
||||||
|
ForumReport report = reportRepository.findById(reportId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Report not found: " + reportId));
|
||||||
|
|
||||||
|
report.setStatus(status);
|
||||||
|
report.setReviewedBy(reviewerId);
|
||||||
|
report.setReviewedAt(Instant.now());
|
||||||
|
|
||||||
|
auditService.logEvent(AuditEventType.FORUM_REPORT_REVIEWED, reviewerId,
|
||||||
|
"Report reviewed: " + status + " for " + report.getTargetType() + " " + report.getTargetId(),
|
||||||
|
reportId.toString());
|
||||||
|
|
||||||
|
log.info("Report {} reviewed by {}: {}", reportId, reviewerId, status);
|
||||||
|
return reportRepository.save(report);
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
package de.cannamanage.service.repository;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.ForumReaction;
|
||||||
|
import de.cannamanage.domain.enums.ForumTargetType;
|
||||||
|
import de.cannamanage.domain.enums.ReactionType;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface ForumReactionRepository extends JpaRepository<ForumReaction, UUID> {
|
||||||
|
|
||||||
|
Optional<ForumReaction> findByTargetTypeAndTargetIdAndUserId(
|
||||||
|
ForumTargetType targetType, UUID targetId, UUID userId);
|
||||||
|
|
||||||
|
long countByTargetTypeAndTargetIdAndReactionType(
|
||||||
|
ForumTargetType targetType, UUID targetId, ReactionType reactionType);
|
||||||
|
|
||||||
|
List<ForumReaction> findByTargetTypeAndTargetId(ForumTargetType targetType, UUID targetId);
|
||||||
|
|
||||||
|
void deleteByTargetTypeAndTargetId(ForumTargetType targetType, UUID targetId);
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
package de.cannamanage.service.repository;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.ForumReply;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface ForumReplyRepository extends JpaRepository<ForumReply, UUID> {
|
||||||
|
|
||||||
|
Page<ForumReply> findByTopicIdOrderByCreatedAtAsc(UUID topicId, Pageable pageable);
|
||||||
|
|
||||||
|
void deleteByTopicId(UUID topicId);
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
package de.cannamanage.service.repository;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.ForumReport;
|
||||||
|
import de.cannamanage.domain.enums.ReportStatus;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface ForumReportRepository extends JpaRepository<ForumReport, UUID> {
|
||||||
|
|
||||||
|
Page<ForumReport> findByClubIdAndStatusOrderByCreatedAtDesc(
|
||||||
|
UUID clubId, ReportStatus status, Pageable pageable);
|
||||||
|
|
||||||
|
long countByClubIdAndStatus(UUID clubId, ReportStatus status);
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
package de.cannamanage.service.repository;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.ForumTopic;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface ForumTopicRepository extends JpaRepository<ForumTopic, UUID> {
|
||||||
|
|
||||||
|
Page<ForumTopic> findByClubIdOrderByPinnedDescLastReplyAtDescCreatedAtDesc(
|
||||||
|
UUID clubId, Pageable pageable);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user