Compare commits
14 Commits
01f9e3dac1
...
0.11.0
| Author | SHA1 | Date | |
|---|---|---|---|
| e01d5ee642 | |||
| d333ab3d39 | |||
| 541017965f | |||
| 981920f004 | |||
| 3908c89998 | |||
| bf0f4ffb7f | |||
| 58043d1507 | |||
|
|
264c4ec21f | ||
| 6d7a55fdb3 | |||
| a8aacf4ee9 | |||
| 0a404ecde3 | |||
|
|
7fb296b47f | ||
|
|
8ab7d345c8 | ||
|
|
cf2139f229 |
@@ -8,8 +8,9 @@ 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.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.adapter.in.web.model.PatchEventRequest;
|
||||||
import de.fete.application.service.InvalidTimezoneException;
|
import de.fete.application.service.exception.EventNotFoundException;
|
||||||
|
import de.fete.application.service.exception.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;
|
||||||
@@ -22,6 +23,7 @@ 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.GetAttendeesUseCase;
|
||||||
import de.fete.domain.port.in.GetEventUseCase;
|
import de.fete.domain.port.in.GetEventUseCase;
|
||||||
|
import de.fete.domain.port.in.UpdateEventUseCase;
|
||||||
import java.time.DateTimeException;
|
import java.time.DateTimeException;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -40,6 +42,7 @@ public class EventController implements EventsApi {
|
|||||||
private final CancelRsvpUseCase cancelRsvpUseCase;
|
private final CancelRsvpUseCase cancelRsvpUseCase;
|
||||||
private final CountAttendeesByEventUseCase countAttendeesByEventUseCase;
|
private final CountAttendeesByEventUseCase countAttendeesByEventUseCase;
|
||||||
private final GetAttendeesUseCase getAttendeesUseCase;
|
private final GetAttendeesUseCase getAttendeesUseCase;
|
||||||
|
private final UpdateEventUseCase updateEventUseCase;
|
||||||
|
|
||||||
/** Creates a new controller with the given use cases. */
|
/** Creates a new controller with the given use cases. */
|
||||||
public EventController(
|
public EventController(
|
||||||
@@ -48,13 +51,15 @@ public class EventController implements EventsApi {
|
|||||||
CreateRsvpUseCase createRsvpUseCase,
|
CreateRsvpUseCase createRsvpUseCase,
|
||||||
CancelRsvpUseCase cancelRsvpUseCase,
|
CancelRsvpUseCase cancelRsvpUseCase,
|
||||||
CountAttendeesByEventUseCase countAttendeesByEventUseCase,
|
CountAttendeesByEventUseCase countAttendeesByEventUseCase,
|
||||||
GetAttendeesUseCase getAttendeesUseCase) {
|
GetAttendeesUseCase getAttendeesUseCase,
|
||||||
|
UpdateEventUseCase updateEventUseCase) {
|
||||||
this.createEventUseCase = createEventUseCase;
|
this.createEventUseCase = createEventUseCase;
|
||||||
this.getEventUseCase = getEventUseCase;
|
this.getEventUseCase = getEventUseCase;
|
||||||
this.createRsvpUseCase = createRsvpUseCase;
|
this.createRsvpUseCase = createRsvpUseCase;
|
||||||
this.cancelRsvpUseCase = cancelRsvpUseCase;
|
this.cancelRsvpUseCase = cancelRsvpUseCase;
|
||||||
this.countAttendeesByEventUseCase = countAttendeesByEventUseCase;
|
this.countAttendeesByEventUseCase = countAttendeesByEventUseCase;
|
||||||
this.getAttendeesUseCase = getAttendeesUseCase;
|
this.getAttendeesUseCase = getAttendeesUseCase;
|
||||||
|
this.updateEventUseCase = updateEventUseCase;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -73,42 +78,55 @@ public class EventController implements EventsApi {
|
|||||||
Event event = createEventUseCase.createEvent(command);
|
Event event = createEventUseCase.createEvent(command);
|
||||||
|
|
||||||
var response = new CreateEventResponse();
|
var response = new CreateEventResponse();
|
||||||
response.setEventToken(event.getEventToken().value());
|
response.setEventToken(event.eventToken().value());
|
||||||
response.setOrganizerToken(event.getOrganizerToken().value());
|
response.setOrganizerToken(event.organizerToken().value());
|
||||||
response.setTitle(event.getTitle());
|
response.setTitle(event.title());
|
||||||
response.setDateTime(event.getDateTime());
|
response.setDateTime(event.dateTime());
|
||||||
response.setTimezone(event.getTimezone().getId());
|
response.setTimezone(event.timezone().getId());
|
||||||
|
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ResponseEntity<GetEventResponse> getEvent(UUID token) {
|
public ResponseEntity<GetEventResponse> getEvent(UUID eventToken) {
|
||||||
var eventToken = new de.fete.domain.model.EventToken(token);
|
var evtToken = new EventToken(eventToken);
|
||||||
Event event = getEventUseCase.getByEventToken(eventToken)
|
Event event = getEventUseCase.getByEventToken(evtToken)
|
||||||
.orElseThrow(() -> new EventNotFoundException(token));
|
.orElseThrow(() -> new EventNotFoundException(eventToken));
|
||||||
|
|
||||||
var response = new GetEventResponse();
|
var response = new GetEventResponse();
|
||||||
response.setEventToken(event.getEventToken().value());
|
response.setEventToken(event.eventToken().value());
|
||||||
response.setTitle(event.getTitle());
|
response.setTitle(event.title());
|
||||||
response.setDescription(event.getDescription());
|
response.setDescription(event.description());
|
||||||
response.setDateTime(event.getDateTime());
|
response.setDateTime(event.dateTime());
|
||||||
response.setTimezone(event.getTimezone().getId());
|
response.setTimezone(event.timezone().getId());
|
||||||
response.setLocation(event.getLocation());
|
response.setLocation(event.location());
|
||||||
response.setAttendeeCount(
|
response.setAttendeeCount(
|
||||||
(int) countAttendeesByEventUseCase.countByEvent(eventToken));
|
(int) countAttendeesByEventUseCase.countByEvent(evtToken));
|
||||||
|
response.setCancelled(event.cancelled());
|
||||||
|
response.setCancellationReason(event.cancellationReason());
|
||||||
|
|
||||||
return ResponseEntity.ok(response);
|
return ResponseEntity.ok(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResponseEntity<Void> patchEvent(
|
||||||
|
UUID eventToken, UUID organizerToken, PatchEventRequest request) {
|
||||||
|
updateEventUseCase.cancelEvent(
|
||||||
|
new EventToken(eventToken),
|
||||||
|
new OrganizerToken(organizerToken),
|
||||||
|
request.getCancelled(),
|
||||||
|
request.getCancellationReason());
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ResponseEntity<GetAttendeesResponse> getAttendees(
|
public ResponseEntity<GetAttendeesResponse> getAttendees(
|
||||||
UUID token, UUID organizerToken) {
|
UUID eventToken, UUID organizerToken) {
|
||||||
var eventToken = new EventToken(token);
|
var evtToken = new EventToken(eventToken);
|
||||||
var orgToken = new OrganizerToken(organizerToken);
|
var orgToken = new OrganizerToken(organizerToken);
|
||||||
|
|
||||||
List<String> names = getAttendeesUseCase
|
List<String> names = getAttendeesUseCase
|
||||||
.getAttendeeNames(eventToken, orgToken);
|
.getAttendeeNames(evtToken, orgToken);
|
||||||
|
|
||||||
var attendees = names.stream()
|
var attendees = names.stream()
|
||||||
.map(name -> new Attendee().name(name))
|
.map(name -> new Attendee().name(name))
|
||||||
@@ -122,20 +140,20 @@ public class EventController implements EventsApi {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ResponseEntity<CreateRsvpResponse> createRsvp(
|
public ResponseEntity<CreateRsvpResponse> createRsvp(
|
||||||
UUID token, CreateRsvpRequest createRsvpRequest) {
|
UUID eventToken, CreateRsvpRequest createRsvpRequest) {
|
||||||
var eventToken = new EventToken(token);
|
var evtToken = new EventToken(eventToken);
|
||||||
Rsvp rsvp = createRsvpUseCase.createRsvp(eventToken, createRsvpRequest.getName());
|
Rsvp rsvp = createRsvpUseCase.createRsvp(evtToken, createRsvpRequest.getName());
|
||||||
|
|
||||||
var response = new CreateRsvpResponse();
|
var response = new CreateRsvpResponse();
|
||||||
response.setRsvpToken(rsvp.getRsvpToken().value());
|
response.setRsvpToken(rsvp.rsvpToken().value());
|
||||||
response.setName(rsvp.getName());
|
response.setName(rsvp.name());
|
||||||
|
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ResponseEntity<Void> cancelRsvp(UUID token, UUID rsvpToken) {
|
public ResponseEntity<Void> cancelRsvp(UUID eventToken, UUID rsvpToken) {
|
||||||
cancelRsvpUseCase.cancelRsvp(new EventToken(token), new RsvpToken(rsvpToken));
|
cancelRsvpUseCase.cancelRsvp(new EventToken(eventToken), new RsvpToken(rsvpToken));
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
package de.fete.adapter.in.web;
|
package de.fete.adapter.in.web;
|
||||||
|
|
||||||
import de.fete.application.service.EventExpiredException;
|
import de.fete.application.service.exception.EventAlreadyCancelledException;
|
||||||
import de.fete.application.service.EventNotFoundException;
|
import de.fete.application.service.exception.EventCancelledException;
|
||||||
import de.fete.application.service.ExpiryDateBeforeEventException;
|
import de.fete.application.service.exception.EventExpiredException;
|
||||||
import de.fete.application.service.ExpiryDateInPastException;
|
import de.fete.application.service.exception.EventNotFoundException;
|
||||||
import de.fete.application.service.InvalidOrganizerTokenException;
|
import de.fete.application.service.exception.ExpiryDateBeforeEventException;
|
||||||
import de.fete.application.service.InvalidTimezoneException;
|
import de.fete.application.service.exception.ExpiryDateInPastException;
|
||||||
|
import de.fete.application.service.exception.InvalidOrganizerTokenException;
|
||||||
|
import de.fete.application.service.exception.InvalidTimezoneException;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -75,6 +77,32 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
|
|||||||
.body(problemDetail);
|
.body(problemDetail);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Handles attempt to cancel an already cancelled event. */
|
||||||
|
@ExceptionHandler(EventAlreadyCancelledException.class)
|
||||||
|
public ResponseEntity<ProblemDetail> handleEventAlreadyCancelled(
|
||||||
|
EventAlreadyCancelledException ex) {
|
||||||
|
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
|
||||||
|
HttpStatus.CONFLICT, ex.getMessage());
|
||||||
|
problemDetail.setTitle("Event Already Cancelled");
|
||||||
|
problemDetail.setType(URI.create("urn:problem-type:event-already-cancelled"));
|
||||||
|
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||||
|
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
|
||||||
|
.body(problemDetail);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handles RSVP on cancelled event. */
|
||||||
|
@ExceptionHandler(EventCancelledException.class)
|
||||||
|
public ResponseEntity<ProblemDetail> handleEventCancelled(
|
||||||
|
EventCancelledException ex) {
|
||||||
|
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
|
||||||
|
HttpStatus.CONFLICT, ex.getMessage());
|
||||||
|
problemDetail.setTitle("Event Cancelled");
|
||||||
|
problemDetail.setType(URI.create("urn:problem-type:event-cancelled"));
|
||||||
|
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||||
|
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
|
||||||
|
.body(problemDetail);
|
||||||
|
}
|
||||||
|
|
||||||
/** Handles RSVP on expired event. */
|
/** Handles RSVP on expired event. */
|
||||||
@ExceptionHandler(EventExpiredException.class)
|
@ExceptionHandler(EventExpiredException.class)
|
||||||
public ResponseEntity<ProblemDetail> handleEventExpired(
|
public ResponseEntity<ProblemDetail> handleEventExpired(
|
||||||
|
|||||||
@@ -68,17 +68,17 @@ public class SpaController {
|
|||||||
|
|
||||||
/** Serves SPA HTML with event-specific meta-tags. */
|
/** Serves SPA HTML with event-specific meta-tags. */
|
||||||
@GetMapping(
|
@GetMapping(
|
||||||
value = "/events/{token}",
|
value = "/events/{eventToken}",
|
||||||
produces = MediaType.TEXT_HTML_VALUE
|
produces = MediaType.TEXT_HTML_VALUE
|
||||||
)
|
)
|
||||||
@ResponseBody
|
@ResponseBody
|
||||||
public String serveEventPage(@PathVariable String token,
|
public String serveEventPage(@PathVariable String eventToken,
|
||||||
HttpServletRequest request) {
|
HttpServletRequest request) {
|
||||||
if (htmlTemplate == null) {
|
if (htmlTemplate == null) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
String baseUrl = getBaseUrl(request);
|
String baseUrl = getBaseUrl(request);
|
||||||
Map<String, String> meta = resolveEventMeta(token, baseUrl);
|
Map<String, String> meta = resolveEventMeta(eventToken, baseUrl);
|
||||||
return htmlTemplate.replace(PLACEHOLDER, renderTags(meta));
|
return htmlTemplate.replace(PLACEHOLDER, renderTags(meta));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,11 +86,11 @@ public class SpaController {
|
|||||||
|
|
||||||
private Map<String, String> buildEventMeta(Event event, String baseUrl) {
|
private Map<String, String> buildEventMeta(Event event, String baseUrl) {
|
||||||
var tags = new LinkedHashMap<String, String>();
|
var tags = new LinkedHashMap<String, String>();
|
||||||
String title = truncateTitle(event.getTitle());
|
String title = truncateTitle(event.title());
|
||||||
String description = formatDescription(event);
|
String description = formatDescription(event);
|
||||||
tags.put("og:title", title);
|
tags.put("og:title", title);
|
||||||
tags.put("og:description", description);
|
tags.put("og:description", description);
|
||||||
tags.put("og:url", baseUrl + "/events/" + event.getEventToken().value());
|
tags.put("og:url", baseUrl + "/events/" + event.eventToken().value());
|
||||||
tags.put("og:type", "website");
|
tags.put("og:type", "website");
|
||||||
tags.put("og:site_name", GENERIC_TITLE);
|
tags.put("og:site_name", GENERIC_TITLE);
|
||||||
tags.put("og:image", baseUrl + "/og-image.png");
|
tags.put("og:image", baseUrl + "/og-image.png");
|
||||||
@@ -138,16 +138,16 @@ public class SpaController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String formatDescription(Event event) {
|
private String formatDescription(Event event) {
|
||||||
ZonedDateTime zoned = event.getDateTime().atZoneSameInstant(event.getTimezone());
|
ZonedDateTime zoned = event.dateTime().atZoneSameInstant(event.timezone());
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
sb.append("📅 ").append(zoned.format(DATE_FORMAT));
|
sb.append("📅 ").append(zoned.format(DATE_FORMAT));
|
||||||
|
|
||||||
if (event.getLocation() != null && !event.getLocation().isBlank()) {
|
if (event.location() != null && !event.location().isBlank()) {
|
||||||
sb.append(" · 📍 ").append(event.getLocation());
|
sb.append(" · 📍 ").append(event.location());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.getDescription() != null && !event.getDescription().isBlank()) {
|
if (event.description() != null && !event.description().isBlank()) {
|
||||||
sb.append(" — ").append(event.getDescription());
|
sb.append(" — ").append(event.description());
|
||||||
}
|
}
|
||||||
|
|
||||||
String result = sb.toString();
|
String result = sb.toString();
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ public class EventJpaEntity {
|
|||||||
@Column(name = "created_at", nullable = false)
|
@Column(name = "created_at", nullable = false)
|
||||||
private OffsetDateTime createdAt;
|
private OffsetDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "cancelled", nullable = false)
|
||||||
|
private boolean cancelled;
|
||||||
|
|
||||||
|
@Column(name = "cancellation_reason", length = 2000)
|
||||||
|
private String cancellationReason;
|
||||||
|
|
||||||
/** Returns the internal database ID. */
|
/** Returns the internal database ID. */
|
||||||
public Long getId() {
|
public Long getId() {
|
||||||
return id;
|
return id;
|
||||||
@@ -145,4 +151,24 @@ public class EventJpaEntity {
|
|||||||
public void setCreatedAt(OffsetDateTime createdAt) {
|
public void setCreatedAt(OffsetDateTime createdAt) {
|
||||||
this.createdAt = createdAt;
|
this.createdAt = createdAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns whether the event is cancelled. */
|
||||||
|
public boolean isCancelled() {
|
||||||
|
return cancelled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the cancelled flag. */
|
||||||
|
public void setCancelled(boolean cancelled) {
|
||||||
|
this.cancelled = cancelled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the cancellation reason. */
|
||||||
|
public String getCancellationReason() {
|
||||||
|
return cancellationReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the cancellation reason. */
|
||||||
|
public void setCancellationReason(String cancellationReason) {
|
||||||
|
this.cancellationReason = cancellationReason;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,31 +38,34 @@ public class EventPersistenceAdapter implements EventRepository {
|
|||||||
|
|
||||||
private EventJpaEntity toEntity(Event event) {
|
private EventJpaEntity toEntity(Event event) {
|
||||||
var entity = new EventJpaEntity();
|
var entity = new EventJpaEntity();
|
||||||
entity.setId(event.getId());
|
entity.setId(event.id());
|
||||||
entity.setEventToken(event.getEventToken().value());
|
entity.setEventToken(event.eventToken().value());
|
||||||
entity.setOrganizerToken(event.getOrganizerToken().value());
|
entity.setOrganizerToken(event.organizerToken().value());
|
||||||
entity.setTitle(event.getTitle());
|
entity.setTitle(event.title());
|
||||||
entity.setDescription(event.getDescription());
|
entity.setDescription(event.description());
|
||||||
entity.setDateTime(event.getDateTime());
|
entity.setDateTime(event.dateTime());
|
||||||
entity.setTimezone(event.getTimezone().getId());
|
entity.setTimezone(event.timezone().getId());
|
||||||
entity.setLocation(event.getLocation());
|
entity.setLocation(event.location());
|
||||||
entity.setExpiryDate(event.getExpiryDate());
|
entity.setExpiryDate(event.expiryDate());
|
||||||
entity.setCreatedAt(event.getCreatedAt());
|
entity.setCreatedAt(event.createdAt());
|
||||||
|
entity.setCancelled(event.cancelled());
|
||||||
|
entity.setCancellationReason(event.cancellationReason());
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Event toDomain(EventJpaEntity entity) {
|
private Event toDomain(EventJpaEntity entity) {
|
||||||
var event = new Event();
|
return new Event(
|
||||||
event.setId(entity.getId());
|
entity.getId(),
|
||||||
event.setEventToken(new EventToken(entity.getEventToken()));
|
new EventToken(entity.getEventToken()),
|
||||||
event.setOrganizerToken(new OrganizerToken(entity.getOrganizerToken()));
|
new OrganizerToken(entity.getOrganizerToken()),
|
||||||
event.setTitle(entity.getTitle());
|
entity.getTitle(),
|
||||||
event.setDescription(entity.getDescription());
|
entity.getDescription(),
|
||||||
event.setDateTime(entity.getDateTime());
|
entity.getDateTime(),
|
||||||
event.setTimezone(ZoneId.of(entity.getTimezone()));
|
ZoneId.of(entity.getTimezone()),
|
||||||
event.setLocation(entity.getLocation());
|
entity.getLocation(),
|
||||||
event.setExpiryDate(entity.getExpiryDate());
|
entity.getExpiryDate(),
|
||||||
event.setCreatedAt(entity.getCreatedAt());
|
entity.getCreatedAt(),
|
||||||
return event;
|
entity.isCancelled(),
|
||||||
|
entity.getCancellationReason());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,19 +43,18 @@ public class RsvpPersistenceAdapter implements RsvpRepository {
|
|||||||
|
|
||||||
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.id());
|
||||||
entity.setRsvpToken(rsvp.getRsvpToken().value());
|
entity.setRsvpToken(rsvp.rsvpToken().value());
|
||||||
entity.setEventId(rsvp.getEventId());
|
entity.setEventId(rsvp.eventId());
|
||||||
entity.setName(rsvp.getName());
|
entity.setName(rsvp.name());
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Rsvp toDomain(RsvpJpaEntity entity) {
|
private Rsvp toDomain(RsvpJpaEntity entity) {
|
||||||
var rsvp = new Rsvp();
|
return new Rsvp(
|
||||||
rsvp.setId(entity.getId());
|
entity.getId(),
|
||||||
rsvp.setRsvpToken(new RsvpToken(entity.getRsvpToken()));
|
new RsvpToken(entity.getRsvpToken()),
|
||||||
rsvp.setEventId(entity.getEventId());
|
entity.getEventId(),
|
||||||
rsvp.setName(entity.getName());
|
entity.getName());
|
||||||
return rsvp;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,26 @@
|
|||||||
package de.fete.application.service;
|
package de.fete.application.service;
|
||||||
|
|
||||||
|
import de.fete.application.service.exception.EventAlreadyCancelledException;
|
||||||
|
import de.fete.application.service.exception.EventNotFoundException;
|
||||||
|
import de.fete.application.service.exception.InvalidOrganizerTokenException;
|
||||||
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.OrganizerToken;
|
||||||
import de.fete.domain.port.in.CreateEventUseCase;
|
import de.fete.domain.port.in.CreateEventUseCase;
|
||||||
import de.fete.domain.port.in.GetEventUseCase;
|
import de.fete.domain.port.in.GetEventUseCase;
|
||||||
|
import de.fete.domain.port.in.UpdateEventUseCase;
|
||||||
import de.fete.domain.port.out.EventRepository;
|
import de.fete.domain.port.out.EventRepository;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
/** Application service implementing event creation and retrieval. */
|
/** Application service implementing event creation and retrieval. */
|
||||||
@Service
|
@Service
|
||||||
public class EventService implements CreateEventUseCase, GetEventUseCase {
|
public class EventService implements CreateEventUseCase, GetEventUseCase, UpdateEventUseCase {
|
||||||
|
|
||||||
private static final int EXPIRY_DAYS_AFTER_EVENT = 7;
|
private static final int EXPIRY_DAYS_AFTER_EVENT = 7;
|
||||||
|
|
||||||
@@ -32,16 +37,19 @@ public class EventService implements CreateEventUseCase, GetEventUseCase {
|
|||||||
public Event createEvent(CreateEventCommand command) {
|
public Event createEvent(CreateEventCommand command) {
|
||||||
LocalDate expiryDate = command.dateTime().toLocalDate().plusDays(EXPIRY_DAYS_AFTER_EVENT);
|
LocalDate expiryDate = command.dateTime().toLocalDate().plusDays(EXPIRY_DAYS_AFTER_EVENT);
|
||||||
|
|
||||||
var event = new Event();
|
var event = new Event(
|
||||||
event.setEventToken(EventToken.generate());
|
null,
|
||||||
event.setOrganizerToken(OrganizerToken.generate());
|
EventToken.generate(),
|
||||||
event.setTitle(command.title());
|
OrganizerToken.generate(),
|
||||||
event.setDescription(command.description());
|
command.title(),
|
||||||
event.setDateTime(command.dateTime());
|
command.description(),
|
||||||
event.setTimezone(command.timezone());
|
command.dateTime(),
|
||||||
event.setLocation(command.location());
|
command.timezone(),
|
||||||
event.setExpiryDate(expiryDate);
|
command.location(),
|
||||||
event.setCreatedAt(OffsetDateTime.now(clock));
|
expiryDate,
|
||||||
|
OffsetDateTime.now(clock),
|
||||||
|
false,
|
||||||
|
null);
|
||||||
|
|
||||||
return eventRepository.save(event);
|
return eventRepository.save(event);
|
||||||
}
|
}
|
||||||
@@ -50,4 +58,27 @@ public class EventService implements CreateEventUseCase, GetEventUseCase {
|
|||||||
public Optional<Event> getByEventToken(EventToken eventToken) {
|
public Optional<Event> getByEventToken(EventToken eventToken) {
|
||||||
return eventRepository.findByEventToken(eventToken);
|
return eventRepository.findByEventToken(eventToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@Override
|
||||||
|
public void cancelEvent(
|
||||||
|
EventToken eventToken, OrganizerToken organizerToken,
|
||||||
|
Boolean cancelled, String reason) {
|
||||||
|
if (!Boolean.TRUE.equals(cancelled)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Event event = eventRepository.findByEventToken(eventToken)
|
||||||
|
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
|
||||||
|
|
||||||
|
if (!event.organizerToken().equals(organizerToken)) {
|
||||||
|
throw new InvalidOrganizerTokenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.cancelled()) {
|
||||||
|
throw new EventAlreadyCancelledException(eventToken.value());
|
||||||
|
}
|
||||||
|
|
||||||
|
eventRepository.save(event.withCancellation(true, reason));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
package de.fete.application.service;
|
package de.fete.application.service;
|
||||||
|
|
||||||
|
import de.fete.application.service.exception.EventCancelledException;
|
||||||
|
import de.fete.application.service.exception.EventExpiredException;
|
||||||
|
import de.fete.application.service.exception.EventNotFoundException;
|
||||||
|
import de.fete.application.service.exception.InvalidOrganizerTokenException;
|
||||||
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.OrganizerToken;
|
||||||
@@ -42,14 +46,15 @@ public class RsvpService
|
|||||||
Event event = eventRepository.findByEventToken(eventToken)
|
Event event = eventRepository.findByEventToken(eventToken)
|
||||||
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
|
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
|
||||||
|
|
||||||
if (!event.getExpiryDate().isAfter(LocalDate.now(clock))) {
|
if (event.cancelled()) {
|
||||||
|
throw new EventCancelledException(eventToken.value());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event.expiryDate().isAfter(LocalDate.now(clock))) {
|
||||||
throw new EventExpiredException(eventToken.value());
|
throw new EventExpiredException(eventToken.value());
|
||||||
}
|
}
|
||||||
|
|
||||||
var rsvp = new Rsvp();
|
var rsvp = new Rsvp(null, RsvpToken.generate(), event.id(), name.strip());
|
||||||
rsvp.setRsvpToken(RsvpToken.generate());
|
|
||||||
rsvp.setEventId(event.getId());
|
|
||||||
rsvp.setName(name.strip());
|
|
||||||
|
|
||||||
return rsvpRepository.save(rsvp);
|
return rsvpRepository.save(rsvp);
|
||||||
}
|
}
|
||||||
@@ -59,14 +64,14 @@ public class RsvpService
|
|||||||
public void cancelRsvp(EventToken eventToken, RsvpToken rsvpToken) {
|
public void cancelRsvp(EventToken eventToken, RsvpToken rsvpToken) {
|
||||||
eventRepository.findByEventToken(eventToken)
|
eventRepository.findByEventToken(eventToken)
|
||||||
.ifPresent(event ->
|
.ifPresent(event ->
|
||||||
rsvpRepository.deleteByEventIdAndRsvpToken(event.getId(), rsvpToken));
|
rsvpRepository.deleteByEventIdAndRsvpToken(event.id(), rsvpToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public long countByEvent(EventToken eventToken) {
|
public long countByEvent(EventToken eventToken) {
|
||||||
Event event = eventRepository.findByEventToken(eventToken)
|
Event event = eventRepository.findByEventToken(eventToken)
|
||||||
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
|
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
|
||||||
return rsvpRepository.countByEventId(event.getId());
|
return rsvpRepository.countByEventId(event.id());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -74,12 +79,12 @@ public class RsvpService
|
|||||||
Event event = eventRepository.findByEventToken(eventToken)
|
Event event = eventRepository.findByEventToken(eventToken)
|
||||||
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
|
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
|
||||||
|
|
||||||
if (!event.getOrganizerToken().equals(organizerToken)) {
|
if (!event.organizerToken().equals(organizerToken)) {
|
||||||
throw new InvalidOrganizerTokenException();
|
throw new InvalidOrganizerTokenException();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rsvpRepository.findByEventId(event.getId()).stream()
|
return rsvpRepository.findByEventId(event.id()).stream()
|
||||||
.map(Rsvp::getName)
|
.map(Rsvp::name)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package de.fete.application.service.exception;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** Thrown when attempting to cancel an event that is already cancelled. */
|
||||||
|
public class EventAlreadyCancelledException extends RuntimeException {
|
||||||
|
|
||||||
|
/** Creates a new exception for the given event token. */
|
||||||
|
public EventAlreadyCancelledException(UUID eventToken) {
|
||||||
|
super("Event is already cancelled: " + eventToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package de.fete.application.service.exception;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** Thrown when an RSVP is attempted on a cancelled event. */
|
||||||
|
public class EventCancelledException extends RuntimeException {
|
||||||
|
|
||||||
|
/** Creates a new exception for the given event token. */
|
||||||
|
public EventCancelledException(UUID eventToken) {
|
||||||
|
super("Event is cancelled: " + eventToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.fete.application.service;
|
package de.fete.application.service.exception;
|
||||||
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.fete.application.service;
|
package de.fete.application.service.exception;
|
||||||
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.fete.application.service;
|
package de.fete.application.service.exception;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.fete.application.service;
|
package de.fete.application.service.exception;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.fete.application.service;
|
package de.fete.application.service.exception;
|
||||||
|
|
||||||
/** Thrown when an invalid organizer token is provided. */
|
/** Thrown when an invalid organizer token is provided. */
|
||||||
public class InvalidOrganizerTokenException extends RuntimeException {
|
public class InvalidOrganizerTokenException extends RuntimeException {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.fete.application.service;
|
package de.fete.application.service.exception;
|
||||||
|
|
||||||
/** Thrown when an invalid IANA timezone ID is provided. */
|
/** Thrown when an invalid IANA timezone ID is provided. */
|
||||||
public class InvalidTimezoneException extends RuntimeException {
|
public class InvalidTimezoneException extends RuntimeException {
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/**
|
||||||
|
* Application-layer exceptions thrown by service use case implementations.
|
||||||
|
*/
|
||||||
|
package de.fete.application.service.exception;
|
||||||
@@ -5,116 +5,26 @@ import java.time.OffsetDateTime;
|
|||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
|
|
||||||
/** Domain entity representing an event. */
|
/** Domain entity representing an event. */
|
||||||
public class Event {
|
public record Event(
|
||||||
|
Long id,
|
||||||
|
EventToken eventToken,
|
||||||
|
OrganizerToken organizerToken,
|
||||||
|
String title,
|
||||||
|
String description,
|
||||||
|
OffsetDateTime dateTime,
|
||||||
|
ZoneId timezone,
|
||||||
|
String location,
|
||||||
|
LocalDate expiryDate,
|
||||||
|
OffsetDateTime createdAt,
|
||||||
|
boolean cancelled,
|
||||||
|
String cancellationReason
|
||||||
|
) {
|
||||||
|
|
||||||
private Long id;
|
/** Returns a copy of this event with cancellation applied. */
|
||||||
private EventToken eventToken;
|
public Event withCancellation(boolean cancelled, String cancellationReason) {
|
||||||
private OrganizerToken organizerToken;
|
return new Event(
|
||||||
private String title;
|
id, eventToken, organizerToken, title, description,
|
||||||
private String description;
|
dateTime, timezone, location, expiryDate, createdAt,
|
||||||
private OffsetDateTime dateTime;
|
cancelled, cancellationReason);
|
||||||
private ZoneId timezone;
|
|
||||||
private String location;
|
|
||||||
private LocalDate expiryDate;
|
|
||||||
private OffsetDateTime createdAt;
|
|
||||||
|
|
||||||
/** 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 public event token. */
|
|
||||||
public EventToken getEventToken() {
|
|
||||||
return eventToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sets the public event token. */
|
|
||||||
public void setEventToken(EventToken eventToken) {
|
|
||||||
this.eventToken = eventToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns the secret organizer token. */
|
|
||||||
public OrganizerToken getOrganizerToken() {
|
|
||||||
return organizerToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sets the secret organizer token. */
|
|
||||||
public void setOrganizerToken(OrganizerToken organizerToken) {
|
|
||||||
this.organizerToken = organizerToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns the event title. */
|
|
||||||
public String getTitle() {
|
|
||||||
return title;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sets the event title. */
|
|
||||||
public void setTitle(String title) {
|
|
||||||
this.title = title;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns the event description. */
|
|
||||||
public String getDescription() {
|
|
||||||
return description;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sets the event description. */
|
|
||||||
public void setDescription(String description) {
|
|
||||||
this.description = description;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns the event date and time with UTC offset. */
|
|
||||||
public OffsetDateTime getDateTime() {
|
|
||||||
return dateTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sets the event date and time. */
|
|
||||||
public void setDateTime(OffsetDateTime dateTime) {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sets the event location. */
|
|
||||||
public void setLocation(String location) {
|
|
||||||
this.location = location;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns the expiry date after which event data is deleted. */
|
|
||||||
public LocalDate getExpiryDate() {
|
|
||||||
return expiryDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sets the expiry date. */
|
|
||||||
public void setExpiryDate(LocalDate expiryDate) {
|
|
||||||
this.expiryDate = expiryDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns the creation timestamp. */
|
|
||||||
public OffsetDateTime getCreatedAt() {
|
|
||||||
return createdAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sets the creation timestamp. */
|
|
||||||
public void setCreatedAt(OffsetDateTime createdAt) {
|
|
||||||
this.createdAt = createdAt;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,50 +1,9 @@
|
|||||||
package de.fete.domain.model;
|
package de.fete.domain.model;
|
||||||
|
|
||||||
/** Domain entity representing an RSVP. */
|
/** Domain entity representing an RSVP. */
|
||||||
public class Rsvp {
|
public record Rsvp(
|
||||||
|
Long id,
|
||||||
private Long id;
|
RsvpToken rsvpToken,
|
||||||
private RsvpToken rsvpToken;
|
Long eventId,
|
||||||
private Long eventId;
|
String name
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package de.fete.domain.port.in;
|
||||||
|
|
||||||
|
import de.fete.domain.model.EventToken;
|
||||||
|
import de.fete.domain.model.OrganizerToken;
|
||||||
|
|
||||||
|
/** Inbound port for updating an event. */
|
||||||
|
public interface UpdateEventUseCase {
|
||||||
|
|
||||||
|
/** Cancels the event identified by the given token. */
|
||||||
|
void cancelEvent(
|
||||||
|
EventToken eventToken, OrganizerToken organizerToken,
|
||||||
|
Boolean cancelled, String reason);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?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="004-add-cancellation-columns" author="fete">
|
||||||
|
<addColumn tableName="events">
|
||||||
|
<column name="cancelled" type="BOOLEAN" defaultValueBoolean="false">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="cancellation_reason" type="VARCHAR(2000)"/>
|
||||||
|
</addColumn>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
@@ -9,5 +9,6 @@
|
|||||||
<include file="db/changelog/001-create-events-table.xml"/>
|
<include file="db/changelog/001-create-events-table.xml"/>
|
||||||
<include file="db/changelog/002-add-timezone-column.xml"/>
|
<include file="db/changelog/002-add-timezone-column.xml"/>
|
||||||
<include file="db/changelog/003-create-rsvps-table.xml"/>
|
<include file="db/changelog/003-create-rsvps-table.xml"/>
|
||||||
|
<include file="db/changelog/004-add-cancellation-columns.xml"/>
|
||||||
|
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/ValidationProblemDetail"
|
$ref: "#/components/schemas/ValidationProblemDetail"
|
||||||
|
|
||||||
/events/{token}/rsvps/{rsvpToken}:
|
/events/{eventToken}/rsvps/{rsvpToken}:
|
||||||
delete:
|
delete:
|
||||||
operationId: cancelRsvp
|
operationId: cancelRsvp
|
||||||
summary: Cancel RSVP
|
summary: Cancel RSVP
|
||||||
@@ -47,7 +47,7 @@ paths:
|
|||||||
tags:
|
tags:
|
||||||
- events
|
- events
|
||||||
parameters:
|
parameters:
|
||||||
- name: token
|
- name: eventToken
|
||||||
in: path
|
in: path
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
@@ -69,14 +69,14 @@ paths:
|
|||||||
"500":
|
"500":
|
||||||
description: Internal server error
|
description: Internal server error
|
||||||
|
|
||||||
/events/{token}/rsvps:
|
/events/{eventToken}/rsvps:
|
||||||
post:
|
post:
|
||||||
operationId: createRsvp
|
operationId: createRsvp
|
||||||
summary: Submit an RSVP for an event
|
summary: Submit an RSVP for an event
|
||||||
tags:
|
tags:
|
||||||
- events
|
- events
|
||||||
parameters:
|
parameters:
|
||||||
- name: token
|
- name: eventToken
|
||||||
in: path
|
in: path
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
@@ -115,14 +115,14 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/ProblemDetail"
|
$ref: "#/components/schemas/ProblemDetail"
|
||||||
|
|
||||||
/events/{token}/attendees:
|
/events/{eventToken}/attendees:
|
||||||
get:
|
get:
|
||||||
operationId: getAttendees
|
operationId: getAttendees
|
||||||
summary: Get attendee list for an event (organizer only)
|
summary: Get attendee list for an event (organizer only)
|
||||||
tags:
|
tags:
|
||||||
- events
|
- events
|
||||||
parameters:
|
parameters:
|
||||||
- name: token
|
- name: eventToken
|
||||||
in: path
|
in: path
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
@@ -156,14 +156,14 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/ProblemDetail"
|
$ref: "#/components/schemas/ProblemDetail"
|
||||||
|
|
||||||
/events/{token}:
|
/events/{eventToken}:
|
||||||
get:
|
get:
|
||||||
operationId: getEvent
|
operationId: getEvent
|
||||||
summary: Get public event details by token
|
summary: Get public event details by token
|
||||||
tags:
|
tags:
|
||||||
- events
|
- events
|
||||||
parameters:
|
parameters:
|
||||||
- name: token
|
- name: eventToken
|
||||||
in: path
|
in: path
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
@@ -184,6 +184,58 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/ProblemDetail"
|
$ref: "#/components/schemas/ProblemDetail"
|
||||||
|
|
||||||
|
patch:
|
||||||
|
operationId: patchEvent
|
||||||
|
summary: Update an event (currently cancel)
|
||||||
|
description: |
|
||||||
|
Partial update of an event resource. Currently the only supported operation
|
||||||
|
is cancellation (setting cancelled to true). Requires the organizer token.
|
||||||
|
Cancellation is irreversible.
|
||||||
|
tags:
|
||||||
|
- events
|
||||||
|
parameters:
|
||||||
|
- name: eventToken
|
||||||
|
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
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/PatchEventRequest"
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Event updated successfully
|
||||||
|
"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"
|
||||||
|
"409":
|
||||||
|
description: Event is already cancelled
|
||||||
|
content:
|
||||||
|
application/problem+json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ProblemDetail"
|
||||||
|
|
||||||
components:
|
components:
|
||||||
schemas:
|
schemas:
|
||||||
CreateEventRequest:
|
CreateEventRequest:
|
||||||
@@ -252,6 +304,7 @@ components:
|
|||||||
- dateTime
|
- dateTime
|
||||||
- timezone
|
- timezone
|
||||||
- attendeeCount
|
- attendeeCount
|
||||||
|
- cancelled
|
||||||
properties:
|
properties:
|
||||||
eventToken:
|
eventToken:
|
||||||
type: string
|
type: string
|
||||||
@@ -284,6 +337,31 @@ components:
|
|||||||
minimum: 0
|
minimum: 0
|
||||||
description: Number of confirmed attendees (attending=true)
|
description: Number of confirmed attendees (attending=true)
|
||||||
example: 12
|
example: 12
|
||||||
|
cancelled:
|
||||||
|
type: boolean
|
||||||
|
description: Whether the event has been cancelled
|
||||||
|
example: false
|
||||||
|
cancellationReason:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- "null"
|
||||||
|
description: Reason for cancellation, if provided
|
||||||
|
example: null
|
||||||
|
|
||||||
|
PatchEventRequest:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- cancelled
|
||||||
|
properties:
|
||||||
|
cancelled:
|
||||||
|
type: boolean
|
||||||
|
description: Set to true to cancel the event (irreversible)
|
||||||
|
example: true
|
||||||
|
cancellationReason:
|
||||||
|
type: string
|
||||||
|
maxLength: 2000
|
||||||
|
description: Optional cancellation reason
|
||||||
|
example: "Unfortunately the venue is no longer available."
|
||||||
|
|
||||||
CreateRsvpRequest:
|
CreateRsvpRequest:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -4,10 +4,14 @@ import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
|
|||||||
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
|
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
|
||||||
import static com.tngtech.archunit.library.Architectures.onionArchitecture;
|
import static com.tngtech.archunit.library.Architectures.onionArchitecture;
|
||||||
|
|
||||||
|
import com.tngtech.archunit.core.domain.JavaClass;
|
||||||
import com.tngtech.archunit.core.importer.ImportOption;
|
import com.tngtech.archunit.core.importer.ImportOption;
|
||||||
import com.tngtech.archunit.junit.AnalyzeClasses;
|
import com.tngtech.archunit.junit.AnalyzeClasses;
|
||||||
import com.tngtech.archunit.junit.ArchTest;
|
import com.tngtech.archunit.junit.ArchTest;
|
||||||
|
import com.tngtech.archunit.lang.ArchCondition;
|
||||||
import com.tngtech.archunit.lang.ArchRule;
|
import com.tngtech.archunit.lang.ArchRule;
|
||||||
|
import com.tngtech.archunit.lang.ConditionEvents;
|
||||||
|
import com.tngtech.archunit.lang.SimpleConditionEvent;
|
||||||
|
|
||||||
@AnalyzeClasses(packages = "de.fete", importOptions = ImportOption.DoNotIncludeTests.class)
|
@AnalyzeClasses(packages = "de.fete", importOptions = ImportOption.DoNotIncludeTests.class)
|
||||||
class HexagonalArchitectureTest {
|
class HexagonalArchitectureTest {
|
||||||
@@ -65,4 +69,24 @@ class HexagonalArchitectureTest {
|
|||||||
static final ArchRule webAdapterMustNotDependOnOutboundPorts = noClasses()
|
static final ArchRule webAdapterMustNotDependOnOutboundPorts = noClasses()
|
||||||
.that().resideInAPackage("de.fete.adapter.in.web..")
|
.that().resideInAPackage("de.fete.adapter.in.web..")
|
||||||
.should().dependOnClassesThat().resideInAPackage("de.fete.domain.port.out..");
|
.should().dependOnClassesThat().resideInAPackage("de.fete.domain.port.out..");
|
||||||
|
|
||||||
|
@ArchTest
|
||||||
|
static final ArchRule domainModelsMustBeRecords = classes()
|
||||||
|
.that().resideInAPackage("de.fete.domain.model..")
|
||||||
|
.and().doNotHaveSimpleName("package-info")
|
||||||
|
.should(beRecords());
|
||||||
|
|
||||||
|
private static ArchCondition<JavaClass> beRecords() {
|
||||||
|
return new ArchCondition<>("be records") {
|
||||||
|
@Override
|
||||||
|
public void check(JavaClass javaClass,
|
||||||
|
ConditionEvents events) {
|
||||||
|
boolean isRecord = javaClass.reflect().isRecord();
|
||||||
|
if (!isRecord) {
|
||||||
|
events.add(SimpleConditionEvent.violated(javaClass,
|
||||||
|
javaClass.getFullName() + " is not a record"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package de.fete.adapter.in.web;
|
|||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
@@ -21,6 +22,7 @@ import de.fete.adapter.out.persistence.RsvpJpaRepository;
|
|||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.time.ZoneOffset;
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
@@ -431,6 +433,147 @@ class EventControllerIntegrationTest {
|
|||||||
.andExpect(jsonPath("$.attendeeCount").value(1));
|
.andExpect(jsonPath("$.attendeeCount").value(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Cancel Event tests ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelEventReturns204AndPersists() throws Exception {
|
||||||
|
EventJpaEntity event = seedEvent(
|
||||||
|
"Cancel Me", null, "Europe/Berlin", null, LocalDate.now().plusDays(30));
|
||||||
|
|
||||||
|
var body = Map.of(
|
||||||
|
"cancelled", true,
|
||||||
|
"cancellationReason", "Venue closed");
|
||||||
|
|
||||||
|
mockMvc.perform(patch("/api/events/" + event.getEventToken()
|
||||||
|
+ "?organizerToken=" + event.getOrganizerToken())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(body)))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
EventJpaEntity persisted = jpaRepository
|
||||||
|
.findByEventToken(event.getEventToken()).orElseThrow();
|
||||||
|
assertThat(persisted.isCancelled()).isTrue();
|
||||||
|
assertThat(persisted.getCancellationReason()).isEqualTo("Venue closed");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelEventWithoutReasonReturns204() throws Exception {
|
||||||
|
EventJpaEntity event = seedEvent(
|
||||||
|
"Cancel No Reason", null, "Europe/Berlin", null, LocalDate.now().plusDays(30));
|
||||||
|
|
||||||
|
var body = Map.of("cancelled", true);
|
||||||
|
|
||||||
|
mockMvc.perform(patch("/api/events/" + event.getEventToken()
|
||||||
|
+ "?organizerToken=" + event.getOrganizerToken())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(body)))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
EventJpaEntity persisted = jpaRepository
|
||||||
|
.findByEventToken(event.getEventToken()).orElseThrow();
|
||||||
|
assertThat(persisted.isCancelled()).isTrue();
|
||||||
|
assertThat(persisted.getCancellationReason()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelEventWithWrongOrganizerTokenReturns403() throws Exception {
|
||||||
|
EventJpaEntity event = seedEvent(
|
||||||
|
"Wrong Token", null, "Europe/Berlin", null, LocalDate.now().plusDays(30));
|
||||||
|
|
||||||
|
var body = Map.of("cancelled", true);
|
||||||
|
|
||||||
|
mockMvc.perform(patch("/api/events/" + event.getEventToken()
|
||||||
|
+ "?organizerToken=" + UUID.randomUUID())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(body)))
|
||||||
|
.andExpect(status().isForbidden())
|
||||||
|
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||||
|
.andExpect(jsonPath("$.type").value("urn:problem-type:invalid-organizer-token"));
|
||||||
|
|
||||||
|
assertThat(jpaRepository.findByEventToken(event.getEventToken())
|
||||||
|
.orElseThrow().isCancelled()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelEventNotFoundReturns404() throws Exception {
|
||||||
|
var body = Map.of("cancelled", true);
|
||||||
|
|
||||||
|
mockMvc.perform(patch("/api/events/" + UUID.randomUUID()
|
||||||
|
+ "?organizerToken=" + UUID.randomUUID())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(body)))
|
||||||
|
.andExpect(status().isNotFound())
|
||||||
|
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||||
|
.andExpect(jsonPath("$.type").value("urn:problem-type:event-not-found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelAlreadyCancelledEventReturns409() throws Exception {
|
||||||
|
EventJpaEntity event = seedCancelledEvent("Already Cancelled");
|
||||||
|
|
||||||
|
var body = Map.of("cancelled", true);
|
||||||
|
|
||||||
|
mockMvc.perform(patch("/api/events/" + event.getEventToken()
|
||||||
|
+ "?organizerToken=" + event.getOrganizerToken())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(body)))
|
||||||
|
.andExpect(status().isConflict())
|
||||||
|
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||||
|
.andExpect(jsonPath("$.type").value("urn:problem-type:event-already-cancelled"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getEventReturnsCancelledFields() throws Exception {
|
||||||
|
EventJpaEntity event = seedCancelledEvent("Weather Event");
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/events/" + event.getEventToken()))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.cancelled").value(true))
|
||||||
|
.andExpect(jsonPath("$.cancellationReason").value("Cancelled"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getEventReturnsNotCancelledByDefault() throws Exception {
|
||||||
|
EventJpaEntity event = seedEvent(
|
||||||
|
"Active Event", null, "Europe/Berlin", null, LocalDate.now().plusDays(30));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/events/" + event.getEventToken()))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.cancelled").value(false))
|
||||||
|
.andExpect(jsonPath("$.cancellationReason").doesNotExist());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createRsvpOnCancelledEventReturns409() throws Exception {
|
||||||
|
EventJpaEntity event = seedCancelledEvent("Cancelled RSVP");
|
||||||
|
long countBefore = rsvpJpaRepository.count();
|
||||||
|
|
||||||
|
var request = new CreateRsvpRequest().name("Late Guest");
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/events/" + event.getEventToken() + "/rsvps")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
|
.andExpect(status().isConflict())
|
||||||
|
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||||
|
.andExpect(jsonPath("$.type").value("urn:problem-type:event-cancelled"));
|
||||||
|
|
||||||
|
assertThat(rsvpJpaRepository.count()).isEqualTo(countBefore);
|
||||||
|
}
|
||||||
|
|
||||||
|
private EventJpaEntity seedCancelledEvent(String title) {
|
||||||
|
var entity = new EventJpaEntity();
|
||||||
|
entity.setEventToken(UUID.randomUUID());
|
||||||
|
entity.setOrganizerToken(UUID.randomUUID());
|
||||||
|
entity.setTitle(title);
|
||||||
|
entity.setDateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)));
|
||||||
|
entity.setTimezone("Europe/Berlin");
|
||||||
|
entity.setExpiryDate(LocalDate.now().plusDays(30));
|
||||||
|
entity.setCreatedAt(OffsetDateTime.now());
|
||||||
|
entity.setCancelled(true);
|
||||||
|
entity.setCancellationReason("Cancelled");
|
||||||
|
return jpaRepository.save(entity);
|
||||||
|
}
|
||||||
|
|
||||||
private UUID seedRsvpAndGetToken(EventJpaEntity event, String name) {
|
private UUID seedRsvpAndGetToken(EventJpaEntity event, String name) {
|
||||||
var rsvp = new RsvpJpaEntity();
|
var rsvp = new RsvpJpaEntity();
|
||||||
UUID token = UUID.randomUUID();
|
UUID token = UUID.randomUUID();
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ class EventPersistenceAdapterIntegrationTest {
|
|||||||
|
|
||||||
eventRepository.deleteExpired();
|
eventRepository.deleteExpired();
|
||||||
|
|
||||||
assertThat(eventRepository.findByEventToken(saved.getEventToken())).isPresent();
|
assertThat(eventRepository.findByEventToken(saved.eventToken())).isPresent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -52,7 +52,7 @@ class EventPersistenceAdapterIntegrationTest {
|
|||||||
|
|
||||||
eventRepository.deleteExpired();
|
eventRepository.deleteExpired();
|
||||||
|
|
||||||
assertThat(eventRepository.findByEventToken(saved.getEventToken())).isPresent();
|
assertThat(eventRepository.findByEventToken(saved.eventToken())).isPresent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -66,16 +66,18 @@ class EventPersistenceAdapterIntegrationTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Event buildEvent(String title, LocalDate expiryDate) {
|
private Event buildEvent(String title, LocalDate expiryDate) {
|
||||||
var event = new Event();
|
return new Event(
|
||||||
event.setEventToken(EventToken.generate());
|
null,
|
||||||
event.setOrganizerToken(OrganizerToken.generate());
|
EventToken.generate(),
|
||||||
event.setTitle(title);
|
OrganizerToken.generate(),
|
||||||
event.setDescription("Test description");
|
title,
|
||||||
event.setDateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)));
|
"Test description",
|
||||||
event.setTimezone(ZoneId.of("Europe/Berlin"));
|
OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)),
|
||||||
event.setLocation("Test Location");
|
ZoneId.of("Europe/Berlin"),
|
||||||
event.setExpiryDate(expiryDate);
|
"Test Location",
|
||||||
event.setCreatedAt(OffsetDateTime.now());
|
expiryDate,
|
||||||
return event;
|
OffsetDateTime.now(),
|
||||||
|
false,
|
||||||
|
null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ class EventPersistenceAdapterTest {
|
|||||||
|
|
||||||
Event saved = eventRepository.save(event);
|
Event saved = eventRepository.save(event);
|
||||||
|
|
||||||
assertThat(saved.getId()).isNotNull();
|
assertThat(saved.id()).isNotNull();
|
||||||
assertThat(saved.getTitle()).isEqualTo("Test Event");
|
assertThat(saved.title()).isEqualTo("Test Event");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -39,11 +39,11 @@ class EventPersistenceAdapterTest {
|
|||||||
Event event = buildEvent();
|
Event event = buildEvent();
|
||||||
Event saved = eventRepository.save(event);
|
Event saved = eventRepository.save(event);
|
||||||
|
|
||||||
Optional<Event> found = eventRepository.findByEventToken(saved.getEventToken());
|
Optional<Event> found = eventRepository.findByEventToken(saved.eventToken());
|
||||||
|
|
||||||
assertThat(found).isPresent();
|
assertThat(found).isPresent();
|
||||||
assertThat(found.get().getTitle()).isEqualTo("Test Event");
|
assertThat(found.get().title()).isEqualTo("Test Event");
|
||||||
assertThat(found.get().getId()).isEqualTo(saved.getId());
|
assertThat(found.get().id()).isEqualTo(saved.id());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -61,42 +61,47 @@ class EventPersistenceAdapterTest {
|
|||||||
OffsetDateTime createdAt =
|
OffsetDateTime createdAt =
|
||||||
OffsetDateTime.of(2026, 3, 4, 12, 0, 0, 0, ZoneOffset.UTC);
|
OffsetDateTime.of(2026, 3, 4, 12, 0, 0, 0, ZoneOffset.UTC);
|
||||||
|
|
||||||
var event = new Event();
|
var event = new Event(
|
||||||
event.setEventToken(EventToken.generate());
|
null,
|
||||||
event.setOrganizerToken(OrganizerToken.generate());
|
EventToken.generate(),
|
||||||
event.setTitle("Full Event");
|
OrganizerToken.generate(),
|
||||||
event.setDescription("A detailed description");
|
"Full Event",
|
||||||
event.setDateTime(dateTime);
|
"A detailed description",
|
||||||
event.setTimezone(ZoneId.of("Europe/Berlin"));
|
dateTime,
|
||||||
event.setLocation("Berlin, Germany");
|
ZoneId.of("Europe/Berlin"),
|
||||||
event.setExpiryDate(expiryDate);
|
"Berlin, Germany",
|
||||||
event.setCreatedAt(createdAt);
|
expiryDate,
|
||||||
|
createdAt,
|
||||||
|
false,
|
||||||
|
null);
|
||||||
|
|
||||||
Event saved = eventRepository.save(event);
|
Event saved = eventRepository.save(event);
|
||||||
Event found = eventRepository.findByEventToken(saved.getEventToken()).orElseThrow();
|
Event found = eventRepository.findByEventToken(saved.eventToken()).orElseThrow();
|
||||||
|
|
||||||
assertThat(found.getEventToken()).isEqualTo(event.getEventToken());
|
assertThat(found.eventToken()).isEqualTo(event.eventToken());
|
||||||
assertThat(found.getOrganizerToken()).isEqualTo(event.getOrganizerToken());
|
assertThat(found.organizerToken()).isEqualTo(event.organizerToken());
|
||||||
assertThat(found.getTitle()).isEqualTo("Full Event");
|
assertThat(found.title()).isEqualTo("Full Event");
|
||||||
assertThat(found.getDescription()).isEqualTo("A detailed description");
|
assertThat(found.description()).isEqualTo("A detailed description");
|
||||||
assertThat(found.getDateTime().toInstant()).isEqualTo(dateTime.toInstant());
|
assertThat(found.dateTime().toInstant()).isEqualTo(dateTime.toInstant());
|
||||||
assertThat(found.getTimezone()).isEqualTo(ZoneId.of("Europe/Berlin"));
|
assertThat(found.timezone()).isEqualTo(ZoneId.of("Europe/Berlin"));
|
||||||
assertThat(found.getLocation()).isEqualTo("Berlin, Germany");
|
assertThat(found.location()).isEqualTo("Berlin, Germany");
|
||||||
assertThat(found.getExpiryDate()).isEqualTo(expiryDate);
|
assertThat(found.expiryDate()).isEqualTo(expiryDate);
|
||||||
assertThat(found.getCreatedAt().toInstant()).isEqualTo(createdAt.toInstant());
|
assertThat(found.createdAt().toInstant()).isEqualTo(createdAt.toInstant());
|
||||||
}
|
}
|
||||||
|
|
||||||
private Event buildEvent() {
|
private Event buildEvent() {
|
||||||
var event = new Event();
|
return new Event(
|
||||||
event.setEventToken(EventToken.generate());
|
null,
|
||||||
event.setOrganizerToken(OrganizerToken.generate());
|
EventToken.generate(),
|
||||||
event.setTitle("Test Event");
|
OrganizerToken.generate(),
|
||||||
event.setDescription("Test description");
|
"Test Event",
|
||||||
event.setDateTime(OffsetDateTime.now().plusDays(7));
|
"Test description",
|
||||||
event.setTimezone(ZoneId.of("Europe/Berlin"));
|
OffsetDateTime.now().plusDays(7),
|
||||||
event.setLocation("Somewhere");
|
ZoneId.of("Europe/Berlin"),
|
||||||
event.setExpiryDate(LocalDate.now().plusDays(30));
|
"Somewhere",
|
||||||
event.setCreatedAt(OffsetDateTime.now());
|
LocalDate.now().plusDays(30),
|
||||||
return event;
|
OffsetDateTime.now(),
|
||||||
|
false,
|
||||||
|
null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
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.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import de.fete.application.service.exception.EventAlreadyCancelledException;
|
||||||
|
import de.fete.application.service.exception.EventNotFoundException;
|
||||||
|
import de.fete.application.service.exception.InvalidOrganizerTokenException;
|
||||||
|
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.Clock;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
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 EventServiceCancelTest {
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
private EventService eventService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
eventService = new EventService(eventRepository, FIXED_CLOCK);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelEventDelegatesToDomainAndSaves() {
|
||||||
|
EventToken eventToken = EventToken.generate();
|
||||||
|
OrganizerToken organizerToken = OrganizerToken.generate();
|
||||||
|
var event = new Event(null, eventToken, organizerToken, null, null, null, null, null, null,
|
||||||
|
null, false, null);
|
||||||
|
|
||||||
|
when(eventRepository.findByEventToken(eventToken))
|
||||||
|
.thenReturn(Optional.of(event));
|
||||||
|
when(eventRepository.save(any(Event.class)))
|
||||||
|
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
||||||
|
eventService.cancelEvent(eventToken, organizerToken, true, "Venue unavailable");
|
||||||
|
|
||||||
|
ArgumentCaptor<Event> captor = ArgumentCaptor.forClass(Event.class);
|
||||||
|
verify(eventRepository).save(captor.capture());
|
||||||
|
assertThat(captor.getValue().cancelled()).isTrue();
|
||||||
|
assertThat(captor.getValue().cancellationReason()).isEqualTo("Venue unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelEventWithNullReason() {
|
||||||
|
EventToken eventToken = EventToken.generate();
|
||||||
|
OrganizerToken organizerToken = OrganizerToken.generate();
|
||||||
|
var event = new Event(null, eventToken, organizerToken, null, null, null, null, null, null,
|
||||||
|
null, false, null);
|
||||||
|
|
||||||
|
when(eventRepository.findByEventToken(eventToken))
|
||||||
|
.thenReturn(Optional.of(event));
|
||||||
|
when(eventRepository.save(any(Event.class)))
|
||||||
|
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
||||||
|
eventService.cancelEvent(eventToken, organizerToken, true, null);
|
||||||
|
|
||||||
|
ArgumentCaptor<Event> captor = ArgumentCaptor.forClass(Event.class);
|
||||||
|
verify(eventRepository).save(captor.capture());
|
||||||
|
assertThat(captor.getValue().cancelled()).isTrue();
|
||||||
|
assertThat(captor.getValue().cancellationReason()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelEventThrows404WhenNotFound() {
|
||||||
|
EventToken eventToken = EventToken.generate();
|
||||||
|
OrganizerToken organizerToken = OrganizerToken.generate();
|
||||||
|
|
||||||
|
when(eventRepository.findByEventToken(eventToken))
|
||||||
|
.thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> eventService.cancelEvent(eventToken, organizerToken, true, null))
|
||||||
|
.isInstanceOf(EventNotFoundException.class);
|
||||||
|
|
||||||
|
verify(eventRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelEventThrows403WhenWrongOrganizerToken() {
|
||||||
|
EventToken eventToken = EventToken.generate();
|
||||||
|
OrganizerToken correctToken = OrganizerToken.generate();
|
||||||
|
var event = new Event(null, eventToken, correctToken, null, null, null, null, null, null,
|
||||||
|
null, false, null);
|
||||||
|
|
||||||
|
when(eventRepository.findByEventToken(eventToken))
|
||||||
|
.thenReturn(Optional.of(event));
|
||||||
|
|
||||||
|
final OrganizerToken wrongToken = OrganizerToken.generate();
|
||||||
|
assertThatThrownBy(() -> eventService.cancelEvent(eventToken, wrongToken, true, null))
|
||||||
|
.isInstanceOf(InvalidOrganizerTokenException.class);
|
||||||
|
|
||||||
|
verify(eventRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelEventThrows409WhenAlreadyCancelled() {
|
||||||
|
EventToken eventToken = EventToken.generate();
|
||||||
|
OrganizerToken organizerToken = OrganizerToken.generate();
|
||||||
|
var event = new Event(null, eventToken, organizerToken, null, null, null, null, null, null,
|
||||||
|
null, true, null);
|
||||||
|
|
||||||
|
when(eventRepository.findByEventToken(eventToken))
|
||||||
|
.thenReturn(Optional.of(event));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> eventService.cancelEvent(eventToken, organizerToken, true, null))
|
||||||
|
.isInstanceOf(EventAlreadyCancelledException.class);
|
||||||
|
|
||||||
|
verify(eventRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,13 +57,13 @@ class EventServiceTest {
|
|||||||
|
|
||||||
Event result = eventService.createEvent(command);
|
Event result = eventService.createEvent(command);
|
||||||
|
|
||||||
assertThat(result.getTitle()).isEqualTo("Birthday Party");
|
assertThat(result.title()).isEqualTo("Birthday Party");
|
||||||
assertThat(result.getDescription()).isEqualTo("Come celebrate!");
|
assertThat(result.description()).isEqualTo("Come celebrate!");
|
||||||
assertThat(result.getTimezone()).isEqualTo(ZONE);
|
assertThat(result.timezone()).isEqualTo(ZONE);
|
||||||
assertThat(result.getLocation()).isEqualTo("Berlin");
|
assertThat(result.location()).isEqualTo("Berlin");
|
||||||
assertThat(result.getEventToken()).isNotNull();
|
assertThat(result.eventToken()).isNotNull();
|
||||||
assertThat(result.getOrganizerToken()).isNotNull();
|
assertThat(result.organizerToken()).isNotNull();
|
||||||
assertThat(result.getCreatedAt()).isEqualTo(OffsetDateTime.ofInstant(FIXED_INSTANT, ZONE));
|
assertThat(result.createdAt()).isEqualTo(OffsetDateTime.ofInstant(FIXED_INSTANT, ZONE));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -80,7 +80,7 @@ class EventServiceTest {
|
|||||||
|
|
||||||
ArgumentCaptor<Event> captor = ArgumentCaptor.forClass(Event.class);
|
ArgumentCaptor<Event> captor = ArgumentCaptor.forClass(Event.class);
|
||||||
verify(eventRepository, times(1)).save(captor.capture());
|
verify(eventRepository, times(1)).save(captor.capture());
|
||||||
assertThat(captor.getValue().getTitle()).isEqualTo("Test");
|
assertThat(captor.getValue().title()).isEqualTo("Test");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -96,7 +96,7 @@ class EventServiceTest {
|
|||||||
|
|
||||||
Event result = eventService.createEvent(command);
|
Event result = eventService.createEvent(command);
|
||||||
|
|
||||||
assertThat(result.getExpiryDate()).isEqualTo(eventDate.plusDays(7));
|
assertThat(result.expiryDate()).isEqualTo(eventDate.plusDays(7));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- GetEventUseCase tests (T004) ---
|
// --- GetEventUseCase tests (T004) ---
|
||||||
@@ -104,16 +104,15 @@ class EventServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void getByEventTokenReturnsEvent() {
|
void getByEventTokenReturnsEvent() {
|
||||||
EventToken token = EventToken.generate();
|
EventToken token = EventToken.generate();
|
||||||
var event = new Event();
|
var event = new Event(null, token, null, "Found Event", null, null, null, null, null, null,
|
||||||
event.setEventToken(token);
|
false, null);
|
||||||
event.setTitle("Found Event");
|
|
||||||
when(eventRepository.findByEventToken(token))
|
when(eventRepository.findByEventToken(token))
|
||||||
.thenReturn(Optional.of(event));
|
.thenReturn(Optional.of(event));
|
||||||
|
|
||||||
Optional<Event> result = eventService.getByEventToken(token);
|
Optional<Event> result = eventService.getByEventToken(token);
|
||||||
|
|
||||||
assertThat(result).isPresent();
|
assertThat(result).isPresent();
|
||||||
assertThat(result.get().getTitle()).isEqualTo("Found Event");
|
assertThat(result.get().title()).isEqualTo("Found Event");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -142,6 +141,6 @@ class EventServiceTest {
|
|||||||
|
|
||||||
Event result = eventService.createEvent(command);
|
Event result = eventService.createEvent(command);
|
||||||
|
|
||||||
assertThat(result.getTimezone()).isEqualTo(ZoneId.of("America/New_York"));
|
assertThat(result.timezone()).isEqualTo(ZoneId.of("America/New_York"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import static org.mockito.ArgumentMatchers.any;
|
|||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import de.fete.application.service.exception.EventCancelledException;
|
||||||
|
import de.fete.application.service.exception.EventExpiredException;
|
||||||
|
import de.fete.application.service.exception.EventNotFoundException;
|
||||||
|
import de.fete.application.service.exception.InvalidOrganizerTokenException;
|
||||||
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.OrganizerToken;
|
||||||
@@ -51,23 +55,23 @@ class RsvpServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createRsvpSucceedsForActiveEvent() {
|
void createRsvpSucceedsForActiveEvent() {
|
||||||
Event event = buildActiveEvent();
|
Event event = buildActiveEvent(TODAY.plusDays(30));
|
||||||
EventToken token = event.getEventToken();
|
EventToken token = event.eventToken();
|
||||||
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
||||||
when(rsvpRepository.save(any(Rsvp.class)))
|
when(rsvpRepository.save(any(Rsvp.class)))
|
||||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
||||||
Rsvp result = rsvpService.createRsvp(token, "Max Mustermann");
|
Rsvp result = rsvpService.createRsvp(token, "Max Mustermann");
|
||||||
|
|
||||||
assertThat(result.getName()).isEqualTo("Max Mustermann");
|
assertThat(result.name()).isEqualTo("Max Mustermann");
|
||||||
assertThat(result.getRsvpToken()).isNotNull();
|
assertThat(result.rsvpToken()).isNotNull();
|
||||||
assertThat(result.getEventId()).isEqualTo(event.getId());
|
assertThat(result.eventId()).isEqualTo(event.id());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createRsvpPersistsViaRepository() {
|
void createRsvpPersistsViaRepository() {
|
||||||
Event event = buildActiveEvent();
|
Event event = buildActiveEvent(TODAY.plusDays(30));
|
||||||
EventToken token = event.getEventToken();
|
EventToken token = event.eventToken();
|
||||||
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
||||||
when(rsvpRepository.save(any(Rsvp.class)))
|
when(rsvpRepository.save(any(Rsvp.class)))
|
||||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
@@ -76,8 +80,8 @@ class RsvpServiceTest {
|
|||||||
|
|
||||||
ArgumentCaptor<Rsvp> captor = ArgumentCaptor.forClass(Rsvp.class);
|
ArgumentCaptor<Rsvp> captor = ArgumentCaptor.forClass(Rsvp.class);
|
||||||
verify(rsvpRepository).save(captor.capture());
|
verify(rsvpRepository).save(captor.capture());
|
||||||
assertThat(captor.getValue().getName()).isEqualTo("Test Guest");
|
assertThat(captor.getValue().name()).isEqualTo("Test Guest");
|
||||||
assertThat(captor.getValue().getEventId()).isEqualTo(event.getId());
|
assertThat(captor.getValue().eventId()).isEqualTo(event.id());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -91,22 +95,21 @@ class RsvpServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createRsvpTrimsName() {
|
void createRsvpTrimsName() {
|
||||||
Event event = buildActiveEvent();
|
Event event = buildActiveEvent(TODAY.plusDays(30));
|
||||||
EventToken token = event.getEventToken();
|
EventToken token = event.eventToken();
|
||||||
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
||||||
when(rsvpRepository.save(any(Rsvp.class)))
|
when(rsvpRepository.save(any(Rsvp.class)))
|
||||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
||||||
Rsvp result = rsvpService.createRsvp(token, " Max ");
|
Rsvp result = rsvpService.createRsvp(token, " Max ");
|
||||||
|
|
||||||
assertThat(result.getName()).isEqualTo("Max");
|
assertThat(result.name()).isEqualTo("Max");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createRsvpThrowsWhenEventExpired() {
|
void createRsvpThrowsWhenEventExpired() {
|
||||||
var event = buildActiveEvent();
|
Event event = buildActiveEvent(TODAY.minusDays(1));
|
||||||
event.setExpiryDate(TODAY.minusDays(1));
|
EventToken token = event.eventToken();
|
||||||
EventToken token = event.getEventToken();
|
|
||||||
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
||||||
|
|
||||||
assertThatThrownBy(() -> rsvpService.createRsvp(token, "Late Guest"))
|
assertThatThrownBy(() -> rsvpService.createRsvp(token, "Late Guest"))
|
||||||
@@ -115,9 +118,8 @@ class RsvpServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createRsvpThrowsWhenEventExpiresToday() {
|
void createRsvpThrowsWhenEventExpiresToday() {
|
||||||
var event = buildActiveEvent();
|
Event event = buildActiveEvent(TODAY);
|
||||||
event.setExpiryDate(TODAY);
|
EventToken token = event.eventToken();
|
||||||
EventToken token = event.getEventToken();
|
|
||||||
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
||||||
|
|
||||||
assertThatThrownBy(() -> rsvpService.createRsvp(token, "Late Guest"))
|
assertThatThrownBy(() -> rsvpService.createRsvp(token, "Late Guest"))
|
||||||
@@ -126,12 +128,12 @@ class RsvpServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getAttendeeNamesReturnsNamesInOrder() {
|
void getAttendeeNamesReturnsNamesInOrder() {
|
||||||
Event event = buildActiveEvent();
|
Event event = buildActiveEvent(TODAY.plusDays(30));
|
||||||
EventToken token = event.getEventToken();
|
EventToken token = event.eventToken();
|
||||||
OrganizerToken orgToken = event.getOrganizerToken();
|
OrganizerToken orgToken = event.organizerToken();
|
||||||
when(eventRepository.findByEventToken(token))
|
when(eventRepository.findByEventToken(token))
|
||||||
.thenReturn(Optional.of(event));
|
.thenReturn(Optional.of(event));
|
||||||
when(rsvpRepository.findByEventId(event.getId()))
|
when(rsvpRepository.findByEventId(event.id()))
|
||||||
.thenReturn(List.of(
|
.thenReturn(List.of(
|
||||||
buildRsvp(1L, "Alice"),
|
buildRsvp(1L, "Alice"),
|
||||||
buildRsvp(2L, "Bob"),
|
buildRsvp(2L, "Bob"),
|
||||||
@@ -144,12 +146,12 @@ class RsvpServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getAttendeeNamesReturnsEmptyListWhenNoRsvps() {
|
void getAttendeeNamesReturnsEmptyListWhenNoRsvps() {
|
||||||
Event event = buildActiveEvent();
|
Event event = buildActiveEvent(TODAY.plusDays(30));
|
||||||
EventToken token = event.getEventToken();
|
EventToken token = event.eventToken();
|
||||||
OrganizerToken orgToken = event.getOrganizerToken();
|
OrganizerToken orgToken = event.organizerToken();
|
||||||
when(eventRepository.findByEventToken(token))
|
when(eventRepository.findByEventToken(token))
|
||||||
.thenReturn(Optional.of(event));
|
.thenReturn(Optional.of(event));
|
||||||
when(rsvpRepository.findByEventId(event.getId()))
|
when(rsvpRepository.findByEventId(event.id()))
|
||||||
.thenReturn(List.of());
|
.thenReturn(List.of());
|
||||||
|
|
||||||
List<String> names = rsvpService.getAttendeeNames(token, orgToken);
|
List<String> names = rsvpService.getAttendeeNames(token, orgToken);
|
||||||
@@ -171,8 +173,8 @@ class RsvpServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getAttendeeNamesThrowsWhenOrganizerTokenInvalid() {
|
void getAttendeeNamesThrowsWhenOrganizerTokenInvalid() {
|
||||||
Event event = buildActiveEvent();
|
Event event = buildActiveEvent(TODAY.plusDays(30));
|
||||||
EventToken token = event.getEventToken();
|
EventToken token = event.eventToken();
|
||||||
OrganizerToken wrongToken = OrganizerToken.generate();
|
OrganizerToken wrongToken = OrganizerToken.generate();
|
||||||
when(eventRepository.findByEventToken(token))
|
when(eventRepository.findByEventToken(token))
|
||||||
.thenReturn(Optional.of(event));
|
.thenReturn(Optional.of(event));
|
||||||
@@ -183,38 +185,33 @@ class RsvpServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Rsvp buildRsvp(Long id, String name) {
|
private Rsvp buildRsvp(Long id, String name) {
|
||||||
var rsvp = new Rsvp();
|
return new Rsvp(id, RsvpToken.generate(), 1L, name);
|
||||||
rsvp.setId(id);
|
|
||||||
rsvp.setRsvpToken(RsvpToken.generate());
|
|
||||||
rsvp.setEventId(1L);
|
|
||||||
rsvp.setName(name);
|
|
||||||
return rsvp;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void cancelRsvpDeletesWhenEventAndRsvpExist() {
|
void cancelRsvpDeletesWhenEventAndRsvpExist() {
|
||||||
Event event = buildActiveEvent();
|
Event event = buildActiveEvent(TODAY.plusDays(30));
|
||||||
EventToken token = event.getEventToken();
|
EventToken token = event.eventToken();
|
||||||
RsvpToken rsvpToken = RsvpToken.generate();
|
RsvpToken rsvpToken = RsvpToken.generate();
|
||||||
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
||||||
when(rsvpRepository.deleteByEventIdAndRsvpToken(event.getId(), rsvpToken)).thenReturn(true);
|
when(rsvpRepository.deleteByEventIdAndRsvpToken(event.id(), rsvpToken)).thenReturn(true);
|
||||||
|
|
||||||
rsvpService.cancelRsvp(token, rsvpToken);
|
rsvpService.cancelRsvp(token, rsvpToken);
|
||||||
|
|
||||||
verify(rsvpRepository).deleteByEventIdAndRsvpToken(event.getId(), rsvpToken);
|
verify(rsvpRepository).deleteByEventIdAndRsvpToken(event.id(), rsvpToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void cancelRsvpSucceedsWhenRsvpNotFound() {
|
void cancelRsvpSucceedsWhenRsvpNotFound() {
|
||||||
Event event = buildActiveEvent();
|
Event event = buildActiveEvent(TODAY.plusDays(30));
|
||||||
EventToken token = event.getEventToken();
|
EventToken token = event.eventToken();
|
||||||
RsvpToken rsvpToken = RsvpToken.generate();
|
RsvpToken rsvpToken = RsvpToken.generate();
|
||||||
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
||||||
when(rsvpRepository.deleteByEventIdAndRsvpToken(event.getId(), rsvpToken)).thenReturn(false);
|
when(rsvpRepository.deleteByEventIdAndRsvpToken(event.id(), rsvpToken)).thenReturn(false);
|
||||||
|
|
||||||
rsvpService.cancelRsvp(token, rsvpToken);
|
rsvpService.cancelRsvp(token, rsvpToken);
|
||||||
|
|
||||||
verify(rsvpRepository).deleteByEventIdAndRsvpToken(event.getId(), rsvpToken);
|
verify(rsvpRepository).deleteByEventIdAndRsvpToken(event.id(), rsvpToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -226,16 +223,19 @@ class RsvpServiceTest {
|
|||||||
rsvpService.cancelRsvp(token, rsvpToken);
|
rsvpService.cancelRsvp(token, rsvpToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Event buildActiveEvent() {
|
private Event buildActiveEvent(LocalDate expiryDate) {
|
||||||
var event = new Event();
|
return new Event(
|
||||||
event.setId(1L);
|
1L,
|
||||||
event.setEventToken(EventToken.generate());
|
EventToken.generate(),
|
||||||
event.setOrganizerToken(OrganizerToken.generate());
|
OrganizerToken.generate(),
|
||||||
event.setTitle("Test Event");
|
"Test Event",
|
||||||
event.setDateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)));
|
null,
|
||||||
event.setTimezone(ZONE);
|
OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)),
|
||||||
event.setExpiryDate(TODAY.plusDays(30));
|
ZONE,
|
||||||
event.setCreatedAt(OffsetDateTime.now());
|
null,
|
||||||
return event;
|
expiryDate,
|
||||||
|
OffsetDateTime.now(),
|
||||||
|
false,
|
||||||
|
null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
162
frontend/e2e/cancel-event.spec.ts
Normal file
162
frontend/e2e/cancel-event.spec.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { test, expect } from './msw-setup'
|
||||||
|
import type { StoredEvent } from '../src/composables/useEventStorage'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'fete:events'
|
||||||
|
|
||||||
|
const fullEvent = {
|
||||||
|
eventToken: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||||
|
title: 'Summer BBQ',
|
||||||
|
description: 'Bring your own drinks!',
|
||||||
|
dateTime: '2026-03-15T20:00:00+01:00',
|
||||||
|
timezone: 'Europe/Berlin',
|
||||||
|
location: 'Central Park, NYC',
|
||||||
|
attendeeCount: 12,
|
||||||
|
cancelled: false,
|
||||||
|
cancellationReason: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const organizerToken = '550e8400-e29b-41d4-a716-446655440001'
|
||||||
|
|
||||||
|
function seedEvents(events: StoredEvent[]): string {
|
||||||
|
return `window.localStorage.setItem('${STORAGE_KEY}', ${JSON.stringify(JSON.stringify(events))})`
|
||||||
|
}
|
||||||
|
|
||||||
|
function organizerSeed(): StoredEvent {
|
||||||
|
return {
|
||||||
|
eventToken: fullEvent.eventToken,
|
||||||
|
organizerToken,
|
||||||
|
title: fullEvent.title,
|
||||||
|
dateTime: fullEvent.dateTime,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('US1: Organizer cancels event with reason', () => {
|
||||||
|
test('organizer opens cancel bottom sheet, enters reason, confirms — event shows as cancelled on reload', async ({
|
||||||
|
page,
|
||||||
|
network,
|
||||||
|
}) => {
|
||||||
|
let cancelled = false
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => {
|
||||||
|
if (cancelled) {
|
||||||
|
return HttpResponse.json({
|
||||||
|
...fullEvent,
|
||||||
|
cancelled: true,
|
||||||
|
cancellationReason: 'Venue closed',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return HttpResponse.json(fullEvent)
|
||||||
|
}),
|
||||||
|
http.patch('*/api/events/:token', ({ request }) => {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const token = url.searchParams.get('organizerToken')
|
||||||
|
if (token === organizerToken) {
|
||||||
|
cancelled = true
|
||||||
|
return new HttpResponse(null, { status: 204 })
|
||||||
|
}
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ type: 'urn:problem-type:invalid-organizer-token', title: 'Forbidden', status: 403 },
|
||||||
|
{ status: 403 },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.addInitScript(seedEvents([organizerSeed()]))
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
|
||||||
|
// Cancel button visible for organizer
|
||||||
|
const cancelBtn = page.getByRole('button', { name: /Cancel event/i })
|
||||||
|
await expect(cancelBtn).toBeVisible()
|
||||||
|
|
||||||
|
// Open cancel bottom sheet
|
||||||
|
await cancelBtn.click()
|
||||||
|
|
||||||
|
// Fill in reason
|
||||||
|
const reasonField = page.getByLabel(/reason/i)
|
||||||
|
await expect(reasonField).toBeVisible()
|
||||||
|
await reasonField.fill('Venue closed')
|
||||||
|
|
||||||
|
// Confirm cancellation
|
||||||
|
await page.getByRole('button', { name: /Confirm cancellation/i }).click()
|
||||||
|
|
||||||
|
// Event should show as cancelled
|
||||||
|
await expect(page.getByText(/This event has been cancelled/i)).toBeVisible()
|
||||||
|
await expect(page.getByText('Venue closed')).toBeVisible()
|
||||||
|
|
||||||
|
// Cancel button should be gone
|
||||||
|
await expect(cancelBtn).not.toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('US1: Organizer cancels event without reason', () => {
|
||||||
|
test('organizer cancels without reason — event shows as cancelled', async ({
|
||||||
|
page,
|
||||||
|
network,
|
||||||
|
}) => {
|
||||||
|
let cancelled = false
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => {
|
||||||
|
if (cancelled) {
|
||||||
|
return HttpResponse.json({
|
||||||
|
...fullEvent,
|
||||||
|
cancelled: true,
|
||||||
|
cancellationReason: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return HttpResponse.json(fullEvent)
|
||||||
|
}),
|
||||||
|
http.patch('*/api/events/:token', ({ request }) => {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const token = url.searchParams.get('organizerToken')
|
||||||
|
if (token === organizerToken) {
|
||||||
|
cancelled = true
|
||||||
|
return new HttpResponse(null, { status: 204 })
|
||||||
|
}
|
||||||
|
return HttpResponse.json({}, { status: 403 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.addInitScript(seedEvents([organizerSeed()]))
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Cancel event/i }).click()
|
||||||
|
|
||||||
|
// Don't fill in reason, just confirm
|
||||||
|
await page.getByRole('button', { name: /Confirm cancellation/i }).click()
|
||||||
|
|
||||||
|
// Event should show as cancelled without reason text
|
||||||
|
await expect(page.getByText(/This event has been cancelled/i)).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('US1: Cancel API failure', () => {
|
||||||
|
test('cancel API fails — error displayed in bottom sheet, button re-enabled for retry', async ({
|
||||||
|
page,
|
||||||
|
network,
|
||||||
|
}) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||||
|
http.patch('*/api/events/:token', () => {
|
||||||
|
return HttpResponse.json(
|
||||||
|
{
|
||||||
|
type: 'about:blank',
|
||||||
|
title: 'Internal Server Error',
|
||||||
|
status: 500,
|
||||||
|
detail: 'Something went wrong',
|
||||||
|
},
|
||||||
|
{ status: 500, headers: { 'Content-Type': 'application/problem+json' } },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.addInitScript(seedEvents([organizerSeed()]))
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Cancel event/i }).click()
|
||||||
|
await page.getByRole('button', { name: /Confirm cancellation/i }).click()
|
||||||
|
|
||||||
|
// Error message in bottom sheet
|
||||||
|
await expect(page.getByText(/Could not cancel event/i)).toBeVisible()
|
||||||
|
|
||||||
|
// Confirm button should be re-enabled
|
||||||
|
await expect(page.getByRole('button', { name: /Confirm cancellation/i })).toBeEnabled()
|
||||||
|
})
|
||||||
|
})
|
||||||
74
frontend/e2e/cancelled-event-visitor.spec.ts
Normal file
74
frontend/e2e/cancelled-event-visitor.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { test, expect } from './msw-setup'
|
||||||
|
|
||||||
|
const cancelledEventWithReason = {
|
||||||
|
eventToken: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||||
|
title: 'Summer BBQ',
|
||||||
|
description: 'Bring your own drinks!',
|
||||||
|
dateTime: '2026-03-15T20:00:00+01:00',
|
||||||
|
timezone: 'Europe/Berlin',
|
||||||
|
location: 'Central Park, NYC',
|
||||||
|
attendeeCount: 12,
|
||||||
|
cancelled: true,
|
||||||
|
cancellationReason: 'Venue no longer available',
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelledEventWithoutReason = {
|
||||||
|
...cancelledEventWithReason,
|
||||||
|
cancellationReason: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('US2: Visitor sees cancelled event with reason', () => {
|
||||||
|
test('visitor sees red banner with cancellation reason on cancelled event', async ({
|
||||||
|
page,
|
||||||
|
network,
|
||||||
|
}) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => HttpResponse.json(cancelledEventWithReason)),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/events/${cancelledEventWithReason.eventToken}`)
|
||||||
|
|
||||||
|
await expect(page.getByText(/This event has been cancelled/i)).toBeVisible()
|
||||||
|
await expect(page.getByText('Venue no longer available')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('US2: Visitor sees cancelled event without reason', () => {
|
||||||
|
test('visitor sees red banner without reason when no reason was provided', async ({
|
||||||
|
page,
|
||||||
|
network,
|
||||||
|
}) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => HttpResponse.json(cancelledEventWithoutReason)),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/events/${cancelledEventWithoutReason.eventToken}`)
|
||||||
|
|
||||||
|
await expect(page.getByText(/This event has been cancelled/i)).toBeVisible()
|
||||||
|
// No reason text shown
|
||||||
|
await expect(page.getByText('Venue no longer available')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('US2: RSVP buttons hidden on cancelled event', () => {
|
||||||
|
test('RSVP buttons hidden on cancelled event, other details remain visible', async ({
|
||||||
|
page,
|
||||||
|
network,
|
||||||
|
}) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => HttpResponse.json(cancelledEventWithReason)),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/events/${cancelledEventWithReason.eventToken}`)
|
||||||
|
|
||||||
|
// Event details are still visible
|
||||||
|
await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible()
|
||||||
|
await expect(page.getByText('Bring your own drinks!')).toBeVisible()
|
||||||
|
await expect(page.getByText('Central Park, NYC')).toBeVisible()
|
||||||
|
await expect(page.getByText('12 going')).toBeVisible()
|
||||||
|
|
||||||
|
// RSVP bar is NOT visible
|
||||||
|
await expect(page.getByRole('button', { name: "I'm attending" })).not.toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
928
frontend/package-lock.json
generated
928
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -38,7 +38,7 @@
|
|||||||
"@vue/tsconfig": "^0.9.0",
|
"@vue/tsconfig": "^0.9.0",
|
||||||
"eslint": "^10.0.2",
|
"eslint": "^10.0.2",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-oxlint": "~1.54.0",
|
"eslint-plugin-oxlint": "~1.55.0",
|
||||||
"eslint-plugin-vue": "~10.8.0",
|
"eslint-plugin-vue": "~10.8.0",
|
||||||
"jiti": "^2.6.1",
|
"jiti": "^2.6.1",
|
||||||
"jsdom": "^28.1.0",
|
"jsdom": "^28.1.0",
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
"oxlint": "~1.55.0",
|
"oxlint": "~1.55.0",
|
||||||
"prettier": "3.8.1",
|
"prettier": "3.8.1",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"vite": "^7.3.1",
|
"vite": "^8.0.0",
|
||||||
"vite-plugin-vue-devtools": "^8.0.6",
|
"vite-plugin-vue-devtools": "^8.0.6",
|
||||||
"vitest": "^4.0.18",
|
"vitest": "^4.0.18",
|
||||||
"vue-tsc": "^3.2.5"
|
"vue-tsc": "^3.2.5"
|
||||||
|
|||||||
@@ -18,6 +18,14 @@
|
|||||||
--color-card: #ffffff;
|
--color-card: #ffffff;
|
||||||
--color-dark-base: #1B1730;
|
--color-dark-base: #1B1730;
|
||||||
|
|
||||||
|
/* Danger / destructive actions */
|
||||||
|
--color-danger: #fca5a5;
|
||||||
|
--color-danger-bg: rgba(220, 38, 38, 0.15);
|
||||||
|
--color-danger-bg-hover: rgba(220, 38, 38, 0.25);
|
||||||
|
--color-danger-bg-strong: rgba(220, 38, 38, 0.2);
|
||||||
|
--color-danger-border: rgba(220, 38, 38, 0.3);
|
||||||
|
--color-danger-border-strong: rgba(220, 38, 38, 0.4);
|
||||||
|
|
||||||
/* Glass system */
|
/* Glass system */
|
||||||
--color-glass: rgba(255, 255, 255, 0.1);
|
--color-glass: rgba(255, 255, 255, 0.1);
|
||||||
--color-glass-strong: rgba(255, 255, 255, 0.15);
|
--color-glass-strong: rgba(255, 255, 255, 0.15);
|
||||||
|
|||||||
@@ -76,10 +76,10 @@ async function confirmDelete() {
|
|||||||
|
|
||||||
if (rsvp) {
|
if (rsvp) {
|
||||||
try {
|
try {
|
||||||
const { response } = await api.DELETE('/events/{token}/rsvps/{rsvpToken}', {
|
const { response } = await api.DELETE('/events/{eventToken}/rsvps/{rsvpToken}', {
|
||||||
params: {
|
params: {
|
||||||
path: {
|
path: {
|
||||||
token: eventToken,
|
eventToken: eventToken,
|
||||||
rsvpToken: rsvp.rsvpToken,
|
rsvpToken: rsvp.rsvpToken,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,6 +25,12 @@
|
|||||||
|
|
||||||
<!-- Loaded state -->
|
<!-- Loaded state -->
|
||||||
<div v-else-if="state === 'loaded' && event" class="detail__content">
|
<div v-else-if="state === 'loaded' && event" class="detail__content">
|
||||||
|
<!-- Cancellation banner -->
|
||||||
|
<div v-if="event.cancelled" class="detail__cancelled-banner" role="alert">
|
||||||
|
<p class="detail__cancelled-banner-title">This event has been cancelled</p>
|
||||||
|
<p v-if="event.cancellationReason" class="detail__cancelled-banner-reason">{{ event.cancellationReason }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h1 class="detail__title">{{ event.title }}</h1>
|
<h1 class="detail__title">{{ event.title }}</h1>
|
||||||
|
|
||||||
<dl class="detail__meta">
|
<dl class="detail__meta">
|
||||||
@@ -70,14 +76,49 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Cancel error message -->
|
<!-- Cancel event button (organizer only, not already cancelled) -->
|
||||||
|
<div v-if="state === 'loaded' && event && isOrganizer && !event.cancelled" class="detail__cancel-event">
|
||||||
|
<button class="detail__cancel-event-btn" type="button" @click="cancelSheetOpen = true">
|
||||||
|
Cancel event
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cancel event bottom sheet -->
|
||||||
|
<BottomSheet :open="cancelSheetOpen" label="Cancel event" @close="cancelSheetOpen = false">
|
||||||
|
<h2 class="sheet-title">Cancel event</h2>
|
||||||
|
<form class="cancel-form" @submit.prevent="handleCancelEvent" novalidate>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="cancel-form__label" for="cancel-reason">Reason (optional)</label>
|
||||||
|
<textarea
|
||||||
|
id="cancel-reason"
|
||||||
|
v-model.trim="cancelReasonInput"
|
||||||
|
class="form-field glass cancel-form__textarea"
|
||||||
|
placeholder="e.g. Venue no longer available"
|
||||||
|
maxlength="2000"
|
||||||
|
rows="3"
|
||||||
|
@input="cancelEventError = ''"
|
||||||
|
/>
|
||||||
|
<span class="cancel-form__counter">{{ cancelReasonInput.length }} / 2000</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="cancel-form__confirm glass-inner"
|
||||||
|
type="submit"
|
||||||
|
:disabled="cancellingEvent"
|
||||||
|
>
|
||||||
|
{{ cancellingEvent ? 'Cancelling…' : 'Confirm cancellation' }}
|
||||||
|
</button>
|
||||||
|
<p v-if="cancelEventError" class="cancel-form__error" role="alert">{{ cancelEventError }}</p>
|
||||||
|
</form>
|
||||||
|
</BottomSheet>
|
||||||
|
|
||||||
|
<!-- Cancel RSVP error message -->
|
||||||
<div v-if="cancelError" class="detail__cancel-error" role="alert">
|
<div v-if="cancelError" class="detail__cancel-error" role="alert">
|
||||||
<p>{{ cancelError }}</p>
|
<p>{{ cancelError }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- RSVP bar -->
|
<!-- RSVP bar (hidden when cancelled) -->
|
||||||
<RsvpBar
|
<RsvpBar
|
||||||
v-if="state === 'loaded' && event && !isOrganizer"
|
v-if="state === 'loaded' && event && !isOrganizer && !event.cancelled"
|
||||||
:has-rsvp="!!rsvpName"
|
:has-rsvp="!!rsvpName"
|
||||||
@open="sheetOpen = true"
|
@open="sheetOpen = true"
|
||||||
@cancel="confirmCancelOpen = true"
|
@cancel="confirmCancelOpen = true"
|
||||||
@@ -155,6 +196,12 @@ const cancelError = ref('')
|
|||||||
const isOrganizer = ref(false)
|
const isOrganizer = ref(false)
|
||||||
const attendeeNames = ref<string[] | null>(null)
|
const attendeeNames = ref<string[] | null>(null)
|
||||||
|
|
||||||
|
// Cancel event state
|
||||||
|
const cancelSheetOpen = ref(false)
|
||||||
|
const cancelReasonInput = ref('')
|
||||||
|
const cancelEventError = ref('')
|
||||||
|
const cancellingEvent = ref(false)
|
||||||
|
|
||||||
const formattedDateTime = computed(() => {
|
const formattedDateTime = computed(() => {
|
||||||
if (!event.value) return ''
|
if (!event.value) return ''
|
||||||
const formatted = new Intl.DateTimeFormat(undefined, {
|
const formatted = new Intl.DateTimeFormat(undefined, {
|
||||||
@@ -169,8 +216,8 @@ async function fetchEvent() {
|
|||||||
event.value = null
|
event.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data, error, response } = await api.GET('/events/{token}', {
|
const { data, error, response } = await api.GET('/events/{eventToken}', {
|
||||||
params: { path: { token: route.params.eventToken as string } },
|
params: { path: { eventToken: route.params.eventToken as string } },
|
||||||
})
|
})
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -217,8 +264,8 @@ async function submitRsvp() {
|
|||||||
submitting.value = true
|
submitting.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data, error } = await api.POST('/events/{token}/rsvps', {
|
const { data, error } = await api.POST('/events/{eventToken}/rsvps', {
|
||||||
params: { path: { token: route.params.eventToken as string } },
|
params: { path: { eventToken: route.params.eventToken as string } },
|
||||||
body: { name: nameInput.value },
|
body: { name: nameInput.value },
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -256,10 +303,10 @@ async function handleCancelRsvp() {
|
|||||||
if (!stored) return
|
if (!stored) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { response } = await api.DELETE('/events/{token}/rsvps/{rsvpToken}', {
|
const { response } = await api.DELETE('/events/{eventToken}/rsvps/{rsvpToken}', {
|
||||||
params: {
|
params: {
|
||||||
path: {
|
path: {
|
||||||
token: route.params.eventToken as string,
|
eventToken: route.params.eventToken as string,
|
||||||
rsvpToken: stored.rsvpToken,
|
rsvpToken: stored.rsvpToken,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -279,11 +326,45 @@ async function handleCancelRsvp() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCancelEvent() {
|
||||||
|
cancelEventError.value = ''
|
||||||
|
cancellingEvent.value = true
|
||||||
|
|
||||||
|
const orgToken = getOrganizerToken(route.params.eventToken as string)
|
||||||
|
if (!orgToken) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { error } = await api.PATCH('/events/{eventToken}', {
|
||||||
|
params: {
|
||||||
|
path: { eventToken: route.params.eventToken as string },
|
||||||
|
query: { organizerToken: orgToken },
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
cancelled: true,
|
||||||
|
cancellationReason: cancelReasonInput.value || undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
cancelEventError.value = 'Could not cancel event. Please try again.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelSheetOpen.value = false
|
||||||
|
cancelReasonInput.value = ''
|
||||||
|
await fetchEvent()
|
||||||
|
} catch {
|
||||||
|
cancelEventError.value = 'Could not cancel event. Please try again.'
|
||||||
|
} finally {
|
||||||
|
cancellingEvent.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchAttendees(eventToken: string, organizerToken: string) {
|
async function fetchAttendees(eventToken: string, organizerToken: string) {
|
||||||
try {
|
try {
|
||||||
const { data, error } = await api.GET('/events/{token}/attendees', {
|
const { data, error } = await api.GET('/events/{eventToken}/attendees', {
|
||||||
params: {
|
params: {
|
||||||
path: { token: eventToken },
|
path: { eventToken: eventToken },
|
||||||
query: { organizerToken },
|
query: { organizerToken },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -521,4 +602,105 @@ onMounted(fetchEvent)
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Cancellation banner */
|
||||||
|
.detail__cancelled-banner {
|
||||||
|
padding: var(--spacing-md) var(--spacing-lg);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--color-danger-bg);
|
||||||
|
border: 1px solid var(--color-danger-border-strong);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__cancelled-banner-title {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__cancelled-banner-reason {
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-soft);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cancel event button */
|
||||||
|
.detail__cancel-event {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: var(--spacing-md) var(--content-padding);
|
||||||
|
padding-bottom: calc(var(--spacing-md) + env(safe-area-inset-bottom, 0px));
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__cancel-event-btn {
|
||||||
|
width: 100%;
|
||||||
|
max-width: var(--content-max-width);
|
||||||
|
padding: var(--spacing-md) var(--spacing-lg);
|
||||||
|
border-radius: var(--radius-button);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-danger);
|
||||||
|
background: var(--color-danger-bg);
|
||||||
|
border: 1px solid var(--color-danger-border);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__cancel-event-btn:hover {
|
||||||
|
background: var(--color-danger-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cancel event form (inside bottom sheet) */
|
||||||
|
.cancel-form__textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 4rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-form__counter {
|
||||||
|
display: block;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-form__confirm {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: var(--spacing-md);
|
||||||
|
padding: var(--spacing-md) var(--spacing-lg);
|
||||||
|
border-radius: var(--radius-button);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-danger);
|
||||||
|
background: var(--color-danger-bg-strong);
|
||||||
|
border: 1px solid var(--color-danger-border);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-form__confirm:hover {
|
||||||
|
background: var(--color-danger-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-form__confirm:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-form__error {
|
||||||
|
margin-top: var(--spacing-sm);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-danger);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -280,8 +280,8 @@ describe('EventDetailView', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
// Verify API call
|
// Verify API call
|
||||||
expect(vi.mocked(api.POST)).toHaveBeenCalledWith('/events/{token}/rsvps', {
|
expect(vi.mocked(api.POST)).toHaveBeenCalledWith('/events/{eventToken}/rsvps', {
|
||||||
params: { path: { token: 'test-token' } },
|
params: { path: { eventToken: 'test-token' } },
|
||||||
body: { name: 'Max' },
|
body: { name: 'Max' },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
36
specs/016-cancel-event/checklists/requirements.md
Normal file
36
specs/016-cancel-event/checklists/requirements.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Specification Quality Checklist: Cancel Event
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-03-12
|
||||||
|
**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`.
|
||||||
|
- Feature scope is deliberately tight: cancel + display. No notifications, no undo, no event-list changes.
|
||||||
|
- Both user stories are P1 because they are two sides of the same coin (cancel action + display result).
|
||||||
78
specs/016-cancel-event/contracts/patch-event-endpoint.yaml
Normal file
78
specs/016-cancel-event/contracts/patch-event-endpoint.yaml
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# OpenAPI additions for Cancel Event feature
|
||||||
|
# To be merged into backend/src/main/resources/openapi/api.yaml
|
||||||
|
|
||||||
|
# PATCH method added to existing /events/{eventToken} path
|
||||||
|
# Under paths./events/{eventToken}:
|
||||||
|
|
||||||
|
# --- Add PATCH method to existing path ---
|
||||||
|
# /events/{eventToken}:
|
||||||
|
# patch:
|
||||||
|
# operationId: patchEvent
|
||||||
|
# summary: Update an event (currently: cancel)
|
||||||
|
# description: |
|
||||||
|
# Partial update of an event resource. Currently the only supported operation
|
||||||
|
# is cancellation (setting cancelled to true). Requires the organizer token.
|
||||||
|
# Cancellation is irreversible.
|
||||||
|
# tags: [Events]
|
||||||
|
# parameters:
|
||||||
|
# - $ref: '#/components/parameters/EventToken'
|
||||||
|
# requestBody:
|
||||||
|
# required: true
|
||||||
|
# content:
|
||||||
|
# application/json:
|
||||||
|
# schema:
|
||||||
|
# $ref: '#/components/schemas/PatchEventRequest'
|
||||||
|
# responses:
|
||||||
|
# '204':
|
||||||
|
# description: Event updated successfully
|
||||||
|
# '403':
|
||||||
|
# description: Invalid organizer token
|
||||||
|
# content:
|
||||||
|
# application/json:
|
||||||
|
# schema:
|
||||||
|
# $ref: '#/components/schemas/ErrorResponse'
|
||||||
|
# '404':
|
||||||
|
# description: Event not found
|
||||||
|
# content:
|
||||||
|
# application/json:
|
||||||
|
# schema:
|
||||||
|
# $ref: '#/components/schemas/ErrorResponse'
|
||||||
|
# '409':
|
||||||
|
# description: Event is already cancelled
|
||||||
|
# content:
|
||||||
|
# application/json:
|
||||||
|
# schema:
|
||||||
|
# $ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
|
# --- New schemas ---
|
||||||
|
|
||||||
|
# PatchEventRequest:
|
||||||
|
# type: object
|
||||||
|
# required: [organizerToken, cancelled]
|
||||||
|
# properties:
|
||||||
|
# organizerToken:
|
||||||
|
# type: string
|
||||||
|
# format: uuid
|
||||||
|
# description: The organizer token proving ownership of the event
|
||||||
|
# example: "550e8400-e29b-41d4-a716-446655440001"
|
||||||
|
# cancelled:
|
||||||
|
# type: boolean
|
||||||
|
# description: Set to true to cancel the event (irreversible)
|
||||||
|
# example: true
|
||||||
|
# cancellationReason:
|
||||||
|
# type: string
|
||||||
|
# maxLength: 2000
|
||||||
|
# description: Optional cancellation reason
|
||||||
|
# example: "Unfortunately the venue is no longer available."
|
||||||
|
|
||||||
|
# --- Extended schema: GetEventResponse ---
|
||||||
|
# Add to existing GetEventResponse properties:
|
||||||
|
# cancelled:
|
||||||
|
# type: boolean
|
||||||
|
# description: Whether the event has been cancelled
|
||||||
|
# example: false
|
||||||
|
# cancellationReason:
|
||||||
|
# type: string
|
||||||
|
# nullable: true
|
||||||
|
# description: Reason for cancellation, if provided
|
||||||
|
# example: null
|
||||||
82
specs/016-cancel-event/data-model.md
Normal file
82
specs/016-cancel-event/data-model.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# Data Model: Cancel Event
|
||||||
|
|
||||||
|
**Feature Branch**: `016-cancel-event` | **Date**: 2026-03-12
|
||||||
|
|
||||||
|
## Entity Changes
|
||||||
|
|
||||||
|
### Event (extended)
|
||||||
|
|
||||||
|
Two new fields added to the existing Event entity:
|
||||||
|
|
||||||
|
| Field | Type | Constraints | Description |
|
||||||
|
|--------------------|----------------|------------------------------|--------------------------------------------------|
|
||||||
|
| cancelled | boolean | NOT NULL, DEFAULT false | Whether the event has been cancelled |
|
||||||
|
| cancellationReason | String (2000) | Nullable | Optional reason provided by organizer |
|
||||||
|
|
||||||
|
### State Transition
|
||||||
|
|
||||||
|
```
|
||||||
|
ACTIVE ──cancel()──► CANCELLED
|
||||||
|
```
|
||||||
|
|
||||||
|
- One-way transition only. No path from CANCELLED back to ACTIVE.
|
||||||
|
- `cancel()` sets `cancelled = true` and optionally sets `cancellationReason`.
|
||||||
|
- Once cancelled, the event remains visible but RSVP creation is blocked.
|
||||||
|
|
||||||
|
### Validation Rules
|
||||||
|
|
||||||
|
- `cancellationReason` max length: 2000 characters (matches description field).
|
||||||
|
- `cancellationReason` is plain text only (no HTML/markdown).
|
||||||
|
- `cancelled` can only transition from `false` to `true`, never back.
|
||||||
|
- Existing RSVPs are preserved when an event is cancelled (no cascade).
|
||||||
|
|
||||||
|
## Database Migration (Liquibase Changeset 004)
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<changeSet id="004-add-cancellation-columns" author="fete">
|
||||||
|
<addColumn tableName="events">
|
||||||
|
<column name="cancelled" type="BOOLEAN" defaultValueBoolean="false">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="cancellation_reason" type="VARCHAR(2000)"/>
|
||||||
|
</addColumn>
|
||||||
|
</changeSet>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Domain Model Impact
|
||||||
|
|
||||||
|
### Event.java (domain)
|
||||||
|
|
||||||
|
Add fields:
|
||||||
|
```java
|
||||||
|
private boolean cancelled;
|
||||||
|
private String cancellationReason;
|
||||||
|
```
|
||||||
|
|
||||||
|
Add method:
|
||||||
|
```java
|
||||||
|
public void cancel(String reason) {
|
||||||
|
if (this.cancelled) {
|
||||||
|
throw new EventAlreadyCancelledException();
|
||||||
|
}
|
||||||
|
this.cancelled = true;
|
||||||
|
this.cancellationReason = reason;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### EventJpaEntity.java (persistence)
|
||||||
|
|
||||||
|
Add columns:
|
||||||
|
```java
|
||||||
|
@Column(name = "cancelled", nullable = false)
|
||||||
|
private boolean cancelled;
|
||||||
|
|
||||||
|
@Column(name = "cancellation_reason", length = 2000)
|
||||||
|
private String cancellationReason;
|
||||||
|
```
|
||||||
|
|
||||||
|
## RSVP Impact
|
||||||
|
|
||||||
|
- `POST /events/{eventToken}/rsvps` must check `event.isCancelled()` before accepting.
|
||||||
|
- If cancelled → return `409 Conflict`.
|
||||||
|
- Existing RSVPs remain untouched — no delete, no status change.
|
||||||
79
specs/016-cancel-event/plan.md
Normal file
79
specs/016-cancel-event/plan.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# Implementation Plan: Cancel Event
|
||||||
|
|
||||||
|
**Branch**: `016-cancel-event` | **Date**: 2026-03-12 | **Spec**: [spec.md](spec.md)
|
||||||
|
**Input**: Feature specification from `/specs/016-cancel-event/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Allow organizers to permanently cancel events via a bottom sheet UI. Cancelled events display a red banner to visitors and block new RSVPs. Implementation adds a `PATCH /events/{eventToken}` endpoint, extends the Event entity with `cancelled` and `cancellationReason` fields, and reuses the existing `BottomSheet.vue` component for the cancel interaction.
|
||||||
|
|
||||||
|
## 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 (backend), Vitest (frontend unit), Playwright + MSW (frontend E2E)
|
||||||
|
**Target Platform**: Self-hosted Linux server, mobile-first PWA
|
||||||
|
**Project Type**: Web application (REST API + SPA)
|
||||||
|
**Performance Goals**: N/A — simple state transition, no performance-critical path
|
||||||
|
**Constraints**: Privacy by design (no analytics/tracking), WCAG AA, mobile-first
|
||||||
|
**Scale/Scope**: Single new endpoint, 2 new DB columns, 1 view extension
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
| Principle | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| I. Privacy by Design | PASS | No new data collection beyond organizer-provided reason. No analytics. |
|
||||||
|
| II. Test-Driven Methodology | PASS | TDD enforced: tests before implementation, E2E mandatory for both user stories. |
|
||||||
|
| III. API-First Development | PASS | OpenAPI spec updated first, types generated before implementation. `example:` fields included. |
|
||||||
|
| IV. Simplicity & Quality | PASS | Minimal change: 2 columns, 1 endpoint, reuse existing BottomSheet. No over-engineering. |
|
||||||
|
| V. Dependency Discipline | PASS | No new dependencies required. |
|
||||||
|
| VI. Accessibility | PASS | Reuses accessible BottomSheet component. Banner uses semantic HTML + ARIA. |
|
||||||
|
|
||||||
|
**Gate result: PASS** — no violations.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/016-cancel-event/
|
||||||
|
├── plan.md # This file
|
||||||
|
├── spec.md # Feature specification
|
||||||
|
├── research.md # Phase 0 output — design decisions
|
||||||
|
├── data-model.md # Phase 1 output — entity changes
|
||||||
|
├── quickstart.md # Phase 1 output — implementation overview
|
||||||
|
├── contracts/ # Phase 1 output — API contract additions
|
||||||
|
│ └── patch-event-endpoint.yaml
|
||||||
|
└── tasks.md # Phase 2 output (/speckit.tasks command)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
backend/
|
||||||
|
├── src/main/java/de/fete/
|
||||||
|
│ ├── domain/model/Event.java # + cancelled, cancellationReason, cancel()
|
||||||
|
│ ├── application/service/EventService.java # + CancelEventUseCase implementation
|
||||||
|
│ ├── adapter/in/web/EventController.java # + cancelEvent endpoint
|
||||||
|
│ └── adapter/out/persistence/
|
||||||
|
│ ├── EventJpaEntity.java # + cancelled, cancellation_reason columns
|
||||||
|
│ └── EventPersistenceAdapter.java # + mapper updates
|
||||||
|
├── src/main/resources/
|
||||||
|
│ ├── openapi/api.yaml # + cancel endpoint, request/response schemas
|
||||||
|
│ └── db/changelog/004-add-cancellation-columns.xml # New migration
|
||||||
|
└── src/test/java/de/fete/ # Unit + integration tests
|
||||||
|
|
||||||
|
frontend/
|
||||||
|
├── src/
|
||||||
|
│ └── views/EventDetailView.vue # + cancel button, bottom sheet, banner
|
||||||
|
└── e2e/ # E2E tests for both user stories
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Web application (Option 2) — matches existing project layout with `backend/` and `frontend/` at repository root.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
> No violations — table not applicable.
|
||||||
48
specs/016-cancel-event/quickstart.md
Normal file
48
specs/016-cancel-event/quickstart.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Quickstart: Cancel Event
|
||||||
|
|
||||||
|
**Feature Branch**: `016-cancel-event`
|
||||||
|
|
||||||
|
## What This Feature Does
|
||||||
|
|
||||||
|
Adds the ability for an organizer to permanently cancel an event. Cancelled events display a red banner to visitors and block new RSVPs.
|
||||||
|
|
||||||
|
## Implementation Scope
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
1. **Liquibase migration** (003): Add `cancelled` (boolean) and `cancellation_reason` (varchar 2000) columns to `events` table.
|
||||||
|
2. **Domain model**: Extend `Event.java` with `cancelled` and `cancellationReason` fields + `cancel()` method.
|
||||||
|
3. **JPA entity**: Extend `EventJpaEntity.java` with matching columns and mapper updates.
|
||||||
|
4. **OpenAPI spec**: Add `PATCH /events/{eventToken}` endpoint + extend `GetEventResponse` with cancellation fields.
|
||||||
|
5. **Use case**: New `CancelEventUseCase` interface + implementation in `EventService`.
|
||||||
|
6. **Controller**: Implement `cancelEvent` in `EventController`.
|
||||||
|
7. **RSVP guard**: Add cancelled check to RSVP creation (return 409).
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
1. **Cancel bottom sheet**: Add cancel button (organizer-only) + bottom sheet with textarea and confirm button in `EventDetailView.vue`.
|
||||||
|
2. **Cancellation banner**: Red banner at top of event detail when `cancelled === true`.
|
||||||
|
3. **RSVP hiding**: Hide `RsvpBar` when event is cancelled.
|
||||||
|
4. **API client**: Use generated types from updated OpenAPI spec.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
1. **Backend unit tests**: Cancel use case, RSVP rejection on cancelled events.
|
||||||
|
2. **Backend integration tests**: Full cancel flow via API.
|
||||||
|
3. **Frontend unit tests**: Cancel bottom sheet, banner display, RSVP hiding.
|
||||||
|
4. **E2E tests**: Organizer cancels event, attendee sees cancelled event.
|
||||||
|
|
||||||
|
## Key Files to Modify
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `backend/src/main/resources/openapi/api.yaml` | New endpoint + schema extensions |
|
||||||
|
| `backend/src/main/resources/db/changelog/` | New changeset 003 |
|
||||||
|
| `backend/src/main/java/de/fete/domain/model/Event.java` | Add cancelled fields + cancel() |
|
||||||
|
| `backend/src/main/java/de/fete/adapter/out/persistence/EventJpaEntity.java` | Add columns |
|
||||||
|
| `backend/src/main/java/de/fete/application/service/EventService.java` | Implement cancel |
|
||||||
|
| `backend/src/main/java/de/fete/adapter/in/web/EventController.java` | Implement endpoint |
|
||||||
|
| `frontend/src/views/EventDetailView.vue` | Cancel button, bottom sheet, banner |
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Existing RSVP bottom sheet pattern (already implemented)
|
||||||
|
- Organizer token stored in localStorage (already implemented)
|
||||||
|
- `BottomSheet.vue` component (already exists)
|
||||||
87
specs/016-cancel-event/research.md
Normal file
87
specs/016-cancel-event/research.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# Research: Cancel Event
|
||||||
|
|
||||||
|
**Feature Branch**: `016-cancel-event` | **Date**: 2026-03-12
|
||||||
|
|
||||||
|
## Decision 1: API Endpoint Design
|
||||||
|
|
||||||
|
**Decision**: Use `PATCH /events/{eventToken}` with organizer token and cancellation fields in request body.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- PATCH is standard REST for partial resource updates — cancellation is a state change on the event resource.
|
||||||
|
- The event is not removed, so DELETE is not appropriate. The event remains visible with a cancellation banner.
|
||||||
|
- The organizer token is sent in the request body to keep it out of URL/query strings and server access logs.
|
||||||
|
- Request body: `{ "organizerToken": "uuid", "cancelled": true, "cancellationReason": "optional string" }`.
|
||||||
|
- Response: `204 No Content` on success.
|
||||||
|
- Error responses: `404` if event not found, `403` if organizer token is wrong, `409` if already cancelled.
|
||||||
|
- Currently the only supported PATCH operation is cancellation. The endpoint validates that `cancelled` is `true` and rejects requests that attempt to set other fields.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- `POST /events/{eventToken}/cancel` — rejected because a dedicated sub-resource endpoint is RPC-style, not RESTful. PATCH on the resource itself is the standard approach.
|
||||||
|
- `DELETE /events/{eventToken}` — rejected because the event is not deleted, it remains visible with a cancellation banner.
|
||||||
|
|
||||||
|
## Decision 2: Database Schema Extension
|
||||||
|
|
||||||
|
**Decision**: Add two columns to the `events` table: `cancelled BOOLEAN NOT NULL DEFAULT FALSE` and `cancellation_reason VARCHAR(2000)`.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Boolean flag is the simplest representation of the cancelled state.
|
||||||
|
- 2000 chars matches the existing description field limit — consistent and generous.
|
||||||
|
- DEFAULT FALSE ensures backward compatibility with existing rows.
|
||||||
|
- A Liquibase changeset (003) adds both columns.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Enum status field (`ACTIVE`, `CANCELLED`) — rejected as over-engineering for a binary state with no other planned transitions.
|
||||||
|
- Separate cancellation table — rejected as unnecessary complexity for two columns.
|
||||||
|
|
||||||
|
## Decision 3: RSVP Blocking on Cancelled Events
|
||||||
|
|
||||||
|
**Decision**: The RSVP creation endpoint (`POST /events/{eventToken}/rsvps`) checks the event's cancelled flag and returns `409 Conflict` if the event is cancelled.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Server-side enforcement is required (FR-006) — frontend hiding the button is not sufficient.
|
||||||
|
- 409 Conflict is semantically correct: the request conflicts with the current state of the resource.
|
||||||
|
- Existing RSVPs are preserved (FR-007) — no cascade or cleanup needed.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- 400 Bad Request — rejected because the request itself is well-formed; the conflict is with resource state.
|
||||||
|
- 422 Unprocessable Entity — rejected because the issue is not validation but state conflict.
|
||||||
|
|
||||||
|
## Decision 4: Frontend Cancel Bottom Sheet
|
||||||
|
|
||||||
|
**Decision**: Reuse the existing `BottomSheet.vue` component. Add cancel-specific content (textarea + confirm button) directly in `EventDetailView.vue`, similar to how the RSVP form is embedded.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- The spec explicitly requires the bottom sheet pattern consistent with RSVP flow (FR-002).
|
||||||
|
- `BottomSheet.vue` is already a generic, accessible, glassmorphism-styled container.
|
||||||
|
- No need for a separate component — the cancel form is simple (textarea + button + error message).
|
||||||
|
- Error handling follows the same pattern as RSVP: inline error in the sheet, button re-enabled.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Separate `CancelBottomSheet.vue` component — rejected as unnecessary extraction for a simple form.
|
||||||
|
- ConfirmDialog instead of BottomSheet — rejected because spec explicitly requires bottom sheet.
|
||||||
|
|
||||||
|
## Decision 5: Organizer Token Authorization
|
||||||
|
|
||||||
|
**Decision**: The cancel endpoint receives the organizer token in the request body. The frontend retrieves it from localStorage via `useEventStorage.getOrganizerToken()`.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Consistent with how organizer identity works throughout the app — token-based, no auth system.
|
||||||
|
- The organizer token is already stored in localStorage when the event is created.
|
||||||
|
- Body parameter keeps the token out of URL/query strings and server access logs.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Authorization header — rejected because there's no auth system; the organizer token is not a session token.
|
||||||
|
- Query parameter — rejected to keep token out of server logs (same reason the attendee endpoint should eventually be migrated away from query params).
|
||||||
|
|
||||||
|
## Decision 6: GetEventResponse Extension
|
||||||
|
|
||||||
|
**Decision**: Add `cancelled: boolean` and `cancellationReason: string | null` to the `GetEventResponse` schema.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- The frontend needs to know whether an event is cancelled to show the banner and hide RSVP buttons.
|
||||||
|
- Both fields are always returned (no separate endpoint needed).
|
||||||
|
- `cancelled` defaults to `false` for existing events.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Separate endpoint for cancellation status — rejected as unnecessary network overhead.
|
||||||
|
- Only return cancellation info for cancelled events — rejected because the frontend needs the boolean regardless to decide UI state.
|
||||||
97
specs/016-cancel-event/spec.md
Normal file
97
specs/016-cancel-event/spec.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# Feature Specification: Cancel Event
|
||||||
|
|
||||||
|
**Feature Branch**: `016-cancel-event`
|
||||||
|
**Created**: 2026-03-12
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "As an organizer, I want to cancel an event so that attendees know the event will not take place"
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Organizer Cancels an Event (Priority: P1)
|
||||||
|
|
||||||
|
An organizer navigates to their event page (identified by having the organizer token). They decide to cancel the event. They tap a "Cancel Event" button, which opens a bottom sheet (visually similar to the attend/RSVP flow). The bottom sheet contains a text area for an optional cancellation reason and a confirm button. After confirming, the event is permanently marked as cancelled. This action is irreversible.
|
||||||
|
|
||||||
|
**Why this priority**: This is the core action of the feature. Without it, nothing else matters.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by viewing the event with the organizer token, tapping cancel, optionally entering a reason, and confirming. The event's cancelled state persists on reload.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an active event viewed by someone who has the organizer token, **When** the organizer taps "Cancel Event", **Then** a bottom sheet opens with a text area and a confirm button.
|
||||||
|
2. **Given** the cancel bottom sheet is open, **When** the organizer enters a cancellation reason and taps the confirm button, **Then** the event is marked as cancelled with the provided reason.
|
||||||
|
3. **Given** the cancel bottom sheet is open, **When** the organizer taps confirm without entering a reason, **Then** the event is marked as cancelled without a reason.
|
||||||
|
4. **Given** a cancelled event, **When** the organizer revisits the event page, **Then** the event remains cancelled (irreversible).
|
||||||
|
5. **Given** the cancel bottom sheet is open, **When** the organizer taps confirm and the API call fails, **Then** an error message is displayed in the bottom sheet, the sheet remains open, and the confirm button is re-enabled for retry.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Attendee Sees Cancelled Event (Priority: P1)
|
||||||
|
|
||||||
|
An attendee (or any visitor) opens the event detail page for a cancelled event. A prominent red banner is displayed at the top of the page, clearly communicating that the event has been cancelled. If the organizer provided a cancellation reason, it is shown within the banner. The RSVP buttons are hidden — no new RSVPs can be submitted.
|
||||||
|
|
||||||
|
**Why this priority**: Equal to P1 because the cancellation must be visible to attendees for the feature to deliver value. Without this, cancelling has no effect from the attendee's perspective.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by viewing a cancelled event's detail page and verifying the banner appears, the reason is displayed (if provided), and RSVP buttons are hidden.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a cancelled event with a cancellation reason, **When** a visitor opens the event detail page, **Then** a prominent red banner is displayed showing that the event is cancelled along with the reason.
|
||||||
|
2. **Given** a cancelled event without a cancellation reason, **When** a visitor opens the event detail page, **Then** a prominent red banner is displayed showing that the event is cancelled, without a reason text.
|
||||||
|
3. **Given** a cancelled event, **When** a visitor opens the event detail page, **Then** the RSVP buttons are not visible.
|
||||||
|
4. **Given** a cancelled event, **When** a visitor opens the event detail page, **Then** all other event details (title, date, location, description) remain visible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- What happens when the organizer tries to cancel an already cancelled event? The cancel button is not available on an already cancelled event.
|
||||||
|
- What happens to existing RSVPs when an event is cancelled? They are preserved as-is but no new RSVPs can be submitted.
|
||||||
|
- What happens when the event is both cancelled and expired? The auto-delete mechanism (feature 013) continues to apply normally — cancelled events are deleted on the same schedule as non-cancelled events.
|
||||||
|
- What happens when the cancellation API call fails (network error, server error)? The bottom sheet remains open, a visible error message is displayed within the sheet, and the confirm button is re-enabled so the organizer can retry.
|
||||||
|
- How are cancelled events displayed in the event list? Out of scope for this feature — the event list view is not affected.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: System MUST allow the organizer to cancel an event via the organizer view.
|
||||||
|
- **FR-002**: The cancellation interaction MUST use a bottom sheet pattern consistent with the existing RSVP/attend flow.
|
||||||
|
- **FR-003**: The bottom sheet MUST contain a text area for an optional cancellation reason and a confirm button.
|
||||||
|
- **FR-004**: Cancellation MUST be irreversible — once cancelled, there is no way to undo it.
|
||||||
|
- **FR-005**: System MUST store a cancelled flag and an optional cancellation reason for the event.
|
||||||
|
- **FR-006**: System MUST NOT allow new RSVPs for a cancelled event.
|
||||||
|
- **FR-007**: System MUST preserve existing RSVPs when an event is cancelled.
|
||||||
|
- **FR-008**: The event detail page MUST display a prominent red banner for cancelled events.
|
||||||
|
- **FR-009**: The banner MUST include the cancellation reason when one was provided.
|
||||||
|
- **FR-010**: The RSVP buttons MUST be hidden on a cancelled event's detail page.
|
||||||
|
- **FR-011**: All other event information MUST remain visible on a cancelled event's detail page.
|
||||||
|
- **FR-012**: The cancel button MUST NOT be shown on an already cancelled event.
|
||||||
|
- **FR-013**: There MUST be no push notifications, emails, or any active notification mechanism for cancellations.
|
||||||
|
- **FR-014**: If the cancellation API call fails, the bottom sheet MUST remain open, display an error message, and allow the organizer to retry.
|
||||||
|
- **FR-015**: Changes to the event list view for cancelled events are explicitly OUT OF SCOPE for this feature.
|
||||||
|
|
||||||
|
### Key Entities
|
||||||
|
|
||||||
|
- **Event** (extended): Gains a cancelled state (boolean) and an optional cancellation reason (free text). An event can transition from active to cancelled, but not back.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: Organizer can cancel an event in under 30 seconds (open bottom sheet, optionally type reason, confirm).
|
||||||
|
- **SC-002**: 100% of visitors to a cancelled event's detail page see the cancellation banner without scrolling.
|
||||||
|
- **SC-003**: 0% of cancelled events accept new RSVPs.
|
||||||
|
- **SC-004**: Existing RSVPs are fully preserved after cancellation (no data loss).
|
||||||
|
|
||||||
|
## Clarifications
|
||||||
|
|
||||||
|
### Session 2026-03-12
|
||||||
|
|
||||||
|
- Q: How should cancelled events appear in the event list view? → A: Out of scope for this feature — event list view is not affected.
|
||||||
|
- Q: What should happen when the cancellation API call fails? → A: Error message displayed in the bottom sheet, sheet remains open, confirm button re-enabled for retry.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- The bottom sheet component pattern already exists from the RSVP/attend flow and can be reused.
|
||||||
|
- The cancellation reason has a maximum length of 2000 characters (consistent with the event description field).
|
||||||
|
- The cancellation reason is plain text (no formatting or markup).
|
||||||
96
specs/016-cancel-event/tasks.md
Normal file
96
specs/016-cancel-event/tasks.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# Tasks: Cancel Event
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/016-cancel-event/`
|
||||||
|
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/patch-event-endpoint.yaml
|
||||||
|
|
||||||
|
**Tests**: Included — constitution mandates TDD (tests before implementation) and E2E for every frontend user story.
|
||||||
|
|
||||||
|
**Organization**: Tasks grouped by user story. Both stories are P1 but US2 depends on US1's backend work.
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
- Include exact file paths in descriptions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Setup (Shared Infrastructure)
|
||||||
|
|
||||||
|
**Purpose**: OpenAPI spec, database migration, and domain model changes that both user stories depend on.
|
||||||
|
|
||||||
|
- [X] T001 Update OpenAPI spec with PATCH endpoint on `/events/{eventToken}` (organizerToken as query param), PatchEventRequest schema (`cancelled`, `cancellationReason`), and extend GetEventResponse with `cancelled`/`cancellationReason` fields in `backend/src/main/resources/openapi/api.yaml`
|
||||||
|
- [X] T002 Add Liquibase changeset 004 adding `cancelled` (BOOLEAN NOT NULL DEFAULT FALSE) and `cancellation_reason` (VARCHAR 2000) columns to `events` table in `backend/src/main/resources/db/changelog/004-add-cancellation-columns.xml` and register it in `db.changelog-master.xml`
|
||||||
|
- [X] T003 [P] Extend domain model `Event.java` with `cancelled`, `cancellationReason` fields and `cancel(String reason)` method (throws `EventAlreadyCancelledException`). Create `EventAlreadyCancelledException` in `backend/src/main/java/de/fete/domain/model/`. Domain model: `backend/src/main/java/de/fete/domain/model/Event.java`
|
||||||
|
- [X] T004 [P] Extend JPA entity `EventJpaEntity.java` with `cancelled` and `cancellation_reason` columns and update mapper in `backend/src/main/java/de/fete/adapter/out/persistence/EventPersistenceAdapter.java`
|
||||||
|
- [X] T005 Regenerate frontend TypeScript types from updated OpenAPI spec via `cd frontend && npm run generate:api`
|
||||||
|
|
||||||
|
**Checkpoint**: Schema, migration, and domain model ready. Both user stories can now proceed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: User Story 1 — Organizer Cancels an Event (Priority: P1) MVP
|
||||||
|
|
||||||
|
**Goal**: Organizer can cancel an event via a bottom sheet with optional reason. Cancellation is irreversible and persists on reload.
|
||||||
|
|
||||||
|
**Independent Test**: View event with organizer token, tap cancel, optionally enter reason, confirm. Event remains cancelled on reload.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
> **Write these tests FIRST — ensure they FAIL before implementation**
|
||||||
|
|
||||||
|
- [X] ~~T006~~ Removed (EventTest.java unnecessary — cancel() tested via service/integration tests)
|
||||||
|
- [X] T007 [P] [US1] Write unit test for cancel use case in EventService (delegates to domain, saves, 403/404/409 cases) in `backend/src/test/java/de/fete/application/service/EventServiceCancelTest.java`
|
||||||
|
- [X] T008 [P] [US1] Write integration tests for `PATCH /events/{eventToken}` endpoint (204 success, 403 wrong token, 404 not found, 409 already cancelled) in `backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java`
|
||||||
|
- [X] T009 [P] [US1] Write E2E test: organizer opens cancel bottom sheet, enters reason, confirms — event shows as cancelled on reload in `frontend/e2e/cancel-event.spec.ts`
|
||||||
|
- [X] T010 [P] [US1] Write E2E test: organizer cancels without reason — event shows as cancelled in `frontend/e2e/cancel-event.spec.ts`
|
||||||
|
- [X] T011 [P] [US1] Write E2E test: cancel API fails — error displayed in bottom sheet, button re-enabled for retry in `frontend/e2e/cancel-event.spec.ts`
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [X] T012 [US1] Create `UpdateEventUseCase` interface in `backend/src/main/java/de/fete/domain/port/in/UpdateEventUseCase.java`
|
||||||
|
- [X] T013 [US1] Implement cancel logic in `EventService.java` — load event, verify organizer token, call `event.cancel(reason)`, persist in `backend/src/main/java/de/fete/application/service/EventService.java`
|
||||||
|
- [X] T014 [US1] Implement `patchEvent` endpoint in `EventController.java` — PATCH handler, query param organizerToken, request body binding, error mapping (403/404/409) in `backend/src/main/java/de/fete/adapter/in/web/EventController.java`
|
||||||
|
- [X] T015 [US1] Add cancel button (visible only when organizer token exists and event not cancelled — covers FR-012) and cancel bottom sheet (textarea with 2000 char limit + confirm button + inline error) to `frontend/src/views/EventDetailView.vue`
|
||||||
|
- [X] T016 [US1] Wire cancel bottom sheet confirm action to `PATCH /events/{eventToken}` API call via openapi-fetch, handle success (reload event data) and error (show inline message, re-enable button) in `frontend/src/views/EventDetailView.vue`
|
||||||
|
|
||||||
|
**Checkpoint**: Organizer can cancel an event. All US1 acceptance scenarios pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 2 — Attendee Sees Cancelled Event (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: Visitors see a prominent red cancellation banner on cancelled events. RSVP buttons are hidden. All other event details remain visible.
|
||||||
|
|
||||||
|
**Independent Test**: View a cancelled event's detail page — banner visible (with reason if provided), RSVP buttons hidden, other details intact.
|
||||||
|
|
||||||
|
### Tests for User Story 2
|
||||||
|
|
||||||
|
> **Write these tests FIRST — ensure they FAIL before implementation**
|
||||||
|
|
||||||
|
- [X] ~~T017~~ Removed (RsvpServiceCancelledTest unnecessary — covered by integration test)
|
||||||
|
- [X] T018 [P] [US2] Write integration test for `POST /events/{eventToken}/rsvps` returning 409 when event is cancelled in `backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java`
|
||||||
|
- [X] T019 [P] [US2] Write E2E test: visitor sees red banner with cancellation reason on cancelled event in `frontend/e2e/cancelled-event-visitor.spec.ts`
|
||||||
|
- [X] T020 [P] [US2] Write E2E test: visitor sees red banner without reason when no reason was provided in `frontend/e2e/cancelled-event-visitor.spec.ts`
|
||||||
|
- [X] T021 [P] [US2] Write E2E test: RSVP buttons hidden on cancelled event, other details remain visible in `frontend/e2e/cancelled-event-visitor.spec.ts`
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [X] T022 [US2] Add cancelled-event guard to RSVP creation — check `event.isCancelled()`, return 409 Conflict in `backend/src/main/java/de/fete/application/service/RsvpService.java`
|
||||||
|
- [X] T023 [US2] Add cancellation banner component/section (red, prominent, includes reason if present, WCAG AA contrast) to `frontend/src/views/EventDetailView.vue`
|
||||||
|
- [X] T024 [US2] Hide RSVP buttons (`RsvpBar` or equivalent) when `event.cancelled === true` in `frontend/src/views/EventDetailView.vue`
|
||||||
|
- [X] ~~T025~~ Merged into T015 (cancel button v-if already handles FR-012)
|
||||||
|
|
||||||
|
**Checkpoint**: Both user stories fully functional. All acceptance scenarios pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Validation, edge cases, and final cleanup.
|
||||||
|
|
||||||
|
- [X] T026 Verify cancellationReason max length (2000 chars) is enforced at API level (OpenAPI `maxLength`), domain level in `Event.java`, and UI level (textarea maxlength/counter)
|
||||||
|
- [X] T027 Run full backend test suite (`cd backend && ./mvnw verify`) and fix any failures
|
||||||
|
- [X] T028 Run full frontend test suite (`cd frontend && npm run test:unit`) and fix any failures
|
||||||
|
- [X] T029 Run E2E tests (`cd frontend && npx playwright test`) and fix any failures
|
||||||
|
- [X] T030 Run backend checkstyle (`cd backend && ./mvnw checkstyle:check`) and fix violations
|
||||||
Reference in New Issue
Block a user