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