Implement GET /events/{token} backend with timezone support

Domain: add timezone field to Event and CreateEventCommand.
Ports: new GetEventUseCase inbound port.
Service: implement getByEventToken, validate IANA timezone on create.
Controller: map to GetEventResponse, compute expired flag via Clock.
Persistence: timezone column in JPA entity and mapping.
Tests: integration tests use DTOs + ObjectMapper instead of inline JSON,
GET tests seed DB directly via JpaRepository for isolation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 22:33:40 +01:00
parent e77e479e2a
commit e5d0dd5f8f
13 changed files with 391 additions and 99 deletions

View File

@@ -3,9 +3,18 @@ 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.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.port.in.CreateEventUseCase;
import de.fete.domain.port.in.GetEventUseCase;
import java.time.Clock;
import java.time.DateTimeException;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
@@ -15,19 +24,29 @@ import org.springframework.web.bind.annotation.RestController;
public class EventController implements EventsApi {
private final CreateEventUseCase createEventUseCase;
private final GetEventUseCase getEventUseCase;
private final Clock clock;
/** Creates a new controller with the given use case. */
public EventController(CreateEventUseCase createEventUseCase) {
/** Creates a new controller with the given use cases and clock. */
public EventController(
CreateEventUseCase createEventUseCase,
GetEventUseCase getEventUseCase,
Clock clock) {
this.createEventUseCase = createEventUseCase;
this.getEventUseCase = getEventUseCase;
this.clock = clock;
}
@Override
public ResponseEntity<CreateEventResponse> createEvent(
CreateEventRequest request) {
ZoneId zoneId = parseTimezone(request.getTimezone());
var command = new CreateEventCommand(
request.getTitle(),
request.getDescription(),
request.getDateTime(),
zoneId,
request.getLocation(),
request.getExpiryDate()
);
@@ -39,8 +58,36 @@ public class EventController implements EventsApi {
response.setOrganizerToken(event.getOrganizerToken());
response.setTitle(event.getTitle());
response.setDateTime(event.getDateTime());
response.setTimezone(event.getTimezone().getId());
response.setExpiryDate(event.getExpiryDate());
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@Override
public ResponseEntity<GetEventResponse> getEvent(UUID token) {
Event event = getEventUseCase.getByEventToken(token)
.orElseThrow(() -> new EventNotFoundException(token));
var response = new GetEventResponse();
response.setEventToken(event.getEventToken());
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.setExpired(
event.getExpiryDate().isBefore(LocalDate.now(clock)));
return ResponseEntity.ok(response);
}
private static ZoneId parseTimezone(String timezone) {
try {
return ZoneId.of(timezone);
} catch (DateTimeException e) {
throw new InvalidTimezoneException(timezone);
}
}
}

View File

@@ -1,6 +1,8 @@
package de.fete.adapter.in.web;
import de.fete.application.service.EventNotFoundException;
import de.fete.application.service.ExpiryDateInPastException;
import de.fete.application.service.InvalidTimezoneException;
import java.net.URI;
import java.util.List;
import java.util.Map;
@@ -57,6 +59,32 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
.body(problemDetail);
}
/** Handles event not found. */
@ExceptionHandler(EventNotFoundException.class)
public ResponseEntity<ProblemDetail> handleEventNotFound(
EventNotFoundException ex) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND, ex.getMessage());
problemDetail.setTitle("Event Not Found");
problemDetail.setType(URI.create("urn:problem-type:event-not-found"));
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(problemDetail);
}
/** Handles invalid timezone. */
@ExceptionHandler(InvalidTimezoneException.class)
public ResponseEntity<ProblemDetail> handleInvalidTimezone(
InvalidTimezoneException ex) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST, ex.getMessage());
problemDetail.setTitle("Invalid Timezone");
problemDetail.setType(URI.create("urn:problem-type:invalid-timezone"));
return ResponseEntity.badRequest()
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(problemDetail);
}
/** Catches all unhandled exceptions. */
@ExceptionHandler(Exception.class)
public ResponseEntity<ProblemDetail> handleAll(Exception ex) {

View File

@@ -34,6 +34,9 @@ public class EventJpaEntity {
@Column(name = "date_time", nullable = false)
private OffsetDateTime dateTime;
@Column(nullable = false, length = 64)
private String timezone;
@Column(length = 500)
private String location;
@@ -103,6 +106,16 @@ public class EventJpaEntity {
this.dateTime = dateTime;
}
/** Returns the IANA timezone name. */
public String getTimezone() {
return timezone;
}
/** Sets the IANA timezone name. */
public void setTimezone(String timezone) {
this.timezone = timezone;
}
/** Returns the event location. */
public String getLocation() {
return location;

View File

@@ -2,6 +2,7 @@ package de.fete.adapter.out.persistence;
import de.fete.domain.model.Event;
import de.fete.domain.port.out.EventRepository;
import java.time.ZoneId;
import java.util.Optional;
import java.util.UUID;
import org.springframework.stereotype.Repository;
@@ -37,6 +38,7 @@ public class EventPersistenceAdapter implements EventRepository {
entity.setTitle(event.getTitle());
entity.setDescription(event.getDescription());
entity.setDateTime(event.getDateTime());
entity.setTimezone(event.getTimezone().getId());
entity.setLocation(event.getLocation());
entity.setExpiryDate(event.getExpiryDate());
entity.setCreatedAt(event.getCreatedAt());
@@ -51,6 +53,7 @@ public class EventPersistenceAdapter implements EventRepository {
event.setTitle(entity.getTitle());
event.setDescription(entity.getDescription());
event.setDateTime(entity.getDateTime());
event.setTimezone(ZoneId.of(entity.getTimezone()));
event.setLocation(entity.getLocation());
event.setExpiryDate(entity.getExpiryDate());
event.setCreatedAt(entity.getCreatedAt());

View File

@@ -0,0 +1,12 @@
package de.fete.application.service;
import java.util.UUID;
/** Thrown when an event cannot be found by its token. */
public class EventNotFoundException extends RuntimeException {
/** Creates a new exception for the given event token. */
public EventNotFoundException(UUID eventToken) {
super("Event not found: " + eventToken);
}
}

View File

@@ -3,16 +3,18 @@ package de.fete.application.service;
import de.fete.domain.model.CreateEventCommand;
import de.fete.domain.model.Event;
import de.fete.domain.port.in.CreateEventUseCase;
import de.fete.domain.port.in.GetEventUseCase;
import de.fete.domain.port.out.EventRepository;
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. */
/** Application service implementing event creation and retrieval. */
@Service
public class EventService implements CreateEventUseCase {
public class EventService implements CreateEventUseCase, GetEventUseCase {
private final EventRepository eventRepository;
private final Clock clock;
@@ -35,10 +37,16 @@ public class EventService implements CreateEventUseCase {
event.setTitle(command.title());
event.setDescription(command.description());
event.setDateTime(command.dateTime());
event.setTimezone(command.timezone());
event.setLocation(command.location());
event.setExpiryDate(command.expiryDate());
event.setCreatedAt(OffsetDateTime.now(clock));
return eventRepository.save(event);
}
@Override
public Optional<Event> getByEventToken(UUID eventToken) {
return eventRepository.findByEventToken(eventToken);
}
}

View File

@@ -0,0 +1,10 @@
package de.fete.application.service;
/** Thrown when an invalid IANA timezone ID is provided. */
public class InvalidTimezoneException extends RuntimeException {
/** Creates a new exception for the given invalid timezone. */
public InvalidTimezoneException(String timezone) {
super("Invalid IANA timezone: " + timezone);
}
}

View File

@@ -2,12 +2,14 @@ package de.fete.domain.model;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneId;
/** Command carrying the data needed to create an event. */
public record CreateEventCommand(
String title,
String description,
OffsetDateTime dateTime,
ZoneId timezone,
String location,
LocalDate expiryDate
) {}

View File

@@ -2,6 +2,7 @@ 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. */
@@ -13,6 +14,7 @@ public class Event {
private String title;
private String description;
private OffsetDateTime dateTime;
private ZoneId timezone;
private String location;
private LocalDate expiryDate;
private OffsetDateTime createdAt;
@@ -77,6 +79,16 @@ public class Event {
this.dateTime = dateTime;
}
/** Returns the IANA timezone. */
public ZoneId getTimezone() {
return timezone;
}
/** Sets the IANA timezone. */
public void setTimezone(ZoneId timezone) {
this.timezone = timezone;
}
/** Returns the event location. */
public String getLocation() {
return location;

View File

@@ -0,0 +1,12 @@
package de.fete.domain.port.in;
import de.fete.domain.model.Event;
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);
}