Compare commits
59 Commits
e6711b33d4
...
renovate/m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22b5648dae | ||
| b12106d3bf | |||
| d0ed6790ef | |||
| 92372b6a59 | |||
| 7817ad182b | |||
| 9483e9b1f7 | |||
| 75e6548403 | |||
| d4a1f0dc23 | |||
| 3d7efb14f7 | |||
| 2f8b911af8 | |||
| e9791de4e2 | |||
| 3b4cc7fbb9 | |||
| 9c0e9249ce | |||
| 5082ec1333 | |||
| 35b488a8be | |||
| b067c0ef1e | |||
|
|
42686502d8 | ||
| 51ab99fc61 | |||
| d52f51d6e1 | |||
| c1760ae376 | |||
| 6d51327e56 | |||
| 96044ae1ed | |||
| f972a41e45 | |||
| 13b01dfba8 | |||
| fd8724db8f | |||
| 8885dbd722 | |||
| c51eacb261 | |||
| c450849e4d | |||
| e01d5ee642 | |||
| d333ab3d39 | |||
| 541017965f | |||
| 981920f004 | |||
| 3908c89998 | |||
| bf0f4ffb7f | |||
| 58043d1507 | |||
|
|
264c4ec21f | ||
| 6d7a55fdb3 | |||
| a8aacf4ee9 | |||
| 0a404ecde3 | |||
| 01f9e3dac1 | |||
| ad607afe83 | |||
| f0424223de | |||
| 7ab9068c14 | |||
| 41bb17d5c9 | |||
|
|
a44b938f08 | ||
|
|
7477a953c5 | ||
|
|
7fb296b47f | ||
|
|
8ab7d345c8 | ||
|
|
cf2139f229 | ||
|
|
79f33d659c | ||
| e5b71f8fb8 | |||
|
|
60649ae4de | ||
| e90aefae15 | |||
|
|
622932418d | ||
| a1855ff8d6 | |||
| 4bfaee685c | |||
| 2a6a658df9 | |||
| 37d378ca59 | |||
| 0441ca0c33 |
@@ -84,31 +84,12 @@ Die folgenden Punkte wurden in Diskussionen bereits geklärt und sind verbindlic
|
||||
* (derzeit keine offenen Architekturentscheidungen)
|
||||
|
||||
## Nicht umgesetzte Feature-Ideen (ehemals Specs 009–026)
|
||||
|
||||
### 009 – Gästeliste
|
||||
Organisator sieht alle RSVPs (Name, Status) und kann einzelne Einträge löschen.
|
||||
* Nur mit gültigem Organizer-Token sichtbar
|
||||
* Gäste ohne Token sehen keine Gästeliste
|
||||
* Löschung serverseitig validiert
|
||||
|
||||
### 010 – Event bearbeiten
|
||||
Organisator kann Titel, Beschreibung, Datum, Ort und Ablaufdatum ändern.
|
||||
* Formular vorausgefüllt mit aktuellen Werten
|
||||
* Ablaufdatum muss in der Zukunft liegen
|
||||
* Ohne Organizer-Token kein Edit-UI sichtbar
|
||||
|
||||
### 011 – Event merken/bookmarken
|
||||
Gäste können Events lokal merken, ohne RSVP abzugeben — rein clientseitig via localStorage.
|
||||
* Kein Serverkontakt nötig
|
||||
* Unabhängig vom RSVP-Status
|
||||
* Auch bei abgelaufenen Events möglich
|
||||
|
||||
### 012 – Lokale Event-Übersicht
|
||||
Startseite (`/`) zeigt alle getrackten Events (erstellt, zugesagt, gemerkt) aus localStorage.
|
||||
* Zeigt Titel, Datum, Beziehungstyp (Organisator/Gast/Gemerkt)
|
||||
* Vergangene Events als "beendet" markiert
|
||||
* Einträge können entfernt werden
|
||||
|
||||
### 013 – Kalender-Export
|
||||
.ics-Download (RFC 5545) mit Event-Details, optional webcal:// für Live-Updates.
|
||||
* Stabile UID aus Event-Token (Re-Import aktualisiert statt dupliziert)
|
||||
@@ -137,19 +118,6 @@ Badge/Indikator bei ungelesenen Organisator-Updates, rein clientseitig via local
|
||||
Event-Seite zeigt QR-Code mit der öffentlichen Event-URL.
|
||||
* Serverseitig generiert (kein externer QR-Service)
|
||||
* Download als SVG oder hochauflösendes PNG
|
||||
* Auch bei abgelaufenen Events verfügbar
|
||||
|
||||
### 018 – Datenlöschung
|
||||
Automatische Löschung aller Event-Daten nach Ablaufdatum (Privacy-Garantie).
|
||||
* Scheduled Job oder Lazy Cleanup bei Zugriff
|
||||
* Löscht Event, RSVPs, Updates, Bilder, Metadaten
|
||||
* Idempotent, kein PII im Log
|
||||
|
||||
### 019 – Instanz-Limit
|
||||
`MAX_ACTIVE_EVENTS` als Env-Variable begrenzt aktive Events für Self-Hoster.
|
||||
* Nur nicht-abgelaufene Events zählen
|
||||
* Unset/leer = unbegrenzt
|
||||
* Serverseitige Durchsetzung bei Event-Erstellung
|
||||
|
||||
### 020 – PWA
|
||||
Web App Manifest + Service Worker für Installierbarkeit und Offline-Caching.
|
||||
@@ -169,26 +137,11 @@ Organisator sucht Headerbild über integrierte Unsplash-Suche.
|
||||
* Bild lokal gespeichert + Unsplash-Attribution
|
||||
* Feature deaktiviert wenn kein API-Key konfiguriert
|
||||
|
||||
### 023 – Dark Mode
|
||||
App erkennt `prefers-color-scheme` und bietet manuellen Toggle.
|
||||
* Manuelle Auswahl in localStorage gespeichert
|
||||
* Gilt für globales App-Chrome, nicht Event-Farbthemen
|
||||
* Beide Modi WCAG AA konform
|
||||
|
||||
### 024 – Event absagen
|
||||
Organisator kann Event absagen (mit optionaler Nachricht, Einweg-Transition).
|
||||
* RSVPs werden nach Absage abgelehnt
|
||||
* Absage-Nachricht nachträglich editierbar
|
||||
* Kann nicht rückgängig gemacht werden
|
||||
|
||||
### 025 – Event löschen
|
||||
Organisator löscht Event permanent und unwiderruflich.
|
||||
* Entfernt alle zugehörigen Daten sofort
|
||||
* localStorage-Eintrag wird entfernt, Redirect zu `/`
|
||||
* Funktioniert in jedem Event-Status
|
||||
|
||||
### 026 – 404-Seite
|
||||
Catch-all Route für ungültige Pfade mit "Seite nicht gefunden" und Link zur Startseite.
|
||||
* Folgt dem Design System (Electric Dusk + Sora)
|
||||
* WCAG AA konform
|
||||
* Verhindert leere Seiten bei Fehlnavigation
|
||||
|
||||
### 027 - Update der EventListe
|
||||
* Irgendwie ein update der event liste, wenn man sie betritt oder wenn man mit touch die seite nach unten zieht (hier müssen wir noch überlegen, wie wir mit den verschiedenen update fällen umgehen und wie wir das update überhaupt requesten. Ich meine sowas wie: was ist, wenn das event nicht mehr gefunden wurde?)
|
||||
|
||||
@@ -51,10 +51,8 @@ The following skills are available and should be used for their respective purpo
|
||||
- Project specifications (user stories, setup tasks, personas, etc.) live in `specs/` (feature dirs) and `.specify/memory/` (cross-cutting docs).
|
||||
|
||||
## Active Technologies
|
||||
- Java 25 (backend), TypeScript 5.9 (frontend) + Spring Boot 3.5.x, Vue 3, Vue Router 5, openapi-fetch, openapi-typescript (007-view-event)
|
||||
- PostgreSQL (JPA via Spring Data, Liquibase migrations) (007-view-event)
|
||||
- TypeScript 5.9 (frontend only) + Vue 3, Vue Router 5 (existing — no additions) (010-event-list-grouping)
|
||||
- localStorage via `useEventStorage.ts` composable (existing — no changes) (010-event-list-grouping)
|
||||
- TypeScript 5.x, Vue 3 (Composition API) + openapi-fetch, Vue Router, Vite (018-cancel-event-list)
|
||||
- localStorage via `useEventStorage()` composable (018-cancel-event-list)
|
||||
|
||||
## Recent Changes
|
||||
- 007-view-event: Added Java 25 (backend), TypeScript 5.9 (frontend) + Spring Boot 3.5.x, Vue 3, Vue Router 5, openapi-fetch, openapi-typescript
|
||||
- 018-cancel-event-list: Added TypeScript 5.x, Vue 3 (Composition API) + openapi-fetch, Vue Router, Vite
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Stage 1: Build frontend
|
||||
FROM node:24-alpine AS frontend-build
|
||||
FROM node:25-alpine AS frontend-build
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
wrapperVersion=3.3.4
|
||||
distributionType=only-script
|
||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.13/apache-maven-3.9.13-bin.zip
|
||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.14/apache-maven-3.9.14-bin.zip
|
||||
|
||||
@@ -7,4 +7,8 @@
|
||||
<Match>
|
||||
<Package name="de.fete.adapter.in.web.model"/>
|
||||
</Match>
|
||||
<!-- Constructor-injected Spring beans storing interfaces/proxies are not a real exposure risk -->
|
||||
<Match>
|
||||
<Bug pattern="EI_EXPOSE_REP2"/>
|
||||
</Match>
|
||||
</FindBugsFilter>
|
||||
|
||||
@@ -2,9 +2,11 @@ package de.fete;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/** Spring Boot entry point for the fete application. */
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
public class FeteApplication {
|
||||
|
||||
/** Starts the application. */
|
||||
|
||||
@@ -8,21 +8,23 @@ 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.application.service.EventNotFoundException;
|
||||
import de.fete.application.service.InvalidTimezoneException;
|
||||
import de.fete.adapter.in.web.model.PatchEventRequest;
|
||||
import de.fete.application.service.exception.EventNotFoundException;
|
||||
import de.fete.application.service.exception.InvalidTimezoneException;
|
||||
import de.fete.domain.model.CreateEventCommand;
|
||||
import de.fete.domain.model.Event;
|
||||
import de.fete.domain.model.EventToken;
|
||||
import de.fete.domain.model.OrganizerToken;
|
||||
import de.fete.domain.model.Rsvp;
|
||||
import de.fete.domain.model.RsvpToken;
|
||||
import de.fete.domain.port.in.CancelRsvpUseCase;
|
||||
import de.fete.domain.port.in.CountAttendeesByEventUseCase;
|
||||
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 java.time.Clock;
|
||||
import de.fete.domain.port.in.UpdateEventUseCase;
|
||||
import java.time.DateTimeException;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
@@ -37,24 +39,27 @@ public class EventController implements EventsApi {
|
||||
private final CreateEventUseCase createEventUseCase;
|
||||
private final GetEventUseCase getEventUseCase;
|
||||
private final CreateRsvpUseCase createRsvpUseCase;
|
||||
private final CancelRsvpUseCase cancelRsvpUseCase;
|
||||
private final CountAttendeesByEventUseCase countAttendeesByEventUseCase;
|
||||
private final GetAttendeesUseCase getAttendeesUseCase;
|
||||
private final Clock clock;
|
||||
private final UpdateEventUseCase updateEventUseCase;
|
||||
|
||||
/** Creates a new controller with the given use cases and clock. */
|
||||
/** Creates a new controller with the given use cases. */
|
||||
public EventController(
|
||||
CreateEventUseCase createEventUseCase,
|
||||
GetEventUseCase getEventUseCase,
|
||||
CreateRsvpUseCase createRsvpUseCase,
|
||||
CancelRsvpUseCase cancelRsvpUseCase,
|
||||
CountAttendeesByEventUseCase countAttendeesByEventUseCase,
|
||||
GetAttendeesUseCase getAttendeesUseCase,
|
||||
Clock clock) {
|
||||
UpdateEventUseCase updateEventUseCase) {
|
||||
this.createEventUseCase = createEventUseCase;
|
||||
this.getEventUseCase = getEventUseCase;
|
||||
this.createRsvpUseCase = createRsvpUseCase;
|
||||
this.cancelRsvpUseCase = cancelRsvpUseCase;
|
||||
this.countAttendeesByEventUseCase = countAttendeesByEventUseCase;
|
||||
this.getAttendeesUseCase = getAttendeesUseCase;
|
||||
this.clock = clock;
|
||||
this.updateEventUseCase = updateEventUseCase;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -67,52 +72,61 @@ public class EventController implements EventsApi {
|
||||
request.getDescription(),
|
||||
request.getDateTime(),
|
||||
zoneId,
|
||||
request.getLocation(),
|
||||
request.getExpiryDate()
|
||||
request.getLocation()
|
||||
);
|
||||
|
||||
Event event = createEventUseCase.createEvent(command);
|
||||
|
||||
var response = new CreateEventResponse();
|
||||
response.setEventToken(event.getEventToken().value());
|
||||
response.setOrganizerToken(event.getOrganizerToken().value());
|
||||
response.setTitle(event.getTitle());
|
||||
response.setDateTime(event.getDateTime());
|
||||
response.setTimezone(event.getTimezone().getId());
|
||||
response.setExpiryDate(event.getExpiryDate());
|
||||
response.setEventToken(event.eventToken().value());
|
||||
response.setOrganizerToken(event.organizerToken().value());
|
||||
response.setTitle(event.title());
|
||||
response.setDateTime(event.dateTime());
|
||||
response.setTimezone(event.timezone().getId());
|
||||
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseEntity<GetEventResponse> getEvent(UUID token) {
|
||||
var eventToken = new de.fete.domain.model.EventToken(token);
|
||||
Event event = getEventUseCase.getByEventToken(eventToken)
|
||||
.orElseThrow(() -> new EventNotFoundException(token));
|
||||
public ResponseEntity<GetEventResponse> getEvent(UUID eventToken) {
|
||||
var evtToken = new EventToken(eventToken);
|
||||
Event event = getEventUseCase.getByEventToken(evtToken)
|
||||
.orElseThrow(() -> new EventNotFoundException(eventToken));
|
||||
|
||||
var response = new GetEventResponse();
|
||||
response.setEventToken(event.getEventToken().value());
|
||||
response.setTitle(event.getTitle());
|
||||
response.setDescription(event.getDescription());
|
||||
response.setDateTime(event.getDateTime());
|
||||
response.setTimezone(event.getTimezone().getId());
|
||||
response.setLocation(event.getLocation());
|
||||
response.setEventToken(event.eventToken().value());
|
||||
response.setTitle(event.title());
|
||||
response.setDescription(event.description());
|
||||
response.setDateTime(event.dateTime());
|
||||
response.setTimezone(event.timezone().getId());
|
||||
response.setLocation(event.location());
|
||||
response.setAttendeeCount(
|
||||
(int) countAttendeesByEventUseCase.countByEvent(eventToken));
|
||||
response.setExpired(
|
||||
event.getExpiryDate().isBefore(LocalDate.now(clock)));
|
||||
(int) countAttendeesByEventUseCase.countByEvent(evtToken));
|
||||
response.setCancelled(event.cancelled());
|
||||
response.setCancellationReason(event.cancellationReason());
|
||||
|
||||
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 token, UUID organizerToken) {
|
||||
var eventToken = new EventToken(token);
|
||||
UUID eventToken, UUID organizerToken) {
|
||||
var evtToken = new EventToken(eventToken);
|
||||
var orgToken = new OrganizerToken(organizerToken);
|
||||
|
||||
List<String> names = getAttendeesUseCase
|
||||
.getAttendeeNames(eventToken, orgToken);
|
||||
.getAttendeeNames(evtToken, orgToken);
|
||||
|
||||
var attendees = names.stream()
|
||||
.map(name -> new Attendee().name(name))
|
||||
@@ -126,17 +140,23 @@ public class EventController implements EventsApi {
|
||||
|
||||
@Override
|
||||
public ResponseEntity<CreateRsvpResponse> createRsvp(
|
||||
UUID token, CreateRsvpRequest createRsvpRequest) {
|
||||
var eventToken = new EventToken(token);
|
||||
Rsvp rsvp = createRsvpUseCase.createRsvp(eventToken, createRsvpRequest.getName());
|
||||
UUID eventToken, CreateRsvpRequest createRsvpRequest) {
|
||||
var evtToken = new EventToken(eventToken);
|
||||
Rsvp rsvp = createRsvpUseCase.createRsvp(evtToken, createRsvpRequest.getName());
|
||||
|
||||
var response = new CreateRsvpResponse();
|
||||
response.setRsvpToken(rsvp.getRsvpToken().value());
|
||||
response.setName(rsvp.getName());
|
||||
response.setRsvpToken(rsvp.rsvpToken().value());
|
||||
response.setName(rsvp.name());
|
||||
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseEntity<Void> cancelRsvp(UUID eventToken, UUID rsvpToken) {
|
||||
cancelRsvpUseCase.cancelRsvp(new EventToken(eventToken), new RsvpToken(rsvpToken));
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
private static ZoneId parseTimezone(String timezone) {
|
||||
try {
|
||||
return ZoneId.of(timezone);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package de.fete.adapter.in.web;
|
||||
|
||||
import de.fete.application.service.EventExpiredException;
|
||||
import de.fete.application.service.EventNotFoundException;
|
||||
import de.fete.application.service.ExpiryDateBeforeEventException;
|
||||
import de.fete.application.service.ExpiryDateInPastException;
|
||||
import de.fete.application.service.InvalidOrganizerTokenException;
|
||||
import de.fete.application.service.InvalidTimezoneException;
|
||||
import de.fete.application.service.exception.EventAlreadyCancelledException;
|
||||
import de.fete.application.service.exception.EventCancelledException;
|
||||
import de.fete.application.service.exception.EventExpiredException;
|
||||
import de.fete.application.service.exception.EventNotFoundException;
|
||||
import de.fete.application.service.exception.ExpiryDateBeforeEventException;
|
||||
import de.fete.application.service.exception.ExpiryDateInPastException;
|
||||
import de.fete.application.service.exception.InvalidOrganizerTokenException;
|
||||
import de.fete.application.service.exception.InvalidTimezoneException;
|
||||
import java.net.URI;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -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(
|
||||
|
||||
@@ -68,17 +68,17 @@ public class SpaController {
|
||||
|
||||
/** Serves SPA HTML with event-specific meta-tags. */
|
||||
@GetMapping(
|
||||
value = "/events/{token}",
|
||||
value = "/events/{eventToken}",
|
||||
produces = MediaType.TEXT_HTML_VALUE
|
||||
)
|
||||
@ResponseBody
|
||||
public String serveEventPage(@PathVariable String token,
|
||||
public String serveEventPage(@PathVariable String eventToken,
|
||||
HttpServletRequest request) {
|
||||
if (htmlTemplate == null) {
|
||||
return "";
|
||||
}
|
||||
String baseUrl = getBaseUrl(request);
|
||||
Map<String, String> meta = resolveEventMeta(token, baseUrl);
|
||||
Map<String, String> meta = resolveEventMeta(eventToken, baseUrl);
|
||||
return htmlTemplate.replace(PLACEHOLDER, renderTags(meta));
|
||||
}
|
||||
|
||||
@@ -86,11 +86,11 @@ public class SpaController {
|
||||
|
||||
private Map<String, String> buildEventMeta(Event event, String baseUrl) {
|
||||
var tags = new LinkedHashMap<String, String>();
|
||||
String title = truncateTitle(event.getTitle());
|
||||
String title = truncateTitle(event.title());
|
||||
String description = formatDescription(event);
|
||||
tags.put("og:title", title);
|
||||
tags.put("og:description", description);
|
||||
tags.put("og:url", baseUrl + "/events/" + event.getEventToken().value());
|
||||
tags.put("og:url", baseUrl + "/events/" + event.eventToken().value());
|
||||
tags.put("og:type", "website");
|
||||
tags.put("og:site_name", GENERIC_TITLE);
|
||||
tags.put("og:image", baseUrl + "/og-image.png");
|
||||
@@ -138,16 +138,16 @@ public class SpaController {
|
||||
}
|
||||
|
||||
private String formatDescription(Event event) {
|
||||
ZonedDateTime zoned = event.getDateTime().atZoneSameInstant(event.getTimezone());
|
||||
ZonedDateTime zoned = event.dateTime().atZoneSameInstant(event.timezone());
|
||||
var sb = new StringBuilder();
|
||||
sb.append("📅 ").append(zoned.format(DATE_FORMAT));
|
||||
|
||||
if (event.getLocation() != null && !event.getLocation().isBlank()) {
|
||||
sb.append(" · 📍 ").append(event.getLocation());
|
||||
if (event.location() != null && !event.location().isBlank()) {
|
||||
sb.append(" · 📍 ").append(event.location());
|
||||
}
|
||||
|
||||
if (event.getDescription() != null && !event.getDescription().isBlank()) {
|
||||
sb.append(" — ").append(event.getDescription());
|
||||
if (event.description() != null && !event.description().isBlank()) {
|
||||
sb.append(" — ").append(event.description());
|
||||
}
|
||||
|
||||
String result = sb.toString();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,17 @@ package de.fete.adapter.out.persistence;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
|
||||
/** Spring Data JPA repository for event entities. */
|
||||
public interface EventJpaRepository extends JpaRepository<EventJpaEntity, Long> {
|
||||
|
||||
/** Finds an event by its public event token. */
|
||||
Optional<EventJpaEntity> findByEventToken(UUID eventToken);
|
||||
|
||||
/** Deletes all events whose expiry date is before today. Returns the number of deleted rows. */
|
||||
@Modifying
|
||||
@Query(value = "DELETE FROM events WHERE expiry_date < CURRENT_DATE", nativeQuery = true)
|
||||
int deleteExpired();
|
||||
}
|
||||
|
||||
@@ -31,33 +31,41 @@ public class EventPersistenceAdapter implements EventRepository {
|
||||
return jpaRepository.findByEventToken(eventToken.value()).map(this::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int deleteExpired() {
|
||||
return jpaRepository.deleteExpired();
|
||||
}
|
||||
|
||||
private EventJpaEntity toEntity(Event event) {
|
||||
var entity = new EventJpaEntity();
|
||||
entity.setId(event.getId());
|
||||
entity.setEventToken(event.getEventToken().value());
|
||||
entity.setOrganizerToken(event.getOrganizerToken().value());
|
||||
entity.setTitle(event.getTitle());
|
||||
entity.setDescription(event.getDescription());
|
||||
entity.setDateTime(event.getDateTime());
|
||||
entity.setTimezone(event.getTimezone().getId());
|
||||
entity.setLocation(event.getLocation());
|
||||
entity.setExpiryDate(event.getExpiryDate());
|
||||
entity.setCreatedAt(event.getCreatedAt());
|
||||
entity.setId(event.id());
|
||||
entity.setEventToken(event.eventToken().value());
|
||||
entity.setOrganizerToken(event.organizerToken().value());
|
||||
entity.setTitle(event.title());
|
||||
entity.setDescription(event.description());
|
||||
entity.setDateTime(event.dateTime());
|
||||
entity.setTimezone(event.timezone().getId());
|
||||
entity.setLocation(event.location());
|
||||
entity.setExpiryDate(event.expiryDate());
|
||||
entity.setCreatedAt(event.createdAt());
|
||||
entity.setCancelled(event.cancelled());
|
||||
entity.setCancellationReason(event.cancellationReason());
|
||||
return entity;
|
||||
}
|
||||
|
||||
private Event toDomain(EventJpaEntity entity) {
|
||||
var event = new Event();
|
||||
event.setId(entity.getId());
|
||||
event.setEventToken(new EventToken(entity.getEventToken()));
|
||||
event.setOrganizerToken(new OrganizerToken(entity.getOrganizerToken()));
|
||||
event.setTitle(entity.getTitle());
|
||||
event.setDescription(entity.getDescription());
|
||||
event.setDateTime(entity.getDateTime());
|
||||
event.setTimezone(ZoneId.of(entity.getTimezone()));
|
||||
event.setLocation(entity.getLocation());
|
||||
event.setExpiryDate(entity.getExpiryDate());
|
||||
event.setCreatedAt(entity.getCreatedAt());
|
||||
return event;
|
||||
return new Event(
|
||||
entity.getId(),
|
||||
new EventToken(entity.getEventToken()),
|
||||
new OrganizerToken(entity.getOrganizerToken()),
|
||||
entity.getTitle(),
|
||||
entity.getDescription(),
|
||||
entity.getDateTime(),
|
||||
ZoneId.of(entity.getTimezone()),
|
||||
entity.getLocation(),
|
||||
entity.getExpiryDate(),
|
||||
entity.getCreatedAt(),
|
||||
entity.isCancelled(),
|
||||
entity.getCancellationReason());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,4 +14,7 @@ public interface RsvpJpaRepository extends JpaRepository<RsvpJpaEntity, Long> {
|
||||
|
||||
/** Finds all RSVPs for the given event, ordered by ID ascending. */
|
||||
java.util.List<RsvpJpaEntity> findAllByEventIdOrderByIdAsc(Long eventId);
|
||||
|
||||
/** Deletes an RSVP by event ID and RSVP token. Returns count of deleted rows. */
|
||||
long deleteByEventIdAndRsvpToken(Long eventId, UUID rsvpToken);
|
||||
}
|
||||
|
||||
@@ -36,21 +36,25 @@ public class RsvpPersistenceAdapter implements RsvpRepository {
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteByEventIdAndRsvpToken(Long eventId, RsvpToken rsvpToken) {
|
||||
return jpaRepository.deleteByEventIdAndRsvpToken(eventId, rsvpToken.value()) > 0;
|
||||
}
|
||||
|
||||
private RsvpJpaEntity toEntity(Rsvp rsvp) {
|
||||
var entity = new RsvpJpaEntity();
|
||||
entity.setId(rsvp.getId());
|
||||
entity.setRsvpToken(rsvp.getRsvpToken().value());
|
||||
entity.setEventId(rsvp.getEventId());
|
||||
entity.setName(rsvp.getName());
|
||||
entity.setId(rsvp.id());
|
||||
entity.setRsvpToken(rsvp.rsvpToken().value());
|
||||
entity.setEventId(rsvp.eventId());
|
||||
entity.setName(rsvp.name());
|
||||
return entity;
|
||||
}
|
||||
|
||||
private Rsvp toDomain(RsvpJpaEntity entity) {
|
||||
var rsvp = new Rsvp();
|
||||
rsvp.setId(entity.getId());
|
||||
rsvp.setRsvpToken(new RsvpToken(entity.getRsvpToken()));
|
||||
rsvp.setEventId(entity.getEventId());
|
||||
rsvp.setName(entity.getName());
|
||||
return rsvp;
|
||||
return new Rsvp(
|
||||
entity.getId(),
|
||||
new RsvpToken(entity.getRsvpToken()),
|
||||
entity.getEventId(),
|
||||
entity.getName());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
package de.fete.application.service;
|
||||
|
||||
import de.fete.application.service.exception.EventAlreadyCancelledException;
|
||||
import de.fete.application.service.exception.EventNotFoundException;
|
||||
import de.fete.application.service.exception.InvalidOrganizerTokenException;
|
||||
import de.fete.domain.model.CreateEventCommand;
|
||||
import de.fete.domain.model.Event;
|
||||
import de.fete.domain.model.EventToken;
|
||||
import de.fete.domain.model.OrganizerToken;
|
||||
import de.fete.domain.port.in.CreateEventUseCase;
|
||||
import de.fete.domain.port.in.GetEventUseCase;
|
||||
import de.fete.domain.port.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;
|
||||
|
||||
private final EventRepository eventRepository;
|
||||
private final Clock clock;
|
||||
@@ -28,24 +35,21 @@ public class EventService implements CreateEventUseCase, GetEventUseCase {
|
||||
|
||||
@Override
|
||||
public Event createEvent(CreateEventCommand command) {
|
||||
if (!command.expiryDate().isAfter(LocalDate.now(clock))) {
|
||||
throw new ExpiryDateInPastException(command.expiryDate());
|
||||
}
|
||||
LocalDate expiryDate = command.dateTime().toLocalDate().plusDays(EXPIRY_DAYS_AFTER_EVENT);
|
||||
|
||||
if (!command.expiryDate().isAfter(command.dateTime().toLocalDate())) {
|
||||
throw new ExpiryDateBeforeEventException(command.expiryDate(), command.dateTime());
|
||||
}
|
||||
|
||||
var event = new Event();
|
||||
event.setEventToken(EventToken.generate());
|
||||
event.setOrganizerToken(OrganizerToken.generate());
|
||||
event.setTitle(command.title());
|
||||
event.setDescription(command.description());
|
||||
event.setDateTime(command.dateTime());
|
||||
event.setTimezone(command.timezone());
|
||||
event.setLocation(command.location());
|
||||
event.setExpiryDate(command.expiryDate());
|
||||
event.setCreatedAt(OffsetDateTime.now(clock));
|
||||
var event = new Event(
|
||||
null,
|
||||
EventToken.generate(),
|
||||
OrganizerToken.generate(),
|
||||
command.title(),
|
||||
command.description(),
|
||||
command.dateTime(),
|
||||
command.timezone(),
|
||||
command.location(),
|
||||
expiryDate,
|
||||
OffsetDateTime.now(clock),
|
||||
false,
|
||||
null);
|
||||
|
||||
return eventRepository.save(event);
|
||||
}
|
||||
@@ -54,4 +58,27 @@ 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.organizerToken().equals(organizerToken)) {
|
||||
throw new InvalidOrganizerTokenException();
|
||||
}
|
||||
|
||||
if (event.cancelled()) {
|
||||
throw new EventAlreadyCancelledException(eventToken.value());
|
||||
}
|
||||
|
||||
eventRepository.save(event.withCancellation(true, reason));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package de.fete.application.service;
|
||||
|
||||
import de.fete.domain.port.out.EventRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/** Scheduled job that deletes events whose expiry date is in the past. */
|
||||
@Component
|
||||
public class ExpiredEventCleanupJob {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ExpiredEventCleanupJob.class);
|
||||
|
||||
private final EventRepository eventRepository;
|
||||
|
||||
/** Creates a new cleanup job with the given event repository. */
|
||||
public ExpiredEventCleanupJob(EventRepository eventRepository) {
|
||||
this.eventRepository = eventRepository;
|
||||
}
|
||||
|
||||
/** Runs daily at 03:00 and deletes all expired events. */
|
||||
@Scheduled(cron = "0 0 3 * * *")
|
||||
@Transactional
|
||||
public void deleteExpiredEvents() {
|
||||
int deleted = eventRepository.deleteExpired();
|
||||
log.info("Expired event cleanup: deleted {} event(s)", deleted);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,21 @@
|
||||
package de.fete.application.service;
|
||||
|
||||
import de.fete.application.service.exception.EventCancelledException;
|
||||
import de.fete.application.service.exception.EventExpiredException;
|
||||
import de.fete.application.service.exception.EventNotFoundException;
|
||||
import de.fete.application.service.exception.InvalidOrganizerTokenException;
|
||||
import de.fete.domain.model.Event;
|
||||
import de.fete.domain.model.EventToken;
|
||||
import de.fete.domain.model.OrganizerToken;
|
||||
import de.fete.domain.model.Rsvp;
|
||||
import de.fete.domain.model.RsvpToken;
|
||||
import de.fete.domain.port.in.CancelRsvpUseCase;
|
||||
import de.fete.domain.port.in.CountAttendeesByEventUseCase;
|
||||
import de.fete.domain.port.in.CreateRsvpUseCase;
|
||||
import de.fete.domain.port.in.GetAttendeesUseCase;
|
||||
import de.fete.domain.port.out.EventRepository;
|
||||
import de.fete.domain.port.out.RsvpRepository;
|
||||
import jakarta.transaction.Transactional;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
@@ -18,7 +24,8 @@ import org.springframework.stereotype.Service;
|
||||
/** Application service implementing RSVP operations. */
|
||||
@Service
|
||||
public class RsvpService
|
||||
implements CreateRsvpUseCase, CountAttendeesByEventUseCase, GetAttendeesUseCase {
|
||||
implements CreateRsvpUseCase, CancelRsvpUseCase, CountAttendeesByEventUseCase,
|
||||
GetAttendeesUseCase {
|
||||
|
||||
private final EventRepository eventRepository;
|
||||
private final RsvpRepository rsvpRepository;
|
||||
@@ -39,23 +46,32 @@ public class RsvpService
|
||||
Event event = eventRepository.findByEventToken(eventToken)
|
||||
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
|
||||
|
||||
if (!event.getExpiryDate().isAfter(LocalDate.now(clock))) {
|
||||
if (event.cancelled()) {
|
||||
throw new EventCancelledException(eventToken.value());
|
||||
}
|
||||
|
||||
if (!event.expiryDate().isAfter(LocalDate.now(clock))) {
|
||||
throw new EventExpiredException(eventToken.value());
|
||||
}
|
||||
|
||||
var rsvp = new Rsvp();
|
||||
rsvp.setRsvpToken(RsvpToken.generate());
|
||||
rsvp.setEventId(event.getId());
|
||||
rsvp.setName(name.strip());
|
||||
var rsvp = new Rsvp(null, RsvpToken.generate(), event.id(), name.strip());
|
||||
|
||||
return rsvpRepository.save(rsvp);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void cancelRsvp(EventToken eventToken, RsvpToken rsvpToken) {
|
||||
eventRepository.findByEventToken(eventToken)
|
||||
.ifPresent(event ->
|
||||
rsvpRepository.deleteByEventIdAndRsvpToken(event.id(), rsvpToken));
|
||||
}
|
||||
|
||||
@Override
|
||||
public long countByEvent(EventToken eventToken) {
|
||||
Event event = eventRepository.findByEventToken(eventToken)
|
||||
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
|
||||
return rsvpRepository.countByEventId(event.getId());
|
||||
return rsvpRepository.countByEventId(event.id());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -63,12 +79,12 @@ public class RsvpService
|
||||
Event event = eventRepository.findByEventToken(eventToken)
|
||||
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
|
||||
|
||||
if (!event.getOrganizerToken().equals(organizerToken)) {
|
||||
if (!event.organizerToken().equals(organizerToken)) {
|
||||
throw new InvalidOrganizerTokenException();
|
||||
}
|
||||
|
||||
return rsvpRepository.findByEventId(event.getId()).stream()
|
||||
.map(Rsvp::getName)
|
||||
return rsvpRepository.findByEventId(event.id()).stream()
|
||||
.map(Rsvp::name)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package de.fete.application.service.exception;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/** Thrown when attempting to cancel an event that is already cancelled. */
|
||||
public class EventAlreadyCancelledException extends RuntimeException {
|
||||
|
||||
/** Creates a new exception for the given event token. */
|
||||
public EventAlreadyCancelledException(UUID eventToken) {
|
||||
super("Event is already cancelled: " + eventToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package de.fete.application.service.exception;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/** Thrown when an RSVP is attempted on a cancelled event. */
|
||||
public class EventCancelledException extends RuntimeException {
|
||||
|
||||
/** Creates a new exception for the given event token. */
|
||||
public EventCancelledException(UUID eventToken) {
|
||||
super("Event is cancelled: " + eventToken);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.fete.application.service;
|
||||
package de.fete.application.service.exception;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.fete.application.service;
|
||||
package de.fete.application.service.exception;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.fete.application.service;
|
||||
package de.fete.application.service.exception;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.fete.application.service;
|
||||
package de.fete.application.service.exception;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.fete.application.service;
|
||||
package de.fete.application.service.exception;
|
||||
|
||||
/** Thrown when an invalid organizer token is provided. */
|
||||
public class InvalidOrganizerTokenException extends RuntimeException {
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.fete.application.service;
|
||||
package de.fete.application.service.exception;
|
||||
|
||||
/** Thrown when an invalid IANA timezone ID is provided. */
|
||||
public class InvalidTimezoneException extends RuntimeException {
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Application-layer exceptions thrown by service use case implementations.
|
||||
*/
|
||||
package de.fete.application.service.exception;
|
||||
@@ -10,6 +10,5 @@ public record CreateEventCommand(
|
||||
String description,
|
||||
OffsetDateTime dateTime,
|
||||
ZoneId timezone,
|
||||
String location,
|
||||
LocalDate expiryDate
|
||||
String location
|
||||
) {}
|
||||
|
||||
@@ -5,116 +5,26 @@ import java.time.OffsetDateTime;
|
||||
import java.time.ZoneId;
|
||||
|
||||
/** Domain entity representing an event. */
|
||||
public class Event {
|
||||
public record Event(
|
||||
Long id,
|
||||
EventToken eventToken,
|
||||
OrganizerToken organizerToken,
|
||||
String title,
|
||||
String description,
|
||||
OffsetDateTime dateTime,
|
||||
ZoneId timezone,
|
||||
String location,
|
||||
LocalDate expiryDate,
|
||||
OffsetDateTime createdAt,
|
||||
boolean cancelled,
|
||||
String cancellationReason
|
||||
) {
|
||||
|
||||
private Long id;
|
||||
private EventToken eventToken;
|
||||
private OrganizerToken organizerToken;
|
||||
private String title;
|
||||
private String description;
|
||||
private OffsetDateTime dateTime;
|
||||
private ZoneId timezone;
|
||||
private String location;
|
||||
private LocalDate expiryDate;
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
/** Returns the internal database ID. */
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Sets the internal database ID. */
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
/** Returns the public event token. */
|
||||
public EventToken getEventToken() {
|
||||
return eventToken;
|
||||
}
|
||||
|
||||
/** Sets the public event token. */
|
||||
public void setEventToken(EventToken eventToken) {
|
||||
this.eventToken = eventToken;
|
||||
}
|
||||
|
||||
/** Returns the secret organizer token. */
|
||||
public OrganizerToken getOrganizerToken() {
|
||||
return organizerToken;
|
||||
}
|
||||
|
||||
/** Sets the secret organizer token. */
|
||||
public void setOrganizerToken(OrganizerToken organizerToken) {
|
||||
this.organizerToken = organizerToken;
|
||||
}
|
||||
|
||||
/** Returns the event title. */
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
/** Sets the event title. */
|
||||
public void setTitle(String title) {
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
/** Returns the event description. */
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
/** Sets the event description. */
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
/** Returns the event date and time with UTC offset. */
|
||||
public OffsetDateTime getDateTime() {
|
||||
return dateTime;
|
||||
}
|
||||
|
||||
/** Sets the event date and time. */
|
||||
public void setDateTime(OffsetDateTime dateTime) {
|
||||
this.dateTime = dateTime;
|
||||
}
|
||||
|
||||
/** Returns the IANA timezone. */
|
||||
public ZoneId getTimezone() {
|
||||
return timezone;
|
||||
}
|
||||
|
||||
/** Sets the IANA timezone. */
|
||||
public void setTimezone(ZoneId timezone) {
|
||||
this.timezone = timezone;
|
||||
}
|
||||
|
||||
/** Returns the event location. */
|
||||
public String getLocation() {
|
||||
return location;
|
||||
}
|
||||
|
||||
/** Sets the event location. */
|
||||
public void setLocation(String location) {
|
||||
this.location = location;
|
||||
}
|
||||
|
||||
/** Returns the expiry date after which event data is deleted. */
|
||||
public LocalDate getExpiryDate() {
|
||||
return expiryDate;
|
||||
}
|
||||
|
||||
/** Sets the expiry date. */
|
||||
public void setExpiryDate(LocalDate expiryDate) {
|
||||
this.expiryDate = expiryDate;
|
||||
}
|
||||
|
||||
/** Returns the creation timestamp. */
|
||||
public OffsetDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
/** Sets the creation timestamp. */
|
||||
public void setCreatedAt(OffsetDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
/** Returns a copy of this event with cancellation applied. */
|
||||
public Event withCancellation(boolean cancelled, String cancellationReason) {
|
||||
return new Event(
|
||||
id, eventToken, organizerToken, title, description,
|
||||
dateTime, timezone, location, expiryDate, createdAt,
|
||||
cancelled, cancellationReason);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +1,9 @@
|
||||
package de.fete.domain.model;
|
||||
|
||||
/** Domain entity representing an RSVP. */
|
||||
public class Rsvp {
|
||||
|
||||
private Long id;
|
||||
private RsvpToken rsvpToken;
|
||||
private Long eventId;
|
||||
private String name;
|
||||
|
||||
/** Returns the internal database ID. */
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Sets the internal database ID. */
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
/** Returns the RSVP token. */
|
||||
public RsvpToken getRsvpToken() {
|
||||
return rsvpToken;
|
||||
}
|
||||
|
||||
/** Sets the RSVP token. */
|
||||
public void setRsvpToken(RsvpToken rsvpToken) {
|
||||
this.rsvpToken = rsvpToken;
|
||||
}
|
||||
|
||||
/** Returns the event ID this RSVP belongs to. */
|
||||
public Long getEventId() {
|
||||
return eventId;
|
||||
}
|
||||
|
||||
/** Sets the event ID. */
|
||||
public void setEventId(Long eventId) {
|
||||
this.eventId = eventId;
|
||||
}
|
||||
|
||||
/** Returns the guest's display name. */
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
/** Sets the guest's display name. */
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
public record Rsvp(
|
||||
Long id,
|
||||
RsvpToken rsvpToken,
|
||||
Long eventId,
|
||||
String name
|
||||
) {}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package de.fete.domain.port.in;
|
||||
|
||||
import de.fete.domain.model.EventToken;
|
||||
import de.fete.domain.model.RsvpToken;
|
||||
|
||||
/** Inbound port for cancelling an RSVP. */
|
||||
public interface CancelRsvpUseCase {
|
||||
|
||||
/** Cancels the RSVP identified by the given tokens. Idempotent — no error if not found. */
|
||||
void cancelRsvp(EventToken eventToken, RsvpToken rsvpToken);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -12,4 +12,7 @@ public interface EventRepository {
|
||||
|
||||
/** Finds an event by its public event token. */
|
||||
Optional<Event> findByEventToken(EventToken eventToken);
|
||||
|
||||
/** Deletes all events whose expiry date is in the past. Returns the number of deleted events. */
|
||||
int deleteExpired();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.fete.domain.port.out;
|
||||
|
||||
import de.fete.domain.model.Rsvp;
|
||||
import de.fete.domain.model.RsvpToken;
|
||||
import java.util.List;
|
||||
|
||||
/** Outbound port for persisting and querying RSVPs. */
|
||||
@@ -14,4 +15,7 @@ public interface RsvpRepository {
|
||||
|
||||
/** Finds all RSVPs for the given event, ordered by ID ascending. */
|
||||
List<Rsvp> findByEventId(Long eventId);
|
||||
|
||||
/** Deletes an RSVP by event ID and RSVP token. Returns true if a record was deleted. */
|
||||
boolean deleteByEventIdAndRsvpToken(Long eventId, RsvpToken rsvpToken);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -37,14 +37,46 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ValidationProblemDetail"
|
||||
|
||||
/events/{token}/rsvps:
|
||||
/events/{eventToken}/rsvps/{rsvpToken}:
|
||||
delete:
|
||||
operationId: cancelRsvp
|
||||
summary: Cancel RSVP
|
||||
description: |
|
||||
Permanently deletes an RSVP identified by the RSVP token.
|
||||
Idempotent: returns 204 whether the RSVP existed or not.
|
||||
tags:
|
||||
- events
|
||||
parameters:
|
||||
- name: eventToken
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Event token (UUID)
|
||||
- name: rsvpToken
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: RSVP token (UUID) identifying the attendance to cancel
|
||||
responses:
|
||||
"204":
|
||||
description: >
|
||||
RSVP successfully cancelled (or was already cancelled).
|
||||
No response body.
|
||||
"500":
|
||||
description: Internal server error
|
||||
|
||||
/events/{eventToken}/rsvps:
|
||||
post:
|
||||
operationId: createRsvp
|
||||
summary: Submit an RSVP for an event
|
||||
tags:
|
||||
- events
|
||||
parameters:
|
||||
- name: token
|
||||
- name: eventToken
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
@@ -83,14 +115,14 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ProblemDetail"
|
||||
|
||||
/events/{token}/attendees:
|
||||
/events/{eventToken}/attendees:
|
||||
get:
|
||||
operationId: getAttendees
|
||||
summary: Get attendee list for an event (organizer only)
|
||||
tags:
|
||||
- events
|
||||
parameters:
|
||||
- name: token
|
||||
- name: eventToken
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
@@ -124,14 +156,14 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ProblemDetail"
|
||||
|
||||
/events/{token}:
|
||||
/events/{eventToken}:
|
||||
get:
|
||||
operationId: getEvent
|
||||
summary: Get public event details by token
|
||||
tags:
|
||||
- events
|
||||
parameters:
|
||||
- name: token
|
||||
- name: eventToken
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
@@ -152,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:
|
||||
@@ -160,7 +244,6 @@ components:
|
||||
- title
|
||||
- dateTime
|
||||
- timezone
|
||||
- expiryDate
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
@@ -181,11 +264,6 @@ components:
|
||||
location:
|
||||
type: string
|
||||
maxLength: 500
|
||||
expiryDate:
|
||||
type: string
|
||||
format: date
|
||||
description: Date after which event data is deleted. Must be in the future.
|
||||
example: "2026-06-15"
|
||||
|
||||
CreateEventResponse:
|
||||
type: object
|
||||
@@ -195,7 +273,6 @@ components:
|
||||
- title
|
||||
- dateTime
|
||||
- timezone
|
||||
- expiryDate
|
||||
properties:
|
||||
eventToken:
|
||||
type: string
|
||||
@@ -218,10 +295,6 @@ components:
|
||||
type: string
|
||||
description: IANA timezone of the organizer
|
||||
example: "Europe/Berlin"
|
||||
expiryDate:
|
||||
type: string
|
||||
format: date
|
||||
example: "2026-06-15"
|
||||
|
||||
GetEventResponse:
|
||||
type: object
|
||||
@@ -231,7 +304,7 @@ components:
|
||||
- dateTime
|
||||
- timezone
|
||||
- attendeeCount
|
||||
- expired
|
||||
- cancelled
|
||||
properties:
|
||||
eventToken:
|
||||
type: string
|
||||
@@ -264,10 +337,31 @@ components:
|
||||
minimum: 0
|
||||
description: Number of confirmed attendees (attending=true)
|
||||
example: 12
|
||||
expired:
|
||||
cancelled:
|
||||
type: boolean
|
||||
description: Whether the event's expiry date has passed
|
||||
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
|
||||
|
||||
@@ -4,10 +4,14 @@ import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
|
||||
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
|
||||
import static com.tngtech.archunit.library.Architectures.onionArchitecture;
|
||||
|
||||
import com.tngtech.archunit.core.domain.JavaClass;
|
||||
import com.tngtech.archunit.core.importer.ImportOption;
|
||||
import com.tngtech.archunit.junit.AnalyzeClasses;
|
||||
import com.tngtech.archunit.junit.ArchTest;
|
||||
import com.tngtech.archunit.lang.ArchCondition;
|
||||
import com.tngtech.archunit.lang.ArchRule;
|
||||
import com.tngtech.archunit.lang.ConditionEvents;
|
||||
import com.tngtech.archunit.lang.SimpleConditionEvent;
|
||||
|
||||
@AnalyzeClasses(packages = "de.fete", importOptions = ImportOption.DoNotIncludeTests.class)
|
||||
class HexagonalArchitectureTest {
|
||||
@@ -65,4 +69,24 @@ class HexagonalArchitectureTest {
|
||||
static final ArchRule webAdapterMustNotDependOnOutboundPorts = noClasses()
|
||||
.that().resideInAPackage("de.fete.adapter.in.web..")
|
||||
.should().dependOnClassesThat().resideInAPackage("de.fete.domain.port.out..");
|
||||
|
||||
@ArchTest
|
||||
static final ArchRule domainModelsMustBeRecords = classes()
|
||||
.that().resideInAPackage("de.fete.domain.model..")
|
||||
.and().doNotHaveSimpleName("package-info")
|
||||
.should(beRecords());
|
||||
|
||||
private static ArchCondition<JavaClass> beRecords() {
|
||||
return new ArchCondition<>("be records") {
|
||||
@Override
|
||||
public void check(JavaClass javaClass,
|
||||
ConditionEvents events) {
|
||||
boolean isRecord = javaClass.reflect().isRecord();
|
||||
if (!isRecord) {
|
||||
events.add(SimpleConditionEvent.violated(javaClass,
|
||||
javaClass.getFullName() + " is not a record"));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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;
|
||||
@@ -20,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;
|
||||
@@ -55,8 +58,7 @@ class EventControllerIntegrationTest {
|
||||
.description("Come celebrate!")
|
||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||
.timezone("Europe/Berlin")
|
||||
.location("Berlin")
|
||||
.expiryDate(LocalDate.of(2026, 6, 16));
|
||||
.location("Berlin");
|
||||
|
||||
var result = mockMvc.perform(post("/api/events")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
@@ -67,7 +69,6 @@ class EventControllerIntegrationTest {
|
||||
.andExpect(jsonPath("$.title").value("Birthday Party"))
|
||||
.andExpect(jsonPath("$.timezone").value("Europe/Berlin"))
|
||||
.andExpect(jsonPath("$.dateTime").isNotEmpty())
|
||||
.andExpect(jsonPath("$.expiryDate").isNotEmpty())
|
||||
.andReturn();
|
||||
|
||||
var response = objectMapper.readValue(
|
||||
@@ -79,7 +80,7 @@ class EventControllerIntegrationTest {
|
||||
assertThat(persisted.getDescription()).isEqualTo("Come celebrate!");
|
||||
assertThat(persisted.getTimezone()).isEqualTo("Europe/Berlin");
|
||||
assertThat(persisted.getLocation()).isEqualTo("Berlin");
|
||||
assertThat(persisted.getExpiryDate()).isEqualTo(request.getExpiryDate());
|
||||
assertThat(persisted.getExpiryDate()).isEqualTo(LocalDate.of(2026, 6, 22));
|
||||
assertThat(persisted.getDateTime().toInstant())
|
||||
.isEqualTo(request.getDateTime().toInstant());
|
||||
assertThat(persisted.getOrganizerToken()).isNotNull();
|
||||
@@ -91,8 +92,7 @@ class EventControllerIntegrationTest {
|
||||
var request = new CreateEventRequest()
|
||||
.title("Minimal Event")
|
||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||
.timezone("UTC")
|
||||
.expiryDate(LocalDate.of(2026, 6, 16));
|
||||
.timezone("UTC");
|
||||
|
||||
var result = mockMvc.perform(post("/api/events")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
@@ -119,8 +119,7 @@ class EventControllerIntegrationTest {
|
||||
|
||||
var request = new CreateEventRequest()
|
||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||
.timezone("Europe/Berlin")
|
||||
.expiryDate(LocalDate.of(2026, 6, 16));
|
||||
.timezone("Europe/Berlin");
|
||||
|
||||
mockMvc.perform(post("/api/events")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
@@ -139,26 +138,6 @@ class EventControllerIntegrationTest {
|
||||
|
||||
var request = new CreateEventRequest()
|
||||
.title("No Date")
|
||||
.timezone("Europe/Berlin")
|
||||
.expiryDate(LocalDate.of(2026, 6, 16));
|
||||
|
||||
mockMvc.perform(post("/api/events")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||
.andExpect(jsonPath("$.fieldErrors").isArray());
|
||||
|
||||
assertThat(jpaRepository.count()).isEqualTo(countBefore);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createEventMissingExpiryDateReturns400() throws Exception {
|
||||
long countBefore = jpaRepository.count();
|
||||
|
||||
var request = new CreateEventRequest()
|
||||
.title("No Expiry")
|
||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||
.timezone("Europe/Berlin");
|
||||
|
||||
mockMvc.perform(post("/api/events")
|
||||
@@ -171,93 +150,12 @@ class EventControllerIntegrationTest {
|
||||
assertThat(jpaRepository.count()).isEqualTo(countBefore);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createEventExpiryDateInPastReturns400() throws Exception {
|
||||
long countBefore = jpaRepository.count();
|
||||
|
||||
var request = new CreateEventRequest()
|
||||
.title("Past Expiry")
|
||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||
.timezone("Europe/Berlin")
|
||||
.expiryDate(LocalDate.of(2025, 1, 1));
|
||||
|
||||
mockMvc.perform(post("/api/events")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||
.andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-in-past"));
|
||||
|
||||
assertThat(jpaRepository.count()).isEqualTo(countBefore);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createEventExpiryDateTodayReturns400() throws Exception {
|
||||
long countBefore = jpaRepository.count();
|
||||
|
||||
var request = new CreateEventRequest()
|
||||
.title("Today Expiry")
|
||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||
.timezone("Europe/Berlin")
|
||||
.expiryDate(LocalDate.now());
|
||||
|
||||
mockMvc.perform(post("/api/events")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||
.andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-in-past"));
|
||||
|
||||
assertThat(jpaRepository.count()).isEqualTo(countBefore);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createEventExpiryDateBeforeEventDateReturns400() throws Exception {
|
||||
long countBefore = jpaRepository.count();
|
||||
|
||||
var request = new CreateEventRequest()
|
||||
.title("Bad Expiry")
|
||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||
.timezone("Europe/Berlin")
|
||||
.expiryDate(LocalDate.of(2026, 6, 10));
|
||||
|
||||
mockMvc.perform(post("/api/events")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||
.andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-before-event"));
|
||||
|
||||
assertThat(jpaRepository.count()).isEqualTo(countBefore);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createEventExpiryDateSameAsEventDateReturns400() throws Exception {
|
||||
long countBefore = jpaRepository.count();
|
||||
|
||||
var request = new CreateEventRequest()
|
||||
.title("Same Day Expiry")
|
||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||
.timezone("Europe/Berlin")
|
||||
.expiryDate(LocalDate.of(2026, 6, 15));
|
||||
|
||||
mockMvc.perform(post("/api/events")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||
.andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-before-event"));
|
||||
|
||||
assertThat(jpaRepository.count()).isEqualTo(countBefore);
|
||||
}
|
||||
|
||||
@Test
|
||||
void errorResponseContentTypeIsProblemJson() throws Exception {
|
||||
var request = new CreateEventRequest()
|
||||
.title("")
|
||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||
.timezone("Europe/Berlin")
|
||||
.expiryDate(LocalDate.of(2026, 6, 16));
|
||||
.timezone("Europe/Berlin");
|
||||
|
||||
mockMvc.perform(post("/api/events")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
@@ -273,8 +171,7 @@ class EventControllerIntegrationTest {
|
||||
var request = new CreateEventRequest()
|
||||
.title("Bad TZ")
|
||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||
.timezone("Not/A/Zone")
|
||||
.expiryDate(LocalDate.of(2026, 6, 16));
|
||||
.timezone("Not/A/Zone");
|
||||
|
||||
mockMvc.perform(post("/api/events")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
@@ -302,7 +199,6 @@ class EventControllerIntegrationTest {
|
||||
.andExpect(jsonPath("$.timezone").value("Europe/Berlin"))
|
||||
.andExpect(jsonPath("$.location").value("Central Park"))
|
||||
.andExpect(jsonPath("$.attendeeCount").value(0))
|
||||
.andExpect(jsonPath("$.expired").value(false))
|
||||
.andExpect(jsonPath("$.dateTime").isNotEmpty());
|
||||
}
|
||||
|
||||
@@ -327,18 +223,6 @@ class EventControllerIntegrationTest {
|
||||
.andExpect(jsonPath("$.type").value("urn:problem-type:event-not-found"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getExpiredEventReturnsExpiredTrue() throws Exception {
|
||||
EventJpaEntity entity = seedEvent(
|
||||
"Past Event", "It happened", "Europe/Berlin",
|
||||
"Old Venue", LocalDate.now().minusDays(1));
|
||||
|
||||
mockMvc.perform(get("/api/events/" + entity.getEventToken()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.title").value("Past Event"))
|
||||
.andExpect(jsonPath("$.expired").value(true));
|
||||
}
|
||||
|
||||
// --- RSVP tests ---
|
||||
|
||||
@Test
|
||||
@@ -493,6 +377,213 @@ class EventControllerIntegrationTest {
|
||||
"application/problem+json"));
|
||||
}
|
||||
|
||||
// --- Cancel RSVP tests ---
|
||||
|
||||
@Test
|
||||
void cancelRsvpReturns204AndDeletesRow() throws Exception {
|
||||
EventJpaEntity event = seedEvent(
|
||||
"Cancel Event", null, "Europe/Berlin",
|
||||
null, LocalDate.now().plusDays(30));
|
||||
UUID rsvpToken = seedRsvpAndGetToken(event, "Departing Guest");
|
||||
|
||||
long countBefore = rsvpJpaRepository.count();
|
||||
|
||||
mockMvc.perform(delete("/api/events/" + event.getEventToken()
|
||||
+ "/rsvps/" + rsvpToken))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
assertThat(rsvpJpaRepository.count()).isEqualTo(countBefore - 1);
|
||||
assertThat(rsvpJpaRepository.findByRsvpToken(rsvpToken)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelRsvpReturns204WhenAlreadyDeleted() throws Exception {
|
||||
EventJpaEntity event = seedEvent(
|
||||
"Idempotent Event", null, "Europe/Berlin",
|
||||
null, LocalDate.now().plusDays(30));
|
||||
|
||||
mockMvc.perform(delete("/api/events/" + event.getEventToken()
|
||||
+ "/rsvps/" + UUID.randomUUID()))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelRsvpReturns204WhenEventNotFound() throws Exception {
|
||||
mockMvc.perform(delete("/api/events/" + UUID.randomUUID()
|
||||
+ "/rsvps/" + UUID.randomUUID()))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void attendeeCountDecreasesAfterCancelRsvp() throws Exception {
|
||||
EventJpaEntity event = seedEvent(
|
||||
"Count Cancel Event", null, "Europe/Berlin",
|
||||
null, LocalDate.now().plusDays(30));
|
||||
UUID rsvpToken = seedRsvpAndGetToken(event, "Leaving Guest");
|
||||
seedRsvp(event, "Staying Guest");
|
||||
|
||||
mockMvc.perform(get("/api/events/" + event.getEventToken()))
|
||||
.andExpect(jsonPath("$.attendeeCount").value(2));
|
||||
|
||||
mockMvc.perform(delete("/api/events/" + event.getEventToken()
|
||||
+ "/rsvps/" + rsvpToken))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
mockMvc.perform(get("/api/events/" + event.getEventToken()))
|
||||
.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();
|
||||
rsvp.setRsvpToken(token);
|
||||
rsvp.setEventId(event.getId());
|
||||
rsvp.setName(name);
|
||||
rsvpJpaRepository.save(rsvp);
|
||||
return token;
|
||||
}
|
||||
|
||||
private void seedRsvp(EventJpaEntity event, String name) {
|
||||
var rsvp = new RsvpJpaEntity();
|
||||
rsvp.setRsvpToken(UUID.randomUUID());
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package de.fete.adapter.out.persistence;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import de.fete.TestcontainersConfig;
|
||||
import de.fete.domain.model.Event;
|
||||
import de.fete.domain.model.EventToken;
|
||||
import de.fete.domain.model.OrganizerToken;
|
||||
import de.fete.domain.port.out.EventRepository;
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@SpringBootTest
|
||||
@Import(TestcontainersConfig.class)
|
||||
@Transactional
|
||||
class EventPersistenceAdapterIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private EventRepository eventRepository;
|
||||
|
||||
@Test
|
||||
void deleteExpiredRemovesExpiredEvents() {
|
||||
Event expired = buildEvent("Expired Party", LocalDate.now().minusDays(1));
|
||||
eventRepository.save(expired);
|
||||
|
||||
int deleted = eventRepository.deleteExpired();
|
||||
|
||||
assertThat(deleted).isGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteExpiredKeepsNonExpiredEvents() {
|
||||
Event future = buildEvent("Future Party", LocalDate.now().plusDays(30));
|
||||
Event saved = eventRepository.save(future);
|
||||
|
||||
eventRepository.deleteExpired();
|
||||
|
||||
assertThat(eventRepository.findByEventToken(saved.eventToken())).isPresent();
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteExpiredKeepsEventsExpiringToday() {
|
||||
Event today = buildEvent("Today Party", LocalDate.now());
|
||||
Event saved = eventRepository.save(today);
|
||||
|
||||
eventRepository.deleteExpired();
|
||||
|
||||
assertThat(eventRepository.findByEventToken(saved.eventToken())).isPresent();
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteExpiredReturnsZeroWhenNoneExpired() {
|
||||
// Only save a future event
|
||||
buildEvent("Future Only", LocalDate.now().plusDays(60));
|
||||
|
||||
int deleted = eventRepository.deleteExpired();
|
||||
|
||||
assertThat(deleted).isGreaterThanOrEqualTo(0);
|
||||
}
|
||||
|
||||
private Event buildEvent(String title, LocalDate expiryDate) {
|
||||
return new Event(
|
||||
null,
|
||||
EventToken.generate(),
|
||||
OrganizerToken.generate(),
|
||||
title,
|
||||
"Test description",
|
||||
OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)),
|
||||
ZoneId.of("Europe/Berlin"),
|
||||
"Test Location",
|
||||
expiryDate,
|
||||
OffsetDateTime.now(),
|
||||
false,
|
||||
null);
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,8 @@ class EventPersistenceAdapterTest {
|
||||
|
||||
Event saved = eventRepository.save(event);
|
||||
|
||||
assertThat(saved.getId()).isNotNull();
|
||||
assertThat(saved.getTitle()).isEqualTo("Test Event");
|
||||
assertThat(saved.id()).isNotNull();
|
||||
assertThat(saved.title()).isEqualTo("Test Event");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -39,11 +39,11 @@ class EventPersistenceAdapterTest {
|
||||
Event event = buildEvent();
|
||||
Event saved = eventRepository.save(event);
|
||||
|
||||
Optional<Event> found = eventRepository.findByEventToken(saved.getEventToken());
|
||||
Optional<Event> found = eventRepository.findByEventToken(saved.eventToken());
|
||||
|
||||
assertThat(found).isPresent();
|
||||
assertThat(found.get().getTitle()).isEqualTo("Test Event");
|
||||
assertThat(found.get().getId()).isEqualTo(saved.getId());
|
||||
assertThat(found.get().title()).isEqualTo("Test Event");
|
||||
assertThat(found.get().id()).isEqualTo(saved.id());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -61,42 +61,47 @@ class EventPersistenceAdapterTest {
|
||||
OffsetDateTime createdAt =
|
||||
OffsetDateTime.of(2026, 3, 4, 12, 0, 0, 0, ZoneOffset.UTC);
|
||||
|
||||
var event = new Event();
|
||||
event.setEventToken(EventToken.generate());
|
||||
event.setOrganizerToken(OrganizerToken.generate());
|
||||
event.setTitle("Full Event");
|
||||
event.setDescription("A detailed description");
|
||||
event.setDateTime(dateTime);
|
||||
event.setTimezone(ZoneId.of("Europe/Berlin"));
|
||||
event.setLocation("Berlin, Germany");
|
||||
event.setExpiryDate(expiryDate);
|
||||
event.setCreatedAt(createdAt);
|
||||
var event = new Event(
|
||||
null,
|
||||
EventToken.generate(),
|
||||
OrganizerToken.generate(),
|
||||
"Full Event",
|
||||
"A detailed description",
|
||||
dateTime,
|
||||
ZoneId.of("Europe/Berlin"),
|
||||
"Berlin, Germany",
|
||||
expiryDate,
|
||||
createdAt,
|
||||
false,
|
||||
null);
|
||||
|
||||
Event saved = eventRepository.save(event);
|
||||
Event found = eventRepository.findByEventToken(saved.getEventToken()).orElseThrow();
|
||||
Event found = eventRepository.findByEventToken(saved.eventToken()).orElseThrow();
|
||||
|
||||
assertThat(found.getEventToken()).isEqualTo(event.getEventToken());
|
||||
assertThat(found.getOrganizerToken()).isEqualTo(event.getOrganizerToken());
|
||||
assertThat(found.getTitle()).isEqualTo("Full Event");
|
||||
assertThat(found.getDescription()).isEqualTo("A detailed description");
|
||||
assertThat(found.getDateTime().toInstant()).isEqualTo(dateTime.toInstant());
|
||||
assertThat(found.getTimezone()).isEqualTo(ZoneId.of("Europe/Berlin"));
|
||||
assertThat(found.getLocation()).isEqualTo("Berlin, Germany");
|
||||
assertThat(found.getExpiryDate()).isEqualTo(expiryDate);
|
||||
assertThat(found.getCreatedAt().toInstant()).isEqualTo(createdAt.toInstant());
|
||||
assertThat(found.eventToken()).isEqualTo(event.eventToken());
|
||||
assertThat(found.organizerToken()).isEqualTo(event.organizerToken());
|
||||
assertThat(found.title()).isEqualTo("Full Event");
|
||||
assertThat(found.description()).isEqualTo("A detailed description");
|
||||
assertThat(found.dateTime().toInstant()).isEqualTo(dateTime.toInstant());
|
||||
assertThat(found.timezone()).isEqualTo(ZoneId.of("Europe/Berlin"));
|
||||
assertThat(found.location()).isEqualTo("Berlin, Germany");
|
||||
assertThat(found.expiryDate()).isEqualTo(expiryDate);
|
||||
assertThat(found.createdAt().toInstant()).isEqualTo(createdAt.toInstant());
|
||||
}
|
||||
|
||||
private Event buildEvent() {
|
||||
var event = new Event();
|
||||
event.setEventToken(EventToken.generate());
|
||||
event.setOrganizerToken(OrganizerToken.generate());
|
||||
event.setTitle("Test Event");
|
||||
event.setDescription("Test description");
|
||||
event.setDateTime(OffsetDateTime.now().plusDays(7));
|
||||
event.setTimezone(ZoneId.of("Europe/Berlin"));
|
||||
event.setLocation("Somewhere");
|
||||
event.setExpiryDate(LocalDate.now().plusDays(30));
|
||||
event.setCreatedAt(OffsetDateTime.now());
|
||||
return event;
|
||||
return new Event(
|
||||
null,
|
||||
EventToken.generate(),
|
||||
OrganizerToken.generate(),
|
||||
"Test Event",
|
||||
"Test description",
|
||||
OffsetDateTime.now().plusDays(7),
|
||||
ZoneId.of("Europe/Berlin"),
|
||||
"Somewhere",
|
||||
LocalDate.now().plusDays(30),
|
||||
OffsetDateTime.now(),
|
||||
false,
|
||||
null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package de.fete.application.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import de.fete.application.service.exception.EventAlreadyCancelledException;
|
||||
import de.fete.application.service.exception.EventNotFoundException;
|
||||
import de.fete.application.service.exception.InvalidOrganizerTokenException;
|
||||
import de.fete.domain.model.Event;
|
||||
import de.fete.domain.model.EventToken;
|
||||
import de.fete.domain.model.OrganizerToken;
|
||||
import de.fete.domain.port.out.EventRepository;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class EventServiceCancelTest {
|
||||
|
||||
private static final ZoneId ZONE = ZoneId.of("Europe/Berlin");
|
||||
private static final Instant FIXED_INSTANT =
|
||||
LocalDate.of(2026, 3, 5).atStartOfDay(ZONE).toInstant();
|
||||
private static final Clock FIXED_CLOCK = Clock.fixed(FIXED_INSTANT, ZONE);
|
||||
|
||||
@Mock
|
||||
private EventRepository eventRepository;
|
||||
|
||||
private EventService eventService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
eventService = new EventService(eventRepository, FIXED_CLOCK);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelEventDelegatesToDomainAndSaves() {
|
||||
EventToken eventToken = EventToken.generate();
|
||||
OrganizerToken organizerToken = OrganizerToken.generate();
|
||||
var event = new Event(null, eventToken, organizerToken, null, null, null, null, null, null,
|
||||
null, false, null);
|
||||
|
||||
when(eventRepository.findByEventToken(eventToken))
|
||||
.thenReturn(Optional.of(event));
|
||||
when(eventRepository.save(any(Event.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
eventService.cancelEvent(eventToken, organizerToken, true, "Venue unavailable");
|
||||
|
||||
ArgumentCaptor<Event> captor = ArgumentCaptor.forClass(Event.class);
|
||||
verify(eventRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().cancelled()).isTrue();
|
||||
assertThat(captor.getValue().cancellationReason()).isEqualTo("Venue unavailable");
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelEventWithNullReason() {
|
||||
EventToken eventToken = EventToken.generate();
|
||||
OrganizerToken organizerToken = OrganizerToken.generate();
|
||||
var event = new Event(null, eventToken, organizerToken, null, null, null, null, null, null,
|
||||
null, false, null);
|
||||
|
||||
when(eventRepository.findByEventToken(eventToken))
|
||||
.thenReturn(Optional.of(event));
|
||||
when(eventRepository.save(any(Event.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
eventService.cancelEvent(eventToken, organizerToken, true, null);
|
||||
|
||||
ArgumentCaptor<Event> captor = ArgumentCaptor.forClass(Event.class);
|
||||
verify(eventRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().cancelled()).isTrue();
|
||||
assertThat(captor.getValue().cancellationReason()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelEventThrows404WhenNotFound() {
|
||||
EventToken eventToken = EventToken.generate();
|
||||
OrganizerToken organizerToken = OrganizerToken.generate();
|
||||
|
||||
when(eventRepository.findByEventToken(eventToken))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> eventService.cancelEvent(eventToken, organizerToken, true, null))
|
||||
.isInstanceOf(EventNotFoundException.class);
|
||||
|
||||
verify(eventRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelEventThrows403WhenWrongOrganizerToken() {
|
||||
EventToken eventToken = EventToken.generate();
|
||||
OrganizerToken correctToken = OrganizerToken.generate();
|
||||
var event = new Event(null, eventToken, correctToken, null, null, null, null, null, null,
|
||||
null, false, null);
|
||||
|
||||
when(eventRepository.findByEventToken(eventToken))
|
||||
.thenReturn(Optional.of(event));
|
||||
|
||||
final OrganizerToken wrongToken = OrganizerToken.generate();
|
||||
assertThatThrownBy(() -> eventService.cancelEvent(eventToken, wrongToken, true, null))
|
||||
.isInstanceOf(InvalidOrganizerTokenException.class);
|
||||
|
||||
verify(eventRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelEventThrows409WhenAlreadyCancelled() {
|
||||
EventToken eventToken = EventToken.generate();
|
||||
OrganizerToken organizerToken = OrganizerToken.generate();
|
||||
var event = new Event(null, eventToken, organizerToken, null, null, null, null, null, null,
|
||||
null, true, null);
|
||||
|
||||
when(eventRepository.findByEventToken(eventToken))
|
||||
.thenReturn(Optional.of(event));
|
||||
|
||||
assertThatThrownBy(() -> eventService.cancelEvent(eventToken, organizerToken, true, null))
|
||||
.isInstanceOf(EventAlreadyCancelledException.class);
|
||||
|
||||
verify(eventRepository, never()).save(any());
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
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.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -53,19 +52,18 @@ class EventServiceTest {
|
||||
"Come celebrate!",
|
||||
TODAY.plusDays(90).atStartOfDay(ZONE).toOffsetDateTime(),
|
||||
ZONE,
|
||||
"Berlin",
|
||||
TODAY.plusDays(120)
|
||||
"Berlin"
|
||||
);
|
||||
|
||||
Event result = eventService.createEvent(command);
|
||||
|
||||
assertThat(result.getTitle()).isEqualTo("Birthday Party");
|
||||
assertThat(result.getDescription()).isEqualTo("Come celebrate!");
|
||||
assertThat(result.getTimezone()).isEqualTo(ZONE);
|
||||
assertThat(result.getLocation()).isEqualTo("Berlin");
|
||||
assertThat(result.getEventToken()).isNotNull();
|
||||
assertThat(result.getOrganizerToken()).isNotNull();
|
||||
assertThat(result.getCreatedAt()).isEqualTo(OffsetDateTime.ofInstant(FIXED_INSTANT, ZONE));
|
||||
assertThat(result.title()).isEqualTo("Birthday Party");
|
||||
assertThat(result.description()).isEqualTo("Come celebrate!");
|
||||
assertThat(result.timezone()).isEqualTo(ZONE);
|
||||
assertThat(result.location()).isEqualTo("Berlin");
|
||||
assertThat(result.eventToken()).isNotNull();
|
||||
assertThat(result.organizerToken()).isNotNull();
|
||||
assertThat(result.createdAt()).isEqualTo(OffsetDateTime.ofInstant(FIXED_INSTANT, ZONE));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -75,98 +73,30 @@ class EventServiceTest {
|
||||
|
||||
var command = new CreateEventCommand(
|
||||
"Test", null,
|
||||
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null,
|
||||
TODAY.plusDays(11)
|
||||
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null
|
||||
);
|
||||
|
||||
eventService.createEvent(command);
|
||||
|
||||
ArgumentCaptor<Event> captor = ArgumentCaptor.forClass(Event.class);
|
||||
verify(eventRepository, times(1)).save(captor.capture());
|
||||
assertThat(captor.getValue().getTitle()).isEqualTo("Test");
|
||||
assertThat(captor.getValue().title()).isEqualTo("Test");
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiryDateTodayThrowsException() {
|
||||
var command = new CreateEventCommand(
|
||||
"Test", null,
|
||||
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null,
|
||||
TODAY
|
||||
);
|
||||
|
||||
assertThatThrownBy(() -> eventService.createEvent(command))
|
||||
.isInstanceOf(ExpiryDateInPastException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiryDateInPastThrowsException() {
|
||||
var command = new CreateEventCommand(
|
||||
"Test", null,
|
||||
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null,
|
||||
TODAY.minusDays(5)
|
||||
);
|
||||
|
||||
assertThatThrownBy(() -> eventService.createEvent(command))
|
||||
.isInstanceOf(ExpiryDateInPastException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiryDateTomorrowSucceeds() {
|
||||
void expiryDateIsEventDatePlusSevenDays() {
|
||||
when(eventRepository.save(any(Event.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
var eventDate = TODAY.plusDays(10);
|
||||
var command = new CreateEventCommand(
|
||||
"Test", null,
|
||||
TODAY.plusDays(1).atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null,
|
||||
TODAY.plusDays(2)
|
||||
eventDate.atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null
|
||||
);
|
||||
|
||||
Event result = eventService.createEvent(command);
|
||||
|
||||
assertThat(result.getExpiryDate()).isEqualTo(TODAY.plusDays(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiryDateSameAsEventDateThrowsException() {
|
||||
var command = new CreateEventCommand(
|
||||
"Test", null,
|
||||
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(),
|
||||
ZONE, null,
|
||||
TODAY.plusDays(10)
|
||||
);
|
||||
|
||||
assertThatThrownBy(() -> eventService.createEvent(command))
|
||||
.isInstanceOf(ExpiryDateBeforeEventException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiryDateBeforeEventDateThrowsException() {
|
||||
var command = new CreateEventCommand(
|
||||
"Test", null,
|
||||
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(),
|
||||
ZONE, null,
|
||||
TODAY.plusDays(5)
|
||||
);
|
||||
|
||||
assertThatThrownBy(() -> eventService.createEvent(command))
|
||||
.isInstanceOf(ExpiryDateBeforeEventException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiryDateDayAfterEventDateSucceeds() {
|
||||
when(eventRepository.save(any(Event.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
var command = new CreateEventCommand(
|
||||
"Test", null,
|
||||
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(),
|
||||
ZONE, null,
|
||||
TODAY.plusDays(11)
|
||||
);
|
||||
|
||||
Event result = eventService.createEvent(command);
|
||||
|
||||
assertThat(result.getExpiryDate()).isEqualTo(TODAY.plusDays(11));
|
||||
assertThat(result.expiryDate()).isEqualTo(eventDate.plusDays(7));
|
||||
}
|
||||
|
||||
// --- GetEventUseCase tests (T004) ---
|
||||
@@ -174,16 +104,15 @@ class EventServiceTest {
|
||||
@Test
|
||||
void getByEventTokenReturnsEvent() {
|
||||
EventToken token = EventToken.generate();
|
||||
var event = new Event();
|
||||
event.setEventToken(token);
|
||||
event.setTitle("Found Event");
|
||||
var event = new Event(null, token, null, "Found Event", null, null, null, null, null, null,
|
||||
false, null);
|
||||
when(eventRepository.findByEventToken(token))
|
||||
.thenReturn(Optional.of(event));
|
||||
|
||||
Optional<Event> result = eventService.getByEventToken(token);
|
||||
|
||||
assertThat(result).isPresent();
|
||||
assertThat(result.get().getTitle()).isEqualTo("Found Event");
|
||||
assertThat(result.get().title()).isEqualTo("Found Event");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -207,12 +136,11 @@ class EventServiceTest {
|
||||
var command = new CreateEventCommand(
|
||||
"Test", null,
|
||||
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(),
|
||||
ZoneId.of("America/New_York"), null,
|
||||
TODAY.plusDays(11)
|
||||
ZoneId.of("America/New_York"), null
|
||||
);
|
||||
|
||||
Event result = eventService.createEvent(command);
|
||||
|
||||
assertThat(result.getTimezone()).isEqualTo(ZoneId.of("America/New_York"));
|
||||
assertThat(result.timezone()).isEqualTo(ZoneId.of("America/New_York"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import de.fete.application.service.exception.EventCancelledException;
|
||||
import de.fete.application.service.exception.EventExpiredException;
|
||||
import de.fete.application.service.exception.EventNotFoundException;
|
||||
import de.fete.application.service.exception.InvalidOrganizerTokenException;
|
||||
import de.fete.domain.model.Event;
|
||||
import de.fete.domain.model.EventToken;
|
||||
import de.fete.domain.model.OrganizerToken;
|
||||
@@ -51,23 +55,23 @@ class RsvpServiceTest {
|
||||
|
||||
@Test
|
||||
void createRsvpSucceedsForActiveEvent() {
|
||||
Event event = buildActiveEvent();
|
||||
EventToken token = event.getEventToken();
|
||||
Event event = buildActiveEvent(TODAY.plusDays(30));
|
||||
EventToken token = event.eventToken();
|
||||
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
||||
when(rsvpRepository.save(any(Rsvp.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
Rsvp result = rsvpService.createRsvp(token, "Max Mustermann");
|
||||
|
||||
assertThat(result.getName()).isEqualTo("Max Mustermann");
|
||||
assertThat(result.getRsvpToken()).isNotNull();
|
||||
assertThat(result.getEventId()).isEqualTo(event.getId());
|
||||
assertThat(result.name()).isEqualTo("Max Mustermann");
|
||||
assertThat(result.rsvpToken()).isNotNull();
|
||||
assertThat(result.eventId()).isEqualTo(event.id());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createRsvpPersistsViaRepository() {
|
||||
Event event = buildActiveEvent();
|
||||
EventToken token = event.getEventToken();
|
||||
Event event = buildActiveEvent(TODAY.plusDays(30));
|
||||
EventToken token = event.eventToken();
|
||||
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
||||
when(rsvpRepository.save(any(Rsvp.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
@@ -76,8 +80,8 @@ class RsvpServiceTest {
|
||||
|
||||
ArgumentCaptor<Rsvp> captor = ArgumentCaptor.forClass(Rsvp.class);
|
||||
verify(rsvpRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getName()).isEqualTo("Test Guest");
|
||||
assertThat(captor.getValue().getEventId()).isEqualTo(event.getId());
|
||||
assertThat(captor.getValue().name()).isEqualTo("Test Guest");
|
||||
assertThat(captor.getValue().eventId()).isEqualTo(event.id());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -91,22 +95,21 @@ class RsvpServiceTest {
|
||||
|
||||
@Test
|
||||
void createRsvpTrimsName() {
|
||||
Event event = buildActiveEvent();
|
||||
EventToken token = event.getEventToken();
|
||||
Event event = buildActiveEvent(TODAY.plusDays(30));
|
||||
EventToken token = event.eventToken();
|
||||
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
||||
when(rsvpRepository.save(any(Rsvp.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
Rsvp result = rsvpService.createRsvp(token, " Max ");
|
||||
|
||||
assertThat(result.getName()).isEqualTo("Max");
|
||||
assertThat(result.name()).isEqualTo("Max");
|
||||
}
|
||||
|
||||
@Test
|
||||
void createRsvpThrowsWhenEventExpired() {
|
||||
var event = buildActiveEvent();
|
||||
event.setExpiryDate(TODAY.minusDays(1));
|
||||
EventToken token = event.getEventToken();
|
||||
Event event = buildActiveEvent(TODAY.minusDays(1));
|
||||
EventToken token = event.eventToken();
|
||||
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
||||
|
||||
assertThatThrownBy(() -> rsvpService.createRsvp(token, "Late Guest"))
|
||||
@@ -115,9 +118,8 @@ class RsvpServiceTest {
|
||||
|
||||
@Test
|
||||
void createRsvpThrowsWhenEventExpiresToday() {
|
||||
var event = buildActiveEvent();
|
||||
event.setExpiryDate(TODAY);
|
||||
EventToken token = event.getEventToken();
|
||||
Event event = buildActiveEvent(TODAY);
|
||||
EventToken token = event.eventToken();
|
||||
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
||||
|
||||
assertThatThrownBy(() -> rsvpService.createRsvp(token, "Late Guest"))
|
||||
@@ -126,12 +128,12 @@ class RsvpServiceTest {
|
||||
|
||||
@Test
|
||||
void getAttendeeNamesReturnsNamesInOrder() {
|
||||
Event event = buildActiveEvent();
|
||||
EventToken token = event.getEventToken();
|
||||
OrganizerToken orgToken = event.getOrganizerToken();
|
||||
Event event = buildActiveEvent(TODAY.plusDays(30));
|
||||
EventToken token = event.eventToken();
|
||||
OrganizerToken orgToken = event.organizerToken();
|
||||
when(eventRepository.findByEventToken(token))
|
||||
.thenReturn(Optional.of(event));
|
||||
when(rsvpRepository.findByEventId(event.getId()))
|
||||
when(rsvpRepository.findByEventId(event.id()))
|
||||
.thenReturn(List.of(
|
||||
buildRsvp(1L, "Alice"),
|
||||
buildRsvp(2L, "Bob"),
|
||||
@@ -144,12 +146,12 @@ class RsvpServiceTest {
|
||||
|
||||
@Test
|
||||
void getAttendeeNamesReturnsEmptyListWhenNoRsvps() {
|
||||
Event event = buildActiveEvent();
|
||||
EventToken token = event.getEventToken();
|
||||
OrganizerToken orgToken = event.getOrganizerToken();
|
||||
Event event = buildActiveEvent(TODAY.plusDays(30));
|
||||
EventToken token = event.eventToken();
|
||||
OrganizerToken orgToken = event.organizerToken();
|
||||
when(eventRepository.findByEventToken(token))
|
||||
.thenReturn(Optional.of(event));
|
||||
when(rsvpRepository.findByEventId(event.getId()))
|
||||
when(rsvpRepository.findByEventId(event.id()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
List<String> names = rsvpService.getAttendeeNames(token, orgToken);
|
||||
@@ -171,8 +173,8 @@ class RsvpServiceTest {
|
||||
|
||||
@Test
|
||||
void getAttendeeNamesThrowsWhenOrganizerTokenInvalid() {
|
||||
Event event = buildActiveEvent();
|
||||
EventToken token = event.getEventToken();
|
||||
Event event = buildActiveEvent(TODAY.plusDays(30));
|
||||
EventToken token = event.eventToken();
|
||||
OrganizerToken wrongToken = OrganizerToken.generate();
|
||||
when(eventRepository.findByEventToken(token))
|
||||
.thenReturn(Optional.of(event));
|
||||
@@ -183,24 +185,57 @@ class RsvpServiceTest {
|
||||
}
|
||||
|
||||
private Rsvp buildRsvp(Long id, String name) {
|
||||
var rsvp = new Rsvp();
|
||||
rsvp.setId(id);
|
||||
rsvp.setRsvpToken(RsvpToken.generate());
|
||||
rsvp.setEventId(1L);
|
||||
rsvp.setName(name);
|
||||
return rsvp;
|
||||
return new Rsvp(id, RsvpToken.generate(), 1L, name);
|
||||
}
|
||||
|
||||
private Event buildActiveEvent() {
|
||||
var event = new Event();
|
||||
event.setId(1L);
|
||||
event.setEventToken(EventToken.generate());
|
||||
event.setOrganizerToken(OrganizerToken.generate());
|
||||
event.setTitle("Test Event");
|
||||
event.setDateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)));
|
||||
event.setTimezone(ZONE);
|
||||
event.setExpiryDate(TODAY.plusDays(30));
|
||||
event.setCreatedAt(OffsetDateTime.now());
|
||||
return event;
|
||||
@Test
|
||||
void cancelRsvpDeletesWhenEventAndRsvpExist() {
|
||||
Event event = buildActiveEvent(TODAY.plusDays(30));
|
||||
EventToken token = event.eventToken();
|
||||
RsvpToken rsvpToken = RsvpToken.generate();
|
||||
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
||||
when(rsvpRepository.deleteByEventIdAndRsvpToken(event.id(), rsvpToken)).thenReturn(true);
|
||||
|
||||
rsvpService.cancelRsvp(token, rsvpToken);
|
||||
|
||||
verify(rsvpRepository).deleteByEventIdAndRsvpToken(event.id(), rsvpToken);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelRsvpSucceedsWhenRsvpNotFound() {
|
||||
Event event = buildActiveEvent(TODAY.plusDays(30));
|
||||
EventToken token = event.eventToken();
|
||||
RsvpToken rsvpToken = RsvpToken.generate();
|
||||
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
||||
when(rsvpRepository.deleteByEventIdAndRsvpToken(event.id(), rsvpToken)).thenReturn(false);
|
||||
|
||||
rsvpService.cancelRsvp(token, rsvpToken);
|
||||
|
||||
verify(rsvpRepository).deleteByEventIdAndRsvpToken(event.id(), rsvpToken);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelRsvpSucceedsWhenEventNotFound() {
|
||||
EventToken token = EventToken.generate();
|
||||
RsvpToken rsvpToken = RsvpToken.generate();
|
||||
when(eventRepository.findByEventToken(token)).thenReturn(Optional.empty());
|
||||
|
||||
rsvpService.cancelRsvp(token, rsvpToken);
|
||||
}
|
||||
|
||||
private Event buildActiveEvent(LocalDate expiryDate) {
|
||||
return new Event(
|
||||
1L,
|
||||
EventToken.generate(),
|
||||
OrganizerToken.generate(),
|
||||
"Test Event",
|
||||
null,
|
||||
OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)),
|
||||
ZONE,
|
||||
null,
|
||||
expiryDate,
|
||||
OffsetDateTime.now(),
|
||||
false,
|
||||
null);
|
||||
}
|
||||
}
|
||||
|
||||
210
frontend/e2e/cancel-event-list.spec.ts
Normal file
210
frontend/e2e/cancel-event-list.spec.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { test, expect } from './msw-setup'
|
||||
import type { StoredEvent } from '../src/composables/useEventStorage'
|
||||
|
||||
const STORAGE_KEY = 'fete:events'
|
||||
|
||||
const organizerEvent: StoredEvent = {
|
||||
eventToken: 'org-event-aaa',
|
||||
title: 'Summer BBQ',
|
||||
dateTime: '2027-06-15T18:00:00Z',
|
||||
organizerToken: 'org-secret-token',
|
||||
}
|
||||
|
||||
const attendeeEvent: StoredEvent = {
|
||||
eventToken: 'att-event-bbb',
|
||||
title: 'Team Meeting',
|
||||
dateTime: '2027-01-10T09:00:00Z',
|
||||
rsvpToken: 'rsvp-token-1',
|
||||
rsvpName: 'Alice',
|
||||
}
|
||||
|
||||
function seedEvents(events: StoredEvent[]): string {
|
||||
return `window.localStorage.setItem('${STORAGE_KEY}', ${JSON.stringify(JSON.stringify(events))})`
|
||||
}
|
||||
|
||||
test.describe('US1: Organizer Cancels Event from List', () => {
|
||||
test('T001: organizer taps delete, confirms, event is removed after successful API call', async ({
|
||||
page,
|
||||
network,
|
||||
}) => {
|
||||
await page.addInitScript(seedEvents([organizerEvent, attendeeEvent]))
|
||||
|
||||
const { http, HttpResponse } = await import('msw')
|
||||
let patchCalled = false
|
||||
network.use(
|
||||
http.patch('*/api/events/:token', ({ request, params }) => {
|
||||
const url = new URL(request.url)
|
||||
if (
|
||||
params['token'] === organizerEvent.eventToken &&
|
||||
url.searchParams.get('organizerToken') === organizerEvent.organizerToken
|
||||
) {
|
||||
patchCalled = true
|
||||
return new HttpResponse(null, { status: 204 })
|
||||
}
|
||||
return HttpResponse.json(
|
||||
{ type: 'about:blank', title: 'Forbidden', status: 403 },
|
||||
{ status: 403, headers: { 'Content-Type': 'application/problem+json' } },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
await page.goto('/')
|
||||
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||
|
||||
// Click delete on organizer event
|
||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||
|
||||
// Confirmation dialog appears with organizer-specific text
|
||||
await expect(page.getByRole('alertdialog')).toBeVisible()
|
||||
|
||||
// Confirm cancellation
|
||||
await page.getByRole('button', { name: 'Remove', exact: true }).click()
|
||||
|
||||
// Event is removed from list
|
||||
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
|
||||
// Other event remains
|
||||
await expect(page.getByText('Team Meeting')).toBeVisible()
|
||||
|
||||
expect(patchCalled).toBe(true)
|
||||
})
|
||||
|
||||
test('T002: organizer confirms cancellation, API fails, event stays in list and error shown', async ({
|
||||
page,
|
||||
network,
|
||||
}) => {
|
||||
await page.addInitScript(seedEvents([organizerEvent]))
|
||||
|
||||
const { http, HttpResponse } = await import('msw')
|
||||
network.use(
|
||||
http.patch('*/api/events/:token', () => {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
type: 'about:blank',
|
||||
title: 'Internal Server Error',
|
||||
status: 500,
|
||||
},
|
||||
{ status: 500, headers: { 'Content-Type': 'application/problem+json' } },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
await page.goto('/')
|
||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||
await expect(page.getByRole('alertdialog')).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Remove', exact: true }).click()
|
||||
|
||||
// Event stays in list
|
||||
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||
})
|
||||
|
||||
test('T003: organizer confirms cancellation, API returns 409 Conflict, event is silently removed', async ({
|
||||
page,
|
||||
network,
|
||||
}) => {
|
||||
await page.addInitScript(seedEvents([organizerEvent]))
|
||||
|
||||
const { http, HttpResponse } = await import('msw')
|
||||
network.use(
|
||||
http.patch('*/api/events/:token', () => {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
type: 'about:blank',
|
||||
title: 'Conflict',
|
||||
status: 409,
|
||||
detail: 'Event is already cancelled.',
|
||||
},
|
||||
{ status: 409, headers: { 'Content-Type': 'application/problem+json' } },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
await page.goto('/')
|
||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||
await expect(page.getByRole('alertdialog')).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Remove', exact: true }).click()
|
||||
|
||||
// 409 treated as success — event removed
|
||||
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('T004: organizer opens cancel dialog then dismisses (cancel button), event remains', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript(seedEvents([organizerEvent]))
|
||||
await page.goto('/')
|
||||
|
||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||
await expect(page.getByRole('alertdialog')).toBeVisible()
|
||||
|
||||
// Dismiss via Cancel button
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
await expect(page.getByRole('alertdialog')).not.toBeVisible()
|
||||
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||
})
|
||||
|
||||
test('T004b: organizer opens cancel dialog then dismisses via Escape', async ({ page }) => {
|
||||
await page.addInitScript(seedEvents([organizerEvent]))
|
||||
await page.goto('/')
|
||||
|
||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||
await expect(page.getByRole('alertdialog')).toBeVisible()
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
await expect(page.getByRole('alertdialog')).not.toBeVisible()
|
||||
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||
})
|
||||
|
||||
test('T004c: organizer opens cancel dialog then dismisses via overlay click', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript(seedEvents([organizerEvent]))
|
||||
await page.goto('/')
|
||||
|
||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||
await expect(page.getByRole('alertdialog')).toBeVisible()
|
||||
|
||||
// Click on overlay (outside dialog)
|
||||
await page.locator('.confirm-dialog__overlay').click({ position: { x: 10, y: 10 } })
|
||||
|
||||
await expect(page.getByRole('alertdialog')).not.toBeVisible()
|
||||
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('US2: Distinct Dialog for Organizer vs. Attendee', () => {
|
||||
test('T011: organizer dialog shows event-cancellation warning', async ({ page }) => {
|
||||
await page.addInitScript(seedEvents([organizerEvent]))
|
||||
await page.goto('/')
|
||||
|
||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||
const dialog = page.getByRole('alertdialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
// Organizer-specific title and message
|
||||
await expect(dialog.locator('.confirm-dialog__title')).toHaveText('Cancel event?')
|
||||
await expect(dialog.locator('.confirm-dialog__message')).toContainText(
|
||||
'all attendees',
|
||||
)
|
||||
})
|
||||
|
||||
test('T012: attendee dialog preserves existing RSVP-cancellation message', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript(seedEvents([attendeeEvent]))
|
||||
await page.goto('/')
|
||||
|
||||
await page.getByRole('button', { name: /Remove Team Meeting/ }).click()
|
||||
const dialog = page.getByRole('alertdialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
// Attendee-specific title and message
|
||||
await expect(dialog.locator('.confirm-dialog__title')).toHaveText('Remove event?')
|
||||
await expect(dialog.locator('.confirm-dialog__message')).toContainText(
|
||||
'attendance will be cancelled',
|
||||
)
|
||||
})
|
||||
})
|
||||
166
frontend/e2e/cancel-event.spec.ts
Normal file
166
frontend/e2e/cancel-event.spec.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
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}`)
|
||||
|
||||
// Open kebab menu, then cancel event
|
||||
const kebabBtn = page.getByRole('button', { name: /Event actions/i })
|
||||
await expect(kebabBtn).toBeVisible()
|
||||
await kebabBtn.click()
|
||||
|
||||
const cancelItem = page.getByRole('menuitem', { name: /Cancel event/i })
|
||||
await expect(cancelItem).toBeVisible()
|
||||
await cancelItem.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()
|
||||
|
||||
// Kebab menu should be gone (event is cancelled)
|
||||
await expect(kebabBtn).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: /Event actions/i }).click()
|
||||
await page.getByRole('menuitem', { 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: /Event actions/i }).click()
|
||||
await page.getByRole('menuitem', { 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()
|
||||
})
|
||||
})
|
||||
276
frontend/e2e/cancel-rsvp.spec.ts
Normal file
276
frontend/e2e/cancel-rsvp.spec.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
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,
|
||||
}
|
||||
|
||||
const rsvpToken = 'd4e5f6a7-b8c9-0123-4567-890abcdef012'
|
||||
|
||||
function seedEvents(events: StoredEvent[]): string {
|
||||
return `window.localStorage.setItem('${STORAGE_KEY}', ${JSON.stringify(JSON.stringify(events))})`
|
||||
}
|
||||
|
||||
function rsvpSeed(): StoredEvent {
|
||||
return {
|
||||
eventToken: fullEvent.eventToken,
|
||||
title: fullEvent.title,
|
||||
dateTime: fullEvent.dateTime,
|
||||
rsvpToken,
|
||||
rsvpName: 'Anna',
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('US1: Cancel RSVP from Event Detail View', () => {
|
||||
test('status bar shows cancel affordance when RSVP\'d', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||
)
|
||||
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
// Status bar visible
|
||||
const statusBar = page.getByRole('button', { name: /You're attending/ })
|
||||
await expect(statusBar).toBeVisible()
|
||||
|
||||
// Cancel button hidden initially
|
||||
await expect(page.getByRole('button', { name: 'Cancel RSVP' })).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('tapping status bar reveals cancel button', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||
)
|
||||
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
// Tap status bar
|
||||
await page.getByRole('button', { name: /You're attending/ }).click()
|
||||
|
||||
// Cancel button appears
|
||||
await expect(page.getByRole('button', { name: 'Cancel RSVP' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('confirm cancellation → localStorage cleared, count decremented, bar reset', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
|
||||
return new HttpResponse(null, { status: 204 })
|
||||
}),
|
||||
)
|
||||
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
// Expand → Cancel RSVP → Confirm in dialog
|
||||
await page.getByRole('button', { name: /You're attending/ }).click()
|
||||
await page.locator('.rsvp-bar__cancel').click()
|
||||
|
||||
// Confirm dialog
|
||||
await expect(page.getByText('The organizer will no longer see you as attending.')).toBeVisible()
|
||||
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel RSVP' }).click()
|
||||
|
||||
// Bar resets to CTA state
|
||||
await expect(page.getByRole('button', { name: "I'm attending" })).toBeVisible()
|
||||
await expect(page.getByText("You're attending!")).not.toBeVisible()
|
||||
|
||||
// Attendee count decremented
|
||||
await expect(page.getByText('11 going')).toBeVisible()
|
||||
|
||||
// localStorage cleared
|
||||
const stored = await page.evaluate(() => {
|
||||
const raw = localStorage.getItem('fete:events')
|
||||
return raw ? JSON.parse(raw) : null
|
||||
})
|
||||
const event = stored?.find((e: StoredEvent) => e.eventToken === fullEvent.eventToken)
|
||||
expect(event?.rsvpToken).toBeUndefined()
|
||||
expect(event?.rsvpName).toBeUndefined()
|
||||
})
|
||||
|
||||
test('server error → error message, state unchanged', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
|
||||
return HttpResponse.json({ error: 'fail' }, { status: 500 })
|
||||
}),
|
||||
)
|
||||
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
// Expand → Cancel → Confirm in dialog
|
||||
await page.getByRole('button', { name: /You're attending/ }).click()
|
||||
await page.locator('.rsvp-bar__cancel').click()
|
||||
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel RSVP' }).click()
|
||||
|
||||
// Error message
|
||||
await expect(page.getByText('Could not cancel RSVP. Please try again.')).toBeVisible()
|
||||
|
||||
// Attendee count unchanged
|
||||
await expect(page.getByText('12 going')).toBeVisible()
|
||||
})
|
||||
|
||||
test('re-RSVP after cancel works', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
|
||||
return new HttpResponse(null, { status: 204 })
|
||||
}),
|
||||
http.post('*/api/events/:token/rsvps', () => {
|
||||
return HttpResponse.json(
|
||||
{ rsvpToken: 'new-rsvp-token', name: 'Max' },
|
||||
{ status: 201 },
|
||||
)
|
||||
}),
|
||||
)
|
||||
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
// Cancel first
|
||||
await page.getByRole('button', { name: /You're attending/ }).click()
|
||||
await page.locator('.rsvp-bar__cancel').click()
|
||||
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel RSVP' }).click()
|
||||
|
||||
// CTA should be back
|
||||
await expect(page.getByRole('button', { name: "I'm attending" })).toBeVisible()
|
||||
|
||||
// Re-RSVP
|
||||
await page.getByRole('button', { name: "I'm attending" }).click()
|
||||
const dialog = page.getByRole('dialog', { name: 'RSVP' })
|
||||
await dialog.getByLabel('Your name').fill('Max')
|
||||
await dialog.getByRole('button', { name: 'Count me in' }).click()
|
||||
|
||||
// Status bar returns
|
||||
await expect(page.getByText("You're attending!")).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('US2: Auto-Cancel on Event List Removal', () => {
|
||||
test('removal of RSVP\'d event shows attendance warning in dialog', async ({ page }) => {
|
||||
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||
await page.goto('/')
|
||||
|
||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||
|
||||
await expect(page.getByText('your attendance will be cancelled')).toBeVisible()
|
||||
})
|
||||
|
||||
test('removal of non-RSVP\'d watcher event shows standard dialog', async ({ page }) => {
|
||||
const watcherEvent: StoredEvent = {
|
||||
eventToken: 'watcher-token',
|
||||
title: 'Watcher Event',
|
||||
dateTime: '2027-06-15T18:00:00Z',
|
||||
}
|
||||
await page.addInitScript(seedEvents([watcherEvent]))
|
||||
await page.goto('/')
|
||||
|
||||
// Watcher events are removed directly without dialog
|
||||
await page.getByRole('button', { name: /Remove Watcher Event/ }).click()
|
||||
|
||||
// Watcher removal is immediate — event disappears
|
||||
await expect(page.getByText('Watcher Event')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('confirm removal → DELETE called → event removed from list', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
|
||||
return new HttpResponse(null, { status: 204 })
|
||||
}),
|
||||
)
|
||||
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||
await page.goto('/')
|
||||
|
||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||
await page.getByRole('button', { name: 'Remove', exact: true }).click()
|
||||
|
||||
// Event gone
|
||||
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
|
||||
|
||||
// localStorage updated
|
||||
const stored = await page.evaluate(() => {
|
||||
const raw = localStorage.getItem('fete:events')
|
||||
return raw ? JSON.parse(raw) : null
|
||||
})
|
||||
const found = stored?.find((e: StoredEvent) => e.eventToken === fullEvent.eventToken)
|
||||
expect(found).toBeUndefined()
|
||||
})
|
||||
|
||||
test('server error on DELETE → error message, event stays in list', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
|
||||
return HttpResponse.json({ error: 'fail' }, { status: 500 })
|
||||
}),
|
||||
)
|
||||
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||
await page.goto('/')
|
||||
|
||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||
await page.getByRole('button', { name: 'Remove', exact: true }).click()
|
||||
|
||||
// Event still in list
|
||||
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||
})
|
||||
|
||||
test('dismiss dialog → no changes', async ({ page }) => {
|
||||
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||
await page.goto('/')
|
||||
|
||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
// Event still there
|
||||
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('US3: Cancel RSVP with Stale/Invalid Token', () => {
|
||||
test('cancel from detail view with stale token (404) → treated as success', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
|
||||
return HttpResponse.json({ error: 'not found' }, { status: 404 })
|
||||
}),
|
||||
)
|
||||
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
// Cancel flow
|
||||
await page.getByRole('button', { name: /You're attending/ }).click()
|
||||
await page.locator('.rsvp-bar__cancel').click()
|
||||
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel RSVP' }).click()
|
||||
|
||||
// Treated as success — CTA returns
|
||||
await expect(page.getByRole('button', { name: "I'm attending" })).toBeVisible()
|
||||
|
||||
// localStorage cleaned
|
||||
const stored = await page.evaluate(() => {
|
||||
const raw = localStorage.getItem('fete:events')
|
||||
return raw ? JSON.parse(raw) : null
|
||||
})
|
||||
const event = stored?.find((e: StoredEvent) => e.eventToken === fullEvent.eventToken)
|
||||
expect(event?.rsvpToken).toBeUndefined()
|
||||
})
|
||||
|
||||
test('event list removal with stale token (404) → treated as success', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
|
||||
return HttpResponse.json({ error: 'not found' }, { status: 404 })
|
||||
}),
|
||||
)
|
||||
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||
await page.goto('/')
|
||||
|
||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||
await page.getByRole('button', { name: 'Remove', exact: true }).click()
|
||||
|
||||
// Event removed from list
|
||||
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -9,7 +9,6 @@ test.describe('US-1: Create an event', () => {
|
||||
|
||||
await expect(page.getByText('Title is required.')).toBeVisible()
|
||||
await expect(page.getByText('Date and time are required.')).toBeVisible()
|
||||
await expect(page.getByText('Expiry date is required.')).toBeVisible()
|
||||
})
|
||||
|
||||
test('creates an event and redirects to event detail page', async ({ page }) => {
|
||||
@@ -19,7 +18,6 @@ test.describe('US-1: Create an event', () => {
|
||||
await page.getByLabel(/description/i).fill('Bring your own drinks')
|
||||
await page.getByLabel(/date/i).first().fill('2026-04-15T18:00')
|
||||
await page.getByLabel(/location/i).fill('Central Park')
|
||||
await page.getByLabel(/expiry/i).fill('2026-06-15')
|
||||
|
||||
await page.getByRole('button', { name: /create event/i }).click()
|
||||
|
||||
@@ -31,7 +29,6 @@ test.describe('US-1: Create an event', () => {
|
||||
|
||||
await page.getByLabel(/title/i).fill('Summer BBQ')
|
||||
await page.getByLabel(/date/i).first().fill('2026-04-15T18:00')
|
||||
await page.getByLabel(/expiry/i).fill('2026-06-15')
|
||||
|
||||
await page.getByRole('button', { name: /create event/i }).click()
|
||||
await expect(page).toHaveURL(/\/events\/.+/)
|
||||
@@ -59,7 +56,6 @@ test.describe('US-1: Create an event', () => {
|
||||
await page.goto('/create')
|
||||
await page.getByLabel(/title/i).fill('Test')
|
||||
await page.getByLabel(/date/i).first().fill('2026-04-15T18:00')
|
||||
await page.getByLabel(/expiry/i).fill('2026-06-15')
|
||||
|
||||
await page.getByRole('button', { name: /create event/i }).click()
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ const fullEvent = {
|
||||
timezone: 'Europe/Berlin',
|
||||
location: 'Central Park, NYC',
|
||||
attendeeCount: 12,
|
||||
expired: false,
|
||||
}
|
||||
|
||||
test.describe('US1: RSVP submission flow', () => {
|
||||
@@ -170,16 +169,4 @@ test.describe('US1: RSVP submission flow', () => {
|
||||
await expect(page.getByText("You're attending!")).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('does not show RSVP bar on expired event', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => {
|
||||
return HttpResponse.json({ ...fullEvent, expired: true })
|
||||
}),
|
||||
)
|
||||
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
await expect(page.getByText('This event has ended.')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: "I'm attending" })).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,7 +9,6 @@ const fullEvent = {
|
||||
timezone: 'Europe/Berlin',
|
||||
location: 'Central Park, NYC',
|
||||
attendeeCount: 12,
|
||||
expired: false,
|
||||
}
|
||||
|
||||
test.describe('US-1: View event details', () => {
|
||||
@@ -52,20 +51,6 @@ test.describe('US-1: View event details', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('US-2: View expired event', () => {
|
||||
test('shows "event has ended" banner for expired event', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => {
|
||||
return HttpResponse.json({ ...fullEvent, expired: true })
|
||||
}),
|
||||
)
|
||||
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
await expect(page.getByText('This event has ended.')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('US-4: Event not found', () => {
|
||||
test('shows "event not found" for unknown token', async ({ page, network }) => {
|
||||
network.use(
|
||||
|
||||
@@ -7,7 +7,6 @@ const futureEvent1: StoredEvent = {
|
||||
eventToken: 'future-aaa',
|
||||
title: 'Summer BBQ',
|
||||
dateTime: '2027-06-15T18:00:00Z',
|
||||
expiryDate: '2027-06-16T00:00:00Z',
|
||||
organizerToken: 'org-token-1',
|
||||
}
|
||||
|
||||
@@ -15,7 +14,6 @@ const futureEvent2: StoredEvent = {
|
||||
eventToken: 'future-bbb',
|
||||
title: 'Team Meeting',
|
||||
dateTime: '2027-01-10T09:00:00Z',
|
||||
expiryDate: '2027-01-11T00:00:00Z',
|
||||
rsvpToken: 'rsvp-token-1',
|
||||
rsvpName: 'Alice',
|
||||
}
|
||||
@@ -24,7 +22,6 @@ const pastEvent: StoredEvent = {
|
||||
eventToken: 'past-ccc',
|
||||
title: 'New Year Party',
|
||||
dateTime: '2025-01-01T00:00:00Z',
|
||||
expiryDate: '2025-01-02T00:00:00Z',
|
||||
}
|
||||
|
||||
function seedEvents(events: StoredEvent[]): string {
|
||||
@@ -85,7 +82,6 @@ test.describe('US4: Past Events Appear Faded', () => {
|
||||
location: '',
|
||||
timezone: 'UTC',
|
||||
attendeeCount: 0,
|
||||
expired: true,
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -97,19 +93,30 @@ test.describe('US4: Past Events Appear Faded', () => {
|
||||
})
|
||||
|
||||
test.describe('US3: Remove Event from List', () => {
|
||||
test('delete icon triggers confirmation dialog, confirm removes event', async ({ page }) => {
|
||||
test('delete icon triggers confirmation dialog, confirm removes event', async ({
|
||||
page,
|
||||
network,
|
||||
}) => {
|
||||
await page.addInitScript(seedEvents([futureEvent1, futureEvent2]))
|
||||
|
||||
const { http, HttpResponse } = await import('msw')
|
||||
network.use(
|
||||
http.patch('*/api/events/:token', () => {
|
||||
return new HttpResponse(null, { status: 204 })
|
||||
}),
|
||||
)
|
||||
|
||||
await page.goto('/')
|
||||
|
||||
// Both events visible
|
||||
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||
await expect(page.getByText('Team Meeting')).toBeVisible()
|
||||
|
||||
// Click delete on Summer BBQ
|
||||
// Click delete on Summer BBQ (organizer event)
|
||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||
|
||||
// Confirmation dialog appears
|
||||
await expect(page.getByText('Remove event?')).toBeVisible()
|
||||
// Confirmation dialog appears (organizer event shows "Cancel event?")
|
||||
await expect(page.getByText('Cancel event?')).toBeVisible()
|
||||
|
||||
// Confirm removal
|
||||
await page.getByRole('button', { name: 'Remove', exact: true }).click()
|
||||
@@ -124,13 +131,13 @@ test.describe('US3: Remove Event from List', () => {
|
||||
await page.goto('/')
|
||||
|
||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||
await expect(page.getByText('Remove event?')).toBeVisible()
|
||||
await expect(page.getByText('Cancel event?')).toBeVisible()
|
||||
|
||||
// Cancel
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
// Dialog gone, event still there
|
||||
await expect(page.getByText('Remove event?')).not.toBeVisible()
|
||||
await expect(page.getByText('Cancel event?')).not.toBeVisible()
|
||||
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -143,7 +150,7 @@ test.describe('US5: Visual Distinction for Event Roles', () => {
|
||||
const card = page.locator('.event-card').filter({ hasText: 'Summer BBQ' })
|
||||
const badge = card.locator('.event-card__badge')
|
||||
await expect(badge).toBeVisible()
|
||||
await expect(badge).toHaveText('Organizer')
|
||||
await expect(badge).toHaveText('Organizing')
|
||||
await expect(badge).toHaveClass(/event-card__badge--organizer/)
|
||||
})
|
||||
|
||||
@@ -154,16 +161,19 @@ test.describe('US5: Visual Distinction for Event Roles', () => {
|
||||
const card = page.locator('.event-card').filter({ hasText: 'Team Meeting' })
|
||||
const badge = card.locator('.event-card__badge')
|
||||
await expect(badge).toBeVisible()
|
||||
await expect(badge).toHaveText('Attendee')
|
||||
await expect(badge).toHaveText('Attending')
|
||||
await expect(badge).toHaveClass(/event-card__badge--attendee/)
|
||||
})
|
||||
|
||||
test('shows no badge for events without organizerToken or rsvpToken', async ({ page }) => {
|
||||
test('shows watcher badge for events without organizerToken or rsvpToken', async ({ page }) => {
|
||||
await page.addInitScript(seedEvents([pastEvent]))
|
||||
await page.goto('/')
|
||||
|
||||
const card = page.locator('.event-card').filter({ hasText: 'New Year Party' })
|
||||
await expect(card.locator('.event-card__badge')).toHaveCount(0)
|
||||
const badge = card.locator('.event-card__badge')
|
||||
await expect(badge).toBeVisible()
|
||||
await expect(badge).toHaveText('Watching')
|
||||
await expect(badge).toHaveClass(/event-card__badge--watcher/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -199,13 +209,11 @@ test.describe('Temporal Grouping: Section Headers', () => {
|
||||
eventToken: 'today-1',
|
||||
title: 'Today Standup',
|
||||
dateTime: new Date(now.getFullYear(), now.getMonth(), now.getDate(), 18, 0, 0).toISOString(),
|
||||
expiryDate: '',
|
||||
}
|
||||
const laterEvent: StoredEvent = {
|
||||
eventToken: 'later-1',
|
||||
title: 'Future Conference',
|
||||
dateTime: new Date(now.getFullYear() + 1, 0, 15, 10, 0, 0).toISOString(),
|
||||
expiryDate: '',
|
||||
}
|
||||
await page.addInitScript(seedEvents([todayEvent, laterEvent, pastEvent]))
|
||||
await page.goto('/')
|
||||
@@ -245,7 +253,6 @@ test.describe('Temporal Grouping: Section Headers', () => {
|
||||
eventToken: 'today-emph',
|
||||
title: 'Emphasis Test',
|
||||
dateTime: new Date(now.getFullYear(), now.getMonth(), now.getDate(), 20, 0, 0).toISOString(),
|
||||
expiryDate: '',
|
||||
}
|
||||
await page.addInitScript(seedEvents([todayEvent]))
|
||||
await page.goto('/')
|
||||
@@ -262,7 +269,6 @@ test.describe('Temporal Grouping: Date Subheaders', () => {
|
||||
eventToken: 'today-sub',
|
||||
title: 'No Subheader Test',
|
||||
dateTime: new Date(now.getFullYear(), now.getMonth(), now.getDate(), 19, 0, 0).toISOString(),
|
||||
expiryDate: '',
|
||||
}
|
||||
await page.addInitScript(seedEvents([todayEvent]))
|
||||
await page.goto('/')
|
||||
@@ -355,7 +361,6 @@ test.describe('US1: View My Events', () => {
|
||||
location: '',
|
||||
timezone: 'UTC',
|
||||
attendeeCount: 0,
|
||||
expired: false,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
108
frontend/e2e/ical-download.spec.ts
Normal file
108
frontend/e2e/ical-download.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
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: 'Sommerfest am See',
|
||||
description: 'Bring your own drinks!',
|
||||
dateTime: '2026-07-15T18:00:00+02:00',
|
||||
timezone: 'Europe/Berlin',
|
||||
location: 'Stadtpark Berlin',
|
||||
attendeeCount: 12,
|
||||
cancelled: false,
|
||||
}
|
||||
|
||||
const cancelledEvent = {
|
||||
...fullEvent,
|
||||
cancelled: true,
|
||||
cancellationReason: 'Bad weather',
|
||||
}
|
||||
|
||||
function seedEvents(events: StoredEvent[]): string {
|
||||
return `window.localStorage.setItem('${STORAGE_KEY}', ${JSON.stringify(JSON.stringify(events))})`
|
||||
}
|
||||
|
||||
function rsvpSeed(): StoredEvent {
|
||||
return {
|
||||
eventToken: fullEvent.eventToken,
|
||||
title: fullEvent.title,
|
||||
dateTime: fullEvent.dateTime,
|
||||
rsvpToken: 'd4e5f6a7-b8c9-0123-4567-890abcdef012',
|
||||
rsvpName: 'Anna',
|
||||
}
|
||||
}
|
||||
|
||||
function organizerSeed(): StoredEvent {
|
||||
return {
|
||||
eventToken: fullEvent.eventToken,
|
||||
title: fullEvent.title,
|
||||
dateTime: fullEvent.dateTime,
|
||||
organizerToken: 'org-token-1234',
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('iCal download: calendar button visibility', () => {
|
||||
test('calendar button visible for pre-RSVP visitor', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||
)
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
const calendarBtn = page.getByRole('button', { name: /add to calendar/i })
|
||||
await expect(calendarBtn).toBeVisible()
|
||||
})
|
||||
|
||||
test('calendar button visible for post-RSVP attendee', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||
)
|
||||
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
const calendarBtn = page.getByRole('button', { name: /add to calendar/i })
|
||||
await expect(calendarBtn).toBeVisible()
|
||||
})
|
||||
|
||||
test('calendar button visible for organizer', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||
http.get('*/api/events/:token/attendees*', () =>
|
||||
HttpResponse.json({ attendees: [] }),
|
||||
),
|
||||
)
|
||||
await page.addInitScript(seedEvents([organizerSeed()]))
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
const calendarBtn = page.getByRole('button', { name: /add to calendar/i })
|
||||
await expect(calendarBtn).toBeVisible()
|
||||
})
|
||||
|
||||
test('calendar button NOT visible for cancelled event', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => HttpResponse.json(cancelledEvent)),
|
||||
)
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
const calendarBtn = page.getByRole('button', { name: /add to calendar/i })
|
||||
await expect(calendarBtn).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('iCal download: file generation', () => {
|
||||
test('triggers download with correct filename', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||
)
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
// Intercept the download by overriding the click-link mechanism
|
||||
const downloadPromise = page.waitForEvent('download')
|
||||
await page.getByRole('button', { name: /add to calendar/i }).click()
|
||||
const download = await downloadPromise
|
||||
|
||||
expect(download.suggestedFilename()).toBe('sommerfest-am-see.ics')
|
||||
})
|
||||
})
|
||||
218
frontend/e2e/watch-event.spec.ts
Normal file
218
frontend/e2e/watch-event.spec.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
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,
|
||||
}
|
||||
|
||||
const rsvpToken = 'd4e5f6a7-b8c9-0123-4567-890abcdef012'
|
||||
const organizerToken = 'org-token-1234'
|
||||
|
||||
function seedEvents(events: StoredEvent[]): string {
|
||||
return `window.localStorage.setItem('${STORAGE_KEY}', ${JSON.stringify(JSON.stringify(events))})`
|
||||
}
|
||||
|
||||
function watchSeed(): StoredEvent {
|
||||
return {
|
||||
eventToken: fullEvent.eventToken,
|
||||
title: fullEvent.title,
|
||||
dateTime: fullEvent.dateTime,
|
||||
}
|
||||
}
|
||||
|
||||
function rsvpSeed(): StoredEvent {
|
||||
return {
|
||||
eventToken: fullEvent.eventToken,
|
||||
title: fullEvent.title,
|
||||
dateTime: fullEvent.dateTime,
|
||||
rsvpToken,
|
||||
rsvpName: 'Anna',
|
||||
}
|
||||
}
|
||||
|
||||
function organizerSeed(): StoredEvent {
|
||||
return {
|
||||
eventToken: fullEvent.eventToken,
|
||||
title: fullEvent.title,
|
||||
dateTime: fullEvent.dateTime,
|
||||
organizerToken,
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('US1: Watch event from detail page', () => {
|
||||
test('bookmark unfilled by default, tapping watches the event', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||
)
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
const bookmark = page.getByLabel(/watch.*this event/i)
|
||||
await expect(bookmark).toBeVisible()
|
||||
await expect(bookmark).toHaveAttribute('aria-label', 'Watch this event')
|
||||
|
||||
await bookmark.click()
|
||||
|
||||
await expect(bookmark).toHaveAttribute('aria-label', 'Stop watching this event')
|
||||
|
||||
// Navigate to event list via back link
|
||||
await page.getByLabel('Back to home').click()
|
||||
|
||||
// Event appears with "Watching" label
|
||||
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||
await expect(page.getByText('Watching')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('US2: Un-watch event from detail page', () => {
|
||||
test('tapping filled bookmark un-watches the event', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||
)
|
||||
await page.addInitScript(seedEvents([watchSeed()]))
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
const bookmark = page.getByLabel(/watch.*this event/i)
|
||||
await expect(bookmark).toHaveAttribute('aria-label', 'Stop watching this event')
|
||||
|
||||
await bookmark.click()
|
||||
|
||||
await expect(bookmark).toHaveAttribute('aria-label', 'Watch this event')
|
||||
|
||||
// Navigate to event list via back link (avoid page.goto re-running addInitScript)
|
||||
await page.getByLabel('Back to home').click()
|
||||
|
||||
// Event is gone
|
||||
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('US3: Bookmark reflects attending status', () => {
|
||||
test('bookmark is not visible when user has RSVPed, list shows Attendee', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||
)
|
||||
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
// Bookmark not shown for attendees — RsvpBar shows status state
|
||||
const bookmark = page.getByLabel(/watch.*this event/i)
|
||||
await expect(bookmark).not.toBeVisible()
|
||||
|
||||
// Navigate to list via back link
|
||||
await page.getByLabel('Back to home').click()
|
||||
await expect(page.getByText('Attending')).toBeVisible()
|
||||
await expect(page.getByText('Watching')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('US4: RSVP cancellation preserves watch status', () => {
|
||||
test('cancel RSVP → bookmark reappears, list shows Watching', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
|
||||
return new HttpResponse(null, { status: 204 })
|
||||
}),
|
||||
)
|
||||
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
// Cancel RSVP
|
||||
await page.getByRole('button', { name: /You're attending/ }).click()
|
||||
await page.locator('.rsvp-bar__cancel').click()
|
||||
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel RSVP' }).click()
|
||||
|
||||
// Bookmark reappears in CTA state, filled because event is still stored
|
||||
const bookmark = page.getByLabel(/watch.*this event/i)
|
||||
await expect(bookmark).toBeVisible()
|
||||
await expect(bookmark).toHaveAttribute('aria-label', 'Stop watching this event')
|
||||
|
||||
// Navigate to list via back link
|
||||
await page.getByLabel('Back to home').click()
|
||||
await expect(page.getByText('Watching')).toBeVisible()
|
||||
await expect(page.getByText('Attending')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('US5: No bookmark for attendees and organizers', () => {
|
||||
test('attendee does not see bookmark', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||
)
|
||||
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
const bookmark = page.getByLabel(/watch.*this event/i)
|
||||
await expect(bookmark).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('organizer does not see bookmark', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||
)
|
||||
await page.addInitScript(seedEvents([organizerSeed()]))
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
const bookmark = page.getByLabel(/watch.*this event/i)
|
||||
await expect(bookmark).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('US6: Un-watch from event list', () => {
|
||||
test('deleting a watched event skips confirmation dialog', async ({ page }) => {
|
||||
await page.addInitScript(seedEvents([watchSeed()]))
|
||||
await page.goto('/')
|
||||
|
||||
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||
|
||||
// No confirmation dialog — event removed immediately
|
||||
await expect(page.getByText('Remove event?')).not.toBeVisible()
|
||||
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('US7: Watcher upgrades to attendee', () => {
|
||||
test('watch → RSVP → bookmark disappears, list shows Attendee', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||
http.post('*/api/events/:token/rsvps', () => {
|
||||
return HttpResponse.json(
|
||||
{ rsvpToken: 'new-rsvp-token', name: 'Max' },
|
||||
{ status: 201 },
|
||||
)
|
||||
}),
|
||||
)
|
||||
await page.addInitScript(seedEvents([watchSeed()]))
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
// Verify watching state — bookmark visible
|
||||
const bookmark = page.getByLabel(/watch.*this event/i)
|
||||
await expect(bookmark).toBeVisible()
|
||||
await expect(bookmark).toHaveAttribute('aria-label', 'Stop watching this event')
|
||||
|
||||
// RSVP
|
||||
await page.getByRole('button', { name: "I'm attending" }).click()
|
||||
const dialog = page.getByRole('dialog', { name: 'RSVP' })
|
||||
await dialog.getByLabel('Your name').fill('Max')
|
||||
await dialog.getByRole('button', { name: 'Count me in' }).click()
|
||||
|
||||
// Bookmark gone — status bar shown instead
|
||||
await expect(bookmark).not.toBeVisible()
|
||||
|
||||
// Navigate to list via back link
|
||||
await page.getByLabel('Back to home').click()
|
||||
await expect(page.getByText('Attending')).toBeVisible()
|
||||
await expect(page.getByText('Watching')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
1138
frontend/package-lock.json
generated
1138
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -38,21 +38,27 @@
|
||||
"@vue/tsconfig": "^0.9.0",
|
||||
"eslint": "^10.0.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-oxlint": "~1.51.0",
|
||||
"eslint-plugin-oxlint": "~1.55.0",
|
||||
"eslint-plugin-vue": "~10.8.0",
|
||||
"jiti": "^2.6.1",
|
||||
"jsdom": "^28.1.0",
|
||||
"msw": "^2.12.10",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
"oxlint": "~1.51.0",
|
||||
"oxlint": "~1.55.0",
|
||||
"prettier": "3.8.1",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vite": "^8.0.0",
|
||||
"vite-plugin-vue-devtools": "^8.0.6",
|
||||
"vitest": "^4.0.18",
|
||||
"vue-tsc": "^3.2.5"
|
||||
},
|
||||
"browserslist": [
|
||||
">= 0.5%",
|
||||
"last 2 versions",
|
||||
"Firefox ESR",
|
||||
"not dead"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
|
||||
@@ -1,9 +1,35 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<header v-if="route.name !== 'home'" class="app-header">
|
||||
<BackLink />
|
||||
<div id="header-actions"></div>
|
||||
</header>
|
||||
<RouterView />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
import { RouterView, useRoute } from 'vue-router'
|
||||
import BackLink from '@/components/BackLink.vue'
|
||||
|
||||
const route = useRoute()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-lg) var(--content-padding);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-header :deep(*) {
|
||||
pointer-events: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,6 +18,17 @@
|
||||
--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);
|
||||
--color-danger-solid: #d32f2f;
|
||||
--color-danger-solid-hover: #b71c1c;
|
||||
--color-danger-solid-text: #fff;
|
||||
|
||||
/* Glass system */
|
||||
--color-glass: rgba(255, 255, 255, 0.1);
|
||||
--color-glass-strong: rgba(255, 255, 255, 0.15);
|
||||
@@ -151,11 +162,32 @@ textarea.form-field {
|
||||
min-height: 5rem;
|
||||
}
|
||||
|
||||
/* iOS Safari: datetime-local overflows container and shows empty when no value */
|
||||
input[type="datetime-local"].form-field {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
input[type="datetime-local"].form-field.glass::-webkit-date-and-time-value {
|
||||
color: var(--color-text-on-gradient);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
input[type="datetime-local"].form-field.glass::-webkit-datetime-edit {
|
||||
color: var(--color-text-on-gradient);
|
||||
}
|
||||
|
||||
input[type="datetime-local"].form-field.glass::-webkit-datetime-edit-fields-wrapper {
|
||||
color: var(--color-text-on-gradient);
|
||||
}
|
||||
|
||||
/* Form group (label + input) */
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
@@ -206,7 +238,7 @@ textarea.form-field {
|
||||
|
||||
/* Error message */
|
||||
.field-error {
|
||||
color: #fff;
|
||||
color: var(--color-danger-solid);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
padding-left: 0.25rem;
|
||||
@@ -233,7 +265,6 @@ textarea.form-field {
|
||||
border: 1px solid var(--color-glass-border);
|
||||
box-shadow: var(--shadow-card);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.glass:hover:not(input):not(textarea):not(.btn-primary) {
|
||||
@@ -245,7 +276,6 @@ textarea.form-field {
|
||||
.glass-inner {
|
||||
background: var(--color-glass-inner);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
/* Glow border: conic gradient wrapper with halo (static) */
|
||||
@@ -287,6 +317,72 @@ textarea.form-field {
|
||||
to { --glow-angle: 360deg; }
|
||||
}
|
||||
|
||||
/* ── Fixed Bottom Bar Components ── */
|
||||
|
||||
/* CTA wrapper (text button, e.g. "I'm attending!", "Post an update") */
|
||||
.bar-cta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border-radius: var(--radius-button);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.bar-cta:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.bar-cta:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.bar-cta-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-radius: calc(var(--radius-button) - 2px);
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-on-gradient);
|
||||
text-align: center;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
/* Icon wrapper (e.g. calendar, bookmark buttons) */
|
||||
.bar-icon {
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-button);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.bar-icon:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.bar-icon:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.bar-icon-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: var(--spacing-md);
|
||||
border-radius: calc(var(--radius-button) - 2px);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-on-gradient);
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.bar-icon-btn svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Utility */
|
||||
.text-center {
|
||||
text-align: center;
|
||||
@@ -317,7 +413,8 @@ textarea.form-field {
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.rsvp-form__label {
|
||||
.rsvp-form__label,
|
||||
.cancel-form__label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-on-gradient);
|
||||
@@ -325,7 +422,7 @@ textarea.form-field {
|
||||
}
|
||||
|
||||
.rsvp-form__field-error {
|
||||
color: #d32f2f;
|
||||
color: var(--color-danger-solid);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
padding-left: 0.25rem;
|
||||
|
||||
28
frontend/src/components/BackLink.vue
Normal file
28
frontend/src/components/BackLink.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<RouterLink to="/" class="back-link" aria-label="Back to home">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
<span class="back-link__brand">fete</span>
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
color: var(--color-text-on-gradient);
|
||||
text-decoration: none;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.back-link__brand {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,18 @@
|
||||
<Teleport to="body">
|
||||
<Transition name="sheet">
|
||||
<div v-if="open" class="sheet-backdrop" @click.self="$emit('close')" @keydown.escape="$emit('close')">
|
||||
<div class="sheet" role="dialog" aria-modal="true" :aria-label="label" ref="sheetEl" tabindex="-1">
|
||||
<div
|
||||
class="sheet"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="label"
|
||||
ref="sheetEl"
|
||||
tabindex="-1"
|
||||
:style="dragStyle"
|
||||
@touchstart="onTouchStart"
|
||||
@touchmove="onTouchMove"
|
||||
@touchend="onTouchEnd"
|
||||
>
|
||||
<div class="sheet__handle" aria-hidden="true" />
|
||||
<slot />
|
||||
</div>
|
||||
@@ -12,14 +23,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
open: boolean
|
||||
label: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
@@ -39,6 +50,45 @@ watch(
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/* ── Drag-to-dismiss ── */
|
||||
const DISMISS_THRESHOLD = 100
|
||||
const dragY = ref(0)
|
||||
const dragging = ref(false)
|
||||
let startY = 0
|
||||
|
||||
const dragStyle = computed(() => {
|
||||
if (!dragging.value || dragY.value <= 0) return undefined
|
||||
return {
|
||||
transform: `translateY(${dragY.value}px)`,
|
||||
transition: 'none',
|
||||
}
|
||||
})
|
||||
|
||||
function onTouchStart(e: TouchEvent) {
|
||||
const touch = e.touches[0]
|
||||
if (!touch) return
|
||||
startY = touch.clientY
|
||||
dragging.value = true
|
||||
dragY.value = 0
|
||||
}
|
||||
|
||||
function onTouchMove(e: TouchEvent) {
|
||||
if (!dragging.value) return
|
||||
const touch = e.touches[0]
|
||||
if (!touch) return
|
||||
const delta = touch.clientY - startY
|
||||
if (delta > 0) e.preventDefault()
|
||||
dragY.value = Math.max(0, delta)
|
||||
}
|
||||
|
||||
function onTouchEnd() {
|
||||
if (dragY.value >= DISMISS_THRESHOLD) {
|
||||
emit('close')
|
||||
}
|
||||
dragging.value = false
|
||||
dragY.value = 0
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -57,7 +107,6 @@ watch(
|
||||
border: 1px solid var(--color-glass-border);
|
||||
border-bottom: none;
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border-radius: 20px 20px 0 0;
|
||||
padding: var(--spacing-lg) var(--spacing-xl) var(--spacing-2xl);
|
||||
width: 100%;
|
||||
|
||||
@@ -87,7 +87,6 @@ watch(
|
||||
background: linear-gradient(135deg, var(--color-glass-strong) 0%, var(--color-glass-subtle) 100%);
|
||||
border: 1px solid var(--color-glass-border);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
padding: var(--spacing-xl);
|
||||
@@ -139,8 +138,8 @@ watch(
|
||||
}
|
||||
|
||||
.confirm-dialog__btn--confirm {
|
||||
background: #d32f2f;
|
||||
color: #fff;
|
||||
background: var(--color-danger-solid);
|
||||
color: var(--color-danger-solid-text);
|
||||
}
|
||||
|
||||
.confirm-dialog-enter-active,
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<span class="event-card__time">{{ displayTime }}</span>
|
||||
</RouterLink>
|
||||
<span v-if="eventRole" class="event-card__badge" :class="`event-card__badge--${eventRole}`">
|
||||
{{ eventRole === 'organizer' ? 'Organizer' : 'Attendee' }}
|
||||
{{ eventRole === 'organizer' ? 'Organizing' : eventRole === 'attendee' ? 'Attending' : 'Watching' }}
|
||||
</span>
|
||||
<button
|
||||
class="event-card__delete"
|
||||
@@ -34,7 +34,7 @@ const props = defineProps<{
|
||||
title: string
|
||||
relativeTime: string
|
||||
isPast: boolean
|
||||
eventRole?: 'organizer' | 'attendee'
|
||||
eventRole?: 'organizer' | 'attendee' | 'watcher'
|
||||
timeDisplayMode?: 'clock' | 'relative'
|
||||
dateTime?: string
|
||||
}>()
|
||||
@@ -152,6 +152,12 @@ function onTouchEnd() {
|
||||
color: var(--color-text-bright);
|
||||
}
|
||||
|
||||
.event-card__badge--watcher {
|
||||
background: var(--color-glass);
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-glass-border);
|
||||
}
|
||||
|
||||
.event-card__delete {
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
@@ -169,7 +175,7 @@ function onTouchEnd() {
|
||||
}
|
||||
|
||||
.event-card__delete:hover {
|
||||
color: #d32f2f;
|
||||
color: var(--color-danger-solid);
|
||||
background: rgba(211, 47, 47, 0.08);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
</section>
|
||||
<ConfirmDialog
|
||||
:open="!!pendingDeleteToken"
|
||||
title="Remove event?"
|
||||
message="This event will be removed from your list."
|
||||
:title="deleteDialogTitle"
|
||||
:message="deleteDialogMessage"
|
||||
confirm-label="Remove"
|
||||
cancel-label="Cancel"
|
||||
@confirm="confirmDelete"
|
||||
@@ -42,24 +42,106 @@ import { computed, ref } from 'vue'
|
||||
import { useEventStorage, isValidStoredEvent } from '../composables/useEventStorage'
|
||||
import { useEventGrouping } from '../composables/useEventGrouping'
|
||||
import { formatRelativeTime } from '../composables/useRelativeTime'
|
||||
import { api } from '../api/client'
|
||||
import EventCard from './EventCard.vue'
|
||||
import SectionHeader from './SectionHeader.vue'
|
||||
import DateSubheader from './DateSubheader.vue'
|
||||
import ConfirmDialog from './ConfirmDialog.vue'
|
||||
import type { StoredEvent } from '../composables/useEventStorage'
|
||||
|
||||
const { getStoredEvents, removeEvent } = useEventStorage()
|
||||
const { getStoredEvents, getRsvp, getOrganizerToken, removeEvent } = useEventStorage()
|
||||
|
||||
const pendingDeleteToken = ref<string | null>(null)
|
||||
const deleteError = ref('')
|
||||
|
||||
const pendingDeleteRole = computed(() => {
|
||||
if (!pendingDeleteToken.value) return null
|
||||
const event = getStoredEvents().find((e) => e.eventToken === pendingDeleteToken.value)
|
||||
return event ? getRole(event) : null
|
||||
})
|
||||
|
||||
const deleteDialogTitle = computed(() => {
|
||||
return pendingDeleteRole.value === 'organizer' ? 'Cancel event?' : 'Remove event?'
|
||||
})
|
||||
|
||||
const deleteDialogMessage = computed(() => {
|
||||
if (!pendingDeleteToken.value) return ''
|
||||
if (pendingDeleteRole.value === 'organizer') {
|
||||
return 'This will permanently cancel the event for all attendees.'
|
||||
}
|
||||
const rsvp = getRsvp(pendingDeleteToken.value)
|
||||
if (rsvp) {
|
||||
return 'This event will be removed from your list and your attendance will be cancelled.'
|
||||
}
|
||||
return 'This event will be removed from your list.'
|
||||
})
|
||||
|
||||
function requestDelete(eventToken: string) {
|
||||
deleteError.value = ''
|
||||
const role = getRole(getStoredEvents().find((e) => e.eventToken === eventToken)!)
|
||||
if (role === 'watcher') {
|
||||
removeEvent(eventToken)
|
||||
return
|
||||
}
|
||||
pendingDeleteToken.value = eventToken
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
if (pendingDeleteToken.value) {
|
||||
removeEvent(pendingDeleteToken.value)
|
||||
async function confirmDelete() {
|
||||
if (!pendingDeleteToken.value) return
|
||||
|
||||
const eventToken = pendingDeleteToken.value
|
||||
const organizerToken = getOrganizerToken(eventToken)
|
||||
|
||||
if (organizerToken) {
|
||||
try {
|
||||
const { response } = await api.PATCH('/events/{eventToken}', {
|
||||
params: {
|
||||
path: { eventToken },
|
||||
query: { organizerToken },
|
||||
},
|
||||
body: { cancelled: true },
|
||||
})
|
||||
|
||||
if (response.status !== 204 && response.status !== 409 && response.status !== 404) {
|
||||
deleteError.value = 'Could not cancel event. Please try again.'
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
deleteError.value = 'Could not cancel event. Please try again.'
|
||||
return
|
||||
}
|
||||
|
||||
removeEvent(eventToken)
|
||||
pendingDeleteToken.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const rsvp = getRsvp(eventToken)
|
||||
|
||||
if (rsvp) {
|
||||
try {
|
||||
const { response } = await api.DELETE('/events/{eventToken}/rsvps/{rsvpToken}', {
|
||||
params: {
|
||||
path: {
|
||||
eventToken: eventToken,
|
||||
rsvpToken: rsvp.rsvpToken,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (response.status !== 204 && response.status !== 404) {
|
||||
deleteError.value = 'Could not cancel attendance. Please try again.'
|
||||
pendingDeleteToken.value = null
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
deleteError.value = 'Could not cancel attendance. Please try again.'
|
||||
pendingDeleteToken.value = null
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
removeEvent(eventToken)
|
||||
pendingDeleteToken.value = null
|
||||
}
|
||||
|
||||
@@ -67,10 +149,10 @@ function cancelDelete() {
|
||||
pendingDeleteToken.value = null
|
||||
}
|
||||
|
||||
function getRole(event: StoredEvent): 'organizer' | 'attendee' | undefined {
|
||||
function getRole(event: StoredEvent): 'organizer' | 'attendee' | 'watcher' {
|
||||
if (event.organizerToken) return 'organizer'
|
||||
if (event.rsvpToken) return 'attendee'
|
||||
return undefined
|
||||
return 'watcher'
|
||||
}
|
||||
|
||||
const groupedSections = computed(() => {
|
||||
|
||||
@@ -2,29 +2,111 @@
|
||||
<div class="rsvp-bar">
|
||||
<div class="rsvp-bar__inner">
|
||||
<!-- Status state: already RSVPed -->
|
||||
<div v-if="hasRsvp" class="rsvp-bar__status">
|
||||
<span class="rsvp-bar__check" aria-hidden="true">✓</span>
|
||||
<span class="rsvp-bar__text">You're attending!</span>
|
||||
<div v-if="hasRsvp" class="rsvp-bar__status-wrapper">
|
||||
<div class="rsvp-bar__status-row">
|
||||
<div
|
||||
class="rsvp-bar__status"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-expanded="expanded"
|
||||
aria-label="You're attending. Tap to show cancel option."
|
||||
@click="expanded = !expanded"
|
||||
@keydown.enter.prevent="expanded = !expanded"
|
||||
@keydown.space.prevent="expanded = !expanded"
|
||||
@keydown.escape="expanded = false"
|
||||
>
|
||||
<span class="rsvp-bar__check" aria-hidden="true">✓</span>
|
||||
<span class="rsvp-bar__text">You're attending!</span>
|
||||
<span class="rsvp-bar__chevron" :class="{ 'rsvp-bar__chevron--open': expanded }" aria-hidden="true">›</span>
|
||||
</div>
|
||||
<button
|
||||
class="rsvp-bar__calendar-glass"
|
||||
type="button"
|
||||
aria-label="Add to calendar"
|
||||
@click="$emit('calendar')"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<Transition name="rsvp-bar-cancel">
|
||||
<button
|
||||
v-if="expanded"
|
||||
class="rsvp-bar__cancel"
|
||||
type="button"
|
||||
@click="$emit('cancel')"
|
||||
>
|
||||
Cancel RSVP
|
||||
</button>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- CTA state: no RSVP yet -->
|
||||
<div v-else class="rsvp-bar__cta glow-border glow-border--animated">
|
||||
<button class="rsvp-bar__cta-inner glass-inner" type="button" @click="$emit('open')">
|
||||
I'm attending
|
||||
</button>
|
||||
<div v-else class="rsvp-bar__row">
|
||||
<div class="bar-icon glow-border glow-border--animated">
|
||||
<button
|
||||
class="bar-icon-btn glass-inner"
|
||||
type="button"
|
||||
:aria-label="bookmarked ? 'Stop watching this event' : 'Watch this event'"
|
||||
@click="$emit('bookmark')"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" :fill="bookmarked ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="bar-cta glow-border glow-border--animated">
|
||||
<button class="bar-cta-btn glass-inner" type="button" @click="$emit('open')">
|
||||
I'm attending!
|
||||
</button>
|
||||
</div>
|
||||
<div class="bar-icon glow-border glow-border--animated">
|
||||
<button
|
||||
class="bar-icon-btn glass-inner"
|
||||
type="button"
|
||||
aria-label="Add to calendar"
|
||||
@click="$emit('calendar')"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
hasRsvp?: boolean
|
||||
bookmarked?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
open: []
|
||||
cancel: []
|
||||
bookmark: []
|
||||
calendar: []
|
||||
}>()
|
||||
|
||||
const expanded = ref(false)
|
||||
|
||||
watch(() => props.hasRsvp, () => {
|
||||
expanded.value = false
|
||||
})
|
||||
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
if (!target.closest('.rsvp-bar__status-wrapper')) {
|
||||
expanded.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(expanded, (isExpanded) => {
|
||||
if (isExpanded) {
|
||||
document.addEventListener('click', onClickOutside, { capture: true })
|
||||
} else {
|
||||
document.removeEventListener('click', onClickOutside, { capture: true })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -45,35 +127,26 @@ defineEmits<{
|
||||
max-width: var(--content-max-width);
|
||||
}
|
||||
|
||||
.rsvp-bar__cta {
|
||||
width: 100%;
|
||||
border-radius: var(--radius-button);
|
||||
transition: transform 0.1s ease;
|
||||
.rsvp-bar__row {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.rsvp-bar__cta:hover {
|
||||
transform: scale(1.02);
|
||||
|
||||
.rsvp-bar__status-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.rsvp-bar__cta:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.rsvp-bar__cta-inner {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-radius: calc(var(--radius-button) - 2px);
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-on-gradient);
|
||||
text-align: center;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
.rsvp-bar__status-row {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.rsvp-bar__status {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -81,13 +154,19 @@ defineEmits<{
|
||||
background: linear-gradient(135deg, var(--color-glass-strong) 0%, var(--color-glass-subtle) 100%);
|
||||
border: 1px solid var(--color-glass-border);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border-radius: var(--radius-card);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text-on-gradient);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.rsvp-bar__status:hover {
|
||||
background: linear-gradient(135deg, var(--color-glass-hover) 0%, var(--color-glass-strong) 100%);
|
||||
}
|
||||
|
||||
.rsvp-bar__check {
|
||||
@@ -101,4 +180,81 @@ defineEmits<{
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rsvp-bar__chevron {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
transition: transform 0.2s ease;
|
||||
transform: rotate(0deg);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.rsvp-bar__chevron--open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.rsvp-bar__cancel {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
border-radius: var(--radius-card);
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #ef5350;
|
||||
background: linear-gradient(135deg, var(--color-glass-strong) 0%, var(--color-glass-subtle) 100%);
|
||||
border: 1px solid var(--color-glass-border);
|
||||
backdrop-filter: blur(16px);
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.rsvp-bar__cancel:hover {
|
||||
background: linear-gradient(135deg, var(--color-glass-hover) 0%, var(--color-glass-strong) 100%);
|
||||
}
|
||||
|
||||
.rsvp-bar-cancel-enter-active,
|
||||
.rsvp-bar-cancel-leave-active {
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.rsvp-bar-cancel-enter-from,
|
||||
.rsvp-bar-cancel-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
|
||||
/* Calendar button — glassmorphic variant (post-RSVP status row) */
|
||||
.rsvp-bar__calendar-glass {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-md);
|
||||
background: linear-gradient(135deg, var(--color-glass-strong) 0%, var(--color-glass-subtle) 100%);
|
||||
border: 1px solid var(--color-glass-border);
|
||||
backdrop-filter: blur(16px);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
color: var(--color-text-on-gradient);
|
||||
cursor: pointer;
|
||||
line-height: 0;
|
||||
transition: transform 0.1s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.rsvp-bar__calendar-glass:hover {
|
||||
transform: scale(1.02);
|
||||
background: linear-gradient(135deg, var(--color-glass-hover) 0%, var(--color-glass-strong) 100%);
|
||||
}
|
||||
|
||||
.rsvp-bar__calendar-glass:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.rsvp-bar__calendar-glass svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -55,12 +55,18 @@ describe('EventCard', () => {
|
||||
|
||||
it('renders organizer badge when eventRole is organizer', () => {
|
||||
const wrapper = mountCard({ eventRole: 'organizer' })
|
||||
expect(wrapper.text()).toContain('Organizer')
|
||||
expect(wrapper.text()).toContain('Organizing')
|
||||
})
|
||||
|
||||
it('renders attendee badge when eventRole is attendee', () => {
|
||||
const wrapper = mountCard({ eventRole: 'attendee' })
|
||||
expect(wrapper.text()).toContain('Attendee')
|
||||
expect(wrapper.text()).toContain('Attending')
|
||||
})
|
||||
|
||||
it('renders watcher badge when eventRole is watcher', () => {
|
||||
const wrapper = mountCard({ eventRole: 'watcher' })
|
||||
expect(wrapper.find('.event-card__badge--watcher').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('Watching')
|
||||
})
|
||||
|
||||
it('renders no badge when eventRole is undefined', () => {
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import EventList from '../EventList.vue'
|
||||
|
||||
vi.mock('../../api/client', () => ({
|
||||
api: {
|
||||
PATCH: vi.fn(),
|
||||
DELETE: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
@@ -15,13 +22,17 @@ const router = createRouter({
|
||||
const NOW = new Date(2026, 2, 11, 12, 0, 0)
|
||||
|
||||
const mockEvents = [
|
||||
{ eventToken: 'past-1', title: 'Past Event', dateTime: '2026-03-01T10:00:00', expiryDate: '' },
|
||||
{ eventToken: 'later-1', title: 'Later Event', dateTime: '2027-06-15T10:00:00', expiryDate: '' },
|
||||
{ eventToken: 'today-1', title: 'Today Event', dateTime: '2026-03-11T18:00:00', expiryDate: '' },
|
||||
{ eventToken: 'week-1', title: 'This Week Event', dateTime: '2026-03-13T10:00:00', expiryDate: '' },
|
||||
{ eventToken: 'nextweek-1', title: 'Next Week Event', dateTime: '2026-03-16T10:00:00', expiryDate: '' },
|
||||
{ eventToken: 'past-1', title: 'Past Event', dateTime: '2026-03-01T10:00:00' },
|
||||
{ eventToken: 'later-1', title: 'Later Event', dateTime: '2027-06-15T10:00:00' },
|
||||
{ eventToken: 'today-1', title: 'Today Event', dateTime: '2026-03-11T18:00:00' },
|
||||
{ eventToken: 'week-1', title: 'This Week Event', dateTime: '2026-03-13T10:00:00' },
|
||||
{ eventToken: 'nextweek-1', title: 'Next Week Event', dateTime: '2026-03-16T10:00:00' },
|
||||
{ eventToken: 'org-1', title: 'Organized Event', dateTime: '2026-03-11T19:00:00', organizerToken: 'org-token' },
|
||||
{ eventToken: 'rsvp-1', title: 'Attending Event', dateTime: '2026-03-11T20:00:00', rsvpToken: 'rsvp-token', rsvpName: 'Max' },
|
||||
]
|
||||
|
||||
const removeEventMock = vi.fn()
|
||||
|
||||
vi.mock('../../composables/useEventStorage', () => ({
|
||||
isValidStoredEvent: (e: unknown) => {
|
||||
if (typeof e !== 'object' || e === null) return false
|
||||
@@ -32,7 +43,21 @@ vi.mock('../../composables/useEventStorage', () => ({
|
||||
},
|
||||
useEventStorage: () => ({
|
||||
getStoredEvents: () => mockEvents,
|
||||
removeEvent: vi.fn(),
|
||||
getRsvp: (token: string) => {
|
||||
const evt = mockEvents.find((e) => e.eventToken === token)
|
||||
if (evt && 'rsvpToken' in evt && 'rsvpName' in evt) {
|
||||
return { rsvpToken: evt.rsvpToken, rsvpName: evt.rsvpName }
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
getOrganizerToken: (token: string) => {
|
||||
const evt = mockEvents.find((e) => e.eventToken === token)
|
||||
if (evt && 'organizerToken' in evt) {
|
||||
return (evt as Record<string, unknown>).organizerToken as string
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
removeEvent: removeEventMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -40,7 +65,9 @@ vi.mock('../../composables/useRelativeTime', () => ({
|
||||
formatRelativeTime: (dateTime: string) => {
|
||||
if (dateTime.includes('03-01')) return '10 days ago'
|
||||
if (dateTime.includes('06-15')) return 'in 1 year'
|
||||
if (dateTime.includes('03-11')) return 'in 6 hours'
|
||||
if (dateTime.includes('03-11T18')) return 'in 6 hours'
|
||||
if (dateTime.includes('03-11T19')) return 'in 7 hours'
|
||||
if (dateTime.includes('03-11T20')) return 'in 8 hours'
|
||||
if (dateTime.includes('03-13')) return 'in 2 days'
|
||||
if (dateTime.includes('03-16')) return 'in 5 days'
|
||||
return 'sometime'
|
||||
@@ -49,7 +76,10 @@ vi.mock('../../composables/useRelativeTime', () => ({
|
||||
|
||||
function mountList() {
|
||||
return mount(EventList, {
|
||||
global: { plugins: [router] },
|
||||
global: {
|
||||
plugins: [router],
|
||||
stubs: { Teleport: true },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -89,7 +119,7 @@ describe('EventList', () => {
|
||||
it('renders all valid events as cards', () => {
|
||||
const wrapper = mountList()
|
||||
const cards = wrapper.findAll('.event-card')
|
||||
expect(cards).toHaveLength(5)
|
||||
expect(cards).toHaveLength(7)
|
||||
})
|
||||
|
||||
it('marks past events with isPast class', () => {
|
||||
@@ -137,4 +167,140 @@ describe('EventList', () => {
|
||||
const pastSection = wrapper.findAll('.event-section')[4]!
|
||||
expect(pastSection.find('.date-subheader').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('assigns watcher role when event has no organizerToken and no rsvpToken', () => {
|
||||
const wrapper = mountList()
|
||||
const badges = wrapper.findAll('.event-card__badge--watcher')
|
||||
expect(badges.length).toBeGreaterThanOrEqual(1)
|
||||
expect(badges[0]!.text()).toBe('Watching')
|
||||
})
|
||||
|
||||
it('assigns organizer role when event has organizerToken', () => {
|
||||
const wrapper = mountList()
|
||||
const badge = wrapper.find('.event-card__badge--organizer')
|
||||
expect(badge.exists()).toBe(true)
|
||||
expect(badge.text()).toBe('Organizing')
|
||||
})
|
||||
|
||||
it('assigns attendee role when event has rsvpToken', () => {
|
||||
const wrapper = mountList()
|
||||
const badge = wrapper.find('.event-card__badge--attendee')
|
||||
expect(badge.exists()).toBe(true)
|
||||
expect(badge.text()).toBe('Attending')
|
||||
})
|
||||
})
|
||||
|
||||
describe('EventList — Organizer Cancel (US1)', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(NOW)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('T005: confirmDelete calls PATCH cancel-event API when role is organizer', async () => {
|
||||
const { api } = await import('../../api/client')
|
||||
const patchMock = vi.mocked(api.PATCH)
|
||||
patchMock.mockResolvedValue({ response: { status: 204 } } as never)
|
||||
|
||||
const wrapper = mountList()
|
||||
|
||||
// Find the organizer event delete button and click it
|
||||
const orgCard = wrapper.findAll('.event-card').find((c) => c.text().includes('Organized Event'))
|
||||
expect(orgCard).toBeTruthy()
|
||||
await orgCard!.find('.event-card__delete').trigger('click')
|
||||
|
||||
// Confirm the dialog
|
||||
const confirmBtn = wrapper.find('.confirm-dialog__btn--confirm')
|
||||
await confirmBtn.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(patchMock).toHaveBeenCalledWith(
|
||||
'/events/{eventToken}',
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
path: { eventToken: 'org-1' },
|
||||
query: { organizerToken: 'org-token' },
|
||||
}),
|
||||
body: { cancelled: true },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('T006: confirmDelete treats 409 response as success (removes event from list)', async () => {
|
||||
const { api } = await import('../../api/client')
|
||||
const patchMock = vi.mocked(api.PATCH)
|
||||
patchMock.mockResolvedValue({ response: { status: 409 } } as never)
|
||||
|
||||
removeEventMock.mockClear()
|
||||
|
||||
const wrapper = mountList()
|
||||
|
||||
const orgCard = wrapper.findAll('.event-card').find((c) => c.text().includes('Organized Event'))
|
||||
await orgCard!.find('.event-card__delete').trigger('click')
|
||||
|
||||
const confirmBtn = wrapper.find('.confirm-dialog__btn--confirm')
|
||||
await confirmBtn.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
// 409 should be treated as success — removeEvent should have been called
|
||||
expect(removeEventMock).toHaveBeenCalledWith('org-1')
|
||||
})
|
||||
|
||||
it('T006b: confirmDelete treats 404 response as success (removes event from list)', async () => {
|
||||
const { api } = await import('../../api/client')
|
||||
const patchMock = vi.mocked(api.PATCH)
|
||||
patchMock.mockResolvedValue({ response: { status: 404 } } as never)
|
||||
|
||||
removeEventMock.mockClear()
|
||||
|
||||
const wrapper = mountList()
|
||||
|
||||
const orgCard = wrapper.findAll('.event-card').find((c) => c.text().includes('Organized Event'))
|
||||
await orgCard!.find('.event-card__delete').trigger('click')
|
||||
|
||||
const confirmBtn = wrapper.find('.confirm-dialog__btn--confirm')
|
||||
await confirmBtn.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(removeEventMock).toHaveBeenCalledWith('org-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('EventList — Dialog Differentiation (US2)', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(NOW)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('T013: deleteDialogTitle returns organizer-specific text when role is organizer', async () => {
|
||||
const wrapper = mountList()
|
||||
|
||||
// Click delete on organizer event
|
||||
const orgCard = wrapper.findAll('.event-card').find((c) => c.text().includes('Organized Event'))
|
||||
await orgCard!.find('.event-card__delete').trigger('click')
|
||||
|
||||
const dialogTitle = wrapper.find('.confirm-dialog__title')
|
||||
expect(dialogTitle.text()).toBe('Cancel event?')
|
||||
})
|
||||
|
||||
it('T014: deleteDialogMessage returns existing attendee text when role is attendee', async () => {
|
||||
const wrapper = mountList()
|
||||
|
||||
// Click delete on attendee event
|
||||
const attCard = wrapper.findAll('.event-card').find((c) => c.text().includes('Attending Event'))
|
||||
await attCard!.find('.event-card__delete').trigger('click')
|
||||
|
||||
const dialogTitle = wrapper.find('.confirm-dialog__title')
|
||||
expect(dialogTitle.text()).toBe('Remove event?')
|
||||
|
||||
const dialogMsg = wrapper.find('.confirm-dialog__message')
|
||||
expect(dialogMsg.text()).toContain('attendance will be cancelled')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,8 +5,8 @@ import RsvpBar from '../RsvpBar.vue'
|
||||
describe('RsvpBar', () => {
|
||||
it('renders CTA button when hasRsvp is false', () => {
|
||||
const wrapper = mount(RsvpBar)
|
||||
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(true)
|
||||
expect(wrapper.find('.rsvp-bar__cta-inner').text()).toBe("I'm attending")
|
||||
expect(wrapper.find('.bar-cta').exists()).toBe(true)
|
||||
expect(wrapper.find('.bar-cta-btn').text()).toBe("I'm attending!")
|
||||
expect(wrapper.find('.rsvp-bar__status').exists()).toBe(false)
|
||||
})
|
||||
|
||||
@@ -14,17 +14,17 @@ describe('RsvpBar', () => {
|
||||
const wrapper = mount(RsvpBar, { props: { hasRsvp: true } })
|
||||
expect(wrapper.find('.rsvp-bar__status').exists()).toBe(true)
|
||||
expect(wrapper.find('.rsvp-bar__text').text()).toBe("You're attending!")
|
||||
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(false)
|
||||
expect(wrapper.find('.bar-cta').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('emits open when CTA button is clicked', async () => {
|
||||
const wrapper = mount(RsvpBar)
|
||||
await wrapper.find('.rsvp-bar__cta-inner').trigger('click')
|
||||
await wrapper.find('.bar-cta-btn').trigger('click')
|
||||
expect(wrapper.emitted('open')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not render CTA button when hasRsvp is true', () => {
|
||||
const wrapper = mount(RsvpBar, { props: { hasRsvp: true } })
|
||||
expect(wrapper.find('button').exists()).toBe(false)
|
||||
expect(wrapper.find('.bar-cta-btn').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,7 +6,6 @@ function makeEvent(overrides: Partial<StoredEvent> & { dateTime: string }): Stor
|
||||
return {
|
||||
eventToken: `evt-${Math.random().toString(36).slice(2, 8)}`,
|
||||
title: 'Test Event',
|
||||
expiryDate: '',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,6 @@ describe('useEventStorage', () => {
|
||||
organizerToken: 'org-456',
|
||||
title: 'Birthday',
|
||||
dateTime: '2026-06-15T20:00:00+02:00',
|
||||
expiryDate: '2026-07-15',
|
||||
})
|
||||
|
||||
const events = getStoredEvents()
|
||||
@@ -61,7 +60,6 @@ describe('useEventStorage', () => {
|
||||
organizerToken: 'org-456',
|
||||
title: 'Test',
|
||||
dateTime: '2026-06-15T20:00:00+02:00',
|
||||
expiryDate: '2026-07-15',
|
||||
})
|
||||
|
||||
expect(getOrganizerToken('abc-123')).toBe('org-456')
|
||||
@@ -79,14 +77,12 @@ describe('useEventStorage', () => {
|
||||
eventToken: 'event-1',
|
||||
title: 'First',
|
||||
dateTime: '2026-06-15T20:00:00+02:00',
|
||||
expiryDate: '2026-07-15',
|
||||
})
|
||||
|
||||
saveCreatedEvent({
|
||||
eventToken: 'event-2',
|
||||
title: 'Second',
|
||||
dateTime: '2026-07-15T20:00:00+02:00',
|
||||
expiryDate: '2026-08-15',
|
||||
})
|
||||
|
||||
const events = getStoredEvents()
|
||||
@@ -102,14 +98,12 @@ describe('useEventStorage', () => {
|
||||
eventToken: 'abc-123',
|
||||
title: 'Old Title',
|
||||
dateTime: '2026-06-15T20:00:00+02:00',
|
||||
expiryDate: '2026-07-15',
|
||||
})
|
||||
|
||||
saveCreatedEvent({
|
||||
eventToken: 'abc-123',
|
||||
title: 'New Title',
|
||||
dateTime: '2026-06-15T20:00:00+02:00',
|
||||
expiryDate: '2026-07-15',
|
||||
})
|
||||
|
||||
const events = getStoredEvents()
|
||||
@@ -124,7 +118,6 @@ describe('useEventStorage', () => {
|
||||
eventToken: 'abc-123',
|
||||
title: 'Birthday',
|
||||
dateTime: '2026-06-15T20:00:00+02:00',
|
||||
expiryDate: '2026-07-15',
|
||||
})
|
||||
|
||||
saveRsvp('abc-123', 'rsvp-token-1', 'Max', 'Birthday', '2026-06-15T20:00:00+02:00')
|
||||
@@ -154,7 +147,6 @@ describe('useEventStorage', () => {
|
||||
eventToken: 'abc-123',
|
||||
title: 'Test',
|
||||
dateTime: '2026-06-15T20:00:00+02:00',
|
||||
expiryDate: '2026-07-15',
|
||||
})
|
||||
|
||||
expect(getRsvp('abc-123')).toBeUndefined()
|
||||
@@ -172,14 +164,12 @@ describe('useEventStorage', () => {
|
||||
eventToken: 'event-1',
|
||||
title: 'First',
|
||||
dateTime: '2026-06-15T20:00:00+02:00',
|
||||
expiryDate: '2026-07-15',
|
||||
})
|
||||
|
||||
saveCreatedEvent({
|
||||
eventToken: 'event-2',
|
||||
title: 'Second',
|
||||
dateTime: '2026-07-15T20:00:00+02:00',
|
||||
expiryDate: '2026-08-15',
|
||||
})
|
||||
|
||||
removeEvent('event-1')
|
||||
@@ -196,7 +186,6 @@ describe('useEventStorage', () => {
|
||||
eventToken: 'event-1',
|
||||
title: 'First',
|
||||
dateTime: '2026-06-15T20:00:00+02:00',
|
||||
expiryDate: '2026-07-15',
|
||||
})
|
||||
|
||||
removeEvent('nonexistent')
|
||||
@@ -205,6 +194,71 @@ describe('useEventStorage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('useEventStorage – saveWatch / isStored', () => {
|
||||
beforeEach(() => {
|
||||
clearStorage()
|
||||
})
|
||||
|
||||
it('saves a watch-only event (no rsvpToken, no organizerToken)', () => {
|
||||
const { saveWatch, getStoredEvents } = useEventStorage()
|
||||
|
||||
saveWatch('watch-1', 'Concert', '2026-07-01T20:00:00+02:00')
|
||||
|
||||
const events = getStoredEvents()
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]!.eventToken).toBe('watch-1')
|
||||
expect(events[0]!.title).toBe('Concert')
|
||||
expect(events[0]!.dateTime).toBe('2026-07-01T20:00:00+02:00')
|
||||
expect(events[0]!.rsvpToken).toBeUndefined()
|
||||
expect(events[0]!.organizerToken).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not duplicate if event already stored', () => {
|
||||
const { saveWatch, saveRsvp, getStoredEvents } = useEventStorage()
|
||||
|
||||
saveRsvp('evt-1', 'rsvp-1', 'Max', 'Party', '2026-07-01T20:00:00+02:00')
|
||||
saveWatch('evt-1', 'Party', '2026-07-01T20:00:00+02:00')
|
||||
|
||||
expect(getStoredEvents()).toHaveLength(1)
|
||||
expect(getStoredEvents()[0]!.rsvpToken).toBe('rsvp-1')
|
||||
})
|
||||
|
||||
it('isStored returns true for watched events', () => {
|
||||
const { saveWatch, isStored } = useEventStorage()
|
||||
|
||||
saveWatch('watch-1', 'Concert', '2026-07-01T20:00:00+02:00')
|
||||
|
||||
expect(isStored('watch-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('isStored returns true for attended events', () => {
|
||||
const { saveRsvp, isStored } = useEventStorage()
|
||||
|
||||
saveRsvp('evt-1', 'rsvp-1', 'Max', 'Party', '2026-07-01T20:00:00+02:00')
|
||||
|
||||
expect(isStored('evt-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('isStored returns true for organized events', () => {
|
||||
const { saveCreatedEvent, isStored } = useEventStorage()
|
||||
|
||||
saveCreatedEvent({
|
||||
eventToken: 'evt-1',
|
||||
organizerToken: 'org-1',
|
||||
title: 'My Event',
|
||||
dateTime: '2026-07-01T20:00:00+02:00',
|
||||
})
|
||||
|
||||
expect(isStored('evt-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('isStored returns false for unknown tokens', () => {
|
||||
const { isStored } = useEventStorage()
|
||||
|
||||
expect(isStored('unknown')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidStoredEvent', () => {
|
||||
// Import directly since it's an exported function
|
||||
let isValidStoredEvent: (e: unknown) => boolean
|
||||
@@ -220,8 +274,7 @@ describe('isValidStoredEvent', () => {
|
||||
eventToken: 'abc-123',
|
||||
title: 'Birthday',
|
||||
dateTime: '2026-06-15T20:00:00+02:00',
|
||||
expiryDate: '2026-07-15',
|
||||
}),
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
|
||||
120
frontend/src/composables/__tests__/useIcalDownload.spec.ts
Normal file
120
frontend/src/composables/__tests__/useIcalDownload.spec.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { generateIcs } from '../useIcalDownload'
|
||||
|
||||
describe('generateIcs', () => {
|
||||
const baseEvent = {
|
||||
eventToken: '550e8400-e29b-41d4-a716-446655440000',
|
||||
title: 'Sommerfest am See',
|
||||
dateTime: '2026-07-15T18:00:00+02:00',
|
||||
location: 'Stadtpark Berlin',
|
||||
description: 'Bring your own drinks',
|
||||
}
|
||||
|
||||
it('generates valid VCALENDAR wrapper', () => {
|
||||
const ics = generateIcs(baseEvent)
|
||||
expect(ics).toMatch(/^BEGIN:VCALENDAR\r\n/)
|
||||
expect(ics).toMatch(/\r\nEND:VCALENDAR\r\n$/)
|
||||
})
|
||||
|
||||
it('includes VERSION and PRODID', () => {
|
||||
const ics = generateIcs(baseEvent)
|
||||
expect(ics).toContain('VERSION:2.0\r\n')
|
||||
expect(ics).toContain('PRODID:-//fete//EN\r\n')
|
||||
})
|
||||
|
||||
it('generates valid VEVENT block', () => {
|
||||
const ics = generateIcs(baseEvent)
|
||||
expect(ics).toContain('BEGIN:VEVENT\r\n')
|
||||
expect(ics).toContain('END:VEVENT\r\n')
|
||||
})
|
||||
|
||||
it('sets UID from eventToken', () => {
|
||||
const ics = generateIcs(baseEvent)
|
||||
expect(ics).toContain('UID:550e8400-e29b-41d4-a716-446655440000@fete\r\n')
|
||||
})
|
||||
|
||||
it('sets DTSTART in UTC format', () => {
|
||||
const ics = generateIcs(baseEvent)
|
||||
expect(ics).toContain('DTSTART:20260715T160000Z\r\n')
|
||||
})
|
||||
|
||||
it('does NOT include DTEND or DURATION', () => {
|
||||
const ics = generateIcs(baseEvent)
|
||||
expect(ics).not.toContain('DTEND')
|
||||
expect(ics).not.toContain('DURATION')
|
||||
})
|
||||
|
||||
it('sets SUMMARY from title', () => {
|
||||
const ics = generateIcs(baseEvent)
|
||||
expect(ics).toContain('SUMMARY:Sommerfest am See\r\n')
|
||||
})
|
||||
|
||||
it('sets LOCATION when present', () => {
|
||||
const ics = generateIcs(baseEvent)
|
||||
expect(ics).toContain('LOCATION:Stadtpark Berlin\r\n')
|
||||
})
|
||||
|
||||
it('sets DESCRIPTION when present', () => {
|
||||
const ics = generateIcs(baseEvent)
|
||||
expect(ics).toContain('DESCRIPTION:Bring your own drinks\r\n')
|
||||
})
|
||||
|
||||
it('omits LOCATION when not provided', () => {
|
||||
const { location: _location, ...noLocation } = baseEvent
|
||||
const ics = generateIcs(noLocation)
|
||||
expect(ics).not.toContain('LOCATION')
|
||||
})
|
||||
|
||||
it('omits DESCRIPTION when not provided', () => {
|
||||
const { description: _description, ...noDesc } = baseEvent
|
||||
const ics = generateIcs(noDesc)
|
||||
expect(ics).not.toContain('DESCRIPTION')
|
||||
})
|
||||
|
||||
it('includes SEQUENCE:0', () => {
|
||||
const ics = generateIcs(baseEvent)
|
||||
expect(ics).toContain('SEQUENCE:0\r\n')
|
||||
})
|
||||
|
||||
it('includes DTSTAMP in UTC format', () => {
|
||||
const ics = generateIcs(baseEvent)
|
||||
expect(ics).toMatch(/DTSTAMP:\d{8}T\d{6}Z\r\n/)
|
||||
})
|
||||
|
||||
it('escapes commas in text fields', () => {
|
||||
const ics = generateIcs({ ...baseEvent, title: 'Hello, World' })
|
||||
expect(ics).toContain('SUMMARY:Hello\\, World\r\n')
|
||||
})
|
||||
|
||||
it('escapes semicolons in text fields', () => {
|
||||
const ics = generateIcs({ ...baseEvent, description: 'foo; bar' })
|
||||
expect(ics).toContain('DESCRIPTION:foo\\; bar\r\n')
|
||||
})
|
||||
|
||||
it('escapes backslashes in text fields', () => {
|
||||
const ics = generateIcs({ ...baseEvent, title: 'path\\to' })
|
||||
expect(ics).toContain('SUMMARY:path\\\\to\r\n')
|
||||
})
|
||||
|
||||
it('escapes newlines in text fields', () => {
|
||||
const ics = generateIcs({ ...baseEvent, description: 'line1\nline2' })
|
||||
expect(ics).toContain('DESCRIPTION:line1\\nline2\r\n')
|
||||
})
|
||||
|
||||
it('produces deterministic output for the same input', () => {
|
||||
const ics1 = generateIcs(baseEvent)
|
||||
const ics2 = generateIcs(baseEvent)
|
||||
// DTSTAMP changes with time, so strip it for comparison
|
||||
const strip = (s: string) => s.replace(/DTSTAMP:\d{8}T\d{6}Z\r\n/, '')
|
||||
expect(strip(ics1)).toBe(strip(ics2))
|
||||
})
|
||||
|
||||
it('uses CRLF line endings throughout', () => {
|
||||
const ics = generateIcs(baseEvent)
|
||||
const lines = ics.split('\r\n')
|
||||
// Every "line" split by CRLF should not contain a bare LF
|
||||
for (const line of lines) {
|
||||
expect(line).not.toContain('\n')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,6 @@ export interface StoredEvent {
|
||||
organizerToken?: string
|
||||
title: string
|
||||
dateTime: string
|
||||
expiryDate: string
|
||||
rsvpToken?: string
|
||||
rsvpName?: string
|
||||
}
|
||||
@@ -66,7 +65,7 @@ export function useEventStorage() {
|
||||
existing.rsvpToken = rsvpToken
|
||||
existing.rsvpName = rsvpName
|
||||
} else {
|
||||
events.push({ eventToken, title, dateTime, expiryDate: '', rsvpToken, rsvpName })
|
||||
events.push({ eventToken, title, dateTime, rsvpToken, rsvpName })
|
||||
}
|
||||
writeEvents(events)
|
||||
}
|
||||
@@ -79,10 +78,34 @@ export function useEventStorage() {
|
||||
return undefined
|
||||
}
|
||||
|
||||
function removeRsvp(eventToken: string): void {
|
||||
const events = readEvents()
|
||||
const event = events.find((e) => e.eventToken === eventToken)
|
||||
if (event) {
|
||||
delete event.rsvpToken
|
||||
delete event.rsvpName
|
||||
writeEvents(events)
|
||||
}
|
||||
}
|
||||
|
||||
function saveWatch(eventToken: string, title: string, dateTime: string): void {
|
||||
const events = readEvents()
|
||||
const existing = events.find((e) => e.eventToken === eventToken)
|
||||
if (!existing) {
|
||||
events.push({ eventToken, title, dateTime })
|
||||
writeEvents(events)
|
||||
}
|
||||
}
|
||||
|
||||
function isStored(eventToken: string): boolean {
|
||||
void version.value
|
||||
return readEvents().some((e) => e.eventToken === eventToken)
|
||||
}
|
||||
|
||||
function removeEvent(eventToken: string): void {
|
||||
const events = readEvents().filter((e) => e.eventToken !== eventToken)
|
||||
writeEvents(events)
|
||||
}
|
||||
|
||||
return { saveCreatedEvent, getStoredEvents, getOrganizerToken, saveRsvp, getRsvp, removeEvent }
|
||||
return { saveCreatedEvent, getStoredEvents, getOrganizerToken, saveRsvp, getRsvp, removeRsvp, saveWatch, isStored, removeEvent }
|
||||
}
|
||||
|
||||
71
frontend/src/composables/useIcalDownload.ts
Normal file
71
frontend/src/composables/useIcalDownload.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { slugify } from '@/utils/slugify'
|
||||
|
||||
export interface IcalEvent {
|
||||
eventToken: string
|
||||
title: string
|
||||
dateTime: string
|
||||
location?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
function escapeText(value: string): string {
|
||||
return value
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/;/g, '\\;')
|
||||
.replace(/,/g, '\\,')
|
||||
.replace(/\n/g, '\\n')
|
||||
}
|
||||
|
||||
function toUtcString(isoDateTime: string): string {
|
||||
const d = new Date(isoDateTime)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return (
|
||||
`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
|
||||
`T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`
|
||||
)
|
||||
}
|
||||
|
||||
export function generateIcs(event: IcalEvent): string {
|
||||
const lines: string[] = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
'PRODID:-//fete//EN',
|
||||
'BEGIN:VEVENT',
|
||||
`UID:${event.eventToken}@fete`,
|
||||
`DTSTAMP:${toUtcString(new Date().toISOString())}`,
|
||||
`DTSTART:${toUtcString(event.dateTime)}`,
|
||||
`SUMMARY:${escapeText(event.title)}`,
|
||||
'SEQUENCE:0',
|
||||
]
|
||||
|
||||
if (event.location) {
|
||||
lines.push(`LOCATION:${escapeText(event.location)}`)
|
||||
}
|
||||
|
||||
if (event.description) {
|
||||
lines.push(`DESCRIPTION:${escapeText(event.description)}`)
|
||||
}
|
||||
|
||||
lines.push('END:VEVENT', 'END:VCALENDAR', '')
|
||||
|
||||
return lines.join('\r\n')
|
||||
}
|
||||
|
||||
export function useIcalDownload() {
|
||||
function download(event: IcalEvent) {
|
||||
const ics = generateIcs(event)
|
||||
const blob = new Blob([ics], { type: 'text/calendar;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
|
||||
const filename = `${slugify(event.title) || 'event'}.ics`
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
return { download }
|
||||
}
|
||||
69
frontend/src/utils/__tests__/slugify.spec.ts
Normal file
69
frontend/src/utils/__tests__/slugify.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { slugify } from '../slugify'
|
||||
|
||||
describe('slugify', () => {
|
||||
it('converts to lowercase', () => {
|
||||
expect(slugify('Hello World')).toBe('hello-world')
|
||||
})
|
||||
|
||||
it('replaces spaces with hyphens', () => {
|
||||
expect(slugify('foo bar baz')).toBe('foo-bar-baz')
|
||||
})
|
||||
|
||||
it('transliterates German umlauts', () => {
|
||||
expect(slugify('Ärger über Öl füßen')).toBe('aerger-ueber-oel-fuessen')
|
||||
})
|
||||
|
||||
it('transliterates uppercase umlauts', () => {
|
||||
expect(slugify('Ä Ö Ü')).toBe('ae-oe-ue')
|
||||
})
|
||||
|
||||
it('transliterates ß', () => {
|
||||
expect(slugify('Straße')).toBe('strasse')
|
||||
})
|
||||
|
||||
it('removes non-ASCII characters after transliteration', () => {
|
||||
expect(slugify('Café résumé')).toBe('caf-rsum')
|
||||
})
|
||||
|
||||
it('replaces special characters with hyphens', () => {
|
||||
expect(slugify('hello@world! #test')).toBe('hello-world-test')
|
||||
})
|
||||
|
||||
it('collapses consecutive hyphens', () => {
|
||||
expect(slugify('foo---bar')).toBe('foo-bar')
|
||||
})
|
||||
|
||||
it('trims leading and trailing hyphens', () => {
|
||||
expect(slugify('--hello--')).toBe('hello')
|
||||
})
|
||||
|
||||
it('truncates to 60 characters', () => {
|
||||
const long = 'a'.repeat(80)
|
||||
expect(slugify(long).length).toBeLessThanOrEqual(60)
|
||||
})
|
||||
|
||||
it('does not break mid-word when truncating', () => {
|
||||
// 60 chars of 'a' should just be 60 a's (no word boundary issue)
|
||||
const result = slugify('a'.repeat(65))
|
||||
expect(result.length).toBe(60)
|
||||
expect(result).toBe('a'.repeat(60))
|
||||
})
|
||||
|
||||
it('handles empty string', () => {
|
||||
expect(slugify('')).toBe('')
|
||||
})
|
||||
|
||||
it('handles string that becomes empty after processing', () => {
|
||||
expect(slugify('!@#$%')).toBe('')
|
||||
})
|
||||
|
||||
it('handles emoji', () => {
|
||||
const result = slugify('Party 🎉 time')
|
||||
expect(result).toBe('party-time')
|
||||
})
|
||||
|
||||
it('produces Sommerfest am See example from spec', () => {
|
||||
expect(slugify('Sommerfest am See')).toBe('sommerfest-am-see')
|
||||
})
|
||||
})
|
||||
28
frontend/src/utils/slugify.ts
Normal file
28
frontend/src/utils/slugify.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
const UMLAUT_MAP: Record<string, string> = {
|
||||
ä: 'ae',
|
||||
ö: 'oe',
|
||||
ü: 'ue',
|
||||
ß: 'ss',
|
||||
Ä: 'Ae',
|
||||
Ö: 'Oe',
|
||||
Ü: 'Ue',
|
||||
}
|
||||
|
||||
export function slugify(input: string): string {
|
||||
return (
|
||||
input
|
||||
// Transliterate German umlauts
|
||||
.replace(/[äöüßÄÖÜ]/g, (ch) => UMLAUT_MAP[ch] ?? ch)
|
||||
.toLowerCase()
|
||||
// Remove non-ASCII characters
|
||||
.replace(/[^\x20-\x7E]/g, '')
|
||||
// Replace non-alphanumeric characters with hyphens
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
// Collapse consecutive hyphens
|
||||
.replace(/-{2,}/g, '-')
|
||||
// Trim leading/trailing hyphens
|
||||
.replace(/^-|-$/g, '')
|
||||
// Truncate to 60 characters
|
||||
.slice(0, 60)
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
<template>
|
||||
<main class="create">
|
||||
<header class="create__header">
|
||||
<RouterLink to="/" class="create__back" aria-label="Back to home">←</RouterLink>
|
||||
<h1 class="create__title">Create</h1>
|
||||
</header>
|
||||
<h1 class="create__title">Great, a Party!</h1>
|
||||
|
||||
<form class="create__form" novalidate @submit.prevent="handleSubmit">
|
||||
<div class="form-group">
|
||||
@@ -65,21 +62,6 @@
|
||||
<span v-if="errors.location" id="location-error" class="field-error" role="alert">{{ errors.location }}</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="expiryDate" class="form-label">Expiry Date *</label>
|
||||
<input
|
||||
id="expiryDate"
|
||||
v-model="form.expiryDate"
|
||||
type="date"
|
||||
class="form-field glass"
|
||||
required
|
||||
:min="tomorrow"
|
||||
:aria-invalid="!!errors.expiryDate"
|
||||
:aria-describedby="errors.expiryDate ? 'expiryDate-error' : undefined"
|
||||
/>
|
||||
<span v-if="errors.expiryDate" id="expiryDate-error" class="field-error" role="alert">{{ errors.expiryDate }}</span>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary glass" :disabled="submitting">
|
||||
{{ submitting ? 'Creating…' : 'Create Event' }}
|
||||
</button>
|
||||
@@ -90,8 +72,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, computed, watch } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api } from '@/api/client'
|
||||
import { useEventStorage } from '@/composables/useEventStorage'
|
||||
|
||||
@@ -103,7 +85,6 @@ const form = reactive({
|
||||
description: '',
|
||||
dateTime: '',
|
||||
location: '',
|
||||
expiryDate: '',
|
||||
})
|
||||
|
||||
const errors = reactive({
|
||||
@@ -111,31 +92,22 @@ const errors = reactive({
|
||||
description: '',
|
||||
dateTime: '',
|
||||
location: '',
|
||||
expiryDate: '',
|
||||
})
|
||||
|
||||
const submitting = ref(false)
|
||||
const serverError = ref('')
|
||||
|
||||
const tomorrow = computed(() => {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() + 1)
|
||||
return d.toISOString().split('T')[0]
|
||||
})
|
||||
|
||||
function clearErrors() {
|
||||
errors.title = ''
|
||||
errors.description = ''
|
||||
errors.dateTime = ''
|
||||
errors.location = ''
|
||||
errors.expiryDate = ''
|
||||
serverError.value = ''
|
||||
}
|
||||
|
||||
// Clear individual field errors when the user types
|
||||
watch(() => form.title, () => { errors.title = ''; serverError.value = '' })
|
||||
watch(() => form.dateTime, () => { errors.dateTime = ''; serverError.value = '' })
|
||||
watch(() => form.expiryDate, () => { errors.expiryDate = ''; serverError.value = '' })
|
||||
watch(() => form.description, () => { serverError.value = '' })
|
||||
watch(() => form.location, () => { serverError.value = '' })
|
||||
|
||||
@@ -153,14 +125,6 @@ function validate(): boolean {
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (!form.expiryDate) {
|
||||
errors.expiryDate = 'Expiry date is required.'
|
||||
valid = false
|
||||
} else if (form.expiryDate <= (new Date().toISOString().split('T')[0] ?? '')) {
|
||||
errors.expiryDate = 'Expiry date must be in the future.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
return valid
|
||||
}
|
||||
|
||||
@@ -186,7 +150,6 @@ async function handleSubmit() {
|
||||
dateTime: dateTimeWithOffset,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
location: form.location.trim() || undefined,
|
||||
expiryDate: form.expiryDate,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -212,7 +175,6 @@ async function handleSubmit() {
|
||||
organizerToken: data.organizerToken,
|
||||
title: data.title,
|
||||
dateTime: data.dateTime,
|
||||
expiryDate: data.expiryDate,
|
||||
})
|
||||
|
||||
router.push({ name: 'event', params: { eventToken: data.eventToken } })
|
||||
@@ -229,20 +191,7 @@ async function handleSubmit() {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
padding-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.create__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.create__back {
|
||||
color: var(--color-text-on-gradient);
|
||||
font-size: 1.5rem;
|
||||
text-decoration: none;
|
||||
line-height: 1;
|
||||
padding-top: calc(var(--spacing-lg) + 2.5rem);
|
||||
}
|
||||
|
||||
.create__title {
|
||||
|
||||
@@ -8,12 +8,35 @@
|
||||
alt=""
|
||||
/>
|
||||
<div class="detail__hero-overlay" />
|
||||
<header class="detail__header">
|
||||
<RouterLink to="/" class="detail__back" aria-label="Back to home">←</RouterLink>
|
||||
<span class="detail__brand">fete</span>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<!-- Kebab menu (teleported into app header) -->
|
||||
<Teleport to="#header-actions">
|
||||
<div v-if="state === 'loaded' && event && isOrganizer && !event.cancelled" class="detail__kebab-wrapper">
|
||||
<button
|
||||
class="detail__kebab-btn"
|
||||
type="button"
|
||||
aria-label="Event actions"
|
||||
:aria-expanded="kebabOpen"
|
||||
@click="kebabOpen = !kebabOpen"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg>
|
||||
</button>
|
||||
<Transition name="kebab-menu">
|
||||
<div v-if="kebabOpen" class="detail__kebab-menu" role="menu">
|
||||
<button
|
||||
class="detail__kebab-item detail__kebab-item--danger"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@click="kebabOpen = false; cancelSheetOpen = true"
|
||||
>
|
||||
Cancel event
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<div class="detail__body">
|
||||
<!-- Loading state -->
|
||||
<div v-if="state === 'loading'" class="detail__content" aria-busy="true" aria-label="Loading event details">
|
||||
@@ -25,29 +48,31 @@
|
||||
|
||||
<!-- Loaded state -->
|
||||
<div v-else-if="state === 'loaded' && event" class="detail__content">
|
||||
<div v-if="event.expired" class="detail__banner detail__banner--expired" role="status">
|
||||
This event has ended.
|
||||
<!-- 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">
|
||||
<div class="detail__meta-item">
|
||||
<dt class="detail__meta-icon glass" aria-label="Date and time">
|
||||
<dt class="detail__meta-icon" aria-label="Date and time">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
</dt>
|
||||
<dd class="detail__meta-text">{{ formattedDateTime }}</dd>
|
||||
</div>
|
||||
|
||||
<div v-if="event.location" class="detail__meta-item">
|
||||
<dt class="detail__meta-icon glass" aria-label="Location">
|
||||
<dt class="detail__meta-icon" aria-label="Location">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||
</dt>
|
||||
<dd class="detail__meta-text">{{ event.location }}</dd>
|
||||
</div>
|
||||
|
||||
<div class="detail__meta-item">
|
||||
<dt class="detail__meta-icon glass" aria-label="Attendees">
|
||||
<dt class="detail__meta-icon" aria-label="Attendees">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
</dt>
|
||||
<dd class="detail__meta-text">{{ event.attendeeCount }} going</dd>
|
||||
@@ -74,11 +99,80 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RSVP bar (only for loaded, non-expired events) -->
|
||||
<!-- Organizer bottom bar (not cancelled) -->
|
||||
<div v-if="state === 'loaded' && event && isOrganizer && !event.cancelled" class="detail__organizer-bar">
|
||||
<div class="detail__organizer-bar-inner">
|
||||
<div class="bar-cta glow-border glow-border--animated">
|
||||
<button class="bar-cta-btn glass-inner" type="button">
|
||||
Post an update
|
||||
</button>
|
||||
</div>
|
||||
<div class="bar-icon glow-border glow-border--animated">
|
||||
<button
|
||||
class="bar-icon-btn glass-inner"
|
||||
type="button"
|
||||
aria-label="Add to calendar"
|
||||
@click="handleCalendarDownload"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</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 (hidden when cancelled) -->
|
||||
<RsvpBar
|
||||
v-if="state === 'loaded' && event && !event.expired && !isOrganizer"
|
||||
v-if="state === 'loaded' && event && !isOrganizer && !event.cancelled"
|
||||
:has-rsvp="!!rsvpName"
|
||||
:bookmarked="eventIsStored"
|
||||
@open="sheetOpen = true"
|
||||
@cancel="confirmCancelOpen = true"
|
||||
@bookmark="handleBookmarkClick"
|
||||
@calendar="handleCalendarDownload"
|
||||
/>
|
||||
|
||||
<!-- Cancel confirmation dialog -->
|
||||
<ConfirmDialog
|
||||
:open="confirmCancelOpen"
|
||||
title="Cancel RSVP?"
|
||||
message="The organizer will no longer see you as attending."
|
||||
confirm-label="Cancel RSVP"
|
||||
cancel-label="Keep"
|
||||
@confirm="handleCancelRsvp"
|
||||
@cancel="confirmCancelOpen = false"
|
||||
/>
|
||||
|
||||
<!-- RSVP bottom sheet -->
|
||||
@@ -111,12 +205,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { api } from '@/api/client'
|
||||
import { useEventStorage } from '@/composables/useEventStorage'
|
||||
import { useIcalDownload } from '@/composables/useIcalDownload'
|
||||
import AttendeeList from '@/components/AttendeeList.vue'
|
||||
import BottomSheet from '@/components/BottomSheet.vue'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import RsvpBar from '@/components/RsvpBar.vue'
|
||||
import type { components } from '@/api/schema'
|
||||
|
||||
@@ -124,7 +220,8 @@ type GetEventResponse = components['schemas']['GetEventResponse']
|
||||
type State = 'loading' | 'loaded' | 'not-found' | 'error'
|
||||
|
||||
const route = useRoute()
|
||||
const { saveRsvp, getRsvp, getOrganizerToken } = useEventStorage()
|
||||
const { saveRsvp, getRsvp, removeRsvp, getOrganizerToken, saveWatch, isStored, removeEvent } = useEventStorage()
|
||||
const { download: downloadIcal } = useIcalDownload()
|
||||
|
||||
const state = ref<State>('loading')
|
||||
const event = ref<GetEventResponse | null>(null)
|
||||
@@ -136,9 +233,60 @@ const nameError = ref('')
|
||||
const submitError = ref('')
|
||||
const submitting = ref(false)
|
||||
const rsvpName = ref<string | undefined>(undefined)
|
||||
const confirmCancelOpen = ref(false)
|
||||
const cancelError = ref('')
|
||||
const isOrganizer = ref(false)
|
||||
const attendeeNames = ref<string[] | null>(null)
|
||||
|
||||
// Kebab menu state
|
||||
const kebabOpen = ref(false)
|
||||
|
||||
function onKebabClickOutside(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
if (!target.closest('.detail__kebab-wrapper')) {
|
||||
kebabOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(kebabOpen, (isOpen) => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('click', onKebabClickOutside, { capture: true })
|
||||
} else {
|
||||
document.removeEventListener('click', onKebabClickOutside, { capture: true })
|
||||
}
|
||||
})
|
||||
|
||||
// Cancel event state
|
||||
const cancelSheetOpen = ref(false)
|
||||
const cancelReasonInput = ref('')
|
||||
const cancelEventError = ref('')
|
||||
const cancellingEvent = ref(false)
|
||||
|
||||
const eventToken = computed(() => route.params.eventToken as string)
|
||||
|
||||
const eventIsStored = computed(() => isStored(eventToken.value))
|
||||
|
||||
function handleCalendarDownload() {
|
||||
if (!event.value) return
|
||||
downloadIcal({
|
||||
eventToken: event.value.eventToken,
|
||||
title: event.value.title,
|
||||
dateTime: event.value.dateTime,
|
||||
location: event.value.location,
|
||||
description: event.value.description,
|
||||
})
|
||||
}
|
||||
|
||||
function handleBookmarkClick() {
|
||||
if (!event.value) return
|
||||
if (isOrganizer.value || rsvpName.value) return
|
||||
if (eventIsStored.value) {
|
||||
removeEvent(eventToken.value)
|
||||
} else {
|
||||
saveWatch(eventToken.value, event.value.title, event.value.dateTime)
|
||||
}
|
||||
}
|
||||
|
||||
const formattedDateTime = computed(() => {
|
||||
if (!event.value) return ''
|
||||
const formatted = new Intl.DateTimeFormat(undefined, {
|
||||
@@ -153,8 +301,8 @@ async function fetchEvent() {
|
||||
event.value = null
|
||||
|
||||
try {
|
||||
const { data, error, response } = await api.GET('/events/{token}', {
|
||||
params: { path: { token: route.params.eventToken as string } },
|
||||
const { data, error, response } = await api.GET('/events/{eventToken}', {
|
||||
params: { path: { eventToken: route.params.eventToken as string } },
|
||||
})
|
||||
|
||||
if (error) {
|
||||
@@ -201,8 +349,8 @@ async function submitRsvp() {
|
||||
submitting.value = true
|
||||
|
||||
try {
|
||||
const { data, error } = await api.POST('/events/{token}/rsvps', {
|
||||
params: { path: { token: route.params.eventToken as string } },
|
||||
const { data, error } = await api.POST('/events/{eventToken}/rsvps', {
|
||||
params: { path: { eventToken: route.params.eventToken as string } },
|
||||
body: { name: nameInput.value },
|
||||
})
|
||||
|
||||
@@ -232,11 +380,76 @@ async function submitRsvp() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancelRsvp() {
|
||||
confirmCancelOpen.value = false
|
||||
cancelError.value = ''
|
||||
|
||||
const stored = getRsvp(route.params.eventToken as string)
|
||||
if (!stored) return
|
||||
|
||||
try {
|
||||
const { response } = await api.DELETE('/events/{eventToken}/rsvps/{rsvpToken}', {
|
||||
params: {
|
||||
path: {
|
||||
eventToken: route.params.eventToken as string,
|
||||
rsvpToken: stored.rsvpToken,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (response.status === 204 || response.status === 404) {
|
||||
removeRsvp(route.params.eventToken as string)
|
||||
rsvpName.value = undefined
|
||||
if (event.value) {
|
||||
event.value.attendeeCount = Math.max(0, event.value.attendeeCount - 1)
|
||||
}
|
||||
} else {
|
||||
cancelError.value = 'Could not cancel RSVP. Please try again.'
|
||||
}
|
||||
} catch {
|
||||
cancelError.value = 'Could not cancel RSVP. Please try again.'
|
||||
}
|
||||
}
|
||||
|
||||
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/{token}/attendees', {
|
||||
const { data, error } = await api.GET('/events/{eventToken}/attendees', {
|
||||
params: {
|
||||
path: { token: eventToken },
|
||||
path: { eventToken: eventToken },
|
||||
query: { organizerToken },
|
||||
},
|
||||
})
|
||||
@@ -293,32 +506,7 @@ onMounted(fetchEvent)
|
||||
var(--color-glass-overlay) 0%,
|
||||
transparent 50%
|
||||
);
|
||||
}
|
||||
|
||||
.detail__header {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-lg) var(--content-padding);
|
||||
padding-top: env(safe-area-inset-top, var(--spacing-lg));
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.detail__back {
|
||||
color: var(--color-text-on-gradient);
|
||||
font-size: 1.5rem;
|
||||
text-decoration: none;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.detail__brand {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-on-gradient);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.detail__body {
|
||||
@@ -341,6 +529,10 @@ onMounted(fetchEvent)
|
||||
padding-top: 4rem;
|
||||
}
|
||||
|
||||
.detail__meta-icon svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Title */
|
||||
.detail__title {
|
||||
font-size: 2rem;
|
||||
@@ -373,6 +565,10 @@ onMounted(fetchEvent)
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
color: var(--color-text-on-gradient);
|
||||
line-height: 0;
|
||||
background: linear-gradient(135deg, var(--color-glass-strong) 0%, var(--color-glass-subtle) 100%);
|
||||
border: 1px solid var(--color-glass-border);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.detail__meta-text {
|
||||
@@ -412,12 +608,6 @@ onMounted(fetchEvent)
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.detail__banner--expired {
|
||||
background: var(--color-glass);
|
||||
color: var(--color-text-soft);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* Error / not-found message */
|
||||
.detail__message {
|
||||
font-size: 1.1rem;
|
||||
@@ -480,4 +670,165 @@ 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;
|
||||
}
|
||||
|
||||
/* Kebab menu (teleported into app header) */
|
||||
.detail__kebab-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.detail__kebab-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-on-gradient);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.detail__kebab-btn:hover {
|
||||
background: var(--color-glass-hover);
|
||||
}
|
||||
|
||||
.detail__kebab-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--spacing-xs));
|
||||
right: 0;
|
||||
min-width: 180px;
|
||||
padding: var(--spacing-xs) 0;
|
||||
border-radius: var(--radius-card);
|
||||
background: linear-gradient(135deg, var(--color-glass-strong) 0%, var(--color-glass-subtle) 100%);
|
||||
border: 1px solid var(--color-glass-border);
|
||||
backdrop-filter: blur(16px);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.detail__kebab-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-on-gradient);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.detail__kebab-item:hover {
|
||||
background: var(--color-glass-hover);
|
||||
}
|
||||
|
||||
.detail__kebab-item--danger {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.kebab-menu-enter-active,
|
||||
.kebab-menu-leave-active {
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.kebab-menu-enter-from,
|
||||
.kebab-menu-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
/* Organizer bottom bar — mirrors RsvpBar layout */
|
||||
.detail__organizer-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
padding: var(--spacing-md) var(--content-padding);
|
||||
padding-bottom: calc(var(--spacing-md) + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.detail__organizer-bar-inner {
|
||||
width: 100%;
|
||||
max-width: var(--content-max-width);
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
|
||||
/* 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-solid-text);
|
||||
background: var(--color-danger-solid);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.cancel-form__confirm:hover {
|
||||
background: var(--color-danger-solid-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>
|
||||
|
||||
@@ -44,7 +44,6 @@ describe('EventCreateView', () => {
|
||||
expect(wrapper.find('#description').exists()).toBe(true)
|
||||
expect(wrapper.find('#dateTime').exists()).toBe(true)
|
||||
expect(wrapper.find('#location').exists()).toBe(true)
|
||||
expect(wrapper.find('#expiryDate').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('has required attribute on required fields', async () => {
|
||||
@@ -58,7 +57,6 @@ describe('EventCreateView', () => {
|
||||
|
||||
expect(wrapper.find('#title').attributes('required')).toBeDefined()
|
||||
expect(wrapper.find('#dateTime').attributes('required')).toBeDefined()
|
||||
expect(wrapper.find('#expiryDate').attributes('required')).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not have required attribute on optional fields', async () => {
|
||||
@@ -102,7 +100,6 @@ describe('EventCreateView', () => {
|
||||
// Fill required fields
|
||||
await wrapper.find('#title').setValue('My Event')
|
||||
await wrapper.find('#dateTime').setValue('2026-12-25T18:00')
|
||||
await wrapper.find('#expiryDate').setValue('2026-12-24')
|
||||
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
@@ -127,7 +124,7 @@ describe('EventCreateView', () => {
|
||||
await wrapper.find('form').trigger('submit')
|
||||
|
||||
const errorsBefore = wrapper.findAll('[role="alert"]').map((el) => el.text()).filter((t) => t.length > 0)
|
||||
expect(errorsBefore.length).toBeGreaterThanOrEqual(3)
|
||||
expect(errorsBefore.length).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// Type into title field
|
||||
await wrapper.find('#title').setValue('My Event')
|
||||
@@ -138,9 +135,6 @@ describe('EventCreateView', () => {
|
||||
|
||||
const dateTimeError = wrapper.find('#dateTime').element.closest('.form-group')!.querySelector('[role="alert"]')!
|
||||
expect(dateTimeError.textContent).not.toBe('')
|
||||
|
||||
const expiryError = wrapper.find('#expiryDate').element.closest('.form-group')!.querySelector('[role="alert"]')!
|
||||
expect(expiryError.textContent).not.toBe('')
|
||||
})
|
||||
|
||||
it('shows validation errors when submitting empty form', async () => {
|
||||
@@ -156,7 +150,7 @@ describe('EventCreateView', () => {
|
||||
|
||||
const errorElements = wrapper.findAll('[role="alert"]')
|
||||
const errorTexts = errorElements.map((el) => el.text()).filter((t) => t.length > 0)
|
||||
expect(errorTexts.length).toBeGreaterThanOrEqual(3)
|
||||
expect(errorTexts.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('submits successfully, saves to storage, and navigates to event page', async () => {
|
||||
@@ -169,6 +163,9 @@ describe('EventCreateView', () => {
|
||||
getOrganizerToken: vi.fn(),
|
||||
saveRsvp: vi.fn(),
|
||||
getRsvp: vi.fn(),
|
||||
removeRsvp: vi.fn(),
|
||||
saveWatch: vi.fn(),
|
||||
isStored: vi.fn(() => false),
|
||||
removeEvent: vi.fn(),
|
||||
})
|
||||
|
||||
@@ -179,7 +176,6 @@ describe('EventCreateView', () => {
|
||||
title: 'Birthday Party',
|
||||
dateTime: '2026-12-25T18:00:00+01:00',
|
||||
timezone: 'Europe/Berlin',
|
||||
expiryDate: '2026-12-24',
|
||||
},
|
||||
error: undefined,
|
||||
response: new Response(),
|
||||
@@ -198,7 +194,6 @@ describe('EventCreateView', () => {
|
||||
await wrapper.find('#description').setValue('Come celebrate!')
|
||||
await wrapper.find('#dateTime').setValue('2026-12-25T18:00')
|
||||
await wrapper.find('#location').setValue('Berlin')
|
||||
await wrapper.find('#expiryDate').setValue('2026-12-24')
|
||||
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
@@ -208,7 +203,6 @@ describe('EventCreateView', () => {
|
||||
title: 'Birthday Party',
|
||||
description: 'Come celebrate!',
|
||||
location: 'Berlin',
|
||||
expiryDate: '2026-12-24',
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -217,7 +211,6 @@ describe('EventCreateView', () => {
|
||||
organizerToken: 'org-456',
|
||||
title: 'Birthday Party',
|
||||
dateTime: '2026-12-25T18:00:00+01:00',
|
||||
expiryDate: '2026-12-24',
|
||||
})
|
||||
|
||||
expect(pushSpy).toHaveBeenCalledWith({
|
||||
@@ -245,7 +238,6 @@ describe('EventCreateView', () => {
|
||||
|
||||
await wrapper.find('#title').setValue('Duplicate Event')
|
||||
await wrapper.find('#dateTime').setValue('2026-12-25T18:00')
|
||||
await wrapper.find('#expiryDate').setValue('2026-12-24')
|
||||
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
@@ -256,6 +248,5 @@ describe('EventCreateView', () => {
|
||||
|
||||
// Other field errors should not be present
|
||||
expect(wrapper.find('#dateTime-error').exists()).toBe(false)
|
||||
expect(wrapper.find('#expiryDate-error').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,6 +14,9 @@ vi.mock('@/api/client', () => ({
|
||||
const mockSaveRsvp = vi.fn()
|
||||
const mockGetRsvp = vi.fn()
|
||||
const mockGetOrganizerToken = vi.fn()
|
||||
const mockSaveWatch = vi.fn()
|
||||
const mockIsStored = vi.fn()
|
||||
const mockRemoveEvent = vi.fn()
|
||||
|
||||
vi.mock('@/composables/useEventStorage', () => ({
|
||||
useEventStorage: vi.fn(() => ({
|
||||
@@ -22,7 +25,10 @@ vi.mock('@/composables/useEventStorage', () => ({
|
||||
getOrganizerToken: mockGetOrganizerToken,
|
||||
saveRsvp: mockSaveRsvp,
|
||||
getRsvp: mockGetRsvp,
|
||||
removeEvent: vi.fn(),
|
||||
removeRsvp: vi.fn(),
|
||||
saveWatch: mockSaveWatch,
|
||||
isStored: mockIsStored,
|
||||
removeEvent: mockRemoveEvent,
|
||||
})),
|
||||
}))
|
||||
|
||||
@@ -54,7 +60,6 @@ const fullEvent = {
|
||||
timezone: 'Europe/Berlin',
|
||||
location: 'Central Park, NYC',
|
||||
attendeeCount: 12,
|
||||
expired: false,
|
||||
}
|
||||
|
||||
function mockLoadedEvent(eventOverrides = {}) {
|
||||
@@ -69,6 +74,16 @@ beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
mockGetRsvp.mockReturnValue(undefined)
|
||||
mockGetOrganizerToken.mockReturnValue(undefined)
|
||||
mockIsStored.mockReturnValue(false)
|
||||
mockSaveWatch.mockClear()
|
||||
mockRemoveEvent.mockClear()
|
||||
|
||||
// Provide Teleport target for kebab menu
|
||||
if (!document.getElementById('header-actions')) {
|
||||
const target = document.createElement('div')
|
||||
target.id = 'header-actions'
|
||||
document.body.appendChild(target)
|
||||
}
|
||||
})
|
||||
|
||||
describe('EventDetailView', () => {
|
||||
@@ -124,29 +139,6 @@ describe('EventDetailView', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
// Expired state
|
||||
it('renders "event has ended" banner when expired', async () => {
|
||||
mockLoadedEvent({ expired: true })
|
||||
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('This event has ended.')
|
||||
expect(wrapper.find('.detail__banner--expired').exists()).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
// No expired banner when not expired
|
||||
it('does not render expired banner when event is active', async () => {
|
||||
mockLoadedEvent()
|
||||
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.detail__banner--expired').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
// Not found state
|
||||
it('renders "event not found" when API returns 404', async () => {
|
||||
vi.mocked(api.GET).mockResolvedValue({
|
||||
@@ -212,8 +204,8 @@ describe('EventDetailView', () => {
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(true)
|
||||
expect(wrapper.find('.rsvp-bar__cta').text()).toBe("I'm attending")
|
||||
expect(wrapper.find('.bar-cta').exists()).toBe(true)
|
||||
expect(wrapper.find('.bar-cta').text()).toBe("I'm attending!")
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -224,18 +216,6 @@ describe('EventDetailView', () => {
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.rsvp-bar').exists()).toBe(false)
|
||||
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not show RSVP bar on expired event', async () => {
|
||||
mockLoadedEvent({ expired: true })
|
||||
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(false)
|
||||
expect(wrapper.find('.rsvp-bar').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -249,7 +229,7 @@ describe('EventDetailView', () => {
|
||||
|
||||
expect(wrapper.find('.rsvp-bar__status').exists()).toBe(true)
|
||||
expect(wrapper.find('.rsvp-bar__text').text()).toBe("You're attending!")
|
||||
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(false)
|
||||
expect(wrapper.find('.bar-cta').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -262,7 +242,7 @@ describe('EventDetailView', () => {
|
||||
|
||||
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
|
||||
|
||||
await wrapper.find('.rsvp-bar__cta-inner').trigger('click')
|
||||
await wrapper.find('.bar-cta-btn').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.querySelector('[role="dialog"]')).not.toBeNull()
|
||||
@@ -275,7 +255,7 @@ describe('EventDetailView', () => {
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('.rsvp-bar__cta-inner').trigger('click')
|
||||
await wrapper.find('.bar-cta-btn').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
// Form is inside Teleport — find via document.body
|
||||
@@ -300,7 +280,7 @@ describe('EventDetailView', () => {
|
||||
await flushPromises()
|
||||
|
||||
// Open sheet
|
||||
await wrapper.find('.rsvp-bar__cta-inner').trigger('click')
|
||||
await wrapper.find('.bar-cta-btn').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
// Fill name via Teleported input
|
||||
@@ -315,8 +295,8 @@ describe('EventDetailView', () => {
|
||||
await flushPromises()
|
||||
|
||||
// Verify API call
|
||||
expect(vi.mocked(api.POST)).toHaveBeenCalledWith('/events/{token}/rsvps', {
|
||||
params: { path: { token: 'test-token' } },
|
||||
expect(vi.mocked(api.POST)).toHaveBeenCalledWith('/events/{eventToken}/rsvps', {
|
||||
params: { path: { eventToken: 'test-token' } },
|
||||
body: { name: 'Max' },
|
||||
})
|
||||
|
||||
@@ -331,7 +311,7 @@ describe('EventDetailView', () => {
|
||||
|
||||
// Verify UI switched to status
|
||||
expect(wrapper.find('.rsvp-bar__text').text()).toBe("You're attending!")
|
||||
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(false)
|
||||
expect(wrapper.find('.bar-cta').exists()).toBe(false)
|
||||
|
||||
// Verify attendee count incremented
|
||||
expect(wrapper.text()).toContain('13')
|
||||
@@ -386,7 +366,7 @@ describe('EventDetailView', () => {
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('.rsvp-bar__cta-inner').trigger('click')
|
||||
await wrapper.find('.bar-cta-btn').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const input = document.body.querySelector('#rsvp-name')! as HTMLInputElement
|
||||
@@ -401,4 +381,89 @@ describe('EventDetailView', () => {
|
||||
expect(document.body.textContent).toContain('Could not submit RSVP. Please try again.')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
// Bookmark — T007: bookmark state is passed to RsvpBar via props
|
||||
it('passes bookmarked=false to RsvpBar when event is not in storage', async () => {
|
||||
mockIsStored.mockReturnValue(false)
|
||||
mockLoadedEvent()
|
||||
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
const rsvpBar = wrapper.findComponent({ name: 'RsvpBar' })
|
||||
expect(rsvpBar.props('bookmarked')).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('passes bookmarked=true to RsvpBar when event is in storage', async () => {
|
||||
mockIsStored.mockReturnValue(true)
|
||||
mockLoadedEvent()
|
||||
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
const rsvpBar = wrapper.findComponent({ name: 'RsvpBar' })
|
||||
expect(rsvpBar.props('bookmarked')).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('bookmark event emitted from RsvpBar calls saveWatch', async () => {
|
||||
mockIsStored.mockReturnValue(false)
|
||||
mockLoadedEvent()
|
||||
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
const rsvpBar = wrapper.findComponent({ name: 'RsvpBar' })
|
||||
rsvpBar.vm.$emit('bookmark')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockSaveWatch).toHaveBeenCalledWith('test-token', 'Summer BBQ', '2026-03-15T20:00:00+01:00')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('bookmark event emitted from RsvpBar calls removeEvent when user is watcher', async () => {
|
||||
mockIsStored.mockReturnValue(true)
|
||||
mockLoadedEvent()
|
||||
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
const rsvpBar = wrapper.findComponent({ name: 'RsvpBar' })
|
||||
rsvpBar.vm.$emit('bookmark')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockRemoveEvent).toHaveBeenCalledWith('test-token')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('bookmark event ignored when user is attendee', async () => {
|
||||
mockGetRsvp.mockReturnValue({ rsvpToken: 'rsvp-1', rsvpName: 'Max' })
|
||||
mockIsStored.mockReturnValue(true)
|
||||
mockLoadedEvent()
|
||||
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
const rsvpBar = wrapper.findComponent({ name: 'RsvpBar' })
|
||||
rsvpBar.vm.$emit('bookmark')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockRemoveEvent).not.toHaveBeenCalled()
|
||||
expect(mockSaveWatch).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('passes bookmarked=true to RsvpBar after removeRsvp (event still in storage)', async () => {
|
||||
mockIsStored.mockReturnValue(true)
|
||||
mockGetRsvp.mockReturnValue(undefined)
|
||||
mockLoadedEvent()
|
||||
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
const rsvpBar = wrapper.findComponent({ name: 'RsvpBar' })
|
||||
expect(rsvpBar.props('bookmarked')).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
34
specs/013-auto-delete-expired/checklists/requirements.md
Normal file
34
specs/013-auto-delete-expired/checklists/requirements.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Specification Quality Checklist: Auto-Delete Expired Events
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-09
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- All items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
|
||||
38
specs/013-auto-delete-expired/data-model.md
Normal file
38
specs/013-auto-delete-expired/data-model.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Data Model: Auto-Delete Expired Events
|
||||
|
||||
**Feature**: 013-auto-delete-expired | **Date**: 2026-03-09
|
||||
|
||||
## Existing Entities (no changes)
|
||||
|
||||
### Event
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| id | BIGSERIAL | PK, internal |
|
||||
| event_token | UUID | Public identifier |
|
||||
| organizer_token | UUID | Organizer access |
|
||||
| title | VARCHAR(200) | Required |
|
||||
| description | VARCHAR(2000) | Optional |
|
||||
| date_time | TIMESTAMPTZ | Event date/time |
|
||||
| location | VARCHAR(500) | Optional |
|
||||
| expiry_date | DATE | **Deletion trigger** — indexed (`idx_events_expiry_date`) |
|
||||
| created_at | TIMESTAMPTZ | Auto-set |
|
||||
|
||||
### RSVP
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| id | BIGSERIAL | PK, internal |
|
||||
| rsvp_token | UUID | Public identifier |
|
||||
| event_id | BIGINT | FK → events(id), **ON DELETE CASCADE** |
|
||||
| name | VARCHAR(100) | Guest name |
|
||||
|
||||
## Deletion Behavior
|
||||
|
||||
- `DELETE FROM events WHERE expiry_date < CURRENT_DATE` removes expired events.
|
||||
- RSVPs are automatically cascade-deleted by the FK constraint `fk_rsvps_event_id` with `ON DELETE CASCADE`.
|
||||
- No new tables, columns, or migrations required.
|
||||
|
||||
## Indexes Used
|
||||
|
||||
- `idx_events_expiry_date` on `events(expiry_date)` — ensures the cleanup query is efficient.
|
||||
90
specs/013-auto-delete-expired/plan.md
Normal file
90
specs/013-auto-delete-expired/plan.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# Implementation Plan: Auto-Delete Expired Events
|
||||
|
||||
**Branch**: `013-auto-delete-expired` | **Date**: 2026-03-09 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from `/specs/013-auto-delete-expired/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Add a scheduled background job that runs daily and deletes all events whose `expiryDate` has passed. Deletion is performed via a native SQL query (`DELETE FROM events WHERE expiry_date < CURRENT_DATE`). Cascade deletion of RSVPs is handled by the existing `ON DELETE CASCADE` FK constraint. No API or frontend changes required.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Java 25, Spring Boot 3.5.x
|
||||
**Primary Dependencies**: Spring Scheduling (`@Scheduled`), Spring Data JPA (for native query)
|
||||
**Storage**: PostgreSQL (existing, Liquibase migrations)
|
||||
**Testing**: JUnit 5, Spring Boot Test, Testcontainers (existing)
|
||||
**Target Platform**: Linux server (Docker)
|
||||
**Project Type**: Web service (backend only for this feature)
|
||||
**Performance Goals**: N/A — daily batch job on small dataset
|
||||
**Constraints**: Single native DELETE query, no entity loading
|
||||
**Scale/Scope**: Self-hosted, small-scale — typically < 100 events total
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| I. Privacy by Design | PASS | Deleting expired data supports privacy goals. No PII logged. |
|
||||
| II. Test-Driven Methodology | PASS | Tests written before implementation (TDD). |
|
||||
| III. API-First Development | N/A | No API changes — this is a backend-internal job. |
|
||||
| IV. Simplicity & Quality | PASS | Single query, no over-engineering. |
|
||||
| V. Dependency Discipline | PASS | Uses only existing Spring dependencies (`@Scheduled`). |
|
||||
| VI. Accessibility | N/A | No frontend changes. |
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/013-auto-delete-expired/
|
||||
├── plan.md # This file
|
||||
├── spec.md # Feature specification
|
||||
├── research.md # Phase 0 output (minimal — no unknowns)
|
||||
├── data-model.md # Phase 1 output
|
||||
└── checklists/
|
||||
└── requirements.md # Spec quality checklist
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
backend/src/main/java/de/fete/
|
||||
├── application/service/
|
||||
│ └── ExpiredEventCleanupJob.java # NEW: Scheduled job
|
||||
├── domain/port/out/
|
||||
│ └── EventRepository.java # MODIFIED: Add deleteExpired method
|
||||
└── adapter/out/persistence/
|
||||
├── EventJpaRepository.java # MODIFIED: Add native DELETE query
|
||||
└── EventPersistenceAdapter.java # MODIFIED: Implement deleteExpired
|
||||
|
||||
backend/src/test/java/de/fete/
|
||||
├── application/service/
|
||||
│ └── ExpiredEventCleanupJobTest.java # NEW: Unit test for job
|
||||
└── adapter/out/persistence/
|
||||
└── EventPersistenceAdapterIntegrationTest.java # NEW or MODIFIED: Integration test for native query
|
||||
```
|
||||
|
||||
**Structure Decision**: Backend-only change. Follows existing hexagonal architecture: port defines the contract, adapter implements with native query, service layer schedules the job.
|
||||
|
||||
## Design
|
||||
|
||||
### Hexagonal Flow
|
||||
|
||||
```
|
||||
@Scheduled trigger
|
||||
→ ExpiredEventCleanupJob (application/service)
|
||||
→ EventRepository.deleteExpired() (domain/port/out)
|
||||
→ EventPersistenceAdapter.deleteExpired() (adapter/out/persistence)
|
||||
→ EventJpaRepository native query (adapter/out/persistence)
|
||||
→ DELETE FROM events WHERE expiry_date < CURRENT_DATE
|
||||
```
|
||||
|
||||
### Key Decisions
|
||||
|
||||
1. **Port method**: `int deleteExpired()` on `EventRepository` — returns count of deleted events for logging.
|
||||
2. **Native query**: `@Modifying @Query(value = "DELETE FROM events WHERE expiry_date < CURRENT_DATE", nativeQuery = true)` on `EventJpaRepository`.
|
||||
3. **Schedule**: `@Scheduled(cron = "0 0 3 * * *")` — runs daily at 03:00 server time. Low-traffic window.
|
||||
4. **Logging**: INFO-level log after each run: `"Deleted {count} expired event(s)"`. No log if count is 0 (or DEBUG-level).
|
||||
5. **Transaction**: The native DELETE runs in a single transaction — atomic, no partial state.
|
||||
6. **Enable scheduling**: Add `@EnableScheduling` to `FeteApplication` (or a config class).
|
||||
31
specs/013-auto-delete-expired/research.md
Normal file
31
specs/013-auto-delete-expired/research.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Research: Auto-Delete Expired Events
|
||||
|
||||
**Feature**: 013-auto-delete-expired | **Date**: 2026-03-09
|
||||
|
||||
## Deletion Strategy
|
||||
|
||||
- **Decision**: Direct native SQL DELETE query via Spring Data JPA `@Query`.
|
||||
- **Rationale**: Simplest approach. No entity loading overhead. The existing `ON DELETE CASCADE` FK constraint on `fk_rsvps_event_id` (migration `003-create-rsvps-table.xml`) handles cascading deletion of RSVPs automatically. The existing `idx_events_expiry_date` index ensures the WHERE clause is efficient.
|
||||
- **Alternatives considered**:
|
||||
- JPA repository `deleteAll(findExpired())`: Loads entities into memory first, unnecessary overhead.
|
||||
- Database-level cron (`pg_cron`): Less portable, adds external dependency.
|
||||
- Soft delete with lazy cleanup: Over-engineered for fete's scale and privacy goals.
|
||||
- No deletion (filter only): Contradicts privacy-by-design principle.
|
||||
|
||||
## Scheduling Mechanism
|
||||
|
||||
- **Decision**: Spring `@Scheduled(cron = ...)` annotation.
|
||||
- **Rationale**: Already available in Spring Boot, no additional dependencies. Simple, declarative, well-tested.
|
||||
- **Alternatives considered**:
|
||||
- Quartz Scheduler: Too heavy for a single daily job.
|
||||
- External cron (OS-level): Requires separate process management, harder to test.
|
||||
|
||||
## Transaction Behavior
|
||||
|
||||
- **Decision**: Single transaction for the DELETE statement.
|
||||
- **Rationale**: A single `DELETE FROM events WHERE expiry_date < CURRENT_DATE` is atomic. If the DB connection drops mid-execution, the transaction rolls back and no events are partially deleted. The next run picks up all expired events.
|
||||
|
||||
## Enabling @Scheduled
|
||||
|
||||
- **Decision**: Add `@EnableScheduling` to `FeteApplication.java`.
|
||||
- **Rationale**: Simplest approach. Only one scheduled job exists, no need for a separate config class.
|
||||
71
specs/013-auto-delete-expired/spec.md
Normal file
71
specs/013-auto-delete-expired/spec.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Feature Specification: Auto-Delete Expired Events
|
||||
|
||||
**Feature Branch**: `013-auto-delete-expired`
|
||||
**Created**: 2026-03-09
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Delete events automatically after they expired"
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Automatic Cleanup of Expired Events (Priority: P1)
|
||||
|
||||
As a self-hoster, I want expired events to be automatically deleted from the database so that personal data is removed without manual intervention and storage stays clean.
|
||||
|
||||
A scheduled background job periodically checks all events. Any event whose `expiryDate` is in the past gets permanently deleted — including all associated data (RSVPs, tokens). No user action is required; the system handles this autonomously.
|
||||
|
||||
**Why this priority**: This is the core and only feature — automated, hands-off cleanup of expired events. It directly supports the privacy promise of fete.
|
||||
|
||||
**Independent Test**: Can be fully tested by creating events with past expiry dates, triggering the cleanup job, and verifying the events are gone from the database.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an event with an `expiryDate` in the past, **When** the scheduled cleanup job runs, **Then** the event and all its associated data (RSVPs, tokens) are permanently deleted.
|
||||
2. **Given** an event with an `expiryDate` in the future, **When** the scheduled cleanup job runs, **Then** the event remains untouched.
|
||||
3. **Given** multiple expired events exist, **When** the cleanup job runs, **Then** all expired events are deleted in a single run.
|
||||
4. **Given** no expired events exist, **When** the cleanup job runs, **Then** nothing is deleted and the job completes without error.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens if the cleanup job fails mid-deletion (e.g., database connection lost)? Events that were not yet deleted remain and will be picked up in the next run. No partial state.
|
||||
- What happens if the server was offline for an extended period? On the next run, all accumulated expired events are deleted — no special catch-up logic needed.
|
||||
- What happens if an organizer is viewing their event page while it gets deleted? The page shows a "not found" state on next interaction. This is acceptable because the event has expired.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: System MUST automatically delete events whose `expiryDate` is before the current date/time.
|
||||
- **FR-002**: When an event is deleted, all associated data (RSVPs, organizer tokens, event tokens) MUST be deleted as well (cascade delete).
|
||||
- **FR-003**: The cleanup job MUST run on a recurring schedule (default: once daily).
|
||||
- **FR-004**: The cleanup job MUST be idempotent — running it multiple times produces the same result.
|
||||
- **FR-005**: The cleanup job MUST log how many events were deleted per run.
|
||||
- **FR-006**: Events that have not yet expired MUST NOT be affected by the cleanup job.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Event**: The primary entity being cleaned up. Has an `expiryDate` field that determines when it becomes eligible for deletion.
|
||||
- **RSVP**: Associated guest responses linked to an event. Deleted when the parent event is deleted.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Expired events are deleted within 24 hours of their expiry date without manual intervention.
|
||||
- **SC-002**: Zero data residue — when an event is deleted, no orphaned RSVPs or tokens remain in the system.
|
||||
- **SC-003**: The cleanup process completes without errors under normal operating conditions.
|
||||
- **SC-004**: Non-expired events are never affected by the cleanup process.
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-03-09
|
||||
|
||||
- Q: How should deletion be implemented — application code (JPA) or direct database query? → A: Direct database query. A single native `DELETE FROM events WHERE expiry_date < CURRENT_DATE` is sufficient. The existing `ON DELETE CASCADE` on the RSVPs foreign key ensures associated data is removed automatically. The existing `idx_events_expiry_date` index ensures the query is performant.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The `expiryDate` field already exists on events and is auto-set to `eventDate + 7 days` (implemented in the previous feature).
|
||||
- Cascade deletion of associated data (RSVPs, tokens) is handled at the database level via foreign key constraints (`ON DELETE CASCADE` on `fk_rsvps_event_id`, verified in migration `003-create-rsvps-table.xml`).
|
||||
- The daily schedule is sufficient — there is no requirement for near-real-time deletion.
|
||||
- No "grace period" or "soft delete" is needed — events are permanently removed once expired.
|
||||
101
specs/013-auto-delete-expired/tasks.md
Normal file
101
specs/013-auto-delete-expired/tasks.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# Tasks: Auto-Delete Expired Events
|
||||
|
||||
**Input**: Design documents from `/specs/013-auto-delete-expired/`
|
||||
**Prerequisites**: plan.md, spec.md, research.md, data-model.md
|
||||
|
||||
**Tests**: Included — constitution mandates TDD (Principle II).
|
||||
|
||||
**Organization**: Single user story (US1), so phases are compact.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||
- **[Story]**: Which user story this task belongs to (e.g., US1)
|
||||
- Include exact file paths in descriptions
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup
|
||||
|
||||
**Purpose**: Enable Spring scheduling in the application.
|
||||
|
||||
- [x] T001 Add `@EnableScheduling` annotation to `backend/src/main/java/de/fete/FeteApplication.java`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: User Story 1 — Automatic Cleanup of Expired Events (Priority: P1)
|
||||
|
||||
**Goal**: A daily scheduled job deletes all events whose `expiryDate` is in the past, including cascaded RSVPs.
|
||||
|
||||
**Independent Test**: Create events with past expiry dates, trigger the cleanup job, verify events and RSVPs are gone.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
|
||||
|
||||
- [x] T002 [P] [US1] Unit test for `ExpiredEventCleanupJob` in `backend/src/test/java/de/fete/application/service/ExpiredEventCleanupJobTest.java` — verify job calls `deleteExpired()` on repository and logs the count
|
||||
- [x] T003 [P] [US1] Integration test for native DELETE query in `backend/src/test/java/de/fete/adapter/out/persistence/EventPersistenceAdapterIntegrationTest.java` — verify expired events + RSVPs are deleted, non-expired events survive
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [x] T004 [P] [US1] Add `int deleteExpired()` method to port interface `backend/src/main/java/de/fete/domain/port/out/EventRepository.java`
|
||||
- [x] T005 [P] [US1] Add native `@Modifying @Query("DELETE FROM events WHERE expiry_date < CURRENT_DATE")` method to `backend/src/main/java/de/fete/adapter/out/persistence/EventJpaRepository.java`
|
||||
- [x] T006 [US1] Implement `deleteExpired()` in `backend/src/main/java/de/fete/adapter/out/persistence/EventPersistenceAdapter.java` — delegates to JPA repository native query
|
||||
- [x] T007 [US1] Create `ExpiredEventCleanupJob` with `@Scheduled(cron = "0 0 3 * * *")` in `backend/src/main/java/de/fete/application/service/ExpiredEventCleanupJob.java` — calls `deleteExpired()`, logs count at INFO level
|
||||
|
||||
**Checkpoint**: All tests pass. Expired events are automatically deleted daily at 03:00.
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies — can start immediately
|
||||
- **User Story 1 (Phase 2)**: Depends on Setup completion
|
||||
|
||||
### Within User Story 1
|
||||
|
||||
- T002, T003 (tests) can run in parallel — write first, must fail (RED)
|
||||
- T004, T005 (port + JPA query) can run in parallel — different files
|
||||
- T006 depends on T004 + T005 (adapter implements port, delegates to JPA)
|
||||
- T007 depends on T006 (job calls adapter via port)
|
||||
- After T007: all tests should pass (GREEN)
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
```
|
||||
T002 ──┐
|
||||
├── (tests written, failing)
|
||||
T003 ──┘
|
||||
|
||||
T004 ──┐
|
||||
├── T006 ── T007 ── (all tests green)
|
||||
T005 ──┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP (this is the MVP — single story)
|
||||
|
||||
1. T001: Enable scheduling
|
||||
2. T002 + T003: Write failing tests (RED)
|
||||
3. T004 + T005: Port interface + native query (parallel)
|
||||
4. T006: Adapter implementation
|
||||
5. T007: Scheduled job
|
||||
6. Verify all tests pass (GREEN)
|
||||
7. Done — commit and ship
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Total tasks: 7
|
||||
- User Story 1 tasks: 6 (T002–T007)
|
||||
- Setup tasks: 1 (T001)
|
||||
- Parallel opportunities: T002||T003, T004||T005
|
||||
- No frontend changes needed
|
||||
- No API/OpenAPI changes needed
|
||||
- No new migrations needed (existing schema + FK constraints sufficient)
|
||||
36
specs/014-cancel-rsvp/checklists/requirements.md
Normal file
36
specs/014-cancel-rsvp/checklists/requirements.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Specification Quality Checklist: Cancel RSVP
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-09
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- All items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
|
||||
- The "Design Proposals" section is intentionally included as requested by the user — these are UX options, not implementation details.
|
||||
- One decision point remains: which design option (A, B, or C) for the cancel UI. This is deferred to discussion with the user rather than marked as [NEEDS CLARIFICATION] since the user explicitly requested proposals.
|
||||
40
specs/014-cancel-rsvp/contracts/cancel-rsvp.yaml
Normal file
40
specs/014-cancel-rsvp/contracts/cancel-rsvp.yaml
Normal file
@@ -0,0 +1,40 @@
|
||||
# Cancel RSVP — API Contract
|
||||
# This contract will be merged into backend/src/main/resources/openapi/api.yaml
|
||||
|
||||
# Path: /events/{token}/rsvps/{rsvpToken}
|
||||
# Method: DELETE
|
||||
|
||||
path:
|
||||
/events/{token}/rsvps/{rsvpToken}:
|
||||
delete:
|
||||
summary: Cancel RSVP
|
||||
description: |
|
||||
Permanently deletes an RSVP identified by the RSVP token.
|
||||
Idempotent: returns 204 whether the RSVP existed or not.
|
||||
operationId: cancelRsvp
|
||||
tags:
|
||||
- Events
|
||||
parameters:
|
||||
- name: token
|
||||
in: path
|
||||
required: true
|
||||
description: Event token (UUID)
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
example: "550e8400-e29b-41d4-a716-446655440000"
|
||||
- name: rsvpToken
|
||||
in: path
|
||||
required: true
|
||||
description: RSVP token (UUID) identifying the attendance to cancel
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
example: "7c9e6679-7425-40de-944b-e07fc1f90ae7"
|
||||
responses:
|
||||
"204":
|
||||
description: >
|
||||
RSVP successfully cancelled (or was already cancelled).
|
||||
No response body.
|
||||
"500":
|
||||
description: Internal server error
|
||||
74
specs/014-cancel-rsvp/data-model.md
Normal file
74
specs/014-cancel-rsvp/data-model.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# Data Model: Cancel RSVP
|
||||
|
||||
**Feature**: 014-cancel-rsvp | **Date**: 2026-03-09
|
||||
|
||||
## Entities
|
||||
|
||||
### RSVP (existing — no schema changes)
|
||||
|
||||
| Field | Type | Constraints | Notes |
|
||||
|-------|------|-------------|-------|
|
||||
| id | BIGSERIAL | PK, auto-generated | Internal DB ID, never exposed |
|
||||
| rsvp_token | UUID | UNIQUE, NOT NULL | Bearer credential for the guest |
|
||||
| event_id | BIGINT | FK → events.id, NOT NULL | Links RSVP to event |
|
||||
| name | VARCHAR(100) | NOT NULL | Guest display name |
|
||||
|
||||
**No migration needed.** The cancel feature only deletes existing rows — no new columns or tables.
|
||||
|
||||
## State Transitions
|
||||
|
||||
```
|
||||
┌──────────┐ POST /events/{token}/rsvps ┌──────────┐
|
||||
│ No RSVP │ ──────────────────────────────► │ Attending │
|
||||
│ (guest) │ ◄────────────────────────────── │ (guest) │
|
||||
└──────────┘ DELETE /events/{token}/rsvps/ └──────────┘
|
||||
{rsvpToken}
|
||||
```
|
||||
|
||||
The transition is fully reversible: after cancellation, the guest can create a new RSVP (new token generated).
|
||||
|
||||
## Client-Side Storage (localStorage)
|
||||
|
||||
### StoredEvent (existing interface — no changes)
|
||||
|
||||
```typescript
|
||||
interface StoredEvent {
|
||||
eventToken: string // always present
|
||||
title: string // always present
|
||||
dateTime: string // always present
|
||||
organizerToken?: string // present if organizer
|
||||
rsvpToken?: string // present if RSVP'd — CLEARED on cancel
|
||||
rsvpName?: string // present if RSVP'd — CLEARED on cancel
|
||||
}
|
||||
```
|
||||
|
||||
### Cancel Effects on localStorage
|
||||
|
||||
| Action | rsvpToken | rsvpName | Event in list |
|
||||
|--------|-----------|----------|---------------|
|
||||
| Cancel from detail view | Removed | Removed | Kept |
|
||||
| Remove from event list | Removed | Removed | Removed |
|
||||
|
||||
## Repository Changes
|
||||
|
||||
### RsvpRepository (domain port)
|
||||
|
||||
New method:
|
||||
|
||||
```java
|
||||
/**
|
||||
* Delete an RSVP by event ID and RSVP token.
|
||||
* @return true if a record was deleted, false if not found
|
||||
*/
|
||||
boolean deleteByEventIdAndRsvpToken(Long eventId, RsvpToken rsvpToken);
|
||||
```
|
||||
|
||||
### RsvpJpaRepository
|
||||
|
||||
New method:
|
||||
|
||||
```java
|
||||
long deleteByEventIdAndRsvpToken(Long eventId, UUID rsvpToken);
|
||||
```
|
||||
|
||||
Returns count of deleted rows (0 or 1). Spring Data JPA auto-implements this derived query.
|
||||
90
specs/014-cancel-rsvp/plan.md
Normal file
90
specs/014-cancel-rsvp/plan.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# Implementation Plan: Cancel RSVP
|
||||
|
||||
**Branch**: `014-cancel-rsvp` | **Date**: 2026-03-09 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from `/specs/014-cancel-rsvp/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Allow guests to cancel their RSVP either explicitly from the event detail view (tap-to-reveal pattern on the RSVP bar) or implicitly when removing an RSVP'd event from their event list. The backend provides an idempotent DELETE endpoint; the frontend handles confirmation, API call, localStorage cleanup, and UI state reset.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Java 25 (backend), TypeScript 5.9 (frontend)
|
||||
**Primary Dependencies**: Spring Boot 3.5.x, Vue 3, Vue Router 5, openapi-fetch, openapi-typescript
|
||||
**Storage**: PostgreSQL (JPA via Spring Data, Liquibase migrations)
|
||||
**Testing**: JUnit + Mockito (backend unit), MockMvc + Testcontainers (backend integration), Vitest (frontend unit), Playwright + MSW (frontend E2E)
|
||||
**Target Platform**: Self-hosted PWA (web browser, mobile-first)
|
||||
**Project Type**: Web application (hexagonal backend + SPA frontend)
|
||||
**Performance Goals**: Cancel operation < 500ms server-side
|
||||
**Constraints**: Privacy by design (no analytics), token-based auth (no login), idempotent delete
|
||||
**Scale/Scope**: Single new endpoint, 3 modified frontend components, 1 new composable method
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| I. Privacy by Design | PASS | No new PII stored. Delete operation removes data. No analytics. |
|
||||
| II. Test-Driven Methodology | PASS | Plan includes TDD cycle: tests before implementation. E2E mandatory. |
|
||||
| III. API-First Development | PASS | OpenAPI spec updated first, types generated before implementation. |
|
||||
| IV. Simplicity & Quality | PASS | Minimal changes to existing code. No new abstractions. Idempotent delete is the simplest correct approach. |
|
||||
| V. Dependency Discipline | PASS | No new dependencies required. |
|
||||
| VI. Accessibility | PASS | Interactive elements will use semantic HTML, ARIA, keyboard navigation. Confirm dialog already accessible. |
|
||||
|
||||
**Gate result**: ALL PASS — proceed to Phase 0.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/014-cancel-rsvp/
|
||||
├── plan.md # This file
|
||||
├── research.md # Phase 0 output
|
||||
├── data-model.md # Phase 1 output
|
||||
├── contracts/ # Phase 1 output
|
||||
│ └── cancel-rsvp.yaml # DELETE endpoint contract
|
||||
└── tasks.md # Phase 2 output (by /speckit.tasks)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
backend/
|
||||
├── src/main/java/de/fete/
|
||||
│ ├── domain/
|
||||
│ │ ├── model/Rsvp.java # existing (no changes)
|
||||
│ │ ├── model/RsvpToken.java # existing (no changes)
|
||||
│ │ └── port/
|
||||
│ │ ├── in/CancelRsvpUseCase.java # NEW use case port
|
||||
│ │ └── out/RsvpRepository.java # MODIFY (add deleteByRsvpToken)
|
||||
│ ├── application/service/RsvpService.java # MODIFY (implement cancel)
|
||||
│ └── adapter/
|
||||
│ ├── in/web/EventController.java # MODIFY (add DELETE handler)
|
||||
│ └── out/persistence/
|
||||
│ ├── RsvpJpaRepository.java # MODIFY (add deleteByRsvpToken)
|
||||
│ └── RsvpPersistenceAdapter.java # MODIFY (implement delete)
|
||||
├── src/main/resources/openapi/api.yaml # MODIFY (add DELETE endpoint)
|
||||
└── src/test/java/de/fete/
|
||||
├── application/service/RsvpServiceTest.java # MODIFY (add cancel tests)
|
||||
└── adapter/in/web/EventControllerIntegrationTest.java # MODIFY (add cancel tests)
|
||||
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── api/schema.d.ts # REGENERATE (from updated OpenAPI)
|
||||
│ ├── components/
|
||||
│ │ ├── RsvpBar.vue # MODIFY (tap-to-reveal cancel)
|
||||
│ │ ├── EventList.vue # MODIFY (conditional dialog msg)
|
||||
│ │ └── ConfirmDialog.vue # existing (no changes)
|
||||
│ ├── composables/useEventStorage.ts # MODIFY (add removeRsvp)
|
||||
│ └── views/EventDetailView.vue # MODIFY (add cancel logic)
|
||||
└── e2e/
|
||||
└── cancel-rsvp.spec.ts # NEW (E2E tests)
|
||||
```
|
||||
|
||||
**Structure Decision**: Follows existing hexagonal architecture. New use case port + implementation in existing service. Single new endpoint added to existing controller.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitution violations — table not needed.
|
||||
57
specs/014-cancel-rsvp/quickstart.md
Normal file
57
specs/014-cancel-rsvp/quickstart.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Quickstart: Cancel RSVP
|
||||
|
||||
**Feature**: 014-cancel-rsvp | **Date**: 2026-03-09
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **OpenAPI spec** — Add DELETE endpoint to `api.yaml`
|
||||
2. **Regenerate types** — Backend (Maven generate) + Frontend (`npm run generate:api`)
|
||||
3. **Backend TDD** — Write tests → implement repository → service → controller
|
||||
4. **Frontend composable** — Add `removeRsvp()` to `useEventStorage.ts`
|
||||
5. **Frontend US-1** — RsvpBar tap-to-reveal + EventDetailView cancel logic
|
||||
6. **Frontend US-2** — EventList conditional dialog + server-side cancel before removal
|
||||
7. **Frontend US-3** — Edge case handling (stale tokens treated as success)
|
||||
8. **E2E tests** — Playwright tests for all three user stories
|
||||
|
||||
## Key Commands
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd backend && ./mvnw test # Run backend tests
|
||||
cd backend && ./mvnw verify # Full verify (checkstyle + tests)
|
||||
cd backend && ./mvnw spring-boot:run # Run backend locally
|
||||
|
||||
# Frontend
|
||||
cd frontend && npm run generate:api # Regenerate types from OpenAPI
|
||||
cd frontend && npm run test:unit # Run unit tests
|
||||
cd frontend && npx playwright test # Run E2E tests
|
||||
cd frontend && npm run dev # Dev server
|
||||
|
||||
# Both
|
||||
cd backend && ./mvnw test && cd ../frontend && npm run test:unit # Quick check
|
||||
```
|
||||
|
||||
## Key Files to Modify
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `backend/src/main/resources/openapi/api.yaml` | Add DELETE `/events/{token}/rsvps/{rsvpToken}` |
|
||||
| `backend/.../port/in/CancelRsvpUseCase.java` | New use case interface |
|
||||
| `backend/.../port/out/RsvpRepository.java` | Add `deleteByEventIdAndRsvpToken()` |
|
||||
| `backend/.../RsvpJpaRepository.java` | Add derived delete query |
|
||||
| `backend/.../RsvpPersistenceAdapter.java` | Implement delete |
|
||||
| `backend/.../RsvpService.java` | Implement `CancelRsvpUseCase` |
|
||||
| `backend/.../EventController.java` | Add `cancelRsvp()` handler |
|
||||
| `frontend/src/composables/useEventStorage.ts` | Add `removeRsvp()` |
|
||||
| `frontend/src/components/RsvpBar.vue` | Tap-to-reveal cancel button |
|
||||
| `frontend/src/views/EventDetailView.vue` | Cancel logic + confirm dialog |
|
||||
| `frontend/src/components/EventList.vue` | Conditional dialog message + server cancel |
|
||||
| `frontend/e2e/cancel-rsvp.spec.ts` | E2E tests for all scenarios |
|
||||
|
||||
## Gotchas
|
||||
|
||||
- The JPA `deleteBy...` method requires `@Transactional` on the calling service method.
|
||||
- The `@Transactional` import must be `jakarta.transaction.Transactional` (not Spring's).
|
||||
- After updating OpenAPI spec, run `npm run generate:api` in frontend AND Maven generate-sources in backend.
|
||||
- The RsvpBar component currently has no click handler on the status state — this needs to be added carefully with proper accessibility (role, aria-expanded, keyboard support).
|
||||
- The EventList's `confirmDelete` currently calls `removeEvent()` synchronously — it needs to become async for the server call.
|
||||
82
specs/014-cancel-rsvp/research.md
Normal file
82
specs/014-cancel-rsvp/research.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# Research: Cancel RSVP
|
||||
|
||||
**Feature**: 014-cancel-rsvp | **Date**: 2026-03-09
|
||||
|
||||
## 1. Idempotent DELETE Semantics
|
||||
|
||||
**Decision**: Return 204 No Content for both successful deletion and "already deleted" cases.
|
||||
|
||||
**Rationale**: HTTP DELETE is defined as idempotent (RFC 9110 §9.3.5). Returning 204 regardless of whether the RSVP existed simplifies client logic — the client doesn't need to distinguish "deleted now" from "was already gone." This directly satisfies FR-002 and US-3.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Return 404 for "not found" RSVP: Violates idempotency expectations, forces client to handle two success paths.
|
||||
- Return 200 with body: Unnecessary — no useful information to return after deletion.
|
||||
|
||||
## 2. Authorization Model for Delete
|
||||
|
||||
**Decision**: DELETE requires both event token (path) and RSVP token (path). The RSVP token acts as a bearer credential — possession equals authorization.
|
||||
|
||||
**Rationale**: Consistent with the existing privacy model. The RSVP token is a UUID v4 generated server-side, unguessable. No additional auth needed. The event token scopes the operation to prevent cross-event token collision (defense in depth).
|
||||
|
||||
**Alternatives considered**:
|
||||
- RSVP token only: Sufficient in practice (UUIDs are globally unique) but loses the event context in the URL, making the API less RESTful.
|
||||
- Require organizer token: Would prevent guests from self-cancelling — contradicts the spec.
|
||||
|
||||
## 3. Backend Delete Implementation Pattern
|
||||
|
||||
**Decision**: Use `deleteByRsvpToken(UUID)` on the JPA repository. Return the count of deleted rows (0 or 1) to determine if a record was actually removed (needed for attendee count response).
|
||||
|
||||
**Rationale**: Spring Data JPA supports `deleteBy...` derived queries returning `long` (count of deleted rows). This is a single query, no need to fetch-then-delete. The existing `findByRsvpToken()` method confirms the naming convention.
|
||||
|
||||
**Alternatives considered**:
|
||||
- `findByRsvpToken()` then `delete(entity)`: Two queries instead of one. Unnecessary.
|
||||
- Native `@Query` DELETE: Overkill for a simple single-column delete.
|
||||
|
||||
## 4. Backend Validation: Event Token Check
|
||||
|
||||
**Decision**: Validate that the RSVP belongs to the specified event before deleting. If the RSVP token exists but belongs to a different event, return 404.
|
||||
|
||||
**Rationale**: Defense in depth. Prevents accidental or malicious cross-event RSVP deletion via URL manipulation. The combined lookup (`findByEventIdAndRsvpToken`) is a single indexed query.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Skip event validation: Simpler but allows deleting RSVPs via wrong event URLs. Minor security concern but violates principle of least surprise.
|
||||
|
||||
## 5. Frontend: Tap-to-Reveal Pattern for Cancel
|
||||
|
||||
**Decision**: The "You're attending!" bar becomes tappable. Tapping reveals a slide-out "Cancel attendance" button. Tapping outside or pressing Escape collapses it. A subtle chevron/icon hints at interactivity.
|
||||
|
||||
**Rationale**: Specified in the feature spec's design decision. Prevents accidental cancellation (two-step: reveal + confirm dialog). Keeps the default state clean and positive.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Always-visible cancel button: Too prominent, encourages cancellation over attendance.
|
||||
- Long-press to reveal: Not discoverable, no established mobile convention for this.
|
||||
- Swipe gesture: Already used for event list deletion — would create gesture ambiguity.
|
||||
|
||||
## 6. Frontend: removeRsvp vs removeEvent in localStorage
|
||||
|
||||
**Decision**: Add `removeRsvp(eventToken)` method to `useEventStorage.ts` that clears only `rsvpToken` and `rsvpName` from a stored event, keeping the event itself in the list.
|
||||
|
||||
**Rationale**: Cancel from event detail view should NOT remove the event from the list — the guest may still want to see event details. Only the RSVP fields need clearing. The existing `removeEvent()` method is used for event list removal (US-2).
|
||||
|
||||
**Alternatives considered**:
|
||||
- Reuse `removeEvent()` for both: Would remove the event from the list when cancelling from detail view — unexpected behavior.
|
||||
|
||||
## 7. Frontend: Event List Removal with RSVP
|
||||
|
||||
**Decision**: When removing an event that has an RSVP token, call DELETE on the server FIRST. On success (or 404), then remove from localStorage. On server error, show error and keep the event.
|
||||
|
||||
**Rationale**: Server-first ensures data consistency (FR-007). If we removed from localStorage first and the server call failed, the RSVP would remain on the server with no way for the guest to cancel it.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Optimistic removal (remove from localStorage, fire-and-forget server call): Risks data inconsistency if server call fails.
|
||||
- Remove from localStorage first, retry server call: Complex retry logic, still risks inconsistency.
|
||||
|
||||
## 8. Attendee Count After Cancellation
|
||||
|
||||
**Decision**: After successful DELETE, decrement the local attendee count by 1 in the event detail view. Do not re-fetch the event.
|
||||
|
||||
**Rationale**: Avoids an extra GET request. The count is deterministic — if the delete succeeded, exactly one attendee was removed. The same pattern is used for RSVP creation (increment by 1).
|
||||
|
||||
**Alternatives considered**:
|
||||
- Re-fetch event data: Extra network request, slower UX, unnecessary.
|
||||
- Return updated count from DELETE endpoint: Adds response body to a 204 — semantically wrong.
|
||||
114
specs/014-cancel-rsvp/spec.md
Normal file
114
specs/014-cancel-rsvp/spec.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# Feature Specification: Cancel RSVP
|
||||
|
||||
**Feature Branch**: `014-cancel-rsvp`
|
||||
**Created**: 2026-03-09
|
||||
**Status**: Draft
|
||||
**Input**: User description: "A guest can cancel their attendance if they still have their RSVP token in localStorage. The event detail view should offer this functionality (design proposals needed). The RSVP is permanently deleted from the database by RSVP token. When a guest removes an entry from their event list, attendance is automatically cancelled. The confirmation dialog informs the guest about this behavior."
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Cancel RSVP from Event Detail View (Priority: P1)
|
||||
|
||||
A guest who previously RSVP'd to an event visits the event detail page. The sticky bottom bar shows their attendance status ("You're attending!"). The guest can cancel their attendance directly from this view. After cancellation, their RSVP is permanently removed from the server and from localStorage. The attendee count decreases by one, and the RSVP bar returns to the initial "I'm attending" call-to-action state, allowing the guest to re-RSVP if desired.
|
||||
|
||||
**Why this priority**: This is the core feature — the explicit, intentional cancellation flow. It gives guests direct control over their attendance and is the primary interaction point.
|
||||
|
||||
**Independent Test**: Can be fully tested by navigating to an event detail page as an RSVP'd guest, cancelling, and verifying the RSVP is deleted server-side and the UI resets.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a guest has an RSVP token stored in localStorage for an event, **When** they view the event detail page, **Then** they see a way to cancel their attendance alongside their attendance status.
|
||||
2. **Given** the guest initiates cancellation, **When** the system presents a confirmation prompt, **Then** the prompt clearly states that attendance will be permanently cancelled.
|
||||
3. **Given** the guest confirms cancellation, **When** the server successfully deletes the RSVP, **Then** the RSVP token and name are removed from localStorage, the attendee count decreases by one, and the RSVP bar returns to the initial call-to-action state.
|
||||
4. **Given** the guest confirms cancellation, **When** the server returns an error, **Then** the guest sees an error message and their attendance status remains unchanged.
|
||||
5. **Given** the guest cancels and the RSVP bar resets, **When** the guest taps the call-to-action, **Then** they can submit a new RSVP as normal.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Auto-Cancel on Event List Removal (Priority: P2)
|
||||
|
||||
A guest removes an event from their personal event list (via delete button or swipe gesture on the event card). If the guest has an RSVP token for that event, the confirmation dialog informs them that removing the event will also cancel their attendance on the server. Upon confirmation, the RSVP is deleted from the server before the event is removed from localStorage.
|
||||
|
||||
**Why this priority**: This ensures data consistency between client and server. Without it, a guest could believe they cancelled but their name would remain on the attendee list.
|
||||
|
||||
**Independent Test**: Can be fully tested by adding an RSVP'd event to the list, removing it via the event list, and verifying the RSVP is deleted server-side.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a guest has an event in their list with an RSVP token, **When** they initiate removal (delete button or swipe), **Then** the confirmation dialog explicitly mentions that their attendance will also be cancelled.
|
||||
2. **Given** a guest has an event in their list without an RSVP token (organizer-only or link-only), **When** they initiate removal, **Then** the confirmation dialog does not mention attendance cancellation (existing behavior unchanged).
|
||||
3. **Given** the guest confirms removal of an RSVP'd event, **When** the server successfully deletes the RSVP, **Then** the event is removed from localStorage (including RSVP token) and disappears from the list.
|
||||
4. **Given** the guest confirms removal of an RSVP'd event, **When** the server fails to delete the RSVP, **Then** the guest sees an error message and the event remains in the list unchanged.
|
||||
5. **Given** the guest dismisses the confirmation dialog, **When** no action is taken, **Then** the event and RSVP remain unchanged.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Cancel RSVP with Expired/Invalid Token (Priority: P3)
|
||||
|
||||
A guest attempts to cancel their RSVP, but the token is no longer valid on the server (e.g., the event was deleted, or the RSVP was already removed by another means). The system handles this gracefully.
|
||||
|
||||
**Why this priority**: Edge case handling — less common but important for a smooth user experience.
|
||||
|
||||
**Independent Test**: Can be tested by manipulating localStorage to contain a stale RSVP token and attempting cancellation.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a guest has a stale RSVP token in localStorage, **When** they attempt to cancel from the event detail view, **Then** the system treats a "not found" server response as a successful cancellation (the RSVP is already gone), cleans up localStorage, and resets the UI.
|
||||
2. **Given** a guest has a stale RSVP token in localStorage, **When** they remove the event from their list, **Then** the system treats a "not found" server response as success and removes the event from localStorage.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when the guest has no internet connection during cancellation? → Show an error message; do not modify localStorage or UI state.
|
||||
- What happens if the event itself has been deleted? → The event detail view already handles the "not found" state. For list removal, treat the 404 as success and clean up localStorage.
|
||||
- What happens if multiple browser tabs are open? → localStorage changes propagate across tabs; the RSVP bar should reflect the current localStorage state on visibility/focus.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: System MUST provide a cancellation endpoint that permanently deletes an RSVP record identified by event token and RSVP token.
|
||||
- **FR-002**: System MUST return a success response when the RSVP is deleted, including when the RSVP does not exist (idempotent delete).
|
||||
- **FR-003**: The event detail view MUST display a cancel option when the guest has an RSVP token in localStorage for the current event.
|
||||
- **FR-004**: The cancel option MUST require explicit confirmation before proceeding.
|
||||
- **FR-005**: After successful cancellation, the system MUST remove the RSVP token and RSVP name from localStorage for that event.
|
||||
- **FR-006**: After successful cancellation on the event detail view, the attendee count MUST decrease by one and the RSVP bar MUST return to its initial call-to-action state.
|
||||
- **FR-007**: When a guest removes an RSVP'd event from their event list, the system MUST attempt to delete the RSVP on the server before removing it from localStorage.
|
||||
- **FR-008**: The event list removal confirmation dialog MUST inform the guest that their attendance will be cancelled when an RSVP token is present.
|
||||
- **FR-009**: If the server returns an error (other than "not found") during cancellation, the system MUST show an error message and leave the local state unchanged.
|
||||
- **FR-010**: The cancellation endpoint MUST only delete the RSVP matching the provided RSVP token — no other RSVPs or event data may be affected.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **RSVP**: An attendance record linking a guest name to an event. Identified by a unique RSVP token (UUID). Existence indicates attendance; deletion indicates cancellation.
|
||||
- **Stored Event (client-side)**: A localStorage entry containing event metadata, and optionally an RSVP token and name if the guest has RSVP'd.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: A guest can cancel their RSVP from the event detail view in under 5 seconds (two taps: cancel + confirm).
|
||||
- **SC-002**: After cancellation, the guest's name no longer appears in the attendee list when viewed by the organizer.
|
||||
- **SC-003**: Removing an RSVP'd event from the event list results in server-side RSVP deletion 100% of the time when the server is reachable.
|
||||
- **SC-004**: The confirmation dialog clearly communicates the consequence (attendance cancellation) — no guest should be surprised by the side effect.
|
||||
- **SC-005**: A guest can re-RSVP after cancellation without any issues.
|
||||
|
||||
## Design Decision: Cancel UI on Event Detail View
|
||||
|
||||
**Chosen**: Tap-to-Reveal Pattern
|
||||
|
||||
The current RSVP bar (sticky bottom) shows "You're attending!" after an RSVP. The status bar becomes tappable. Tapping it reveals a slide-out or expand animation with a "Cancel attendance" button. Tapping outside collapses it back. A subtle visual hint (chevron or icon) indicates the bar is interactive.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The existing `findByRsvpToken()` repository method can be leveraged for the delete operation.
|
||||
- The RSVP token alone (combined with the event token in the URL) is sufficient authorization for deletion — consistent with the project's token-based privacy model.
|
||||
- The delete operation is idempotent: deleting an already-deleted RSVP returns success (not an error).
|
||||
- The event list confirmation dialog already exists and can be extended with conditional messaging.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **008-rsvp**: The RSVP creation flow and localStorage storage pattern (completed).
|
||||
- **007-view-event**: The event detail view and RSVP bar component (completed).
|
||||
- **009-list-events**: The event list with delete functionality (completed).
|
||||
206
specs/014-cancel-rsvp/tasks.md
Normal file
206
specs/014-cancel-rsvp/tasks.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# Tasks: Cancel RSVP
|
||||
|
||||
**Input**: Design documents from `/specs/014-cancel-rsvp/`
|
||||
**Prerequisites**: plan.md (required), spec.md (required), research.md, data-model.md, contracts/
|
||||
|
||||
**Tests**: Included — constitution mandates TDD (Red → Green → Refactor). E2E tests mandatory per principle II.
|
||||
|
||||
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
|
||||
- Include exact file paths in descriptions
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup (API Contract & Type Generation)
|
||||
|
||||
**Purpose**: Define the DELETE endpoint contract and regenerate types for both backend and frontend.
|
||||
|
||||
- [x] T001 Add DELETE `/events/{token}/rsvps/{rsvpToken}` endpoint to `backend/src/main/resources/openapi/api.yaml` per `specs/014-cancel-rsvp/contracts/cancel-rsvp.yaml` — operationId `cancelRsvp`, responses 204 (success/idempotent) and 500
|
||||
- [x] T002 Regenerate backend API interfaces from updated OpenAPI spec via `cd backend && ./mvnw generate-sources`
|
||||
- [x] T003 Regenerate frontend TypeScript types from updated OpenAPI spec via `cd frontend && npm run generate:api`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Backend Delete Infrastructure)
|
||||
|
||||
**Purpose**: Backend repository and service layer for RSVP deletion — blocks all user stories.
|
||||
|
||||
**⚠️ CRITICAL**: No user story work can begin until this phase is complete.
|
||||
|
||||
### Tests (write FIRST, must FAIL before implementation)
|
||||
|
||||
- [x] T004 [P] Write unit test for `cancelRsvp()` in `backend/src/test/java/de/fete/application/service/RsvpServiceTest.java` — test cases: successful delete (returns true), RSVP not found (returns false), event not found (returns false)
|
||||
- [x] T005 [P] Write integration test for `DELETE /api/events/{token}/rsvps/{rsvpToken}` in `backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java` — test cases: 204 on successful delete, 204 on already-deleted RSVP (idempotent), 204 when event not found (idempotent), verify RSVP row actually removed from DB
|
||||
|
||||
### Implementation
|
||||
|
||||
- [x] T006 Create `CancelRsvpUseCase` interface in `backend/src/main/java/de/fete/domain/port/in/CancelRsvpUseCase.java` — single method `cancelRsvp(EventToken, RsvpToken)` returning void
|
||||
- [x] T007 Add `deleteByEventIdAndRsvpToken(Long eventId, RsvpToken rsvpToken)` to domain port `backend/src/main/java/de/fete/domain/port/out/RsvpRepository.java`
|
||||
- [x] T008 Add `deleteByEventIdAndRsvpToken(Long eventId, UUID rsvpToken)` derived delete query to `backend/src/main/java/de/fete/adapter/out/persistence/RsvpJpaRepository.java`
|
||||
- [x] T009 Implement `deleteByEventIdAndRsvpToken()` in `backend/src/main/java/de/fete/adapter/out/persistence/RsvpPersistenceAdapter.java` — delegate to JPA repository, return boolean (deleted count > 0)
|
||||
- [x] T010 Implement `CancelRsvpUseCase` in `backend/src/main/java/de/fete/application/service/RsvpService.java` — look up event by token, if found call repository delete, no error on not-found (idempotent). Add `@Transactional`
|
||||
- [x] T011 Implement `cancelRsvp()` handler in `backend/src/main/java/de/fete/adapter/in/web/EventController.java` — accept event token and RSVP token path params, call use case, return 204 No Content
|
||||
- [x] T012 Run `cd backend && ./mvnw verify` — all tests (existing + new) must pass
|
||||
|
||||
**Checkpoint**: Backend DELETE endpoint functional and tested. Verify via integration tests.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Cancel RSVP from Event Detail View (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Guest can tap the "You're attending!" bar to reveal a cancel button, confirm cancellation, and have RSVP deleted server-side with UI reset.
|
||||
|
||||
**Independent Test**: Navigate to event detail as RSVP'd guest → tap status bar → tap cancel → confirm → verify RSVP deleted, attendee count decremented, bar reset to CTA state. Then re-RSVP to verify flow works again.
|
||||
|
||||
### Tests (write FIRST, must FAIL before implementation)
|
||||
|
||||
- [x] T013 [US1] Write E2E test file `frontend/e2e/cancel-rsvp.spec.ts` — US1 scenarios: (1) status bar shows cancel affordance when RSVP'd, (2) tap reveals cancel button, (3) confirm cancellation → 204 → localStorage cleared + count decremented + bar reset, (4) server error → error message + state unchanged, (5) re-RSVP after cancel works
|
||||
|
||||
### Implementation
|
||||
|
||||
- [x] T014 [US1] Add `removeRsvp(eventToken: string)` method to `frontend/src/composables/useEventStorage.ts` — clears `rsvpToken` and `rsvpName` from the stored event without removing the event from the list
|
||||
- [x] T015 [US1] Modify `frontend/src/components/RsvpBar.vue` — make status state tappable with tap-to-reveal pattern: (1) add `expanded` state, (2) tapping "You're attending!" toggles expanded, (3) expanded state shows "Cancel attendance" button, (4) emit `cancel` event on button click, (5) collapse on outside click/Escape, (6) add subtle chevron icon hint, (7) ARIA: `role="button"`, `aria-expanded`, keyboard support (Enter/Space to toggle, Escape to collapse)
|
||||
- [x] T016 [US1] Add cancel RSVP logic to `frontend/src/views/EventDetailView.vue` — (1) handle `cancel` emit from RsvpBar, (2) show ConfirmDialog with message "Your attendance will be permanently cancelled.", (3) on confirm: call `api.DELETE('/events/{token}/rsvps/{rsvpToken}')`, (4) on 204: call `removeRsvp()`, decrement attendee count, reset RSVP state (`rsvpName = ''`), (5) on error: show error message "Could not cancel RSVP. Please try again.", (6) keep local state unchanged on error
|
||||
- [x] T017 [US1] Run frontend unit tests `cd frontend && npm run test:unit` and E2E tests `cd frontend && npx playwright test cancel-rsvp` — all must pass
|
||||
|
||||
**Checkpoint**: US1 fully functional. Guest can cancel RSVP from event detail view and re-RSVP.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Auto-Cancel on Event List Removal (Priority: P2)
|
||||
|
||||
**Goal**: When a guest removes an RSVP'd event from their event list, the confirmation dialog warns about attendance cancellation and the RSVP is deleted server-side before localStorage cleanup.
|
||||
|
||||
**Independent Test**: Add RSVP'd event to list → initiate removal → verify dialog mentions attendance → confirm → verify server DELETE called → event removed from list. Also test: event without RSVP shows standard dialog (no mention of attendance).
|
||||
|
||||
### Tests (write FIRST, must FAIL before implementation)
|
||||
|
||||
- [x] T018 [US2] Add US2 E2E scenarios to `frontend/e2e/cancel-rsvp.spec.ts` — (1) removal of RSVP'd event shows "attendance will be cancelled" in dialog, (2) removal of non-RSVP'd event shows standard dialog (no attendance mention), (3) confirm removal → DELETE called → event removed from list, (4) server error on DELETE → error message + event stays in list, (5) dismiss dialog → no changes
|
||||
|
||||
### Implementation
|
||||
|
||||
- [x] T019 [US2] Modify `frontend/src/components/EventList.vue` — (1) detect if pending-delete event has RSVP token via `getRsvp(eventToken)`, (2) set conditional dialog message: with RSVP → "This event will be removed from your list and your attendance will be cancelled." / without RSVP → existing message, (3) make `confirmDelete()` async: if RSVP exists, call `api.DELETE('/events/{token}/rsvps/{rsvpToken}')` first, (4) on success or 404: proceed with `removeEvent()`, (5) on other error: show error message, abort removal
|
||||
- [x] T020 [US2] Import API client and `getRsvp` from `useEventStorage` in `frontend/src/components/EventList.vue` — ensure API client is available for server calls
|
||||
- [x] T021 [US2] Run E2E tests `cd frontend && npx playwright test cancel-rsvp` — all US1 + US2 scenarios must pass
|
||||
|
||||
**Checkpoint**: US1 + US2 functional. Both cancel paths work independently.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — Cancel RSVP with Expired/Invalid Token (Priority: P3)
|
||||
|
||||
**Goal**: Gracefully handle stale RSVP tokens — treat "not found" server responses as successful cancellation.
|
||||
|
||||
**Independent Test**: Set stale RSVP token in localStorage → attempt cancel from detail view → verify 404 treated as success → localStorage cleaned. Same for event list removal.
|
||||
|
||||
### Tests (write FIRST, must FAIL before implementation)
|
||||
|
||||
- [x] T022 [US3] Add US3 E2E scenarios to `frontend/e2e/cancel-rsvp.spec.ts` — (1) cancel from detail view with stale token (server 404) → treated as success, localStorage cleaned, UI reset, (2) event list removal with stale token (server 404) → treated as success, event removed
|
||||
|
||||
### Implementation
|
||||
|
||||
- [x] T023 (already implemented in T016 — 404 treated as success) [US3] Update cancel logic in `frontend/src/views/EventDetailView.vue` — treat 404 response from DELETE as success (RSVP already gone): clean up localStorage and reset UI same as 204
|
||||
- [x] T024 (already implemented in T019 — 404 treated as success) [US3] Update cancel logic in `frontend/src/components/EventList.vue` — treat 404 response from DELETE as success: proceed with `removeEvent()` (note: this may already be handled if T019 implemented "success or 404" correctly — verify and adjust if needed)
|
||||
- [x] T025 [US3] Run all E2E tests `cd frontend && npx playwright test cancel-rsvp` — all US1 + US2 + US3 scenarios must pass
|
||||
|
||||
**Checkpoint**: All user stories functional. Edge cases handled gracefully.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Final verification and cleanup across all stories.
|
||||
|
||||
- [x] T026 Run full backend verify `cd backend && ./mvnw verify` — checkstyle + all tests
|
||||
- [x] T027 Run full frontend test suite `cd frontend && npm run test:unit && npx playwright test` — all unit + E2E tests
|
||||
- [x] T028 Verify accessibility: RsvpBar cancel interaction is keyboard-navigable (Tab, Enter/Space, Escape), ARIA attributes correct, confirm dialog focus management works
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies — start immediately
|
||||
- **Foundational (Phase 2)**: Depends on Phase 1 (generated API interfaces needed)
|
||||
- **US1 (Phase 3)**: Depends on Phase 2 (backend endpoint must exist)
|
||||
- **US2 (Phase 4)**: Depends on Phase 2 (backend endpoint must exist). Independent of US1.
|
||||
- **US3 (Phase 5)**: Depends on US1 and US2 (refines their error handling)
|
||||
- **Polish (Phase 6)**: Depends on all stories complete
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1 (P1)**: Independent after Phase 2 — core cancel flow
|
||||
- **US2 (P2)**: Independent after Phase 2 — can be implemented in parallel with US1
|
||||
- **US3 (P3)**: Depends on US1 + US2 — refines error handling in both
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Tests MUST be written and FAIL before implementation
|
||||
- Composable/model changes before component changes
|
||||
- Component changes before view integration
|
||||
- Story complete before moving to next priority
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- T004 + T005 (backend tests) can run in parallel
|
||||
- T006 + T007 (use case + repository port) can run in parallel
|
||||
- US1 and US2 can be implemented in parallel after Phase 2 (different files)
|
||||
- T026 + T027 (backend verify + frontend tests) can run in parallel
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: Phase 2 (Foundational)
|
||||
|
||||
```bash
|
||||
# Write tests in parallel:
|
||||
Task T004: "Unit test for cancelRsvp in RsvpServiceTest.java"
|
||||
Task T005: "Integration test for DELETE endpoint in EventControllerIntegrationTest.java"
|
||||
|
||||
# Then implement sequentially: T006 → T007 → T008 → T009 → T010 → T011 → T012
|
||||
```
|
||||
|
||||
## Parallel Example: US1 + US2
|
||||
|
||||
```bash
|
||||
# After Phase 2 completes, launch in parallel:
|
||||
# Stream A (US1): T013 → T014 → T015 → T016 → T017
|
||||
# Stream B (US2): T018 → T019 → T020 → T021
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Complete Phase 1: Setup (OpenAPI + type generation)
|
||||
2. Complete Phase 2: Foundational (backend DELETE endpoint)
|
||||
3. Complete Phase 3: User Story 1 (cancel from detail view)
|
||||
4. **STOP and VALIDATE**: Test cancel flow end-to-end
|
||||
5. Deploy/demo if ready
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Setup + Foundational → Backend ready
|
||||
2. Add US1 → Cancel from detail view works → Deploy (MVP!)
|
||||
3. Add US2 → Auto-cancel on list removal → Deploy
|
||||
4. Add US3 → Stale token edge cases handled → Deploy
|
||||
5. Each story adds value without breaking previous stories
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- [P] tasks = different files, no dependencies
|
||||
- [Story] label maps task to specific user story for traceability
|
||||
- Each user story should be independently completable and testable
|
||||
- Verify tests fail before implementing
|
||||
- Commit after each task or logical group
|
||||
- Stop at any checkpoint to validate story independently
|
||||
- Backend idempotent DELETE (204 always) simplifies all frontend error handling
|
||||
- The `@Transactional` annotation is required on the service method calling JPA `deleteBy...`
|
||||
36
specs/016-cancel-event/checklists/requirements.md
Normal file
36
specs/016-cancel-event/checklists/requirements.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Specification Quality Checklist: Cancel Event
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-12
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- All items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
|
||||
- Feature scope is deliberately tight: cancel + display. No notifications, no undo, no event-list changes.
|
||||
- Both user stories are P1 because they are two sides of the same coin (cancel action + display result).
|
||||
78
specs/016-cancel-event/contracts/patch-event-endpoint.yaml
Normal file
78
specs/016-cancel-event/contracts/patch-event-endpoint.yaml
Normal file
@@ -0,0 +1,78 @@
|
||||
# OpenAPI additions for Cancel Event feature
|
||||
# To be merged into backend/src/main/resources/openapi/api.yaml
|
||||
|
||||
# PATCH method added to existing /events/{eventToken} path
|
||||
# Under paths./events/{eventToken}:
|
||||
|
||||
# --- Add PATCH method to existing path ---
|
||||
# /events/{eventToken}:
|
||||
# patch:
|
||||
# operationId: patchEvent
|
||||
# summary: Update an event (currently: cancel)
|
||||
# description: |
|
||||
# Partial update of an event resource. Currently the only supported operation
|
||||
# is cancellation (setting cancelled to true). Requires the organizer token.
|
||||
# Cancellation is irreversible.
|
||||
# tags: [Events]
|
||||
# parameters:
|
||||
# - $ref: '#/components/parameters/EventToken'
|
||||
# requestBody:
|
||||
# required: true
|
||||
# content:
|
||||
# application/json:
|
||||
# schema:
|
||||
# $ref: '#/components/schemas/PatchEventRequest'
|
||||
# responses:
|
||||
# '204':
|
||||
# description: Event updated successfully
|
||||
# '403':
|
||||
# description: Invalid organizer token
|
||||
# content:
|
||||
# application/json:
|
||||
# schema:
|
||||
# $ref: '#/components/schemas/ErrorResponse'
|
||||
# '404':
|
||||
# description: Event not found
|
||||
# content:
|
||||
# application/json:
|
||||
# schema:
|
||||
# $ref: '#/components/schemas/ErrorResponse'
|
||||
# '409':
|
||||
# description: Event is already cancelled
|
||||
# content:
|
||||
# application/json:
|
||||
# schema:
|
||||
# $ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
# --- New schemas ---
|
||||
|
||||
# PatchEventRequest:
|
||||
# type: object
|
||||
# required: [organizerToken, cancelled]
|
||||
# properties:
|
||||
# organizerToken:
|
||||
# type: string
|
||||
# format: uuid
|
||||
# description: The organizer token proving ownership of the event
|
||||
# example: "550e8400-e29b-41d4-a716-446655440001"
|
||||
# cancelled:
|
||||
# type: boolean
|
||||
# description: Set to true to cancel the event (irreversible)
|
||||
# example: true
|
||||
# cancellationReason:
|
||||
# type: string
|
||||
# maxLength: 2000
|
||||
# description: Optional cancellation reason
|
||||
# example: "Unfortunately the venue is no longer available."
|
||||
|
||||
# --- Extended schema: GetEventResponse ---
|
||||
# Add to existing GetEventResponse properties:
|
||||
# cancelled:
|
||||
# type: boolean
|
||||
# description: Whether the event has been cancelled
|
||||
# example: false
|
||||
# cancellationReason:
|
||||
# type: string
|
||||
# nullable: true
|
||||
# description: Reason for cancellation, if provided
|
||||
# example: null
|
||||
82
specs/016-cancel-event/data-model.md
Normal file
82
specs/016-cancel-event/data-model.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# Data Model: Cancel Event
|
||||
|
||||
**Feature Branch**: `016-cancel-event` | **Date**: 2026-03-12
|
||||
|
||||
## Entity Changes
|
||||
|
||||
### Event (extended)
|
||||
|
||||
Two new fields added to the existing Event entity:
|
||||
|
||||
| Field | Type | Constraints | Description |
|
||||
|--------------------|----------------|------------------------------|--------------------------------------------------|
|
||||
| cancelled | boolean | NOT NULL, DEFAULT false | Whether the event has been cancelled |
|
||||
| cancellationReason | String (2000) | Nullable | Optional reason provided by organizer |
|
||||
|
||||
### State Transition
|
||||
|
||||
```
|
||||
ACTIVE ──cancel()──► CANCELLED
|
||||
```
|
||||
|
||||
- One-way transition only. No path from CANCELLED back to ACTIVE.
|
||||
- `cancel()` sets `cancelled = true` and optionally sets `cancellationReason`.
|
||||
- Once cancelled, the event remains visible but RSVP creation is blocked.
|
||||
|
||||
### Validation Rules
|
||||
|
||||
- `cancellationReason` max length: 2000 characters (matches description field).
|
||||
- `cancellationReason` is plain text only (no HTML/markdown).
|
||||
- `cancelled` can only transition from `false` to `true`, never back.
|
||||
- Existing RSVPs are preserved when an event is cancelled (no cascade).
|
||||
|
||||
## Database Migration (Liquibase Changeset 004)
|
||||
|
||||
```xml
|
||||
<changeSet id="004-add-cancellation-columns" author="fete">
|
||||
<addColumn tableName="events">
|
||||
<column name="cancelled" type="BOOLEAN" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="cancellation_reason" type="VARCHAR(2000)"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
```
|
||||
|
||||
## Domain Model Impact
|
||||
|
||||
### Event.java (domain)
|
||||
|
||||
Add fields:
|
||||
```java
|
||||
private boolean cancelled;
|
||||
private String cancellationReason;
|
||||
```
|
||||
|
||||
Add method:
|
||||
```java
|
||||
public void cancel(String reason) {
|
||||
if (this.cancelled) {
|
||||
throw new EventAlreadyCancelledException();
|
||||
}
|
||||
this.cancelled = true;
|
||||
this.cancellationReason = reason;
|
||||
}
|
||||
```
|
||||
|
||||
### EventJpaEntity.java (persistence)
|
||||
|
||||
Add columns:
|
||||
```java
|
||||
@Column(name = "cancelled", nullable = false)
|
||||
private boolean cancelled;
|
||||
|
||||
@Column(name = "cancellation_reason", length = 2000)
|
||||
private String cancellationReason;
|
||||
```
|
||||
|
||||
## RSVP Impact
|
||||
|
||||
- `POST /events/{eventToken}/rsvps` must check `event.isCancelled()` before accepting.
|
||||
- If cancelled → return `409 Conflict`.
|
||||
- Existing RSVPs remain untouched — no delete, no status change.
|
||||
79
specs/016-cancel-event/plan.md
Normal file
79
specs/016-cancel-event/plan.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Implementation Plan: Cancel Event
|
||||
|
||||
**Branch**: `016-cancel-event` | **Date**: 2026-03-12 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from `/specs/016-cancel-event/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Allow organizers to permanently cancel events via a bottom sheet UI. Cancelled events display a red banner to visitors and block new RSVPs. Implementation adds a `PATCH /events/{eventToken}` endpoint, extends the Event entity with `cancelled` and `cancellationReason` fields, and reuses the existing `BottomSheet.vue` component for the cancel interaction.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Java 25 (backend), TypeScript 5.9 (frontend)
|
||||
**Primary Dependencies**: Spring Boot 3.5.x, Vue 3, Vue Router 5, openapi-fetch, openapi-typescript
|
||||
**Storage**: PostgreSQL (JPA via Spring Data, Liquibase migrations)
|
||||
**Testing**: JUnit (backend), Vitest (frontend unit), Playwright + MSW (frontend E2E)
|
||||
**Target Platform**: Self-hosted Linux server, mobile-first PWA
|
||||
**Project Type**: Web application (REST API + SPA)
|
||||
**Performance Goals**: N/A — simple state transition, no performance-critical path
|
||||
**Constraints**: Privacy by design (no analytics/tracking), WCAG AA, mobile-first
|
||||
**Scale/Scope**: Single new endpoint, 2 new DB columns, 1 view extension
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| I. Privacy by Design | PASS | No new data collection beyond organizer-provided reason. No analytics. |
|
||||
| II. Test-Driven Methodology | PASS | TDD enforced: tests before implementation, E2E mandatory for both user stories. |
|
||||
| III. API-First Development | PASS | OpenAPI spec updated first, types generated before implementation. `example:` fields included. |
|
||||
| IV. Simplicity & Quality | PASS | Minimal change: 2 columns, 1 endpoint, reuse existing BottomSheet. No over-engineering. |
|
||||
| V. Dependency Discipline | PASS | No new dependencies required. |
|
||||
| VI. Accessibility | PASS | Reuses accessible BottomSheet component. Banner uses semantic HTML + ARIA. |
|
||||
|
||||
**Gate result: PASS** — no violations.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/016-cancel-event/
|
||||
├── plan.md # This file
|
||||
├── spec.md # Feature specification
|
||||
├── research.md # Phase 0 output — design decisions
|
||||
├── data-model.md # Phase 1 output — entity changes
|
||||
├── quickstart.md # Phase 1 output — implementation overview
|
||||
├── contracts/ # Phase 1 output — API contract additions
|
||||
│ └── patch-event-endpoint.yaml
|
||||
└── tasks.md # Phase 2 output (/speckit.tasks command)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
backend/
|
||||
├── src/main/java/de/fete/
|
||||
│ ├── domain/model/Event.java # + cancelled, cancellationReason, cancel()
|
||||
│ ├── application/service/EventService.java # + CancelEventUseCase implementation
|
||||
│ ├── adapter/in/web/EventController.java # + cancelEvent endpoint
|
||||
│ └── adapter/out/persistence/
|
||||
│ ├── EventJpaEntity.java # + cancelled, cancellation_reason columns
|
||||
│ └── EventPersistenceAdapter.java # + mapper updates
|
||||
├── src/main/resources/
|
||||
│ ├── openapi/api.yaml # + cancel endpoint, request/response schemas
|
||||
│ └── db/changelog/004-add-cancellation-columns.xml # New migration
|
||||
└── src/test/java/de/fete/ # Unit + integration tests
|
||||
|
||||
frontend/
|
||||
├── src/
|
||||
│ └── views/EventDetailView.vue # + cancel button, bottom sheet, banner
|
||||
└── e2e/ # E2E tests for both user stories
|
||||
```
|
||||
|
||||
**Structure Decision**: Web application (Option 2) — matches existing project layout with `backend/` and `frontend/` at repository root.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> No violations — table not applicable.
|
||||
48
specs/016-cancel-event/quickstart.md
Normal file
48
specs/016-cancel-event/quickstart.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Quickstart: Cancel Event
|
||||
|
||||
**Feature Branch**: `016-cancel-event`
|
||||
|
||||
## What This Feature Does
|
||||
|
||||
Adds the ability for an organizer to permanently cancel an event. Cancelled events display a red banner to visitors and block new RSVPs.
|
||||
|
||||
## Implementation Scope
|
||||
|
||||
### Backend
|
||||
1. **Liquibase migration** (003): Add `cancelled` (boolean) and `cancellation_reason` (varchar 2000) columns to `events` table.
|
||||
2. **Domain model**: Extend `Event.java` with `cancelled` and `cancellationReason` fields + `cancel()` method.
|
||||
3. **JPA entity**: Extend `EventJpaEntity.java` with matching columns and mapper updates.
|
||||
4. **OpenAPI spec**: Add `PATCH /events/{eventToken}` endpoint + extend `GetEventResponse` with cancellation fields.
|
||||
5. **Use case**: New `CancelEventUseCase` interface + implementation in `EventService`.
|
||||
6. **Controller**: Implement `cancelEvent` in `EventController`.
|
||||
7. **RSVP guard**: Add cancelled check to RSVP creation (return 409).
|
||||
|
||||
### Frontend
|
||||
1. **Cancel bottom sheet**: Add cancel button (organizer-only) + bottom sheet with textarea and confirm button in `EventDetailView.vue`.
|
||||
2. **Cancellation banner**: Red banner at top of event detail when `cancelled === true`.
|
||||
3. **RSVP hiding**: Hide `RsvpBar` when event is cancelled.
|
||||
4. **API client**: Use generated types from updated OpenAPI spec.
|
||||
|
||||
### Testing
|
||||
1. **Backend unit tests**: Cancel use case, RSVP rejection on cancelled events.
|
||||
2. **Backend integration tests**: Full cancel flow via API.
|
||||
3. **Frontend unit tests**: Cancel bottom sheet, banner display, RSVP hiding.
|
||||
4. **E2E tests**: Organizer cancels event, attendee sees cancelled event.
|
||||
|
||||
## Key Files to Modify
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `backend/src/main/resources/openapi/api.yaml` | New endpoint + schema extensions |
|
||||
| `backend/src/main/resources/db/changelog/` | New changeset 003 |
|
||||
| `backend/src/main/java/de/fete/domain/model/Event.java` | Add cancelled fields + cancel() |
|
||||
| `backend/src/main/java/de/fete/adapter/out/persistence/EventJpaEntity.java` | Add columns |
|
||||
| `backend/src/main/java/de/fete/application/service/EventService.java` | Implement cancel |
|
||||
| `backend/src/main/java/de/fete/adapter/in/web/EventController.java` | Implement endpoint |
|
||||
| `frontend/src/views/EventDetailView.vue` | Cancel button, bottom sheet, banner |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Existing RSVP bottom sheet pattern (already implemented)
|
||||
- Organizer token stored in localStorage (already implemented)
|
||||
- `BottomSheet.vue` component (already exists)
|
||||
87
specs/016-cancel-event/research.md
Normal file
87
specs/016-cancel-event/research.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Research: Cancel Event
|
||||
|
||||
**Feature Branch**: `016-cancel-event` | **Date**: 2026-03-12
|
||||
|
||||
## Decision 1: API Endpoint Design
|
||||
|
||||
**Decision**: Use `PATCH /events/{eventToken}` with organizer token and cancellation fields in request body.
|
||||
|
||||
**Rationale**:
|
||||
- PATCH is standard REST for partial resource updates — cancellation is a state change on the event resource.
|
||||
- The event is not removed, so DELETE is not appropriate. The event remains visible with a cancellation banner.
|
||||
- The organizer token is sent in the request body to keep it out of URL/query strings and server access logs.
|
||||
- Request body: `{ "organizerToken": "uuid", "cancelled": true, "cancellationReason": "optional string" }`.
|
||||
- Response: `204 No Content` on success.
|
||||
- Error responses: `404` if event not found, `403` if organizer token is wrong, `409` if already cancelled.
|
||||
- Currently the only supported PATCH operation is cancellation. The endpoint validates that `cancelled` is `true` and rejects requests that attempt to set other fields.
|
||||
|
||||
**Alternatives considered**:
|
||||
- `POST /events/{eventToken}/cancel` — rejected because a dedicated sub-resource endpoint is RPC-style, not RESTful. PATCH on the resource itself is the standard approach.
|
||||
- `DELETE /events/{eventToken}` — rejected because the event is not deleted, it remains visible with a cancellation banner.
|
||||
|
||||
## Decision 2: Database Schema Extension
|
||||
|
||||
**Decision**: Add two columns to the `events` table: `cancelled BOOLEAN NOT NULL DEFAULT FALSE` and `cancellation_reason VARCHAR(2000)`.
|
||||
|
||||
**Rationale**:
|
||||
- Boolean flag is the simplest representation of the cancelled state.
|
||||
- 2000 chars matches the existing description field limit — consistent and generous.
|
||||
- DEFAULT FALSE ensures backward compatibility with existing rows.
|
||||
- A Liquibase changeset (003) adds both columns.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Enum status field (`ACTIVE`, `CANCELLED`) — rejected as over-engineering for a binary state with no other planned transitions.
|
||||
- Separate cancellation table — rejected as unnecessary complexity for two columns.
|
||||
|
||||
## Decision 3: RSVP Blocking on Cancelled Events
|
||||
|
||||
**Decision**: The RSVP creation endpoint (`POST /events/{eventToken}/rsvps`) checks the event's cancelled flag and returns `409 Conflict` if the event is cancelled.
|
||||
|
||||
**Rationale**:
|
||||
- Server-side enforcement is required (FR-006) — frontend hiding the button is not sufficient.
|
||||
- 409 Conflict is semantically correct: the request conflicts with the current state of the resource.
|
||||
- Existing RSVPs are preserved (FR-007) — no cascade or cleanup needed.
|
||||
|
||||
**Alternatives considered**:
|
||||
- 400 Bad Request — rejected because the request itself is well-formed; the conflict is with resource state.
|
||||
- 422 Unprocessable Entity — rejected because the issue is not validation but state conflict.
|
||||
|
||||
## Decision 4: Frontend Cancel Bottom Sheet
|
||||
|
||||
**Decision**: Reuse the existing `BottomSheet.vue` component. Add cancel-specific content (textarea + confirm button) directly in `EventDetailView.vue`, similar to how the RSVP form is embedded.
|
||||
|
||||
**Rationale**:
|
||||
- The spec explicitly requires the bottom sheet pattern consistent with RSVP flow (FR-002).
|
||||
- `BottomSheet.vue` is already a generic, accessible, glassmorphism-styled container.
|
||||
- No need for a separate component — the cancel form is simple (textarea + button + error message).
|
||||
- Error handling follows the same pattern as RSVP: inline error in the sheet, button re-enabled.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Separate `CancelBottomSheet.vue` component — rejected as unnecessary extraction for a simple form.
|
||||
- ConfirmDialog instead of BottomSheet — rejected because spec explicitly requires bottom sheet.
|
||||
|
||||
## Decision 5: Organizer Token Authorization
|
||||
|
||||
**Decision**: The cancel endpoint receives the organizer token in the request body. The frontend retrieves it from localStorage via `useEventStorage.getOrganizerToken()`.
|
||||
|
||||
**Rationale**:
|
||||
- Consistent with how organizer identity works throughout the app — token-based, no auth system.
|
||||
- The organizer token is already stored in localStorage when the event is created.
|
||||
- Body parameter keeps the token out of URL/query strings and server access logs.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Authorization header — rejected because there's no auth system; the organizer token is not a session token.
|
||||
- Query parameter — rejected to keep token out of server logs (same reason the attendee endpoint should eventually be migrated away from query params).
|
||||
|
||||
## Decision 6: GetEventResponse Extension
|
||||
|
||||
**Decision**: Add `cancelled: boolean` and `cancellationReason: string | null` to the `GetEventResponse` schema.
|
||||
|
||||
**Rationale**:
|
||||
- The frontend needs to know whether an event is cancelled to show the banner and hide RSVP buttons.
|
||||
- Both fields are always returned (no separate endpoint needed).
|
||||
- `cancelled` defaults to `false` for existing events.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Separate endpoint for cancellation status — rejected as unnecessary network overhead.
|
||||
- Only return cancellation info for cancelled events — rejected because the frontend needs the boolean regardless to decide UI state.
|
||||
97
specs/016-cancel-event/spec.md
Normal file
97
specs/016-cancel-event/spec.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# Feature Specification: Cancel Event
|
||||
|
||||
**Feature Branch**: `016-cancel-event`
|
||||
**Created**: 2026-03-12
|
||||
**Status**: Draft
|
||||
**Input**: User description: "As an organizer, I want to cancel an event so that attendees know the event will not take place"
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Organizer Cancels an Event (Priority: P1)
|
||||
|
||||
An organizer navigates to their event page (identified by having the organizer token). They decide to cancel the event. They tap a "Cancel Event" button, which opens a bottom sheet (visually similar to the attend/RSVP flow). The bottom sheet contains a text area for an optional cancellation reason and a confirm button. After confirming, the event is permanently marked as cancelled. This action is irreversible.
|
||||
|
||||
**Why this priority**: This is the core action of the feature. Without it, nothing else matters.
|
||||
|
||||
**Independent Test**: Can be fully tested by viewing the event with the organizer token, tapping cancel, optionally entering a reason, and confirming. The event's cancelled state persists on reload.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an active event viewed by someone who has the organizer token, **When** the organizer taps "Cancel Event", **Then** a bottom sheet opens with a text area and a confirm button.
|
||||
2. **Given** the cancel bottom sheet is open, **When** the organizer enters a cancellation reason and taps the confirm button, **Then** the event is marked as cancelled with the provided reason.
|
||||
3. **Given** the cancel bottom sheet is open, **When** the organizer taps confirm without entering a reason, **Then** the event is marked as cancelled without a reason.
|
||||
4. **Given** a cancelled event, **When** the organizer revisits the event page, **Then** the event remains cancelled (irreversible).
|
||||
5. **Given** the cancel bottom sheet is open, **When** the organizer taps confirm and the API call fails, **Then** an error message is displayed in the bottom sheet, the sheet remains open, and the confirm button is re-enabled for retry.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Attendee Sees Cancelled Event (Priority: P1)
|
||||
|
||||
An attendee (or any visitor) opens the event detail page for a cancelled event. A prominent red banner is displayed at the top of the page, clearly communicating that the event has been cancelled. If the organizer provided a cancellation reason, it is shown within the banner. The RSVP buttons are hidden — no new RSVPs can be submitted.
|
||||
|
||||
**Why this priority**: Equal to P1 because the cancellation must be visible to attendees for the feature to deliver value. Without this, cancelling has no effect from the attendee's perspective.
|
||||
|
||||
**Independent Test**: Can be tested by viewing a cancelled event's detail page and verifying the banner appears, the reason is displayed (if provided), and RSVP buttons are hidden.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a cancelled event with a cancellation reason, **When** a visitor opens the event detail page, **Then** a prominent red banner is displayed showing that the event is cancelled along with the reason.
|
||||
2. **Given** a cancelled event without a cancellation reason, **When** a visitor opens the event detail page, **Then** a prominent red banner is displayed showing that the event is cancelled, without a reason text.
|
||||
3. **Given** a cancelled event, **When** a visitor opens the event detail page, **Then** the RSVP buttons are not visible.
|
||||
4. **Given** a cancelled event, **When** a visitor opens the event detail page, **Then** all other event details (title, date, location, description) remain visible.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when the organizer tries to cancel an already cancelled event? The cancel button is not available on an already cancelled event.
|
||||
- What happens to existing RSVPs when an event is cancelled? They are preserved as-is but no new RSVPs can be submitted.
|
||||
- What happens when the event is both cancelled and expired? The auto-delete mechanism (feature 013) continues to apply normally — cancelled events are deleted on the same schedule as non-cancelled events.
|
||||
- What happens when the cancellation API call fails (network error, server error)? The bottom sheet remains open, a visible error message is displayed within the sheet, and the confirm button is re-enabled so the organizer can retry.
|
||||
- How are cancelled events displayed in the event list? Out of scope for this feature — the event list view is not affected.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: System MUST allow the organizer to cancel an event via the organizer view.
|
||||
- **FR-002**: The cancellation interaction MUST use a bottom sheet pattern consistent with the existing RSVP/attend flow.
|
||||
- **FR-003**: The bottom sheet MUST contain a text area for an optional cancellation reason and a confirm button.
|
||||
- **FR-004**: Cancellation MUST be irreversible — once cancelled, there is no way to undo it.
|
||||
- **FR-005**: System MUST store a cancelled flag and an optional cancellation reason for the event.
|
||||
- **FR-006**: System MUST NOT allow new RSVPs for a cancelled event.
|
||||
- **FR-007**: System MUST preserve existing RSVPs when an event is cancelled.
|
||||
- **FR-008**: The event detail page MUST display a prominent red banner for cancelled events.
|
||||
- **FR-009**: The banner MUST include the cancellation reason when one was provided.
|
||||
- **FR-010**: The RSVP buttons MUST be hidden on a cancelled event's detail page.
|
||||
- **FR-011**: All other event information MUST remain visible on a cancelled event's detail page.
|
||||
- **FR-012**: The cancel button MUST NOT be shown on an already cancelled event.
|
||||
- **FR-013**: There MUST be no push notifications, emails, or any active notification mechanism for cancellations.
|
||||
- **FR-014**: If the cancellation API call fails, the bottom sheet MUST remain open, display an error message, and allow the organizer to retry.
|
||||
- **FR-015**: Changes to the event list view for cancelled events are explicitly OUT OF SCOPE for this feature.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Event** (extended): Gains a cancelled state (boolean) and an optional cancellation reason (free text). An event can transition from active to cancelled, but not back.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Organizer can cancel an event in under 30 seconds (open bottom sheet, optionally type reason, confirm).
|
||||
- **SC-002**: 100% of visitors to a cancelled event's detail page see the cancellation banner without scrolling.
|
||||
- **SC-003**: 0% of cancelled events accept new RSVPs.
|
||||
- **SC-004**: Existing RSVPs are fully preserved after cancellation (no data loss).
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-03-12
|
||||
|
||||
- Q: How should cancelled events appear in the event list view? → A: Out of scope for this feature — event list view is not affected.
|
||||
- Q: What should happen when the cancellation API call fails? → A: Error message displayed in the bottom sheet, sheet remains open, confirm button re-enabled for retry.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The bottom sheet component pattern already exists from the RSVP/attend flow and can be reused.
|
||||
- The cancellation reason has a maximum length of 2000 characters (consistent with the event description field).
|
||||
- The cancellation reason is plain text (no formatting or markup).
|
||||
96
specs/016-cancel-event/tasks.md
Normal file
96
specs/016-cancel-event/tasks.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# Tasks: Cancel Event
|
||||
|
||||
**Input**: Design documents from `/specs/016-cancel-event/`
|
||||
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/patch-event-endpoint.yaml
|
||||
|
||||
**Tests**: Included — constitution mandates TDD (tests before implementation) and E2E for every frontend user story.
|
||||
|
||||
**Organization**: Tasks grouped by user story. Both stories are P1 but US2 depends on US1's backend work.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||
- **[Story]**: Which user story this task belongs to (e.g., US1, US2)
|
||||
- Include exact file paths in descriptions
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: OpenAPI spec, database migration, and domain model changes that both user stories depend on.
|
||||
|
||||
- [X] T001 Update OpenAPI spec with PATCH endpoint on `/events/{eventToken}` (organizerToken as query param), PatchEventRequest schema (`cancelled`, `cancellationReason`), and extend GetEventResponse with `cancelled`/`cancellationReason` fields in `backend/src/main/resources/openapi/api.yaml`
|
||||
- [X] T002 Add Liquibase changeset 004 adding `cancelled` (BOOLEAN NOT NULL DEFAULT FALSE) and `cancellation_reason` (VARCHAR 2000) columns to `events` table in `backend/src/main/resources/db/changelog/004-add-cancellation-columns.xml` and register it in `db.changelog-master.xml`
|
||||
- [X] T003 [P] Extend domain model `Event.java` with `cancelled`, `cancellationReason` fields and `cancel(String reason)` method (throws `EventAlreadyCancelledException`). Create `EventAlreadyCancelledException` in `backend/src/main/java/de/fete/domain/model/`. Domain model: `backend/src/main/java/de/fete/domain/model/Event.java`
|
||||
- [X] T004 [P] Extend JPA entity `EventJpaEntity.java` with `cancelled` and `cancellation_reason` columns and update mapper in `backend/src/main/java/de/fete/adapter/out/persistence/EventPersistenceAdapter.java`
|
||||
- [X] T005 Regenerate frontend TypeScript types from updated OpenAPI spec via `cd frontend && npm run generate:api`
|
||||
|
||||
**Checkpoint**: Schema, migration, and domain model ready. Both user stories can now proceed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: User Story 1 — Organizer Cancels an Event (Priority: P1) MVP
|
||||
|
||||
**Goal**: Organizer can cancel an event via a bottom sheet with optional reason. Cancellation is irreversible and persists on reload.
|
||||
|
||||
**Independent Test**: View event with organizer token, tap cancel, optionally enter reason, confirm. Event remains cancelled on reload.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
> **Write these tests FIRST — ensure they FAIL before implementation**
|
||||
|
||||
- [X] ~~T006~~ Removed (EventTest.java unnecessary — cancel() tested via service/integration tests)
|
||||
- [X] T007 [P] [US1] Write unit test for cancel use case in EventService (delegates to domain, saves, 403/404/409 cases) in `backend/src/test/java/de/fete/application/service/EventServiceCancelTest.java`
|
||||
- [X] T008 [P] [US1] Write integration tests for `PATCH /events/{eventToken}` endpoint (204 success, 403 wrong token, 404 not found, 409 already cancelled) in `backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java`
|
||||
- [X] T009 [P] [US1] Write E2E test: organizer opens cancel bottom sheet, enters reason, confirms — event shows as cancelled on reload in `frontend/e2e/cancel-event.spec.ts`
|
||||
- [X] T010 [P] [US1] Write E2E test: organizer cancels without reason — event shows as cancelled in `frontend/e2e/cancel-event.spec.ts`
|
||||
- [X] T011 [P] [US1] Write E2E test: cancel API fails — error displayed in bottom sheet, button re-enabled for retry in `frontend/e2e/cancel-event.spec.ts`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T012 [US1] Create `UpdateEventUseCase` interface in `backend/src/main/java/de/fete/domain/port/in/UpdateEventUseCase.java`
|
||||
- [X] T013 [US1] Implement cancel logic in `EventService.java` — load event, verify organizer token, call `event.cancel(reason)`, persist in `backend/src/main/java/de/fete/application/service/EventService.java`
|
||||
- [X] T014 [US1] Implement `patchEvent` endpoint in `EventController.java` — PATCH handler, query param organizerToken, request body binding, error mapping (403/404/409) in `backend/src/main/java/de/fete/adapter/in/web/EventController.java`
|
||||
- [X] T015 [US1] Add cancel button (visible only when organizer token exists and event not cancelled — covers FR-012) and cancel bottom sheet (textarea with 2000 char limit + confirm button + inline error) to `frontend/src/views/EventDetailView.vue`
|
||||
- [X] T016 [US1] Wire cancel bottom sheet confirm action to `PATCH /events/{eventToken}` API call via openapi-fetch, handle success (reload event data) and error (show inline message, re-enable button) in `frontend/src/views/EventDetailView.vue`
|
||||
|
||||
**Checkpoint**: Organizer can cancel an event. All US1 acceptance scenarios pass.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 2 — Attendee Sees Cancelled Event (Priority: P1)
|
||||
|
||||
**Goal**: Visitors see a prominent red cancellation banner on cancelled events. RSVP buttons are hidden. All other event details remain visible.
|
||||
|
||||
**Independent Test**: View a cancelled event's detail page — banner visible (with reason if provided), RSVP buttons hidden, other details intact.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
> **Write these tests FIRST — ensure they FAIL before implementation**
|
||||
|
||||
- [X] ~~T017~~ Removed (RsvpServiceCancelledTest unnecessary — covered by integration test)
|
||||
- [X] T018 [P] [US2] Write integration test for `POST /events/{eventToken}/rsvps` returning 409 when event is cancelled in `backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java`
|
||||
- [X] T019 [P] [US2] Write E2E test: visitor sees red banner with cancellation reason on cancelled event in `frontend/e2e/cancelled-event-visitor.spec.ts`
|
||||
- [X] T020 [P] [US2] Write E2E test: visitor sees red banner without reason when no reason was provided in `frontend/e2e/cancelled-event-visitor.spec.ts`
|
||||
- [X] T021 [P] [US2] Write E2E test: RSVP buttons hidden on cancelled event, other details remain visible in `frontend/e2e/cancelled-event-visitor.spec.ts`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T022 [US2] Add cancelled-event guard to RSVP creation — check `event.isCancelled()`, return 409 Conflict in `backend/src/main/java/de/fete/application/service/RsvpService.java`
|
||||
- [X] T023 [US2] Add cancellation banner component/section (red, prominent, includes reason if present, WCAG AA contrast) to `frontend/src/views/EventDetailView.vue`
|
||||
- [X] T024 [US2] Hide RSVP buttons (`RsvpBar` or equivalent) when `event.cancelled === true` in `frontend/src/views/EventDetailView.vue`
|
||||
- [X] ~~T025~~ Merged into T015 (cancel button v-if already handles FR-012)
|
||||
|
||||
**Checkpoint**: Both user stories fully functional. All acceptance scenarios pass.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Validation, edge cases, and final cleanup.
|
||||
|
||||
- [X] T026 Verify cancellationReason max length (2000 chars) is enforced at API level (OpenAPI `maxLength`), domain level in `Event.java`, and UI level (textarea maxlength/counter)
|
||||
- [X] T027 Run full backend test suite (`cd backend && ./mvnw verify`) and fix any failures
|
||||
- [X] T028 Run full frontend test suite (`cd frontend && npm run test:unit`) and fix any failures
|
||||
- [X] T029 Run E2E tests (`cd frontend && npx playwright test`) and fix any failures
|
||||
- [X] T030 Run backend checkstyle (`cd backend && ./mvnw checkstyle:check`) and fix violations
|
||||
35
specs/017-watch-event/checklists/requirements.md
Normal file
35
specs/017-watch-event/checklists/requirements.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Specification Quality Checklist: Watch Event
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-12
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- All items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
|
||||
- localStorage is mentioned as a storage mechanism — this is an existing project convention (not an implementation detail introduced by this spec).
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user