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,216 @@
package de.cannamanage.service;
import de.cannamanage.domain.entity.InfoBoardPost;
import de.cannamanage.domain.entity.PostReadStatus;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.domain.enums.AuditEventType;
import de.cannamanage.domain.enums.InfoBoardCategory;
import de.cannamanage.domain.enums.NotificationType;
import de.cannamanage.service.repository.InfoBoardPostRepository;
import de.cannamanage.service.repository.MemberRepository;
import de.cannamanage.service.repository.PostReadStatusRepository;
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.util.UUID;
/**
* Service for Info Board (Schwarzes Brett) operations.
*/
@Service
@Transactional
public class InfoBoardService {
private static final Logger log = LoggerFactory.getLogger(InfoBoardService.class);
private final InfoBoardPostRepository postRepository;
private final PostReadStatusRepository readStatusRepository;
private final MemberRepository memberRepository;
private final NotificationService notificationService;
private final AuditService auditService;
public InfoBoardService(InfoBoardPostRepository postRepository,
PostReadStatusRepository readStatusRepository,
MemberRepository memberRepository,
NotificationService notificationService,
AuditService auditService) {
this.postRepository = postRepository;
this.readStatusRepository = readStatusRepository;
this.memberRepository = memberRepository;
this.notificationService = notificationService;
this.auditService = auditService;
}
/**
* Create a new info board post and notify all club members.
*/
public InfoBoardPost createPost(UUID clubId, String title, String content,
InfoBoardCategory category, boolean isPinned, UUID authorId) {
InfoBoardPost post = new InfoBoardPost(clubId, title, content, category, authorId);
post.setPinned(isPinned);
InfoBoardPost saved = postRepository.save(post);
log.info("Info board post created: {} in club {}", saved.getId(), clubId);
// Dispatch notification to all club members
try {
var members = memberRepository.findByClubId(clubId);
members.forEach(member -> {
if (member.getUserId() != null) {
notificationService.createNotification(
member.getUserId(),
NotificationType.INFO_BOARD_POST,
title,
"Neuer Beitrag im Schwarzen Brett: " + title,
"/portal/info-board"
);
}
});
} catch (Exception e) {
log.warn("Failed to send info board notifications: {}", e.getMessage());
}
// Audit log
auditService.log(
AuditEventType.INFO_BOARD_POST_CREATED,
"InfoBoardPost",
saved.getId(),
authorId,
null, null,
"Info board post created: " + title,
"{\"category\":\"" + category.name() + "\",\"pinned\":" + isPinned + "}",
null
);
return saved;
}
/**
* Update an existing post.
*/
public InfoBoardPost updatePost(UUID postId, String title, String content,
InfoBoardCategory category, Boolean isPinned) {
InfoBoardPost post = postRepository.findById(postId)
.orElseThrow(() -> new IllegalArgumentException("Post not found: " + postId));
if (title != null) post.setTitle(title);
if (content != null) post.setContent(content);
if (category != null) post.setCategory(category);
if (isPinned != null) post.setPinned(isPinned);
InfoBoardPost saved = postRepository.save(post);
log.debug("Info board post updated: {}", postId);
auditService.log(
AuditEventType.INFO_BOARD_POST_UPDATED,
"InfoBoardPost",
postId,
null, null, null,
"Info board post updated: " + post.getTitle(),
null, null
);
return saved;
}
/**
* Archive a post (hides from portal members).
*/
public InfoBoardPost archivePost(UUID postId) {
InfoBoardPost post = postRepository.findById(postId)
.orElseThrow(() -> new IllegalArgumentException("Post not found: " + postId));
post.setArchived(true);
return postRepository.save(post);
}
/**
* Unarchive a post.
*/
public InfoBoardPost unarchivePost(UUID postId) {
InfoBoardPost post = postRepository.findById(postId)
.orElseThrow(() -> new IllegalArgumentException("Post not found: " + postId));
post.setArchived(false);
return postRepository.save(post);
}
/**
* Delete a post permanently.
*/
public void deletePost(UUID postId) {
InfoBoardPost post = postRepository.findById(postId)
.orElseThrow(() -> new IllegalArgumentException("Post not found: " + postId));
postRepository.delete(post);
log.info("Info board post deleted: {}", postId);
auditService.log(
AuditEventType.INFO_BOARD_POST_DELETED,
"InfoBoardPost",
postId,
null, null, null,
"Info board post deleted: " + post.getTitle(),
null, null
);
}
/**
* Get posts for a club (admin view — optionally includes archived).
*/
@Transactional(readOnly = true)
public Page<InfoBoardPost> getPosts(UUID clubId, InfoBoardCategory category,
boolean includeArchived, int page, int size) {
var pageable = PageRequest.of(page, size);
if (includeArchived) {
if (category != null) {
return postRepository.findByClubIdAndCategoryOrderByPinnedDescCreatedAtDesc(clubId, category, pageable);
}
return postRepository.findByClubIdOrderByPinnedDescCreatedAtDesc(clubId, pageable);
} else {
if (category != null) {
return postRepository.findByClubIdAndCategoryAndArchivedFalseOrderByPinnedDescCreatedAtDesc(clubId, category, pageable);
}
return postRepository.findByClubIdAndArchivedFalseOrderByPinnedDescCreatedAtDesc(clubId, pageable);
}
}
/**
* Get a single post by ID.
*/
@Transactional(readOnly = true)
public InfoBoardPost getPost(UUID postId) {
return postRepository.findById(postId)
.orElseThrow(() -> new IllegalArgumentException("Post not found: " + postId));
}
/**
* Mark a post as read by a member.
*/
public void markAsRead(UUID postId, UUID memberId) {
if (!readStatusRepository.existsByPostIdAndMemberId(postId, memberId)) {
readStatusRepository.save(new PostReadStatus(postId, memberId));
}
}
/**
* Get unread post count for a member in their club.
*/
@Transactional(readOnly = true)
public long getUnreadCount(UUID clubId, UUID memberId) {
return postRepository.countUnreadByClubIdAndMemberId(clubId, memberId);
}
/**
* Toggle pin status.
*/
public InfoBoardPost togglePin(UUID postId) {
InfoBoardPost post = postRepository.findById(postId)
.orElseThrow(() -> new IllegalArgumentException("Post not found: " + postId));
post.setPinned(!post.isPinned());
return postRepository.save(post);
}
}
@@ -0,0 +1,32 @@
package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.InfoBoardPost;
import de.cannamanage.domain.enums.InfoBoardCategory;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.UUID;
@Repository
public interface InfoBoardPostRepository extends JpaRepository<InfoBoardPost, UUID> {
Page<InfoBoardPost> findByClubIdAndArchivedFalseOrderByPinnedDescCreatedAtDesc(
UUID clubId, Pageable pageable);
Page<InfoBoardPost> findByClubIdAndCategoryAndArchivedFalseOrderByPinnedDescCreatedAtDesc(
UUID clubId, InfoBoardCategory category, Pageable pageable);
Page<InfoBoardPost> findByClubIdOrderByPinnedDescCreatedAtDesc(
UUID clubId, Pageable pageable);
Page<InfoBoardPost> findByClubIdAndCategoryOrderByPinnedDescCreatedAtDesc(
UUID clubId, InfoBoardCategory category, Pageable pageable);
@Query("SELECT COUNT(p) FROM InfoBoardPost p WHERE p.clubId = :clubId AND p.archived = false " +
"AND p.id NOT IN (SELECT r.postId FROM PostReadStatus r WHERE r.memberId = :memberId)")
long countUnreadByClubIdAndMemberId(@Param("clubId") UUID clubId, @Param("memberId") UUID memberId);
}
@@ -0,0 +1,14 @@
package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.PostAttachment;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.UUID;
@Repository
public interface PostAttachmentRepository extends JpaRepository<PostAttachment, UUID> {
List<PostAttachment> findByPostId(UUID postId);
}
@@ -0,0 +1,13 @@
package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.PostReadStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.UUID;
@Repository
public interface PostReadStatusRepository extends JpaRepository<PostReadStatus, PostReadStatus.PostReadStatusId> {
boolean existsByPostIdAndMemberId(UUID postId, UUID memberId);
}