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);
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+31
@@ -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",
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
+20
@@ -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);
|
||||
}
|
||||
+22
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user