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,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
|
||||
}
|
||||
Reference in New Issue
Block a user