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,224 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.dto.event.*;
import de.cannamanage.api.security.StaffPermissionChecker;
import de.cannamanage.domain.entity.ClubEvent;
import de.cannamanage.domain.entity.EventRsvp;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.domain.enums.RsvpStatus;
import de.cannamanage.domain.enums.StaffPermission;
import de.cannamanage.service.EventService;
import de.cannamanage.service.repository.EventRsvpRepository;
import de.cannamanage.service.repository.MemberRepository;
import jakarta.validation.Valid;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import java.time.Instant;
import java.util.*;
/**
* REST controller for club event management.
* Admin endpoints require MANAGE_INFO_BOARD permission.
* Portal endpoints are accessible to authenticated members.
*/
@RestController
@RequestMapping("/api/v1")
public class EventController {
private final EventService eventService;
private final EventRsvpRepository rsvpRepository;
private final MemberRepository memberRepository;
private final StaffPermissionChecker permissionChecker;
public EventController(EventService eventService,
EventRsvpRepository rsvpRepository,
MemberRepository memberRepository,
StaffPermissionChecker permissionChecker) {
this.eventService = eventService;
this.rsvpRepository = rsvpRepository;
this.memberRepository = memberRepository;
this.permissionChecker = permissionChecker;
}
// === Admin endpoints ===
@PostMapping("/events")
public ResponseEntity<EventResponse> createEvent(@Valid @RequestBody CreateEventRequest request,
@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_INFO_BOARD);
UUID clubId = TenantContext.getCurrentTenant();
UUID userId = UUID.fromString(principal.getUsername());
boolean postToInfoBoard = request.postToInfoBoard() == null || request.postToInfoBoard();
ClubEvent event = eventService.createEvent(
clubId, request.title(), request.description(), request.eventType(),
request.startAt(), request.endAt(), request.location(), request.maxAttendees(),
request.recurring(), request.recurrenceRule(), request.recurrenceEndDate(),
userId, postToInfoBoard
);
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(event, null));
}
@GetMapping("/events")
public ResponseEntity<List<EventResponse>> listEvents(
@RequestParam Instant from,
@RequestParam Instant to,
@AuthenticationPrincipal UserDetails principal) {
List<ClubEvent> events = eventService.listEvents(from, to);
UUID userId = UUID.fromString(principal.getUsername());
UUID memberId = getMemberIdForUser(userId);
List<EventResponse> responses = events.stream()
.map(e -> toResponse(e, memberId))
.toList();
return ResponseEntity.ok(responses);
}
@GetMapping("/events/{id}")
public ResponseEntity<EventResponse> getEvent(@PathVariable UUID id,
@AuthenticationPrincipal UserDetails principal) {
ClubEvent event = eventService.getEvent(id);
UUID userId = UUID.fromString(principal.getUsername());
UUID memberId = getMemberIdForUser(userId);
return ResponseEntity.ok(toResponse(event, memberId));
}
@PutMapping("/events/{id}")
public ResponseEntity<EventResponse> updateEvent(@PathVariable UUID id,
@Valid @RequestBody UpdateEventRequest request,
@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_INFO_BOARD);
ClubEvent event = eventService.updateEvent(id, request.title(), request.description(),
request.eventType(), request.startAt(), request.endAt(), request.location(),
request.maxAttendees(), request.recurring(), request.recurrenceRule(),
request.recurrenceEndDate());
return ResponseEntity.ok(toResponse(event, null));
}
@DeleteMapping("/events/{id}")
public ResponseEntity<Void> cancelEvent(@PathVariable UUID id,
@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_INFO_BOARD);
eventService.cancelEvent(id);
return ResponseEntity.noContent().build();
}
@PostMapping("/events/{id}/rsvp")
public ResponseEntity<?> rsvp(@PathVariable UUID id,
@Valid @RequestBody RsvpRequest request,
@AuthenticationPrincipal UserDetails principal) {
UUID userId = UUID.fromString(principal.getUsername());
UUID memberId = getMemberIdForUser(userId);
if (memberId == null) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
try {
EventRsvp rsvp = eventService.rsvp(id, memberId, request.status());
return ResponseEntity.ok(Map.of(
"status", rsvp.getStatus(),
"respondedAt", rsvp.getRespondedAt()
));
} catch (IllegalStateException e) {
if ("EVENT_FULL".equals(e.getMessage())) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "EVENT_FULL", "message", "Veranstaltung ist ausgebucht"));
}
throw e;
}
}
@GetMapping("/events/{id}/attendees")
public ResponseEntity<List<RsvpResponse>> getAttendees(@PathVariable UUID id) {
List<EventRsvp> rsvps = eventService.getAttendees(id);
List<RsvpResponse> responses = rsvps.stream()
.map(r -> {
String memberName = memberRepository.findById(r.getMemberId())
.map(m -> m.getFirstName() + " " + m.getLastName())
.orElse("Unknown");
return new RsvpResponse(r.getMemberId(), memberName, r.getStatus(), r.getRespondedAt());
})
.toList();
return ResponseEntity.ok(responses);
}
@GetMapping("/events/{id}/ical")
public ResponseEntity<String> downloadIcal(@PathVariable UUID id) {
String ical = eventService.generateIcal(id);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"event.ics\"")
.contentType(MediaType.parseMediaType("text/calendar"))
.body(ical);
}
// === Portal endpoints ===
@GetMapping("/portal/events")
public ResponseEntity<List<EventResponse>> portalEvents(@AuthenticationPrincipal UserDetails principal) {
UUID userId = UUID.fromString(principal.getUsername());
UUID memberId = getMemberIdForUser(userId);
List<ClubEvent> events = eventService.listUpcomingEvents(10);
List<EventResponse> responses = events.stream()
.map(e -> toResponse(e, memberId))
.toList();
return ResponseEntity.ok(responses);
}
@PostMapping("/portal/events/{id}/rsvp")
public ResponseEntity<?> portalRsvp(@PathVariable UUID id,
@Valid @RequestBody RsvpRequest request,
@AuthenticationPrincipal UserDetails principal) {
return rsvp(id, request, principal);
}
// === Helpers ===
private EventResponse toResponse(ClubEvent event, UUID memberId) {
Map<RsvpStatus, Long> counts = new HashMap<>();
RsvpStatus myStatus = null;
if (event.getId() != null) {
try {
counts = eventService.getAttendeeCounts(event.getId());
if (memberId != null) {
myStatus = rsvpRepository.findByEventIdAndMemberId(event.getId(), memberId)
.map(EventRsvp::getStatus)
.orElse(null);
}
} catch (Exception e) {
// Virtual expanded events may not have a DB id
}
}
return new EventResponse(
event.getId(),
event.getTitle(),
event.getDescription(),
event.getEventType(),
event.getStartAt(),
event.getEndAt(),
event.getLocation(),
event.getMaxAttendees(),
event.isRecurring(),
event.getRecurrenceRule(),
event.getRecurrenceEndDate(),
event.getCreatedBy(),
event.getCreatedAt(),
counts,
myStatus
);
}
private UUID getMemberIdForUser(UUID userId) {
return memberRepository.findByUserId(userId)
.map(m -> m.getId())
.orElse(null);
}
}
@@ -0,0 +1,24 @@
package de.cannamanage.api.dto.event;
import de.cannamanage.domain.enums.EventType;
import de.cannamanage.domain.enums.RecurrenceRule;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.Instant;
import java.time.LocalDate;
public record CreateEventRequest(
@NotBlank @Size(max = 200) String title,
String description,
@NotNull EventType eventType,
@NotNull Instant startAt,
Instant endAt,
@Size(max = 300) String location,
Integer maxAttendees,
boolean recurring,
RecurrenceRule recurrenceRule,
LocalDate recurrenceEndDate,
Boolean postToInfoBoard // defaults to true if null
) {}
@@ -0,0 +1,28 @@
package de.cannamanage.api.dto.event;
import de.cannamanage.domain.enums.EventType;
import de.cannamanage.domain.enums.RecurrenceRule;
import de.cannamanage.domain.enums.RsvpStatus;
import java.time.Instant;
import java.time.LocalDate;
import java.util.Map;
import java.util.UUID;
public record EventResponse(
UUID id,
String title,
String description,
EventType eventType,
Instant startAt,
Instant endAt,
String location,
Integer maxAttendees,
boolean recurring,
RecurrenceRule recurrenceRule,
LocalDate recurrenceEndDate,
UUID createdBy,
Instant createdAt,
Map<RsvpStatus, Long> attendeeCounts,
RsvpStatus myRsvpStatus
) {}
@@ -0,0 +1,8 @@
package de.cannamanage.api.dto.event;
import de.cannamanage.domain.enums.RsvpStatus;
import jakarta.validation.constraints.NotNull;
public record RsvpRequest(
@NotNull RsvpStatus status
) {}
@@ -0,0 +1,13 @@
package de.cannamanage.api.dto.event;
import de.cannamanage.domain.enums.RsvpStatus;
import java.time.Instant;
import java.util.UUID;
public record RsvpResponse(
UUID memberId,
String memberName,
RsvpStatus status,
Instant respondedAt
) {}
@@ -0,0 +1,23 @@
package de.cannamanage.api.dto.event;
import de.cannamanage.domain.enums.EventType;
import de.cannamanage.domain.enums.RecurrenceRule;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.Instant;
import java.time.LocalDate;
public record UpdateEventRequest(
@NotBlank @Size(max = 200) String title,
String description,
@NotNull EventType eventType,
@NotNull Instant startAt,
Instant endAt,
@Size(max = 300) String location,
Integer maxAttendees,
boolean recurring,
RecurrenceRule recurrenceRule,
LocalDate recurrenceEndDate
) {}
@@ -0,0 +1,40 @@
-- Sprint 7 Phase 2.5: Club Event Calendar
-- Club events with RSVP support, recurring events, and iCal export
CREATE TABLE club_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
club_id UUID NOT NULL REFERENCES clubs(id),
title VARCHAR(200) NOT NULL,
description TEXT,
event_type VARCHAR(50) NOT NULL,
start_at TIMESTAMP WITH TIME ZONE NOT NULL,
end_at TIMESTAMP WITH TIME ZONE,
location VARCHAR(300),
max_attendees INTEGER,
is_recurring BOOLEAN NOT NULL DEFAULT FALSE,
recurrence_rule VARCHAR(100),
recurrence_end_date DATE,
reminder_sent BOOLEAN NOT NULL DEFAULT FALSE,
created_by UUID NOT NULL REFERENCES users(id),
tenant_id UUID NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_club_events_tenant_start ON club_events(tenant_id, start_at);
CREATE INDEX idx_club_events_type ON club_events(tenant_id, event_type);
CREATE INDEX idx_club_events_club_id ON club_events(club_id);
-- Event RSVPs
CREATE TABLE event_rsvps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID NOT NULL REFERENCES club_events(id) ON DELETE CASCADE,
member_id UUID NOT NULL REFERENCES members(id),
status VARCHAR(20) NOT NULL,
responded_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
tenant_id UUID NOT NULL,
UNIQUE(event_id, member_id)
);
CREATE INDEX idx_event_rsvps_event ON event_rsvps(event_id);
CREATE INDEX idx_event_rsvps_member ON event_rsvps(member_id);