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,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,
FEE_SCHEDULE_CREATED,
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:
PAYMENT_REMINDER,
PAYMENT_OVERDUE,
PAYMENT_RECEIVED
PAYMENT_RECEIVED,
// Sprint 8 — Assembly:
ASSEMBLY_INVITATION,
ASSEMBLY_REMINDER
}
@@ -20,5 +20,6 @@ public enum StaffPermission {
MODERATE_FORUM,
// Sprint 8:
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
}