diff --git a/backend/src/main/java/de/fete/adapter/in/web/EventController.java b/backend/src/main/java/de/fete/adapter/in/web/EventController.java index a0fc363..e103ab8 100644 --- a/backend/src/main/java/de/fete/adapter/in/web/EventController.java +++ b/backend/src/main/java/de/fete/adapter/in/web/EventController.java @@ -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 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 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); diff --git a/backend/src/main/java/de/fete/adapter/out/persistence/EventPersistenceAdapter.java b/backend/src/main/java/de/fete/adapter/out/persistence/EventPersistenceAdapter.java index c4e78c2..e9fc2fe 100644 --- a/backend/src/main/java/de/fete/adapter/out/persistence/EventPersistenceAdapter.java +++ b/backend/src/main/java/de/fete/adapter/out/persistence/EventPersistenceAdapter.java @@ -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 findByEventToken(UUID eventToken) { - return jpaRepository.findByEventToken(eventToken).map(this::toDomain); + public Optional 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()); diff --git a/backend/src/main/java/de/fete/adapter/out/persistence/RsvpJpaEntity.java b/backend/src/main/java/de/fete/adapter/out/persistence/RsvpJpaEntity.java new file mode 100644 index 0000000..06e783b --- /dev/null +++ b/backend/src/main/java/de/fete/adapter/out/persistence/RsvpJpaEntity.java @@ -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; + } +} diff --git a/backend/src/main/java/de/fete/adapter/out/persistence/RsvpJpaRepository.java b/backend/src/main/java/de/fete/adapter/out/persistence/RsvpJpaRepository.java new file mode 100644 index 0000000..5f440c9 --- /dev/null +++ b/backend/src/main/java/de/fete/adapter/out/persistence/RsvpJpaRepository.java @@ -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 { + + /** Finds an RSVP by its token. */ + java.util.Optional findByRsvpToken(UUID rsvpToken); + + /** Counts RSVPs for the given event. */ + long countByEventId(Long eventId); +} diff --git a/backend/src/main/java/de/fete/adapter/out/persistence/RsvpPersistenceAdapter.java b/backend/src/main/java/de/fete/adapter/out/persistence/RsvpPersistenceAdapter.java new file mode 100644 index 0000000..e94181d --- /dev/null +++ b/backend/src/main/java/de/fete/adapter/out/persistence/RsvpPersistenceAdapter.java @@ -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; + } +} diff --git a/backend/src/main/java/de/fete/application/service/EventExpiredException.java b/backend/src/main/java/de/fete/application/service/EventExpiredException.java new file mode 100644 index 0000000..374830d --- /dev/null +++ b/backend/src/main/java/de/fete/application/service/EventExpiredException.java @@ -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); + } +} diff --git a/backend/src/main/java/de/fete/application/service/EventService.java b/backend/src/main/java/de/fete/application/service/EventService.java index 315c5a1..407b5d3 100644 --- a/backend/src/main/java/de/fete/application/service/EventService.java +++ b/backend/src/main/java/de/fete/application/service/EventService.java @@ -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 getByEventToken(UUID eventToken) { + public Optional getByEventToken(EventToken eventToken) { return eventRepository.findByEventToken(eventToken); } } diff --git a/backend/src/main/java/de/fete/application/service/RsvpService.java b/backend/src/main/java/de/fete/application/service/RsvpService.java new file mode 100644 index 0000000..5153a24 --- /dev/null +++ b/backend/src/main/java/de/fete/application/service/RsvpService.java @@ -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); + } +} diff --git a/backend/src/main/java/de/fete/domain/model/Event.java b/backend/src/main/java/de/fete/domain/model/Event.java index 1575137..27d2cf6 100644 --- a/backend/src/main/java/de/fete/domain/model/Event.java +++ b/backend/src/main/java/de/fete/domain/model/Event.java @@ -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; } diff --git a/backend/src/main/java/de/fete/domain/model/EventToken.java b/backend/src/main/java/de/fete/domain/model/EventToken.java new file mode 100644 index 0000000..f6482ee --- /dev/null +++ b/backend/src/main/java/de/fete/domain/model/EventToken.java @@ -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()); + } +} diff --git a/backend/src/main/java/de/fete/domain/model/OrganizerToken.java b/backend/src/main/java/de/fete/domain/model/OrganizerToken.java new file mode 100644 index 0000000..8c797fd --- /dev/null +++ b/backend/src/main/java/de/fete/domain/model/OrganizerToken.java @@ -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()); + } +} diff --git a/backend/src/main/java/de/fete/domain/model/Rsvp.java b/backend/src/main/java/de/fete/domain/model/Rsvp.java new file mode 100644 index 0000000..53285db --- /dev/null +++ b/backend/src/main/java/de/fete/domain/model/Rsvp.java @@ -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; + } +} diff --git a/backend/src/main/java/de/fete/domain/model/RsvpToken.java b/backend/src/main/java/de/fete/domain/model/RsvpToken.java new file mode 100644 index 0000000..769150a --- /dev/null +++ b/backend/src/main/java/de/fete/domain/model/RsvpToken.java @@ -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()); + } +} diff --git a/backend/src/main/java/de/fete/domain/port/in/CreateRsvpUseCase.java b/backend/src/main/java/de/fete/domain/port/in/CreateRsvpUseCase.java new file mode 100644 index 0000000..5eeef72 --- /dev/null +++ b/backend/src/main/java/de/fete/domain/port/in/CreateRsvpUseCase.java @@ -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); +} diff --git a/backend/src/main/java/de/fete/domain/port/in/GetEventUseCase.java b/backend/src/main/java/de/fete/domain/port/in/GetEventUseCase.java index 1731e92..2a91b76 100644 --- a/backend/src/main/java/de/fete/domain/port/in/GetEventUseCase.java +++ b/backend/src/main/java/de/fete/domain/port/in/GetEventUseCase.java @@ -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 getByEventToken(UUID eventToken); + Optional getByEventToken(EventToken eventToken); } diff --git a/backend/src/main/java/de/fete/domain/port/out/EventRepository.java b/backend/src/main/java/de/fete/domain/port/out/EventRepository.java index 62db149..84381c2 100644 --- a/backend/src/main/java/de/fete/domain/port/out/EventRepository.java +++ b/backend/src/main/java/de/fete/domain/port/out/EventRepository.java @@ -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 findByEventToken(UUID eventToken); + Optional findByEventToken(EventToken eventToken); } diff --git a/backend/src/main/java/de/fete/domain/port/out/RsvpRepository.java b/backend/src/main/java/de/fete/domain/port/out/RsvpRepository.java new file mode 100644 index 0000000..e2af4fa --- /dev/null +++ b/backend/src/main/java/de/fete/domain/port/out/RsvpRepository.java @@ -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); +} diff --git a/backend/src/main/resources/db/changelog/003-create-rsvps-table.xml b/backend/src/main/resources/db/changelog/003-create-rsvps-table.xml new file mode 100644 index 0000000..001b32b --- /dev/null +++ b/backend/src/main/resources/db/changelog/003-create-rsvps-table.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/src/main/resources/db/changelog/db.changelog-master.xml b/backend/src/main/resources/db/changelog/db.changelog-master.xml index fdd403c..069351a 100644 --- a/backend/src/main/resources/db/changelog/db.changelog-master.xml +++ b/backend/src/main/resources/db/changelog/db.changelog-master.xml @@ -8,5 +8,6 @@ + diff --git a/backend/src/main/resources/openapi/api.yaml b/backend/src/main/resources/openapi/api.yaml index 7108b4c..4356b54 100644 --- a/backend/src/main/resources/openapi/api.yaml +++ b/backend/src/main/resources/openapi/api.yaml @@ -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: diff --git a/backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java b/backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java index f47f433..70c8518 100644 --- a/backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java +++ b/backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java @@ -11,8 +11,12 @@ import com.fasterxml.jackson.databind.ObjectMapper; import de.fete.TestcontainersConfig; 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.out.persistence.EventJpaEntity; import de.fete.adapter.out.persistence.EventJpaRepository; +import de.fete.adapter.out.persistence.RsvpJpaEntity; +import de.fete.adapter.out.persistence.RsvpJpaRepository; import java.time.LocalDate; import java.time.OffsetDateTime; import java.time.ZoneOffset; @@ -39,6 +43,9 @@ class EventControllerIntegrationTest { @Autowired private EventJpaRepository jpaRepository; + @Autowired + private RsvpJpaRepository rsvpJpaRepository; + // --- Create Event tests --- @Test @@ -268,6 +275,80 @@ class EventControllerIntegrationTest { .andExpect(jsonPath("$.expired").value(true)); } + // --- RSVP tests --- + + @Test + void createRsvpReturns201WithToken() throws Exception { + EventJpaEntity event = seedEvent( + "RSVP Event", "Join us!", "Europe/Berlin", + "Berlin", LocalDate.now().plusDays(30)); + + var request = new CreateRsvpRequest().name("Max Mustermann"); + + var result = mockMvc.perform(post("/api/events/" + event.getEventToken() + "/rsvps") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.rsvpToken").isNotEmpty()) + .andExpect(jsonPath("$.name").value("Max Mustermann")) + .andReturn(); + + var response = objectMapper.readValue( + result.getResponse().getContentAsString(), CreateRsvpResponse.class); + + RsvpJpaEntity persisted = rsvpJpaRepository + .findByRsvpToken(response.getRsvpToken()).orElseThrow(); + assertThat(persisted.getName()).isEqualTo("Max Mustermann"); + assertThat(persisted.getEventId()).isEqualTo(event.getId()); + } + + @Test + void createRsvpWithBlankNameReturns400() throws Exception { + EventJpaEntity event = seedEvent( + "RSVP Event", null, "Europe/Berlin", + null, LocalDate.now().plusDays(30)); + + var request = new CreateRsvpRequest().name(""); + + mockMvc.perform(post("/api/events/" + event.getEventToken() + "/rsvps") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(content().contentTypeCompatibleWith("application/problem+json")); + } + + @Test + void attendeeCountIncreasesAfterRsvp() throws Exception { + EventJpaEntity event = seedEvent( + "Count Event", null, "Europe/Berlin", + null, LocalDate.now().plusDays(30)); + + mockMvc.perform(get("/api/events/" + event.getEventToken())) + .andExpect(jsonPath("$.attendeeCount").value(0)); + + var request = new CreateRsvpRequest().name("First Guest"); + + mockMvc.perform(post("/api/events/" + event.getEventToken() + "/rsvps") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()); + + mockMvc.perform(get("/api/events/" + event.getEventToken())) + .andExpect(jsonPath("$.attendeeCount").value(1)); + } + + @Test + void createRsvpForUnknownEventReturns404() throws Exception { + var request = new CreateRsvpRequest().name("Ghost"); + + mockMvc.perform(post("/api/events/" + UUID.randomUUID() + "/rsvps") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()) + .andExpect(content().contentTypeCompatibleWith("application/problem+json")) + .andExpect(jsonPath("$.type").value("urn:problem-type:event-not-found")); + } + private EventJpaEntity seedEvent( String title, String description, String timezone, String location, LocalDate expiryDate) { diff --git a/backend/src/test/java/de/fete/adapter/out/persistence/EventPersistenceAdapterTest.java b/backend/src/test/java/de/fete/adapter/out/persistence/EventPersistenceAdapterTest.java index eedac71..d12c789 100644 --- a/backend/src/test/java/de/fete/adapter/out/persistence/EventPersistenceAdapterTest.java +++ b/backend/src/test/java/de/fete/adapter/out/persistence/EventPersistenceAdapterTest.java @@ -4,13 +4,14 @@ import static org.assertj.core.api.Assertions.assertThat; import de.fete.TestcontainersConfig; 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.LocalDate; import java.time.OffsetDateTime; import java.time.ZoneId; import java.time.ZoneOffset; import java.util.Optional; -import java.util.UUID; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -47,7 +48,7 @@ class EventPersistenceAdapterTest { @Test void findByUnknownEventTokenReturnsEmpty() { - Optional found = eventRepository.findByEventToken(UUID.randomUUID()); + Optional found = eventRepository.findByEventToken(EventToken.generate()); assertThat(found).isEmpty(); } @@ -61,8 +62,8 @@ class EventPersistenceAdapterTest { OffsetDateTime.of(2026, 3, 4, 12, 0, 0, 0, ZoneOffset.UTC); var event = new Event(); - event.setEventToken(UUID.randomUUID()); - event.setOrganizerToken(UUID.randomUUID()); + event.setEventToken(EventToken.generate()); + event.setOrganizerToken(OrganizerToken.generate()); event.setTitle("Full Event"); event.setDescription("A detailed description"); event.setDateTime(dateTime); @@ -87,8 +88,8 @@ class EventPersistenceAdapterTest { private Event buildEvent() { var event = new Event(); - event.setEventToken(UUID.randomUUID()); - event.setOrganizerToken(UUID.randomUUID()); + event.setEventToken(EventToken.generate()); + event.setOrganizerToken(OrganizerToken.generate()); event.setTitle("Test Event"); event.setDescription("Test description"); event.setDateTime(OffsetDateTime.now().plusDays(7)); diff --git a/backend/src/test/java/de/fete/application/service/EventServiceTest.java b/backend/src/test/java/de/fete/application/service/EventServiceTest.java index 7062536..eee8920 100644 --- a/backend/src/test/java/de/fete/application/service/EventServiceTest.java +++ b/backend/src/test/java/de/fete/application/service/EventServiceTest.java @@ -9,6 +9,7 @@ import static org.mockito.Mockito.when; import de.fete.domain.model.CreateEventCommand; import de.fete.domain.model.Event; +import de.fete.domain.model.EventToken; import de.fete.domain.port.out.EventRepository; import java.time.Clock; import java.time.Instant; @@ -17,7 +18,6 @@ import java.time.OffsetDateTime; import java.time.ZoneId; import java.time.ZoneOffset; import java.util.Optional; -import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -130,7 +130,7 @@ class EventServiceTest { @Test void getByEventTokenReturnsEvent() { - UUID token = UUID.randomUUID(); + EventToken token = EventToken.generate(); var event = new Event(); event.setEventToken(token); event.setTitle("Found Event"); @@ -145,7 +145,7 @@ class EventServiceTest { @Test void getByEventTokenReturnsEmptyForUnknownToken() { - UUID token = UUID.randomUUID(); + EventToken token = EventToken.generate(); when(eventRepository.findByEventToken(token)) .thenReturn(Optional.empty()); diff --git a/backend/src/test/java/de/fete/application/service/RsvpServiceTest.java b/backend/src/test/java/de/fete/application/service/RsvpServiceTest.java new file mode 100644 index 0000000..d5f6f36 --- /dev/null +++ b/backend/src/test/java/de/fete/application/service/RsvpServiceTest.java @@ -0,0 +1,115 @@ +package de.fete.application.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import de.fete.domain.model.Event; +import de.fete.domain.model.EventToken; +import de.fete.domain.model.OrganizerToken; +import de.fete.domain.model.Rsvp; +import de.fete.domain.port.out.EventRepository; +import de.fete.domain.port.out.RsvpRepository; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class RsvpServiceTest { + + private static final ZoneId ZONE = ZoneId.of("Europe/Berlin"); + private static final Instant FIXED_INSTANT = + LocalDate.of(2026, 3, 5).atStartOfDay(ZONE).toInstant(); + private static final Clock FIXED_CLOCK = Clock.fixed(FIXED_INSTANT, ZONE); + + @Mock + private EventRepository eventRepository; + + @Mock + private RsvpRepository rsvpRepository; + + private RsvpService rsvpService; + + @BeforeEach + void setUp() { + rsvpService = new RsvpService(eventRepository, rsvpRepository, FIXED_CLOCK); + } + + @Test + void createRsvpSucceedsForActiveEvent() { + Event event = buildActiveEvent(); + EventToken token = event.getEventToken(); + when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event)); + when(rsvpRepository.save(any(Rsvp.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + Rsvp result = rsvpService.createRsvp(token, "Max Mustermann"); + + assertThat(result.getName()).isEqualTo("Max Mustermann"); + assertThat(result.getRsvpToken()).isNotNull(); + assertThat(result.getEventId()).isEqualTo(event.getId()); + } + + @Test + void createRsvpPersistsViaRepository() { + Event event = buildActiveEvent(); + EventToken token = event.getEventToken(); + when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event)); + when(rsvpRepository.save(any(Rsvp.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + rsvpService.createRsvp(token, "Test Guest"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Rsvp.class); + verify(rsvpRepository).save(captor.capture()); + assertThat(captor.getValue().getName()).isEqualTo("Test Guest"); + assertThat(captor.getValue().getEventId()).isEqualTo(event.getId()); + } + + @Test + void createRsvpThrowsWhenEventNotFound() { + EventToken token = EventToken.generate(); + when(eventRepository.findByEventToken(token)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> rsvpService.createRsvp(token, "Guest")) + .isInstanceOf(EventNotFoundException.class); + } + + @Test + void createRsvpTrimsName() { + Event event = buildActiveEvent(); + EventToken token = event.getEventToken(); + when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event)); + when(rsvpRepository.save(any(Rsvp.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + Rsvp result = rsvpService.createRsvp(token, " Max "); + + assertThat(result.getName()).isEqualTo("Max"); + } + + private Event buildActiveEvent() { + var event = new Event(); + event.setId(1L); + event.setEventToken(EventToken.generate()); + event.setOrganizerToken(OrganizerToken.generate()); + event.setTitle("Test Event"); + event.setDateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))); + event.setTimezone(ZONE); + event.setExpiryDate(LocalDate.of(2026, 7, 15)); + event.setCreatedAt(OffsetDateTime.now(FIXED_CLOCK)); + return event; + } +}