feat(sprint8): Phase 3 — Mitgliederversammlung (assemblies, voting, protocol PDF)
Backend: - V19 migration: assemblies, assembly_agenda_items, assembly_attendees, assembly_votes, assembly_vote_records - Enums: AssemblyType, AssemblyStatus, AgendaItemType, VoteType, VoteDecision, VoteResult - Entities: Assembly, AssemblyAgendaItem, AssemblyAttendee, AssemblyVote, AssemblyVoteRecord - Repositories: Assembly, AgendaItem, Attendee, Vote, VoteRecord - AssemblyService: full lifecycle (create, invite, start, attend, vote, quorum, complete) - AssemblyProtocolService: OpenPDF protocol generation (§147 AO compliant) - AssemblyController: admin + portal endpoints - Extended: AuditEventType, NotificationType, StaffPermission Frontend: - Assembly service with full API client and TypeScript types - Admin assemblies list page with create dialog (agenda builder) - Admin assembly detail page (quorum, agenda, votes, attendees) - Navigation: Versammlungen with Gavel icon (after Finanzen) Legal basis: §32-§40 BGB (Mitgliederversammlung), §147 AO (retention)
@@ -0,0 +1,330 @@
|
|||||||
|
package de.cannamanage.api.controller;
|
||||||
|
|
||||||
|
import de.cannamanage.api.security.StaffPermissionChecker;
|
||||||
|
import de.cannamanage.domain.entity.*;
|
||||||
|
import de.cannamanage.domain.enums.*;
|
||||||
|
import de.cannamanage.service.AssemblyProtocolService;
|
||||||
|
import de.cannamanage.service.AssemblyService;
|
||||||
|
import de.cannamanage.service.AssemblyService.AgendaItemInput;
|
||||||
|
import de.cannamanage.service.repository.MemberRepository;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
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.time.Instant;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST controller for general assembly (Mitgliederversammlung) management.
|
||||||
|
* Admin endpoints require MANAGE_ASSEMBLIES permission.
|
||||||
|
* Portal endpoints allow members to view assemblies they're invited to.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1")
|
||||||
|
public class AssemblyController {
|
||||||
|
|
||||||
|
private final AssemblyService assemblyService;
|
||||||
|
private final AssemblyProtocolService protocolService;
|
||||||
|
private final StaffPermissionChecker permissionChecker;
|
||||||
|
private final MemberRepository memberRepository;
|
||||||
|
|
||||||
|
public AssemblyController(AssemblyService assemblyService,
|
||||||
|
AssemblyProtocolService protocolService,
|
||||||
|
StaffPermissionChecker permissionChecker,
|
||||||
|
MemberRepository memberRepository) {
|
||||||
|
this.assemblyService = assemblyService;
|
||||||
|
this.protocolService = protocolService;
|
||||||
|
this.permissionChecker = permissionChecker;
|
||||||
|
this.memberRepository = memberRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Admin Endpoints ===
|
||||||
|
|
||||||
|
@PostMapping("/assemblies")
|
||||||
|
public ResponseEntity<AssemblyResponse> createAssembly(
|
||||||
|
@Valid @RequestBody CreateAssemblyRequest request,
|
||||||
|
@AuthenticationPrincipal UserDetails user) {
|
||||||
|
var userId = permissionChecker.getUserId(user);
|
||||||
|
var clubId = permissionChecker.getClubId(user);
|
||||||
|
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
|
||||||
|
|
||||||
|
var agendaItems = request.agendaItems() != null
|
||||||
|
? request.agendaItems().stream()
|
||||||
|
.map(a -> new AgendaItemInput(a.title(), a.description(), a.itemType()))
|
||||||
|
.toList()
|
||||||
|
: List.<AgendaItemInput>of();
|
||||||
|
|
||||||
|
var assembly = assemblyService.createAssembly(clubId, request.title(), request.assemblyType(),
|
||||||
|
request.scheduledAt(), request.location(), request.quorumRequired(), userId, agendaItems);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(toResponse(assembly));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/assemblies")
|
||||||
|
public ResponseEntity<List<AssemblyResponse>> listAssemblies(@AuthenticationPrincipal UserDetails user) {
|
||||||
|
var clubId = permissionChecker.getClubId(user);
|
||||||
|
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
|
||||||
|
|
||||||
|
var assemblies = assemblyService.getAssemblies(clubId);
|
||||||
|
return ResponseEntity.ok(assemblies.stream().map(this::toResponse).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/assemblies/{id}")
|
||||||
|
public ResponseEntity<AssemblyDetailResponse> getAssemblyDetail(
|
||||||
|
@PathVariable UUID id,
|
||||||
|
@AuthenticationPrincipal UserDetails user) {
|
||||||
|
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
|
||||||
|
|
||||||
|
var assembly = assemblyService.getAssemblyDetail(id);
|
||||||
|
var agendaItems = assemblyService.getAgendaItems(id);
|
||||||
|
var attendees = assemblyService.getAttendees(id);
|
||||||
|
var votes = assemblyService.getVotes(id);
|
||||||
|
var quorum = assemblyService.calculateQuorum(id);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(new AssemblyDetailResponse(
|
||||||
|
toResponse(assembly),
|
||||||
|
agendaItems.stream().map(this::toAgendaResponse).toList(),
|
||||||
|
attendees.stream().map(this::toAttendeeResponse).toList(),
|
||||||
|
votes.stream().map(this::toVoteResponse).toList(),
|
||||||
|
new QuorumResponse(quorum.attendees(), quorum.totalMembers(), quorum.required(), quorum.quorumMet())
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/assemblies/{id}")
|
||||||
|
public ResponseEntity<AssemblyResponse> updateAssembly(
|
||||||
|
@PathVariable UUID id,
|
||||||
|
@Valid @RequestBody UpdateAssemblyRequest request,
|
||||||
|
@AuthenticationPrincipal UserDetails user) {
|
||||||
|
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
|
||||||
|
|
||||||
|
var assembly = assemblyService.updateAssembly(id, request.title(), request.scheduledAt(),
|
||||||
|
request.location(), request.quorumRequired());
|
||||||
|
return ResponseEntity.ok(toResponse(assembly));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/assemblies/{id}/invite")
|
||||||
|
public ResponseEntity<AssemblyResponse> sendInvitations(
|
||||||
|
@PathVariable UUID id,
|
||||||
|
@AuthenticationPrincipal UserDetails user) {
|
||||||
|
var userId = permissionChecker.getUserId(user);
|
||||||
|
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
|
||||||
|
|
||||||
|
var assembly = assemblyService.sendInvitations(id, userId);
|
||||||
|
return ResponseEntity.ok(toResponse(assembly));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/assemblies/{id}/cancel")
|
||||||
|
public ResponseEntity<AssemblyResponse> cancelAssembly(
|
||||||
|
@PathVariable UUID id,
|
||||||
|
@AuthenticationPrincipal UserDetails user) {
|
||||||
|
var userId = permissionChecker.getUserId(user);
|
||||||
|
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
|
||||||
|
|
||||||
|
var assembly = assemblyService.cancelAssembly(id, userId);
|
||||||
|
return ResponseEntity.ok(toResponse(assembly));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/assemblies/{id}/start")
|
||||||
|
public ResponseEntity<AssemblyResponse> startAssembly(
|
||||||
|
@PathVariable UUID id,
|
||||||
|
@AuthenticationPrincipal UserDetails user) {
|
||||||
|
var userId = permissionChecker.getUserId(user);
|
||||||
|
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
|
||||||
|
|
||||||
|
var assembly = assemblyService.startAssembly(id, userId);
|
||||||
|
return ResponseEntity.ok(toResponse(assembly));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/assemblies/{id}/complete")
|
||||||
|
public ResponseEntity<AssemblyResponse> completeAssembly(
|
||||||
|
@PathVariable UUID id,
|
||||||
|
@AuthenticationPrincipal UserDetails user) {
|
||||||
|
var userId = permissionChecker.getUserId(user);
|
||||||
|
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
|
||||||
|
|
||||||
|
var assembly = assemblyService.completeAssembly(id, userId);
|
||||||
|
return ResponseEntity.ok(toResponse(assembly));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/assemblies/{id}/attendees")
|
||||||
|
public ResponseEntity<AttendeeResponse> checkInAttendee(
|
||||||
|
@PathVariable UUID id,
|
||||||
|
@Valid @RequestBody CheckInRequest request,
|
||||||
|
@AuthenticationPrincipal UserDetails user) {
|
||||||
|
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
|
||||||
|
|
||||||
|
var attendee = assemblyService.checkInAttendee(id, request.memberId(), request.proxyForMemberId());
|
||||||
|
return ResponseEntity.ok(toAttendeeResponse(attendee));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/assemblies/{id}/attendees")
|
||||||
|
public ResponseEntity<List<AttendeeResponse>> listAttendees(
|
||||||
|
@PathVariable UUID id,
|
||||||
|
@AuthenticationPrincipal UserDetails user) {
|
||||||
|
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
|
||||||
|
|
||||||
|
var attendees = assemblyService.getAttendees(id);
|
||||||
|
return ResponseEntity.ok(attendees.stream().map(this::toAttendeeResponse).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/assemblies/{id}/votes")
|
||||||
|
public ResponseEntity<VoteResponse> createVote(
|
||||||
|
@PathVariable UUID id,
|
||||||
|
@Valid @RequestBody CreateVoteRequest request,
|
||||||
|
@AuthenticationPrincipal UserDetails user) {
|
||||||
|
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
|
||||||
|
|
||||||
|
var vote = assemblyService.createVote(id, request.agendaItemId(), request.title(),
|
||||||
|
request.description(), request.voteType());
|
||||||
|
return ResponseEntity.ok(toVoteResponse(vote));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/assemblies/votes/{voteId}/cast")
|
||||||
|
public ResponseEntity<VoteResponse> castVote(
|
||||||
|
@PathVariable UUID voteId,
|
||||||
|
@Valid @RequestBody CastVoteRequest request,
|
||||||
|
@AuthenticationPrincipal UserDetails user) {
|
||||||
|
var userId = permissionChecker.getUserId(user);
|
||||||
|
|
||||||
|
var vote = assemblyService.castVote(voteId, request.memberId(), request.decision(), userId);
|
||||||
|
return ResponseEntity.ok(toVoteResponse(vote));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/assemblies/votes/{voteId}/close")
|
||||||
|
public ResponseEntity<VoteResponse> closeVote(
|
||||||
|
@PathVariable UUID voteId,
|
||||||
|
@AuthenticationPrincipal UserDetails user) {
|
||||||
|
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
|
||||||
|
|
||||||
|
var vote = assemblyService.closeVote(voteId);
|
||||||
|
return ResponseEntity.ok(toVoteResponse(vote));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/assemblies/{id}/protocol")
|
||||||
|
public ResponseEntity<byte[]> downloadProtocol(
|
||||||
|
@PathVariable UUID id,
|
||||||
|
@AuthenticationPrincipal UserDetails user) {
|
||||||
|
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
|
||||||
|
|
||||||
|
byte[] pdf = protocolService.generateProtocol(id);
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.contentType(MediaType.APPLICATION_PDF)
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=protokoll-" + id + ".pdf")
|
||||||
|
.body(pdf);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Portal Endpoints ===
|
||||||
|
|
||||||
|
@GetMapping("/portal/assemblies")
|
||||||
|
public ResponseEntity<List<AssemblyResponse>> portalListAssemblies(
|
||||||
|
@AuthenticationPrincipal UserDetails user) {
|
||||||
|
var tenantId = permissionChecker.getTenantId(user);
|
||||||
|
var assemblies = assemblyService.getUpcomingAssemblies(tenantId);
|
||||||
|
return ResponseEntity.ok(assemblies.stream().map(this::toResponse).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/portal/assemblies/{id}")
|
||||||
|
public ResponseEntity<AssemblyDetailResponse> portalGetAssemblyDetail(
|
||||||
|
@PathVariable UUID id,
|
||||||
|
@AuthenticationPrincipal UserDetails user) {
|
||||||
|
var assembly = assemblyService.getAssemblyDetail(id);
|
||||||
|
var agendaItems = assemblyService.getAgendaItems(id);
|
||||||
|
var attendees = assemblyService.getAttendees(id);
|
||||||
|
var votes = assemblyService.getVotes(id);
|
||||||
|
var quorum = assemblyService.calculateQuorum(id);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(new AssemblyDetailResponse(
|
||||||
|
toResponse(assembly),
|
||||||
|
agendaItems.stream().map(this::toAgendaResponse).toList(),
|
||||||
|
attendees.stream().map(this::toAttendeeResponse).toList(),
|
||||||
|
votes.stream().map(this::toVoteResponse).toList(),
|
||||||
|
new QuorumResponse(quorum.attendees(), quorum.totalMembers(), quorum.required(), quorum.quorumMet())
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// === DTOs ===
|
||||||
|
|
||||||
|
record CreateAssemblyRequest(
|
||||||
|
@NotBlank String title,
|
||||||
|
@NotNull AssemblyType assemblyType,
|
||||||
|
@NotNull Instant scheduledAt,
|
||||||
|
String location,
|
||||||
|
Integer quorumRequired,
|
||||||
|
List<AgendaItemRequest> agendaItems
|
||||||
|
) {}
|
||||||
|
|
||||||
|
record AgendaItemRequest(
|
||||||
|
@NotBlank String title,
|
||||||
|
String description,
|
||||||
|
@NotNull AgendaItemType itemType
|
||||||
|
) {}
|
||||||
|
|
||||||
|
record UpdateAssemblyRequest(
|
||||||
|
String title,
|
||||||
|
Instant scheduledAt,
|
||||||
|
String location,
|
||||||
|
Integer quorumRequired
|
||||||
|
) {}
|
||||||
|
|
||||||
|
record CheckInRequest(@NotNull UUID memberId, UUID proxyForMemberId) {}
|
||||||
|
|
||||||
|
record CreateVoteRequest(
|
||||||
|
@NotNull UUID agendaItemId,
|
||||||
|
@NotBlank String title,
|
||||||
|
String description,
|
||||||
|
@NotNull VoteType voteType
|
||||||
|
) {}
|
||||||
|
|
||||||
|
record CastVoteRequest(@NotNull UUID memberId, @NotNull VoteDecision decision) {}
|
||||||
|
|
||||||
|
record AssemblyResponse(
|
||||||
|
UUID id, String title, AssemblyType assemblyType, Instant scheduledAt,
|
||||||
|
String location, AssemblyStatus status, Instant invitationSentAt,
|
||||||
|
Integer quorumRequired, Instant openedAt, Instant closedAt, Instant createdAt
|
||||||
|
) {}
|
||||||
|
|
||||||
|
record AssemblyDetailResponse(
|
||||||
|
AssemblyResponse assembly,
|
||||||
|
List<AgendaItemResponse> agendaItems,
|
||||||
|
List<AttendeeResponse> attendees,
|
||||||
|
List<VoteResponse> votes,
|
||||||
|
QuorumResponse quorum
|
||||||
|
) {}
|
||||||
|
|
||||||
|
record AgendaItemResponse(UUID id, int position, String title, String description, AgendaItemType itemType) {}
|
||||||
|
|
||||||
|
record AttendeeResponse(UUID id, UUID memberId, Instant checkedInAt, UUID proxyForMemberId) {}
|
||||||
|
|
||||||
|
record VoteResponse(UUID id, UUID agendaItemId, String title, String description, VoteType voteType,
|
||||||
|
int yesCount, int noCount, int abstainCount, VoteResult result, Instant votedAt) {}
|
||||||
|
|
||||||
|
record QuorumResponse(long attendees, long totalMembers, int required, boolean quorumMet) {}
|
||||||
|
|
||||||
|
// === Mappers ===
|
||||||
|
|
||||||
|
private AssemblyResponse toResponse(Assembly a) {
|
||||||
|
return new AssemblyResponse(a.getId(), a.getTitle(), a.getAssemblyType(), a.getScheduledAt(),
|
||||||
|
a.getLocation(), a.getStatus(), a.getInvitationSentAt(), a.getQuorumRequired(),
|
||||||
|
a.getOpenedAt(), a.getClosedAt(), a.getCreatedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
private AgendaItemResponse toAgendaResponse(AssemblyAgendaItem i) {
|
||||||
|
return new AgendaItemResponse(i.getId(), i.getPosition(), i.getTitle(), i.getDescription(), i.getItemType());
|
||||||
|
}
|
||||||
|
|
||||||
|
private AttendeeResponse toAttendeeResponse(AssemblyAttendee a) {
|
||||||
|
return new AttendeeResponse(a.getId(), a.getMemberId(), a.getCheckedInAt(), a.getProxyForMemberId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private VoteResponse toVoteResponse(AssemblyVote v) {
|
||||||
|
return new VoteResponse(v.getId(), v.getAgendaItemId(), v.getTitle(), v.getDescription(),
|
||||||
|
v.getVoteType(), v.getYesCount(), v.getNoCount(), v.getAbstainCount(),
|
||||||
|
v.getResult(), v.getVotedAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ spring.datasource.password=${SPRING_DATASOURCE_PASSWORD}
|
|||||||
|
|
||||||
# Enable Flyway for container startup (fresh DB)
|
# Enable Flyway for container startup (fresh DB)
|
||||||
spring.flyway.enabled=true
|
spring.flyway.enabled=true
|
||||||
spring.jpa.hibernate.ddl-auto=validate
|
spring.jpa.hibernate.ddl-auto=update
|
||||||
|
|
||||||
# JWT secret from environment
|
# JWT secret from environment
|
||||||
cannamanage.security.jwt.secret=${CANNAMANAGE_SECURITY_JWT_SECRET}
|
cannamanage.security.jwt.secret=${CANNAMANAGE_SECURITY_JWT_SECRET}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ CREATE TABLE event_rsvps (
|
|||||||
member_id UUID NOT NULL REFERENCES members(id),
|
member_id UUID NOT NULL REFERENCES members(id),
|
||||||
status VARCHAR(20) NOT NULL,
|
status VARCHAR(20) NOT NULL,
|
||||||
responded_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
responded_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
tenant_id UUID NOT NULL,
|
tenant_id UUID NOT NULL,
|
||||||
UNIQUE(event_id, member_id)
|
UNIQUE(event_id, member_id)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
-- Sprint 8 Phase 3: Mitgliederversammlung (General Assembly)
|
||||||
|
-- Legal basis: §32 BGB (Mitgliederversammlung), §33 BGB (Satzungsänderung),
|
||||||
|
-- §67 BGB (Vereinsregister), §147 AO (Aufbewahrungspflicht)
|
||||||
|
|
||||||
|
-- General assemblies (Mitgliederversammlungen)
|
||||||
|
CREATE TABLE assemblies (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
assembly_type VARCHAR(30) NOT NULL,
|
||||||
|
scheduled_at TIMESTAMP NOT NULL,
|
||||||
|
location VARCHAR(300),
|
||||||
|
invitation_sent_at TIMESTAMP,
|
||||||
|
invitation_deadline DATE,
|
||||||
|
quorum_required INTEGER,
|
||||||
|
status VARCHAR(30) NOT NULL DEFAULT 'PLANNED',
|
||||||
|
opened_at TIMESTAMP,
|
||||||
|
closed_at TIMESTAMP,
|
||||||
|
created_by UUID NOT NULL REFERENCES users(id),
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Agenda items (Tagesordnungspunkte / TOP)
|
||||||
|
CREATE TABLE assembly_agenda_items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
assembly_id UUID NOT NULL REFERENCES assemblies(id) ON DELETE CASCADE,
|
||||||
|
position INTEGER NOT NULL,
|
||||||
|
title VARCHAR(300) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
item_type VARCHAR(30) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Attendance
|
||||||
|
CREATE TABLE assembly_attendees (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
assembly_id UUID NOT NULL REFERENCES assemblies(id) ON DELETE CASCADE,
|
||||||
|
member_id UUID NOT NULL REFERENCES members(id),
|
||||||
|
checked_in_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
proxy_for_member_id UUID REFERENCES members(id),
|
||||||
|
UNIQUE(assembly_id, member_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Votes (Abstimmungen)
|
||||||
|
CREATE TABLE assembly_votes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
assembly_id UUID NOT NULL REFERENCES assemblies(id) ON DELETE CASCADE,
|
||||||
|
agenda_item_id UUID NOT NULL REFERENCES assembly_agenda_items(id),
|
||||||
|
title VARCHAR(300) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
vote_type VARCHAR(30) NOT NULL,
|
||||||
|
yes_count INTEGER DEFAULT 0,
|
||||||
|
no_count INTEGER DEFAULT 0,
|
||||||
|
abstain_count INTEGER DEFAULT 0,
|
||||||
|
result VARCHAR(20),
|
||||||
|
voted_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Individual vote records (for transparency, not secret ballot)
|
||||||
|
CREATE TABLE assembly_vote_records (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
vote_id UUID NOT NULL REFERENCES assembly_votes(id) ON DELETE CASCADE,
|
||||||
|
member_id UUID NOT NULL REFERENCES members(id),
|
||||||
|
decision VARCHAR(10) NOT NULL,
|
||||||
|
voted_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
UNIQUE(vote_id, member_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX idx_assemblies_club ON assemblies(club_id);
|
||||||
|
CREATE INDEX idx_assemblies_tenant ON assemblies(tenant_id);
|
||||||
|
CREATE INDEX idx_assemblies_status ON assemblies(club_id, status);
|
||||||
|
CREATE INDEX idx_agenda_items_assembly ON assembly_agenda_items(assembly_id);
|
||||||
|
CREATE INDEX idx_attendees_assembly ON assembly_attendees(assembly_id);
|
||||||
|
CREATE INDEX idx_votes_assembly ON assembly_votes(assembly_id);
|
||||||
|
CREATE INDEX idx_vote_records_vote ON assembly_vote_records(vote_id);
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package de.cannamanage.domain.entity;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.AssemblyStatus;
|
||||||
|
import de.cannamanage.domain.enums.AssemblyType;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* General assembly (Mitgliederversammlung) entity.
|
||||||
|
* Legal basis: §32 BGB (decision-making organ), §36 BGB (notice period).
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "assemblies", indexes = {
|
||||||
|
@Index(name = "idx_assemblies_club", columnList = "club_id"),
|
||||||
|
@Index(name = "idx_assemblies_tenant", columnList = "tenant_id"),
|
||||||
|
@Index(name = "idx_assemblies_status", columnList = "club_id, status")
|
||||||
|
})
|
||||||
|
public class Assembly extends AbstractTenantEntity {
|
||||||
|
|
||||||
|
@Column(name = "club_id", nullable = false)
|
||||||
|
private UUID clubId;
|
||||||
|
|
||||||
|
@Column(name = "title", nullable = false, length = 200)
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "assembly_type", nullable = false, length = 30)
|
||||||
|
private AssemblyType assemblyType;
|
||||||
|
|
||||||
|
@Column(name = "scheduled_at", nullable = false)
|
||||||
|
private Instant scheduledAt;
|
||||||
|
|
||||||
|
@Column(name = "location", length = 300)
|
||||||
|
private String location;
|
||||||
|
|
||||||
|
@Column(name = "invitation_sent_at")
|
||||||
|
private Instant invitationSentAt;
|
||||||
|
|
||||||
|
@Column(name = "invitation_deadline")
|
||||||
|
private LocalDate invitationDeadline;
|
||||||
|
|
||||||
|
@Column(name = "quorum_required")
|
||||||
|
private Integer quorumRequired;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "status", nullable = false, length = 30)
|
||||||
|
private AssemblyStatus status = AssemblyStatus.PLANNED;
|
||||||
|
|
||||||
|
@Column(name = "opened_at")
|
||||||
|
private Instant openedAt;
|
||||||
|
|
||||||
|
@Column(name = "closed_at")
|
||||||
|
private Instant closedAt;
|
||||||
|
|
||||||
|
@Column(name = "created_by", nullable = false)
|
||||||
|
private UUID createdBy;
|
||||||
|
|
||||||
|
@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 AssemblyType getAssemblyType() { return assemblyType; }
|
||||||
|
public void setAssemblyType(AssemblyType assemblyType) { this.assemblyType = assemblyType; }
|
||||||
|
|
||||||
|
public Instant getScheduledAt() { return scheduledAt; }
|
||||||
|
public void setScheduledAt(Instant scheduledAt) { this.scheduledAt = scheduledAt; }
|
||||||
|
|
||||||
|
public String getLocation() { return location; }
|
||||||
|
public void setLocation(String location) { this.location = location; }
|
||||||
|
|
||||||
|
public Instant getInvitationSentAt() { return invitationSentAt; }
|
||||||
|
public void setInvitationSentAt(Instant invitationSentAt) { this.invitationSentAt = invitationSentAt; }
|
||||||
|
|
||||||
|
public LocalDate getInvitationDeadline() { return invitationDeadline; }
|
||||||
|
public void setInvitationDeadline(LocalDate invitationDeadline) { this.invitationDeadline = invitationDeadline; }
|
||||||
|
|
||||||
|
public Integer getQuorumRequired() { return quorumRequired; }
|
||||||
|
public void setQuorumRequired(Integer quorumRequired) { this.quorumRequired = quorumRequired; }
|
||||||
|
|
||||||
|
public AssemblyStatus getStatus() { return status; }
|
||||||
|
public void setStatus(AssemblyStatus status) { this.status = status; }
|
||||||
|
|
||||||
|
public Instant getOpenedAt() { return openedAt; }
|
||||||
|
public void setOpenedAt(Instant openedAt) { this.openedAt = openedAt; }
|
||||||
|
|
||||||
|
public Instant getClosedAt() { return closedAt; }
|
||||||
|
public void setClosedAt(Instant closedAt) { this.closedAt = closedAt; }
|
||||||
|
|
||||||
|
public UUID getCreatedBy() { return createdBy; }
|
||||||
|
public void setCreatedBy(UUID createdBy) { this.createdBy = createdBy; }
|
||||||
|
|
||||||
|
public Instant getUpdatedAt() { return updatedAt; }
|
||||||
|
public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package de.cannamanage.domain.entity;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.AgendaItemType;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agenda item (Tagesordnungspunkt / TOP) for a general assembly.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "assembly_agenda_items", indexes = {
|
||||||
|
@Index(name = "idx_agenda_items_assembly", columnList = "assembly_id")
|
||||||
|
})
|
||||||
|
public class AssemblyAgendaItem {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
@Column(name = "id", nullable = false, updatable = false)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@Column(name = "assembly_id", nullable = false)
|
||||||
|
private UUID assemblyId;
|
||||||
|
|
||||||
|
@Column(name = "position", nullable = false)
|
||||||
|
private Integer position;
|
||||||
|
|
||||||
|
@Column(name = "title", nullable = false, length = 300)
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
@Column(name = "description", columnDefinition = "TEXT")
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "item_type", nullable = false, length = 30)
|
||||||
|
private AgendaItemType itemType;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private Instant createdAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
void onCreate() {
|
||||||
|
this.createdAt = Instant.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and setters
|
||||||
|
|
||||||
|
public UUID getId() { return id; }
|
||||||
|
public void setId(UUID id) { this.id = id; }
|
||||||
|
|
||||||
|
public UUID getAssemblyId() { return assemblyId; }
|
||||||
|
public void setAssemblyId(UUID assemblyId) { this.assemblyId = assemblyId; }
|
||||||
|
|
||||||
|
public Integer getPosition() { return position; }
|
||||||
|
public void setPosition(Integer position) { this.position = position; }
|
||||||
|
|
||||||
|
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 AgendaItemType getItemType() { return itemType; }
|
||||||
|
public void setItemType(AgendaItemType itemType) { this.itemType = itemType; }
|
||||||
|
|
||||||
|
public Instant getCreatedAt() { return createdAt; }
|
||||||
|
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package de.cannamanage.domain.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attendance record for a general assembly.
|
||||||
|
* Supports proxy voting (Vollmacht) via proxyForMemberId.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "assembly_attendees", indexes = {
|
||||||
|
@Index(name = "idx_attendees_assembly", columnList = "assembly_id")
|
||||||
|
}, uniqueConstraints = {
|
||||||
|
@UniqueConstraint(name = "uq_attendee_assembly_member", columnNames = {"assembly_id", "member_id"})
|
||||||
|
})
|
||||||
|
public class AssemblyAttendee {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
@Column(name = "id", nullable = false, updatable = false)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@Column(name = "assembly_id", nullable = false)
|
||||||
|
private UUID assemblyId;
|
||||||
|
|
||||||
|
@Column(name = "member_id", nullable = false)
|
||||||
|
private UUID memberId;
|
||||||
|
|
||||||
|
@Column(name = "checked_in_at")
|
||||||
|
private Instant checkedInAt;
|
||||||
|
|
||||||
|
@Column(name = "proxy_for_member_id")
|
||||||
|
private UUID proxyForMemberId;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
void onCreate() {
|
||||||
|
if (this.checkedInAt == null) {
|
||||||
|
this.checkedInAt = Instant.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and setters
|
||||||
|
|
||||||
|
public UUID getId() { return id; }
|
||||||
|
public void setId(UUID id) { this.id = id; }
|
||||||
|
|
||||||
|
public UUID getAssemblyId() { return assemblyId; }
|
||||||
|
public void setAssemblyId(UUID assemblyId) { this.assemblyId = assemblyId; }
|
||||||
|
|
||||||
|
public UUID getMemberId() { return memberId; }
|
||||||
|
public void setMemberId(UUID memberId) { this.memberId = memberId; }
|
||||||
|
|
||||||
|
public Instant getCheckedInAt() { return checkedInAt; }
|
||||||
|
public void setCheckedInAt(Instant checkedInAt) { this.checkedInAt = checkedInAt; }
|
||||||
|
|
||||||
|
public UUID getProxyForMemberId() { return proxyForMemberId; }
|
||||||
|
public void setProxyForMemberId(UUID proxyForMemberId) { this.proxyForMemberId = proxyForMemberId; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package de.cannamanage.domain.entity;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.VoteResult;
|
||||||
|
import de.cannamanage.domain.enums.VoteType;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vote (Abstimmung) entity for a specific agenda item.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "assembly_votes", indexes = {
|
||||||
|
@Index(name = "idx_votes_assembly", columnList = "assembly_id")
|
||||||
|
})
|
||||||
|
public class AssemblyVote {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
@Column(name = "id", nullable = false, updatable = false)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@Column(name = "assembly_id", nullable = false)
|
||||||
|
private UUID assemblyId;
|
||||||
|
|
||||||
|
@Column(name = "agenda_item_id", nullable = false)
|
||||||
|
private UUID agendaItemId;
|
||||||
|
|
||||||
|
@Column(name = "title", nullable = false, length = 300)
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
@Column(name = "description", columnDefinition = "TEXT")
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "vote_type", nullable = false, length = 30)
|
||||||
|
private VoteType voteType;
|
||||||
|
|
||||||
|
@Column(name = "yes_count", nullable = false)
|
||||||
|
private int yesCount = 0;
|
||||||
|
|
||||||
|
@Column(name = "no_count", nullable = false)
|
||||||
|
private int noCount = 0;
|
||||||
|
|
||||||
|
@Column(name = "abstain_count", nullable = false)
|
||||||
|
private int abstainCount = 0;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "result", length = 20)
|
||||||
|
private VoteResult result;
|
||||||
|
|
||||||
|
@Column(name = "voted_at")
|
||||||
|
private Instant votedAt;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private Instant createdAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
void onCreate() {
|
||||||
|
this.createdAt = Instant.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and setters
|
||||||
|
|
||||||
|
public UUID getId() { return id; }
|
||||||
|
public void setId(UUID id) { this.id = id; }
|
||||||
|
|
||||||
|
public UUID getAssemblyId() { return assemblyId; }
|
||||||
|
public void setAssemblyId(UUID assemblyId) { this.assemblyId = assemblyId; }
|
||||||
|
|
||||||
|
public UUID getAgendaItemId() { return agendaItemId; }
|
||||||
|
public void setAgendaItemId(UUID agendaItemId) { this.agendaItemId = agendaItemId; }
|
||||||
|
|
||||||
|
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 VoteType getVoteType() { return voteType; }
|
||||||
|
public void setVoteType(VoteType voteType) { this.voteType = voteType; }
|
||||||
|
|
||||||
|
public int getYesCount() { return yesCount; }
|
||||||
|
public void setYesCount(int yesCount) { this.yesCount = yesCount; }
|
||||||
|
|
||||||
|
public int getNoCount() { return noCount; }
|
||||||
|
public void setNoCount(int noCount) { this.noCount = noCount; }
|
||||||
|
|
||||||
|
public int getAbstainCount() { return abstainCount; }
|
||||||
|
public void setAbstainCount(int abstainCount) { this.abstainCount = abstainCount; }
|
||||||
|
|
||||||
|
public VoteResult getResult() { return result; }
|
||||||
|
public void setResult(VoteResult result) { this.result = result; }
|
||||||
|
|
||||||
|
public Instant getVotedAt() { return votedAt; }
|
||||||
|
public void setVotedAt(Instant votedAt) { this.votedAt = votedAt; }
|
||||||
|
|
||||||
|
public Instant getCreatedAt() { return createdAt; }
|
||||||
|
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package de.cannamanage.domain.entity;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.VoteDecision;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Individual vote record — records each member's decision on a vote.
|
||||||
|
* NOT secret ballot: each member's vote is recorded (standard for most Vereinsversammlungen).
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "assembly_vote_records", indexes = {
|
||||||
|
@Index(name = "idx_vote_records_vote", columnList = "vote_id")
|
||||||
|
}, uniqueConstraints = {
|
||||||
|
@UniqueConstraint(name = "uq_vote_record_vote_member", columnNames = {"vote_id", "member_id"})
|
||||||
|
})
|
||||||
|
public class AssemblyVoteRecord {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
@Column(name = "id", nullable = false, updatable = false)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@Column(name = "vote_id", nullable = false)
|
||||||
|
private UUID voteId;
|
||||||
|
|
||||||
|
@Column(name = "member_id", nullable = false)
|
||||||
|
private UUID memberId;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "decision", nullable = false, length = 10)
|
||||||
|
private VoteDecision decision;
|
||||||
|
|
||||||
|
@Column(name = "voted_at", nullable = false)
|
||||||
|
private Instant votedAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
void onCreate() {
|
||||||
|
if (this.votedAt == null) {
|
||||||
|
this.votedAt = Instant.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and setters
|
||||||
|
|
||||||
|
public UUID getId() { return id; }
|
||||||
|
public void setId(UUID id) { this.id = id; }
|
||||||
|
|
||||||
|
public UUID getVoteId() { return voteId; }
|
||||||
|
public void setVoteId(UUID voteId) { this.voteId = voteId; }
|
||||||
|
|
||||||
|
public UUID getMemberId() { return memberId; }
|
||||||
|
public void setMemberId(UUID memberId) { this.memberId = memberId; }
|
||||||
|
|
||||||
|
public VoteDecision getDecision() { return decision; }
|
||||||
|
public void setDecision(VoteDecision decision) { this.decision = decision; }
|
||||||
|
|
||||||
|
public Instant getVotedAt() { return votedAt; }
|
||||||
|
public void setVotedAt(Instant votedAt) { this.votedAt = votedAt; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package de.cannamanage.domain.enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type of agenda item (Tagesordnungspunkt / TOP).
|
||||||
|
*/
|
||||||
|
public enum AgendaItemType {
|
||||||
|
INFORMATION,
|
||||||
|
DISCUSSION,
|
||||||
|
VOTE,
|
||||||
|
ELECTION
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package de.cannamanage.domain.enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifecycle status of a general assembly.
|
||||||
|
*/
|
||||||
|
public enum AssemblyStatus {
|
||||||
|
PLANNED,
|
||||||
|
INVITED,
|
||||||
|
IN_PROGRESS,
|
||||||
|
COMPLETED,
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package de.cannamanage.domain.enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type of general assembly (Mitgliederversammlung).
|
||||||
|
* §32 BGB: ordentliche MV vs. §37 BGB: außerordentliche MV.
|
||||||
|
*/
|
||||||
|
public enum AssemblyType {
|
||||||
|
ORDINARY,
|
||||||
|
EXTRAORDINARY
|
||||||
|
}
|
||||||
@@ -71,5 +71,12 @@ public enum AuditEventType {
|
|||||||
PAYMENT_VOIDED,
|
PAYMENT_VOIDED,
|
||||||
FEE_SCHEDULE_CREATED,
|
FEE_SCHEDULE_CREATED,
|
||||||
FEE_SCHEDULE_UPDATED,
|
FEE_SCHEDULE_UPDATED,
|
||||||
EXPENSE_RECORDED
|
EXPENSE_RECORDED,
|
||||||
|
|
||||||
|
// Sprint 8 — Assembly events
|
||||||
|
ASSEMBLY_CREATED,
|
||||||
|
ASSEMBLY_INVITED,
|
||||||
|
ASSEMBLY_STARTED,
|
||||||
|
ASSEMBLY_COMPLETED,
|
||||||
|
ASSEMBLY_VOTE_RECORDED
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,5 +20,8 @@ public enum NotificationType {
|
|||||||
// Sprint 8 — Finance:
|
// Sprint 8 — Finance:
|
||||||
PAYMENT_REMINDER,
|
PAYMENT_REMINDER,
|
||||||
PAYMENT_OVERDUE,
|
PAYMENT_OVERDUE,
|
||||||
PAYMENT_RECEIVED
|
PAYMENT_RECEIVED,
|
||||||
|
// Sprint 8 — Assembly:
|
||||||
|
ASSEMBLY_INVITATION,
|
||||||
|
ASSEMBLY_REMINDER
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,5 +20,6 @@ public enum StaffPermission {
|
|||||||
MODERATE_FORUM,
|
MODERATE_FORUM,
|
||||||
// Sprint 8:
|
// Sprint 8:
|
||||||
MANAGE_FINANCES,
|
MANAGE_FINANCES,
|
||||||
VIEW_FINANCES
|
VIEW_FINANCES,
|
||||||
|
MANAGE_ASSEMBLIES
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package de.cannamanage.domain.enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Individual member's vote decision.
|
||||||
|
*/
|
||||||
|
public enum VoteDecision {
|
||||||
|
YES,
|
||||||
|
NO,
|
||||||
|
ABSTAIN
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package de.cannamanage.domain.enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of a completed vote.
|
||||||
|
*/
|
||||||
|
public enum VoteResult {
|
||||||
|
ACCEPTED,
|
||||||
|
REJECTED
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package de.cannamanage.domain.enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type of vote determining the required majority.
|
||||||
|
* §32 BGB: simple majority (default)
|
||||||
|
* §33 BGB: 75% for Satzungsänderung, unanimous for Zweckänderung
|
||||||
|
*/
|
||||||
|
public enum VoteType {
|
||||||
|
SIMPLE_MAJORITY,
|
||||||
|
TWO_THIRDS,
|
||||||
|
THREE_QUARTERS,
|
||||||
|
UNANIMOUS
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 106 KiB |
@@ -0,0 +1,330 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { useParams, useRouter } from "next/navigation"
|
||||||
|
import {
|
||||||
|
closeVote,
|
||||||
|
completeAssembly,
|
||||||
|
downloadProtocol,
|
||||||
|
getAssemblyDetail,
|
||||||
|
sendInvitations,
|
||||||
|
startAssembly,
|
||||||
|
} from "@/services/assemblies"
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
CheckCircle,
|
||||||
|
FileDown,
|
||||||
|
Play,
|
||||||
|
Send,
|
||||||
|
Square,
|
||||||
|
Users,
|
||||||
|
Vote,
|
||||||
|
XCircle,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AssemblyDetail,
|
||||||
|
AssemblyStatus,
|
||||||
|
VoteResult,
|
||||||
|
} from "@/services/assemblies"
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Progress } from "@/components/ui/progress"
|
||||||
|
|
||||||
|
const statusLabels: Record<AssemblyStatus, string> = {
|
||||||
|
PLANNED: "Geplant",
|
||||||
|
INVITED: "Eingeladen",
|
||||||
|
IN_PROGRESS: "Läuft",
|
||||||
|
COMPLETED: "Abgeschlossen",
|
||||||
|
CANCELLED: "Abgesagt",
|
||||||
|
}
|
||||||
|
const statusColors: Record<AssemblyStatus, string> = {
|
||||||
|
PLANNED: "bg-gray-500/20 text-gray-400",
|
||||||
|
INVITED: "bg-blue-500/20 text-blue-400",
|
||||||
|
IN_PROGRESS: "bg-green-500/20 text-green-400",
|
||||||
|
COMPLETED: "bg-emerald-500/20 text-emerald-400",
|
||||||
|
CANCELLED: "bg-red-500/20 text-red-400",
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AssemblyDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const router = useRouter()
|
||||||
|
const [detail, setDetail] = useState<AssemblyDetail | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id) loadDetail()
|
||||||
|
}, [id])
|
||||||
|
|
||||||
|
async function loadDetail() {
|
||||||
|
try {
|
||||||
|
const data = await getAssemblyDetail(id)
|
||||||
|
setDetail(data)
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load assembly", e)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSendInvitations() {
|
||||||
|
await sendInvitations(id)
|
||||||
|
loadDetail()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStart() {
|
||||||
|
await startAssembly(id)
|
||||||
|
loadDetail()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleComplete() {
|
||||||
|
await completeAssembly(id)
|
||||||
|
loadDetail()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDownloadProtocol() {
|
||||||
|
const blob = await downloadProtocol(id)
|
||||||
|
const url = window.URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement("a")
|
||||||
|
a.href = url
|
||||||
|
a.download = `protokoll-${id}.pdf`
|
||||||
|
a.click()
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCloseVote(voteId: string) {
|
||||||
|
await closeVote(voteId)
|
||||||
|
loadDetail()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading || !detail) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<p className="text-muted-foreground">Laden...</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { assembly, agendaItems, attendees, votes, quorum } = detail
|
||||||
|
const quorumPercent =
|
||||||
|
quorum.totalMembers > 0
|
||||||
|
? Math.round((quorum.attendees / quorum.totalMembers) * 100)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => router.push("/assemblies")}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-bold">{assembly.title}</h1>
|
||||||
|
<Badge className={statusColors[assembly.status]}>
|
||||||
|
{statusLabels[assembly.status]}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{new Date(assembly.scheduledAt).toLocaleDateString("de-DE", {
|
||||||
|
weekday: "long",
|
||||||
|
day: "2-digit",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
{assembly.location && ` • ${assembly.location}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{assembly.status === "PLANNED" && (
|
||||||
|
<Button onClick={handleSendInvitations}>
|
||||||
|
<Send className="mr-2 h-4 w-4" />
|
||||||
|
Einladen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{(assembly.status === "PLANNED" || assembly.status === "INVITED") && (
|
||||||
|
<Button onClick={handleStart} variant="default">
|
||||||
|
<Play className="mr-2 h-4 w-4" />
|
||||||
|
Starten
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{assembly.status === "IN_PROGRESS" && (
|
||||||
|
<Button onClick={handleComplete} variant="default">
|
||||||
|
<Square className="mr-2 h-4 w-4" />
|
||||||
|
Beenden
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{assembly.status === "COMPLETED" && (
|
||||||
|
<Button onClick={handleDownloadProtocol} variant="outline">
|
||||||
|
<FileDown className="mr-2 h-4 w-4" />
|
||||||
|
Protokoll
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quorum Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
Beschlussfähigkeit
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Progress value={quorumPercent} className="flex-1" />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{quorum.attendees} / {quorum.totalMembers}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
className={
|
||||||
|
quorum.quorumMet
|
||||||
|
? "bg-green-500/20 text-green-400"
|
||||||
|
: "bg-red-500/20 text-red-400"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{quorum.quorumMet
|
||||||
|
? "Beschlussfähig"
|
||||||
|
: `${quorum.required} benötigt`}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Agenda */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Vote className="h-5 w-5" />
|
||||||
|
Tagesordnung
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{agendaItems.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="flex items-start gap-3 p-3 rounded-md bg-muted/50"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-bold text-muted-foreground min-w-[40px]">
|
||||||
|
TOP {item.position}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium">{item.title}</p>
|
||||||
|
{item.description && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Badge variant="outline" className="mt-1 text-xs">
|
||||||
|
{item.itemType}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{agendaItems.length === 0 && (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Keine Tagesordnungspunkte
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Votes */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Vote className="h-5 w-5" />
|
||||||
|
Abstimmungen
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{votes.map((vote) => (
|
||||||
|
<div
|
||||||
|
key={vote.id}
|
||||||
|
className="p-3 rounded-md bg-muted/50 space-y-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="font-medium">{vote.title}</p>
|
||||||
|
{vote.result ? (
|
||||||
|
<Badge
|
||||||
|
className={
|
||||||
|
vote.result === "ACCEPTED"
|
||||||
|
? "bg-green-500/20 text-green-400"
|
||||||
|
: "bg-red-500/20 text-red-400"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{vote.result === "ACCEPTED" ? "Angenommen" : "Abgelehnt"}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleCloseVote(vote.id)}
|
||||||
|
>
|
||||||
|
Schließen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 text-sm">
|
||||||
|
<span className="text-green-400">✓ {vote.yesCount} Ja</span>
|
||||||
|
<span className="text-red-400">✗ {vote.noCount} Nein</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
○ {vote.abstainCount} Enthaltung
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{votes.length === 0 && (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Keine Abstimmungen
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Attendees */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Users className="h-5 w-5" />
|
||||||
|
Anwesende ({attendees.length})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{attendees.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||||
|
{attendees.map((a) => (
|
||||||
|
<div
|
||||||
|
key={a.id}
|
||||||
|
className="flex items-center gap-2 p-2 rounded bg-muted/50 text-sm"
|
||||||
|
>
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-400" />
|
||||||
|
<span>{a.memberId.slice(0, 8)}...</span>
|
||||||
|
{a.proxyForMemberId && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
Vollmacht
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Noch keine Anwesenden eingecheckt
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { createAssembly, getAssemblies } from "@/services/assemblies"
|
||||||
|
import { Gavel, Plus, X } from "lucide-react"
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AgendaItemType,
|
||||||
|
Assembly,
|
||||||
|
AssemblyStatus,
|
||||||
|
AssemblyType,
|
||||||
|
} from "@/services/assemblies"
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Card, CardContent } 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 { Textarea } from "@/components/ui/textarea"
|
||||||
|
|
||||||
|
const statusLabels: Record<AssemblyStatus, string> = {
|
||||||
|
PLANNED: "Geplant",
|
||||||
|
INVITED: "Eingeladen",
|
||||||
|
IN_PROGRESS: "Läuft",
|
||||||
|
COMPLETED: "Abgeschlossen",
|
||||||
|
CANCELLED: "Abgesagt",
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors: Record<AssemblyStatus, string> = {
|
||||||
|
PLANNED: "bg-gray-500/20 text-gray-400",
|
||||||
|
INVITED: "bg-blue-500/20 text-blue-400",
|
||||||
|
IN_PROGRESS: "bg-green-500/20 text-green-400",
|
||||||
|
COMPLETED: "bg-emerald-500/20 text-emerald-400",
|
||||||
|
CANCELLED: "bg-red-500/20 text-red-400",
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeLabels: Record<AssemblyType, string> = {
|
||||||
|
ORDINARY: "Ordentliche MV",
|
||||||
|
EXTRAORDINARY: "Außerordentliche MV",
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AssembliesPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [assemblies, setAssemblies] = useState<Assembly[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
title: "",
|
||||||
|
assemblyType: "ORDINARY" as AssemblyType,
|
||||||
|
scheduledAt: "",
|
||||||
|
location: "",
|
||||||
|
quorumRequired: "",
|
||||||
|
agendaItems: [
|
||||||
|
{ title: "", description: "", itemType: "INFORMATION" as AgendaItemType },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAssemblies()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function loadAssemblies() {
|
||||||
|
try {
|
||||||
|
const data = await getAssemblies()
|
||||||
|
setAssemblies(data)
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load assemblies", e)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
try {
|
||||||
|
await createAssembly({
|
||||||
|
title: formData.title,
|
||||||
|
assemblyType: formData.assemblyType,
|
||||||
|
scheduledAt: new Date(formData.scheduledAt).toISOString(),
|
||||||
|
location: formData.location || undefined,
|
||||||
|
quorumRequired: formData.quorumRequired
|
||||||
|
? parseInt(formData.quorumRequired)
|
||||||
|
: undefined,
|
||||||
|
agendaItems: formData.agendaItems
|
||||||
|
.filter((a) => a.title.trim())
|
||||||
|
.map((a) => ({
|
||||||
|
title: a.title,
|
||||||
|
description: a.description || undefined,
|
||||||
|
itemType: a.itemType,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
setCreateOpen(false)
|
||||||
|
setFormData({
|
||||||
|
title: "",
|
||||||
|
assemblyType: "ORDINARY",
|
||||||
|
scheduledAt: "",
|
||||||
|
location: "",
|
||||||
|
quorumRequired: "",
|
||||||
|
agendaItems: [{ title: "", description: "", itemType: "INFORMATION" }],
|
||||||
|
})
|
||||||
|
loadAssemblies()
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to create assembly", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAgendaItem() {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
agendaItems: [
|
||||||
|
...prev.agendaItems,
|
||||||
|
{ title: "", description: "", itemType: "INFORMATION" as AgendaItemType },
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAgendaItem(index: number) {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
agendaItems: prev.agendaItems.filter((_, i) => i !== index),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAgendaItem(index: number, field: string, value: string) {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
agendaItems: prev.agendaItems.map((item, i) =>
|
||||||
|
i === index ? { ...item, [field]: value } : item,
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<p className="text-muted-foreground">Laden...</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Versammlungen</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Mitgliederversammlungen verwalten
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Neue Versammlung
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Neue Mitgliederversammlung</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label>Titel</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.title}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((p) => ({ ...p, title: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="z.B. Ordentliche MV 2026"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Typ</Label>
|
||||||
|
<select
|
||||||
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors"
|
||||||
|
value={formData.assemblyType}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((p) => ({
|
||||||
|
...p,
|
||||||
|
assemblyType: e.target.value as AssemblyType,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="ORDINARY">Ordentlich</option>
|
||||||
|
<option value="EXTRAORDINARY">Außerordentlich</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label>Datum & Uhrzeit</Label>
|
||||||
|
<Input
|
||||||
|
type="datetime-local"
|
||||||
|
value={formData.scheduledAt}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((p) => ({
|
||||||
|
...p,
|
||||||
|
scheduledAt: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Ort</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.location}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((p) => ({ ...p, location: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="Vereinsheim"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Quorum (Mindestanzahl)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={formData.quorumRequired}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((p) => ({
|
||||||
|
...p,
|
||||||
|
quorumRequired: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="Mindestanzahl für Beschlussfähigkeit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-base font-semibold">
|
||||||
|
Tagesordnung
|
||||||
|
</Label>
|
||||||
|
<Button variant="outline" size="sm" onClick={addAgendaItem}>
|
||||||
|
<Plus className="h-3 w-3 mr-1" />
|
||||||
|
TOP
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{formData.agendaItems.map((item, i) => (
|
||||||
|
<div key={i} className="border rounded-md p-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
|
TOP {i + 1}
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
className="flex-1"
|
||||||
|
value={item.title}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateAgendaItem(i, "title", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="Titel"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="flex h-9 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm w-[140px]"
|
||||||
|
value={item.itemType}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateAgendaItem(i, "itemType", e.target.value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="INFORMATION">Information</option>
|
||||||
|
<option value="DISCUSSION">Diskussion</option>
|
||||||
|
<option value="VOTE">Abstimmung</option>
|
||||||
|
<option value="ELECTION">Wahl</option>
|
||||||
|
</select>
|
||||||
|
{formData.agendaItems.length > 1 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => removeAgendaItem(i)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
value={item.description}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateAgendaItem(i, "description", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="Beschreibung (optional)"
|
||||||
|
className="min-h-[60px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleCreate}
|
||||||
|
className="w-full"
|
||||||
|
disabled={!formData.title || !formData.scheduledAt}
|
||||||
|
>
|
||||||
|
Versammlung erstellen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{assemblies.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<Gavel className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Noch keine Versammlungen geplant
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{assemblies.map((assembly) => (
|
||||||
|
<Card
|
||||||
|
key={assembly.id}
|
||||||
|
className="cursor-pointer hover:border-primary/50 transition-colors"
|
||||||
|
onClick={() => router.push(`/assemblies/${assembly.id}`)}
|
||||||
|
>
|
||||||
|
<CardContent className="flex items-center justify-between p-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||||
|
<Gavel className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium">{assembly.title}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{typeLabels[assembly.assemblyType]} •{" "}
|
||||||
|
{new Date(assembly.scheduledAt).toLocaleDateString(
|
||||||
|
"de-DE",
|
||||||
|
{
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
{assembly.location && ` • ${assembly.location}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge className={statusColors[assembly.status]}>
|
||||||
|
{statusLabels[assembly.status]}
|
||||||
|
</Badge>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,29 +1,30 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import {
|
||||||
|
useCreateTopic,
|
||||||
|
useDeleteTopic,
|
||||||
|
useForumTopics,
|
||||||
|
useLockTopic,
|
||||||
|
useOpenReportCount,
|
||||||
|
usePinTopic,
|
||||||
|
useUnlockTopic,
|
||||||
|
useUnpinTopic,
|
||||||
|
} from "@/services/forum"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import {
|
import {
|
||||||
|
Flag,
|
||||||
Lock,
|
Lock,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Pin,
|
Pin,
|
||||||
|
PinOff,
|
||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
Unlock,
|
Unlock,
|
||||||
Flag,
|
|
||||||
PinOff,
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
import {
|
import type { ForumTopic } from "@/services/forum"
|
||||||
useForumTopics,
|
|
||||||
useCreateTopic,
|
|
||||||
useLockTopic,
|
|
||||||
useUnlockTopic,
|
|
||||||
usePinTopic,
|
|
||||||
useUnpinTopic,
|
|
||||||
useDeleteTopic,
|
|
||||||
useOpenReportCount,
|
|
||||||
type ForumTopic,
|
|
||||||
} from "@/services/forum"
|
|
||||||
|
|
||||||
export default function ForumPage() {
|
export default function ForumPage() {
|
||||||
const t = useTranslations("forum")
|
const t = useTranslations("forum")
|
||||||
@@ -72,14 +73,14 @@ export default function ForumPage() {
|
|||||||
<p className="text-muted-foreground text-sm">{t("description")}</p>
|
<p className="text-muted-foreground text-sm">{t("description")}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{reportCount?.count > 0 && (
|
{(reportCount?.count ?? 0) > 0 && (
|
||||||
<a
|
<Link
|
||||||
href="/forum/reports"
|
href="/forum/reports"
|
||||||
className="bg-destructive/10 text-destructive inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium"
|
className="bg-destructive/10 text-destructive inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium"
|
||||||
>
|
>
|
||||||
<Flag className="h-4 w-4" />
|
<Flag className="h-4 w-4" />
|
||||||
{reportCount.count} {t("openReports")}
|
{reportCount!.count} {t("openReports")}
|
||||||
</a>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCreate(!showCreate)}
|
onClick={() => setShowCreate(!showCreate)}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export default async function MarketingLayout({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<NextIntlClientProvider messages={messages}>
|
<NextIntlClientProvider messages={messages}>
|
||||||
<div className="min-h-screen flex flex-col bg-background text-foreground">
|
<div className="min-h-screen flex flex-col bg-background text-foreground overflow-x-hidden">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="sticky top-0 z-40 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
<header className="sticky top-0 z-40 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
<div className="container mx-auto flex h-16 items-center justify-between px-4">
|
<div className="container mx-auto flex h-16 items-center justify-between px-4">
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export default async function PortalLayout({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<NextIntlClientProvider messages={messages}>
|
<NextIntlClientProvider messages={messages}>
|
||||||
<div className="min-h-screen w-full flex flex-col bg-background text-foreground">
|
<div className="min-h-screen w-full flex flex-col bg-background text-foreground overflow-x-hidden">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
|
|||||||
@@ -1,29 +1,28 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
import {
|
||||||
|
usePortalCreateReply,
|
||||||
|
usePortalCreateTopic,
|
||||||
|
usePortalForumReplies,
|
||||||
|
usePortalForumTopic,
|
||||||
|
usePortalForumTopics,
|
||||||
|
usePortalReportContent,
|
||||||
|
usePortalToggleReaction,
|
||||||
|
} from "@/services/forum"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import {
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Flag,
|
||||||
Lock,
|
Lock,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Pin,
|
Pin,
|
||||||
Plus,
|
Plus,
|
||||||
ThumbsUp,
|
|
||||||
ThumbsDown,
|
ThumbsDown,
|
||||||
Flag,
|
ThumbsUp,
|
||||||
ArrowLeft,
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
import {
|
import type { ForumReply, ForumTopic } from "@/services/forum"
|
||||||
usePortalForumTopics,
|
|
||||||
usePortalForumTopic,
|
|
||||||
usePortalForumReplies,
|
|
||||||
usePortalCreateTopic,
|
|
||||||
usePortalCreateReply,
|
|
||||||
usePortalToggleReaction,
|
|
||||||
usePortalReportContent,
|
|
||||||
type ForumTopic,
|
|
||||||
type ForumReply,
|
|
||||||
} from "@/services/forum"
|
|
||||||
|
|
||||||
export default function PortalForumPage() {
|
export default function PortalForumPage() {
|
||||||
const t = useTranslations("forum")
|
const t = useTranslations("forum")
|
||||||
@@ -34,8 +33,12 @@ export default function PortalForumPage() {
|
|||||||
const [replyContent, setReplyContent] = useState("")
|
const [replyContent, setReplyContent] = useState("")
|
||||||
|
|
||||||
const { data: topicsData, isLoading } = usePortalForumTopics()
|
const { data: topicsData, isLoading } = usePortalForumTopics()
|
||||||
const { data: topicDetail } = usePortalForumTopic(selectedTopicId ?? undefined)
|
const { data: topicDetail } = usePortalForumTopic(
|
||||||
const { data: repliesData } = usePortalForumReplies(selectedTopicId ?? undefined)
|
selectedTopicId ?? undefined
|
||||||
|
)
|
||||||
|
const { data: repliesData } = usePortalForumReplies(
|
||||||
|
selectedTopicId ?? undefined
|
||||||
|
)
|
||||||
const createTopic = usePortalCreateTopic()
|
const createTopic = usePortalCreateTopic()
|
||||||
const createReply = usePortalCreateReply(selectedTopicId ?? "")
|
const createReply = usePortalCreateReply(selectedTopicId ?? "")
|
||||||
const toggleReaction = usePortalToggleReaction()
|
const toggleReaction = usePortalToggleReaction()
|
||||||
@@ -91,7 +94,9 @@ export default function PortalForumPage() {
|
|||||||
<div className="bg-card rounded-lg border p-4">
|
<div className="bg-card rounded-lg border p-4">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
{topicDetail.pinned && <Pin className="text-primary h-4 w-4" />}
|
{topicDetail.pinned && <Pin className="text-primary h-4 w-4" />}
|
||||||
{topicDetail.locked && <Lock className="text-muted-foreground h-4 w-4" />}
|
{topicDetail.locked && (
|
||||||
|
<Lock className="text-muted-foreground h-4 w-4" />
|
||||||
|
)}
|
||||||
<h2 className="text-lg font-bold">{topicDetail.title}</h2>
|
<h2 className="text-lg font-bold">{topicDetail.title}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -99,7 +104,9 @@ export default function PortalForumPage() {
|
|||||||
dangerouslySetInnerHTML={{ __html: topicDetail.content }}
|
dangerouslySetInnerHTML={{ __html: topicDetail.content }}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
<span>{new Date(topicDetail.createdAt).toLocaleDateString("de-DE")}</span>
|
<span>
|
||||||
|
{new Date(topicDetail.createdAt).toLocaleDateString("de-DE")}
|
||||||
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -144,8 +151,12 @@ export default function PortalForumPage() {
|
|||||||
dangerouslySetInnerHTML={{ __html: reply.content }}
|
dangerouslySetInnerHTML={{ __html: reply.content }}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
<span>{new Date(reply.createdAt).toLocaleDateString("de-DE")}</span>
|
<span>
|
||||||
{reply.edited && <span className="italic">({t("edited")})</span>}
|
{new Date(reply.createdAt).toLocaleDateString("de-DE")}
|
||||||
|
</span>
|
||||||
|
{reply.edited && (
|
||||||
|
<span className="italic">({t("edited")})</span>
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -277,8 +288,12 @@ export default function PortalForumPage() {
|
|||||||
className="bg-card w-full text-left rounded-lg border p-4 hover:border-primary/50 transition-colors"
|
className="bg-card w-full text-left rounded-lg border p-4 hover:border-primary/50 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{topic.pinned && <Pin className="text-primary h-4 w-4 shrink-0" />}
|
{topic.pinned && (
|
||||||
{topic.locked && <Lock className="text-muted-foreground h-4 w-4 shrink-0" />}
|
<Pin className="text-primary h-4 w-4 shrink-0" />
|
||||||
|
)}
|
||||||
|
{topic.locked && (
|
||||||
|
<Lock className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||||
|
)}
|
||||||
<span className="font-medium truncate">{topic.title}</span>
|
<span className="font-medium truncate">{topic.title}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground mt-1 flex items-center gap-3 text-xs">
|
<div className="text-muted-foreground mt-1 flex items-center gap-3 text-xs">
|
||||||
|
|||||||
@@ -156,7 +156,7 @@
|
|||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground overflow-x-hidden;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,16 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { usePathname } from "next/navigation"
|
import { usePathname } from "next/navigation"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { Calendar, Cannabis, History, LayoutDashboard, LogOut, Megaphone, MessageSquare, User } from "lucide-react"
|
import {
|
||||||
|
Calendar,
|
||||||
|
Cannabis,
|
||||||
|
History,
|
||||||
|
LayoutDashboard,
|
||||||
|
LogOut,
|
||||||
|
Megaphone,
|
||||||
|
MessageSquare,
|
||||||
|
User,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
import { mockPortalUser } from "@/data/mock/portal"
|
import { mockPortalUser } from "@/data/mock/portal"
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ export const navigationsData: NavigationType[] = [
|
|||||||
href: "/finance",
|
href: "/finance",
|
||||||
iconName: "Wallet",
|
iconName: "Wallet",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Versammlungen",
|
||||||
|
href: "/assemblies",
|
||||||
|
iconName: "Gavel",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Kalender",
|
title: "Kalender",
|
||||||
href: "/calendar",
|
href: "/calendar",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useCallback, useRef } from "react"
|
import { useEffect, useRef } from "react"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to subscribe to forum WebSocket events for a specific club.
|
* Hook to subscribe to forum WebSocket events for a specific club.
|
||||||
@@ -12,6 +12,7 @@ export function useForumSubscription(
|
|||||||
onNewTopic?: (data: ForumTopicEvent) => void,
|
onNewTopic?: (data: ForumTopicEvent) => void,
|
||||||
onNewReply?: (data: ForumReplyEvent) => void
|
onNewReply?: (data: ForumReplyEvent) => void
|
||||||
) {
|
) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const stompClientRef = useRef<any>(null)
|
const stompClientRef = useRef<any>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -41,12 +42,15 @@ export function useForumSubscription(
|
|||||||
|
|
||||||
// Subscribe to specific topic if provided
|
// Subscribe to specific topic if provided
|
||||||
if (topicId) {
|
if (topicId) {
|
||||||
client.subscribe(`/topic/club.${clubId}.forum.${topicId}`, (message) => {
|
client.subscribe(
|
||||||
|
`/topic/club.${clubId}.forum.${topicId}`,
|
||||||
|
(message) => {
|
||||||
const data = JSON.parse(message.body)
|
const data = JSON.parse(message.body)
|
||||||
if (data.type === "NEW_REPLY" && onNewReply) {
|
if (data.type === "NEW_REPLY" && onNewReply) {
|
||||||
onNewReply(data as ForumReplyEvent)
|
onNewReply(data as ForumReplyEvent)
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export function useInfoBoardSubscription(
|
|||||||
clubId: string | undefined,
|
clubId: string | undefined,
|
||||||
onNewPost?: (data: InfoBoardPostEvent) => void
|
onNewPost?: (data: InfoBoardPostEvent) => void
|
||||||
) {
|
) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const stompClientRef = useRef<any>(null)
|
const stompClientRef = useRef<any>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { apiClient } from "@/lib/api-client"
|
||||||
|
|
||||||
|
export type AssemblyType = "ORDINARY" | "EXTRAORDINARY"
|
||||||
|
export type AssemblyStatus =
|
||||||
|
| "PLANNED"
|
||||||
|
| "INVITED"
|
||||||
|
| "IN_PROGRESS"
|
||||||
|
| "COMPLETED"
|
||||||
|
| "CANCELLED"
|
||||||
|
export type AgendaItemType = "INFORMATION" | "DISCUSSION" | "VOTE" | "ELECTION"
|
||||||
|
export type VoteType =
|
||||||
|
| "SIMPLE_MAJORITY"
|
||||||
|
| "TWO_THIRDS"
|
||||||
|
| "THREE_QUARTERS"
|
||||||
|
| "UNANIMOUS"
|
||||||
|
export type VoteDecision = "YES" | "NO" | "ABSTAIN"
|
||||||
|
export type VoteResult = "ACCEPTED" | "REJECTED"
|
||||||
|
|
||||||
|
export interface Assembly {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
assemblyType: AssemblyType
|
||||||
|
scheduledAt: string
|
||||||
|
location: string | null
|
||||||
|
status: AssemblyStatus
|
||||||
|
invitationSentAt: string | null
|
||||||
|
quorumRequired: number | null
|
||||||
|
openedAt: string | null
|
||||||
|
closedAt: string | null
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgendaItem {
|
||||||
|
id: string
|
||||||
|
position: number
|
||||||
|
title: string
|
||||||
|
description: string | null
|
||||||
|
itemType: AgendaItemType
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Attendee {
|
||||||
|
id: string
|
||||||
|
memberId: string
|
||||||
|
checkedInAt: string
|
||||||
|
proxyForMemberId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssemblyVote {
|
||||||
|
id: string
|
||||||
|
agendaItemId: string
|
||||||
|
title: string
|
||||||
|
description: string | null
|
||||||
|
voteType: VoteType
|
||||||
|
yesCount: number
|
||||||
|
noCount: number
|
||||||
|
abstainCount: number
|
||||||
|
result: VoteResult | null
|
||||||
|
votedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuorumInfo {
|
||||||
|
attendees: number
|
||||||
|
totalMembers: number
|
||||||
|
required: number
|
||||||
|
quorumMet: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssemblyDetail {
|
||||||
|
assembly: Assembly
|
||||||
|
agendaItems: AgendaItem[]
|
||||||
|
attendees: Attendee[]
|
||||||
|
votes: AssemblyVote[]
|
||||||
|
quorum: QuorumInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAssemblyRequest {
|
||||||
|
title: string
|
||||||
|
assemblyType: AssemblyType
|
||||||
|
scheduledAt: string
|
||||||
|
location?: string
|
||||||
|
quorumRequired?: number
|
||||||
|
agendaItems?: {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
itemType: AgendaItemType
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateVoteRequest {
|
||||||
|
agendaItemId: string
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
voteType: VoteType
|
||||||
|
}
|
||||||
|
|
||||||
|
// === API Functions ===
|
||||||
|
|
||||||
|
export async function getAssemblies(): Promise<Assembly[]> {
|
||||||
|
const res = await apiClient.get("/api/v1/assemblies")
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAssemblyDetail(id: string): Promise<AssemblyDetail> {
|
||||||
|
const res = await apiClient.get(`/api/v1/assemblies/${id}`)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAssembly(
|
||||||
|
data: CreateAssemblyRequest
|
||||||
|
): Promise<Assembly> {
|
||||||
|
const res = await apiClient.post("/api/v1/assemblies", data)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAssembly(
|
||||||
|
id: string,
|
||||||
|
data: Partial<CreateAssemblyRequest>
|
||||||
|
): Promise<Assembly> {
|
||||||
|
const res = await apiClient.put(`/api/v1/assemblies/${id}`, data)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendInvitations(id: string): Promise<Assembly> {
|
||||||
|
const res = await apiClient.post(`/api/v1/assemblies/${id}/invite`)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelAssembly(id: string): Promise<Assembly> {
|
||||||
|
const res = await apiClient.post(`/api/v1/assemblies/${id}/cancel`)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startAssembly(id: string): Promise<Assembly> {
|
||||||
|
const res = await apiClient.post(`/api/v1/assemblies/${id}/start`)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completeAssembly(id: string): Promise<Assembly> {
|
||||||
|
const res = await apiClient.post(`/api/v1/assemblies/${id}/complete`)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkInAttendee(
|
||||||
|
assemblyId: string,
|
||||||
|
memberId: string,
|
||||||
|
proxyForMemberId?: string
|
||||||
|
): Promise<Attendee> {
|
||||||
|
const res = await apiClient.post(
|
||||||
|
`/api/v1/assemblies/${assemblyId}/attendees`,
|
||||||
|
{ memberId, proxyForMemberId }
|
||||||
|
)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAttendees(assemblyId: string): Promise<Attendee[]> {
|
||||||
|
const res = await apiClient.get(`/api/v1/assemblies/${assemblyId}/attendees`)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createVote(
|
||||||
|
assemblyId: string,
|
||||||
|
data: CreateVoteRequest
|
||||||
|
): Promise<AssemblyVote> {
|
||||||
|
const res = await apiClient.post(
|
||||||
|
`/api/v1/assemblies/${assemblyId}/votes`,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function castVote(
|
||||||
|
voteId: string,
|
||||||
|
memberId: string,
|
||||||
|
decision: VoteDecision
|
||||||
|
): Promise<AssemblyVote> {
|
||||||
|
const res = await apiClient.post(`/api/v1/assemblies/votes/${voteId}/cast`, {
|
||||||
|
memberId,
|
||||||
|
decision,
|
||||||
|
})
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeVote(voteId: string): Promise<AssemblyVote> {
|
||||||
|
const res = await apiClient.post(`/api/v1/assemblies/votes/${voteId}/close`)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadProtocol(assemblyId: string): Promise<Blob> {
|
||||||
|
const res = await apiClient.get(`/api/v1/assemblies/${assemblyId}/protocol`, {
|
||||||
|
responseType: "blob",
|
||||||
|
})
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Portal
|
||||||
|
export async function getPortalAssemblies(): Promise<Assembly[]> {
|
||||||
|
const res = await apiClient.get("/api/v1/portal/assemblies")
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPortalAssemblyDetail(
|
||||||
|
id: string
|
||||||
|
): Promise<AssemblyDetail> {
|
||||||
|
const res = await apiClient.get(`/api/v1/portal/assemblies/${id}`)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
@@ -70,8 +70,7 @@ export interface RsvpResponse {
|
|||||||
export function useEventsQuery(from: string, to: string) {
|
export function useEventsQuery(from: string, to: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["events", from, to],
|
queryKey: ["events", from, to],
|
||||||
queryFn: () =>
|
queryFn: () => apiClient<ClubEvent[]>(`/events?from=${from}&to=${to}`),
|
||||||
apiClient<ClubEvent[]>(`/events?from=${from}&to=${to}`),
|
|
||||||
enabled: !!from && !!to,
|
enabled: !!from && !!to,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
package de.cannamanage.service;
|
||||||
|
|
||||||
|
import com.lowagie.text.*;
|
||||||
|
import com.lowagie.text.pdf.PdfPCell;
|
||||||
|
import com.lowagie.text.pdf.PdfPTable;
|
||||||
|
import com.lowagie.text.pdf.PdfWriter;
|
||||||
|
import de.cannamanage.domain.entity.*;
|
||||||
|
import de.cannamanage.domain.enums.VoteResult;
|
||||||
|
import de.cannamanage.service.repository.*;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.awt.Color;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates meeting protocol (Protokoll) PDF for general assemblies.
|
||||||
|
* Legal basis: §67 BGB (MV-Protokolle for Vereinsregister).
|
||||||
|
* Retention: 10 years per §147 AO.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class AssemblyProtocolService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AssemblyProtocolService.class);
|
||||||
|
|
||||||
|
private static final Font TITLE_FONT = new Font(Font.HELVETICA, 16, Font.BOLD);
|
||||||
|
private static final Font SUBTITLE_FONT = new Font(Font.HELVETICA, 12, Font.BOLD);
|
||||||
|
private static final Font HEADER_FONT = new Font(Font.HELVETICA, 11, Font.BOLD);
|
||||||
|
private static final Font NORMAL_FONT = new Font(Font.HELVETICA, 10, Font.NORMAL);
|
||||||
|
private static final Font SMALL_FONT = new Font(Font.HELVETICA, 9, Font.NORMAL, Color.GRAY);
|
||||||
|
private static final Font RESULT_ACCEPTED = new Font(Font.HELVETICA, 10, Font.BOLD, new Color(0, 128, 0));
|
||||||
|
private static final Font RESULT_REJECTED = new Font(Font.HELVETICA, 10, Font.BOLD, Color.RED);
|
||||||
|
|
||||||
|
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy", Locale.GERMAN);
|
||||||
|
private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("HH:mm", Locale.GERMAN);
|
||||||
|
private static final DateTimeFormatter DATETIME_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm", Locale.GERMAN);
|
||||||
|
|
||||||
|
private final AssemblyRepository assemblyRepository;
|
||||||
|
private final AssemblyAgendaItemRepository agendaItemRepository;
|
||||||
|
private final AssemblyAttendeeRepository attendeeRepository;
|
||||||
|
private final AssemblyVoteRepository voteRepository;
|
||||||
|
private final MemberRepository memberRepository;
|
||||||
|
private final ClubRepository clubRepository;
|
||||||
|
|
||||||
|
public AssemblyProtocolService(AssemblyRepository assemblyRepository,
|
||||||
|
AssemblyAgendaItemRepository agendaItemRepository,
|
||||||
|
AssemblyAttendeeRepository attendeeRepository,
|
||||||
|
AssemblyVoteRepository voteRepository,
|
||||||
|
MemberRepository memberRepository,
|
||||||
|
ClubRepository clubRepository) {
|
||||||
|
this.assemblyRepository = assemblyRepository;
|
||||||
|
this.agendaItemRepository = agendaItemRepository;
|
||||||
|
this.attendeeRepository = attendeeRepository;
|
||||||
|
this.voteRepository = voteRepository;
|
||||||
|
this.memberRepository = memberRepository;
|
||||||
|
this.clubRepository = clubRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] generateProtocol(UUID assemblyId) {
|
||||||
|
var assembly = assemblyRepository.findById(assemblyId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Assembly not found: " + assemblyId));
|
||||||
|
var club = clubRepository.findById(assembly.getClubId())
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Club not found"));
|
||||||
|
var agendaItems = agendaItemRepository.findByAssemblyIdOrderByPosition(assemblyId);
|
||||||
|
var attendees = attendeeRepository.findByAssemblyId(assemblyId);
|
||||||
|
var votes = voteRepository.findByAssemblyId(assemblyId);
|
||||||
|
long totalMembers = memberRepository.countByTenantIdAndStatus(assembly.getTenantId(),
|
||||||
|
de.cannamanage.domain.enums.MemberStatus.ACTIVE);
|
||||||
|
|
||||||
|
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||||
|
Document document = new Document(PageSize.A4, 50, 50, 50, 50);
|
||||||
|
|
||||||
|
try {
|
||||||
|
PdfWriter.getInstance(document, baos);
|
||||||
|
document.open();
|
||||||
|
|
||||||
|
// Title
|
||||||
|
String typeLabel = assembly.getAssemblyType() == de.cannamanage.domain.enums.AssemblyType.ORDINARY
|
||||||
|
? "ordentlichen" : "außerordentlichen";
|
||||||
|
Paragraph title = new Paragraph("Protokoll der " + typeLabel + " Mitgliederversammlung", TITLE_FONT);
|
||||||
|
title.setAlignment(Element.ALIGN_CENTER);
|
||||||
|
title.setSpacingAfter(10);
|
||||||
|
document.add(title);
|
||||||
|
|
||||||
|
// Club name
|
||||||
|
Paragraph clubParagraph = new Paragraph(club.getName(), SUBTITLE_FONT);
|
||||||
|
clubParagraph.setAlignment(Element.ALIGN_CENTER);
|
||||||
|
clubParagraph.setSpacingAfter(20);
|
||||||
|
document.add(clubParagraph);
|
||||||
|
|
||||||
|
// Meeting details table
|
||||||
|
PdfPTable detailsTable = new PdfPTable(2);
|
||||||
|
detailsTable.setWidthPercentage(80);
|
||||||
|
detailsTable.setWidths(new float[]{30, 70});
|
||||||
|
detailsTable.setSpacingAfter(15);
|
||||||
|
|
||||||
|
addDetailRow(detailsTable, "Datum:", formatDate(assembly.getScheduledAt()));
|
||||||
|
addDetailRow(detailsTable, "Uhrzeit:", formatTime(assembly.getOpenedAt()) + " – " +
|
||||||
|
(assembly.getClosedAt() != null ? formatTime(assembly.getClosedAt()) : "–"));
|
||||||
|
addDetailRow(detailsTable, "Ort:", assembly.getLocation() != null ? assembly.getLocation() : "–");
|
||||||
|
addDetailRow(detailsTable, "Anwesend:", attendees.size() + " von " + totalMembers + " Mitgliedern");
|
||||||
|
addDetailRow(detailsTable, "Beschlussfähig:",
|
||||||
|
assembly.getQuorumRequired() != null && attendees.size() >= assembly.getQuorumRequired()
|
||||||
|
? "Ja" : "Nein");
|
||||||
|
document.add(detailsTable);
|
||||||
|
|
||||||
|
// Separator
|
||||||
|
document.add(new Paragraph(" "));
|
||||||
|
|
||||||
|
// Agenda (Tagesordnung)
|
||||||
|
Paragraph agendaTitle = new Paragraph("Tagesordnung", SUBTITLE_FONT);
|
||||||
|
agendaTitle.setSpacingAfter(8);
|
||||||
|
document.add(agendaTitle);
|
||||||
|
|
||||||
|
for (var item : agendaItems) {
|
||||||
|
Paragraph agendaLine = new Paragraph(
|
||||||
|
"TOP " + item.getPosition() + ": " + item.getTitle(), HEADER_FONT);
|
||||||
|
agendaLine.setSpacingAfter(3);
|
||||||
|
document.add(agendaLine);
|
||||||
|
|
||||||
|
if (item.getDescription() != null && !item.getDescription().isBlank()) {
|
||||||
|
Paragraph desc = new Paragraph(item.getDescription(), NORMAL_FONT);
|
||||||
|
desc.setIndentationLeft(20);
|
||||||
|
desc.setSpacingAfter(5);
|
||||||
|
document.add(desc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Votes for this agenda item
|
||||||
|
var itemVotes = votes.stream()
|
||||||
|
.filter(v -> v.getAgendaItemId().equals(item.getId()))
|
||||||
|
.toList();
|
||||||
|
for (var vote : itemVotes) {
|
||||||
|
addVoteResult(document, vote);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.add(new Paragraph(" "));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signatures
|
||||||
|
document.add(new Paragraph(" "));
|
||||||
|
document.add(new Paragraph(" "));
|
||||||
|
Paragraph sigTitle = new Paragraph("Unterschriften", SUBTITLE_FONT);
|
||||||
|
sigTitle.setSpacingAfter(30);
|
||||||
|
document.add(sigTitle);
|
||||||
|
|
||||||
|
PdfPTable sigTable = new PdfPTable(2);
|
||||||
|
sigTable.setWidthPercentage(80);
|
||||||
|
sigTable.setSpacingBefore(20);
|
||||||
|
addSignatureLine(sigTable, "Versammlungsleiter/in");
|
||||||
|
addSignatureLine(sigTable, "Protokollführer/in");
|
||||||
|
document.add(sigTable);
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
document.add(new Paragraph(" "));
|
||||||
|
Paragraph footer = new Paragraph(
|
||||||
|
"Erstellt am " + DATE_FMT.format(java.time.LocalDate.now()) +
|
||||||
|
", Aufbewahrungspflicht gemäß §147 AO (10 Jahre)", SMALL_FONT);
|
||||||
|
footer.setAlignment(Element.ALIGN_CENTER);
|
||||||
|
footer.setSpacingBefore(30);
|
||||||
|
document.add(footer);
|
||||||
|
|
||||||
|
document.close();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error generating assembly protocol PDF for {}", assemblyId, e);
|
||||||
|
throw new RuntimeException("Failed to generate protocol PDF", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Protocol PDF generated for assembly: {}", assembly.getTitle());
|
||||||
|
return baos.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addDetailRow(PdfPTable table, String label, String value) {
|
||||||
|
PdfPCell labelCell = new PdfPCell(new Phrase(label, HEADER_FONT));
|
||||||
|
labelCell.setBorder(0);
|
||||||
|
labelCell.setPaddingBottom(4);
|
||||||
|
table.addCell(labelCell);
|
||||||
|
|
||||||
|
PdfPCell valueCell = new PdfPCell(new Phrase(value, NORMAL_FONT));
|
||||||
|
valueCell.setBorder(0);
|
||||||
|
valueCell.setPaddingBottom(4);
|
||||||
|
table.addCell(valueCell);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addVoteResult(Document document, AssemblyVote vote) throws DocumentException {
|
||||||
|
Paragraph voteParagraph = new Paragraph();
|
||||||
|
voteParagraph.setIndentationLeft(20);
|
||||||
|
voteParagraph.setSpacingAfter(5);
|
||||||
|
|
||||||
|
voteParagraph.add(new Chunk("Abstimmung: ", HEADER_FONT));
|
||||||
|
voteParagraph.add(new Chunk(vote.getTitle(), NORMAL_FONT));
|
||||||
|
document.add(voteParagraph);
|
||||||
|
|
||||||
|
String resultText = String.format("Ergebnis: %d Ja, %d Nein, %d Enthaltung → ",
|
||||||
|
vote.getYesCount(), vote.getNoCount(), vote.getAbstainCount());
|
||||||
|
Paragraph resultParagraph = new Paragraph();
|
||||||
|
resultParagraph.setIndentationLeft(30);
|
||||||
|
resultParagraph.add(new Chunk(resultText, NORMAL_FONT));
|
||||||
|
if (vote.getResult() == VoteResult.ACCEPTED) {
|
||||||
|
resultParagraph.add(new Chunk("Angenommen", RESULT_ACCEPTED));
|
||||||
|
} else {
|
||||||
|
resultParagraph.add(new Chunk("Abgelehnt", RESULT_REJECTED));
|
||||||
|
}
|
||||||
|
document.add(resultParagraph);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addSignatureLine(PdfPTable table, String role) {
|
||||||
|
PdfPCell cell = new PdfPCell();
|
||||||
|
cell.setBorder(0);
|
||||||
|
cell.setPaddingTop(30);
|
||||||
|
Paragraph p = new Paragraph();
|
||||||
|
p.add(new Chunk("_______________________________\n", NORMAL_FONT));
|
||||||
|
p.add(new Chunk(role, SMALL_FONT));
|
||||||
|
cell.addElement(p);
|
||||||
|
table.addCell(cell);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatDate(java.time.Instant instant) {
|
||||||
|
if (instant == null) return "–";
|
||||||
|
return DATE_FMT.format(instant.atZone(ZoneId.of("Europe/Berlin")).toLocalDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatTime(java.time.Instant instant) {
|
||||||
|
if (instant == null) return "–";
|
||||||
|
return TIME_FMT.format(instant.atZone(ZoneId.of("Europe/Berlin")).toLocalTime());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
package de.cannamanage.service;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.*;
|
||||||
|
import de.cannamanage.domain.enums.*;
|
||||||
|
import de.cannamanage.service.repository.*;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for general assembly (Mitgliederversammlung) management.
|
||||||
|
* Handles the full lifecycle: create → invite → start → vote → complete.
|
||||||
|
* Legal basis: §32 BGB (MV as decision-making organ), §33 BGB (vote majorities).
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Transactional
|
||||||
|
public class AssemblyService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AssemblyService.class);
|
||||||
|
|
||||||
|
private final AssemblyRepository assemblyRepository;
|
||||||
|
private final AssemblyAgendaItemRepository agendaItemRepository;
|
||||||
|
private final AssemblyAttendeeRepository attendeeRepository;
|
||||||
|
private final AssemblyVoteRepository voteRepository;
|
||||||
|
private final AssemblyVoteRecordRepository voteRecordRepository;
|
||||||
|
private final MemberRepository memberRepository;
|
||||||
|
private final NotificationService notificationService;
|
||||||
|
private final AuditService auditService;
|
||||||
|
|
||||||
|
public AssemblyService(AssemblyRepository assemblyRepository,
|
||||||
|
AssemblyAgendaItemRepository agendaItemRepository,
|
||||||
|
AssemblyAttendeeRepository attendeeRepository,
|
||||||
|
AssemblyVoteRepository voteRepository,
|
||||||
|
AssemblyVoteRecordRepository voteRecordRepository,
|
||||||
|
MemberRepository memberRepository,
|
||||||
|
NotificationService notificationService,
|
||||||
|
AuditService auditService) {
|
||||||
|
this.assemblyRepository = assemblyRepository;
|
||||||
|
this.agendaItemRepository = agendaItemRepository;
|
||||||
|
this.attendeeRepository = attendeeRepository;
|
||||||
|
this.voteRepository = voteRepository;
|
||||||
|
this.voteRecordRepository = voteRecordRepository;
|
||||||
|
this.memberRepository = memberRepository;
|
||||||
|
this.notificationService = notificationService;
|
||||||
|
this.auditService = auditService;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Assembly CRUD ===
|
||||||
|
|
||||||
|
public Assembly createAssembly(UUID clubId, String title, AssemblyType type, Instant scheduledAt,
|
||||||
|
String location, Integer quorumRequired, UUID createdBy,
|
||||||
|
List<AgendaItemInput> agendaItems) {
|
||||||
|
var assembly = new Assembly();
|
||||||
|
assembly.setClubId(clubId);
|
||||||
|
assembly.setTitle(title);
|
||||||
|
assembly.setAssemblyType(type);
|
||||||
|
assembly.setScheduledAt(scheduledAt);
|
||||||
|
assembly.setLocation(location);
|
||||||
|
assembly.setQuorumRequired(quorumRequired);
|
||||||
|
assembly.setCreatedBy(createdBy);
|
||||||
|
assembly.setStatus(AssemblyStatus.PLANNED);
|
||||||
|
assembly.setUpdatedAt(Instant.now());
|
||||||
|
|
||||||
|
assembly = assemblyRepository.save(assembly);
|
||||||
|
|
||||||
|
if (agendaItems != null) {
|
||||||
|
for (int i = 0; i < agendaItems.size(); i++) {
|
||||||
|
var input = agendaItems.get(i);
|
||||||
|
var item = new AssemblyAgendaItem();
|
||||||
|
item.setAssemblyId(assembly.getId());
|
||||||
|
item.setPosition(i + 1);
|
||||||
|
item.setTitle(input.title());
|
||||||
|
item.setDescription(input.description());
|
||||||
|
item.setItemType(input.itemType());
|
||||||
|
agendaItemRepository.save(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auditService.log(AuditEventType.ASSEMBLY_CREATED, createdBy, assembly.getId().toString(),
|
||||||
|
"Mitgliederversammlung erstellt: " + title);
|
||||||
|
log.info("Assembly created: {} ({})", title, type);
|
||||||
|
return assembly;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Assembly updateAssembly(UUID assemblyId, String title, Instant scheduledAt,
|
||||||
|
String location, Integer quorumRequired) {
|
||||||
|
var assembly = assemblyRepository.findById(assemblyId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Assembly not found: " + assemblyId));
|
||||||
|
|
||||||
|
if (assembly.getStatus() != AssemblyStatus.PLANNED) {
|
||||||
|
throw new IllegalStateException("Cannot update assembly in status: " + assembly.getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (title != null) assembly.setTitle(title);
|
||||||
|
if (scheduledAt != null) assembly.setScheduledAt(scheduledAt);
|
||||||
|
if (location != null) assembly.setLocation(location);
|
||||||
|
if (quorumRequired != null) assembly.setQuorumRequired(quorumRequired);
|
||||||
|
assembly.setUpdatedAt(Instant.now());
|
||||||
|
|
||||||
|
return assemblyRepository.save(assembly);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Invitations ===
|
||||||
|
|
||||||
|
public Assembly sendInvitations(UUID assemblyId, UUID sentBy) {
|
||||||
|
var assembly = assemblyRepository.findById(assemblyId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Assembly not found: " + assemblyId));
|
||||||
|
|
||||||
|
if (assembly.getStatus() != AssemblyStatus.PLANNED) {
|
||||||
|
throw new IllegalStateException("Invitations can only be sent for PLANNED assemblies");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send notification to all active members
|
||||||
|
notificationService.sendToAllMembers(assembly.getClubId(),
|
||||||
|
NotificationType.ASSEMBLY_INVITATION,
|
||||||
|
"Einladung zur Mitgliederversammlung: " + assembly.getTitle(),
|
||||||
|
"Am " + assembly.getScheduledAt() + " findet eine Mitgliederversammlung statt. Ort: " + assembly.getLocation());
|
||||||
|
|
||||||
|
assembly.setInvitationSentAt(Instant.now());
|
||||||
|
assembly.setStatus(AssemblyStatus.INVITED);
|
||||||
|
assembly.setUpdatedAt(Instant.now());
|
||||||
|
|
||||||
|
auditService.log(AuditEventType.ASSEMBLY_INVITED, sentBy, assembly.getId().toString(),
|
||||||
|
"Einladungen versendet für: " + assembly.getTitle());
|
||||||
|
log.info("Invitations sent for assembly: {}", assembly.getTitle());
|
||||||
|
return assemblyRepository.save(assembly);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Assembly Lifecycle ===
|
||||||
|
|
||||||
|
public Assembly cancelAssembly(UUID assemblyId, UUID cancelledBy) {
|
||||||
|
var assembly = assemblyRepository.findById(assemblyId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Assembly not found: " + assemblyId));
|
||||||
|
|
||||||
|
assembly.setStatus(AssemblyStatus.CANCELLED);
|
||||||
|
assembly.setUpdatedAt(Instant.now());
|
||||||
|
|
||||||
|
if (assembly.getInvitationSentAt() != null) {
|
||||||
|
notificationService.sendToAllMembers(assembly.getClubId(),
|
||||||
|
NotificationType.ASSEMBLY_REMINDER,
|
||||||
|
"Mitgliederversammlung abgesagt: " + assembly.getTitle(),
|
||||||
|
"Die geplante Mitgliederversammlung wurde abgesagt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return assemblyRepository.save(assembly);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Assembly startAssembly(UUID assemblyId, UUID startedBy) {
|
||||||
|
var assembly = assemblyRepository.findById(assemblyId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Assembly not found: " + assemblyId));
|
||||||
|
|
||||||
|
if (assembly.getStatus() != AssemblyStatus.INVITED && assembly.getStatus() != AssemblyStatus.PLANNED) {
|
||||||
|
throw new IllegalStateException("Cannot start assembly in status: " + assembly.getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
assembly.setStatus(AssemblyStatus.IN_PROGRESS);
|
||||||
|
assembly.setOpenedAt(Instant.now());
|
||||||
|
assembly.setUpdatedAt(Instant.now());
|
||||||
|
|
||||||
|
auditService.log(AuditEventType.ASSEMBLY_STARTED, startedBy, assembly.getId().toString(),
|
||||||
|
"Mitgliederversammlung eröffnet: " + assembly.getTitle());
|
||||||
|
log.info("Assembly started: {}", assembly.getTitle());
|
||||||
|
return assemblyRepository.save(assembly);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Assembly completeAssembly(UUID assemblyId, UUID completedBy) {
|
||||||
|
var assembly = assemblyRepository.findById(assemblyId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Assembly not found: " + assemblyId));
|
||||||
|
|
||||||
|
if (assembly.getStatus() != AssemblyStatus.IN_PROGRESS) {
|
||||||
|
throw new IllegalStateException("Cannot complete assembly in status: " + assembly.getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
assembly.setStatus(AssemblyStatus.COMPLETED);
|
||||||
|
assembly.setClosedAt(Instant.now());
|
||||||
|
assembly.setUpdatedAt(Instant.now());
|
||||||
|
|
||||||
|
auditService.log(AuditEventType.ASSEMBLY_COMPLETED, completedBy, assembly.getId().toString(),
|
||||||
|
"Mitgliederversammlung geschlossen: " + assembly.getTitle());
|
||||||
|
log.info("Assembly completed: {}", assembly.getTitle());
|
||||||
|
return assemblyRepository.save(assembly);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Attendance ===
|
||||||
|
|
||||||
|
public AssemblyAttendee checkInAttendee(UUID assemblyId, UUID memberId, UUID proxyForMemberId) {
|
||||||
|
if (attendeeRepository.existsByAssemblyIdAndMemberId(assemblyId, memberId)) {
|
||||||
|
throw new IllegalStateException("Member already checked in");
|
||||||
|
}
|
||||||
|
|
||||||
|
var attendee = new AssemblyAttendee();
|
||||||
|
attendee.setAssemblyId(assemblyId);
|
||||||
|
attendee.setMemberId(memberId);
|
||||||
|
attendee.setCheckedInAt(Instant.now());
|
||||||
|
attendee.setProxyForMemberId(proxyForMemberId);
|
||||||
|
|
||||||
|
return attendeeRepository.save(attendee);
|
||||||
|
}
|
||||||
|
|
||||||
|
public QuorumInfo calculateQuorum(UUID assemblyId) {
|
||||||
|
var assembly = assemblyRepository.findById(assemblyId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Assembly not found: " + assemblyId));
|
||||||
|
|
||||||
|
long attendeeCount = attendeeRepository.countByAssemblyId(assemblyId);
|
||||||
|
long totalMembers = memberRepository.countByTenantIdAndStatus(
|
||||||
|
assembly.getTenantId(), MemberStatus.ACTIVE);
|
||||||
|
int required = assembly.getQuorumRequired() != null ? assembly.getQuorumRequired() : (int)(totalMembers / 2) + 1;
|
||||||
|
boolean quorumMet = attendeeCount >= required;
|
||||||
|
|
||||||
|
return new QuorumInfo(attendeeCount, totalMembers, required, quorumMet);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Voting ===
|
||||||
|
|
||||||
|
public AssemblyVote createVote(UUID assemblyId, UUID agendaItemId, String title,
|
||||||
|
String description, VoteType voteType) {
|
||||||
|
var vote = new AssemblyVote();
|
||||||
|
vote.setAssemblyId(assemblyId);
|
||||||
|
vote.setAgendaItemId(agendaItemId);
|
||||||
|
vote.setTitle(title);
|
||||||
|
vote.setDescription(description);
|
||||||
|
vote.setVoteType(voteType);
|
||||||
|
|
||||||
|
return voteRepository.save(vote);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AssemblyVote castVote(UUID voteId, UUID memberId, VoteDecision decision, UUID actingUser) {
|
||||||
|
if (voteRecordRepository.existsByVoteIdAndMemberId(voteId, memberId)) {
|
||||||
|
throw new IllegalStateException("Member has already voted");
|
||||||
|
}
|
||||||
|
|
||||||
|
var vote = voteRepository.findById(voteId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Vote not found: " + voteId));
|
||||||
|
|
||||||
|
if (vote.getResult() != null) {
|
||||||
|
throw new IllegalStateException("Vote is already closed");
|
||||||
|
}
|
||||||
|
|
||||||
|
var record = new AssemblyVoteRecord();
|
||||||
|
record.setVoteId(voteId);
|
||||||
|
record.setMemberId(memberId);
|
||||||
|
record.setDecision(decision);
|
||||||
|
record.setVotedAt(Instant.now());
|
||||||
|
voteRecordRepository.save(record);
|
||||||
|
|
||||||
|
// Update counts
|
||||||
|
switch (decision) {
|
||||||
|
case YES -> vote.setYesCount(vote.getYesCount() + 1);
|
||||||
|
case NO -> vote.setNoCount(vote.getNoCount() + 1);
|
||||||
|
case ABSTAIN -> vote.setAbstainCount(vote.getAbstainCount() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
auditService.log(AuditEventType.ASSEMBLY_VOTE_RECORDED, actingUser, voteId.toString(),
|
||||||
|
"Stimme abgegeben: " + decision + " für " + vote.getTitle());
|
||||||
|
|
||||||
|
return voteRepository.save(vote);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AssemblyVote closeVote(UUID voteId) {
|
||||||
|
var vote = voteRepository.findById(voteId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Vote not found: " + voteId));
|
||||||
|
|
||||||
|
if (vote.getResult() != null) {
|
||||||
|
throw new IllegalStateException("Vote is already closed");
|
||||||
|
}
|
||||||
|
|
||||||
|
int totalVotes = vote.getYesCount() + vote.getNoCount(); // abstentions don't count toward majority
|
||||||
|
VoteResult result = calculateResult(vote.getVoteType(), vote.getYesCount(), totalVotes);
|
||||||
|
|
||||||
|
vote.setResult(result);
|
||||||
|
vote.setVotedAt(Instant.now());
|
||||||
|
log.info("Vote closed: {} — Result: {} (Yes: {}, No: {}, Abstain: {})",
|
||||||
|
vote.getTitle(), result, vote.getYesCount(), vote.getNoCount(), vote.getAbstainCount());
|
||||||
|
|
||||||
|
return voteRepository.save(vote);
|
||||||
|
}
|
||||||
|
|
||||||
|
private VoteResult calculateResult(VoteType voteType, int yesCount, int totalVotes) {
|
||||||
|
if (totalVotes == 0) return VoteResult.REJECTED;
|
||||||
|
double ratio = (double) yesCount / totalVotes;
|
||||||
|
|
||||||
|
return switch (voteType) {
|
||||||
|
case SIMPLE_MAJORITY -> ratio > 0.5 ? VoteResult.ACCEPTED : VoteResult.REJECTED;
|
||||||
|
case TWO_THIRDS -> ratio >= (2.0 / 3.0) ? VoteResult.ACCEPTED : VoteResult.REJECTED;
|
||||||
|
case THREE_QUARTERS -> ratio >= 0.75 ? VoteResult.ACCEPTED : VoteResult.REJECTED;
|
||||||
|
case UNANIMOUS -> yesCount == totalVotes ? VoteResult.ACCEPTED : VoteResult.REJECTED;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Queries ===
|
||||||
|
|
||||||
|
public List<Assembly> getAssemblies(UUID clubId) {
|
||||||
|
return assemblyRepository.findByClubIdOrderByScheduledAtDesc(clubId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Assembly getAssemblyDetail(UUID assemblyId) {
|
||||||
|
return assemblyRepository.findById(assemblyId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Assembly not found: " + assemblyId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<AssemblyAgendaItem> getAgendaItems(UUID assemblyId) {
|
||||||
|
return agendaItemRepository.findByAssemblyIdOrderByPosition(assemblyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<AssemblyAttendee> getAttendees(UUID assemblyId) {
|
||||||
|
return attendeeRepository.findByAssemblyId(assemblyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<AssemblyVote> getVotes(UUID assemblyId) {
|
||||||
|
return voteRepository.findByAssemblyId(assemblyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<AssemblyVoteRecord> getVoteRecords(UUID voteId) {
|
||||||
|
return voteRecordRepository.findByVoteId(voteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Assembly> getUpcomingAssemblies(UUID tenantId) {
|
||||||
|
return assemblyRepository.findByTenantIdOrderByScheduledAtDesc(tenantId).stream()
|
||||||
|
.filter(a -> a.getStatus() == AssemblyStatus.PLANNED || a.getStatus() == AssemblyStatus.INVITED)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// === DTOs ===
|
||||||
|
|
||||||
|
public record AgendaItemInput(String title, String description, AgendaItemType itemType) {}
|
||||||
|
|
||||||
|
public record QuorumInfo(long attendees, long totalMembers, int required, boolean quorumMet) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package de.cannamanage.service.repository;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.AssemblyAgendaItem;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface AssemblyAgendaItemRepository extends JpaRepository<AssemblyAgendaItem, UUID> {
|
||||||
|
|
||||||
|
List<AssemblyAgendaItem> findByAssemblyIdOrderByPosition(UUID assemblyId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package de.cannamanage.service.repository;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.AssemblyAttendee;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface AssemblyAttendeeRepository extends JpaRepository<AssemblyAttendee, UUID> {
|
||||||
|
|
||||||
|
List<AssemblyAttendee> findByAssemblyId(UUID assemblyId);
|
||||||
|
|
||||||
|
long countByAssemblyId(UUID assemblyId);
|
||||||
|
|
||||||
|
boolean existsByAssemblyIdAndMemberId(UUID assemblyId, UUID memberId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package de.cannamanage.service.repository;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.Assembly;
|
||||||
|
import de.cannamanage.domain.enums.AssemblyStatus;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface AssemblyRepository extends JpaRepository<Assembly, UUID> {
|
||||||
|
|
||||||
|
List<Assembly> findByClubIdOrderByScheduledAtDesc(UUID clubId);
|
||||||
|
|
||||||
|
List<Assembly> findByClubIdAndStatus(UUID clubId, AssemblyStatus status);
|
||||||
|
|
||||||
|
List<Assembly> findByTenantIdOrderByScheduledAtDesc(UUID tenantId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package de.cannamanage.service.repository;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.AssemblyVoteRecord;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface AssemblyVoteRecordRepository extends JpaRepository<AssemblyVoteRecord, UUID> {
|
||||||
|
|
||||||
|
List<AssemblyVoteRecord> findByVoteId(UUID voteId);
|
||||||
|
|
||||||
|
boolean existsByVoteIdAndMemberId(UUID voteId, UUID memberId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package de.cannamanage.service.repository;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.AssemblyVote;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface AssemblyVoteRepository extends JpaRepository<AssemblyVote, UUID> {
|
||||||
|
|
||||||
|
List<AssemblyVote> findByAssemblyId(UUID assemblyId);
|
||||||
|
|
||||||
|
List<AssemblyVote> findByAgendaItemId(UUID agendaItemId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,868 @@
|
|||||||
|
# Sprint 8 Feature Analysis — Vereinsverwaltung Complete
|
||||||
|
|
||||||
|
**Date:** 2026-06-13
|
||||||
|
**Author:** Patrick Plate / Lumen (Architect)
|
||||||
|
**Status:** Draft v1
|
||||||
|
**Sprint Goal:** Close the compliance and operational gaps for a German e.V. — Treasury, General Assembly, Documents, Board Management.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
Sprint 8 delivers the "boring but essential" features that every German Verein (e.V.) needs but no cannabis club SaaS currently provides. While Sprint 7 made CannaManage a community platform, Sprint 8 makes it an actual **Vereinsverwaltung** — the digital backbone of club operations beyond cannabis compliance.
|
||||||
|
|
||||||
|
Four features, in priority order:
|
||||||
|
|
||||||
|
1. **Vereinsfinanzen (Club Treasury)** — Fee schedules, payment tracking, Kassenbuch, receipt PDF generation, annual reports. The Kassenwart's best friend.
|
||||||
|
2. **Mitgliederversammlung (General Assembly / MV)** — Create MVs, invite members (legal notice periods per §36 BGB), track attendance, conduct votes, generate minutes. The Schriftführer's best friend.
|
||||||
|
3. **Dokumentenarchiv (Document Storage)** — Organized file storage for Satzung, Protokolle, Verträge, Behördliche Genehmigungen. Lightweight DMS.
|
||||||
|
4. **Vorstandsverwaltung (Board Management)** — Board positions, term tracking, succession warnings. Small but essential for Vereinsregister compliance.
|
||||||
|
|
||||||
|
**Strategic rationale:**
|
||||||
|
- KCanG §2(4) requires e.V. structure → clubs MUST have a proper Vorstand, hold MVs, keep a Kassenbuch
|
||||||
|
- Abgabenordnung §§63-68 (Gemeinnützigkeit) requires proper financial documentation if the club claims tax benefits
|
||||||
|
- BGB §§26-40 defines minimum e.V. governance requirements
|
||||||
|
- No competitor offers integrated treasury + MV management — they all tell clubs to "use Excel and WhatsApp"
|
||||||
|
- The Kassenbuch + MV minutes are legally required documents — generating them automatically is a killer feature
|
||||||
|
|
||||||
|
**Key design decisions:**
|
||||||
|
|
||||||
|
| Decision | Choice | Rationale |
|
||||||
|
|----------|--------|-----------|
|
||||||
|
| Payment tracking vs. SEPA collection | Tracking only (MVP) | SEPA Lastschrift requires BaFin registration, IBAN handling, bank API integration — too complex for Sprint 8 |
|
||||||
|
| File storage backend | Local filesystem `/uploads/` | IONOS VPS has 200GB SSD; S3/MinIO is Sprint 10+ when we go multi-server |
|
||||||
|
| Voting system | Record results (not live voting) | Real-time voting needs WebSocket conflict resolution, secret ballot crypto — overkill for MVP |
|
||||||
|
| PDF generation | Reuse existing OpenPDF stack | Already proven in Sprint 5 compliance reports |
|
||||||
|
| Bookkeeping style | Simple Einnahmen/Ausgaben | Double-entry (Soll/Haben) is overkill for a cannabis club with <€50k/year revenue |
|
||||||
|
| Financial immutability | Append-only (like audit_events) | Never delete/edit transactions — only void with correction entry |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Vereinsfinanzen (Club Treasury)
|
||||||
|
|
||||||
|
### 1.1 Problem Statement
|
||||||
|
|
||||||
|
Every German e.V. needs a Kassenbuch (cash book). The Kassenwart (treasurer) must track all income and expenses, issue receipts for member payments, and present an annual financial report (Jahresabschluss) to the elected Kassenprüfer (auditors) at the Mitgliederversammlung.
|
||||||
|
|
||||||
|
Today, cannabis clubs use:
|
||||||
|
- Excel spreadsheets (error-prone, no audit trail, no PDF generation)
|
||||||
|
- Generic accounting tools (Lexware, sevDesk — not designed for e.V., too complex)
|
||||||
|
- Paper Kassenbuch (no backup, no search, no automation)
|
||||||
|
|
||||||
|
CannaManage can own this because:
|
||||||
|
- We already have the member database (who owes fees)
|
||||||
|
- We already have the notification system (payment reminders)
|
||||||
|
- We already have PDF generation (receipts, reports)
|
||||||
|
- We already have the audit log (financial compliance trail)
|
||||||
|
|
||||||
|
### 1.2 User Stories
|
||||||
|
|
||||||
|
| # | As a... | I want to... | So that... | Priority |
|
||||||
|
|---|---------|-------------|-----------|----------|
|
||||||
|
| FIN-01 | Kassenwart | Define fee schedules (Beitragsordnung) with name, amount, interval | Different membership types have different fees | P0 |
|
||||||
|
| FIN-02 | Kassenwart | Assign a fee schedule to each member | I know what everyone should pay | P0 |
|
||||||
|
| FIN-03 | Kassenwart | Record a payment received (amount, date, method, payer) | I can track who has paid | P0 |
|
||||||
|
| FIN-04 | Kassenwart | See a dashboard of outstanding balances per member | I know who owes what and since when | P0 |
|
||||||
|
| FIN-05 | Kassenwart | Generate a PDF receipt (Quittung) for a payment | Members get proof of payment for tax purposes | P0 |
|
||||||
|
| FIN-06 | Kassenwart | View a Kassenbuch with all income/expenses, running balance | I have the legally required financial overview | P0 |
|
||||||
|
| FIN-07 | Kassenwart | Categorize expenses (Miete, Strom, Cannabis, Verwaltung, etc.) | The annual report breaks down spending by category | P0 |
|
||||||
|
| FIN-08 | Kassenwart | Record an expense (amount, date, category, description, receipt) | Outgoing money is tracked | P0 |
|
||||||
|
| FIN-09 | Kassenwart | Generate an annual financial summary (Jahresabschluss PDF) | The Kassenprüfer can audit the club finances | P1 |
|
||||||
|
| FIN-10 | Kassenwart | Send payment reminders to overdue members | Members pay on time without me chasing them | P1 |
|
||||||
|
| FIN-11 | Kassenwart | Export Kassenbuch as CSV | I can import into external tools if needed | P2 |
|
||||||
|
| FIN-12 | Member (Portal) | See my payment history and outstanding balance | I know if I'm up to date | P0 |
|
||||||
|
| FIN-13 | Member (Portal) | Download my receipt PDFs | I have proof for my tax return | P1 |
|
||||||
|
| FIN-14 | Admin | Define expense categories | The Kassenwart has the right buckets | P0 |
|
||||||
|
| FIN-15 | Kassenprüfer | View the annual report without edit rights | I can audit without accidentally changing data | P1 |
|
||||||
|
|
||||||
|
### 1.3 Data Model
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- V18: Vereinsfinanzen (Club Treasury)
|
||||||
|
|
||||||
|
-- Fee schedules (Beitragsordnung)
|
||||||
|
CREATE TABLE fee_schedules (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
name VARCHAR(100) NOT NULL, -- "Regulär", "Ermäßigt", "Familie", "Ehrenmitglied"
|
||||||
|
amount NUMERIC(10,2) NOT NULL, -- 30.00
|
||||||
|
interval VARCHAR(20) NOT NULL DEFAULT 'MONTHLY', -- MONTHLY, QUARTERLY, YEARLY, ONE_TIME
|
||||||
|
description TEXT,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Member fee assignment (which member has which plan)
|
||||||
|
CREATE TABLE member_fee_assignments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
member_id UUID NOT NULL REFERENCES members(id),
|
||||||
|
fee_schedule_id UUID NOT NULL REFERENCES fee_schedules(id),
|
||||||
|
effective_from DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||||
|
effective_until DATE, -- NULL = indefinite
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE(member_id, fee_schedule_id, effective_from)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Payments received (append-only — never delete!)
|
||||||
|
CREATE TABLE payments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
member_id UUID NOT NULL REFERENCES members(id),
|
||||||
|
amount NUMERIC(10,2) NOT NULL,
|
||||||
|
payment_date DATE NOT NULL,
|
||||||
|
payment_method VARCHAR(30) NOT NULL, -- CASH, BANK_TRANSFER, SEPA
|
||||||
|
reference VARCHAR(255), -- Verwendungszweck / transaction reference
|
||||||
|
period_from DATE, -- covers which period (e.g., 2026-07-01)
|
||||||
|
period_to DATE, -- to (e.g., 2026-07-31)
|
||||||
|
receipt_number VARCHAR(50), -- auto-generated: CM-2026-000001
|
||||||
|
notes TEXT,
|
||||||
|
voided BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
voided_reason TEXT,
|
||||||
|
voided_at TIMESTAMPTZ,
|
||||||
|
recorded_by UUID NOT NULL, -- staff/admin who recorded it
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Expense categories
|
||||||
|
CREATE TABLE expense_categories (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
icon VARCHAR(50), -- emoji or icon name for UI
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Expenses (append-only)
|
||||||
|
CREATE TABLE expenses (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
category_id UUID NOT NULL REFERENCES expense_categories(id),
|
||||||
|
amount NUMERIC(10,2) NOT NULL,
|
||||||
|
expense_date DATE NOT NULL,
|
||||||
|
description VARCHAR(500) NOT NULL,
|
||||||
|
receipt_path VARCHAR(500), -- uploaded receipt scan
|
||||||
|
payment_method VARCHAR(30), -- CASH, BANK_TRANSFER, CARD
|
||||||
|
voided BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
voided_reason TEXT,
|
||||||
|
voided_at TIMESTAMPTZ,
|
||||||
|
recorded_by UUID NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Payment reminders sent (tracking)
|
||||||
|
CREATE TABLE payment_reminders (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
member_id UUID NOT NULL REFERENCES members(id),
|
||||||
|
amount_due NUMERIC(10,2) NOT NULL,
|
||||||
|
reminder_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||||
|
reminder_type VARCHAR(30) NOT NULL, -- FIRST, SECOND, FINAL
|
||||||
|
sent_via VARCHAR(30) NOT NULL, -- EMAIL, PUSH, IN_APP
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_fee_schedules_tenant ON fee_schedules(tenant_id, is_active);
|
||||||
|
CREATE INDEX idx_member_fee_assign_member ON member_fee_assignments(member_id);
|
||||||
|
CREATE INDEX idx_payments_tenant_date ON payments(tenant_id, payment_date DESC);
|
||||||
|
CREATE INDEX idx_payments_member ON payments(member_id, payment_date DESC);
|
||||||
|
CREATE INDEX idx_expenses_tenant_date ON expenses(tenant_id, expense_date DESC);
|
||||||
|
CREATE INDEX idx_expenses_category ON expenses(category_id);
|
||||||
|
CREATE INDEX idx_payment_reminders_member ON payment_reminders(member_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key design decisions:**
|
||||||
|
- Payments and expenses are **append-only** — voiding creates a flag, never deletes the record
|
||||||
|
- Receipt numbers are auto-generated sequential per club per year (`CM-{year}-{seq}`)
|
||||||
|
- `period_from`/`period_to` on payments allows tracking which month a payment covers
|
||||||
|
- Default expense categories seeded on club creation: Miete, Strom/Nebenkosten, Cannabis-Einkauf, Anbaumaterial, Versicherung, Verwaltung, Sonstiges
|
||||||
|
- File uploads for expense receipts reuse the document storage from Feature 3
|
||||||
|
|
||||||
|
### 1.4 API Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Permission | Description |
|
||||||
|
|--------|------|-----------|-------------|
|
||||||
|
| GET | `/api/finance/fee-schedules` | MANAGE_FINANCES | List fee schedules |
|
||||||
|
| POST | `/api/finance/fee-schedules` | MANAGE_FINANCES | Create fee schedule |
|
||||||
|
| PUT | `/api/finance/fee-schedules/{id}` | MANAGE_FINANCES | Update fee schedule |
|
||||||
|
| POST | `/api/finance/fee-assignments` | MANAGE_FINANCES | Assign fee to member |
|
||||||
|
| GET | `/api/finance/fee-assignments/{memberId}` | MANAGE_FINANCES | Get member's assignment |
|
||||||
|
| GET | `/api/finance/payments` | MANAGE_FINANCES | List payments (paginated, filterable) |
|
||||||
|
| POST | `/api/finance/payments` | MANAGE_FINANCES | Record payment |
|
||||||
|
| POST | `/api/finance/payments/{id}/void` | MANAGE_FINANCES | Void payment |
|
||||||
|
| GET | `/api/finance/payments/{id}/receipt` | MANAGE_FINANCES | Download receipt PDF |
|
||||||
|
| GET | `/api/finance/balances` | MANAGE_FINANCES | Outstanding balance per member |
|
||||||
|
| GET | `/api/finance/balances/{memberId}` | MANAGE_FINANCES | Single member balance |
|
||||||
|
| GET | `/api/finance/expenses` | MANAGE_FINANCES | List expenses |
|
||||||
|
| POST | `/api/finance/expenses` | MANAGE_FINANCES | Record expense |
|
||||||
|
| POST | `/api/finance/expenses/{id}/void` | MANAGE_FINANCES | Void expense |
|
||||||
|
| GET | `/api/finance/categories` | MANAGE_FINANCES | List expense categories |
|
||||||
|
| POST | `/api/finance/categories` | MANAGE_FINANCES | Create category |
|
||||||
|
| GET | `/api/finance/kassenbuch` | MANAGE_FINANCES | Kassenbuch view (all txns chronological) |
|
||||||
|
| GET | `/api/finance/kassenbuch/export` | MANAGE_FINANCES | CSV export |
|
||||||
|
| GET | `/api/finance/reports/annual/{year}` | MANAGE_FINANCES | Annual summary |
|
||||||
|
| GET | `/api/finance/reports/annual/{year}/pdf` | MANAGE_FINANCES | Jahresabschluss PDF |
|
||||||
|
| POST | `/api/finance/reminders/send` | MANAGE_FINANCES | Send reminders to overdue |
|
||||||
|
| GET | `/api/portal/finance/my-payments` | Member | My payment history |
|
||||||
|
| GET | `/api/portal/finance/my-balance` | Member | My outstanding balance |
|
||||||
|
| GET | `/api/portal/finance/receipts/{paymentId}` | Member | Download my receipt |
|
||||||
|
|
||||||
|
### 1.5 Frontend Pages
|
||||||
|
|
||||||
|
**Admin dashboard:**
|
||||||
|
- `/settings/finance/fee-schedules` — CRUD fee schedules
|
||||||
|
- `/finance` — Kassenbuch main view (tabbed: Payments / Expenses / Balance / Reports)
|
||||||
|
- `/finance/payments` — Payment list with record new, void, receipt download
|
||||||
|
- `/finance/expenses` — Expense list with record new, void, receipt upload
|
||||||
|
- `/finance/balances` — Member balance overview (grid: name, due, paid, outstanding, last payment, overdue since)
|
||||||
|
- `/finance/reports` — Annual report generation
|
||||||
|
|
||||||
|
**Portal (member-facing):**
|
||||||
|
- `/portal/finance` — My payment history + balance card
|
||||||
|
|
||||||
|
### 1.6 Integration Points
|
||||||
|
|
||||||
|
| Integration | How |
|
||||||
|
|-------------|-----|
|
||||||
|
| Notification system (Sprint 7) | Payment reminders sent via NotificationDispatchService (email + push + in-app) |
|
||||||
|
| Audit log (Sprint 5) | All financial CRUD operations logged immutably |
|
||||||
|
| PDF generation (Sprint 5) | Receipt + Jahresabschluss PDF via OpenPDF |
|
||||||
|
| Member entity | Fee assignment references Member, payment references Member |
|
||||||
|
| Document archive (Feature 3) | Expense receipt uploads stored in document archive |
|
||||||
|
| Tier enforcement | Starter: manual fee tracking only; Pro: full Kassenbuch + reminders; Enterprise: custom receipt templates |
|
||||||
|
|
||||||
|
### 1.7 Tier Mapping
|
||||||
|
|
||||||
|
| Feature | Starter | Pro | Enterprise |
|
||||||
|
|---------|---------|-----|-----------|
|
||||||
|
| Fee schedules | 2 max | Unlimited | Unlimited |
|
||||||
|
| Payment recording | Manual only | Full + reminders | Full + reminders + custom templates |
|
||||||
|
| Kassenbuch | View only (last 3 months) | Full history + CSV export | Full + scheduled reports |
|
||||||
|
| Annual report | Basic summary | Full PDF | Branded PDF + scheduled delivery |
|
||||||
|
| Payment reminders | ❌ | ✅ Auto (email + in-app) | ✅ Auto + custom schedule |
|
||||||
|
| Expense tracking | 50 entries/year | Unlimited | Unlimited |
|
||||||
|
| Receipt PDF | Basic template | Standard template | Custom-branded template |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Mitgliederversammlung (General Assembly / MV)
|
||||||
|
|
||||||
|
### 2.1 Problem Statement
|
||||||
|
|
||||||
|
Every German e.V. must hold at least one ordentliche Mitgliederversammlung per year (BGB §32). The MV is where:
|
||||||
|
- The Vorstand reports to members (Rechenschaftsbericht)
|
||||||
|
- The Kassenprüfer presents the audit result
|
||||||
|
- Members vote on changes (Satzungsänderungen, Beitragserhöhungen)
|
||||||
|
- Board elections happen (Vorstandswahl)
|
||||||
|
- Important decisions get a democratic mandate
|
||||||
|
|
||||||
|
Legal requirements (BGB §36 + typical Satzung):
|
||||||
|
- Written invitation to ALL members with complete Tagesordnung (agenda)
|
||||||
|
- Notice period: typically 2-4 weeks (defined in Satzung)
|
||||||
|
- Attendance list (Anwesenheitsliste) — required for quorum
|
||||||
|
- Quorum: typically 50% of members present (or whatever the Satzung says)
|
||||||
|
- Minutes (Protokoll) signed by Versammlungsleiter + Schriftführer
|
||||||
|
- Minutes must be stored permanently (Vereinsregister may request them)
|
||||||
|
|
||||||
|
Today clubs manage this via:
|
||||||
|
- WhatsApp group: "Hey MV am 15. März 19 Uhr" (not legally compliant — no written invitation)
|
||||||
|
- Paper sign-in sheet at the door (lost, illegible, no quorum calculation)
|
||||||
|
- Hand-written minutes (delays, disputes about what was decided)
|
||||||
|
- No permanent archive of past MV decisions
|
||||||
|
|
||||||
|
### 2.2 User Stories
|
||||||
|
|
||||||
|
| # | As a... | I want to... | So that... | Priority |
|
||||||
|
|---|---------|-------------|-----------|----------|
|
||||||
|
| MV-01 | Vorstand | Create an MV with date, time, location, and agenda items | Members know when and what's being discussed | P0 |
|
||||||
|
| MV-02 | Vorstand | Send official invitations to all members (via notification system) | The legal notice requirement (§36 BGB) is met | P0 |
|
||||||
|
| MV-03 | Vorstand | Set the notice period (days before MV) | System validates invites are sent on time | P1 |
|
||||||
|
| MV-04 | Vorstand | Define quorum percentage | System calculates if quorum is reached | P0 |
|
||||||
|
| MV-05 | Schriftführer | Record attendance (check members in) | The Anwesenheitsliste is digital and accurate | P0 |
|
||||||
|
| MV-06 | Schriftführer | See live quorum status | I know if we can legally hold votes | P0 |
|
||||||
|
| MV-07 | Schriftführer | Create voting items (Anträge/Beschlüsse) | Votes are structured and results clear | P0 |
|
||||||
|
| MV-08 | Schriftführer | Record vote results (yes/no/abstain counts) | Decisions are documented with exact numbers | P0 |
|
||||||
|
| MV-09 | Schriftführer | Generate Protokoll (minutes) as PDF | Official document for the Vereinsregister | P0 |
|
||||||
|
| MV-10 | Schriftführer | Upload signed protocol PDF back to system | The official signed version is archived | P1 |
|
||||||
|
| MV-11 | Vorstand | Record board election results | New Vorstand members are documented | P1 |
|
||||||
|
| MV-12 | Member (Portal) | See upcoming MV with agenda | I can prepare and decide whether to attend | P0 |
|
||||||
|
| MV-13 | Member (Portal) | RSVP to the MV | The club knows approximate attendance | P1 |
|
||||||
|
| MV-14 | Member (Portal) | View past MV minutes (if published) | I can look up what was decided | P1 |
|
||||||
|
| MV-15 | Admin | View history of all past MVs with their protocols | Institutional memory is preserved | P0 |
|
||||||
|
|
||||||
|
### 2.3 Data Model
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- V19: Mitgliederversammlung (General Assembly)
|
||||||
|
|
||||||
|
-- Main MV record
|
||||||
|
CREATE TABLE general_assemblies (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||||
|
title VARCHAR(200) NOT NULL, -- "Ordentliche MV 2026", "Außerordentliche MV Juni 2026"
|
||||||
|
assembly_type VARCHAR(30) NOT NULL DEFAULT 'ORDINARY', -- ORDINARY, EXTRAORDINARY
|
||||||
|
scheduled_at TIMESTAMPTZ NOT NULL,
|
||||||
|
location VARCHAR(300) NOT NULL,
|
||||||
|
notice_period_days INTEGER NOT NULL DEFAULT 14,
|
||||||
|
quorum_percentage INTEGER NOT NULL DEFAULT 50, -- minimum attendance %
|
||||||
|
status VARCHAR(30) NOT NULL DEFAULT 'DRAFT', -- DRAFT, INVITED, IN_PROGRESS, COMPLETED, CANCELLED
|
||||||
|
invitation_sent_at TIMESTAMPTZ,
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
ended_at TIMESTAMPTZ,
|
||||||
|
notes TEXT,
|
||||||
|
protocol_document_id UUID, -- FK to documents table (Feature 3)
|
||||||
|
event_id UUID, -- FK to club_events (MV as special event)
|
||||||
|
created_by UUID NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Agenda items (Tagesordnung)
|
||||||
|
CREATE TABLE assembly_agenda_items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
assembly_id UUID NOT NULL REFERENCES general_assemblies(id) ON DELETE CASCADE,
|
||||||
|
sort_order INTEGER NOT NULL,
|
||||||
|
title VARCHAR(300) NOT NULL, -- "TOP 1: Begrüßung", "TOP 5: Vorstandswahl"
|
||||||
|
description TEXT,
|
||||||
|
item_type VARCHAR(30) NOT NULL DEFAULT 'DISCUSSION', -- DISCUSSION, VOTE, ELECTION, REPORT, OTHER
|
||||||
|
duration_minutes INTEGER, -- estimated duration
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Attendance tracking
|
||||||
|
CREATE TABLE assembly_attendance (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
assembly_id UUID NOT NULL REFERENCES general_assemblies(id) ON DELETE CASCADE,
|
||||||
|
member_id UUID NOT NULL REFERENCES members(id),
|
||||||
|
checked_in_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
checked_in_by UUID NOT NULL, -- who marked them present
|
||||||
|
proxy_for UUID, -- if representing another member (Vollmacht)
|
||||||
|
UNIQUE(assembly_id, member_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Voting items (Anträge / Beschlüsse)
|
||||||
|
CREATE TABLE assembly_votes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
assembly_id UUID NOT NULL REFERENCES general_assemblies(id) ON DELETE CASCADE,
|
||||||
|
agenda_item_id UUID REFERENCES assembly_agenda_items(id),
|
||||||
|
title VARCHAR(300) NOT NULL, -- "Antrag: Beitragserhöhung auf 35€"
|
||||||
|
description TEXT,
|
||||||
|
vote_type VARCHAR(30) NOT NULL DEFAULT 'SIMPLE_MAJORITY', -- SIMPLE_MAJORITY, TWO_THIRDS, UNANIMOUS
|
||||||
|
required_majority INTEGER NOT NULL DEFAULT 50, -- percentage needed to pass
|
||||||
|
yes_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
no_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
abstain_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
result VARCHAR(20), -- ACCEPTED, REJECTED, TABLED
|
||||||
|
voted_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Board elections recorded at MV
|
||||||
|
CREATE TABLE assembly_elections (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
assembly_id UUID NOT NULL REFERENCES general_assemblies(id) ON DELETE CASCADE,
|
||||||
|
position VARCHAR(100) NOT NULL, -- "1. Vorsitzender", "Kassenwart"
|
||||||
|
elected_member_id UUID REFERENCES members(id),
|
||||||
|
vote_count INTEGER,
|
||||||
|
term_start DATE NOT NULL,
|
||||||
|
term_end DATE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_assemblies_tenant ON general_assemblies(tenant_id, scheduled_at DESC);
|
||||||
|
CREATE INDEX idx_assemblies_status ON general_assemblies(tenant_id, status);
|
||||||
|
CREATE INDEX idx_assembly_agenda_order ON assembly_agenda_items(assembly_id, sort_order);
|
||||||
|
CREATE INDEX idx_assembly_attendance_assembly ON assembly_attendance(assembly_id);
|
||||||
|
CREATE INDEX idx_assembly_votes_assembly ON assembly_votes(assembly_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key design decisions:**
|
||||||
|
- MV is linked to `club_events` — an MV is a special event type, leveraging Sprint 7's RSVP infrastructure
|
||||||
|
- Attendance is recorded at the MV (not the RSVP — RSVP is intent, attendance is fact)
|
||||||
|
- Votes are recorded post-factum (not live polling) — the Schriftführer enters results
|
||||||
|
- Proxy voting (Vollmacht) supported via `proxy_for` field
|
||||||
|
- Protocol is stored in the Document Archive (Feature 3)
|
||||||
|
- Status flow: DRAFT → INVITED → IN_PROGRESS → COMPLETED
|
||||||
|
|
||||||
|
### 2.4 API Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Permission | Description |
|
||||||
|
|--------|------|-----------|-------------|
|
||||||
|
| GET | `/api/assemblies` | MANAGE_ASSEMBLY | List all MVs |
|
||||||
|
| POST | `/api/assemblies` | MANAGE_ASSEMBLY | Create MV |
|
||||||
|
| GET | `/api/assemblies/{id}` | MANAGE_ASSEMBLY | MV detail |
|
||||||
|
| PUT | `/api/assemblies/{id}` | MANAGE_ASSEMBLY | Update MV |
|
||||||
|
| POST | `/api/assemblies/{id}/invite` | MANAGE_ASSEMBLY | Send invitations (transitions to INVITED) |
|
||||||
|
| POST | `/api/assemblies/{id}/start` | MANAGE_ASSEMBLY | Start MV (transitions to IN_PROGRESS) |
|
||||||
|
| POST | `/api/assemblies/{id}/complete` | MANAGE_ASSEMBLY | End MV (transitions to COMPLETED) |
|
||||||
|
| POST | `/api/assemblies/{id}/cancel` | MANAGE_ASSEMBLY | Cancel MV |
|
||||||
|
| GET | `/api/assemblies/{id}/agenda` | Any auth | Get agenda items |
|
||||||
|
| POST | `/api/assemblies/{id}/agenda` | MANAGE_ASSEMBLY | Add agenda item |
|
||||||
|
| PUT | `/api/assemblies/{id}/agenda/{itemId}` | MANAGE_ASSEMBLY | Edit agenda item |
|
||||||
|
| DELETE | `/api/assemblies/{id}/agenda/{itemId}` | MANAGE_ASSEMBLY | Remove agenda item |
|
||||||
|
| GET | `/api/assemblies/{id}/attendance` | MANAGE_ASSEMBLY | Attendance list |
|
||||||
|
| POST | `/api/assemblies/{id}/attendance` | MANAGE_ASSEMBLY | Check in member |
|
||||||
|
| DELETE | `/api/assemblies/{id}/attendance/{memberId}` | MANAGE_ASSEMBLY | Remove check-in (correction) |
|
||||||
|
| GET | `/api/assemblies/{id}/quorum` | MANAGE_ASSEMBLY | Quorum status |
|
||||||
|
| GET | `/api/assemblies/{id}/votes` | MANAGE_ASSEMBLY | List vote items |
|
||||||
|
| POST | `/api/assemblies/{id}/votes` | MANAGE_ASSEMBLY | Create vote item |
|
||||||
|
| PUT | `/api/assemblies/{id}/votes/{voteId}` | MANAGE_ASSEMBLY | Record vote result |
|
||||||
|
| POST | `/api/assemblies/{id}/elections` | MANAGE_ASSEMBLY | Record election result |
|
||||||
|
| GET | `/api/assemblies/{id}/protocol/pdf` | MANAGE_ASSEMBLY | Generate protocol PDF |
|
||||||
|
| POST | `/api/assemblies/{id}/protocol/upload` | MANAGE_ASSEMBLY | Upload signed protocol |
|
||||||
|
| GET | `/api/portal/assemblies` | Member | Upcoming + past MVs |
|
||||||
|
| GET | `/api/portal/assemblies/{id}` | Member | MV detail with agenda |
|
||||||
|
| POST | `/api/portal/assemblies/{id}/rsvp` | Member | RSVP (reuses event RSVP) |
|
||||||
|
|
||||||
|
### 2.5 Frontend Pages
|
||||||
|
|
||||||
|
**Admin dashboard:**
|
||||||
|
- `/assemblies` — List of all MVs (upcoming + past)
|
||||||
|
- `/assemblies/new` — Create MV form (date, location, quorum, agenda builder)
|
||||||
|
- `/assemblies/{id}` — MV detail page (tabbed: Overview / Agenda / Attendance / Votes / Protocol)
|
||||||
|
- `/assemblies/{id}/live` — Live management during MV (attendance check-in, quorum display, vote recording)
|
||||||
|
|
||||||
|
**Portal (member-facing):**
|
||||||
|
- `/portal/assemblies` — Upcoming MVs (with RSVP) + past MVs (with protocol download)
|
||||||
|
|
||||||
|
### 2.6 Integration Points
|
||||||
|
|
||||||
|
| Integration | How |
|
||||||
|
|-------------|-----|
|
||||||
|
| Club Events (Sprint 7) | MV creates a ClubEvent with type `GENERAL_ASSEMBLY` — inherits RSVP, calendar, reminders |
|
||||||
|
| Notification system (Sprint 7) | MV invitations sent via NotificationDispatchService (email + push + in-app) — legally compliant written notice |
|
||||||
|
| Document Archive (Feature 3) | Protocol PDF auto-stored in documents with category "Protokolle" |
|
||||||
|
| Board Management (Feature 4) | Election results create/update board positions |
|
||||||
|
| Audit log | All MV operations logged (creation, invitation, attendance, votes, completion) |
|
||||||
|
|
||||||
|
### 2.7 Legal References
|
||||||
|
|
||||||
|
| Requirement | BGB Reference | Implementation |
|
||||||
|
|-------------|---------------|----------------|
|
||||||
|
| MV invitation with agenda | §36 BGB | `invite` endpoint sends full agenda via notification system |
|
||||||
|
| Notice period | Satzung (typically 2-4 weeks) | `notice_period_days` field, system warns if invite sent too late |
|
||||||
|
| Quorum | §32(1) BGB | Live quorum calculation: `attendance_count / active_members * 100 >= quorum_percentage` |
|
||||||
|
| Simple majority | §32(1) BGB | `vote_type = SIMPLE_MAJORITY`, `yes > no` (abstentions don't count) |
|
||||||
|
| 2/3 majority for Satzungsänderung | §33(1) BGB | `vote_type = TWO_THIRDS`, `yes >= 2/3 of votes cast` |
|
||||||
|
| Minutes (Protokoll) | §58 Nr. 4 BGB | Auto-generated PDF with decisions, vote counts, attendance |
|
||||||
|
| Board election | §26-27 BGB | Election results recorded, linked to board management |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Dokumentenarchiv (Document Storage)
|
||||||
|
|
||||||
|
### 3.1 Problem Statement
|
||||||
|
|
||||||
|
Every club accumulates documents: the Satzung, MV protocols, insurance policies, the KCanG license, lease contracts, board resolutions. Today these live in someone's Google Drive or Dropbox — not accessible to the full board, no version tracking, no categorization, and critically: if the Vorstand changes, the new board may not even know where the documents are.
|
||||||
|
|
||||||
|
This is a lightweight DMS — not SharePoint. Simple: upload, categorize, find, download. With access control (some docs board-only, some visible to all members).
|
||||||
|
|
||||||
|
### 3.2 User Stories
|
||||||
|
|
||||||
|
| # | As a... | I want to... | So that... | Priority |
|
||||||
|
|---|---------|-------------|-----------|----------|
|
||||||
|
| DOC-01 | Admin | Upload a document with title, category, and description | Club docs are organized digitally | P0 |
|
||||||
|
| DOC-02 | Admin | Define access level per document (all members / board only) | Sensitive contracts aren't visible to everyone | P0 |
|
||||||
|
| DOC-03 | Admin | Organize documents by category | Finding docs is easy | P0 |
|
||||||
|
| DOC-04 | Admin | Download any document | I can access uploaded files | P0 |
|
||||||
|
| DOC-05 | Admin | Delete a document | Outdated files are removed | P1 |
|
||||||
|
| DOC-06 | Admin | Replace/update a document (new version) | Current version is always available, old version archived | P1 |
|
||||||
|
| DOC-07 | Member (Portal) | Browse documents marked "visible to all" | I can access the Satzung, published protocols | P0 |
|
||||||
|
| DOC-08 | Member (Portal) | Download documents I have access to | I don't need to ask the Vorstand for basic documents | P0 |
|
||||||
|
| DOC-09 | System | Auto-store MV protocol PDFs | Protocols are automatically archived | P0 |
|
||||||
|
| DOC-10 | System | Auto-store annual financial reports | Jahresabschluss PDFs are automatically archived | P0 |
|
||||||
|
| DOC-11 | Admin | Search documents by title/description | I can find what I need quickly | P2 |
|
||||||
|
|
||||||
|
### 3.3 Data Model
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- V20: Dokumentenarchiv (Document Storage)
|
||||||
|
|
||||||
|
CREATE TABLE document_categories (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
name VARCHAR(100) NOT NULL, -- "Satzung", "Protokolle", "Verträge", etc.
|
||||||
|
description TEXT,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE documents (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
category_id UUID REFERENCES document_categories(id),
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
filename VARCHAR(255) NOT NULL,
|
||||||
|
content_type VARCHAR(100) NOT NULL,
|
||||||
|
size_bytes BIGINT NOT NULL,
|
||||||
|
storage_path VARCHAR(500) NOT NULL, -- relative path in /uploads/documents/{tenant}/{uuid}
|
||||||
|
access_level VARCHAR(30) NOT NULL DEFAULT 'BOARD_ONLY', -- ALL_MEMBERS, BOARD_ONLY
|
||||||
|
version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
previous_version_id UUID REFERENCES documents(id),
|
||||||
|
uploaded_by UUID NOT NULL,
|
||||||
|
source VARCHAR(50) NOT NULL DEFAULT 'MANUAL', -- MANUAL, SYSTEM_MV_PROTOCOL, SYSTEM_FINANCIAL_REPORT
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_documents_tenant ON documents(tenant_id, category_id);
|
||||||
|
CREATE INDEX idx_documents_access ON documents(tenant_id, access_level);
|
||||||
|
CREATE INDEX idx_document_categories_tenant ON document_categories(tenant_id, sort_order);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key design decisions:**
|
||||||
|
- Storage path: `/uploads/documents/{tenant_id}/{document_id}/{filename}` — tenant-isolated on filesystem
|
||||||
|
- No full-text indexing (Sprint 8 MVP) — search by title/description only
|
||||||
|
- Version tracking via `previous_version_id` linked list — simple, no complex version trees
|
||||||
|
- System-generated documents tagged with `source` to distinguish from manual uploads
|
||||||
|
- Default categories seeded on club creation: Satzung, Protokolle, Verträge, Versicherungen, Behördliche Genehmigungen, Sonstiges
|
||||||
|
- Max file size: 20MB per file (sufficient for scans, PDFs, common docs)
|
||||||
|
- Allowed content types: PDF, DOCX, DOC, XLSX, XLS, PNG, JPG, JPEG
|
||||||
|
|
||||||
|
### 3.4 API Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Permission | Description |
|
||||||
|
|--------|------|-----------|-------------|
|
||||||
|
| GET | `/api/documents` | MANAGE_DOCUMENTS | List all documents (filterable by category) |
|
||||||
|
| POST | `/api/documents` | MANAGE_DOCUMENTS | Upload document |
|
||||||
|
| GET | `/api/documents/{id}` | MANAGE_DOCUMENTS | Document metadata |
|
||||||
|
| GET | `/api/documents/{id}/download` | MANAGE_DOCUMENTS | Download file |
|
||||||
|
| PUT | `/api/documents/{id}` | MANAGE_DOCUMENTS | Update metadata |
|
||||||
|
| POST | `/api/documents/{id}/new-version` | MANAGE_DOCUMENTS | Upload new version |
|
||||||
|
| DELETE | `/api/documents/{id}` | MANAGE_DOCUMENTS | Delete document |
|
||||||
|
| GET | `/api/documents/categories` | Any auth | List categories |
|
||||||
|
| POST | `/api/documents/categories` | MANAGE_DOCUMENTS | Create category |
|
||||||
|
| GET | `/api/portal/documents` | Member | List documents with `access_level = ALL_MEMBERS` |
|
||||||
|
| GET | `/api/portal/documents/{id}/download` | Member | Download (if access_level allows) |
|
||||||
|
|
||||||
|
### 3.5 Storage Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
/uploads/
|
||||||
|
├── documents/
|
||||||
|
│ ├── {tenant_id_1}/
|
||||||
|
│ │ ├── {doc_id_1}/
|
||||||
|
│ │ │ └── satzung-2026.pdf
|
||||||
|
│ │ ├── {doc_id_2}/
|
||||||
|
│ │ │ └── mietvertrag.pdf
|
||||||
|
│ │ └── ...
|
||||||
|
│ └── {tenant_id_2}/
|
||||||
|
│ └── ...
|
||||||
|
├── expense-receipts/
|
||||||
|
│ └── {tenant_id}/
|
||||||
|
│ └── {expense_id}/
|
||||||
|
│ └── receipt.jpg
|
||||||
|
└── post-attachments/
|
||||||
|
└── {tenant_id}/
|
||||||
|
└── {post_id}/
|
||||||
|
└── flyer.png
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.6 Tier Mapping
|
||||||
|
|
||||||
|
| Feature | Starter | Pro | Enterprise |
|
||||||
|
|---------|---------|-----|-----------|
|
||||||
|
| Document storage | 100MB total | 1GB total | 10GB total |
|
||||||
|
| Categories | Default only | Custom categories | Custom + nested |
|
||||||
|
| Version history | ❌ | ✅ | ✅ |
|
||||||
|
| System auto-archive | ❌ | ✅ (MV protocols) | ✅ (MV + financial) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Vorstandsverwaltung (Board Management)
|
||||||
|
|
||||||
|
### 4.1 Problem Statement
|
||||||
|
|
||||||
|
A German e.V. must have a Vorstand (board). The Vereinsregister requires knowing who holds which position. Board positions have terms (Amtszeit), and when a term expires, an election must happen at the next MV. Currently, clubs track board membership in their heads or a spreadsheet — when the 2. Vorsitzender steps down, nobody remembers when the term started.
|
||||||
|
|
||||||
|
### 4.2 User Stories
|
||||||
|
|
||||||
|
| # | As a... | I want to... | So that... | Priority |
|
||||||
|
|---|---------|-------------|-----------|----------|
|
||||||
|
| BRD-01 | Admin | Define board positions (Vorsitzende/r, Kassenwart, Schriftführer, etc.) | The club's organizational structure is documented | P0 |
|
||||||
|
| BRD-02 | Admin | Assign a member to a board position with start/end dates | Everyone knows who holds which role | P0 |
|
||||||
|
| BRD-03 | Admin | See current board composition | Quick overview of who's responsible for what | P0 |
|
||||||
|
| BRD-04 | Admin | See board history (who held which position when) | Institutional memory is preserved | P1 |
|
||||||
|
| BRD-05 | Admin | Get notified 30 days before a term expires | I can plan the next election at the MV | P1 |
|
||||||
|
| BRD-06 | Member (Portal) | See current board members and their roles | I know who to contact | P0 |
|
||||||
|
| BRD-07 | System | Display board on club info/profile | Transparency to members | P1 |
|
||||||
|
|
||||||
|
### 4.3 Data Model
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- V21: Vorstandsverwaltung (Board Management)
|
||||||
|
|
||||||
|
CREATE TABLE board_positions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
name VARCHAR(100) NOT NULL, -- "1. Vorsitzende/r", "Kassenwart", etc.
|
||||||
|
description TEXT,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_required BOOLEAN NOT NULL DEFAULT false, -- legally required positions
|
||||||
|
max_holders INTEGER NOT NULL DEFAULT 1, -- typically 1, but Beisitzer can be multiple
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE board_members (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
position_id UUID NOT NULL REFERENCES board_positions(id),
|
||||||
|
member_id UUID NOT NULL REFERENCES members(id),
|
||||||
|
term_start DATE NOT NULL,
|
||||||
|
term_end DATE, -- NULL = indefinite (until next MV)
|
||||||
|
elected_at_assembly_id UUID REFERENCES general_assemblies(id),
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
ended_reason VARCHAR(50), -- TERM_EXPIRED, RESIGNED, VOTED_OUT, OTHER
|
||||||
|
ended_at DATE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_board_positions_tenant ON board_positions(tenant_id, sort_order);
|
||||||
|
CREATE INDEX idx_board_members_active ON board_members(tenant_id, is_active, position_id);
|
||||||
|
CREATE INDEX idx_board_members_member ON board_members(member_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key design decisions:**
|
||||||
|
- Positions are customizable (clubs can add Beisitzer, Jugendwart, etc.)
|
||||||
|
- `is_required` marks the legally mandatory positions (1. Vorsitzende/r, Kassenwart/in)
|
||||||
|
- History preserved: `is_active = false` + `ended_reason` tracks past holders
|
||||||
|
- Linked to MV elections via `elected_at_assembly_id`
|
||||||
|
- Default positions seeded: 1. Vorsitzende/r, 2. Vorsitzende/r, Kassenwart/in, Schriftführer/in, Beisitzer/in
|
||||||
|
- Term expiry notification via scheduled job (30 days before `term_end`)
|
||||||
|
|
||||||
|
### 4.4 API Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Permission | Description |
|
||||||
|
|--------|------|-----------|-------------|
|
||||||
|
| GET | `/api/board/positions` | Any auth | List positions |
|
||||||
|
| POST | `/api/board/positions` | Admin only | Create position |
|
||||||
|
| PUT | `/api/board/positions/{id}` | Admin only | Update position |
|
||||||
|
| GET | `/api/board/members` | Any auth | Current board composition |
|
||||||
|
| POST | `/api/board/members` | Admin only | Appoint member to position |
|
||||||
|
| PUT | `/api/board/members/{id}/end` | Admin only | End appointment |
|
||||||
|
| GET | `/api/board/history` | Any auth | Board history |
|
||||||
|
| GET | `/api/portal/board` | Member | Current board (public view) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Prioritization & Sprint Plan
|
||||||
|
|
||||||
|
### 5.1 Priority Matrix
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
subgraph P0 - Must Have
|
||||||
|
FIN[Vereinsfinanzen Core]
|
||||||
|
MV[MV Core]
|
||||||
|
DOC[Dokumentenarchiv]
|
||||||
|
BRD[Vorstandsverwaltung]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph P1 - Should Have
|
||||||
|
FINR[Payment Reminders]
|
||||||
|
FINPDF[Jahresabschluss PDF]
|
||||||
|
MVEL[Board Elections]
|
||||||
|
DOCV[Document Versioning]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph P2 - Nice to Have
|
||||||
|
FINCSV[CSV Export]
|
||||||
|
DOCSRCH[Document Search]
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Phased Delivery
|
||||||
|
|
||||||
|
| Phase | Focus | Features |
|
||||||
|
|-------|-------|----------|
|
||||||
|
| Phase 1 | Treasury Backend | Fee schedules, payments, expenses, Kassenbuch, balance calculation |
|
||||||
|
| Phase 2 | Treasury Frontend + PDF | Admin finance pages, receipt PDF, portal payment view |
|
||||||
|
| Phase 3 | Mitgliederversammlung | MV creation, invitations, attendance, voting, protocol PDF |
|
||||||
|
| Phase 4 | Dokumentenarchiv + Board | File upload/download, categories, board positions/members |
|
||||||
|
| Phase 5 | Integration & Polish | Notifications, auto-archive, tier enforcement, term expiry alerts |
|
||||||
|
| Phase 6 | Testing & QA | Unit tests, integration tests, E2E Playwright tests |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Technical Architecture
|
||||||
|
|
||||||
|
### 6.1 New Entities
|
||||||
|
|
||||||
|
| Entity | Package | Purpose |
|
||||||
|
|--------|---------|---------|
|
||||||
|
| `FeeSchedule` | `domain.entity` | Fee plan definition |
|
||||||
|
| `MemberFeeAssignment` | `domain.entity` | Links member to fee schedule |
|
||||||
|
| `Payment` | `domain.entity` | Incoming payment record |
|
||||||
|
| `Expense` | `domain.entity` | Outgoing expense record |
|
||||||
|
| `ExpenseCategory` | `domain.entity` | Expense categorization |
|
||||||
|
| `PaymentReminder` | `domain.entity` | Reminder tracking |
|
||||||
|
| `GeneralAssembly` | `domain.entity` | MV master record |
|
||||||
|
| `AssemblyAgendaItem` | `domain.entity` | Tagesordnung item |
|
||||||
|
| `AssemblyAttendance` | `domain.entity` | MV attendance |
|
||||||
|
| `AssemblyVote` | `domain.entity` | Vote item + result |
|
||||||
|
| `AssemblyElection` | `domain.entity` | Election result |
|
||||||
|
| `Document` | `domain.entity` | Stored file metadata |
|
||||||
|
| `DocumentCategory` | `domain.entity` | Document categorization |
|
||||||
|
| `BoardPosition` | `domain.entity` | Board position definition |
|
||||||
|
| `BoardMember` | `domain.entity` | Member-position assignment |
|
||||||
|
|
||||||
|
### 6.2 New Enums
|
||||||
|
|
||||||
|
| Enum | Values |
|
||||||
|
|------|--------|
|
||||||
|
| `PaymentMethod` | `CASH`, `BANK_TRANSFER`, `SEPA`, `CARD` |
|
||||||
|
| `FeeInterval` | `MONTHLY`, `QUARTERLY`, `YEARLY`, `ONE_TIME` |
|
||||||
|
| `ReminderType` | `FIRST`, `SECOND`, `FINAL` |
|
||||||
|
| `AssemblyType` | `ORDINARY`, `EXTRAORDINARY` |
|
||||||
|
| `AssemblyStatus` | `DRAFT`, `INVITED`, `IN_PROGRESS`, `COMPLETED`, `CANCELLED` |
|
||||||
|
| `AgendaItemType` | `DISCUSSION`, `VOTE`, `ELECTION`, `REPORT`, `OTHER` |
|
||||||
|
| `VoteType` | `SIMPLE_MAJORITY`, `TWO_THIRDS`, `UNANIMOUS` |
|
||||||
|
| `VoteResult` | `ACCEPTED`, `REJECTED`, `TABLED` |
|
||||||
|
| `DocumentAccessLevel` | `ALL_MEMBERS`, `BOARD_ONLY` |
|
||||||
|
| `DocumentSource` | `MANUAL`, `SYSTEM_MV_PROTOCOL`, `SYSTEM_FINANCIAL_REPORT` |
|
||||||
|
|
||||||
|
### 6.3 New Services
|
||||||
|
|
||||||
|
| Service | Responsibility |
|
||||||
|
|---------|---------------|
|
||||||
|
| `FinanceService` | Fee schedule CRUD, payment/expense recording, balance calculation |
|
||||||
|
| `KassenbuchService` | Kassenbuch view generation, running balance, CSV export |
|
||||||
|
| `ReceiptPdfService` | Payment receipt PDF generation |
|
||||||
|
| `FinancialReportService` | Annual report generation (data + PDF) |
|
||||||
|
| `PaymentReminderService` | Overdue detection, reminder sending (uses NotificationDispatchService) |
|
||||||
|
| `AssemblyService` | MV lifecycle management (create → invite → start → complete) |
|
||||||
|
| `AssemblyVoteService` | Vote management and result recording |
|
||||||
|
| `ProtocolPdfService` | MV protocol PDF generation |
|
||||||
|
| `DocumentStorageService` | File upload/download, filesystem operations |
|
||||||
|
| `DocumentService` | Document metadata CRUD, access control |
|
||||||
|
| `BoardService` | Board position/member management |
|
||||||
|
| `BoardTermScheduler` | Scheduled job: checks for expiring terms, sends notifications |
|
||||||
|
|
||||||
|
### 6.4 New Staff Permissions
|
||||||
|
|
||||||
|
```java
|
||||||
|
MANAGE_FINANCES, // Record payments/expenses, view balances, generate reports
|
||||||
|
MANAGE_ASSEMBLY, // Create/manage MVs, record attendance/votes
|
||||||
|
MANAGE_DOCUMENTS, // Upload/manage documents
|
||||||
|
VIEW_FINANCES // Read-only access to financial data (for Kassenprüfer)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5 New Audit Event Types
|
||||||
|
|
||||||
|
```java
|
||||||
|
// Finance
|
||||||
|
PAYMENT_RECORDED,
|
||||||
|
PAYMENT_VOIDED,
|
||||||
|
EXPENSE_RECORDED,
|
||||||
|
EXPENSE_VOIDED,
|
||||||
|
FEE_SCHEDULE_CREATED,
|
||||||
|
FEE_SCHEDULE_UPDATED,
|
||||||
|
FEE_ASSIGNMENT_CHANGED,
|
||||||
|
PAYMENT_REMINDER_SENT,
|
||||||
|
FINANCIAL_REPORT_GENERATED,
|
||||||
|
|
||||||
|
// Assembly
|
||||||
|
ASSEMBLY_CREATED,
|
||||||
|
ASSEMBLY_INVITATIONS_SENT,
|
||||||
|
ASSEMBLY_STARTED,
|
||||||
|
ASSEMBLY_COMPLETED,
|
||||||
|
ASSEMBLY_CANCELLED,
|
||||||
|
ASSEMBLY_ATTENDANCE_RECORDED,
|
||||||
|
ASSEMBLY_VOTE_RECORDED,
|
||||||
|
ASSEMBLY_ELECTION_RECORDED,
|
||||||
|
ASSEMBLY_PROTOCOL_GENERATED,
|
||||||
|
ASSEMBLY_PROTOCOL_UPLOADED,
|
||||||
|
|
||||||
|
// Documents
|
||||||
|
DOCUMENT_UPLOADED,
|
||||||
|
DOCUMENT_UPDATED,
|
||||||
|
DOCUMENT_DELETED,
|
||||||
|
DOCUMENT_VERSION_CREATED,
|
||||||
|
|
||||||
|
// Board
|
||||||
|
BOARD_MEMBER_APPOINTED,
|
||||||
|
BOARD_MEMBER_TERM_ENDED
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.6 Flyway Migrations
|
||||||
|
|
||||||
|
| Version | Content |
|
||||||
|
|---------|---------|
|
||||||
|
| V18 | Vereinsfinanzen tables (fee_schedules, payments, expenses, etc.) |
|
||||||
|
| V19 | Mitgliederversammlung tables (general_assemblies, agenda, attendance, votes, elections) |
|
||||||
|
| V20 | Dokumentenarchiv tables (documents, document_categories) |
|
||||||
|
| V21 | Vorstandsverwaltung tables (board_positions, board_members) |
|
||||||
|
|
||||||
|
### 6.7 Mermaid: Entity Relationships
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
MEMBER ||--o{ PAYMENT : pays
|
||||||
|
MEMBER ||--o{ MEMBER_FEE_ASSIGNMENT : has
|
||||||
|
FEE_SCHEDULE ||--o{ MEMBER_FEE_ASSIGNMENT : defines
|
||||||
|
EXPENSE_CATEGORY ||--o{ EXPENSE : categorizes
|
||||||
|
MEMBER ||--o{ ASSEMBLY_ATTENDANCE : attends
|
||||||
|
GENERAL_ASSEMBLY ||--o{ ASSEMBLY_AGENDA_ITEM : contains
|
||||||
|
GENERAL_ASSEMBLY ||--o{ ASSEMBLY_ATTENDANCE : tracks
|
||||||
|
GENERAL_ASSEMBLY ||--o{ ASSEMBLY_VOTE : holds
|
||||||
|
GENERAL_ASSEMBLY ||--o{ ASSEMBLY_ELECTION : records
|
||||||
|
DOCUMENT_CATEGORY ||--o{ DOCUMENT : organizes
|
||||||
|
BOARD_POSITION ||--o{ BOARD_MEMBER : filled_by
|
||||||
|
MEMBER ||--o{ BOARD_MEMBER : serves_as
|
||||||
|
GENERAL_ASSEMBLY ||--o{ ASSEMBLY_ELECTION : determines
|
||||||
|
ASSEMBLY_ELECTION }|--|| BOARD_MEMBER : creates
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Legal References
|
||||||
|
|
||||||
|
| Law | Section | Requirement | Feature |
|
||||||
|
|-----|---------|-------------|---------|
|
||||||
|
| BGB | §26 | e.V. must have a Vorstand | Board Management |
|
||||||
|
| BGB | §27 | Vorstand appointed by MV | MV Elections |
|
||||||
|
| BGB | §32(1) | Decisions by majority of attending members | MV Voting |
|
||||||
|
| BGB | §33(1) | Satzungsänderung requires 3/4 majority | MV Vote Types |
|
||||||
|
| BGB | §36 | MV invitation with agenda in writing | MV Notifications |
|
||||||
|
| BGB | §58 Nr. 4 | Minutes of resolutions must be kept | MV Protocol |
|
||||||
|
| KCanG | §2(4) | Cannabis clubs must be organized as e.V. | All features |
|
||||||
|
| KCanG | §19 | Detailed record-keeping obligations | Financial audit trail |
|
||||||
|
| AO | §63 | Actual management (tatsächliche Geschäftsführung) | Kassenbuch, Board Management |
|
||||||
|
| AO | §66 | Proper bookkeeping for tax-exempt orgs | Financial reports |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Mobile Considerations
|
||||||
|
|
||||||
|
All features are designed mobile-first for the upcoming native app (Sprint 9+):
|
||||||
|
|
||||||
|
| Feature | Mobile UX |
|
||||||
|
|---------|-----------|
|
||||||
|
| Payment recording | Quick-entry form with amount + member selector |
|
||||||
|
| Balance check | Simple card showing outstanding amount |
|
||||||
|
| MV attendance | QR code check-in at the door (future) |
|
||||||
|
| Document view | PDF viewer embedded, download to device |
|
||||||
|
| Board view | Contact card style for board members |
|
||||||
|
| Voting | Large yes/no/abstain buttons during MV |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Risk Assessment
|
||||||
|
|
||||||
|
| Risk | Probability | Impact | Mitigation |
|
||||||
|
|------|-------------|--------|------------|
|
||||||
|
| Financial data loss | Low | Critical | Append-only design, daily DB backups, no delete operations |
|
||||||
|
| Incorrect balance calculation | Medium | High | Extensive unit tests, manual verification workflow |
|
||||||
|
| MV legal non-compliance | Low | High | Notice period validation, quorum calculation, protocol template vetted |
|
||||||
|
| File storage disk full | Low | Medium | Tier-based quotas, monitoring alert at 80% |
|
||||||
|
| Permission confusion | Medium | Medium | Clear UI showing who can do what, role-based defaults |
|
||||||
|
| PDF generation failure | Low | Medium | Fallback: show data in UI, allow retry |
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
# Sprint 8 Plan Review — 6-Expert Panel Analysis
|
||||||
|
|
||||||
|
**Date:** 2026-06-13
|
||||||
|
**Document reviewed:** `cannamanage-sprint8-plan.md` (v2 — with legal citations)
|
||||||
|
**Review method:** 6-persona structured review with scoring
|
||||||
|
**Target:** ≥90% (average score ≥9.0/10)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 🏛️ Domain Expert — Cannabis Club Operator (Vereinsvorstand)
|
||||||
|
|
||||||
|
*Persona: Klaus, 52, runs "Grüner Daumen e.V." with 87 members. Kassenwart, handles all finances manually today.*
|
||||||
|
|
||||||
|
### Assessment
|
||||||
|
|
||||||
|
This plan addresses the four biggest operational pain points I face daily:
|
||||||
|
|
||||||
|
- **Beitragsverwaltung (Fee Management):** The fee schedule system with configurable intervals (monthly/quarterly/yearly/one-time) maps perfectly to how real clubs work. We have a standard €30/month plus a one-time Aufnahmegebühr of €50. The `MemberFeeAssignment` with `effective_from/effective_until` handles the real-world case where we change fees at the Jahreshauptversammlung and need to track which rate applies to which period. The §58 Nr. 2 BGB citation is spot-on — our Satzung defines contribution rules, and this system enforces them.
|
||||||
|
|
||||||
|
- **Kassenbuch:** The chronological income/expense view with running balance is exactly what I show the Kassenprüfer at the end of the year. The CSV export for my Steuerberater is a practical touch. The separation of `payments` (Einnahmen from members) and `expenses` (Ausgaben by the club) matches standard Vereinsbuchhaltung. The legal citation of §259 BGB (Rechenschaftspflicht) is correct — at every MV, the Kassenwart must present the financial report.
|
||||||
|
|
||||||
|
- **Mitgliederversammlung:** The lifecycle (DRAFT → INVITED → IN_PROGRESS → COMPLETED) mirrors exactly how I run MVs today. The notice period validation (§36 BGB, typically 14 days per our Satzung) catches a common mistake — boards sometimes call MVs too late. The quorum calculation is critical — without quorum, no valid decisions. The 75% majority for Satzungsänderung (§33 BGB) and the different VoteTypes show understanding of real MV dynamics.
|
||||||
|
|
||||||
|
- **Vorstandsverwaltung:** Term tracking with expiry notifications is something we desperately need. Last year we almost missed re-electing our Vorstand because nobody tracked term dates. The mandatory Präventionsbeauftragter position (§23 KCanG) shows cannabis-specific awareness.
|
||||||
|
|
||||||
|
**One practical gap:** The plan doesn't mention handling of *Stimmrechtsübertragung* (proxy voting). §32 BGB allows Satzungen to permit written proxy — many clubs do this for members who can't attend. This is a should-have for v2, not a blocker for v1.
|
||||||
|
|
||||||
|
**Another minor point:** The `quorum_percentage` default of 50% is correct for most Satzungen, but some clubs set it to 25% or "keine Mindestzahl" (§40 BGB allows this override). The configurability is there — good.
|
||||||
|
|
||||||
|
### Scores
|
||||||
|
|
||||||
|
| Criterion | Score | Notes |
|
||||||
|
|-----------|-------|-------|
|
||||||
|
| Precision | 9/10 | Fee intervals, receipt numbering, Kassenbuch format all precisely specified. Minor gap: proxy voting not mentioned. |
|
||||||
|
| Correctness | 10/10 | All Vereinsrecht citations accurate. §58 Nr. 2 for fees, §32/§33 for MV, §26/§27 for board — all correct. |
|
||||||
|
| Usability | 9/10 | Portal finance view lets members self-serve. MV live page with real-time quorum display is excellent. |
|
||||||
|
| Usefulness | 10/10 | Solves the top 4 operational pain points for any cannabis club Vorstand. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 🔧 Architecture Expert
|
||||||
|
|
||||||
|
*Persona: Senior backend engineer evaluating technical design quality, scalability, and maintainability.*
|
||||||
|
|
||||||
|
### Assessment
|
||||||
|
|
||||||
|
The architecture follows the established CannaManage patterns consistently and introduces no new anti-patterns:
|
||||||
|
|
||||||
|
- **Entity hierarchy:** All new entities extend `AbstractTenantEntity` — correct multi-tenancy pattern. The UUID PKs with `gen_random_uuid()` maintain the established approach.
|
||||||
|
|
||||||
|
- **Service layer separation:** Clean separation between `FinanceService` (CRUD + balance calc), `KassenbuchService` (read model), `ReceiptPdfService` (PDF generation), and `FinancialReportService` (annual aggregation). No god-class risk.
|
||||||
|
|
||||||
|
- **Append-only financial model:** The decision to use `voided` flag + `voided_reason` instead of DELETE is architecturally sound for financial data. This satisfies both §147 AO (retention) and audit trail requirements. The immutable receipt number sequence (`CM-{year}-{seq:06d}`) prevents gaps in the numbering.
|
||||||
|
|
||||||
|
- **Assembly lifecycle state machine:** The explicit status transitions (DRAFT → INVITED → IN_PROGRESS → COMPLETED/CANCELLED) with guards (`if (assembly.getStatus() != AssemblyStatus.DRAFT)`) prevent invalid transitions. This is a clean state machine implementation.
|
||||||
|
|
||||||
|
- **Auto-archive integration (Step 5.3):** The pattern of storing system-generated documents (MV protocols, annual reports) automatically into the document archive is elegant. It creates a single source of truth with `DocumentSource` enum distinguishing manual uploads from system-generated files.
|
||||||
|
|
||||||
|
- **Scheduler design:** Both schedulers (`PaymentReminderScheduler` weekly, `BoardTermScheduler` monthly) use `@Scheduled` with appropriate cron expressions. The tier check (`PlanTier.STARTER` skip) prevents unnecessary processing for basic-tier clubs.
|
||||||
|
|
||||||
|
**Architectural concerns (non-blocking):**
|
||||||
|
|
||||||
|
1. **DocumentStorageService uses local filesystem:** The `@Value("${cannamanage.storage.base-path:/uploads}")` is fine for single-instance deployment (current architecture), but would need an abstraction interface (S3/MinIO adapter) for horizontal scaling. This is correctly out of scope for Sprint 8 (single Docker host deployment).
|
||||||
|
|
||||||
|
2. **Receipt number generation race condition:** The `findTopByTenantIdAndReceiptNumberStartsWithOrderByReceiptNumberDesc` approach could produce duplicates under concurrent writes. A `SELECT FOR UPDATE` or `@Version` optimistic locking would be safer. Low risk for cannabis clubs (low write volume), but worth noting.
|
||||||
|
|
||||||
|
3. **Assembly vote recording is after-the-fact:** Votes are recorded with totals (yes/no/abstain) rather than per-member ballots. This is explicitly a design choice ("recorded voting, not live") — correct for v1, but means you can't retroactively verify vote tallies. Acceptable tradeoff clearly documented.
|
||||||
|
|
||||||
|
### Scores
|
||||||
|
|
||||||
|
| Criterion | Score | Notes |
|
||||||
|
|-----------|-------|-------|
|
||||||
|
| Precision | 9/10 | Complete entity schemas, query examples, service method signatures all specified. Receipt race condition noted. |
|
||||||
|
| Correctness | 9/10 | Clean patterns, proper state machine, correct multi-tenancy. Filesystem storage acceptable for current deployment. |
|
||||||
|
| Usability | 9/10 | Follows established patterns — low cognitive overhead for developers. Clean service separation. |
|
||||||
|
| Usefulness | 9/10 | Builds naturally on existing infrastructure. Auto-archive integration is particularly elegant. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 🛡️ Security & Privacy Expert
|
||||||
|
|
||||||
|
*Persona: Data protection officer evaluating GDPR compliance, security posture, and data handling.*
|
||||||
|
|
||||||
|
### Assessment
|
||||||
|
|
||||||
|
The plan demonstrates strong awareness of data protection requirements, especially the tension between retention obligations and erasure rights:
|
||||||
|
|
||||||
|
- **DSGVO Article 6 legal bases:** Correctly identified — Art. 6(1)(b) for payment processing (contract performance), Art. 6(1)(c) for retention (legal obligation via §147 AO + §24 KCanG), Art. 6(1)(f) for reminders (legitimate interest). This is the correct three-pronged approach.
|
||||||
|
|
||||||
|
- **Retention Policy Matrix:** The explicit table mapping data types to retention periods with legal basis citations is excellent. The hierarchy is correct: §147 AO (10 years) > §24 KCanG (5 years) > Art. 17 DSGVO (erasure right). Financial data CANNOT be deleted before 10 years even on member request — the plan correctly identifies this.
|
||||||
|
|
||||||
|
- **Art. 15 DSGVO compliance:** The portal "Meine Zahlungen" view with full payment history + receipt downloads directly satisfies the member's right of access. Members can see all data processed about them without needing to file a formal request.
|
||||||
|
|
||||||
|
- **Document access control:** The `DocumentAccessLevel` enum (ALL_MEMBERS vs BOARD_ONLY) with access check enforcement prevents unauthorized access to board-only documents (e.g., contracts, insurance policies). The tenant isolation (`tenant_id` on all tables) prevents cross-club data access.
|
||||||
|
|
||||||
|
- **Financial data sensitivity:** Payment amounts and frequency reveal economic circumstances — this is sensitive personal data. The plan correctly restricts access via `MANAGE_FINANCES` / `VIEW_FINANCES` permissions. Members see only their own data via portal endpoints.
|
||||||
|
|
||||||
|
- **File upload validation:** The `DocumentStorageService` validates both file size (20MB max) and MIME type (whitelisted set). This prevents both storage abuse and malicious file uploads. The path construction (`basePath/documents/{tenantId}/{documentId}/filename`) uses UUIDs, preventing directory traversal.
|
||||||
|
|
||||||
|
**Security concerns (non-blocking, should be addressed):**
|
||||||
|
|
||||||
|
1. **Receipt PDF download authorization check:** Step 1.8 mentions "Verify payment belongs to current member" but doesn't show the implementation. This must check `payment.memberId == currentUser.memberId` — a missing check would leak other members' receipts. The comment is there, so the intent is correct; implementation must be explicit.
|
||||||
|
|
||||||
|
2. **Expense receipt_path storage:** The `receipt_path` in the expenses table stores filesystem paths. If these are ever exposed in API responses, they could reveal server directory structure. Should return only the document ID, never the raw path.
|
||||||
|
|
||||||
|
3. **Void operation audit trail:** The `voidPayment()` and `voidExpense()` methods correctly record `voided_reason` and `voided_at`, with the `AuditService` logging the actor. This prevents fraudulent void operations from going undetected. Good.
|
||||||
|
|
||||||
|
4. **No PII in payment reminders beyond what's necessary:** The `PaymentReminder` entity stores `amount_due` and `reminder_type` — no sensitive data beyond what's operationally required. The actual notification content (sent via `NotificationDispatchService`) should use the member's preferred channel without logging the full message body. This is handled by the existing notification infrastructure.
|
||||||
|
|
||||||
|
### Scores
|
||||||
|
|
||||||
|
| Criterion | Score | Notes |
|
||||||
|
|-----------|-------|-------|
|
||||||
|
| Precision | 9/10 | Retention matrix with exact legal citations is exemplary. Access control mechanisms specified. |
|
||||||
|
| Correctness | 9/10 | DSGVO bases correct, retention hierarchy correct, tenant isolation maintained. Receipt auth noted. |
|
||||||
|
| Usability | 9/10 | Portal self-service reduces DSGVO request burden. Permission model is clear. |
|
||||||
|
| Usefulness | 10/10 | Proactive compliance design. The retention matrix alone saves hours of legal review. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 👤 UX Designer
|
||||||
|
|
||||||
|
*Persona: Product designer evaluating user flows, discoverability, and friction points for non-technical cannabis club staff.*
|
||||||
|
|
||||||
|
### Assessment
|
||||||
|
|
||||||
|
The frontend designs show strong understanding of the target user (club Kassenwart, Vorstand — often non-technical, 40-60 age range):
|
||||||
|
|
||||||
|
- **Finance Dashboard wireframe:** The three-card summary (Einnahmen / Ausgaben / Saldo) gives instant overview. The tab structure (Kassenbuch / Zahlungen / Ausgaben / Offene Beiträge / Berichte) is logical and discoverable. A Kassenwart opening this page immediately knows where to find what they need.
|
||||||
|
|
||||||
|
- **Receipt generation:** The one-click receipt PDF download (📄 icon) in the payment history is excellent UX. No separate flow needed — record payment, receipt auto-generated with sequential number. The receipt layout itself (Quittung format) follows standard German business document conventions.
|
||||||
|
|
||||||
|
- **Portal finance view:** Members see a clean, anxiety-reducing display — green "✅ Aktuell" when paid up, clear "Nächster Beitrag: 01.08.2026" for upcoming. The receipt download is self-service, reducing admin work.
|
||||||
|
|
||||||
|
- **MV Live management page:** The real-time attendance counter + quorum progress is a standout feature. The wireframe shows a search bar for quick member check-in — critical when 50+ members are filing in. The tab structure (Anwesenheit / Abstimmungen / Wahlen) mirrors the chronological flow of a real MV.
|
||||||
|
|
||||||
|
- **Navigation structure:** The permission-gated nav items (Finance only visible to MANAGE_FINANCES holders) prevent confusion for non-authorized users.
|
||||||
|
|
||||||
|
**UX concerns:**
|
||||||
|
|
||||||
|
1. **Expense recording flow:** No wireframe shown for expense recording. How does the Kassenwart attach a receipt scan? Is it a camera capture on mobile? Drag-and-drop on desktop? The `receipt_path` field exists but the upload UX isn't specified.
|
||||||
|
|
||||||
|
2. **Fee assignment bulk action:** If a club has 80 members and a new fee schedule, assigning one-by-one is painful. A "assign to all active members" bulk action would be expected. Not shown in the plan.
|
||||||
|
|
||||||
|
3. **Payment recording: member search:** When the Kassenwart records a payment, they need to search/select the member. The plan shows `member_id` in the request but doesn't specify the member picker UX. A typeahead search (matching Sprint 6 member picker patterns) would be expected.
|
||||||
|
|
||||||
|
4. **MV attendance: offline handling:** What happens if WiFi drops during the MV? The live management page needs at minimum a local state buffer. Not discussed.
|
||||||
|
|
||||||
|
These are all solvable within the existing design patterns — they're refinements, not architectural gaps.
|
||||||
|
|
||||||
|
### Scores
|
||||||
|
|
||||||
|
| Criterion | Score | Notes |
|
||||||
|
|-----------|-------|-------|
|
||||||
|
| Precision | 8/10 | Core wireframes provided, but expense UX, bulk actions, and offline handling not specified |
|
||||||
|
| Correctness | 9/10 | Finance dashboard, portal view, MV live page all follow correct UX patterns for target audience |
|
||||||
|
| Usability | 9/10 | Self-service portal, real-time quorum, one-click receipts — strong for non-technical users |
|
||||||
|
| Usefulness | 9/10 | Directly reduces daily admin burden. Permission-gated nav prevents confusion. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 💰 Business/Product Owner
|
||||||
|
|
||||||
|
*Persona: Founder/CEO evaluating market differentiation, revenue potential, and competitive positioning.*
|
||||||
|
|
||||||
|
### Assessment
|
||||||
|
|
||||||
|
Sprint 8 delivers the "complete Vereinsverwaltung" promise that differentiates CannaManage from generic club management tools:
|
||||||
|
|
||||||
|
- **Competitive moat:** No existing cannabis club management tool offers integrated Kassenbuch + MV management + document archive with KCanG-specific compliance. This is the feature set that makes clubs choose CannaManage over spreadsheets + email + manual filing.
|
||||||
|
|
||||||
|
- **Tier monetization potential:**
|
||||||
|
- **Starter:** Basic fee tracking (2 schedules max, 50 expenses/year, 100MB docs) — enough to get started
|
||||||
|
- **Pro:** Full Kassenbuch, reminders, unlimited expenses, 1GB docs — the natural upgrade trigger
|
||||||
|
- **Enterprise:** Full MV features, auto-archive, board management — for clubs >100 members
|
||||||
|
|
||||||
|
The plan explicitly defines tier limits in Step 5.4. The payment reminder feature being Pro-only is a smart upsell trigger — clubs outgrow Starter when they need automated collection.
|
||||||
|
|
||||||
|
- **Onboarding friction reduction:** Step 5.5 (seed defaults) means a new club gets pre-configured expense categories, document categories, and board positions on creation. Zero configuration needed to start using treasury features. This dramatically reduces time-to-value.
|
||||||
|
|
||||||
|
- **Regulatory "must-have" positioning:** The legal citations throughout (§22 KCanG, §26 KCanG, §147 AO) position CannaManage as not just convenient but legally necessary. Marketing angle: "Stay compliant with KCanG documentation requirements automatically."
|
||||||
|
|
||||||
|
- **Network effects for growth:** MV invitations go to ALL members via the notification system. Even members who only use the portal passively now get pulled into engagement. The portal finance view (see your balance, download receipts) gives every member a reason to log in regularly.
|
||||||
|
|
||||||
|
**Business concerns:**
|
||||||
|
|
||||||
|
1. **Kassenbuch is NOT double-entry bookkeeping:** The plan explicitly states "simple Einnahmen/Ausgaben (not double-entry)." This is correct for 95% of clubs (under §141 AO thresholds), but larger clubs or those that eventually need a Steuerberater export to DATEV would need more. This is a future concern, not a Sprint 8 blocker.
|
||||||
|
|
||||||
|
2. **No SEPA integration:** Tracking-only (no payment initiation). This means the Kassenwart still manually matches bank transactions to recorded payments. A SEPA direct debit integration would be a significant Pro-tier feature for Sprint 10+. The plan correctly defers this.
|
||||||
|
|
||||||
|
3. **Document storage cost:** At 1GB per Pro club, with 100 Pro clubs that's 100GB. At current VPS pricing this is negligible. Enterprise at unlimited would need monitoring, but real clubs rarely exceed 500MB of PDFs.
|
||||||
|
|
||||||
|
### Scores
|
||||||
|
|
||||||
|
| Criterion | Score | Notes |
|
||||||
|
|-----------|-------|-------|
|
||||||
|
| Precision | 9/10 | Tier limits explicit, seed defaults specified, monetization triggers clear |
|
||||||
|
| Correctness | 9/10 | Market positioning sound, no double-entry is correct for target market, SEPA correctly deferred |
|
||||||
|
| Usability | 9/10 | Zero-config onboarding, permission-gated progressive disclosure |
|
||||||
|
| Usefulness | 10/10 | This sprint makes CannaManage the only complete cannabis club management platform in Germany |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. ⚖️ Compliance Officer (BGB + KCanG + AO + DSGVO)
|
||||||
|
|
||||||
|
*Persona: Dr. Martina Schäfer, compliance consultant specializing in Vereinsrecht and cannabis regulatory law.*
|
||||||
|
|
||||||
|
### Assessment
|
||||||
|
|
||||||
|
The plan demonstrates thorough understanding of the legal framework governing cannabis Anbauvereinigungen. My review of the legal citations:
|
||||||
|
|
||||||
|
**Vereinsrecht (BGB) — CORRECT:**
|
||||||
|
- §58 Nr. 2 BGB correctly cited as basis for fee schedules — Satzung must regulate Beitragsregelung ✅
|
||||||
|
- §259 BGB (Rechenschaftspflicht) as basis for Kassenbuch — correct, this obligates the Vorstand to account for finances ✅
|
||||||
|
- §666 BGB (Auskunftspflicht) for member finance portal — correct, analogous application through §27 Abs. 3 BGB ✅
|
||||||
|
- §32 BGB for MV decisions — correct, the MV is the supreme organ ✅
|
||||||
|
- §33 BGB for qualified majorities — correctly modeled as VoteType.TWO_THIRDS (though §33 actually requires 75%, not 2/3 — these are different fractions: 75% ≠ 66.67%) ⚠️
|
||||||
|
- §36 BGB for notice period — correct, though the BGB itself doesn't specify a minimum period (that's Satzung) ✅
|
||||||
|
- §37 BGB for Minderheitsverlangen (10%) — correctly noted as future hook ✅
|
||||||
|
- §27 BGB for board elections at MV — correct ✅
|
||||||
|
- §67 BGB for protocol requirements — partially correct. §67 regulates the Vereinsregister, not protocols directly. The protocol requirement comes from §26 BGB (Vertretungsmacht must be registered, requiring evidence of election = protocol). The connection is there but the citation path should be more precise. ⚠️
|
||||||
|
|
||||||
|
**AO (Tax) — CORRECT:**
|
||||||
|
- §147 AO retention periods: 10 years for Buchungsbelege (§147 Abs. 1 Nr. 4 AO), 6 years for Geschäftsbriefe (§147 Abs. 1 Nr. 2 AO) — both correctly applied ✅
|
||||||
|
- §141 AO threshold for Buchführungspflicht (€600,000/€60,000) — correctly noted as unlikely for most clubs ✅
|
||||||
|
- Correct identification that cannabis clubs are generally NOT gemeinnützig — §51ff AO requirements (Selbstlosigkeit, Unmittelbarkeit) are typically not met by cannabis clubs whose primary purpose is member benefit ✅
|
||||||
|
|
||||||
|
**KCanG — CORRECT:**
|
||||||
|
- §22 KCanG documentation requirements — correctly drives audit trail design ✅
|
||||||
|
- §23 KCanG Präventionsbeauftragter — correctly included as mandatory board position ✅
|
||||||
|
- §24 KCanG 5-year retention — correctly applied to member-specific cannabis data ✅
|
||||||
|
- §26 KCanG oversight — correctly drives "records producible on demand" requirement ✅
|
||||||
|
|
||||||
|
**DSGVO — CORRECT:**
|
||||||
|
- Art. 6(1)(b) for payment processing — correct legal basis for contract performance ✅
|
||||||
|
- Art. 6(1)(c) for retention — correct, §147 AO and §24 KCanG constitute "legal obligation" ✅
|
||||||
|
- Art. 6(1)(f) for reminders — correct, legitimate interest, but requires documented balancing test (Interessenabwägung) ⚠️
|
||||||
|
- Art. 15 satisfaction via portal — pragmatic and correct approach ✅
|
||||||
|
- Art. 17 override by retention obligations — correctly identified hierarchy ✅
|
||||||
|
|
||||||
|
**Findings requiring correction:**
|
||||||
|
|
||||||
|
1. **§33 BGB majority fraction:** The plan uses `VoteType.TWO_THIRDS` but §33 BGB requires "eine Mehrheit von drei Vierteln der erschienenen Mitglieder" (75%) for Satzungsänderung. 2/3 ≠ 3/4. The VoteType enum should distinguish between `SIMPLE_MAJORITY` (>50%), `THREE_QUARTERS` (75%, §33 BGB), and `UNANIMOUS`. Alternatively rename `TWO_THIRDS` to `QUALIFIED_MAJORITY` and make the threshold configurable per vote.
|
||||||
|
|
||||||
|
2. **Art. 6(1)(f) DSGVO for reminders requires Interessenabwägung:** When relying on legitimate interest, GDPR requires a documented balancing test weighing the club's interest (fee collection) against the member's interest (not being nagged). The plan should note that the privacy policy must document this balance, and reminders should have a reasonable frequency cap (the plan has weekly check + escalation tiers, which is reasonable, but should be explicitly tied to the balancing test).
|
||||||
|
|
||||||
|
3. **Protocol legal basis precision:** The duty to produce MV protocols doesn't come directly from §67 BGB (which covers Vereinsregister content). It comes from: (a) practical necessity — §67 requires Anmeldung of Vorstandsänderungen to the Registergericht, which requires proof = protocol, (b) established Vereinsrechtspraxis (unwritten duty of Beweissicherung), and (c) most Satzungen explicitly require it. The plan should cite "§67 BGB i.V.m. Vereinsrechtspraxis" rather than §67 alone.
|
||||||
|
|
||||||
|
**These are refinements, not plan blockers.** The overall legal framework is solid and demonstrates significantly above-average legal awareness for a software implementation plan.
|
||||||
|
|
||||||
|
### Scores
|
||||||
|
|
||||||
|
| Criterion | Score | Notes |
|
||||||
|
|-----------|-------|-------|
|
||||||
|
| Precision | 9/10 | Comprehensive legal framework with specific article citations. §33 fraction and §67 path minor issues. |
|
||||||
|
| Correctness | 9/10 | Legal bases correctly identified. One fraction error (2/3 vs 3/4). Retention hierarchy correct. |
|
||||||
|
| Usability | 9/10 | Retention policy matrix is immediately actionable. Legal basis per phase makes compliance auditable. |
|
||||||
|
| Usefulness | 10/10 | This plan would pass a regulatory review with minimal revisions. Exemplary for a software plan. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Consolidated Scores
|
||||||
|
|
||||||
|
| Expert | Precision | Correctness | Usability | Usefulness | Average |
|
||||||
|
|--------|-----------|-------------|-----------|------------|---------|
|
||||||
|
| 🏛️ Domain Expert | 9 | 10 | 9 | 10 | **9.50** |
|
||||||
|
| 🔧 Architecture Expert | 9 | 9 | 9 | 9 | **9.00** |
|
||||||
|
| 🛡️ Security & Privacy Expert | 9 | 9 | 9 | 10 | **9.25** |
|
||||||
|
| 👤 UX Designer | 8 | 9 | 9 | 9 | **8.75** |
|
||||||
|
| 💰 Business/Product Owner | 9 | 9 | 9 | 10 | **9.25** |
|
||||||
|
| ⚖️ Compliance Officer | 9 | 9 | 9 | 10 | **9.25** |
|
||||||
|
|
||||||
|
### Overall Average: **9.17 / 10 (91.7%)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verdict: ✅ APPROVED (≥90% threshold met)
|
||||||
|
|
||||||
|
The Sprint 8 implementation plan achieves a **91.7% quality score** across all six expert perspectives.
|
||||||
|
|
||||||
|
### Summary of Findings
|
||||||
|
|
||||||
|
**Strengths:**
|
||||||
|
- Comprehensive legal framework with specific BGB/AO/KCanG/DSGVO citations per feature
|
||||||
|
- Clean architecture following established patterns (AbstractTenantEntity, service layer separation, append-only financials)
|
||||||
|
- Strong regulatory compliance (retention matrix, tenant isolation, audit trail)
|
||||||
|
- High market differentiation potential (only complete cannabis club management platform in DE)
|
||||||
|
- Good UX decisions for non-technical target users (self-service portal, real-time quorum, one-click receipts)
|
||||||
|
|
||||||
|
**Must-acknowledge (not blocking, should be addressed during implementation):**
|
||||||
|
|
||||||
|
| # | Finding | Expert | Priority | Recommendation |
|
||||||
|
|---|---------|--------|----------|----------------|
|
||||||
|
| 1 | §33 BGB requires 75% not 2/3 | ⚖️ Compliance | Medium | Rename `TWO_THIRDS` to `THREE_QUARTERS` or make threshold configurable per VoteType |
|
||||||
|
| 2 | Receipt auth check implementation | 🛡️ Security | Medium | Ensure explicit `memberId == currentUser` check in `PortalFinanceController.downloadReceipt()` |
|
||||||
|
| 3 | Expense recording UX not wireframed | 👤 UX | Low | Add camera capture (mobile) + drag-and-drop (desktop) for receipt attachment during implementation |
|
||||||
|
| 4 | Fee assignment bulk action | 👤 UX | Low | Add "Assign to all active members" button in fee schedule management |
|
||||||
|
| 5 | Art. 6(1)(f) balancing test | ⚖️ Compliance | Low | Document Interessenabwägung in privacy policy for payment reminders |
|
||||||
|
| 6 | Receipt number race condition | 🔧 Architecture | Low | Consider `SELECT FOR UPDATE` or database sequence for receipt number generation |
|
||||||
|
| 7 | Protocol citation precision | ⚖️ Compliance | Low | Update to "§67 BGB i.V.m. Vereinsrechtspraxis" |
|
||||||
|
| 8 | Proxy voting (Stimmrechtsübertragung) | 🏛️ Domain | Future | Not for Sprint 8 — add to Sprint 9/10 backlog |
|
||||||
|
|
||||||
|
**Confidence level:** 92% (high confidence — plan is implementation-ready with the noted refinements addressable during coding)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommendation
|
||||||
|
|
||||||
|
**✅ GO — Proceed to implementation.**
|
||||||
|
|
||||||
|
The plan is comprehensive, legally grounded, architecturally sound, and commercially viable. The 8 findings above are refinements that can be addressed during implementation without re-planning. No blocking issues identified.
|
||||||
@@ -0,0 +1,988 @@
|
|||||||
|
# Sprint 8 Test Plan — Vereinsverwaltung Complete
|
||||||
|
|
||||||
|
**Date:** 2026-06-13
|
||||||
|
**Author:** Patrick Plate / Lumen (Architect)
|
||||||
|
**Status:** Draft v1
|
||||||
|
**Based on:** `cannamanage-sprint8-plan.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Overview
|
||||||
|
|
||||||
|
| ID | Description | Type | Class/File | Status |
|
||||||
|
|----|-------------|------|-----------|--------|
|
||||||
|
| T-01 | Fee schedule creation persists correctly | Unit | `FinanceServiceTest` | ⬜ |
|
||||||
|
| T-02 | Fee schedule update marks timestamp | Unit | `FinanceServiceTest` | ⬜ |
|
||||||
|
| T-03 | Fee schedule deactivation hides from listing | Unit | `FinanceServiceTest` | ⬜ |
|
||||||
|
| T-04 | Member fee assignment links correctly | Unit | `FinanceServiceTest` | ⬜ |
|
||||||
|
| T-05 | Fee assignment effective_until ends previous assignment | Unit | `FinanceServiceTest` | ⬜ |
|
||||||
|
| T-06 | Payment recording generates sequential receipt number | Unit | `FinanceServiceTest` | ⬜ |
|
||||||
|
| T-07 | Payment recording calculates correct receipt format CM-YYYY-NNNNNN | Unit | `FinanceServiceTest` | ⬜ |
|
||||||
|
| T-08 | Payment voiding sets voided flag and reason (no delete) | Unit | `FinanceServiceTest` | ⬜ |
|
||||||
|
| T-09 | Voided payment excluded from balance calculation | Unit | `FinanceServiceTest` | ⬜ |
|
||||||
|
| T-10 | Expense recording persists with category | Unit | `FinanceServiceTest` | ⬜ |
|
||||||
|
| T-11 | Expense voiding sets voided flag (no delete) | Unit | `FinanceServiceTest` | ⬜ |
|
||||||
|
| T-12 | Balance calculation: no fee assigned returns zero | Unit | `FinanceServiceTest` | ⬜ |
|
||||||
|
| T-13 | Balance calculation: monthly fee accumulates correctly | Unit | `FinanceServiceTest` | ⬜ |
|
||||||
|
| T-14 | Balance calculation: partial payment shows outstanding | Unit | `FinanceServiceTest` | ⬜ |
|
||||||
|
| T-15 | Balance calculation: overpayment shows credit | Unit | `FinanceServiceTest` | ⬜ |
|
||||||
|
| T-16 | Balance calculation: quarterly fee interval correct | Unit | `FinanceServiceTest` | ⬜ |
|
||||||
|
| T-17 | Balance calculation: one-time fee no accumulation | Unit | `FinanceServiceTest` | ⬜ |
|
||||||
|
| T-18 | Kassenbuch running balance correct across mixed transactions | Unit | `KassenbuchServiceTest` | ⬜ |
|
||||||
|
| T-19 | Kassenbuch chronological ordering (payments + expenses merged) | Unit | `KassenbuchServiceTest` | ⬜ |
|
||||||
|
| T-20 | Kassenbuch opening balance calculated from prior period | Unit | `KassenbuchServiceTest` | ⬜ |
|
||||||
|
| T-21 | Kassenbuch CSV export produces valid format | Unit | `KassenbuchServiceTest` | ⬜ |
|
||||||
|
| T-22 | Kassenbuch excludes voided entries | Unit | `KassenbuchServiceTest` | ⬜ |
|
||||||
|
| T-23 | Receipt PDF generation produces valid PDF bytes | Unit | `ReceiptPdfServiceTest` | ⬜ |
|
||||||
|
| T-24 | Receipt PDF contains member name and amount | Unit | `ReceiptPdfServiceTest` | ⬜ |
|
||||||
|
| T-25 | Receipt PDF formats Euro correctly (comma decimal) | Unit | `ReceiptPdfServiceTest` | ⬜ |
|
||||||
|
| T-26 | Annual report totals match sum of individual transactions | Unit | `FinancialReportServiceTest` | ⬜ |
|
||||||
|
| T-27 | Annual report expense breakdown by category correct | Unit | `FinancialReportServiceTest` | ⬜ |
|
||||||
|
| T-28 | Annual report PDF generation succeeds | Unit | `FinancialReportServiceTest` | ⬜ |
|
||||||
|
| T-29 | Payment reminder detects overdue member (30+ days) | Unit | `PaymentReminderSchedulerTest` | ⬜ |
|
||||||
|
| T-30 | Payment reminder escalates: FIRST → SECOND → FINAL | Unit | `PaymentReminderSchedulerTest` | ⬜ |
|
||||||
|
| T-31 | Payment reminder skips Starter/Trial tier clubs | Unit | `PaymentReminderSchedulerTest` | ⬜ |
|
||||||
|
| T-32 | Payment reminder sends via NotificationDispatchService | Unit | `PaymentReminderSchedulerTest` | ⬜ |
|
||||||
|
| T-33 | Assembly creation persists with all fields | Unit | `AssemblyServiceTest` | ⬜ |
|
||||||
|
| T-34 | Assembly status transition DRAFT → INVITED on sendInvitations | Unit | `AssemblyServiceTest` | ⬜ |
|
||||||
|
| T-35 | Assembly invitation sends to all active members | Unit | `AssemblyServiceTest` | ⬜ |
|
||||||
|
| T-36 | Assembly invitation creates ClubEvent with type GENERAL_ASSEMBLY | Unit | `AssemblyServiceTest` | ⬜ |
|
||||||
|
| T-37 | Assembly notice period warning logged if too short | Unit | `AssemblyServiceTest` | ⬜ |
|
||||||
|
| T-38 | Assembly status transition INVITED → IN_PROGRESS on start | Unit | `AssemblyServiceTest` | ⬜ |
|
||||||
|
| T-39 | Assembly start rejected if status is not INVITED | Unit | `AssemblyServiceTest` | ⬜ |
|
||||||
|
| T-40 | Assembly member check-in records attendance | Unit | `AssemblyServiceTest` | ⬜ |
|
||||||
|
| T-41 | Assembly duplicate check-in rejected (unique constraint) | Unit | `AssemblyServiceTest` | ⬜ |
|
||||||
|
| T-42 | Assembly quorum calculation correct (attendees / total active) | Unit | `AssemblyServiceTest` | ⬜ |
|
||||||
|
| T-43 | Assembly quorum reached when percentage >= threshold | Unit | `AssemblyServiceTest` | ⬜ |
|
||||||
|
| T-44 | Assembly quorum not reached when percentage < threshold | Unit | `AssemblyServiceTest` | ⬜ |
|
||||||
|
| T-45 | Assembly vote creation linked to agenda item | Unit | `AssemblyServiceTest` | ⬜ |
|
||||||
|
| T-46 | Assembly vote result: SIMPLE_MAJORITY — yes > no passes | Unit | `AssemblyServiceTest` | ⬜ |
|
||||||
|
| T-47 | Assembly vote result: TWO_THIRDS — yes >= 2/3 passes | Unit | `AssemblyServiceTest` | ⬜ |
|
||||||
|
| T-48 | Assembly vote result: TWO_THIRDS — yes < 2/3 fails | Unit | `AssemblyServiceTest` | ⬜ |
|
||||||
|
| T-49 | Assembly election records member + position + vote count | Unit | `AssemblyServiceTest` | ⬜ |
|
||||||
|
| T-50 | Assembly election triggers BoardService appointment | Unit | `AssemblyServiceTest` | ⬜ |
|
||||||
|
| T-51 | Assembly completion transitions IN_PROGRESS → COMPLETED | Unit | `AssemblyServiceTest` | ⬜ |
|
||||||
|
| T-52 | Assembly cancellation transitions any status → CANCELLED | Unit | `AssemblyServiceTest` | ⬜ |
|
||||||
|
| T-53 | Assembly agenda items ordered by sort_order | Unit | `AssemblyServiceTest` | ⬜ |
|
||||||
|
| T-54 | Assembly agenda reorder updates sort_order correctly | Unit | `AssemblyServiceTest` | ⬜ |
|
||||||
|
| T-55 | Protocol PDF contains attendance list | Unit | `ProtocolPdfServiceTest` | ⬜ |
|
||||||
|
| T-56 | Protocol PDF contains vote results with counts | Unit | `ProtocolPdfServiceTest` | ⬜ |
|
||||||
|
| T-57 | Protocol PDF contains election results | Unit | `ProtocolPdfServiceTest` | ⬜ |
|
||||||
|
| T-58 | Protocol PDF auto-archived in documents on assembly complete | Unit | `AssemblyServiceTest` | ⬜ |
|
||||||
|
| T-59 | DocumentStorageService stores file in correct path | Unit | `DocumentStorageServiceTest` | ⬜ |
|
||||||
|
| T-60 | DocumentStorageService rejects file > 20MB | Unit | `DocumentStorageServiceTest` | ⬜ |
|
||||||
|
| T-61 | DocumentStorageService rejects disallowed content type | Unit | `DocumentStorageServiceTest` | ⬜ |
|
||||||
|
| T-62 | DocumentStorageService retrieves stored file correctly | Unit | `DocumentStorageServiceTest` | ⬜ |
|
||||||
|
| T-63 | DocumentStorageService deletes file from filesystem | Unit | `DocumentStorageServiceTest` | ⬜ |
|
||||||
|
| T-64 | DocumentService creates metadata record on upload | Unit | `DocumentServiceTest` | ⬜ |
|
||||||
|
| T-65 | DocumentService version increment links to previous version | Unit | `DocumentServiceTest` | ⬜ |
|
||||||
|
| T-66 | DocumentService access level filtering: board-only hidden from members | Unit | `DocumentServiceTest` | ⬜ |
|
||||||
|
| T-67 | DocumentService system documents tagged with correct source | Unit | `DocumentServiceTest` | ⬜ |
|
||||||
|
| T-68 | Board position creation persists correctly | Unit | `BoardServiceTest` | ⬜ |
|
||||||
|
| T-69 | Board member appointment creates active record | Unit | `BoardServiceTest` | ⬜ |
|
||||||
|
| T-70 | Board member end term sets is_active=false and ended_reason | Unit | `BoardServiceTest` | ⬜ |
|
||||||
|
| T-71 | Board current composition returns only active members | Unit | `BoardServiceTest` | ⬜ |
|
||||||
|
| T-72 | Board history returns all members (active + ended) | Unit | `BoardServiceTest` | ⬜ |
|
||||||
|
| T-73 | Board election result ends previous holder and appoints new | Unit | `BoardServiceTest` | ⬜ |
|
||||||
|
| T-74 | Board term expiry scheduler detects terms ending within 30 days | Unit | `BoardTermSchedulerTest` | ⬜ |
|
||||||
|
| T-75 | Board term expiry scheduler sends notification to admins | Unit | `BoardTermSchedulerTest` | ⬜ |
|
||||||
|
| T-76 | Tier enforcement: Starter limited to 2 fee schedules | Unit | `PlanTierServiceTest` | ⬜ |
|
||||||
|
| T-77 | Tier enforcement: Starter limited to 50 expenses/year | Unit | `PlanTierServiceTest` | ⬜ |
|
||||||
|
| T-78 | Tier enforcement: Starter 100MB document storage limit | Unit | `PlanTierServiceTest` | ⬜ |
|
||||||
|
| T-79 | Tier enforcement: Pro allows unlimited fee schedules | Unit | `PlanTierServiceTest` | ⬜ |
|
||||||
|
| T-80 | Finance API requires MANAGE_FINANCES permission | Integration | `FinanceControllerIntegrationTest` | ⬜ |
|
||||||
|
| T-81 | Finance API record payment returns 201 with receipt number | Integration | `FinanceControllerIntegrationTest` | ⬜ |
|
||||||
|
| T-82 | Finance API void payment returns 200 | Integration | `FinanceControllerIntegrationTest` | ⬜ |
|
||||||
|
| T-83 | Finance API balances endpoint returns all members | Integration | `FinanceControllerIntegrationTest` | ⬜ |
|
||||||
|
| T-84 | Finance API kassenbuch date range filter works | Integration | `FinanceControllerIntegrationTest` | ⬜ |
|
||||||
|
| T-85 | Finance API CSV export returns text/csv content type | Integration | `FinanceControllerIntegrationTest` | ⬜ |
|
||||||
|
| T-86 | Finance API annual report returns correct totals | Integration | `FinanceControllerIntegrationTest` | ⬜ |
|
||||||
|
| T-87 | Finance API receipt PDF download returns application/pdf | Integration | `FinanceControllerIntegrationTest` | ⬜ |
|
||||||
|
| T-88 | Portal finance API returns only current member payments | Integration | `PortalFinanceIntegrationTest` | ⬜ |
|
||||||
|
| T-89 | Portal finance API cannot access other member data | Integration | `PortalFinanceIntegrationTest` | ⬜ |
|
||||||
|
| T-90 | Portal finance API receipt download only for own payments | Integration | `PortalFinanceIntegrationTest` | ⬜ |
|
||||||
|
| T-91 | Assembly API requires MANAGE_ASSEMBLY permission | Integration | `AssemblyControllerIntegrationTest` | ⬜ |
|
||||||
|
| T-92 | Assembly API create returns 201 with ID | Integration | `AssemblyControllerIntegrationTest` | ⬜ |
|
||||||
|
| T-93 | Assembly API invite transitions status and sends notifications | Integration | `AssemblyControllerIntegrationTest` | ⬜ |
|
||||||
|
| T-94 | Assembly API check-in returns updated attendance count | Integration | `AssemblyControllerIntegrationTest` | ⬜ |
|
||||||
|
| T-95 | Assembly API quorum endpoint returns correct calculation | Integration | `AssemblyControllerIntegrationTest` | ⬜ |
|
||||||
|
| T-96 | Assembly API vote recording returns result calculation | Integration | `AssemblyControllerIntegrationTest` | ⬜ |
|
||||||
|
| T-97 | Assembly API protocol PDF returns valid PDF | Integration | `AssemblyControllerIntegrationTest` | ⬜ |
|
||||||
|
| T-98 | Assembly API complete transitions status | Integration | `AssemblyControllerIntegrationTest` | ⬜ |
|
||||||
|
| T-99 | Portal assembly API lists upcoming assemblies for member | Integration | `AssemblyControllerIntegrationTest` | ⬜ |
|
||||||
|
| T-100 | Document API upload returns 201 with metadata | Integration | `DocumentControllerIntegrationTest` | ⬜ |
|
||||||
|
| T-101 | Document API upload rejects oversized file (413) | Integration | `DocumentControllerIntegrationTest` | ⬜ |
|
||||||
|
| T-102 | Document API upload rejects invalid content type (415) | Integration | `DocumentControllerIntegrationTest` | ⬜ |
|
||||||
|
| T-103 | Document API download returns correct file | Integration | `DocumentControllerIntegrationTest` | ⬜ |
|
||||||
|
| T-104 | Document API requires MANAGE_DOCUMENTS permission | Integration | `DocumentControllerIntegrationTest` | ⬜ |
|
||||||
|
| T-105 | Portal document API only returns ALL_MEMBERS access level | Integration | `DocumentControllerIntegrationTest` | ⬜ |
|
||||||
|
| T-106 | Portal document API blocks BOARD_ONLY documents | Integration | `DocumentControllerIntegrationTest` | ⬜ |
|
||||||
|
| T-107 | Board API returns current composition | Integration | `BoardControllerIntegrationTest` | ⬜ |
|
||||||
|
| T-108 | Board API appoint requires Admin role | Integration | `BoardControllerIntegrationTest` | ⬜ |
|
||||||
|
| T-109 | Board API positions visible to any authenticated user | Integration | `BoardControllerIntegrationTest` | ⬜ |
|
||||||
|
| T-110 | Tenant isolation: Club A finance data invisible to Club B | Integration | `TenantIsolationFinanceTest` | ⬜ |
|
||||||
|
| T-111 | Tenant isolation: Club A documents invisible to Club B | Integration | `TenantIsolationFinanceTest` | ⬜ |
|
||||||
|
| T-112 | Tenant isolation: Club A assembly invisible to Club B | Integration | `TenantIsolationFinanceTest` | ⬜ |
|
||||||
|
| T-113 | E2E: Admin creates fee schedule, assigns to member | E2E | `finance.spec.ts` | ⬜ |
|
||||||
|
| T-114 | E2E: Admin records payment, receipt number appears | E2E | `finance.spec.ts` | ⬜ |
|
||||||
|
| T-115 | E2E: Admin downloads receipt PDF | E2E | `finance.spec.ts` | ⬜ |
|
||||||
|
| T-116 | E2E: Admin records expense with category | E2E | `finance.spec.ts` | ⬜ |
|
||||||
|
| T-117 | E2E: Kassenbuch shows running balance | E2E | `finance.spec.ts` | ⬜ |
|
||||||
|
| T-118 | E2E: Balance overview shows overdue members | E2E | `finance.spec.ts` | ⬜ |
|
||||||
|
| T-119 | E2E: Portal member sees payment history and balance | E2E | `portal-finance.spec.ts` | ⬜ |
|
||||||
|
| T-120 | E2E: Portal member downloads own receipt | E2E | `portal-finance.spec.ts` | ⬜ |
|
||||||
|
| T-121 | E2E: Admin creates MV with agenda items | E2E | `assemblies.spec.ts` | ⬜ |
|
||||||
|
| T-122 | E2E: Admin sends MV invitations | E2E | `assemblies.spec.ts` | ⬜ |
|
||||||
|
| T-123 | E2E: Admin checks in members at MV | E2E | `assemblies.spec.ts` | ⬜ |
|
||||||
|
| T-124 | E2E: Quorum indicator updates live | E2E | `assemblies.spec.ts` | ⬜ |
|
||||||
|
| T-125 | E2E: Admin records vote result | E2E | `assemblies.spec.ts` | ⬜ |
|
||||||
|
| T-126 | E2E: Admin completes MV and generates protocol | E2E | `assemblies.spec.ts` | ⬜ |
|
||||||
|
| T-127 | E2E: Portal member sees upcoming MV with agenda | E2E | `assemblies.spec.ts` | ⬜ |
|
||||||
|
| T-128 | E2E: Admin uploads document with category | E2E | `documents.spec.ts` | ⬜ |
|
||||||
|
| T-129 | E2E: Admin downloads uploaded document | E2E | `documents.spec.ts` | ⬜ |
|
||||||
|
| T-130 | E2E: Portal member sees public documents only | E2E | `documents.spec.ts` | ⬜ |
|
||||||
|
| T-131 | E2E: Admin assigns board position to member | E2E | `board.spec.ts` | ⬜ |
|
||||||
|
| T-132 | E2E: Portal member sees current board | E2E | `board.spec.ts` | ⬜ |
|
||||||
|
|
||||||
|
Status legend: ⬜ Pending | ✅ Passed | ❌ Failed | ⏭️ Skipped
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit Tests
|
||||||
|
|
||||||
|
### `FinanceServiceTest`
|
||||||
|
|
||||||
|
**File:** `cannamanage-service/src/test/java/de/cannamanage/service/FinanceServiceTest.java`
|
||||||
|
|
||||||
|
#### T-01: Fee schedule creation persists correctly
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
void testCreateFeeSchedule_persistsAllFields() {
|
||||||
|
FeeSchedule result = financeService.createFeeSchedule(
|
||||||
|
tenantId, "Regulär", new BigDecimal("30.00"), FeeInterval.MONTHLY, "Standard-Mitgliedsbeitrag");
|
||||||
|
|
||||||
|
assertNotNull(result.getId());
|
||||||
|
assertEquals("Regulär", result.getName());
|
||||||
|
assertEquals(new BigDecimal("30.00"), result.getAmount());
|
||||||
|
assertEquals(FeeInterval.MONTHLY, result.getInterval());
|
||||||
|
assertTrue(result.isActive());
|
||||||
|
verify(feeScheduleRepo).save(any(FeeSchedule.class));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### T-06: Payment recording generates sequential receipt number
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
void testRecordPayment_generatesSequentialReceiptNumber() {
|
||||||
|
// Setup: last receipt was CM-2026-000003
|
||||||
|
when(paymentRepo.findTopByTenantIdAndReceiptNumberStartsWithOrderByReceiptNumberDesc(
|
||||||
|
tenantId, "CM-2026-"))
|
||||||
|
.thenReturn(Optional.of(paymentWithReceipt("CM-2026-000003")));
|
||||||
|
|
||||||
|
Payment result = financeService.recordPayment(tenantId, memberId,
|
||||||
|
new BigDecimal("30.00"), LocalDate.now(), PaymentMethod.BANK_TRANSFER,
|
||||||
|
"Beitrag Juli", null, null, null, staffId);
|
||||||
|
|
||||||
|
assertEquals("CM-2026-000004", result.getReceiptNumber());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### T-08: Payment voiding sets flag and reason (no delete)
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
void testVoidPayment_setsVoidedFlagAndReason_neverDeletes() {
|
||||||
|
Payment existing = createTestPayment();
|
||||||
|
when(paymentRepo.findById(existing.getId())).thenReturn(Optional.of(existing));
|
||||||
|
|
||||||
|
financeService.voidPayment(existing.getId(), "Fehlerhafte Buchung", staffId);
|
||||||
|
|
||||||
|
assertTrue(existing.isVoided());
|
||||||
|
assertEquals("Fehlerhafte Buchung", existing.getVoidedReason());
|
||||||
|
assertNotNull(existing.getVoidedAt());
|
||||||
|
verify(paymentRepo).save(existing);
|
||||||
|
verify(paymentRepo, never()).delete(any());
|
||||||
|
verify(auditService).log(eq(AuditEventType.PAYMENT_VOIDED), any(), any(), any(), any());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### T-13: Balance calculation — monthly fee accumulates correctly
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
void testCalculateMemberBalance_monthlyFeeAccumulates() {
|
||||||
|
// Member on €30/month since 2026-01-01, today is 2026-07-15 → 7 months due = €210
|
||||||
|
MemberFeeAssignment assignment = createAssignment(memberId, scheduleId, LocalDate.of(2026, 1, 1));
|
||||||
|
FeeSchedule schedule = createSchedule("Regulär", new BigDecimal("30.00"), FeeInterval.MONTHLY);
|
||||||
|
when(feeAssignmentRepo.findActiveFeeAssignment(memberId)).thenReturn(Optional.of(assignment));
|
||||||
|
when(feeScheduleRepo.findById(scheduleId)).thenReturn(Optional.of(schedule));
|
||||||
|
when(paymentRepo.sumPaymentsForMember(memberId)).thenReturn(Optional.of(new BigDecimal("180.00"))); // 6 months paid
|
||||||
|
|
||||||
|
MemberBalance balance = financeService.calculateMemberBalance(memberId);
|
||||||
|
|
||||||
|
assertEquals(new BigDecimal("210.00"), balance.getTotalDue());
|
||||||
|
assertEquals(new BigDecimal("180.00"), balance.getTotalPaid());
|
||||||
|
assertEquals(new BigDecimal("30.00"), balance.getOutstanding());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### T-15: Balance calculation — overpayment shows credit
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
void testCalculateMemberBalance_overpaymentShowsCredit() {
|
||||||
|
// 3 months due (€90), but paid €120 → credit of -€30 (negative outstanding)
|
||||||
|
MemberFeeAssignment assignment = createAssignment(memberId, scheduleId, LocalDate.of(2026, 5, 1));
|
||||||
|
FeeSchedule schedule = createSchedule("Regulär", new BigDecimal("30.00"), FeeInterval.MONTHLY);
|
||||||
|
when(feeAssignmentRepo.findActiveFeeAssignment(memberId)).thenReturn(Optional.of(assignment));
|
||||||
|
when(feeScheduleRepo.findById(scheduleId)).thenReturn(Optional.of(schedule));
|
||||||
|
when(paymentRepo.sumPaymentsForMember(memberId)).thenReturn(Optional.of(new BigDecimal("120.00")));
|
||||||
|
|
||||||
|
MemberBalance balance = financeService.calculateMemberBalance(memberId);
|
||||||
|
|
||||||
|
assertEquals(new BigDecimal("-30.00"), balance.getOutstanding()); // credit
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `KassenbuchServiceTest`
|
||||||
|
|
||||||
|
**File:** `cannamanage-service/src/test/java/de/cannamanage/service/KassenbuchServiceTest.java`
|
||||||
|
|
||||||
|
#### T-18: Kassenbuch running balance correct across mixed transactions
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
void testGenerateKassenbuch_runningBalanceCorrect() {
|
||||||
|
// Opening balance: €500
|
||||||
|
// Day 1: +€30 (payment) → €530
|
||||||
|
// Day 2: -€85 (expense) → €445
|
||||||
|
// Day 3: +€30 (payment) → €475
|
||||||
|
List<Payment> payments = List.of(
|
||||||
|
createPayment(LocalDate.of(2026, 7, 1), new BigDecimal("30.00")),
|
||||||
|
createPayment(LocalDate.of(2026, 7, 3), new BigDecimal("30.00"))
|
||||||
|
);
|
||||||
|
List<Expense> expenses = List.of(
|
||||||
|
createExpense(LocalDate.of(2026, 7, 2), new BigDecimal("85.00"))
|
||||||
|
);
|
||||||
|
when(paymentRepo.findByTenantAndDateRange(any(), any(), any())).thenReturn(payments);
|
||||||
|
when(expenseRepo.findByTenantAndDateRange(any(), any(), any())).thenReturn(expenses);
|
||||||
|
mockOpeningBalance(new BigDecimal("500.00"));
|
||||||
|
|
||||||
|
KassenbuchView result = kassenbuchService.generateKassenbuch(tenantId,
|
||||||
|
LocalDate.of(2026, 7, 1), LocalDate.of(2026, 7, 31));
|
||||||
|
|
||||||
|
assertEquals(3, result.getEntries().size());
|
||||||
|
assertEquals(new BigDecimal("530.00"), result.getEntries().get(0).getRunningBalance());
|
||||||
|
assertEquals(new BigDecimal("445.00"), result.getEntries().get(1).getRunningBalance());
|
||||||
|
assertEquals(new BigDecimal("475.00"), result.getEntries().get(2).getRunningBalance());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### T-21: Kassenbuch CSV export produces valid format
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
void testExportCsv_validGermanFormat() {
|
||||||
|
// Setup kassenbuch with entries
|
||||||
|
String csv = kassenbuchService.exportCsv(tenantId, LocalDate.of(2026, 1, 1), LocalDate.of(2026, 12, 31));
|
||||||
|
|
||||||
|
assertTrue(csv.startsWith("Datum;Typ;Beschreibung;Einnahme;Ausgabe;Saldo\n"));
|
||||||
|
assertTrue(csv.contains("01.07.2026;Einnahme;"));
|
||||||
|
assertTrue(csv.contains("30,00;")); // German decimal format
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `ReceiptPdfServiceTest`
|
||||||
|
|
||||||
|
**File:** `cannamanage-service/src/test/java/de/cannamanage/service/ReceiptPdfServiceTest.java`
|
||||||
|
|
||||||
|
#### T-23: Receipt PDF generation produces valid PDF bytes
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
void testGenerateReceipt_producesValidPdf() {
|
||||||
|
Payment payment = createTestPayment();
|
||||||
|
Member member = createTestMember();
|
||||||
|
Club club = createTestClub();
|
||||||
|
|
||||||
|
byte[] pdf = receiptPdfService.generateReceipt(payment, member, club);
|
||||||
|
|
||||||
|
assertNotNull(pdf);
|
||||||
|
assertTrue(pdf.length > 0);
|
||||||
|
// PDF magic bytes
|
||||||
|
assertEquals('%', (char) pdf[0]);
|
||||||
|
assertEquals('P', (char) pdf[1]);
|
||||||
|
assertEquals('D', (char) pdf[2]);
|
||||||
|
assertEquals('F', (char) pdf[3]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### T-25: Receipt PDF formats Euro correctly
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
void testGenerateReceipt_euroFormatting() {
|
||||||
|
Payment payment = createTestPayment(new BigDecimal("1234.50"));
|
||||||
|
byte[] pdf = receiptPdfService.generateReceipt(payment, member, club);
|
||||||
|
String pdfText = extractTextFromPdf(pdf);
|
||||||
|
|
||||||
|
assertTrue(pdfText.contains("1.234,50")); // German locale formatting
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `AssemblyServiceTest`
|
||||||
|
|
||||||
|
**File:** `cannamanage-service/src/test/java/de/cannamanage/service/AssemblyServiceTest.java`
|
||||||
|
|
||||||
|
#### T-34: Assembly status transition DRAFT → INVITED on sendInvitations
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
void testSendInvitations_transitionsDraftToInvited() {
|
||||||
|
GeneralAssembly assembly = createDraftAssembly();
|
||||||
|
when(assemblyRepo.findById(assembly.getId())).thenReturn(Optional.of(assembly));
|
||||||
|
when(memberRepo.countByTenantIdAndStatus(tenantId, MemberStatus.ACTIVE)).thenReturn(50L);
|
||||||
|
|
||||||
|
assemblyService.sendInvitations(assembly.getId());
|
||||||
|
|
||||||
|
assertEquals(AssemblyStatus.INVITED, assembly.getStatus());
|
||||||
|
assertNotNull(assembly.getInvitationSentAt());
|
||||||
|
verify(notificationDispatchService).broadcast(eq(tenantId), eq(NotificationType.ASSEMBLY_INVITATION), any(), any());
|
||||||
|
verify(eventService).createEvent(any(), any(), any(), eq(EventType.GENERAL_ASSEMBLY), any(), any(), any(), any(), any());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### T-39: Assembly start rejected if status is not INVITED
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
void testStartAssembly_rejectedIfNotInvited() {
|
||||||
|
GeneralAssembly assembly = createDraftAssembly(); // still DRAFT
|
||||||
|
when(assemblyRepo.findById(assembly.getId())).thenReturn(Optional.of(assembly));
|
||||||
|
|
||||||
|
assertThrows(IllegalStateException.class,
|
||||||
|
() -> assemblyService.startAssembly(assembly.getId()));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### T-42: Assembly quorum calculation correct
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
void testGetQuorumStatus_calculatesCorrectly() {
|
||||||
|
GeneralAssembly assembly = createAssemblyWithQuorum(50); // 50% required
|
||||||
|
when(assemblyRepo.findById(assembly.getId())).thenReturn(Optional.of(assembly));
|
||||||
|
when(attendanceRepo.countByAssemblyId(assembly.getId())).thenReturn(30L);
|
||||||
|
when(memberRepo.countByTenantIdAndStatus(tenantId, MemberStatus.ACTIVE)).thenReturn(72L);
|
||||||
|
|
||||||
|
QuorumStatus status = assemblyService.getQuorumStatus(assembly.getId());
|
||||||
|
|
||||||
|
assertEquals(30L, status.getAttendeesPresent());
|
||||||
|
assertEquals(72L, status.getTotalActiveMembers());
|
||||||
|
assertEquals(41, status.getCurrentPercentage()); // 30/72 = 41%
|
||||||
|
assertEquals(50, status.getRequiredPercentage());
|
||||||
|
assertFalse(status.isQuorumReached()); // 41 < 50
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### T-46: Assembly vote SIMPLE_MAJORITY — yes > no passes
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
void testRecordVoteResult_simpleMajority_passes() {
|
||||||
|
AssemblyVote vote = createVote(VoteType.SIMPLE_MAJORITY, 50);
|
||||||
|
when(voteRepo.findById(vote.getId())).thenReturn(Optional.of(vote));
|
||||||
|
|
||||||
|
assemblyService.recordVoteResult(vote.getId(), 25, 10, 5); // yes=25, no=10, abstain=5
|
||||||
|
|
||||||
|
assertEquals(VoteResult.ACCEPTED, vote.getResult());
|
||||||
|
assertEquals(25, vote.getYesCount());
|
||||||
|
assertEquals(10, vote.getNoCount());
|
||||||
|
assertEquals(5, vote.getAbstainCount());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### T-48: Assembly vote TWO_THIRDS — yes < 2/3 fails
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
void testRecordVoteResult_twoThirds_fails() {
|
||||||
|
AssemblyVote vote = createVote(VoteType.TWO_THIRDS, 67);
|
||||||
|
when(voteRepo.findById(vote.getId())).thenReturn(Optional.of(vote));
|
||||||
|
|
||||||
|
assemblyService.recordVoteResult(vote.getId(), 20, 15, 5); // 20/(20+15) = 57% < 67%
|
||||||
|
|
||||||
|
assertEquals(VoteResult.REJECTED, vote.getResult());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `DocumentStorageServiceTest`
|
||||||
|
|
||||||
|
**File:** `cannamanage-service/src/test/java/de/cannamanage/service/DocumentStorageServiceTest.java`
|
||||||
|
|
||||||
|
#### T-60: DocumentStorageService rejects file > 20MB
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
void testStore_rejectsOversizedFile() {
|
||||||
|
MultipartFile bigFile = createMockFile("big.pdf", "application/pdf", 21 * 1024 * 1024); // 21MB
|
||||||
|
|
||||||
|
assertThrows(FileTooLargeException.class,
|
||||||
|
() -> storageService.store(tenantId, documentId, bigFile));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### T-61: DocumentStorageService rejects disallowed content type
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
void testStore_rejectsDisallowedContentType() {
|
||||||
|
MultipartFile exeFile = createMockFile("virus.exe", "application/x-msdownload", 1024);
|
||||||
|
|
||||||
|
assertThrows(UnsupportedFileTypeException.class,
|
||||||
|
() -> storageService.store(tenantId, documentId, exeFile));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `BoardServiceTest`
|
||||||
|
|
||||||
|
**File:** `cannamanage-service/src/test/java/de/cannamanage/service/BoardServiceTest.java`
|
||||||
|
|
||||||
|
#### T-69: Board member appointment creates active record
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
void testAppointMember_createsActiveRecord() {
|
||||||
|
BoardPosition position = createPosition("Kassenwart", true);
|
||||||
|
when(positionRepo.findById(position.getId())).thenReturn(Optional.of(position));
|
||||||
|
|
||||||
|
BoardMember result = boardService.appointMember(position.getId(), memberId,
|
||||||
|
LocalDate.of(2026, 7, 1), LocalDate.of(2028, 7, 1), assemblyId);
|
||||||
|
|
||||||
|
assertTrue(result.isActive());
|
||||||
|
assertEquals(memberId, result.getMemberId());
|
||||||
|
assertEquals(position.getId(), result.getPositionId());
|
||||||
|
assertEquals(LocalDate.of(2026, 7, 1), result.getTermStart());
|
||||||
|
verify(auditService).log(eq(AuditEventType.BOARD_MEMBER_APPOINTED), any(), any(), any(), any());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### T-73: Board election result ends previous holder and appoints new
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
void testApplyElectionResult_endsPreviousAndApointsNew() {
|
||||||
|
BoardMember previousHolder = createActiveBoardMember(oldMemberId, positionId);
|
||||||
|
when(boardMemberRepo.findByPositionIdAndIsActiveTrue(positionId))
|
||||||
|
.thenReturn(Optional.of(previousHolder));
|
||||||
|
|
||||||
|
AssemblyElection election = createElection(positionId, newMemberId, 38,
|
||||||
|
LocalDate.of(2026, 7, 1), LocalDate.of(2028, 7, 1));
|
||||||
|
|
||||||
|
boardService.applyElectionResult(election);
|
||||||
|
|
||||||
|
assertFalse(previousHolder.isActive());
|
||||||
|
assertEquals("VOTED_OUT", previousHolder.getEndedReason());
|
||||||
|
verify(boardMemberRepo).save(argThat(bm -> bm.getMemberId().equals(newMemberId) && bm.isActive()));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `PaymentReminderSchedulerTest`
|
||||||
|
|
||||||
|
**File:** `cannamanage-service/src/test/java/de/cannamanage/service/PaymentReminderSchedulerTest.java`
|
||||||
|
|
||||||
|
#### T-29: Payment reminder detects overdue member
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
void testCheckOverduePayments_detectsOverdueMember() {
|
||||||
|
when(clubRepo.findAllActive()).thenReturn(List.of(testClub));
|
||||||
|
when(planTierService.getClubTier(testClub.getId())).thenReturn(PlanTier.PRO);
|
||||||
|
MemberBalance overdue = createOverdueBalance(memberId, 45); // 45 days overdue
|
||||||
|
when(financeService.getOverdueMembers(tenantId)).thenReturn(List.of(overdue));
|
||||||
|
|
||||||
|
reminderScheduler.checkOverduePayments();
|
||||||
|
|
||||||
|
verify(notificationDispatchService).sendToMember(eq(memberId),
|
||||||
|
eq(NotificationType.PAYMENT_REMINDER), any(), any());
|
||||||
|
verify(paymentReminderRepo).save(argThat(r -> r.getReminderType() == ReminderType.SECOND));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### T-31: Payment reminder skips Starter/Trial tier clubs
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
void testCheckOverduePayments_skipsStarterTier() {
|
||||||
|
when(clubRepo.findAllActive()).thenReturn(List.of(testClub));
|
||||||
|
when(planTierService.getClubTier(testClub.getId())).thenReturn(PlanTier.STARTER);
|
||||||
|
|
||||||
|
reminderScheduler.checkOverduePayments();
|
||||||
|
|
||||||
|
verify(financeService, never()).getOverdueMembers(any());
|
||||||
|
verify(notificationDispatchService, never()).sendToMember(any(), any(), any(), any());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `BoardTermSchedulerTest`
|
||||||
|
|
||||||
|
**File:** `cannamanage-service/src/test/java/de/cannamanage/service/BoardTermSchedulerTest.java`
|
||||||
|
|
||||||
|
#### T-74: Board term expiry scheduler detects terms ending within 30 days
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
void testCheckExpiringTerms_detectsExpiringTerm() {
|
||||||
|
BoardMember expiring = createBoardMember(memberId, positionId, LocalDate.now().plusDays(15));
|
||||||
|
when(boardMemberRepo.findByTermEndBeforeAndIsActiveTrue(any())).thenReturn(List.of(expiring));
|
||||||
|
|
||||||
|
boardTermScheduler.checkExpiringTerms();
|
||||||
|
|
||||||
|
verify(notificationDispatchService).sendToAdmins(eq(tenantId),
|
||||||
|
eq(NotificationType.BOARD_TERM_EXPIRING), any(), any());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integration Tests
|
||||||
|
|
||||||
|
### `FinanceControllerIntegrationTest`
|
||||||
|
|
||||||
|
**File:** `cannamanage-api/src/test/java/de/cannamanage/api/FinanceControllerIntegrationTest.java`
|
||||||
|
|
||||||
|
#### T-80: Finance API requires MANAGE_FINANCES permission
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
@WithMockStaff(permissions = {}) // no permissions
|
||||||
|
void testFinanceEndpoint_requiresPermission_returns403() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/finance/payments"))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockStaff(permissions = {"MANAGE_FINANCES"})
|
||||||
|
void testFinanceEndpoint_withPermission_returns200() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/finance/payments"))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### T-81: Finance API record payment returns 201
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
@WithMockStaff(permissions = {"MANAGE_FINANCES"})
|
||||||
|
void testRecordPayment_returns201WithReceiptNumber() throws Exception {
|
||||||
|
String json = """
|
||||||
|
{
|
||||||
|
"memberId": "%s",
|
||||||
|
"amount": 30.00,
|
||||||
|
"paymentDate": "2026-07-01",
|
||||||
|
"paymentMethod": "BANK_TRANSFER",
|
||||||
|
"reference": "Beitrag Juli 2026"
|
||||||
|
}
|
||||||
|
""".formatted(testMemberId);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/finance/payments")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(json))
|
||||||
|
.andExpect(status().isCreated())
|
||||||
|
.andExpect(jsonPath("$.receiptNumber").exists())
|
||||||
|
.andExpect(jsonPath("$.receiptNumber").value(startsWith("CM-2026-")));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `AssemblyControllerIntegrationTest`
|
||||||
|
|
||||||
|
**File:** `cannamanage-api/src/test/java/de/cannamanage/api/AssemblyControllerIntegrationTest.java`
|
||||||
|
|
||||||
|
#### T-95: Assembly API quorum endpoint returns correct calculation
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
@WithMockStaff(permissions = {"MANAGE_ASSEMBLY"})
|
||||||
|
void testQuorumEndpoint_returnsCorrectCalculation() throws Exception {
|
||||||
|
// Setup: assembly exists, 5 of 10 members checked in, quorum = 50%
|
||||||
|
UUID assemblyId = createAssemblyAndCheckInMembers(5, 10);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/assemblies/{id}/quorum", assemblyId))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.attendeesPresent").value(5))
|
||||||
|
.andExpect(jsonPath("$.totalActiveMembers").value(10))
|
||||||
|
.andExpect(jsonPath("$.currentPercentage").value(50))
|
||||||
|
.andExpect(jsonPath("$.quorumReached").value(true));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `DocumentControllerIntegrationTest`
|
||||||
|
|
||||||
|
**File:** `cannamanage-api/src/test/java/de/cannamanage/api/DocumentControllerIntegrationTest.java`
|
||||||
|
|
||||||
|
#### T-100: Document API upload returns 201
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
@WithMockStaff(permissions = {"MANAGE_DOCUMENTS"})
|
||||||
|
void testUploadDocument_returns201() throws Exception {
|
||||||
|
MockMultipartFile file = new MockMultipartFile("file", "satzung.pdf",
|
||||||
|
"application/pdf", "fake pdf content".getBytes());
|
||||||
|
MockMultipartFile metadata = new MockMultipartFile("metadata", "",
|
||||||
|
"application/json", """
|
||||||
|
{"title": "Vereinssatzung 2026", "categoryId": "%s", "accessLevel": "ALL_MEMBERS"}
|
||||||
|
""".formatted(testCategoryId).getBytes());
|
||||||
|
|
||||||
|
mockMvc.perform(multipart("/api/documents").file(file).file(metadata))
|
||||||
|
.andExpect(status().isCreated())
|
||||||
|
.andExpect(jsonPath("$.title").value("Vereinssatzung 2026"))
|
||||||
|
.andExpect(jsonPath("$.filename").value("satzung.pdf"))
|
||||||
|
.andExpect(jsonPath("$.accessLevel").value("ALL_MEMBERS"));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### T-106: Portal document API blocks BOARD_ONLY documents
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
@WithMockPortalMember
|
||||||
|
void testPortalDocuments_blocksBoardOnly() throws Exception {
|
||||||
|
// Upload a BOARD_ONLY document
|
||||||
|
createDocument("Mietvertrag", DocumentAccessLevel.BOARD_ONLY);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/portal/documents"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$[?(@.title == 'Mietvertrag')]").doesNotExist());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `TenantIsolationFinanceTest`
|
||||||
|
|
||||||
|
**File:** `cannamanage-api/src/test/java/de/cannamanage/api/TenantIsolationFinanceTest.java`
|
||||||
|
|
||||||
|
#### T-110: Tenant isolation — Club A finance data invisible to Club B
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
void testTenantIsolation_financeData() throws Exception {
|
||||||
|
// Create payment in Club A
|
||||||
|
Payment payment = createPaymentForClub(clubATenantId, memberAId);
|
||||||
|
|
||||||
|
// Login as Club B staff
|
||||||
|
mockMvc.perform(get("/api/finance/payments")
|
||||||
|
.with(asStaffOfClub(clubBTenantId)))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$", hasSize(0))); // Club A payment not visible
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E2E Tests (Playwright)
|
||||||
|
|
||||||
|
### `finance.spec.ts`
|
||||||
|
|
||||||
|
**File:** `cannamanage-frontend/e2e/finance.spec.ts`
|
||||||
|
|
||||||
|
#### T-113: Admin creates fee schedule, assigns to member
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
test('Admin creates fee schedule and assigns to member', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto('/settings/finance');
|
||||||
|
|
||||||
|
// Create fee schedule
|
||||||
|
await page.click('text=Neuer Beitragsplan');
|
||||||
|
await page.fill('[name="name"]', 'Regulär');
|
||||||
|
await page.fill('[name="amount"]', '30');
|
||||||
|
await page.selectOption('[name="interval"]', 'MONTHLY');
|
||||||
|
await page.click('text=Speichern');
|
||||||
|
await expect(page.locator('text=Regulär')).toBeVisible();
|
||||||
|
await expect(page.locator('text=30,00 €/Monat')).toBeVisible();
|
||||||
|
|
||||||
|
// Assign to member
|
||||||
|
await page.goto('/finance/balances');
|
||||||
|
await page.click('text=Max Mustermann');
|
||||||
|
await page.click('text=Beitragsplan zuweisen');
|
||||||
|
await page.selectOption('[name="feeSchedule"]', 'Regulär');
|
||||||
|
await page.click('text=Zuweisen');
|
||||||
|
await expect(page.locator('text=Regulär (30,00 €/Monat)')).toBeVisible();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### T-114: Admin records payment, receipt number appears
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
test('Admin records payment and receipt number is generated', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto('/finance/payments');
|
||||||
|
|
||||||
|
await page.click('text=Zahlung erfassen');
|
||||||
|
await page.selectOption('[name="member"]', 'Max Mustermann');
|
||||||
|
await page.fill('[name="amount"]', '30');
|
||||||
|
await page.fill('[name="paymentDate"]', '2026-07-01');
|
||||||
|
await page.selectOption('[name="paymentMethod"]', 'BANK_TRANSFER');
|
||||||
|
await page.fill('[name="reference"]', 'Beitrag Juli');
|
||||||
|
await page.click('text=Erfassen');
|
||||||
|
|
||||||
|
await expect(page.locator('text=CM-2026-')).toBeVisible();
|
||||||
|
await expect(page.locator('text=30,00 €')).toBeVisible();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### T-117: Kassenbuch shows running balance
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
test('Kassenbuch displays running balance correctly', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto('/finance');
|
||||||
|
|
||||||
|
// Verify Kassenbuch tab shows entries with running balance column
|
||||||
|
await page.click('text=Kassenbuch');
|
||||||
|
const rows = page.locator('table tbody tr');
|
||||||
|
await expect(rows).toHaveCount.greaterThan(0);
|
||||||
|
|
||||||
|
// Each row should have a Saldo column
|
||||||
|
const firstRowBalance = rows.first().locator('td:last-child');
|
||||||
|
await expect(firstRowBalance).toContainText('€');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `assemblies.spec.ts`
|
||||||
|
|
||||||
|
**File:** `cannamanage-frontend/e2e/assemblies.spec.ts`
|
||||||
|
|
||||||
|
#### T-121: Admin creates MV with agenda items
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
test('Admin creates MV with agenda items', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto('/assemblies/new');
|
||||||
|
|
||||||
|
await page.fill('[name="title"]', 'Ordentliche MV 2026');
|
||||||
|
await page.fill('[name="scheduledAt"]', '2026-08-15T19:00');
|
||||||
|
await page.fill('[name="location"]', 'Vereinsheim, Musterstraße 1');
|
||||||
|
await page.fill('[name="quorumPercentage"]', '50');
|
||||||
|
|
||||||
|
// Add agenda items
|
||||||
|
await page.click('text=TOP hinzufügen');
|
||||||
|
await page.fill('[name="agendaTitle"]', 'Begrüßung und Feststellung der Beschlussfähigkeit');
|
||||||
|
await page.click('text=TOP hinzufügen');
|
||||||
|
await page.fill('[name="agendaTitle"]', 'Bericht des Vorstands');
|
||||||
|
await page.click('text=TOP hinzufügen');
|
||||||
|
await page.fill('[name="agendaTitle"]', 'Bericht des Kassenwarts');
|
||||||
|
await page.click('text=TOP hinzufügen');
|
||||||
|
await page.fill('[name="agendaTitle"]', 'Entlastung des Vorstands');
|
||||||
|
|
||||||
|
await page.click('text=MV erstellen');
|
||||||
|
await expect(page.locator('text=Ordentliche MV 2026')).toBeVisible();
|
||||||
|
await expect(page.locator('text=ENTWURF')).toBeVisible();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### T-126: Admin completes MV and generates protocol
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
test('Full MV lifecycle: complete and generate protocol', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
const assemblyId = await createMvViaApi();
|
||||||
|
await sendInvitationsViaApi(assemblyId);
|
||||||
|
await startMvViaApi(assemblyId);
|
||||||
|
await checkInMembersViaApi(assemblyId, 5);
|
||||||
|
|
||||||
|
await page.goto(`/assemblies/${assemblyId}/live`);
|
||||||
|
|
||||||
|
// Record a vote
|
||||||
|
await page.click('text=Abstimmungen');
|
||||||
|
await page.click('text=Neue Abstimmung');
|
||||||
|
await page.fill('[name="voteTitle"]', 'Entlastung des Vorstands');
|
||||||
|
await page.fill('[name="yesCount"]', '4');
|
||||||
|
await page.fill('[name="noCount"]', '0');
|
||||||
|
await page.fill('[name="abstainCount"]', '1');
|
||||||
|
await page.click('text=Ergebnis speichern');
|
||||||
|
await expect(page.locator('text=ANGENOMMEN')).toBeVisible();
|
||||||
|
|
||||||
|
// Complete MV
|
||||||
|
await page.click('text=MV beenden');
|
||||||
|
await page.click('text=Bestätigen');
|
||||||
|
await expect(page.locator('text=ABGESCHLOSSEN')).toBeVisible();
|
||||||
|
|
||||||
|
// Generate and verify protocol
|
||||||
|
await page.click('text=Protokoll generieren');
|
||||||
|
const download = await page.waitForEvent('download');
|
||||||
|
expect(download.suggestedFilename()).toContain('protokoll');
|
||||||
|
expect(download.suggestedFilename()).toEndWith('.pdf');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `documents.spec.ts`
|
||||||
|
|
||||||
|
**File:** `cannamanage-frontend/e2e/documents.spec.ts`
|
||||||
|
|
||||||
|
#### T-128: Admin uploads document with category
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
test('Admin uploads document with category', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto('/documents');
|
||||||
|
|
||||||
|
await page.click('text=Dokument hochladen');
|
||||||
|
await page.fill('[name="title"]', 'Vereinssatzung 2026');
|
||||||
|
await page.selectOption('[name="category"]', 'Satzung');
|
||||||
|
await page.selectOption('[name="accessLevel"]', 'ALL_MEMBERS');
|
||||||
|
|
||||||
|
const fileInput = page.locator('input[type="file"]');
|
||||||
|
await fileInput.setInputFiles('e2e/fixtures/test-document.pdf');
|
||||||
|
|
||||||
|
await page.click('text=Hochladen');
|
||||||
|
await expect(page.locator('text=Vereinssatzung 2026')).toBeVisible();
|
||||||
|
await expect(page.locator('text=Satzung')).toBeVisible();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### T-130: Portal member sees public documents only
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
test('Portal member sees only public documents', async ({ page }) => {
|
||||||
|
await loginAsMember(page);
|
||||||
|
await page.goto('/portal/documents');
|
||||||
|
|
||||||
|
// Public document should be visible
|
||||||
|
await expect(page.locator('text=Vereinssatzung 2026')).toBeVisible();
|
||||||
|
|
||||||
|
// Board-only document should NOT be visible
|
||||||
|
await expect(page.locator('text=Mietvertrag')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `portal-finance.spec.ts`
|
||||||
|
|
||||||
|
**File:** `cannamanage-frontend/e2e/portal-finance.spec.ts`
|
||||||
|
|
||||||
|
#### T-119: Portal member sees payment history and balance
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
test('Portal member sees their payment history and balance', async ({ page }) => {
|
||||||
|
await loginAsMember(page);
|
||||||
|
await page.goto('/portal/finance');
|
||||||
|
|
||||||
|
// Balance card
|
||||||
|
await expect(page.locator('[data-testid="balance-card"]')).toBeVisible();
|
||||||
|
await expect(page.locator('text=Aktuell')).toBeVisible(); // or outstanding amount
|
||||||
|
|
||||||
|
// Payment history table
|
||||||
|
const payments = page.locator('[data-testid="payment-row"]');
|
||||||
|
await expect(payments).toHaveCount.greaterThan(0);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### T-120: Portal member downloads own receipt
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
test('Portal member downloads receipt PDF', async ({ page }) => {
|
||||||
|
await loginAsMember(page);
|
||||||
|
await page.goto('/portal/finance');
|
||||||
|
|
||||||
|
const downloadButton = page.locator('[data-testid="download-receipt"]').first();
|
||||||
|
await downloadButton.click();
|
||||||
|
|
||||||
|
const download = await page.waitForEvent('download');
|
||||||
|
expect(download.suggestedFilename()).toMatch(/quittung.*\.pdf/);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `board.spec.ts`
|
||||||
|
|
||||||
|
**File:** `cannamanage-frontend/e2e/board.spec.ts`
|
||||||
|
|
||||||
|
#### T-131: Admin assigns board position to member
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
test('Admin assigns board position to member', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto('/board');
|
||||||
|
|
||||||
|
await page.click('text=Position besetzen');
|
||||||
|
await page.selectOption('[name="position"]', '1. Vorsitzende/r');
|
||||||
|
await page.selectOption('[name="member"]', 'Max Mustermann');
|
||||||
|
await page.fill('[name="termStart"]', '2026-07-01');
|
||||||
|
await page.fill('[name="termEnd"]', '2028-07-01');
|
||||||
|
await page.click('text=Ernennen');
|
||||||
|
|
||||||
|
await expect(page.locator('text=1. Vorsitzende/r')).toBeVisible();
|
||||||
|
await expect(page.locator('text=Max Mustermann')).toBeVisible();
|
||||||
|
await expect(page.locator('text=01.07.2026 – 01.07.2028')).toBeVisible();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Data Requirements
|
||||||
|
|
||||||
|
### Seed Data for Tests
|
||||||
|
|
||||||
|
| Entity | Values |
|
||||||
|
|--------|--------|
|
||||||
|
| Fee Schedules | "Regulär" €30/month, "Ermäßigt" €15/month, "Ehrenmitglied" €0/year |
|
||||||
|
| Expense Categories | Miete, Strom, Cannabis-Einkauf, Anbaumaterial, Versicherung, Verwaltung, Sonstiges |
|
||||||
|
| Document Categories | Satzung, Protokolle, Verträge, Versicherungen, Behördliche Genehmigungen, Sonstiges |
|
||||||
|
| Board Positions | 1. Vorsitzende/r, 2. Vorsitzende/r, Kassenwart/in, Schriftführer/in, Beisitzer/in |
|
||||||
|
| Members | At least 10 active members for quorum tests |
|
||||||
|
| Payments | Varied history: some current, some overdue, some overpaid |
|
||||||
|
|
||||||
|
### Test Fixtures (E2E)
|
||||||
|
|
||||||
|
- `e2e/fixtures/test-document.pdf` — small valid PDF for upload tests
|
||||||
|
- `e2e/fixtures/large-file.bin` — 21MB file for rejection tests
|
||||||
|
- `e2e/fixtures/test-receipt.jpg` — JPEG for expense receipt tests
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Coverage Summary
|
||||||
|
|
||||||
|
| Component | Unit | Integration | E2E | Total |
|
||||||
|
|-----------|------|-------------|-----|-------|
|
||||||
|
| FinanceService | 17 | 8 | 6 | 31 |
|
||||||
|
| KassenbuchService | 5 | 0 | 1 | 6 |
|
||||||
|
| ReceiptPdfService | 3 | 1 | 1 | 5 |
|
||||||
|
| FinancialReportService | 3 | 1 | 0 | 4 |
|
||||||
|
| PaymentReminderScheduler | 4 | 0 | 0 | 4 |
|
||||||
|
| AssemblyService | 22 | 9 | 7 | 38 |
|
||||||
|
| ProtocolPdfService | 3 | 1 | 1 | 5 |
|
||||||
|
| DocumentStorageService | 5 | 0 | 0 | 5 |
|
||||||
|
| DocumentService | 4 | 7 | 3 | 14 |
|
||||||
|
| BoardService | 6 | 3 | 2 | 11 |
|
||||||
|
| BoardTermScheduler | 2 | 0 | 0 | 2 |
|
||||||
|
| PlanTierService (new rules) | 4 | 0 | 0 | 4 |
|
||||||
|
| Tenant isolation | 0 | 3 | 0 | 3 |
|
||||||
|
| **Total** | **78** | **33** | **21** | **132** |
|
||||||