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,100 @@
package de.cannamanage.api.controller;
import de.cannamanage.domain.entity.BoardMember;
import de.cannamanage.domain.entity.BoardPosition;
import de.cannamanage.service.BoardService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1")
public class BoardController {
private final BoardService boardService;
public BoardController(BoardService boardService) {
this.boardService = boardService;
}
// --- Positions ---
@PostMapping("/board/positions")
public ResponseEntity<BoardPosition> createPosition(
@RequestParam UUID clubId,
@RequestBody Map<String, Object> body) {
String title = (String) body.get("title");
String description = (String) body.get("description");
Integer sortOrder = body.containsKey("sortOrder") ? (Integer) body.get("sortOrder") : 0;
BoardPosition pos = boardService.createPosition(clubId, title, description, sortOrder);
return ResponseEntity.ok(pos);
}
@GetMapping("/board/positions")
public ResponseEntity<List<BoardPosition>> getPositions(@RequestParam UUID clubId) {
return ResponseEntity.ok(boardService.getPositions(clubId));
}
@PutMapping("/board/positions/{id}")
public ResponseEntity<BoardPosition> updatePosition(
@PathVariable UUID id,
@RequestBody Map<String, Object> body) {
String title = (String) body.get("title");
String description = (String) body.get("description");
Integer sortOrder = body.containsKey("sortOrder") ? (Integer) body.get("sortOrder") : null;
Boolean isActive = body.containsKey("isActive") ? (Boolean) body.get("isActive") : null;
BoardPosition pos = boardService.updatePosition(id, title, description, sortOrder, isActive);
return ResponseEntity.ok(pos);
}
// --- Board Members ---
@PostMapping("/board/members")
public ResponseEntity<BoardMember> electBoardMember(
@RequestParam UUID clubId,
@RequestBody Map<String, Object> body,
Principal principal) {
UUID positionId = UUID.fromString((String) body.get("positionId"));
UUID memberId = UUID.fromString((String) body.get("memberId"));
LocalDate electedAt = LocalDate.parse((String) body.get("electedAt"));
LocalDate termStart = LocalDate.parse((String) body.get("termStart"));
LocalDate termEnd = body.get("termEnd") != null ? LocalDate.parse((String) body.get("termEnd")) : null;
UUID assemblyId = body.get("assemblyId") != null ? UUID.fromString((String) body.get("assemblyId")) : null;
UUID userId = UUID.fromString(principal.getName());
BoardMember bm = boardService.electBoardMember(clubId, positionId, memberId,
electedAt, termStart, termEnd, assemblyId, userId);
return ResponseEntity.ok(bm);
}
@GetMapping("/board")
public ResponseEntity<List<BoardMember>> getCurrentBoard(@RequestParam UUID clubId) {
return ResponseEntity.ok(boardService.getCurrentBoard(clubId));
}
@GetMapping("/board/history")
public ResponseEntity<List<BoardMember>> getBoardHistory(@RequestParam UUID clubId) {
return ResponseEntity.ok(boardService.getBoardHistory(clubId));
}
@DeleteMapping("/board/members/{id}")
public ResponseEntity<Void> removeBoardMember(
@PathVariable UUID id,
@RequestParam UUID clubId,
Principal principal) {
UUID userId = UUID.fromString(principal.getName());
boardService.removeBoardMember(id, userId, clubId);
return ResponseEntity.noContent().build();
}
// Portal endpoint
@GetMapping("/portal/board")
public ResponseEntity<List<BoardMember>> getPortalBoard(@RequestParam UUID clubId) {
return ResponseEntity.ok(boardService.getCurrentBoard(clubId));
}
}
@@ -0,0 +1,84 @@
package de.cannamanage.api.controller;
import de.cannamanage.domain.entity.Document;
import de.cannamanage.domain.enums.DocumentAccessLevel;
import de.cannamanage.domain.enums.DocumentCategory;
import de.cannamanage.service.DocumentService;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.security.Principal;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1")
public class DocumentController {
private final DocumentService documentService;
public DocumentController(DocumentService documentService) {
this.documentService = documentService;
}
@PostMapping("/documents/upload")
public ResponseEntity<Document> uploadDocument(
@RequestParam UUID clubId,
@RequestParam String title,
@RequestParam DocumentCategory category,
@RequestParam(defaultValue = "ALL_MEMBERS") DocumentAccessLevel accessLevel,
@RequestParam(required = false) String description,
@RequestParam("file") MultipartFile file,
Principal principal) throws IOException {
UUID userId = UUID.fromString(principal.getName());
Document doc = documentService.uploadDocument(clubId, title, category, accessLevel, description, file, userId);
return ResponseEntity.ok(doc);
}
@GetMapping("/documents")
public ResponseEntity<List<Document>> listDocuments(
@RequestParam UUID clubId,
@RequestParam(required = false) DocumentCategory category,
@RequestParam(required = false) DocumentAccessLevel accessLevel) {
List<Document> docs = documentService.listDocuments(clubId, category, accessLevel);
return ResponseEntity.ok(docs);
}
@GetMapping("/documents/{id}/download")
public ResponseEntity<byte[]> downloadDocument(@PathVariable UUID id) throws IOException {
Document doc = documentService.getDocument(id);
byte[] content = documentService.downloadDocument(id);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + doc.getFilename() + "\"")
.contentType(MediaType.parseMediaType(doc.getContentType()))
.body(content);
}
@DeleteMapping("/documents/{id}")
public ResponseEntity<Void> deleteDocument(
@PathVariable UUID id,
@RequestParam UUID clubId,
Principal principal) throws IOException {
UUID userId = UUID.fromString(principal.getName());
documentService.deleteDocument(id, userId, clubId);
return ResponseEntity.noContent().build();
}
@GetMapping("/documents/usage")
public ResponseEntity<Map<String, Long>> getStorageUsage(@RequestParam UUID clubId) {
long usage = documentService.getStorageUsage(clubId);
return ResponseEntity.ok(Map.of("bytesUsed", usage));
}
// Portal endpoint — only ALL_MEMBERS documents
@GetMapping("/portal/documents")
public ResponseEntity<List<Document>> getPortalDocuments(@RequestParam UUID clubId) {
List<Document> docs = documentService.listDocuments(clubId, null, DocumentAccessLevel.ALL_MEMBERS);
return ResponseEntity.ok(docs);
}
}
@@ -0,0 +1,23 @@
-- V20: Document archive for club documents (Satzung, Protokolle, Verträge, etc.)
-- Legal basis: §22 KCanG (Dokumentationspflichten), §147 AO (Aufbewahrungspflichten)
CREATE TABLE documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
club_id UUID NOT NULL REFERENCES clubs(id),
title VARCHAR(300) NOT NULL,
category VARCHAR(50) NOT NULL,
filename VARCHAR(255) NOT NULL,
content_type VARCHAR(100) NOT NULL,
file_size BIGINT NOT NULL,
storage_path VARCHAR(500) NOT NULL,
access_level VARCHAR(20) NOT NULL DEFAULT 'ALL_MEMBERS',
description TEXT,
uploaded_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_documents_club ON documents(club_id);
CREATE INDEX idx_documents_category ON documents(club_id, category);
CREATE INDEX idx_documents_tenant ON documents(tenant_id);
@@ -0,0 +1,33 @@
-- V21: Board management (Vorstandsverwaltung)
-- Legal basis: §26 BGB (Vorstand), §27 BGB (Bestellung/Abberufung), §23 KCanG (Präventionsbeauftragter)
CREATE TABLE board_positions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
club_id UUID NOT NULL REFERENCES clubs(id),
title VARCHAR(100) NOT NULL,
description TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE board_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
club_id UUID NOT NULL REFERENCES clubs(id),
position_id UUID NOT NULL REFERENCES board_positions(id),
member_id UUID NOT NULL REFERENCES members(id),
elected_at DATE NOT NULL,
term_start DATE NOT NULL,
term_end DATE,
is_current BOOLEAN DEFAULT TRUE,
elected_in_assembly_id UUID REFERENCES assemblies(id),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_board_positions_club ON board_positions(club_id);
CREATE INDEX idx_board_positions_tenant ON board_positions(tenant_id);
CREATE INDEX idx_board_members_club ON board_members(club_id);
CREATE INDEX idx_board_members_tenant ON board_members(tenant_id);
CREATE INDEX idx_board_members_current ON board_members(club_id, is_current) WHERE is_current = TRUE;
@@ -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
}
+56 -1
View File
@@ -949,5 +949,60 @@
"paymentHistory": "Zahlungshistorie",
"noPayments": "Noch keine Zahlungen vorhanden"
}
},
"documents": {
"title": "Dokumentenarchiv",
"description": "Vereinsdokumente verwalten und archivieren",
"upload": "Hochladen",
"uploadDocument": "Dokument hochladen",
"documentTitle": "Titel",
"titlePlaceholder": "z.B. Vereinssatzung 2024",
"category": "Kategorie",
"selectCategory": "Kategorie wählen",
"accessLevel": "Zugriff",
"allMembers": "Alle Mitglieder",
"boardOnly": "Nur Vorstand",
"descriptionLabel": "Beschreibung",
"descriptionPlaceholder": "Optionale Beschreibung...",
"file": "Datei",
"fileHint": "PDF, DOCX, XLSX, PNG, JPG — max. 10 MB",
"uploadButton": "Dokument hochladen",
"allCategories": "Alle Kategorien",
"documentsCount": "Dokumente",
"name": "Name",
"access": "Zugriff",
"size": "Größe",
"date": "Datum",
"actions": "Aktionen"
},
"board": {
"title": "Vorstand",
"description": "Vorstandspositionen und -mitglieder verwalten",
"addPosition": "Position anlegen",
"electMember": "Mitglied wählen",
"createPosition": "Position erstellen",
"positionTitle": "Titel",
"positionTitlePlaceholder": "z.B. 1. Vorsitzender",
"positionDescription": "Beschreibung",
"positionDescPlaceholder": "z.B. Gesetzlicher Vertreter gem. §26 BGB",
"sortOrder": "Reihenfolge",
"save": "Speichern",
"electBoardMember": "Vorstandsmitglied wählen",
"position": "Position",
"selectPosition": "Position wählen",
"member": "Mitglied",
"selectMember": "Mitglied wählen",
"electedAt": "Gewählt am",
"termStart": "Amtszeit Beginn",
"termEnd": "Amtszeit Ende",
"confirmElection": "Wahl bestätigen",
"elected": "Gewählt",
"term": "Amtszeit",
"unlimited": "unbefristet",
"termExpiringSoon": "Läuft bald ab",
"termActive": "Aktiv",
"positions": "Positionen",
"active": "Aktiv",
"inactive": "Inaktiv"
}
}
}
+56 -1
View File
@@ -949,5 +949,60 @@
"paymentHistory": "Payment History",
"noPayments": "No payments yet"
}
},
"documents": {
"title": "Document Archive",
"description": "Manage and archive club documents",
"upload": "Upload",
"uploadDocument": "Upload Document",
"documentTitle": "Title",
"titlePlaceholder": "e.g. Club Bylaws 2024",
"category": "Category",
"selectCategory": "Select category",
"accessLevel": "Access",
"allMembers": "All Members",
"boardOnly": "Board Only",
"descriptionLabel": "Description",
"descriptionPlaceholder": "Optional description...",
"file": "File",
"fileHint": "PDF, DOCX, XLSX, PNG, JPG — max. 10 MB",
"uploadButton": "Upload Document",
"allCategories": "All Categories",
"documentsCount": "documents",
"name": "Name",
"access": "Access",
"size": "Size",
"date": "Date",
"actions": "Actions"
},
"board": {
"title": "Board",
"description": "Manage board positions and members",
"addPosition": "Add Position",
"electMember": "Elect Member",
"createPosition": "Create Position",
"positionTitle": "Title",
"positionTitlePlaceholder": "e.g. President",
"positionDescription": "Description",
"positionDescPlaceholder": "e.g. Legal representative",
"sortOrder": "Sort Order",
"save": "Save",
"electBoardMember": "Elect Board Member",
"position": "Position",
"selectPosition": "Select position",
"member": "Member",
"selectMember": "Select member",
"electedAt": "Elected on",
"termStart": "Term Start",
"termEnd": "Term End",
"confirmElection": "Confirm Election",
"elected": "Elected",
"term": "Term",
"unlimited": "unlimited",
"termExpiringSoon": "Expiring soon",
"termActive": "Active",
"positions": "Positions",
"active": "Active",
"inactive": "Inactive"
}
}
}
@@ -0,0 +1,348 @@
"use client"
import { useState } from "react"
import { useTranslations } from "next-intl"
import { Calendar, Edit, Plus, Shield, UserMinus, UserPlus } from "lucide-react"
import type { BoardMember, BoardPosition } from "@/services/board"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select } from "@/components/ui/select"
// Mock data
const mockPositions: BoardPosition[] = [
{
id: "1",
title: "1. Vorsitzender",
description: "Gesetzlicher Vertreter gem. §26 BGB",
sortOrder: 1,
isActive: true,
createdAt: "2024-01-01T00:00:00Z",
},
{
id: "2",
title: "2. Vorsitzender",
description: "Stellvertreter",
sortOrder: 2,
isActive: true,
createdAt: "2024-01-01T00:00:00Z",
},
{
id: "3",
title: "Kassenwart",
description: "Finanzverwaltung gem. §259 BGB",
sortOrder: 3,
isActive: true,
createdAt: "2024-01-01T00:00:00Z",
},
{
id: "4",
title: "Schriftführer",
description: "Protokollführung",
sortOrder: 4,
isActive: true,
createdAt: "2024-01-01T00:00:00Z",
},
{
id: "5",
title: "Präventionsbeauftragter",
description: "Gem. §23 KCanG",
sortOrder: 5,
isActive: true,
createdAt: "2024-01-01T00:00:00Z",
},
]
const mockBoardMembers: (BoardMember & {
memberName?: string
positionTitle?: string
})[] = [
{
id: "1",
clubId: "c1",
positionId: "1",
memberId: "m1",
electedAt: "2024-03-15",
termStart: "2024-04-01",
termEnd: "2026-03-31",
isCurrent: true,
electedInAssemblyId: "a1",
createdAt: "2024-03-15T00:00:00Z",
memberName: "Max Mustermann",
positionTitle: "1. Vorsitzender",
},
{
id: "2",
clubId: "c1",
positionId: "2",
memberId: "m2",
electedAt: "2024-03-15",
termStart: "2024-04-01",
termEnd: "2026-03-31",
isCurrent: true,
electedInAssemblyId: "a1",
createdAt: "2024-03-15T00:00:00Z",
memberName: "Anna Schmidt",
positionTitle: "2. Vorsitzender",
},
{
id: "3",
clubId: "c1",
positionId: "3",
memberId: "m3",
electedAt: "2024-03-15",
termStart: "2024-04-01",
termEnd: "2026-03-31",
isCurrent: true,
electedInAssemblyId: "a1",
createdAt: "2024-03-15T00:00:00Z",
memberName: "Peter Weber",
positionTitle: "Kassenwart",
},
{
id: "4",
clubId: "c1",
positionId: "4",
memberId: "m4",
electedAt: "2024-03-15",
termStart: "2024-04-01",
termEnd: "2026-03-31",
isCurrent: true,
electedInAssemblyId: "a1",
createdAt: "2024-03-15T00:00:00Z",
memberName: "Lisa Müller",
positionTitle: "Schriftführer",
},
{
id: "5",
clubId: "c1",
positionId: "5",
memberId: "m5",
electedAt: "2024-03-15",
termStart: "2024-04-01",
termEnd: null,
isCurrent: true,
electedInAssemblyId: "a1",
createdAt: "2024-03-15T00:00:00Z",
memberName: "Thomas Braun",
positionTitle: "Präventionsbeauftragter",
},
]
export default function BoardPage() {
const t = useTranslations("board")
const [positionDialogOpen, setPositionDialogOpen] = useState(false)
const [electDialogOpen, setElectDialogOpen] = useState(false)
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">{t("title")}</h1>
<p className="text-muted-foreground">{t("description")}</p>
</div>
<div className="flex gap-2">
<Dialog
open={positionDialogOpen}
onOpenChange={setPositionDialogOpen}
>
<DialogTrigger asChild>
<Button variant="outline">
<Plus className="mr-2 h-4 w-4" />
{t("addPosition")}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("createPosition")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="posTitle">{t("positionTitle")}</Label>
<Input
id="posTitle"
placeholder={t("positionTitlePlaceholder")}
/>
</div>
<div>
<Label htmlFor="posDesc">{t("positionDescription")}</Label>
<Input
id="posDesc"
placeholder={t("positionDescPlaceholder")}
/>
</div>
<div>
<Label htmlFor="sortOrder">{t("sortOrder")}</Label>
<Input id="sortOrder" type="number" defaultValue={0} />
</div>
<Button
className="w-full"
onClick={() => setPositionDialogOpen(false)}
>
{t("save")}
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={electDialogOpen} onOpenChange={setElectDialogOpen}>
<DialogTrigger asChild>
<Button>
<UserPlus className="mr-2 h-4 w-4" />
{t("electMember")}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("electBoardMember")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>{t("position")}</Label>
<Select>
<option value="">{t("selectPosition")}</option>
{mockPositions.map((pos) => (
<option key={pos.id} value={pos.id}>
{pos.title}
</option>
))}
</Select>
</div>
<div>
<Label>{t("member")}</Label>
<Select>
<option value="">{t("selectMember")}</option>
<option value="m1">Max Mustermann</option>
<option value="m2">Anna Schmidt</option>
<option value="m3">Peter Weber</option>
</Select>
</div>
<div>
<Label htmlFor="electedAt">{t("electedAt")}</Label>
<Input id="electedAt" type="date" />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="termStart">{t("termStart")}</Label>
<Input id="termStart" type="date" />
</div>
<div>
<Label htmlFor="termEnd">{t("termEnd")}</Label>
<Input id="termEnd" type="date" />
</div>
</div>
<Button
className="w-full"
onClick={() => setElectDialogOpen(false)}
>
{t("confirmElection")}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
</div>
{/* Current Board Members as cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{mockBoardMembers.map((bm) => (
<Card key={bm.id}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-primary" />
<CardTitle className="text-base">
{bm.positionTitle}
</CardTitle>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
>
<UserMinus className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2">
<p className="font-medium">{bm.memberName}</p>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Calendar className="h-3 w-3" />
<span>
{t("elected")}:{" "}
{new Date(bm.electedAt).toLocaleDateString("de-DE")}
</span>
</div>
<div className="text-sm text-muted-foreground">
{t("term")}:{" "}
{new Date(bm.termStart).toLocaleDateString("de-DE")}
{bm.termEnd
? ` ${new Date(bm.termEnd).toLocaleDateString("de-DE")}`
: ` ${t("unlimited")}`}
</div>
{bm.termEnd && (
<Badge
variant={
new Date(bm.termEnd) <
new Date(Date.now() + 90 * 24 * 60 * 60 * 1000)
? "destructive"
: "secondary"
}
>
{new Date(bm.termEnd) <
new Date(Date.now() + 90 * 24 * 60 * 60 * 1000)
? t("termExpiringSoon")
: t("termActive")}
</Badge>
)}
</div>
</CardContent>
</Card>
))}
</div>
{/* Positions overview */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Edit className="h-5 w-5" />
{t("positions")}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{mockPositions.map((pos) => (
<div
key={pos.id}
className="flex items-center justify-between rounded-lg border p-3"
>
<div>
<p className="font-medium">{pos.title}</p>
{pos.description && (
<p className="text-sm text-muted-foreground">
{pos.description}
</p>
)}
</div>
<Badge variant={pos.isActive ? "default" : "secondary"}>
{pos.isActive ? t("active") : t("inactive")}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
</div>
)
}
@@ -0,0 +1,328 @@
"use client"
import { useState } from "react"
import { categoryLabels, formatFileSize } from "@/services/documents"
import { useTranslations } from "next-intl"
import {
Download,
File,
FileSpreadsheet,
FileText,
Filter,
Image,
Trash2,
Upload,
} from "lucide-react"
import type { ClubDocument, DocumentCategory } from "@/services/documents"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select } from "@/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Textarea } from "@/components/ui/textarea"
// Mock data for development
const mockDocuments: ClubDocument[] = [
{
id: "1",
title: "Vereinssatzung 2024",
category: "SATZUNG",
filename: "satzung-2024.pdf",
contentType: "application/pdf",
fileSize: 245000,
accessLevel: "ALL_MEMBERS",
description: "Aktuelle Satzung des Vereins",
uploadedBy: "admin-1",
createdAt: "2024-01-15T10:00:00Z",
updatedAt: null,
},
{
id: "2",
title: "Protokoll MV März 2024",
category: "PROTOKOLL",
filename: "mv-protokoll-2024-03.pdf",
contentType: "application/pdf",
fileSize: 180000,
accessLevel: "ALL_MEMBERS",
description: "Protokoll der ordentlichen Mitgliederversammlung",
uploadedBy: "admin-1",
createdAt: "2024-03-20T14:00:00Z",
updatedAt: null,
},
{
id: "3",
title: "Mietvertrag Vereinsräume",
category: "VERTRAG",
filename: "mietvertrag-2023.pdf",
contentType: "application/pdf",
fileSize: 520000,
accessLevel: "BOARD_ONLY",
description: "Mietvertrag für die Vereinsräumlichkeiten",
uploadedBy: "admin-1",
createdAt: "2023-06-01T09:00:00Z",
updatedAt: null,
},
{
id: "4",
title: "KCanG-Genehmigung",
category: "GENEHMIGUNG",
filename: "genehmigung-kcanG.pdf",
contentType: "application/pdf",
fileSize: 310000,
accessLevel: "BOARD_ONLY",
description: "Anbaugenehmigung nach KCanG",
uploadedBy: "admin-1",
createdAt: "2024-04-01T08:00:00Z",
updatedAt: null,
},
{
id: "5",
title: "Haftpflichtversicherung",
category: "VERSICHERUNG",
filename: "haftpflicht-2024.pdf",
contentType: "application/pdf",
fileSize: 150000,
accessLevel: "BOARD_ONLY",
description: "Vereinshaftpflichtversicherung Police Nr. 12345",
uploadedBy: "admin-1",
createdAt: "2024-01-01T10:00:00Z",
updatedAt: null,
},
]
function getFileIcon(contentType: string) {
if (contentType === "application/pdf") return <FileText className="h-4 w-4" />
if (contentType.includes("spreadsheet"))
return <FileSpreadsheet className="h-4 w-4" />
if (contentType.startsWith("image/")) return <Image className="h-4 w-4" />
return <File className="h-4 w-4" />
}
function getCategoryBadgeVariant(
category: DocumentCategory
): "default" | "secondary" | "destructive" | "outline" {
const variants: Record<
DocumentCategory,
"default" | "secondary" | "destructive" | "outline"
> = {
SATZUNG: "default",
PROTOKOLL: "secondary",
VERTRAG: "outline",
VERSICHERUNG: "outline",
GENEHMIGUNG: "destructive",
SONSTIGES: "secondary",
}
return variants[category]
}
export default function DocumentsPage() {
const t = useTranslations("documents")
const [documents] = useState<ClubDocument[]>(mockDocuments)
const [uploadOpen, setUploadOpen] = useState(false)
const [filterCategory, setFilterCategory] = useState<string>("ALL")
const filteredDocuments =
filterCategory === "ALL"
? documents
: documents.filter((d) => d.category === filterCategory)
// Group by category
const grouped = filteredDocuments.reduce(
(acc, doc) => {
const cat = doc.category
if (!acc[cat]) acc[cat] = []
acc[cat].push(doc)
return acc
},
{} as Record<string, ClubDocument[]>
)
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">{t("title")}</h1>
<p className="text-muted-foreground">{t("description")}</p>
</div>
<Dialog open={uploadOpen} onOpenChange={setUploadOpen}>
<DialogTrigger asChild>
<Button>
<Upload className="mr-2 h-4 w-4" />
{t("upload")}
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("uploadDocument")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="title">{t("documentTitle")}</Label>
<Input id="title" placeholder={t("titlePlaceholder")} />
</div>
<div>
<Label htmlFor="category">{t("category")}</Label>
<Select id="category">
<option value="">{t("selectCategory")}</option>
{Object.entries(categoryLabels).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</Select>
</div>
<div>
<Label htmlFor="accessLevel">{t("accessLevel")}</Label>
<Select id="accessLevel" defaultValue="ALL_MEMBERS">
<option value="ALL_MEMBERS">{t("allMembers")}</option>
<option value="BOARD_ONLY">{t("boardOnly")}</option>
</Select>
</div>
<div>
<Label htmlFor="description">{t("descriptionLabel")}</Label>
<Textarea
id="description"
placeholder={t("descriptionPlaceholder")}
/>
</div>
<div>
<Label htmlFor="file">{t("file")}</Label>
<Input
id="file"
type="file"
accept=".pdf,.docx,.xlsx,.png,.jpg,.jpeg"
/>
<p className="mt-1 text-xs text-muted-foreground">
{t("fileHint")}
</p>
</div>
<Button className="w-full" onClick={() => setUploadOpen(false)}>
<Upload className="mr-2 h-4 w-4" />
{t("uploadButton")}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
{/* Filter */}
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-muted-foreground" />
<Select
className="w-48"
value={filterCategory}
onChange={(e) => setFilterCategory(e.target.value)}
>
<option value="ALL">{t("allCategories")}</option>
{Object.entries(categoryLabels).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</Select>
<span className="text-sm text-muted-foreground">
{filteredDocuments.length} {t("documentsCount")}
</span>
</div>
{/* Documents grouped by category */}
{Object.entries(grouped).map(([category, docs]) => (
<Card key={category}>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Badge
variant={getCategoryBadgeVariant(category as DocumentCategory)}
>
{categoryLabels[category as DocumentCategory]}
</Badge>
<span className="text-sm text-muted-foreground">
({docs.length})
</span>
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("name")}</TableHead>
<TableHead>{t("access")}</TableHead>
<TableHead>{t("size")}</TableHead>
<TableHead>{t("date")}</TableHead>
<TableHead className="text-right">{t("actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{docs.map((doc) => (
<TableRow key={doc.id}>
<TableCell>
<div className="flex items-center gap-2">
{getFileIcon(doc.contentType)}
<div>
<p className="font-medium">{doc.title}</p>
{doc.description && (
<p className="text-xs text-muted-foreground">
{doc.description}
</p>
)}
</div>
</div>
</TableCell>
<TableCell>
<Badge
variant={
doc.accessLevel === "BOARD_ONLY"
? "destructive"
: "secondary"
}
>
{doc.accessLevel === "BOARD_ONLY"
? t("boardOnly")
: t("allMembers")}
</Badge>
</TableCell>
<TableCell>{formatFileSize(doc.fileSize)}</TableCell>
<TableCell>
{new Date(doc.createdAt).toLocaleDateString("de-DE")}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon">
<Download className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
))}
</div>
)
}
@@ -49,6 +49,16 @@ export const navigationsData: NavigationType[] = [
href: "/assemblies",
iconName: "Gavel",
},
{
title: "Dokumente",
href: "/documents",
iconName: "FileArchive",
},
{
title: "Vorstand",
href: "/board",
iconName: "Shield",
},
{
title: "Kalender",
href: "/calendar",
@@ -0,0 +1,90 @@
import { apiClient } from "@/lib/api-client"
export interface BoardPosition {
id: string
title: string
description: string | null
sortOrder: number
isActive: boolean
createdAt: string
}
export interface BoardMember {
id: string
clubId: string
positionId: string
memberId: string
electedAt: string
termStart: string
termEnd: string | null
isCurrent: boolean
electedInAssemblyId: string | null
createdAt: string
}
export interface CreatePositionRequest {
title: string
description?: string
sortOrder?: number
}
export interface ElectBoardMemberRequest {
positionId: string
memberId: string
electedAt: string
termStart: string
termEnd?: string
assemblyId?: string
}
export function createPosition(
clubId: string,
data: CreatePositionRequest
): Promise<BoardPosition> {
return apiClient<BoardPosition>(`/board/positions?clubId=${clubId}`, {
method: "POST",
body: data,
})
}
export function getPositions(clubId: string): Promise<BoardPosition[]> {
return apiClient<BoardPosition[]>(`/board/positions?clubId=${clubId}`)
}
export function updatePosition(
id: string,
data: Partial<CreatePositionRequest & { isActive: boolean }>
): Promise<BoardPosition> {
return apiClient<BoardPosition>(`/board/positions/${id}`, {
method: "PUT",
body: data,
})
}
export function electBoardMember(
clubId: string,
data: ElectBoardMemberRequest
): Promise<BoardMember> {
return apiClient<BoardMember>(`/board/members?clubId=${clubId}`, {
method: "POST",
body: data,
})
}
export function getCurrentBoard(clubId: string): Promise<BoardMember[]> {
return apiClient<BoardMember[]>(`/board?clubId=${clubId}`)
}
export function getBoardHistory(clubId: string): Promise<BoardMember[]> {
return apiClient<BoardMember[]>(`/board/history?clubId=${clubId}`)
}
export function removeBoardMember(id: string, clubId: string): Promise<void> {
return apiClient<void>(`/board/members/${id}?clubId=${clubId}`, {
method: "DELETE",
})
}
export function getPortalBoard(clubId: string): Promise<BoardMember[]> {
return apiClient<BoardMember[]>(`/portal/board?clubId=${clubId}`)
}
@@ -0,0 +1,108 @@
import { apiClient } from "@/lib/api-client"
export type DocumentCategory =
| "SATZUNG"
| "PROTOKOLL"
| "VERTRAG"
| "VERSICHERUNG"
| "GENEHMIGUNG"
| "SONSTIGES"
export type DocumentAccessLevel = "ALL_MEMBERS" | "BOARD_ONLY"
export interface ClubDocument {
id: string
title: string
category: DocumentCategory
filename: string
contentType: string
fileSize: number
accessLevel: DocumentAccessLevel
description: string | null
uploadedBy: string
createdAt: string
updatedAt: string | null
}
export interface StorageUsage {
bytesUsed: number
}
export async function uploadDocument(
clubId: string,
title: string,
category: DocumentCategory,
accessLevel: DocumentAccessLevel,
description: string | null,
file: File
): Promise<ClubDocument> {
const formData = new FormData()
formData.append("file", file)
const params = new URLSearchParams({
clubId,
title,
category,
accessLevel,
})
if (description) params.append("description", description)
// Multipart upload — use raw fetch since apiClient assumes JSON
const res = await fetch(
`/api/backend/documents/upload?${params.toString()}`,
{
method: "POST",
body: formData,
}
)
if (!res.ok) throw new Error("Upload failed")
return res.json()
}
export function listDocuments(
clubId: string,
category?: DocumentCategory,
accessLevel?: DocumentAccessLevel
): Promise<ClubDocument[]> {
const params = new URLSearchParams({ clubId })
if (category) params.append("category", category)
if (accessLevel) params.append("accessLevel", accessLevel)
return apiClient<ClubDocument[]>(`/documents?${params.toString()}`)
}
export async function downloadDocument(id: string): Promise<Blob> {
const res = await fetch(`/api/backend/documents/${id}/download`)
if (!res.ok) throw new Error("Download failed")
return res.blob()
}
export function deleteDocument(id: string, clubId: string): Promise<void> {
return apiClient<void>(`/documents/${id}?clubId=${clubId}`, {
method: "DELETE",
})
}
export function getStorageUsage(clubId: string): Promise<StorageUsage> {
return apiClient<StorageUsage>(`/documents/usage?clubId=${clubId}`)
}
export function getPortalDocuments(clubId: string): Promise<ClubDocument[]> {
return apiClient<ClubDocument[]>(`/portal/documents?clubId=${clubId}`)
}
// Helper: format file size
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
// Category labels
export const categoryLabels: Record<DocumentCategory, string> = {
SATZUNG: "Satzung",
PROTOKOLL: "Protokoll",
VERTRAG: "Vertrag",
VERSICHERUNG: "Versicherung",
GENEHMIGUNG: "Genehmigung",
SONSTIGES: "Sonstiges",
}
@@ -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);
}