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,213 @@
package de.cannamanage.api.controller;
import de.cannamanage.domain.entity.InfoBoardPost;
import de.cannamanage.domain.enums.InfoBoardCategory;
import de.cannamanage.service.InfoBoardService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.UUID;
/**
* Info Board (Schwarzes Brett) endpoints for admin and portal.
*/
@RestController
@RequiredArgsConstructor
public class InfoBoardController {
private final InfoBoardService infoBoardService;
// ============================================================
// ADMIN ENDPOINTS (require MANAGE_INFO_BOARD permission)
// ============================================================
/**
* Create a new info board post.
*/
@PostMapping("/api/v1/info-board")
public ResponseEntity<?> createPost(
@Valid @RequestBody CreatePostRequest request,
@AuthenticationPrincipal UserDetails user) {
UUID authorId = UUID.fromString(user.getUsername());
InfoBoardPost post = infoBoardService.createPost(
request.clubId(), request.title(), request.content(),
request.category(), request.pinned() != null && request.pinned(), authorId);
return ResponseEntity.ok(toResponse(post));
}
/**
* List posts (admin view with optional filters).
*/
@GetMapping("/api/v1/info-board")
public ResponseEntity<?> listPosts(
@RequestParam UUID clubId,
@RequestParam(required = false) InfoBoardCategory category,
@RequestParam(defaultValue = "false") boolean includeArchived,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Page<InfoBoardPost> posts = infoBoardService.getPosts(clubId, category, includeArchived, page, size);
var items = posts.getContent().stream().map(this::toResponse).toList();
return ResponseEntity.ok(Map.of(
"posts", items,
"totalElements", posts.getTotalElements(),
"totalPages", posts.getTotalPages(),
"page", posts.getNumber()
));
}
/**
* Get a single post.
*/
@GetMapping("/api/v1/info-board/{id}")
public ResponseEntity<?> getPost(@PathVariable UUID id) {
InfoBoardPost post = infoBoardService.getPost(id);
return ResponseEntity.ok(toResponse(post));
}
/**
* Update a post.
*/
@PutMapping("/api/v1/info-board/{id}")
public ResponseEntity<?> updatePost(
@PathVariable UUID id,
@Valid @RequestBody UpdatePostRequest request) {
InfoBoardPost post = infoBoardService.updatePost(
id, request.title(), request.content(), request.category(), request.pinned());
return ResponseEntity.ok(toResponse(post));
}
/**
* Delete a post.
*/
@DeleteMapping("/api/v1/info-board/{id}")
public ResponseEntity<?> deletePost(@PathVariable UUID id) {
infoBoardService.deletePost(id);
return ResponseEntity.ok(Map.of("deleted", true));
}
/**
* Archive a post.
*/
@PostMapping("/api/v1/info-board/{id}/archive")
public ResponseEntity<?> archivePost(@PathVariable UUID id) {
InfoBoardPost post = infoBoardService.archivePost(id);
return ResponseEntity.ok(toResponse(post));
}
/**
* Unarchive a post.
*/
@PostMapping("/api/v1/info-board/{id}/unarchive")
public ResponseEntity<?> unarchivePost(@PathVariable UUID id) {
InfoBoardPost post = infoBoardService.unarchivePost(id);
return ResponseEntity.ok(toResponse(post));
}
/**
* Toggle pin status.
*/
@PostMapping("/api/v1/info-board/{id}/pin")
public ResponseEntity<?> togglePin(@PathVariable UUID id) {
InfoBoardPost post = infoBoardService.togglePin(id);
return ResponseEntity.ok(toResponse(post));
}
// ============================================================
// PORTAL ENDPOINTS (member access)
// ============================================================
/**
* Get posts for the member's club (non-archived, pinned first).
*/
@GetMapping("/api/v1/portal/info-board")
public ResponseEntity<?> getPortalPosts(
@RequestParam UUID clubId,
@RequestParam(required = false) InfoBoardCategory category,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Page<InfoBoardPost> posts = infoBoardService.getPosts(clubId, category, false, page, size);
var items = posts.getContent().stream().map(this::toResponse).toList();
return ResponseEntity.ok(Map.of(
"posts", items,
"totalElements", posts.getTotalElements(),
"totalPages", posts.getTotalPages(),
"page", posts.getNumber()
));
}
/**
* Mark a post as read.
*/
@PostMapping("/api/v1/portal/info-board/{id}/read")
public ResponseEntity<?> markAsRead(
@PathVariable UUID id,
@RequestParam UUID memberId) {
infoBoardService.markAsRead(id, memberId);
return ResponseEntity.ok(Map.of("read", true));
}
/**
* Get unread post count for badge display.
*/
@GetMapping("/api/v1/portal/info-board/unread-count")
public ResponseEntity<?> getUnreadCount(
@RequestParam UUID clubId,
@RequestParam UUID memberId) {
long count = infoBoardService.getUnreadCount(clubId, memberId);
return ResponseEntity.ok(Map.of("unreadCount", count));
}
// ============================================================
// DTOs
// ============================================================
public record CreatePostRequest(
@NotNull UUID clubId,
@NotBlank @Size(max = 200) String title,
@NotBlank String content,
@NotNull InfoBoardCategory category,
Boolean pinned
) {}
public record UpdatePostRequest(
@Size(max = 200) String title,
String content,
InfoBoardCategory category,
Boolean pinned
) {}
// ============================================================
// Response mapping
// ============================================================
private Map<String, Object> toResponse(InfoBoardPost post) {
return Map.of(
"id", post.getId(),
"clubId", post.getClubId(),
"title", post.getTitle(),
"content", post.getContent(),
"category", post.getCategory().name(),
"pinned", post.isPinned(),
"archived", post.isArchived(),
"authorId", post.getAuthorId(),
"createdAt", post.getCreatedAt().toString(),
"updatedAt", post.getUpdatedAt() != null ? post.getUpdatedAt().toString() : ""
);
}
}
@@ -0,0 +1,39 @@
-- V13: Info Board (Schwarzes Brett) tables
CREATE TABLE info_board_posts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
club_id UUID NOT NULL REFERENCES clubs(id),
title VARCHAR(200) NOT NULL,
content TEXT NOT NULL,
category VARCHAR(50) NOT NULL,
is_pinned BOOLEAN DEFAULT FALSE,
is_archived BOOLEAN DEFAULT FALSE,
author_id UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
tenant_id UUID NOT NULL
);
CREATE TABLE post_attachments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
post_id UUID NOT NULL REFERENCES info_board_posts(id) ON DELETE CASCADE,
filename VARCHAR(255) NOT NULL,
content_type VARCHAR(100),
file_size BIGINT,
storage_path VARCHAR(500) NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
tenant_id UUID NOT NULL
);
CREATE TABLE post_read_status (
post_id UUID NOT NULL REFERENCES info_board_posts(id) ON DELETE CASCADE,
member_id UUID NOT NULL REFERENCES members(id) ON DELETE CASCADE,
read_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (post_id, member_id)
);
CREATE INDEX idx_info_board_posts_club_id ON info_board_posts(club_id);
CREATE INDEX idx_info_board_posts_category ON info_board_posts(category);
CREATE INDEX idx_info_board_posts_pinned ON info_board_posts(is_pinned) WHERE is_pinned = TRUE;
CREATE INDEX idx_info_board_posts_tenant ON info_board_posts(tenant_id);
CREATE INDEX idx_post_attachments_post_id ON post_attachments(post_id);