Implement cancel-event feature (016)
Add PATCH /events/{eventToken} endpoint for organizers to cancel events,
cancellation banner for visitors, and RSVP rejection on cancelled events.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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.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.adapter.in.web.model.PatchEventRequest;
|
||||||
import de.fete.application.service.EventNotFoundException;
|
import de.fete.application.service.EventNotFoundException;
|
||||||
import de.fete.application.service.InvalidTimezoneException;
|
import de.fete.application.service.InvalidTimezoneException;
|
||||||
import de.fete.domain.model.CreateEventCommand;
|
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.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
|
||||||
@@ -97,10 +102,23 @@ public class EventController implements EventsApi {
|
|||||||
response.setLocation(event.getLocation());
|
response.setLocation(event.getLocation());
|
||||||
response.setAttendeeCount(
|
response.setAttendeeCount(
|
||||||
(int) countAttendeesByEventUseCase.countByEvent(evtToken));
|
(int) countAttendeesByEventUseCase.countByEvent(evtToken));
|
||||||
|
response.setCancelled(event.isCancelled());
|
||||||
|
response.setCancellationReason(event.getCancellationReason());
|
||||||
|
|
||||||
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 eventToken, UUID organizerToken) {
|
UUID eventToken, UUID organizerToken) {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package de.fete.adapter.in.web;
|
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.EventExpiredException;
|
||||||
import de.fete.application.service.EventNotFoundException;
|
import de.fete.application.service.EventNotFoundException;
|
||||||
import de.fete.application.service.ExpiryDateBeforeEventException;
|
import de.fete.application.service.ExpiryDateBeforeEventException;
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ public class EventPersistenceAdapter implements EventRepository {
|
|||||||
entity.setLocation(event.getLocation());
|
entity.setLocation(event.getLocation());
|
||||||
entity.setExpiryDate(event.getExpiryDate());
|
entity.setExpiryDate(event.getExpiryDate());
|
||||||
entity.setCreatedAt(event.getCreatedAt());
|
entity.setCreatedAt(event.getCreatedAt());
|
||||||
|
entity.setCancelled(event.isCancelled());
|
||||||
|
entity.setCancellationReason(event.getCancellationReason());
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +65,8 @@ public class EventPersistenceAdapter implements EventRepository {
|
|||||||
event.setLocation(entity.getLocation());
|
event.setLocation(entity.getLocation());
|
||||||
event.setExpiryDate(entity.getExpiryDate());
|
event.setExpiryDate(entity.getExpiryDate());
|
||||||
event.setCreatedAt(entity.getCreatedAt());
|
event.setCreatedAt(entity.getCreatedAt());
|
||||||
|
event.setCancelled(entity.isCancelled());
|
||||||
|
event.setCancellationReason(entity.getCancellationReason());
|
||||||
return event;
|
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.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;
|
||||||
|
|
||||||
@@ -50,4 +52,29 @@ 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.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)
|
Event event = eventRepository.findByEventToken(eventToken)
|
||||||
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
|
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
|
||||||
|
|
||||||
|
if (event.isCancelled()) {
|
||||||
|
throw new EventCancelledException(eventToken.value());
|
||||||
|
}
|
||||||
|
|
||||||
if (!event.getExpiryDate().isAfter(LocalDate.now(clock))) {
|
if (!event.getExpiryDate().isAfter(LocalDate.now(clock))) {
|
||||||
throw new EventExpiredException(eventToken.value());
|
throw new EventExpiredException(eventToken.value());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ public class Event {
|
|||||||
private String location;
|
private String location;
|
||||||
private LocalDate expiryDate;
|
private LocalDate expiryDate;
|
||||||
private OffsetDateTime createdAt;
|
private OffsetDateTime createdAt;
|
||||||
|
private boolean cancelled;
|
||||||
|
private String cancellationReason;
|
||||||
|
|
||||||
/** Returns the internal database ID. */
|
/** Returns the internal database ID. */
|
||||||
public Long getId() {
|
public Long getId() {
|
||||||
@@ -117,4 +119,24 @@ public class Event {
|
|||||||
public void setCreatedAt(OffsetDateTime createdAt) {
|
public void setCreatedAt(OffsetDateTime createdAt) {
|
||||||
this.createdAt = 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/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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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-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);
|
||||||
|
|||||||
@@ -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, {
|
||||||
@@ -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) {
|
async function fetchAttendees(eventToken: string, organizerToken: string) {
|
||||||
try {
|
try {
|
||||||
const { data, error } = await api.GET('/events/{eventToken}/attendees', {
|
const { data, error } = await api.GET('/events/{eventToken}/attendees', {
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -19,11 +19,11 @@
|
|||||||
|
|
||||||
**Purpose**: OpenAPI spec, database migration, and domain model changes that both user stories depend on.
|
**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`
|
- [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`
|
||||||
- [ ] 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] 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`
|
- [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`
|
||||||
- [ ] 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`
|
- [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`
|
||||||
- [ ] T005 Regenerate frontend TypeScript types from updated OpenAPI spec via `cd frontend && npm run generate-types` (or equivalent openapi-typescript command)
|
- [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.
|
**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**
|
> **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`
|
- [X] ~~T006~~ Removed (EventTest.java unnecessary — cancel() tested via service/integration tests)
|
||||||
- [ ] 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] 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`
|
- [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`
|
||||||
- [ ] 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] 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`
|
- [X] 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] 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
|
### 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/`
|
- [X] T012 [US1] Create `UpdateEventUseCase` interface in `backend/src/main/java/de/fete/domain/port/in/UpdateEventUseCase.java`
|
||||||
- [ ] 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] 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`
|
- [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`
|
||||||
- [ ] 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] 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] 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.
|
**Checkpoint**: Organizer can cancel an event. All US1 acceptance scenarios pass.
|
||||||
|
|
||||||
@@ -68,18 +68,18 @@
|
|||||||
|
|
||||||
> **Write these tests FIRST — ensure they FAIL before implementation**
|
> **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`
|
- [X] ~~T017~~ Removed (RsvpServiceCancelledTest unnecessary — covered by integration test)
|
||||||
- [ ] 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`
|
- [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`
|
||||||
- [ ] 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] 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`
|
- [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`
|
||||||
- [ ] 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] 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
|
### 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`
|
- [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`
|
||||||
- [ ] T023 [US2] Add cancellation banner component/section (red, prominent, includes reason if present, WCAG AA contrast) to `frontend/src/views/EventDetailView.vue`
|
- [X] 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`
|
- [X] 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] ~~T025~~ Merged into T015 (cancel button v-if already handles FR-012)
|
||||||
|
|
||||||
**Checkpoint**: Both user stories fully functional. All acceptance scenarios pass.
|
**Checkpoint**: Both user stories fully functional. All acceptance scenarios pass.
|
||||||
|
|
||||||
@@ -89,79 +89,8 @@
|
|||||||
|
|
||||||
**Purpose**: Validation, edge cases, and final cleanup.
|
**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)
|
- [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)
|
||||||
- [ ] T027 Run full backend test suite (`cd backend && ./mvnw verify`) and fix any failures
|
- [X] 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
|
- [X] 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
|
- [X] 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
|
- [X] 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
|
|
||||||
|
|||||||
Reference in New Issue
Block a user