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,69 @@
package de.cannamanage.domain.entity;
import jakarta.persistence.*;
import java.time.Instant;
import java.time.LocalDate;
import java.util.UUID;
/**
* Board member assignment — links a member to a board position for a term.
* Legal basis: §27 BGB (Bestellung/Abberufung des Vorstands).
*/
@Entity
@Table(name = "board_members", indexes = {
@Index(name = "idx_board_members_club", columnList = "club_id"),
@Index(name = "idx_board_members_tenant", columnList = "tenant_id")
})
public class BoardMember extends AbstractTenantEntity {
@Column(name = "club_id", nullable = false)
private UUID clubId;
@Column(name = "position_id", nullable = false)
private UUID positionId;
@Column(name = "member_id", nullable = false)
private UUID memberId;
@Column(name = "elected_at", nullable = false)
private LocalDate electedAt;
@Column(name = "term_start", nullable = false)
private LocalDate termStart;
@Column(name = "term_end")
private LocalDate termEnd;
@Column(name = "is_current", nullable = false)
private Boolean isCurrent = true;
@Column(name = "elected_in_assembly_id")
private UUID electedInAssemblyId;
// Getters and setters
public UUID getClubId() { return clubId; }
public void setClubId(UUID clubId) { this.clubId = clubId; }
public UUID getPositionId() { return positionId; }
public void setPositionId(UUID positionId) { this.positionId = positionId; }
public UUID getMemberId() { return memberId; }
public void setMemberId(UUID memberId) { this.memberId = memberId; }
public LocalDate getElectedAt() { return electedAt; }
public void setElectedAt(LocalDate electedAt) { this.electedAt = electedAt; }
public LocalDate getTermStart() { return termStart; }
public void setTermStart(LocalDate termStart) { this.termStart = termStart; }
public LocalDate getTermEnd() { return termEnd; }
public void setTermEnd(LocalDate termEnd) { this.termEnd = termEnd; }
public Boolean getIsCurrent() { return isCurrent; }
public void setIsCurrent(Boolean isCurrent) { this.isCurrent = isCurrent; }
public UUID getElectedInAssemblyId() { return electedInAssemblyId; }
public void setElectedInAssemblyId(UUID electedInAssemblyId) { this.electedInAssemblyId = electedInAssemblyId; }
}
@@ -0,0 +1,50 @@
package de.cannamanage.domain.entity;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
/**
* Board position definition (e.g., "1. Vorsitzender", "Kassenwart", "Präventionsbeauftragter").
* Legal basis: §26 BGB (Vorstand), §30 BGB (Besonderer Vertreter), §23 KCanG (Präventionsbeauftragter).
*/
@Entity
@Table(name = "board_positions", indexes = {
@Index(name = "idx_board_positions_club", columnList = "club_id"),
@Index(name = "idx_board_positions_tenant", columnList = "tenant_id")
})
public class BoardPosition extends AbstractTenantEntity {
@Column(name = "club_id", nullable = false)
private UUID clubId;
@Column(name = "title", nullable = false, length = 100)
private String title;
@Column(name = "description", columnDefinition = "TEXT")
private String description;
@Column(name = "sort_order", nullable = false)
private Integer sortOrder = 0;
@Column(name = "is_active", nullable = false)
private Boolean isActive = true;
// Getters and setters
public UUID getClubId() { return clubId; }
public void setClubId(UUID clubId) { this.clubId = clubId; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public Integer getSortOrder() { return sortOrder; }
public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; }
public Boolean getIsActive() { return isActive; }
public void setIsActive(Boolean isActive) { this.isActive = isActive; }
}
@@ -0,0 +1,96 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.DocumentAccessLevel;
import de.cannamanage.domain.enums.DocumentCategory;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
/**
* Club document entity for the document archive.
* Legal basis: §22 KCanG (documentation requirements), §147 AO (retention).
*/
@Entity
@Table(name = "documents", indexes = {
@Index(name = "idx_documents_club", columnList = "club_id"),
@Index(name = "idx_documents_tenant", columnList = "tenant_id"),
@Index(name = "idx_documents_category", columnList = "club_id, category")
})
public class Document extends AbstractTenantEntity {
@Column(name = "club_id", nullable = false)
private UUID clubId;
@Column(name = "title", nullable = false, length = 300)
private String title;
@Enumerated(EnumType.STRING)
@Column(name = "category", nullable = false, length = 50)
private DocumentCategory category;
@Column(name = "filename", nullable = false, length = 255)
private String filename;
@Column(name = "content_type", nullable = false, length = 100)
private String contentType;
@Column(name = "file_size", nullable = false)
private Long fileSize;
@Column(name = "storage_path", nullable = false, length = 500)
private String storagePath;
@Enumerated(EnumType.STRING)
@Column(name = "access_level", nullable = false, length = 20)
private DocumentAccessLevel accessLevel = DocumentAccessLevel.ALL_MEMBERS;
@Column(name = "description", columnDefinition = "TEXT")
private String description;
@Column(name = "uploaded_by", nullable = false)
private UUID uploadedBy;
@Column(name = "updated_at")
private Instant updatedAt;
@PreUpdate
void onUpdate() {
this.updatedAt = Instant.now();
}
// Getters and setters
public UUID getClubId() { return clubId; }
public void setClubId(UUID clubId) { this.clubId = clubId; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public DocumentCategory getCategory() { return category; }
public void setCategory(DocumentCategory category) { this.category = category; }
public String getFilename() { return filename; }
public void setFilename(String filename) { this.filename = filename; }
public String getContentType() { return contentType; }
public void setContentType(String contentType) { this.contentType = contentType; }
public Long getFileSize() { return fileSize; }
public void setFileSize(Long fileSize) { this.fileSize = fileSize; }
public String getStoragePath() { return storagePath; }
public void setStoragePath(String storagePath) { this.storagePath = storagePath; }
public DocumentAccessLevel getAccessLevel() { return accessLevel; }
public void setAccessLevel(DocumentAccessLevel accessLevel) { this.accessLevel = accessLevel; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public UUID getUploadedBy() { return uploadedBy; }
public void setUploadedBy(UUID uploadedBy) { this.uploadedBy = uploadedBy; }
public Instant getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
}
@@ -78,5 +78,13 @@ public enum AuditEventType {
ASSEMBLY_INVITED,
ASSEMBLY_STARTED,
ASSEMBLY_COMPLETED,
ASSEMBLY_VOTE_RECORDED
ASSEMBLY_VOTE_RECORDED,
// Sprint 8 — Document events
DOCUMENT_UPLOADED,
DOCUMENT_DELETED,
// Sprint 8 — Board events
BOARD_MEMBER_ELECTED,
BOARD_MEMBER_REMOVED
}
@@ -0,0 +1,10 @@
package de.cannamanage.domain.enums;
/**
* Access levels for documents in the document archive.
* Controls visibility: ALL_MEMBERS = everyone, BOARD_ONLY = Vorstand only.
*/
public enum DocumentAccessLevel {
ALL_MEMBERS,
BOARD_ONLY
}
@@ -0,0 +1,14 @@
package de.cannamanage.domain.enums;
/**
* Categories for club documents in the document archive.
* Based on typical Vereinsverwaltung document types.
*/
public enum DocumentCategory {
SATZUNG,
PROTOKOLL,
VERTRAG,
VERSICHERUNG,
GENEHMIGUNG,
SONSTIGES
}
@@ -23,5 +23,7 @@ public enum NotificationType {
PAYMENT_RECEIVED,
// Sprint 8 — Assembly:
ASSEMBLY_INVITATION,
ASSEMBLY_REMINDER
ASSEMBLY_REMINDER,
// Sprint 8 — Board:
BOARD_TERM_EXPIRING
}
@@ -21,5 +21,6 @@ public enum StaffPermission {
// Sprint 8:
MANAGE_FINANCES,
VIEW_FINANCES,
MANAGE_ASSEMBLIES
MANAGE_ASSEMBLIES,
MANAGE_DOCUMENTS
}