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,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