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,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_LOCKED,
FORUM_TOPIC_DELETED,
FORUM_REPLY_CREATED,
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
}