Implement cancel-event feature (016) #38
@@ -8,6 +8,7 @@ import de.fete.adapter.in.web.model.CreateRsvpRequest;
|
||||
import de.fete.adapter.in.web.model.CreateRsvpResponse;
|
||||
import de.fete.adapter.in.web.model.GetAttendeesResponse;
|
||||
import de.fete.adapter.in.web.model.GetEventResponse;
|
||||
import de.fete.adapter.in.web.model.PatchEventRequest;
|
||||
import de.fete.application.service.EventNotFoundException;
|
||||
import de.fete.application.service.InvalidTimezoneException;
|
||||
import de.fete.domain.model.CreateEventCommand;
|
||||
@@ -22,6 +23,7 @@ import de.fete.domain.port.in.CreateEventUseCase;
|
||||
import de.fete.domain.port.in.CreateRsvpUseCase;
|
||||
import de.fete.domain.port.in.GetAttendeesUseCase;
|
||||
import de.fete.domain.port.in.GetEventUseCase;
|
||||
import de.fete.domain.port.in.UpdateEventUseCase;
|
||||
import java.time.DateTimeException;
|
||||
import java.time.ZoneId;
|
||||
import java.util.List;
|
||||
@@ -40,6 +42,7 @@ public class EventController implements EventsApi {
|
||||
private final CancelRsvpUseCase cancelRsvpUseCase;
|
||||
private final CountAttendeesByEventUseCase countAttendeesByEventUseCase;
|
||||
private final GetAttendeesUseCase getAttendeesUseCase;
|
||||
private final UpdateEventUseCase updateEventUseCase;
|
||||
|
||||
/** Creates a new controller with the given use cases. */
|
||||
public EventController(
|
||||
@@ -48,13 +51,15 @@ public class EventController implements EventsApi {
|
||||
CreateRsvpUseCase createRsvpUseCase,
|
||||
CancelRsvpUseCase cancelRsvpUseCase,
|
||||
CountAttendeesByEventUseCase countAttendeesByEventUseCase,
|
||||
GetAttendeesUseCase getAttendeesUseCase) {
|
||||
GetAttendeesUseCase getAttendeesUseCase,
|
||||
UpdateEventUseCase updateEventUseCase) {
|
||||
this.createEventUseCase = createEventUseCase;
|
||||
this.getEventUseCase = getEventUseCase;
|
||||
this.createRsvpUseCase = createRsvpUseCase;
|
||||
this.cancelRsvpUseCase = cancelRsvpUseCase;
|
||||
this.countAttendeesByEventUseCase = countAttendeesByEventUseCase;
|
||||
this.getAttendeesUseCase = getAttendeesUseCase;
|
||||
this.updateEventUseCase = updateEventUseCase;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -97,10 +102,23 @@ public class EventController implements EventsApi {
|
||||
response.setLocation(event.getLocation());
|
||||
response.setAttendeeCount(
|
||||
(int) countAttendeesByEventUseCase.countByEvent(evtToken));
|
||||
response.setCancelled(event.isCancelled());
|
||||
response.setCancellationReason(event.getCancellationReason());
|
||||
|
||||
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
|
||||
public ResponseEntity<GetAttendeesResponse> getAttendees(
|
||||
UUID eventToken, UUID organizerToken) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package de.fete.adapter.in.web;
|
||||
|
||||
import de.fete.application.service.EventAlreadyCancelledException;
|
||||
import de.fete.application.service.EventCancelledException;
|
||||
import de.fete.application.service.EventExpiredException;
|
||||
import de.fete.application.service.EventNotFoundException;
|
||||
import de.fete.application.service.ExpiryDateBeforeEventException;
|
||||
@@ -75,6 +77,32 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
|
||||
.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. */
|
||||
@ExceptionHandler(EventExpiredException.class)
|
||||
public ResponseEntity<ProblemDetail> handleEventExpired(
|
||||
|
||||
@@ -46,6 +46,12 @@ public class EventJpaEntity {
|
||||
@Column(name = "created_at", nullable = false)
|
||||
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. */
|
||||
public Long getId() {
|
||||
return id;
|
||||
@@ -145,4 +151,24 @@ public class EventJpaEntity {
|
||||
public void setCreatedAt(OffsetDateTime 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,8 @@ public class EventPersistenceAdapter implements EventRepository {
|
||||
entity.setLocation(event.getLocation());
|
||||
entity.setExpiryDate(event.getExpiryDate());
|
||||
entity.setCreatedAt(event.getCreatedAt());
|
||||
entity.setCancelled(event.isCancelled());
|
||||
entity.setCancellationReason(event.getCancellationReason());
|
||||
return entity;
|
||||
}
|
||||
|
||||
@@ -63,6 +65,8 @@ public class EventPersistenceAdapter implements EventRepository {
|
||||
event.setLocation(entity.getLocation());
|
||||
event.setExpiryDate(entity.getExpiryDate());
|
||||
event.setCreatedAt(entity.getCreatedAt());
|
||||
event.setCancelled(entity.isCancelled());
|
||||
event.setCancellationReason(entity.getCancellationReason());
|
||||
return event;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package de.fete.application.service;
|
||||
|
||||
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;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -6,16 +6,18 @@ import de.fete.domain.model.EventToken;
|
||||
import de.fete.domain.model.OrganizerToken;
|
||||
import de.fete.domain.port.in.CreateEventUseCase;
|
||||
import de.fete.domain.port.in.GetEventUseCase;
|
||||
import de.fete.domain.port.in.UpdateEventUseCase;
|
||||
import de.fete.domain.port.out.EventRepository;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Optional;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/** Application service implementing event creation and retrieval. */
|
||||
@Service
|
||||
public class EventService implements CreateEventUseCase, GetEventUseCase {
|
||||
public class EventService implements CreateEventUseCase, GetEventUseCase, UpdateEventUseCase {
|
||||
|
||||
private static final int EXPIRY_DAYS_AFTER_EVENT = 7;
|
||||
|
||||
@@ -50,4 +52,29 @@ public class EventService implements CreateEventUseCase, GetEventUseCase {
|
||||
public Optional<Event> getByEventToken(EventToken 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.getOrganizerToken().equals(organizerToken)) {
|
||||
throw new InvalidOrganizerTokenException();
|
||||
}
|
||||
|
||||
if (event.isCancelled()) {
|
||||
throw new EventAlreadyCancelledException(eventToken.value());
|
||||
}
|
||||
|
||||
event.setCancelled(true);
|
||||
event.setCancellationReason(reason);
|
||||
eventRepository.save(event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,10 @@ public class RsvpService
|
||||
Event event = eventRepository.findByEventToken(eventToken)
|
||||
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
|
||||
|
||||
if (event.isCancelled()) {
|
||||
throw new EventCancelledException(eventToken.value());
|
||||
}
|
||||
|
||||
if (!event.getExpiryDate().isAfter(LocalDate.now(clock))) {
|
||||
throw new EventExpiredException(eventToken.value());
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ public class Event {
|
||||
private String location;
|
||||
private LocalDate expiryDate;
|
||||
private OffsetDateTime createdAt;
|
||||
private boolean cancelled;
|
||||
private String cancellationReason;
|
||||
|
||||
/** Returns the internal database ID. */
|
||||
public Long getId() {
|
||||
@@ -117,4 +119,24 @@ public class Event {
|
||||
public void setCreatedAt(OffsetDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
/** Returns whether the event has been cancelled. */
|
||||
public boolean isCancelled() {
|
||||
return cancelled;
|
||||
}
|
||||
|
||||
/** Sets the cancelled flag. */
|
||||
public void setCancelled(boolean cancelled) {
|
||||
this.cancelled = cancelled;
|
||||
}
|
||||
|
||||
/** Returns the cancellation reason, if any. */
|
||||
public String getCancellationReason() {
|
||||
return cancellationReason;
|
||||
}
|
||||
|
||||
/** Sets the cancellation reason. */
|
||||
public void setCancellationReason(String cancellationReason) {
|
||||
this.cancellationReason = cancellationReason;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/002-add-timezone-column.xml"/>
|
||||
<include file="db/changelog/003-create-rsvps-table.xml"/>
|
||||
<include file="db/changelog/004-add-cancellation-columns.xml"/>
|
||||
|
||||
</databaseChangeLog>
|
||||
|
||||
@@ -184,6 +184,58 @@ paths:
|
||||
schema:
|
||||
$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:
|
||||
schemas:
|
||||
CreateEventRequest:
|
||||
@@ -252,6 +304,7 @@ components:
|
||||
- dateTime
|
||||
- timezone
|
||||
- attendeeCount
|
||||
- cancelled
|
||||
properties:
|
||||
eventToken:
|
||||
type: string
|
||||
@@ -284,6 +337,31 @@ components:
|
||||
minimum: 0
|
||||
description: Number of confirmed attendees (attending=true)
|
||||
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:
|
||||
type: object
|
||||
|
||||
@@ -3,6 +3,7 @@ package de.fete.adapter.in.web;
|
||||
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.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.result.MockMvcResultMatchers.content;
|
||||
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.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -431,6 +433,147 @@ class EventControllerIntegrationTest {
|
||||
.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) {
|
||||
var rsvp = new RsvpJpaEntity();
|
||||
UUID token = UUID.randomUUID();
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
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.EventAlreadyCancelledException;
|
||||
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();
|
||||
event.setEventToken(eventToken);
|
||||
event.setOrganizerToken(organizerToken);
|
||||
event.setCancelled(false);
|
||||
|
||||
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().isCancelled()).isTrue();
|
||||
assertThat(captor.getValue().getCancellationReason()).isEqualTo("Venue unavailable");
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelEventWithNullReason() {
|
||||
EventToken eventToken = EventToken.generate();
|
||||
OrganizerToken organizerToken = OrganizerToken.generate();
|
||||
var event = new Event();
|
||||
event.setEventToken(eventToken);
|
||||
event.setOrganizerToken(organizerToken);
|
||||
event.setCancelled(false);
|
||||
|
||||
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().isCancelled()).isTrue();
|
||||
assertThat(captor.getValue().getCancellationReason()).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();
|
||||
event.setEventToken(eventToken);
|
||||
event.setOrganizerToken(correctToken);
|
||||
|
||||
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();
|
||||
event.setEventToken(eventToken);
|
||||
event.setOrganizerToken(organizerToken);
|
||||
event.setCancelled(true);
|
||||
|
||||
when(eventRepository.findByEventToken(eventToken))
|
||||
.thenReturn(Optional.of(event));
|
||||
|
||||
assertThatThrownBy(() -> eventService.cancelEvent(eventToken, organizerToken, true, null))
|
||||
.isInstanceOf(EventAlreadyCancelledException.class);
|
||||
|
||||
verify(eventRepository, never()).save(any());
|
||||
}
|
||||
}
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -18,6 +18,14 @@
|
||||
--color-card: #ffffff;
|
||||
--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 */
|
||||
--color-glass: rgba(255, 255, 255, 0.1);
|
||||
--color-glass-strong: rgba(255, 255, 255, 0.15);
|
||||
|
||||
@@ -25,6 +25,12 @@
|
||||
|
||||
<!-- Loaded state -->
|
||||
<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>
|
||||
|
||||
<dl class="detail__meta">
|
||||
@@ -70,14 +76,49 @@
|
||||
</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">
|
||||
<p>{{ cancelError }}</p>
|
||||
</div>
|
||||
|
||||
<!-- RSVP bar -->
|
||||
<!-- RSVP bar (hidden when cancelled) -->
|
||||
<RsvpBar
|
||||
v-if="state === 'loaded' && event && !isOrganizer"
|
||||
v-if="state === 'loaded' && event && !isOrganizer && !event.cancelled"
|
||||
:has-rsvp="!!rsvpName"
|
||||
@open="sheetOpen = true"
|
||||
@cancel="confirmCancelOpen = true"
|
||||
@@ -155,6 +196,12 @@ const cancelError = ref('')
|
||||
const isOrganizer = ref(false)
|
||||
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(() => {
|
||||
if (!event.value) return ''
|
||||
const formatted = new Intl.DateTimeFormat(undefined, {
|
||||
@@ -279,6 +326,40 @@ 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) {
|
||||
try {
|
||||
const { data, error } = await api.GET('/events/{eventToken}/attendees', {
|
||||
@@ -521,4 +602,105 @@ onMounted(fetchEvent)
|
||||
opacity: 0.6;
|
||||
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>
|
||||
|
||||
@@ -19,11 +19,11 @@
|
||||
|
||||
**Purpose**: OpenAPI spec, database migration, and domain model changes that both user stories depend on.
|
||||
|
||||
- [ ] T001 Update OpenAPI spec with PATCH endpoint on `/events/{eventToken}`, PatchEventRequest schema (`organizerToken`, `cancelled`, `cancellationReason`), and extend GetEventResponse with `cancelled`/`cancellationReason` fields in `backend/src/main/resources/openapi/api.yaml`
|
||||
- [ ] 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`
|
||||
- [ ] 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/application/service/` following the pattern of existing exceptions. Domain model: `backend/src/main/java/de/fete/domain/model/Event.java`
|
||||
- [ ] 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/EventJpaEntity.java`
|
||||
- [ ] T005 Regenerate frontend TypeScript types from updated OpenAPI spec via `cd frontend && npm run generate-types` (or equivalent openapi-typescript command)
|
||||
- [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.
|
||||
|
||||
@@ -39,20 +39,20 @@
|
||||
|
||||
> **Write these tests FIRST — ensure they FAIL before implementation**
|
||||
|
||||
- [ ] T006 [P] [US1] Write unit test for `cancel()` domain method (happy path, already-cancelled throws) in `backend/src/test/java/de/fete/domain/model/EventTest.java`
|
||||
- [ ] 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`
|
||||
- [ ] T008 [P] [US1] Write integration test 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/EventControllerCancelTest.java`
|
||||
- [ ] 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`
|
||||
- [ ] T010 [P] [US1] Write E2E test: organizer cancels without reason — event shows as cancelled in `frontend/e2e/cancel-event.spec.ts`
|
||||
- [ ] 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`
|
||||
- [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
|
||||
|
||||
- [ ] T012 [US1] Create `CancelEventUseCase` interface (or add method to existing use case interface) in `backend/src/main/java/de/fete/domain/port/in/`
|
||||
- [ ] 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`
|
||||
- [ ] T014 [US1] Implement `cancelEvent` endpoint in `EventController.java` — PATCH handler, request body binding, error mapping (403/404/409) in `backend/src/main/java/de/fete/adapter/in/web/EventController.java`
|
||||
- [ ] 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`
|
||||
- [ ] 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`
|
||||
- [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.
|
||||
|
||||
@@ -68,18 +68,18 @@
|
||||
|
||||
> **Write these tests FIRST — ensure they FAIL before implementation**
|
||||
|
||||
- [ ] T017 [P] [US2] Write unit test for RSVP creation rejection on cancelled events (409 Conflict) in `backend/src/test/java/de/fete/application/service/RsvpServiceCancelledTest.java`
|
||||
- [ ] 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/RsvpControllerCancelledTest.java`
|
||||
- [ ] T019 [P] [US2] Write E2E test: visitor sees red banner with cancellation reason on cancelled event in `frontend/e2e/cancelled-event-visitor.spec.ts`
|
||||
- [ ] 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`
|
||||
- [ ] T021 [P] [US2] Write E2E test: RSVP buttons hidden on cancelled event, other details remain visible in `frontend/e2e/cancelled-event-visitor.spec.ts`
|
||||
- [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
|
||||
|
||||
- [ ] 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`
|
||||
- [ ] T023 [US2] Add cancellation banner component/section (red, prominent, includes reason if present, WCAG AA contrast) to `frontend/src/views/EventDetailView.vue`
|
||||
- [ ] T024 [US2] Hide RSVP buttons (`RsvpBar` or equivalent) when `event.cancelled === true` in `frontend/src/views/EventDetailView.vue`
|
||||
- [ ] ~~T025~~ Merged into T015 (cancel button v-if already handles FR-012)
|
||||
- [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.
|
||||
|
||||
@@ -89,79 +89,8 @@
|
||||
|
||||
**Purpose**: Validation, edge cases, and final cleanup.
|
||||
|
||||
- [ ] 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)
|
||||
- [ ] T027 Run full backend test suite (`cd backend && ./mvnw verify`) and fix any failures
|
||||
- [ ] T028 Run full frontend test suite (`cd frontend && npm run test:unit`) and fix any failures
|
||||
- [ ] T029 Run E2E tests (`cd frontend && npx playwright test`) and fix any failures
|
||||
- [ ] T030 Run backend checkstyle (`cd backend && ./mvnw checkstyle:check`) and fix violations
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies — start immediately
|
||||
- **US1 (Phase 2)**: Depends on Setup completion
|
||||
- **US2 (Phase 3)**: Depends on Setup completion; backend RSVP guard is independent of US1, but frontend banner/hiding can be built in parallel with US1
|
||||
- **Polish (Phase 4)**: Depends on all user stories complete
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1 (P1)**: Can start after Phase 1 (Setup). No dependency on US2.
|
||||
- **US2 (P1)**: Can start after Phase 1 (Setup). Backend RSVP guard is independent of US1. Frontend banner/hiding only needs the `cancelled`/`cancellationReason` fields in GetEventResponse (from Setup).
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Tests MUST be written and FAIL before implementation
|
||||
- Domain model before service layer
|
||||
- Service layer before controller/endpoint
|
||||
- Backend before frontend (API must exist before UI wires to it)
|
||||
- Core implementation before error handling polish
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- T003 + T004 (domain model + JPA entity) can run in parallel
|
||||
- All US1 test tasks (T006–T011) can run in parallel
|
||||
- All US2 test tasks (T017–T021) can run in parallel
|
||||
- US1 backend (T012–T014) and US2 backend (T022) can run in parallel after Setup
|
||||
- US1 frontend (T015–T016) and US2 frontend (T023–T025) can run in parallel if backend is ready
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Launch all US1 tests together (TDD - write first, expect failures):
|
||||
Task: T006 "Unit test for cancel() domain method"
|
||||
Task: T007 "Unit test for cancel use case in EventService"
|
||||
Task: T008 "Integration test for PATCH /events/{eventToken}"
|
||||
Task: T009 "E2E test: organizer cancels with reason"
|
||||
Task: T010 "E2E test: organizer cancels without reason"
|
||||
Task: T011 "E2E test: cancel API failure handling"
|
||||
|
||||
# Then implement sequentially:
|
||||
Task: T012 "CancelEventUseCase interface"
|
||||
Task: T013 "EventService cancel implementation"
|
||||
Task: T014 "EventController cancelEvent endpoint"
|
||||
Task: T015 "Cancel button + bottom sheet in EventDetailView"
|
||||
Task: T016 "Wire cancel action to API"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Complete Phase 1: Setup (OpenAPI, migration, domain model)
|
||||
2. Complete Phase 2: US1 — Organizer cancels event
|
||||
3. **STOP and VALIDATE**: All US1 acceptance scenarios pass
|
||||
4. Deploy/demo if ready — cancellation works end-to-end
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Setup → Shared infrastructure ready
|
||||
2. US1 → Organizer can cancel → Test → Deploy (MVP!)
|
||||
3. US2 → Attendees see cancellation + RSVP blocked → Test → Deploy
|
||||
4. Polish → Full verification, checkstyle, edge cases
|
||||
- [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