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