Add organizer-only attendee list to event detail view (011)
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:
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -83,6 +83,47 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ProblemDetail"
|
||||
|
||||
/events/{token}/attendees:
|
||||
get:
|
||||
operationId: getAttendees
|
||||
summary: Get attendee list for an event (organizer only)
|
||||
tags:
|
||||
- events
|
||||
parameters:
|
||||
- name: token
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Public event token
|
||||
- name: organizerToken
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Organizer token for authorization
|
||||
responses:
|
||||
"200":
|
||||
description: Attendee list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GetAttendeesResponse"
|
||||
"403":
|
||||
description: Invalid organizer token
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ProblemDetail"
|
||||
"404":
|
||||
description: Event not found
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ProblemDetail"
|
||||
|
||||
/events/{token}:
|
||||
get:
|
||||
operationId: getEvent
|
||||
@@ -256,6 +297,30 @@ components:
|
||||
description: Guest's display name as stored
|
||||
example: "Max Mustermann"
|
||||
|
||||
GetAttendeesResponse:
|
||||
type: object
|
||||
required:
|
||||
- attendees
|
||||
properties:
|
||||
attendees:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Attendee"
|
||||
example:
|
||||
- name: "Alice"
|
||||
- name: "Bob"
|
||||
|
||||
Attendee:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 100
|
||||
example: "Alice"
|
||||
|
||||
ProblemDetail:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
Reference in New Issue
Block a user