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);
@@ -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
}
+36
View File
@@ -772,5 +772,41 @@
"noPosts": "Noch keine Beiträge vorhanden. Erstelle den ersten Beitrag!",
"confirmDelete": "Möchtest du diesen Beitrag wirklich löschen?",
"unreadCount": "{count} ungelesen"
},
"events": {
"title": "Kalender",
"description": "Veranstaltungen und Termine des Vereins",
"portalTitle": "Veranstaltungen",
"portalDescription": "Kommende Termine und Events deines Vereins",
"createEvent": "Veranstaltung erstellen",
"upcomingEvents": "Nächste Termine",
"noUpcomingEvents": "Keine anstehenden Veranstaltungen",
"noEventsOnDay": "Keine Veranstaltungen an diesem Tag",
"cancel": "Absagen",
"full": "Ausgebucht",
"form": {
"title": "Titel",
"type": "Art",
"start": "Beginn",
"end": "Ende",
"location": "Ort",
"description": "Beschreibung",
"maxAttendees": "Max. Teilnehmer",
"recurring": "Wiederkehrend",
"recurrenceRule": "Wiederholung",
"recurrenceEnd": "Enddatum"
},
"rsvp": {
"accept": "Zusage",
"decline": "Absage",
"maybe": "Vielleicht"
},
"types": {
"MEETING": "Mitgliederversammlung",
"HARVEST_FESTIVAL": "Erntefest",
"BOARD_MEETING": "Vorstandssitzung",
"WORKSHOP": "Workshop",
"OTHER": "Sonstiges"
}
}
}
+36
View File
@@ -717,5 +717,41 @@
"noPosts": "No posts yet. Create the first one!",
"confirmDelete": "Are you sure you want to delete this post?",
"unreadCount": "{count} unread"
},
"events": {
"title": "Calendar",
"description": "Club events and appointments",
"portalTitle": "Events",
"portalDescription": "Upcoming events from your club",
"createEvent": "Create Event",
"upcomingEvents": "Upcoming Events",
"noUpcomingEvents": "No upcoming events",
"noEventsOnDay": "No events on this day",
"cancel": "Cancel",
"full": "Fully booked",
"form": {
"title": "Title",
"type": "Type",
"start": "Start",
"end": "End",
"location": "Location",
"description": "Description",
"maxAttendees": "Max Attendees",
"recurring": "Recurring",
"recurrenceRule": "Recurrence",
"recurrenceEnd": "End Date"
},
"rsvp": {
"accept": "Accept",
"decline": "Decline",
"maybe": "Maybe"
},
"types": {
"MEETING": "Member Meeting",
"HARVEST_FESTIVAL": "Harvest Festival",
"BOARD_MEETING": "Board Meeting",
"WORKSHOP": "Workshop",
"OTHER": "Other"
}
}
}
+1
View File
@@ -41,6 +41,7 @@
"@radix-ui/react-scroll-area": "1.1.0",
"@radix-ui/react-separator": "1.1.1",
"@radix-ui/react-slot": "1.1.1",
"@radix-ui/react-switch": "^1.3.0",
"@radix-ui/react-toast": "1.2.1",
"@radix-ui/react-tooltip": "1.1.5",
"@tanstack/react-query": "^5.101.0",
+31
View File
@@ -50,6 +50,9 @@ importers:
'@radix-ui/react-slot':
specifier: 1.1.1
version: 1.1.1(@types/react@19.0.12)(react@19.1.3)
'@radix-ui/react-switch':
specifier: ^1.3.0
version: 1.3.0(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.3(react@19.1.3))(react@19.1.3)
'@radix-ui/react-toast':
specifier: 1.2.1
version: 1.2.1(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.3(react@19.1.3))(react@19.1.3)
@@ -1514,6 +1517,19 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-switch@1.3.0':
resolution: {integrity: sha512-GP1EZwhoZO/GGnhM1P5/2Vpm8iN8EnngyU0oezn2l78kN8tj25pyrvjIaT7azBhK615KSt+P2w39y57YV5jVkA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-toast@1.2.1':
resolution: {integrity: sha512-5trl7piMXcZiCq7MW6r8YYmu0bK5qDpTWz+FdEPdKyft2UixkspheYbjbrLXVN5NGKHFbOP7lm8eD0biiSqZqg==}
peerDependencies:
@@ -5761,6 +5777,21 @@ snapshots:
optionalDependencies:
'@types/react': 19.0.12
'@radix-ui/react-switch@1.3.0(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.3(react@19.1.3))(react@19.1.3)':
dependencies:
'@radix-ui/primitive': 1.1.4
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.0.12)(react@19.1.3)
'@radix-ui/react-context': 1.1.4(@types/react@19.0.12)(react@19.1.3)
'@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.3(react@19.1.3))(react@19.1.3)
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.0.12)(react@19.1.3)
'@radix-ui/react-use-previous': 1.1.2(@types/react@19.0.12)(react@19.1.3)
'@radix-ui/react-use-size': 1.1.2(@types/react@19.0.12)(react@19.1.3)
react: 19.1.3
react-dom: 19.1.3(react@19.1.3)
optionalDependencies:
'@types/react': 19.0.12
'@types/react-dom': 19.0.4(@types/react@19.0.12)
'@radix-ui/react-toast@1.2.1(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.3(react@19.1.3))(react@19.1.3)':
dependencies:
'@radix-ui/primitive': 1.1.0
@@ -0,0 +1,437 @@
"use client"
import { useState } from "react"
import {
getEventTypeColor,
getEventTypeLabel,
getIcalUrl,
useCancelEventMutation,
useCreateEventMutation,
useEventsQuery,
} from "@/services/events"
import {
addMonths,
eachDayOfInterval,
endOfMonth,
endOfWeek,
format,
isSameDay,
isSameMonth,
startOfMonth,
startOfWeek,
subMonths,
} from "date-fns"
import { de } from "date-fns/locale"
import { useTranslations } from "next-intl"
import {
Calendar,
ChevronLeft,
ChevronRight,
Download,
MapPin,
Plus,
Trash2,
Users,
} from "lucide-react"
import type { ClubEvent, EventType, RecurrenceRule } from "@/services/events"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select } from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
export default function CalendarPage() {
const t = useTranslations("events")
const [currentMonth, setCurrentMonth] = useState(new Date())
const [selectedDate, setSelectedDate] = useState<Date | null>(null)
const [showCreateDialog, setShowCreateDialog] = useState(false)
const monthStart = startOfMonth(currentMonth)
const monthEnd = endOfMonth(currentMonth)
const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 })
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 })
const from = monthStart.toISOString()
const to = monthEnd.toISOString()
const { data: events = [] as ClubEvent[], isLoading } = useEventsQuery(
from,
to
)
const cancelEvent = useCancelEventMutation()
const days = eachDayOfInterval({ start: calendarStart, end: calendarEnd })
const weekDays = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
const getEventsForDay = (day: Date): ClubEvent[] =>
events.filter((event: ClubEvent) => isSameDay(new Date(event.startAt), day))
const selectedDayEvents: ClubEvent[] = selectedDate
? events.filter((event: ClubEvent) =>
isSameDay(new Date(event.startAt), selectedDate)
)
: []
const upcomingEvents = events
.filter((e: ClubEvent) => new Date(e.startAt) >= new Date())
.sort(
(a: ClubEvent, b: ClubEvent) =>
new Date(a.startAt).getTime() - new Date(b.startAt).getTime()
)
.slice(0, 5)
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">{t("title")}</h1>
<p className="text-muted-foreground">{t("description")}</p>
</div>
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
{t("createEvent")}
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{t("createEvent")}</DialogTitle>
</DialogHeader>
<CreateEventForm onSuccess={() => setShowCreateDialog(false)} />
</DialogContent>
</Dialog>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Calendar Grid */}
<Card className="lg:col-span-2">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<Button
variant="ghost"
size="icon"
onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<CardTitle className="text-lg">
{format(currentMonth, "MMMM yyyy", { locale: de })}
</CardTitle>
<Button
variant="ghost"
size="icon"
onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}
>
<ChevronRight className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent>
{/* Week day headers */}
<div className="grid grid-cols-7 gap-1 mb-1">
{weekDays.map((day) => (
<div
key={day}
className="text-center text-xs font-medium text-muted-foreground py-2"
>
{day}
</div>
))}
</div>
{/* Day cells */}
<div className="grid grid-cols-7 gap-1">
{days.map((day) => {
const dayEvents = getEventsForDay(day)
const isCurrentMonth = isSameMonth(day, currentMonth)
const isSelected = selectedDate && isSameDay(day, selectedDate)
const isToday = isSameDay(day, new Date())
return (
<button
key={day.toISOString()}
onClick={() => setSelectedDate(day)}
className={`
relative flex flex-col items-center justify-start p-1 h-16 rounded-md text-sm transition-colors
${!isCurrentMonth ? "text-muted-foreground/40" : ""}
${isSelected ? "bg-primary/10 ring-1 ring-primary" : "hover:bg-muted"}
${isToday ? "font-bold" : ""}
`}
>
<span
className={
isToday
? "bg-primary text-primary-foreground rounded-full w-6 h-6 flex items-center justify-center text-xs"
: ""
}
>
{format(day, "d")}
</span>
{dayEvents.length > 0 && (
<div className="flex gap-0.5 mt-1 flex-wrap justify-center">
{dayEvents.slice(0, 3).map((event: ClubEvent) => (
<div
key={event.id}
className={`w-1.5 h-1.5 rounded-full ${getEventTypeColor(event.eventType)}`}
/>
))}
</div>
)}
</button>
)
})}
</div>
</CardContent>
</Card>
{/* Sidebar: Selected day events or upcoming */}
<div className="space-y-4">
{selectedDate ? (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">
{format(selectedDate, "EEEE, d. MMMM", { locale: de })}
</CardTitle>
</CardHeader>
<CardContent>
{selectedDayEvents.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t("noEventsOnDay")}
</p>
) : (
<div className="space-y-3">
{selectedDayEvents.map((event: ClubEvent) => (
<EventCard
key={event.id}
event={event}
onCancel={() => cancelEvent.mutate(event.id)}
/>
))}
</div>
)}
</CardContent>
</Card>
) : (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Calendar className="h-4 w-4" />
{t("upcomingEvents")}
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<p className="text-sm text-muted-foreground">Laden...</p>
) : upcomingEvents.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t("noUpcomingEvents")}
</p>
) : (
<div className="space-y-3">
{upcomingEvents.map((event: ClubEvent) => (
<EventCard
key={event.id}
event={event}
onCancel={() => cancelEvent.mutate(event.id)}
/>
))}
</div>
)}
</CardContent>
</Card>
)}
</div>
</div>
</div>
)
}
function EventCard({
event,
onCancel,
}: {
event: ClubEvent
onCancel: () => void
}) {
const t = useTranslations("events")
const accepted = event.attendeeCounts?.ACCEPTED ?? 0
const maybe = event.attendeeCounts?.MAYBE ?? 0
return (
<div className="rounded-lg border p-3 space-y-2">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs">
{getEventTypeLabel(event.eventType)}
</Badge>
{event.recurring && (
<Badge variant="outline" className="text-xs">
🔁
</Badge>
)}
</div>
<h4 className="font-medium mt-1">{event.title}</h4>
</div>
</div>
<div className="text-xs text-muted-foreground space-y-0.5">
<p>
📅{" "}
{format(new Date(event.startAt), "dd.MM.yyyy HH:mm", { locale: de })}
</p>
{event.location && (
<p className="flex items-center gap-1">
<MapPin className="h-3 w-3" /> {event.location}
</p>
)}
<p className="flex items-center gap-1">
<Users className="h-3 w-3" /> {accepted} Zusagen, {maybe} Vielleicht
{event.maxAttendees && ` / max. ${event.maxAttendees}`}
</p>
</div>
<div className="flex gap-1 pt-1">
<a href={getIcalUrl(event.id)} download className="inline-flex">
<Button variant="ghost" size="sm" className="h-7 text-xs">
<Download className="h-3 w-3 mr-1" /> iCal
</Button>
</a>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-destructive"
onClick={onCancel}
>
<Trash2 className="h-3 w-3 mr-1" /> {t("cancel")}
</Button>
</div>
</div>
)
}
function CreateEventForm({ onSuccess }: { onSuccess: () => void }) {
const t = useTranslations("events")
const createEvent = useCreateEventMutation()
const [recurring, setRecurring] = useState(false)
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
createEvent.mutate(
{
title: formData.get("title") as string,
description: (formData.get("description") as string) || undefined,
eventType: formData.get("eventType") as EventType,
startAt: new Date(formData.get("startAt") as string).toISOString(),
endAt: formData.get("endAt")
? new Date(formData.get("endAt") as string).toISOString()
: undefined,
location: (formData.get("location") as string) || undefined,
maxAttendees: formData.get("maxAttendees")
? Number(formData.get("maxAttendees"))
: undefined,
recurring,
recurrenceRule: recurring
? (formData.get("recurrenceRule") as RecurrenceRule)
: undefined,
recurrenceEndDate:
recurring && formData.get("recurrenceEndDate")
? (formData.get("recurrenceEndDate") as string)
: undefined,
postToInfoBoard: true,
},
{ onSuccess }
)
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">{t("form.title")}</Label>
<Input id="title" name="title" required maxLength={200} />
</div>
<div className="space-y-2">
<Label htmlFor="eventType">{t("form.type")}</Label>
<Select name="eventType" defaultValue="MEETING">
<option value="MEETING">Mitgliederversammlung</option>
<option value="HARVEST_FESTIVAL">Erntefest</option>
<option value="BOARD_MEETING">Vorstandssitzung</option>
<option value="WORKSHOP">Workshop</option>
<option value="OTHER">Sonstiges</option>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="startAt">{t("form.start")}</Label>
<Input id="startAt" name="startAt" type="datetime-local" required />
</div>
<div className="space-y-2">
<Label htmlFor="endAt">{t("form.end")}</Label>
<Input id="endAt" name="endAt" type="datetime-local" />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="location">{t("form.location")}</Label>
<Input id="location" name="location" maxLength={300} />
</div>
<div className="space-y-2">
<Label htmlFor="description">{t("form.description")}</Label>
<Textarea id="description" name="description" rows={3} />
</div>
<div className="space-y-2">
<Label htmlFor="maxAttendees">{t("form.maxAttendees")}</Label>
<Input id="maxAttendees" name="maxAttendees" type="number" min={1} />
</div>
<div className="flex items-center gap-3">
<Switch
id="recurring"
checked={recurring}
onCheckedChange={setRecurring}
/>
<Label htmlFor="recurring">{t("form.recurring")}</Label>
</div>
{recurring && (
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="recurrenceRule">{t("form.recurrenceRule")}</Label>
<Select name="recurrenceRule" defaultValue="WEEKLY">
<option value="WEEKLY">Wöchentlich</option>
<option value="BIWEEKLY">Alle 2 Wochen</option>
<option value="MONTHLY">Monatlich</option>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="recurrenceEndDate">{t("form.recurrenceEnd")}</Label>
<Input
id="recurrenceEndDate"
name="recurrenceEndDate"
type="date"
/>
</div>
</div>
)}
<Button type="submit" className="w-full" disabled={createEvent.isPending}>
{createEvent.isPending ? "Erstelle..." : t("createEvent")}
</Button>
</form>
)
}
@@ -0,0 +1,143 @@
"use client"
import {
getEventTypeLabel,
getIcalUrl,
usePortalRsvpMutation,
useUpcomingEventsQuery,
} from "@/services/events"
import { format } from "date-fns"
import { de } from "date-fns/locale"
import { useTranslations } from "next-intl"
import {
Calendar,
Check,
Download,
HelpCircle,
MapPin,
Users,
X,
} from "lucide-react"
import type { ClubEvent, RsvpStatus } from "@/services/events"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
export default function PortalEventsPage() {
const t = useTranslations("events")
const { data: events = [], isLoading } = useUpcomingEventsQuery()
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Calendar className="h-6 w-6" />
{t("portalTitle")}
</h1>
<p className="text-muted-foreground">{t("portalDescription")}</p>
</div>
{isLoading ? (
<p className="text-muted-foreground">Laden...</p>
) : events.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
{t("noUpcomingEvents")}
</CardContent>
</Card>
) : (
<div className="space-y-4">
{events.map((event: ClubEvent) => (
<PortalEventCard key={event.id} event={event} />
))}
</div>
)}
</div>
)
}
function PortalEventCard({ event }: { event: ClubEvent }) {
const t = useTranslations("events")
const rsvpMutation = usePortalRsvpMutation(event.id)
const accepted = event.attendeeCounts?.ACCEPTED ?? 0
const maybe = event.attendeeCounts?.MAYBE ?? 0
const isFull = event.maxAttendees != null && accepted >= event.maxAttendees
const handleRsvp = (status: RsvpStatus) => {
rsvpMutation.mutate(status)
}
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div>
<Badge variant="secondary" className="mb-2">
{getEventTypeLabel(event.eventType)}
</Badge>
<CardTitle className="text-lg">{event.title}</CardTitle>
</div>
{isFull && <Badge variant="destructive">{t("full")}</Badge>}
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-sm text-muted-foreground space-y-1">
<p>
📅{" "}
{format(new Date(event.startAt), "EEEE, d. MMMM yyyy · HH:mm", {
locale: de,
})}{" "}
Uhr
{event.endAt && ` ${format(new Date(event.endAt), "HH:mm")} Uhr`}
</p>
{event.location && (
<p className="flex items-center gap-1">
<MapPin className="h-4 w-4" /> {event.location}
</p>
)}
<p className="flex items-center gap-1">
<Users className="h-4 w-4" /> {accepted} Zusagen, {maybe} Vielleicht
{event.maxAttendees && ` (max. ${event.maxAttendees})`}
</p>
</div>
{event.description && <p className="text-sm">{event.description}</p>}
{/* RSVP Buttons */}
<div className="flex flex-wrap gap-2 pt-2">
<Button
size="sm"
variant={event.myRsvpStatus === "ACCEPTED" ? "default" : "outline"}
onClick={() => handleRsvp("ACCEPTED")}
disabled={isFull && event.myRsvpStatus !== "ACCEPTED"}
>
<Check className="h-4 w-4 mr-1" /> {t("rsvp.accept")}
</Button>
<Button
size="sm"
variant={event.myRsvpStatus === "MAYBE" ? "default" : "outline"}
onClick={() => handleRsvp("MAYBE")}
>
<HelpCircle className="h-4 w-4 mr-1" /> {t("rsvp.maybe")}
</Button>
<Button
size="sm"
variant={
event.myRsvpStatus === "DECLINED" ? "destructive" : "outline"
}
onClick={() => handleRsvp("DECLINED")}
>
<X className="h-4 w-4 mr-1" /> {t("rsvp.decline")}
</Button>
<a href={getIcalUrl(event.id)} download>
<Button size="sm" variant="ghost">
<Download className="h-4 w-4 mr-1" /> iCal
</Button>
</a>
</div>
</CardContent>
</Card>
)
}
@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ComponentRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }
@@ -39,6 +39,11 @@ export const navigationsData: NavigationType[] = [
href: "/info-board",
iconName: "Megaphone",
},
{
title: "Kalender",
href: "/calendar",
iconName: "Calendar",
},
{
title: "Personal",
href: "/settings/staff",
+200
View File
@@ -0,0 +1,200 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { apiClient } from "@/lib/api-client"
// --- Types ---
export type EventType =
| "MEETING"
| "HARVEST_FESTIVAL"
| "BOARD_MEETING"
| "WORKSHOP"
| "OTHER"
export type RsvpStatus = "ACCEPTED" | "DECLINED" | "MAYBE"
export type RecurrenceRule = "WEEKLY" | "BIWEEKLY" | "MONTHLY"
export interface ClubEvent {
id: string
title: string
description: string | null
eventType: EventType
startAt: string
endAt: string | null
location: string | null
maxAttendees: number | null
recurring: boolean
recurrenceRule: RecurrenceRule | null
recurrenceEndDate: string | null
createdBy: string
createdAt: string
attendeeCounts: Record<RsvpStatus, number>
myRsvpStatus: RsvpStatus | null
}
export interface CreateEventRequest {
title: string
description?: string
eventType: EventType
startAt: string
endAt?: string
location?: string
maxAttendees?: number
recurring: boolean
recurrenceRule?: RecurrenceRule
recurrenceEndDate?: string
postToInfoBoard?: boolean
}
export interface UpdateEventRequest {
title: string
description?: string
eventType: EventType
startAt: string
endAt?: string
location?: string
maxAttendees?: number
recurring: boolean
recurrenceRule?: RecurrenceRule
recurrenceEndDate?: string
}
export interface RsvpResponse {
memberId: string
memberName: string
status: RsvpStatus
respondedAt: string
}
// --- Query Hooks ---
export function useEventsQuery(from: string, to: string) {
return useQuery({
queryKey: ["events", from, to],
queryFn: () =>
apiClient<ClubEvent[]>(`/events?from=${from}&to=${to}`),
enabled: !!from && !!to,
})
}
export function useEventQuery(eventId: string | undefined) {
return useQuery({
queryKey: ["events", eventId],
queryFn: () => apiClient<ClubEvent>(`/events/${eventId}`),
enabled: !!eventId,
})
}
export function useUpcomingEventsQuery() {
return useQuery({
queryKey: ["events", "upcoming"],
queryFn: () => apiClient<ClubEvent[]>("/portal/events"),
})
}
export function useEventAttendeesQuery(eventId: string | undefined) {
return useQuery({
queryKey: ["events", eventId, "attendees"],
queryFn: () => apiClient<RsvpResponse[]>(`/events/${eventId}/attendees`),
enabled: !!eventId,
})
}
// --- Mutation Hooks ---
export function useCreateEventMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: CreateEventRequest) =>
apiClient<ClubEvent>("/events", { method: "POST", body: data }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["events"] })
},
})
}
export function useUpdateEventMutation(eventId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: UpdateEventRequest) =>
apiClient<ClubEvent>(`/events/${eventId}`, { method: "PUT", body: data }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["events"] })
},
})
}
export function useCancelEventMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (eventId: string) =>
apiClient<void>(`/events/${eventId}`, { method: "DELETE" }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["events"] })
},
})
}
export function useRsvpMutation(eventId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (status: RsvpStatus) =>
apiClient<{ status: RsvpStatus; respondedAt: string }>(
`/events/${eventId}/rsvp`,
{ method: "POST", body: { status } }
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["events"] })
},
})
}
export function usePortalRsvpMutation(eventId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (status: RsvpStatus) =>
apiClient<{ status: RsvpStatus; respondedAt: string }>(
`/portal/events/${eventId}/rsvp`,
{ method: "POST", body: { status } }
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["events"] })
},
})
}
// --- Helpers ---
export function getEventTypeLabel(type: EventType): string {
const labels: Record<EventType, string> = {
MEETING: "Mitgliederversammlung",
HARVEST_FESTIVAL: "Erntefest",
BOARD_MEETING: "Vorstandssitzung",
WORKSHOP: "Workshop",
OTHER: "Sonstiges",
}
return labels[type]
}
export function getEventTypeColor(type: EventType): string {
const colors: Record<EventType, string> = {
MEETING: "bg-blue-500",
HARVEST_FESTIVAL: "bg-green-500",
BOARD_MEETING: "bg-purple-500",
WORKSHOP: "bg-amber-500",
OTHER: "bg-gray-500",
}
return colors[type]
}
export function getRsvpStatusLabel(status: RsvpStatus): string {
const labels: Record<RsvpStatus, string> = {
ACCEPTED: "Zusage",
DECLINED: "Absage",
MAYBE: "Vielleicht",
}
return labels[status]
}
export function getIcalUrl(eventId: string): string {
return `/api/backend/events/${eventId}/ical`
}
@@ -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);
}