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)
This commit is contained in:
Patrick Plate
2026-06-15 08:39:10 +02:00
parent 3211ade5be
commit b22702317a
57 changed files with 6338 additions and 55 deletions
@@ -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)
spring.flyway.enabled=true
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.hibernate.ddl-auto=update
# JWT secret from environment
cannamanage.security.jwt.secret=${CANNAMANAGE_SECURITY_JWT_SECRET}
@@ -32,6 +32,7 @@ CREATE TABLE event_rsvps (
member_id UUID NOT NULL REFERENCES members(id),
status VARCHAR(20) NOT NULL,
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,
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);