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