Add organizer-only attendee list to event detail view (011)
All checks were successful
CI / backend-test (push) Successful in 59s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m11s
CI / build-and-publish (push) Has been skipped

New GET /events/{token}/attendees endpoint returns attendee names when
a valid organizer token is provided (403 otherwise). The frontend
conditionally renders the list below the attendee count for organizers,
silently degrading for visitors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 18:34:27 +01:00
parent d7ed28e036
commit 763811fce6
24 changed files with 1307 additions and 3 deletions

View File

@@ -1,25 +1,30 @@
package de.fete.adapter.in.web;
import de.fete.adapter.in.web.api.EventsApi;
import de.fete.adapter.in.web.model.Attendee;
import de.fete.adapter.in.web.model.CreateEventRequest;
import de.fete.adapter.in.web.model.CreateEventResponse;
import de.fete.adapter.in.web.model.CreateRsvpRequest;
import de.fete.adapter.in.web.model.CreateRsvpResponse;
import de.fete.adapter.in.web.model.GetAttendeesResponse;
import de.fete.adapter.in.web.model.GetEventResponse;
import de.fete.application.service.EventNotFoundException;
import de.fete.application.service.InvalidTimezoneException;
import de.fete.domain.model.CreateEventCommand;
import de.fete.domain.model.Event;
import de.fete.domain.model.EventToken;
import de.fete.domain.model.OrganizerToken;
import de.fete.domain.model.Rsvp;
import de.fete.domain.port.in.CountAttendeesByEventUseCase;
import de.fete.domain.port.in.CreateEventUseCase;
import de.fete.domain.port.in.CreateRsvpUseCase;
import de.fete.domain.port.in.GetAttendeesUseCase;
import de.fete.domain.port.in.GetEventUseCase;
import java.time.Clock;
import java.time.DateTimeException;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
@@ -33,6 +38,7 @@ public class EventController implements EventsApi {
private final GetEventUseCase getEventUseCase;
private final CreateRsvpUseCase createRsvpUseCase;
private final CountAttendeesByEventUseCase countAttendeesByEventUseCase;
private final GetAttendeesUseCase getAttendeesUseCase;
private final Clock clock;
/** Creates a new controller with the given use cases and clock. */
@@ -41,11 +47,13 @@ public class EventController implements EventsApi {
GetEventUseCase getEventUseCase,
CreateRsvpUseCase createRsvpUseCase,
CountAttendeesByEventUseCase countAttendeesByEventUseCase,
GetAttendeesUseCase getAttendeesUseCase,
Clock clock) {
this.createEventUseCase = createEventUseCase;
this.getEventUseCase = getEventUseCase;
this.createRsvpUseCase = createRsvpUseCase;
this.countAttendeesByEventUseCase = countAttendeesByEventUseCase;
this.getAttendeesUseCase = getAttendeesUseCase;
this.clock = clock;
}
@@ -97,6 +105,25 @@ public class EventController implements EventsApi {
return ResponseEntity.ok(response);
}
@Override
public ResponseEntity<GetAttendeesResponse> getAttendees(
UUID token, UUID organizerToken) {
var eventToken = new EventToken(token);
var orgToken = new OrganizerToken(organizerToken);
List<String> names = getAttendeesUseCase
.getAttendeeNames(eventToken, orgToken);
var attendees = names.stream()
.map(name -> new Attendee().name(name))
.toList();
var response = new GetAttendeesResponse();
response.setAttendees(attendees);
return ResponseEntity.ok(response);
}
@Override
public ResponseEntity<CreateRsvpResponse> createRsvp(
UUID token, CreateRsvpRequest createRsvpRequest) {

View File

@@ -4,6 +4,7 @@ import de.fete.application.service.EventExpiredException;
import de.fete.application.service.EventNotFoundException;
import de.fete.application.service.ExpiryDateBeforeEventException;
import de.fete.application.service.ExpiryDateInPastException;
import de.fete.application.service.InvalidOrganizerTokenException;
import de.fete.application.service.InvalidTimezoneException;
import java.net.URI;
import java.util.List;
@@ -87,6 +88,19 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
.body(problemDetail);
}
/** Handles invalid organizer token. */
@ExceptionHandler(InvalidOrganizerTokenException.class)
public ResponseEntity<ProblemDetail> handleInvalidOrganizerToken(
InvalidOrganizerTokenException ex) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
HttpStatus.FORBIDDEN, ex.getMessage());
problemDetail.setTitle("Forbidden");
problemDetail.setType(URI.create("urn:problem-type:invalid-organizer-token"));
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(problemDetail);
}
/** Handles event not found. */
@ExceptionHandler(EventNotFoundException.class)
public ResponseEntity<ProblemDetail> handleEventNotFound(

View File

@@ -11,4 +11,7 @@ public interface RsvpJpaRepository extends JpaRepository<RsvpJpaEntity, Long> {
/** Counts RSVPs for the given event. */
long countByEventId(Long eventId);
/** Finds all RSVPs for the given event, ordered by ID ascending. */
java.util.List<RsvpJpaEntity> findAllByEventIdOrderByIdAsc(Long eventId);
}

View File

@@ -3,6 +3,7 @@ package de.fete.adapter.out.persistence;
import de.fete.domain.model.Rsvp;
import de.fete.domain.model.RsvpToken;
import de.fete.domain.port.out.RsvpRepository;
import java.util.List;
import org.springframework.stereotype.Repository;
/** Persistence adapter implementing the RsvpRepository outbound port. */
@@ -28,6 +29,13 @@ public class RsvpPersistenceAdapter implements RsvpRepository {
return jpaRepository.countByEventId(eventId);
}
@Override
public List<Rsvp> findByEventId(Long eventId) {
return jpaRepository.findAllByEventIdOrderByIdAsc(eventId).stream()
.map(this::toDomain)
.toList();
}
private RsvpJpaEntity toEntity(Rsvp rsvp) {
var entity = new RsvpJpaEntity();
entity.setId(rsvp.getId());

View File

@@ -0,0 +1,10 @@
package de.fete.application.service;
/** Thrown when an invalid organizer token is provided. */
public class InvalidOrganizerTokenException extends RuntimeException {
/** Creates a new exception for an invalid organizer token. */
public InvalidOrganizerTokenException() {
super("Invalid organizer token.");
}
}

View File

@@ -2,19 +2,23 @@ package de.fete.application.service;
import de.fete.domain.model.Event;
import de.fete.domain.model.EventToken;
import de.fete.domain.model.OrganizerToken;
import de.fete.domain.model.Rsvp;
import de.fete.domain.model.RsvpToken;
import de.fete.domain.port.in.CountAttendeesByEventUseCase;
import de.fete.domain.port.in.CreateRsvpUseCase;
import de.fete.domain.port.in.GetAttendeesUseCase;
import de.fete.domain.port.out.EventRepository;
import de.fete.domain.port.out.RsvpRepository;
import java.time.Clock;
import java.time.LocalDate;
import java.util.List;
import org.springframework.stereotype.Service;
/** Application service implementing RSVP creation. */
/** Application service implementing RSVP operations. */
@Service
public class RsvpService implements CreateRsvpUseCase, CountAttendeesByEventUseCase {
public class RsvpService
implements CreateRsvpUseCase, CountAttendeesByEventUseCase, GetAttendeesUseCase {
private final EventRepository eventRepository;
private final RsvpRepository rsvpRepository;
@@ -53,4 +57,18 @@ public class RsvpService implements CreateRsvpUseCase, CountAttendeesByEventUseC
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
return rsvpRepository.countByEventId(event.getId());
}
@Override
public List<String> getAttendeeNames(EventToken eventToken, OrganizerToken organizerToken) {
Event event = eventRepository.findByEventToken(eventToken)
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
if (!event.getOrganizerToken().equals(organizerToken)) {
throw new InvalidOrganizerTokenException();
}
return rsvpRepository.findByEventId(event.getId()).stream()
.map(Rsvp::getName)
.toList();
}
}

View File

@@ -0,0 +1,12 @@
package de.fete.domain.port.in;
import de.fete.domain.model.EventToken;
import de.fete.domain.model.OrganizerToken;
import java.util.List;
/** Inbound port for retrieving attendee names of an event. */
public interface GetAttendeesUseCase {
/** Returns attendee names ordered by RSVP submission time. */
List<String> getAttendeeNames(EventToken eventToken, OrganizerToken organizerToken);
}

View File

@@ -1,6 +1,7 @@
package de.fete.domain.port.out;
import de.fete.domain.model.Rsvp;
import java.util.List;
/** Outbound port for persisting and querying RSVPs. */
public interface RsvpRepository {
@@ -10,4 +11,7 @@ public interface RsvpRepository {
/** Counts the number of RSVPs for the given event. */
long countByEventId(Long eventId);
/** Finds all RSVPs for the given event, ordered by ID ascending. */
List<Rsvp> findByEventId(Long eventId);
}