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,84 @@
package de.cannamanage.service;
import de.cannamanage.domain.enums.NotificationType;
import de.cannamanage.domain.enums.RsvpStatus;
import de.cannamanage.service.repository.ClubEventRepository;
import de.cannamanage.service.repository.EventRsvpRepository;
import de.cannamanage.service.repository.MemberRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.List;
/**
* Scheduler that sends event reminders ~24h before an event starts.
* Runs every hour, finds events in the 24-25h window, sends notifications to ACCEPTED/MAYBE attendees.
*/
@Service
public class EventReminderScheduler {
private static final Logger log = LoggerFactory.getLogger(EventReminderScheduler.class);
private final ClubEventRepository eventRepository;
private final EventRsvpRepository rsvpRepository;
private final MemberRepository memberRepository;
private final NotificationService notificationService;
public EventReminderScheduler(ClubEventRepository eventRepository,
EventRsvpRepository rsvpRepository,
MemberRepository memberRepository,
NotificationService notificationService) {
this.eventRepository = eventRepository;
this.rsvpRepository = rsvpRepository;
this.memberRepository = memberRepository;
this.notificationService = notificationService;
}
@Scheduled(fixedRate = 3600000) // Every hour
@Transactional
public void sendUpcomingEventReminders() {
var now = Instant.now();
var in24h = now.plus(24, ChronoUnit.HOURS);
var in25h = now.plus(25, ChronoUnit.HOURS);
var upcomingEvents = eventRepository.findByStartAtBetweenAndReminderSentFalse(in24h, in25h);
for (var event : upcomingEvents) {
var attendees = rsvpRepository.findByEventIdAndStatusIn(
event.getId(), List.of(RsvpStatus.ACCEPTED, RsvpStatus.MAYBE));
for (var rsvp : attendees) {
var member = memberRepository.findById(rsvp.getMemberId());
member.ifPresent(m -> {
if (m.getUserId() != null) {
notificationService.sendNotification(
m.getUserId(),
NotificationType.EVENT_REMINDER,
"Erinnerung: " + event.getTitle(),
"Morgen um " + formatTime(event.getStartAt()) +
(event.getLocation() != null ? "" + event.getLocation() : ""),
"/portal/events/" + event.getId()
);
}
});
}
// Mark reminder as sent
event.setReminderSent(true);
eventRepository.save(event);
log.info("Sent {} reminders for event '{}'", attendees.size(), event.getTitle());
}
}
private String formatTime(Instant instant) {
return DateTimeFormatter.ofPattern("HH:mm")
.format(instant.atZone(ZoneId.of("Europe/Berlin")));
}
}
@@ -0,0 +1,397 @@
package de.cannamanage.service;
import de.cannamanage.domain.entity.ClubEvent;
import de.cannamanage.domain.entity.EventRsvp;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.domain.enums.*;
import de.cannamanage.service.repository.ClubEventRepository;
import de.cannamanage.service.repository.EventRsvpRepository;
import de.cannamanage.service.repository.MemberRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.*;
/**
* Service for club event management — CRUD, RSVP, iCal generation, and recurring event expansion.
*/
@Service
@Transactional
public class EventService {
private static final Logger log = LoggerFactory.getLogger(EventService.class);
private static final DateTimeFormatter ICAL_DATE_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'");
private final ClubEventRepository eventRepository;
private final EventRsvpRepository rsvpRepository;
private final MemberRepository memberRepository;
private final NotificationService notificationService;
private final InfoBoardService infoBoardService;
private final AuditService auditService;
public EventService(ClubEventRepository eventRepository,
EventRsvpRepository rsvpRepository,
MemberRepository memberRepository,
NotificationService notificationService,
InfoBoardService infoBoardService,
AuditService auditService) {
this.eventRepository = eventRepository;
this.rsvpRepository = rsvpRepository;
this.memberRepository = memberRepository;
this.notificationService = notificationService;
this.infoBoardService = infoBoardService;
this.auditService = auditService;
}
/**
* Create a new club event and notify all members.
*/
public ClubEvent createEvent(UUID clubId, String title, String description, EventType eventType,
Instant startAt, Instant endAt, String location, Integer maxAttendees,
boolean recurring, RecurrenceRule recurrenceRule, LocalDate recurrenceEndDate,
UUID createdBy, boolean postToInfoBoard) {
var event = new ClubEvent(clubId, title, description, eventType, startAt, endAt, location, maxAttendees, createdBy);
event.setRecurring(recurring);
event.setRecurrenceRule(recurrenceRule);
event.setRecurrenceEndDate(recurrenceEndDate);
ClubEvent saved = eventRepository.save(event);
log.info("Club event created: {} '{}' in club {}", saved.getId(), title, clubId);
// Notify all club members
try {
var members = memberRepository.findByClubId(clubId);
members.forEach(member -> {
if (member.getUserId() != null) {
notificationService.sendNotification(
member.getUserId(),
NotificationType.EVENT_CREATED,
title,
formatEventNotification(saved),
"/portal/events/" + saved.getId()
);
}
});
} catch (Exception e) {
log.warn("Failed to send event notifications: {}", e.getMessage());
}
// Auto-post to info board
if (postToInfoBoard) {
try {
String infoContent = formatEventInfoBoardContent(saved);
infoBoardService.createPost(clubId, "📅 " + title, infoContent,
InfoBoardCategory.EVENT, false, createdBy);
} catch (Exception e) {
log.warn("Failed to auto-post event to info board: {}", e.getMessage());
}
}
// Audit
auditService.log(AuditEventType.EVENT_CREATED, "ClubEvent", saved.getId().toString(),
"Event created: " + title);
return saved;
}
/**
* Update event details.
*/
public ClubEvent updateEvent(UUID eventId, String title, String description, EventType eventType,
Instant startAt, Instant endAt, String location, Integer maxAttendees,
boolean recurring, RecurrenceRule recurrenceRule, LocalDate recurrenceEndDate) {
var event = eventRepository.findById(eventId)
.orElseThrow(() -> new NoSuchElementException("Event not found: " + eventId));
event.setTitle(title);
event.setDescription(description);
event.setEventType(eventType);
event.setStartAt(startAt);
event.setEndAt(endAt);
event.setLocation(location);
event.setMaxAttendees(maxAttendees);
event.setRecurring(recurring);
event.setRecurrenceRule(recurrenceRule);
event.setRecurrenceEndDate(recurrenceEndDate);
ClubEvent saved = eventRepository.save(event);
auditService.log(AuditEventType.EVENT_UPDATED, "ClubEvent", saved.getId().toString(),
"Event updated: " + title);
return saved;
}
/**
* Cancel (delete) an event and notify attendees.
*/
public void cancelEvent(UUID eventId) {
var event = eventRepository.findById(eventId)
.orElseThrow(() -> new NoSuchElementException("Event not found: " + eventId));
// Notify ACCEPTED and MAYBE attendees
var attendees = rsvpRepository.findByEventIdAndStatusIn(eventId,
List.of(RsvpStatus.ACCEPTED, RsvpStatus.MAYBE));
for (var rsvp : attendees) {
var member = memberRepository.findById(rsvp.getMemberId());
member.ifPresent(m -> {
if (m.getUserId() != null) {
notificationService.sendNotification(
m.getUserId(),
NotificationType.EVENT_CANCELLED,
"Veranstaltung abgesagt: " + event.getTitle(),
"Die Veranstaltung \"" + event.getTitle() + "\" wurde abgesagt.",
null
);
}
});
}
auditService.log(AuditEventType.EVENT_CANCELLED, "ClubEvent", event.getId().toString(),
"Event cancelled: " + event.getTitle());
eventRepository.delete(event);
log.info("Event cancelled and deleted: {}", eventId);
}
/**
* Get a single event by ID.
*/
@Transactional(readOnly = true)
public ClubEvent getEvent(UUID eventId) {
return eventRepository.findById(eventId)
.orElseThrow(() -> new NoSuchElementException("Event not found: " + eventId));
}
/**
* List events in a date range for calendar view.
* Includes recurring event expansion.
*/
@Transactional(readOnly = true)
public List<ClubEvent> listEvents(Instant from, Instant to) {
UUID tenantId = TenantContext.getCurrentTenant();
var events = eventRepository.findByTenantIdAndStartAtBetweenOrderByStartAtAsc(tenantId, from, to);
// Also find recurring events that started before 'from' but may have occurrences in range
var allEvents = eventRepository.findByTenantIdAndStartAtBetweenOrderByStartAtAsc(
tenantId, Instant.EPOCH, to);
List<ClubEvent> result = new ArrayList<>(events);
for (var event : allEvents) {
if (event.isRecurring() && event.getStartAt().isBefore(from)) {
result.addAll(expandRecurring(event, from, to));
}
}
result.sort(Comparator.comparing(ClubEvent::getStartAt));
return result;
}
/**
* List upcoming events (next 30 days) for portal widget.
*/
@Transactional(readOnly = true)
public List<ClubEvent> listUpcomingEvents(int limit) {
UUID tenantId = TenantContext.getCurrentTenant();
return eventRepository.findByTenantIdAndStartAtAfterOrderByStartAtAsc(
tenantId, Instant.now(), PageRequest.of(0, limit));
}
/**
* Create or update an RSVP. Enforces maxAttendees for ACCEPTED status.
* Returns the RSVP or throws if event is full.
*/
public EventRsvp rsvp(UUID eventId, UUID memberId, RsvpStatus status) {
var event = eventRepository.findById(eventId)
.orElseThrow(() -> new NoSuchElementException("Event not found: " + eventId));
// Enforce max attendees for ACCEPTED
if (status == RsvpStatus.ACCEPTED && event.getMaxAttendees() != null) {
long currentAccepted = rsvpRepository.countByEventIdAndStatus(eventId, RsvpStatus.ACCEPTED);
// Check if this member already had ACCEPTED (update case)
var existing = rsvpRepository.findByEventIdAndMemberId(eventId, memberId);
boolean alreadyAccepted = existing.isPresent() && existing.get().getStatus() == RsvpStatus.ACCEPTED;
if (!alreadyAccepted && currentAccepted >= event.getMaxAttendees()) {
throw new IllegalStateException("EVENT_FULL");
}
}
// Upsert RSVP
var rsvp = rsvpRepository.findByEventIdAndMemberId(eventId, memberId)
.orElseGet(() -> new EventRsvp(event, memberId, status));
rsvp.setStatus(status);
rsvp.setRespondedAt(Instant.now());
return rsvpRepository.save(rsvp);
}
/**
* Get all RSVPs for an event.
*/
@Transactional(readOnly = true)
public List<EventRsvp> getAttendees(UUID eventId) {
return rsvpRepository.findByEventId(eventId);
}
/**
* Get RSVP counts by status for an event.
*/
@Transactional(readOnly = true)
public Map<RsvpStatus, Long> getAttendeeCounts(UUID eventId) {
Map<RsvpStatus, Long> counts = new HashMap<>();
for (RsvpStatus status : RsvpStatus.values()) {
counts.put(status, rsvpRepository.countByEventIdAndStatus(eventId, status));
}
return counts;
}
/**
* Generate RFC 5545 iCal (.ics) content for an event.
*/
@Transactional(readOnly = true)
public String generateIcal(UUID eventId) {
var event = eventRepository.findById(eventId)
.orElseThrow(() -> new NoSuchElementException("Event not found: " + eventId));
var sb = new StringBuilder();
sb.append("BEGIN:VCALENDAR\r\n");
sb.append("VERSION:2.0\r\n");
sb.append("PRODID:-//CannaManage//Events//EN\r\n");
sb.append("BEGIN:VEVENT\r\n");
sb.append("UID:").append(event.getId()).append("@cannamanage.de\r\n");
sb.append("DTSTART:").append(formatIcalDate(event.getStartAt())).append("\r\n");
if (event.getEndAt() != null) {
sb.append("DTEND:").append(formatIcalDate(event.getEndAt())).append("\r\n");
}
sb.append("SUMMARY:").append(escapeIcal(event.getTitle())).append("\r\n");
if (event.getDescription() != null) {
sb.append("DESCRIPTION:").append(escapeIcal(event.getDescription())).append("\r\n");
}
if (event.getLocation() != null) {
sb.append("LOCATION:").append(escapeIcal(event.getLocation())).append("\r\n");
}
if (event.isRecurring() && event.getRecurrenceRule() != null) {
sb.append("RRULE:FREQ=").append(toIcalFreq(event.getRecurrenceRule()));
if (event.getRecurrenceRule() == RecurrenceRule.BIWEEKLY) {
sb.append(";INTERVAL=2");
}
if (event.getRecurrenceEndDate() != null) {
sb.append(";UNTIL=").append(event.getRecurrenceEndDate().format(DateTimeFormatter.BASIC_ISO_DATE)).append("T235959Z");
}
sb.append("\r\n");
}
sb.append("DTSTAMP:").append(formatIcalDate(Instant.now())).append("\r\n");
sb.append("END:VEVENT\r\n");
sb.append("END:VCALENDAR\r\n");
return sb.toString();
}
/**
* Expand a recurring event into virtual occurrences within the given date range.
*/
List<ClubEvent> expandRecurring(ClubEvent event, Instant from, Instant to) {
if (!event.isRecurring() || event.getRecurrenceRule() == null) {
return Collections.emptyList();
}
List<ClubEvent> occurrences = new ArrayList<>();
ZoneId zone = ZoneId.of("Europe/Berlin");
LocalDateTime baseStart = LocalDateTime.ofInstant(event.getStartAt(), zone);
Duration duration = event.getEndAt() != null
? Duration.between(event.getStartAt(), event.getEndAt())
: Duration.ZERO;
LocalDate endDate = event.getRecurrenceEndDate() != null
? event.getRecurrenceEndDate()
: baseStart.toLocalDate().plusYears(1);
LocalDateTime cursor = baseStart;
while (true) {
cursor = advanceCursor(cursor, event.getRecurrenceRule());
Instant occurrenceStart = cursor.atZone(zone).toInstant();
if (occurrenceStart.isAfter(to)) break;
if (cursor.toLocalDate().isAfter(endDate)) break;
if (occurrenceStart.isAfter(from) || occurrenceStart.equals(from)) {
// Create a virtual (non-persisted) event representing this occurrence
ClubEvent virtual = new ClubEvent(
event.getClubId(), event.getTitle(), event.getDescription(),
event.getEventType(), occurrenceStart,
duration.isZero() ? null : occurrenceStart.plus(duration),
event.getLocation(), event.getMaxAttendees(), event.getCreatedBy()
);
virtual.setId(event.getId()); // Same ID for reference
virtual.setRecurring(true);
virtual.setRecurrenceRule(event.getRecurrenceRule());
occurrences.add(virtual);
}
}
return occurrences;
}
// --- Private helpers ---
private LocalDateTime advanceCursor(LocalDateTime cursor, RecurrenceRule rule) {
return switch (rule) {
case WEEKLY -> cursor.plusWeeks(1);
case BIWEEKLY -> cursor.plusWeeks(2);
case MONTHLY -> cursor.plusMonths(1);
};
}
private String formatIcalDate(Instant instant) {
return ICAL_DATE_FORMAT.format(instant.atZone(ZoneOffset.UTC));
}
private String escapeIcal(String text) {
return text.replace("\\", "\\\\")
.replace(",", "\\,")
.replace(";", "\\;")
.replace("\n", "\\n");
}
private String toIcalFreq(RecurrenceRule rule) {
return switch (rule) {
case WEEKLY, BIWEEKLY -> "WEEKLY";
case MONTHLY -> "MONTHLY";
};
}
private String formatEventNotification(ClubEvent event) {
var sb = new StringBuilder("Neue Veranstaltung: ");
sb.append(event.getTitle());
if (event.getLocation() != null) {
sb.append("").append(event.getLocation());
}
return sb.toString();
}
private String formatEventInfoBoardContent(ClubEvent event) {
var sb = new StringBuilder();
sb.append("<p><strong>").append(event.getTitle()).append("</strong></p>");
sb.append("<p>📅 ").append(formatGermanDate(event.getStartAt()));
if (event.getEndAt() != null) {
sb.append(" ").append(formatGermanDate(event.getEndAt()));
}
sb.append("</p>");
if (event.getLocation() != null) {
sb.append("<p>📍 ").append(event.getLocation()).append("</p>");
}
if (event.getDescription() != null) {
sb.append("<p>").append(event.getDescription()).append("</p>");
}
if (event.getMaxAttendees() != null) {
sb.append("<p>Max. Teilnehmer: ").append(event.getMaxAttendees()).append("</p>");
}
return sb.toString();
}
private String formatGermanDate(Instant instant) {
return DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm")
.format(instant.atZone(ZoneId.of("Europe/Berlin")));
}
}
@@ -0,0 +1,20 @@
package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.ClubEvent;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
@Repository
public interface ClubEventRepository extends JpaRepository<ClubEvent, UUID> {
List<ClubEvent> findByTenantIdAndStartAtBetweenOrderByStartAtAsc(UUID tenantId, Instant from, Instant to);
List<ClubEvent> findByTenantIdAndStartAtAfterOrderByStartAtAsc(UUID tenantId, Instant after, Pageable pageable);
List<ClubEvent> findByStartAtBetweenAndReminderSentFalse(Instant from, Instant to);
}
@@ -0,0 +1,22 @@
package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.EventRsvp;
import de.cannamanage.domain.enums.RsvpStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface EventRsvpRepository extends JpaRepository<EventRsvp, UUID> {
List<EventRsvp> findByEventId(UUID eventId);
Optional<EventRsvp> findByEventIdAndMemberId(UUID eventId, UUID memberId);
long countByEventIdAndStatus(UUID eventId, RsvpStatus status);
List<EventRsvp> findByEventIdAndStatusIn(UUID eventId, List<RsvpStatus> statuses);
}