feat(sprint8): Phase 4 — Dokumentenarchiv + Vorstandsverwaltung

Backend:
- V20 migration: documents table with category, access_level, file storage
- V21 migration: board_positions + board_members with term tracking
- Document entity + DocumentCategory/DocumentAccessLevel enums
- BoardPosition + BoardMember entities
- Extended AuditEventType (DOCUMENT_UPLOADED/DELETED, BOARD_MEMBER_ELECTED/REMOVED)
- Extended StaffPermission (MANAGE_DOCUMENTS)
- Extended NotificationType (BOARD_TERM_EXPIRING)
- DocumentService: upload, list, download, delete, storage usage
- BoardService: positions CRUD, elect/remove members, current/history
- DocumentController: multipart upload, filtered list, download, delete, portal
- BoardController: positions, elect, remove, current board, history, portal

Frontend:
- documents.ts + board.ts service layers
- Admin /documents page: grouped by category, upload dialog, filter, download/delete
- Admin /board page: current board cards, position management, elect member dialog
- Navigation: added Dokumente + Vorstand to sidebar
- i18n: documents.* + board.* keys in de.json + en.json
This commit is contained in:
Patrick Plate
2026-06-15 08:53:38 +02:00
parent b22702317a
commit e4698827ee
24 changed files with 1812 additions and 5 deletions
@@ -0,0 +1,120 @@
package de.cannamanage.service;
import de.cannamanage.domain.entity.BoardMember;
import de.cannamanage.domain.entity.BoardPosition;
import de.cannamanage.domain.enums.AuditEventType;
import de.cannamanage.service.repository.BoardMemberRepository;
import de.cannamanage.service.repository.BoardPositionRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
@Service
public class BoardService {
private static final Logger log = LoggerFactory.getLogger(BoardService.class);
private final BoardPositionRepository positionRepository;
private final BoardMemberRepository memberRepository;
private final AuditService auditService;
public BoardService(BoardPositionRepository positionRepository,
BoardMemberRepository memberRepository,
AuditService auditService) {
this.positionRepository = positionRepository;
this.memberRepository = memberRepository;
this.auditService = auditService;
}
// --- Positions ---
@Transactional
public BoardPosition createPosition(UUID clubId, String title, String description, Integer sortOrder) {
BoardPosition pos = new BoardPosition();
pos.setClubId(clubId);
pos.setTitle(title);
pos.setDescription(description);
pos.setSortOrder(sortOrder != null ? sortOrder : 0);
return positionRepository.save(pos);
}
@Transactional
public BoardPosition updatePosition(UUID positionId, String title, String description, Integer sortOrder, Boolean isActive) {
BoardPosition pos = positionRepository.findById(positionId)
.orElseThrow(() -> new IllegalArgumentException("Position not found: " + positionId));
if (title != null) pos.setTitle(title);
if (description != null) pos.setDescription(description);
if (sortOrder != null) pos.setSortOrder(sortOrder);
if (isActive != null) pos.setIsActive(isActive);
return positionRepository.save(pos);
}
public List<BoardPosition> getPositions(UUID clubId) {
return positionRepository.findByClubIdAndIsActiveTrueOrderBySortOrderAsc(clubId);
}
// --- Board Members ---
@Transactional
public BoardMember electBoardMember(UUID clubId, UUID positionId, UUID memberId,
LocalDate electedAt, LocalDate termStart, LocalDate termEnd,
UUID assemblyId, UUID electedBy) {
// Mark current holder as not current
memberRepository.findByPositionIdAndIsCurrentTrue(positionId)
.ifPresent(current -> {
current.setIsCurrent(false);
memberRepository.save(current);
});
// Create new board member
BoardMember bm = new BoardMember();
bm.setClubId(clubId);
bm.setPositionId(positionId);
bm.setMemberId(memberId);
bm.setElectedAt(electedAt);
bm.setTermStart(termStart);
bm.setTermEnd(termEnd);
bm.setIsCurrent(true);
bm.setElectedInAssemblyId(assemblyId);
BoardMember saved = memberRepository.save(bm);
auditService.log(AuditEventType.BOARD_MEMBER_ELECTED, electedBy, clubId,
"Board member elected to position " + positionId);
log.info("Board member {} elected to position {} in club {}", memberId, positionId, clubId);
return saved;
}
@Transactional
public void removeBoardMember(UUID boardMemberId, UUID removedBy, UUID clubId) {
BoardMember bm = memberRepository.findById(boardMemberId)
.orElseThrow(() -> new IllegalArgumentException("Board member not found: " + boardMemberId));
bm.setIsCurrent(false);
memberRepository.save(bm);
auditService.log(AuditEventType.BOARD_MEMBER_REMOVED, removedBy, clubId,
"Board member removed from position " + bm.getPositionId());
log.info("Board member {} removed from position {} in club {}", bm.getMemberId(), bm.getPositionId(), clubId);
}
public List<BoardMember> getCurrentBoard(UUID clubId) {
return memberRepository.findByClubIdAndIsCurrentTrueOrderByCreatedAtAsc(clubId);
}
public List<BoardMember> getBoardHistory(UUID clubId) {
return memberRepository.findByClubIdOrderByCreatedAtDesc(clubId);
}
public List<BoardMember> getExpiringTerms(UUID clubId, int withinDays) {
LocalDate now = LocalDate.now();
LocalDate deadline = now.plusDays(withinDays);
return memberRepository.findByClubIdAndIsCurrentTrueAndTermEndBetween(clubId, now, deadline);
}
}
@@ -0,0 +1,141 @@
package de.cannamanage.service;
import de.cannamanage.domain.entity.Document;
import de.cannamanage.domain.enums.AuditEventType;
import de.cannamanage.domain.enums.DocumentAccessLevel;
import de.cannamanage.domain.enums.DocumentCategory;
import de.cannamanage.service.repository.DocumentRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.Set;
import java.util.UUID;
@Service
public class DocumentService {
private static final Logger log = LoggerFactory.getLogger(DocumentService.class);
private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
private static final Set<String> ALLOWED_TYPES = Set.of(
"application/pdf",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"image/png",
"image/jpeg"
);
private static final String UPLOAD_BASE = "/data/uploads";
private final DocumentRepository documentRepository;
private final AuditService auditService;
public DocumentService(DocumentRepository documentRepository, AuditService auditService) {
this.documentRepository = documentRepository;
this.auditService = auditService;
}
@Transactional
public Document uploadDocument(UUID clubId, String title, DocumentCategory category,
DocumentAccessLevel accessLevel, String description,
MultipartFile file, UUID uploadedBy) throws IOException {
// Validate file
if (file.isEmpty()) {
throw new IllegalArgumentException("File is empty");
}
if (file.getSize() > MAX_FILE_SIZE) {
throw new IllegalArgumentException("File exceeds maximum size of 10MB");
}
if (!ALLOWED_TYPES.contains(file.getContentType())) {
throw new IllegalArgumentException("File type not allowed. Allowed: PDF, DOCX, XLSX, PNG, JPG");
}
// Generate storage path
UUID documentId = UUID.randomUUID();
String filename = file.getOriginalFilename() != null ? file.getOriginalFilename() : "document";
String storagePath = clubId + "/" + documentId + "_" + filename;
Path fullPath = Paths.get(UPLOAD_BASE, storagePath);
// Ensure directory exists
Files.createDirectories(fullPath.getParent());
// Write file to disk
Files.write(fullPath, file.getBytes());
// Create DB record
Document doc = new Document();
doc.setId(documentId);
doc.setClubId(clubId);
doc.setTitle(title);
doc.setCategory(category);
doc.setFilename(filename);
doc.setContentType(file.getContentType());
doc.setFileSize(file.getSize());
doc.setStoragePath(storagePath);
doc.setAccessLevel(accessLevel);
doc.setDescription(description);
doc.setUploadedBy(uploadedBy);
Document saved = documentRepository.save(doc);
auditService.log(AuditEventType.DOCUMENT_UPLOADED, uploadedBy, clubId,
"Document uploaded: " + title + " (" + category + ")");
log.info("Document uploaded: {} ({}) for club {}", title, category, clubId);
return saved;
}
public List<Document> listDocuments(UUID clubId, DocumentCategory category, DocumentAccessLevel accessLevel) {
if (category != null && accessLevel != null) {
return documentRepository.findByClubIdAndCategoryAndAccessLevelOrderByCreatedAtDesc(clubId, category, accessLevel);
} else if (category != null) {
return documentRepository.findByClubIdAndCategoryOrderByCreatedAtDesc(clubId, category);
} else if (accessLevel != null) {
return documentRepository.findByClubIdAndAccessLevelOrderByCreatedAtDesc(clubId, accessLevel);
}
return documentRepository.findByClubIdOrderByCreatedAtDesc(clubId);
}
public Document getDocument(UUID documentId) {
return documentRepository.findById(documentId)
.orElseThrow(() -> new IllegalArgumentException("Document not found: " + documentId));
}
public byte[] downloadDocument(UUID documentId) throws IOException {
Document doc = getDocument(documentId);
Path fullPath = Paths.get(UPLOAD_BASE, doc.getStoragePath());
if (!Files.exists(fullPath)) {
throw new IllegalStateException("File not found on disk: " + doc.getStoragePath());
}
return Files.readAllBytes(fullPath);
}
@Transactional
public void deleteDocument(UUID documentId, UUID deletedBy, UUID clubId) throws IOException {
Document doc = getDocument(documentId);
Path fullPath = Paths.get(UPLOAD_BASE, doc.getStoragePath());
// Delete file from disk
if (Files.exists(fullPath)) {
Files.delete(fullPath);
}
// Delete DB record
documentRepository.delete(doc);
auditService.log(AuditEventType.DOCUMENT_DELETED, deletedBy, clubId,
"Document deleted: " + doc.getTitle());
log.info("Document deleted: {} for club {}", doc.getTitle(), clubId);
}
public long getStorageUsage(UUID clubId) {
return documentRepository.sumFileSizeByClubId(clubId);
}
}
@@ -0,0 +1,22 @@
package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.BoardMember;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface BoardMemberRepository extends JpaRepository<BoardMember, UUID> {
List<BoardMember> findByClubIdAndIsCurrentTrueOrderByCreatedAtAsc(UUID clubId);
List<BoardMember> findByClubIdOrderByCreatedAtDesc(UUID clubId);
Optional<BoardMember> findByPositionIdAndIsCurrentTrue(UUID positionId);
List<BoardMember> findByClubIdAndIsCurrentTrueAndTermEndBefore(UUID clubId, LocalDate date);
List<BoardMember> findByClubIdAndIsCurrentTrueAndTermEndBetween(UUID clubId, LocalDate from, LocalDate to);
}
@@ -0,0 +1,14 @@
package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.BoardPosition;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface BoardPositionRepository extends JpaRepository<BoardPosition, UUID> {
List<BoardPosition> findByClubIdAndIsActiveTrueOrderBySortOrderAsc(UUID clubId);
List<BoardPosition> findByClubIdOrderBySortOrderAsc(UUID clubId);
}
@@ -0,0 +1,26 @@
package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.Document;
import de.cannamanage.domain.enums.DocumentAccessLevel;
import de.cannamanage.domain.enums.DocumentCategory;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.UUID;
public interface DocumentRepository extends JpaRepository<Document, UUID> {
List<Document> findByClubIdOrderByCreatedAtDesc(UUID clubId);
List<Document> findByClubIdAndCategoryOrderByCreatedAtDesc(UUID clubId, DocumentCategory category);
List<Document> findByClubIdAndAccessLevelOrderByCreatedAtDesc(UUID clubId, DocumentAccessLevel accessLevel);
List<Document> findByClubIdAndCategoryAndAccessLevelOrderByCreatedAtDesc(
UUID clubId, DocumentCategory category, DocumentAccessLevel accessLevel);
@Query("SELECT COALESCE(SUM(d.fileSize), 0) FROM Document d WHERE d.clubId = :clubId")
Long sumFileSizeByClubId(@Param("clubId") UUID clubId);
}