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:
@@ -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);
|
||||
}
|
||||
}
|
||||
+32
@@ -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);
|
||||
}
|
||||
+14
@@ -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);
|
||||
}
|
||||
+13
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user