feat(sprint7): Phase 3 — Forum MVP

- Flyway V15: forum_topics, forum_replies, forum_reactions, forum_reports tables
- Enums: ForumTargetType, ReactionType, ReportStatus
- Extended AuditEventType with FORUM_REPLY_CREATED, FORUM_REPORT_REVIEWED
- Entities: ForumTopic, ForumReply, ForumReaction, ForumReport
- Repositories: ForumTopicRepository, ForumReplyRepository, ForumReactionRepository, ForumReportRepository
- ForumService: full CRUD, moderation (lock/pin/delete), 60-min edit window,
  toggle reactions, content reporting, notifications on new topics/replies
- ForumController: admin + portal endpoints (topics, replies, reactions, reports, moderation)
- Frontend: forum.ts service with React Query hooks (admin + portal)
- Frontend: Admin forum page with topic list, moderation actions (lock/pin/delete)
- Frontend: Portal forum page with topic list, reply thread, reactions, report
- Navigation: added Forum with MessageSquare icon
- i18n: forum.* keys in de.json and en.json
This commit is contained in:
Patrick Plate
2026-06-13 20:31:17 +02:00
parent 05fd679c4d
commit a539ed9eb2
21 changed files with 2059 additions and 14 deletions
@@ -0,0 +1,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) {}
}