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,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
|
||||
}
|
||||
Reference in New Issue
Block a user