feat(sprint7): Phase 2.5 — Club Event Calendar

- Flyway V14: club_events + event_rsvps tables with reminder_sent tracking
- Enums: EventType, RsvpStatus, RecurrenceRule + extend AuditEventType/NotificationType
- Entities: ClubEvent (extends AbstractTenantEntity), EventRsvp (unique event+member)
- Repositories: ClubEventRepository, EventRsvpRepository with date-range and status queries
- EventService: CRUD, RSVP with maxAttendees enforcement (409 if full), iCal RFC 5545 generation, recurring event virtual expansion, notifications on create/cancel, auto-post to Info Board
- EventReminderScheduler: hourly check, 24h reminder to ACCEPTED/MAYBE attendees
- EventController: admin CRUD (MANAGE_INFO_BOARD permission), portal upcoming events, RSVP endpoint, iCal download (text/calendar), attendee list
- Frontend: events.ts service (React Query hooks matching apiClient pattern), admin calendar page (month grid with event dots, create dialog, event cards), portal events page (RSVP buttons, capacity display)
- Navigation: added Kalender with Calendar icon
- i18n: events.* keys in de.json and en.json
- UI: added @radix-ui/react-switch + Switch component
This commit is contained in:
Patrick Plate
2026-06-13 20:16:56 +02:00
parent 4aa27cd4f9
commit 05fd679c4d
27 changed files with 2044 additions and 1 deletions
@@ -0,0 +1,139 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.EventType;
import de.cannamanage.domain.enums.RecurrenceRule;
import jakarta.persistence.*;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* Club event entity — supports RSVP, recurring events, and iCal export.
*/
@Entity
@Table(name = "club_events", indexes = {
@Index(name = "idx_club_events_tenant_start", columnList = "tenant_id, start_at"),
@Index(name = "idx_club_events_type", columnList = "tenant_id, event_type"),
@Index(name = "idx_club_events_club_id", columnList = "club_id")
})
public class ClubEvent extends AbstractTenantEntity {
@Column(name = "club_id", nullable = false)
private UUID clubId;
@Column(name = "title", nullable = false, length = 200)
private String title;
@Column(name = "description", columnDefinition = "TEXT")
private String description;
@Enumerated(EnumType.STRING)
@Column(name = "event_type", nullable = false, length = 50)
private EventType eventType;
@Column(name = "start_at", nullable = false)
private Instant startAt;
@Column(name = "end_at")
private Instant endAt;
@Column(name = "location", length = 300)
private String location;
@Column(name = "max_attendees")
private Integer maxAttendees;
@Column(name = "is_recurring", nullable = false)
private boolean recurring = false;
@Enumerated(EnumType.STRING)
@Column(name = "recurrence_rule", length = 100)
private RecurrenceRule recurrenceRule;
@Column(name = "recurrence_end_date")
private LocalDate recurrenceEndDate;
@Column(name = "reminder_sent", nullable = false)
private boolean reminderSent = false;
@Column(name = "created_by", nullable = false)
private UUID createdBy;
@Column(name = "updated_at")
private Instant updatedAt;
@OneToMany(mappedBy = "event", cascade = CascadeType.ALL, orphanRemoval = true)
private List<EventRsvp> rsvps = new ArrayList<>();
public ClubEvent() {}
public ClubEvent(UUID clubId, String title, String description, EventType eventType,
Instant startAt, Instant endAt, String location, Integer maxAttendees,
UUID createdBy) {
this.clubId = clubId;
this.title = title;
this.description = description;
this.eventType = eventType;
this.startAt = startAt;
this.endAt = endAt;
this.location = location;
this.maxAttendees = maxAttendees;
this.createdBy = createdBy;
this.updatedAt = Instant.now();
}
@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 String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public EventType getEventType() { return eventType; }
public void setEventType(EventType eventType) { this.eventType = eventType; }
public Instant getStartAt() { return startAt; }
public void setStartAt(Instant startAt) { this.startAt = startAt; }
public Instant getEndAt() { return endAt; }
public void setEndAt(Instant endAt) { this.endAt = endAt; }
public String getLocation() { return location; }
public void setLocation(String location) { this.location = location; }
public Integer getMaxAttendees() { return maxAttendees; }
public void setMaxAttendees(Integer maxAttendees) { this.maxAttendees = maxAttendees; }
public boolean isRecurring() { return recurring; }
public void setRecurring(boolean recurring) { this.recurring = recurring; }
public RecurrenceRule getRecurrenceRule() { return recurrenceRule; }
public void setRecurrenceRule(RecurrenceRule recurrenceRule) { this.recurrenceRule = recurrenceRule; }
public LocalDate getRecurrenceEndDate() { return recurrenceEndDate; }
public void setRecurrenceEndDate(LocalDate recurrenceEndDate) { this.recurrenceEndDate = recurrenceEndDate; }
public boolean isReminderSent() { return reminderSent; }
public void setReminderSent(boolean reminderSent) { this.reminderSent = reminderSent; }
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; }
public List<EventRsvp> getRsvps() { return rsvps; }
public void setRsvps(List<EventRsvp> rsvps) { this.rsvps = rsvps; }
}
@@ -0,0 +1,62 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.RsvpStatus;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
/**
* Event RSVP entity — tracks member attendance responses.
* Unique constraint on (event_id, member_id) ensures one RSVP per member per event.
*/
@Entity
@Table(name = "event_rsvps",
uniqueConstraints = @UniqueConstraint(
name = "uq_event_rsvps_event_member",
columnNames = {"event_id", "member_id"}
),
indexes = {
@Index(name = "idx_event_rsvps_event", columnList = "event_id"),
@Index(name = "idx_event_rsvps_member", columnList = "member_id")
}
)
public class EventRsvp extends AbstractTenantEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "event_id", nullable = false)
private ClubEvent event;
@Column(name = "member_id", nullable = false)
private UUID memberId;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20)
private RsvpStatus status;
@Column(name = "responded_at", nullable = false)
private Instant respondedAt;
public EventRsvp() {}
public EventRsvp(ClubEvent event, UUID memberId, RsvpStatus status) {
this.event = event;
this.memberId = memberId;
this.status = status;
this.respondedAt = Instant.now();
}
// Getters and Setters
public ClubEvent getEvent() { return event; }
public void setEvent(ClubEvent event) { this.event = event; }
public UUID getMemberId() { return memberId; }
public void setMemberId(UUID memberId) { this.memberId = memberId; }
public RsvpStatus getStatus() { return status; }
public void setStatus(RsvpStatus status) { this.status = status; }
public Instant getRespondedAt() { return respondedAt; }
public void setRespondedAt(Instant respondedAt) { this.respondedAt = respondedAt; }
}
@@ -51,6 +51,11 @@ public enum AuditEventType {
INFO_BOARD_POST_PINNED,
INFO_BOARD_POST_ARCHIVED,
// Sprint 7 — Event Calendar events
EVENT_CREATED,
EVENT_UPDATED,
EVENT_CANCELLED,
// Sprint 7 — Forum events
FORUM_TOPIC_CREATED,
FORUM_TOPIC_LOCKED,
@@ -0,0 +1,12 @@
package de.cannamanage.domain.enums;
/**
* Types of club events.
*/
public enum EventType {
MEETING, // Mitgliederversammlung
HARVEST_FESTIVAL, // Erntefest
BOARD_MEETING, // Vorstandssitzung
WORKSHOP, // Workshop
OTHER // Sonstiges
}
@@ -12,5 +12,9 @@ public enum NotificationType {
ADMIN_MESSAGE,
INFO_BOARD_POST,
FORUM_REPLY,
FORUM_MENTION
FORUM_MENTION,
// Sprint 7 Phase 2.5 — Events:
EVENT_CREATED,
EVENT_REMINDER,
EVENT_CANCELLED
}
@@ -0,0 +1,10 @@
package de.cannamanage.domain.enums;
/**
* Recurrence rules for recurring events.
*/
public enum RecurrenceRule {
WEEKLY,
BIWEEKLY,
MONTHLY
}
@@ -0,0 +1,10 @@
package de.cannamanage.domain.enums;
/**
* RSVP status for event attendance.
*/
public enum RsvpStatus {
ACCEPTED,
DECLINED,
MAYBE
}