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:
+232
@@ -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) {}
|
||||
}
|
||||
+14
@@ -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);
|
||||
}
|
||||
+18
@@ -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);
|
||||
}
|
||||
+19
@@ -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);
|
||||
}
|
||||
+16
@@ -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);
|
||||
}
|
||||
+16
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user