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,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);
|
||||
Reference in New Issue
Block a user