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,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);
}
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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);
}