feat(sprint7): Phase 2 — Info Board (Schwarzes Brett)

Backend:
- V13 Flyway migration: info_board_posts, post_attachments, post_read_status tables
- InfoBoardPost entity with category enum (EVENT, RULE, GENERAL, MAINTENANCE)
- PostAttachment entity (table created, upload deferred to later)
- PostReadStatus entity with composite key (post_id, member_id)
- InfoBoardPostRepository with paginated queries + unread count
- InfoBoardService: CRUD, pin/archive, mark-as-read, notification dispatch
- InfoBoardController: admin CRUD + portal read/unread endpoints
- Integration with NotificationService and AuditService

Frontend:
- info-board.ts service with React Query hooks for all endpoints
- Admin Info Board page at /info-board with create dialog, filters, pin/archive/delete
- Navigation: added 'Schwarzes Brett' to admin sidebar
- i18n: added infoBoard.* keys to de.json and en.json
- Fixed pre-existing prettier issues in notification-compose.ts
- Fixed BufferSource type issue in push-subscription.ts
This commit is contained in:
Patrick Plate
2026-06-13 19:41:20 +02:00
parent 706a6e257b
commit 4aa27cd4f9
53 changed files with 2724 additions and 28 deletions
@@ -0,0 +1,104 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.InfoBoardCategory;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* Info board post entity — club-scoped announcements (Schwarzes Brett).
* Content is stored as HTML (from Tiptap rich text editor).
*/
@Entity
@Table(name = "info_board_posts", indexes = {
@Index(name = "idx_info_board_posts_club_id", columnList = "club_id"),
@Index(name = "idx_info_board_posts_category", columnList = "category"),
@Index(name = "idx_info_board_posts_tenant", columnList = "tenant_id")
})
public class InfoBoardPost extends AbstractTenantEntity {
@Column(name = "club_id", nullable = false)
private UUID clubId;
@Column(name = "title", nullable = false, length = 200)
private String title;
@Column(name = "content", nullable = false, columnDefinition = "TEXT")
private String content;
@Enumerated(EnumType.STRING)
@Column(name = "category", nullable = false, length = 50)
private InfoBoardCategory category;
@Column(name = "is_pinned", nullable = false)
private boolean pinned = false;
@Column(name = "is_archived", nullable = false)
private boolean archived = false;
@Column(name = "author_id", nullable = false)
private UUID authorId;
@Column(name = "updated_at")
private Instant updatedAt;
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
private List<PostAttachment> attachments = new ArrayList<>();
public InfoBoardPost() {}
public InfoBoardPost(UUID clubId, String title, String content, InfoBoardCategory category, UUID authorId) {
this.clubId = clubId;
this.title = title;
this.content = content;
this.category = category;
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 InfoBoardCategory getCategory() { return category; }
public void setCategory(InfoBoardCategory category) { this.category = category; }
public boolean isPinned() { return pinned; }
public void setPinned(boolean pinned) { this.pinned = pinned; }
public boolean isArchived() { return archived; }
public void setArchived(boolean archived) { this.archived = archived; }
public UUID getAuthorId() { return authorId; }
public void setAuthorId(UUID authorId) { this.authorId = authorId; }
public Instant getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
public List<PostAttachment> getAttachments() { return attachments; }
public void setAttachments(List<PostAttachment> attachments) { this.attachments = attachments; }
}
@@ -0,0 +1,52 @@
package de.cannamanage.domain.entity;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
/**
* File attachment for an info board post.
* MVP: table created but upload not yet implemented.
*/
@Entity
@Table(name = "post_attachments", indexes = {
@Index(name = "idx_post_attachments_post_id", columnList = "post_id")
})
public class PostAttachment extends AbstractTenantEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id", nullable = false)
private InfoBoardPost post;
@Column(name = "filename", nullable = false, length = 255)
private String filename;
@Column(name = "content_type", length = 100)
private String contentType;
@Column(name = "file_size")
private Long fileSize;
@Column(name = "storage_path", nullable = false, length = 500)
private String storagePath;
public PostAttachment() {}
// Getters and setters
public InfoBoardPost getPost() { return post; }
public void setPost(InfoBoardPost post) { this.post = post; }
public String getFilename() { return filename; }
public void setFilename(String filename) { this.filename = filename; }
public String getContentType() { return contentType; }
public void setContentType(String contentType) { this.contentType = contentType; }
public Long getFileSize() { return fileSize; }
public void setFileSize(Long fileSize) { this.fileSize = fileSize; }
public String getStoragePath() { return storagePath; }
public void setStoragePath(String storagePath) { this.storagePath = storagePath; }
}
@@ -0,0 +1,76 @@
package de.cannamanage.domain.entity;
import jakarta.persistence.*;
import java.io.Serializable;
import java.time.Instant;
import java.util.Objects;
import java.util.UUID;
/**
* Tracks which members have read which info board posts.
* Composite primary key: (post_id, member_id).
*/
@Entity
@Table(name = "post_read_status")
@IdClass(PostReadStatus.PostReadStatusId.class)
public class PostReadStatus {
@Id
@Column(name = "post_id", nullable = false)
private UUID postId;
@Id
@Column(name = "member_id", nullable = false)
private UUID memberId;
@Column(name = "read_at", nullable = false)
private Instant readAt;
public PostReadStatus() {}
public PostReadStatus(UUID postId, UUID memberId) {
this.postId = postId;
this.memberId = memberId;
this.readAt = Instant.now();
}
// Getters and setters
public UUID getPostId() { return postId; }
public void setPostId(UUID postId) { this.postId = postId; }
public UUID getMemberId() { return memberId; }
public void setMemberId(UUID memberId) { this.memberId = memberId; }
public Instant getReadAt() { return readAt; }
public void setReadAt(Instant readAt) { this.readAt = readAt; }
/**
* Composite key class for PostReadStatus.
*/
public static class PostReadStatusId implements Serializable {
private UUID postId;
private UUID memberId;
public PostReadStatusId() {}
public PostReadStatusId(UUID postId, UUID memberId) {
this.postId = postId;
this.memberId = memberId;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
PostReadStatusId that = (PostReadStatusId) o;
return Objects.equals(postId, that.postId) && Objects.equals(memberId, that.memberId);
}
@Override
public int hashCode() {
return Objects.hash(postId, memberId);
}
}
}
@@ -0,0 +1,11 @@
package de.cannamanage.domain.enums;
/**
* Categories for info board posts.
*/
public enum InfoBoardCategory {
EVENT,
RULE,
GENERAL,
MAINTENANCE
}