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