diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/controller/ForumController.java b/cannamanage-api/src/main/java/de/cannamanage/api/controller/ForumController.java new file mode 100644 index 0000000..e27a22e --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/controller/ForumController.java @@ -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 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> 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 getTopic(@PathVariable UUID id) { + return forumService.getTopic(id) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + @PostMapping("/forum/topics/{id}/lock") + public ResponseEntity lockTopic(@PathVariable UUID id, + @RequestHeader("X-User-Id") UUID userId) { + return ResponseEntity.ok(forumService.lockTopic(id, userId)); + } + + @PostMapping("/forum/topics/{id}/unlock") + public ResponseEntity unlockTopic(@PathVariable UUID id, + @RequestHeader("X-User-Id") UUID userId) { + return ResponseEntity.ok(forumService.unlockTopic(id, userId)); + } + + @PostMapping("/forum/topics/{id}/pin") + public ResponseEntity pinTopic(@PathVariable UUID id, + @RequestHeader("X-User-Id") UUID userId) { + return ResponseEntity.ok(forumService.pinTopic(id, userId)); + } + + @PostMapping("/forum/topics/{id}/unpin") + public ResponseEntity unpinTopic(@PathVariable UUID id, + @RequestHeader("X-User-Id") UUID userId) { + return ResponseEntity.ok(forumService.unpinTopic(id, userId)); + } + + @DeleteMapping("/forum/topics/{id}") + public ResponseEntity 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> 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 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 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 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> 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> 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> 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> getOpenReportCount(@RequestHeader("X-Club-Id") UUID clubId) { + return ResponseEntity.ok(Map.of("count", forumService.getOpenReportCount(clubId))); + } + + @PostMapping("/forum/reports/{id}/review") + public ResponseEntity 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 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> 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 portalGetTopic(@PathVariable UUID id) { + return forumService.getTopic(id) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + @GetMapping("/portal/forum/topics/{topicId}/replies") + public ResponseEntity> 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 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 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> 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> 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) {} +} diff --git a/cannamanage-api/src/main/resources/db/migration/V15__forum.sql b/cannamanage-api/src/main/resources/db/migration/V15__forum.sql new file mode 100644 index 0000000..9b58a08 --- /dev/null +++ b/cannamanage-api/src/main/resources/db/migration/V15__forum.sql @@ -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); diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/ForumReaction.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/ForumReaction.java new file mode 100644 index 0000000..eefe2c4 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/ForumReaction.java @@ -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; } +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/ForumReply.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/ForumReply.java new file mode 100644 index 0000000..90cd8ec --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/ForumReply.java @@ -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; } +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/ForumReport.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/ForumReport.java new file mode 100644 index 0000000..9ea3de8 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/ForumReport.java @@ -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; } +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/ForumTopic.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/ForumTopic.java new file mode 100644 index 0000000..bacc165 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/ForumTopic.java @@ -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; } +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/AuditEventType.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/AuditEventType.java index e83759e..8d2b84c 100644 --- a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/AuditEventType.java +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/AuditEventType.java @@ -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 } diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/ForumTargetType.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/ForumTargetType.java new file mode 100644 index 0000000..03d13c1 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/ForumTargetType.java @@ -0,0 +1,9 @@ +package de.cannamanage.domain.enums; + +/** + * Target type for forum reactions and reports. + */ +public enum ForumTargetType { + TOPIC, + REPLY +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/ReactionType.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/ReactionType.java new file mode 100644 index 0000000..724d26b --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/ReactionType.java @@ -0,0 +1,9 @@ +package de.cannamanage.domain.enums; + +/** + * Reaction types for forum content. + */ +public enum ReactionType { + THUMBS_UP, + THUMBS_DOWN +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/ReportStatus.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/ReportStatus.java new file mode 100644 index 0000000..38b3bc3 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/ReportStatus.java @@ -0,0 +1,10 @@ +package de.cannamanage.domain.enums; + +/** + * Status of a forum content report. + */ +public enum ReportStatus { + OPEN, + REVIEWED, + DISMISSED +} diff --git a/cannamanage-frontend/messages/de.json b/cannamanage-frontend/messages/de.json index d8d231b..5be2e4c 100644 --- a/cannamanage-frontend/messages/de.json +++ b/cannamanage-frontend/messages/de.json @@ -808,5 +808,33 @@ "WORKSHOP": "Workshop", "OTHER": "Sonstiges" } + }, + "forum": { + "title": "Forum", + "description": "Vereinsinternes Diskussionsforum", + "newTopic": "Neues Thema", + "topicTitlePlaceholder": "Titel des Themas...", + "topicContentPlaceholder": "Beschreibe dein Thema...", + "creating": "Wird erstellt...", + "create": "Erstellen", + "cancel": "Abbrechen", + "loading": "Wird geladen...", + "noTopics": "Noch keine Themen vorhanden. Erstelle das erste!", + "replies": "Antworten", + "lastReply": "Letzte Antwort", + "openReports": "offene Meldungen", + "pin": "Anheften", + "unpin": "Lösen", + "lock": "Sperren", + "unlock": "Entsperren", + "delete": "Löschen", + "deleteReason": "Grund für die Löschung (optional):", + "replyPlaceholder": "Schreibe eine Antwort...", + "sending": "Wird gesendet...", + "reply": "Antworten", + "edited": "bearbeitet", + "topicLocked": "Dieses Thema ist gesperrt. Neue Antworten sind nicht möglich.", + "reportReason": "Grund der Meldung:", + "backToTopics": "Zurück zur Übersicht" } } diff --git a/cannamanage-frontend/messages/en.json b/cannamanage-frontend/messages/en.json index 822d179..9c66429 100644 --- a/cannamanage-frontend/messages/en.json +++ b/cannamanage-frontend/messages/en.json @@ -610,21 +610,76 @@ "onboarding": "Personal onboarding" }, "comparison": { - "compMembers": { "label": "Members", "starter": "Up to 30", "pro": "Up to 100", "enterprise": "Unlimited" }, - "compDistributions": { "label": "Distribution tracking", "starter": "✓", "pro": "✓", "enterprise": "✓" }, - "compReports": { "label": "Reports (PDF/CSV)", "starter": "Standard", "pro": "Advanced", "enterprise": "Custom" }, - "compGrow": { "label": "Grow calendar", "starter": "—", "pro": "✓", "enterprise": "✓" }, - "compStaff": { "label": "Staff management", "starter": "—", "pro": "✓", "enterprise": "✓" }, - "compApi": { "label": "API access", "starter": "—", "pro": "✓", "enterprise": "✓" }, - "compMultiClub": { "label": "Multi-club", "starter": "—", "pro": "—", "enterprise": "✓" }, - "compSupport": { "label": "Support", "starter": "Email", "pro": "Priority", "enterprise": "Dedicated" } + "compMembers": { + "label": "Members", + "starter": "Up to 30", + "pro": "Up to 100", + "enterprise": "Unlimited" + }, + "compDistributions": { + "label": "Distribution tracking", + "starter": "✓", + "pro": "✓", + "enterprise": "✓" + }, + "compReports": { + "label": "Reports (PDF/CSV)", + "starter": "Standard", + "pro": "Advanced", + "enterprise": "Custom" + }, + "compGrow": { + "label": "Grow calendar", + "starter": "—", + "pro": "✓", + "enterprise": "✓" + }, + "compStaff": { + "label": "Staff management", + "starter": "—", + "pro": "✓", + "enterprise": "✓" + }, + "compApi": { + "label": "API access", + "starter": "—", + "pro": "✓", + "enterprise": "✓" + }, + "compMultiClub": { + "label": "Multi-club", + "starter": "—", + "pro": "—", + "enterprise": "✓" + }, + "compSupport": { + "label": "Support", + "starter": "Email", + "pro": "Priority", + "enterprise": "Dedicated" + } }, "faq": { - "trial": { "question": "How does the free trial work?", "answer": "You can test CannaManage free for 3 months with no commitment. All features of your chosen plan are available immediately. After the trial, you decide whether to continue." }, - "payment": { "question": "Which payment methods are accepted?", "answer": "We accept SEPA direct debit, credit card (Visa, Mastercard) and PayPal. Billing is monthly through our payment partner Stripe." }, - "cancel": { "question": "Can I cancel anytime?", "answer": "Yes, you can cancel your subscription at any time at the end of the current billing period. There is no minimum contract period." }, - "data": { "question": "What happens to my data after cancellation?", "answer": "After cancellation, you have 30 days to export your data. After that, all personal data is deleted in accordance with GDPR. Data subject to retention requirements remains stored in compliance with the law." }, - "migration": { "question": "Can I switch plans later?", "answer": "Yes, you can switch between Starter and Pro at any time. Upgrades take effect immediately, downgrades at the next billing period." } + "trial": { + "question": "How does the free trial work?", + "answer": "You can test CannaManage free for 3 months with no commitment. All features of your chosen plan are available immediately. After the trial, you decide whether to continue." + }, + "payment": { + "question": "Which payment methods are accepted?", + "answer": "We accept SEPA direct debit, credit card (Visa, Mastercard) and PayPal. Billing is monthly through our payment partner Stripe." + }, + "cancel": { + "question": "Can I cancel anytime?", + "answer": "Yes, you can cancel your subscription at any time at the end of the current billing period. There is no minimum contract period." + }, + "data": { + "question": "What happens to my data after cancellation?", + "answer": "After cancellation, you have 30 days to export your data. After that, all personal data is deleted in accordance with GDPR. Data subject to retention requirements remains stored in compliance with the law." + }, + "migration": { + "question": "Can I switch plans later?", + "answer": "Yes, you can switch between Starter and Pro at any time. Upgrades take effect immediately, downgrades at the next billing period." + } } }, "impressum": { @@ -753,5 +808,33 @@ "WORKSHOP": "Workshop", "OTHER": "Other" } + }, + "forum": { + "title": "Forum", + "description": "Club-internal discussion forum", + "newTopic": "New Topic", + "topicTitlePlaceholder": "Topic title...", + "topicContentPlaceholder": "Describe your topic...", + "creating": "Creating...", + "create": "Create", + "cancel": "Cancel", + "loading": "Loading...", + "noTopics": "No topics yet. Create the first one!", + "replies": "Replies", + "lastReply": "Last reply", + "openReports": "open reports", + "pin": "Pin", + "unpin": "Unpin", + "lock": "Lock", + "unlock": "Unlock", + "delete": "Delete", + "deleteReason": "Reason for deletion (optional):", + "replyPlaceholder": "Write a reply...", + "sending": "Sending...", + "reply": "Reply", + "edited": "edited", + "topicLocked": "This topic is locked. New replies are not possible.", + "reportReason": "Reason for report:", + "backToTopics": "Back to overview" } } diff --git a/cannamanage-frontend/src/app/(dashboard-layout)/forum/page.tsx b/cannamanage-frontend/src/app/(dashboard-layout)/forum/page.tsx new file mode 100644 index 0000000..ffc982b --- /dev/null +++ b/cannamanage-frontend/src/app/(dashboard-layout)/forum/page.tsx @@ -0,0 +1,227 @@ +"use client" + +import { useState } from "react" +import { useTranslations } from "next-intl" +import { + Lock, + MessageSquare, + Pin, + Plus, + Trash2, + Unlock, + Flag, + PinOff, +} from "lucide-react" + +import { + useForumTopics, + useCreateTopic, + useLockTopic, + useUnlockTopic, + usePinTopic, + useUnpinTopic, + useDeleteTopic, + useOpenReportCount, + type ForumTopic, +} from "@/services/forum" + +export default function ForumPage() { + const t = useTranslations("forum") + const [showCreate, setShowCreate] = useState(false) + const [title, setTitle] = useState("") + const [content, setContent] = useState("") + + const { data: topicsData, isLoading } = useForumTopics() + const { data: reportCount } = useOpenReportCount() + const createTopic = useCreateTopic() + const lockTopic = useLockTopic() + const unlockTopic = useUnlockTopic() + const pinTopic = usePinTopic() + const unpinTopic = useUnpinTopic() + const deleteTopic = useDeleteTopic() + + const topics: ForumTopic[] = topicsData?.content ?? [] + + const handleCreate = () => { + if (!title.trim() || !content.trim()) return + createTopic.mutate( + { title: title.trim(), content: content.trim() }, + { + onSuccess: () => { + setTitle("") + setContent("") + setShowCreate(false) + }, + } + ) + } + + const handleDelete = (topicId: string) => { + const reason = prompt(t("deleteReason")) + if (reason !== null) { + deleteTopic.mutate({ topicId, reason }) + } + } + + return ( +
+ {/* Header */} +
+
+

{t("title")}

+

{t("description")}

+
+
+ {reportCount?.count > 0 && ( + + + {reportCount.count} {t("openReports")} + + )} + +
+
+ + {/* Create Topic Form */} + {showCreate && ( +
+ setTitle(e.target.value)} + placeholder={t("topicTitlePlaceholder")} + className="bg-background w-full rounded-md border px-3 py-2 text-sm" + /> +