Merge pull request 'Add organizer-only attendee list (011)' (#20) from 011-view-attendee-list into master
This commit was merged in pull request #20.
This commit is contained in:
@@ -1,25 +1,30 @@
|
|||||||
package de.fete.adapter.in.web;
|
package de.fete.adapter.in.web;
|
||||||
|
|
||||||
import de.fete.adapter.in.web.api.EventsApi;
|
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.CreateEventRequest;
|
||||||
import de.fete.adapter.in.web.model.CreateEventResponse;
|
import de.fete.adapter.in.web.model.CreateEventResponse;
|
||||||
import de.fete.adapter.in.web.model.CreateRsvpRequest;
|
import de.fete.adapter.in.web.model.CreateRsvpRequest;
|
||||||
import de.fete.adapter.in.web.model.CreateRsvpResponse;
|
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.adapter.in.web.model.GetEventResponse;
|
||||||
import de.fete.application.service.EventNotFoundException;
|
import de.fete.application.service.EventNotFoundException;
|
||||||
import de.fete.application.service.InvalidTimezoneException;
|
import de.fete.application.service.InvalidTimezoneException;
|
||||||
import de.fete.domain.model.CreateEventCommand;
|
import de.fete.domain.model.CreateEventCommand;
|
||||||
import de.fete.domain.model.Event;
|
import de.fete.domain.model.Event;
|
||||||
import de.fete.domain.model.EventToken;
|
import de.fete.domain.model.EventToken;
|
||||||
|
import de.fete.domain.model.OrganizerToken;
|
||||||
import de.fete.domain.model.Rsvp;
|
import de.fete.domain.model.Rsvp;
|
||||||
import de.fete.domain.port.in.CountAttendeesByEventUseCase;
|
import de.fete.domain.port.in.CountAttendeesByEventUseCase;
|
||||||
import de.fete.domain.port.in.CreateEventUseCase;
|
import de.fete.domain.port.in.CreateEventUseCase;
|
||||||
import de.fete.domain.port.in.CreateRsvpUseCase;
|
import de.fete.domain.port.in.CreateRsvpUseCase;
|
||||||
|
import de.fete.domain.port.in.GetAttendeesUseCase;
|
||||||
import de.fete.domain.port.in.GetEventUseCase;
|
import de.fete.domain.port.in.GetEventUseCase;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.DateTimeException;
|
import java.time.DateTimeException;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -33,6 +38,7 @@ public class EventController implements EventsApi {
|
|||||||
private final GetEventUseCase getEventUseCase;
|
private final GetEventUseCase getEventUseCase;
|
||||||
private final CreateRsvpUseCase createRsvpUseCase;
|
private final CreateRsvpUseCase createRsvpUseCase;
|
||||||
private final CountAttendeesByEventUseCase countAttendeesByEventUseCase;
|
private final CountAttendeesByEventUseCase countAttendeesByEventUseCase;
|
||||||
|
private final GetAttendeesUseCase getAttendeesUseCase;
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
|
|
||||||
/** Creates a new controller with the given use cases and clock. */
|
/** Creates a new controller with the given use cases and clock. */
|
||||||
@@ -41,11 +47,13 @@ public class EventController implements EventsApi {
|
|||||||
GetEventUseCase getEventUseCase,
|
GetEventUseCase getEventUseCase,
|
||||||
CreateRsvpUseCase createRsvpUseCase,
|
CreateRsvpUseCase createRsvpUseCase,
|
||||||
CountAttendeesByEventUseCase countAttendeesByEventUseCase,
|
CountAttendeesByEventUseCase countAttendeesByEventUseCase,
|
||||||
|
GetAttendeesUseCase getAttendeesUseCase,
|
||||||
Clock clock) {
|
Clock clock) {
|
||||||
this.createEventUseCase = createEventUseCase;
|
this.createEventUseCase = createEventUseCase;
|
||||||
this.getEventUseCase = getEventUseCase;
|
this.getEventUseCase = getEventUseCase;
|
||||||
this.createRsvpUseCase = createRsvpUseCase;
|
this.createRsvpUseCase = createRsvpUseCase;
|
||||||
this.countAttendeesByEventUseCase = countAttendeesByEventUseCase;
|
this.countAttendeesByEventUseCase = countAttendeesByEventUseCase;
|
||||||
|
this.getAttendeesUseCase = getAttendeesUseCase;
|
||||||
this.clock = clock;
|
this.clock = clock;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +105,25 @@ public class EventController implements EventsApi {
|
|||||||
return ResponseEntity.ok(response);
|
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
|
@Override
|
||||||
public ResponseEntity<CreateRsvpResponse> createRsvp(
|
public ResponseEntity<CreateRsvpResponse> createRsvp(
|
||||||
UUID token, CreateRsvpRequest createRsvpRequest) {
|
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.EventNotFoundException;
|
||||||
import de.fete.application.service.ExpiryDateBeforeEventException;
|
import de.fete.application.service.ExpiryDateBeforeEventException;
|
||||||
import de.fete.application.service.ExpiryDateInPastException;
|
import de.fete.application.service.ExpiryDateInPastException;
|
||||||
|
import de.fete.application.service.InvalidOrganizerTokenException;
|
||||||
import de.fete.application.service.InvalidTimezoneException;
|
import de.fete.application.service.InvalidTimezoneException;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -87,6 +88,19 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
|
|||||||
.body(problemDetail);
|
.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. */
|
/** Handles event not found. */
|
||||||
@ExceptionHandler(EventNotFoundException.class)
|
@ExceptionHandler(EventNotFoundException.class)
|
||||||
public ResponseEntity<ProblemDetail> handleEventNotFound(
|
public ResponseEntity<ProblemDetail> handleEventNotFound(
|
||||||
|
|||||||
@@ -11,4 +11,7 @@ public interface RsvpJpaRepository extends JpaRepository<RsvpJpaEntity, Long> {
|
|||||||
|
|
||||||
/** Counts RSVPs for the given event. */
|
/** Counts RSVPs for the given event. */
|
||||||
long countByEventId(Long eventId);
|
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.Rsvp;
|
||||||
import de.fete.domain.model.RsvpToken;
|
import de.fete.domain.model.RsvpToken;
|
||||||
import de.fete.domain.port.out.RsvpRepository;
|
import de.fete.domain.port.out.RsvpRepository;
|
||||||
|
import java.util.List;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
/** Persistence adapter implementing the RsvpRepository outbound port. */
|
/** Persistence adapter implementing the RsvpRepository outbound port. */
|
||||||
@@ -28,6 +29,13 @@ public class RsvpPersistenceAdapter implements RsvpRepository {
|
|||||||
return jpaRepository.countByEventId(eventId);
|
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) {
|
private RsvpJpaEntity toEntity(Rsvp rsvp) {
|
||||||
var entity = new RsvpJpaEntity();
|
var entity = new RsvpJpaEntity();
|
||||||
entity.setId(rsvp.getId());
|
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.Event;
|
||||||
import de.fete.domain.model.EventToken;
|
import de.fete.domain.model.EventToken;
|
||||||
|
import de.fete.domain.model.OrganizerToken;
|
||||||
import de.fete.domain.model.Rsvp;
|
import de.fete.domain.model.Rsvp;
|
||||||
import de.fete.domain.model.RsvpToken;
|
import de.fete.domain.model.RsvpToken;
|
||||||
import de.fete.domain.port.in.CountAttendeesByEventUseCase;
|
import de.fete.domain.port.in.CountAttendeesByEventUseCase;
|
||||||
import de.fete.domain.port.in.CreateRsvpUseCase;
|
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.EventRepository;
|
||||||
import de.fete.domain.port.out.RsvpRepository;
|
import de.fete.domain.port.out.RsvpRepository;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
/** Application service implementing RSVP creation. */
|
/** Application service implementing RSVP operations. */
|
||||||
@Service
|
@Service
|
||||||
public class RsvpService implements CreateRsvpUseCase, CountAttendeesByEventUseCase {
|
public class RsvpService
|
||||||
|
implements CreateRsvpUseCase, CountAttendeesByEventUseCase, GetAttendeesUseCase {
|
||||||
|
|
||||||
private final EventRepository eventRepository;
|
private final EventRepository eventRepository;
|
||||||
private final RsvpRepository rsvpRepository;
|
private final RsvpRepository rsvpRepository;
|
||||||
@@ -53,4 +57,18 @@ public class RsvpService implements CreateRsvpUseCase, CountAttendeesByEventUseC
|
|||||||
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
|
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
|
||||||
return rsvpRepository.countByEventId(event.getId());
|
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;
|
package de.fete.domain.port.out;
|
||||||
|
|
||||||
import de.fete.domain.model.Rsvp;
|
import de.fete.domain.model.Rsvp;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/** Outbound port for persisting and querying RSVPs. */
|
/** Outbound port for persisting and querying RSVPs. */
|
||||||
public interface RsvpRepository {
|
public interface RsvpRepository {
|
||||||
@@ -10,4 +11,7 @@ public interface RsvpRepository {
|
|||||||
|
|
||||||
/** Counts the number of RSVPs for the given event. */
|
/** Counts the number of RSVPs for the given event. */
|
||||||
long countByEventId(Long eventId);
|
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:
|
schema:
|
||||||
$ref: "#/components/schemas/ProblemDetail"
|
$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}:
|
/events/{token}:
|
||||||
get:
|
get:
|
||||||
operationId: getEvent
|
operationId: getEvent
|
||||||
@@ -256,6 +297,30 @@ components:
|
|||||||
description: Guest's display name as stored
|
description: Guest's display name as stored
|
||||||
example: "Max Mustermann"
|
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:
|
ProblemDetail:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
@@ -439,6 +439,68 @@ class EventControllerIntegrationTest {
|
|||||||
assertThat(rsvpJpaRepository.count()).isEqualTo(countBefore);
|
assertThat(rsvpJpaRepository.count()).isEqualTo(countBefore);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- GET /events/{token}/attendees tests ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAttendeesReturnsNamesForOrganizer() throws Exception {
|
||||||
|
EventJpaEntity event = seedEvent(
|
||||||
|
"Party", null, "Europe/Berlin", null,
|
||||||
|
LocalDate.now().plusDays(30));
|
||||||
|
seedRsvp(event, "Alice");
|
||||||
|
seedRsvp(event, "Bob");
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/events/" + event.getEventToken()
|
||||||
|
+ "/attendees?organizerToken=" + event.getOrganizerToken()))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.attendees").isArray())
|
||||||
|
.andExpect(jsonPath("$.attendees.length()").value(2))
|
||||||
|
.andExpect(jsonPath("$.attendees[0].name").value("Alice"))
|
||||||
|
.andExpect(jsonPath("$.attendees[1].name").value("Bob"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAttendeesReturnsEmptyListWhenNoRsvps() throws Exception {
|
||||||
|
EventJpaEntity event = seedEvent(
|
||||||
|
"Empty Party", null, "Europe/Berlin", null,
|
||||||
|
LocalDate.now().plusDays(30));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/events/" + event.getEventToken()
|
||||||
|
+ "/attendees?organizerToken=" + event.getOrganizerToken()))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.attendees").isArray())
|
||||||
|
.andExpect(jsonPath("$.attendees.length()").value(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAttendeesReturns403ForInvalidOrganizerToken() throws Exception {
|
||||||
|
EventJpaEntity event = seedEvent(
|
||||||
|
"Secret Party", null, "Europe/Berlin", null,
|
||||||
|
LocalDate.now().plusDays(30));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/events/" + event.getEventToken()
|
||||||
|
+ "/attendees?organizerToken=" + UUID.randomUUID()))
|
||||||
|
.andExpect(status().isForbidden())
|
||||||
|
.andExpect(content().contentTypeCompatibleWith(
|
||||||
|
"application/problem+json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAttendeesReturns404ForUnknownEvent() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/events/" + UUID.randomUUID()
|
||||||
|
+ "/attendees?organizerToken=" + UUID.randomUUID()))
|
||||||
|
.andExpect(status().isNotFound())
|
||||||
|
.andExpect(content().contentTypeCompatibleWith(
|
||||||
|
"application/problem+json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void seedRsvp(EventJpaEntity event, String name) {
|
||||||
|
var rsvp = new RsvpJpaEntity();
|
||||||
|
rsvp.setRsvpToken(UUID.randomUUID());
|
||||||
|
rsvp.setEventId(event.getId());
|
||||||
|
rsvp.setName(name);
|
||||||
|
rsvpJpaRepository.save(rsvp);
|
||||||
|
}
|
||||||
|
|
||||||
private EventJpaEntity seedEvent(
|
private EventJpaEntity seedEvent(
|
||||||
String title, String description, String timezone,
|
String title, String description, String timezone,
|
||||||
String location, LocalDate expiryDate) {
|
String location, LocalDate expiryDate) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import de.fete.domain.model.Event;
|
|||||||
import de.fete.domain.model.EventToken;
|
import de.fete.domain.model.EventToken;
|
||||||
import de.fete.domain.model.OrganizerToken;
|
import de.fete.domain.model.OrganizerToken;
|
||||||
import de.fete.domain.model.Rsvp;
|
import de.fete.domain.model.Rsvp;
|
||||||
|
import de.fete.domain.model.RsvpToken;
|
||||||
import de.fete.domain.port.out.EventRepository;
|
import de.fete.domain.port.out.EventRepository;
|
||||||
import de.fete.domain.port.out.RsvpRepository;
|
import de.fete.domain.port.out.RsvpRepository;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
@@ -18,6 +19,7 @@ import java.time.LocalDate;
|
|||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
import java.time.ZoneOffset;
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -122,6 +124,73 @@ class RsvpServiceTest {
|
|||||||
.isInstanceOf(EventExpiredException.class);
|
.isInstanceOf(EventExpiredException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAttendeeNamesReturnsNamesInOrder() {
|
||||||
|
Event event = buildActiveEvent();
|
||||||
|
EventToken token = event.getEventToken();
|
||||||
|
OrganizerToken orgToken = event.getOrganizerToken();
|
||||||
|
when(eventRepository.findByEventToken(token))
|
||||||
|
.thenReturn(Optional.of(event));
|
||||||
|
when(rsvpRepository.findByEventId(event.getId()))
|
||||||
|
.thenReturn(List.of(
|
||||||
|
buildRsvp(1L, "Alice"),
|
||||||
|
buildRsvp(2L, "Bob"),
|
||||||
|
buildRsvp(3L, "Charlie")));
|
||||||
|
|
||||||
|
List<String> names = rsvpService.getAttendeeNames(token, orgToken);
|
||||||
|
|
||||||
|
assertThat(names).containsExactly("Alice", "Bob", "Charlie");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAttendeeNamesReturnsEmptyListWhenNoRsvps() {
|
||||||
|
Event event = buildActiveEvent();
|
||||||
|
EventToken token = event.getEventToken();
|
||||||
|
OrganizerToken orgToken = event.getOrganizerToken();
|
||||||
|
when(eventRepository.findByEventToken(token))
|
||||||
|
.thenReturn(Optional.of(event));
|
||||||
|
when(rsvpRepository.findByEventId(event.getId()))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
|
||||||
|
List<String> names = rsvpService.getAttendeeNames(token, orgToken);
|
||||||
|
|
||||||
|
assertThat(names).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAttendeeNamesThrowsWhenEventNotFound() {
|
||||||
|
EventToken token = EventToken.generate();
|
||||||
|
OrganizerToken orgToken = OrganizerToken.generate();
|
||||||
|
when(eventRepository.findByEventToken(token))
|
||||||
|
.thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(
|
||||||
|
() -> rsvpService.getAttendeeNames(token, orgToken))
|
||||||
|
.isInstanceOf(EventNotFoundException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAttendeeNamesThrowsWhenOrganizerTokenInvalid() {
|
||||||
|
Event event = buildActiveEvent();
|
||||||
|
EventToken token = event.getEventToken();
|
||||||
|
OrganizerToken wrongToken = OrganizerToken.generate();
|
||||||
|
when(eventRepository.findByEventToken(token))
|
||||||
|
.thenReturn(Optional.of(event));
|
||||||
|
|
||||||
|
assertThatThrownBy(
|
||||||
|
() -> rsvpService.getAttendeeNames(token, wrongToken))
|
||||||
|
.isInstanceOf(InvalidOrganizerTokenException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Rsvp buildRsvp(Long id, String name) {
|
||||||
|
var rsvp = new Rsvp();
|
||||||
|
rsvp.setId(id);
|
||||||
|
rsvp.setRsvpToken(RsvpToken.generate());
|
||||||
|
rsvp.setEventId(1L);
|
||||||
|
rsvp.setName(name);
|
||||||
|
return rsvp;
|
||||||
|
}
|
||||||
|
|
||||||
private Event buildActiveEvent() {
|
private Event buildActiveEvent() {
|
||||||
var event = new Event();
|
var event = new Event();
|
||||||
event.setId(1L);
|
event.setId(1L);
|
||||||
|
|||||||
99
frontend/e2e/view-attendee-list.spec.ts
Normal file
99
frontend/e2e/view-attendee-list.spec.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { test, expect } from './msw-setup'
|
||||||
|
|
||||||
|
const eventToken = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
||||||
|
const organizerToken = 'f9e8d7c6-b5a4-3210-fedc-ba9876543210'
|
||||||
|
|
||||||
|
const fullEvent = {
|
||||||
|
eventToken,
|
||||||
|
title: 'Summer BBQ',
|
||||||
|
description: 'Bring your own drinks!',
|
||||||
|
dateTime: '2026-03-15T20:00:00+01:00',
|
||||||
|
timezone: 'Europe/Berlin',
|
||||||
|
location: 'Central Park, NYC',
|
||||||
|
attendeeCount: 3,
|
||||||
|
expired: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const attendeesResponse = {
|
||||||
|
attendees: [
|
||||||
|
{ name: 'Alice' },
|
||||||
|
{ name: 'Bob' },
|
||||||
|
{ name: 'Charlie' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('US-1: View attendee list as organizer', () => {
|
||||||
|
test('organizer sees attendee names', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => {
|
||||||
|
return HttpResponse.json(fullEvent)
|
||||||
|
}),
|
||||||
|
http.get('*/api/events/:token/attendees', () => {
|
||||||
|
return HttpResponse.json(attendeesResponse)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Set organizer token in localStorage before navigating
|
||||||
|
await page.goto('/')
|
||||||
|
await page.evaluate(
|
||||||
|
([et, ot]) => {
|
||||||
|
localStorage.setItem(
|
||||||
|
'fete:events',
|
||||||
|
JSON.stringify([{ eventToken: et, organizerToken: ot, title: 'Summer BBQ', dateTime: '2026-03-15T20:00:00+01:00', expiryDate: '' }]),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[eventToken, organizerToken],
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/events/${eventToken}`)
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible()
|
||||||
|
await expect(page.getByText('3 Attendees')).toBeVisible()
|
||||||
|
await expect(page.getByText('Alice')).toBeVisible()
|
||||||
|
await expect(page.getByText('Bob')).toBeVisible()
|
||||||
|
await expect(page.getByText('Charlie')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('visitor does not see attendee list', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => {
|
||||||
|
return HttpResponse.json(fullEvent)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/events/${eventToken}`)
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible()
|
||||||
|
await expect(page.getByText('3 going')).toBeVisible()
|
||||||
|
await expect(page.locator('.attendee-list')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('organizer sees empty state when no attendees', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => {
|
||||||
|
return HttpResponse.json({ ...fullEvent, attendeeCount: 0 })
|
||||||
|
}),
|
||||||
|
http.get('*/api/events/:token/attendees', () => {
|
||||||
|
return HttpResponse.json({ attendees: [] })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto('/')
|
||||||
|
await page.evaluate(
|
||||||
|
([et, ot]) => {
|
||||||
|
localStorage.setItem(
|
||||||
|
'fete:events',
|
||||||
|
JSON.stringify([{ eventToken: et, organizerToken: ot, title: 'Summer BBQ', dateTime: '2026-03-15T20:00:00+01:00', expiryDate: '' }]),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[eventToken, organizerToken],
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/events/${eventToken}`)
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible()
|
||||||
|
await expect(page.getByText('0 Attendees')).toBeVisible()
|
||||||
|
await expect(page.getByText('No attendees yet.')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
59
frontend/src/components/AttendeeList.vue
Normal file
59
frontend/src/components/AttendeeList.vue
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<template>
|
||||||
|
<section class="attendee-list">
|
||||||
|
<h3 class="attendee-list__heading">
|
||||||
|
{{ attendees.length === 1 ? '1 Attendee' : `${attendees.length} Attendees` }}
|
||||||
|
</h3>
|
||||||
|
<ul v-if="attendees.length > 0" class="attendee-list__items">
|
||||||
|
<li v-for="(name, index) in attendees" :key="index" class="attendee-list__item">
|
||||||
|
{{ name }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p v-else class="attendee-list__empty">No attendees yet.</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
attendees: string[]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.attendee-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendee-list__heading {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendee-list__items {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendee-list__item {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
line-height: 1.4;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendee-list__empty {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
50
frontend/src/components/__tests__/AttendeeList.spec.ts
Normal file
50
frontend/src/components/__tests__/AttendeeList.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import AttendeeList from '../AttendeeList.vue'
|
||||||
|
|
||||||
|
describe('AttendeeList', () => {
|
||||||
|
it('renders attendee names as list items', () => {
|
||||||
|
const wrapper = mount(AttendeeList, {
|
||||||
|
props: { attendees: ['Alice', 'Bob', 'Charlie'] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const items = wrapper.findAll('.attendee-list__item')
|
||||||
|
expect(items).toHaveLength(3)
|
||||||
|
expect(items[0]!.text()).toBe('Alice')
|
||||||
|
expect(items[1]!.text()).toBe('Bob')
|
||||||
|
expect(items[2]!.text()).toBe('Charlie')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows empty state message when no attendees', () => {
|
||||||
|
const wrapper = mount(AttendeeList, {
|
||||||
|
props: { attendees: [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('.attendee-list__empty').text()).toBe('No attendees yet.')
|
||||||
|
expect(wrapper.find('.attendee-list__items').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows plural count heading for multiple attendees', () => {
|
||||||
|
const wrapper = mount(AttendeeList, {
|
||||||
|
props: { attendees: ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve'] },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('.attendee-list__heading').text()).toBe('5 Attendees')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows singular count heading for one attendee', () => {
|
||||||
|
const wrapper = mount(AttendeeList, {
|
||||||
|
props: { attendees: ['Alice'] },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('.attendee-list__heading').text()).toBe('1 Attendee')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows zero count heading for no attendees', () => {
|
||||||
|
const wrapper = mount(AttendeeList, {
|
||||||
|
props: { attendees: [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('.attendee-list__heading').text()).toBe('0 Attendees')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -54,6 +54,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
|
<AttendeeList v-if="isOrganizer && attendeeNames !== null" :attendees="attendeeNames" />
|
||||||
|
|
||||||
<div v-if="event.description" class="detail__section">
|
<div v-if="event.description" class="detail__section">
|
||||||
<h2 class="detail__section-title">About</h2>
|
<h2 class="detail__section-title">About</h2>
|
||||||
<p class="detail__description">{{ event.description }}</p>
|
<p class="detail__description">{{ event.description }}</p>
|
||||||
@@ -111,6 +113,7 @@ import { ref, computed, onMounted } from 'vue'
|
|||||||
import { RouterLink, useRoute } from 'vue-router'
|
import { RouterLink, useRoute } from 'vue-router'
|
||||||
import { api } from '@/api/client'
|
import { api } from '@/api/client'
|
||||||
import { useEventStorage } from '@/composables/useEventStorage'
|
import { useEventStorage } from '@/composables/useEventStorage'
|
||||||
|
import AttendeeList from '@/components/AttendeeList.vue'
|
||||||
import BottomSheet from '@/components/BottomSheet.vue'
|
import BottomSheet from '@/components/BottomSheet.vue'
|
||||||
import RsvpBar from '@/components/RsvpBar.vue'
|
import RsvpBar from '@/components/RsvpBar.vue'
|
||||||
import type { components } from '@/api/schema'
|
import type { components } from '@/api/schema'
|
||||||
@@ -132,6 +135,7 @@ const submitError = ref('')
|
|||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const rsvpName = ref<string | undefined>(undefined)
|
const rsvpName = ref<string | undefined>(undefined)
|
||||||
const isOrganizer = ref(false)
|
const isOrganizer = ref(false)
|
||||||
|
const attendeeNames = ref<string[] | null>(null)
|
||||||
|
|
||||||
const formattedDateTime = computed(() => {
|
const formattedDateTime = computed(() => {
|
||||||
if (!event.value) return ''
|
if (!event.value) return ''
|
||||||
@@ -160,7 +164,13 @@ async function fetchEvent() {
|
|||||||
state.value = 'loaded'
|
state.value = 'loaded'
|
||||||
|
|
||||||
// Check if current user is the organizer
|
// Check if current user is the organizer
|
||||||
isOrganizer.value = !!getOrganizerToken(event.value.eventToken)
|
const orgToken = getOrganizerToken(event.value.eventToken)
|
||||||
|
isOrganizer.value = !!orgToken
|
||||||
|
|
||||||
|
// Fetch attendee list for organizer
|
||||||
|
if (orgToken) {
|
||||||
|
fetchAttendees(event.value.eventToken, orgToken)
|
||||||
|
}
|
||||||
|
|
||||||
// Restore RSVP status from localStorage
|
// Restore RSVP status from localStorage
|
||||||
const stored = getRsvp(event.value.eventToken)
|
const stored = getRsvp(event.value.eventToken)
|
||||||
@@ -220,6 +230,23 @@ async function submitRsvp() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchAttendees(eventToken: string, organizerToken: string) {
|
||||||
|
try {
|
||||||
|
const { data, error } = await api.GET('/events/{token}/attendees', {
|
||||||
|
params: {
|
||||||
|
path: { token: eventToken },
|
||||||
|
query: { organizerToken },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!error) {
|
||||||
|
attendeeNames.value = data!.attendees.map((a) => a.name)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silently degrade — don't show attendee list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(fetchEvent)
|
onMounted(fetchEvent)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -339,6 +339,42 @@ describe('EventDetailView', () => {
|
|||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Attendee list (organizer)
|
||||||
|
it('shows attendee list for organizer', async () => {
|
||||||
|
mockGetOrganizerToken.mockReturnValue('org-token-123')
|
||||||
|
mockLoadedEvent()
|
||||||
|
vi.mocked(api.GET)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: fullEvent,
|
||||||
|
error: undefined,
|
||||||
|
response: new Response(null, { status: 200 }),
|
||||||
|
} as never)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: { attendees: [{ name: 'Alice' }, { name: 'Bob' }] },
|
||||||
|
error: undefined,
|
||||||
|
response: new Response(null, { status: 200 }),
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
const wrapper = await mountWithToken()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.attendee-list').exists()).toBe(true)
|
||||||
|
expect(wrapper.text()).toContain('Alice')
|
||||||
|
expect(wrapper.text()).toContain('Bob')
|
||||||
|
expect(wrapper.find('.attendee-list__heading').text()).toBe('2 Attendees')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show attendee list for visitor', async () => {
|
||||||
|
mockLoadedEvent()
|
||||||
|
|
||||||
|
const wrapper = await mountWithToken()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.attendee-list').exists()).toBe(false)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
it('shows error when RSVP submission fails', async () => {
|
it('shows error when RSVP submission fails', async () => {
|
||||||
mockLoadedEvent()
|
mockLoadedEvent()
|
||||||
vi.mocked(api.POST).mockResolvedValue({
|
vi.mocked(api.POST).mockResolvedValue({
|
||||||
|
|||||||
34
specs/011-view-attendee-list/checklists/requirements.md
Normal file
34
specs/011-view-attendee-list/checklists/requirements.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Specification Quality Checklist: View Attendee List
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-03-08
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [x] Written for non-technical stakeholders
|
||||||
|
- [x] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [x] Requirements are testable and unambiguous
|
||||||
|
- [x] Success criteria are measurable
|
||||||
|
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [x] All acceptance scenarios are defined
|
||||||
|
- [x] Edge cases are identified
|
||||||
|
- [x] Scope is clearly bounded
|
||||||
|
- [x] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria
|
||||||
|
- [x] User scenarios cover primary flows
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [x] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- All items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
|
||||||
136
specs/011-view-attendee-list/contracts/api.md
Normal file
136
specs/011-view-attendee-list/contracts/api.md
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
# API Contract: View Attendee List (011)
|
||||||
|
|
||||||
|
**Date**: 2026-03-08
|
||||||
|
|
||||||
|
## New Endpoint
|
||||||
|
|
||||||
|
### `GET /events/{token}/attendees`
|
||||||
|
|
||||||
|
Retrieves the list of attendees for an event. Restricted to the event organizer.
|
||||||
|
|
||||||
|
**Path Parameters**:
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| token | string (UUID) | Event token |
|
||||||
|
|
||||||
|
**Query Parameters**:
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|------|----------|-------------|
|
||||||
|
| organizerToken | string (UUID) | Yes | Organizer token for authorization |
|
||||||
|
|
||||||
|
**Responses**:
|
||||||
|
|
||||||
|
#### 200 OK
|
||||||
|
|
||||||
|
Organizer token is valid. Returns the attendee list.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"attendees": [
|
||||||
|
{ "name": "Alice" },
|
||||||
|
{ "name": "Bob" },
|
||||||
|
{ "name": "Charlie" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 200 OK (empty list)
|
||||||
|
|
||||||
|
No RSVPs yet.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"attendees": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 403 Forbidden
|
||||||
|
|
||||||
|
Organizer token is missing, invalid, or does not match the event.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "about:blank",
|
||||||
|
"title": "Forbidden",
|
||||||
|
"status": 403,
|
||||||
|
"detail": "Invalid organizer token."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 404 Not Found
|
||||||
|
|
||||||
|
Event token does not exist.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "about:blank",
|
||||||
|
"title": "Not Found",
|
||||||
|
"status": 404,
|
||||||
|
"detail": "Event not found."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## OpenAPI Schema Addition
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
/events/{token}/attendees:
|
||||||
|
get:
|
||||||
|
operationId: getAttendees
|
||||||
|
summary: Get attendee list for an event (organizer only)
|
||||||
|
parameters:
|
||||||
|
- name: token
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
- name: organizerToken
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Attendee list
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/GetAttendeesResponse'
|
||||||
|
'403':
|
||||||
|
description: Invalid organizer token
|
||||||
|
'404':
|
||||||
|
description: Event not found
|
||||||
|
|
||||||
|
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"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Existing Endpoints (unchanged)
|
||||||
|
|
||||||
|
- `POST /events` — no changes
|
||||||
|
- `GET /events/{token}` — no changes (still returns `attendeeCount` publicly)
|
||||||
|
- `POST /events/{token}/rsvps` — no changes
|
||||||
72
specs/011-view-attendee-list/data-model.md
Normal file
72
specs/011-view-attendee-list/data-model.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# Data Model: View Attendee List (011)
|
||||||
|
|
||||||
|
**Date**: 2026-03-08
|
||||||
|
|
||||||
|
## Entities
|
||||||
|
|
||||||
|
### Rsvp (existing — no schema changes)
|
||||||
|
|
||||||
|
The attendee list feature reads from the existing `rsvps` table. No new tables or columns are required.
|
||||||
|
|
||||||
|
| Field | Type | Constraints | Notes |
|
||||||
|
|-------|------|-------------|-------|
|
||||||
|
| id | BIGSERIAL | PK, auto-increment | Chronological order proxy |
|
||||||
|
| rsvp_token | UUID | UNIQUE, NOT NULL | Public identifier |
|
||||||
|
| event_id | BIGINT | FK → events.id, NOT NULL | CASCADE DELETE |
|
||||||
|
| name | VARCHAR(100) | NOT NULL | Display name shown to organizer |
|
||||||
|
|
||||||
|
**Existing indexes**: `idx_rsvps_event_id` (on `event_id`), `idx_rsvps_rsvp_token` (on `rsvp_token`).
|
||||||
|
|
||||||
|
### Event (existing — no schema changes)
|
||||||
|
|
||||||
|
The `organizer_token` column on the `events` table is used for authorization. The endpoint verifies that the provided organizer token matches the event's stored token.
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------|------|-------|
|
||||||
|
| organizer_token | UUID | UNIQUE, NOT NULL — used for attendee list authorization |
|
||||||
|
|
||||||
|
## Query Patterns
|
||||||
|
|
||||||
|
### Get attendees by event token
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT r.name
|
||||||
|
FROM rsvps r
|
||||||
|
JOIN events e ON r.event_id = e.id
|
||||||
|
WHERE e.event_token = :eventToken
|
||||||
|
ORDER BY r.id ASC;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Performance**: Uses existing `idx_rsvps_event_id` index. Expected result set is small (spec assumes small-to-medium events, no pagination needed).
|
||||||
|
|
||||||
|
### Organizer token verification
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT e.organizer_token
|
||||||
|
FROM events e
|
||||||
|
WHERE e.event_token = :eventToken;
|
||||||
|
```
|
||||||
|
|
||||||
|
Already implemented in `EventService.getByEventToken()` — the event entity includes the organizer token. The use case compares the provided token against the stored one.
|
||||||
|
|
||||||
|
## Domain Model Changes
|
||||||
|
|
||||||
|
### New Outbound Port Method
|
||||||
|
|
||||||
|
```java
|
||||||
|
// RsvpRepository (existing interface)
|
||||||
|
List<Rsvp> findByEventId(Long eventId); // NEW
|
||||||
|
```
|
||||||
|
|
||||||
|
### New Inbound Port
|
||||||
|
|
||||||
|
```java
|
||||||
|
// GetAttendeesUseCase (new interface)
|
||||||
|
List<String> getAttendeeNames(EventToken eventToken, OrganizerToken organizerToken);
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns a list of attendee display names. Throws `EventNotFoundException` if event token is invalid. Throws `AccessDeniedException` (or similar) if organizer token does not match.
|
||||||
|
|
||||||
|
## No Migration Required
|
||||||
|
|
||||||
|
All required data structures already exist from changeset `003-create-rsvps-table.xml`. This feature only adds read access to existing data.
|
||||||
101
specs/011-view-attendee-list/plan.md
Normal file
101
specs/011-view-attendee-list/plan.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# Implementation Plan: View Attendee List
|
||||||
|
|
||||||
|
**Branch**: `011-view-attendee-list` | **Date**: 2026-03-08 | **Spec**: [spec.md](spec.md)
|
||||||
|
**Input**: Feature specification from `/specs/011-view-attendee-list/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add an organizer-only attendee list to the event detail view. A new `GET /events/{token}/attendees?organizerToken=<uuid>` endpoint returns attendee names when the organizer token is valid (403 otherwise). The frontend conditionally renders the list below the attendee count when the viewer is identified as the organizer via localStorage.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: Java 25 (backend), TypeScript 5.9 (frontend)
|
||||||
|
**Primary Dependencies**: Spring Boot 3.5.x, Vue 3, Vue Router 5, openapi-fetch, openapi-typescript
|
||||||
|
**Storage**: PostgreSQL (JPA via Spring Data, Liquibase migrations)
|
||||||
|
**Testing**: JUnit + Testcontainers (backend integration), Vitest (frontend unit), Playwright + MSW (E2E)
|
||||||
|
**Target Platform**: Self-hosted web application (PWA)
|
||||||
|
**Project Type**: Web application (full-stack)
|
||||||
|
**Performance Goals**: Attendee list loads within 2 seconds (SC-001)
|
||||||
|
**Constraints**: Privacy by Design — attendee names only exposed to organizer; no PII logging
|
||||||
|
**Scale/Scope**: Small-to-medium events; no pagination required (spec assumption)
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
| Principle | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| I. Privacy by Design | ✅ PASS | Attendee names only exposed via organizer token verification. Non-organizers see count only (FR-003). No analytics/tracking added. |
|
||||||
|
| II. Test-Driven Methodology | ✅ PASS | Plan follows Research → Spec → Test → Implement. TDD enforced. E2E tests mandatory for both user stories. |
|
||||||
|
| III. API-First Development | ✅ PASS | New endpoint defined in OpenAPI spec first. Types generated before implementation. Response schemas include `example:` fields. |
|
||||||
|
| IV. Simplicity & Quality | ✅ PASS | Minimal new code: one endpoint, one use case, one component section. No over-engineering. |
|
||||||
|
| V. Dependency Discipline | ✅ PASS | No new dependencies introduced. |
|
||||||
|
| VI. Accessibility | ✅ PASS | Semantic HTML list for attendees. WCAG AA contrast. Keyboard-navigable. |
|
||||||
|
|
||||||
|
**Gate result**: ALL PASS — proceed to Phase 0.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/011-view-attendee-list/
|
||||||
|
├── plan.md # This file
|
||||||
|
├── research.md # Phase 0 output
|
||||||
|
├── data-model.md # Phase 1 output
|
||||||
|
├── contracts/ # Phase 1 output
|
||||||
|
│ └── api.md # New endpoint contract
|
||||||
|
└── tasks.md # Phase 2 output (/speckit.tasks)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
backend/
|
||||||
|
├── src/main/java/de/fete/
|
||||||
|
│ ├── domain/
|
||||||
|
│ │ ├── model/ # Existing: Event, Rsvp, tokens
|
||||||
|
│ │ └── port/
|
||||||
|
│ │ ├── in/
|
||||||
|
│ │ │ └── GetAttendeesUseCase.java # NEW: inbound port
|
||||||
|
│ │ └── out/
|
||||||
|
│ │ └── RsvpRepository.java # MODIFY: add findByEventId
|
||||||
|
│ ├── application/service/
|
||||||
|
│ │ └── RsvpService.java # MODIFY: implement GetAttendeesUseCase
|
||||||
|
│ ├── adapter/
|
||||||
|
│ │ ├── in/web/
|
||||||
|
│ │ │ └── EventController.java # MODIFY: add attendees endpoint
|
||||||
|
│ │ └── out/persistence/
|
||||||
|
│ │ ├── RsvpJpaRepository.java # MODIFY: add findByEventId query
|
||||||
|
│ │ └── RsvpPersistenceAdapter.java # MODIFY: implement findByEventId
|
||||||
|
│ └── src/main/resources/openapi/
|
||||||
|
│ └── api.yaml # MODIFY: add attendees endpoint + schema
|
||||||
|
├── src/test/java/de/fete/
|
||||||
|
│ ├── adapter/in/web/
|
||||||
|
│ │ └── EventControllerIntegrationTest.java # MODIFY: add attendees tests
|
||||||
|
│ └── application/service/
|
||||||
|
│ └── RsvpServiceTest.java # MODIFY: add getAttendees tests
|
||||||
|
|
||||||
|
frontend/
|
||||||
|
├── src/
|
||||||
|
│ ├── views/
|
||||||
|
│ │ └── EventDetailView.vue # MODIFY: add attendee list section
|
||||||
|
│ ├── components/
|
||||||
|
│ │ └── AttendeeList.vue # NEW: attendee list component
|
||||||
|
│ ├── api/
|
||||||
|
│ │ └── schema.d.ts # REGENERATED from OpenAPI
|
||||||
|
│ └── composables/
|
||||||
|
│ └── useEventStorage.ts # NO CHANGES (read-only usage)
|
||||||
|
├── src/views/__tests__/
|
||||||
|
│ └── EventDetailView.spec.ts # MODIFY: add attendee list tests
|
||||||
|
├── src/components/__tests__/
|
||||||
|
│ └── AttendeeList.spec.ts # NEW: unit tests
|
||||||
|
└── e2e/
|
||||||
|
└── view-attendee-list.spec.ts # NEW: E2E tests
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Extends the existing web application structure. Backend follows hexagonal architecture with new inbound port + implementation. Frontend adds one new component integrated into the existing EventDetailView.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
> No constitution violations — section not applicable.
|
||||||
76
specs/011-view-attendee-list/quickstart.md
Normal file
76
specs/011-view-attendee-list/quickstart.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Quickstart: View Attendee List (011)
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Java 25 (SDKMAN)
|
||||||
|
- Node.js 20+ / npm
|
||||||
|
- PostgreSQL running (or Docker for Testcontainers)
|
||||||
|
|
||||||
|
## Development Flow
|
||||||
|
|
||||||
|
### 1. Update OpenAPI spec
|
||||||
|
|
||||||
|
Edit `backend/src/main/resources/openapi/api.yaml` to add the `GET /events/{token}/attendees` endpoint and response schemas (see `contracts/api.md`).
|
||||||
|
|
||||||
|
### 2. Generate types
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend: regenerate Spring interfaces
|
||||||
|
cd backend && ./mvnw compile
|
||||||
|
|
||||||
|
# Frontend: regenerate TypeScript types
|
||||||
|
cd frontend && npm run generate:api
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Backend implementation (TDD)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Write tests first
|
||||||
|
cd backend && ./mvnw test
|
||||||
|
|
||||||
|
# Run specific test class
|
||||||
|
cd backend && ./mvnw test -Dtest=EventControllerIntegrationTest
|
||||||
|
cd backend && ./mvnw test -Dtest=RsvpServiceTest
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Frontend implementation (TDD)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Unit tests
|
||||||
|
cd frontend && npm run test:unit
|
||||||
|
|
||||||
|
# E2E tests
|
||||||
|
cd frontend && npx playwright test e2e/view-attendee-list.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend full verify (includes checkstyle)
|
||||||
|
cd backend && ./mvnw verify
|
||||||
|
|
||||||
|
# Frontend build check
|
||||||
|
cd frontend && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Files to Modify
|
||||||
|
|
||||||
|
| Layer | File | Change |
|
||||||
|
|-------|------|--------|
|
||||||
|
| API Spec | `backend/src/main/resources/openapi/api.yaml` | Add endpoint + schemas |
|
||||||
|
| Port (in) | `de.fete.domain.port.in.GetAttendeesUseCase` | New interface |
|
||||||
|
| Port (out) | `de.fete.domain.port.out.RsvpRepository` | Add `findByEventId` |
|
||||||
|
| Service | `de.fete.application.service.RsvpService` | Implement use case |
|
||||||
|
| Persistence | `de.fete.adapter.out.persistence.RsvpJpaRepository` | Add query method |
|
||||||
|
| Persistence | `de.fete.adapter.out.persistence.RsvpPersistenceAdapter` | Implement port method |
|
||||||
|
| Controller | `de.fete.adapter.in.web.EventController` | Add endpoint handler |
|
||||||
|
| Frontend | `src/views/EventDetailView.vue` | Integrate AttendeeList |
|
||||||
|
| Frontend | `src/components/AttendeeList.vue` | New component |
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [ ] Backend unit test: `RsvpService.getAttendeeNames` — valid token, invalid token, no RSVPs
|
||||||
|
- [ ] Backend integration test: `GET /events/{token}/attendees` — 200, 403, 404
|
||||||
|
- [ ] Frontend unit test: `AttendeeList.vue` — renders names, empty state, loading
|
||||||
|
- [ ] Frontend unit test: `EventDetailView.vue` — shows list for organizer, hides for visitor
|
||||||
|
- [ ] E2E test: organizer sees attendee names, visitor sees count only
|
||||||
68
specs/011-view-attendee-list/research.md
Normal file
68
specs/011-view-attendee-list/research.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# Research: View Attendee List (011)
|
||||||
|
|
||||||
|
**Date**: 2026-03-08 | **Status**: Complete
|
||||||
|
|
||||||
|
## 1. Organizer Token Verification Pattern
|
||||||
|
|
||||||
|
**Decision**: Query parameter `?organizerToken=<uuid>` on the new endpoint.
|
||||||
|
|
||||||
|
**Rationale**: The project uses token-based access control without persistent sessions. The organizer token is stored in localStorage on the client. Passing it as a query parameter is the simplest approach that fits the existing architecture. The `GET /events/{token}` endpoint already uses path-based token lookup; adding a query parameter for the organizer token keeps the two concerns separate.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Authorization header: More RESTful, but adds complexity without benefit — no auth framework in place, and query params are simpler for this single use case.
|
||||||
|
- Embed attendees in existing `GET /events/{token}` response: Rejected per spec clarification — separate endpoint keeps concerns clean and avoids exposing attendee data in the public response.
|
||||||
|
|
||||||
|
## 2. Endpoint Design
|
||||||
|
|
||||||
|
**Decision**: `GET /events/{token}/attendees?organizerToken=<uuid>` returns `{ attendees: [{ name: string }] }`.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Nested under `/events/{token}` — resource hierarchy is clear.
|
||||||
|
- Returns an object with an `attendees` array (not a raw array) — allows future extension (e.g., adding metadata) without breaking the contract.
|
||||||
|
- Each attendee object contains only `name` — minimal data exposure per Privacy by Design.
|
||||||
|
- HTTP 403 for invalid/missing organizer token (not 401 — no authentication scheme exists).
|
||||||
|
- HTTP 404 if the event token is invalid (consistent with existing `GET /events/{token}`).
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Return `{ attendees: [...], count: N }`: Rejected — count is derivable from array length, and already available on the existing event detail endpoint. Avoids redundancy.
|
||||||
|
- Include RSVP timestamp: Rejected — spec says chronological order but doesn't require displaying timestamps. Order is implicit in array position.
|
||||||
|
|
||||||
|
## 3. Backend Implementation Approach
|
||||||
|
|
||||||
|
**Decision**: New `GetAttendeesUseCase` inbound port, implemented by `RsvpService`. New `findByEventId` method on `RsvpRepository` outbound port.
|
||||||
|
|
||||||
|
**Rationale**: Follows the established hexagonal architecture pattern exactly. Each use case gets its own inbound port interface. The persistence layer already has `RsvpJpaRepository` with `countByEventId`; adding `findAllByEventIdOrderByIdAsc` is a natural extension (ID order = chronological insertion order).
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Add to `GetEventUseCase`: Rejected — violates single responsibility. The event detail endpoint is public; attendee retrieval is organizer-only.
|
||||||
|
- Direct repository call in controller: Rejected — violates hexagonal architecture.
|
||||||
|
|
||||||
|
## 4. Frontend Integration Approach
|
||||||
|
|
||||||
|
**Decision**: New `AttendeeList.vue` component rendered conditionally in `EventDetailView.vue` when the viewer is the organizer. Fetches attendees via separate API call after event loads.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Separate component keeps EventDetailView manageable (it's already ~300 lines).
|
||||||
|
- Separate API call (not bundled with event fetch) — the attendee list is organizer-only; non-organizers never trigger the request.
|
||||||
|
- Component placed below attendee count, before RSVP form — matches spec FR-004.
|
||||||
|
- Empty state handled within the component (FR-005).
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Inline in EventDetailView without separate component: Rejected — view is already complex. A dedicated component improves readability and testability.
|
||||||
|
- Fetch attendees in the same call as event details: Not possible — separate endpoint by design.
|
||||||
|
|
||||||
|
## 5. Error Handling
|
||||||
|
|
||||||
|
**Decision**: Frontend silently degrades on 403 (does not render attendee list). No error toast or message shown.
|
||||||
|
|
||||||
|
**Rationale**: Per FR-007, the frontend "degrades gracefully by not rendering the list." If the organizer token is invalid (e.g., localStorage cleared on another device), the user sees the same view as a regular visitor. This is intentional — no confusing error states for edge cases that self-resolve.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Show error message on 403: Rejected — would confuse users who aren't expecting organizer features.
|
||||||
|
- Retry with different token: Not applicable — only one token per event in localStorage.
|
||||||
|
|
||||||
|
## 6. Accessibility Considerations
|
||||||
|
|
||||||
|
**Decision**: Attendee list rendered as semantic `<ul>` with `<li>` items. Section has a heading for screen readers. Count label uses singular/plural form.
|
||||||
|
|
||||||
|
**Rationale**: Constitution VI requires WCAG AA compliance, semantic HTML, and keyboard navigation. A list of names is naturally a `<ul>`. The heading provides structure for screen reader navigation.
|
||||||
87
specs/011-view-attendee-list/spec.md
Normal file
87
specs/011-view-attendee-list/spec.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# Feature Specification: View Attendee List
|
||||||
|
|
||||||
|
**Feature Branch**: `011-view-attendee-list`
|
||||||
|
**Created**: 2026-03-08
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "der organisator soll die Teilnehmerliste einsehen können, wenn er sich die detail view eines eigenen events anschaut"
|
||||||
|
|
||||||
|
## Clarifications
|
||||||
|
|
||||||
|
### Session 2026-03-08
|
||||||
|
|
||||||
|
- Q: API-Design — separater Endpoint oder bestehenden erweitern? → A: Separater Endpoint `GET /events/{token}/attendees`.
|
||||||
|
- Q: Übermittlung des Organizer-Tokens? → A: Query-Parameter `?organizerToken=<uuid>`.
|
||||||
|
- Q: UI-Platzierung der Attendee-Liste auf der Detail-Seite? → A: Direkt unter dem bestehenden Attendee-Count (vor dem RSVP-Formular).
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - View Attendee List as Organizer (Priority: P1)
|
||||||
|
|
||||||
|
As the organizer of an event, I want to see a list of all attendees (people who RSVPed) when I view my event's detail page, so that I know who is coming.
|
||||||
|
|
||||||
|
When the organizer opens the event detail view for an event they created, the page displays a list of attendee names directly below the existing attendee count (before the RSVP form). This list is only visible to the organizer — regular visitors only see the attendee count (existing behavior).
|
||||||
|
|
||||||
|
**Why this priority**: This is the core feature. Without it, organizers have no way to see who signed up for their event.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by creating an event, submitting RSVPs from other browsers/sessions, then viewing the event detail page with the organizer token. The attendee names should be listed.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an organizer views their event with 3 RSVPs, **When** the detail page loads, **Then** the organizer sees a list showing all 3 attendee names.
|
||||||
|
2. **Given** an organizer views their event with 0 RSVPs, **When** the detail page loads, **Then** the organizer sees an empty state message indicating no one has RSVPed yet.
|
||||||
|
3. **Given** a regular visitor (non-organizer) views the same event, **When** the detail page loads, **Then** only the attendee count is shown — no individual names are visible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Attendee Count Label (Priority: P2)
|
||||||
|
|
||||||
|
As the organizer, I want the attendee list to show the total count alongside the names, so I can quickly see how many people are attending at a glance.
|
||||||
|
|
||||||
|
**Why this priority**: Enhances the organizer experience but the count is already visible in the existing detail view, so this is supplementary.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by verifying the attendee count displayed next to/above the list matches the number of entries in the list.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an organizer views their event with 5 RSVPs, **When** the attendee list is displayed, **Then** a heading or label shows "5 Attendees" (or equivalent) above the list.
|
||||||
|
2. **Given** an organizer views their event with 1 RSVP, **When** the attendee list is displayed, **Then** the label uses singular form ("1 Attendee").
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- What happens when the organizer token stored locally is invalid or belongs to a different event? The system treats the viewer as a regular visitor and shows the count only — no error is displayed.
|
||||||
|
- What happens when an attendee name contains special characters or is very long? Names are displayed safely (escaped) and truncated visually if necessary.
|
||||||
|
- What happens if a large number of attendees (e.g. 100+) have RSVPed? The list remains scrollable and performs well without pagination (events are expected to be small-to-medium scale).
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: System MUST provide a dedicated endpoint `GET /events/{token}/attendees?organizerToken=<uuid>` for organizers to retrieve the attendee list, separate from the public event detail endpoint.
|
||||||
|
- **FR-002**: System MUST return each attendee's display name in the attendee list response.
|
||||||
|
- **FR-003**: System MUST NOT expose individual attendee names to non-organizer visitors — only the aggregate count is shown (existing behavior preserved).
|
||||||
|
- **FR-004**: The attendee list MUST be displayed directly below the attendee count on the event detail view (before the RSVP form) when the viewer is identified as the organizer.
|
||||||
|
- **FR-005**: System MUST display an empty state message when no RSVPs exist for the event.
|
||||||
|
- **FR-006**: System MUST display the total attendee count as a label alongside the attendee list.
|
||||||
|
- **FR-007**: System MUST reject attendee list requests with an invalid or missing organizer token by returning HTTP 403 (no attendee data exposed; frontend degrades gracefully by not rendering the list).
|
||||||
|
|
||||||
|
### Key Entities
|
||||||
|
|
||||||
|
- **Attendee (RSVP)**: A person who has RSVPed to an event. The organizer sees their display name in a list; visitors see only the aggregate count.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: Organizers can see the full attendee name list within 2 seconds of opening their event detail page.
|
||||||
|
- **SC-002**: Non-organizer visitors never see individual attendee names — only the count is visible.
|
||||||
|
- **SC-003**: The attendee list correctly reflects all RSVPs submitted for the event, with no missing or duplicate entries.
|
||||||
|
- **SC-004**: The feature works correctly on both mobile and desktop viewports.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- The organizer is identified by having a valid organizer token stored on the client. No additional login or authentication mechanism is introduced.
|
||||||
|
- The attendee list is read-only — the organizer cannot remove or edit attendees from this view.
|
||||||
|
- Attendee names are displayed in the order they RSVPed (chronological).
|
||||||
|
- The existing event detail view layout is extended, not replaced, to accommodate the attendee list section.
|
||||||
167
specs/011-view-attendee-list/tasks.md
Normal file
167
specs/011-view-attendee-list/tasks.md
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
# Tasks: View Attendee List
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/011-view-attendee-list/`
|
||||||
|
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/api.md
|
||||||
|
|
||||||
|
**Tests**: Included — TDD enforced per constitution principle II. E2E tests mandatory per plan.
|
||||||
|
|
||||||
|
**Organization**: Tasks grouped by user story for independent implementation and testing.
|
||||||
|
|
||||||
|
## Format: `[ID] [P?] [Story] Description`
|
||||||
|
|
||||||
|
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||||
|
- **[Story]**: Which user story this task belongs to (e.g., US1, US2)
|
||||||
|
- Exact file paths included in all descriptions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Setup (API Contract)
|
||||||
|
|
||||||
|
**Purpose**: Define the API contract in OpenAPI and generate types for both backend and frontend.
|
||||||
|
|
||||||
|
- [x] T001 Add `GET /events/{token}/attendees` endpoint, `GetAttendeesResponse`, and `Attendee` schemas to `backend/src/main/resources/openapi/api.yaml` per `contracts/api.md`
|
||||||
|
- [x] T002 Regenerate backend Spring interfaces (`cd backend && ./mvnw compile`)
|
||||||
|
- [x] T003 Regenerate frontend TypeScript types (`cd frontend && npm run generate:api`)
|
||||||
|
|
||||||
|
**Checkpoint**: OpenAPI spec updated, generated types available in both backend and frontend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Backend Domain Ports)
|
||||||
|
|
||||||
|
**Purpose**: Create the inbound port and extend the outbound port that all backend implementation depends on.
|
||||||
|
|
||||||
|
- [x] T004 [P] Create `GetAttendeesUseCase` inbound port interface in `backend/src/main/java/de/fete/domain/port/in/GetAttendeesUseCase.java` with method `List<String> getAttendeeNames(UUID eventToken, UUID organizerToken)`
|
||||||
|
- [x] T005 [P] Add `List<Rsvp> findByEventId(Long eventId)` method to `backend/src/main/java/de/fete/domain/port/out/RsvpRepository.java`
|
||||||
|
|
||||||
|
**Checkpoint**: Domain ports defined — service and adapter implementation can begin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 — View Attendee List as Organizer (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: Organizer sees a list of attendee names on the event detail page. Non-organizers see only the count (existing behavior).
|
||||||
|
|
||||||
|
**Independent Test**: Create an event, submit RSVPs, then view the detail page with the organizer token. Attendee names should be listed.
|
||||||
|
|
||||||
|
### Tests for User Story 1 ⚠️
|
||||||
|
|
||||||
|
> **Write these tests FIRST — ensure they FAIL before implementation.**
|
||||||
|
|
||||||
|
- [x] T006 [P] [US1] Write unit tests for `RsvpService.getAttendeeNames` (valid token, invalid token, event not found, no RSVPs) in `backend/src/test/java/de/fete/application/service/RsvpServiceTest.java`
|
||||||
|
- [x] T007 [P] [US1] Write integration tests for `GET /events/{token}/attendees` (200 with attendees, 200 empty, 403 invalid token, 404 event not found) in `backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java`
|
||||||
|
- [x] T008 [P] [US1] Write unit tests for `AttendeeList.vue` (renders attendee names, empty state message, loading state) in `frontend/src/components/__tests__/AttendeeList.spec.ts`
|
||||||
|
- [x] T009 [P] [US1] Write unit tests for organizer-conditional rendering in `EventDetailView.vue` (shows list for organizer, hides for visitor) in `frontend/src/views/__tests__/EventDetailView.spec.ts`
|
||||||
|
- [x] T010 [P] [US1] Write E2E test: organizer sees attendee names, visitor sees count only, in `frontend/e2e/view-attendee-list.spec.ts`
|
||||||
|
|
||||||
|
### Backend Implementation for User Story 1
|
||||||
|
|
||||||
|
- [x] T011 [P] [US1] Add `findAllByEventIdOrderByIdAsc` query method to `backend/src/main/java/de/fete/adapter/out/persistence/RsvpJpaRepository.java`
|
||||||
|
- [x] T012 [P] [US1] Implement `findByEventId` in `backend/src/main/java/de/fete/adapter/out/persistence/RsvpPersistenceAdapter.java`
|
||||||
|
- [x] T013 [US1] Implement `GetAttendeesUseCase` in `backend/src/main/java/de/fete/application/service/RsvpService.java` — look up event by token, verify organizer token, return attendee names ordered by ID
|
||||||
|
- [x] T014 [US1] Add `getAttendees` endpoint handler to `backend/src/main/java/de/fete/adapter/in/web/EventController.java` — map to `GetAttendeesUseCase`, return 200/403/404
|
||||||
|
|
||||||
|
### Frontend Implementation for User Story 1
|
||||||
|
|
||||||
|
- [x] T015 [US1] Create `AttendeeList.vue` component in `frontend/src/components/AttendeeList.vue` — accepts attendee names array as prop, renders semantic `<ul>/<li>` list, shows empty state message when no attendees
|
||||||
|
- [x] T016 [US1] Integrate `AttendeeList.vue` into `frontend/src/views/EventDetailView.vue` — fetch `GET /events/{token}/attendees` with organizer token from localStorage, render below attendee count (before RSVP form), silently degrade on 403
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 1 fully functional. Organizer sees attendee names; visitor sees count only. All tests pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 — Attendee Count Label (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: The attendee list section shows a count label ("5 Attendees" / "1 Attendee") alongside the names.
|
||||||
|
|
||||||
|
**Independent Test**: Verify the count label above the list matches the number of entries, and uses singular/plural correctly.
|
||||||
|
|
||||||
|
### Tests for User Story 2 ⚠️
|
||||||
|
|
||||||
|
> **Write these tests FIRST — ensure they FAIL before implementation.**
|
||||||
|
|
||||||
|
- [x] T017 [P] [US2] Write unit tests for count label in `AttendeeList.vue` (plural "5 Attendees", singular "1 Attendee", zero "0 Attendees") in `frontend/src/components/__tests__/AttendeeList.spec.ts`
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [x] T018 [US2] Add count heading to `AttendeeList.vue` in `frontend/src/components/AttendeeList.vue` — render `<h3>` with singular/plural label based on attendee array length
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 2 complete. Count label renders correctly with singular/plural form. All tests pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Verification across both stories, accessibility, edge cases.
|
||||||
|
|
||||||
|
- [x] T019 Run full backend verification (`cd backend && ./mvnw verify`) — checkstyle, all tests green
|
||||||
|
- [x] T020 Run full frontend build and tests (`cd frontend && npm run build && npm run test:unit`)
|
||||||
|
- [x] T021 Run E2E tests (`cd frontend && npx playwright test e2e/view-attendee-list.spec.ts`)
|
||||||
|
- [x] T022 Verify WCAG AA contrast and semantic HTML (attendee list uses `<ul>/<li>`, section has heading for screen readers)
|
||||||
|
- [x] T023 Verify edge cases: long names truncated visually, special characters escaped, large attendee list scrollable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Setup (Phase 1)**: No dependencies — start immediately
|
||||||
|
- **Foundational (Phase 2)**: Depends on T002 (generated backend interfaces)
|
||||||
|
- **User Story 1 (Phase 3)**: Depends on Phase 2 completion
|
||||||
|
- **User Story 2 (Phase 4)**: Depends on T015 (AttendeeList component exists)
|
||||||
|
- **Polish (Phase 5)**: Depends on Phase 3 + Phase 4 completion
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **User Story 1 (P1)**: Can start after Phase 2 — no dependencies on other stories
|
||||||
|
- **User Story 2 (P2)**: Depends on US1's `AttendeeList.vue` component (T015) existing
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Tests MUST be written and FAIL before implementation
|
||||||
|
- Ports/models before services
|
||||||
|
- Services before endpoints/controllers
|
||||||
|
- Backend before frontend (API must exist for frontend integration)
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- T004 + T005 can run in parallel (different files)
|
||||||
|
- T006 + T007 + T008 + T009 + T010 can all run in parallel (different test files)
|
||||||
|
- T011 + T012 can run in parallel (different persistence files)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: User Story 1
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Launch all tests in parallel (TDD — write first):
|
||||||
|
Task: T006 "Unit tests for RsvpService.getAttendeeNames"
|
||||||
|
Task: T007 "Integration tests for GET /events/{token}/attendees"
|
||||||
|
Task: T008 "Unit tests for AttendeeList.vue"
|
||||||
|
Task: T009 "Unit tests for EventDetailView.vue organizer rendering"
|
||||||
|
Task: T010 "E2E test for attendee list"
|
||||||
|
|
||||||
|
# Launch persistence layer in parallel:
|
||||||
|
Task: T011 "Add findAllByEventIdOrderByIdAsc to RsvpJpaRepository"
|
||||||
|
Task: T012 "Implement findByEventId in RsvpPersistenceAdapter"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (User Story 1 Only)
|
||||||
|
|
||||||
|
1. Complete Phase 1: Setup (OpenAPI + type generation)
|
||||||
|
2. Complete Phase 2: Foundational (domain ports)
|
||||||
|
3. Complete Phase 3: User Story 1 (backend + frontend)
|
||||||
|
4. **STOP and VALIDATE**: Test independently — organizer sees names, visitor sees count only
|
||||||
|
5. Deploy/demo if ready
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Setup + Foundational → API contract and ports ready
|
||||||
|
2. Add User Story 1 → Test independently → Deploy (MVP!)
|
||||||
|
3. Add User Story 2 → Test independently → Deploy (count label enhancement)
|
||||||
|
4. Polish phase → Full verification, accessibility, edge cases
|
||||||
Reference in New Issue
Block a user