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);
|
||||
Reference in New Issue
Block a user