Add RSVP creation endpoint with typed tokens and attendee count

Introduce typed token value objects (EventToken, OrganizerToken,
RsvpToken) and refactor all existing Event code to use them.

Add POST /events/{token}/rsvps endpoint that persists an RSVP and
returns an rsvpToken. Populate attendeeCount in GET /events/{token}
from a real count query instead of hardcoded 0.

Includes: OpenAPI spec, Liquibase migration (rsvps table with
ON DELETE CASCADE), domain model, hexagonal ports/adapters,
service layer, and full test coverage (unit + integration).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 11:49:49 +01:00
parent 4828d06aba
commit a625e34fe4
24 changed files with 688 additions and 39 deletions

View File

@@ -3,13 +3,19 @@ package de.fete.adapter.in.web;
import de.fete.adapter.in.web.api.EventsApi;
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.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.Rsvp;
import de.fete.domain.port.in.CreateEventUseCase;
import de.fete.domain.port.in.CreateRsvpUseCase;
import de.fete.domain.port.in.GetEventUseCase;
import de.fete.domain.port.out.RsvpRepository;
import java.time.Clock;
import java.time.DateTimeException;
import java.time.LocalDate;
@@ -25,15 +31,21 @@ public class EventController implements EventsApi {
private final CreateEventUseCase createEventUseCase;
private final GetEventUseCase getEventUseCase;
private final CreateRsvpUseCase createRsvpUseCase;
private final RsvpRepository rsvpRepository;
private final Clock clock;
/** Creates a new controller with the given use cases and clock. */
/** Creates a new controller with the given use cases, repository, and clock. */
public EventController(
CreateEventUseCase createEventUseCase,
GetEventUseCase getEventUseCase,
CreateRsvpUseCase createRsvpUseCase,
RsvpRepository rsvpRepository,
Clock clock) {
this.createEventUseCase = createEventUseCase;
this.getEventUseCase = getEventUseCase;
this.createRsvpUseCase = createRsvpUseCase;
this.rsvpRepository = rsvpRepository;
this.clock = clock;
}
@@ -54,8 +66,8 @@ public class EventController implements EventsApi {
Event event = createEventUseCase.createEvent(command);
var response = new CreateEventResponse();
response.setEventToken(event.getEventToken());
response.setOrganizerToken(event.getOrganizerToken());
response.setEventToken(event.getEventToken().value());
response.setOrganizerToken(event.getOrganizerToken().value());
response.setTitle(event.getTitle());
response.setDateTime(event.getDateTime());
response.setTimezone(event.getTimezone().getId());
@@ -66,23 +78,38 @@ public class EventController implements EventsApi {
@Override
public ResponseEntity<GetEventResponse> getEvent(UUID token) {
Event event = getEventUseCase.getByEventToken(token)
var eventToken = new de.fete.domain.model.EventToken(token);
Event event = getEventUseCase.getByEventToken(eventToken)
.orElseThrow(() -> new EventNotFoundException(token));
var response = new GetEventResponse();
response.setEventToken(event.getEventToken());
response.setEventToken(event.getEventToken().value());
response.setTitle(event.getTitle());
response.setDescription(event.getDescription());
response.setDateTime(event.getDateTime());
response.setTimezone(event.getTimezone().getId());
response.setLocation(event.getLocation());
response.setAttendeeCount(0);
response.setAttendeeCount(
(int) rsvpRepository.countByEventId(event.getId()));
response.setExpired(
event.getExpiryDate().isBefore(LocalDate.now(clock)));
return ResponseEntity.ok(response);
}
@Override
public ResponseEntity<CreateRsvpResponse> createRsvp(
UUID token, CreateRsvpRequest createRsvpRequest) {
var eventToken = new EventToken(token);
Rsvp rsvp = createRsvpUseCase.createRsvp(eventToken, createRsvpRequest.getName());
var response = new CreateRsvpResponse();
response.setRsvpToken(rsvp.getRsvpToken().value());
response.setName(rsvp.getName());
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
private static ZoneId parseTimezone(String timezone) {
try {
return ZoneId.of(timezone);

View File

@@ -1,10 +1,11 @@
package de.fete.adapter.out.persistence;
import de.fete.domain.model.Event;
import de.fete.domain.model.EventToken;
import de.fete.domain.model.OrganizerToken;
import de.fete.domain.port.out.EventRepository;
import java.time.ZoneId;
import java.util.Optional;
import java.util.UUID;
import org.springframework.stereotype.Repository;
/** Persistence adapter implementing the EventRepository outbound port. */
@@ -26,15 +27,15 @@ public class EventPersistenceAdapter implements EventRepository {
}
@Override
public Optional<Event> findByEventToken(UUID eventToken) {
return jpaRepository.findByEventToken(eventToken).map(this::toDomain);
public Optional<Event> findByEventToken(EventToken eventToken) {
return jpaRepository.findByEventToken(eventToken.value()).map(this::toDomain);
}
private EventJpaEntity toEntity(Event event) {
var entity = new EventJpaEntity();
entity.setId(event.getId());
entity.setEventToken(event.getEventToken());
entity.setOrganizerToken(event.getOrganizerToken());
entity.setEventToken(event.getEventToken().value());
entity.setOrganizerToken(event.getOrganizerToken().value());
entity.setTitle(event.getTitle());
entity.setDescription(event.getDescription());
entity.setDateTime(event.getDateTime());
@@ -48,8 +49,8 @@ public class EventPersistenceAdapter implements EventRepository {
private Event toDomain(EventJpaEntity entity) {
var event = new Event();
event.setId(entity.getId());
event.setEventToken(entity.getEventToken());
event.setOrganizerToken(entity.getOrganizerToken());
event.setEventToken(new EventToken(entity.getEventToken()));
event.setOrganizerToken(new OrganizerToken(entity.getOrganizerToken()));
event.setTitle(entity.getTitle());
event.setDescription(entity.getDescription());
event.setDateTime(entity.getDateTime());

View File

@@ -0,0 +1,68 @@
package de.fete.adapter.out.persistence;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.util.UUID;
/** JPA entity mapping to the rsvps table. */
@Entity
@Table(name = "rsvps")
public class RsvpJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "rsvp_token", nullable = false, unique = true)
private UUID rsvpToken;
@Column(name = "event_id", nullable = false)
private Long eventId;
@Column(nullable = false, length = 100)
private String name;
/** Returns the internal database ID. */
public Long getId() {
return id;
}
/** Sets the internal database ID. */
public void setId(Long id) {
this.id = id;
}
/** Returns the RSVP token. */
public UUID getRsvpToken() {
return rsvpToken;
}
/** Sets the RSVP token. */
public void setRsvpToken(UUID rsvpToken) {
this.rsvpToken = rsvpToken;
}
/** Returns the event ID. */
public Long getEventId() {
return eventId;
}
/** Sets the event ID. */
public void setEventId(Long eventId) {
this.eventId = eventId;
}
/** Returns the guest's display name. */
public String getName() {
return name;
}
/** Sets the guest's display name. */
public void setName(String name) {
this.name = name;
}
}

View File

@@ -0,0 +1,14 @@
package de.fete.adapter.out.persistence;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
/** Spring Data JPA repository for RSVP entities. */
public interface RsvpJpaRepository extends JpaRepository<RsvpJpaEntity, Long> {
/** Finds an RSVP by its token. */
java.util.Optional<RsvpJpaEntity> findByRsvpToken(UUID rsvpToken);
/** Counts RSVPs for the given event. */
long countByEventId(Long eventId);
}

View File

@@ -0,0 +1,48 @@
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 org.springframework.stereotype.Repository;
/** Persistence adapter implementing the RsvpRepository outbound port. */
@Repository
public class RsvpPersistenceAdapter implements RsvpRepository {
private final RsvpJpaRepository jpaRepository;
/** Creates a new adapter with the given JPA repository. */
public RsvpPersistenceAdapter(RsvpJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
public Rsvp save(Rsvp rsvp) {
RsvpJpaEntity entity = toEntity(rsvp);
RsvpJpaEntity saved = jpaRepository.save(entity);
return toDomain(saved);
}
@Override
public long countByEventId(Long eventId) {
return jpaRepository.countByEventId(eventId);
}
private RsvpJpaEntity toEntity(Rsvp rsvp) {
var entity = new RsvpJpaEntity();
entity.setId(rsvp.getId());
entity.setRsvpToken(rsvp.getRsvpToken().value());
entity.setEventId(rsvp.getEventId());
entity.setName(rsvp.getName());
return entity;
}
private Rsvp toDomain(RsvpJpaEntity entity) {
var rsvp = new Rsvp();
rsvp.setId(entity.getId());
rsvp.setRsvpToken(new RsvpToken(entity.getRsvpToken()));
rsvp.setEventId(entity.getEventId());
rsvp.setName(entity.getName());
return rsvp;
}
}

View File

@@ -0,0 +1,12 @@
package de.fete.application.service;
import java.util.UUID;
/** Thrown when an RSVP is attempted on an expired event. */
public class EventExpiredException extends RuntimeException {
/** Creates a new exception for the given event token. */
public EventExpiredException(UUID eventToken) {
super("Event has expired: " + eventToken);
}
}

View File

@@ -2,6 +2,8 @@ package de.fete.application.service;
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.port.in.CreateEventUseCase;
import de.fete.domain.port.in.GetEventUseCase;
import de.fete.domain.port.out.EventRepository;
@@ -9,7 +11,6 @@ import java.time.Clock;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.Optional;
import java.util.UUID;
import org.springframework.stereotype.Service;
/** Application service implementing event creation and retrieval. */
@@ -32,8 +33,8 @@ public class EventService implements CreateEventUseCase, GetEventUseCase {
}
var event = new Event();
event.setEventToken(UUID.randomUUID());
event.setOrganizerToken(UUID.randomUUID());
event.setEventToken(EventToken.generate());
event.setOrganizerToken(OrganizerToken.generate());
event.setTitle(command.title());
event.setDescription(command.description());
event.setDateTime(command.dateTime());
@@ -46,7 +47,7 @@ public class EventService implements CreateEventUseCase, GetEventUseCase {
}
@Override
public Optional<Event> getByEventToken(UUID eventToken) {
public Optional<Event> getByEventToken(EventToken eventToken) {
return eventRepository.findByEventToken(eventToken);
}
}

View File

@@ -0,0 +1,43 @@
package de.fete.application.service;
import de.fete.domain.model.Event;
import de.fete.domain.model.EventToken;
import de.fete.domain.model.Rsvp;
import de.fete.domain.model.RsvpToken;
import de.fete.domain.port.in.CreateRsvpUseCase;
import de.fete.domain.port.out.EventRepository;
import de.fete.domain.port.out.RsvpRepository;
import java.time.Clock;
import org.springframework.stereotype.Service;
/** Application service implementing RSVP creation. */
@Service
public class RsvpService implements CreateRsvpUseCase {
private final EventRepository eventRepository;
private final RsvpRepository rsvpRepository;
private final Clock clock;
/** Creates a new RsvpService. */
public RsvpService(
EventRepository eventRepository,
RsvpRepository rsvpRepository,
Clock clock) {
this.eventRepository = eventRepository;
this.rsvpRepository = rsvpRepository;
this.clock = clock;
}
@Override
public Rsvp createRsvp(EventToken eventToken, String name) {
Event event = eventRepository.findByEventToken(eventToken)
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
var rsvp = new Rsvp();
rsvp.setRsvpToken(RsvpToken.generate());
rsvp.setEventId(event.getId());
rsvp.setName(name.strip());
return rsvpRepository.save(rsvp);
}
}

View File

@@ -3,14 +3,13 @@ package de.fete.domain.model;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.util.UUID;
/** Domain entity representing an event. */
public class Event {
private Long id;
private UUID eventToken;
private UUID organizerToken;
private EventToken eventToken;
private OrganizerToken organizerToken;
private String title;
private String description;
private OffsetDateTime dateTime;
@@ -29,23 +28,23 @@ public class Event {
this.id = id;
}
/** Returns the public event token (UUID). */
public UUID getEventToken() {
/** Returns the public event token. */
public EventToken getEventToken() {
return eventToken;
}
/** Sets the public event token. */
public void setEventToken(UUID eventToken) {
public void setEventToken(EventToken eventToken) {
this.eventToken = eventToken;
}
/** Returns the secret organizer token (UUID). */
public UUID getOrganizerToken() {
/** Returns the secret organizer token. */
public OrganizerToken getOrganizerToken() {
return organizerToken;
}
/** Sets the secret organizer token. */
public void setOrganizerToken(UUID organizerToken) {
public void setOrganizerToken(OrganizerToken organizerToken) {
this.organizerToken = organizerToken;
}

View File

@@ -0,0 +1,18 @@
package de.fete.domain.model;
import java.util.Objects;
import java.util.UUID;
/** Type-safe wrapper for the public event token. */
public record EventToken(UUID value) {
/** Validates that the token value is not null. */
public EventToken {
Objects.requireNonNull(value, "eventToken must not be null");
}
/** Generates a new random event token. */
public static EventToken generate() {
return new EventToken(UUID.randomUUID());
}
}

View File

@@ -0,0 +1,18 @@
package de.fete.domain.model;
import java.util.Objects;
import java.util.UUID;
/** Type-safe wrapper for the secret organizer token. */
public record OrganizerToken(UUID value) {
/** Validates that the token value is not null. */
public OrganizerToken {
Objects.requireNonNull(value, "organizerToken must not be null");
}
/** Generates a new random organizer token. */
public static OrganizerToken generate() {
return new OrganizerToken(UUID.randomUUID());
}
}

View File

@@ -0,0 +1,50 @@
package de.fete.domain.model;
/** Domain entity representing an RSVP. */
public class Rsvp {
private Long id;
private RsvpToken rsvpToken;
private Long eventId;
private String name;
/** Returns the internal database ID. */
public Long getId() {
return id;
}
/** Sets the internal database ID. */
public void setId(Long id) {
this.id = id;
}
/** Returns the RSVP token. */
public RsvpToken getRsvpToken() {
return rsvpToken;
}
/** Sets the RSVP token. */
public void setRsvpToken(RsvpToken rsvpToken) {
this.rsvpToken = rsvpToken;
}
/** Returns the event ID this RSVP belongs to. */
public Long getEventId() {
return eventId;
}
/** Sets the event ID. */
public void setEventId(Long eventId) {
this.eventId = eventId;
}
/** Returns the guest's display name. */
public String getName() {
return name;
}
/** Sets the guest's display name. */
public void setName(String name) {
this.name = name;
}
}

View File

@@ -0,0 +1,18 @@
package de.fete.domain.model;
import java.util.Objects;
import java.util.UUID;
/** Type-safe wrapper for the RSVP token. */
public record RsvpToken(UUID value) {
/** Validates that the token value is not null. */
public RsvpToken {
Objects.requireNonNull(value, "rsvpToken must not be null");
}
/** Generates a new random RSVP token. */
public static RsvpToken generate() {
return new RsvpToken(UUID.randomUUID());
}
}

View File

@@ -0,0 +1,11 @@
package de.fete.domain.port.in;
import de.fete.domain.model.EventToken;
import de.fete.domain.model.Rsvp;
/** Inbound port for creating a new RSVP. */
public interface CreateRsvpUseCase {
/** Creates an RSVP for the given event and guest name. */
Rsvp createRsvp(EventToken eventToken, String name);
}

View File

@@ -1,12 +1,12 @@
package de.fete.domain.port.in;
import de.fete.domain.model.Event;
import de.fete.domain.model.EventToken;
import java.util.Optional;
import java.util.UUID;
/** Inbound port for retrieving a public event by its token. */
public interface GetEventUseCase {
/** Finds an event by its public event token. */
Optional<Event> getByEventToken(UUID eventToken);
Optional<Event> getByEventToken(EventToken eventToken);
}

View File

@@ -1,8 +1,8 @@
package de.fete.domain.port.out;
import de.fete.domain.model.Event;
import de.fete.domain.model.EventToken;
import java.util.Optional;
import java.util.UUID;
/** Outbound port for persisting and retrieving events. */
public interface EventRepository {
@@ -11,5 +11,5 @@ public interface EventRepository {
Event save(Event event);
/** Finds an event by its public event token. */
Optional<Event> findByEventToken(UUID eventToken);
Optional<Event> findByEventToken(EventToken eventToken);
}

View File

@@ -0,0 +1,13 @@
package de.fete.domain.port.out;
import de.fete.domain.model.Rsvp;
/** Outbound port for persisting and querying RSVPs. */
public interface RsvpRepository {
/** Persists the given RSVP and returns it with generated fields populated. */
Rsvp save(Rsvp rsvp);
/** Counts the number of RSVPs for the given event. */
long countByEventId(Long eventId);
}

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet id="003-create-rsvps-table" author="fete">
<createTable tableName="rsvps">
<column name="id" type="bigserial" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="rsvp_token" type="uuid">
<constraints nullable="false" unique="true"/>
</column>
<column name="event_id" type="bigint">
<constraints nullable="false"
foreignKeyName="fk_rsvps_event_id"
references="events(id)"
deleteCascade="true"/>
</column>
<column name="name" type="varchar(100)">
<constraints nullable="false"/>
</column>
</createTable>
<createIndex tableName="rsvps" indexName="idx_rsvps_event_id">
<column name="event_id"/>
</createIndex>
<createIndex tableName="rsvps" indexName="idx_rsvps_rsvp_token">
<column name="rsvp_token"/>
</createIndex>
</changeSet>
</databaseChangeLog>

View File

@@ -8,5 +8,6 @@
<include file="db/changelog/000-baseline.xml"/>
<include file="db/changelog/001-create-events-table.xml"/>
<include file="db/changelog/002-add-timezone-column.xml"/>
<include file="db/changelog/003-create-rsvps-table.xml"/>
</databaseChangeLog>

View File

@@ -37,6 +37,52 @@ paths:
schema:
$ref: "#/components/schemas/ValidationProblemDetail"
/events/{token}/rsvps:
post:
operationId: createRsvp
summary: Submit an RSVP for an event
tags:
- events
parameters:
- name: token
in: path
required: true
schema:
type: string
format: uuid
description: Public event token
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateRsvpRequest"
responses:
"201":
description: RSVP created successfully
content:
application/json:
schema:
$ref: "#/components/schemas/CreateRsvpResponse"
"400":
description: Validation failed (e.g. blank name)
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ValidationProblemDetail"
"404":
description: Event not found
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
"409":
description: Event has expired — RSVPs no longer accepted
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
/events/{token}:
get:
operationId: getEvent
@@ -182,6 +228,34 @@ components:
description: Whether the event's expiry date has passed
example: false
CreateRsvpRequest:
type: object
required:
- name
properties:
name:
type: string
minLength: 1
maxLength: 100
description: Guest's display name
example: "Max Mustermann"
CreateRsvpResponse:
type: object
required:
- rsvpToken
- name
properties:
rsvpToken:
type: string
format: uuid
description: Token identifying this RSVP (store client-side for future updates)
example: "d4e5f6a7-b8c9-0123-4567-890abcdef012"
name:
type: string
description: Guest's display name as stored
example: "Max Mustermann"
ProblemDetail:
type: object
properties: